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.
- dataframe_textual/__init__.py +1 -2
- dataframe_textual/__main__.py +62 -14
- dataframe_textual/common.py +587 -92
- dataframe_textual/data_frame_help_panel.py +28 -8
- dataframe_textual/data_frame_table.py +2579 -704
- dataframe_textual/data_frame_viewer.py +215 -179
- dataframe_textual/sql_screen.py +202 -0
- dataframe_textual/table_screen.py +296 -100
- dataframe_textual/yes_no_screen.py +454 -165
- dataframe_textual-1.5.0.dist-info/METADATA +987 -0
- dataframe_textual-1.5.0.dist-info/RECORD +14 -0
- {dataframe_textual-0.3.2.dist-info → dataframe_textual-1.5.0.dist-info}/entry_points.txt +1 -0
- dataframe_textual-0.3.2.dist-info/METADATA +0 -548
- dataframe_textual-0.3.2.dist-info/RECORD +0 -13
- {dataframe_textual-0.3.2.dist-info → dataframe_textual-1.5.0.dist-info}/WHEEL +0 -0
- {dataframe_textual-0.3.2.dist-info → dataframe_textual-1.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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.
|
|
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
|
-
|
|
27
|
+
NULL,
|
|
28
|
+
NULL_DISPLAY,
|
|
29
|
+
RIDX,
|
|
26
30
|
SUBSCRIPT_DIGITS,
|
|
27
31
|
DtypeConfig,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 .
|
|
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
|
-
- **
|
|
76
|
-
-
|
|
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
|
-
-
|
|
86
|
-
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
-
|
|
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
|
-
- **
|
|
92
|
-
- **
|
|
93
|
-
- **
|
|
94
|
-
- **
|
|
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
|
-
- **
|
|
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+
|
|
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** - 🔄
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
127
|
-
|
|
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.
|
|
143
|
-
self.
|
|
144
|
-
|
|
145
|
-
) # Track
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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.
|
|
530
|
+
self._highlight_row(new_coordinate.row)
|
|
255
531
|
elif self.cursor_type == "column":
|
|
256
532
|
self.refresh_column(old_coordinate.column)
|
|
257
|
-
self.
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
#
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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 =
|
|
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
|
|
378
|
-
stop = row_idx +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
1045
|
+
# Get the width for this column (None means auto-size)
|
|
1046
|
+
width = column_widths.get(col)
|
|
424
1047
|
|
|
425
|
-
|
|
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.
|
|
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
|
-
|
|
442
|
-
|
|
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
|
-
|
|
464
|
-
|
|
1070
|
+
def _continue(result: bool) -> None:
|
|
1071
|
+
if result:
|
|
1072
|
+
self._load_rows_async(stop, move_to_end=move_to_end)
|
|
465
1073
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
self.selected_rows = [False] * len(self.df)
|
|
1082
|
+
return
|
|
474
1083
|
|
|
475
|
-
#
|
|
476
|
-
|
|
477
|
-
self._load_rows(stop)
|
|
1084
|
+
# Load rows asynchronously
|
|
1085
|
+
self._load_rows_async(stop, move_to_end=move_to_end)
|
|
478
1086
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
"""
|
|
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
|
-
|
|
1178
|
+
force: If True, clear all highlights and restore default styles.
|
|
505
1179
|
"""
|
|
506
|
-
|
|
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
|
|
521
|
-
"""
|
|
522
|
-
if
|
|
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
|
|
1259
|
+
# Recreate table for display
|
|
540
1260
|
self._setup_table()
|
|
541
1261
|
|
|
542
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
1362
|
+
if fixed_rows >= 0:
|
|
585
1363
|
self.fixed_rows = fixed_rows
|
|
586
|
-
if fixed_columns
|
|
1364
|
+
if fixed_columns >= 0:
|
|
587
1365
|
self.fixed_columns = fixed_columns
|
|
588
1366
|
|
|
589
|
-
self.
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
#
|
|
602
|
-
|
|
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(
|
|
1490
|
+
self._add_history(message)
|
|
606
1491
|
|
|
607
|
-
# Remove the
|
|
608
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
616
|
-
|
|
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(
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
639
|
-
if
|
|
640
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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[
|
|
658
|
-
self.selected_rows = [
|
|
659
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
701
|
-
|
|
702
|
-
swap_name =
|
|
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[
|
|
1715
|
+
cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
|
|
730
1716
|
self.df = self.df.select(cols)
|
|
731
1717
|
|
|
732
|
-
self.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
first, second = sorted([
|
|
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.
|
|
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(
|
|
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 [
|
|
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[
|
|
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
|
-
#
|
|
843
|
-
del self.sorted_columns[
|
|
844
|
-
self.sorted_columns[
|
|
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
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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[
|
|
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(
|
|
1829
|
+
self.df = df_sorted.drop(RIDX)
|
|
860
1830
|
|
|
861
|
-
# Recreate
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
#
|
|
879
|
-
self._add_history(f"Edited cell [
|
|
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(
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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)) ==
|
|
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(
|
|
1878
|
+
cell_value = self.df.item(ridx, cidx)
|
|
907
1879
|
if cell_value is None:
|
|
908
|
-
cell_value =
|
|
909
|
-
dtype = self.df.dtypes[
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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.
|
|
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.
|
|
922
|
-
|
|
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
|
-
|
|
956
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
965
|
-
callback=self.
|
|
1901
|
+
EditColumnScreen(cidx, self.df),
|
|
1902
|
+
callback=self._do_edit_column,
|
|
966
1903
|
)
|
|
967
1904
|
|
|
968
|
-
def
|
|
969
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
#
|
|
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.
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
#
|
|
1015
|
-
|
|
1940
|
+
# Add to history
|
|
1941
|
+
self._add_history(f"Edited column [$accent]{col_name}[/] with expression")
|
|
1016
1942
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
self.
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
#
|
|
1027
|
-
self.
|
|
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
|
-
#
|
|
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
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
|
1044
|
-
"""
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
#
|
|
1094
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
1107
|
-
|
|
1999
|
+
# Recreate table for display
|
|
2000
|
+
self._setup_table()
|
|
1108
2001
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
title="Global Search",
|
|
1112
|
-
)
|
|
2002
|
+
# Move cursor to the renamed column
|
|
2003
|
+
self.move_cursor(column=col_idx)
|
|
1113
2004
|
|
|
1114
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1139
|
-
self.selected_rows = [not match for match in self.selected_rows]
|
|
2052
|
+
new_name = col_name
|
|
1140
2053
|
|
|
1141
|
-
#
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
#
|
|
1149
|
-
self.
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
self.
|
|
1157
|
-
"
|
|
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
|
-
|
|
1162
|
-
self.
|
|
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
|
-
|
|
1165
|
-
|
|
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
|
-
|
|
1168
|
-
|
|
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
|
|
1172
|
-
"""
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
#
|
|
1181
|
-
self._add_history("
|
|
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
|
-
|
|
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
|
|
2468
|
+
# Recreate table for display
|
|
1188
2469
|
self._setup_table()
|
|
1189
2470
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
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
|
|
1213
|
-
"""Handle
|
|
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
|
-
|
|
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
|
-
|
|
2635
|
+
term_find, term_replace, match_nocase, match_whole, replace_all = result
|
|
1221
2636
|
|
|
1222
|
-
|
|
1223
|
-
|
|
2637
|
+
if cidx is None:
|
|
2638
|
+
col_name = "all columns"
|
|
2639
|
+
else:
|
|
2640
|
+
col_name = self.df.columns[cidx]
|
|
1224
2641
|
|
|
1225
|
-
#
|
|
1226
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
2645
|
+
if not matches:
|
|
2646
|
+
self.notify(f"No matches found for [$warning]{term_find}[/]", title="Replace", severity="warning")
|
|
2647
|
+
return
|
|
1231
2648
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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(
|
|
2883
|
+
self._add_history("Toggled row selection")
|
|
1243
2884
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
self.selected_rows[
|
|
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
|
|
2900
|
+
# Recreate table for display
|
|
1253
2901
|
self._setup_table()
|
|
1254
2902
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
-
"""
|
|
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,
|
|
1264
|
-
Otherwise,
|
|
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
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
col_idx = self.cursor_column
|
|
2999
|
+
ridx = self.cursor_row_idx
|
|
3000
|
+
term = str(self.df.item(ridx, cidx))
|
|
1274
3001
|
|
|
1275
|
-
|
|
3002
|
+
self._do_view_rows((term, cidx, False, True))
|
|
1276
3003
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
-
|
|
1282
|
-
|
|
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
|
-
|
|
3058
|
+
# Lazyframe with row indices
|
|
3059
|
+
lf = self.df.lazy().with_row_index(RIDX)
|
|
1285
3060
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
1363
|
-
|
|
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.
|
|
1368
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
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.
|
|
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.
|
|
1391
|
-
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [
|
|
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
|
+
)
|