dataframe-textual 0.3.2__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,17 @@
1
1
  """DataFrameTable widget for displaying and interacting with Polars DataFrames."""
2
2
 
3
3
  import sys
4
- from collections import deque
4
+ from collections import defaultdict, deque
5
5
  from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
  from textwrap import dedent
8
+ from typing import Any
8
9
 
9
10
  import polars as pl
10
11
  from rich.text import Text
11
12
  from textual.coordinate import Coordinate
12
- from textual.widgets import DataTable
13
+ from textual.events import Click
14
+ from textual.widgets import DataTable, TabPane
13
15
  from textual.widgets._data_table import (
14
16
  CellDoesNotExist,
15
17
  CellKey,
@@ -19,22 +21,28 @@ from textual.widgets._data_table import (
19
21
  )
20
22
 
21
23
  from .common import (
22
- BATCH_SIZE,
23
- BOOLS,
24
24
  CURSOR_TYPES,
25
- INITIAL_BATCH_SIZE,
25
+ NULL,
26
+ NULL_DISPLAY,
27
+ RIDX,
26
28
  SUBSCRIPT_DIGITS,
27
29
  DtypeConfig,
28
- _format_row,
29
- _next,
30
- _rindex,
30
+ format_row,
31
+ get_next_item,
32
+ rindex,
33
+ tentative_expr,
34
+ validate_expr,
31
35
  )
32
- from .table_screen import FrequencyScreen, RowDetailScreen
36
+ from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
33
37
  from .yes_no_screen import (
38
+ AddColumnScreen,
34
39
  ConfirmScreen,
35
40
  EditCellScreen,
41
+ EditColumnScreen,
36
42
  FilterScreen,
43
+ FindReplaceScreen,
37
44
  FreezeScreen,
45
+ RenameColumnScreen,
38
46
  SaveFileScreen,
39
47
  SearchScreen,
40
48
  )
@@ -49,11 +57,33 @@ class History:
49
57
  filename: str
50
58
  loaded_rows: int
51
59
  sorted_columns: dict[str, bool]
60
+ hidden_columns: set[str]
52
61
  selected_rows: list[bool]
53
62
  visible_rows: list[bool]
54
63
  fixed_rows: int
55
64
  fixed_columns: int
56
65
  cursor_coordinate: Coordinate
66
+ matches: dict[int, set[int]]
67
+
68
+
69
+ @dataclass
70
+ class ReplaceState:
71
+ """Class to track state during interactive replace operations."""
72
+
73
+ term_find: str
74
+ term_replace: str
75
+ match_nocase: bool
76
+ match_whole: bool
77
+ cidx: int # Column index to search in, could be None for all columns
78
+ rows: list[int] # List of row indices
79
+ cols_per_row: list[list[int]] # List of list of column indices per row
80
+ current_rpos: int # Current row position index in rows
81
+ current_cpos: int # Current column position index within current row's cols
82
+ current_occurrence: int # Current occurrence count (for display)
83
+ total_occurrence: int # Total number of occurrences
84
+ replaced_occurrence: int # Number of occurrences already replaced
85
+ skipped_occurrence: int # Number of occurrences skipped
86
+ done: bool = False # Whether the replace operation is complete
57
87
 
58
88
 
59
89
  class DataFrameTable(DataTable):
@@ -72,118 +102,337 @@ class DataFrameTable(DataTable):
72
102
  ## 👁️ View & Display
73
103
  - **Enter** - 📋 Show row details in modal
74
104
  - **F** - 📊 Show frequency distribution
75
- - **C** - 🔄 Cycle cursor (cell row → column → cell)
76
- - **#** - 🏷️ Toggle row labels
105
+ - **s** - 📈 Show statistics for current column
106
+ - **S** - 📊 Show statistics for entire dataframe
107
+ - **h** - 👁️ Hide current column
108
+ - **H** - 👀 Show all hidden rows/columns
109
+ - **z** - 📌 Freeze rows and columns
110
+ - **~** - 🏷️ Toggle row labels
111
+ - **,** - 🔢 Toggle thousand separator for numeric display
112
+ - **K** - 🔄 Cycle cursor (cell → row → column → cell)
77
113
 
78
114
  ## ↕️ Sorting
79
115
  - **[** - 🔼 Sort column ascending
80
116
  - **]** - 🔽 Sort column descending
81
117
  - *(Multi-column sort supported)*
82
118
 
83
- ## 🔍 Search
84
- - **|** - 🔎 Search in current column
85
- - **/** - 🌐 Global search (all columns)
86
- - **\\\\** - 🔍 Search using current cell value
87
-
88
- ## 🔧 Filter & Select
89
- - **s** - ✓️ Select/deselect current row
119
+ ## 🔍 Search & Filter
120
+ - **|** - 🔎 Search in current column with expression
121
+ - **\\\\** - 🔎 Search in current column using cursor value
122
+ - **/** - 🔎 Find in current column with cursor value
123
+ - **?** - 🔎 Find in current column with expression
124
+ - **f** - 🌐 Global find using cursor value
125
+ - **Ctrl+f** - 🌐 Global find with expression
126
+ - **n** - ⬇️ Go to next match
127
+ - **N** - ⬆️ Go to previous match
128
+ - **v** - 👁️ View/filter rows by cell or selected rows
129
+ - **V** - 🔧 View/filter rows by expression
130
+ - *(All search/find support case-insensitive & whole-word matching)*
131
+
132
+ ## ✏️ Replace
133
+ - **r** - 🔄 Replace in current column (interactive or all)
134
+ - **R** - 🔄 Replace across all columns (interactive or all)
135
+ - *(Supports case-insensitive & whole-word matching)*
136
+
137
+ ## ✅ Selection & Filtering
138
+ - **'** - ✓️ Select/deselect current row
90
139
  - **t** - 💡 Toggle row selection (invert all)
91
- - **"** - 📍 Filter to selected rows only
92
- - **T** - 🧹 Clear all selections
93
- - **v** - 🎯 Filter by selected rows or current cell value
94
- - **V** - 🔧 Filter by Polars expression
140
+ - **{** - ⬆️ Go to previous selected row
141
+ - **}** - ⬇️ Go to next selected row
142
+ - **"** - 📍 Filter to show only selected rows
143
+ - **T** - 🧹 Clear all selections and matches
95
144
 
96
145
  ## ✏️ Edit & Modify
146
+ - **Double-click** - ✍️ Edit cell or rename column header
97
147
  - **e** - ✍️ Edit current cell
98
- - **d** - 🗑️ Delete current row
148
+ - **E** - 📊 Edit entire column with expression
149
+ - **a** - ➕ Add empty column after current
150
+ - **A** - ➕ Add column with name and optional expression
151
+ - **x** - ❌ Delete current row
152
+ - **X** - ❌ Delete row and those below
153
+ - **Ctrl+X** - ❌ Delete row and those above
154
+ - **delete** - ❌ Clear current cell (set to NULL)
99
155
  - **-** - ❌ Delete current column
156
+ - **_** - ❌ Delete column and those after
157
+ - **Ctrl+-** - ❌ Delete column and those before
158
+ - **d** - 📋 Duplicate current column
159
+ - **D** - 📋 Duplicate current row
160
+
100
161
 
101
162
  ## 🎯 Reorder
102
163
  - **Shift+↑↓** - ⬆️⬇️ Move row up/down
103
164
  - **Shift+←→** - ⬅️➡️ Move column left/right
104
165
 
166
+ ## 🎨 Type Conversion
167
+ - **#** - 🔢 Cast column to integer
168
+ - **%** - 🔢 Cast column to float
169
+ - **!** - ✅ Cast column to boolean
170
+ - **$** - 📝 Cast column to string
171
+
172
+ ## 🔗 URL Handling
173
+ - **@** - 🔗 Make URLs in current column clickable with Ctrl/Cmd
174
+
105
175
  ## 💾 Data Management
106
- - **f** - 📌 Freeze rows/columns
107
176
  - **c** - 📋 Copy cell to clipboard
108
- - **Ctrl+S** - 💾 Save current tabto file
177
+ - **Ctrl+c** - 📊 Copy column to clipboard
178
+ - **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
179
+ - **Ctrl+s** - 💾 Save current tab to file
109
180
  - **u** - ↩️ Undo last action
110
- - **U** - 🔄 Reset to original data
111
-
112
- *Use `?` to see app-level controls*
181
+ - **U** - 🔄 Redo last undone action
182
+ - **Ctrl+U** - 🔁 Reset to initial state
113
183
  """).strip()
114
184
 
115
- def __init__(
116
- self,
117
- df: pl.DataFrame,
118
- filename: str = "",
119
- tabname: str = "",
120
- **kwargs,
121
- ):
185
+ # fmt: off
186
+ BINDINGS = [
187
+ # Navigation
188
+ ("g", "jump_top", "Jump to top"),
189
+ ("G", "jump_bottom", "Jump to bottom"),
190
+ # Display
191
+ ("h", "hide_column", "Hide column"),
192
+ ("H", "show_hidden_rows_columns", "Show hidden rows/columns"),
193
+ ("tilde", "toggle_row_labels", "Toggle row labels"), # `~`
194
+ ("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
195
+ ("z", "freeze_row_column", "Freeze rows/columns"),
196
+ ("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
197
+ # Copy
198
+ ("c", "copy_cell", "Copy cell to clipboard"),
199
+ ("ctrl+c", "copy_column", "Copy column to clipboard"),
200
+ ("ctrl+r", "copy_row", "Copy row to clipboard"),
201
+ # Save
202
+ ("ctrl+s", "save_to_file", "Save to file"),
203
+ # Detail, Frequency, and Statistics
204
+ ("enter", "view_row_detail", "View row details"),
205
+ ("F", "show_frequency", "Show frequency"),
206
+ ("s", "show_statistics", "Show statistics for column"),
207
+ ("S", "show_statistics('dataframe')", "Show statistics for dataframe"),
208
+ # Sort
209
+ ("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
210
+ ("right_square_bracket", "sort_descending", "Sort descending"), # `]`
211
+ # View
212
+ ("v", "view_rows", "View rows"),
213
+ ("V", "view_rows_expr", "View rows by expression"),
214
+ # Search
215
+ ("backslash", "search_cursor_value", "Search column with cursor value"), # `\`
216
+ ("vertical_line", "search_expr", "Search column with expression"), # `|`
217
+ ("right_curly_bracket", "next_selected_row", "Go to next selected row"), # `}`
218
+ ("left_curly_bracket", "previous_selected_row", "Go to previous selected row"), # `{`
219
+ # Find
220
+ ("slash", "find_cursor_value", "Find in column with cursor value"), # `/`
221
+ ("question_mark", "find_expr", "Find in column with expression"), # `?`
222
+ ("f", "find_cursor_value('global')", "Global find with cursor value"), # `f`
223
+ ("ctrl+f", "find_expr('global')", "Global find with expression"), # `Ctrl+F`
224
+ ("n", "next_match", "Go to next match"), # `n`
225
+ ("N", "previous_match", "Go to previous match"), # `Shift+n`
226
+ # Replace
227
+ ("r", "replace", "Replace in column"), # `r`
228
+ ("R", "replace_global", "Replace global"), # `Shift+R`
229
+ # Selection
230
+ ("apostrophe", "make_selections", "Toggle row selection"), # `'`
231
+ ("t", "toggle_selections", "Toggle all row selections"),
232
+ ("T", "clear_selections_and_matches", "Clear selections"),
233
+ ("quotation_mark", "filter_selected_rows", "Filter selected"), # `"`
234
+ # Delete
235
+ ("delete", "clear_cell", "Clear cell"),
236
+ ("minus", "delete_column", "Delete column"), # `-`
237
+ ("underscore", "delete_column_and_after", "Delete column and those after"), # `_`
238
+ ("ctrl+minus", "delete_column_and_before", "Delete column and those before"), # `Ctrl+-`
239
+ ("x", "delete_row", "Delete row"),
240
+ ("X", "delete_row_and_below", "Delete row and those below"),
241
+ ("ctrl+x", "delete_row_and_up", "Delete row and those up"),
242
+ # Duplicate
243
+ ("d", "duplicate_column", "Duplicate column"),
244
+ ("D", "duplicate_row", "Duplicate row"),
245
+ # Edit
246
+ ("e", "edit_cell", "Edit cell"),
247
+ ("E", "edit_column", "Edit column"),
248
+ # Add
249
+ ("a", "add_column", "Add column"),
250
+ ("A", "add_column_expr", "Add column with expression"),
251
+ # Reorder
252
+ ("shift+left", "move_column_left", "Move column left"),
253
+ ("shift+right", "move_column_right", "Move column right"),
254
+ ("shift+up", "move_row_up", "Move row up"),
255
+ ("shift+down", "move_row_down", "Move row down"),
256
+ # Type Conversion
257
+ ("number_sign", "cast_column_dtype('int')", "Cast column dtype to int"), # `#`
258
+ ("percent_sign", "cast_column_dtype('float')", "Cast column dtype to float"), # `%`
259
+ ("exclamation_mark", "cast_column_dtype('bool')", "Cast column dtype to bool"), # `!`
260
+ ("dollar_sign", "cast_column_dtype('string')", "Cast column dtype to string"), # `$`
261
+ ("at", "make_cell_clickable", "Make cell clickable"), # `@`
262
+ # Undo/Redo
263
+ ("u", "undo", "Undo"),
264
+ ("U", "redo", "Redo"),
265
+ ("ctrl+u", "reset", "Reset to initial state"),
266
+ ]
267
+ # fmt: on
268
+
269
+ def __init__(self, df: pl.DataFrame | pl.LazyFrame, filename: str = "", name: str = "", **kwargs) -> None:
122
270
  """Initialize the DataFrameTable with a dataframe and manage all state.
123
271
 
272
+ Sets up the table widget with display configuration, loads the dataframe, and
273
+ initializes all state tracking variables for row/column operations.
274
+
124
275
  Args:
125
- df: The Polars DataFrame to display
126
- filename: Optional filename of the source CSV
127
- kwargs: Additional keyword arguments for DataTable
276
+ df: The Polars DataFrame or LazyFrame to display and edit.
277
+ filename: Optional source filename for the data (used in save operations). Defaults to "".
278
+ name: Optional display name for the table tab. Defaults to "" (uses filename stem).
279
+ **kwargs: Additional keyword arguments passed to the parent DataTable widget.
280
+
281
+ Returns:
282
+ None
128
283
  """
129
- super().__init__(**kwargs)
284
+ super().__init__(name=(name or Path(filename).stem), **kwargs)
130
285
 
131
286
  # DataFrame state
132
- self.dataframe = df # Original dataframe
133
- self.df = df # Internal/working dataframe
287
+ self.lazyframe = df.lazy() # Original dataframe
288
+ self.df = self.lazyframe.collect() # Internal/working dataframe
134
289
  self.filename = filename # Current filename
135
- self.tabname = tabname or Path(filename).stem # Current tab name
136
290
 
137
291
  # Pagination & Loading
292
+ self.INITIAL_BATCH_SIZE = (self.app.size.height // 100 + 1) * 100
293
+ self.BATCH_SIZE = self.INITIAL_BATCH_SIZE // 2
138
294
  self.loaded_rows = 0 # Track how many rows are currently loaded
139
295
 
140
296
  # State tracking (all 0-based indexing)
141
297
  self.sorted_columns: dict[str, bool] = {} # col_name -> descending
142
- self.selected_rows: list[bool] = [False] * len(df) # Track selected rows
143
- self.visible_rows: list[bool] = [True] * len(
144
- df
145
- ) # Track visible rows (for filtering)
298
+ self.hidden_columns: set[str] = set() # Set of hidden column names
299
+ self.selected_rows: list[bool] = [False] * len(self.df) # Track selected rows
300
+ self.visible_rows: list[bool] = [True] * len(self.df) # Track visible rows (for filtering)
301
+ self.matches: dict[int, set[int]] = defaultdict(set) # Track search matches: row_idx -> set of col_idx
146
302
 
147
303
  # Freezing
148
304
  self.fixed_rows = 0 # Number of fixed rows
149
305
  self.fixed_columns = 0 # Number of fixed columns
150
306
 
151
- # History stack for undo/redo
307
+ # History stack for undo
152
308
  self.histories: deque[History] = deque()
309
+ # Current history state for redo
310
+ self.history: History = None
153
311
 
154
312
  # Pending filename for save operations
155
313
  self._pending_filename = ""
156
314
 
315
+ # Whether to use thousand separator for numeric display
316
+ self.thousand_separator = False
317
+
157
318
  @property
158
319
  def cursor_key(self) -> CellKey:
159
- """Get the current cursor position as a CellKey."""
320
+ """Get the current cursor position as a CellKey.
321
+
322
+ Returns:
323
+ CellKey: A CellKey object representing the current cursor position.
324
+ """
160
325
  return self.coordinate_to_cell_key(self.cursor_coordinate)
161
326
 
162
327
  @property
163
328
  def cursor_row_key(self) -> RowKey:
164
- """Get the current cursor row as a CellKey."""
329
+ """Get the current cursor row as a RowKey.
330
+
331
+ Returns:
332
+ RowKey: The row key for the row containing the cursor.
333
+ """
165
334
  return self.cursor_key.row_key
166
335
 
167
336
  @property
168
- def cursor_column_key(self) -> ColumnKey:
169
- """Get the current cursor column as a ColumnKey."""
337
+ def cursor_col_key(self) -> ColumnKey:
338
+ """Get the current cursor column as a ColumnKey.
339
+
340
+ Returns:
341
+ ColumnKey: The column key for the column containing the cursor.
342
+ """
170
343
  return self.cursor_key.column_key
171
344
 
172
345
  @property
173
- def cursor_row_index(self) -> int:
174
- """Get the current cursor row index (0-based)."""
175
- return int(self.cursor_row_key.value) - 1
346
+ def cursor_row_idx(self) -> int:
347
+ """Get the current cursor row index (0-based) as in dataframe.
176
348
 
177
- def on_mount(self) -> None:
178
- """Initialize table display when widget is mounted."""
179
- self._setup_table()
349
+ Returns:
350
+ int: The 0-based row index of the cursor position.
351
+
352
+ Raises:
353
+ AssertionError: If the cursor row index is out of bounds.
354
+ """
355
+ ridx = int(self.cursor_row_key.value)
356
+ assert 0 <= ridx < len(self.df), "Cursor row index is out of bounds"
357
+ return ridx
358
+
359
+ @property
360
+ def cursor_col_idx(self) -> int:
361
+ """Get the current cursor column index (0-based) as in dataframe.
362
+
363
+ Returns:
364
+ int: The 0-based column index of the cursor position.
365
+
366
+ Raises:
367
+ AssertionError: If the cursor column index is out of bounds.
368
+ """
369
+ cidx = self.df.columns.index(self.cursor_col_key.value)
370
+ assert 0 <= cidx < len(self.df.columns), "Cursor column index is out of bounds"
371
+ return cidx
372
+
373
+ @property
374
+ def cursor_col_name(self) -> str:
375
+ """Get the current cursor column name as in dataframe.
376
+
377
+ Returns:
378
+ str: The name of the column containing the cursor.
379
+ """
380
+ return self.cursor_col_key.value
381
+
382
+ @property
383
+ def cursor_value(self) -> Any:
384
+ """Get the current cursor cell value.
385
+
386
+ Returns:
387
+ Any: The value of the cell at the cursor position.
388
+ """
389
+ return self.df.item(self.cursor_row_idx, self.cursor_col_idx)
390
+
391
+ @property
392
+ def ordered_selected_rows(self) -> list[int]:
393
+ """Get the list of selected row indices in order.
394
+
395
+ Returns:
396
+ list[int]: A list of 0-based row indices that are currently selected.
397
+ """
398
+ return [ridx for ridx, selected in enumerate(self.selected_rows) if selected]
399
+
400
+ @property
401
+ def ordered_matches(self) -> list[tuple[int, int]]:
402
+ """Get the list of matched cell coordinates in order.
403
+
404
+ Returns:
405
+ list[tuple[int, int]]: A list of (row_idx, col_idx) tuples for matched cells.
406
+ """
407
+ matches = []
408
+ for ridx in sorted(self.matches.keys()):
409
+ for cidx in sorted(self.matches[ridx]):
410
+ matches.append((ridx, cidx))
411
+ return matches
412
+
413
+ def get_row_key(self, row_idx: int) -> RowKey:
414
+ """Get the row key for a given table row index.
415
+
416
+ Args:
417
+ row_idx: Row index in the table display.
418
+
419
+ Returns:
420
+ Corresponding row key as string.
421
+ """
422
+ return self._row_locations.get_key(row_idx)
423
+
424
+ def get_column_key(self, col_idx: int) -> ColumnKey:
425
+ """Get the column key for a given table column index.
426
+
427
+ Args:
428
+ col_idx: Column index in the table display.
429
+
430
+ Returns:
431
+ Corresponding column key as string.
432
+ """
433
+ return self._column_locations.get_key(col_idx)
180
434
 
181
- def _should_highlight(
182
- self,
183
- cursor: Coordinate,
184
- target_cell: Coordinate,
185
- type_of_cursor: CursorType,
186
- ) -> bool:
435
+ def _should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
187
436
  """Determine if the given cell should be highlighted because of the cursor.
188
437
 
189
438
  In "cell" mode, also highlights the row and column headers. In "row" and "column"
@@ -192,10 +441,10 @@ class DataFrameTable(DataTable):
192
441
  Args:
193
442
  cursor: The current position of the cursor.
194
443
  target_cell: The cell we're checking for the need to highlight.
195
- type_of_cursor: The type of cursor that is currently active.
444
+ type_of_cursor: The type of cursor that is currently active ("cell", "row", or "column").
196
445
 
197
446
  Returns:
198
- Whether or not the given cell should be highlighted.
447
+ bool: True if the target cell should be highlighted, False otherwise.
199
448
  """
200
449
  if type_of_cursor == "cell":
201
450
  # Return true if the cursor is over the target cell
@@ -216,15 +465,19 @@ class DataFrameTable(DataTable):
216
465
  else:
217
466
  return False
218
467
 
219
- def watch_cursor_coordinate(
220
- self, old_coordinate: Coordinate, new_coordinate: Coordinate
221
- ) -> None:
222
- """Refresh highlighting when cursor coordinate changes.
468
+ def watch_cursor_coordinate(self, old_coordinate: Coordinate, new_coordinate: Coordinate) -> None:
469
+ """Handle cursor position changes and refresh highlighting.
223
470
 
224
- This explicitly refreshes cells that need to change their highlight state
225
- to fix the delay issue with column label highlighting. Also emits CellSelected
226
- message when cursor type is "cell" for keyboard navigation only (mouse clicks
227
- already trigger the parent class's CellSelected message).
471
+ This method is called by Textual whenever the cursor moves. It refreshes cells that need
472
+ to change their highlight state. Also emits CellSelected message when cursor type is "cell"
473
+ for keyboard navigation only (mouse clicks already trigger it).
474
+
475
+ Args:
476
+ old_coordinate: The previous cursor coordinate.
477
+ new_coordinate: The new cursor coordinate.
478
+
479
+ Returns:
480
+ None
228
481
  """
229
482
  if old_coordinate != new_coordinate:
230
483
  # Emit CellSelected message for cell cursor type (keyboard navigation only)
@@ -251,10 +504,10 @@ class DataFrameTable(DataTable):
251
504
  self.refresh_row(new_row)
252
505
  elif self.cursor_type == "row":
253
506
  self.refresh_row(old_coordinate.row)
254
- self.refresh_row(new_coordinate.row)
507
+ self._highlight_row(new_coordinate.row)
255
508
  elif self.cursor_type == "column":
256
509
  self.refresh_column(old_coordinate.column)
257
- self.refresh_column(new_coordinate.column)
510
+ self._highlight_column(new_coordinate.column)
258
511
 
259
512
  # Handle scrolling if needed
260
513
  if self._require_update_dimensions:
@@ -262,94 +515,344 @@ class DataFrameTable(DataTable):
262
515
  else:
263
516
  self._scroll_cursor_into_view()
264
517
 
518
+ def move_cursor_to(self, ridx: int, cidx: int) -> None:
519
+ """Move cursor based on the dataframe indices.
520
+
521
+ Args:
522
+ ridx: Row index (0-based) in the dataframe.
523
+ cidx: Column index (0-based) in the dataframe.
524
+ """
525
+ row_key = str(ridx)
526
+ col_key = self.df.columns[cidx]
527
+ row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
528
+ self.move_cursor(row=row_idx, column=col_idx)
529
+
530
+ def on_mount(self) -> None:
531
+ """Initialize table display when the widget is mounted.
532
+
533
+ Called by Textual when the widget is first added to the display tree.
534
+ Currently a placeholder as table setup is deferred until first use.
535
+
536
+ Returns:
537
+ None
538
+ """
539
+ # self._setup_table()
540
+ pass
541
+
265
542
  def on_key(self, event) -> None:
266
- """Handle keyboard events for table operations and navigation."""
267
- if event.key == "g":
268
- # Jump to top
269
- self.move_cursor(row=0)
270
- elif event.key == "G":
271
- # Load all remaining rows before jumping to end
272
- self._load_rows()
273
- self.move_cursor(row=self.row_count - 1)
274
- elif event.key in ("pagedown", "down"):
543
+ """Handle key press events for pagination.
544
+
545
+ Currently handles "pagedown" and "down" keys to trigger lazy loading of additional rows
546
+ when scrolling near the end of the loaded data.
547
+
548
+ Args:
549
+ event: The key event object.
550
+
551
+ Returns:
552
+ None
553
+ """
554
+ if event.key in ("pagedown", "down"):
275
555
  # Let the table handle the navigation first
276
556
  self._check_and_load_more()
277
- elif event.key == "enter":
278
- # Open row detail modal
279
- self._view_row_detail()
280
- elif event.key == "minus":
281
- # Remove the current column
282
- self._delete_column()
283
- elif event.key == "left_square_bracket": # '['
284
- # Sort by current column in ascending order
285
- self._sort_by_column(descending=False)
286
- elif event.key == "right_square_bracket": # ']'
287
- # Sort by current column in descending order
288
- self._sort_by_column(descending=True)
289
- elif event.key == "ctrl+s":
290
- # Save dataframe to CSV
291
- self._save_to_file()
292
- elif event.key == "F": # shift+f
293
- # Open frequency modal for current column
294
- self._show_frequency()
295
- elif event.key == "v":
296
- # Filter by current cell value
297
- self._filter_rows()
298
- elif event.key == "V": # shift+v
299
- # Open filter screen for current column
300
- self._open_filter_screen()
301
- elif event.key == "e":
302
- # Open edit modal for current cell
303
- self._edit_cell()
304
- elif event.key == "backslash": # '\' key
305
- # Search with current cell value and highlight matched rows
306
- self._search_with_cell_value()
307
- elif event.key == "vertical_line": # '|' key
308
- # Open search modal for current column
309
- self._search_column()
310
- elif event.key == "slash": # '/' key
311
- # Open search modal for all columns
312
- self._search_column(all_columns=True)
313
- elif event.key == "s":
314
- # Toggle selection for current row
315
- self._toggle_selected_rows(current_row=True)
316
- elif event.key == "t":
317
- # Toggle selected rows highlighting
318
- self._toggle_selected_rows()
319
- elif event.key == "quotation_mark": # '"' key
320
- # Display selected rows only
321
- self._filter_selected_rows()
322
- elif event.key == "d":
323
- # Delete the current row
324
- self._delete_row()
325
- elif event.key == "u":
326
- # Undo last action
327
- self._undo()
328
- elif event.key == "U":
329
- # Undo all changes and restore original dataframe
330
- self._setup_table(reset=True)
331
- self.app.notify("Restored original display", title="Reset")
332
- elif event.key == "shift+left": # shift + left arrow
333
- # Move current column to the left
334
- self._move_column("left")
335
- elif event.key == "shift+right": # shift + right arrow
336
- # Move current column to the right
337
- self._move_column("right")
338
- elif event.key == "shift+up": # shift + up arrow
339
- # Move current row up
340
- self._move_row("up")
341
- elif event.key == "shift+down": # shift + down arrow
342
- # Move current row down
343
- self._move_row("down")
344
- elif event.key == "T": # shift+t
345
- # Clear all selected rows
346
- self._clear_selected_rows()
347
- elif event.key == "C": # shift+c
348
- # Cycle through cursor types
349
- self._cycle_cursor_type()
350
- elif event.key == "f":
351
- # Open pin screen to set fixed rows and columns
352
- self._open_freeze_screen()
557
+
558
+ def on_click(self, event: Click) -> None:
559
+ """Handle mouse click events on the table.
560
+
561
+ Supports double-click editing of cells and renaming of column headers.
562
+
563
+ Args:
564
+ event: The click event containing row and column information.
565
+
566
+ Returns:
567
+ None
568
+ """
569
+ if self.cursor_type == "cell" and event.chain > 1: # only on double-click or more
570
+ try:
571
+ row_idx = event.style.meta["row"]
572
+ # col_idx = event.style.meta["column"]
573
+ except (KeyError, TypeError):
574
+ return # Unable to get row/column info
575
+
576
+ # header row
577
+ if row_idx == -1:
578
+ self._rename_column()
579
+ else:
580
+ self._edit_cell()
581
+
582
+ # Action handlers for BINDINGS
583
+ def action_jump_top(self) -> None:
584
+ """Jump to the top of the table."""
585
+ self.move_cursor(row=0)
586
+
587
+ def action_jump_bottom(self) -> None:
588
+ """Jump to the bottom of the table."""
589
+ self._load_rows()
590
+ self.move_cursor(row=self.row_count - 1)
591
+
592
+ def action_view_row_detail(self) -> None:
593
+ """View details of the current row."""
594
+ self._view_row_detail()
595
+
596
+ def action_delete_column(self) -> None:
597
+ """Delete the current column."""
598
+ self._delete_column()
599
+
600
+ def action_delete_column_and_after(self) -> None:
601
+ """Delete the current column and those after."""
602
+ self._delete_column(more="after")
603
+
604
+ def action_delete_column_and_before(self) -> None:
605
+ """Delete the current column and those before."""
606
+ self._delete_column(more="before")
607
+
608
+ def action_hide_column(self) -> None:
609
+ """Hide the current column."""
610
+ self._hide_column()
611
+
612
+ def action_show_hidden_rows_columns(self) -> None:
613
+ """Show all hidden rows/columns."""
614
+ self._show_hidden_rows_columns()
615
+
616
+ def action_sort_ascending(self) -> None:
617
+ """Sort by current column in ascending order."""
618
+ self._sort_by_column(descending=False)
619
+
620
+ def action_sort_descending(self) -> None:
621
+ """Sort by current column in descending order."""
622
+ self._sort_by_column(descending=True)
623
+
624
+ def action_save_to_file(self) -> None:
625
+ """Save the current dataframe to a file."""
626
+ self._save_to_file()
627
+
628
+ def action_show_frequency(self) -> None:
629
+ """Show frequency distribution for the current column."""
630
+ self._show_frequency()
631
+
632
+ def action_show_statistics(self, scope: str = "column") -> None:
633
+ """Show statistics for the current column or entire dataframe.
634
+
635
+ Args:
636
+ scope: Either "column" for current column stats or "dataframe" for all columns.
637
+ """
638
+ self._show_statistics(scope)
639
+
640
+ def action_view_rows(self) -> None:
641
+ """View rows by current cell value."""
642
+ self._view_rows()
643
+
644
+ def action_view_rows_expr(self) -> None:
645
+ """Open the advanced filter screen."""
646
+ self._view_rows_expr()
647
+
648
+ def action_edit_cell(self) -> None:
649
+ """Edit the current cell."""
650
+ self._edit_cell()
651
+
652
+ def action_edit_column(self) -> None:
653
+ """Edit the entire current column with an expression."""
654
+ self._edit_column()
655
+
656
+ def action_add_column(self) -> None:
657
+ """Add an empty column after the current column."""
658
+ self._add_column()
659
+
660
+ def action_add_column_expr(self) -> None:
661
+ """Add a new column with optional expression after the current column."""
662
+ self._add_column_expr()
663
+
664
+ def action_rename_column(self) -> None:
665
+ """Rename the current column."""
666
+ self._rename_column()
667
+
668
+ def action_clear_cell(self) -> None:
669
+ """Clear the current cell (set to None)."""
670
+ self._clear_cell()
671
+
672
+ def action_search_cursor_value(self) -> None:
673
+ """Search cursor value in the current column."""
674
+ self._search_cursor_value()
675
+
676
+ def action_search_expr(self) -> None:
677
+ """Search by expression in the current column."""
678
+ self._search_expr()
679
+
680
+ def action_find_cursor_value(self, scope="column") -> None:
681
+ """Find by cursor value.
682
+
683
+ Args:
684
+ scope: "column" to find in current column, "global" to find across all columns.
685
+ """
686
+ self._find_cursor_value(scope=scope)
687
+
688
+ def action_find_expr(self, scope="column") -> None:
689
+ """Find by expression.
690
+
691
+ Args:
692
+ scope: "column" to find in current column, "global" to find across all columns.
693
+ """
694
+ self._find_expr(scope=scope)
695
+
696
+ def action_replace(self) -> None:
697
+ """Replace values in current column."""
698
+ self._replace()
699
+
700
+ def action_replace_global(self) -> None:
701
+ """Replace values across all columns."""
702
+ self._replace_global()
703
+
704
+ def action_make_selections(self) -> None:
705
+ """Toggle selection for the current row."""
706
+ self._make_selections()
707
+
708
+ def action_toggle_selections(self) -> None:
709
+ """Toggle all row selections."""
710
+ self._toggle_selections()
711
+
712
+ def action_filter_selected_rows(self) -> None:
713
+ """Filter to show only selected rows."""
714
+ self._filter_selected_rows()
715
+
716
+ def action_delete_row(self) -> None:
717
+ """Delete the current row."""
718
+ self._delete_row()
719
+
720
+ def action_delete_row_and_below(self) -> None:
721
+ """Delete the current row and those below."""
722
+ self._delete_row(more="below")
723
+
724
+ def action_delete_row_and_up(self) -> None:
725
+ """Delete the current row and those above."""
726
+ self._delete_row(more="above")
727
+
728
+ def action_duplicate_column(self) -> None:
729
+ """Duplicate the current column."""
730
+ self._duplicate_column()
731
+
732
+ def action_duplicate_row(self) -> None:
733
+ """Duplicate the current row."""
734
+ self._duplicate_row()
735
+
736
+ def action_undo(self) -> None:
737
+ """Undo the last action."""
738
+ self._undo()
739
+
740
+ def action_redo(self) -> None:
741
+ """Redo the last undone action."""
742
+ self._redo()
743
+
744
+ def action_reset(self) -> None:
745
+ """Reset to the initial state."""
746
+ self._setup_table(reset=True)
747
+ self.notify("Restored initial state", title="Reset")
748
+
749
+ def action_move_column_left(self) -> None:
750
+ """Move the current column to the left."""
751
+ self._move_column("left")
752
+
753
+ def action_move_column_right(self) -> None:
754
+ """Move the current column to the right."""
755
+ self._move_column("right")
756
+
757
+ def action_move_row_up(self) -> None:
758
+ """Move the current row up."""
759
+ self._move_row("up")
760
+
761
+ def action_move_row_down(self) -> None:
762
+ """Move the current row down."""
763
+ self._move_row("down")
764
+
765
+ def action_clear_selections_and_matches(self) -> None:
766
+ """Clear all row selections and matches."""
767
+ self._clear_selections_and_matches()
768
+
769
+ def action_cycle_cursor_type(self) -> None:
770
+ """Cycle through cursor types."""
771
+ self._cycle_cursor_type()
772
+
773
+ def action_freeze_row_column(self) -> None:
774
+ """Open the freeze screen."""
775
+ self._freeze_row_column()
776
+
777
+ def action_toggle_row_labels(self) -> None:
778
+ """Toggle row labels visibility."""
779
+ self.show_row_labels = not self.show_row_labels
780
+ # status = "shown" if self.show_row_labels else "hidden"
781
+ # self.notify(f"Row labels {status}", title="Labels")
782
+
783
+ def action_cast_column_dtype(self, dtype: str | pl.DataType) -> None:
784
+ """Cast the current column to a different data type."""
785
+ self._cast_column_dtype(dtype)
786
+
787
+ def action_copy_cell(self) -> None:
788
+ """Copy the current cell to clipboard."""
789
+ ridx = self.cursor_row_idx
790
+ cidx = self.cursor_col_idx
791
+
792
+ try:
793
+ cell_str = str(self.df.item(ridx, cidx))
794
+ self._copy_to_clipboard(cell_str, f"Copied: [$success]{cell_str[:50]}[/]")
795
+ except IndexError:
796
+ self.notify("Error copying cell", title="Clipboard", severity="error")
797
+
798
+ def action_copy_column(self) -> None:
799
+ """Copy the current column to clipboard (one value per line)."""
800
+ col_name = self.cursor_col_name
801
+
802
+ try:
803
+ # Get all values in the column and join with newlines
804
+ col_values = [str(val) for val in self.df[col_name].to_list()]
805
+ col_str = "\n".join(col_values)
806
+
807
+ self._copy_to_clipboard(
808
+ col_str,
809
+ f"Copied [$accent]{len(col_values)}[/] values from column [$success]{col_name}[/]",
810
+ )
811
+ except (FileNotFoundError, IndexError):
812
+ self.notify("Error copying column", title="Clipboard", severity="error")
813
+
814
+ def action_copy_row(self) -> None:
815
+ """Copy the current row to clipboard (values separated by tabs)."""
816
+ ridx = self.cursor_row_idx
817
+
818
+ try:
819
+ # Get all values in the row and join with tabs
820
+ row_values = [str(val) for val in self.df.row(ridx)]
821
+ row_str = "\t".join(row_values)
822
+
823
+ self._copy_to_clipboard(
824
+ row_str,
825
+ f"Copied row [$accent]{ridx + 1}[/] with [$success]{len(row_values)}[/] values",
826
+ )
827
+ except (FileNotFoundError, IndexError):
828
+ self.notify("Error copying row", title="Clipboard", severity="error")
829
+
830
+ def action_make_cell_clickable(self) -> None:
831
+ """Make cells with URLs in current column clickable."""
832
+ self._make_cell_clickable()
833
+
834
+ def action_show_thousand_separator(self) -> None:
835
+ """Toggle thousand separator for numeric display."""
836
+ self.thousand_separator = not self.thousand_separator
837
+ self._setup_table()
838
+ # status = "enabled" if self.thousand_separator else "disabled"
839
+ # self.notify(f"Thousand separator {status}", title="Display")
840
+
841
+ def action_next_match(self) -> None:
842
+ """Go to the next matched cell."""
843
+ self._next_match()
844
+
845
+ def action_previous_match(self) -> None:
846
+ """Go to the previous matched cell."""
847
+ self._previous_match()
848
+
849
+ def action_next_selected_row(self) -> None:
850
+ """Go to the next selected row."""
851
+ self._next_selected_row()
852
+
853
+ def action_previous_selected_row(self) -> None:
854
+ """Go to the previous selected row."""
855
+ self._previous_selected_row()
353
856
 
354
857
  def on_mouse_scroll_down(self, event) -> None:
355
858
  """Load more rows when scrolling down with mouse."""
@@ -357,16 +860,22 @@ class DataFrameTable(DataTable):
357
860
 
358
861
  # Setup & Loading
359
862
  def _setup_table(self, reset: bool = False) -> None:
360
- """Setup the table for display."""
863
+ """Setup the table for display.
864
+
865
+ Row keys are 0-based indices, which map directly to dataframe row indices.
866
+ Column keys are header names from the dataframe.
867
+ """
361
868
  # Reset to original dataframe
362
869
  if reset:
363
- self.df = self.dataframe
870
+ self.df = self.lazyframe.collect()
364
871
  self.loaded_rows = 0
365
872
  self.sorted_columns = {}
873
+ self.hidden_columns = set()
366
874
  self.selected_rows = [False] * len(self.df)
367
875
  self.visible_rows = [True] * len(self.df)
368
876
  self.fixed_rows = 0
369
877
  self.fixed_columns = 0
878
+ self.matches = defaultdict(set)
370
879
 
371
880
  # Lazy load up to INITIAL_BATCH_SIZE visible rows
372
881
  stop, visible_count = len(self.df), 0
@@ -374,61 +883,55 @@ class DataFrameTable(DataTable):
374
883
  if not visible:
375
884
  continue
376
885
  visible_count += 1
377
- if visible_count >= INITIAL_BATCH_SIZE:
886
+ if visible_count >= self.INITIAL_BATCH_SIZE:
378
887
  stop = row_idx + 1
379
888
  break
380
889
 
890
+ # Save current cursor position before clearing
891
+ row_idx, col_idx = self.cursor_coordinate
892
+
381
893
  self._setup_columns()
382
894
  self._load_rows(stop)
383
- self._highlight_rows()
895
+ self._do_highlight()
384
896
 
385
897
  # Restore cursor position
386
- row_idx, col_idx = self.cursor_coordinate
387
898
  if row_idx < len(self.rows) and col_idx < len(self.columns):
388
899
  self.move_cursor(row=row_idx, column=col_idx)
389
900
 
390
901
  def _setup_columns(self) -> None:
391
- """Clear table and setup columns."""
902
+ """Clear table and setup columns.
903
+
904
+ Column keys are header names from the dataframe.
905
+ Column labels contain column names from the dataframe, with sort indicators if applicable.
906
+ """
392
907
  self.loaded_rows = 0
393
908
  self.clear(columns=True)
394
909
  self.show_row_labels = True
395
910
 
396
911
  # Add columns with justified headers
397
912
  for col, dtype in zip(self.df.columns, self.df.dtypes):
913
+ if col in self.hidden_columns:
914
+ continue # Skip hidden columns
398
915
  for idx, c in enumerate(self.sorted_columns, 1):
399
916
  if c == col:
400
917
  # Add sort indicator to column header
401
918
  descending = self.sorted_columns[col]
402
919
  sort_indicator = (
403
- f" ▼{SUBSCRIPT_DIGITS.get(idx, '')}"
404
- if descending
405
- else f" ▲{SUBSCRIPT_DIGITS.get(idx, '')}"
920
+ f" ▼{SUBSCRIPT_DIGITS.get(idx, '')}" if descending else f" ▲{SUBSCRIPT_DIGITS.get(idx, '')}"
406
921
  )
407
- header_text = col + sort_indicator
408
- self.add_column(
409
- Text(header_text, justify=DtypeConfig(dtype).justify), key=col
410
- )
411
-
922
+ cell_value = col + sort_indicator
412
923
  break
413
924
  else: # No break occurred, so column is not sorted
414
- self.add_column(Text(col, justify=DtypeConfig(dtype).justify), key=col)
415
-
416
- def _check_and_load_more(self) -> None:
417
- """Check if we need to load more rows and load them."""
418
- # If we've loaded everything, no need to check
419
- if self.loaded_rows >= len(self.df):
420
- return
421
-
422
- visible_row_count = self.size.height - self.header_height
423
- bottom_visible_row = self.scroll_y + visible_row_count
925
+ cell_value = col
424
926
 
425
- # If visible area is close to the end of loaded rows, load more
426
- if bottom_visible_row >= self.loaded_rows - 10:
427
- self._load_rows(self.loaded_rows + BATCH_SIZE)
927
+ self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col)
428
928
 
