dataframe-textual 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1393 @@
1
+ """DataFrameTable widget for displaying and interacting with Polars DataFrames."""
2
+
3
+ import sys
4
+ from collections import deque
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from textwrap import dedent
8
+
9
+ import polars as pl
10
+ from rich.text import Text
11
+ from textual.coordinate import Coordinate
12
+ from textual.widgets import DataTable
13
+ from textual.widgets._data_table import (
14
+ CellDoesNotExist,
15
+ CellKey,
16
+ ColumnKey,
17
+ CursorType,
18
+ RowKey,
19
+ )
20
+
21
+ from .common import (
22
+ BATCH_SIZE,
23
+ BOOLS,
24
+ CURSOR_TYPES,
25
+ INITIAL_BATCH_SIZE,
26
+ SUBSCRIPT_DIGITS,
27
+ DtypeConfig,
28
+ _format_row,
29
+ _next,
30
+ _rindex,
31
+ )
32
+ from .table_screen import FrequencyScreen, RowDetailScreen
33
+ from .yes_no_screen import (
34
+ ConfirmScreen,
35
+ EditCellScreen,
36
+ FilterScreen,
37
+ FreezeScreen,
38
+ SaveFileScreen,
39
+ SearchScreen,
40
+ )
41
+
42
+
43
+ @dataclass
44
+ class History:
45
+ """Class to track history of dataframe states for undo/redo functionality."""
46
+
47
+ description: str
48
+ df: pl.DataFrame
49
+ filename: str
50
+ loaded_rows: int
51
+ sorted_columns: dict[str, bool]
52
+ selected_rows: list[bool]
53
+ visible_rows: list[bool]
54
+ fixed_rows: int
55
+ fixed_columns: int
56
+ cursor_coordinate: Coordinate
57
+
58
+
59
+ class DataFrameTable(DataTable):
60
+ """Custom DataTable to highlight row/column labels based on cursor position."""
61
+
62
+ # Help text for the DataTable which will be shown in the HelpPanel
63
+ HELP = dedent("""
64
+ # ๐Ÿ“Š DataFrame Viewer - Table Controls
65
+
66
+ ## โฌ†๏ธ Navigation
67
+ - **โ†‘โ†“โ†โ†’** - ๐ŸŽฏ Move cursor (cell/row/column)
68
+ - **g** - โฌ†๏ธ Jump to first row
69
+ - **G** - โฌ‡๏ธ Jump to last row
70
+ - **PgUp/PgDn** - ๐Ÿ“œ Page up/down
71
+
72
+ ## ๐Ÿ‘๏ธ View & Display
73
+ - **Enter** - ๐Ÿ“‹ Show row details in modal
74
+ - **F** - ๐Ÿ“Š Show frequency distribution
75
+ - **C** - ๐Ÿ”„ Cycle cursor (cell โ†’ row โ†’ column โ†’ cell)
76
+ - **#** - ๐Ÿท๏ธ Toggle row labels
77
+
78
+ ## โ†•๏ธ Sorting
79
+ - **[** - ๐Ÿ”ผ Sort column ascending
80
+ - **]** - ๐Ÿ”ฝ Sort column descending
81
+ - *(Multi-column sort supported)*
82
+
83
+ ## ๐Ÿ” Search
84
+ - **|** - ๐Ÿ”Ž Search in current column
85
+ - **/** - ๐ŸŒ Global search (all columns)
86
+ - **\\\\** - ๐Ÿ” Search using current cell value
87
+
88
+ ## ๐Ÿ”ง Filter & Select
89
+ - **s** - โœ“๏ธ Select/deselect current row
90
+ - **t** - ๐Ÿ’ก Toggle row selection (invert all)
91
+ - **"** - ๐Ÿ“ Filter to selected rows only
92
+ - **T** - ๐Ÿงน Clear all selections
93
+ - **v** - ๐ŸŽฏ Filter by selected rows or current cell value
94
+ - **V** - ๐Ÿ”ง Filter by Polars expression
95
+
96
+ ## โœ๏ธ Edit & Modify
97
+ - **e** - โœ๏ธ Edit current cell
98
+ - **d** - ๐Ÿ—‘๏ธ Delete current row
99
+ - **-** - โŒ Delete current column
100
+
101
+ ## ๐ŸŽฏ Reorder
102
+ - **Shift+โ†‘โ†“** - โฌ†๏ธโฌ‡๏ธ Move row up/down
103
+ - **Shift+โ†โ†’** - โฌ…๏ธโžก๏ธ Move column left/right
104
+
105
+ ## ๐Ÿ’พ Data Management
106
+ - **f** - ๐Ÿ“Œ Freeze rows/columns
107
+ - **c** - ๐Ÿ“‹ Copy cell to clipboard
108
+ - **Ctrl+S** - ๐Ÿ’พ Save current tabto file
109
+ - **u** - โ†ฉ๏ธ Undo last action
110
+ - **U** - ๐Ÿ”„ Reset to original data
111
+
112
+ *Use `?` to see app-level controls*
113
+ """).strip()
114
+
115
+ def __init__(
116
+ self,
117
+ df: pl.DataFrame,
118
+ filename: str = "",
119
+ tabname: str = "",
120
+ **kwargs,
121
+ ):
122
+ """Initialize the DataFrameTable with a dataframe and manage all state.
123
+
124
+ Args:
125
+ df: The Polars DataFrame to display
126
+ filename: Optional filename of the source CSV
127
+ kwargs: Additional keyword arguments for DataTable
128
+ """
129
+ super().__init__(**kwargs)
130
+
131
+ # DataFrame state
132
+ self.dataframe = df # Original dataframe
133
+ self.df = df # Internal/working dataframe
134
+ self.filename = filename # Current filename
135
+ self.tabname = tabname or Path(filename).stem # Current tab name
136
+
137
+ # Pagination & Loading
138
+ self.loaded_rows = 0 # Track how many rows are currently loaded
139
+
140
+ # State tracking (all 0-based indexing)
141
+ self.sorted_columns: dict[str, bool] = {} # col_name -> descending
142
+ self.selected_rows: list[bool] = [False] * len(df) # Track selected rows
143
+ self.visible_rows: list[bool] = [True] * len(
144
+ df
145
+ ) # Track visible rows (for filtering)
146
+
147
+ # Freezing
148
+ self.fixed_rows = 0 # Number of fixed rows
149
+ self.fixed_columns = 0 # Number of fixed columns
150
+
151
+ # History stack for undo/redo
152
+ self.histories: deque[History] = deque()
153
+
154
+ # Pending filename for save operations
155
+ self._pending_filename = ""
156
+
157
+ @property
158
+ def cursor_key(self) -> CellKey:
159
+ """Get the current cursor position as a CellKey."""
160
+ return self.coordinate_to_cell_key(self.cursor_coordinate)
161
+
162
+ @property
163
+ def cursor_row_key(self) -> RowKey:
164
+ """Get the current cursor row as a CellKey."""
165
+ return self.cursor_key.row_key
166
+
167
+ @property
168
+ def cursor_column_key(self) -> ColumnKey:
169
+ """Get the current cursor column as a ColumnKey."""
170
+ return self.cursor_key.column_key
171
+
172
+ @property
173
+ def cursor_row_index(self) -> int:
174
+ """Get the current cursor row index (0-based)."""
175
+ return int(self.cursor_row_key.value) - 1
176
+
177
+ def on_mount(self) -> None:
178
+ """Initialize table display when widget is mounted."""
179
+ self._setup_table()
180
+
181
+ def _should_highlight(
182
+ self,
183
+ cursor: Coordinate,
184
+ target_cell: Coordinate,
185
+ type_of_cursor: CursorType,
186
+ ) -> bool:
187
+ """Determine if the given cell should be highlighted because of the cursor.
188
+
189
+ In "cell" mode, also highlights the row and column headers. In "row" and "column"
190
+ modes, highlights the entire row or column respectively.
191
+
192
+ Args:
193
+ cursor: The current position of the cursor.
194
+ target_cell: The cell we're checking for the need to highlight.
195
+ type_of_cursor: The type of cursor that is currently active.
196
+
197
+ Returns:
198
+ Whether or not the given cell should be highlighted.
199
+ """
200
+ if type_of_cursor == "cell":
201
+ # Return true if the cursor is over the target cell
202
+ # This includes the case where the cursor is in the same row or column
203
+ return (
204
+ cursor == target_cell
205
+ or (target_cell.row == -1 and target_cell.column == cursor.column)
206
+ or (target_cell.column == -1 and target_cell.row == cursor.row)
207
+ )
208
+ elif type_of_cursor == "row":
209
+ cursor_row, _ = cursor
210
+ cell_row, _ = target_cell
211
+ return cursor_row == cell_row
212
+ elif type_of_cursor == "column":
213
+ _, cursor_column = cursor
214
+ _, cell_column = target_cell
215
+ return cursor_column == cell_column
216
+ else:
217
+ return False
218
+
219
+ def watch_cursor_coordinate(
220
+ self, old_coordinate: Coordinate, new_coordinate: Coordinate
221
+ ) -> None:
222
+ """Refresh highlighting when cursor coordinate changes.
223
+
224
+ This explicitly refreshes cells that need to change their highlight state
225
+ to fix the delay issue with column label highlighting. Also emits CellSelected
226
+ message when cursor type is "cell" for keyboard navigation only (mouse clicks
227
+ already trigger the parent class's CellSelected message).
228
+ """
229
+ if old_coordinate != new_coordinate:
230
+ # Emit CellSelected message for cell cursor type (keyboard navigation only)
231
+ # Only emit if this is from keyboard navigation (flag is True when from keyboard)
232
+ if self.cursor_type == "cell" and getattr(self, "_from_keyboard", False):
233
+ self._from_keyboard = False # Reset flag
234
+ try:
235
+ self._post_selected_message()
236
+ except CellDoesNotExist:
237
+ # This could happen when after calling clear(), the old coordinate is invalid
238
+ pass
239
+
240
+ # For cell cursor type, refresh old and new row/column headers
241
+ if self.cursor_type == "cell":
242
+ old_row, old_col = old_coordinate
243
+ new_row, new_col = new_coordinate
244
+
245
+ # Refresh entire column (not just header) to ensure proper highlighting
246
+ self.refresh_column(old_col)
247
+ self.refresh_column(new_col)
248
+
249
+ # Refresh entire row (not just header) to ensure proper highlighting
250
+ self.refresh_row(old_row)
251
+ self.refresh_row(new_row)
252
+ elif self.cursor_type == "row":
253
+ self.refresh_row(old_coordinate.row)
254
+ self.refresh_row(new_coordinate.row)
255
+ elif self.cursor_type == "column":
256
+ self.refresh_column(old_coordinate.column)
257
+ self.refresh_column(new_coordinate.column)
258
+
259
+ # Handle scrolling if needed
260
+ if self._require_update_dimensions:
261
+ self.call_after_refresh(self._scroll_cursor_into_view)
262
+ else:
263
+ self._scroll_cursor_into_view()
264
+
265
+ def on_key(self, event) -> None:
266
+ """Handle keyboard events for table operations and navigation."""
267
+ if event.key == "g":
268
+ # Jump to top
269
+ self.move_cursor(row=0)
270
+ elif event.key == "G":
271
+ # Load all remaining rows before jumping to end
272
+ self._load_rows()
273
+ self.move_cursor(row=self.row_count - 1)
274
+ elif event.key in ("pagedown", "down"):
275
+ # Let the table handle the navigation first
276
+ self._check_and_load_more()
277
+ elif event.key == "enter":
278
+ # Open row detail modal
279
+ self._view_row_detail()
280
+ elif event.key == "minus":
281
+ # Remove the current column
282
+ self._delete_column()
283
+ elif event.key == "left_square_bracket": # '['
284
+ # Sort by current column in ascending order
285
+ self._sort_by_column(descending=False)
286
+ elif event.key == "right_square_bracket": # ']'
287
+ # Sort by current column in descending order
288
+ self._sort_by_column(descending=True)
289
+ elif event.key == "ctrl+s":
290
+ # Save dataframe to CSV
291
+ self._save_to_file()
292
+ elif event.key == "F": # shift+f
293
+ # Open frequency modal for current column
294
+ self._show_frequency()
295
+ elif event.key == "v":
296
+ # Filter by current cell value
297
+ self._filter_rows()
298
+ elif event.key == "V": # shift+v
299
+ # Open filter screen for current column
300
+ self._open_filter_screen()
301
+ elif event.key == "e":
302
+ # Open edit modal for current cell
303
+ self._edit_cell()
304
+ elif event.key == "backslash": # '\' key
305
+ # Search with current cell value and highlight matched rows
306
+ self._search_with_cell_value()
307
+ elif event.key == "vertical_line": # '|' key
308
+ # Open search modal for current column
309
+ self._search_column()
310
+ elif event.key == "slash": # '/' key
311
+ # Open search modal for all columns
312
+ self._search_column(all_columns=True)
313
+ elif event.key == "s":
314
+ # Toggle selection for current row
315
+ self._toggle_selected_rows(current_row=True)
316
+ elif event.key == "t":
317
+ # Toggle selected rows highlighting
318
+ self._toggle_selected_rows()
319
+ elif event.key == "quotation_mark": # '"' key
320
+ # Display selected rows only
321
+ self._filter_selected_rows()
322
+ elif event.key == "d":
323
+ # Delete the current row
324
+ self._delete_row()
325
+ elif event.key == "u":
326
+ # Undo last action
327
+ self._undo()
328
+ elif event.key == "U":
329
+ # Undo all changes and restore original dataframe
330
+ self._setup_table(reset=True)
331
+ self.app.notify("Restored original display", title="Reset")
332
+ elif event.key == "shift+left": # shift + left arrow
333
+ # Move current column to the left
334
+ self._move_column("left")
335
+ elif event.key == "shift+right": # shift + right arrow
336
+ # Move current column to the right
337
+ self._move_column("right")
338
+ elif event.key == "shift+up": # shift + up arrow
339
+ # Move current row up
340
+ self._move_row("up")
341
+ elif event.key == "shift+down": # shift + down arrow
342
+ # Move current row down
343
+ self._move_row("down")
344
+ elif event.key == "T": # shift+t
345
+ # Clear all selected rows
346
+ self._clear_selected_rows()
347
+ elif event.key == "C": # shift+c
348
+ # Cycle through cursor types
349
+ self._cycle_cursor_type()
350
+ elif event.key == "f":
351
+ # Open pin screen to set fixed rows and columns
352
+ self._open_freeze_screen()
353
+
354
+ def on_mouse_scroll_down(self, event) -> None:
355
+ """Load more rows when scrolling down with mouse."""
356
+ self._check_and_load_more()
357
+
358
+ # Setup & Loading
359
+ def _setup_table(self, reset: bool = False) -> None:
360
+ """Setup the table for display."""
361
+ # Reset to original dataframe
362
+ if reset:
363
+ self.df = self.dataframe
364
+ self.loaded_rows = 0
365
+ self.sorted_columns = {}
366
+ self.selected_rows = [False] * len(self.df)
367
+ self.visible_rows = [True] * len(self.df)
368
+ self.fixed_rows = 0
369
+ self.fixed_columns = 0
370
+
371
+ # Lazy load up to INITIAL_BATCH_SIZE visible rows
372
+ stop, visible_count = len(self.df), 0
373
+ for row_idx, visible in enumerate(self.visible_rows):
374
+ if not visible:
375
+ continue
376
+ visible_count += 1
377
+ if visible_count >= INITIAL_BATCH_SIZE:
378
+ stop = row_idx + 1
379
+ break
380
+
381
+ self._setup_columns()
382
+ self._load_rows(stop)
383
+ self._highlight_rows()
384
+
385
+ # Restore cursor position
386
+ row_idx, col_idx = self.cursor_coordinate
387
+ if row_idx < len(self.rows) and col_idx < len(self.columns):
388
+ self.move_cursor(row=row_idx, column=col_idx)
389
+
390
+ def _setup_columns(self) -> None:
391
+ """Clear table and setup columns."""
392
+ self.loaded_rows = 0
393
+ self.clear(columns=True)
394
+ self.show_row_labels = True
395
+
396
+ # Add columns with justified headers
397
+ for col, dtype in zip(self.df.columns, self.df.dtypes):
398
+ for idx, c in enumerate(self.sorted_columns, 1):
399
+ if c == col:
400
+ # Add sort indicator to column header
401
+ descending = self.sorted_columns[col]
402
+ sort_indicator = (
403
+ f" โ–ผ{SUBSCRIPT_DIGITS.get(idx, '')}"
404
+ if descending
405
+ else f" โ–ฒ{SUBSCRIPT_DIGITS.get(idx, '')}"
406
+ )
407
+ header_text = col + sort_indicator
408
+ self.add_column(
409
+ Text(header_text, justify=DtypeConfig(dtype).justify), key=col
410
+ )
411
+
412
+ break
413
+ else: # No break occurred, so column is not sorted
414
+ self.add_column(Text(col, justify=DtypeConfig(dtype).justify), key=col)
415
+
416
+ def _check_and_load_more(self) -> None:
417
+ """Check if we need to load more rows and load them."""
418
+ # If we've loaded everything, no need to check
419
+ if self.loaded_rows >= len(self.df):
420
+ return
421
+
422
+ visible_row_count = self.size.height - self.header_height
423
+ bottom_visible_row = self.scroll_y + visible_row_count
424
+
425
+ # If visible area is close to the end of loaded rows, load more
426
+ if bottom_visible_row >= self.loaded_rows - 10:
427
+ self._load_rows(self.loaded_rows + BATCH_SIZE)
428
+
429
+ def _load_rows(self, stop: int | None = None) -> None:
430
+ """Load a batch of rows into the table.
431
+
432
+ Args:
433
+ stop: Stop loading rows when this index is reached. If None, load until the end of the dataframe.
434
+ """
435
+ if stop is None or stop > len(self.df):
436
+ stop = len(self.df)
437
+
438
+ if stop <= self.loaded_rows:
439
+ return
440
+
441
+ start = self.loaded_rows
442
+ df_slice = self.df.slice(start, stop - start)
443
+
444
+ for row_idx, row in enumerate(df_slice.rows(), start):
445
+ if not self.visible_rows[row_idx]:
446
+ continue # Skip hidden rows
447
+ vals, dtypes = [], []
448
+ for val, dtype in zip(row, self.df.dtypes):
449
+ vals.append(val)
450
+ dtypes.append(dtype)
451
+ formatted_row = _format_row(vals, dtypes)
452
+ # Always add labels so they can be shown/hidden via CSS
453
+ self.add_row(*formatted_row, key=str(row_idx + 1), label=str(row_idx + 1))
454
+
455
+ # Update loaded rows count
456
+ self.loaded_rows = stop
457
+
458
+ self.app.notify(
459
+ f"Loaded [$accent]{self.loaded_rows}/{len(self.df)}[/] rows from [on $primary]{self.tabname}[/]",
460
+ title="Load",
461
+ )
462
+
463
+ def _highlight_rows(self, clear: bool = False) -> None:
464
+ """Update all rows, highlighting selected ones in red and restoring others to default.
465
+
466
+ Args:
467
+ clear: If True, clear all highlights.
468
+ """
469
+ if True not in self.selected_rows:
470
+ return
471
+
472
+ if clear:
473
+ self.selected_rows = [False] * len(self.df)
474
+
475
+ # Ensure all highlighted rows are loaded
476
+ stop = _rindex(self.selected_rows, True) + 1
477
+ self._load_rows(stop)
478
+
479
+ # Update all rows based on selected state
480
+ for row in self.ordered_rows:
481
+ row_idx = int(row.key.value) - 1 # Convert to 0-based index
482
+ is_selected = self.selected_rows[row_idx]
483
+
484
+ # Update all cells in this row
485
+ for col_idx, col in enumerate(self.ordered_columns):
486
+ cell_text: Text = self.get_cell(row.key, col.key)
487
+ dtype = self.df.dtypes[col_idx]
488
+
489
+ # Get style config based on dtype
490
+ dc = DtypeConfig(dtype)
491
+
492
+ # Use red for selected rows, default style for others
493
+ style = "red" if is_selected else dc.style
494
+ cell_text.style = style
495
+
496
+ # Update the cell in the table
497
+ self.update_cell(row.key, col.key, cell_text)
498
+
499
+ # History & Undo
500
+ def _add_history(self, description: str) -> None:
501
+ """Add the current state to the history stack.
502
+
503
+ Args:
504
+ description: Description of the action for this history entry.
505
+ """
506
+ history = History(
507
+ description=description,
508
+ df=self.df,
509
+ filename=self.filename,
510
+ loaded_rows=self.loaded_rows,
511
+ sorted_columns=self.sorted_columns.copy(),
512
+ selected_rows=self.selected_rows.copy(),
513
+ visible_rows=self.visible_rows.copy(),
514
+ fixed_rows=self.fixed_rows,
515
+ fixed_columns=self.fixed_columns,
516
+ cursor_coordinate=self.cursor_coordinate,
517
+ )
518
+ self.histories.append(history)
519
+
520
+ def _undo(self) -> None:
521
+ """Undo the last action."""
522
+ if not self.histories:
523
+ self.app.notify("No actions to undo", title="Undo", severity="warning")
524
+ return
525
+
526
+ history = self.histories.pop()
527
+
528
+ # Restore state
529
+ self.df = history.df
530
+ self.filename = history.filename
531
+ self.loaded_rows = history.loaded_rows
532
+ self.sorted_columns = history.sorted_columns.copy()
533
+ self.selected_rows = history.selected_rows.copy()
534
+ self.visible_rows = history.visible_rows.copy()
535
+ self.fixed_rows = history.fixed_rows
536
+ self.fixed_columns = history.fixed_columns
537
+ self.cursor_coordinate = history.cursor_coordinate
538
+
539
+ # Recreate the table for display
540
+ self._setup_table()
541
+
542
+ self.app.notify(f"Reverted: {history.description}", title="Undo")
543
+
544
+ # View
545
+ def _view_row_detail(self) -> None:
546
+ """Open a modal screen to view the selected row's details."""
547
+ row_idx = self.cursor_row
548
+ if row_idx >= len(self.df):
549
+ return
550
+
551
+ # Push the modal screen
552
+ self.app.push_screen(RowDetailScreen(row_idx, self))
553
+
554
+ def _show_frequency(self) -> None:
555
+ """Show frequency distribution for the current column."""
556
+ col_idx = self.cursor_column
557
+ if col_idx >= len(self.df.columns):
558
+ return
559
+
560
+ # Push the frequency modal screen
561
+ self.app.push_screen(FrequencyScreen(col_idx, self))
562
+
563
+ def _open_freeze_screen(self) -> None:
564
+ """Open the freeze screen to set fixed rows and columns."""
565
+ self.app.push_screen(FreezeScreen(), callback=self._do_freeze)
566
+
567
+ def _do_freeze(self, result: tuple[int, int] | None) -> None:
568
+ """Handle result from FreezeScreen.
569
+
570
+ Args:
571
+ result: Tuple of (fixed_rows, fixed_columns) or None if cancelled.
572
+ """
573
+ if result is None:
574
+ return
575
+
576
+ fixed_rows, fixed_columns = result
577
+
578
+ # Add to history
579
+ self._add_history(
580
+ f"Pinned [$accent]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns"
581
+ )
582
+
583
+ # Apply the pin settings to the table
584
+ if fixed_rows > 0:
585
+ self.fixed_rows = fixed_rows
586
+ if fixed_columns > 0:
587
+ self.fixed_columns = fixed_columns
588
+
589
+ self.app.notify(
590
+ f"Pinned [$accent]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns",
591
+ title="Pin",
592
+ )
593
+
594
+ # Delete & Move
595
+ def _delete_column(self) -> None:
596
+ """Remove the currently selected column from the table."""
597
+ col_idx = self.cursor_column
598
+ if col_idx >= len(self.df.columns):
599
+ return
600
+
601
+ # Get the column name to remove
602
+ col_to_remove = self.df.columns[col_idx]
603
+
604
+ # Add to history
605
+ self._add_history(f"Removed column [on $primary]{col_to_remove}[/]")
606
+
607
+ # Remove the column from the table display using the column name as key
608
+ self.remove_column(col_to_remove)
609
+
610
+ # Move cursor left if we deleted the last column
611
+ if col_idx >= len(self.columns):
612
+ self.move_cursor(column=len(self.columns) - 1)
613
+
614
+ # Remove from sorted columns if present
615
+ if col_to_remove in self.sorted_columns:
616
+ del self.sorted_columns[col_to_remove]
617
+
618
+ # Remove from dataframe
619
+ self.df = self.df.drop(col_to_remove)
620
+
621
+ self.app.notify(
622
+ f"Removed column [on $primary]{col_to_remove}[/] from display",
623
+ title="Column",
624
+ )
625
+
626
+ def _delete_row(self) -> None:
627
+ """Delete rows from the table and dataframe.
628
+
629
+ Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
630
+ """
631
+ old_count = len(self.df)
632
+ filter_expr = [True] * len(self.df)
633
+
634
+ # Delete all selected rows
635
+ if selected_count := self.selected_rows.count(True):
636
+ history_desc = f"Deleted {selected_count} selected row(s)"
637
+
638
+ for i, is_selected in enumerate(self.selected_rows):
639
+ if is_selected:
640
+ filter_expr[i] = False
641
+ # Delete the row at the cursor
642
+ else:
643
+ row_key = self.cursor_row_key
644
+ i = int(row_key.value) - 1 # Convert to 0-based index
645
+
646
+ filter_expr[i] = False
647
+ history_desc = f"Deleted row [on $primary]{row_key.value}[/]"
648
+
649
+ # Add to history
650
+ self._add_history(history_desc)
651
+
652
+ # Apply the filter to remove rows
653
+ df = self.df.with_row_index("__rid__").filter(filter_expr)
654
+ self.df = df.drop("__rid__")
655
+
656
+ # Update selected and visible rows tracking
657
+ old_row_indices = set(df["__rid__"].to_list())
658
+ self.selected_rows = [
659
+ selected
660
+ for i, selected in enumerate(self.selected_rows)
661
+ if i in old_row_indices
662
+ ]
663
+ self.visible_rows = [
664
+ visible
665
+ for i, visible in enumerate(self.visible_rows)
666
+ if i in old_row_indices
667
+ ]
668
+
669
+ # Recreate the table display
670
+ self._setup_table()
671
+
672
+ deleted_count = old_count - len(self.df)
673
+ self.app.notify(f"Deleted {deleted_count} row(s)", title="Delete")
674
+
675
+ def _move_column(self, direction: str) -> None:
676
+ """Move the current column left or right.
677
+
678
+ Args:
679
+ direction: "left" to move left, "right" to move right.
680
+ """
681
+ row_idx, col_idx = self.cursor_coordinate
682
+ col_key = self.cursor_column_key
683
+
684
+ # Validate move is possible
685
+ if direction == "left":
686
+ if col_idx <= 0:
687
+ self.app.notify(
688
+ "Cannot move column left", title="Move", severity="warning"
689
+ )
690
+ return
691
+ swap_idx = col_idx - 1
692
+ elif direction == "right":
693
+ if col_idx >= len(self.columns) - 1:
694
+ self.app.notify(
695
+ "Cannot move column right", title="Move", severity="warning"
696
+ )
697
+ return
698
+ swap_idx = col_idx + 1
699
+
700
+ # Get column names to swap
701
+ col_name = self.df.columns[col_idx]
702
+ swap_name = self.df.columns[swap_idx]
703
+
704
+ # Add to history
705
+ self._add_history(
706
+ f"Moved column [on $primary]{col_name}[/] {direction} (swapped with [on $primary]{swap_name}[/])"
707
+ )
708
+
709
+ # Swap columns in the table's internal column locations
710
+ self.check_idle()
711
+ swap_key = self.df.columns[swap_idx] # str as column key
712
+
713
+ (
714
+ self._column_locations[col_key],
715
+ self._column_locations[swap_key],
716
+ ) = (
717
+ self._column_locations.get(swap_key),
718
+ self._column_locations.get(col_key),
719
+ )
720
+
721
+ self._update_count += 1
722
+ self.refresh()
723
+
724
+ # Restore cursor position on the moved column
725
+ self.move_cursor(row=row_idx, column=swap_idx)
726
+
727
+ # Update the dataframe column order
728
+ cols = list(self.df.columns)
729
+ cols[col_idx], cols[swap_idx] = cols[swap_idx], cols[col_idx]
730
+ self.df = self.df.select(cols)
731
+
732
+ self.app.notify(
733
+ f"Moved column [on $primary]{col_name}[/] {direction}",
734
+ title="Move",
735
+ )
736
+
737
+ def _move_row(self, direction: str) -> None:
738
+ """Move the current row up or down.
739
+
740
+ Args:
741
+ direction: "up" to move up, "down" to move down.
742
+ """
743
+ row_idx, col_idx = self.cursor_coordinate
744
+
745
+ # Validate move is possible
746
+ if direction == "up":
747
+ if row_idx <= 0:
748
+ self.app.notify("Cannot move row up", title="Move", severity="warning")
749
+ return
750
+ swap_idx = row_idx - 1
751
+ elif direction == "down":
752
+ if row_idx >= len(self.rows) - 1:
753
+ self.app.notify(
754
+ "Cannot move row down", title="Move", severity="warning"
755
+ )
756
+ return
757
+ swap_idx = row_idx + 1
758
+ else:
759
+ self.app.notify(
760
+ f"Invalid direction: {direction}", title="Move", severity="error"
761
+ )
762
+ return
763
+
764
+ row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
765
+ swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
766
+
767
+ # Add to history
768
+ self._add_history(
769
+ f"Moved row [on $primary]{row_key.value}[/] {direction} (swapped with row [on $primary]{swap_key.value}[/])"
770
+ )
771
+
772
+ # Swap rows in the table's internal row locations
773
+ self.check_idle()
774
+
775
+ (
776
+ self._row_locations[row_key],
777
+ self._row_locations[swap_key],
778
+ ) = (
779
+ self._row_locations.get(swap_key),
780
+ self._row_locations.get(row_key),
781
+ )
782
+
783
+ self._update_count += 1
784
+ self.refresh()
785
+
786
+ # Restore cursor position on the moved row
787
+ self.move_cursor(row=swap_idx, column=col_idx)
788
+
789
+ # Swap rows in the dataframe
790
+ rid = int(row_key.value) - 1 # 0-based
791
+ swap_rid = int(swap_key.value) - 1 # 0-based
792
+ first, second = sorted([rid, swap_rid])
793
+
794
+ self.df = pl.concat(
795
+ [
796
+ self.df.slice(0, first),
797
+ self.df.slice(second, 1),
798
+ self.df.slice(first + 1, second - first - 1),
799
+ self.df.slice(first, 1),
800
+ self.df.slice(second + 1),
801
+ ]
802
+ )
803
+
804
+ self.app.notify(
805
+ f"Moved row [on $primary]{row_key.value}[/] {direction}", title="Move"
806
+ )
807
+
808
+ # Sort
809
+ def _sort_by_column(self, descending: bool = False) -> None:
810
+ """Sort by the currently selected column.
811
+
812
+ Supports multi-column sorting:
813
+ - First press on a column: sort by that column only
814
+ - Subsequent presses on other columns: add to sort order
815
+
816
+ Args:
817
+ descending: If True, sort in descending order. If False, ascending order.
818
+ """
819
+ col_idx = self.cursor_column
820
+ if col_idx >= len(self.df.columns):
821
+ return
822
+
823
+ col_to_sort = self.df.columns[col_idx]
824
+
825
+ # Check if this column is already in the sort keys
826
+ old_desc = self.sorted_columns.get(col_to_sort)
827
+ if old_desc == descending:
828
+ # Same direction - remove this column from sort
829
+ self.app.notify(
830
+ f"Already sorted by [on $primary]{col_to_sort}[/] ({'desc' if descending else 'asc'})",
831
+ title="Sort",
832
+ severity="warning",
833
+ )
834
+ return
835
+
836
+ # Add to history
837
+ self._add_history(f"Sorted on column [on $primary]{col_to_sort}[/]")
838
+ if old_desc is None:
839
+ # Add new column to sort
840
+ self.sorted_columns[col_to_sort] = descending
841
+ else:
842
+ # Toggle direction and move to end of sort order
843
+ del self.sorted_columns[col_to_sort]
844
+ self.sorted_columns[col_to_sort] = descending
845
+
846
+ # Apply multi-column sort
847
+ sort_cols = list(self.sorted_columns.keys())
848
+ descending_flags = list(self.sorted_columns.values())
849
+ df_sorted = self.df.with_row_index("__rid__").sort(
850
+ sort_cols, descending=descending_flags, nulls_last=True
851
+ )
852
+
853
+ # Updated selected_rows and visible_rows to match new order
854
+ old_row_indices = df_sorted["__rid__"].to_list()
855
+ self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
856
+ self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
857
+
858
+ # Update the dataframe
859
+ self.df = df_sorted.drop("__rid__")
860
+
861
+ # Recreate the table for display
862
+ self._setup_table()
863
+
864
+ # Restore cursor position on the sorted column
865
+ self.move_cursor(column=col_idx, row=0)
866
+
867
+ # Edit
868
+ def _edit_cell(self) -> None:
869
+ """Open modal to edit the selected cell."""
870
+ row_key = self.cursor_row_key
871
+ row_idx = int(row_key.value) - 1 # Convert to 0-based
872
+ col_idx = self.cursor_column
873
+
874
+ if row_idx >= len(self.df) or col_idx >= len(self.df.columns):
875
+ return
876
+ col_name = self.df.columns[col_idx]
877
+
878
+ # Save current state to history
879
+ self._add_history(f"Edited cell [on $primary]({row_idx + 1}, {col_name})[/]")
880
+
881
+ # Push the edit modal screen
882
+ self.app.push_screen(
883
+ EditCellScreen(row_key, col_idx, self.df),
884
+ callback=self._do_edit_cell,
885
+ )
886
+
887
+ def _do_edit_cell(self, result) -> None:
888
+ """Handle result from EditCellScreen."""
889
+ if result is None:
890
+ return
891
+
892
+ row_key, col_idx, new_value = result
893
+ row_idx = int(row_key.value) - 1 # Convert to 0-based
894
+ col_name = self.df.columns[col_idx]
895
+
896
+ # Update the cell in the dataframe
897
+ try:
898
+ self.df = self.df.with_columns(
899
+ pl.when(pl.arange(0, len(self.df)) == row_idx)
900
+ .then(pl.lit(new_value))
901
+ .otherwise(pl.col(col_name))
902
+ .alias(col_name)
903
+ )
904
+
905
+ # Update the display
906
+ cell_value = self.df.item(row_idx, col_idx)
907
+ if cell_value is None:
908
+ cell_value = "-"
909
+ dtype = self.df.dtypes[col_idx]
910
+ dc = DtypeConfig(dtype)
911
+ formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
912
+
913
+ row_key = str(row_idx + 1)
914
+ col_key = str(col_name)
915
+ self.update_cell(row_key, col_key, formatted_value)
916
+
917
+ self.app.notify(
918
+ f"Cell updated to [on $primary]{cell_value}[/]", title="Edit"
919
+ )
920
+ except Exception as e:
921
+ self.app.notify(
922
+ f"Failed to update cell: {str(e)}", title="Edit", severity="error"
923
+ )
924
+ raise e
925
+
926
+ def _copy_cell(self) -> None:
927
+ """Copy the current cell to clipboard."""
928
+ import subprocess
929
+
930
+ row_idx = self.cursor_row
931
+ col_idx = self.cursor_column
932
+
933
+ try:
934
+ cell_str = str(self.df.item(row_idx, col_idx))
935
+ subprocess.run(
936
+ [
937
+ "pbcopy" if sys.platform == "darwin" else "xclip",
938
+ "-selection",
939
+ "clipboard",
940
+ ],
941
+ input=cell_str,
942
+ text=True,
943
+ )
944
+ self.app.notify(f"Copied: {cell_str[:50]}", title="Clipboard")
945
+ except (FileNotFoundError, IndexError):
946
+ self.app.notify("Error copying cell", title="Clipboard", severity="error")
947
+
948
+ def _search_column(self, all_columns: bool = False) -> None:
949
+ """Open modal to search in the selected column."""
950
+ row_idx, col_idx = self.cursor_coordinate
951
+ if col_idx >= len(self.df.columns):
952
+ self.app.notify("Invalid column selected", title="Search", severity="error")
953
+ return
954
+
955
+ col_name = None if all_columns else self.df.columns[col_idx]
956
+ col_dtype = self.df.dtypes[col_idx]
957
+
958
+ # Get current cell value as default search term
959
+ term = self.df.item(row_idx, col_idx)
960
+ term = "NULL" if term is None else str(term)
961
+
962
+ # Push the search modal screen
963
+ self.app.push_screen(
964
+ SearchScreen(term, col_dtype, col_name),
965
+ callback=self._do_search_column,
966
+ )
967
+
968
+ def _do_search_column(self, result) -> None:
969
+ """Handle result from SearchScreen."""
970
+ if result is None:
971
+ return
972
+
973
+ term, col_dtype, col_name = result
974
+ if col_name:
975
+ # Perform search in the specified column
976
+ self._search_single_column(term, col_dtype, col_name)
977
+ else:
978
+ # Perform search in all columns
979
+ self._search_all_columns(term)
980
+
981
+ def _search_single_column(
982
+ self, term: str, col_dtype: pl.DataType, col_name: str
983
+ ) -> None:
984
+ """Search for a term in a single column and update selected rows.
985
+
986
+ Args:
987
+ term: The search term to find
988
+ col_dtype: The data type of the column
989
+ col_name: The name of the column to search in
990
+ """
991
+ df_rid = self.df.with_row_index("__rid__")
992
+ if False in self.visible_rows:
993
+ df_rid = df_rid.filter(self.visible_rows)
994
+
995
+ # Perform type-aware search based on column dtype
996
+ if term.lower() == "null":
997
+ masks = df_rid[col_name].is_null()
998
+ elif col_dtype == pl.String:
999
+ masks = df_rid[col_name].str.contains(term)
1000
+ elif col_dtype == pl.Boolean:
1001
+ masks = df_rid[col_name] == BOOLS[term.lower()]
1002
+ elif col_dtype in (pl.Int32, pl.Int64):
1003
+ masks = df_rid[col_name] == int(term)
1004
+ elif col_dtype in (pl.Float32, pl.Float64):
1005
+ masks = df_rid[col_name] == float(term)
1006
+ else:
1007
+ self.app.notify(
1008
+ f"Search not yet supported for column type: [on $primary]{col_dtype}[/]",
1009
+ title="Search",
1010
+ severity="warning",
1011
+ )
1012
+ return
1013
+
1014
+ # Apply filter to get matched row indices
1015
+ matches = set(df_rid.filter(masks)["__rid__"].to_list())
1016
+
1017
+ match_count = len(matches)
1018
+ if match_count == 0:
1019
+ self.app.notify(
1020
+ f"No matches found for: [on $primary]{term}[/]",
1021
+ title="Search",
1022
+ severity="warning",
1023
+ )
1024
+ return
1025
+
1026
+ # Add to history
1027
+ self._add_history(
1028
+ f"Searched and highlighted [on $primary]{term}[/] in column [on $primary]{col_name}[/]"
1029
+ )
1030
+
1031
+ # Update selected rows to include new matches
1032
+ for m in matches:
1033
+ self.selected_rows[m] = True
1034
+
1035
+ # Highlight selected rows
1036
+ self._highlight_rows()
1037
+
1038
+ self.app.notify(
1039
+ f"Found [on $primary]{match_count}[/] matches for [on $primary]{term}[/]",
1040
+ title="Search",
1041
+ )
1042
+
1043
+ def _search_all_columns(self, term: str) -> None:
1044
+ """Search for a term across all columns and highlight matching cells.
1045
+
1046
+ Args:
1047
+ term: The search term to find
1048
+ """
1049
+ df_rid = self.df.with_row_index("__rid__")
1050
+ if False in self.visible_rows:
1051
+ df_rid = df_rid.filter(self.visible_rows)
1052
+
1053
+ matches: dict[int, set[int]] = {}
1054
+ match_count = 0
1055
+ if term.lower() == "null":
1056
+ # Search for NULL values across all columns
1057
+ for col_idx, col in enumerate(df_rid.columns[1:]):
1058
+ masks = df_rid[col].is_null()
1059
+ matched_rids = set(df_rid.filter(masks)["__rid__"].to_list())
1060
+ for rid in matched_rids:
1061
+ if rid not in matches:
1062
+ matches[rid] = set()
1063
+ matches[rid].add(col_idx)
1064
+ match_count += 1
1065
+ else:
1066
+ # Search for the term in all columns
1067
+ for col_idx, col in enumerate(df_rid.columns[1:]):
1068
+ col_series = df_rid[col].cast(pl.String)
1069
+ masks = col_series.str.contains(term)
1070
+ matched_rids = set(df_rid.filter(masks)["__rid__"].to_list())
1071
+ for rid in matched_rids:
1072
+ if rid not in matches:
1073
+ matches[rid] = set()
1074
+ matches[rid].add(col_idx)
1075
+ match_count += 1
1076
+
1077
+ if match_count == 0:
1078
+ self.app.notify(
1079
+ f"No matches found for: [on $primary]{term}[/] in any column",
1080
+ title="Global Search",
1081
+ severity="warning",
1082
+ )
1083
+ return
1084
+
1085
+ # Ensure all matching rows are loaded
1086
+ self._load_rows(max(matches.keys()) + 1)
1087
+
1088
+ # Add to history
1089
+ self._add_history(
1090
+ f"Searched and highlighted [on $primary]{term}[/] across all columns"
1091
+ )
1092
+
1093
+ # Highlight matching cells directly
1094
+ for row in self.ordered_rows:
1095
+ row_idx = int(row.key.value) - 1 # Convert to 0-based index
1096
+ if row_idx not in matches:
1097
+ continue
1098
+
1099
+ for col_idx in matches[row_idx]:
1100
+ row_key = row.key
1101
+ col_key = self.df.columns[col_idx]
1102
+
1103
+ cell_text: Text = self.get_cell(row_key, col_key)
1104
+ cell_text.style = "red"
1105
+
1106
+ # Update the cell in the table
1107
+ self.update_cell(row_key, col_key, cell_text)
1108
+
1109
+ self.app.notify(
1110
+ f"Found [$accent]{match_count}[/] matches for [on $primary]{term}[/] across all columns",
1111
+ title="Global Search",
1112
+ )
1113
+
1114
+ def _search_with_cell_value(self) -> None:
1115
+ """Search in the current column using the value of the currently selected cell."""
1116
+ row_key = self.cursor_row_key
1117
+ row_idx = int(row_key.value) - 1 # Convert to 0-based index
1118
+ col_idx = self.cursor_column
1119
+
1120
+ # Get the value of the currently selected cell
1121
+ term = self.df.item(row_idx, col_idx)
1122
+ term = "NULL" if term is None else str(term)
1123
+
1124
+ col_dtype = self.df.dtypes[col_idx]
1125
+ col_name = self.df.columns[col_idx]
1126
+ self._do_search_column((term, col_dtype, col_name))
1127
+
1128
+ def _toggle_selected_rows(self, current_row=False) -> None:
1129
+ """Toggle selected rows highlighting on/off."""
1130
+ # Save current state to history
1131
+ self._add_history("Toggled row selection")
1132
+
1133
+ # Select current row if no rows are currently selected
1134
+ if current_row:
1135
+ cursor_row_idx = int(self.cursor_row_key.value) - 1
1136
+ self.selected_rows[cursor_row_idx] = not self.selected_rows[cursor_row_idx]
1137
+ else:
1138
+ # Invert all selected rows
1139
+ self.selected_rows = [not match for match in self.selected_rows]
1140
+
1141
+ # Check if we're highlighting or un-highlighting
1142
+ if new_selected_count := self.selected_rows.count(True):
1143
+ self.app.notify(
1144
+ f"Toggled selection - now showing [$accent]{new_selected_count}[/] rows",
1145
+ title="Toggle",
1146
+ )
1147
+
1148
+ # Refresh the highlighting (also restores default styles for unselected rows)
1149
+ self._highlight_rows()
1150
+
1151
+ def _clear_selected_rows(self) -> None:
1152
+ """Clear all selected rows without removing them from the dataframe."""
1153
+ # Check if any rows are currently selected
1154
+ selected_count = self.selected_rows.count(True)
1155
+ if selected_count == 0:
1156
+ self.app.notify(
1157
+ "No rows selected to clear", title="Clear", severity="warning"
1158
+ )
1159
+ return
1160
+
1161
+ # Save current state to history
1162
+ self._add_history("Cleared all selected rows")
1163
+
1164
+ # Clear all selections and refresh highlighting
1165
+ self._highlight_rows(clear=True)
1166
+
1167
+ self.app.notify(
1168
+ f"Cleared [$accent]{selected_count}[/] selected rows", title="Clear"
1169
+ )
1170
+
1171
+ def _filter_selected_rows(self) -> None:
1172
+ """Display only the selected rows."""
1173
+ selected_count = self.selected_rows.count(True)
1174
+ if selected_count == 0:
1175
+ self.app.notify(
1176
+ "No rows selected to filter", title="Filter", severity="warning"
1177
+ )
1178
+ return
1179
+
1180
+ # Save current state to history
1181
+ self._add_history("Filtered to selected rows")
1182
+
1183
+ # Update dataframe to only include selected rows
1184
+ self.df = self.df.filter(self.selected_rows)
1185
+ self.selected_rows = [True] * len(self.df)
1186
+
1187
+ # Recreate the table for display
1188
+ self._setup_table()
1189
+
1190
+ self.app.notify(
1191
+ f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
1192
+ title="Filter",
1193
+ )
1194
+
1195
+ def _open_filter_screen(self) -> None:
1196
+ """Open the filter screen to enter a filter expression."""
1197
+ row_key = self.cursor_row_key
1198
+ row_idx = int(row_key.value) - 1 # Convert to 0-based index
1199
+ col_idx = self.cursor_column
1200
+
1201
+ cell_value = self.df.item(row_idx, col_idx)
1202
+ if self.df.dtypes[col_idx] == pl.String and cell_value is not None:
1203
+ cell_value = repr(cell_value)
1204
+
1205
+ self.app.push_screen(
1206
+ FilterScreen(
1207
+ self.df, current_col_idx=col_idx, current_cell_value=cell_value
1208
+ ),
1209
+ callback=self._do_filter,
1210
+ )
1211
+
1212
+ def _do_filter(self, result) -> None:
1213
+ """Handle result from FilterScreen.
1214
+
1215
+ Args:
1216
+ expression: The filter expression or None if cancelled.
1217
+ """
1218
+ if result is None:
1219
+ return
1220
+ expr, expr_str = result
1221
+
1222
+ # Add a row index column to track original row indices
1223
+ df_with_rid = self.df.with_row_index("__rid__")
1224
+
1225
+ # Apply existing visibility filter first
1226
+ if False in self.visible_rows:
1227
+ df_with_rid = df_with_rid.filter(self.visible_rows)
1228
+
1229
+ # Apply the filter expression
1230
+ df_filtered = df_with_rid.filter(expr)
1231
+
1232
+ matched_count = len(df_filtered)
1233
+ if not matched_count:
1234
+ self.app.notify(
1235
+ f"No rows match the expression: [on $primary]{expr_str}[/]",
1236
+ title="Filter",
1237
+ severity="warning",
1238
+ )
1239
+ return
1240
+
1241
+ # Add to history
1242
+ self._add_history(f"Filtered by expression [on $primary]{expr_str}[/]")
1243
+
1244
+ # Mark unfiltered rows as invisible and unselected
1245
+ filtered_row_indices = set(df_filtered["__rid__"].to_list())
1246
+ if filtered_row_indices:
1247
+ for rid in range(len(self.visible_rows)):
1248
+ if rid not in filtered_row_indices:
1249
+ self.visible_rows[rid] = False
1250
+ self.selected_rows[rid] = False
1251
+
1252
+ # Recreate the table for display
1253
+ self._setup_table()
1254
+
1255
+ self.app.notify(
1256
+ f"Filtered to [$accent]{matched_count}[/] matching rows",
1257
+ title="Filter",
1258
+ )
1259
+
1260
+ def _filter_rows(self) -> None:
1261
+ """Filter rows.
1262
+
1263
+ If there are selected rows, filter to those rows.
1264
+ Otherwise, filter based on the value of the currently selected cell.
1265
+ """
1266
+
1267
+ if True in self.selected_rows:
1268
+ expr = self.selected_rows
1269
+ expr_str = "selected rows"
1270
+ else:
1271
+ row_key = self.cursor_row_key
1272
+ row_idx = int(row_key.value) - 1 # Convert to 0-based index
1273
+ col_idx = self.cursor_column
1274
+
1275
+ cell_value = self.df.item(row_idx, col_idx)
1276
+
1277
+ if cell_value is None:
1278
+ expr = pl.col(self.df.columns[col_idx]).is_null()
1279
+ expr_str = "NULL"
1280
+ else:
1281
+ expr = pl.col(self.df.columns[col_idx]) == cell_value
1282
+ expr_str = f"$_ == {repr(cell_value)}"
1283
+
1284
+ self._do_filter((expr, expr_str))
1285
+
1286
+ def _cycle_cursor_type(self) -> None:
1287
+ """Cycle through cursor types: cell -> row -> column -> cell."""
1288
+ next_type = _next(CURSOR_TYPES, self.cursor_type)
1289
+ self.cursor_type = next_type
1290
+
1291
+ self.app.notify(
1292
+ f"Changed cursor type to [on $primary]{next_type}[/]", title="Cursor"
1293
+ )
1294
+
1295
+ def _toggle_row_labels(self) -> None:
1296
+ """Toggle row labels visibility."""
1297
+ self.show_row_labels = not self.show_row_labels
1298
+ status = "shown" if self.show_row_labels else "hidden"
1299
+ self.app.notify(f"Row labels {status}", title="Labels")
1300
+
1301
+ def _save_to_file(self) -> None:
1302
+ """Open save file dialog."""
1303
+ self.app.push_screen(
1304
+ SaveFileScreen(self.filename), callback=self._on_save_file_screen
1305
+ )
1306
+
1307
+ def _on_save_file_screen(
1308
+ self, filename: str | None, all_tabs: bool = False
1309
+ ) -> None:
1310
+ """Handle result from SaveFileScreen."""
1311
+ if filename is None:
1312
+ return
1313
+ filepath = Path(filename)
1314
+ ext = filepath.suffix.lower()
1315
+
1316
+ # Whether to save all tabs (for Excel files)
1317
+ self._all_tabs = all_tabs
1318
+
1319
+ # Check if file exists
1320
+ if filepath.exists():
1321
+ self._pending_filename = filename
1322
+ self.app.push_screen(
1323
+ ConfirmScreen("File already exists. Overwrite?"),
1324
+ callback=self._on_overwrite_screen,
1325
+ )
1326
+ elif ext in (".xlsx", ".xls"):
1327
+ self._do_save_excel(filename)
1328
+ else:
1329
+ self._do_save(filename)
1330
+
1331
+ def _on_overwrite_screen(self, should_overwrite: bool) -> None:
1332
+ """Handle result from ConfirmScreen."""
1333
+ if should_overwrite:
1334
+ self._do_save(self._pending_filename)
1335
+ else:
1336
+ # Go back to SaveFileScreen to allow user to enter a different name
1337
+ self.app.push_screen(
1338
+ SaveFileScreen(self._pending_filename),
1339
+ callback=self._on_save_file_screen,
1340
+ )
1341
+
1342
+ def _do_save(self, filename: str) -> None:
1343
+ """Actually save the dataframe to a file."""
1344
+ filepath = Path(filename)
1345
+ ext = filepath.suffix.lower()
1346
+
1347
+ try:
1348
+ if ext in (".xlsx", ".xls"):
1349
+ self._do_save_excel(filename)
1350
+ elif ext in (".tsv", ".tab"):
1351
+ self.df.write_csv(filename, separator="\t")
1352
+ elif ext == ".json":
1353
+ self.df.write_json(filename)
1354
+ elif ext == ".parquet":
1355
+ self.df.write_parquet(filename)
1356
+ else:
1357
+ self.df.write_csv(filename)
1358
+
1359
+ self.dataframe = self.df # Update original dataframe
1360
+ self.filename = filename # Update current filename
1361
+ if not self._all_tabs:
1362
+ self.app.notify(
1363
+ f"Saved [$accent]{len(self.df)}[/] rows to [on $primary]{filename}[/]",
1364
+ title="Save",
1365
+ )
1366
+ except Exception as e:
1367
+ self.app.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
1368
+ raise e
1369
+
1370
+ def _do_save_excel(self, filename: str) -> None:
1371
+ """Save to an Excel file."""
1372
+ import xlsxwriter
1373
+
1374
+ if not self._all_tabs or len(self.app.tabs) == 1:
1375
+ # Single tab - save directly
1376
+ self.df.write_excel(filename)
1377
+ else:
1378
+ # Multiple tabs - use xlsxwriter to create multiple sheets
1379
+ with xlsxwriter.Workbook(filename) as wb:
1380
+ for table in self.app.tabs.values():
1381
+ table.df.write_excel(wb, worksheet=table.tabname[:31])
1382
+
1383
+ # From ConfirmScreen callback, so notify accordingly
1384
+ if self._all_tabs is True:
1385
+ self.app.notify(
1386
+ f"Saved all tabs to [on $primary]{filename}[/]",
1387
+ title="Save",
1388
+ )
1389
+ else:
1390
+ self.app.notify(
1391
+ f"Saved current tab with [$accent]{len(self.df)}[/] rows to [on $primary]{filename}[/]",
1392
+ title="Save",
1393
+ )