dataframe-textual 0.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,1395 @@
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.df))
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(
562
+ FrequencyScreen(col_idx, self.df.filter(self.visible_rows))
563
+ )
564
+
565
+ def _open_freeze_screen(self) -> None:
566
+ """Open the freeze screen to set fixed rows and columns."""
567
+ self.app.push_screen(FreezeScreen(), callback=self._do_freeze)
568
+
569
+ def _do_freeze(self, result: tuple[int, int] | None) -> None:
570
+ """Handle result from FreezeScreen.
571
+
572
+ Args:
573
+ result: Tuple of (fixed_rows, fixed_columns) or None if cancelled.
574
+ """
575
+ if result is None:
576
+ return
577
+
578
+ fixed_rows, fixed_columns = result
579
+
580
+ # Add to history
581
+ self._add_history(
582
+ f"Pinned [on $primary]{fixed_rows}[/] rows and [on $primary]{fixed_columns}[/] columns"
583
+ )
584
+
585
+ # Apply the pin settings to the table
586
+ if fixed_rows > 0:
587
+ self.fixed_rows = fixed_rows
588
+ if fixed_columns > 0:
589
+ self.fixed_columns = fixed_columns
590
+
591
+ self.app.notify(
592
+ f"Pinned [on $primary]{fixed_rows}[/] rows and [on $primary]{fixed_columns}[/] columns",
593
+ title="Pin",
594
+ )
595
+
596
+ # Delete & Move
597
+ def _delete_column(self) -> None:
598
+ """Remove the currently selected column from the table."""
599
+ col_idx = self.cursor_column
600
+ if col_idx >= len(self.df.columns):
601
+ return
602
+
603
+ # Get the column name to remove
604
+ col_to_remove = self.df.columns[col_idx]
605
+
606
+ # Add to history
607
+ self._add_history(f"Removed column [on $primary]{col_to_remove}[/]")
608
+
609
+ # Remove the column from the table display using the column name as key
610
+ self.remove_column(col_to_remove)
611
+
612
+ # Move cursor left if we deleted the last column
613
+ if col_idx >= len(self.columns):
614
+ self.move_cursor(column=len(self.columns) - 1)
615
+
616
+ # Remove from sorted columns if present
617
+ if col_to_remove in self.sorted_columns:
618
+ del self.sorted_columns[col_to_remove]
619
+
620
+ # Remove from dataframe
621
+ self.df = self.df.drop(col_to_remove)
622
+
623
+ self.app.notify(
624
+ f"Removed column [on $primary]{col_to_remove}[/] from display",
625
+ title="Column",
626
+ )
627
+
628
+ def _delete_row(self) -> None:
629
+ """Delete rows from the table and dataframe.
630
+
631
+ Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
632
+ """
633
+ old_count = len(self.df)
634
+ filter_expr = [True] * len(self.df)
635
+
636
+ # Delete all selected rows
637
+ if selected_count := self.selected_rows.count(True):
638
+ history_desc = f"Deleted {selected_count} selected row(s)"
639
+
640
+ for i, is_selected in enumerate(self.selected_rows):
641
+ if is_selected:
642
+ filter_expr[i] = False
643
+ # Delete the row at the cursor
644
+ else:
645
+ row_key = self.cursor_row_key
646
+ i = int(row_key.value) - 1 # Convert to 0-based index
647
+
648
+ filter_expr[i] = False
649
+ history_desc = f"Deleted row [on $primary]{row_key.value}[/]"
650
+
651
+ # Add to history
652
+ self._add_history(history_desc)
653
+
654
+ # Apply the filter to remove rows
655
+ df = self.df.with_row_index("__rid__").filter(filter_expr)
656
+ self.df = df.drop("__rid__")
657
+
658
+ # Update selected and visible rows tracking
659
+ old_row_indices = set(df["__rid__"].to_list())
660
+ self.selected_rows = [
661
+ selected
662
+ for i, selected in enumerate(self.selected_rows)
663
+ if i in old_row_indices
664
+ ]
665
+ self.visible_rows = [
666
+ visible
667
+ for i, visible in enumerate(self.visible_rows)
668
+ if i in old_row_indices
669
+ ]
670
+
671
+ # Recreate the table display
672
+ self._setup_table()
673
+
674
+ deleted_count = old_count - len(self.df)
675
+ self.app.notify(f"Deleted {deleted_count} row(s)", title="Delete")
676
+
677
+ def _move_column(self, direction: str) -> None:
678
+ """Move the current column left or right.
679
+
680
+ Args:
681
+ direction: "left" to move left, "right" to move right.
682
+ """
683
+ row_idx, col_idx = self.cursor_coordinate
684
+ col_key = self.cursor_column_key
685
+
686
+ # Validate move is possible
687
+ if direction == "left":
688
+ if col_idx <= 0:
689
+ self.app.notify(
690
+ "Cannot move column left", title="Move", severity="warning"
691
+ )
692
+ return
693
+ swap_idx = col_idx - 1
694
+ elif direction == "right":
695
+ if col_idx >= len(self.columns) - 1:
696
+ self.app.notify(
697
+ "Cannot move column right", title="Move", severity="warning"
698
+ )
699
+ return
700
+ swap_idx = col_idx + 1
701
+
702
+ # Get column names to swap
703
+ col_name = self.df.columns[col_idx]
704
+ swap_name = self.df.columns[swap_idx]
705
+
706
+ # Add to history
707
+ self._add_history(
708
+ f"Moved column [on $primary]{col_name}[/] {direction} (swapped with [on $primary]{swap_name}[/])"
709
+ )
710
+
711
+ # Swap columns in the table's internal column locations
712
+ self.check_idle()
713
+ swap_key = self.df.columns[swap_idx] # str as column key
714
+
715
+ (
716
+ self._column_locations[col_key],
717
+ self._column_locations[swap_key],
718
+ ) = (
719
+ self._column_locations.get(swap_key),
720
+ self._column_locations.get(col_key),
721
+ )
722
+
723
+ self._update_count += 1
724
+ self.refresh()
725
+
726
+ # Restore cursor position on the moved column
727
+ self.move_cursor(row=row_idx, column=swap_idx)
728
+
729
+ # Update the dataframe column order
730
+ cols = list(self.df.columns)
731
+ cols[col_idx], cols[swap_idx] = cols[swap_idx], cols[col_idx]
732
+ self.df = self.df.select(cols)
733
+
734
+ self.app.notify(
735
+ f"Moved column [on $primary]{col_name}[/] {direction}",
736
+ title="Move",
737
+ )
738
+
739
+ def _move_row(self, direction: str) -> None:
740
+ """Move the current row up or down.
741
+
742
+ Args:
743
+ direction: "up" to move up, "down" to move down.
744
+ """
745
+ row_idx, col_idx = self.cursor_coordinate
746
+
747
+ # Validate move is possible
748
+ if direction == "up":
749
+ if row_idx <= 0:
750
+ self.app.notify("Cannot move row up", title="Move", severity="warning")
751
+ return
752
+ swap_idx = row_idx - 1
753
+ elif direction == "down":
754
+ if row_idx >= len(self.rows) - 1:
755
+ self.app.notify(
756
+ "Cannot move row down", title="Move", severity="warning"
757
+ )
758
+ return
759
+ swap_idx = row_idx + 1
760
+ else:
761
+ self.app.notify(
762
+ f"Invalid direction: {direction}", title="Move", severity="error"
763
+ )
764
+ return
765
+
766
+ row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
767
+ swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
768
+
769
+ # Add to history
770
+ self._add_history(
771
+ f"Moved row [on $primary]{row_key.value}[/] {direction} (swapped with row [on $primary]{swap_key.value}[/])"
772
+ )
773
+
774
+ # Swap rows in the table's internal row locations
775
+ self.check_idle()
776
+
777
+ (
778
+ self._row_locations[row_key],
779
+ self._row_locations[swap_key],
780
+ ) = (
781
+ self._row_locations.get(swap_key),
782
+ self._row_locations.get(row_key),
783
+ )
784
+
785
+ self._update_count += 1
786
+ self.refresh()
787
+
788
+ # Restore cursor position on the moved row
789
+ self.move_cursor(row=swap_idx, column=col_idx)
790
+
791
+ # Swap rows in the dataframe
792
+ rid = int(row_key.value) - 1 # 0-based
793
+ swap_rid = int(swap_key.value) - 1 # 0-based
794
+ first, second = sorted([rid, swap_rid])
795
+
796
+ self.df = pl.concat(
797
+ [
798
+ self.df.slice(0, first),
799
+ self.df.slice(second, 1),
800
+ self.df.slice(first + 1, second - first - 1),
801
+ self.df.slice(first, 1),
802
+ self.df.slice(second + 1),
803
+ ]
804
+ )
805
+
806
+ self.app.notify(
807
+ f"Moved row [on $primary]{row_key.value}[/] {direction}", title="Move"
808
+ )
809
+
810
+ # Sort
811
+ def _sort_by_column(self, descending: bool = False) -> None:
812
+ """Sort by the currently selected column.
813
+
814
+ Supports multi-column sorting:
815
+ - First press on a column: sort by that column only
816
+ - Subsequent presses on other columns: add to sort order
817
+
818
+ Args:
819
+ descending: If True, sort in descending order. If False, ascending order.
820
+ """
821
+ col_idx = self.cursor_column
822
+ if col_idx >= len(self.df.columns):
823
+ return
824
+
825
+ col_to_sort = self.df.columns[col_idx]
826
+
827
+ # Check if this column is already in the sort keys
828
+ old_desc = self.sorted_columns.get(col_to_sort)
829
+ if old_desc == descending:
830
+ # Same direction - remove this column from sort
831
+ self.app.notify(
832
+ f"Already sorted by [on $primary]{col_to_sort}[/] ({'desc' if descending else 'asc'})",
833
+ title="Sort",
834
+ severity="warning",
835
+ )
836
+ return
837
+
838
+ # Add to history
839
+ self._add_history(f"Sorted on column [on $primary]{col_to_sort}[/]")
840
+ if old_desc is None:
841
+ # Add new column to sort
842
+ self.sorted_columns[col_to_sort] = descending
843
+ else:
844
+ # Toggle direction and move to end of sort order
845
+ del self.sorted_columns[col_to_sort]
846
+ self.sorted_columns[col_to_sort] = descending
847
+
848
+ # Apply multi-column sort
849
+ sort_cols = list(self.sorted_columns.keys())
850
+ descending_flags = list(self.sorted_columns.values())
851
+ df_sorted = self.df.with_row_index("__rid__").sort(
852
+ sort_cols, descending=descending_flags, nulls_last=True
853
+ )
854
+
855
+ # Updated selected_rows and visible_rows to match new order
856
+ old_row_indices = df_sorted["__rid__"].to_list()
857
+ self.selected_rows = [self.selected_rows[i] for i in old_row_indices]
858
+ self.visible_rows = [self.visible_rows[i] for i in old_row_indices]
859
+
860
+ # Update the dataframe
861
+ self.df = df_sorted.drop("__rid__")
862
+
863
+ # Recreate the table for display
864
+ self._setup_table()
865
+
866
+ # Restore cursor position on the sorted column
867
+ self.move_cursor(column=col_idx, row=0)
868
+
869
+ # Edit
870
+ def _edit_cell(self) -> None:
871
+ """Open modal to edit the selected cell."""
872
+ row_key = self.cursor_row_key
873
+ row_idx = int(row_key.value) - 1 # Convert to 0-based
874
+ col_idx = self.cursor_column
875
+
876
+ if row_idx >= len(self.df) or col_idx >= len(self.df.columns):
877
+ return
878
+ col_name = self.df.columns[col_idx]
879
+
880
+ # Save current state to history
881
+ self._add_history(f"Edited cell [on $primary]({row_idx + 1}, {col_name})[/]")
882
+
883
+ # Push the edit modal screen
884
+ self.app.push_screen(
885
+ EditCellScreen(row_key, col_idx, self.df),
886
+ callback=self._do_edit_cell,
887
+ )
888
+
889
+ def _do_edit_cell(self, result) -> None:
890
+ """Handle result from EditCellScreen."""
891
+ if result is None:
892
+ return
893
+
894
+ row_key, col_idx, new_value = result
895
+ row_idx = int(row_key.value) - 1 # Convert to 0-based
896
+ col_name = self.df.columns[col_idx]
897
+
898
+ # Update the cell in the dataframe
899
+ try:
900
+ self.df = self.df.with_columns(
901
+ pl.when(pl.arange(0, len(self.df)) == row_idx)
902
+ .then(pl.lit(new_value))
903
+ .otherwise(pl.col(col_name))
904
+ .alias(col_name)
905
+ )
906
+
907
+ # Update the display
908
+ cell_value = self.df.item(row_idx, col_idx)
909
+ if cell_value is None:
910
+ cell_value = "-"
911
+ dtype = self.df.dtypes[col_idx]
912
+ dc = DtypeConfig(dtype)
913
+ formatted_value = Text(str(cell_value), style=dc.style, justify=dc.justify)
914
+
915
+ row_key = str(row_idx + 1)
916
+ col_key = str(col_name)
917
+ self.update_cell(row_key, col_key, formatted_value)
918
+
919
+ self.app.notify(
920
+ f"Cell updated to [on $primary]{cell_value}[/]", title="Edit"
921
+ )
922
+ except Exception as e:
923
+ self.app.notify(
924
+ f"Failed to update cell: {str(e)}", title="Edit", severity="error"
925
+ )
926
+ raise e
927
+
928
+ def _copy_cell(self) -> None:
929
+ """Copy the current cell to clipboard."""
930
+ import subprocess
931
+
932
+ row_idx = self.cursor_row
933
+ col_idx = self.cursor_column
934
+
935
+ try:
936
+ cell_str = str(self.df.item(row_idx, col_idx))
937
+ subprocess.run(
938
+ [
939
+ "pbcopy" if sys.platform == "darwin" else "xclip",
940
+ "-selection",
941
+ "clipboard",
942
+ ],
943
+ input=cell_str,
944
+ text=True,
945
+ )
946
+ self.app.notify(f"Copied: {cell_str[:50]}", title="Clipboard")
947
+ except (FileNotFoundError, IndexError):
948
+ self.app.notify("Error copying cell", title="Clipboard", severity="error")
949
+
950
+ def _search_column(self, all_columns: bool = False) -> None:
951
+ """Open modal to search in the selected column."""
952
+ row_idx, col_idx = self.cursor_coordinate
953
+ if col_idx >= len(self.df.columns):
954
+ self.app.notify("Invalid column selected", title="Search", severity="error")
955
+ return
956
+
957
+ col_name = None if all_columns else self.df.columns[col_idx]
958
+ col_dtype = self.df.dtypes[col_idx]
959
+
960
+ # Get current cell value as default search term
961
+ term = self.df.item(row_idx, col_idx)
962
+ term = "NULL" if term is None else str(term)
963
+
964
+ # Push the search modal screen
965
+ self.app.push_screen(
966
+ SearchScreen(term, col_dtype, col_name),
967
+ callback=self._do_search_column,
968
+ )
969
+
970
+ def _do_search_column(self, result) -> None:
971
+ """Handle result from SearchScreen."""
972
+ if result is None:
973
+ return
974
+
975
+ term, col_dtype, col_name = result
976
+ if col_name:
977
+ # Perform search in the specified column
978
+ self._search_single_column(term, col_dtype, col_name)
979
+ else:
980
+ # Perform search in all columns
981
+ self._search_all_columns(term)
982
+
983
+ def _search_single_column(
984
+ self, term: str, col_dtype: pl.DataType, col_name: str
985
+ ) -> None:
986
+ """Search for a term in a single column and update selected rows.
987
+
988
+ Args:
989
+ term: The search term to find
990
+ col_dtype: The data type of the column
991
+ col_name: The name of the column to search in
992
+ """
993
+ df_rid = self.df.with_row_index("__rid__")
994
+ if False in self.visible_rows:
995
+ df_rid = df_rid.filter(self.visible_rows)
996
+
997
+ # Perform type-aware search based on column dtype
998
+ if term.lower() == "null":
999
+ masks = df_rid[col_name].is_null()
1000
+ elif col_dtype == pl.String:
1001
+ masks = df_rid[col_name].str.contains(term)
1002
+ elif col_dtype == pl.Boolean:
1003
+ masks = df_rid[col_name] == BOOLS[term.lower()]
1004
+ elif col_dtype in (pl.Int32, pl.Int64):
1005
+ masks = df_rid[col_name] == int(term)
1006
+ elif col_dtype in (pl.Float32, pl.Float64):
1007
+ masks = df_rid[col_name] == float(term)
1008
+ else:
1009
+ self.app.notify(
1010
+ f"Search not yet supported for column type: [on $primary]{col_dtype}[/]",
1011
+ title="Search",
1012
+ severity="warning",
1013
+ )
1014
+ return
1015
+
1016
+ # Apply filter to get matched row indices
1017
+ matches = set(df_rid.filter(masks)["__rid__"].to_list())
1018
+
1019
+ match_count = len(matches)
1020
+ if match_count == 0:
1021
+ self.app.notify(
1022
+ f"No matches found for: [on $primary]{term}[/]",
1023
+ title="Search",
1024
+ severity="warning",
1025
+ )
1026
+ return
1027
+
1028
+ # Add to history
1029
+ self._add_history(
1030
+ f"Searched and highlighted [on $primary]{term}[/] in column [on $primary]{col_name}[/]"
1031
+ )
1032
+
1033
+ # Update selected rows to include new matches
1034
+ for m in matches:
1035
+ self.selected_rows[m] = True
1036
+
1037
+ # Highlight selected rows
1038
+ self._highlight_rows()
1039
+
1040
+ self.app.notify(
1041
+ f"Found [on $primary]{match_count}[/] matches for [on $primary]{term}[/]",
1042
+ title="Search",
1043
+ )
1044
+
1045
+ def _search_all_columns(self, term: str) -> None:
1046
+ """Search for a term across all columns and highlight matching cells.
1047
+
1048
+ Args:
1049
+ term: The search term to find
1050
+ """
1051
+ df_rid = self.df.with_row_index("__rid__")
1052
+ if False in self.visible_rows:
1053
+ df_rid = df_rid.filter(self.visible_rows)
1054
+
1055
+ matches: dict[int, set[int]] = {}
1056
+ match_count = 0
1057
+ if term.lower() == "null":
1058
+ # Search for NULL values across all columns
1059
+ for col_idx, col in enumerate(df_rid.columns[1:]):
1060
+ masks = df_rid[col].is_null()
1061
+ matched_rids = set(df_rid.filter(masks)["__rid__"].to_list())
1062
+ for rid in matched_rids:
1063
+ if rid not in matches:
1064
+ matches[rid] = set()
1065
+ matches[rid].add(col_idx)
1066
+ match_count += 1
1067
+ else:
1068
+ # Search for the term in all columns
1069
+ for col_idx, col in enumerate(df_rid.columns[1:]):
1070
+ col_series = df_rid[col].cast(pl.String)
1071
+ masks = col_series.str.contains(term)
1072
+ matched_rids = set(df_rid.filter(masks)["__rid__"].to_list())
1073
+ for rid in matched_rids:
1074
+ if rid not in matches:
1075
+ matches[rid] = set()
1076
+ matches[rid].add(col_idx)
1077
+ match_count += 1
1078
+
1079
+ if match_count == 0:
1080
+ self.app.notify(
1081
+ f"No matches found for: [on $primary]{term}[/] in any column",
1082
+ title="Global Search",
1083
+ severity="warning",
1084
+ )
1085
+ return
1086
+
1087
+ # Ensure all matching rows are loaded
1088
+ self._load_rows(max(matches.keys()) + 1)
1089
+
1090
+ # Add to history
1091
+ self._add_history(
1092
+ f"Searched and highlighted [on $primary]{term}[/] across all columns"
1093
+ )
1094
+
1095
+ # Highlight matching cells directly
1096
+ for row in self.ordered_rows:
1097
+ row_idx = int(row.key.value) - 1 # Convert to 0-based index
1098
+ if row_idx not in matches:
1099
+ continue
1100
+
1101
+ for col_idx in matches[row_idx]:
1102
+ row_key = row.key
1103
+ col_key = self.df.columns[col_idx]
1104
+
1105
+ cell_text: Text = self.get_cell(row_key, col_key)
1106
+ cell_text.style = "red"
1107
+
1108
+ # Update the cell in the table
1109
+ self.update_cell(row_key, col_key, cell_text)
1110
+
1111
+ self.app.notify(
1112
+ f"Found [on $success]{match_count}[/] matches for [on $primary]{term}[/] across all columns",
1113
+ title="Global Search",
1114
+ )
1115
+
1116
+ def _search_with_cell_value(self) -> None:
1117
+ """Search in the current column using the value of the currently selected cell."""
1118
+ row_key = self.cursor_row_key
1119
+ row_idx = int(row_key.value) - 1 # Convert to 0-based index
1120
+ col_idx = self.cursor_column
1121
+
1122
+ # Get the value of the currently selected cell
1123
+ term = self.df.item(row_idx, col_idx)
1124
+ term = "NULL" if term is None else str(term)
1125
+
1126
+ col_dtype = self.df.dtypes[col_idx]
1127
+ col_name = self.df.columns[col_idx]
1128
+ self._do_search_column((term, col_dtype, col_name))
1129
+
1130
+ def _toggle_selected_rows(self, current_row=False) -> None:
1131
+ """Toggle selected rows highlighting on/off."""
1132
+ # Save current state to history
1133
+ self._add_history("Toggled row selection")
1134
+
1135
+ # Select current row if no rows are currently selected
1136
+ if current_row:
1137
+ cursor_row_idx = int(self.cursor_row_key.value) - 1
1138
+ self.selected_rows[cursor_row_idx] = not self.selected_rows[cursor_row_idx]
1139
+ else:
1140
+ # Invert all selected rows
1141
+ self.selected_rows = [not match for match in self.selected_rows]
1142
+
1143
+ # Check if we're highlighting or un-highlighting
1144
+ if new_selected_count := self.selected_rows.count(True):
1145
+ self.app.notify(
1146
+ f"Toggled selection - now showing [on $primary]{new_selected_count}[/] rows",
1147
+ title="Toggle",
1148
+ )
1149
+
1150
+ # Refresh the highlighting (also restores default styles for unselected rows)
1151
+ self._highlight_rows()
1152
+
1153
+ def _clear_selected_rows(self) -> None:
1154
+ """Clear all selected rows without removing them from the dataframe."""
1155
+ # Check if any rows are currently selected
1156
+ selected_count = self.selected_rows.count(True)
1157
+ if selected_count == 0:
1158
+ self.app.notify(
1159
+ "No rows selected to clear", title="Clear", severity="warning"
1160
+ )
1161
+ return
1162
+
1163
+ # Save current state to history
1164
+ self._add_history("Cleared all selected rows")
1165
+
1166
+ # Clear all selections and refresh highlighting
1167
+ self._highlight_rows(clear=True)
1168
+
1169
+ self.app.notify(
1170
+ f"Cleared [on $primary]{selected_count}[/] selected rows", title="Clear"
1171
+ )
1172
+
1173
+ def _filter_selected_rows(self) -> None:
1174
+ """Display only the selected rows."""
1175
+ selected_count = self.selected_rows.count(True)
1176
+ if selected_count == 0:
1177
+ self.app.notify(
1178
+ "No rows selected to filter", title="Filter", severity="warning"
1179
+ )
1180
+ return
1181
+
1182
+ # Save current state to history
1183
+ self._add_history("Filtered to selected rows")
1184
+
1185
+ # Update dataframe to only include selected rows
1186
+ self.df = self.df.filter(self.selected_rows)
1187
+ self.selected_rows = [True] * len(self.df)
1188
+
1189
+ # Recreate the table for display
1190
+ self._setup_table()
1191
+
1192
+ self.app.notify(
1193
+ f"Removed unselected rows. Now showing [on $primary]{selected_count}[/] rows",
1194
+ title="Filter",
1195
+ )
1196
+
1197
+ def _open_filter_screen(self) -> None:
1198
+ """Open the filter screen to enter a filter expression."""
1199
+ row_key = self.cursor_row_key
1200
+ row_idx = int(row_key.value) - 1 # Convert to 0-based index
1201
+ col_idx = self.cursor_column
1202
+
1203
+ cell_value = self.df.item(row_idx, col_idx)
1204
+ if self.df.dtypes[col_idx] == pl.String and cell_value is not None:
1205
+ cell_value = repr(cell_value)
1206
+
1207
+ self.app.push_screen(
1208
+ FilterScreen(
1209
+ self.df, current_col_idx=col_idx, current_cell_value=cell_value
1210
+ ),
1211
+ callback=self._do_filter,
1212
+ )
1213
+
1214
+ def _do_filter(self, result) -> None:
1215
+ """Handle result from FilterScreen.
1216
+
1217
+ Args:
1218
+ expression: The filter expression or None if cancelled.
1219
+ """
1220
+ if result is None:
1221
+ return
1222
+ expr, expr_str = result
1223
+
1224
+ # Add a row index column to track original row indices
1225
+ df_with_rid = self.df.with_row_index("__rid__")
1226
+
1227
+ # Apply existing visibility filter first
1228
+ if False in self.visible_rows:
1229
+ df_with_rid = df_with_rid.filter(self.visible_rows)
1230
+
1231
+ # Apply the filter expression
1232
+ df_filtered = df_with_rid.filter(expr)
1233
+
1234
+ matched_count = len(df_filtered)
1235
+ if not matched_count:
1236
+ self.app.notify(
1237
+ f"No rows match the expression: [on $primary]{expr_str}[/]",
1238
+ title="Filter",
1239
+ severity="warning",
1240
+ )
1241
+ return
1242
+
1243
+ # Add to history
1244
+ self._add_history(f"Filtered by expression [on $primary]{expr_str}[/]")
1245
+
1246
+ # Mark unfiltered rows as invisible and unselected
1247
+ filtered_row_indices = set(df_filtered["__rid__"].to_list())
1248
+ if filtered_row_indices:
1249
+ for rid in range(len(self.visible_rows)):
1250
+ if rid not in filtered_row_indices:
1251
+ self.visible_rows[rid] = False
1252
+ self.selected_rows[rid] = False
1253
+
1254
+ # Recreate the table for display
1255
+ self._setup_table()
1256
+
1257
+ self.app.notify(
1258
+ f"Filtered to [on $primary]{matched_count}[/] matching rows",
1259
+ title="Filter",
1260
+ )
1261
+
1262
+ def _filter_rows(self) -> None:
1263
+ """Filter rows.
1264
+
1265
+ If there are selected rows, filter to those rows.
1266
+ Otherwise, filter based on the value of the currently selected cell.
1267
+ """
1268
+
1269
+ if True in self.selected_rows:
1270
+ expr = self.selected_rows
1271
+ expr_str = "selected rows"
1272
+ else:
1273
+ row_key = self.cursor_row_key
1274
+ row_idx = int(row_key.value) - 1 # Convert to 0-based index
1275
+ col_idx = self.cursor_column
1276
+
1277
+ cell_value = self.df.item(row_idx, col_idx)
1278
+
1279
+ if cell_value is None:
1280
+ expr = pl.col(self.df.columns[col_idx]).is_null()
1281
+ expr_str = "NULL"
1282
+ else:
1283
+ expr = pl.col(self.df.columns[col_idx]) == cell_value
1284
+ expr_str = f"$_ == {repr(cell_value)}"
1285
+
1286
+ self._do_filter((expr, expr_str))
1287
+
1288
+ def _cycle_cursor_type(self) -> None:
1289
+ """Cycle through cursor types: cell -> row -> column -> cell."""
1290
+ next_type = _next(CURSOR_TYPES, self.cursor_type)
1291
+ self.cursor_type = next_type
1292
+
1293
+ self.app.notify(
1294
+ f"Changed cursor type to [on $primary]{next_type}[/]", title="Cursor"
1295
+ )
1296
+
1297
+ def _toggle_row_labels(self) -> None:
1298
+ """Toggle row labels visibility."""
1299
+ self.show_row_labels = not self.show_row_labels
1300
+ status = "shown" if self.show_row_labels else "hidden"
1301
+ self.app.notify(f"Row labels {status}", title="Labels")
1302
+
1303
+ def _save_to_file(self) -> None:
1304
+ """Open save file dialog."""
1305
+ self.app.push_screen(
1306
+ SaveFileScreen(self.filename), callback=self._on_save_file_screen
1307
+ )
1308
+
1309
+ def _on_save_file_screen(
1310
+ self, filename: str | None, all_tabs: bool = False
1311
+ ) -> None:
1312
+ """Handle result from SaveFileScreen."""
1313
+ if filename is None:
1314
+ return
1315
+ filepath = Path(filename)
1316
+ ext = filepath.suffix.lower()
1317
+
1318
+ # Whether to save all tabs (for Excel files)
1319
+ self._all_tabs = all_tabs
1320
+
1321
+ # Check if file exists
1322
+ if filepath.exists():
1323
+ self._pending_filename = filename
1324
+ self.app.push_screen(
1325
+ ConfirmScreen("File already exists. Overwrite?"),
1326
+ callback=self._on_overwrite_screen,
1327
+ )
1328
+ elif ext in (".xlsx", ".xls"):
1329
+ self._do_save_excel(filename)
1330
+ else:
1331
+ self._do_save(filename)
1332
+
1333
+ def _on_overwrite_screen(self, should_overwrite: bool) -> None:
1334
+ """Handle result from ConfirmScreen."""
1335
+ if should_overwrite:
1336
+ self._do_save(self._pending_filename)
1337
+ else:
1338
+ # Go back to SaveFileScreen to allow user to enter a different name
1339
+ self.app.push_screen(
1340
+ SaveFileScreen(self._pending_filename),
1341
+ callback=self._on_save_file_screen,
1342
+ )
1343
+
1344
+ def _do_save(self, filename: str) -> None:
1345
+ """Actually save the dataframe to a file."""
1346
+ filepath = Path(filename)
1347
+ ext = filepath.suffix.lower()
1348
+
1349
+ try:
1350
+ if ext in (".xlsx", ".xls"):
1351
+ self._do_save_excel(filename)
1352
+ elif ext in (".tsv", ".tab"):
1353
+ self.df.write_csv(filename, separator="\t")
1354
+ elif ext == ".json":
1355
+ self.df.write_json(filename)
1356
+ elif ext == ".parquet":
1357
+ self.df.write_parquet(filename)
1358
+ else:
1359
+ self.df.write_csv(filename)
1360
+
1361
+ self.dataframe = self.df # Update original dataframe
1362
+ self.filename = filename # Update current filename
1363
+ if not self._all_tabs:
1364
+ self.app.notify(
1365
+ f"Saved [$accent]{len(self.df)}[/] rows to [on $primary]{filename}[/]",
1366
+ title="Save",
1367
+ )
1368
+ except Exception as e:
1369
+ self.app.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
1370
+ raise e
1371
+
1372
+ def _do_save_excel(self, filename: str) -> None:
1373
+ """Save to an Excel file."""
1374
+ import xlsxwriter
1375
+
1376
+ if not self._all_tabs or len(self.app.tabs) == 1:
1377
+ # Single tab - save directly
1378
+ self.df.write_excel(filename)
1379
+ else:
1380
+ # Multiple tabs - use xlsxwriter to create multiple sheets
1381
+ with xlsxwriter.Workbook(filename) as wb:
1382
+ for table in self.app.tabs.values():
1383
+ table.df.write_excel(wb, worksheet=table.tabname[:31])
1384
+
1385
+ # From ConfirmScreen callback, so notify accordingly
1386
+ if self._all_tabs is True:
1387
+ self.app.notify(
1388
+ f"Saved all tabs to [on $primary]{filename}[/]",
1389
+ title="Save",
1390
+ )
1391
+ else:
1392
+ self.app.notify(
1393
+ f"Saved current tab with [$accent]{len(self.df)}[/] rows to [on $primary]{filename}[/]",
1394
+ title="Save",
1395
+ )