429
929
  def _load_rows(self, stop: int | None = None) -> None:
430
930
  """Load a batch of rows into the table.
431
931
 
932
+ Row keys are 0-based indices as strings, which map directly to dataframe row indices.
933
+ Row labels are 1-based indices as strings.
934
+
432
935
  Args:
433
936
  stop: Stop loading rows when this index is reached. If None, load until the end of the dataframe.
434
937
  """
@@ -444,128 +947,202 @@ class DataFrameTable(DataTable):
444
947
  for row_idx, row in enumerate(df_slice.rows(), start):
445
948
  if not self.visible_rows[row_idx]:
446
949
  continue # Skip hidden rows
950
+
447
951
  vals, dtypes = [], []
448
- for val, dtype in zip(row, self.df.dtypes):
952
+ for val, col, dtype in zip(row, self.df.columns, self.df.dtypes):
953
+ if col in self.hidden_columns:
954
+ continue # Skip hidden columns
449
955
  vals.append(val)
450
956
  dtypes.append(dtype)
451
- formatted_row = _format_row(vals, dtypes)
957
+ formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
958
+
452
959
  # Always add labels so they can be shown/hidden via CSS
453
- self.add_row(*formatted_row, key=str(row_idx + 1), label=str(row_idx + 1))
960
+ self.add_row(*formatted_row, key=str(row_idx), label=str(row_idx + 1))
454
961
 
