dataframe-textual 0.1.0__py3-none-any.whl → 1.1.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.

Potentially problematic release.


This version of dataframe-textual might be problematic. Click here for more details.

@@ -0,0 +1,2768 @@
1
+ """DataFrameTable widget for displaying and interacting with Polars DataFrames."""
2
+
3
+ import sys
4
+ from collections import defaultdict, deque
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from textwrap import dedent
8
+ from typing import Any
9
+
10
+ import polars as pl
11
+ from rich.text import Text
12
+ from textual.coordinate import Coordinate
13
+ from textual.events import Click
14
+ from textual.widgets import DataTable, TabPane
15
+ from textual.widgets._data_table import (
16
+ CellDoesNotExist,
17
+ CellKey,
18
+ ColumnKey,
19
+ CursorType,
20
+ RowKey,
21
+ )
22
+
23
+ from .common import (
24
+ CURSOR_TYPES,
25
+ NULL,
26
+ NULL_DISPLAY,
27
+ RIDX,
28
+ SUBSCRIPT_DIGITS,
29
+ DtypeConfig,
30
+ format_row,
31
+ get_next_item,
32
+ rindex,
33
+ tentative_expr,
34
+ validate_expr,
35
+ )
36
+ from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
37
+ from .yes_no_screen import (
38
+ AddColumnScreen,
39
+ ConfirmScreen,
40
+ EditCellScreen,
41
+ EditColumnScreen,
42
+ FilterScreen,
43
+ FindReplaceScreen,
44
+ FreezeScreen,
45
+ RenameColumnScreen,
46
+ SaveFileScreen,
47
+ SearchScreen,
48
+ )
49
+
50
+
51
+ @dataclass
52
+ class History:
53
+ """Class to track history of dataframe states for undo/redo functionality."""
54
+
55
+ description: str
56
+ df: pl.DataFrame
57
+ filename: str
58
+ loaded_rows: int
59
+ sorted_columns: dict[str, bool]
60
+ hidden_columns: set[str]
61
+ selected_rows: list[bool]
62
+ visible_rows: list[bool]
63
+ fixed_rows: int
64
+ fixed_columns: int
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
87
+
88
+
89
+ class DataFrameTable(DataTable):
90
+ """Custom DataTable to highlight row/column labels based on cursor position."""
91
+
92
+ # Help text for the DataTable which will be shown in the HelpPanel
93
+ HELP = dedent("""
94
+ # 📊 DataFrame Viewer - Table Controls
95
+
96
+ ## ⬆️ Navigation
97
+ - **↑↓←→** - 🎯 Move cursor (cell/row/column)
98
+ - **g** - ⬆️ Jump to first row
99
+ - **G** - ⬇️ Jump to last row
100
+ - **PgUp/PgDn** - 📜 Page up/down
101
+
102
+ ## 👁️ View & Display
103
+ - **Enter** - 📋 Show row details in modal
104
+ - **F** - 📊 Show frequency distribution
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
109
+
110
+ ## ↕️ Sorting
111
+ - **[** - 🔼 Sort column ascending
112
+ - **]** - 🔽 Sort column descending
113
+ - *(Multi-column sort supported)*
114
+
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
135
+ - **t** - 💡 Toggle row selection (invert all)
136
+ - **{** - ⬆️ Go to previous selected row
137
+ - **}** - ⬇️ Go to next selected row
138
+ - **"** - 📍 Filter to show only selected rows
139
+ - **T** - 🧹 Clear all selections
140
+
141
+ ## ✏️ Edit & Modify
142
+ - **Double-click** - ✍️ Edit cell or rename column header
143
+ - **e** - ✍️ Edit current cell
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
150
+ - **-** - ❌ Delete current column
151
+ - **d** - 📋 Duplicate current column
152
+ - **h** - 👁️ Hide current column
153
+ - **H** - 👀 Show all hidden columns
154
+
155
+ ## 🎯 Reorder
156
+ - **Shift+↑↓** - ⬆️⬇️ Move row up/down
157
+ - **Shift+←→** - ⬅️➡️ Move column left/right
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
+
168
+ ## 💾 Data Management
169
+ - **z** - 📌 Freeze rows and columns
170
+ - **,** - 🔢 Toggle thousand separator for numeric display
171
+ - **c** - 📋 Copy cell to clipboard
172
+ - **Ctrl+c** - 📊 Copy column to clipboard
173
+ - **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
174
+ - **Ctrl+s** - 💾 Save current tab to file
175
+ - **u** - ↩️ Undo last action
176
+ - **U** - 🔄 Reset to original data
177
+ """).strip()
178
+
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:
253
+ """Initialize the DataFrameTable with a dataframe and manage all state.
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
+
258
+ Args:
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
266
+ """
267
+ super().__init__(name=(name or Path(filename).stem), **kwargs)
268
+
269
+ # DataFrame state
270
+ self.lazyframe = df.lazy() # Original dataframe
271
+ self.df = self.lazyframe.collect() # Internal/working dataframe
272
+ self.filename = filename # Current filename
273
+
274
+ # Pagination & Loading
275
+ self.INITIAL_BATCH_SIZE = (self.app.size.height // 100 + 1) * 100
276
+ self.BATCH_SIZE = self.INITIAL_BATCH_SIZE // 2
277
+ self.loaded_rows = 0 # Track how many rows are currently loaded
278
+
279
+ # State tracking (all 0-based indexing)
280
+ self.sorted_columns: dict[str, bool] = {} # col_name -> descending
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
285
+
286
+ # Freezing
287
+ self.fixed_rows = 0 # Number of fixed rows
288
+ self.fixed_columns = 0 # Number of fixed columns
289
+
290
+ # History stack for undo/redo
291
+ self.histories: deque[History] = deque()
292
+
293
+ # Pending filename for save operations
294
+ self._pending_filename = ""
295
+
296
+ # Whether to use thousand separator for numeric display
297
+ self.thousand_separator = False
298
+
299
+ @property
300
+ def cursor_key(self) -> CellKey:
301
+ """Get the current cursor position as a CellKey.
302
+
303
+ Returns:
304
+ CellKey: A CellKey object representing the current cursor position.
305
+ """
306
+ return self.coordinate_to_cell_key(self.cursor_coordinate)
307
+
308
+ @property
309
+ def cursor_row_key(self) -> RowKey:
310
+ """Get the current cursor row as a RowKey.
311
+
312
+ Returns:
313
+ RowKey: The row key for the row containing the cursor.
314
+ """
315
+ return self.cursor_key.row_key
316
+
317
+ @property
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
+ """
324
+ return self.cursor_key.column_key
325
+
326
+ @property
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
393
+
394
+ def on_mount(self) -> None:
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.
399
+
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:
407
+ """Determine if the given cell should be highlighted because of the cursor.
408
+
409
+ In "cell" mode, also highlights the row and column headers. In "row" and "column"
410
+ modes, highlights the entire row or column respectively.
411
+
412
+ Args:
413
+ cursor: The current position of the cursor.
414
+ target_cell: The cell we're checking for the need to highlight.
415
+ type_of_cursor: The type of cursor that is currently active ("cell", "row", or "column").
416
+
417
+ Returns:
418
+ bool: True if the target cell should be highlighted, False otherwise.
419
+ """
420
+ if type_of_cursor == "cell":
421
+ # Return true if the cursor is over the target cell
422
+ # This includes the case where the cursor is in the same row or column
423
+ return (
424
+ cursor == target_cell
425
+ or (target_cell.row == -1 and target_cell.column == cursor.column)
426
+ or (target_cell.column == -1 and target_cell.row == cursor.row)
427
+ )
428
+ elif type_of_cursor == "row":
429
+ cursor_row, _ = cursor
430
+ cell_row, _ = target_cell
431
+ return cursor_row == cell_row
432
+ elif type_of_cursor == "column":
433
+ _, cursor_column = cursor
434
+ _, cell_column = target_cell
435
+ return cursor_column == cell_column
436
+ else:
437
+ return False
438
+
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).
445
+
446
+ Args:
447
+ old_coordinate: The previous cursor coordinate.
448
+ new_coordinate: The new cursor coordinate.
449
+
450
+ Returns:
451
+ None
452
+ """
453
+ if old_coordinate != new_coordinate:
454
+ # Emit CellSelected message for cell cursor type (keyboard navigation only)
455
+ # Only emit if this is from keyboard navigation (flag is True when from keyboard)
456
+ if self.cursor_type == "cell" and getattr(self, "_from_keyboard", False):
457
+ self._from_keyboard = False # Reset flag
458
+ try:
459
+ self._post_selected_message()
460
+ except CellDoesNotExist:
461
+ # This could happen when after calling clear(), the old coordinate is invalid
462
+ pass
463
+
464
+ # For cell cursor type, refresh old and new row/column headers
465
+ if self.cursor_type == "cell":
466
+ old_row, old_col = old_coordinate
467
+ new_row, new_col = new_coordinate
468
+
469
+ # Refresh entire column (not just header) to ensure proper highlighting
470
+ self.refresh_column(old_col)
471
+ self.refresh_column(new_col)
472
+
473
+ # Refresh entire row (not just header) to ensure proper highlighting
474
+ self.refresh_row(old_row)
475
+ self.refresh_row(new_row)
476
+ elif self.cursor_type == "row":
477
+ self.refresh_row(old_coordinate.row)
478
+ self._highlight_row(new_coordinate.row)
479
+ elif self.cursor_type == "column":
480
+ self.refresh_column(old_coordinate.column)
481
+ self._highlight_column(new_coordinate.column)
482
+
483
+ # Handle scrolling if needed
484
+ if self._require_update_dimensions:
485
+ self.call_after_refresh(self._scroll_cursor_into_view)
486
+ else:
487
+ self._scroll_cursor_into_view()
488
+
489
+ def on_key(self, event) -> None:
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"):
502
+ # Let the table handle the navigation first
503
+ self._check_and_load_more()
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
+ )
818
+
819
+ def on_mouse_scroll_down(self, event) -> None:
820
+ """Load more rows when scrolling down with mouse."""
821
+ self._check_and_load_more()
822
+
823
+ # Setup & Loading
824
+ def _setup_table(self, reset: bool = False) -> None:
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
+ """
830
+ # Reset to original dataframe
831
+ if reset:
832
+ self.df = self.lazyframe.collect()
833
+ self.loaded_rows = 0
834
+ self.sorted_columns = {}
835
+ self.hidden_columns = set()
836
+ self.selected_rows = [False] * len(self.df)
837
+ self.visible_rows = [True] * len(self.df)
838
+ self.fixed_rows = 0
839
+ self.fixed_columns = 0
840
+ self.matches = defaultdict(set)
841
+
842
+ # Lazy load up to INITIAL_BATCH_SIZE visible rows
843
+ stop, visible_count = len(self.df), 0
844
+ for row_idx, visible in enumerate(self.visible_rows):
845
+ if not visible:
846
+ continue
847
+ visible_count += 1
848
+ if visible_count >= self.INITIAL_BATCH_SIZE:
849
+ stop = row_idx + 1
850
+ break
851
+
852
+ # Save current cursor position before clearing
853
+ row_idx, col_idx = self.cursor_coordinate
854
+
855
+ self._setup_columns()
856
+ self._load_rows(stop)
857
+ self._do_highlight()
858
+
859
+ # Restore cursor position
860
+ if row_idx < len(self.rows) and col_idx < len(self.columns):
861
+ self.move_cursor(row=row_idx, column=col_idx)
862
+
863
+ def _setup_columns(self) -> None:
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
+ """
869
+ self.loaded_rows = 0
870
+ self.clear(columns=True)
871
+ self.show_row_labels = True
872
+
873
+ # Add columns with justified headers
874
+ for col, dtype in zip(self.df.columns, self.df.dtypes):
875
+ if col in self.hidden_columns:
876
+ continue # Skip hidden columns
877
+ for idx, c in enumerate(self.sorted_columns, 1):
878
+ if c == col:
879
+ # Add sort indicator to column header
880
+ descending = self.sorted_columns[col]
881
+ sort_indicator = (
882
+ f" ▼{SUBSCRIPT_DIGITS.get(idx, '')}" if descending else f" ▲{SUBSCRIPT_DIGITS.get(idx, '')}"
883
+ )
884
+ cell_value = col + sort_indicator
885
+ break
886
+ else: # No break occurred, so column is not sorted
887
+ cell_value = col
888
+
889
+ self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col)
890
+
891
+ def _load_rows(self, stop: int | None = None) -> None:
892
+ """Load a batch of rows into the table.
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
+
897
+ Args:
898
+ stop: Stop loading rows when this index is reached. If None, load until the end of the dataframe.
899
+ """
900
+ if stop is None or stop > len(self.df):
901
+ stop = len(self.df)
902
+
903
+ if stop <= self.loaded_rows:
904
+ return
905
+
906
+ start = self.loaded_rows
907
+ df_slice = self.df.slice(start, stop - start)
908
+
909
+ for row_idx, row in enumerate(df_slice.rows(), start):
910
+ if not self.visible_rows[row_idx]:
911
+ continue # Skip hidden rows
912
+ vals, 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
916
+ vals.append(val)
917
+ dtypes.append(dtype)
918
+ formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
919
+ # Always add labels so they can be shown/hidden via CSS
920
+ self.add_row(*formatted_row, key=str(row_idx), label=str(row_idx + 1))
921
+
922
+ # Update loaded rows count
923
+ self.loaded_rows = stop
924
+
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)
939
+
940
+ def _do_highlight(self, clear: bool = False) -> None:
941
+ """Update all rows, highlighting selected ones and restoring others to default.
942
+
943
+ Args:
944
+ clear: If True, clear all highlights.
945
+ """
946
+ if clear:
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)
953
+
954
+ self._load_rows(stop)
955
+ self._highlight_table()
956
+
957
+ def _highlight_table(self) -> None:
958
+ """Highlight selected rows/cells in red."""
959
+ # Update all rows based on selected state
960
+ for row in self.ordered_rows:
961
+ row_idx = int(row.key.value) # 0-based index
962
+ is_selected = self.selected_rows[row_idx]
963
+ match_cols = self.matches.get(row_idx, set())
964
+
965
+ # Update all cells in this row
966
+ for col_idx, col in enumerate(self.ordered_columns):
967
+ cell_text: Text = self.get_cell(row.key, col.key)
968
+
969
+ # Get style config based on dtype
970
+ dtype = self.df.dtypes[col_idx]
971
+ dc = DtypeConfig(dtype)
972
+ cell_text.style = "red" if is_selected or col_idx in match_cols else dc.style
973
+
974
+ # Update the cell in the table
975
+ self.update_cell(row.key, col.key, cell_text)
976
+
977
+ # History & Undo
978
+ def _add_history(self, description: str) -> None:
979
+ """Add the current state to the history stack.
980
+
981
+ Args:
982
+ description: Description of the action for this history entry.
983
+ """
984
+ history = History(
985
+ description=description,
986
+ df=self.df,
987
+ filename=self.filename,
988
+ loaded_rows=self.loaded_rows,
989
+ sorted_columns=self.sorted_columns.copy(),
990
+ hidden_columns=self.hidden_columns.copy(),
991
+ selected_rows=self.selected_rows.copy(),
992
+ visible_rows=self.visible_rows.copy(),
993
+ fixed_rows=self.fixed_rows,
994
+ fixed_columns=self.fixed_columns,
995
+ cursor_coordinate=self.cursor_coordinate,
996
+ matches={k: v.copy() for k, v in self.matches.items()},
997
+ )
998
+ self.histories.append(history)
999
+
1000
+ def _undo(self) -> None:
1001
+ """Undo the last action."""
1002
+ if not self.histories:
1003
+ self.notify("No actions to undo", title="Undo", severity="warning")
1004
+ return
1005
+
1006
+ history = self.histories.pop()
1007
+
1008
+ # Restore state
1009
+ self.df = history.df
1010
+ self.filename = history.filename
1011
+ self.loaded_rows = history.loaded_rows
1012
+ self.sorted_columns = history.sorted_columns.copy()
1013
+ self.hidden_columns = history.hidden_columns.copy()
1014
+ self.selected_rows = history.selected_rows.copy()
1015
+ self.visible_rows = history.visible_rows.copy()
1016
+ self.fixed_rows = history.fixed_rows
1017
+ self.fixed_columns = history.fixed_columns
1018
+ self.cursor_coordinate = history.cursor_coordinate
1019
+ self.matches = {k: v.copy() for k, v in history.matches.items()}
1020
+
1021
+ # Recreate the table for display
1022
+ self._setup_table()
1023
+
1024
+ # self.notify(f"Reverted: {history.description}", title="Undo")
1025
+
1026
+ # View
1027
+ def _view_row_detail(self) -> None:
1028
+ """Open a modal screen to view the selected row's details."""
1029
+ ridx = self.cursor_row_idx
1030
+
1031
+ # Push the modal screen
1032
+ self.app.push_screen(RowDetailScreen(ridx, self))
1033
+
1034
+ def _show_frequency(self) -> None:
1035
+ """Show frequency distribution for the current column."""
1036
+ cidx = self.cursor_col_idx
1037
+
1038
+ # Push the frequency modal screen
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.
1043
+
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:
1056
+ """Open the freeze screen to set fixed rows and columns."""
1057
+ self.app.push_screen(FreezeScreen(), callback=self._do_freeze)
1058
+
1059
+ def _do_freeze(self, result: tuple[int, int] | None) -> None:
1060
+ """Handle result from PinScreen.
1061
+
1062
+ Args:
1063
+ result: Tuple of (fixed_rows, fixed_columns) or None if cancelled.
1064
+ """
1065
+ if result is None:
1066
+ return
1067
+
1068
+ fixed_rows, fixed_columns = result
1069
+
1070
+ # Add to history
1071
+ self._add_history(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns")
1072
+
1073
+ # Apply the pin settings to the table
1074
+ if fixed_rows > 0:
1075
+ self.fixed_rows = fixed_rows
1076
+ if fixed_columns > 0:
1077
+ self.fixed_columns = fixed_columns
1078
+
1079
+ self.notify(
1080
+ f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
1081
+ title="Pin",
1082
+ )
1083
+
1084
+ # Delete & Move
1085
+ def _delete_column(self) -> None:
1086
+ """Remove the currently selected column from the table."""
1087
+ # Get the column to remove
1088
+ col_idx = self.cursor_column
1089
+ col_name = self.cursor_col_name
1090
+ col_key = self.cursor_col_key
1091
+
1092
+ # Add to history
1093
+ self._add_history(f"Removed column [$success]{col_name}[/]")
1094
+
1095
+ # Remove the column from the table display using the column name as key
1096
+ self.remove_column(col_key)
1097
+
1098
+ # Move cursor left if we deleted the last column
1099
+ if col_idx >= len(self.columns):
1100
+ self.move_cursor(column=len(self.columns) - 1)
1101
+
1102
+ # Remove from sorted columns if present
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]
1112
+
1113
+ # Remove from dataframe
1114
+ self.df = self.df.drop(col_name)
1115
+
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",
1191
+ )
1192
+
1193
+ def _delete_row(self) -> None:
1194
+ """Delete rows from the table and dataframe.
1195
+
1196
+ Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
1197
+ """
1198
+ old_count = len(self.df)
1199
+ predicates = [True] * len(self.df)
1200
+
1201
+ # Delete all selected rows
1202
+ if selected_count := self.selected_rows.count(True):
1203
+ history_desc = f"Deleted {selected_count} selected row(s)"
1204
+
1205
+ for ridx, selected in enumerate(self.selected_rows):
1206
+ if selected:
1207
+ predicates[ridx] = False
1208
+
1209
+ # Delete the row at the cursor
1210
+ else:
1211
+ ridx = self.cursor_row_idx
1212
+ history_desc = f"Deleted row [$success]{ridx + 1}[/]"
1213
+ predicates[ridx] = False
1214
+
1215
+ # Add to history
1216
+ self._add_history(history_desc)
1217
+
1218
+ # Apply the filter to remove rows
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)
1227
+
1228
+ # Update selected and visible rows tracking
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)
1235
+
1236
+ # Recreate the table display
1237
+ self._setup_table()
1238
+
1239
+ deleted_count = old_count - len(self.df)
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")
1276
+
1277
+ def _move_column(self, direction: str) -> None:
1278
+ """Move the current column left or right.
1279
+
1280
+ Args:
1281
+ direction: "left" to move left, "right" to move right.
1282
+ """
1283
+ row_idx, col_idx = self.cursor_coordinate
1284
+ col_key = self.cursor_col_key
1285
+ col_name = col_key.value
1286
+ cidx = self.cursor_col_idx
1287
+
1288
+ # Validate move is possible
1289
+ if direction == "left":
1290
+ if col_idx <= 0:
1291
+ self.notify("Cannot move column left", title="Move", severity="warning")
1292
+ return
1293
+ swap_idx = col_idx - 1
1294
+ elif direction == "right":
1295
+ if col_idx >= len(self.columns) - 1:
1296
+ self.notify("Cannot move column right", title="Move", severity="warning")
1297
+ return
1298
+ swap_idx = col_idx + 1
1299
+
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)
1304
+
1305
+ # Add to history
1306
+ self._add_history(f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])")
1307
+
1308
+ # Swap columns in the table's internal column locations
1309
+ self.check_idle()
1310
+
1311
+ (
1312
+ self._column_locations[col_key],
1313
+ self._column_locations[swap_key],
1314
+ ) = (
1315
+ self._column_locations.get(swap_key),
1316
+ self._column_locations.get(col_key),
1317
+ )
1318
+
1319
+ self._update_count += 1
1320
+ self.refresh()
1321
+
1322
+ # Restore cursor position on the moved column
1323
+ self.move_cursor(row=row_idx, column=swap_idx)
1324
+
1325
+ # Update the dataframe column order
1326
+ cols = list(self.df.columns)
1327
+ cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
1328
+ self.df = self.df.select(cols)
1329
+
1330
+ # self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
1331
+
1332
+ def _move_row(self, direction: str) -> None:
1333
+ """Move the current row up or down.
1334
+
1335
+ Args:
1336
+ direction: "up" to move up, "down" to move down.
1337
+ """
1338
+ row_idx, col_idx = self.cursor_coordinate
1339
+
1340
+ # Validate move is possible
1341
+ if direction == "up":
1342
+ if row_idx <= 0:
1343
+ self.notify("Cannot move row up", title="Move", severity="warning")
1344
+ return
1345
+ swap_idx = row_idx - 1
1346
+ elif direction == "down":
1347
+ if row_idx >= len(self.rows) - 1:
1348
+ self.notify("Cannot move row down", title="Move", severity="warning")
1349
+ return
1350
+ swap_idx = row_idx + 1
1351
+ else:
1352
+ self.notify(f"Invalid direction: {direction}", title="Move", severity="error")
1353
+ return
1354
+
1355
+ row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
1356
+ swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
1357
+
1358
+ # Add to history
1359
+ self._add_history(
1360
+ f"Moved row [$success]{row_key.value}[/] {direction} (swapped with row [$success]{swap_key.value}[/])"
1361
+ )
1362
+
1363
+ # Swap rows in the table's internal row locations
1364
+ self.check_idle()
1365
+
1366
+ (
1367
+ self._row_locations[row_key],
1368
+ self._row_locations[swap_key],
1369
+ ) = (
1370
+ self._row_locations.get(swap_key),
1371
+ self._row_locations.get(row_key),
1372
+ )
1373
+
1374
+ self._update_count += 1
1375
+ self.refresh()
1376
+
1377
+ # Restore cursor position on the moved row
1378
+ self.move_cursor(row=swap_idx, column=col_idx)
1379
+
1380
+ # Swap rows in the dataframe
1381
+ ridx = int(row_key.value) # 0-based
1382
+ swap_ridx = int(swap_key.value) # 0-based
1383
+ first, second = sorted([ridx, swap_ridx])
1384
+
1385
+ self.df = pl.concat(
1386
+ [
1387
+ self.df.slice(0, first),
1388
+ self.df.slice(second, 1),
1389
+ self.df.slice(first + 1, second - first - 1),
1390
+ self.df.slice(first, 1),
1391
+ self.df.slice(second + 1),
1392
+ ]
1393
+ )
1394
+
1395
+ # self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move")
1396
+
1397
+ # Sort
1398
+ def _sort_by_column(self, descending: bool = False) -> None:
1399
+ """Sort by the currently selected column.
1400
+
1401
+ Supports multi-column sorting:
1402
+ - First press on a column: sort by that column only
1403
+ - Subsequent presses on other columns: add to sort order
1404
+
1405
+ Args:
1406
+ descending: If True, sort in descending order. If False, ascending order.
1407
+ """
1408
+ col_name = self.cursor_col_name
1409
+ col_idx = self.cursor_column
1410
+
1411
+ # Check if this column is already in the sort keys
1412
+ old_desc = self.sorted_columns.get(col_name)
1413
+
1414
+ # Add to history
1415
+ self._add_history(f"Sorted on column [$success]{col_name}[/]")
1416
+ if old_desc is None:
1417
+ # Add new column to sort
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]
1422
+ else:
1423
+ # Move to end of sort order
1424
+ del self.sorted_columns[col_name]
1425
+ self.sorted_columns[col_name] = descending
1426
+
1427
+ # Apply multi-column sort
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)
1434
+
1435
+ # Updated selected_rows and visible_rows to match new order
1436
+ old_row_indices = df_sorted[RIDX].to_list()
1437
+ self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
1438
+ self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
1439
+
1440
+ # Update the dataframe
1441
+ self.df = df_sorted.drop(RIDX)
1442
+
1443
+ # Recreate the table for display
1444
+ self._setup_table()
1445
+
1446
+ # Restore cursor position on the sorted column
1447
+ self.move_cursor(column=col_idx, row=0)
1448
+
1449
+ # Edit
1450
+ def _edit_cell(self, ridx: int = None, cidx: int = None) -> None:
1451
+ """Open modal to edit the selected cell."""
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]
1455
+
1456
+ # Save current state to history
1457
+ self._add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]")
1458
+
1459
+ # Push the edit modal screen
1460
+ self.app.push_screen(
1461
+ EditCellScreen(ridx, cidx, self.df),
1462
+ callback=self._do_edit_cell,
1463
+ )
1464
+
1465
+ def _do_edit_cell(self, result) -> None:
1466
+ """Handle result from EditCellScreen."""
1467
+ if result is None:
1468
+ return
1469
+
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]
1479
+
1480
+ # Update the cell in the dataframe
1481
+ try:
1482
+ self.df = self.df.with_columns(
1483
+ pl.when(pl.arange(0, len(self.df)) == ridx)
1484
+ .then(pl.lit(new_value))
1485
+ .otherwise(pl.col(col_name))
1486
+ .alias(col_name)
1487
+ )
1488
+
1489
+ # Update the display
1490
+ cell_value = self.df.item(ridx, cidx)
1491
+ if cell_value is None:
1492
+ cell_value = NULL_DISPLAY
1493
+ dtype = self.df.dtypes[cidx]
1494
+ dc = DtypeConfig(dtype)
1495
+ formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
1496
+
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)
1501
+
1502
+ self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1503
+ except Exception as e:
1504
+ self.notify(f"Failed to update cell: {str(e)}", title="Edit", severity="error")
1505
+
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()
1562
+
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
1571
+ col_idx = self.cursor_column
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
1629
+ try:
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)
1635
+ )
1636
+
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)
1641
+
1642
+ self.update_cell(row_key, col_key, formatted_value)
1643
+
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
1648
+
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
1697
+ self.app.push_screen(
1698
+ AddColumnScreen(cidx, self.df),
1699
+ self._do_add_column_expr,
1700
+ )
1701
+
1702
+ def _do_add_column_expr(self, result: tuple[int, str, str, pl.Expr] | None) -> None:
1703
+ """Add a new column with an expression."""
1704
+ if result is None:
1705
+ return
1706
+
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)
1715
+
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.
1738
+
1739
+ Args:
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
1747
+ """
1748
+ dtype_map = {
1749
+ "string": pl.String,
1750
+ "int": pl.Int64,
1751
+ "float": pl.Float64,
1752
+ "bool": pl.Boolean,
1753
+ }
1754
+
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
1778
+ else:
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",
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:
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)
1874
+
1875
+ # Apply filter to get matched row indices
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
1885
+
1886
+ match_count = len(matches)
1887
+ if match_count == 0:
1888
+ self.notify(
1889
+ f"No matches found for [$warning]{term}[/]. Try [$accent](?i)abc[/] for case-insensitive search.",
1890
+ title="Search",
1891
+ severity="warning",
1892
+ )
1893
+ return
1894
+
1895
+ # Add to history
1896
+ self._add_history(f"Searched [$accent]{term}[/] in column [$success]{col_name}[/]")
1897
+
1898
+ # Update selected rows to include new matches
1899
+ for m in matches:
1900
+ self.selected_rows[m] = True
1901
+
1902
+ # Highlight matches
1903
+ self._do_highlight()
1904
+
1905
+ self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
1906
+
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.
1911
+
1912
+ Args:
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.
1923
+ """
1924
+ matches: dict[int, set[int]] = defaultdict(set)
1925
+
1926
+ # Lazyframe for filtering
1927
+ lf = self.df.lazy().with_row_index(RIDX)
1928
+ if False in self.visible_rows:
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))
1977
+ else:
1978
+ self._do_find_global((term, None, False, False))
1979
+
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",
2055
+ severity="warning",
2056
+ )
2057
+ return
2058
+
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
2231
+
2232
+ # Add to history
2233
+ self._add_history(
2234
+ f"Replacing [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
2235
+ )
2236
+
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
+ )
2260
+
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)
2268
+
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
+ )
2275
+
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
+ )
2324
+
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",
2334
+ )
2335
+
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
+ )
2347
+
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
2412
+
2413
+ # Skip
2414
+ elif result is False:
2415
+ state.skipped_occurrence += 1
2416
+
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:
2440
+ """Toggle selected rows highlighting on/off."""
2441
+ # Save current state to history
2442
+ self._add_history("Toggled row selection")
2443
+
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
2451
+ else:
2452
+ # Invert all selected rows
2453
+ self.selected_rows = [not selected for selected in self.selected_rows]
2454
+
2455
+ # Check if we're highlighting or un-highlighting
2456
+ if new_selected_count := self.selected_rows.count(True):
2457
+ self.notify(
2458
+ f"Toggled selection for [$accent]{new_selected_count}[/] rows",
2459
+ title="Toggle",
2460
+ )
2461
+
2462
+ # Refresh the highlighting (also restores default styles for unselected 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()
2485
+
2486
+ def _clear_selections(self) -> None:
2487
+ """Clear all selected rows without removing them from the dataframe."""
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")
2491
+ return
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
+
2497
+ # Save current state to history
2498
+ self._add_history("Cleared all selected rows")
2499
+
2500
+ # Clear all selections and refresh highlighting
2501
+ self._do_highlight(clear=True)
2502
+
2503
+ self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
2504
+
2505
+ def _filter_selected_rows(self) -> None:
2506
+ """Keep only the selected rows and remove unselected ones."""
2507
+ selected_count = self.selected_rows.count(True)
2508
+ if selected_count == 0:
2509
+ self.notify("No rows selected to filter", title="Filter", severity="warning")
2510
+ return
2511
+
2512
+ # Save current state to history
2513
+ self._add_history("Filtered to selected rows")
2514
+
2515
+ # Update dataframe to only include selected rows
2516
+ self.df = self.df.filter(self.selected_rows)
2517
+ self.selected_rows = [True] * len(self.df)
2518
+
2519
+ # Recreate the table for display
2520
+ self._setup_table()
2521
+
2522
+ self.notify(
2523
+ f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
2524
+ title="Filter",
2525
+ )
2526
+
2527
+ def _view_rows(self) -> None:
2528
+ """View rows.
2529
+
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))
2553
+
2554
+ self.app.push_screen(
2555
+ FilterScreen(self.df, cidx, cursor_value),
2556
+ callback=self._do_view_rows,
2557
+ )
2558
+
2559
+ def _do_view_rows(self, result) -> None:
2560
+ """Show only those matching rows and hide others. Do not modify the dataframe."""
2561
+ if result is None:
2562
+ return
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
+ )
2604
+
2605
+ # Lazyframe with row indices
2606
+ lf = self.df.lazy().with_row_index(RIDX)
2607
+
2608
+ # Apply existing visibility filter first
2609
+ if False in self.visible_rows:
2610
+ lf = lf.filter(self.visible_rows)
2611
+
2612
+ # Apply the filter expression
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
2619
+
2620
+ matched_count = len(df_filtered)
2621
+ if not matched_count:
2622
+ self.notify(
2623
+ f"No rows match the expression: [$success]{expr}[/]",
2624
+ title="Filter",
2625
+ severity="warning",
2626
+ )
2627
+ return
2628
+
2629
+ # Add to history
2630
+ self._add_history(f"Filtered by expression [$success]{expr}[/]")
2631
+
2632
+ # Mark unfiltered rows as invisible
2633
+ filtered_row_indices = set(df_filtered[RIDX].to_list())
2634
+ if filtered_row_indices:
2635
+ for ridx in range(len(self.visible_rows)):
2636
+ if ridx not in filtered_row_indices:
2637
+ self.visible_rows[ridx] = False
2638
+
2639
+ # Recreate the table for display
2640
+ self._setup_table()
2641
+
2642
+ self.notify(
2643
+ f"Filtered to [$accent]{matched_count}[/] matching rows",
2644
+ title="Filter",
2645
+ )
2646
+
2647
+ def _cycle_cursor_type(self) -> None:
2648
+ """Cycle through cursor types: cell -> row -> column -> cell."""
2649
+ next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
2650
+ self.cursor_type = next_type
2651
+
2652
+ # self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
2653
+
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")
2676
+
2677
+ def _save_to_file(self) -> None:
2678
+ """Open screen to save file."""
2679
+ self.app.push_screen(SaveFileScreen(self.filename), callback=self._do_save_file)
2680
+
2681
+ def _do_save_file(self, filename: str | None, all_tabs: bool = False) -> None:
2682
+ """Handle result from SaveFileScreen."""
2683
+ if filename is None:
2684
+ return
2685
+ filepath = Path(filename)
2686
+ ext = filepath.suffix.lower()
2687
+
2688
+ # Whether to save all tabs (for Excel files)
2689
+ self._all_tabs = all_tabs
2690
+
2691
+ # Check if file exists
2692
+ if filepath.exists():
2693
+ self._pending_filename = filename
2694
+ self.app.push_screen(
2695
+ ConfirmScreen("File already exists. Overwrite?"),
2696
+ callback=self._on_overwrite_screen,
2697
+ )
2698
+ elif ext in (".xlsx", ".xls"):
2699
+ self._do_save_excel(filename)
2700
+ else:
2701
+ self._do_save(filename)
2702
+
2703
+ def _on_overwrite_screen(self, should_overwrite: bool) -> None:
2704
+ """Handle result from ConfirmScreen."""
2705
+ if should_overwrite:
2706
+ self._do_save(self._pending_filename)
2707
+ else:
2708
+ # Go back to SaveFileScreen to allow user to enter a different name
2709
+ self.app.push_screen(
2710
+ SaveFileScreen(self._pending_filename),
2711
+ callback=self._do_save_file,
2712
+ )
2713
+
2714
+ def _do_save(self, filename: str) -> None:
2715
+ """Actually save the dataframe to a file."""
2716
+ filepath = Path(filename)
2717
+ ext = filepath.suffix.lower()
2718
+
2719
+ try:
2720
+ if ext in (".xlsx", ".xls"):
2721
+ self._do_save_excel(filename)
2722
+ elif ext in (".tsv", ".tab"):
2723
+ self.df.write_csv(filename, separator="\t")
2724
+ elif ext == ".json":
2725
+ self.df.write_json(filename)
2726
+ elif ext == ".parquet":
2727
+ self.df.write_parquet(filename)
2728
+ else:
2729
+ self.df.write_csv(filename)
2730
+
2731
+ self.lazyframe = self.df.lazy() # Update original dataframe
2732
+ self.filename = filename # Update current filename
2733
+ if not self._all_tabs:
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}[/]",
2737
+ title="Save",
2738
+ )
2739
+ except Exception as e:
2740
+ self.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
2741
+ raise e
2742
+
2743
+ def _do_save_excel(self, filename: str) -> None:
2744
+ """Save to an Excel file."""
2745
+ import xlsxwriter
2746
+
2747
+ if not self._all_tabs or len(self.app.tabs) == 1:
2748
+ # Single tab - save directly
2749
+ self.df.write_excel(filename)
2750
+ else:
2751
+ # Multiple tabs - use xlsxwriter to create multiple sheets
2752
+ with xlsxwriter.Workbook(filename) as wb:
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)
2757
+
2758
+ # From ConfirmScreen callback, so notify accordingly
2759
+ if self._all_tabs is True:
2760
+ self.notify(
2761
+ f"Saved all tabs to [$success]{filename}[/]",
2762
+ title="Save",
2763
+ )
2764
+ else:
2765
+ self.notify(
2766
+ f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
2767
+ title="Save",
2768
+ )