dataframe-textual 1.9.0__py3-none-any.whl → 2.2.3__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 +27 -1
- dataframe_textual/__main__.py +13 -2
- dataframe_textual/common.py +139 -52
- dataframe_textual/data_frame_help_panel.py +0 -3
- dataframe_textual/data_frame_table.py +1377 -792
- dataframe_textual/data_frame_viewer.py +61 -18
- dataframe_textual/sql_screen.py +17 -20
- dataframe_textual/table_screen.py +164 -144
- dataframe_textual/yes_no_screen.py +34 -39
- {dataframe_textual-1.9.0.dist-info → dataframe_textual-2.2.3.dist-info}/METADATA +213 -215
- dataframe_textual-2.2.3.dist-info/RECORD +14 -0
- {dataframe_textual-1.9.0.dist-info → dataframe_textual-2.2.3.dist-info}/WHEEL +1 -1
- dataframe_textual-1.9.0.dist-info/RECORD +0 -14
- {dataframe_textual-1.9.0.dist-info → dataframe_textual-2.2.3.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-1.9.0.dist-info → dataframe_textual-2.2.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
import sys
|
|
4
4
|
from collections import defaultdict, deque
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
+
from itertools import zip_longest
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from textwrap import dedent
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
10
11
|
import polars as pl
|
|
11
|
-
from rich.text import Text
|
|
12
|
-
from textual import
|
|
12
|
+
from rich.text import Text, TextType
|
|
13
|
+
from textual._two_way_dict import TwoWayDict
|
|
13
14
|
from textual.coordinate import Coordinate
|
|
14
15
|
from textual.events import Click
|
|
15
16
|
from textual.reactive import reactive
|
|
@@ -18,8 +19,12 @@ from textual.widgets import DataTable, TabPane
|
|
|
18
19
|
from textual.widgets._data_table import (
|
|
19
20
|
CellDoesNotExist,
|
|
20
21
|
CellKey,
|
|
22
|
+
CellType,
|
|
23
|
+
Column,
|
|
21
24
|
ColumnKey,
|
|
22
25
|
CursorType,
|
|
26
|
+
DuplicateKey,
|
|
27
|
+
Row,
|
|
23
28
|
RowKey,
|
|
24
29
|
)
|
|
25
30
|
|
|
@@ -27,19 +32,19 @@ from .common import (
|
|
|
27
32
|
CURSOR_TYPES,
|
|
28
33
|
NULL,
|
|
29
34
|
NULL_DISPLAY,
|
|
30
|
-
|
|
35
|
+
RID,
|
|
31
36
|
SUBSCRIPT_DIGITS,
|
|
37
|
+
SUPPORTED_FORMATS,
|
|
32
38
|
DtypeConfig,
|
|
33
39
|
format_row,
|
|
34
40
|
get_next_item,
|
|
35
41
|
parse_placeholders,
|
|
36
|
-
|
|
37
|
-
sleep_async,
|
|
42
|
+
round_to_nearest_hundreds,
|
|
38
43
|
tentative_expr,
|
|
39
44
|
validate_expr,
|
|
40
45
|
)
|
|
41
46
|
from .sql_screen import AdvancedSqlScreen, SimpleSqlScreen
|
|
42
|
-
from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
|
|
47
|
+
from .table_screen import FrequencyScreen, MetaColumnScreen, MetaShape, RowDetailScreen, StatisticsScreen
|
|
43
48
|
from .yes_no_screen import (
|
|
44
49
|
AddColumnScreen,
|
|
45
50
|
AddLinkScreen,
|
|
@@ -57,6 +62,9 @@ from .yes_no_screen import (
|
|
|
57
62
|
# Color for highlighting selections and matches
|
|
58
63
|
HIGHLIGHT_COLOR = "red"
|
|
59
64
|
|
|
65
|
+
# Buffer size for loading rows
|
|
66
|
+
BUFFER_SIZE = 5
|
|
67
|
+
|
|
60
68
|
# Warning threshold for loading rows
|
|
61
69
|
WARN_ROWS_THRESHOLD = 50_000
|
|
62
70
|
|
|
@@ -70,16 +78,15 @@ class History:
|
|
|
70
78
|
|
|
71
79
|
description: str
|
|
72
80
|
df: pl.DataFrame
|
|
81
|
+
df_view: pl.DataFrame | None
|
|
73
82
|
filename: str
|
|
74
|
-
loaded_rows: int
|
|
75
|
-
sorted_columns: dict[str, bool]
|
|
76
83
|
hidden_columns: set[str]
|
|
77
|
-
selected_rows:
|
|
78
|
-
|
|
84
|
+
selected_rows: set[int]
|
|
85
|
+
sorted_columns: dict[str, bool] # col_name -> descending
|
|
86
|
+
matches: dict[int, set[str]] # RID -> set of col names
|
|
79
87
|
fixed_rows: int
|
|
80
88
|
fixed_columns: int
|
|
81
89
|
cursor_coordinate: Coordinate
|
|
82
|
-
matches: dict[int, set[int]]
|
|
83
90
|
dirty: bool = False # Whether this history state has unsaved changes
|
|
84
91
|
|
|
85
92
|
|
|
@@ -103,6 +110,20 @@ class ReplaceState:
|
|
|
103
110
|
done: bool = False # Whether the replace operation is complete
|
|
104
111
|
|
|
105
112
|
|
|
113
|
+
def add_rid_column(df: pl.DataFrame) -> pl.DataFrame:
|
|
114
|
+
"""Add internal row index as last column to the dataframe if not already present.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
df: The Polars DataFrame to modify.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The modified DataFrame with the internal row index column added.
|
|
121
|
+
"""
|
|
122
|
+
if RID not in df.columns:
|
|
123
|
+
df = df.lazy().with_row_index(RID).select(pl.exclude(RID), RID).collect()
|
|
124
|
+
return df
|
|
125
|
+
|
|
126
|
+
|
|
106
127
|
class DataFrameTable(DataTable):
|
|
107
128
|
"""Custom DataTable to highlight row/column labels based on cursor position."""
|
|
108
129
|
|
|
@@ -115,7 +136,7 @@ class DataFrameTable(DataTable):
|
|
|
115
136
|
- **g** - ⬆️ Jump to first row
|
|
116
137
|
- **G** - ⬇️ Jump to last row
|
|
117
138
|
- **HOME/END** - 🎯 Jump to first/last column
|
|
118
|
-
- **Ctrl+HOME/END** - 🎯 Jump to page top/
|
|
139
|
+
- **Ctrl+HOME/END** - 🎯 Jump to page top/top
|
|
119
140
|
- **Ctrl+F** - 📜 Page down
|
|
120
141
|
- **Ctrl+B** - 📜 Page up
|
|
121
142
|
- **PgUp/PgDn** - 📜 Page up/down
|
|
@@ -125,14 +146,16 @@ class DataFrameTable(DataTable):
|
|
|
125
146
|
- **U** - 🔄 Redo last undone action
|
|
126
147
|
- **Ctrl+U** - 🔁 Reset to initial state
|
|
127
148
|
|
|
128
|
-
## 👁️
|
|
149
|
+
## 👁️ Display
|
|
129
150
|
- **Enter** - 📋 Show row details in modal
|
|
130
151
|
- **F** - 📊 Show frequency distribution
|
|
131
152
|
- **s** - 📈 Show statistics for current column
|
|
132
153
|
- **S** - 📊 Show statistics for entire dataframe
|
|
154
|
+
- **m** - 📐 Show dataframe metadata (row/column counts)
|
|
155
|
+
- **M** - 📋 Show column metadata (ID, name, type)
|
|
133
156
|
- **h** - 👁️ Hide current column
|
|
134
157
|
- **H** - 👀 Show all hidden rows/columns
|
|
135
|
-
- **_** - 📏
|
|
158
|
+
- **_** - 📏 Toggle column full width
|
|
136
159
|
- **z** - 📌 Freeze rows and columns
|
|
137
160
|
- **~** - 🏷️ Toggle row labels
|
|
138
161
|
- **,** - 🔢 Toggle thousand separator for numeric display
|
|
@@ -143,31 +166,31 @@ class DataFrameTable(DataTable):
|
|
|
143
166
|
- **]** - 🔽 Sort column descending
|
|
144
167
|
- *(Multi-column sort supported)*
|
|
145
168
|
|
|
146
|
-
##
|
|
147
|
-
-
|
|
148
|
-
-
|
|
169
|
+
## ✅ Row Selection
|
|
170
|
+
- **\\\\** - ✅ Select rows with cell matches or those matching cursor value in current column
|
|
171
|
+
- **|** - ✅ Select rows with expression
|
|
172
|
+
- **'** - ✅ Select/deselect current row
|
|
173
|
+
- **t** - 💡 Toggle row selection (invert all)
|
|
174
|
+
- **T** - 🧹 Clear all selections and matches
|
|
175
|
+
- **{** - ⬆️ Go to previous selected row
|
|
176
|
+
- **}** - ⬇️ Go to next selected row
|
|
177
|
+
- *(Supports case-insensitive & whole-word matching)*
|
|
178
|
+
|
|
179
|
+
## 🔎 Find & Replace
|
|
149
180
|
- **/** - 🔎 Find in current column with cursor value
|
|
150
181
|
- **?** - 🔎 Find in current column with expression
|
|
151
182
|
- **;** - 🌐 Global find using cursor value
|
|
152
183
|
- **:** - 🌐 Global find with expression
|
|
153
184
|
- **n** - ⬇️ Go to next match
|
|
154
185
|
- **N** - ⬆️ Go to previous match
|
|
155
|
-
- **v** - 👁️ View/filter rows by cell or selected rows and hide others
|
|
156
|
-
- **V** - 🔧 View/filter rows by expression and hide others
|
|
157
|
-
- *(All search/find support case-insensitive & whole-word matching)*
|
|
158
|
-
|
|
159
|
-
## ✏️ Replace
|
|
160
186
|
- **r** - 🔄 Replace in current column (interactive or all)
|
|
161
187
|
- **R** - 🔄 Replace across all columns (interactive or all)
|
|
162
188
|
- *(Supports case-insensitive & whole-word matching)*
|
|
163
189
|
|
|
164
|
-
##
|
|
165
|
-
- **
|
|
166
|
-
- **
|
|
167
|
-
- **
|
|
168
|
-
- **{** - ⬆️ Go to previous selected row
|
|
169
|
-
- **}** - ⬇️ Go to next selected row
|
|
170
|
-
- **"** - 📍 Filter selected rows and remove others
|
|
190
|
+
## 👁️ View & Filter
|
|
191
|
+
- **"** - 📍 Filter selected rows (removes others)
|
|
192
|
+
- **v** - 👁️ View selected rows (hides others)
|
|
193
|
+
- **V** - 🔧 View selected rows matching expression (hides others)
|
|
171
194
|
|
|
172
195
|
## 🔍 SQL Interface
|
|
173
196
|
- **l** - 💬 Open simple SQL interface (select columns & where clause)
|
|
@@ -210,8 +233,8 @@ class DataFrameTable(DataTable):
|
|
|
210
233
|
# Navigation
|
|
211
234
|
("g", "jump_top", "Jump to top"),
|
|
212
235
|
("G", "jump_bottom", "Jump to bottom"),
|
|
213
|
-
("ctrl+
|
|
214
|
-
("ctrl+
|
|
236
|
+
("pageup,ctrl+b", "page_up", "Page up"),
|
|
237
|
+
("pagedown,ctrl+f", "page_down", "Page down"),
|
|
215
238
|
# Undo/Redo/Reset
|
|
216
239
|
("u", "undo", "Undo"),
|
|
217
240
|
("U", "redo", "Redo"),
|
|
@@ -222,15 +245,18 @@ class DataFrameTable(DataTable):
|
|
|
222
245
|
("tilde", "toggle_row_labels", "Toggle row labels"), # `~`
|
|
223
246
|
("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
|
|
224
247
|
("z", "freeze_row_column", "Freeze rows/columns"),
|
|
225
|
-
("comma", "
|
|
248
|
+
("comma", "toggle_thousand_separator", "Toggle thousand separator"), # `,`
|
|
226
249
|
("underscore", "expand_column", "Expand column to full width"), # `_`
|
|
250
|
+
("circumflex_accent", "toggle_rid", "Toggle internal row index"), # `^`
|
|
227
251
|
# Copy
|
|
228
252
|
("c", "copy_cell", "Copy cell to clipboard"),
|
|
229
253
|
("ctrl+c", "copy_column", "Copy column to clipboard"),
|
|
230
254
|
("ctrl+r", "copy_row", "Copy row to clipboard"),
|
|
231
255
|
# Save
|
|
232
256
|
("ctrl+s", "save_to_file", "Save to file"),
|
|
233
|
-
# Detail, Frequency, and Statistics
|
|
257
|
+
# Metadata, Detail, Frequency, and Statistics
|
|
258
|
+
("m", "metadata_shape", "Show metadata for row count and column count"),
|
|
259
|
+
("M", "metadata_column", "Show metadata for column"),
|
|
234
260
|
("enter", "view_row_detail", "View row details"),
|
|
235
261
|
("F", "show_frequency", "Show frequency"),
|
|
236
262
|
("s", "show_statistics", "Show statistics for column"),
|
|
@@ -239,28 +265,26 @@ class DataFrameTable(DataTable):
|
|
|
239
265
|
("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
|
|
240
266
|
("right_square_bracket", "sort_descending", "Sort descending"), # `]`
|
|
241
267
|
# View & Filter
|
|
242
|
-
("v", "view_rows", "View rows"),
|
|
243
|
-
("V", "view_rows_expr", "View rows
|
|
244
|
-
("quotation_mark", "filter_rows", "Filter selected"), # `"`
|
|
245
|
-
#
|
|
246
|
-
("backslash", "
|
|
247
|
-
("vertical_line", "
|
|
268
|
+
("v", "view_rows", "View selected rows"),
|
|
269
|
+
("V", "view_rows_expr", "View selected rows matching expression"),
|
|
270
|
+
("quotation_mark", "filter_rows", "Filter selected rows"), # `"`
|
|
271
|
+
# Row Selection
|
|
272
|
+
("backslash", "select_row", "Select rows with cell matches or those matching cursor value in current column"), # `\`
|
|
273
|
+
("vertical_line", "select_row_expr", "Select rows with expression"), # `|`
|
|
248
274
|
("right_curly_bracket", "next_selected_row", "Go to next selected row"), # `}`
|
|
249
275
|
("left_curly_bracket", "previous_selected_row", "Go to previous selected row"), # `{`
|
|
250
|
-
#
|
|
276
|
+
("apostrophe", "toggle_row_selection", "Toggle row selection"), # `'`
|
|
277
|
+
("t", "toggle_selections", "Toggle all row selections"),
|
|
278
|
+
("T", "clear_selections_and_matches", "Clear selections"),
|
|
279
|
+
# Find & Replace
|
|
251
280
|
("slash", "find_cursor_value", "Find in column with cursor value"), # `/`
|
|
252
281
|
("question_mark", "find_expr", "Find in column with expression"), # `?`
|
|
253
282
|
("semicolon", "find_cursor_value('global')", "Global find with cursor value"), # `;`
|
|
254
283
|
("colon", "find_expr('global')", "Global find with expression"), # `:`
|
|
255
284
|
("n", "next_match", "Go to next match"), # `n`
|
|
256
285
|
("N", "previous_match", "Go to previous match"), # `Shift+n`
|
|
257
|
-
# Replace
|
|
258
286
|
("r", "replace", "Replace in column"), # `r`
|
|
259
287
|
("R", "replace_global", "Replace global"), # `Shift+R`
|
|
260
|
-
# Selection
|
|
261
|
-
("apostrophe", "toggle_row_selection", "Toggle row selection"), # `'`
|
|
262
|
-
("t", "toggle_selections", "Toggle all row selections"),
|
|
263
|
-
("T", "clear_selections_and_matches", "Clear selections"),
|
|
264
288
|
# Delete
|
|
265
289
|
("delete", "clear_cell", "Clear cell"),
|
|
266
290
|
("minus", "delete_column", "Delete column"), # `-`
|
|
@@ -311,34 +335,43 @@ class DataFrameTable(DataTable):
|
|
|
311
335
|
super().__init__(**kwargs)
|
|
312
336
|
|
|
313
337
|
# DataFrame state
|
|
314
|
-
self.dataframe = df # Original dataframe
|
|
315
|
-
self.df =
|
|
338
|
+
self.dataframe = add_rid_column(df) # Original dataframe
|
|
339
|
+
self.df = self.dataframe # Internal/working dataframe
|
|
316
340
|
self.filename = filename or "untitled.csv" # Current filename
|
|
317
341
|
self.tabname = tabname or Path(filename).stem # Tab name
|
|
342
|
+
|
|
343
|
+
# In view mode, this is the copy of self.df
|
|
344
|
+
self.df_view = None
|
|
345
|
+
|
|
318
346
|
# Pagination & Loading
|
|
319
|
-
self.
|
|
320
|
-
self.BATCH_SIZE = self.INITIAL_BATCH_SIZE // 2
|
|
347
|
+
self.BATCH_SIZE = max((self.app.size.height // 100 + 1) * 100, 100)
|
|
321
348
|
self.loaded_rows = 0 # Track how many rows are currently loaded
|
|
349
|
+
self.loaded_ranges: list[tuple[int, int]] = [] # List of (start, end) row indices that are loaded
|
|
322
350
|
|
|
323
351
|
# State tracking (all 0-based indexing)
|
|
324
|
-
self.sorted_columns: dict[str, bool] = {} # col_name -> descending
|
|
325
352
|
self.hidden_columns: set[str] = set() # Set of hidden column names
|
|
326
|
-
self.selected_rows:
|
|
327
|
-
self.
|
|
328
|
-
self.matches: dict[int, set[
|
|
353
|
+
self.selected_rows: set[int] = set() # Track selected rows by RID
|
|
354
|
+
self.sorted_columns: dict[str, bool] = {} # col_name -> descending
|
|
355
|
+
self.matches: dict[int, set[str]] = defaultdict(set) # Track search matches: RID -> set of col_names
|
|
329
356
|
|
|
330
357
|
# Freezing
|
|
331
358
|
self.fixed_rows = 0 # Number of fixed rows
|
|
332
359
|
self.fixed_columns = 0 # Number of fixed columns
|
|
333
360
|
|
|
334
361
|
# History stack for undo
|
|
335
|
-
self.
|
|
336
|
-
#
|
|
337
|
-
self.
|
|
362
|
+
self.histories_undo: deque[History] = deque()
|
|
363
|
+
# History stack for redo
|
|
364
|
+
self.histories_redo: deque[History] = deque()
|
|
338
365
|
|
|
339
366
|
# Whether to use thousand separator for numeric display
|
|
340
367
|
self.thousand_separator = False
|
|
341
368
|
|
|
369
|
+
# Set of columns expanded to full width
|
|
370
|
+
self.expanded_columns: set[str] = set()
|
|
371
|
+
|
|
372
|
+
# Whether to show internal row index column
|
|
373
|
+
self.show_rid = False
|
|
374
|
+
|
|
342
375
|
@property
|
|
343
376
|
def cursor_key(self) -> CellKey:
|
|
344
377
|
"""Get the current cursor position as a CellKey.
|
|
@@ -405,7 +438,7 @@ class DataFrameTable(DataTable):
|
|
|
405
438
|
|
|
406
439
|
@property
|
|
407
440
|
def cursor_value(self) -> Any:
|
|
408
|
-
"""Get the current cursor cell value.
|
|
441
|
+
"""Get the current cursor cell value in the dataframe.
|
|
409
442
|
|
|
410
443
|
Returns:
|
|
411
444
|
Any: The value of the cell at the cursor position.
|
|
@@ -419,7 +452,7 @@ class DataFrameTable(DataTable):
|
|
|
419
452
|
Returns:
|
|
420
453
|
list[int]: A list of 0-based row indices that are currently selected.
|
|
421
454
|
"""
|
|
422
|
-
return [ridx for ridx,
|
|
455
|
+
return [ridx for ridx, rid in enumerate(self.df[RID]) if rid in self.selected_rows]
|
|
423
456
|
|
|
424
457
|
@property
|
|
425
458
|
def ordered_matches(self) -> list[tuple[int, int]]:
|
|
@@ -429,19 +462,38 @@ class DataFrameTable(DataTable):
|
|
|
429
462
|
list[tuple[int, int]]: A list of (row_idx, col_idx) tuples for matched cells.
|
|
430
463
|
"""
|
|
431
464
|
matches = []
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
465
|
+
|
|
466
|
+
# Uniq columns
|
|
467
|
+
cols_to_check = set()
|
|
468
|
+
for cols in self.matches.values():
|
|
469
|
+
cols_to_check.update(cols)
|
|
470
|
+
|
|
471
|
+
# Ordered columns
|
|
472
|
+
cidx2col = {cidx: col for cidx, col in enumerate(self.df.columns) if col in cols_to_check}
|
|
473
|
+
|
|
474
|
+
for ridx, rid in enumerate(self.df[RID]):
|
|
475
|
+
if cols := self.matches.get(rid):
|
|
476
|
+
for cidx, col in cidx2col.items():
|
|
477
|
+
if col in cols:
|
|
478
|
+
matches.append((ridx, cidx))
|
|
479
|
+
|
|
435
480
|
return matches
|
|
436
481
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
"""Get the last history state.
|
|
482
|
+
def _round_to_nearest_hundreds(self, num: int):
|
|
483
|
+
"""Round a number to the nearest hundreds.
|
|
440
484
|
|
|
441
|
-
|
|
442
|
-
|
|
485
|
+
Args:
|
|
486
|
+
num: The number to round.
|
|
443
487
|
"""
|
|
444
|
-
return
|
|
488
|
+
return round_to_nearest_hundreds(num, N=self.BATCH_SIZE)
|
|
489
|
+
|
|
490
|
+
def get_row_idx(self, row_key: RowKey) -> int:
|
|
491
|
+
"""Get the row index for a given table row key.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
row_key: Row key as string.
|
|
495
|
+
"""
|
|
496
|
+
return super().get_row_index(row_key)
|
|
445
497
|
|
|
446
498
|
def get_row_key(self, row_idx: int) -> RowKey:
|
|
447
499
|
"""Get the row key for a given table row index.
|
|
@@ -454,7 +506,18 @@ class DataFrameTable(DataTable):
|
|
|
454
506
|
"""
|
|
455
507
|
return self._row_locations.get_key(row_idx)
|
|
456
508
|
|
|
457
|
-
def
|
|
509
|
+
def get_col_idx(self, col_key: ColumnKey) -> int:
|
|
510
|
+
"""Get the column index for a given table column key.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
col_key: Column key as string.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
Corresponding column index as int.
|
|
517
|
+
"""
|
|
518
|
+
return super().get_column_index(col_key)
|
|
519
|
+
|
|
520
|
+
def get_col_key(self, col_idx: int) -> ColumnKey:
|
|
458
521
|
"""Get the column key for a given table column index.
|
|
459
522
|
|
|
460
523
|
Args:
|
|
@@ -465,11 +528,11 @@ class DataFrameTable(DataTable):
|
|
|
465
528
|
"""
|
|
466
529
|
return self._column_locations.get_key(col_idx)
|
|
467
530
|
|
|
468
|
-
def
|
|
531
|
+
def _should_highlight(self, cursor: Coordinate, target_cell: Coordinate, type_of_cursor: CursorType) -> bool:
|
|
469
532
|
"""Determine if the given cell should be highlighted because of the cursor.
|
|
470
533
|
|
|
471
|
-
In "cell" mode, also highlights the row and column headers.
|
|
472
|
-
|
|
534
|
+
In "cell" mode, also highlights the row and column headers. This overrides the default
|
|
535
|
+
behavior of DataTable which only highlights the exact cell under the cursor.
|
|
473
536
|
|
|
474
537
|
Args:
|
|
475
538
|
cursor: The current position of the cursor.
|
|
@@ -566,7 +629,7 @@ class DataFrameTable(DataTable):
|
|
|
566
629
|
else:
|
|
567
630
|
content_tab.remove_class("dirty")
|
|
568
631
|
|
|
569
|
-
def move_cursor_to(self, ridx: int, cidx: int) -> None:
|
|
632
|
+
def move_cursor_to(self, ridx: int | None = None, cidx: int | None = None) -> None:
|
|
570
633
|
"""Move cursor based on the dataframe indices.
|
|
571
634
|
|
|
572
635
|
Args:
|
|
@@ -574,11 +637,11 @@ class DataFrameTable(DataTable):
|
|
|
574
637
|
cidx: Column index (0-based) in the dataframe.
|
|
575
638
|
"""
|
|
576
639
|
# Ensure the target row is loaded
|
|
577
|
-
|
|
578
|
-
|
|
640
|
+
start, stop = self._round_to_nearest_hundreds(ridx)
|
|
641
|
+
self.load_rows_range(start, stop)
|
|
579
642
|
|
|
580
|
-
row_key = str(ridx)
|
|
581
|
-
col_key = self.df.columns[cidx]
|
|
643
|
+
row_key = self.cursor_row_key if ridx is None else str(ridx)
|
|
644
|
+
col_key = self.cursor_col_key if cidx is None else self.df.columns[cidx]
|
|
582
645
|
row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
|
|
583
646
|
self.move_cursor(row=row_idx, column=col_idx)
|
|
584
647
|
|
|
@@ -594,15 +657,15 @@ class DataFrameTable(DataTable):
|
|
|
594
657
|
def on_key(self, event) -> None:
|
|
595
658
|
"""Handle key press events for pagination.
|
|
596
659
|
|
|
597
|
-
Currently handles "pagedown" and "down" keys to trigger lazy loading of additional rows
|
|
598
|
-
when scrolling near the end of the loaded data.
|
|
599
|
-
|
|
600
660
|
Args:
|
|
601
661
|
event: The key event object.
|
|
602
662
|
"""
|
|
603
|
-
if event.key
|
|
663
|
+
if event.key == "up":
|
|
604
664
|
# Let the table handle the navigation first
|
|
605
|
-
self.
|
|
665
|
+
self.load_rows_up()
|
|
666
|
+
elif event.key == "down":
|
|
667
|
+
# Let the table handle the navigation first
|
|
668
|
+
self.load_rows_down()
|
|
606
669
|
|
|
607
670
|
def on_click(self, event: Click) -> None:
|
|
608
671
|
"""Handle mouse click events on the table.
|
|
@@ -615,33 +678,32 @@ class DataFrameTable(DataTable):
|
|
|
615
678
|
if self.cursor_type == "cell" and event.chain > 1: # only on double-click or more
|
|
616
679
|
try:
|
|
617
680
|
row_idx = event.style.meta["row"]
|
|
618
|
-
|
|
681
|
+
col_idx = event.style.meta["column"]
|
|
619
682
|
except (KeyError, TypeError):
|
|
620
683
|
return # Unable to get row/column info
|
|
621
684
|
|
|
622
685
|
# header row
|
|
623
686
|
if row_idx == -1:
|
|
624
|
-
self.do_rename_column()
|
|
687
|
+
self.do_rename_column(col_idx)
|
|
625
688
|
else:
|
|
626
689
|
self.do_edit_cell()
|
|
627
690
|
|
|
628
691
|
# Action handlers for BINDINGS
|
|
629
692
|
def action_jump_top(self) -> None:
|
|
630
693
|
"""Jump to the top of the table."""
|
|
631
|
-
self.
|
|
694
|
+
self.do_jump_top()
|
|
632
695
|
|
|
633
696
|
def action_jump_bottom(self) -> None:
|
|
634
697
|
"""Jump to the bottom of the table."""
|
|
635
|
-
self.
|
|
698
|
+
self.do_jump_bottom()
|
|
636
699
|
|
|
637
|
-
def
|
|
638
|
-
"""
|
|
639
|
-
|
|
640
|
-
self.check_and_load_more()
|
|
700
|
+
def action_page_up(self) -> None:
|
|
701
|
+
"""Move the cursor one page up."""
|
|
702
|
+
self.do_page_up()
|
|
641
703
|
|
|
642
|
-
def
|
|
643
|
-
"""
|
|
644
|
-
|
|
704
|
+
def action_page_down(self) -> None:
|
|
705
|
+
"""Move the cursor one page down."""
|
|
706
|
+
self.do_page_down()
|
|
645
707
|
|
|
646
708
|
def action_view_row_detail(self) -> None:
|
|
647
709
|
"""View details of the current row."""
|
|
@@ -659,6 +721,10 @@ class DataFrameTable(DataTable):
|
|
|
659
721
|
"""Expand the current column to its full width."""
|
|
660
722
|
self.do_expand_column()
|
|
661
723
|
|
|
724
|
+
def action_toggle_rid(self) -> None:
|
|
725
|
+
"""Toggle the internal row index column visibility."""
|
|
726
|
+
self.do_toggle_rid()
|
|
727
|
+
|
|
662
728
|
def action_show_hidden_rows_columns(self) -> None:
|
|
663
729
|
"""Show all hidden rows/columns."""
|
|
664
730
|
self.do_show_hidden_rows_columns()
|
|
@@ -687,6 +753,14 @@ class DataFrameTable(DataTable):
|
|
|
687
753
|
"""
|
|
688
754
|
self.do_show_statistics(scope)
|
|
689
755
|
|
|
756
|
+
def action_metadata_shape(self) -> None:
|
|
757
|
+
"""Show metadata about the dataframe (row and column counts)."""
|
|
758
|
+
self.do_metadata_shape()
|
|
759
|
+
|
|
760
|
+
def action_metadata_column(self) -> None:
|
|
761
|
+
"""Show metadata for the current column."""
|
|
762
|
+
self.do_metadata_column()
|
|
763
|
+
|
|
690
764
|
def action_view_rows(self) -> None:
|
|
691
765
|
"""View rows by current cell value."""
|
|
692
766
|
self.do_view_rows()
|
|
@@ -723,13 +797,13 @@ class DataFrameTable(DataTable):
|
|
|
723
797
|
"""Clear the current cell (set to None)."""
|
|
724
798
|
self.do_clear_cell()
|
|
725
799
|
|
|
726
|
-
def
|
|
727
|
-
"""
|
|
728
|
-
self.
|
|
800
|
+
def action_select_row(self) -> None:
|
|
801
|
+
"""Select rows with cursor value in the current column."""
|
|
802
|
+
self.do_select_row()
|
|
729
803
|
|
|
730
|
-
def
|
|
731
|
-
"""
|
|
732
|
-
self.
|
|
804
|
+
def action_select_row_expr(self) -> None:
|
|
805
|
+
"""Select rows by expression."""
|
|
806
|
+
self.do_select_row_expr()
|
|
733
807
|
|
|
734
808
|
def action_find_cursor_value(self, scope="column") -> None:
|
|
735
809
|
"""Find by cursor value.
|
|
@@ -831,7 +905,7 @@ class DataFrameTable(DataTable):
|
|
|
831
905
|
"""Toggle row labels visibility."""
|
|
832
906
|
self.show_row_labels = not self.show_row_labels
|
|
833
907
|
# status = "shown" if self.show_row_labels else "hidden"
|
|
834
|
-
# self.notify(f"Row labels {status}", title="Labels")
|
|
908
|
+
# self.notify(f"Row labels {status}", title="Toggle Row Labels")
|
|
835
909
|
|
|
836
910
|
def action_cast_column_dtype(self, dtype: str | pl.DataType) -> None:
|
|
837
911
|
"""Cast the current column to a different data type."""
|
|
@@ -846,7 +920,12 @@ class DataFrameTable(DataTable):
|
|
|
846
920
|
cell_str = str(self.df.item(ridx, cidx))
|
|
847
921
|
self.do_copy_to_clipboard(cell_str, f"Copied: [$success]{cell_str[:50]}[/]")
|
|
848
922
|
except IndexError:
|
|
849
|
-
self.notify(
|
|
923
|
+
self.notify(
|
|
924
|
+
f"Error copying cell ([$error]{ridx}[/], [$accent]{cidx}[/]) to clipboard",
|
|
925
|
+
title="Copy Cell",
|
|
926
|
+
severity="error",
|
|
927
|
+
timeout=10,
|
|
928
|
+
)
|
|
850
929
|
|
|
851
930
|
def action_copy_column(self) -> None:
|
|
852
931
|
"""Copy the current column to clipboard (one value per line)."""
|
|
@@ -862,7 +941,12 @@ class DataFrameTable(DataTable):
|
|
|
862
941
|
f"Copied [$accent]{len(col_values)}[/] values from column [$success]{col_name}[/]",
|
|
863
942
|
)
|
|
864
943
|
except (FileNotFoundError, IndexError):
|
|
865
|
-
self.notify(
|
|
944
|
+
self.notify(
|
|
945
|
+
f"Error copying column [$error]{col_name}[/] to clipboard",
|
|
946
|
+
title="Copy Column",
|
|
947
|
+
severity="error",
|
|
948
|
+
timeout=10,
|
|
949
|
+
)
|
|
866
950
|
|
|
867
951
|
def action_copy_row(self) -> None:
|
|
868
952
|
"""Copy the current row to clipboard (values separated by tabs)."""
|
|
@@ -878,14 +962,16 @@ class DataFrameTable(DataTable):
|
|
|
878
962
|
f"Copied row [$accent]{ridx + 1}[/] with [$success]{len(row_values)}[/] values",
|
|
879
963
|
)
|
|
880
964
|
except (FileNotFoundError, IndexError):
|
|
881
|
-
self.notify(
|
|
965
|
+
self.notify(
|
|
966
|
+
f"Error copying row [$error]{ridx}[/] to clipboard", title="Copy Row", severity="error", timeout=10
|
|
967
|
+
)
|
|
882
968
|
|
|
883
|
-
def
|
|
969
|
+
def action_toggle_thousand_separator(self) -> None:
|
|
884
970
|
"""Toggle thousand separator for numeric display."""
|
|
885
971
|
self.thousand_separator = not self.thousand_separator
|
|
886
972
|
self.setup_table()
|
|
887
973
|
# status = "enabled" if self.thousand_separator else "disabled"
|
|
888
|
-
# self.notify(f"Thousand separator {status}", title="
|
|
974
|
+
# self.notify(f"Thousand separator {status}", title="Toggle Thousand Separator")
|
|
889
975
|
|
|
890
976
|
def action_next_match(self) -> None:
|
|
891
977
|
"""Go to the next matched cell."""
|
|
@@ -911,56 +997,50 @@ class DataFrameTable(DataTable):
|
|
|
911
997
|
"""Open the advanced SQL interface screen."""
|
|
912
998
|
self.do_advanced_sql()
|
|
913
999
|
|
|
1000
|
+
def on_mouse_scroll_up(self, event) -> None:
|
|
1001
|
+
"""Load more rows when scrolling up with mouse."""
|
|
1002
|
+
self.load_rows_up()
|
|
1003
|
+
|
|
914
1004
|
def on_mouse_scroll_down(self, event) -> None:
|
|
915
1005
|
"""Load more rows when scrolling down with mouse."""
|
|
916
|
-
self.
|
|
1006
|
+
self.load_rows_down()
|
|
917
1007
|
|
|
918
1008
|
# Setup & Loading
|
|
919
|
-
def
|
|
1009
|
+
def reset_df(self, new_df: pl.DataFrame, dirty: bool = True) -> None:
|
|
1010
|
+
"""Reset the dataframe to a new one and refresh the table.
|
|
1011
|
+
|
|
1012
|
+
Args:
|
|
1013
|
+
new_df: The new Polars DataFrame to set.
|
|
1014
|
+
dirty: Whether to mark the table as dirty (unsaved changes). Defaults to True.
|
|
1015
|
+
"""
|
|
1016
|
+
# Set new dataframe and reset table
|
|
1017
|
+
self.df = new_df
|
|
1018
|
+
self.loaded_rows = 0
|
|
1019
|
+
self.hidden_columns = set()
|
|
1020
|
+
self.selected_rows = set()
|
|
1021
|
+
self.sorted_columns = {}
|
|
1022
|
+
self.fixed_rows = 0
|
|
1023
|
+
self.fixed_columns = 0
|
|
1024
|
+
self.matches = defaultdict(set)
|
|
1025
|
+
# self.histories.clear()
|
|
1026
|
+
# self.histories2.clear()
|
|
1027
|
+
self.dirty = dirty # Mark as dirty since data changed
|
|
1028
|
+
|
|
1029
|
+
def setup_table(self) -> None:
|
|
920
1030
|
"""Setup the table for display.
|
|
921
1031
|
|
|
922
1032
|
Row keys are 0-based indices, which map directly to dataframe row indices.
|
|
923
1033
|
Column keys are header names from the dataframe.
|
|
924
1034
|
"""
|
|
925
1035
|
self.loaded_rows = 0
|
|
1036
|
+
self.loaded_ranges.clear()
|
|
926
1037
|
self.show_row_labels = True
|
|
927
1038
|
|
|
928
|
-
# Reset to original dataframe
|
|
929
|
-
if reset:
|
|
930
|
-
self.df = self.dataframe
|
|
931
|
-
self.loaded_rows = 0
|
|
932
|
-
self.sorted_columns = {}
|
|
933
|
-
self.hidden_columns = set()
|
|
934
|
-
self.selected_rows = [False] * len(self.df)
|
|
935
|
-
self.visible_rows = [True] * len(self.df)
|
|
936
|
-
self.fixed_rows = 0
|
|
937
|
-
self.fixed_columns = 0
|
|
938
|
-
self.matches = defaultdict(set)
|
|
939
|
-
self.histories.clear()
|
|
940
|
-
self.history = None
|
|
941
|
-
self.dirty = False
|
|
942
|
-
|
|
943
|
-
# Lazy load up to INITIAL_BATCH_SIZE visible rows
|
|
944
|
-
stop, visible_count = self.INITIAL_BATCH_SIZE, 0
|
|
945
|
-
for row_idx, visible in enumerate(self.visible_rows):
|
|
946
|
-
if not visible:
|
|
947
|
-
continue
|
|
948
|
-
visible_count += 1
|
|
949
|
-
if visible_count > self.INITIAL_BATCH_SIZE:
|
|
950
|
-
stop = row_idx + self.BATCH_SIZE
|
|
951
|
-
break
|
|
952
|
-
else:
|
|
953
|
-
stop = row_idx + self.BATCH_SIZE
|
|
954
|
-
|
|
955
|
-
# # Ensure all selected rows or matches are loaded
|
|
956
|
-
# stop = max(stop, rindex(self.selected_rows, True) + 1)
|
|
957
|
-
# stop = max(stop, max(self.matches.keys(), default=0) + 1)
|
|
958
|
-
|
|
959
1039
|
# Save current cursor position before clearing
|
|
960
1040
|
row_idx, col_idx = self.cursor_coordinate
|
|
961
1041
|
|
|
962
1042
|
self.setup_columns()
|
|
963
|
-
self.
|
|
1043
|
+
self.load_rows_range(0, self.BATCH_SIZE) # Load initial rows
|
|
964
1044
|
|
|
965
1045
|
# Restore cursor position
|
|
966
1046
|
if row_idx < len(self.rows) and col_idx < len(self.columns):
|
|
@@ -980,20 +1060,20 @@ class DataFrameTable(DataTable):
|
|
|
980
1060
|
Returns:
|
|
981
1061
|
dict[str, int]: Mapping of column name to width (None for auto-sizing columns).
|
|
982
1062
|
"""
|
|
983
|
-
|
|
1063
|
+
col_widths, col_label_widths = {}, {}
|
|
984
1064
|
|
|
985
1065
|
# Get available width for the table (with some padding for borders/scrollbar)
|
|
986
|
-
available_width = self.
|
|
1066
|
+
available_width = self.scrollable_content_region.width
|
|
987
1067
|
|
|
988
1068
|
# Calculate how much width we need for string columns first
|
|
989
1069
|
string_cols = [col for col, dtype in zip(self.df.columns, self.df.dtypes) if dtype == pl.String]
|
|
990
1070
|
|
|
991
1071
|
# No string columns, let TextualDataTable auto-size all columns
|
|
992
1072
|
if not string_cols:
|
|
993
|
-
return
|
|
1073
|
+
return col_widths
|
|
994
1074
|
|
|
995
1075
|
# Sample a reasonable number of rows to calculate widths (don't scan entire dataframe)
|
|
996
|
-
sample_size = min(self.
|
|
1076
|
+
sample_size = min(self.BATCH_SIZE, len(self.df))
|
|
997
1077
|
sample_lf = self.df.lazy().slice(0, sample_size)
|
|
998
1078
|
|
|
999
1079
|
# Determine widths for each column
|
|
@@ -1004,37 +1084,42 @@ class DataFrameTable(DataTable):
|
|
|
1004
1084
|
# Get column label width
|
|
1005
1085
|
# Add padding for sort indicators if any
|
|
1006
1086
|
label_width = measure(self.app.console, col, 1) + 2
|
|
1087
|
+
col_label_widths[col] = label_width
|
|
1088
|
+
|
|
1089
|
+
# Let Textual auto-size for non-string columns and already expanded columns
|
|
1090
|
+
if dtype != pl.String or col in self.expanded_columns:
|
|
1091
|
+
available_width -= label_width
|
|
1092
|
+
continue
|
|
1007
1093
|
|
|
1008
1094
|
try:
|
|
1009
1095
|
# Get sample values from the column
|
|
1010
|
-
sample_values = sample_lf.select(col).collect().get_column(col).to_list()
|
|
1096
|
+
sample_values = sample_lf.select(col).collect().get_column(col).drop_nulls().to_list()
|
|
1011
1097
|
if any(val.startswith(("https://", "http://")) for val in sample_values):
|
|
1012
1098
|
continue # Skip link columns so they can auto-size and be clickable
|
|
1013
1099
|
|
|
1014
1100
|
# Find maximum width in sample
|
|
1015
1101
|
max_cell_width = max(
|
|
1016
|
-
(measure(self.app.console,
|
|
1102
|
+
(measure(self.app.console, val, 1) for val in sample_values),
|
|
1017
1103
|
default=label_width,
|
|
1018
1104
|
)
|
|
1019
1105
|
|
|
1020
1106
|
# Set column width to max of label and sampled data (capped at reasonable max)
|
|
1021
1107
|
max_width = max(label_width, max_cell_width)
|
|
1022
|
-
except Exception:
|
|
1108
|
+
except Exception as e:
|
|
1023
1109
|
# If any error, let Textual auto-size
|
|
1024
1110
|
max_width = label_width
|
|
1111
|
+
self.log(f"Error determining width for column '{col}': {e}")
|
|
1025
1112
|
|
|
1026
|
-
|
|
1027
|
-
column_widths[col] = max_width
|
|
1028
|
-
|
|
1113
|
+
col_widths[col] = max_width
|
|
1029
1114
|
available_width -= max_width
|
|
1030
1115
|
|
|
1031
1116
|
# If there's no more available width, auto-size remaining columns
|
|
1032
1117
|
if available_width < 0:
|
|
1033
|
-
for col in
|
|
1034
|
-
if
|
|
1035
|
-
|
|
1118
|
+
for col in col_widths:
|
|
1119
|
+
if col_widths[col] > STRING_WIDTH_CAP and col_label_widths[col] < STRING_WIDTH_CAP:
|
|
1120
|
+
col_widths[col] = STRING_WIDTH_CAP # Cap string columns
|
|
1036
1121
|
|
|
1037
|
-
return
|
|
1122
|
+
return col_widths
|
|
1038
1123
|
|
|
1039
1124
|
def setup_columns(self) -> None:
|
|
1040
1125
|
"""Clear table and setup columns.
|
|
@@ -1049,8 +1134,8 @@ class DataFrameTable(DataTable):
|
|
|
1049
1134
|
|
|
1050
1135
|
# Add columns with justified headers
|
|
1051
1136
|
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
1052
|
-
if col in self.hidden_columns:
|
|
1053
|
-
continue # Skip hidden columns
|
|
1137
|
+
if col in self.hidden_columns or (col == RID and not self.show_rid):
|
|
1138
|
+
continue # Skip hidden columns and internal RID
|
|
1054
1139
|
for idx, c in enumerate(self.sorted_columns, 1):
|
|
1055
1140
|
if c == col:
|
|
1056
1141
|
# Add sort indicator to column header
|
|
@@ -1068,178 +1153,393 @@ class DataFrameTable(DataTable):
|
|
|
1068
1153
|
|
|
1069
1154
|
self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
|
|
1070
1155
|
|
|
1071
|
-
def
|
|
1072
|
-
"""
|
|
1156
|
+
def _calculate_load_range(self, start: int, stop: int) -> list[tuple[int, int]]:
|
|
1157
|
+
"""Calculate the actual ranges to load, accounting for already-loaded ranges.
|
|
1158
|
+
|
|
1159
|
+
Handles complex cases where a loaded range is fully contained within the requested
|
|
1160
|
+
range (creating head and tail segments to load). All overlapping/adjacent loaded
|
|
1161
|
+
ranges are merged first to minimize gaps.
|
|
1073
1162
|
|
|
1074
1163
|
Args:
|
|
1075
|
-
|
|
1076
|
-
|
|
1164
|
+
start: Requested start index (0-based).
|
|
1165
|
+
stop: Requested stop index (0-based, exclusive).
|
|
1166
|
+
|
|
1167
|
+
Returns:
|
|
1168
|
+
List of (actual_start, actual_stop) tuples to load. Empty list if the entire
|
|
1169
|
+
requested range is already loaded.
|
|
1170
|
+
|
|
1171
|
+
Example:
|
|
1172
|
+
If loaded ranges are [(150, 250)] and requesting (100, 300):
|
|
1173
|
+
- Returns [(100, 150), (250, 300)] to load head and tail
|
|
1174
|
+
If loaded ranges are [(0, 100), (100, 200)] and requesting (50, 150):
|
|
1175
|
+
- After merging, loaded_ranges becomes [(0, 200)]
|
|
1176
|
+
- Returns [] (already fully loaded)
|
|
1077
1177
|
"""
|
|
1078
|
-
if
|
|
1079
|
-
|
|
1178
|
+
if not self.loaded_ranges:
|
|
1179
|
+
return [(start, stop)]
|
|
1180
|
+
|
|
1181
|
+
# Sort loaded ranges by start index
|
|
1182
|
+
sorted_ranges = sorted(self.loaded_ranges)
|
|
1183
|
+
|
|
1184
|
+
# Merge overlapping/adjacent ranges
|
|
1185
|
+
merged = []
|
|
1186
|
+
for range_start, range_stop in sorted_ranges:
|
|
1187
|
+
# Fully covered, no need to load anything
|
|
1188
|
+
if range_start <= start and range_stop >= stop:
|
|
1189
|
+
return []
|
|
1190
|
+
# Overlapping or adjacent: merge
|
|
1191
|
+
elif merged and range_start <= merged[-1][1]:
|
|
1192
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], range_stop))
|
|
1193
|
+
else:
|
|
1194
|
+
merged.append((range_start, range_stop))
|
|
1195
|
+
|
|
1196
|
+
self.loaded_ranges = merged
|
|
1197
|
+
|
|
1198
|
+
# Calculate ranges to load by finding gaps in the merged ranges
|
|
1199
|
+
ranges_to_load = []
|
|
1200
|
+
current_pos = start
|
|
1201
|
+
|
|
1202
|
+
for range_start, range_stop in merged:
|
|
1203
|
+
# If there's a gap before this loaded range, add it to load list
|
|
1204
|
+
if current_pos < range_start and current_pos < stop:
|
|
1205
|
+
gap_end = min(range_start, stop)
|
|
1206
|
+
ranges_to_load.append((current_pos, gap_end))
|
|
1207
|
+
current_pos = range_stop
|
|
1208
|
+
elif current_pos >= range_stop:
|
|
1209
|
+
# Already moved past this loaded range
|
|
1210
|
+
continue
|
|
1211
|
+
else:
|
|
1212
|
+
# Current position is inside this loaded range, skip past it
|
|
1213
|
+
current_pos = max(current_pos, range_stop)
|
|
1214
|
+
|
|
1215
|
+
# If there's remaining range after all loaded ranges, add it
|
|
1216
|
+
if current_pos < stop:
|
|
1217
|
+
ranges_to_load.append((current_pos, stop))
|
|
1218
|
+
|
|
1219
|
+
return ranges_to_load
|
|
1080
1220
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
if move_to_end:
|
|
1084
|
-
self.move_cursor(row=self.row_count - 1)
|
|
1221
|
+
def _merge_loaded_ranges(self) -> None:
|
|
1222
|
+
"""Merge adjacent and overlapping ranges in self.loaded_ranges.
|
|
1085
1223
|
|
|
1224
|
+
Ranges like (0, 100) and (100, 200) are merged into (0, 200).
|
|
1225
|
+
"""
|
|
1226
|
+
if len(self.loaded_ranges) <= 1:
|
|
1086
1227
|
return
|
|
1087
1228
|
|
|
1088
|
-
#
|
|
1089
|
-
|
|
1229
|
+
# Sort by start index
|
|
1230
|
+
sorted_ranges = sorted(self.loaded_ranges)
|
|
1090
1231
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1232
|
+
# Merge overlapping/adjacent ranges
|
|
1233
|
+
merged = [sorted_ranges[0]]
|
|
1234
|
+
for range_start, range_stop in sorted_ranges[1:]:
|
|
1235
|
+
# Overlapping or adjacent: merge
|
|
1236
|
+
if range_start <= merged[-1][1]:
|
|
1237
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], range_stop))
|
|
1238
|
+
else:
|
|
1239
|
+
merged.append((range_start, range_stop))
|
|
1094
1240
|
|
|
1095
|
-
|
|
1096
|
-
ConfirmScreen(
|
|
1097
|
-
f"Load {nrows} Rows",
|
|
1098
|
-
label="Loading a large number of rows may cause the application to become unresponsive. Do you want to continue?",
|
|
1099
|
-
),
|
|
1100
|
-
callback=_continue,
|
|
1101
|
-
)
|
|
1241
|
+
self.loaded_ranges = merged
|
|
1102
1242
|
|
|
1103
|
-
|
|
1243
|
+
def _find_insert_position_for_row(self, ridx: int) -> int:
|
|
1244
|
+
"""Find the correct table position to insert a row with the given dataframe index.
|
|
1245
|
+
|
|
1246
|
+
In the table display, rows are ordered by their dataframe index, regardless of
|
|
1247
|
+
the internal row keys. This method finds where a row should be inserted based on
|
|
1248
|
+
its dataframe index and the indices of already-loaded rows.
|
|
1249
|
+
|
|
1250
|
+
Args:
|
|
1251
|
+
ridx: The 0-based dataframe row index.
|
|
1252
|
+
|
|
1253
|
+
Returns:
|
|
1254
|
+
The 0-based table position where the row should be inserted.
|
|
1255
|
+
"""
|
|
1256
|
+
# Count how many already-loaded rows have lower dataframe indices
|
|
1257
|
+
# Iterate through loaded rows instead of iterating 0..ridx for efficiency
|
|
1258
|
+
insert_pos = 0
|
|
1259
|
+
for row_key in self._row_locations:
|
|
1260
|
+
loaded_ridx = int(row_key.value)
|
|
1261
|
+
if loaded_ridx < ridx:
|
|
1262
|
+
insert_pos += 1
|
|
1104
1263
|
|
|
1105
|
-
|
|
1106
|
-
self.load_rows_async(stop, move_to_end=move_to_end)
|
|
1264
|
+
return insert_pos
|
|
1107
1265
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1266
|
+
def load_rows_segment(self, segment_start: int, segment_stop: int) -> int:
|
|
1267
|
+
"""Load a single contiguous segment of rows into the table.
|
|
1268
|
+
|
|
1269
|
+
This is the core loading logic that inserts rows at correct positions,
|
|
1270
|
+
respecting visibility and selection states. Used by load_rows_range()
|
|
1271
|
+
to handle each segment independently.
|
|
1111
1272
|
|
|
1112
1273
|
Args:
|
|
1113
|
-
|
|
1114
|
-
|
|
1274
|
+
segment_start: Start loading rows from this index (0-based).
|
|
1275
|
+
segment_stop: Stop loading rows when this index is reached (0-based, exclusive).
|
|
1115
1276
|
"""
|
|
1116
|
-
#
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1277
|
+
# Record this range before loading
|
|
1278
|
+
self.loaded_ranges.append((segment_start, segment_stop))
|
|
1279
|
+
|
|
1280
|
+
# Load the dataframe slice
|
|
1281
|
+
df_slice = self.df.slice(segment_start, segment_stop - segment_start)
|
|
1282
|
+
|
|
1283
|
+
# Load each row at the correct position
|
|
1284
|
+
for (ridx, row), rid in zip(enumerate(df_slice.rows(), segment_start), df_slice[RID]):
|
|
1285
|
+
is_selected = rid in self.selected_rows
|
|
1286
|
+
match_cols = self.matches.get(rid, set())
|
|
1287
|
+
|
|
1288
|
+
vals, dtypes, styles = [], [], []
|
|
1289
|
+
for val, col, dtype in zip(row, self.df.columns, self.df.dtypes, strict=True):
|
|
1290
|
+
if col in self.hidden_columns or (col == RID and not self.show_rid):
|
|
1291
|
+
continue # Skip hidden columns and internal RID
|
|
1292
|
+
|
|
1293
|
+
vals.append(val)
|
|
1294
|
+
dtypes.append(dtype)
|
|
1295
|
+
|
|
1296
|
+
# Highlight entire row with selection or cells with matches
|
|
1297
|
+
styles.append(HIGHLIGHT_COLOR if is_selected or col in match_cols else None)
|
|
1130
1298
|
|
|
1131
|
-
|
|
1132
|
-
if move_to_end:
|
|
1133
|
-
self.call_after_refresh(lambda: self.move_cursor(row=self.row_count - 1))
|
|
1299
|
+
formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
|
|
1134
1300
|
|
|
1135
|
-
|
|
1301
|
+
# Find correct insertion position and insert
|
|
1302
|
+
insert_pos = self._find_insert_position_for_row(ridx)
|
|
1303
|
+
self.insert_row(*formatted_row, key=str(ridx), label=str(ridx + 1), position=insert_pos)
|
|
1304
|
+
|
|
1305
|
+
# Number of rows loaded in this segment
|
|
1306
|
+
segment_count = len(df_slice)
|
|
1307
|
+
|
|
1308
|
+
# Update loaded rows count
|
|
1309
|
+
self.loaded_rows += segment_count
|
|
1310
|
+
|
|
1311
|
+
return segment_count
|
|
1312
|
+
|
|
1313
|
+
def load_rows_range(self, start: int, stop: int) -> int:
|
|
1136
1314
|
"""Load a batch of rows into the table.
|
|
1137
1315
|
|
|
1138
1316
|
Row keys are 0-based indices as strings, which map directly to dataframe row indices.
|
|
1139
1317
|
Row labels are 1-based indices as strings.
|
|
1140
1318
|
|
|
1319
|
+
Intelligently handles range loading:
|
|
1320
|
+
1. Calculates which ranges actually need loading (avoiding reloading)
|
|
1321
|
+
2. Handles complex cases where loaded ranges create "holes" (head and tail segments)
|
|
1322
|
+
3. Inserts rows at correct positions in the table
|
|
1323
|
+
4. Merges adjacent/overlapping ranges to optimize future loading
|
|
1324
|
+
|
|
1141
1325
|
Args:
|
|
1142
|
-
|
|
1326
|
+
start: Start loading rows from this index (0-based).
|
|
1327
|
+
stop: Stop loading rows when this index is reached (0-based, exclusive).
|
|
1143
1328
|
"""
|
|
1329
|
+
start = max(0, start) # Clamp to non-negative
|
|
1330
|
+
stop = min(stop, len(self.df)) # Clamp to dataframe length
|
|
1331
|
+
|
|
1144
1332
|
try:
|
|
1145
|
-
|
|
1146
|
-
|
|
1333
|
+
# Calculate actual ranges to load, accounting for already-loaded ranges
|
|
1334
|
+
ranges_to_load = self._calculate_load_range(start, stop)
|
|
1147
1335
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1336
|
+
# If nothing needs loading, return early
|
|
1337
|
+
if not ranges_to_load:
|
|
1338
|
+
return 0 # Already loaded
|
|
1151
1339
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1340
|
+
# Track the number of loaded rows in this range
|
|
1341
|
+
range_count = 0
|
|
1154
1342
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
continue # Skip hidden columns
|
|
1343
|
+
# Load each segment
|
|
1344
|
+
for segment_start, segment_stop in ranges_to_load:
|
|
1345
|
+
range_count += self.load_rows_segment(segment_start, segment_stop)
|
|
1159
1346
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1347
|
+
# Merge adjacent/overlapping ranges to optimize storage
|
|
1348
|
+
self._merge_loaded_ranges()
|
|
1162
1349
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1350
|
+
self.log(f"Loaded {range_count} rows for range {start}-{stop}/{len(self.df)}")
|
|
1351
|
+
return range_count
|
|
1165
1352
|
|
|
1166
|
-
|
|
1353
|
+
except Exception as e:
|
|
1354
|
+
self.notify("Error loading rows", title="Load Rows", severity="error", timeout=10)
|
|
1355
|
+
self.log(f"Error loading rows: {str(e)}")
|
|
1356
|
+
return 0
|
|
1167
1357
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1358
|
+
def load_rows_up(self) -> None:
|
|
1359
|
+
"""Check if we need to load more rows and load them."""
|
|
1360
|
+
# If we've loaded everything, no need to check
|
|
1361
|
+
if self.loaded_rows >= len(self.df):
|
|
1362
|
+
return
|
|
1170
1363
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1364
|
+
top_row_index = int(self.scroll_y) + BUFFER_SIZE
|
|
1365
|
+
top_row_key = self.get_row_key(top_row_index)
|
|
1173
1366
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1367
|
+
if top_row_key:
|
|
1368
|
+
top_ridx = int(top_row_key.value)
|
|
1369
|
+
else:
|
|
1370
|
+
top_ridx = 0 # No top row key at index, default to 0
|
|
1176
1371
|
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1372
|
+
# Load upward
|
|
1373
|
+
start, stop = self._round_to_nearest_hundreds(top_ridx - BUFFER_SIZE * 2)
|
|
1374
|
+
range_count = self.load_rows_range(start, stop)
|
|
1375
|
+
|
|
1376
|
+
# Adjust scroll to maintain position if rows were loaded above
|
|
1377
|
+
if range_count > 0:
|
|
1378
|
+
self.move_cursor(row=top_row_index + range_count)
|
|
1379
|
+
self.log(f"Loaded up: {range_count} rows in range {start}-{stop}/{len(self.df)}")
|
|
1180
1380
|
|
|
1181
|
-
def
|
|
1381
|
+
def load_rows_down(self) -> None:
|
|
1182
1382
|
"""Check if we need to load more rows and load them."""
|
|
1183
1383
|
# If we've loaded everything, no need to check
|
|
1184
1384
|
if self.loaded_rows >= len(self.df):
|
|
1185
1385
|
return
|
|
1186
1386
|
|
|
1187
|
-
visible_row_count = self.
|
|
1188
|
-
|
|
1387
|
+
visible_row_count = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
|
|
1388
|
+
bottom_row_index = self.scroll_y + visible_row_count - BUFFER_SIZE
|
|
1389
|
+
|
|
1390
|
+
bottom_row_key = self.get_row_key(bottom_row_index)
|
|
1391
|
+
if bottom_row_key:
|
|
1392
|
+
bottom_ridx = int(bottom_row_key.value)
|
|
1393
|
+
else:
|
|
1394
|
+
bottom_ridx = 0 # No bottom row key at index, default to 0
|
|
1395
|
+
|
|
1396
|
+
# Load downward
|
|
1397
|
+
start, stop = self._round_to_nearest_hundreds(bottom_ridx + BUFFER_SIZE * 2)
|
|
1398
|
+
range_count = self.load_rows_range(start, stop)
|
|
1399
|
+
|
|
1400
|
+
if range_count > 0:
|
|
1401
|
+
self.log(f"Loaded down: {range_count} rows in range {start}-{stop}/{len(self.df)}")
|
|
1189
1402
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1403
|
+
def insert_row(
|
|
1404
|
+
self,
|
|
1405
|
+
*cells: CellType,
|
|
1406
|
+
height: int | None = 1,
|
|
1407
|
+
key: str | None = None,
|
|
1408
|
+
label: TextType | None = None,
|
|
1409
|
+
position: int | None = None,
|
|
1410
|
+
) -> RowKey:
|
|
1411
|
+
"""Insert a row at a specific position in the DataTable.
|
|
1193
1412
|
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
"""Update all rows, highlighting selected ones and restoring others to default.
|
|
1413
|
+
When inserting, all rows at and after the insertion position are shifted down,
|
|
1414
|
+
and their entries in self._row_locations are updated accordingly.
|
|
1197
1415
|
|
|
1198
1416
|
Args:
|
|
1199
|
-
|
|
1417
|
+
*cells: Positional arguments should contain cell data.
|
|
1418
|
+
height: The height of a row (in lines). Use `None` to auto-detect the optimal
|
|
1419
|
+
height.
|
|
1420
|
+
key: A key which uniquely identifies this row. If None, it will be generated
|
|
1421
|
+
for you and returned.
|
|
1422
|
+
label: The label for the row. Will be displayed to the left if supplied.
|
|
1423
|
+
position: The 0-based row index where the new row should be inserted.
|
|
1424
|
+
If None, inserts at the end (same as add_row). If out of bounds,
|
|
1425
|
+
inserts at the nearest valid position.
|
|
1426
|
+
|
|
1427
|
+
Returns:
|
|
1428
|
+
Unique identifier for this row. Can be used to retrieve this row regardless
|
|
1429
|
+
of its current location in the DataTable (it could have moved after
|
|
1430
|
+
being added due to sorting or insertion/deletion of other rows).
|
|
1431
|
+
|
|
1432
|
+
Raises:
|
|
1433
|
+
DuplicateKey: If a row with the given key already exists.
|
|
1434
|
+
ValueError: If more cells are provided than there are columns.
|
|
1200
1435
|
"""
|
|
1201
|
-
#
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
if
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1436
|
+
# Default to appending if position not specified or >= row_count
|
|
1437
|
+
row_count = self.row_count
|
|
1438
|
+
if position is None or position >= row_count:
|
|
1439
|
+
return self.add_row(*cells, height=height, key=key, label=label)
|
|
1440
|
+
|
|
1441
|
+
# Clamp position to valid range [0, row_count)
|
|
1442
|
+
position = max(0, position)
|
|
1443
|
+
|
|
1444
|
+
row_key = RowKey(key)
|
|
1445
|
+
if row_key in self._row_locations:
|
|
1446
|
+
raise DuplicateKey(f"The row key {row_key!r} already exists.")
|
|
1447
|
+
|
|
1448
|
+
if len(cells) > len(self.ordered_columns):
|
|
1449
|
+
raise ValueError("More values provided than there are columns.")
|
|
1450
|
+
|
|
1451
|
+
# TC: Rebuild self._row_locations to shift rows at and after position down by 1
|
|
1452
|
+
# Create a mapping of old index -> new index
|
|
1453
|
+
old_to_new = {}
|
|
1454
|
+
for old_idx in range(row_count):
|
|
1455
|
+
if old_idx < position:
|
|
1456
|
+
old_to_new[old_idx] = old_idx # No change
|
|
1457
|
+
else:
|
|
1458
|
+
old_to_new[old_idx] = old_idx + 1 # Shift down by 1
|
|
1459
|
+
|
|
1460
|
+
# Update _row_locations with the new indices
|
|
1461
|
+
new_row_locations = TwoWayDict({})
|
|
1462
|
+
for row_key_item in self._row_locations:
|
|
1463
|
+
old_idx = self.get_row_idx(row_key_item)
|
|
1464
|
+
new_idx = old_to_new.get(old_idx, old_idx)
|
|
1465
|
+
new_row_locations[row_key_item] = new_idx
|
|
1466
|
+
|
|
1467
|
+
# Update the internal mapping
|
|
1468
|
+
self._row_locations = new_row_locations
|
|
1469
|
+
# TC
|
|
1470
|
+
|
|
1471
|
+
row_index = position
|
|
1472
|
+
# Map the key of this row to its current index
|
|
1473
|
+
self._row_locations[row_key] = row_index
|
|
1474
|
+
self._data[row_key] = {column.key: cell for column, cell in zip_longest(self.ordered_columns, cells)}
|
|
1475
|
+
|
|
1476
|
+
label = Text.from_markup(label, end="") if isinstance(label, str) else label
|
|
1477
|
+
|
|
1478
|
+
# Rows with auto-height get a height of 0 because 1) we need an integer height
|
|
1479
|
+
# to do some intermediate computations and 2) because 0 doesn't impact the data
|
|
1480
|
+
# table while we don't figure out how tall this row is.
|
|
1481
|
+
self.rows[row_key] = Row(
|
|
1482
|
+
row_key,
|
|
1483
|
+
height or 0,
|
|
1484
|
+
label,
|
|
1485
|
+
height is None,
|
|
1486
|
+
)
|
|
1487
|
+
self._new_rows.add(row_key)
|
|
1488
|
+
self._require_update_dimensions = True
|
|
1489
|
+
self.cursor_coordinate = self.cursor_coordinate
|
|
1490
|
+
|
|
1491
|
+
# If a position has opened for the cursor to appear, where it previously
|
|
1492
|
+
# could not (e.g. when there's no data in the table), then a highlighted
|
|
1493
|
+
# event is posted, since there's now a highlighted cell when there wasn't
|
|
1494
|
+
# before.
|
|
1495
|
+
cell_now_available = self.row_count == 1 and len(self.columns) > 0
|
|
1496
|
+
visible_cursor = self.show_cursor and self.cursor_type != "none"
|
|
1497
|
+
if cell_now_available and visible_cursor:
|
|
1498
|
+
self._highlight_cursor()
|
|
1499
|
+
|
|
1500
|
+
self._update_count += 1
|
|
1501
|
+
self.check_idle()
|
|
1502
|
+
return row_key
|
|
1503
|
+
|
|
1504
|
+
# Navigation
|
|
1505
|
+
def do_jump_top(self) -> None:
|
|
1506
|
+
"""Jump to the top of the table."""
|
|
1507
|
+
self.move_cursor(row=0)
|
|
1508
|
+
|
|
1509
|
+
def do_jump_bottom(self) -> None:
|
|
1510
|
+
"""Jump to the bottom of the table."""
|
|
1511
|
+
stop = len(self.df)
|
|
1512
|
+
start = max(0, stop - self.BATCH_SIZE)
|
|
1513
|
+
|
|
1514
|
+
if start % self.BATCH_SIZE != 0:
|
|
1515
|
+
start = (start // self.BATCH_SIZE + 1) * self.BATCH_SIZE
|
|
1516
|
+
|
|
1517
|
+
if stop - start < self.BATCH_SIZE:
|
|
1518
|
+
start -= self.BATCH_SIZE
|
|
1519
|
+
|
|
1520
|
+
self.load_rows_range(start, stop)
|
|
1521
|
+
self.move_cursor(row=self.row_count - 1)
|
|
1522
|
+
|
|
1523
|
+
def do_page_up(self) -> None:
|
|
1524
|
+
"""Move the cursor one page up."""
|
|
1525
|
+
self._set_hover_cursor(False)
|
|
1526
|
+
if self.show_cursor and self.cursor_type in ("cell", "row"):
|
|
1527
|
+
height = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
|
|
1528
|
+
|
|
1529
|
+
col_idx = self.cursor_column
|
|
1530
|
+
ridx = self.cursor_row_idx
|
|
1531
|
+
next_ridx = max(0, ridx - height - BUFFER_SIZE)
|
|
1532
|
+
start, stop = self._round_to_nearest_hundreds(next_ridx)
|
|
1533
|
+
self.load_rows_range(start, stop)
|
|
1534
|
+
|
|
1535
|
+
self.move_cursor(row=self.get_row_idx(str(next_ridx)), column=col_idx)
|
|
1536
|
+
else:
|
|
1537
|
+
super().action_page_up()
|
|
1538
|
+
|
|
1539
|
+
def do_page_down(self) -> None:
|
|
1540
|
+
"""Move the cursor one page down."""
|
|
1541
|
+
super().action_page_down()
|
|
1542
|
+
self.load_rows_down()
|
|
1243
1543
|
|
|
1244
1544
|
# History & Undo
|
|
1245
1545
|
def create_history(self, description: str) -> None:
|
|
@@ -1247,16 +1547,15 @@ class DataFrameTable(DataTable):
|
|
|
1247
1547
|
return History(
|
|
1248
1548
|
description=description,
|
|
1249
1549
|
df=self.df,
|
|
1550
|
+
df_view=self.df_view,
|
|
1250
1551
|
filename=self.filename,
|
|
1251
|
-
loaded_rows=self.loaded_rows,
|
|
1252
|
-
sorted_columns=self.sorted_columns.copy(),
|
|
1253
1552
|
hidden_columns=self.hidden_columns.copy(),
|
|
1254
1553
|
selected_rows=self.selected_rows.copy(),
|
|
1255
|
-
|
|
1554
|
+
sorted_columns=self.sorted_columns.copy(),
|
|
1555
|
+
matches={k: v.copy() for k, v in self.matches.items()},
|
|
1256
1556
|
fixed_rows=self.fixed_rows,
|
|
1257
1557
|
fixed_columns=self.fixed_columns,
|
|
1258
1558
|
cursor_coordinate=self.cursor_coordinate,
|
|
1259
|
-
matches={k: v.copy() for k, v in self.matches.items()},
|
|
1260
1559
|
dirty=self.dirty,
|
|
1261
1560
|
)
|
|
1262
1561
|
|
|
@@ -1267,30 +1566,32 @@ class DataFrameTable(DataTable):
|
|
|
1267
1566
|
|
|
1268
1567
|
# Restore state
|
|
1269
1568
|
self.df = history.df
|
|
1569
|
+
self.df_view = history.df_view
|
|
1270
1570
|
self.filename = history.filename
|
|
1271
|
-
self.loaded_rows = history.loaded_rows
|
|
1272
|
-
self.sorted_columns = history.sorted_columns.copy()
|
|
1273
1571
|
self.hidden_columns = history.hidden_columns.copy()
|
|
1274
1572
|
self.selected_rows = history.selected_rows.copy()
|
|
1275
|
-
self.
|
|
1573
|
+
self.sorted_columns = history.sorted_columns.copy()
|
|
1574
|
+
self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
|
|
1276
1575
|
self.fixed_rows = history.fixed_rows
|
|
1277
1576
|
self.fixed_columns = history.fixed_columns
|
|
1278
1577
|
self.cursor_coordinate = history.cursor_coordinate
|
|
1279
|
-
self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
|
|
1280
1578
|
self.dirty = history.dirty
|
|
1281
1579
|
|
|
1282
1580
|
# Recreate table for display
|
|
1283
1581
|
self.setup_table()
|
|
1284
1582
|
|
|
1285
|
-
def add_history(self, description: str, dirty: bool = False) -> None:
|
|
1583
|
+
def add_history(self, description: str, dirty: bool = False, clear_redo: bool = True) -> None:
|
|
1286
1584
|
"""Add the current state to the history stack.
|
|
1287
1585
|
|
|
1288
1586
|
Args:
|
|
1289
1587
|
description: Description of the action for this history entry.
|
|
1290
1588
|
dirty: Whether this operation modifies the data (True) or just display state (False).
|
|
1291
1589
|
"""
|
|
1292
|
-
|
|
1293
|
-
|
|
1590
|
+
self.histories_undo.append(self.create_history(description))
|
|
1591
|
+
|
|
1592
|
+
# Clear redo stack when a new action is performed
|
|
1593
|
+
if clear_redo:
|
|
1594
|
+
self.histories_redo.clear()
|
|
1294
1595
|
|
|
1295
1596
|
# Mark table as dirty if this operation modifies data
|
|
1296
1597
|
if dirty:
|
|
@@ -1298,15 +1599,13 @@ class DataFrameTable(DataTable):
|
|
|
1298
1599
|
|
|
1299
1600
|
def do_undo(self) -> None:
|
|
1300
1601
|
"""Undo the last action."""
|
|
1301
|
-
if not self.
|
|
1302
|
-
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
1602
|
+
if not self.histories_undo:
|
|
1603
|
+
# self.notify("No actions to undo", title="Undo", severity="warning")
|
|
1303
1604
|
return
|
|
1304
1605
|
|
|
1305
|
-
# Pop the last history state for undo
|
|
1306
|
-
history = self.
|
|
1307
|
-
|
|
1308
|
-
# Save current state for redo
|
|
1309
|
-
self.history = self.create_history(history.description)
|
|
1606
|
+
# Pop the last history state for undo and save to redo stack
|
|
1607
|
+
history = self.histories_undo.pop()
|
|
1608
|
+
self.histories_redo.append(self.create_history(history.description))
|
|
1310
1609
|
|
|
1311
1610
|
# Restore state
|
|
1312
1611
|
self.apply_history(history)
|
|
@@ -1315,42 +1614,35 @@ class DataFrameTable(DataTable):
|
|
|
1315
1614
|
|
|
1316
1615
|
def do_redo(self) -> None:
|
|
1317
1616
|
"""Redo the last undone action."""
|
|
1318
|
-
if self.
|
|
1319
|
-
self.notify("No actions to redo", title="Redo", severity="warning")
|
|
1617
|
+
if not self.histories_redo:
|
|
1618
|
+
# self.notify("No actions to redo", title="Redo", severity="warning")
|
|
1320
1619
|
return
|
|
1321
1620
|
|
|
1322
|
-
|
|
1621
|
+
# Pop the last undone state from redo stack
|
|
1622
|
+
history = self.histories_redo.pop()
|
|
1623
|
+
description = history.description
|
|
1323
1624
|
|
|
1324
1625
|
# Save current state for undo
|
|
1325
|
-
self.add_history(description)
|
|
1626
|
+
self.add_history(description, clear_redo=False)
|
|
1326
1627
|
|
|
1327
1628
|
# Restore state
|
|
1328
|
-
self.apply_history(
|
|
1329
|
-
|
|
1330
|
-
# Clear redo state
|
|
1331
|
-
self.history = None
|
|
1629
|
+
self.apply_history(history)
|
|
1332
1630
|
|
|
1333
1631
|
self.notify(f"Reapplied: {description}", title="Redo")
|
|
1334
1632
|
|
|
1335
1633
|
def do_reset(self) -> None:
|
|
1336
1634
|
"""Reset the table to the initial state."""
|
|
1337
|
-
self.
|
|
1635
|
+
self.reset_df(self.dataframe, dirty=False)
|
|
1636
|
+
self.setup_table()
|
|
1338
1637
|
self.notify("Restored initial state", title="Reset")
|
|
1339
1638
|
|
|
1340
|
-
def restore_dirty(self, default: bool | None = None) -> None:
|
|
1341
|
-
"""Restore the dirty state from the last history entry."""
|
|
1342
|
-
if self.last_history:
|
|
1343
|
-
self.dirty = self.last_history.dirty
|
|
1344
|
-
elif default is not None:
|
|
1345
|
-
self.dirty = default
|
|
1346
|
-
|
|
1347
1639
|
# Display
|
|
1348
1640
|
def do_cycle_cursor_type(self) -> None:
|
|
1349
1641
|
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
1350
1642
|
next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
|
|
1351
1643
|
self.cursor_type = next_type
|
|
1352
1644
|
|
|
1353
|
-
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
|
|
1645
|
+
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cycle Cursor Type")
|
|
1354
1646
|
|
|
1355
1647
|
def do_view_row_detail(self) -> None:
|
|
1356
1648
|
"""Open a modal screen to view the selected row's details."""
|
|
@@ -1380,6 +1672,14 @@ class DataFrameTable(DataTable):
|
|
|
1380
1672
|
cidx = self.cursor_col_idx
|
|
1381
1673
|
self.app.push_screen(StatisticsScreen(self, col_idx=cidx))
|
|
1382
1674
|
|
|
1675
|
+
def do_metadata_shape(self) -> None:
|
|
1676
|
+
"""Show metadata about the dataframe (row and column counts)."""
|
|
1677
|
+
self.app.push_screen(MetaShape(self))
|
|
1678
|
+
|
|
1679
|
+
def do_metadata_column(self) -> None:
|
|
1680
|
+
"""Show metadata for all columns in the dataframe."""
|
|
1681
|
+
self.app.push_screen(MetaColumnScreen(self))
|
|
1682
|
+
|
|
1383
1683
|
def do_freeze_row_column(self) -> None:
|
|
1384
1684
|
"""Open the freeze screen to set fixed rows and columns."""
|
|
1385
1685
|
self.app.push_screen(FreezeScreen(), callback=self.freeze_row_column)
|
|
@@ -1396,7 +1696,7 @@ class DataFrameTable(DataTable):
|
|
|
1396
1696
|
fixed_rows, fixed_columns = result
|
|
1397
1697
|
|
|
1398
1698
|
# Add to history
|
|
1399
|
-
self.add_history(f"Pinned [$
|
|
1699
|
+
self.add_history(f"Pinned [$success]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns")
|
|
1400
1700
|
|
|
1401
1701
|
# Apply the pin settings to the table
|
|
1402
1702
|
if fixed_rows >= 0:
|
|
@@ -1404,7 +1704,7 @@ class DataFrameTable(DataTable):
|
|
|
1404
1704
|
if fixed_columns >= 0:
|
|
1405
1705
|
self.fixed_columns = fixed_columns
|
|
1406
1706
|
|
|
1407
|
-
# self.notify(f"Pinned [$
|
|
1707
|
+
# self.notify(f"Pinned [$success]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns", title="Pin Row/Column")
|
|
1408
1708
|
|
|
1409
1709
|
def do_hide_column(self) -> None:
|
|
1410
1710
|
"""Hide the currently selected column from the table display."""
|
|
@@ -1425,7 +1725,9 @@ class DataFrameTable(DataTable):
|
|
|
1425
1725
|
if col_idx >= len(self.columns):
|
|
1426
1726
|
self.move_cursor(column=len(self.columns) - 1)
|
|
1427
1727
|
|
|
1428
|
-
# self.notify(
|
|
1728
|
+
# self.notify(
|
|
1729
|
+
# f"Hid column [$success]{col_name}[/]. Press [$accent]H[/] to show hidden columns", title="Hide Column"
|
|
1730
|
+
# )
|
|
1429
1731
|
|
|
1430
1732
|
def do_expand_column(self) -> None:
|
|
1431
1733
|
"""Expand the current column to show the widest cell in the loaded data."""
|
|
@@ -1438,58 +1740,78 @@ class DataFrameTable(DataTable):
|
|
|
1438
1740
|
if dtype != pl.String:
|
|
1439
1741
|
return
|
|
1440
1742
|
|
|
1743
|
+
# The column to expand/shrink
|
|
1744
|
+
col: Column = self.columns[col_key]
|
|
1745
|
+
|
|
1441
1746
|
# Calculate the maximum width across all loaded rows
|
|
1442
|
-
|
|
1747
|
+
label_width = len(col_name) + 2 # Start with column name width + padding
|
|
1443
1748
|
|
|
1444
1749
|
try:
|
|
1750
|
+
need_expand = False
|
|
1751
|
+
max_width = label_width
|
|
1752
|
+
|
|
1445
1753
|
# Scan through all loaded rows that are visible to find max width
|
|
1446
1754
|
for row_idx in range(self.loaded_rows):
|
|
1447
|
-
if not self.visible_rows[row_idx]:
|
|
1448
|
-
continue # Skip hidden rows
|
|
1449
1755
|
cell_value = str(self.df.item(row_idx, col_idx))
|
|
1450
1756
|
cell_width = measure(self.app.console, cell_value, 1)
|
|
1757
|
+
|
|
1758
|
+
if cell_width > max_width:
|
|
1759
|
+
need_expand = True
|
|
1451
1760
|
max_width = max(max_width, cell_width)
|
|
1452
1761
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1762
|
+
if not need_expand:
|
|
1763
|
+
return
|
|
1764
|
+
|
|
1765
|
+
if col_name in self.expanded_columns:
|
|
1766
|
+
col.width = max(label_width, STRING_WIDTH_CAP)
|
|
1767
|
+
self.expanded_columns.remove(col_name)
|
|
1768
|
+
else:
|
|
1769
|
+
self.expanded_columns.add(col_name)
|
|
1456
1770
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
self._require_update_dimensions = True
|
|
1460
|
-
self.refresh(layout=True)
|
|
1771
|
+
# Update the column width
|
|
1772
|
+
col.width = max_width
|
|
1461
1773
|
|
|
1462
|
-
# self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand")
|
|
1463
1774
|
except Exception as e:
|
|
1464
|
-
self.notify(
|
|
1775
|
+
self.notify(
|
|
1776
|
+
f"Error expanding column [$error]{col_name}[/]", title="Expand Column", severity="error", timeout=10
|
|
1777
|
+
)
|
|
1465
1778
|
self.log(f"Error expanding column `{col_name}`: {str(e)}")
|
|
1466
1779
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1780
|
+
# Force a refresh
|
|
1781
|
+
self._update_count += 1
|
|
1782
|
+
self._require_update_dimensions = True
|
|
1783
|
+
self.refresh(layout=True)
|
|
1471
1784
|
|
|
1472
|
-
|
|
1473
|
-
hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
|
|
1785
|
+
# self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand Column")
|
|
1474
1786
|
|
|
1475
|
-
|
|
1476
|
-
|
|
1787
|
+
def do_toggle_rid(self) -> None:
|
|
1788
|
+
"""Toggle display of the internal RID column."""
|
|
1789
|
+
self.show_rid = not self.show_rid
|
|
1790
|
+
|
|
1791
|
+
# Recreate table for display
|
|
1792
|
+
self.setup_table()
|
|
1793
|
+
|
|
1794
|
+
def do_show_hidden_rows_columns(self) -> None:
|
|
1795
|
+
"""Show all hidden rows/columns by recreating the table."""
|
|
1796
|
+
if not self.hidden_columns and self.df_view is None:
|
|
1797
|
+
# self.notify("No hidden rows or columns to show", title="Show Hidden Rows/Columns", severity="warning")
|
|
1477
1798
|
return
|
|
1478
1799
|
|
|
1479
1800
|
# Add to history
|
|
1480
1801
|
self.add_history("Showed hidden rows/columns")
|
|
1481
1802
|
|
|
1803
|
+
# If in a filtered view, restore the full dataframe
|
|
1804
|
+
if self.df_view is not None:
|
|
1805
|
+
self.df = self.df_view
|
|
1806
|
+
self.df_view = None
|
|
1807
|
+
|
|
1482
1808
|
# Clear hidden rows/columns tracking
|
|
1483
|
-
self.visible_rows = [True] * len(self.df)
|
|
1484
1809
|
self.hidden_columns.clear()
|
|
1485
1810
|
|
|
1486
1811
|
# Recreate table for display
|
|
1487
1812
|
self.setup_table()
|
|
1488
1813
|
|
|
1489
|
-
self.notify(
|
|
1490
|
-
f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
|
|
1491
|
-
title="Show",
|
|
1492
|
-
)
|
|
1814
|
+
# self.notify("Showed hidden row(s) and/or hidden column(s)", title="Show Hidden Rows/Columns")
|
|
1493
1815
|
|
|
1494
1816
|
# Sort
|
|
1495
1817
|
def do_sort_by_column(self, descending: bool = False) -> None:
|
|
@@ -1510,32 +1832,40 @@ class DataFrameTable(DataTable):
|
|
|
1510
1832
|
|
|
1511
1833
|
# Add to history
|
|
1512
1834
|
self.add_history(f"Sorted on column [$success]{col_name}[/]", dirty=True)
|
|
1835
|
+
|
|
1836
|
+
# New column - add to sort
|
|
1513
1837
|
if old_desc is None:
|
|
1514
|
-
# Add new column to sort
|
|
1515
1838
|
self.sorted_columns[col_name] = descending
|
|
1839
|
+
|
|
1840
|
+
# Old column, same direction - remove from sort
|
|
1516
1841
|
elif old_desc == descending:
|
|
1517
|
-
# Same direction - remove from sort
|
|
1518
1842
|
del self.sorted_columns[col_name]
|
|
1843
|
+
|
|
1844
|
+
# Old column, different direction - add to sort at end
|
|
1519
1845
|
else:
|
|
1520
|
-
# Move to end of sort order
|
|
1521
1846
|
del self.sorted_columns[col_name]
|
|
1522
1847
|
self.sorted_columns[col_name] = descending
|
|
1523
1848
|
|
|
1849
|
+
lf = self.df.lazy()
|
|
1850
|
+
sort_by = {}
|
|
1851
|
+
|
|
1524
1852
|
# Apply multi-column sort
|
|
1525
1853
|
if sort_cols := list(self.sorted_columns.keys()):
|
|
1526
1854
|
descending_flags = list(self.sorted_columns.values())
|
|
1527
|
-
|
|
1855
|
+
sort_by = {"by": sort_cols, "descending": descending_flags, "nulls_last": True}
|
|
1528
1856
|
else:
|
|
1529
|
-
# No sort
|
|
1530
|
-
|
|
1857
|
+
# No sort - restore original order by adding a temporary index column
|
|
1858
|
+
sort_by = {"by": RID}
|
|
1531
1859
|
|
|
1532
|
-
#
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1860
|
+
# Perform the sort
|
|
1861
|
+
df_sorted = lf.sort(**sort_by).collect()
|
|
1862
|
+
|
|
1863
|
+
# Also update df_view if applicable
|
|
1864
|
+
if self.df_view is not None:
|
|
1865
|
+
self.df_view = self.df_view.lazy().sort(**sort_by).collect()
|
|
1536
1866
|
|
|
1537
1867
|
# Update the dataframe
|
|
1538
|
-
self.df = df_sorted
|
|
1868
|
+
self.df = df_sorted
|
|
1539
1869
|
|
|
1540
1870
|
# Recreate table for display
|
|
1541
1871
|
self.setup_table()
|
|
@@ -1582,6 +1912,17 @@ class DataFrameTable(DataTable):
|
|
|
1582
1912
|
.alias(col_name)
|
|
1583
1913
|
)
|
|
1584
1914
|
|
|
1915
|
+
# Also update the view if applicable
|
|
1916
|
+
if self.df_view is not None:
|
|
1917
|
+
# Get the RID value for this row in df_view
|
|
1918
|
+
ridx_view = self.df.item(ridx, self.df.columns.index(RID))
|
|
1919
|
+
self.df_view = self.df_view.with_columns(
|
|
1920
|
+
pl.when(pl.col(RID) == ridx_view)
|
|
1921
|
+
.then(pl.lit(new_value))
|
|
1922
|
+
.otherwise(pl.col(col_name))
|
|
1923
|
+
.alias(col_name)
|
|
1924
|
+
)
|
|
1925
|
+
|
|
1585
1926
|
# Update the display
|
|
1586
1927
|
cell_value = self.df.item(ridx, cidx)
|
|
1587
1928
|
if cell_value is None:
|
|
@@ -1595,10 +1936,15 @@ class DataFrameTable(DataTable):
|
|
|
1595
1936
|
col_key = col_name
|
|
1596
1937
|
self.update_cell(row_key, col_key, formatted_value, update_width=True)
|
|
1597
1938
|
|
|
1598
|
-
# self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1939
|
+
# self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit Cell")
|
|
1599
1940
|
except Exception as e:
|
|
1600
|
-
self.notify(
|
|
1601
|
-
|
|
1941
|
+
self.notify(
|
|
1942
|
+
f"Error updating cell ([$error]{ridx}[/], [$accent]{col_name}[/])",
|
|
1943
|
+
title="Edit Cell",
|
|
1944
|
+
severity="error",
|
|
1945
|
+
timeout=10,
|
|
1946
|
+
)
|
|
1947
|
+
self.log(f"Error updating cell ({ridx}, {col_name}): {str(e)}")
|
|
1602
1948
|
|
|
1603
1949
|
def do_edit_column(self) -> None:
|
|
1604
1950
|
"""Open modal to edit the entire column with an expression."""
|
|
@@ -1627,7 +1973,9 @@ class DataFrameTable(DataTable):
|
|
|
1627
1973
|
try:
|
|
1628
1974
|
expr = validate_expr(term, self.df.columns, cidx)
|
|
1629
1975
|
except Exception as e:
|
|
1630
|
-
self.notify(
|
|
1976
|
+
self.notify(
|
|
1977
|
+
f"Error validating expression [$error]{term}[/]", title="Edit Column", severity="error", timeout=10
|
|
1978
|
+
)
|
|
1631
1979
|
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
1632
1980
|
return
|
|
1633
1981
|
|
|
@@ -1639,23 +1987,47 @@ class DataFrameTable(DataTable):
|
|
|
1639
1987
|
expr = pl.lit(value)
|
|
1640
1988
|
except Exception:
|
|
1641
1989
|
self.notify(
|
|
1642
|
-
f"Error converting [$
|
|
1643
|
-
title="Edit",
|
|
1990
|
+
f"Error converting [$error]{term}[/] to [$accent]{dtype}[/]. Cast to string.",
|
|
1991
|
+
title="Edit Column",
|
|
1644
1992
|
severity="error",
|
|
1645
1993
|
)
|
|
1646
1994
|
expr = pl.lit(str(term))
|
|
1647
1995
|
|
|
1648
1996
|
# Add to history
|
|
1649
|
-
self.add_history(f"Edited column [$
|
|
1997
|
+
self.add_history(f"Edited column [$success]{col_name}[/] with expression", dirty=True)
|
|
1650
1998
|
|
|
1651
1999
|
try:
|
|
1652
2000
|
# Apply the expression to the column
|
|
1653
|
-
self.df = self.df.with_columns(expr.alias(col_name))
|
|
2001
|
+
self.df = self.df.lazy().with_columns(expr.alias(col_name)).collect()
|
|
2002
|
+
|
|
2003
|
+
# Also update the view if applicable
|
|
2004
|
+
# Update the value of col_name in df_view using the value of col_name from df based on RID mapping between them
|
|
2005
|
+
if self.df_view is not None:
|
|
2006
|
+
# Get updated column from df for rows that exist in df_view
|
|
2007
|
+
col_updated = f"^_{col_name}_^"
|
|
2008
|
+
col_exists = "^_exists_^"
|
|
2009
|
+
lf_updated = self.df.lazy().select(
|
|
2010
|
+
RID, pl.col(col_name).alias(col_updated), pl.lit(True).alias(col_exists)
|
|
2011
|
+
)
|
|
2012
|
+
# Join and use when/then/otherwise to handle all updates including NULLs
|
|
2013
|
+
self.df_view = (
|
|
2014
|
+
self.df_view.lazy()
|
|
2015
|
+
.join(lf_updated, on=RID, how="left")
|
|
2016
|
+
.with_columns(
|
|
2017
|
+
pl.when(pl.col(col_exists))
|
|
2018
|
+
.then(pl.col(col_updated))
|
|
2019
|
+
.otherwise(pl.col(col_name))
|
|
2020
|
+
.alias(col_name)
|
|
2021
|
+
)
|
|
2022
|
+
.drop(col_updated, col_exists)
|
|
2023
|
+
.collect()
|
|
2024
|
+
)
|
|
1654
2025
|
except Exception as e:
|
|
1655
2026
|
self.notify(
|
|
1656
2027
|
f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
|
|
1657
|
-
title="Edit",
|
|
2028
|
+
title="Edit Column",
|
|
1658
2029
|
severity="error",
|
|
2030
|
+
timeout=10,
|
|
1659
2031
|
)
|
|
1660
2032
|
self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
|
|
1661
2033
|
return
|
|
@@ -1663,12 +2035,12 @@ class DataFrameTable(DataTable):
|
|
|
1663
2035
|
# Recreate table for display
|
|
1664
2036
|
self.setup_table()
|
|
1665
2037
|
|
|
1666
|
-
# self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
|
|
2038
|
+
# self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit Column")
|
|
1667
2039
|
|
|
1668
|
-
def do_rename_column(self) -> None:
|
|
2040
|
+
def do_rename_column(self, col_idx: int | None) -> None:
|
|
1669
2041
|
"""Open modal to rename the selected column."""
|
|
1670
|
-
|
|
1671
|
-
|
|
2042
|
+
col_idx = self.cursor_column if col_idx is None else col_idx
|
|
2043
|
+
col_name = self.get_col_key(col_idx).value
|
|
1672
2044
|
|
|
1673
2045
|
# Push the rename column modal screen
|
|
1674
2046
|
self.app.push_screen(
|
|
@@ -1690,19 +2062,30 @@ class DataFrameTable(DataTable):
|
|
|
1690
2062
|
return
|
|
1691
2063
|
|
|
1692
2064
|
# Add to history
|
|
1693
|
-
self.add_history(f"Renamed column [$
|
|
2065
|
+
self.add_history(f"Renamed column [$success]{col_name}[/] to [$accent]{new_name}[/]", dirty=True)
|
|
1694
2066
|
|
|
1695
2067
|
# Rename the column in the dataframe
|
|
1696
2068
|
self.df = self.df.rename({col_name: new_name})
|
|
1697
2069
|
|
|
1698
|
-
#
|
|
2070
|
+
# Also update the view if applicable
|
|
2071
|
+
if self.df_view is not None:
|
|
2072
|
+
self.df_view = self.df_view.rename({col_name: new_name})
|
|
2073
|
+
|
|
2074
|
+
# Update sorted_columns if this column was sorted and maintain order
|
|
1699
2075
|
if col_name in self.sorted_columns:
|
|
1700
|
-
|
|
2076
|
+
sorted_columns = {}
|
|
2077
|
+
for col, order in self.sorted_columns.items():
|
|
2078
|
+
if col == col_name:
|
|
2079
|
+
sorted_columns[new_name] = order
|
|
2080
|
+
else:
|
|
2081
|
+
sorted_columns[col] = order
|
|
2082
|
+
self.sorted_columns = sorted_columns
|
|
1701
2083
|
|
|
1702
|
-
# Update
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
2084
|
+
# Update matches if this column had cell matches
|
|
2085
|
+
for cols in self.matches.values():
|
|
2086
|
+
if col_name in cols:
|
|
2087
|
+
cols.remove(col_name)
|
|
2088
|
+
cols.add(new_name)
|
|
1706
2089
|
|
|
1707
2090
|
# Recreate table for display
|
|
1708
2091
|
self.setup_table()
|
|
@@ -1710,7 +2093,7 @@ class DataFrameTable(DataTable):
|
|
|
1710
2093
|
# Move cursor to the renamed column
|
|
1711
2094
|
self.move_cursor(column=col_idx)
|
|
1712
2095
|
|
|
1713
|
-
# self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
|
|
2096
|
+
# self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Rename Column")
|
|
1714
2097
|
|
|
1715
2098
|
def do_clear_cell(self) -> None:
|
|
1716
2099
|
"""Clear the current cell by setting its value to None."""
|
|
@@ -1731,6 +2114,13 @@ class DataFrameTable(DataTable):
|
|
|
1731
2114
|
.alias(col_name)
|
|
1732
2115
|
)
|
|
1733
2116
|
|
|
2117
|
+
# Also update the view if applicable
|
|
2118
|
+
if self.df_view is not None:
|
|
2119
|
+
ridx_view = self.df.item(ridx, self.df.columns.index(RID))
|
|
2120
|
+
self.df_view = self.df_view.with_columns(
|
|
2121
|
+
pl.when(pl.col(RID) == ridx_view).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
|
|
2122
|
+
)
|
|
2123
|
+
|
|
1734
2124
|
# Update the display
|
|
1735
2125
|
dtype = self.df.dtypes[cidx]
|
|
1736
2126
|
dc = DtypeConfig(dtype)
|
|
@@ -1738,36 +2128,38 @@ class DataFrameTable(DataTable):
|
|
|
1738
2128
|
|
|
1739
2129
|
self.update_cell(row_key, col_key, formatted_value)
|
|
1740
2130
|
|
|
1741
|
-
# self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
2131
|
+
# self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear Cell")
|
|
1742
2132
|
except Exception as e:
|
|
1743
|
-
self.notify(
|
|
1744
|
-
|
|
2133
|
+
self.notify(
|
|
2134
|
+
f"Error clearing cell ([$error]{ridx}[/], [$accent]{col_name}[/])",
|
|
2135
|
+
title="Clear Cell",
|
|
2136
|
+
severity="error",
|
|
2137
|
+
timeout=10,
|
|
2138
|
+
)
|
|
2139
|
+
self.log(f"Error clearing cell ({ridx}, {col_name}): {str(e)}")
|
|
1745
2140
|
raise e
|
|
1746
2141
|
|
|
1747
|
-
def do_add_column(self, col_name: str = None
|
|
2142
|
+
def do_add_column(self, col_name: str = None) -> None:
|
|
1748
2143
|
"""Add acolumn after the current column."""
|
|
1749
2144
|
cidx = self.cursor_col_idx
|
|
1750
2145
|
|
|
1751
2146
|
if not col_name:
|
|
1752
2147
|
# Generate a unique column name
|
|
1753
2148
|
base_name = "new_col"
|
|
1754
|
-
|
|
2149
|
+
new_col_name = base_name
|
|
1755
2150
|
counter = 1
|
|
1756
|
-
while
|
|
1757
|
-
|
|
2151
|
+
while new_col_name in self.df.columns:
|
|
2152
|
+
new_col_name = f"{base_name}_{counter}"
|
|
1758
2153
|
counter += 1
|
|
1759
2154
|
else:
|
|
1760
|
-
|
|
2155
|
+
new_col_name = col_name
|
|
1761
2156
|
|
|
1762
2157
|
# Add to history
|
|
1763
|
-
self.add_history(f"Added column [$success]{
|
|
2158
|
+
self.add_history(f"Added column [$success]{new_col_name}[/] after column [$accent]{cidx + 1}[/]", dirty=True)
|
|
1764
2159
|
|
|
1765
2160
|
try:
|
|
1766
2161
|
# Create an empty column (all None values)
|
|
1767
|
-
|
|
1768
|
-
new_col = col_value.alias(new_name)
|
|
1769
|
-
else:
|
|
1770
|
-
new_col = pl.lit(col_value).alias(new_name)
|
|
2162
|
+
new_col_name = pl.lit(None).alias(new_col_name)
|
|
1771
2163
|
|
|
1772
2164
|
# Get columns up to current, the new column, then remaining columns
|
|
1773
2165
|
cols = self.df.columns
|
|
@@ -1775,8 +2167,12 @@ class DataFrameTable(DataTable):
|
|
|
1775
2167
|
cols_after = cols[cidx + 1 :]
|
|
1776
2168
|
|
|
1777
2169
|
# Build the new dataframe with columns reordered
|
|
1778
|
-
select_cols = cols_before + [
|
|
1779
|
-
self.df = self.df.with_columns(
|
|
2170
|
+
select_cols = cols_before + [new_col_name] + cols_after
|
|
2171
|
+
self.df = self.df.lazy().with_columns(new_col_name).select(select_cols).collect()
|
|
2172
|
+
|
|
2173
|
+
# Also update the view if applicable
|
|
2174
|
+
if self.df_view is not None:
|
|
2175
|
+
self.df_view = self.df_view.lazy().with_columns(new_col_name).select(select_cols).collect()
|
|
1780
2176
|
|
|
1781
2177
|
# Recreate table for display
|
|
1782
2178
|
self.setup_table()
|
|
@@ -1786,8 +2182,10 @@ class DataFrameTable(DataTable):
|
|
|
1786
2182
|
|
|
1787
2183
|
# self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
1788
2184
|
except Exception as e:
|
|
1789
|
-
self.notify(
|
|
1790
|
-
|
|
2185
|
+
self.notify(
|
|
2186
|
+
f"Error adding column [$error]{new_col_name}[/]", title="Add Column", severity="error", timeout=10
|
|
2187
|
+
)
|
|
2188
|
+
self.log(f"Error adding column `{new_col_name}`: {str(e)}")
|
|
1791
2189
|
raise e
|
|
1792
2190
|
|
|
1793
2191
|
def do_add_column_expr(self) -> None:
|
|
@@ -1806,7 +2204,7 @@ class DataFrameTable(DataTable):
|
|
|
1806
2204
|
cidx, new_col_name, expr = result
|
|
1807
2205
|
|
|
1808
2206
|
# Add to history
|
|
1809
|
-
self.add_history(f"Added column [$success]{new_col_name}[/] with expression {expr}.", dirty=True)
|
|
2207
|
+
self.add_history(f"Added column [$success]{new_col_name}[/] with expression [$accent]{expr}[/].", dirty=True)
|
|
1810
2208
|
|
|
1811
2209
|
try:
|
|
1812
2210
|
# Create the column
|
|
@@ -1819,7 +2217,14 @@ class DataFrameTable(DataTable):
|
|
|
1819
2217
|
|
|
1820
2218
|
# Build the new dataframe with columns reordered
|
|
1821
2219
|
select_cols = cols_before + [new_col_name] + cols_after
|
|
1822
|
-
self.df = self.df.
|
|
2220
|
+
self.df = self.df.lazy().with_columns(new_col).select(select_cols).collect()
|
|
2221
|
+
|
|
2222
|
+
# Also update the view if applicable
|
|
2223
|
+
if self.df_view is not None:
|
|
2224
|
+
# Get updated column from df for rows that exist in df_view
|
|
2225
|
+
lf_updated = self.df.lazy().select(RID, pl.col(new_col_name))
|
|
2226
|
+
# Join and use coalesce to prefer updated value or keep original
|
|
2227
|
+
self.df_view = self.df_view.lazy().join(lf_updated, on=RID, how="left").select(select_cols).collect()
|
|
1823
2228
|
|
|
1824
2229
|
# Recreate table for display
|
|
1825
2230
|
self.setup_table()
|
|
@@ -1829,7 +2234,9 @@ class DataFrameTable(DataTable):
|
|
|
1829
2234
|
|
|
1830
2235
|
# self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
|
|
1831
2236
|
except Exception as e:
|
|
1832
|
-
self.notify(
|
|
2237
|
+
self.notify(
|
|
2238
|
+
f"Error adding column [$error]{new_col_name}[/]", title="Add Column", severity="error", timeout=10
|
|
2239
|
+
)
|
|
1833
2240
|
self.log(f"Error adding column `{new_col_name}`: {str(e)}")
|
|
1834
2241
|
|
|
1835
2242
|
def do_add_link_column(self) -> None:
|
|
@@ -1841,10 +2248,10 @@ class DataFrameTable(DataTable):
|
|
|
1841
2248
|
def add_link_column(self, result: tuple[str, str] | None) -> None:
|
|
1842
2249
|
"""Handle result from AddLinkScreen.
|
|
1843
2250
|
|
|
1844
|
-
Creates a new link column in the dataframe
|
|
1845
|
-
|
|
2251
|
+
Creates a new link column in the dataframe based on a user-provided template.
|
|
2252
|
+
Supports multiple placeholder types:
|
|
1846
2253
|
- `$_` - Current column (based on cursor position)
|
|
1847
|
-
- `$1`, `$2`, etc. - Column by 1-based
|
|
2254
|
+
- `$1`, `$2`, etc. - Column by index (1-based)
|
|
1848
2255
|
- `$name` - Column by name (e.g., `$id`, `$product_name`)
|
|
1849
2256
|
|
|
1850
2257
|
The template is evaluated for each row using Polars expressions with vectorized
|
|
@@ -1858,7 +2265,7 @@ class DataFrameTable(DataTable):
|
|
|
1858
2265
|
cidx, new_col_name, link_template = result
|
|
1859
2266
|
|
|
1860
2267
|
self.add_history(
|
|
1861
|
-
f"Added link column [$
|
|
2268
|
+
f"Added link column [$success]{new_col_name}[/] with template [$accent]{link_template}[/].", dirty=True
|
|
1862
2269
|
)
|
|
1863
2270
|
|
|
1864
2271
|
try:
|
|
@@ -1883,7 +2290,14 @@ class DataFrameTable(DataTable):
|
|
|
1883
2290
|
|
|
1884
2291
|
# Build the new dataframe with columns reordered
|
|
1885
2292
|
select_cols = cols_before + [new_col_name] + cols_after
|
|
1886
|
-
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
2293
|
+
self.df = self.df.lazy().with_columns(new_col).select(select_cols).collect()
|
|
2294
|
+
|
|
2295
|
+
# Also update the view if applicable
|
|
2296
|
+
if self.df_view is not None:
|
|
2297
|
+
# Get updated column from df for rows that exist in df_view
|
|
2298
|
+
lf_updated = self.df.lazy().select(RID, pl.col(new_col_name))
|
|
2299
|
+
# Join and use coalesce to prefer updated value or keep original
|
|
2300
|
+
self.df_view = self.df_view.lazy().join(lf_updated, on=RID, how="left").select(select_cols).collect()
|
|
1887
2301
|
|
|
1888
2302
|
# Recreate table for display
|
|
1889
2303
|
self.setup_table()
|
|
@@ -1891,17 +2305,24 @@ class DataFrameTable(DataTable):
|
|
|
1891
2305
|
# Move cursor to the new column
|
|
1892
2306
|
self.move_cursor(column=cidx + 1)
|
|
1893
2307
|
|
|
1894
|
-
self.notify(f"Added link column [$success]{new_col_name}[/]. Use Ctrl/Cmd click to open.", title="Add Link")
|
|
2308
|
+
# self.notify(f"Added link column [$success]{new_col_name}[/]. Use Ctrl/Cmd click to open.", title="Add Link")
|
|
1895
2309
|
|
|
1896
2310
|
except Exception as e:
|
|
1897
|
-
self.notify(
|
|
2311
|
+
self.notify(
|
|
2312
|
+
f"Error adding link column [$error]{new_col_name}[/]", title="Add Link", severity="error", timeout=10
|
|
2313
|
+
)
|
|
1898
2314
|
self.log(f"Error adding link column: {str(e)}")
|
|
1899
2315
|
|
|
1900
2316
|
def do_delete_column(self, more: str = None) -> None:
|
|
1901
2317
|
"""Remove the currently selected column from the table."""
|
|
1902
2318
|
# Get the column to remove
|
|
1903
2319
|
col_idx = self.cursor_column
|
|
1904
|
-
|
|
2320
|
+
try:
|
|
2321
|
+
col_name = self.cursor_col_name
|
|
2322
|
+
except CellDoesNotExist:
|
|
2323
|
+
# self.notify("No column to delete at the current cursor position", title="Delete Column", severity="warning")
|
|
2324
|
+
return
|
|
2325
|
+
|
|
1905
2326
|
col_key = self.cursor_col_key
|
|
1906
2327
|
|
|
1907
2328
|
col_names_to_remove = []
|
|
@@ -1910,7 +2331,7 @@ class DataFrameTable(DataTable):
|
|
|
1910
2331
|
# Remove all columns before the current column
|
|
1911
2332
|
if more == "before":
|
|
1912
2333
|
for i in range(col_idx + 1):
|
|
1913
|
-
col_key = self.
|
|
2334
|
+
col_key = self.get_col_key(i)
|
|
1914
2335
|
col_names_to_remove.append(col_key.value)
|
|
1915
2336
|
col_keys_to_remove.append(col_key)
|
|
1916
2337
|
|
|
@@ -1919,7 +2340,7 @@ class DataFrameTable(DataTable):
|
|
|
1919
2340
|
# Remove all columns after the current column
|
|
1920
2341
|
elif more == "after":
|
|
1921
2342
|
for i in range(col_idx, len(self.columns)):
|
|
1922
|
-
col_key = self.
|
|
2343
|
+
col_key = self.get_col_key(i)
|
|
1923
2344
|
col_names_to_remove.append(col_key.value)
|
|
1924
2345
|
col_keys_to_remove.append(col_key)
|
|
1925
2346
|
|
|
@@ -1948,18 +2369,25 @@ class DataFrameTable(DataTable):
|
|
|
1948
2369
|
if col_name in self.sorted_columns:
|
|
1949
2370
|
del self.sorted_columns[col_name]
|
|
1950
2371
|
|
|
2372
|
+
# Remove from hidden columns if present
|
|
2373
|
+
for col_name in col_names_to_remove:
|
|
2374
|
+
self.hidden_columns.discard(col_name)
|
|
2375
|
+
|
|
1951
2376
|
# Remove from matches
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
self.matches[row_idx].difference_update(col_indices_to_remove)
|
|
2377
|
+
for rid in list(self.matches.keys()):
|
|
2378
|
+
self.matches[rid].difference_update(col_names_to_remove)
|
|
1955
2379
|
# Remove empty entries
|
|
1956
|
-
if not self.matches[
|
|
1957
|
-
del self.matches[
|
|
2380
|
+
if not self.matches[rid]:
|
|
2381
|
+
del self.matches[rid]
|
|
1958
2382
|
|
|
1959
2383
|
# Remove from dataframe
|
|
1960
2384
|
self.df = self.df.drop(col_names_to_remove)
|
|
1961
2385
|
|
|
1962
|
-
|
|
2386
|
+
# Also update the view if applicable
|
|
2387
|
+
if self.df_view is not None:
|
|
2388
|
+
self.df_view = self.df_view.drop(col_names_to_remove)
|
|
2389
|
+
|
|
2390
|
+
# self.notify(message, title="Delete Column")
|
|
1963
2391
|
|
|
1964
2392
|
def do_duplicate_column(self) -> None:
|
|
1965
2393
|
"""Duplicate the currently selected column, inserting it right after the current column."""
|
|
@@ -1969,29 +2397,28 @@ class DataFrameTable(DataTable):
|
|
|
1969
2397
|
col_idx = self.cursor_column
|
|
1970
2398
|
new_col_name = f"{col_name}_copy"
|
|
1971
2399
|
|
|
2400
|
+
# Ensure new column name is unique
|
|
2401
|
+
counter = 1
|
|
2402
|
+
while new_col_name in self.df.columns:
|
|
2403
|
+
new_col_name = f"{new_col_name}{counter}"
|
|
2404
|
+
counter += 1
|
|
2405
|
+
|
|
1972
2406
|
# Add to history
|
|
1973
2407
|
self.add_history(f"Duplicated column [$success]{col_name}[/]", dirty=True)
|
|
1974
2408
|
|
|
1975
2409
|
# Create new column and reorder columns to insert after current column
|
|
1976
2410
|
cols_before = self.df.columns[: cidx + 1]
|
|
1977
2411
|
cols_after = self.df.columns[cidx + 1 :]
|
|
2412
|
+
cols_new = cols_before + [new_col_name] + cols_after
|
|
1978
2413
|
|
|
1979
2414
|
# Add the new column and reorder columns for insertion after current column
|
|
1980
|
-
self.df = self.df.with_columns(pl.col(col_name).alias(new_col_name)).select(
|
|
1981
|
-
list(cols_before) + [new_col_name] + list(cols_after)
|
|
1982
|
-
)
|
|
2415
|
+
self.df = self.df.lazy().with_columns(pl.col(col_name).alias(new_col_name)).select(cols_new).collect()
|
|
1983
2416
|
|
|
1984
|
-
#
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
if col_idx_in_set <= cidx:
|
|
1990
|
-
new_cols.add(col_idx_in_set)
|
|
1991
|
-
else:
|
|
1992
|
-
new_cols.add(col_idx_in_set + 1)
|
|
1993
|
-
new_matches[row_idx] = new_cols
|
|
1994
|
-
self.matches = new_matches
|
|
2417
|
+
# Also update the view if applicable
|
|
2418
|
+
if self.df_view is not None:
|
|
2419
|
+
self.df_view = (
|
|
2420
|
+
self.df_view.lazy().with_columns(pl.col(col_name).alias(new_col_name)).select(cols_new).collect()
|
|
2421
|
+
)
|
|
1995
2422
|
|
|
1996
2423
|
# Recreate table for display
|
|
1997
2424
|
self.setup_table()
|
|
@@ -1999,7 +2426,7 @@ class DataFrameTable(DataTable):
|
|
|
1999
2426
|
# Move cursor to the new duplicated column
|
|
2000
2427
|
self.move_cursor(column=col_idx + 1)
|
|
2001
2428
|
|
|
2002
|
-
# self.notify(f"Duplicated column [$
|
|
2429
|
+
# self.notify(f"Duplicated column [$success]{col_name}[/] as [$accent]{new_col_name}[/]", title="Duplicate Column")
|
|
2003
2430
|
|
|
2004
2431
|
def do_delete_row(self, more: str = None) -> None:
|
|
2005
2432
|
"""Delete rows from the table and dataframe.
|
|
@@ -2007,97 +2434,95 @@ class DataFrameTable(DataTable):
|
|
|
2007
2434
|
Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
|
|
2008
2435
|
"""
|
|
2009
2436
|
old_count = len(self.df)
|
|
2010
|
-
|
|
2437
|
+
rids_to_delete = set()
|
|
2011
2438
|
|
|
2012
2439
|
# Delete all selected rows
|
|
2013
|
-
if selected_count := self.selected_rows
|
|
2440
|
+
if selected_count := len(self.selected_rows):
|
|
2014
2441
|
history_desc = f"Deleted {selected_count} selected row(s)"
|
|
2015
|
-
|
|
2016
|
-
for ridx, selected in enumerate(self.selected_rows):
|
|
2017
|
-
if selected:
|
|
2018
|
-
predicates[ridx] = False
|
|
2442
|
+
rids_to_delete.update(self.selected_rows)
|
|
2019
2443
|
|
|
2020
2444
|
# Delete current row and those above
|
|
2021
2445
|
elif more == "above":
|
|
2022
2446
|
ridx = self.cursor_row_idx
|
|
2023
2447
|
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
|
|
2024
|
-
for
|
|
2025
|
-
|
|
2448
|
+
for rid in self.df[RID][: ridx + 1]:
|
|
2449
|
+
rids_to_delete.add(rid)
|
|
2026
2450
|
|
|
2027
2451
|
# Delete current row and those below
|
|
2028
2452
|
elif more == "below":
|
|
2029
2453
|
ridx = self.cursor_row_idx
|
|
2030
2454
|
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
|
|
2031
|
-
for
|
|
2032
|
-
|
|
2033
|
-
predicates[i] = False
|
|
2455
|
+
for rid in self.df[RID][ridx:]:
|
|
2456
|
+
rids_to_delete.add(rid)
|
|
2034
2457
|
|
|
2035
2458
|
# Delete the row at the cursor
|
|
2036
2459
|
else:
|
|
2037
2460
|
ridx = self.cursor_row_idx
|
|
2038
2461
|
history_desc = f"Deleted row [$success]{ridx + 1}[/]"
|
|
2039
|
-
|
|
2040
|
-
predicates[ridx] = False
|
|
2462
|
+
rids_to_delete.add(self.df[RID][ridx])
|
|
2041
2463
|
|
|
2042
2464
|
# Add to history
|
|
2043
2465
|
self.add_history(history_desc, dirty=True)
|
|
2044
2466
|
|
|
2045
2467
|
# Apply the filter to remove rows
|
|
2046
2468
|
try:
|
|
2047
|
-
|
|
2469
|
+
df_filtered = self.df.lazy().filter(~pl.col(RID).is_in(rids_to_delete)).collect()
|
|
2048
2470
|
except Exception as e:
|
|
2049
|
-
self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error")
|
|
2050
|
-
self.
|
|
2471
|
+
self.notify(f"Error deleting row(s): {e}", title="Delete Row(s)", severity="error", timeout=10)
|
|
2472
|
+
self.histories_undo.pop() # Remove last history entry
|
|
2051
2473
|
return
|
|
2052
2474
|
|
|
2053
|
-
|
|
2475
|
+
# RIDs of remaining rows
|
|
2476
|
+
ok_rids = set(df_filtered[RID])
|
|
2054
2477
|
|
|
2055
|
-
# Update selected
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
self.visible_rows = [visible for i, visible in enumerate(self.visible_rows) if i in old_row_indices]
|
|
2478
|
+
# Update selected rows tracking
|
|
2479
|
+
if self.selected_rows:
|
|
2480
|
+
self.selected_rows.intersection_update(ok_rids)
|
|
2059
2481
|
|
|
2060
|
-
#
|
|
2061
|
-
self.
|
|
2482
|
+
# Update the dataframe
|
|
2483
|
+
self.df = df_filtered
|
|
2484
|
+
|
|
2485
|
+
# Update matches since row indices have changed
|
|
2486
|
+
if self.matches:
|
|
2487
|
+
self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
|
|
2488
|
+
|
|
2489
|
+
# Also update the view if applicable
|
|
2490
|
+
if self.df_view is not None:
|
|
2491
|
+
self.df_view = self.df_view.lazy().filter(~pl.col(RID).is_in(rids_to_delete)).collect()
|
|
2062
2492
|
|
|
2063
2493
|
# Recreate table for display
|
|
2064
2494
|
self.setup_table()
|
|
2065
2495
|
|
|
2066
2496
|
deleted_count = old_count - len(self.df)
|
|
2067
2497
|
if deleted_count > 0:
|
|
2068
|
-
self.notify(f"Deleted [$
|
|
2498
|
+
self.notify(f"Deleted [$success]{deleted_count}[/] row(s)", title="Delete Row(s)")
|
|
2069
2499
|
|
|
2070
2500
|
def do_duplicate_row(self) -> None:
|
|
2071
2501
|
"""Duplicate the currently selected row, inserting it right after the current row."""
|
|
2072
2502
|
ridx = self.cursor_row_idx
|
|
2503
|
+
rid = self.df[RID][ridx]
|
|
2504
|
+
|
|
2505
|
+
lf = self.df.lazy()
|
|
2073
2506
|
|
|
2074
2507
|
# Get the row to duplicate
|
|
2075
|
-
row_to_duplicate =
|
|
2508
|
+
row_to_duplicate = lf.slice(ridx, 1).with_columns(pl.col(RID) + 1)
|
|
2076
2509
|
|
|
2077
2510
|
# Add to history
|
|
2078
2511
|
self.add_history(f"Duplicated row [$success]{ridx + 1}[/]", dirty=True)
|
|
2079
2512
|
|
|
2080
2513
|
# Concatenate: rows before + duplicated row + rows after
|
|
2081
|
-
|
|
2082
|
-
|
|
2514
|
+
lf_before = lf.slice(0, ridx + 1)
|
|
2515
|
+
lf_after = lf.slice(ridx + 1).with_columns(pl.col(RID) + 1)
|
|
2083
2516
|
|
|
2084
2517
|
# Combine the parts
|
|
2085
|
-
self.df = pl.concat([
|
|
2086
|
-
|
|
2087
|
-
#
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
# Update matches to account for new row
|
|
2094
|
-
new_matches = defaultdict(set)
|
|
2095
|
-
for row_idx, cols in self.matches.items():
|
|
2096
|
-
if row_idx <= ridx:
|
|
2097
|
-
new_matches[row_idx] = cols
|
|
2098
|
-
else:
|
|
2099
|
-
new_matches[row_idx + 1] = cols
|
|
2100
|
-
self.matches = new_matches
|
|
2518
|
+
self.df = pl.concat([lf_before, row_to_duplicate, lf_after]).collect()
|
|
2519
|
+
|
|
2520
|
+
# Also update the view if applicable
|
|
2521
|
+
if self.df_view is not None:
|
|
2522
|
+
lf_view = self.df_view.lazy()
|
|
2523
|
+
lf_view_before = lf_view.slice(0, rid + 1)
|
|
2524
|
+
lf_view_after = lf_view.slice(rid + 1).with_columns(pl.col(RID) + 1)
|
|
2525
|
+
self.df_view = pl.concat([lf_view_before, row_to_duplicate, lf_view_after]).collect()
|
|
2101
2526
|
|
|
2102
2527
|
# Recreate table for display
|
|
2103
2528
|
self.setup_table()
|
|
@@ -2105,7 +2530,7 @@ class DataFrameTable(DataTable):
|
|
|
2105
2530
|
# Move cursor to the new duplicated row
|
|
2106
2531
|
self.move_cursor(row=ridx + 1)
|
|
2107
2532
|
|
|
2108
|
-
# self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Row")
|
|
2533
|
+
# self.notify(f"Duplicated row [$success]{ridx + 1}[/]", title="Duplicate Row")
|
|
2109
2534
|
|
|
2110
2535
|
def do_move_column(self, direction: str) -> None:
|
|
2111
2536
|
"""Move the current column left or right.
|
|
@@ -2121,12 +2546,12 @@ class DataFrameTable(DataTable):
|
|
|
2121
2546
|
# Validate move is possible
|
|
2122
2547
|
if direction == "left":
|
|
2123
2548
|
if col_idx <= 0:
|
|
2124
|
-
self.notify("Cannot move column left", title="Move", severity="warning")
|
|
2549
|
+
# self.notify("Cannot move column left", title="Move Column", severity="warning")
|
|
2125
2550
|
return
|
|
2126
2551
|
swap_idx = col_idx - 1
|
|
2127
2552
|
elif direction == "right":
|
|
2128
2553
|
if col_idx >= len(self.columns) - 1:
|
|
2129
|
-
self.notify("Cannot move column right", title="Move", severity="warning")
|
|
2554
|
+
# self.notify("Cannot move column right", title="Move Column", severity="warning")
|
|
2130
2555
|
return
|
|
2131
2556
|
swap_idx = col_idx + 1
|
|
2132
2557
|
|
|
@@ -2137,7 +2562,8 @@ class DataFrameTable(DataTable):
|
|
|
2137
2562
|
|
|
2138
2563
|
# Add to history
|
|
2139
2564
|
self.add_history(
|
|
2140
|
-
f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])",
|
|
2565
|
+
f"Moved column [$success]{col_name}[/] [$accent]{direction}[/] (swapped with [$success]{swap_name}[/])",
|
|
2566
|
+
dirty=True,
|
|
2141
2567
|
)
|
|
2142
2568
|
|
|
2143
2569
|
# Swap columns in the table's internal column locations
|
|
@@ -2162,7 +2588,11 @@ class DataFrameTable(DataTable):
|
|
|
2162
2588
|
cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
|
|
2163
2589
|
self.df = self.df.select(cols)
|
|
2164
2590
|
|
|
2165
|
-
#
|
|
2591
|
+
# Also update the view if applicable
|
|
2592
|
+
if self.df_view is not None:
|
|
2593
|
+
self.df_view = self.df_view.select(cols)
|
|
2594
|
+
|
|
2595
|
+
# self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move Column")
|
|
2166
2596
|
|
|
2167
2597
|
def do_move_row(self, direction: str) -> None:
|
|
2168
2598
|
"""Move the current row up or down.
|
|
@@ -2170,65 +2600,88 @@ class DataFrameTable(DataTable):
|
|
|
2170
2600
|
Args:
|
|
2171
2601
|
direction: "up" to move up, "down" to move down.
|
|
2172
2602
|
"""
|
|
2173
|
-
|
|
2603
|
+
curr_row_idx, col_idx = self.cursor_coordinate
|
|
2174
2604
|
|
|
2175
2605
|
# Validate move is possible
|
|
2176
2606
|
if direction == "up":
|
|
2177
|
-
if
|
|
2178
|
-
self.notify("Cannot move row up", title="Move", severity="warning")
|
|
2607
|
+
if curr_row_idx <= 0:
|
|
2608
|
+
# self.notify("Cannot move row up", title="Move Row", severity="warning")
|
|
2179
2609
|
return
|
|
2180
|
-
|
|
2610
|
+
swap_row_idx = curr_row_idx - 1
|
|
2181
2611
|
elif direction == "down":
|
|
2182
|
-
if
|
|
2183
|
-
self.notify("Cannot move row down", title="Move", severity="warning")
|
|
2612
|
+
if curr_row_idx >= len(self.rows) - 1:
|
|
2613
|
+
# self.notify("Cannot move row down", title="Move Row", severity="warning")
|
|
2184
2614
|
return
|
|
2185
|
-
|
|
2615
|
+
swap_row_idx = curr_row_idx + 1
|
|
2186
2616
|
else:
|
|
2187
2617
|
# Invalid direction
|
|
2188
2618
|
return
|
|
2189
2619
|
|
|
2190
|
-
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
2191
|
-
swap_key = self.coordinate_to_cell_key((swap_idx, 0)).row_key
|
|
2192
|
-
|
|
2193
2620
|
# Add to history
|
|
2194
2621
|
self.add_history(
|
|
2195
|
-
f"Moved row [$success]{
|
|
2622
|
+
f"Moved row [$success]{curr_row_idx}[/] [$accent]{direction}[/] (swapped with row [$success]{swap_row_idx}[/])",
|
|
2196
2623
|
dirty=True,
|
|
2197
2624
|
)
|
|
2198
2625
|
|
|
2199
2626
|
# Swap rows in the table's internal row locations
|
|
2627
|
+
curr_key = self.coordinate_to_cell_key((curr_row_idx, 0)).row_key
|
|
2628
|
+
swap_key = self.coordinate_to_cell_key((swap_row_idx, 0)).row_key
|
|
2629
|
+
|
|
2200
2630
|
self.check_idle()
|
|
2201
2631
|
|
|
2202
2632
|
(
|
|
2203
|
-
self._row_locations[
|
|
2633
|
+
self._row_locations[curr_key],
|
|
2204
2634
|
self._row_locations[swap_key],
|
|
2205
2635
|
) = (
|
|
2206
|
-
self.
|
|
2207
|
-
self.
|
|
2636
|
+
self.get_row_idx(swap_key),
|
|
2637
|
+
self.get_row_idx(curr_key),
|
|
2208
2638
|
)
|
|
2209
2639
|
|
|
2210
2640
|
self._update_count += 1
|
|
2211
2641
|
self.refresh()
|
|
2212
2642
|
|
|
2213
2643
|
# Restore cursor position on the moved row
|
|
2214
|
-
self.move_cursor(row=
|
|
2644
|
+
self.move_cursor(row=swap_row_idx, column=col_idx)
|
|
2215
2645
|
|
|
2216
|
-
#
|
|
2217
|
-
|
|
2218
|
-
swap_ridx =
|
|
2219
|
-
first, second = sorted([
|
|
2646
|
+
# Locate the rows to swap
|
|
2647
|
+
curr_ridx = curr_row_idx
|
|
2648
|
+
swap_ridx = swap_row_idx
|
|
2649
|
+
first, second = sorted([curr_ridx, swap_ridx])
|
|
2220
2650
|
|
|
2651
|
+
# Swap the rows in the dataframe
|
|
2221
2652
|
self.df = pl.concat(
|
|
2222
2653
|
[
|
|
2223
|
-
self.df.slice(0, first),
|
|
2224
|
-
self.df.slice(second, 1),
|
|
2225
|
-
self.df.slice(first + 1, second - first - 1),
|
|
2226
|
-
self.df.slice(first, 1),
|
|
2227
|
-
self.df.slice(second + 1),
|
|
2654
|
+
self.df.slice(0, first).lazy(),
|
|
2655
|
+
self.df.slice(second, 1).lazy(),
|
|
2656
|
+
self.df.slice(first + 1, second - first - 1).lazy(),
|
|
2657
|
+
self.df.slice(first, 1).lazy(),
|
|
2658
|
+
self.df.slice(second + 1).lazy(),
|
|
2228
2659
|
]
|
|
2229
|
-
)
|
|
2660
|
+
).collect()
|
|
2230
2661
|
|
|
2231
|
-
#
|
|
2662
|
+
# Also update the view if applicable
|
|
2663
|
+
if self.df_view is not None:
|
|
2664
|
+
# Find RID values
|
|
2665
|
+
curr_rid = self.df[RID][curr_row_idx]
|
|
2666
|
+
swap_rid = self.df[RID][swap_row_idx]
|
|
2667
|
+
|
|
2668
|
+
# Locate the rows by RID in the view
|
|
2669
|
+
curr_ridx = self.df_view[RID].index_of(curr_rid)
|
|
2670
|
+
swap_ridx = self.df_view[RID].index_of(swap_rid)
|
|
2671
|
+
first, second = sorted([curr_ridx, swap_ridx])
|
|
2672
|
+
|
|
2673
|
+
# Swap the rows in the view
|
|
2674
|
+
self.df_view = pl.concat(
|
|
2675
|
+
[
|
|
2676
|
+
self.df_view.slice(0, first).lazy(),
|
|
2677
|
+
self.df_view.slice(second, 1).lazy(),
|
|
2678
|
+
self.df_view.slice(first + 1, second - first - 1).lazy(),
|
|
2679
|
+
self.df_view.slice(first, 1).lazy(),
|
|
2680
|
+
self.df_view.slice(second + 1).lazy(),
|
|
2681
|
+
]
|
|
2682
|
+
).collect()
|
|
2683
|
+
|
|
2684
|
+
# self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move Row")
|
|
2232
2685
|
|
|
2233
2686
|
# Type casting
|
|
2234
2687
|
def do_cast_column_dtype(self, dtype: str) -> None:
|
|
@@ -2244,20 +2697,22 @@ class DataFrameTable(DataTable):
|
|
|
2244
2697
|
try:
|
|
2245
2698
|
target_dtype = eval(dtype)
|
|
2246
2699
|
except Exception:
|
|
2247
|
-
self.notify(
|
|
2700
|
+
self.notify(
|
|
2701
|
+
f"Invalid target data type: [$error]{dtype}[/]", title="Cast Column", severity="error", timeout=10
|
|
2702
|
+
)
|
|
2248
2703
|
return
|
|
2249
2704
|
|
|
2250
2705
|
if current_dtype == target_dtype:
|
|
2251
|
-
self.notify(
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
)
|
|
2706
|
+
# self.notify(
|
|
2707
|
+
# f"Column [$warning]{col_name}[/] is already of type [$accent]{target_dtype}[/]",
|
|
2708
|
+
# title="Cast Column",
|
|
2709
|
+
# severity="warning",
|
|
2710
|
+
# )
|
|
2256
2711
|
return # No change needed
|
|
2257
2712
|
|
|
2258
2713
|
# Add to history
|
|
2259
2714
|
self.add_history(
|
|
2260
|
-
f"Cast column [$
|
|
2715
|
+
f"Cast column [$success]{col_name}[/] from [$accent]{current_dtype}[/] to [$success]{target_dtype}[/]",
|
|
2261
2716
|
dirty=True,
|
|
2262
2717
|
)
|
|
2263
2718
|
|
|
@@ -2265,30 +2720,49 @@ class DataFrameTable(DataTable):
|
|
|
2265
2720
|
# Cast the column using Polars
|
|
2266
2721
|
self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
|
|
2267
2722
|
|
|
2723
|
+
# Also update the view if applicable
|
|
2724
|
+
if self.df_view is not None:
|
|
2725
|
+
self.df_view = self.df_view.with_columns(pl.col(col_name).cast(target_dtype))
|
|
2726
|
+
|
|
2268
2727
|
# Recreate table for display
|
|
2269
2728
|
self.setup_table()
|
|
2270
2729
|
|
|
2271
|
-
self.notify(f"Cast column [$
|
|
2730
|
+
# self.notify(f"Cast column [$success]{col_name}[/] to [$accent]{target_dtype}[/]", title="Cast")
|
|
2272
2731
|
except Exception as e:
|
|
2273
2732
|
self.notify(
|
|
2274
|
-
f"Error casting column [$
|
|
2275
|
-
title="Cast",
|
|
2733
|
+
f"Error casting column [$error]{col_name}[/] to [$accent]{target_dtype}[/]",
|
|
2734
|
+
title="Cast Column",
|
|
2276
2735
|
severity="error",
|
|
2736
|
+
timeout=10,
|
|
2277
2737
|
)
|
|
2278
2738
|
self.log(f"Error casting column `{col_name}`: {str(e)}")
|
|
2279
2739
|
|
|
2280
|
-
#
|
|
2281
|
-
def
|
|
2282
|
-
"""
|
|
2740
|
+
# Row selection
|
|
2741
|
+
def do_select_row(self) -> None:
|
|
2742
|
+
"""Select rows.
|
|
2743
|
+
|
|
2744
|
+
If there are existing cell matches, use those to select rows.
|
|
2745
|
+
Otherwise, use the current cell value as the search term and select rows matching that value.
|
|
2746
|
+
"""
|
|
2283
2747
|
cidx = self.cursor_col_idx
|
|
2284
2748
|
|
|
2285
|
-
#
|
|
2286
|
-
|
|
2749
|
+
# Use existing cell matches if present
|
|
2750
|
+
if self.matches:
|
|
2751
|
+
term = pl.col(RID).is_in(self.matches)
|
|
2752
|
+
else:
|
|
2753
|
+
col_name = self.cursor_col_name
|
|
2754
|
+
|
|
2755
|
+
# Get the value of the currently selected cell
|
|
2756
|
+
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
2757
|
+
if self.cursor_value is None:
|
|
2758
|
+
term = pl.col(col_name).is_null()
|
|
2759
|
+
else:
|
|
2760
|
+
term = pl.col(col_name) == self.cursor_value
|
|
2287
2761
|
|
|
2288
|
-
self.
|
|
2762
|
+
self.select_row((term, cidx, False, True))
|
|
2289
2763
|
|
|
2290
|
-
def
|
|
2291
|
-
"""
|
|
2764
|
+
def do_select_row_expr(self) -> None:
|
|
2765
|
+
"""Select rows by expression."""
|
|
2292
2766
|
cidx = self.cursor_col_idx
|
|
2293
2767
|
|
|
2294
2768
|
# Use current cell value as default search term
|
|
@@ -2296,27 +2770,38 @@ class DataFrameTable(DataTable):
|
|
|
2296
2770
|
|
|
2297
2771
|
# Push the search modal screen
|
|
2298
2772
|
self.app.push_screen(
|
|
2299
|
-
SearchScreen("
|
|
2300
|
-
callback=self.
|
|
2773
|
+
SearchScreen("Select", term, self.df, cidx),
|
|
2774
|
+
callback=self.select_row,
|
|
2301
2775
|
)
|
|
2302
2776
|
|
|
2303
|
-
def
|
|
2304
|
-
"""
|
|
2777
|
+
def select_row(self, result) -> None:
|
|
2778
|
+
"""Select rows by value or expression."""
|
|
2305
2779
|
if result is None:
|
|
2306
2780
|
return
|
|
2307
2781
|
|
|
2308
2782
|
term, cidx, match_nocase, match_whole = result
|
|
2309
|
-
col_name = self.df.columns[cidx]
|
|
2783
|
+
col_name = "all columns" if cidx is None else self.df.columns[cidx]
|
|
2784
|
+
|
|
2785
|
+
# Already a Polars expression
|
|
2786
|
+
if isinstance(term, pl.Expr):
|
|
2787
|
+
expr = term
|
|
2310
2788
|
|
|
2311
|
-
|
|
2789
|
+
# bool list or Series
|
|
2790
|
+
elif isinstance(term, (list, pl.Series)):
|
|
2791
|
+
expr = term
|
|
2792
|
+
|
|
2793
|
+
# Null case
|
|
2794
|
+
elif term == NULL:
|
|
2312
2795
|
expr = pl.col(col_name).is_null()
|
|
2313
2796
|
|
|
2314
|
-
#
|
|
2797
|
+
# Expression in string form
|
|
2315
2798
|
elif tentative_expr(term):
|
|
2316
2799
|
try:
|
|
2317
2800
|
expr = validate_expr(term, self.df.columns, cidx)
|
|
2318
2801
|
except Exception as e:
|
|
2319
|
-
self.notify(
|
|
2802
|
+
self.notify(
|
|
2803
|
+
f"Error validating expression [$error]{term}[/]", title="Search", severity="error", timeout=10
|
|
2804
|
+
)
|
|
2320
2805
|
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
2321
2806
|
return
|
|
2322
2807
|
|
|
@@ -2340,55 +2825,127 @@ class DataFrameTable(DataTable):
|
|
|
2340
2825
|
term = f"(?i){term}"
|
|
2341
2826
|
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
2342
2827
|
self.notify(
|
|
2343
|
-
f"Error converting [$
|
|
2828
|
+
f"Error converting [$error]{term}[/] to [$accent]{dtype}[/]. Cast to string.",
|
|
2344
2829
|
title="Search",
|
|
2345
2830
|
severity="warning",
|
|
2346
2831
|
)
|
|
2347
2832
|
|
|
2348
2833
|
# Lazyframe for filtering
|
|
2349
|
-
lf = self.df.lazy()
|
|
2350
|
-
if False in self.visible_rows:
|
|
2351
|
-
lf = lf.filter(self.visible_rows)
|
|
2834
|
+
lf = self.df.lazy()
|
|
2352
2835
|
|
|
2353
2836
|
# Apply filter to get matched row indices
|
|
2354
2837
|
try:
|
|
2355
|
-
|
|
2838
|
+
ok_rids = set(lf.filter(expr).collect()[RID])
|
|
2356
2839
|
except Exception as e:
|
|
2357
|
-
self.notify(
|
|
2840
|
+
self.notify(
|
|
2841
|
+
f"Error applying search filter `[$error]{term}[/]`", title="Search", severity="error", timeout=10
|
|
2842
|
+
)
|
|
2358
2843
|
self.log(f"Error applying search filter `{term}`: {str(e)}")
|
|
2359
2844
|
return
|
|
2360
2845
|
|
|
2361
|
-
match_count = len(
|
|
2846
|
+
match_count = len(ok_rids)
|
|
2362
2847
|
if match_count == 0:
|
|
2363
2848
|
self.notify(
|
|
2364
|
-
f"No matches found for [$
|
|
2849
|
+
f"No matches found for `[$warning]{term}[/]`. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2365
2850
|
title="Search",
|
|
2366
2851
|
severity="warning",
|
|
2367
2852
|
)
|
|
2368
2853
|
return
|
|
2369
2854
|
|
|
2855
|
+
message = f"Found [$success]{match_count}[/] matching row(s)"
|
|
2856
|
+
|
|
2370
2857
|
# Add to history
|
|
2371
|
-
self.add_history(
|
|
2858
|
+
self.add_history(message)
|
|
2372
2859
|
|
|
2373
|
-
# Update selected rows to include new
|
|
2374
|
-
|
|
2375
|
-
self.selected_rows[m] = True
|
|
2860
|
+
# Update selected rows to include new selections
|
|
2861
|
+
self.selected_rows.update(ok_rids)
|
|
2376
2862
|
|
|
2377
2863
|
# Show notification immediately, then start highlighting
|
|
2378
|
-
self.notify(
|
|
2864
|
+
self.notify(message, title="Select Row")
|
|
2865
|
+
|
|
2866
|
+
# Recreate table for display
|
|
2867
|
+
self.setup_table()
|
|
2868
|
+
|
|
2869
|
+
def do_toggle_selections(self) -> None:
|
|
2870
|
+
"""Toggle selected rows highlighting on/off."""
|
|
2871
|
+
# Add to history
|
|
2872
|
+
self.add_history("Toggled row selection")
|
|
2873
|
+
|
|
2874
|
+
# Invert all selected rows
|
|
2875
|
+
self.selected_rows = {rid for rid in self.df[RID] if rid not in self.selected_rows}
|
|
2876
|
+
|
|
2877
|
+
# Check if we're highlighting or un-highlighting
|
|
2878
|
+
if selected_count := len(self.selected_rows):
|
|
2879
|
+
self.notify(f"Toggled selection for [$success]{selected_count}[/] rows", title="Toggle Selection(s)")
|
|
2379
2880
|
|
|
2380
2881
|
# Recreate table for display
|
|
2381
2882
|
self.setup_table()
|
|
2382
2883
|
|
|
2383
|
-
|
|
2884
|
+
def do_toggle_row_selection(self) -> None:
|
|
2885
|
+
"""Select/deselect current row."""
|
|
2886
|
+
# Add to history
|
|
2887
|
+
self.add_history("Toggled row selection")
|
|
2888
|
+
|
|
2889
|
+
# Get current row RID
|
|
2890
|
+
ridx = self.cursor_row_idx
|
|
2891
|
+
rid = self.df[RID][ridx]
|
|
2892
|
+
|
|
2893
|
+
if rid in self.selected_rows:
|
|
2894
|
+
self.selected_rows.discard(rid)
|
|
2895
|
+
else:
|
|
2896
|
+
self.selected_rows.add(rid)
|
|
2897
|
+
|
|
2898
|
+
row_key = self.cursor_row_key
|
|
2899
|
+
is_selected = rid in self.selected_rows
|
|
2900
|
+
match_cols = self.matches.get(rid, set())
|
|
2901
|
+
|
|
2902
|
+
for col_idx, col in enumerate(self.ordered_columns):
|
|
2903
|
+
col_key = col.key
|
|
2904
|
+
col_name = col_key.value
|
|
2905
|
+
cell_text: Text = self.get_cell(row_key, col_key)
|
|
2906
|
+
|
|
2907
|
+
if is_selected or (col_name in match_cols):
|
|
2908
|
+
cell_text.style = HIGHLIGHT_COLOR
|
|
2909
|
+
else:
|
|
2910
|
+
# Reset to default style based on dtype
|
|
2911
|
+
dtype = self.df.dtypes[col_idx]
|
|
2912
|
+
dc = DtypeConfig(dtype)
|
|
2913
|
+
cell_text.style = dc.style
|
|
2914
|
+
|
|
2915
|
+
self.update_cell(row_key, col_key, cell_text)
|
|
2916
|
+
|
|
2917
|
+
def do_clear_selections_and_matches(self) -> None:
|
|
2918
|
+
"""Clear all selected rows and matches without removing them from the dataframe."""
|
|
2919
|
+
# Check if any selected rows or matches
|
|
2920
|
+
if not self.selected_rows and not self.matches:
|
|
2921
|
+
# self.notify("No selections to clear", title="Clear Selections and Matches", severity="warning")
|
|
2922
|
+
return
|
|
2923
|
+
|
|
2924
|
+
row_count = len(self.selected_rows | set(self.matches.keys()))
|
|
2925
|
+
|
|
2926
|
+
# Add to history
|
|
2927
|
+
self.add_history("Cleared all selected rows")
|
|
2928
|
+
|
|
2929
|
+
# Clear all selections
|
|
2930
|
+
self.selected_rows = set()
|
|
2931
|
+
self.matches = defaultdict(set)
|
|
2932
|
+
|
|
2933
|
+
# Recreate table for display
|
|
2934
|
+
self.setup_table()
|
|
2935
|
+
|
|
2936
|
+
self.notify(f"Cleared selections for [$success]{row_count}[/] rows", title="Clear Selections and Matches")
|
|
2937
|
+
|
|
2938
|
+
# Find & Replace
|
|
2384
2939
|
def find_matches(
|
|
2385
2940
|
self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
|
|
2386
|
-
) -> dict[int, set[
|
|
2941
|
+
) -> dict[int, set[str]]:
|
|
2387
2942
|
"""Find matches for a term in the dataframe.
|
|
2388
2943
|
|
|
2389
2944
|
Args:
|
|
2390
2945
|
term: The search term (can be NULL, expression, or plain text)
|
|
2391
2946
|
cidx: Column index for column-specific search. If None, searches all columns.
|
|
2947
|
+
match_nocase: Whether to perform case-insensitive matching (for string terms)
|
|
2948
|
+
match_whole: Whether to match the whole cell content (for string terms)
|
|
2392
2949
|
|
|
2393
2950
|
Returns:
|
|
2394
2951
|
Dictionary mapping row indices to sets of column indices containing matches.
|
|
@@ -2398,12 +2955,10 @@ class DataFrameTable(DataTable):
|
|
|
2398
2955
|
Raises:
|
|
2399
2956
|
Exception: If expression validation or filtering fails.
|
|
2400
2957
|
"""
|
|
2401
|
-
matches: dict[int, set[
|
|
2958
|
+
matches: dict[int, set[str]] = defaultdict(set)
|
|
2402
2959
|
|
|
2403
2960
|
# Lazyframe for filtering
|
|
2404
|
-
lf = self.df.lazy()
|
|
2405
|
-
if False in self.visible_rows:
|
|
2406
|
-
lf = lf.filter(self.visible_rows)
|
|
2961
|
+
lf = self.df.lazy()
|
|
2407
2962
|
|
|
2408
2963
|
# Determine which columns to search: single column or all columns
|
|
2409
2964
|
if cidx is not None:
|
|
@@ -2420,7 +2975,9 @@ class DataFrameTable(DataTable):
|
|
|
2420
2975
|
try:
|
|
2421
2976
|
expr = validate_expr(term, self.df.columns, col_idx)
|
|
2422
2977
|
except Exception as e:
|
|
2423
|
-
self.notify(
|
|
2978
|
+
self.notify(
|
|
2979
|
+
f"Error validating expression [$error]{term}[/]", title="Find", severity="error", timeout=10
|
|
2980
|
+
)
|
|
2424
2981
|
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
2425
2982
|
return matches
|
|
2426
2983
|
else:
|
|
@@ -2432,14 +2989,14 @@ class DataFrameTable(DataTable):
|
|
|
2432
2989
|
|
|
2433
2990
|
# Get matched row indices
|
|
2434
2991
|
try:
|
|
2435
|
-
matched_ridxs = lf.filter(expr).
|
|
2992
|
+
matched_ridxs = lf.filter(expr).collect()[RID]
|
|
2436
2993
|
except Exception as e:
|
|
2437
|
-
self.notify(f"Error applying filter: {expr}", title="Find", severity="error")
|
|
2994
|
+
self.notify(f"Error applying filter: [$error]{expr}[/]", title="Find", severity="error", timeout=10)
|
|
2438
2995
|
self.log(f"Error applying filter: {str(e)}")
|
|
2439
2996
|
return matches
|
|
2440
2997
|
|
|
2441
2998
|
for ridx in matched_ridxs:
|
|
2442
|
-
matches[ridx].add(
|
|
2999
|
+
matches[ridx].add(col_name)
|
|
2443
3000
|
|
|
2444
3001
|
return matches
|
|
2445
3002
|
|
|
@@ -2485,27 +3042,27 @@ class DataFrameTable(DataTable):
|
|
|
2485
3042
|
try:
|
|
2486
3043
|
matches = self.find_matches(term, cidx, match_nocase, match_whole)
|
|
2487
3044
|
except Exception as e:
|
|
2488
|
-
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
3045
|
+
self.notify(f"Error finding matches for `[$error]{term}[/]`", title="Find", severity="error", timeout=10)
|
|
2489
3046
|
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
2490
3047
|
return
|
|
2491
3048
|
|
|
2492
3049
|
if not matches:
|
|
2493
3050
|
self.notify(
|
|
2494
|
-
f"No matches found for [$
|
|
3051
|
+
f"No matches found for `[$warning]{term}[/]` in current column. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2495
3052
|
title="Find",
|
|
2496
3053
|
severity="warning",
|
|
2497
3054
|
)
|
|
2498
3055
|
return
|
|
2499
3056
|
|
|
2500
3057
|
# Add to history
|
|
2501
|
-
self.add_history(f"Found [$
|
|
3058
|
+
self.add_history(f"Found `[$success]{term}[/]` in column [$accent]{col_name}[/]")
|
|
2502
3059
|
|
|
2503
3060
|
# Add to matches and count total
|
|
2504
|
-
match_count = sum(len(
|
|
2505
|
-
for
|
|
2506
|
-
self.matches[
|
|
3061
|
+
match_count = sum(len(cols) for cols in matches.values())
|
|
3062
|
+
for rid, cols in matches.items():
|
|
3063
|
+
self.matches[rid].update(cols)
|
|
2507
3064
|
|
|
2508
|
-
self.notify(f"Found [$
|
|
3065
|
+
self.notify(f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]`", title="Find")
|
|
2509
3066
|
|
|
2510
3067
|
# Recreate table for display
|
|
2511
3068
|
self.setup_table()
|
|
@@ -2519,28 +3076,29 @@ class DataFrameTable(DataTable):
|
|
|
2519
3076
|
try:
|
|
2520
3077
|
matches = self.find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
|
|
2521
3078
|
except Exception as e:
|
|
2522
|
-
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
3079
|
+
self.notify(f"Error finding matches for `[$error]{term}[/]`", title="Find", severity="error", timeout=10)
|
|
2523
3080
|
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
2524
3081
|
return
|
|
2525
3082
|
|
|
2526
3083
|
if not matches:
|
|
2527
3084
|
self.notify(
|
|
2528
|
-
f"No matches found for [$
|
|
3085
|
+
f"No matches found for `[$warning]{term}[/]` in any column. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2529
3086
|
title="Global Find",
|
|
2530
3087
|
severity="warning",
|
|
2531
3088
|
)
|
|
2532
3089
|
return
|
|
2533
3090
|
|
|
2534
3091
|
# Add to history
|
|
2535
|
-
self.add_history(f"Found [$success]{term}[/] across all columns")
|
|
3092
|
+
self.add_history(f"Found `[$success]{term}[/]` across all columns")
|
|
2536
3093
|
|
|
2537
3094
|
# Add to matches and count total
|
|
2538
|
-
match_count = sum(len(
|
|
2539
|
-
for
|
|
2540
|
-
self.matches[
|
|
3095
|
+
match_count = sum(len(cols) for cols in matches.values())
|
|
3096
|
+
for rid, cols in matches.items():
|
|
3097
|
+
self.matches[rid].update(cols)
|
|
2541
3098
|
|
|
2542
3099
|
self.notify(
|
|
2543
|
-
f"Found [$
|
|
3100
|
+
f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]` across all columns",
|
|
3101
|
+
title="Global Find",
|
|
2544
3102
|
)
|
|
2545
3103
|
|
|
2546
3104
|
# Recreate table for display
|
|
@@ -2549,7 +3107,7 @@ class DataFrameTable(DataTable):
|
|
|
2549
3107
|
def do_next_match(self) -> None:
|
|
2550
3108
|
"""Move cursor to the next match."""
|
|
2551
3109
|
if not self.matches:
|
|
2552
|
-
self.notify("No matches to navigate", title="Next Match", severity="warning")
|
|
3110
|
+
# self.notify("No matches to navigate", title="Next Match", severity="warning")
|
|
2553
3111
|
return
|
|
2554
3112
|
|
|
2555
3113
|
# Get sorted list of matched coordinates
|
|
@@ -2571,7 +3129,7 @@ class DataFrameTable(DataTable):
|
|
|
2571
3129
|
def do_previous_match(self) -> None:
|
|
2572
3130
|
"""Move cursor to the previous match."""
|
|
2573
3131
|
if not self.matches:
|
|
2574
|
-
self.notify("No matches to navigate", title="Previous Match", severity="warning")
|
|
3132
|
+
# self.notify("No matches to navigate", title="Previous Match", severity="warning")
|
|
2575
3133
|
return
|
|
2576
3134
|
|
|
2577
3135
|
# Get sorted list of matched coordinates
|
|
@@ -2598,8 +3156,8 @@ class DataFrameTable(DataTable):
|
|
|
2598
3156
|
|
|
2599
3157
|
def do_next_selected_row(self) -> None:
|
|
2600
3158
|
"""Move cursor to the next selected row."""
|
|
2601
|
-
if not
|
|
2602
|
-
self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
|
|
3159
|
+
if not self.selected_rows:
|
|
3160
|
+
# self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
|
|
2603
3161
|
return
|
|
2604
3162
|
|
|
2605
3163
|
# Get list of selected row indices in order
|
|
@@ -2620,8 +3178,8 @@ class DataFrameTable(DataTable):
|
|
|
2620
3178
|
|
|
2621
3179
|
def do_previous_selected_row(self) -> None:
|
|
2622
3180
|
"""Move cursor to the previous selected row."""
|
|
2623
|
-
if not
|
|
2624
|
-
self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
|
|
3181
|
+
if not self.selected_rows:
|
|
3182
|
+
# self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
|
|
2625
3183
|
return
|
|
2626
3184
|
|
|
2627
3185
|
# Get list of selected row indices in order
|
|
@@ -2640,7 +3198,6 @@ class DataFrameTable(DataTable):
|
|
|
2640
3198
|
last_ridx = selected_row_indices[-1]
|
|
2641
3199
|
self.move_cursor_to(last_ridx, self.cursor_col_idx)
|
|
2642
3200
|
|
|
2643
|
-
# Replace
|
|
2644
3201
|
def do_replace(self) -> None:
|
|
2645
3202
|
"""Open replace screen for current column."""
|
|
2646
3203
|
# Push the replace modal screen
|
|
@@ -2690,29 +3247,38 @@ class DataFrameTable(DataTable):
|
|
|
2690
3247
|
|
|
2691
3248
|
# Add to history
|
|
2692
3249
|
self.add_history(
|
|
2693
|
-
f"Replaced [$
|
|
3250
|
+
f"Replaced [$success]{term_find}[/] with [$accent]{term_replace}[/] in column [$success]{col_name}[/]"
|
|
2694
3251
|
)
|
|
2695
3252
|
|
|
2696
3253
|
# Update matches
|
|
2697
|
-
self.matches =
|
|
3254
|
+
self.matches = matches
|
|
2698
3255
|
|
|
2699
3256
|
# Recreate table for display
|
|
2700
3257
|
self.setup_table()
|
|
2701
3258
|
|
|
2702
3259
|
# Store state for interactive replacement using dataclass
|
|
2703
|
-
|
|
3260
|
+
rid2ridx = {rid: ridx for ridx, rid in enumerate(self.df[RID]) if rid in self.matches}
|
|
3261
|
+
|
|
3262
|
+
# Unique columns to replace
|
|
3263
|
+
cols_to_replace = set()
|
|
3264
|
+
for cols in self.matches.values():
|
|
3265
|
+
cols_to_replace.update(cols)
|
|
3266
|
+
|
|
3267
|
+
# Sorted column indices to replace
|
|
3268
|
+
cidx2col = {cidx: col for cidx, col in enumerate(self.df.columns) if col in cols_to_replace}
|
|
3269
|
+
|
|
2704
3270
|
self.replace_state = ReplaceState(
|
|
2705
3271
|
term_find=term_find,
|
|
2706
3272
|
term_replace=term_replace,
|
|
2707
3273
|
match_nocase=match_nocase,
|
|
2708
3274
|
match_whole=match_whole,
|
|
2709
3275
|
cidx=cidx,
|
|
2710
|
-
rows=
|
|
2711
|
-
cols_per_row=[
|
|
3276
|
+
rows=list(rid2ridx.values()),
|
|
3277
|
+
cols_per_row=[[cidx for cidx, col in cidx2col.items() if col in self.matches[rid]] for rid in rid2ridx],
|
|
2712
3278
|
current_rpos=0,
|
|
2713
3279
|
current_cpos=0,
|
|
2714
3280
|
current_occurrence=0,
|
|
2715
|
-
total_occurrence=sum(len(
|
|
3281
|
+
total_occurrence=sum(len(cols) for cols in self.matches.values()),
|
|
2716
3282
|
replaced_occurrence=0,
|
|
2717
3283
|
skipped_occurrence=0,
|
|
2718
3284
|
done=False,
|
|
@@ -2728,9 +3294,10 @@ class DataFrameTable(DataTable):
|
|
|
2728
3294
|
|
|
2729
3295
|
except Exception as e:
|
|
2730
3296
|
self.notify(
|
|
2731
|
-
f"Error replacing [$
|
|
3297
|
+
f"Error replacing [$error]{term_find}[/] with [$accent]{term_replace}[/]",
|
|
2732
3298
|
title="Replace",
|
|
2733
3299
|
severity="error",
|
|
3300
|
+
timeout=10,
|
|
2734
3301
|
)
|
|
2735
3302
|
self.log(f"Error replacing `{term_find}` with `{term_replace}`: {str(e)}")
|
|
2736
3303
|
|
|
@@ -2740,7 +3307,7 @@ class DataFrameTable(DataTable):
|
|
|
2740
3307
|
self.app.push_screen(
|
|
2741
3308
|
ConfirmScreen(
|
|
2742
3309
|
"Replace All",
|
|
2743
|
-
label=f"Replace [$success]{term_find}[/] with [$success]{term_replace
|
|
3310
|
+
label=f"Replace `[$success]{term_find}[/]` with `[$success]{term_replace}[/]` for all [$accent]{state.total_occurrence}[/] occurrences?",
|
|
2744
3311
|
),
|
|
2745
3312
|
callback=self.handle_replace_all_confirmation,
|
|
2746
3313
|
)
|
|
@@ -2795,6 +3362,18 @@ class DataFrameTable(DataTable):
|
|
|
2795
3362
|
pl.when(mask).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
|
|
2796
3363
|
)
|
|
2797
3364
|
|
|
3365
|
+
# Also update the view if applicable
|
|
3366
|
+
if self.df_view is not None:
|
|
3367
|
+
col_updated = f"^_{col_name}_^"
|
|
3368
|
+
lf_updated = self.df.lazy().filter(mask).select(pl.col(col_name).alias(col_updated), pl.col(RID))
|
|
3369
|
+
self.df_view = (
|
|
3370
|
+
self.df_view.lazy()
|
|
3371
|
+
.join(lf_updated, on=RID, how="left")
|
|
3372
|
+
.with_columns(pl.coalesce(pl.col(col_updated), pl.col(col_name)).alias(col_name))
|
|
3373
|
+
.drop(col_updated)
|
|
3374
|
+
.collect()
|
|
3375
|
+
)
|
|
3376
|
+
|
|
2798
3377
|
state.replaced_occurrence += len(ridxs)
|
|
2799
3378
|
|
|
2800
3379
|
# Recreate table for display
|
|
@@ -2806,7 +3385,7 @@ class DataFrameTable(DataTable):
|
|
|
2806
3385
|
|
|
2807
3386
|
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
2808
3387
|
self.notify(
|
|
2809
|
-
f"Replaced [$
|
|
3388
|
+
f"Replaced [$success]{state.replaced_occurrence}[/] of [$success]{state.total_occurrence}[/] in [$accent]{col_name}[/]",
|
|
2810
3389
|
title="Replace",
|
|
2811
3390
|
)
|
|
2812
3391
|
|
|
@@ -2817,9 +3396,10 @@ class DataFrameTable(DataTable):
|
|
|
2817
3396
|
self.show_next_replace_confirmation()
|
|
2818
3397
|
except Exception as e:
|
|
2819
3398
|
self.notify(
|
|
2820
|
-
f"Error replacing [$
|
|
3399
|
+
f"Error replacing [$error]{term_find}[/] with [$accent]{term_replace}[/]",
|
|
2821
3400
|
title="Replace",
|
|
2822
3401
|
severity="error",
|
|
3402
|
+
timeout=10,
|
|
2823
3403
|
)
|
|
2824
3404
|
self.log(f"Error in interactive replace: {str(e)}")
|
|
2825
3405
|
|
|
@@ -2829,7 +3409,7 @@ class DataFrameTable(DataTable):
|
|
|
2829
3409
|
if state.done:
|
|
2830
3410
|
# All done - show final notification
|
|
2831
3411
|
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
2832
|
-
msg = f"Replaced [$
|
|
3412
|
+
msg = f"Replaced [$success]{state.replaced_occurrence}[/] of [$success]{state.total_occurrence}[/] in [$accent]{col_name}[/]"
|
|
2833
3413
|
if state.skipped_occurrence > 0:
|
|
2834
3414
|
msg += f", [$warning]{state.skipped_occurrence}[/] skipped"
|
|
2835
3415
|
self.notify(msg, title="Replace")
|
|
@@ -2847,7 +3427,7 @@ class DataFrameTable(DataTable):
|
|
|
2847
3427
|
state.current_occurrence += 1
|
|
2848
3428
|
|
|
2849
3429
|
# Show confirmation
|
|
2850
|
-
label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] ({state.current_occurrence} of {state.total_occurrence})?"
|
|
3430
|
+
label = f"Replace `[$warning]{state.term_find}[/]` with `[$success]{state.term_replace}[/]` ({state.current_occurrence} of {state.total_occurrence})?"
|
|
2851
3431
|
|
|
2852
3432
|
self.app.push_screen(
|
|
2853
3433
|
ConfirmScreen("Replace", label=label, maybe="Skip"),
|
|
@@ -2864,6 +3444,7 @@ class DataFrameTable(DataTable):
|
|
|
2864
3444
|
cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
|
|
2865
3445
|
col_name = self.df.columns[cidx]
|
|
2866
3446
|
dtype = self.df.dtypes[cidx]
|
|
3447
|
+
rid = self.df[RID][ridx]
|
|
2867
3448
|
|
|
2868
3449
|
# Replace
|
|
2869
3450
|
if result is True:
|
|
@@ -2876,6 +3457,15 @@ class DataFrameTable(DataTable):
|
|
|
2876
3457
|
.otherwise(pl.col(col_name))
|
|
2877
3458
|
.alias(col_name)
|
|
2878
3459
|
)
|
|
3460
|
+
|
|
3461
|
+
# Also update the view if applicable
|
|
3462
|
+
if self.df_view is not None:
|
|
3463
|
+
self.df_view = self.df_view.with_columns(
|
|
3464
|
+
pl.when(pl.col(RID) == rid)
|
|
3465
|
+
.then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
|
|
3466
|
+
.otherwise(pl.col(col_name))
|
|
3467
|
+
.alias(col_name)
|
|
3468
|
+
)
|
|
2879
3469
|
else:
|
|
2880
3470
|
# try to convert replacement value to column dtype
|
|
2881
3471
|
try:
|
|
@@ -2890,6 +3480,12 @@ class DataFrameTable(DataTable):
|
|
|
2890
3480
|
.alias(col_name)
|
|
2891
3481
|
)
|
|
2892
3482
|
|
|
3483
|
+
# Also update the view if applicable
|
|
3484
|
+
if self.df_view is not None:
|
|
3485
|
+
self.df_view = self.df_view.with_columns(
|
|
3486
|
+
pl.when(pl.col(RID) == rid).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
|
|
3487
|
+
)
|
|
3488
|
+
|
|
2893
3489
|
state.replaced_occurrence += 1
|
|
2894
3490
|
|
|
2895
3491
|
# Skip
|
|
@@ -2922,128 +3518,25 @@ class DataFrameTable(DataTable):
|
|
|
2922
3518
|
# Show next confirmation
|
|
2923
3519
|
self.show_next_replace_confirmation()
|
|
2924
3520
|
|
|
2925
|
-
#
|
|
2926
|
-
def do_toggle_selections(self) -> None:
|
|
2927
|
-
"""Toggle selected rows highlighting on/off."""
|
|
2928
|
-
# Add to history
|
|
2929
|
-
self.add_history("Toggled row selection")
|
|
2930
|
-
|
|
2931
|
-
if False in self.visible_rows:
|
|
2932
|
-
# Some rows are hidden - invert only selected visible rows and clear selections for hidden rows
|
|
2933
|
-
for i in range(len(self.selected_rows)):
|
|
2934
|
-
if self.visible_rows[i]:
|
|
2935
|
-
self.selected_rows[i] = not self.selected_rows[i]
|
|
2936
|
-
else:
|
|
2937
|
-
self.selected_rows[i] = False
|
|
2938
|
-
else:
|
|
2939
|
-
# Invert all selected rows
|
|
2940
|
-
self.selected_rows = [not selected for selected in self.selected_rows]
|
|
2941
|
-
|
|
2942
|
-
# Check if we're highlighting or un-highlighting
|
|
2943
|
-
if new_selected_count := self.selected_rows.count(True):
|
|
2944
|
-
self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
|
|
2945
|
-
|
|
2946
|
-
# Recreate table for display
|
|
2947
|
-
self.setup_table()
|
|
2948
|
-
|
|
2949
|
-
def do_toggle_row_selection(self) -> None:
|
|
2950
|
-
"""Select/deselect current row."""
|
|
2951
|
-
# Add to history
|
|
2952
|
-
self.add_history("Toggled row selection")
|
|
2953
|
-
|
|
2954
|
-
ridx = self.cursor_row_idx
|
|
2955
|
-
self.selected_rows[ridx] = not self.selected_rows[ridx]
|
|
2956
|
-
|
|
2957
|
-
row_key = str(ridx)
|
|
2958
|
-
match_cols = self.matches.get(ridx, set())
|
|
2959
|
-
for col_idx, col in enumerate(self.ordered_columns):
|
|
2960
|
-
col_key = col.key
|
|
2961
|
-
cell_text: Text = self.get_cell(row_key, col_key)
|
|
2962
|
-
|
|
2963
|
-
if self.selected_rows[ridx] or (col_idx in match_cols):
|
|
2964
|
-
cell_text.style = HIGHLIGHT_COLOR
|
|
2965
|
-
else:
|
|
2966
|
-
# Reset to default style based on dtype
|
|
2967
|
-
dtype = self.df.dtypes[col_idx]
|
|
2968
|
-
dc = DtypeConfig(dtype)
|
|
2969
|
-
cell_text.style = dc.style
|
|
2970
|
-
|
|
2971
|
-
self.update_cell(row_key, col_key, cell_text)
|
|
2972
|
-
|
|
2973
|
-
def do_clear_selections_and_matches(self) -> None:
|
|
2974
|
-
"""Clear all selected rows and matches without removing them from the dataframe."""
|
|
2975
|
-
# Check if any selected rows or matches
|
|
2976
|
-
if not any(self.selected_rows) and not self.matches:
|
|
2977
|
-
self.notify("No selections to clear", title="Clear", severity="warning")
|
|
2978
|
-
return
|
|
2979
|
-
|
|
2980
|
-
row_count = sum(
|
|
2981
|
-
1 if (selected or idx in self.matches) else 0 for idx, selected in enumerate(self.selected_rows)
|
|
2982
|
-
)
|
|
2983
|
-
|
|
2984
|
-
# Add to history
|
|
2985
|
-
self.add_history("Cleared all selected rows")
|
|
2986
|
-
|
|
2987
|
-
# Clear all selections
|
|
2988
|
-
self.selected_rows = [False] * len(self.df)
|
|
2989
|
-
self.matches = defaultdict(set)
|
|
2990
|
-
|
|
2991
|
-
# Recreate table for display
|
|
2992
|
-
self.setup_table()
|
|
2993
|
-
|
|
2994
|
-
self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
|
|
2995
|
-
|
|
2996
|
-
# Filter & View
|
|
2997
|
-
def do_filter_rows(self) -> None:
|
|
2998
|
-
"""Keep only the rows with selections and matches, and remove others."""
|
|
2999
|
-
if not any(self.selected_rows) and not self.matches:
|
|
3000
|
-
self.notify("No rows to filter", title="Filter", severity="warning")
|
|
3001
|
-
return
|
|
3002
|
-
|
|
3003
|
-
filter_expr = [
|
|
3004
|
-
True if (selected or ridx in self.matches) else False for ridx, selected in enumerate(self.selected_rows)
|
|
3005
|
-
]
|
|
3006
|
-
|
|
3007
|
-
# Add to history
|
|
3008
|
-
self.add_history("Filtered to selections and matches", dirty=True)
|
|
3009
|
-
|
|
3010
|
-
# Apply filter to dataframe with row indices
|
|
3011
|
-
df_filtered = self.df.with_row_index(RIDX).filter(filter_expr)
|
|
3012
|
-
|
|
3013
|
-
# Update selections and matches
|
|
3014
|
-
self.selected_rows = [self.selected_rows[ridx] for ridx in df_filtered[RIDX]]
|
|
3015
|
-
self.matches = {
|
|
3016
|
-
idx: self.matches[ridx].copy() for idx, ridx in enumerate(df_filtered[RIDX]) if ridx in self.matches
|
|
3017
|
-
}
|
|
3018
|
-
|
|
3019
|
-
# Update dataframe
|
|
3020
|
-
self.df = df_filtered.drop(RIDX)
|
|
3021
|
-
|
|
3022
|
-
# Recreate table for display
|
|
3023
|
-
self.setup_table()
|
|
3024
|
-
|
|
3025
|
-
self.notify(
|
|
3026
|
-
f"Removed rows without selections or matches. Now showing [$accent]{len(self.df)}[/] rows", title="Filter"
|
|
3027
|
-
)
|
|
3028
|
-
|
|
3521
|
+
# View & Filter
|
|
3029
3522
|
def do_view_rows(self) -> None:
|
|
3030
3523
|
"""View rows.
|
|
3031
3524
|
|
|
3032
|
-
If there are selected rows
|
|
3033
|
-
Otherwise, view based on the value
|
|
3525
|
+
If there are selected rows, view those.
|
|
3526
|
+
Otherwise, view based on the cursor value.
|
|
3034
3527
|
"""
|
|
3035
3528
|
|
|
3036
3529
|
cidx = self.cursor_col_idx
|
|
3530
|
+
col_name = self.cursor_col_name
|
|
3037
3531
|
|
|
3038
|
-
# If there are rows
|
|
3039
|
-
if
|
|
3040
|
-
term =
|
|
3041
|
-
True if (selected or idx in self.matches) else False for idx, selected in enumerate(self.selected_rows)
|
|
3042
|
-
]
|
|
3532
|
+
# If there are selected rows, use those
|
|
3533
|
+
if self.selected_rows:
|
|
3534
|
+
term = pl.col(RID).is_in(self.selected_rows)
|
|
3043
3535
|
# Otherwise, use the current cell value
|
|
3044
3536
|
else:
|
|
3045
3537
|
ridx = self.cursor_row_idx
|
|
3046
|
-
|
|
3538
|
+
value = self.df.item(ridx, cidx)
|
|
3539
|
+
term = pl.col(col_name).is_null() if value is None else pl.col(col_name) == value
|
|
3047
3540
|
|
|
3048
3541
|
self.view_rows((term, cidx, False, True))
|
|
3049
3542
|
|
|
@@ -3051,34 +3544,46 @@ class DataFrameTable(DataTable):
|
|
|
3051
3544
|
"""Open the filter screen to enter an expression."""
|
|
3052
3545
|
ridx = self.cursor_row_idx
|
|
3053
3546
|
cidx = self.cursor_col_idx
|
|
3054
|
-
cursor_value =
|
|
3547
|
+
cursor_value = self.df.item(ridx, cidx)
|
|
3548
|
+
term = NULL if cursor_value is None else str(cursor_value)
|
|
3055
3549
|
|
|
3056
3550
|
self.app.push_screen(
|
|
3057
|
-
FilterScreen(self.df, cidx,
|
|
3551
|
+
FilterScreen(self.df, cidx, term),
|
|
3058
3552
|
callback=self.view_rows,
|
|
3059
3553
|
)
|
|
3060
3554
|
|
|
3061
3555
|
def view_rows(self, result) -> None:
|
|
3062
|
-
"""
|
|
3556
|
+
"""View selected rows and hide others. Do not modify the dataframe."""
|
|
3063
3557
|
if result is None:
|
|
3064
3558
|
return
|
|
3065
3559
|
term, cidx, match_nocase, match_whole = result
|
|
3066
3560
|
|
|
3067
3561
|
col_name = self.df.columns[cidx]
|
|
3068
3562
|
|
|
3069
|
-
|
|
3070
|
-
|
|
3563
|
+
# Support for polars expression
|
|
3564
|
+
if isinstance(term, pl.Expr):
|
|
3565
|
+
expr = term
|
|
3566
|
+
|
|
3567
|
+
# Support for list of booleans (selected rows)
|
|
3071
3568
|
elif isinstance(term, (list, pl.Series)):
|
|
3072
|
-
# Support for list of booleans (selected rows)
|
|
3073
3569
|
expr = term
|
|
3570
|
+
|
|
3571
|
+
# Null case
|
|
3572
|
+
elif term == NULL:
|
|
3573
|
+
expr = pl.col(col_name).is_null()
|
|
3574
|
+
|
|
3575
|
+
# Support for polars expression in string form
|
|
3074
3576
|
elif tentative_expr(term):
|
|
3075
|
-
# Support for polars expressions
|
|
3076
3577
|
try:
|
|
3077
3578
|
expr = validate_expr(term, self.df.columns, cidx)
|
|
3078
3579
|
except Exception as e:
|
|
3079
|
-
self.notify(
|
|
3580
|
+
self.notify(
|
|
3581
|
+
f"Error validating expression [$error]{term}[/]", title="Filter Rows", severity="error", timeout=10
|
|
3582
|
+
)
|
|
3080
3583
|
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
3081
3584
|
return
|
|
3585
|
+
|
|
3586
|
+
# Type-aware search based on column dtype
|
|
3082
3587
|
else:
|
|
3083
3588
|
dtype = self.df.dtypes[cidx]
|
|
3084
3589
|
if dtype == pl.String:
|
|
@@ -3098,49 +3603,108 @@ class DataFrameTable(DataTable):
|
|
|
3098
3603
|
term = f"(?i){term}"
|
|
3099
3604
|
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
3100
3605
|
self.notify(
|
|
3101
|
-
f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
|
|
3606
|
+
f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
|
|
3607
|
+
title="View Rows",
|
|
3608
|
+
severity="warning",
|
|
3102
3609
|
)
|
|
3103
3610
|
|
|
3104
3611
|
# Lazyframe with row indices
|
|
3105
|
-
lf = self.df.lazy()
|
|
3106
|
-
|
|
3107
|
-
# Apply existing visibility filter first
|
|
3108
|
-
if False in self.visible_rows:
|
|
3109
|
-
lf = lf.filter(self.visible_rows)
|
|
3612
|
+
lf = self.df.lazy()
|
|
3110
3613
|
|
|
3111
|
-
if isinstance(expr, (list, pl.Series))
|
|
3112
|
-
expr_str = str(list(expr)[:10]) + ("..." if len(expr) > 10 else "")
|
|
3113
|
-
else:
|
|
3114
|
-
expr_str = str(expr)
|
|
3614
|
+
expr_str = "boolean list or series" if isinstance(expr, (list, pl.Series)) else str(expr)
|
|
3115
3615
|
|
|
3116
3616
|
# Apply the filter expression
|
|
3117
3617
|
try:
|
|
3118
3618
|
df_filtered = lf.filter(expr).collect()
|
|
3119
3619
|
except Exception as e:
|
|
3120
|
-
self.
|
|
3121
|
-
self.notify(f"Error applying filter [$error]{expr_str}[/]", title="
|
|
3620
|
+
self.histories_undo.pop() # Remove last history entry
|
|
3621
|
+
self.notify(f"Error applying filter [$error]{expr_str}[/]", title="View Rows", severity="error", timeout=10)
|
|
3122
3622
|
self.log(f"Error applying filter `{expr_str}`: {str(e)}")
|
|
3123
3623
|
return
|
|
3124
3624
|
|
|
3125
3625
|
matched_count = len(df_filtered)
|
|
3126
3626
|
if not matched_count:
|
|
3127
|
-
self.notify(f"No rows match the expression: [$success]{expr}[/]", title="
|
|
3627
|
+
self.notify(f"No rows match the expression: [$success]{expr}[/]", title="View Rows", severity="warning")
|
|
3128
3628
|
return
|
|
3129
3629
|
|
|
3130
3630
|
# Add to history
|
|
3131
|
-
self.add_history(f"Filtered by expression [$success]{expr_str}[/]"
|
|
3631
|
+
self.add_history(f"Filtered by expression [$success]{expr_str}[/]")
|
|
3632
|
+
|
|
3633
|
+
ok_rids = set(df_filtered[RID])
|
|
3132
3634
|
|
|
3133
|
-
#
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3635
|
+
# Create a view of self.df as a copy
|
|
3636
|
+
if self.df_view is None:
|
|
3637
|
+
self.df_view = self.df
|
|
3638
|
+
|
|
3639
|
+
# Update dataframe
|
|
3640
|
+
self.df = df_filtered
|
|
3641
|
+
|
|
3642
|
+
# Update selected rows
|
|
3643
|
+
if self.selected_rows:
|
|
3644
|
+
self.selected_rows.intersection_update(ok_rids)
|
|
3645
|
+
|
|
3646
|
+
# Update matches
|
|
3647
|
+
if self.matches:
|
|
3648
|
+
self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
|
|
3139
3649
|
|
|
3140
3650
|
# Recreate table for display
|
|
3141
3651
|
self.setup_table()
|
|
3142
3652
|
|
|
3143
|
-
self.notify(f"Filtered to [$
|
|
3653
|
+
self.notify(f"Filtered to [$success]{matched_count}[/] matching row(s)", title="View Rows")
|
|
3654
|
+
|
|
3655
|
+
def do_filter_rows(self) -> None:
|
|
3656
|
+
"""Filter rows.
|
|
3657
|
+
|
|
3658
|
+
If there are selected rows, use those.
|
|
3659
|
+
Otherwise, filter based on the cursor value.
|
|
3660
|
+
"""
|
|
3661
|
+
if self.selected_rows:
|
|
3662
|
+
message = "Filtered to selected rows (other rows removed)"
|
|
3663
|
+
filter_expr = pl.col(RID).is_in(self.selected_rows)
|
|
3664
|
+
else: # Search cursor value in current column
|
|
3665
|
+
message = "Filtered to rows matching cursor value (other rows removed)"
|
|
3666
|
+
cidx = self.cursor_col_idx
|
|
3667
|
+
col_name = self.df.columns[cidx]
|
|
3668
|
+
value = self.cursor_value
|
|
3669
|
+
|
|
3670
|
+
if value is None:
|
|
3671
|
+
filter_expr = pl.col(col_name).is_null()
|
|
3672
|
+
else:
|
|
3673
|
+
filter_expr = pl.col(col_name) == value
|
|
3674
|
+
|
|
3675
|
+
# Add to history
|
|
3676
|
+
self.add_history(message, dirty=True)
|
|
3677
|
+
|
|
3678
|
+
# Apply filter to dataframe with row indices
|
|
3679
|
+
df_filtered = self.df.lazy().filter(filter_expr).collect()
|
|
3680
|
+
ok_rids = set(df_filtered[RID])
|
|
3681
|
+
|
|
3682
|
+
# Update selected rows
|
|
3683
|
+
if self.selected_rows:
|
|
3684
|
+
selected_rows = {rid for rid in self.selected_rows if rid in ok_rids}
|
|
3685
|
+
else:
|
|
3686
|
+
selected_rows = set()
|
|
3687
|
+
|
|
3688
|
+
# Update matches
|
|
3689
|
+
if self.matches:
|
|
3690
|
+
matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
|
|
3691
|
+
else:
|
|
3692
|
+
matches = defaultdict(set)
|
|
3693
|
+
|
|
3694
|
+
# Update dataframe
|
|
3695
|
+
self.reset_df(df_filtered)
|
|
3696
|
+
|
|
3697
|
+
# Clear view for filter mode
|
|
3698
|
+
self.df_view = None
|
|
3699
|
+
|
|
3700
|
+
# Restore selected rows and matches
|
|
3701
|
+
self.selected_rows = selected_rows
|
|
3702
|
+
self.matches = matches
|
|
3703
|
+
|
|
3704
|
+
# Recreate table for display
|
|
3705
|
+
self.setup_table()
|
|
3706
|
+
|
|
3707
|
+
self.notify(f"{message}. Now showing [$success]{len(self.df)}[/] rows.", title="Filter Rows")
|
|
3144
3708
|
|
|
3145
3709
|
# Copy & Save
|
|
3146
3710
|
def do_copy_to_clipboard(self, content: str, message: str) -> None:
|
|
@@ -3162,24 +3726,28 @@ class DataFrameTable(DataTable):
|
|
|
3162
3726
|
input=content,
|
|
3163
3727
|
text=True,
|
|
3164
3728
|
)
|
|
3165
|
-
self.notify(message, title="Clipboard")
|
|
3729
|
+
self.notify(message, title="Copy to Clipboard")
|
|
3166
3730
|
except FileNotFoundError:
|
|
3167
|
-
self.notify("Error copying to clipboard", title="Clipboard", severity="error")
|
|
3731
|
+
self.notify("Error copying to clipboard", title="Copy to Clipboard", severity="error", timeout=10)
|
|
3168
3732
|
|
|
3169
|
-
def do_save_to_file(
|
|
3170
|
-
self, title: str = "Save to File", all_tabs: bool | None = None, task_after_save: str | None = None
|
|
3171
|
-
) -> None:
|
|
3733
|
+
def do_save_to_file(self, all_tabs: bool | None = None, task_after_save: str | None = None) -> None:
|
|
3172
3734
|
"""Open screen to save file."""
|
|
3173
3735
|
self._task_after_save = task_after_save
|
|
3736
|
+
tab_count = len(self.app.tabs)
|
|
3737
|
+
save_all = tab_count > 1 and all_tabs is not False
|
|
3738
|
+
|
|
3739
|
+
filepath = Path(self.filename)
|
|
3740
|
+
if save_all:
|
|
3741
|
+
ext = filepath.suffix.lower()
|
|
3742
|
+
if ext in (".xlsx", ".xls"):
|
|
3743
|
+
filename = self.filename
|
|
3744
|
+
else:
|
|
3745
|
+
filename = "all-tabs.xlsx"
|
|
3746
|
+
else:
|
|
3747
|
+
filename = str(filepath.with_stem(self.tabname))
|
|
3174
3748
|
|
|
3175
|
-
multi_tab = len(self.app.tabs) > 1
|
|
3176
|
-
filename = (
|
|
3177
|
-
"all-tabs.xlsx"
|
|
3178
|
-
if all_tabs or (all_tabs is None and multi_tab)
|
|
3179
|
-
else str(Path(self.filename).with_stem(self.tabname))
|
|
3180
|
-
)
|
|
3181
3749
|
self.app.push_screen(
|
|
3182
|
-
SaveFileScreen(filename,
|
|
3750
|
+
SaveFileScreen(filename, save_all=save_all, tab_count=tab_count),
|
|
3183
3751
|
callback=self.save_to_file,
|
|
3184
3752
|
)
|
|
3185
3753
|
|
|
@@ -3187,13 +3755,11 @@ class DataFrameTable(DataTable):
|
|
|
3187
3755
|
"""Handle result from SaveFileScreen."""
|
|
3188
3756
|
if result is None:
|
|
3189
3757
|
return
|
|
3190
|
-
filename,
|
|
3191
|
-
|
|
3192
|
-
# Whether to save all tabs (for Excel files)
|
|
3193
|
-
self._all_tabs = all_tabs
|
|
3758
|
+
filename, save_all, overwrite_prompt = result
|
|
3759
|
+
self._save_all = save_all
|
|
3194
3760
|
|
|
3195
3761
|
# Check if file exists
|
|
3196
|
-
if Path(filename).exists():
|
|
3762
|
+
if overwrite_prompt and Path(filename).exists():
|
|
3197
3763
|
self._pending_filename = filename
|
|
3198
3764
|
self.app.push_screen(
|
|
3199
3765
|
ConfirmScreen("File already exists. Overwrite?"),
|
|
@@ -3209,7 +3775,7 @@ class DataFrameTable(DataTable):
|
|
|
3209
3775
|
else:
|
|
3210
3776
|
# Go back to SaveFileScreen to allow user to enter a different name
|
|
3211
3777
|
self.app.push_screen(
|
|
3212
|
-
SaveFileScreen(self._pending_filename),
|
|
3778
|
+
SaveFileScreen(self._pending_filename, save_all=self._save_all),
|
|
3213
3779
|
callback=self.save_to_file,
|
|
3214
3780
|
)
|
|
3215
3781
|
|
|
@@ -3217,62 +3783,78 @@ class DataFrameTable(DataTable):
|
|
|
3217
3783
|
"""Actually save the dataframe to a file."""
|
|
3218
3784
|
filepath = Path(filename)
|
|
3219
3785
|
ext = filepath.suffix.lower()
|
|
3786
|
+
if ext == ".gz":
|
|
3787
|
+
ext = Path(filename).with_suffix("").suffix.lower()
|
|
3220
3788
|
|
|
3221
|
-
|
|
3222
|
-
|
|
3789
|
+
fmt = ext.removeprefix(".")
|
|
3790
|
+
if fmt not in SUPPORTED_FORMATS:
|
|
3791
|
+
self.notify(
|
|
3792
|
+
f"Unsupported file format [$success]{fmt}[/]. Use [$accent]CSV[/] as fallback. Supported formats: {', '.join(SUPPORTED_FORMATS)}",
|
|
3793
|
+
title="Save to File",
|
|
3794
|
+
severity="warning",
|
|
3795
|
+
)
|
|
3796
|
+
fmt = "csv"
|
|
3223
3797
|
|
|
3798
|
+
df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
|
|
3224
3799
|
try:
|
|
3225
|
-
if
|
|
3800
|
+
if fmt == "csv":
|
|
3801
|
+
df.write_csv(filename)
|
|
3802
|
+
elif fmt in ("tsv", "tab"):
|
|
3803
|
+
df.write_csv(filename, separator="\t")
|
|
3804
|
+
elif fmt in ("xlsx", "xls"):
|
|
3226
3805
|
self.save_excel(filename)
|
|
3227
|
-
elif
|
|
3228
|
-
|
|
3229
|
-
elif
|
|
3230
|
-
|
|
3231
|
-
elif
|
|
3232
|
-
|
|
3233
|
-
else:
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
self.filename = filename
|
|
3806
|
+
elif fmt == "json":
|
|
3807
|
+
df.write_json(filename)
|
|
3808
|
+
elif fmt == "ndjson":
|
|
3809
|
+
df.write_ndjson(filename)
|
|
3810
|
+
elif fmt == "parquet":
|
|
3811
|
+
df.write_parquet(filename)
|
|
3812
|
+
else: # Fallback to CSV
|
|
3813
|
+
df.write_csv(filename)
|
|
3814
|
+
|
|
3815
|
+
# Update current filename
|
|
3816
|
+
self.filename = filename
|
|
3238
3817
|
|
|
3239
3818
|
# Reset dirty flag after save
|
|
3240
|
-
if self.
|
|
3819
|
+
if self._save_all:
|
|
3241
3820
|
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
3242
3821
|
for table in tabs.values():
|
|
3243
3822
|
table.dirty = False
|
|
3244
3823
|
else:
|
|
3245
3824
|
self.dirty = False
|
|
3246
3825
|
|
|
3247
|
-
if self
|
|
3248
|
-
self.
|
|
3249
|
-
|
|
3250
|
-
self.
|
|
3826
|
+
if hasattr(self, "_task_after_save"):
|
|
3827
|
+
if self._task_after_save == "close_tab":
|
|
3828
|
+
self.app.do_close_tab()
|
|
3829
|
+
elif self._task_after_save == "quit_app":
|
|
3830
|
+
self.app.exit()
|
|
3251
3831
|
|
|
3252
3832
|
# From ConfirmScreen callback, so notify accordingly
|
|
3253
|
-
if self.
|
|
3833
|
+
if self._save_all:
|
|
3254
3834
|
self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
|
|
3255
3835
|
else:
|
|
3256
3836
|
self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
|
|
3257
3837
|
|
|
3258
3838
|
except Exception as e:
|
|
3259
|
-
self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error")
|
|
3839
|
+
self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error", timeout=10)
|
|
3260
3840
|
self.log(f"Error saving file `{filename}`: {str(e)}")
|
|
3261
3841
|
|
|
3262
3842
|
def save_excel(self, filename: str) -> None:
|
|
3263
3843
|
"""Save to an Excel file."""
|
|
3264
3844
|
import xlsxwriter
|
|
3265
3845
|
|
|
3266
|
-
if not self.
|
|
3846
|
+
if not self._save_all or len(self.app.tabs) == 1:
|
|
3267
3847
|
# Single tab - save directly
|
|
3268
|
-
self.df.
|
|
3848
|
+
df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
|
|
3849
|
+
df.write_excel(filename, worksheet=self.tabname)
|
|
3269
3850
|
else:
|
|
3270
3851
|
# Multiple tabs - use xlsxwriter to create multiple sheets
|
|
3271
3852
|
with xlsxwriter.Workbook(filename) as wb:
|
|
3272
3853
|
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
3273
3854
|
for table in tabs.values():
|
|
3274
3855
|
worksheet = wb.add_worksheet(table.tabname)
|
|
3275
|
-
table.df.
|
|
3856
|
+
df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
|
|
3857
|
+
df.write_excel(workbook=wb, worksheet=worksheet)
|
|
3276
3858
|
|
|
3277
3859
|
# SQL Interface
|
|
3278
3860
|
def do_simple_sql(self) -> None:
|
|
@@ -3316,19 +3898,17 @@ class DataFrameTable(DataTable):
|
|
|
3316
3898
|
sql: The SQL query string to execute.
|
|
3317
3899
|
"""
|
|
3318
3900
|
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3901
|
+
sql = sql.replace("$#", f"(`{RID}` + 1)")
|
|
3902
|
+
if RID not in sql and "*" not in sql:
|
|
3903
|
+
# Ensure RID is selected
|
|
3904
|
+
import re
|
|
3322
3905
|
|
|
3323
|
-
|
|
3906
|
+
RE_FROM_SELF = re.compile(r"\bFROM\s+self\b", re.IGNORECASE)
|
|
3907
|
+
sql = RE_FROM_SELF.sub(f", `{RID}` FROM self", sql)
|
|
3324
3908
|
|
|
3325
3909
|
# Execute the SQL query
|
|
3326
3910
|
try:
|
|
3327
|
-
|
|
3328
|
-
if False in self.visible_rows:
|
|
3329
|
-
lf = lf.filter(self.visible_rows)
|
|
3330
|
-
|
|
3331
|
-
df_filtered = lf.sql(sql).collect()
|
|
3911
|
+
df_filtered = self.df.lazy().sql(sql).collect()
|
|
3332
3912
|
|
|
3333
3913
|
if not len(df_filtered):
|
|
3334
3914
|
self.notify(
|
|
@@ -3336,29 +3916,34 @@ class DataFrameTable(DataTable):
|
|
|
3336
3916
|
)
|
|
3337
3917
|
return
|
|
3338
3918
|
|
|
3339
|
-
# Add to history
|
|
3340
|
-
self.add_history(f"SQL Query:\n[$accent]{sql}[/]", dirty=not view)
|
|
3341
|
-
|
|
3342
|
-
if view:
|
|
3343
|
-
# Just view - do not modify the dataframe
|
|
3344
|
-
filtered_row_indices = set(df_filtered[RIDX].to_list())
|
|
3345
|
-
if filtered_row_indices:
|
|
3346
|
-
self.visible_rows = [ridx in filtered_row_indices for ridx in range(len(self.visible_rows))]
|
|
3347
|
-
|
|
3348
|
-
filtered_col_names = set(df_filtered.columns)
|
|
3349
|
-
if filtered_col_names:
|
|
3350
|
-
self.hidden_columns = {
|
|
3351
|
-
col_name for col_name in self.df.columns if col_name not in filtered_col_names
|
|
3352
|
-
}
|
|
3353
|
-
else: # filter - modify the dataframe
|
|
3354
|
-
self.df = df_filtered.drop(RIDX)
|
|
3355
|
-
self.visible_rows = [True] * len(self.df)
|
|
3356
|
-
self.hidden_columns.clear()
|
|
3357
3919
|
except Exception as e:
|
|
3358
3920
|
self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error", timeout=10)
|
|
3359
3921
|
self.log(f"Error executing SQL query `{sql}`: {str(e)}")
|
|
3360
3922
|
return
|
|
3361
3923
|
|
|
3924
|
+
# Add to history
|
|
3925
|
+
self.add_history(f"SQL Query:\n[$success]{sql}[/]", dirty=not view)
|
|
3926
|
+
|
|
3927
|
+
# Create a view of self.df as a copy
|
|
3928
|
+
if view and self.df_view is None:
|
|
3929
|
+
self.df_view = self.df
|
|
3930
|
+
|
|
3931
|
+
# Clear view for filter mode
|
|
3932
|
+
if not view:
|
|
3933
|
+
self.df_view = None
|
|
3934
|
+
|
|
3935
|
+
# Update dataframe
|
|
3936
|
+
self.df = df_filtered
|
|
3937
|
+
ok_rids = set(df_filtered[RID])
|
|
3938
|
+
|
|
3939
|
+
# Update selected rows
|
|
3940
|
+
if self.selected_rows:
|
|
3941
|
+
self.selected_rows.intersection_update(ok_rids)
|
|
3942
|
+
|
|
3943
|
+
# Update matches
|
|
3944
|
+
if self.matches:
|
|
3945
|
+
self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
|
|
3946
|
+
|
|
3362
3947
|
# Recreate table for display
|
|
3363
3948
|
self.setup_table()
|
|
3364
3949
|
|