455
962
  # Update loaded rows count
456
963
  self.loaded_rows = stop
457
964
 
458
- self.app.notify(
459
- f"Loaded [$accent]{self.loaded_rows}/{len(self.df)}[/] rows from [on $primary]{self.tabname}[/]",
460
- title="Load",
461
- )
965
+ # self.notify(f"Loaded [$accent]{stop}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
966
+
967
+ def _check_and_load_more(self) -> None:
968
+ """Check if we need to load more rows and load them."""
969
+ # If we've loaded everything, no need to check
970
+ if self.loaded_rows >= len(self.df):
971
+ return
972
+
973
+ visible_row_count = self.size.height - self.header_height
974
+ bottom_visible_row = self.scroll_y + visible_row_count
462
975
 
463
- def _highlight_rows(self, clear: bool = False) -> None:
464
- """Update all rows, highlighting selected ones in red and restoring others to default.
976
+ # If visible area is close to the end of loaded rows, load more
977
+ if bottom_visible_row >= self.loaded_rows - 10:
978
+ self._load_rows(self.loaded_rows + self.BATCH_SIZE)
979
+
980
+ def _do_highlight(self, force: bool = False) -> None:
981
+ """Update all rows, highlighting selected ones and restoring others to default.
465
982
 
466
983
  Args:
467
- clear: If True, clear all highlights.
984
+ force: If True, clear all highlights and restore default styles.
468
985
  """
469
- if True not in self.selected_rows:
470
- return
986
+ # Ensure all selected rows or matches are loaded
987
+ stop = rindex(self.selected_rows, True) + 1
988
+ stop = max(stop, max(self.matches.keys(), default=0) + 1)
471
989
 
472
- if clear:
473
- self.selected_rows = [False] * len(self.df)
474
-
475
- # Ensure all highlighted rows are loaded
476
- stop = _rindex(self.selected_rows, True) + 1
477
990
  self._load_rows(stop)
991
+ self._highlight_table(force)
992
+
993
+ def _highlight_table(self, force: bool = False) -> None:
994
+ """Highlight selected rows/cells in red."""
995
+ if not force and not any(self.selected_rows) and not self.matches:
996
+ return # Nothing to highlight
478
997
 
479
998
  # Update all rows based on selected state
480
999
  for row in self.ordered_rows:
481
- row_idx = int(row.key.value) - 1 # Convert to 0-based index
482
- is_selected = self.selected_rows[row_idx]
1000
+ ridx = int(row.key.value) # 0-based index
1001
+ is_selected = self.selected_rows[ridx]
1002
+ match_cols = self.matches.get(ridx, set())
1003
+
1004
+ if not force and not is_selected and not match_cols:
1005
+ continue # No highlight needed for this row
483
1006
 
484
1007
  # Update all cells in this row
485
1008
  for col_idx, col in enumerate(self.ordered_columns):
486
- cell_text: Text = self.get_cell(row.key, col.key)
487
- dtype = self.df.dtypes[col_idx]
488
-
489
- # Get style config based on dtype
490
- dc = DtypeConfig(dtype)
1009
+ if not force and not is_selected and col_idx not in match_cols:
1010
+ continue # No highlight needed for this cell
491
1011
 
492
- # Use red for selected rows, default style for others
493
- style = "red" if is_selected else dc.style
494
- cell_text.style = style
1012
+ cell_text: Text = self.get_cell(row.key, col.key)
1013
+ need_update = False
1014
+
1015
+ if is_selected or col_idx in match_cols:
1016
+ cell_text.style = "red"
1017
+ need_update = True
1018
+ elif force:
1019
+ # Restore original style based on dtype
1020
+ dtype = self.df.schema[col.key.value]
1021
+ dc = DtypeConfig(dtype)
1022
+ cell_text.style = dc.style
1023
+ need_update = True
495
1024
 
496
1025
  # Update the cell in the table
497
- self.update_cell(row.key, col.key, cell_text)
1026
+ if need_update:
1027
+ self.update_cell(row.key, col.key, cell_text)
498
1028
 
499
1029
  # History & Undo
500
- def _add_history(self, description: str) -> None:
501
- """Add the current state to the history stack.
502
-
503
- Args:
504
- description: Description of the action for this history entry.
505
- """
506
- history = History(
1030
+ def _create_history(self, description: str) -> None:
1031
+ """Create the initial history state."""
1032
+ return History(
507
1033
  description=description,
508
1034
  df=self.df,
509
1035
  filename=self.filename,
510
1036
  loaded_rows=self.loaded_rows,
511
1037
  sorted_columns=self.sorted_columns.copy(),
1038
+ hidden_columns=self.hidden_columns.copy(),
512
1039
  selected_rows=self.selected_rows.copy(),
513
1040
  visible_rows=self.visible_rows.copy(),
514
1041
  fixed_rows=self.fixed_rows,
515
1042
  fixed_columns=self.fixed_columns,
516
1043
  cursor_coordinate=self.cursor_coordinate,
1044
+ matches={k: v.copy() for k, v in self.matches.items()},
517
1045
  )
518
- self.histories.append(history)
519
1046
 
520
- def _undo(self) -> None:
521
- """Undo the last action."""
522
- if not self.histories:
523
- self.app.notify("No actions to undo", title="Undo", severity="warning")
1047
+ def _apply_history(self, history: History) -> None:
1048
+ """Apply the current history state to the table."""
1049
+ if history is None:
524
1050
  return
525
1051
 
526
- history = self.histories.pop()
527
-
528
1052
  # Restore state
529
1053
  self.df = history.df
530
1054
  self.filename = history.filename
531
1055
  self.loaded_rows = history.loaded_rows
532
1056
  self.sorted_columns = history.sorted_columns.copy()
1057
+ self.hidden_columns = history.hidden_columns.copy()
533
1058
  self.selected_rows = history.selected_rows.copy()
534
1059
  self.visible_rows = history.visible_rows.copy()
535
1060
  self.fixed_rows = history.fixed_rows
536
1061
  self.fixed_columns = history.fixed_columns
537
1062
  self.cursor_coordinate = history.cursor_coordinate
1063
+ self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
538
1064
 
539
1065
  # Recreate the table for display
540
1066
  self._setup_table()
541
1067
 
542
- self.app.notify(f"Reverted: {history.description}", title="Undo")
1068
+ def _add_history(self, description: str) -> None:
1069
+ """Add the current state to the history stack.
543
1070
 
