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

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