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