544
- # View
545
- def _view_row_detail(self) -> None:
546
- """Open a modal screen to view the selected row's details."""
547
- row_idx = self.cursor_row
548
- if row_idx >= len(self.df):
1071
+ Args:
1072
+ description: Description of the action for this history entry.
1073
+ """
1074
+ history = self._create_history(description)
1075
+ self.histories.append(history)
1076
+
1077
+ def _undo(self) -> None:
1078
+ """Undo the last action."""
1079
+ if not self.histories:
1080
+ self.notify("No actions to undo", title="Undo", severity="warning")
549
1081
  return
550
1082
 
551
- # Push the modal screen
552
- self.app.push_screen(RowDetailScreen(row_idx, self))
1083
+ # Save current state for redo
1084
+ self.history = self._create_history("Redo state")
553
1085
 
554
- def _show_frequency(self) -> None:
555
- """Show frequency distribution for the current column."""
556
- col_idx = self.cursor_column
557
- if col_idx >= len(self.df.columns):
558
- return
1086
+ # Pop the last history state for undo
1087
+ history = self.histories.pop()
559
1088
 
560
- # Push the frequency modal screen
561
- self.app.push_screen(FrequencyScreen(col_idx, self))
1089
+ # Restore state
1090
+ self._apply_history(history)
562
1091
 
563
- def _open_freeze_screen(self) -> None:
564
- """Open the freeze screen to set fixed rows and columns."""
565
- self.app.push_screen(FreezeScreen(), callback=self._do_freeze)
1092
+ # self.notify(f"Reverted: {history.description}", title="Undo")
566
1093
 
567
- def _do_freeze(self, result: tuple[int, int] | None) -> None:
568
- """Handle result from FreezeScreen.
1094
+ def _redo(self) -> None:
1095
+ """Redo the last undone action."""
1096
+ if self.history is None:
1097
+ self.notify("No actions to redo", title="Redo", severity="warning")
1098
+ return
1099
+
1100
+ # Save current state for undo
1101
+ self._add_history("Undo state")
1102
+
1103
+ # Restore state
1104
+ self._apply_history(self.history)
1105
+
1106
+ # Clear redo state
1107
+ self.history = None
1108
+
1109
+ # self.notify(f"Reapplied: {history.description}", title="Redo")
1110
+
1111
+ # View
1112
+ def _view_row_detail(self) -> None:
1113
+ """Open a modal screen to view the selected row's details."""
1114
+ ridx = self.cursor_row_idx
1115
+
1116
+ # Push the modal screen
1117
+ self.app.push_screen(RowDetailScreen(ridx, self))
1118
+
1119
+ def _show_frequency(self) -> None:
1120
+ """Show frequency distribution for the current column."""
1121
+ cidx = self.cursor_col_idx
1122
+
1123
+ # Push the frequency modal screen
1124
+ self.app.push_screen(FrequencyScreen(cidx, self))
1125
+
1126
+ def _show_statistics(self, scope: str = "column") -> None:
1127
+ """Show statistics for the current column or entire dataframe.
1128
+
1129
+ Args:
1130
+ scope: Either "column" for current column stats or "dataframe" for all columns.
1131
+ """
1132
+ if scope == "dataframe":
1133
+ # Show statistics for entire dataframe
1134
+ self.app.push_screen(StatisticsScreen(self, col_idx=None))
1135
+ else:
1136
+ # Show statistics for current column
1137
+ cidx = self.cursor_col_idx
1138
+ self.app.push_screen(StatisticsScreen(self, col_idx=cidx))
1139
+
1140
+ def _freeze_row_column(self) -> None:
1141
+ """Open the freeze screen to set fixed rows and columns."""
1142
+ self.app.push_screen(FreezeScreen(), callback=self._do_freeze)
1143
+
1144
+ def _do_freeze(self, result: tuple[int, int] | None) -> None:
1145
+ """Handle result from PinScreen.
569
1146
 
570
1147
  Args:
571
1148
  result: Tuple of (fixed_rows, fixed_columns) or None if cancelled.
@@ -576,101 +1153,280 @@ class DataFrameTable(DataTable):
576
1153
  fixed_rows, fixed_columns = result
577
1154
 
578
1155
  # Add to history
579
- self._add_history(
580
- f"Pinned [$accent]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns"
581
- )
1156
+ self._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
582
1157
 
583
1158
  # Apply the pin settings to the table
584
- if fixed_rows > 0:
1159
+ if fixed_rows >= 0:
585
1160
  self.fixed_rows = fixed_rows
586
- if fixed_columns > 0:
1161
+ if fixed_columns >= 0:
587
1162
  self.fixed_columns = fixed_columns
588
1163
 
589
- self.app.notify(
590
- f"Pinned [$accent]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns",
1164
+ self.notify(
1165
+ f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
591
1166
  title="Pin",
592
1167
  )
593
1168
 
594
1169
  # Delete & Move
595
- def _delete_column(self) -> None:
1170
+ def _delete_column(self, more: str = None) -> None:
596
1171
  """Remove the currently selected column from the table."""
1172
+ # Get the column to remove
597
1173
  col_idx = self.cursor_column
598
- if col_idx >= len(self.df.columns):
599
- return
1174
+ col_name = self.cursor_col_name
1175
+ col_key = self.cursor_col_key
1176
+
1177
+ col_names_to_remove = []
1178
+ col_keys_to_remove = []
1179
+
1180
+ # Remove all columns before the current column
1181
+ if more == "before":
1182
+ for i in range(col_idx + 1):
1183
+ col_key = self.get_column_key(i)
1184
+ col_names_to_remove.append(col_key.value)
1185
+ col_keys_to_remove.append(col_key)
1186
+
1187
+ descr = f"Removed column [$success]{col_name}[/] and all columns before"
600
1188
 
601
- # Get the column name to remove
602
- col_to_remove = self.df.columns[col_idx]
1189
+ # Remove all columns after the current column
1190
+ elif more == "after":
1191
+ for i in range(col_idx, len(self.columns)):
1192
+ col_key = self.get_column_key(i)
1193
+ col_names_to_remove.append(col_key.value)
1194
+ col_keys_to_remove.append(col_key)
1195
+
1196
+ descr = f"Removed column [$success]{col_name}[/] and all columns after"
1197
+
1198
+ # Remove only the current column
1199
+ else:
1200
+ col_names_to_remove.append(col_name)
1201
+ col_keys_to_remove.append(col_key)
1202
+ descr = f"Removed column [$success]{col_name}[/]"
603
1203
 
604
1204
  # Add to history
605
- self._add_history(f"Removed column [on $primary]{col_to_remove}[/]")
1205
+ self._add_history(descr)
606
1206
 
607
- # Remove the column from the table display using the column name as key
608
- self.remove_column(col_to_remove)
1207
+ # Remove the columns from the table display using the column names as keys
1208
+ for ck in col_keys_to_remove:
1209
+ self.remove_column(ck)
609
1210
 
610
- # Move cursor left if we deleted the last column
611
- if col_idx >= len(self.columns):
612
- self.move_cursor(column=len(self.columns) - 1)
1211
+ # Move cursor left if we deleted the last column(s)
1212
+ last_col_idx = len(self.columns) - 1
1213
+ if col_idx > last_col_idx:
1214
+ self.move_cursor(column=last_col_idx)
613
1215
 
614
1216
  # Remove from sorted columns if present
615
- if col_to_remove in self.sorted_columns:
616
- del self.sorted_columns[col_to_remove]
1217
+ for col_name in col_names_to_remove:
1218
+ if col_name in self.sorted_columns:
1219
+ del self.sorted_columns[col_name]
1220
+
1221
+ # Remove from matches
1222
+ col_indices_to_remove = set(self.df.columns.index(name) for name in col_names_to_remove)
1223
+ for row_idx in list(self.matches.keys()):
1224
+ self.matches[row_idx].difference_update(col_indices_to_remove)
1225
+ # Remove empty entries
1226
+ if not self.matches[row_idx]:
1227
+ del self.matches[row_idx]
617
1228
 
618
1229
  # Remove from dataframe
619
- self.df = self.df.drop(col_to_remove)
1230
+ self.df = self.df.drop(col_names_to_remove)
620
1231
 
621
- self.app.notify(
622
- f"Removed column [on $primary]{col_to_remove}[/] from display",
623
- title="Column",
1232
+ # self.notify(descr, title="Delete")
1233
+
1234
+ def _hide_column(self) -> None:
1235
+ """Hide the currently selected column from the table display."""
1236
+ col_key = self.cursor_col_key
1237
+ col_name = col_key.value
1238
+ col_idx = self.cursor_column
1239
+
1240
+ # Add to history
1241
+ self._add_history(f"Hid column [$success]{col_name}[/]")
1242
+
1243
+ # Remove the column from the table display (but keep in dataframe)
1244
+ self.remove_column(col_key)
1245
+
1246
+ # Track hidden columns
1247
+ self.hidden_columns.add(col_name)
1248
+
1249
+ # Move cursor left if we hid the last column
1250
+ if col_idx >= len(self.columns):
1251
+ self.move_cursor(column=len(self.columns) - 1)
1252
+
1253
+ # self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
1254
+
1255
+ def _show_hidden_rows_columns(self) -> None:
1256
+ """Show all hidden rows/columns by recreating the table."""
1257
+ # Get currently visible columns
1258
+ visible_cols = set(col.key for col in self.ordered_columns)
1259
+
1260
+ hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
1261
+ hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
1262
+
1263
+ if not hidden_row_count and not hidden_col_count:
1264
+ self.notify("No hidden columns or rows to show", title="Show", severity="warning")
1265
+ return
1266
+
1267
+ # Add to history
1268
+ self._add_history("Showed hidden rows/columns")
1269
+
1270
+ # Clear hidden rows/columns tracking
1271
+ self.visible_rows = [True] * len(self.df)
1272
+ self.hidden_columns.clear()
1273
+
1274
+ # Recreate table for display
1275
+ self._setup_table()
1276
+
1277
+ self.notify(
1278
+ f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] hidden column(s)",
1279
+ title="Show",
624
1280
  )
625
1281
 
626
- def _delete_row(self) -> None:
1282
+ def _duplicate_column(self) -> None:
1283
+ """Duplicate the currently selected column, inserting it right after the current column."""
1284
+ cidx = self.cursor_col_idx
1285
+ col_name = self.cursor_col_name
1286
+
1287
+ col_idx = self.cursor_column
1288
+ new_col_name = f"{col_name}_copy"
1289
+
1290
+ # Add to history
1291
+ self._add_history(f"Duplicated column [$success]{col_name}[/]")
1292
+
1293
+ # Create new column and reorder columns to insert after current column
1294
+ cols_before = self.df.columns[: cidx + 1]
1295
+ cols_after = self.df.columns[cidx + 1 :]
1296
+
1297
+ # Add the new column and reorder columns for insertion after current column
1298
+ self.df = self.df.with_columns(pl.col(col_name).alias(new_col_name)).select(
1299
+ list(cols_before) + [new_col_name] + list(cols_after)
1300
+ )
1301
+
1302
+ # Update matches to account for new column
1303
+ new_matches = defaultdict(set)
1304
+ for row_idx, cols in self.matches.items():
1305
+ new_cols = set()
1306
+ for col_idx_in_set in cols:
1307
+ if col_idx_in_set <= cidx:
1308
+ new_cols.add(col_idx_in_set)
1309
+ else:
1310
+ new_cols.add(col_idx_in_set + 1)
1311
+ new_matches[row_idx] = new_cols
1312
+ self.matches = new_matches
1313
+
1314
+ # Recreate the table for display
1315
+ self._setup_table()
1316
+
1317
+ # Move cursor to the new duplicated column
1318
+ self.move_cursor(column=col_idx + 1)
1319
+
1320
+ self.notify(
1321
+ f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]",
1322
+ title="Duplicate",
1323
+ )
1324
+
1325
+ def _delete_row(self, more: str = None) -> None:
627
1326
  """Delete rows from the table and dataframe.
628
1327
 
629
1328
  Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
630
1329
  """
631
1330
  old_count = len(self.df)
632
- filter_expr = [True] * len(self.df)
1331
+ predicates = [True] * len(self.df)
633
1332
 
634
1333
  # Delete all selected rows
635
1334
  if selected_count := self.selected_rows.count(True):
636
1335
  history_desc = f"Deleted {selected_count} selected row(s)"
637
1336
 
638
- for i, is_selected in enumerate(self.selected_rows):
639
- if is_selected:
640
- filter_expr[i] = False
1337
+ for ridx, selected in enumerate(self.selected_rows):
1338
+ if selected:
1339
+ predicates[ridx] = False
1340
+
1341
+ # Delete current row and those above
1342
+ elif more == "above":
1343
+ ridx = self.cursor_row_idx
1344
+ history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
1345
+ for i in range(ridx + 1):
1346
+ predicates[i] = False
1347
+
1348
+ # Delete current row and those below
1349
+ elif more == "below":
1350
+ ridx = self.cursor_row_idx
1351
+ history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
1352
+ for i in range(ridx, len(self.df)):
1353
+ if self.visible_rows[i]:
1354
+ predicates[i] = False
1355
+
641
1356
  # Delete the row at the cursor
642
1357
  else:
643
- row_key = self.cursor_row_key
644
- i = int(row_key.value) - 1 # Convert to 0-based index
645
-
646
- filter_expr[i] = False
647
- history_desc = f"Deleted row [on $primary]{row_key.value}[/]"
1358
+ ridx = self.cursor_row_idx
1359
+ history_desc = f"Deleted row [$success]{ridx + 1}[/]"
1360
+ if self.visible_rows[ridx]:
1361
+ predicates[ridx] = False
648
1362
 
649
1363
  # Add to history
650
1364
  self._add_history(history_desc)
651
1365
 
652
1366
  # Apply the filter to remove rows
653
- df = self.df.with_row_index("__rid__").filter(filter_expr)
654
- self.df = df.drop("__rid__")
1367
+ try:
1368
+ df = self.df.with_row_index(RIDX).filter(predicates)
1369
+ except Exception as e:
1370
+ self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error")
1371
+ self.histories.pop() # Remove last history entry
1372
+ return
1373
+
1374
+ self.df = df.drop(RIDX)
655
1375
 
656
1376
  # Update selected and visible rows tracking
657
- old_row_indices = set(df["__rid__"].to_list())
658
- self.selected_rows = [
659
- selected
660
- for i, selected in enumerate(self.selected_rows)
661
- if i in old_row_indices
662
- ]
663
- self.visible_rows = [
664
- visible
665
- for i, visible in enumerate(self.visible_rows)
666
- if i in old_row_indices
667
- ]
1377
+ old_row_indices = set(df[RIDX].to_list())
1378
+ self.selected_rows = [selected for i, selected in enumerate(self.selected_rows) if i in old_row_indices]
1379
+ self.visible_rows = [visible for i, visible in enumerate(self.visible_rows) if i in old_row_indices]
1380
+
1381
+ # Clear all matches since row indices have changed
1382
+ self.matches = defaultdict(set)
668
1383
 
669
1384
  # Recreate the table display
670
1385
  self._setup_table()
671
1386
 
672
1387
  deleted_count = old_count - len(self.df)
673
- self.app.notify(f"Deleted {deleted_count} row(s)", title="Delete")
1388
+ if deleted_count > 1:
1389
+ self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
1390
+
1391
+ def _duplicate_row(self) -> None:
1392
+ """Duplicate the currently selected row, inserting it right after the current row."""
1393
+ ridx = self.cursor_row_idx
1394
+
1395
+ # Get the row to duplicate
1396
+ row_to_duplicate = self.df.slice(ridx, 1)
1397
+
1398
+ # Add to history
1399
+ self._add_history(f"Duplicated row [$success]{ridx + 1}[/]")
1400
+
1401
+ # Concatenate: rows before + duplicated row + rows after
1402
+ df_before = self.df.slice(0, ridx + 1)
1403
+ df_after = self.df.slice(ridx + 1)
1404
+
1405
+ # Combine the parts
1406
+ self.df = pl.concat([df_before, row_to_duplicate, df_after])
1407
+
1408
+ # Update selected and visible rows tracking to account for new row
1409
+ new_selected_rows = self.selected_rows[: ridx + 1] + [self.selected_rows[ridx]] + self.selected_rows[ridx + 1 :]
1410
+ new_visible_rows = self.visible_rows[: ridx + 1] + [self.visible_rows[ridx]] + self.visible_rows[ridx + 1 :]
1411
+ self.selected_rows = new_selected_rows
1412
+ self.visible_rows = new_visible_rows
1413
+
1414
+ # Update matches to account for new row
1415
+ new_matches = defaultdict(set)
1416
+ for row_idx, cols in self.matches.items():
1417
+ if row_idx <= ridx:
1418
+ new_matches[row_idx] = cols
1419
+ else:
1420
+ new_matches[row_idx + 1] = cols
1421
+ self.matches = new_matches
1422
+
1423
+ # Recreate the table display
1424
+ self._setup_table()
1425
+
1426
+ # Move cursor to the new duplicated row
1427
+ self.move_cursor(row=ridx + 1)
1428
+
1429
+ # self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Row")
674
1430
 
675
1431
  def _move_column(self, direction: str) -> None:
676
1432
  """Move the current column left or right.
@@ -679,36 +1435,32 @@ class DataFrameTable(DataTable):
679
1435
  direction: "left" to move left, "right" to move right.
680
1436
  """
681
1437
  row_idx, col_idx = self.cursor_coordinate
682
- col_key = self.cursor_column_key
1438
+ col_key = self.cursor_col_key
1439
+ col_name = col_key.value
1440
+ cidx = self.cursor_col_idx
683
1441
 
684
1442
  # Validate move is possible
685
1443
  if direction == "left":
686
1444
  if col_idx <= 0:
687
- self.app.notify(
688
- "Cannot move column left", title="Move", severity="warning"
689
- )
1445
+ self.notify("Cannot move column left", title="Move", severity="warning")
690
1446
  return
691
1447
  swap_idx = col_idx - 1
692
1448
  elif direction == "right":
693
1449
  if col_idx >= len(self.columns) - 1:
694
- self.app.notify(
695
- "Cannot move column right", title="Move", severity="warning"
696
- )
1450
+ self.notify("Cannot move column right", title="Move", severity="warning")
697
1451
  return
698
1452
  swap_idx = col_idx + 1
699
1453
 
700
- # Get column names to swap
701
- col_name = self.df.columns[col_idx]
702
- swap_name = self.df.columns[swap_idx]
1454
+ # Get column to swap
1455
+ _, swap_key = self.coordinate_to_cell_key(Coordinate(row_idx, swap_idx))
1456
+ swap_name = swap_key.value
1457
+ swap_cidx = self.df.columns.index(swap_name)
703
1458
 
704
1459
  # Add to history
705
- self._add_history(
706
- f"Moved column [on $primary]{col_name}[/] {direction} (swapped with [on $primary]{swap_name}[/])"
707
- )
1460
+ self._add_history(f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])")
708
1461
 
709
1462
  # Swap columns in the table's internal column locations
710
1463
  self.check_idle()
711
- swap_key = self.df.columns[swap_idx] # str as column key
712
1464
 
713
1465
  (
714
1466
  self._column_locations[col_key],
@@ -726,13 +1478,10 @@ class DataFrameTable(DataTable):
726
1478
 
727
1479
  # Update the dataframe column order
728
1480
  cols = list(self.df.columns)
729
- cols[col_idx], cols[swap_idx] = cols[swap_idx], cols[col_idx]
1481
+ cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
730
1482
  self.df = self.df.select(cols)
731
1483
 
732
- self.app.notify(
733
- f"Moved column [on $primary]{col_name}[/] {direction}",
734
- title="Move",
735
- )
1484
+ # self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
736
1485
 
737
1486
  def _move_row(self, direction: str) -> None:
738
1487
  """Move the current row up or down.
@@ -745,20 +1494,16 @@ class DataFrameTable(DataTable):
745
1494
  # Validate move is possible
746
1495
  if direction == "up":
747
1496
  if row_idx <= 0:
748
- self.app.notify("Cannot move row up", title="Move", severity="warning")
1497
+ self.notify("Cannot move row up", title="Move", severity="warning")
749
1498
  return
750
1499
  swap_idx = row_idx - 1
751
1500
  elif direction == "down":
752
1501
  if row_idx >= len(self.rows) - 1:
753
- self.app.notify(
754
- "Cannot move row down", title="Move", severity="warning"
755
- )
1502
+ self.notify("Cannot move row down", title="Move", severity="warning")
756
1503
  return
757
1504
  swap_idx = row_idx + 1
758
1505
  else:
759
- self.app.notify(
760
- f"Invalid direction: {direction}", title="Move", severity="error"
761
- )
1506
+ self.notify(f"Invalid direction: {direction}", title="Move", severity="error")
762
1507
  return
763
1508
 
764
1509
  row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
@@ -766,7 +1511,7 @@ class DataFrameTable(DataTable):
766
1511
 
767
1512
  # Add to history
768
1513
  self._add_history(
769
- f"Moved row [on $primary]{row_key.value}[/] {direction} (swapped with row [on $primary]{swap_key.value}[/])"
1514
+ f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])"
770
1515
  )
771
1516
 
772
1517
  # Swap rows in the table's internal row locations
@@ -787,9 +1532,9 @@ class DataFrameTable(DataTable):
787
1532
  self.move_cursor(row=swap_idx, column=col_idx)
788
1533
 
789
1534
  # Swap rows in the dataframe
790
- rid = int(row_key.value) - 1 # 0-based
791
- swap_rid = int(swap_key.value) - 1 # 0-based
792
- first, second = sorted([rid, swap_rid])
1535
+ ridx = int(row_key.value) # 0-based
1536
+ swap_ridx = int(swap_key.value) # 0-based
1537
+ first, second = sorted([ridx, swap_ridx])
793
1538
 
794
1539
  self.df = pl.concat(
795
1540
  [
@@ -801,9 +1546,7 @@ class DataFrameTable(DataTable):
801
1546
  ]
802
1547
  )
803
1548
 
804
- self.app.notify(
805
- f"Moved row [on $primary]{row_key.value}[/] {direction}", title="Move"
806
- )
1549
+ # self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
807
1550
 
808
1551
  # Sort
809
1552
  def _sort_by_column(self, descending: bool = False) -> None:
@@ -816,47 +1559,40 @@ class DataFrameTable(DataTable):
816
1559
  Args:
817
1560
  descending: If True, sort in descending order. If False, ascending order.
818
1561
  """
1562
+ col_name = self.cursor_col_name
819
1563
  col_idx = self.cursor_column
820
- if col_idx >= len(self.df.columns):
821
- return
822
-
823
- col_to_sort = self.df.columns[col_idx]
824
1564
 
825
1565
  # Check if this column is already in the sort keys
826
- old_desc = self.sorted_columns.get(col_to_sort)
827
- if old_desc == descending:
828
- # Same direction - remove this column from sort
829
- self.app.notify(
830
- f"Already sorted by [on $primary]{col_to_sort}[/] ({'desc' if descending else 'asc'})",
831
- title="Sort",
832
- severity="warning",
833
- )
834
- return
1566
+ old_desc = self.sorted_columns.get(col_name)
835
1567
 
836
1568
  # Add to history
837
- self._add_history(f"Sorted on column [on $primary]{col_to_sort}[/]")
1569
+ self._add_history(f"Sorted on column [$success]{col_name}[/]")
838
1570
  if old_desc is None:
839
1571
  # Add new column to sort
840
- self.sorted_columns[col_to_sort] = descending
1572
+ self.sorted_columns[col_name] = descending
1573
+ elif old_desc == descending:
1574
+ # Same direction - remove from sort
1575
+ del self.sorted_columns[col_name]
841
1576
  else:
842
- # Toggle direction and move to end of sort order
843
- del self.sorted_columns[col_to_sort]
844
- self.sorted_columns[col_to_sort] = descending
1577
+ # Move to end of sort order
1578
+ del self.sorted_columns[col_name]
1579
+ self.sorted_columns[col_name] = descending
845
1580
 
846
1581
  # Apply multi-column sort
847
- sort_cols = list(self.sorted_columns.keys())
848
- descending_flags = list(self.sorted_columns.values())
849
- df_sorted = self.df.with_row_index("__rid__").sort(
850
- sort_cols, descending=descending_flags, nulls_last=True
851
- )
1582
+ if sort_cols := list(self.sorted_columns.keys()):
1583
+ descending_flags = list(self.sorted_columns.values())
1584
+ df_sorted = self.df.with_row_index(RIDX).sort(sort_cols, descending=descending_flags, nulls_last=True)
1585
+ else:
1586
+ # No sort columns - restore original order
1587
+ df_sorted = self.df.with_row_index(RIDX)
852
1588
 
853
1589
  # Updated selected_rows and visible_rows to match new order
854
- old_row_indices = df_sorted["__rid__"].to_list()
1590
+ old_row_indices = df_sorted[RIDX].to_list()
855
1591
  self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
856
1592
  self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
857
1593
 
858
1594
  # Update the dataframe
859
- self.df = df_sorted.drop("__rid__")
1595
+ self.df = df_sorted.drop(RIDX)
860
1596
 
861
1597
  # Recreate the table for display
862
1598
  self._setup_table()
@@ -865,22 +1601,18 @@ class DataFrameTable(DataTable):
865
1601
  self.move_cursor(column=col_idx, row=0)
866
1602
 
867
1603
  # Edit
868
- def _edit_cell(self) -> None:
1604
+ def _edit_cell(self, ridx: int = None, cidx: int = None) -> None:
869
1605
  """Open modal to edit the selected cell."""
870
- row_key = self.cursor_row_key
871
- row_idx = int(row_key.value) - 1 # Convert to 0-based
872
- col_idx = self.cursor_column
873
-
874
- if row_idx >= len(self.df) or col_idx >= len(self.df.columns):
875
- return
876
- col_name = self.df.columns[col_idx]
1606
+ ridx = self.cursor_row_idx if ridx is None else ridx
1607
+ cidx = self.cursor_col_idx if cidx is None else cidx
1608
+ col_name = self.df.columns[cidx]
877
1609
 
878
1610
  # Save current state to history
879
- self._add_history(f"Edited cell [on $primary]({row_idx + 1}, {col_name})[/]")
1611
+ self._add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]")
880
1612
 
881
1613
  # Push the edit modal screen
882
1614
  self.app.push_screen(
883
- EditCellScreen(row_key, col_idx, self.df),
1615
+ EditCellScreen(ridx, cidx, self.df),
884
1616
  callback=self._do_edit_cell,
885
1617
  )
886
1618
 
@@ -889,292 +1621,1038 @@ class DataFrameTable(DataTable):
889
1621
  if result is None:
890
1622
  return
891
1623
 
892
- row_key, col_idx, new_value = result
893
- row_idx = int(row_key.value) - 1 # Convert to 0-based
894
- col_name = self.df.columns[col_idx]
1624
+ ridx, cidx, new_value = result
1625
+ if new_value is None:
1626
+ self.app.push_screen(
1627
+ EditCellScreen(ridx, cidx, self.df),
1628
+ callback=self._do_edit_cell,
1629
+ )
1630
+ return
1631
+
1632
+ col_name = self.df.columns[cidx]
895
1633
 
896
1634
  # Update the cell in the dataframe
897
1635
  try:
898
1636
  self.df = self.df.with_columns(
899
- pl.when(pl.arange(0, len(self.df)) == row_idx)
1637
+ pl.when(pl.arange(0, len(self.df)) == ridx)
900
1638
  .then(pl.lit(new_value))
901
1639
  .otherwise(pl.col(col_name))
902
1640
  .alias(col_name)
903
1641
  )
904
1642
 
905
1643
  # Update the display
906
- cell_value = self.df.item(row_idx, col_idx)
1644
+ cell_value = self.df.item(ridx, cidx)
907
1645
  if cell_value is None:
908
- cell_value = "-"
909
- dtype = self.df.dtypes[col_idx]
1646
+ cell_value = NULL_DISPLAY
1647
+ dtype = self.df.dtypes[cidx]
910
1648
  dc = DtypeConfig(dtype)
911
1649
  formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
912
1650
 
913
- row_key = str(row_idx + 1)
914
- col_key = str(col_name)
915
- self.update_cell(row_key, col_key, formatted_value)
1651
+ # string as keys
1652
+ row_key = str(ridx)
1653
+ col_key = col_name
1654
+ self.update_cell(row_key, col_key, formatted_value, update_width=True)
916
1655
 
917
- self.app.notify(
918
- f"Cell updated to [on $primary]{cell_value}[/]", title="Edit"
919
- )
1656
+ self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
920
1657
  except Exception as e:
921
- self.app.notify(
922
- f"Failed to update cell: {str(e)}", title="Edit", severity="error"
923
- )
924
- raise e
1658
+ self.notify(f"Failed to update cell: {str(e)}", title="Edit", severity="error")
925
1659
 
926
- def _copy_cell(self) -> None:
927
- """Copy the current cell to clipboard."""
928
- import subprocess
1660
+ def _edit_column(self) -> None:
1661
+ """Open modal to edit the entire column with an expression."""
1662
+ cidx = self.cursor_col_idx
1663
+
1664
+ # Push the edit column modal screen
1665
+ self.app.push_screen(
1666
+ EditColumnScreen(cidx, self.df),
1667
+ callback=self._do_edit_column,
1668
+ )
1669
+
1670
+ def _do_edit_column(self, result) -> None:
1671
+ """Edit a column."""
1672
+ if result is None:
1673
+ return
1674
+ term, cidx = result
1675
+
1676
+ col_name = self.df.columns[cidx]
929
1677
 
930
- row_idx = self.cursor_row
1678
+ # Null case
1679
+ if term is None or term == NULL:
1680
+ expr = pl.lit(None)
1681
+
1682
+ # Check if term is a valid expression
1683
+ elif tentative_expr(term):
1684
+ try:
1685
+ expr = validate_expr(term, self.df, cidx)
1686
+ except Exception as e:
1687
+ self.notify(f"Error validating expression [$error]{term}[/]: {str(e)}", title="Edit", severity="error")
1688
+ return
1689
+
1690
+ # Otherwise, treat term as a literal value
1691
+ else:
1692
+ dtype = self.df.dtypes[cidx]
1693
+ try:
1694
+ value = DtypeConfig(dtype).convert(term)
1695
+ expr = pl.lit(value)
1696
+ except Exception:
1697
+ self.notify(
1698
+ f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1699
+ title="Edit",
1700
+ severity="error",
1701
+ )
1702
+ expr = pl.lit(str(term))
1703
+
1704
+ # Add to history
1705
+ self._add_history(f"Edited column [$accent]{col_name}[/] with expression")
1706
+
1707
+ try:
1708
+ # Apply the expression to the column
1709
+ self.df = self.df.with_columns(expr.alias(col_name))
1710
+ except Exception as e:
1711
+ self.notify(f"Failed to apply expression: [$error]{str(e)}[/]", title="Edit", severity="error")
1712
+ return
1713
+
1714
+ # Recreate the table for display
1715
+ self._setup_table()
1716
+
1717
+ self.notify(
1718
+ f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]",
1719
+ title="Edit",
1720
+ )
1721
+
1722
+ def _rename_column(self) -> None:
1723
+ """Open modal to rename the selected column."""
1724
+ col_name = self.cursor_col_name
931
1725
  col_idx = self.cursor_column
932
1726
 
1727
+ # Push the rename column modal screen
1728
+ self.app.push_screen(
1729
+ RenameColumnScreen(col_idx, col_name, self.df.columns),
1730
+ callback=self._do_rename_column,
1731
+ )
1732
+
1733
+ def _do_rename_column(self, result) -> None:
1734
+ """Handle result from RenameColumnScreen."""
1735
+ if result is None:
1736
+ return
1737
+
1738
+ col_idx, col_name, new_name = result
1739
+ if new_name is None:
1740
+ self.app.push_screen(
1741
+ RenameColumnScreen(col_idx, col_name, self.df.columns),
1742
+ callback=self._do_rename_column,
1743
+ )
1744
+ return
1745
+
1746
+ # Add to history
1747
+ self._add_history(f"Renamed column [$accent]{col_name}[/] to [$success]{new_name}[/]")
1748
+
1749
+ # Rename the column in the dataframe
1750
+ self.df = self.df.rename({col_name: new_name})
1751
+
1752
+ # Update sorted_columns if this column was sorted
1753
+ if col_name in self.sorted_columns:
1754
+ self.sorted_columns[new_name] = self.sorted_columns.pop(col_name)
1755
+
1756
+ # Update hidden_columns if this column was hidden
1757
+ if col_name in self.hidden_columns:
1758
+ self.hidden_columns.remove(col_name)
1759
+ self.hidden_columns.add(new_name)
1760
+
1761
+ # Recreate the table for display
1762
+ self._setup_table()
1763
+
1764
+ # Move cursor to the renamed column
1765
+ self.move_cursor(column=col_idx)
1766
+
1767
+ self.notify(
1768
+ f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]",
1769
+ title="Column",
1770
+ )
1771
+
1772
+ def _clear_cell(self) -> None:
1773
+ """Clear the current cell by setting its value to None."""
1774
+ row_key, col_key = self.cursor_key
1775
+ ridx = self.cursor_row_idx
1776
+ cidx = self.cursor_col_idx
1777
+ col_name = self.cursor_col_name
1778
+
1779
+ # Add to history
1780
+ self._add_history(f"Cleared cell [$success]({ridx + 1}, {col_name})[/]")
1781
+
1782
+ # Update the cell to None in the dataframe
933
1783
  try:
934
- cell_str = str(self.df.item(row_idx, col_idx))
935
- subprocess.run(
936
- [
937
- "pbcopy" if sys.platform == "darwin" else "xclip",
938
- "-selection",
939
- "clipboard",
940
- ],
941
- input=cell_str,
942
- text=True,
1784
+ self.df = self.df.with_columns(
1785
+ pl.when(pl.arange(0, len(self.df)) == ridx)
1786
+ .then(pl.lit(None))
1787
+ .otherwise(pl.col(col_name))
1788
+ .alias(col_name)
943
1789
  )
944
- self.app.notify(f"Copied: {cell_str[:50]}", title="Clipboard")
945
- except (FileNotFoundError, IndexError):
946
- self.app.notify("Error copying cell", title="Clipboard", severity="error")
947
1790
 
948
- def _search_column(self, all_columns: bool = False) -> None:
949
- """Open modal to search in the selected column."""
950
- row_idx, col_idx = self.cursor_coordinate
951
- if col_idx >= len(self.df.columns):
952
- self.app.notify("Invalid column selected", title="Search", severity="error")
953
- return
1791
+ # Update the display
1792
+ dtype = self.df.dtypes[cidx]
1793
+ dc = DtypeConfig(dtype)
1794
+ formatted_value = Text(NULL_DISPLAY, style=dc.style, justify=dc.justify)
954
1795
 
955
- col_name = None if all_columns else self.df.columns[col_idx]
956
- col_dtype = self.df.dtypes[col_idx]
1796
+ self.update_cell(row_key, col_key, formatted_value)
957
1797
 
958
- # Get current cell value as default search term
959
- term = self.df.item(row_idx, col_idx)
960
- term = "NULL" if term is None else str(term)
1798
+ self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
1799
+ except Exception as e:
1800
+ self.notify(f"Failed to clear cell: {str(e)}", title="Clear", severity="error")
1801
+ raise e
961
1802
 
962
- # Push the search modal screen
1803
+ def _add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
1804
+ """Add acolumn after the current column."""
1805
+ cidx = self.cursor_col_idx
1806
+
1807
+ if not col_name:
1808
+ # Generate a unique column name
1809
+ base_name = "new_col"
1810
+ new_name = base_name
1811
+ counter = 1
1812
+ while new_name in self.df.columns:
1813
+ new_name = f"{base_name}_{counter}"
1814
+ counter += 1
1815
+ else:
1816
+ new_name = col_name
1817
+
1818
+ # Add to history
1819
+ self._add_history(f"Added column [$success]{new_name}[/] after column {cidx + 1}")
1820
+
1821
+ try:
1822
+ # Create an empty column (all None values)
1823
+ if isinstance(col_value, pl.Expr):
1824
+ new_col = col_value.alias(new_name)
1825
+ else:
1826
+ new_col = pl.lit(col_value).alias(new_name)
1827
+
1828
+ # Get columns up to current, the new column, then remaining columns
1829
+ cols = self.df.columns
1830
+ cols_before = cols[: cidx + 1]
1831
+ cols_after = cols[cidx + 1 :]
1832
+
1833
+ # Build the new dataframe with columns reordered
1834
+ select_cols = cols_before + [new_name] + cols_after
1835
+ self.df = self.df.with_columns(new_col).select(select_cols)
1836
+
1837
+ # Recreate the table display
1838
+ self._setup_table()
1839
+
1840
+ # Move cursor to the new column
1841
+ self.move_cursor(column=cidx + 1)
1842
+
1843
+ self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
1844
+ except Exception as e:
1845
+ self.notify(f"Failed to add column: {str(e)}", title="Add Column", severity="error")
1846
+ raise e
1847
+
1848
+ def _add_column_expr(self) -> None:
1849
+ """Open screen to add a new column with optional expression."""
1850
+ cidx = self.cursor_col_idx
963
1851
  self.app.push_screen(
964
- SearchScreen(term, col_dtype, col_name),
965
- callback=self._do_search_column,
1852
+ AddColumnScreen(cidx, self.df),
1853
+ self._do_add_column_expr,
966
1854
  )
967
1855
 
968
- def _do_search_column(self, result) -> None:
969
- """Handle result from SearchScreen."""
1856
+ def _do_add_column_expr(self, result: tuple[int, str, str, pl.Expr] | None) -> None:
1857
+ """Add a new column with an expression."""
970
1858
  if result is None:
971
1859
  return
972
1860
 
973
- term, col_dtype, col_name = result
974
- if col_name:
975
- # Perform search in the specified column
976
- self._search_single_column(term, col_dtype, col_name)
977
- else:
978
- # Perform search in all columns
979
- self._search_all_columns(term)
1861
+ cidx, col_name, expr = result
1862
+
1863
+ # Add to history
1864
+ self._add_history(f"Added column [$success]{col_name}[/] with expression {expr}.")
980
1865
 
981
- def _search_single_column(
982
- self, term: str, col_dtype: pl.DataType, col_name: str
983
- ) -> None:
984
- """Search for a term in a single column and update selected rows.
1866
+ try:
1867
+ # Create the column
1868
+ new_col = expr.alias(col_name)
1869
+
1870
+ # Get columns up to current, the new column, then remaining columns
1871
+ cols = self.df.columns
1872
+ cols_before = cols[: cidx + 1]
1873
+ cols_after = cols[cidx + 1 :]
1874
+
1875
+ # Build the new dataframe with columns reordered
1876
+ select_cols = cols_before + [col_name] + cols_after
1877
+ self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
1878
+
1879
+ # Recreate the table display
1880
+ self._setup_table()
1881
+
1882
+ # Move cursor to the new column
1883
+ self.move_cursor(column=cidx + 1)
1884
+
1885
+ # self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
1886
+ except Exception as e:
1887
+ self.notify(f"Failed to add column: [$error]{str(e)}[/]", title="Add Column", severity="error")
1888
+ raise e
1889
+
1890
+ def _string_to_polars_dtype(self, dtype_str: str) -> pl.DataType:
1891
+ """Convert string type name to Polars DataType.
985
1892
 
986
1893
  Args:
987
- term: The search term to find
988
- col_dtype: The data type of the column
989
- col_name: The name of the column to search in
1894
+ dtype_str: String representation of the type ("string", "int", "float", "bool")
1895
+
1896
+ Returns:
1897
+ Corresponding Polars DataType
1898
+
1899
+ Raises:
1900
+ ValueError: If the type string is not recognized
990
1901
  """
991
- df_rid = self.df.with_row_index("__rid__")
992
- if False in self.visible_rows:
993
- df_rid = df_rid.filter(self.visible_rows)
1902
+ dtype_map = {
1903
+ "string": pl.String,
1904
+ "int": pl.Int64,
1905
+ "float": pl.Float64,
1906
+ "bool": pl.Boolean,
1907
+ }
994
1908
 
995
- # Perform type-aware search based on column dtype
996
- if term.lower() == "null":
997
- masks = df_rid[col_name].is_null()
998
- elif col_dtype == pl.String:
999
- masks = df_rid[col_name].str.contains(term)
1000
- elif col_dtype == pl.Boolean:
1001
- masks = df_rid[col_name] == BOOLS[term.lower()]
1002
- elif col_dtype in (pl.Int32, pl.Int64):
1003
- masks = df_rid[col_name] == int(term)
1004
- elif col_dtype in (pl.Float32, pl.Float64):
1005
- masks = df_rid[col_name] == float(term)
1909
+ dtype_lower = dtype_str.lower().strip()
1910
+ return dtype_map.get(dtype_lower)
1911
+
1912
+ def _cast_column_dtype(self, dtype: str | pl.DataType = pl.String) -> None:
1913
+ """Cast the current column to a different data type.
1914
+
1915
+ Args:
1916
+ dtype: Target data type (string like "int", "float", "bool", "string" or Polars DataType)
1917
+ """
1918
+ cidx = self.cursor_col_idx
1919
+ col_name = self.cursor_col_name
1920
+ current_dtype = self.df.dtypes[cidx]
1921
+
1922
+ # Convert string dtype to Polars DataType if needed
1923
+ if isinstance(dtype, str):
1924
+ target_dtype = self._string_to_polars_dtype(dtype)
1925
+ if target_dtype is None:
1926
+ self.notify(
1927
+ f"Use string for unknown data type: {dtype}. Supported types: {', '.join(self._string_to_polars_dtype.keys())}",
1928
+ title="Cast",
1929
+ severity="warning",
1930
+ )
1931
+ target_dtype = pl.String
1006
1932
  else:
1007
- self.app.notify(
1008
- f"Search not yet supported for column type: [on $primary]{col_dtype}[/]",
1009
- title="Search",
1010
- severity="warning",
1933
+ target_dtype = dtype
1934
+
1935
+ # Add to history
1936
+ self._add_history(
1937
+ f"Cast column [$accent]{col_name}[/] from [$success]{current_dtype}[/] to [$success]{target_dtype}[/]"
1938
+ )
1939
+
1940
+ try:
1941
+ # Cast the column using Polars
1942
+ self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
1943
+
1944
+ # Recreate the table display
1945
+ self._setup_table()
1946
+
1947
+ self.notify(
1948
+ f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]",
1949
+ title="Cast",
1011
1950
  )
1951
+ except Exception as e:
1952
+ self.notify(f"Failed to cast column: {str(e)}", title="Cast", severity="error")
1953
+ raise e
1954
+
1955
+ def _search_cursor_value(self) -> None:
1956
+ """Search with cursor value in current column."""
1957
+ cidx = self.cursor_col_idx
1958
+
1959
+ # Get the value of the currently selected cell
1960
+ term = NULL if self.cursor_value is None else str(self.cursor_value)
1961
+
1962
+ self._do_search((term, cidx, False, False))
1963
+
1964
+ def _search_expr(self) -> None:
1965
+ """Search by expression."""
1966
+ cidx = self.cursor_col_idx
1967
+
1968
+ # Use current cell value as default search term
1969
+ term = NULL if self.cursor_value is None else str(self.cursor_value)
1970
+
1971
+ # Push the search modal screen
1972
+ self.app.push_screen(
1973
+ SearchScreen("Search", term, self.df, cidx),
1974
+ callback=self._do_search,
1975
+ )
1976
+
1977
+ def _do_search(self, result) -> None:
1978
+ """Search for a term."""
1979
+ if result is None:
1012
1980
  return
1981
+ term, cidx, match_nocase, match_whole = result
1982
+ col_name = self.df.columns[cidx]
1983
+
1984
+ if term == NULL:
1985
+ expr = pl.col(col_name).is_null()
1986
+
1987
+ # Support for polars expressions
1988
+ elif tentative_expr(term):
1989
+ try:
1990
+ expr = validate_expr(term, self.df, cidx)
1991
+ except Exception as e:
1992
+ self.notify(
1993
+ f"Failed to validate Polars expression [$error]{term}[/]: {str(e)}",
1994
+ title="Search",
1995
+ severity="error",
1996
+ )
1997
+ return
1998
+
1999
+ # Perform type-aware search based on column dtype
2000
+ else:
2001
+ dtype = self.df.dtypes[cidx]
2002
+ if dtype == pl.String:
2003
+ if match_whole:
2004
+ term = f"^{term}$"
2005
+ if match_nocase:
2006
+ term = f"(?i){term}"
2007
+ expr = pl.col(col_name).str.contains(term)
2008
+ else:
2009
+ try:
2010
+ value = DtypeConfig(dtype).convert(term)
2011
+ expr = pl.col(col_name) == value
2012
+ except Exception:
2013
+ if match_whole:
2014
+ term = f"^{term}$"
2015
+ if match_nocase:
2016
+ term = f"(?i){term}"
2017
+ expr = pl.col(col_name).cast(pl.String).str.contains(term)
2018
+ self.notify(
2019
+ f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
2020
+ title="Search",
2021
+ severity="warning",
2022
+ )
2023
+
2024
+ # Lazyframe for filtering
2025
+ lf = self.df.lazy().with_row_index(RIDX)
2026
+ if False in self.visible_rows:
2027
+ lf = lf.filter(self.visible_rows)
1013
2028
 
1014
2029
  # Apply filter to get matched row indices
1015
- matches = set(df_rid.filter(masks)["__rid__"].to_list())
2030
+ try:
2031
+ matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
2032
+ except Exception as e:
2033
+ self.notify(
2034
+ f"Error applying search filter: [$error]{str(e)}[/]",
2035
+ title="Search",
2036
+ severity="error",
2037
+ )
2038
+ return
1016
2039
 
1017
2040
  match_count = len(matches)
1018
2041
  if match_count == 0:
1019
- self.app.notify(
1020
- f"No matches found for: [on $primary]{term}[/]",
2042
+ self.notify(
2043
+ f"No matches found for [$warning]{term}[/]. Try [$accent](?i)abc[/] for case-insensitive search.",
1021
2044
  title="Search",
1022
2045
  severity="warning",
1023
2046
  )
1024
2047
  return
1025
2048
 
1026
2049
  # Add to history
1027
- self._add_history(
1028
- f"Searched and highlighted [on $primary]{term}[/] in column [on $primary]{col_name}[/]"
1029
- )
2050
+ self._add_history(f"Searched [$accent]{term}[/] in column [$success]{col_name}[/]")
1030
2051
 
1031
2052
  # Update selected rows to include new matches
1032
2053
  for m in matches:
1033
2054
  self.selected_rows[m] = True
1034
2055
 
1035
- # Highlight selected rows
1036
- self._highlight_rows()
2056
+ # Highlight matches
2057
+ self._do_highlight()
1037
2058
 
1038
- self.app.notify(
1039
- f"Found [on $primary]{match_count}[/] matches for [on $primary]{term}[/]",
1040
- title="Search",
1041
- )
2059
+ self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
1042
2060
 
1043
- def _search_all_columns(self, term: str) -> None:
1044
- """Search for a term across all columns and highlight matching cells.
2061
+ def _find_matches(
2062
+ self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
2063
+ ) -> dict[int, set[int]]:
2064
+ """Find matches for a term in the dataframe.
1045
2065
 
1046
2066
  Args:
1047
- term: The search term to find
2067
+ term: The search term (can be NULL, expression, or plain text)
2068
+ cidx: Column index for column-specific search. If None, searches all columns.
2069
+
2070
+ Returns:
2071
+ Dictionary mapping row indices to sets of column indices containing matches.
2072
+ For column-specific search, each matched row has a set with single cidx.
2073
+ For global search, each matched row has a set of all matching cidxs in that row.
2074
+
2075
+ Raises:
2076
+ Exception: If expression validation or filtering fails.
1048
2077
  """
1049
- df_rid = self.df.with_row_index("__rid__")
2078
+ matches: dict[int, set[int]] = defaultdict(set)
2079
+
2080
+ # Lazyframe for filtering
2081
+ lf = self.df.lazy().with_row_index(RIDX)
1050
2082
  if False in self.visible_rows:
1051
- df_rid = df_rid.filter(self.visible_rows)
1052
-
1053
- matches: dict[int, set[int]] = {}
1054
- match_count = 0
1055
- if term.lower() == "null":
1056
- # Search for NULL values across all columns
1057
- for col_idx, col in enumerate(df_rid.columns[1:]):
1058
- masks = df_rid[col].is_null()
1059
- matched_rids = set(df_rid.filter(masks)["__rid__"].to_list())
1060
- for rid in matched_rids:
1061
- if rid not in matches:
1062
- matches[rid] = set()
1063
- matches[rid].add(col_idx)
1064
- match_count += 1
2083
+ lf = lf.filter(self.visible_rows)
2084
+
2085
+ # Determine which columns to search: single column or all columns
2086
+ if cidx is not None:
2087
+ columns_to_search = [(cidx, self.df.columns[cidx])]
1065
2088
  else:
1066
- # Search for the term in all columns
1067
- for col_idx, col in enumerate(df_rid.columns[1:]):
1068
- col_series = df_rid[col].cast(pl.String)
1069
- masks = col_series.str.contains(term)
1070
- matched_rids = set(df_rid.filter(masks)["__rid__"].to_list())
1071
- for rid in matched_rids:
1072
- if rid not in matches:
1073
- matches[rid] = set()
1074
- matches[rid].add(col_idx)
1075
- match_count += 1
2089
+ columns_to_search = list(enumerate(self.df.columns))
2090
+
2091
+ # Search each column consistently
2092
+ for col_idx, col_name in columns_to_search:
2093
+ # Build expression based on term type
2094
+ if term == NULL:
2095
+ expr = pl.col(col_name).is_null()
2096
+ elif tentative_expr(term):
2097
+ try:
2098
+ expr = validate_expr(term, self.df, col_idx)
2099
+ except Exception as e:
2100
+ raise Exception(f"Error validating Polars expression: {str(e)}")
2101
+ else:
2102
+ if match_whole:
2103
+ term = f"^{term}$"
2104
+ if match_nocase:
2105
+ term = f"(?i){term}"
2106
+ expr = pl.col(col_name).cast(pl.String).str.contains(term)
1076
2107
 
1077
- if match_count == 0:
1078
- self.app.notify(
1079
- f"No matches found for: [on $primary]{term}[/] in any column",
1080
- title="Global Search",
2108
+ # Get matched row indices
2109
+ try:
2110
+ matched_ridxs = lf.filter(expr).select(RIDX).collect().to_series().to_list()
2111
+ except Exception as e:
2112
+ raise Exception(f"Error applying filter: {str(e)}")
2113
+
2114
+ for ridx in matched_ridxs:
2115
+ matches[ridx].add(col_idx)
2116
+
2117
+ return matches
2118
+
2119
+ def _find_cursor_value(self, scope="column") -> None:
2120
+ """Find by cursor value.
2121
+
2122
+ Args:
2123
+ scope: "column" to find in current column, "global" to find across all columns.
2124
+ """
2125
+ # Get the value of the currently selected cell
2126
+ term = NULL if self.cursor_value is None else str(self.cursor_value)
2127
+
2128
+ if scope == "column":
2129
+ cidx = self.cursor_col_idx
2130
+ self._do_find((term, cidx, False, False))
2131
+ else:
2132
+ self._do_find_global((term, None, False, False))
2133
+
2134
+ def _find_expr(self, scope="column") -> None:
2135
+ """Open screen to find by expression.
2136
+
2137
+ Args:
2138
+ scope: "column" to find in current column, "global" to find across all columns.
2139
+ """
2140
+ # Use current cell value as default search term
2141
+ term = NULL if self.cursor_value is None else str(self.cursor_value)
2142
+ cidx = self.cursor_col_idx if scope == "column" else None
2143
+
2144
+ # Push the search modal screen
2145
+ self.app.push_screen(
2146
+ SearchScreen("Find", term, self.df, cidx),
2147
+ callback=self._do_find if scope == "column" else self._do_find_global,
2148
+ )
2149
+
2150
+ def _do_find(self, result) -> None:
2151
+ """Find a term in current column."""
2152
+ if result is None:
2153
+ return
2154
+ term, cidx, match_nocase, match_whole = result
2155
+
2156
+ col_name = self.df.columns[cidx]
2157
+
2158
+ try:
2159
+ matches = self._find_matches(term, cidx, match_nocase, match_whole)
2160
+ except Exception as e:
2161
+ self.notify(
2162
+ f"Error finding matches: [$error]{str(e)}[/]",
2163
+ title="Find",
2164
+ severity="error",
2165
+ )
2166
+ return
2167
+
2168
+ if not matches:
2169
+ self.notify(
2170
+ f"No matches found for [$warning]{term}[/] in current column. Try [$accent](?i)abc[/] for case-insensitive search.",
2171
+ title="Find",
1081
2172
  severity="warning",
1082
2173
  )
1083
2174
  return
1084
2175
 
1085
- # Ensure all matching rows are loaded
1086
- self._load_rows(max(matches.keys()) + 1)
2176
+ # Add to history
2177
+ self._add_history(f"Found [$accent]{term}[/] in column [$success]{col_name}[/]")
2178
+
2179
+ # Add to matches and count total
2180
+ match_count = sum(len(col_idxs) for col_idxs in matches.values())
2181
+ for ridx, col_idxs in matches.items():
2182
+ self.matches[ridx].update(col_idxs)
2183
+
2184
+ # Highlight matches
2185
+ self._do_highlight()
2186
+
2187
+ self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
2188
+
2189
+ def _do_find_global(self, result) -> None:
2190
+ """Global find a term across all columns."""
2191
+ if result is None:
2192
+ return
2193
+ term, cidx, match_nocase, match_whole = result
2194
+
2195
+ try:
2196
+ matches = self._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
2197
+ except Exception as e:
2198
+ self.notify(
2199
+ f"Error finding matches: [$error]{str(e)}[/]",
2200
+ title="Find",
2201
+ severity="error",
2202
+ )
2203
+ return
2204
+
2205
+ if not matches:
2206
+ self.notify(
2207
+ f"No matches found for [$warning]{term}[/] in any column. Try [$accent](?i)abc[/] for case-insensitive search.",
2208
+ title="Global Find",
2209
+ severity="warning",
2210
+ )
2211
+ return
2212
+
2213
+ # Add to history
2214
+ self._add_history(f"Found [$success]{term}[/] across all columns")
2215
+
2216
+ # Add to matches and count total
2217
+ match_count = sum(len(col_idxs) for col_idxs in matches.values())
2218
+ for ridx, col_idxs in matches.items():
2219
+ self.matches[ridx].update(col_idxs)
2220
+
2221
+ # Highlight matches
2222
+ self._do_highlight()
2223
+
2224
+ self.notify(
2225
+ f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns",
2226
+ title="Global Find",
2227
+ )
2228
+
2229
+ def _next_match(self) -> None:
2230
+ """Move cursor to the next match."""
2231
+ if not self.matches:
2232
+ self.notify("No matches to navigate", title="Next Match", severity="warning")
2233
+ return
2234
+
2235
+ # Get sorted list of matched coordinates
2236
+ ordered_matches = self.ordered_matches
2237
+
2238
+ # Current cursor position
2239
+ current_pos = (self.cursor_row_idx, self.cursor_col_idx)
2240
+
2241
+ # Find the next match after current position
2242
+ for ridx, cidx in ordered_matches:
2243
+ if (ridx, cidx) > current_pos:
2244
+ self.move_cursor_to(ridx, cidx)
2245
+ return
2246
+
2247
+ # If no next match, wrap around to the first match
2248
+ first_ridx, first_cidx = ordered_matches[0]
2249
+ self.move_cursor_to(first_ridx, first_cidx)
2250
+
2251
+ def _previous_match(self) -> None:
2252
+ """Move cursor to the previous match."""
2253
+ if not self.matches:
2254
+ self.notify("No matches to navigate", title="Previous Match", severity="warning")
2255
+ return
2256
+
2257
+ # Get sorted list of matched coordinates
2258
+ ordered_matches = self.ordered_matches
2259
+
2260
+ # Current cursor position
2261
+ current_pos = (self.cursor_row_idx, self.cursor_col_idx)
2262
+
2263
+ # Find the previous match before current position
2264
+ for ridx, cidx in reversed(ordered_matches):
2265
+ if (ridx, cidx) < current_pos:
2266
+ row_key = str(ridx)
2267
+ col_key = self.df.columns[cidx]
2268
+ row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
2269
+ self.move_cursor(row=row_idx, column=col_idx)
2270
+ return
2271
+
2272
+ # If no previous match, wrap around to the last match
2273
+ last_ridx, last_cidx = ordered_matches[-1]
2274
+ row_key = str(last_ridx)
2275
+ col_key = self.df.columns[last_cidx]
2276
+ row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
2277
+ self.move_cursor(row=row_idx, column=col_idx)
2278
+
2279
+ def _next_selected_row(self) -> None:
2280
+ """Move cursor to the next selected row."""
2281
+ if not any(self.selected_rows):
2282
+ self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
2283
+ return
2284
+
2285
+ # Get list of selected row indices in order
2286
+ selected_row_indices = self.ordered_selected_rows
2287
+
2288
+ # Current cursor row
2289
+ current_ridx = self.cursor_row_idx
2290
+
2291
+ # Find the next selected row after current position
2292
+ for ridx in selected_row_indices:
2293
+ if ridx > current_ridx:
2294
+ self.move_cursor_to(ridx, self.cursor_col_idx)
2295
+ return
2296
+
2297
+ # If no next selected row, wrap around to the first selected row
2298
+ first_ridx = selected_row_indices[0]
2299
+ self.move_cursor_to(first_ridx, self.cursor_col_idx)
2300
+
2301
+ def _previous_selected_row(self) -> None:
2302
+ """Move cursor to the previous selected row."""
2303
+ if not any(self.selected_rows):
2304
+ self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
2305
+ return
2306
+
2307
+ # Get list of selected row indices in order
2308
+ selected_row_indices = self.ordered_selected_rows
2309
+
2310
+ # Current cursor row
2311
+ current_ridx = self.cursor_row_idx
2312
+
2313
+ # Find the previous selected row before current position
2314
+ for ridx in reversed(selected_row_indices):
2315
+ if ridx < current_ridx:
2316
+ self.move_cursor_to(ridx, self.cursor_col_idx)
2317
+ return
2318
+
2319
+ # If no previous selected row, wrap around to the last selected row
2320
+ last_ridx = selected_row_indices[-1]
2321
+ self.move_cursor_to(last_ridx, self.cursor_col_idx)
2322
+
2323
+ def _replace(self) -> None:
2324
+ """Open replace screen for current column."""
2325
+ # Push the replace modal screen
2326
+ self.app.push_screen(
2327
+ FindReplaceScreen(self),
2328
+ callback=self._do_replace,
2329
+ )
2330
+
2331
+ def _do_replace(self, result) -> None:
2332
+ """Handle replace in current column."""
2333
+ self._handle_replace(result, self.cursor_col_idx)
2334
+
2335
+ def _replace_global(self) -> None:
2336
+ """Open replace screen for all columns."""
2337
+ # Push the replace modal screen
2338
+ self.app.push_screen(
2339
+ FindReplaceScreen(self),
2340
+ callback=self._do_replace_global,
2341
+ )
2342
+
2343
+ def _do_replace_global(self, result) -> None:
2344
+ """Handle replace across all columns."""
2345
+ self._handle_replace(result, None)
2346
+
2347
+ def _handle_replace(self, result, cidx) -> None:
2348
+ """Handle replace result from ReplaceScreen.
2349
+
2350
+ Args:
2351
+ result: Result tuple from ReplaceScreen
2352
+ cidx: Column index to perform replacement. If None, replace across all columns.
2353
+ """
2354
+ if result is None:
2355
+ return
2356
+ term_find, term_replace, match_nocase, match_whole, replace_all = result
2357
+
2358
+ if cidx is None:
2359
+ col_name = "all columns"
2360
+ else:
2361
+ col_name = self.df.columns[cidx]
2362
+
2363
+ # Find all matches
2364
+ matches = self._find_matches(term_find, cidx, match_nocase, match_whole)
2365
+
2366
+ if not matches:
2367
+ self.notify(
2368
+ f"No matches found for [$warning]{term_find}[/]",
2369
+ title="Replace",
2370
+ severity="warning",
2371
+ )
2372
+ return
1087
2373
 
1088
2374
  # Add to history
1089
2375
  self._add_history(
1090
- f"Searched and highlighted [on $primary]{term}[/] across all columns"
2376
+ f"Replacing [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
1091
2377
  )
1092
2378
 
1093
- # Highlight matching cells directly
1094
- for row in self.ordered_rows:
1095
- row_idx = int(row.key.value) - 1 # Convert to 0-based index
1096
- if row_idx not in matches:
1097
- continue
2379
+ # Update matches
2380
+ self.matches = {ridx: set(col_idxs) for ridx, col_idxs in matches.items()}
2381
+
2382
+ # Highlight matches
2383
+ self._do_highlight()
2384
+
2385
+ # Store state for interactive replacement using dataclass
2386
+ self._replace_state = ReplaceState(
2387
+ term_find=term_find,
2388
+ term_replace=term_replace,
2389
+ match_nocase=match_nocase,
2390
+ match_whole=match_whole,
2391
+ cidx=cidx,
2392
+ rows=sorted(list(self.matches.keys())),
2393
+ cols_per_row=[sorted(list(self.matches[ridx])) for ridx in sorted(self.matches.keys())],
2394
+ current_rpos=0,
2395
+ current_cpos=0,
2396
+ current_occurrence=0,
2397
+ total_occurrence=len(self.matches),
2398
+ replaced_occurrence=0,
2399
+ skipped_occurrence=0,
2400
+ done=False,
2401
+ )
1098
2402
 
1099
- for col_idx in matches[row_idx]:
1100
- row_key = row.key
1101
- col_key = self.df.columns[col_idx]
2403
+ try:
2404
+ if replace_all:
2405
+ # Replace all occurrences
2406
+ self._do_replace_all(term_find, term_replace)
2407
+ else:
2408
+ # Replace with confirmation for each occurrence
2409
+ self._do_replace_interactive(term_find, term_replace)
1102
2410
 
1103
- cell_text: Text = self.get_cell(row_key, col_key)
1104
- cell_text.style = "red"
2411
+ except Exception as e:
2412
+ self.notify(
2413
+ f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]: {str(e)}",
2414
+ title="Replace",
2415
+ severity="error",
2416
+ )
1105
2417
 
1106
- # Update the cell in the table
1107
- self.update_cell(row_key, col_key, cell_text)
2418
+ def _do_replace_all(self, term_find: str, term_replace: str) -> None:
2419
+ """Replace all occurrences."""
2420
+ state = self._replace_state
2421
+ self.app.push_screen(
2422
+ ConfirmScreen(
2423
+ "Replace All",
2424
+ label=f"Replace [$accent]{term_find}[/] with [$success]{term_replace}[/] for all [$accent]{state.total_occurrence}[/] occurrences?",
2425
+ ),
2426
+ callback=self._handle_replace_all_confirmation,
2427
+ )
2428
+
2429
+ def _handle_replace_all_confirmation(self, result) -> None:
2430
+ """Handle user's confirmation for replace all."""
2431
+ if result is None:
2432
+ return
1108
2433
 
1109
- self.app.notify(
1110
- f"Found [$accent]{match_count}[/] matches for [on $primary]{term}[/] across all columns",
1111
- title="Global Search",
2434
+ state = self._replace_state
2435
+ rows = state.rows
2436
+ cols_per_row = state.cols_per_row
2437
+
2438
+ # Replace in each matched row/column
2439
+ for ridx, col_idxs in zip(rows, cols_per_row):
2440
+ for cidx in col_idxs:
2441
+ col_name = self.df.columns[cidx]
2442
+ dtype = self.df.dtypes[cidx]
2443
+
2444
+ # Only applicable to string columns for substring matches
2445
+ if dtype == pl.String and not state.match_whole:
2446
+ term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
2447
+ self.df = self.df.with_columns(
2448
+ pl.when(pl.arange(0, len(self.df)) == ridx)
2449
+ .then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
2450
+ .otherwise(pl.col(col_name))
2451
+ .alias(col_name)
2452
+ )
2453
+ else:
2454
+ # try to convert replacement value to column dtype
2455
+ try:
2456
+ value = DtypeConfig(dtype).convert(state.term_replace)
2457
+ except Exception:
2458
+ value = state.term_replace
2459
+
2460
+ self.df = self.df.with_columns(
2461
+ pl.when(pl.arange(0, len(self.df)) == ridx)
2462
+ .then(pl.lit(value))
2463
+ .otherwise(pl.col(col_name))
2464
+ .alias(col_name)
2465
+ )
2466
+
2467
+ state.replaced_occurrence += 1
2468
+
2469
+ # Recreate the table display
2470
+ self._setup_table()
2471
+
2472
+ col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
2473
+ self.notify(
2474
+ f"Replaced [$accent]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$success]{col_name}[/]",
2475
+ title="Replace",
1112
2476
  )
1113
2477
 
1114
- def _search_with_cell_value(self) -> None:
1115
- """Search in the current column using the value of the currently selected cell."""
1116
- row_key = self.cursor_row_key
1117
- row_idx = int(row_key.value) - 1 # Convert to 0-based index
1118
- col_idx = self.cursor_column
2478
+ def _do_replace_interactive(self, term_find: str, term_replace: str) -> None:
2479
+ """Replace with user confirmation for each occurrence."""
2480
+ try:
2481
+ # Start with first match
2482
+ self._show_next_replace_confirmation()
2483
+ except Exception as e:
2484
+ self.notify(
2485
+ f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]: {str(e)}",
2486
+ title="Replace",
2487
+ severity="error",
2488
+ )
1119
2489
 
1120
- # Get the value of the currently selected cell
1121
- term = self.df.item(row_idx, col_idx)
1122
- term = "NULL" if term is None else str(term)
2490
+ def _show_next_replace_confirmation(self) -> None:
2491
+ """Show confirmation for next replacement."""
2492
+ state = self._replace_state
2493
+ if state.done:
2494
+ # All done - show final notification
2495
+ col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
2496
+ msg = f"Replaced [$accent]{state.replaced_occurrence}[/] of [$accent]{state.total_occurrence}[/] in [$success]{col_name}[/]"
2497
+ if state.skipped_occurrence > 0:
2498
+ msg += f", [$warning]{state.skipped_occurrence}[/] skipped"
2499
+ self.notify(msg, title="Replace")
2500
+ return
2501
+
2502
+ # Move cursor to next match
2503
+ ridx = state.rows[state.current_rpos]
2504
+ cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
2505
+ self.move_cursor(row=ridx, column=cidx)
2506
+
2507
+ state.current_occurrence += 1
2508
+
2509
+ # Show confirmation
2510
+ label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] (Occurrence {state.current_occurrence} of {state.total_occurrence})?"
2511
+
2512
+ self.app.push_screen(
2513
+ ConfirmScreen("Replace", label=label, maybe="Skip"),
2514
+ callback=self._handle_replace_confirmation,
2515
+ )
2516
+
2517
+ def _handle_replace_confirmation(self, result) -> None:
2518
+ """Handle user's confirmation response."""
2519
+ state = self._replace_state
2520
+ if state.done:
2521
+ return
2522
+
2523
+ ridx = state.rows[state.current_rpos]
2524
+ cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
2525
+ col_name = self.df.columns[cidx]
2526
+ dtype = self.df.dtypes[cidx]
2527
+
2528
+ # Replace
2529
+ if result is True:
2530
+ # Only applicable to string columns for substring matches
2531
+ if dtype == pl.String and not state.match_whole:
2532
+ term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
2533
+ self.df = self.df.with_columns(
2534
+ pl.when(pl.arange(0, len(self.df)) == ridx)
2535
+ .then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
2536
+ .otherwise(pl.col(col_name))
2537
+ .alias(col_name)
2538
+ )
2539
+ else:
2540
+ # try to convert replacement value to column dtype
2541
+ try:
2542
+ value = DtypeConfig(dtype).convert(state.term_replace)
2543
+ except Exception:
2544
+ value = state.term_replace
2545
+
2546
+ self.df = self.df.with_columns(
2547
+ pl.when(pl.arange(0, len(self.df)) == ridx)
2548
+ .then(pl.lit(value))
2549
+ .otherwise(pl.col(col_name))
2550
+ .alias(col_name)
2551
+ )
2552
+
2553
+ state.replaced_occurrence += 1
2554
+
2555
+ # Skip
2556
+ elif result is False:
2557
+ state.skipped_occurrence += 1
2558
+
2559
+ # Cancel
2560
+ else:
2561
+ state.done = True
2562
+ self._setup_table()
2563
+ return
1123
2564
 
1124
- col_dtype = self.df.dtypes[col_idx]
1125
- col_name = self.df.columns[col_idx]
1126
- self._do_search_column((term, col_dtype, col_name))
2565
+ # Move to next
2566
+ if state.current_cpos + 1 < len(state.cols_per_row[state.current_rpos]):
2567
+ state.current_cpos += 1
2568
+ else:
2569
+ state.current_cpos = 0
2570
+ state.current_rpos += 1
2571
+
2572
+ if state.current_rpos >= len(state.rows):
2573
+ state.done = True
2574
+
2575
+ # Recreate the table display
2576
+ self._setup_table()
2577
+
2578
+ # Show next confirmation
2579
+ self._show_next_replace_confirmation()
1127
2580
 
1128
- def _toggle_selected_rows(self, current_row=False) -> None:
2581
+ def _toggle_selections(self) -> None:
1129
2582
  """Toggle selected rows highlighting on/off."""
1130
2583
  # Save current state to history
1131
2584
  self._add_history("Toggled row selection")
1132
2585
 
1133
- # Select current row if no rows are currently selected
1134
- if current_row:
1135
- cursor_row_idx = int(self.cursor_row_key.value) - 1
1136
- self.selected_rows[cursor_row_idx] = not self.selected_rows[cursor_row_idx]
2586
+ if False in self.visible_rows:
2587
+ # Some rows are hidden - invert only selected visible rows and clear selections for hidden rows
2588
+ for i in range(len(self.selected_rows)):
2589
+ if self.visible_rows[i]:
2590
+ self.selected_rows[i] = not self.selected_rows[i]
2591
+ else:
2592
+ self.selected_rows[i] = False
1137
2593
  else:
1138
2594
  # Invert all selected rows
1139
- self.selected_rows = [not match for match in self.selected_rows]
2595
+ self.selected_rows = [not selected for selected in self.selected_rows]
1140
2596
 
1141
2597
  # Check if we're highlighting or un-highlighting
1142
2598
  if new_selected_count := self.selected_rows.count(True):
1143
- self.app.notify(
1144
- f"Toggled selection - now showing [$accent]{new_selected_count}[/] rows",
2599
+ self.notify(
2600
+ f"Toggled selection for [$accent]{new_selected_count}[/] rows",
1145
2601
  title="Toggle",
1146
2602
  )
1147
2603
 
2604
+ # Refresh the highlighting
2605
+ self._do_highlight(force=True)
2606
+
2607
+ def _make_selections(self) -> None:
2608
+ """Make selections based on current matches or toggle current row selection."""
2609
+ # Save current state to history
2610
+ self._add_history("Toggled row selection")
2611
+
2612
+ if self.matches:
2613
+ # There are matched cells - select rows with matches
2614
+ for ridx in self.matches.keys():
2615
+ self.selected_rows[ridx] = True
2616
+ else:
2617
+ # No matched cells - select/deselect the current row
2618
+ ridx = self.cursor_row_idx
2619
+ self.selected_rows[ridx] = not self.selected_rows[ridx]
2620
+
2621
+ # Check if we're highlighting or un-highlighting
2622
+ if new_selected_count := self.selected_rows.count(True):
2623
+ self.notify(f"Selected [$accent]{new_selected_count}[/] rows", title="Toggle")
2624
+
1148
2625
  # Refresh the highlighting (also restores default styles for unselected rows)
1149
- self._highlight_rows()
2626
+ self._do_highlight(force=True)
1150
2627
 
1151
- def _clear_selected_rows(self) -> None:
1152
- """Clear all selected rows without removing them from the dataframe."""
1153
- # Check if any rows are currently selected
1154
- selected_count = self.selected_rows.count(True)
1155
- if selected_count == 0:
1156
- self.app.notify(
1157
- "No rows selected to clear", title="Clear", severity="warning"
1158
- )
2628
+ def _clear_selections_and_matches(self) -> None:
2629
+ """Clear all selected rows and matches without removing them from the dataframe."""
2630
+ # Check if any selected rows or matches
2631
+ if not any(self.selected_rows) and not self.matches:
2632
+ self.notify("No selections to clear", title="Clear", severity="warning")
1159
2633
  return
1160
2634
 
2635
+ row_count = sum(
2636
+ 1 if (selected or idx in self.matches) else 0 for idx, selected in enumerate(self.selected_rows)
2637
+ )
2638
+
1161
2639
  # Save current state to history
1162
2640
  self._add_history("Cleared all selected rows")
1163
2641
 
1164
- # Clear all selections and refresh highlighting
1165
- self._highlight_rows(clear=True)
2642
+ # Clear all selections
2643
+ self.selected_rows = [False] * len(self.df)
2644
+ self.matches = defaultdict(set)
1166
2645
 
1167
- self.app.notify(
1168
- f"Cleared [$accent]{selected_count}[/] selected rows", title="Clear"
1169
- )
2646
+ # Refresh the highlighting to remove all highlights
2647
+ self._do_highlight(force=True)
2648
+
2649
+ self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
1170
2650
 
1171
2651
  def _filter_selected_rows(self) -> None:
1172
- """Display only the selected rows."""
2652
+ """Keep only the selected rows and remove unselected ones."""
1173
2653
  selected_count = self.selected_rows.count(True)
1174
2654
  if selected_count == 0:
1175
- self.app.notify(
1176
- "No rows selected to filter", title="Filter", severity="warning"
1177
- )
2655
+ self.notify("No rows selected to filter", title="Filter", severity="warning")
1178
2656
  return
1179
2657
 
1180
2658
  # Save current state to history
@@ -1187,126 +2665,166 @@ class DataFrameTable(DataTable):
1187
2665
  # Recreate the table for display
1188
2666
  self._setup_table()
1189
2667
 
1190
- self.app.notify(
2668
+ self.notify(
1191
2669
  f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
1192
2670
  title="Filter",
1193
2671
  )
1194
2672
 
1195
- def _open_filter_screen(self) -> None:
1196
- """Open the filter screen to enter a filter expression."""
1197
- row_key = self.cursor_row_key
1198
- row_idx = int(row_key.value) - 1 # Convert to 0-based index
1199
- col_idx = self.cursor_column
2673
+ def _view_rows(self) -> None:
2674
+ """View rows.
2675
+
2676
+ If there are selected rows, view those rows.
2677
+ Otherwise, view based on the value of the currently selected cell.
2678
+ """
2679
+
2680
+ cidx = self.cursor_col_idx
2681
+
2682
+ # If there are selected rows or matches, use those
2683
+ if any(self.selected_rows) or self.matches:
2684
+ term = [
2685
+ True if (selected or idx in self.matches) else False for idx, selected in enumerate(self.selected_rows)
2686
+ ]
2687
+ # Otherwise, use the current cell value
2688
+ else:
2689
+ ridx = self.cursor_row_idx
2690
+ term = str(self.df.item(ridx, cidx))
1200
2691
 
1201
- cell_value = self.df.item(row_idx, col_idx)
1202
- if self.df.dtypes[col_idx] == pl.String and cell_value is not None:
1203
- cell_value = repr(cell_value)
2692
+ self._do_view_rows((term, cidx, False, False))
2693
+
2694
+ def _view_rows_expr(self) -> None:
2695
+ """Open the filter screen to enter an expression."""
2696
+ ridx = self.cursor_row_idx
2697
+ cidx = self.cursor_col_idx
2698
+ cursor_value = str(self.df.item(ridx, cidx))
1204
2699
 
1205
2700
  self.app.push_screen(
1206
- FilterScreen(
1207
- self.df, current_col_idx=col_idx, current_cell_value=cell_value
1208
- ),
1209
- callback=self._do_filter,
2701
+ FilterScreen(self.df, cidx, cursor_value),
2702
+ callback=self._do_view_rows,
1210
2703
  )
1211
2704
 
1212
- def _do_filter(self, result) -> None:
1213
- """Handle result from FilterScreen.
1214
-
1215
- Args:
1216
- expression: The filter expression or None if cancelled.
1217
- """
2705
+ def _do_view_rows(self, result) -> None:
2706
+ """Show only those matching rows and hide others. Do not modify the dataframe."""
1218
2707
  if result is None:
1219
2708
  return
1220
- expr, expr_str = result
2709
+ term, cidx, match_nocase, match_whole = result
2710
+
2711
+ col_name = self.df.columns[cidx]
2712
+
2713
+ if term == NULL:
2714
+ expr = pl.col(col_name).is_null()
2715
+ elif isinstance(term, (list, pl.Series)):
2716
+ # Support for list of booleans (selected rows)
2717
+ expr = term
2718
+ elif tentative_expr(term):
2719
+ # Support for polars expressions
2720
+ try:
2721
+ expr = validate_expr(term, self.df, cidx)
2722
+ except Exception as e:
2723
+ self.notify(
2724
+ f"Error validating Polars expression [$error]{term}[/]: {str(e)}", title="Filter", severity="error"
2725
+ )
2726
+ return
2727
+ else:
2728
+ dtype = self.df.dtypes[cidx]
2729
+ if dtype == pl.String:
2730
+ if match_whole:
2731
+ term = f"^{term}$"
2732
+ if match_nocase:
2733
+ term = f"(?i){term}"
2734
+ expr = pl.col(col_name).str.contains(term)
2735
+ else:
2736
+ try:
2737
+ value = DtypeConfig(dtype).convert(term)
2738
+ expr = pl.col(col_name) == value
2739
+ except Exception:
2740
+ if match_whole:
2741
+ term = f"^{term}$"
2742
+ if match_nocase:
2743
+ term = f"(?i){term}"
2744
+ expr = pl.col(col_name).cast(pl.String).str.contains(term)
2745
+ self.notify(
2746
+ f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
2747
+ title="Filter",
2748
+ severity="warning",
2749
+ )
1221
2750
 
1222
- # Add a row index column to track original row indices
1223
- df_with_rid = self.df.with_row_index("__rid__")
2751
+ # Lazyframe with row indices
2752
+ lf = self.df.lazy().with_row_index(RIDX)
1224
2753
 
1225
2754
  # Apply existing visibility filter first
1226
2755
  if False in self.visible_rows:
1227
- df_with_rid = df_with_rid.filter(self.visible_rows)
2756
+ lf = lf.filter(self.visible_rows)
1228
2757
 
1229
2758
  # Apply the filter expression
1230
- df_filtered = df_with_rid.filter(expr)
2759
+ try:
2760
+ df_filtered = lf.filter(expr).collect()
2761
+ except Exception as e:
2762
+ self.notify(f"Failed to apply filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
2763
+ self.histories.pop() # Remove last history entry
2764
+ return
1231
2765
 
1232
2766
  matched_count = len(df_filtered)
1233
2767
  if not matched_count:
1234
- self.app.notify(
1235
- f"No rows match the expression: [on $primary]{expr_str}[/]",
2768
+ self.notify(
2769
+ f"No rows match the expression: [$success]{expr}[/]",
1236
2770
  title="Filter",
1237
2771
  severity="warning",
1238
2772
  )
1239
2773
  return
1240
2774
 
1241
2775
  # Add to history
1242
- self._add_history(f"Filtered by expression [on $primary]{expr_str}[/]")
2776
+ self._add_history(f"Filtered by expression [$success]{expr}[/]")
1243
2777
 
1244
- # Mark unfiltered rows as invisible and unselected
1245
- filtered_row_indices = set(df_filtered["__rid__"].to_list())
2778
+ # Mark unfiltered rows as invisible
2779
+ filtered_row_indices = set(df_filtered[RIDX].to_list())
1246
2780
  if filtered_row_indices:
1247
- for rid in range(len(self.visible_rows)):
1248
- if rid not in filtered_row_indices:
1249
- self.visible_rows[rid] = False
1250
- self.selected_rows[rid] = False
2781
+ for ridx in range(len(self.visible_rows)):
2782
+ if ridx not in filtered_row_indices:
2783
+ self.visible_rows[ridx] = False
1251
2784
 
1252
2785
  # Recreate the table for display
1253
2786
  self._setup_table()
1254
2787
 
1255
- self.app.notify(
2788
+ self.notify(
1256
2789
  f"Filtered to [$accent]{matched_count}[/] matching rows",
1257
2790
  title="Filter",
1258
2791
  )
1259
2792
 
1260
- def _filter_rows(self) -> None:
1261
- """Filter rows.
1262
-
1263
- If there are selected rows, filter to those rows.
1264
- Otherwise, filter based on the value of the currently selected cell.
1265
- """
1266
-
1267
- if True in self.selected_rows:
1268
- expr = self.selected_rows
1269
- expr_str = "selected rows"
1270
- else:
1271
- row_key = self.cursor_row_key
1272
- row_idx = int(row_key.value) - 1 # Convert to 0-based index
1273
- col_idx = self.cursor_column
1274
-
1275
- cell_value = self.df.item(row_idx, col_idx)
1276
-
1277
- if cell_value is None:
1278
- expr = pl.col(self.df.columns[col_idx]).is_null()
1279
- expr_str = "NULL"
1280
- else:
1281
- expr = pl.col(self.df.columns[col_idx]) == cell_value
1282
- expr_str = f"$_ == {repr(cell_value)}"
1283
-
1284
- self._do_filter((expr, expr_str))
1285
-
1286
2793
  def _cycle_cursor_type(self) -> None:
1287
2794
  """Cycle through cursor types: cell -> row -> column -> cell."""
1288
- next_type = _next(CURSOR_TYPES, self.cursor_type)
2795
+ next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
1289
2796
  self.cursor_type = next_type
1290
2797
 
1291
- self.app.notify(
1292
- f"Changed cursor type to [on $primary]{next_type}[/]", title="Cursor"
1293
- )
2798
+ # self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
1294
2799
 
1295
- def _toggle_row_labels(self) -> None:
1296
- """Toggle row labels visibility."""
1297
- self.show_row_labels = not self.show_row_labels
1298
- status = "shown" if self.show_row_labels else "hidden"
1299
- self.app.notify(f"Row labels {status}", title="Labels")
2800
+ def _copy_to_clipboard(self, content: str, message: str) -> None:
2801
+ """Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
2802
+
2803
+ Args:
2804
+ content: The text content to copy to clipboard.
2805
+ message: The notification message to display on success.
2806
+ """
2807
+ import subprocess
2808
+
2809
+ try:
2810
+ subprocess.run(
2811
+ [
2812
+ "pbcopy" if sys.platform == "darwin" else "xclip",
2813
+ "-selection",
2814
+ "clipboard",
2815
+ ],
2816
+ input=content,
2817
+ text=True,
2818
+ )
2819
+ self.notify(message, title="Clipboard")
2820
+ except FileNotFoundError:
2821
+ self.notify("Error copying to clipboard", title="Clipboard", severity="error")
1300
2822
 
1301
2823
  def _save_to_file(self) -> None:
1302
- """Open save file dialog."""
1303
- self.app.push_screen(
1304
- SaveFileScreen(self.filename), callback=self._on_save_file_screen
1305
- )
2824
+ """Open screen to save file."""
2825
+ self.app.push_screen(SaveFileScreen(self.filename), callback=self._do_save_file)
1306
2826
 
1307
- def _on_save_file_screen(
1308
- self, filename: str | None, all_tabs: bool = False
1309
- ) -> None:
2827
+ def _do_save_file(self, filename: str | None, all_tabs: bool = False) -> None:
1310
2828
  """Handle result from SaveFileScreen."""
1311
2829
  if filename is None:
1312
2830
  return
@@ -1336,7 +2854,7 @@ class DataFrameTable(DataTable):
1336
2854
  # Go back to SaveFileScreen to allow user to enter a different name
1337
2855
  self.app.push_screen(
1338
2856
  SaveFileScreen(self._pending_filename),
1339
- callback=self._on_save_file_screen,
2857
+ callback=self._do_save_file,
1340
2858
  )
1341
2859
 
1342
2860
  def _do_save(self, filename: str) -> None:
@@ -1356,15 +2874,16 @@ class DataFrameTable(DataTable):
1356
2874
  else:
1357
2875
  self.df.write_csv(filename)
1358
2876
 
1359
- self.dataframe = self.df # Update original dataframe
2877
+ self.lazyframe = self.df.lazy() # Update original dataframe
1360
2878
  self.filename = filename # Update current filename
1361
2879
  if not self._all_tabs:
1362
- self.app.notify(
1363
- f"Saved [$accent]{len(self.df)}[/] rows to [on $primary]{filename}[/]",
2880
+ extra = "current tab with " if len(self.app.tabs) > 1 else ""
2881
+ self.notify(
2882
+ f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
1364
2883
  title="Save",
1365
2884
  )
1366
2885
  except Exception as e:
1367
- self.app.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
2886
+ self.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
1368
2887
  raise e
1369
2888
 
1370
2889
  def _do_save_excel(self, filename: str) -> None:
@@ -1377,17 +2896,54 @@ class DataFrameTable(DataTable):
1377
2896
  else:
1378
2897
  # Multiple tabs - use xlsxwriter to create multiple sheets
1379
2898
  with xlsxwriter.Workbook(filename) as wb:
1380
- for table in self.app.tabs.values():
1381
- table.df.write_excel(wb, worksheet=table.tabname[:31])
2899
+ tabs: dict[TabPane, DataFrameTable] = self.app.tabs
2900
+ for tab, table in tabs.items():
2901
+ worksheet = wb.add_worksheet(tab.name)
2902
+ table.df.write_excel(workbook=wb, worksheet=worksheet)
1382
2903
 
1383
2904
  # From ConfirmScreen callback, so notify accordingly
1384
2905
  if self._all_tabs is True:
1385
- self.app.notify(
1386
- f"Saved all tabs to [on $primary]{filename}[/]",
2906
+ self.notify(
2907
+ f"Saved all tabs to [$success]{filename}[/]",
1387
2908
  title="Save",
1388
2909
  )
1389
2910
  else:
1390
- self.app.notify(
1391
- f"Saved current tab with [$accent]{len(self.df)}[/] rows to [on $primary]{filename}[/]",
2911
+ self.notify(
2912
+ f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
1392
2913
  title="Save",
1393
2914
  )
2915
+
2916
+ def _make_cell_clickable(self) -> None:
2917
+ """Make cells with URLs in the current column clickable.
2918
+
2919
+ Scans all loaded rows in the current column for cells containing URLs
2920
+ (starting with 'http://' or 'https://') and applies Textual link styling
2921
+ to make them clickable. Does not modify the dataframe.
2922
+
2923
+ Returns:
2924
+ None
2925
+ """
2926
+ cidx = self.cursor_col_idx
2927
+ col_key = self.cursor_col_key
2928
+ dtype = self.df.dtypes[cidx]
2929
+
2930
+ # Only process string columns
2931
+ if dtype != pl.String:
2932
+ return
2933
+
2934
+ # Count how many URLs were made clickable
2935
+ url_count = 0
2936
+
2937
+ # Iterate through all loaded rows and make URLs clickable
2938
+ for row in self.ordered_rows:
2939
+ cell_text: Text = self.get_cell(row.key, col_key)
2940
+ if cell_text.plain.startswith(("http://", "https://")):
2941
+ cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
2942
+ self.update_cell(row.key, col_key, cell_text)
2943
+ url_count += 1
2944
+
2945
+ if url_count:
2946
+ self.notify(
2947
+ f"Made [$accent]{url_count}[/] cell(s) clickable in column [$success]{col_key.value}[/]",
2948
+ title="Make Clickable",
2949
+ )