dataframe-textual 1.9.0__py3-none-any.whl → 2.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1336 -764
- dataframe_textual/data_frame_viewer.py +48 -10
- 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.2.dist-info}/METADATA +213 -215
- dataframe_textual-2.2.2.dist-info/RECORD +14 -0
- {dataframe_textual-1.9.0.dist-info → dataframe_textual-2.2.2.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.2.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-1.9.0.dist-info → dataframe_textual-2.2.2.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"),
|
|
@@ -224,13 +247,16 @@ class DataFrameTable(DataTable):
|
|
|
224
247
|
("z", "freeze_row_column", "Freeze rows/columns"),
|
|
225
248
|
("comma", "show_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.
|
|
@@ -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}[/])",
|
|
925
|
+
title="Clipboard",
|
|
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,7 @@ 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("Error copying column", title="Clipboard", severity="error")
|
|
944
|
+
self.notify(f"Error copying column [$error]{col_name}[/]", title="Clipboard", severity="error", timeout=10)
|
|
866
945
|
|
|
867
946
|
def action_copy_row(self) -> None:
|
|
868
947
|
"""Copy the current row to clipboard (values separated by tabs)."""
|
|
@@ -878,7 +957,7 @@ class DataFrameTable(DataTable):
|
|
|
878
957
|
f"Copied row [$accent]{ridx + 1}[/] with [$success]{len(row_values)}[/] values",
|
|
879
958
|
)
|
|
880
959
|
except (FileNotFoundError, IndexError):
|
|
881
|
-
self.notify("Error copying row", title="Clipboard", severity="error")
|
|
960
|
+
self.notify(f"Error copying row [$error]{ridx}[/]", title="Clipboard", severity="error", timeout=10)
|
|
882
961
|
|
|
883
962
|
def action_show_thousand_separator(self) -> None:
|
|
884
963
|
"""Toggle thousand separator for numeric display."""
|
|
@@ -911,56 +990,50 @@ class DataFrameTable(DataTable):
|
|
|
911
990
|
"""Open the advanced SQL interface screen."""
|
|
912
991
|
self.do_advanced_sql()
|
|
913
992
|
|
|
993
|
+
def on_mouse_scroll_up(self, event) -> None:
|
|
994
|
+
"""Load more rows when scrolling up with mouse."""
|
|
995
|
+
self.load_rows_up()
|
|
996
|
+
|
|
914
997
|
def on_mouse_scroll_down(self, event) -> None:
|
|
915
998
|
"""Load more rows when scrolling down with mouse."""
|
|
916
|
-
self.
|
|
999
|
+
self.load_rows_down()
|
|
917
1000
|
|
|
918
1001
|
# Setup & Loading
|
|
919
|
-
def
|
|
1002
|
+
def reset_df(self, new_df: pl.DataFrame, dirty: bool = True) -> None:
|
|
1003
|
+
"""Reset the dataframe to a new one and refresh the table.
|
|
1004
|
+
|
|
1005
|
+
Args:
|
|
1006
|
+
new_df: The new Polars DataFrame to set.
|
|
1007
|
+
dirty: Whether to mark the table as dirty (unsaved changes). Defaults to True.
|
|
1008
|
+
"""
|
|
1009
|
+
# Set new dataframe and reset table
|
|
1010
|
+
self.df = new_df
|
|
1011
|
+
self.loaded_rows = 0
|
|
1012
|
+
self.hidden_columns = set()
|
|
1013
|
+
self.selected_rows = set()
|
|
1014
|
+
self.sorted_columns = {}
|
|
1015
|
+
self.fixed_rows = 0
|
|
1016
|
+
self.fixed_columns = 0
|
|
1017
|
+
self.matches = defaultdict(set)
|
|
1018
|
+
# self.histories.clear()
|
|
1019
|
+
# self.histories2.clear()
|
|
1020
|
+
self.dirty = dirty # Mark as dirty since data changed
|
|
1021
|
+
|
|
1022
|
+
def setup_table(self) -> None:
|
|
920
1023
|
"""Setup the table for display.
|
|
921
1024
|
|
|
922
1025
|
Row keys are 0-based indices, which map directly to dataframe row indices.
|
|
923
1026
|
Column keys are header names from the dataframe.
|
|
924
1027
|
"""
|
|
925
1028
|
self.loaded_rows = 0
|
|
1029
|
+
self.loaded_ranges.clear()
|
|
926
1030
|
self.show_row_labels = True
|
|
927
1031
|
|
|
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
1032
|
# Save current cursor position before clearing
|
|
960
1033
|
row_idx, col_idx = self.cursor_coordinate
|
|
961
1034
|
|
|
962
1035
|
self.setup_columns()
|
|
963
|
-
self.
|
|
1036
|
+
self.load_rows_range(0, self.BATCH_SIZE) # Load initial rows
|
|
964
1037
|
|
|
965
1038
|
# Restore cursor position
|
|
966
1039
|
if row_idx < len(self.rows) and col_idx < len(self.columns):
|
|
@@ -980,20 +1053,20 @@ class DataFrameTable(DataTable):
|
|
|
980
1053
|
Returns:
|
|
981
1054
|
dict[str, int]: Mapping of column name to width (None for auto-sizing columns).
|
|
982
1055
|
"""
|
|
983
|
-
|
|
1056
|
+
col_widths, col_label_widths = {}, {}
|
|
984
1057
|
|
|
985
1058
|
# Get available width for the table (with some padding for borders/scrollbar)
|
|
986
|
-
available_width = self.
|
|
1059
|
+
available_width = self.scrollable_content_region.width
|
|
987
1060
|
|
|
988
1061
|
# Calculate how much width we need for string columns first
|
|
989
1062
|
string_cols = [col for col, dtype in zip(self.df.columns, self.df.dtypes) if dtype == pl.String]
|
|
990
1063
|
|
|
991
1064
|
# No string columns, let TextualDataTable auto-size all columns
|
|
992
1065
|
if not string_cols:
|
|
993
|
-
return
|
|
1066
|
+
return col_widths
|
|
994
1067
|
|
|
995
1068
|
# Sample a reasonable number of rows to calculate widths (don't scan entire dataframe)
|
|
996
|
-
sample_size = min(self.
|
|
1069
|
+
sample_size = min(self.BATCH_SIZE, len(self.df))
|
|
997
1070
|
sample_lf = self.df.lazy().slice(0, sample_size)
|
|
998
1071
|
|
|
999
1072
|
# Determine widths for each column
|
|
@@ -1004,37 +1077,42 @@ class DataFrameTable(DataTable):
|
|
|
1004
1077
|
# Get column label width
|
|
1005
1078
|
# Add padding for sort indicators if any
|
|
1006
1079
|
label_width = measure(self.app.console, col, 1) + 2
|
|
1080
|
+
col_label_widths[col] = label_width
|
|
1081
|
+
|
|
1082
|
+
# Let Textual auto-size for non-string columns and already expanded columns
|
|
1083
|
+
if dtype != pl.String or col in self.expanded_columns:
|
|
1084
|
+
available_width -= label_width
|
|
1085
|
+
continue
|
|
1007
1086
|
|
|
1008
1087
|
try:
|
|
1009
1088
|
# Get sample values from the column
|
|
1010
|
-
sample_values = sample_lf.select(col).collect().get_column(col).to_list()
|
|
1089
|
+
sample_values = sample_lf.select(col).collect().get_column(col).drop_nulls().to_list()
|
|
1011
1090
|
if any(val.startswith(("https://", "http://")) for val in sample_values):
|
|
1012
1091
|
continue # Skip link columns so they can auto-size and be clickable
|
|
1013
1092
|
|
|
1014
1093
|
# Find maximum width in sample
|
|
1015
1094
|
max_cell_width = max(
|
|
1016
|
-
(measure(self.app.console,
|
|
1095
|
+
(measure(self.app.console, val, 1) for val in sample_values),
|
|
1017
1096
|
default=label_width,
|
|
1018
1097
|
)
|
|
1019
1098
|
|
|
1020
1099
|
# Set column width to max of label and sampled data (capped at reasonable max)
|
|
1021
1100
|
max_width = max(label_width, max_cell_width)
|
|
1022
|
-
except Exception:
|
|
1101
|
+
except Exception as e:
|
|
1023
1102
|
# If any error, let Textual auto-size
|
|
1024
1103
|
max_width = label_width
|
|
1104
|
+
self.log(f"Error determining width for column '{col}': {e}")
|
|
1025
1105
|
|
|
1026
|
-
|
|
1027
|
-
column_widths[col] = max_width
|
|
1028
|
-
|
|
1106
|
+
col_widths[col] = max_width
|
|
1029
1107
|
available_width -= max_width
|
|
1030
1108
|
|
|
1031
1109
|
# If there's no more available width, auto-size remaining columns
|
|
1032
1110
|
if available_width < 0:
|
|
1033
|
-
for col in
|
|
1034
|
-
if
|
|
1035
|
-
|
|
1111
|
+
for col in col_widths:
|
|
1112
|
+
if col_widths[col] > STRING_WIDTH_CAP and col_label_widths[col] < STRING_WIDTH_CAP:
|
|
1113
|
+
col_widths[col] = STRING_WIDTH_CAP # Cap string columns
|
|
1036
1114
|
|
|
1037
|
-
return
|
|
1115
|
+
return col_widths
|
|
1038
1116
|
|
|
1039
1117
|
def setup_columns(self) -> None:
|
|
1040
1118
|
"""Clear table and setup columns.
|
|
@@ -1049,8 +1127,8 @@ class DataFrameTable(DataTable):
|
|
|
1049
1127
|
|
|
1050
1128
|
# Add columns with justified headers
|
|
1051
1129
|
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
1052
|
-
if col in self.hidden_columns:
|
|
1053
|
-
continue # Skip hidden columns
|
|
1130
|
+
if col in self.hidden_columns or (col == RID and not self.show_rid):
|
|
1131
|
+
continue # Skip hidden columns and internal RID
|
|
1054
1132
|
for idx, c in enumerate(self.sorted_columns, 1):
|
|
1055
1133
|
if c == col:
|
|
1056
1134
|
# Add sort indicator to column header
|
|
@@ -1068,178 +1146,393 @@ class DataFrameTable(DataTable):
|
|
|
1068
1146
|
|
|
1069
1147
|
self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
|
|
1070
1148
|
|
|
1071
|
-
def
|
|
1072
|
-
"""
|
|
1149
|
+
def _calculate_load_range(self, start: int, stop: int) -> list[tuple[int, int]]:
|
|
1150
|
+
"""Calculate the actual ranges to load, accounting for already-loaded ranges.
|
|
1151
|
+
|
|
1152
|
+
Handles complex cases where a loaded range is fully contained within the requested
|
|
1153
|
+
range (creating head and tail segments to load). All overlapping/adjacent loaded
|
|
1154
|
+
ranges are merged first to minimize gaps.
|
|
1073
1155
|
|
|
1074
1156
|
Args:
|
|
1075
|
-
|
|
1076
|
-
|
|
1157
|
+
start: Requested start index (0-based).
|
|
1158
|
+
stop: Requested stop index (0-based, exclusive).
|
|
1159
|
+
|
|
1160
|
+
Returns:
|
|
1161
|
+
List of (actual_start, actual_stop) tuples to load. Empty list if the entire
|
|
1162
|
+
requested range is already loaded.
|
|
1163
|
+
|
|
1164
|
+
Example:
|
|
1165
|
+
If loaded ranges are [(150, 250)] and requesting (100, 300):
|
|
1166
|
+
- Returns [(100, 150), (250, 300)] to load head and tail
|
|
1167
|
+
If loaded ranges are [(0, 100), (100, 200)] and requesting (50, 150):
|
|
1168
|
+
- After merging, loaded_ranges becomes [(0, 200)]
|
|
1169
|
+
- Returns [] (already fully loaded)
|
|
1077
1170
|
"""
|
|
1078
|
-
if
|
|
1079
|
-
|
|
1171
|
+
if not self.loaded_ranges:
|
|
1172
|
+
return [(start, stop)]
|
|
1173
|
+
|
|
1174
|
+
# Sort loaded ranges by start index
|
|
1175
|
+
sorted_ranges = sorted(self.loaded_ranges)
|
|
1176
|
+
|
|
1177
|
+
# Merge overlapping/adjacent ranges
|
|
1178
|
+
merged = []
|
|
1179
|
+
for range_start, range_stop in sorted_ranges:
|
|
1180
|
+
# Fully covered, no need to load anything
|
|
1181
|
+
if range_start <= start and range_stop >= stop:
|
|
1182
|
+
return []
|
|
1183
|
+
# Overlapping or adjacent: merge
|
|
1184
|
+
elif merged and range_start <= merged[-1][1]:
|
|
1185
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], range_stop))
|
|
1186
|
+
else:
|
|
1187
|
+
merged.append((range_start, range_stop))
|
|
1188
|
+
|
|
1189
|
+
self.loaded_ranges = merged
|
|
1190
|
+
|
|
1191
|
+
# Calculate ranges to load by finding gaps in the merged ranges
|
|
1192
|
+
ranges_to_load = []
|
|
1193
|
+
current_pos = start
|
|
1194
|
+
|
|
1195
|
+
for range_start, range_stop in merged:
|
|
1196
|
+
# If there's a gap before this loaded range, add it to load list
|
|
1197
|
+
if current_pos < range_start and current_pos < stop:
|
|
1198
|
+
gap_end = min(range_start, stop)
|
|
1199
|
+
ranges_to_load.append((current_pos, gap_end))
|
|
1200
|
+
current_pos = range_stop
|
|
1201
|
+
elif current_pos >= range_stop:
|
|
1202
|
+
# Already moved past this loaded range
|
|
1203
|
+
continue
|
|
1204
|
+
else:
|
|
1205
|
+
# Current position is inside this loaded range, skip past it
|
|
1206
|
+
current_pos = max(current_pos, range_stop)
|
|
1207
|
+
|
|
1208
|
+
# If there's remaining range after all loaded ranges, add it
|
|
1209
|
+
if current_pos < stop:
|
|
1210
|
+
ranges_to_load.append((current_pos, stop))
|
|
1080
1211
|
|
|
1081
|
-
|
|
1082
|
-
if stop <= self.loaded_rows:
|
|
1083
|
-
if move_to_end:
|
|
1084
|
-
self.move_cursor(row=self.row_count - 1)
|
|
1212
|
+
return ranges_to_load
|
|
1085
1213
|
|
|
1214
|
+
def _merge_loaded_ranges(self) -> None:
|
|
1215
|
+
"""Merge adjacent and overlapping ranges in self.loaded_ranges.
|
|
1216
|
+
|
|
1217
|
+
Ranges like (0, 100) and (100, 200) are merged into (0, 200).
|
|
1218
|
+
"""
|
|
1219
|
+
if len(self.loaded_ranges) <= 1:
|
|
1086
1220
|
return
|
|
1087
1221
|
|
|
1088
|
-
#
|
|
1089
|
-
|
|
1222
|
+
# Sort by start index
|
|
1223
|
+
sorted_ranges = sorted(self.loaded_ranges)
|
|
1090
1224
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1225
|
+
# Merge overlapping/adjacent ranges
|
|
1226
|
+
merged = [sorted_ranges[0]]
|
|
1227
|
+
for range_start, range_stop in sorted_ranges[1:]:
|
|
1228
|
+
# Overlapping or adjacent: merge
|
|
1229
|
+
if range_start <= merged[-1][1]:
|
|
1230
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], range_stop))
|
|
1231
|
+
else:
|
|
1232
|
+
merged.append((range_start, range_stop))
|
|
1094
1233
|
|
|
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
|
-
)
|
|
1234
|
+
self.loaded_ranges = merged
|
|
1102
1235
|
|
|
1103
|
-
|
|
1236
|
+
def _find_insert_position_for_row(self, ridx: int) -> int:
|
|
1237
|
+
"""Find the correct table position to insert a row with the given dataframe index.
|
|
1238
|
+
|
|
1239
|
+
In the table display, rows are ordered by their dataframe index, regardless of
|
|
1240
|
+
the internal row keys. This method finds where a row should be inserted based on
|
|
1241
|
+
its dataframe index and the indices of already-loaded rows.
|
|
1104
1242
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1243
|
+
Args:
|
|
1244
|
+
ridx: The 0-based dataframe row index.
|
|
1245
|
+
|
|
1246
|
+
Returns:
|
|
1247
|
+
The 0-based table position where the row should be inserted.
|
|
1248
|
+
"""
|
|
1249
|
+
# Count how many already-loaded rows have lower dataframe indices
|
|
1250
|
+
# Iterate through loaded rows instead of iterating 0..ridx for efficiency
|
|
1251
|
+
insert_pos = 0
|
|
1252
|
+
for row_key in self._row_locations:
|
|
1253
|
+
loaded_ridx = int(row_key.value)
|
|
1254
|
+
if loaded_ridx < ridx:
|
|
1255
|
+
insert_pos += 1
|
|
1107
1256
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1257
|
+
return insert_pos
|
|
1258
|
+
|
|
1259
|
+
def load_rows_segment(self, segment_start: int, segment_stop: int) -> int:
|
|
1260
|
+
"""Load a single contiguous segment of rows into the table.
|
|
1261
|
+
|
|
1262
|
+
This is the core loading logic that inserts rows at correct positions,
|
|
1263
|
+
respecting visibility and selection states. Used by load_rows_range()
|
|
1264
|
+
to handle each segment independently.
|
|
1111
1265
|
|
|
1112
1266
|
Args:
|
|
1113
|
-
|
|
1114
|
-
|
|
1267
|
+
segment_start: Start loading rows from this index (0-based).
|
|
1268
|
+
segment_stop: Stop loading rows when this index is reached (0-based, exclusive).
|
|
1115
1269
|
"""
|
|
1116
|
-
#
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1270
|
+
# Record this range before loading
|
|
1271
|
+
self.loaded_ranges.append((segment_start, segment_stop))
|
|
1272
|
+
|
|
1273
|
+
# Load the dataframe slice
|
|
1274
|
+
df_slice = self.df.slice(segment_start, segment_stop - segment_start)
|
|
1275
|
+
|
|
1276
|
+
# Load each row at the correct position
|
|
1277
|
+
for (ridx, row), rid in zip(enumerate(df_slice.rows(), segment_start), df_slice[RID]):
|
|
1278
|
+
is_selected = rid in self.selected_rows
|
|
1279
|
+
match_cols = self.matches.get(rid, set())
|
|
1280
|
+
|
|
1281
|
+
vals, dtypes, styles = [], [], []
|
|
1282
|
+
for val, col, dtype in zip(row, self.df.columns, self.df.dtypes, strict=True):
|
|
1283
|
+
if col in self.hidden_columns or (col == RID and not self.show_rid):
|
|
1284
|
+
continue # Skip hidden columns and internal RID
|
|
1285
|
+
|
|
1286
|
+
vals.append(val)
|
|
1287
|
+
dtypes.append(dtype)
|
|
1288
|
+
|
|
1289
|
+
# Highlight entire row with selection or cells with matches
|
|
1290
|
+
styles.append(HIGHLIGHT_COLOR if is_selected or col in match_cols else None)
|
|
1291
|
+
|
|
1292
|
+
formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
|
|
1293
|
+
|
|
1294
|
+
# Find correct insertion position and insert
|
|
1295
|
+
insert_pos = self._find_insert_position_for_row(ridx)
|
|
1296
|
+
self.insert_row(*formatted_row, key=str(ridx), label=str(ridx + 1), position=insert_pos)
|
|
1297
|
+
|
|
1298
|
+
# Number of rows loaded in this segment
|
|
1299
|
+
segment_count = len(df_slice)
|
|
1130
1300
|
|
|
1131
|
-
#
|
|
1132
|
-
|
|
1133
|
-
self.call_after_refresh(lambda: self.move_cursor(row=self.row_count - 1))
|
|
1301
|
+
# Update loaded rows count
|
|
1302
|
+
self.loaded_rows += segment_count
|
|
1134
1303
|
|
|
1135
|
-
|
|
1304
|
+
return segment_count
|
|
1305
|
+
|
|
1306
|
+
def load_rows_range(self, start: int, stop: int) -> int:
|
|
1136
1307
|
"""Load a batch of rows into the table.
|
|
1137
1308
|
|
|
1138
1309
|
Row keys are 0-based indices as strings, which map directly to dataframe row indices.
|
|
1139
1310
|
Row labels are 1-based indices as strings.
|
|
1140
1311
|
|
|
1312
|
+
Intelligently handles range loading:
|
|
1313
|
+
1. Calculates which ranges actually need loading (avoiding reloading)
|
|
1314
|
+
2. Handles complex cases where loaded ranges create "holes" (head and tail segments)
|
|
1315
|
+
3. Inserts rows at correct positions in the table
|
|
1316
|
+
4. Merges adjacent/overlapping ranges to optimize future loading
|
|
1317
|
+
|
|
1141
1318
|
Args:
|
|
1142
|
-
|
|
1319
|
+
start: Start loading rows from this index (0-based).
|
|
1320
|
+
stop: Stop loading rows when this index is reached (0-based, exclusive).
|
|
1143
1321
|
"""
|
|
1322
|
+
start = max(0, start) # Clamp to non-negative
|
|
1323
|
+
stop = min(stop, len(self.df)) # Clamp to dataframe length
|
|
1324
|
+
|
|
1144
1325
|
try:
|
|
1145
|
-
|
|
1146
|
-
|
|
1326
|
+
# Calculate actual ranges to load, accounting for already-loaded ranges
|
|
1327
|
+
ranges_to_load = self._calculate_load_range(start, stop)
|
|
1147
1328
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1329
|
+
# If nothing needs loading, return early
|
|
1330
|
+
if not ranges_to_load:
|
|
1331
|
+
return 0 # Already loaded
|
|
1151
1332
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1333
|
+
# Track the number of loaded rows in this range
|
|
1334
|
+
range_count = 0
|
|
1154
1335
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
continue # Skip hidden columns
|
|
1336
|
+
# Load each segment
|
|
1337
|
+
for segment_start, segment_stop in ranges_to_load:
|
|
1338
|
+
range_count += self.load_rows_segment(segment_start, segment_stop)
|
|
1159
1339
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1340
|
+
# Merge adjacent/overlapping ranges to optimize storage
|
|
1341
|
+
self._merge_loaded_ranges()
|
|
1162
1342
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1343
|
+
self.log(f"Loaded {range_count} rows for range {start}-{stop}/{len(self.df)}")
|
|
1344
|
+
return range_count
|
|
1165
1345
|
|
|
1166
|
-
|
|
1346
|
+
except Exception as e:
|
|
1347
|
+
self.notify("Error loading rows", title="Load", severity="error", timeout=10)
|
|
1348
|
+
self.log(f"Error loading rows: {str(e)}")
|
|
1349
|
+
return 0
|
|
1350
|
+
|
|
1351
|
+
def load_rows_up(self) -> None:
|
|
1352
|
+
"""Check if we need to load more rows and load them."""
|
|
1353
|
+
# If we've loaded everything, no need to check
|
|
1354
|
+
if self.loaded_rows >= len(self.df):
|
|
1355
|
+
return
|
|
1167
1356
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1357
|
+
top_row_index = int(self.scroll_y) + BUFFER_SIZE
|
|
1358
|
+
top_row_key = self.get_row_key(top_row_index)
|
|
1170
1359
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1360
|
+
if top_row_key:
|
|
1361
|
+
top_ridx = int(top_row_key.value)
|
|
1362
|
+
else:
|
|
1363
|
+
top_ridx = 0 # No top row key at index, default to 0
|
|
1173
1364
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1365
|
+
# Load upward
|
|
1366
|
+
start, stop = self._round_to_nearest_hundreds(top_ridx - BUFFER_SIZE * 2)
|
|
1367
|
+
range_count = self.load_rows_range(start, stop)
|
|
1176
1368
|
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
self.
|
|
1369
|
+
# Adjust scroll to maintain position if rows were loaded above
|
|
1370
|
+
if range_count > 0:
|
|
1371
|
+
self.move_cursor(row=top_row_index + range_count)
|
|
1372
|
+
self.log(f"Loaded up: {range_count} rows in range {start}-{stop}/{len(self.df)}")
|
|
1180
1373
|
|
|
1181
|
-
def
|
|
1374
|
+
def load_rows_down(self) -> None:
|
|
1182
1375
|
"""Check if we need to load more rows and load them."""
|
|
1183
1376
|
# If we've loaded everything, no need to check
|
|
1184
1377
|
if self.loaded_rows >= len(self.df):
|
|
1185
1378
|
return
|
|
1186
1379
|
|
|
1187
|
-
visible_row_count = self.
|
|
1188
|
-
|
|
1380
|
+
visible_row_count = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
|
|
1381
|
+
bottom_row_index = self.scroll_y + visible_row_count - BUFFER_SIZE
|
|
1382
|
+
|
|
1383
|
+
bottom_row_key = self.get_row_key(bottom_row_index)
|
|
1384
|
+
if bottom_row_key:
|
|
1385
|
+
bottom_ridx = int(bottom_row_key.value)
|
|
1386
|
+
else:
|
|
1387
|
+
bottom_ridx = 0 # No bottom row key at index, default to 0
|
|
1388
|
+
|
|
1389
|
+
# Load downward
|
|
1390
|
+
start, stop = self._round_to_nearest_hundreds(bottom_ridx + BUFFER_SIZE * 2)
|
|
1391
|
+
range_count = self.load_rows_range(start, stop)
|
|
1189
1392
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
self.load_rows(self.loaded_rows + self.BATCH_SIZE)
|
|
1393
|
+
if range_count > 0:
|
|
1394
|
+
self.log(f"Loaded down: {range_count} rows in range {start}-{stop}/{len(self.df)}")
|
|
1193
1395
|
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1396
|
+
def insert_row(
|
|
1397
|
+
self,
|
|
1398
|
+
*cells: CellType,
|
|
1399
|
+
height: int | None = 1,
|
|
1400
|
+
key: str | None = None,
|
|
1401
|
+
label: TextType | None = None,
|
|
1402
|
+
position: int | None = None,
|
|
1403
|
+
) -> RowKey:
|
|
1404
|
+
"""Insert a row at a specific position in the DataTable.
|
|
1405
|
+
|
|
1406
|
+
When inserting, all rows at and after the insertion position are shifted down,
|
|
1407
|
+
and their entries in self._row_locations are updated accordingly.
|
|
1197
1408
|
|
|
1198
1409
|
Args:
|
|
1199
|
-
|
|
1410
|
+
*cells: Positional arguments should contain cell data.
|
|
1411
|
+
height: The height of a row (in lines). Use `None` to auto-detect the optimal
|
|
1412
|
+
height.
|
|
1413
|
+
key: A key which uniquely identifies this row. If None, it will be generated
|
|
1414
|
+
for you and returned.
|
|
1415
|
+
label: The label for the row. Will be displayed to the left if supplied.
|
|
1416
|
+
position: The 0-based row index where the new row should be inserted.
|
|
1417
|
+
If None, inserts at the end (same as add_row). If out of bounds,
|
|
1418
|
+
inserts at the nearest valid position.
|
|
1419
|
+
|
|
1420
|
+
Returns:
|
|
1421
|
+
Unique identifier for this row. Can be used to retrieve this row regardless
|
|
1422
|
+
of its current location in the DataTable (it could have moved after
|
|
1423
|
+
being added due to sorting or insertion/deletion of other rows).
|
|
1424
|
+
|
|
1425
|
+
Raises:
|
|
1426
|
+
DuplicateKey: If a row with the given key already exists.
|
|
1427
|
+
ValueError: If more cells are provided than there are columns.
|
|
1200
1428
|
"""
|
|
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
|
-
|
|
1429
|
+
# Default to appending if position not specified or >= row_count
|
|
1430
|
+
row_count = self.row_count
|
|
1431
|
+
if position is None or position >= row_count:
|
|
1432
|
+
return self.add_row(*cells, height=height, key=key, label=label)
|
|
1433
|
+
|
|
1434
|
+
# Clamp position to valid range [0, row_count)
|
|
1435
|
+
position = max(0, position)
|
|
1436
|
+
|
|
1437
|
+
row_key = RowKey(key)
|
|
1438
|
+
if row_key in self._row_locations:
|
|
1439
|
+
raise DuplicateKey(f"The row key {row_key!r} already exists.")
|
|
1440
|
+
|
|
1441
|
+
if len(cells) > len(self.ordered_columns):
|
|
1442
|
+
raise ValueError("More values provided than there are columns.")
|
|
1443
|
+
|
|
1444
|
+
# TC: Rebuild self._row_locations to shift rows at and after position down by 1
|
|
1445
|
+
# Create a mapping of old index -> new index
|
|
1446
|
+
old_to_new = {}
|
|
1447
|
+
for old_idx in range(row_count):
|
|
1448
|
+
if old_idx < position:
|
|
1449
|
+
old_to_new[old_idx] = old_idx # No change
|
|
1450
|
+
else:
|
|
1451
|
+
old_to_new[old_idx] = old_idx + 1 # Shift down by 1
|
|
1452
|
+
|
|
1453
|
+
# Update _row_locations with the new indices
|
|
1454
|
+
new_row_locations = TwoWayDict({})
|
|
1455
|
+
for row_key_item in self._row_locations:
|
|
1456
|
+
old_idx = self.get_row_idx(row_key_item)
|
|
1457
|
+
new_idx = old_to_new.get(old_idx, old_idx)
|
|
1458
|
+
new_row_locations[row_key_item] = new_idx
|
|
1459
|
+
|
|
1460
|
+
# Update the internal mapping
|
|
1461
|
+
self._row_locations = new_row_locations
|
|
1462
|
+
# TC
|
|
1463
|
+
|
|
1464
|
+
row_index = position
|
|
1465
|
+
# Map the key of this row to its current index
|
|
1466
|
+
self._row_locations[row_key] = row_index
|
|
1467
|
+
self._data[row_key] = {column.key: cell for column, cell in zip_longest(self.ordered_columns, cells)}
|
|
1468
|
+
|
|
1469
|
+
label = Text.from_markup(label, end="") if isinstance(label, str) else label
|
|
1470
|
+
|
|
1471
|
+
# Rows with auto-height get a height of 0 because 1) we need an integer height
|
|
1472
|
+
# to do some intermediate computations and 2) because 0 doesn't impact the data
|
|
1473
|
+
# table while we don't figure out how tall this row is.
|
|
1474
|
+
self.rows[row_key] = Row(
|
|
1475
|
+
row_key,
|
|
1476
|
+
height or 0,
|
|
1477
|
+
label,
|
|
1478
|
+
height is None,
|
|
1479
|
+
)
|
|
1480
|
+
self._new_rows.add(row_key)
|
|
1481
|
+
self._require_update_dimensions = True
|
|
1482
|
+
self.cursor_coordinate = self.cursor_coordinate
|
|
1483
|
+
|
|
1484
|
+
# If a position has opened for the cursor to appear, where it previously
|
|
1485
|
+
# could not (e.g. when there's no data in the table), then a highlighted
|
|
1486
|
+
# event is posted, since there's now a highlighted cell when there wasn't
|
|
1487
|
+
# before.
|
|
1488
|
+
cell_now_available = self.row_count == 1 and len(self.columns) > 0
|
|
1489
|
+
visible_cursor = self.show_cursor and self.cursor_type != "none"
|
|
1490
|
+
if cell_now_available and visible_cursor:
|
|
1491
|
+
self._highlight_cursor()
|
|
1492
|
+
|
|
1493
|
+
self._update_count += 1
|
|
1494
|
+
self.check_idle()
|
|
1495
|
+
return row_key
|
|
1496
|
+
|
|
1497
|
+
# Navigation
|
|
1498
|
+
def do_jump_top(self) -> None:
|
|
1499
|
+
"""Jump to the top of the table."""
|
|
1500
|
+
self.move_cursor(row=0)
|
|
1501
|
+
|
|
1502
|
+
def do_jump_bottom(self) -> None:
|
|
1503
|
+
"""Jump to the bottom of the table."""
|
|
1504
|
+
stop = len(self.df)
|
|
1505
|
+
start = max(0, stop - self.BATCH_SIZE)
|
|
1506
|
+
|
|
1507
|
+
if start % self.BATCH_SIZE != 0:
|
|
1508
|
+
start = (start // self.BATCH_SIZE + 1) * self.BATCH_SIZE
|
|
1509
|
+
|
|
1510
|
+
if stop - start < self.BATCH_SIZE:
|
|
1511
|
+
start -= self.BATCH_SIZE
|
|
1512
|
+
|
|
1513
|
+
self.load_rows_range(start, stop)
|
|
1514
|
+
self.move_cursor(row=self.row_count - 1)
|
|
1515
|
+
|
|
1516
|
+
def do_page_up(self) -> None:
|
|
1517
|
+
"""Move the cursor one page up."""
|
|
1518
|
+
self._set_hover_cursor(False)
|
|
1519
|
+
if self.show_cursor and self.cursor_type in ("cell", "row"):
|
|
1520
|
+
height = self.scrollable_content_region.height - (self.header_height if self.show_header else 0)
|
|
1521
|
+
|
|
1522
|
+
col_idx = self.cursor_column
|
|
1523
|
+
ridx = self.cursor_row_idx
|
|
1524
|
+
next_ridx = max(0, ridx - height - BUFFER_SIZE)
|
|
1525
|
+
start, stop = self._round_to_nearest_hundreds(next_ridx)
|
|
1526
|
+
self.load_rows_range(start, stop)
|
|
1527
|
+
|
|
1528
|
+
self.move_cursor(row=self.get_row_idx(str(next_ridx)), column=col_idx)
|
|
1529
|
+
else:
|
|
1530
|
+
super().action_page_up()
|
|
1531
|
+
|
|
1532
|
+
def do_page_down(self) -> None:
|
|
1533
|
+
"""Move the cursor one page down."""
|
|
1534
|
+
super().action_page_down()
|
|
1535
|
+
self.load_rows_down()
|
|
1243
1536
|
|
|
1244
1537
|
# History & Undo
|
|
1245
1538
|
def create_history(self, description: str) -> None:
|
|
@@ -1247,16 +1540,15 @@ class DataFrameTable(DataTable):
|
|
|
1247
1540
|
return History(
|
|
1248
1541
|
description=description,
|
|
1249
1542
|
df=self.df,
|
|
1543
|
+
df_view=self.df_view,
|
|
1250
1544
|
filename=self.filename,
|
|
1251
|
-
loaded_rows=self.loaded_rows,
|
|
1252
|
-
sorted_columns=self.sorted_columns.copy(),
|
|
1253
1545
|
hidden_columns=self.hidden_columns.copy(),
|
|
1254
1546
|
selected_rows=self.selected_rows.copy(),
|
|
1255
|
-
|
|
1547
|
+
sorted_columns=self.sorted_columns.copy(),
|
|
1548
|
+
matches={k: v.copy() for k, v in self.matches.items()},
|
|
1256
1549
|
fixed_rows=self.fixed_rows,
|
|
1257
1550
|
fixed_columns=self.fixed_columns,
|
|
1258
1551
|
cursor_coordinate=self.cursor_coordinate,
|
|
1259
|
-
matches={k: v.copy() for k, v in self.matches.items()},
|
|
1260
1552
|
dirty=self.dirty,
|
|
1261
1553
|
)
|
|
1262
1554
|
|
|
@@ -1267,30 +1559,32 @@ class DataFrameTable(DataTable):
|
|
|
1267
1559
|
|
|
1268
1560
|
# Restore state
|
|
1269
1561
|
self.df = history.df
|
|
1562
|
+
self.df_view = history.df_view
|
|
1270
1563
|
self.filename = history.filename
|
|
1271
|
-
self.loaded_rows = history.loaded_rows
|
|
1272
|
-
self.sorted_columns = history.sorted_columns.copy()
|
|
1273
1564
|
self.hidden_columns = history.hidden_columns.copy()
|
|
1274
1565
|
self.selected_rows = history.selected_rows.copy()
|
|
1275
|
-
self.
|
|
1566
|
+
self.sorted_columns = history.sorted_columns.copy()
|
|
1567
|
+
self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
|
|
1276
1568
|
self.fixed_rows = history.fixed_rows
|
|
1277
1569
|
self.fixed_columns = history.fixed_columns
|
|
1278
1570
|
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
1571
|
self.dirty = history.dirty
|
|
1281
1572
|
|
|
1282
1573
|
# Recreate table for display
|
|
1283
1574
|
self.setup_table()
|
|
1284
1575
|
|
|
1285
|
-
def add_history(self, description: str, dirty: bool = False) -> None:
|
|
1576
|
+
def add_history(self, description: str, dirty: bool = False, clear_redo: bool = True) -> None:
|
|
1286
1577
|
"""Add the current state to the history stack.
|
|
1287
1578
|
|
|
1288
1579
|
Args:
|
|
1289
1580
|
description: Description of the action for this history entry.
|
|
1290
1581
|
dirty: Whether this operation modifies the data (True) or just display state (False).
|
|
1291
1582
|
"""
|
|
1292
|
-
|
|
1293
|
-
|
|
1583
|
+
self.histories_undo.append(self.create_history(description))
|
|
1584
|
+
|
|
1585
|
+
# Clear redo stack when a new action is performed
|
|
1586
|
+
if clear_redo:
|
|
1587
|
+
self.histories_redo.clear()
|
|
1294
1588
|
|
|
1295
1589
|
# Mark table as dirty if this operation modifies data
|
|
1296
1590
|
if dirty:
|
|
@@ -1298,15 +1592,13 @@ class DataFrameTable(DataTable):
|
|
|
1298
1592
|
|
|
1299
1593
|
def do_undo(self) -> None:
|
|
1300
1594
|
"""Undo the last action."""
|
|
1301
|
-
if not self.
|
|
1595
|
+
if not self.histories_undo:
|
|
1302
1596
|
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
1303
1597
|
return
|
|
1304
1598
|
|
|
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)
|
|
1599
|
+
# Pop the last history state for undo and save to redo stack
|
|
1600
|
+
history = self.histories_undo.pop()
|
|
1601
|
+
self.histories_redo.append(self.create_history(history.description))
|
|
1310
1602
|
|
|
1311
1603
|
# Restore state
|
|
1312
1604
|
self.apply_history(history)
|
|
@@ -1315,35 +1607,28 @@ class DataFrameTable(DataTable):
|
|
|
1315
1607
|
|
|
1316
1608
|
def do_redo(self) -> None:
|
|
1317
1609
|
"""Redo the last undone action."""
|
|
1318
|
-
if self.
|
|
1610
|
+
if not self.histories_redo:
|
|
1319
1611
|
self.notify("No actions to redo", title="Redo", severity="warning")
|
|
1320
1612
|
return
|
|
1321
1613
|
|
|
1322
|
-
|
|
1614
|
+
# Pop the last undone state from redo stack
|
|
1615
|
+
history = self.histories_redo.pop()
|
|
1616
|
+
description = history.description
|
|
1323
1617
|
|
|
1324
1618
|
# Save current state for undo
|
|
1325
|
-
self.add_history(description)
|
|
1619
|
+
self.add_history(description, clear_redo=False)
|
|
1326
1620
|
|
|
1327
1621
|
# Restore state
|
|
1328
|
-
self.apply_history(
|
|
1329
|
-
|
|
1330
|
-
# Clear redo state
|
|
1331
|
-
self.history = None
|
|
1622
|
+
self.apply_history(history)
|
|
1332
1623
|
|
|
1333
1624
|
self.notify(f"Reapplied: {description}", title="Redo")
|
|
1334
1625
|
|
|
1335
1626
|
def do_reset(self) -> None:
|
|
1336
1627
|
"""Reset the table to the initial state."""
|
|
1337
|
-
self.
|
|
1628
|
+
self.reset_df(self.dataframe, dirty=False)
|
|
1629
|
+
self.setup_table()
|
|
1338
1630
|
self.notify("Restored initial state", title="Reset")
|
|
1339
1631
|
|
|
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
1632
|
# Display
|
|
1348
1633
|
def do_cycle_cursor_type(self) -> None:
|
|
1349
1634
|
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
@@ -1380,6 +1665,14 @@ class DataFrameTable(DataTable):
|
|
|
1380
1665
|
cidx = self.cursor_col_idx
|
|
1381
1666
|
self.app.push_screen(StatisticsScreen(self, col_idx=cidx))
|
|
1382
1667
|
|
|
1668
|
+
def do_metadata_shape(self) -> None:
|
|
1669
|
+
"""Show metadata about the dataframe (row and column counts)."""
|
|
1670
|
+
self.app.push_screen(MetaShape(self))
|
|
1671
|
+
|
|
1672
|
+
def do_metadata_column(self) -> None:
|
|
1673
|
+
"""Show metadata for all columns in the dataframe."""
|
|
1674
|
+
self.app.push_screen(MetaColumnScreen(self))
|
|
1675
|
+
|
|
1383
1676
|
def do_freeze_row_column(self) -> None:
|
|
1384
1677
|
"""Open the freeze screen to set fixed rows and columns."""
|
|
1385
1678
|
self.app.push_screen(FreezeScreen(), callback=self.freeze_row_column)
|
|
@@ -1396,7 +1689,7 @@ class DataFrameTable(DataTable):
|
|
|
1396
1689
|
fixed_rows, fixed_columns = result
|
|
1397
1690
|
|
|
1398
1691
|
# Add to history
|
|
1399
|
-
self.add_history(f"Pinned [$
|
|
1692
|
+
self.add_history(f"Pinned [$success]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns")
|
|
1400
1693
|
|
|
1401
1694
|
# Apply the pin settings to the table
|
|
1402
1695
|
if fixed_rows >= 0:
|
|
@@ -1404,7 +1697,7 @@ class DataFrameTable(DataTable):
|
|
|
1404
1697
|
if fixed_columns >= 0:
|
|
1405
1698
|
self.fixed_columns = fixed_columns
|
|
1406
1699
|
|
|
1407
|
-
# self.notify(f"Pinned [$
|
|
1700
|
+
# self.notify(f"Pinned [$success]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns", title="Pin")
|
|
1408
1701
|
|
|
1409
1702
|
def do_hide_column(self) -> None:
|
|
1410
1703
|
"""Hide the currently selected column from the table display."""
|
|
@@ -1425,7 +1718,7 @@ class DataFrameTable(DataTable):
|
|
|
1425
1718
|
if col_idx >= len(self.columns):
|
|
1426
1719
|
self.move_cursor(column=len(self.columns) - 1)
|
|
1427
1720
|
|
|
1428
|
-
# self.notify(f"Hid column [$
|
|
1721
|
+
# self.notify(f"Hid column [$success]{col_name}[/]. Press [$accent]H[/] to show hidden columns", title="Hide")
|
|
1429
1722
|
|
|
1430
1723
|
def do_expand_column(self) -> None:
|
|
1431
1724
|
"""Expand the current column to show the widest cell in the loaded data."""
|
|
@@ -1438,58 +1731,78 @@ class DataFrameTable(DataTable):
|
|
|
1438
1731
|
if dtype != pl.String:
|
|
1439
1732
|
return
|
|
1440
1733
|
|
|
1734
|
+
# The column to expand/shrink
|
|
1735
|
+
col: Column = self.columns[col_key]
|
|
1736
|
+
|
|
1441
1737
|
# Calculate the maximum width across all loaded rows
|
|
1442
|
-
|
|
1738
|
+
label_width = len(col_name) + 2 # Start with column name width + padding
|
|
1443
1739
|
|
|
1444
1740
|
try:
|
|
1741
|
+
need_expand = False
|
|
1742
|
+
max_width = label_width
|
|
1743
|
+
|
|
1445
1744
|
# Scan through all loaded rows that are visible to find max width
|
|
1446
1745
|
for row_idx in range(self.loaded_rows):
|
|
1447
|
-
if not self.visible_rows[row_idx]:
|
|
1448
|
-
continue # Skip hidden rows
|
|
1449
1746
|
cell_value = str(self.df.item(row_idx, col_idx))
|
|
1450
1747
|
cell_width = measure(self.app.console, cell_value, 1)
|
|
1748
|
+
|
|
1749
|
+
if cell_width > max_width:
|
|
1750
|
+
need_expand = True
|
|
1451
1751
|
max_width = max(max_width, cell_width)
|
|
1452
1752
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1753
|
+
if not need_expand:
|
|
1754
|
+
return
|
|
1755
|
+
|
|
1756
|
+
if col_name in self.expanded_columns:
|
|
1757
|
+
col.width = max(label_width, STRING_WIDTH_CAP)
|
|
1758
|
+
self.expanded_columns.remove(col_name)
|
|
1759
|
+
else:
|
|
1760
|
+
self.expanded_columns.add(col_name)
|
|
1456
1761
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
self._require_update_dimensions = True
|
|
1460
|
-
self.refresh(layout=True)
|
|
1762
|
+
# Update the column width
|
|
1763
|
+
col.width = max_width
|
|
1461
1764
|
|
|
1462
|
-
# self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand")
|
|
1463
1765
|
except Exception as e:
|
|
1464
|
-
self.notify(
|
|
1766
|
+
self.notify(
|
|
1767
|
+
f"Error expanding column [$error]{col_name}[/]", title="Expand Column", severity="error", timeout=10
|
|
1768
|
+
)
|
|
1465
1769
|
self.log(f"Error expanding column `{col_name}`: {str(e)}")
|
|
1466
1770
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1771
|
+
# Force a refresh
|
|
1772
|
+
self._update_count += 1
|
|
1773
|
+
self._require_update_dimensions = True
|
|
1774
|
+
self.refresh(layout=True)
|
|
1471
1775
|
|
|
1472
|
-
|
|
1473
|
-
hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
|
|
1776
|
+
# self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand")
|
|
1474
1777
|
|
|
1475
|
-
|
|
1476
|
-
|
|
1778
|
+
def do_toggle_rid(self) -> None:
|
|
1779
|
+
"""Toggle display of the internal RID column."""
|
|
1780
|
+
self.show_rid = not self.show_rid
|
|
1781
|
+
|
|
1782
|
+
# Recreate table for display
|
|
1783
|
+
self.setup_table()
|
|
1784
|
+
|
|
1785
|
+
def do_show_hidden_rows_columns(self) -> None:
|
|
1786
|
+
"""Show all hidden rows/columns by recreating the table."""
|
|
1787
|
+
if not self.hidden_columns and self.df_view is None:
|
|
1788
|
+
self.notify("No hidden rows or columns to show", title="Show", severity="warning")
|
|
1477
1789
|
return
|
|
1478
1790
|
|
|
1479
1791
|
# Add to history
|
|
1480
1792
|
self.add_history("Showed hidden rows/columns")
|
|
1481
1793
|
|
|
1794
|
+
# If in a filtered view, restore the full dataframe
|
|
1795
|
+
if self.df_view is not None:
|
|
1796
|
+
self.df = self.df_view
|
|
1797
|
+
self.df_view = None
|
|
1798
|
+
|
|
1482
1799
|
# Clear hidden rows/columns tracking
|
|
1483
|
-
self.visible_rows = [True] * len(self.df)
|
|
1484
1800
|
self.hidden_columns.clear()
|
|
1485
1801
|
|
|
1486
1802
|
# Recreate table for display
|
|
1487
1803
|
self.setup_table()
|
|
1488
1804
|
|
|
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
|
-
)
|
|
1805
|
+
self.notify("Showed hidden row(s) and/or hidden column(s)", title="Show")
|
|
1493
1806
|
|
|
1494
1807
|
# Sort
|
|
1495
1808
|
def do_sort_by_column(self, descending: bool = False) -> None:
|
|
@@ -1510,32 +1823,40 @@ class DataFrameTable(DataTable):
|
|
|
1510
1823
|
|
|
1511
1824
|
# Add to history
|
|
1512
1825
|
self.add_history(f"Sorted on column [$success]{col_name}[/]", dirty=True)
|
|
1826
|
+
|
|
1827
|
+
# New column - add to sort
|
|
1513
1828
|
if old_desc is None:
|
|
1514
|
-
# Add new column to sort
|
|
1515
1829
|
self.sorted_columns[col_name] = descending
|
|
1830
|
+
|
|
1831
|
+
# Old column, same direction - remove from sort
|
|
1516
1832
|
elif old_desc == descending:
|
|
1517
|
-
# Same direction - remove from sort
|
|
1518
1833
|
del self.sorted_columns[col_name]
|
|
1834
|
+
|
|
1835
|
+
# Old column, different direction - add to sort at end
|
|
1519
1836
|
else:
|
|
1520
|
-
# Move to end of sort order
|
|
1521
1837
|
del self.sorted_columns[col_name]
|
|
1522
1838
|
self.sorted_columns[col_name] = descending
|
|
1523
1839
|
|
|
1840
|
+
lf = self.df.lazy()
|
|
1841
|
+
sort_by = {}
|
|
1842
|
+
|
|
1524
1843
|
# Apply multi-column sort
|
|
1525
1844
|
if sort_cols := list(self.sorted_columns.keys()):
|
|
1526
1845
|
descending_flags = list(self.sorted_columns.values())
|
|
1527
|
-
|
|
1846
|
+
sort_by = {"by": sort_cols, "descending": descending_flags, "nulls_last": True}
|
|
1528
1847
|
else:
|
|
1529
|
-
# No sort
|
|
1530
|
-
|
|
1848
|
+
# No sort - restore original order by adding a temporary index column
|
|
1849
|
+
sort_by = {"by": RID}
|
|
1531
1850
|
|
|
1532
|
-
#
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1851
|
+
# Perform the sort
|
|
1852
|
+
df_sorted = lf.sort(**sort_by).collect()
|
|
1853
|
+
|
|
1854
|
+
# Also update df_view if applicable
|
|
1855
|
+
if self.df_view is not None:
|
|
1856
|
+
self.df_view = self.df_view.lazy().sort(**sort_by).collect()
|
|
1536
1857
|
|
|
1537
1858
|
# Update the dataframe
|
|
1538
|
-
self.df = df_sorted
|
|
1859
|
+
self.df = df_sorted
|
|
1539
1860
|
|
|
1540
1861
|
# Recreate table for display
|
|
1541
1862
|
self.setup_table()
|
|
@@ -1582,6 +1903,17 @@ class DataFrameTable(DataTable):
|
|
|
1582
1903
|
.alias(col_name)
|
|
1583
1904
|
)
|
|
1584
1905
|
|
|
1906
|
+
# Also update the view if applicable
|
|
1907
|
+
if self.df_view is not None:
|
|
1908
|
+
# Get the RID value for this row in df_view
|
|
1909
|
+
ridx_view = self.df.item(ridx, self.df.columns.index(RID))
|
|
1910
|
+
self.df_view = self.df_view.with_columns(
|
|
1911
|
+
pl.when(pl.col(RID) == ridx_view)
|
|
1912
|
+
.then(pl.lit(new_value))
|
|
1913
|
+
.otherwise(pl.col(col_name))
|
|
1914
|
+
.alias(col_name)
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1585
1917
|
# Update the display
|
|
1586
1918
|
cell_value = self.df.item(ridx, cidx)
|
|
1587
1919
|
if cell_value is None:
|
|
@@ -1595,10 +1927,15 @@ class DataFrameTable(DataTable):
|
|
|
1595
1927
|
col_key = col_name
|
|
1596
1928
|
self.update_cell(row_key, col_key, formatted_value, update_width=True)
|
|
1597
1929
|
|
|
1598
|
-
# self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1930
|
+
# self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit Cell")
|
|
1599
1931
|
except Exception as e:
|
|
1600
|
-
self.notify(
|
|
1601
|
-
|
|
1932
|
+
self.notify(
|
|
1933
|
+
f"Error updating cell ([$error]{ridx}[/], [$accent]{col_name}[/])",
|
|
1934
|
+
title="Edit Cell",
|
|
1935
|
+
severity="error",
|
|
1936
|
+
timeout=10,
|
|
1937
|
+
)
|
|
1938
|
+
self.log(f"Error updating cell ({ridx}, {col_name}): {str(e)}")
|
|
1602
1939
|
|
|
1603
1940
|
def do_edit_column(self) -> None:
|
|
1604
1941
|
"""Open modal to edit the entire column with an expression."""
|
|
@@ -1627,7 +1964,9 @@ class DataFrameTable(DataTable):
|
|
|
1627
1964
|
try:
|
|
1628
1965
|
expr = validate_expr(term, self.df.columns, cidx)
|
|
1629
1966
|
except Exception as e:
|
|
1630
|
-
self.notify(
|
|
1967
|
+
self.notify(
|
|
1968
|
+
f"Error validating expression [$error]{term}[/]", title="Edit Column", severity="error", timeout=10
|
|
1969
|
+
)
|
|
1631
1970
|
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
1632
1971
|
return
|
|
1633
1972
|
|
|
@@ -1639,23 +1978,47 @@ class DataFrameTable(DataTable):
|
|
|
1639
1978
|
expr = pl.lit(value)
|
|
1640
1979
|
except Exception:
|
|
1641
1980
|
self.notify(
|
|
1642
|
-
f"Error converting [$
|
|
1981
|
+
f"Error converting [$error]{term}[/] to [$accent]{dtype}[/]. Cast to string.",
|
|
1643
1982
|
title="Edit",
|
|
1644
1983
|
severity="error",
|
|
1645
1984
|
)
|
|
1646
1985
|
expr = pl.lit(str(term))
|
|
1647
1986
|
|
|
1648
1987
|
# Add to history
|
|
1649
|
-
self.add_history(f"Edited column [$
|
|
1988
|
+
self.add_history(f"Edited column [$success]{col_name}[/] with expression", dirty=True)
|
|
1650
1989
|
|
|
1651
1990
|
try:
|
|
1652
1991
|
# Apply the expression to the column
|
|
1653
|
-
self.df = self.df.with_columns(expr.alias(col_name))
|
|
1992
|
+
self.df = self.df.lazy().with_columns(expr.alias(col_name)).collect()
|
|
1993
|
+
|
|
1994
|
+
# Also update the view if applicable
|
|
1995
|
+
# Update the value of col_name in df_view using the value of col_name from df based on RID mapping between them
|
|
1996
|
+
if self.df_view is not None:
|
|
1997
|
+
# Get updated column from df for rows that exist in df_view
|
|
1998
|
+
col_updated = f"^_{col_name}_^"
|
|
1999
|
+
col_exists = "^_exists_^"
|
|
2000
|
+
lf_updated = self.df.lazy().select(
|
|
2001
|
+
RID, pl.col(col_name).alias(col_updated), pl.lit(True).alias(col_exists)
|
|
2002
|
+
)
|
|
2003
|
+
# Join and use when/then/otherwise to handle all updates including NULLs
|
|
2004
|
+
self.df_view = (
|
|
2005
|
+
self.df_view.lazy()
|
|
2006
|
+
.join(lf_updated, on=RID, how="left")
|
|
2007
|
+
.with_columns(
|
|
2008
|
+
pl.when(pl.col(col_exists))
|
|
2009
|
+
.then(pl.col(col_updated))
|
|
2010
|
+
.otherwise(pl.col(col_name))
|
|
2011
|
+
.alias(col_name)
|
|
2012
|
+
)
|
|
2013
|
+
.drop(col_updated, col_exists)
|
|
2014
|
+
.collect()
|
|
2015
|
+
)
|
|
1654
2016
|
except Exception as e:
|
|
1655
2017
|
self.notify(
|
|
1656
2018
|
f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
|
|
1657
|
-
title="Edit",
|
|
2019
|
+
title="Edit Column",
|
|
1658
2020
|
severity="error",
|
|
2021
|
+
timeout=10,
|
|
1659
2022
|
)
|
|
1660
2023
|
self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
|
|
1661
2024
|
return
|
|
@@ -1663,12 +2026,12 @@ class DataFrameTable(DataTable):
|
|
|
1663
2026
|
# Recreate table for display
|
|
1664
2027
|
self.setup_table()
|
|
1665
2028
|
|
|
1666
|
-
# self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
|
|
2029
|
+
# self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit Column")
|
|
1667
2030
|
|
|
1668
|
-
def do_rename_column(self) -> None:
|
|
2031
|
+
def do_rename_column(self, col_idx: int | None) -> None:
|
|
1669
2032
|
"""Open modal to rename the selected column."""
|
|
1670
|
-
|
|
1671
|
-
|
|
2033
|
+
col_idx = self.cursor_column if col_idx is None else col_idx
|
|
2034
|
+
col_name = self.get_col_key(col_idx).value
|
|
1672
2035
|
|
|
1673
2036
|
# Push the rename column modal screen
|
|
1674
2037
|
self.app.push_screen(
|
|
@@ -1690,19 +2053,30 @@ class DataFrameTable(DataTable):
|
|
|
1690
2053
|
return
|
|
1691
2054
|
|
|
1692
2055
|
# Add to history
|
|
1693
|
-
self.add_history(f"Renamed column [$
|
|
2056
|
+
self.add_history(f"Renamed column [$success]{col_name}[/] to [$accent]{new_name}[/]", dirty=True)
|
|
1694
2057
|
|
|
1695
2058
|
# Rename the column in the dataframe
|
|
1696
2059
|
self.df = self.df.rename({col_name: new_name})
|
|
1697
2060
|
|
|
1698
|
-
#
|
|
2061
|
+
# Also update the view if applicable
|
|
2062
|
+
if self.df_view is not None:
|
|
2063
|
+
self.df_view = self.df_view.rename({col_name: new_name})
|
|
2064
|
+
|
|
2065
|
+
# Update sorted_columns if this column was sorted and maintain order
|
|
1699
2066
|
if col_name in self.sorted_columns:
|
|
1700
|
-
|
|
2067
|
+
sorted_columns = {}
|
|
2068
|
+
for col, order in self.sorted_columns.items():
|
|
2069
|
+
if col == col_name:
|
|
2070
|
+
sorted_columns[new_name] = order
|
|
2071
|
+
else:
|
|
2072
|
+
sorted_columns[col] = order
|
|
2073
|
+
self.sorted_columns = sorted_columns
|
|
1701
2074
|
|
|
1702
|
-
# Update
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
2075
|
+
# Update matches if this column had cell matches
|
|
2076
|
+
for cols in self.matches.values():
|
|
2077
|
+
if col_name in cols:
|
|
2078
|
+
cols.remove(col_name)
|
|
2079
|
+
cols.add(new_name)
|
|
1706
2080
|
|
|
1707
2081
|
# Recreate table for display
|
|
1708
2082
|
self.setup_table()
|
|
@@ -1731,6 +2105,13 @@ class DataFrameTable(DataTable):
|
|
|
1731
2105
|
.alias(col_name)
|
|
1732
2106
|
)
|
|
1733
2107
|
|
|
2108
|
+
# Also update the view if applicable
|
|
2109
|
+
if self.df_view is not None:
|
|
2110
|
+
ridx_view = self.df.item(ridx, self.df.columns.index(RID))
|
|
2111
|
+
self.df_view = self.df_view.with_columns(
|
|
2112
|
+
pl.when(pl.col(RID) == ridx_view).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
|
|
2113
|
+
)
|
|
2114
|
+
|
|
1734
2115
|
# Update the display
|
|
1735
2116
|
dtype = self.df.dtypes[cidx]
|
|
1736
2117
|
dc = DtypeConfig(dtype)
|
|
@@ -1738,36 +2119,38 @@ class DataFrameTable(DataTable):
|
|
|
1738
2119
|
|
|
1739
2120
|
self.update_cell(row_key, col_key, formatted_value)
|
|
1740
2121
|
|
|
1741
|
-
# self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
2122
|
+
# self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear Cell")
|
|
1742
2123
|
except Exception as e:
|
|
1743
|
-
self.notify(
|
|
1744
|
-
|
|
2124
|
+
self.notify(
|
|
2125
|
+
f"Error clearing cell ([$error]{ridx}[/], [$accent]{col_name}[/])",
|
|
2126
|
+
title="Clear Cell",
|
|
2127
|
+
severity="error",
|
|
2128
|
+
timeout=10,
|
|
2129
|
+
)
|
|
2130
|
+
self.log(f"Error clearing cell ({ridx}, {col_name}): {str(e)}")
|
|
1745
2131
|
raise e
|
|
1746
2132
|
|
|
1747
|
-
def do_add_column(self, col_name: str = None
|
|
2133
|
+
def do_add_column(self, col_name: str = None) -> None:
|
|
1748
2134
|
"""Add acolumn after the current column."""
|
|
1749
2135
|
cidx = self.cursor_col_idx
|
|
1750
2136
|
|
|
1751
2137
|
if not col_name:
|
|
1752
2138
|
# Generate a unique column name
|
|
1753
2139
|
base_name = "new_col"
|
|
1754
|
-
|
|
2140
|
+
new_col_name = base_name
|
|
1755
2141
|
counter = 1
|
|
1756
|
-
while
|
|
1757
|
-
|
|
2142
|
+
while new_col_name in self.df.columns:
|
|
2143
|
+
new_col_name = f"{base_name}_{counter}"
|
|
1758
2144
|
counter += 1
|
|
1759
2145
|
else:
|
|
1760
|
-
|
|
2146
|
+
new_col_name = col_name
|
|
1761
2147
|
|
|
1762
2148
|
# Add to history
|
|
1763
|
-
self.add_history(f"Added column [$success]{
|
|
2149
|
+
self.add_history(f"Added column [$success]{new_col_name}[/] after column [$accent]{cidx + 1}[/]", dirty=True)
|
|
1764
2150
|
|
|
1765
2151
|
try:
|
|
1766
2152
|
# 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)
|
|
2153
|
+
new_col_name = pl.lit(None).alias(new_col_name)
|
|
1771
2154
|
|
|
1772
2155
|
# Get columns up to current, the new column, then remaining columns
|
|
1773
2156
|
cols = self.df.columns
|
|
@@ -1775,8 +2158,12 @@ class DataFrameTable(DataTable):
|
|
|
1775
2158
|
cols_after = cols[cidx + 1 :]
|
|
1776
2159
|
|
|
1777
2160
|
# Build the new dataframe with columns reordered
|
|
1778
|
-
select_cols = cols_before + [
|
|
1779
|
-
self.df = self.df.with_columns(
|
|
2161
|
+
select_cols = cols_before + [new_col_name] + cols_after
|
|
2162
|
+
self.df = self.df.lazy().with_columns(new_col_name).select(select_cols).collect()
|
|
2163
|
+
|
|
2164
|
+
# Also update the view if applicable
|
|
2165
|
+
if self.df_view is not None:
|
|
2166
|
+
self.df_view = self.df_view.lazy().with_columns(new_col_name).select(select_cols).collect()
|
|
1780
2167
|
|
|
1781
2168
|
# Recreate table for display
|
|
1782
2169
|
self.setup_table()
|
|
@@ -1786,8 +2173,10 @@ class DataFrameTable(DataTable):
|
|
|
1786
2173
|
|
|
1787
2174
|
# self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
1788
2175
|
except Exception as e:
|
|
1789
|
-
self.notify(
|
|
1790
|
-
|
|
2176
|
+
self.notify(
|
|
2177
|
+
f"Error adding column [$error]{new_col_name}[/]", title="Add Column", severity="error", timeout=10
|
|
2178
|
+
)
|
|
2179
|
+
self.log(f"Error adding column `{new_col_name}`: {str(e)}")
|
|
1791
2180
|
raise e
|
|
1792
2181
|
|
|
1793
2182
|
def do_add_column_expr(self) -> None:
|
|
@@ -1806,7 +2195,7 @@ class DataFrameTable(DataTable):
|
|
|
1806
2195
|
cidx, new_col_name, expr = result
|
|
1807
2196
|
|
|
1808
2197
|
# Add to history
|
|
1809
|
-
self.add_history(f"Added column [$success]{new_col_name}[/] with expression {expr}.", dirty=True)
|
|
2198
|
+
self.add_history(f"Added column [$success]{new_col_name}[/] with expression [$accent]{expr}[/].", dirty=True)
|
|
1810
2199
|
|
|
1811
2200
|
try:
|
|
1812
2201
|
# Create the column
|
|
@@ -1819,7 +2208,14 @@ class DataFrameTable(DataTable):
|
|
|
1819
2208
|
|
|
1820
2209
|
# Build the new dataframe with columns reordered
|
|
1821
2210
|
select_cols = cols_before + [new_col_name] + cols_after
|
|
1822
|
-
self.df = self.df.
|
|
2211
|
+
self.df = self.df.lazy().with_columns(new_col).select(select_cols).collect()
|
|
2212
|
+
|
|
2213
|
+
# Also update the view if applicable
|
|
2214
|
+
if self.df_view is not None:
|
|
2215
|
+
# Get updated column from df for rows that exist in df_view
|
|
2216
|
+
lf_updated = self.df.lazy().select(RID, pl.col(new_col_name))
|
|
2217
|
+
# Join and use coalesce to prefer updated value or keep original
|
|
2218
|
+
self.df_view = self.df_view.lazy().join(lf_updated, on=RID, how="left").select(select_cols).collect()
|
|
1823
2219
|
|
|
1824
2220
|
# Recreate table for display
|
|
1825
2221
|
self.setup_table()
|
|
@@ -1829,7 +2225,9 @@ class DataFrameTable(DataTable):
|
|
|
1829
2225
|
|
|
1830
2226
|
# self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
|
|
1831
2227
|
except Exception as e:
|
|
1832
|
-
self.notify(
|
|
2228
|
+
self.notify(
|
|
2229
|
+
f"Error adding column [$error]{new_col_name}[/]", title="Add Column", severity="error", timeout=10
|
|
2230
|
+
)
|
|
1833
2231
|
self.log(f"Error adding column `{new_col_name}`: {str(e)}")
|
|
1834
2232
|
|
|
1835
2233
|
def do_add_link_column(self) -> None:
|
|
@@ -1841,10 +2239,10 @@ class DataFrameTable(DataTable):
|
|
|
1841
2239
|
def add_link_column(self, result: tuple[str, str] | None) -> None:
|
|
1842
2240
|
"""Handle result from AddLinkScreen.
|
|
1843
2241
|
|
|
1844
|
-
Creates a new link column in the dataframe
|
|
1845
|
-
|
|
2242
|
+
Creates a new link column in the dataframe based on a user-provided template.
|
|
2243
|
+
Supports multiple placeholder types:
|
|
1846
2244
|
- `$_` - Current column (based on cursor position)
|
|
1847
|
-
- `$1`, `$2`, etc. - Column by 1-based
|
|
2245
|
+
- `$1`, `$2`, etc. - Column by index (1-based)
|
|
1848
2246
|
- `$name` - Column by name (e.g., `$id`, `$product_name`)
|
|
1849
2247
|
|
|
1850
2248
|
The template is evaluated for each row using Polars expressions with vectorized
|
|
@@ -1858,7 +2256,7 @@ class DataFrameTable(DataTable):
|
|
|
1858
2256
|
cidx, new_col_name, link_template = result
|
|
1859
2257
|
|
|
1860
2258
|
self.add_history(
|
|
1861
|
-
f"Added link column [$
|
|
2259
|
+
f"Added link column [$success]{new_col_name}[/] with template [$accent]{link_template}[/].", dirty=True
|
|
1862
2260
|
)
|
|
1863
2261
|
|
|
1864
2262
|
try:
|
|
@@ -1883,7 +2281,14 @@ class DataFrameTable(DataTable):
|
|
|
1883
2281
|
|
|
1884
2282
|
# Build the new dataframe with columns reordered
|
|
1885
2283
|
select_cols = cols_before + [new_col_name] + cols_after
|
|
1886
|
-
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
2284
|
+
self.df = self.df.lazy().with_columns(new_col).select(select_cols).collect()
|
|
2285
|
+
|
|
2286
|
+
# Also update the view if applicable
|
|
2287
|
+
if self.df_view is not None:
|
|
2288
|
+
# Get updated column from df for rows that exist in df_view
|
|
2289
|
+
lf_updated = self.df.lazy().select(RID, pl.col(new_col_name))
|
|
2290
|
+
# Join and use coalesce to prefer updated value or keep original
|
|
2291
|
+
self.df_view = self.df_view.lazy().join(lf_updated, on=RID, how="left").select(select_cols).collect()
|
|
1887
2292
|
|
|
1888
2293
|
# Recreate table for display
|
|
1889
2294
|
self.setup_table()
|
|
@@ -1894,14 +2299,21 @@ class DataFrameTable(DataTable):
|
|
|
1894
2299
|
self.notify(f"Added link column [$success]{new_col_name}[/]. Use Ctrl/Cmd click to open.", title="Add Link")
|
|
1895
2300
|
|
|
1896
2301
|
except Exception as e:
|
|
1897
|
-
self.notify(
|
|
2302
|
+
self.notify(
|
|
2303
|
+
f"Error adding link column [$error]{new_col_name}[/]", title="Add Link", severity="error", timeout=10
|
|
2304
|
+
)
|
|
1898
2305
|
self.log(f"Error adding link column: {str(e)}")
|
|
1899
2306
|
|
|
1900
2307
|
def do_delete_column(self, more: str = None) -> None:
|
|
1901
2308
|
"""Remove the currently selected column from the table."""
|
|
1902
2309
|
# Get the column to remove
|
|
1903
2310
|
col_idx = self.cursor_column
|
|
1904
|
-
|
|
2311
|
+
try:
|
|
2312
|
+
col_name = self.cursor_col_name
|
|
2313
|
+
except CellDoesNotExist:
|
|
2314
|
+
self.notify("No column to delete at the current cursor position", title="Delete Column", severity="warning")
|
|
2315
|
+
return
|
|
2316
|
+
|
|
1905
2317
|
col_key = self.cursor_col_key
|
|
1906
2318
|
|
|
1907
2319
|
col_names_to_remove = []
|
|
@@ -1910,7 +2322,7 @@ class DataFrameTable(DataTable):
|
|
|
1910
2322
|
# Remove all columns before the current column
|
|
1911
2323
|
if more == "before":
|
|
1912
2324
|
for i in range(col_idx + 1):
|
|
1913
|
-
col_key = self.
|
|
2325
|
+
col_key = self.get_col_key(i)
|
|
1914
2326
|
col_names_to_remove.append(col_key.value)
|
|
1915
2327
|
col_keys_to_remove.append(col_key)
|
|
1916
2328
|
|
|
@@ -1919,7 +2331,7 @@ class DataFrameTable(DataTable):
|
|
|
1919
2331
|
# Remove all columns after the current column
|
|
1920
2332
|
elif more == "after":
|
|
1921
2333
|
for i in range(col_idx, len(self.columns)):
|
|
1922
|
-
col_key = self.
|
|
2334
|
+
col_key = self.get_col_key(i)
|
|
1923
2335
|
col_names_to_remove.append(col_key.value)
|
|
1924
2336
|
col_keys_to_remove.append(col_key)
|
|
1925
2337
|
|
|
@@ -1948,18 +2360,25 @@ class DataFrameTable(DataTable):
|
|
|
1948
2360
|
if col_name in self.sorted_columns:
|
|
1949
2361
|
del self.sorted_columns[col_name]
|
|
1950
2362
|
|
|
2363
|
+
# Remove from hidden columns if present
|
|
2364
|
+
for col_name in col_names_to_remove:
|
|
2365
|
+
self.hidden_columns.discard(col_name)
|
|
2366
|
+
|
|
1951
2367
|
# Remove from matches
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
self.matches[row_idx].difference_update(col_indices_to_remove)
|
|
2368
|
+
for rid in list(self.matches.keys()):
|
|
2369
|
+
self.matches[rid].difference_update(col_names_to_remove)
|
|
1955
2370
|
# Remove empty entries
|
|
1956
|
-
if not self.matches[
|
|
1957
|
-
del self.matches[
|
|
2371
|
+
if not self.matches[rid]:
|
|
2372
|
+
del self.matches[rid]
|
|
1958
2373
|
|
|
1959
2374
|
# Remove from dataframe
|
|
1960
2375
|
self.df = self.df.drop(col_names_to_remove)
|
|
1961
2376
|
|
|
1962
|
-
|
|
2377
|
+
# Also update the view if applicable
|
|
2378
|
+
if self.df_view is not None:
|
|
2379
|
+
self.df_view = self.df_view.drop(col_names_to_remove)
|
|
2380
|
+
|
|
2381
|
+
self.notify(message, title="Delete Column")
|
|
1963
2382
|
|
|
1964
2383
|
def do_duplicate_column(self) -> None:
|
|
1965
2384
|
"""Duplicate the currently selected column, inserting it right after the current column."""
|
|
@@ -1969,29 +2388,28 @@ class DataFrameTable(DataTable):
|
|
|
1969
2388
|
col_idx = self.cursor_column
|
|
1970
2389
|
new_col_name = f"{col_name}_copy"
|
|
1971
2390
|
|
|
2391
|
+
# Ensure new column name is unique
|
|
2392
|
+
counter = 1
|
|
2393
|
+
while new_col_name in self.df.columns:
|
|
2394
|
+
new_col_name = f"{new_col_name}{counter}"
|
|
2395
|
+
counter += 1
|
|
2396
|
+
|
|
1972
2397
|
# Add to history
|
|
1973
2398
|
self.add_history(f"Duplicated column [$success]{col_name}[/]", dirty=True)
|
|
1974
2399
|
|
|
1975
2400
|
# Create new column and reorder columns to insert after current column
|
|
1976
2401
|
cols_before = self.df.columns[: cidx + 1]
|
|
1977
2402
|
cols_after = self.df.columns[cidx + 1 :]
|
|
2403
|
+
cols_new = cols_before + [new_col_name] + cols_after
|
|
1978
2404
|
|
|
1979
2405
|
# 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
|
-
)
|
|
2406
|
+
self.df = self.df.lazy().with_columns(pl.col(col_name).alias(new_col_name)).select(cols_new).collect()
|
|
1983
2407
|
|
|
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
|
|
2408
|
+
# Also update the view if applicable
|
|
2409
|
+
if self.df_view is not None:
|
|
2410
|
+
self.df_view = (
|
|
2411
|
+
self.df_view.lazy().with_columns(pl.col(col_name).alias(new_col_name)).select(cols_new).collect()
|
|
2412
|
+
)
|
|
1995
2413
|
|
|
1996
2414
|
# Recreate table for display
|
|
1997
2415
|
self.setup_table()
|
|
@@ -1999,7 +2417,7 @@ class DataFrameTable(DataTable):
|
|
|
1999
2417
|
# Move cursor to the new duplicated column
|
|
2000
2418
|
self.move_cursor(column=col_idx + 1)
|
|
2001
2419
|
|
|
2002
|
-
# self.notify(f"Duplicated column [$
|
|
2420
|
+
# self.notify(f"Duplicated column [$success]{col_name}[/] as [$accent]{new_col_name}[/]", title="Duplicate")
|
|
2003
2421
|
|
|
2004
2422
|
def do_delete_row(self, more: str = None) -> None:
|
|
2005
2423
|
"""Delete rows from the table and dataframe.
|
|
@@ -2007,97 +2425,95 @@ class DataFrameTable(DataTable):
|
|
|
2007
2425
|
Supports deleting multiple selected rows. If no rows are selected, deletes the row at the cursor.
|
|
2008
2426
|
"""
|
|
2009
2427
|
old_count = len(self.df)
|
|
2010
|
-
|
|
2428
|
+
rids_to_delete = set()
|
|
2011
2429
|
|
|
2012
2430
|
# Delete all selected rows
|
|
2013
|
-
if selected_count := self.selected_rows
|
|
2431
|
+
if selected_count := len(self.selected_rows):
|
|
2014
2432
|
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
|
|
2433
|
+
rids_to_delete.update(self.selected_rows)
|
|
2019
2434
|
|
|
2020
2435
|
# Delete current row and those above
|
|
2021
2436
|
elif more == "above":
|
|
2022
2437
|
ridx = self.cursor_row_idx
|
|
2023
2438
|
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those above"
|
|
2024
|
-
for
|
|
2025
|
-
|
|
2439
|
+
for rid in self.df[RID][: ridx + 1]:
|
|
2440
|
+
rids_to_delete.add(rid)
|
|
2026
2441
|
|
|
2027
2442
|
# Delete current row and those below
|
|
2028
2443
|
elif more == "below":
|
|
2029
2444
|
ridx = self.cursor_row_idx
|
|
2030
2445
|
history_desc = f"Deleted current row [$success]{ridx + 1}[/] and those below"
|
|
2031
|
-
for
|
|
2032
|
-
|
|
2033
|
-
predicates[i] = False
|
|
2446
|
+
for rid in self.df[RID][ridx:]:
|
|
2447
|
+
rids_to_delete.add(rid)
|
|
2034
2448
|
|
|
2035
2449
|
# Delete the row at the cursor
|
|
2036
2450
|
else:
|
|
2037
2451
|
ridx = self.cursor_row_idx
|
|
2038
2452
|
history_desc = f"Deleted row [$success]{ridx + 1}[/]"
|
|
2039
|
-
|
|
2040
|
-
predicates[ridx] = False
|
|
2453
|
+
rids_to_delete.add(self.df[RID][ridx])
|
|
2041
2454
|
|
|
2042
2455
|
# Add to history
|
|
2043
2456
|
self.add_history(history_desc, dirty=True)
|
|
2044
2457
|
|
|
2045
2458
|
# Apply the filter to remove rows
|
|
2046
2459
|
try:
|
|
2047
|
-
|
|
2460
|
+
df_filtered = self.df.lazy().filter(~pl.col(RID).is_in(rids_to_delete)).collect()
|
|
2048
2461
|
except Exception as e:
|
|
2049
|
-
self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error")
|
|
2050
|
-
self.
|
|
2462
|
+
self.notify(f"Error deleting row(s): {e}", title="Delete", severity="error", timeout=10)
|
|
2463
|
+
self.histories_undo.pop() # Remove last history entry
|
|
2051
2464
|
return
|
|
2052
2465
|
|
|
2053
|
-
|
|
2466
|
+
# RIDs of remaining rows
|
|
2467
|
+
ok_rids = set(df_filtered[RID])
|
|
2054
2468
|
|
|
2055
|
-
# Update selected
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
self.visible_rows = [visible for i, visible in enumerate(self.visible_rows) if i in old_row_indices]
|
|
2469
|
+
# Update selected rows tracking
|
|
2470
|
+
if self.selected_rows:
|
|
2471
|
+
self.selected_rows.intersection_update(ok_rids)
|
|
2059
2472
|
|
|
2060
|
-
#
|
|
2061
|
-
self.
|
|
2473
|
+
# Update the dataframe
|
|
2474
|
+
self.df = df_filtered
|
|
2475
|
+
|
|
2476
|
+
# Update matches since row indices have changed
|
|
2477
|
+
if self.matches:
|
|
2478
|
+
self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
|
|
2479
|
+
|
|
2480
|
+
# Also update the view if applicable
|
|
2481
|
+
if self.df_view is not None:
|
|
2482
|
+
self.df_view = self.df_view.lazy().filter(~pl.col(RID).is_in(rids_to_delete)).collect()
|
|
2062
2483
|
|
|
2063
2484
|
# Recreate table for display
|
|
2064
2485
|
self.setup_table()
|
|
2065
2486
|
|
|
2066
2487
|
deleted_count = old_count - len(self.df)
|
|
2067
2488
|
if deleted_count > 0:
|
|
2068
|
-
self.notify(f"Deleted [$
|
|
2489
|
+
self.notify(f"Deleted [$success]{deleted_count}[/] row(s)", title="Delete")
|
|
2069
2490
|
|
|
2070
2491
|
def do_duplicate_row(self) -> None:
|
|
2071
2492
|
"""Duplicate the currently selected row, inserting it right after the current row."""
|
|
2072
2493
|
ridx = self.cursor_row_idx
|
|
2494
|
+
rid = self.df[RID][ridx]
|
|
2495
|
+
|
|
2496
|
+
lf = self.df.lazy()
|
|
2073
2497
|
|
|
2074
2498
|
# Get the row to duplicate
|
|
2075
|
-
row_to_duplicate =
|
|
2499
|
+
row_to_duplicate = lf.slice(ridx, 1).with_columns(pl.col(RID) + 1)
|
|
2076
2500
|
|
|
2077
2501
|
# Add to history
|
|
2078
2502
|
self.add_history(f"Duplicated row [$success]{ridx + 1}[/]", dirty=True)
|
|
2079
2503
|
|
|
2080
2504
|
# Concatenate: rows before + duplicated row + rows after
|
|
2081
|
-
|
|
2082
|
-
|
|
2505
|
+
lf_before = lf.slice(0, ridx + 1)
|
|
2506
|
+
lf_after = lf.slice(ridx + 1).with_columns(pl.col(RID) + 1)
|
|
2083
2507
|
|
|
2084
2508
|
# 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
|
|
2509
|
+
self.df = pl.concat([lf_before, row_to_duplicate, lf_after]).collect()
|
|
2510
|
+
|
|
2511
|
+
# Also update the view if applicable
|
|
2512
|
+
if self.df_view is not None:
|
|
2513
|
+
lf_view = self.df_view.lazy()
|
|
2514
|
+
lf_view_before = lf_view.slice(0, rid + 1)
|
|
2515
|
+
lf_view_after = lf_view.slice(rid + 1).with_columns(pl.col(RID) + 1)
|
|
2516
|
+
self.df_view = pl.concat([lf_view_before, row_to_duplicate, lf_view_after]).collect()
|
|
2101
2517
|
|
|
2102
2518
|
# Recreate table for display
|
|
2103
2519
|
self.setup_table()
|
|
@@ -2137,7 +2553,8 @@ class DataFrameTable(DataTable):
|
|
|
2137
2553
|
|
|
2138
2554
|
# Add to history
|
|
2139
2555
|
self.add_history(
|
|
2140
|
-
f"Moved column [$success]{col_name}[/] {direction} (swapped with [$success]{swap_name}[/])",
|
|
2556
|
+
f"Moved column [$success]{col_name}[/] [$accent]{direction}[/] (swapped with [$success]{swap_name}[/])",
|
|
2557
|
+
dirty=True,
|
|
2141
2558
|
)
|
|
2142
2559
|
|
|
2143
2560
|
# Swap columns in the table's internal column locations
|
|
@@ -2162,6 +2579,10 @@ class DataFrameTable(DataTable):
|
|
|
2162
2579
|
cols[cidx], cols[swap_cidx] = cols[swap_cidx], cols[cidx]
|
|
2163
2580
|
self.df = self.df.select(cols)
|
|
2164
2581
|
|
|
2582
|
+
# Also update the view if applicable
|
|
2583
|
+
if self.df_view is not None:
|
|
2584
|
+
self.df_view = self.df_view.select(cols)
|
|
2585
|
+
|
|
2165
2586
|
# self.notify(f"Moved column [$success]{col_name}[/] {direction}", title="Move")
|
|
2166
2587
|
|
|
2167
2588
|
def do_move_row(self, direction: str) -> None:
|
|
@@ -2170,65 +2591,88 @@ class DataFrameTable(DataTable):
|
|
|
2170
2591
|
Args:
|
|
2171
2592
|
direction: "up" to move up, "down" to move down.
|
|
2172
2593
|
"""
|
|
2173
|
-
|
|
2594
|
+
curr_row_idx, col_idx = self.cursor_coordinate
|
|
2174
2595
|
|
|
2175
2596
|
# Validate move is possible
|
|
2176
2597
|
if direction == "up":
|
|
2177
|
-
if
|
|
2598
|
+
if curr_row_idx <= 0:
|
|
2178
2599
|
self.notify("Cannot move row up", title="Move", severity="warning")
|
|
2179
2600
|
return
|
|
2180
|
-
|
|
2601
|
+
swap_row_idx = curr_row_idx - 1
|
|
2181
2602
|
elif direction == "down":
|
|
2182
|
-
if
|
|
2603
|
+
if curr_row_idx >= len(self.rows) - 1:
|
|
2183
2604
|
self.notify("Cannot move row down", title="Move", severity="warning")
|
|
2184
2605
|
return
|
|
2185
|
-
|
|
2606
|
+
swap_row_idx = curr_row_idx + 1
|
|
2186
2607
|
else:
|
|
2187
2608
|
# Invalid direction
|
|
2188
2609
|
return
|
|
2189
2610
|
|
|
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
2611
|
# Add to history
|
|
2194
2612
|
self.add_history(
|
|
2195
|
-
f"Moved row [$success]{
|
|
2613
|
+
f"Moved row [$success]{curr_row_idx}[/] [$accent]{direction}[/] (swapped with row [$success]{swap_row_idx}[/])",
|
|
2196
2614
|
dirty=True,
|
|
2197
2615
|
)
|
|
2198
2616
|
|
|
2199
2617
|
# Swap rows in the table's internal row locations
|
|
2618
|
+
curr_key = self.coordinate_to_cell_key((curr_row_idx, 0)).row_key
|
|
2619
|
+
swap_key = self.coordinate_to_cell_key((swap_row_idx, 0)).row_key
|
|
2620
|
+
|
|
2200
2621
|
self.check_idle()
|
|
2201
2622
|
|
|
2202
2623
|
(
|
|
2203
|
-
self._row_locations[
|
|
2624
|
+
self._row_locations[curr_key],
|
|
2204
2625
|
self._row_locations[swap_key],
|
|
2205
2626
|
) = (
|
|
2206
|
-
self.
|
|
2207
|
-
self.
|
|
2627
|
+
self.get_row_idx(swap_key),
|
|
2628
|
+
self.get_row_idx(curr_key),
|
|
2208
2629
|
)
|
|
2209
2630
|
|
|
2210
2631
|
self._update_count += 1
|
|
2211
2632
|
self.refresh()
|
|
2212
2633
|
|
|
2213
2634
|
# Restore cursor position on the moved row
|
|
2214
|
-
self.move_cursor(row=
|
|
2635
|
+
self.move_cursor(row=swap_row_idx, column=col_idx)
|
|
2215
2636
|
|
|
2216
|
-
#
|
|
2217
|
-
|
|
2218
|
-
swap_ridx =
|
|
2219
|
-
first, second = sorted([
|
|
2637
|
+
# Locate the rows to swap
|
|
2638
|
+
curr_ridx = curr_row_idx
|
|
2639
|
+
swap_ridx = swap_row_idx
|
|
2640
|
+
first, second = sorted([curr_ridx, swap_ridx])
|
|
2220
2641
|
|
|
2642
|
+
# Swap the rows in the dataframe
|
|
2221
2643
|
self.df = pl.concat(
|
|
2222
2644
|
[
|
|
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),
|
|
2645
|
+
self.df.slice(0, first).lazy(),
|
|
2646
|
+
self.df.slice(second, 1).lazy(),
|
|
2647
|
+
self.df.slice(first + 1, second - first - 1).lazy(),
|
|
2648
|
+
self.df.slice(first, 1).lazy(),
|
|
2649
|
+
self.df.slice(second + 1).lazy(),
|
|
2228
2650
|
]
|
|
2229
|
-
)
|
|
2651
|
+
).collect()
|
|
2652
|
+
|
|
2653
|
+
# Also update the view if applicable
|
|
2654
|
+
if self.df_view is not None:
|
|
2655
|
+
# Find RID values
|
|
2656
|
+
curr_rid = self.df[RID][curr_row_idx]
|
|
2657
|
+
swap_rid = self.df[RID][swap_row_idx]
|
|
2230
2658
|
|
|
2231
|
-
|
|
2659
|
+
# Locate the rows by RID in the view
|
|
2660
|
+
curr_ridx = self.df_view[RID].index_of(curr_rid)
|
|
2661
|
+
swap_ridx = self.df_view[RID].index_of(swap_rid)
|
|
2662
|
+
first, second = sorted([curr_ridx, swap_ridx])
|
|
2663
|
+
|
|
2664
|
+
# Swap the rows in the view
|
|
2665
|
+
self.df_view = pl.concat(
|
|
2666
|
+
[
|
|
2667
|
+
self.df_view.slice(0, first).lazy(),
|
|
2668
|
+
self.df_view.slice(second, 1).lazy(),
|
|
2669
|
+
self.df_view.slice(first + 1, second - first - 1).lazy(),
|
|
2670
|
+
self.df_view.slice(first, 1).lazy(),
|
|
2671
|
+
self.df_view.slice(second + 1).lazy(),
|
|
2672
|
+
]
|
|
2673
|
+
).collect()
|
|
2674
|
+
|
|
2675
|
+
# self.notify(f"Moved row [$success]{row_key.value}[/] {direction}", title="Move Row")
|
|
2232
2676
|
|
|
2233
2677
|
# Type casting
|
|
2234
2678
|
def do_cast_column_dtype(self, dtype: str) -> None:
|
|
@@ -2244,12 +2688,12 @@ class DataFrameTable(DataTable):
|
|
|
2244
2688
|
try:
|
|
2245
2689
|
target_dtype = eval(dtype)
|
|
2246
2690
|
except Exception:
|
|
2247
|
-
self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error")
|
|
2691
|
+
self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error", timeout=10)
|
|
2248
2692
|
return
|
|
2249
2693
|
|
|
2250
2694
|
if current_dtype == target_dtype:
|
|
2251
2695
|
self.notify(
|
|
2252
|
-
f"Column [$
|
|
2696
|
+
f"Column [$warning]{col_name}[/] is already of type [$accent]{target_dtype}[/]",
|
|
2253
2697
|
title="Cast",
|
|
2254
2698
|
severity="warning",
|
|
2255
2699
|
)
|
|
@@ -2257,7 +2701,7 @@ class DataFrameTable(DataTable):
|
|
|
2257
2701
|
|
|
2258
2702
|
# Add to history
|
|
2259
2703
|
self.add_history(
|
|
2260
|
-
f"Cast column [$
|
|
2704
|
+
f"Cast column [$success]{col_name}[/] from [$accent]{current_dtype}[/] to [$success]{target_dtype}[/]",
|
|
2261
2705
|
dirty=True,
|
|
2262
2706
|
)
|
|
2263
2707
|
|
|
@@ -2265,30 +2709,49 @@ class DataFrameTable(DataTable):
|
|
|
2265
2709
|
# Cast the column using Polars
|
|
2266
2710
|
self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
|
|
2267
2711
|
|
|
2712
|
+
# Also update the view if applicable
|
|
2713
|
+
if self.df_view is not None:
|
|
2714
|
+
self.df_view = self.df_view.with_columns(pl.col(col_name).cast(target_dtype))
|
|
2715
|
+
|
|
2268
2716
|
# Recreate table for display
|
|
2269
2717
|
self.setup_table()
|
|
2270
2718
|
|
|
2271
|
-
self.notify(f"Cast column [$
|
|
2719
|
+
self.notify(f"Cast column [$success]{col_name}[/] to [$accent]{target_dtype}[/]", title="Cast")
|
|
2272
2720
|
except Exception as e:
|
|
2273
2721
|
self.notify(
|
|
2274
|
-
f"Error casting column [$
|
|
2722
|
+
f"Error casting column [$error]{col_name}[/] to [$accent]{target_dtype}[/]",
|
|
2275
2723
|
title="Cast",
|
|
2276
2724
|
severity="error",
|
|
2725
|
+
timeout=10,
|
|
2277
2726
|
)
|
|
2278
2727
|
self.log(f"Error casting column `{col_name}`: {str(e)}")
|
|
2279
2728
|
|
|
2280
|
-
#
|
|
2281
|
-
def
|
|
2282
|
-
"""
|
|
2729
|
+
# Row selection
|
|
2730
|
+
def do_select_row(self) -> None:
|
|
2731
|
+
"""Select rows.
|
|
2732
|
+
|
|
2733
|
+
If there are existing cell matches, use those to select rows.
|
|
2734
|
+
Otherwise, use the current cell value as the search term and select rows matching that value.
|
|
2735
|
+
"""
|
|
2283
2736
|
cidx = self.cursor_col_idx
|
|
2284
2737
|
|
|
2285
|
-
#
|
|
2286
|
-
|
|
2738
|
+
# Use existing cell matches if present
|
|
2739
|
+
if self.matches:
|
|
2740
|
+
term = pl.col(RID).is_in(self.matches)
|
|
2741
|
+
else:
|
|
2742
|
+
col_name = self.cursor_col_name
|
|
2287
2743
|
|
|
2288
|
-
|
|
2744
|
+
# Get the value of the currently selected cell
|
|
2745
|
+
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
2746
|
+
if self.cursor_value is None:
|
|
2747
|
+
term = pl.col(col_name).is_null()
|
|
2748
|
+
else:
|
|
2749
|
+
term = pl.col(col_name) == self.cursor_value
|
|
2750
|
+
|
|
2751
|
+
self.select_row((term, cidx, False, True))
|
|
2289
2752
|
|
|
2290
|
-
def
|
|
2291
|
-
"""
|
|
2753
|
+
def do_select_row_expr(self) -> None:
|
|
2754
|
+
"""Select rows by expression."""
|
|
2292
2755
|
cidx = self.cursor_col_idx
|
|
2293
2756
|
|
|
2294
2757
|
# Use current cell value as default search term
|
|
@@ -2296,27 +2759,38 @@ class DataFrameTable(DataTable):
|
|
|
2296
2759
|
|
|
2297
2760
|
# Push the search modal screen
|
|
2298
2761
|
self.app.push_screen(
|
|
2299
|
-
SearchScreen("
|
|
2300
|
-
callback=self.
|
|
2762
|
+
SearchScreen("Select", term, self.df, cidx),
|
|
2763
|
+
callback=self.select_row,
|
|
2301
2764
|
)
|
|
2302
2765
|
|
|
2303
|
-
def
|
|
2304
|
-
"""
|
|
2766
|
+
def select_row(self, result) -> None:
|
|
2767
|
+
"""Select rows by value or expression."""
|
|
2305
2768
|
if result is None:
|
|
2306
2769
|
return
|
|
2307
2770
|
|
|
2308
2771
|
term, cidx, match_nocase, match_whole = result
|
|
2309
|
-
col_name = self.df.columns[cidx]
|
|
2772
|
+
col_name = "all columns" if cidx is None else self.df.columns[cidx]
|
|
2310
2773
|
|
|
2311
|
-
|
|
2774
|
+
# Already a Polars expression
|
|
2775
|
+
if isinstance(term, pl.Expr):
|
|
2776
|
+
expr = term
|
|
2777
|
+
|
|
2778
|
+
# bool list or Series
|
|
2779
|
+
elif isinstance(term, (list, pl.Series)):
|
|
2780
|
+
expr = term
|
|
2781
|
+
|
|
2782
|
+
# Null case
|
|
2783
|
+
elif term == NULL:
|
|
2312
2784
|
expr = pl.col(col_name).is_null()
|
|
2313
2785
|
|
|
2314
|
-
#
|
|
2786
|
+
# Expression in string form
|
|
2315
2787
|
elif tentative_expr(term):
|
|
2316
2788
|
try:
|
|
2317
2789
|
expr = validate_expr(term, self.df.columns, cidx)
|
|
2318
2790
|
except Exception as e:
|
|
2319
|
-
self.notify(
|
|
2791
|
+
self.notify(
|
|
2792
|
+
f"Error validating expression [$error]{term}[/]", title="Search", severity="error", timeout=10
|
|
2793
|
+
)
|
|
2320
2794
|
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
2321
2795
|
return
|
|
2322
2796
|
|
|
@@ -2340,55 +2814,127 @@ class DataFrameTable(DataTable):
|
|
|
2340
2814
|
term = f"(?i){term}"
|
|
2341
2815
|
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
2342
2816
|
self.notify(
|
|
2343
|
-
f"Error converting [$
|
|
2817
|
+
f"Error converting [$error]{term}[/] to [$accent]{dtype}[/]. Cast to string.",
|
|
2344
2818
|
title="Search",
|
|
2345
2819
|
severity="warning",
|
|
2346
2820
|
)
|
|
2347
2821
|
|
|
2348
2822
|
# Lazyframe for filtering
|
|
2349
|
-
lf = self.df.lazy()
|
|
2350
|
-
if False in self.visible_rows:
|
|
2351
|
-
lf = lf.filter(self.visible_rows)
|
|
2823
|
+
lf = self.df.lazy()
|
|
2352
2824
|
|
|
2353
2825
|
# Apply filter to get matched row indices
|
|
2354
2826
|
try:
|
|
2355
|
-
|
|
2827
|
+
ok_rids = set(lf.filter(expr).collect()[RID])
|
|
2356
2828
|
except Exception as e:
|
|
2357
|
-
self.notify(
|
|
2829
|
+
self.notify(
|
|
2830
|
+
f"Error applying search filter `[$error]{term}[/]`", title="Search", severity="error", timeout=10
|
|
2831
|
+
)
|
|
2358
2832
|
self.log(f"Error applying search filter `{term}`: {str(e)}")
|
|
2359
2833
|
return
|
|
2360
2834
|
|
|
2361
|
-
match_count = len(
|
|
2835
|
+
match_count = len(ok_rids)
|
|
2362
2836
|
if match_count == 0:
|
|
2363
2837
|
self.notify(
|
|
2364
|
-
f"No matches found for [$
|
|
2838
|
+
f"No matches found for `[$warning]{term}[/]`. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2365
2839
|
title="Search",
|
|
2366
2840
|
severity="warning",
|
|
2367
2841
|
)
|
|
2368
2842
|
return
|
|
2369
2843
|
|
|
2844
|
+
message = f"Found [$success]{match_count}[/] matching row(s)"
|
|
2845
|
+
|
|
2370
2846
|
# Add to history
|
|
2371
|
-
self.add_history(
|
|
2847
|
+
self.add_history(message)
|
|
2372
2848
|
|
|
2373
|
-
# Update selected rows to include new
|
|
2374
|
-
|
|
2375
|
-
self.selected_rows[m] = True
|
|
2849
|
+
# Update selected rows to include new selections
|
|
2850
|
+
self.selected_rows.update(ok_rids)
|
|
2376
2851
|
|
|
2377
2852
|
# Show notification immediately, then start highlighting
|
|
2378
|
-
self.notify(
|
|
2853
|
+
self.notify(message, title="Select Row")
|
|
2379
2854
|
|
|
2380
2855
|
# Recreate table for display
|
|
2381
2856
|
self.setup_table()
|
|
2382
2857
|
|
|
2383
|
-
|
|
2858
|
+
def do_toggle_selections(self) -> None:
|
|
2859
|
+
"""Toggle selected rows highlighting on/off."""
|
|
2860
|
+
# Add to history
|
|
2861
|
+
self.add_history("Toggled row selection")
|
|
2862
|
+
|
|
2863
|
+
# Invert all selected rows
|
|
2864
|
+
self.selected_rows = {rid for rid in self.df[RID] if rid not in self.selected_rows}
|
|
2865
|
+
|
|
2866
|
+
# Check if we're highlighting or un-highlighting
|
|
2867
|
+
if selected_count := len(self.selected_rows):
|
|
2868
|
+
self.notify(f"Toggled selection for [$success]{selected_count}[/] rows", title="Toggle")
|
|
2869
|
+
|
|
2870
|
+
# Recreate table for display
|
|
2871
|
+
self.setup_table()
|
|
2872
|
+
|
|
2873
|
+
def do_toggle_row_selection(self) -> None:
|
|
2874
|
+
"""Select/deselect current row."""
|
|
2875
|
+
# Add to history
|
|
2876
|
+
self.add_history("Toggled row selection")
|
|
2877
|
+
|
|
2878
|
+
# Get current row RID
|
|
2879
|
+
ridx = self.cursor_row_idx
|
|
2880
|
+
rid = self.df[RID][ridx]
|
|
2881
|
+
|
|
2882
|
+
if rid in self.selected_rows:
|
|
2883
|
+
self.selected_rows.discard(rid)
|
|
2884
|
+
else:
|
|
2885
|
+
self.selected_rows.add(rid)
|
|
2886
|
+
|
|
2887
|
+
row_key = self.cursor_row_key
|
|
2888
|
+
is_selected = rid in self.selected_rows
|
|
2889
|
+
match_cols = self.matches.get(rid, set())
|
|
2890
|
+
|
|
2891
|
+
for col_idx, col in enumerate(self.ordered_columns):
|
|
2892
|
+
col_key = col.key
|
|
2893
|
+
col_name = col_key.value
|
|
2894
|
+
cell_text: Text = self.get_cell(row_key, col_key)
|
|
2895
|
+
|
|
2896
|
+
if is_selected or (col_name in match_cols):
|
|
2897
|
+
cell_text.style = HIGHLIGHT_COLOR
|
|
2898
|
+
else:
|
|
2899
|
+
# Reset to default style based on dtype
|
|
2900
|
+
dtype = self.df.dtypes[col_idx]
|
|
2901
|
+
dc = DtypeConfig(dtype)
|
|
2902
|
+
cell_text.style = dc.style
|
|
2903
|
+
|
|
2904
|
+
self.update_cell(row_key, col_key, cell_text)
|
|
2905
|
+
|
|
2906
|
+
def do_clear_selections_and_matches(self) -> None:
|
|
2907
|
+
"""Clear all selected rows and matches without removing them from the dataframe."""
|
|
2908
|
+
# Check if any selected rows or matches
|
|
2909
|
+
if not self.selected_rows and not self.matches:
|
|
2910
|
+
self.notify("No selections to clear", title="Clear", severity="warning")
|
|
2911
|
+
return
|
|
2912
|
+
|
|
2913
|
+
row_count = len(self.selected_rows | set(self.matches.keys()))
|
|
2914
|
+
|
|
2915
|
+
# Add to history
|
|
2916
|
+
self.add_history("Cleared all selected rows")
|
|
2917
|
+
|
|
2918
|
+
# Clear all selections
|
|
2919
|
+
self.selected_rows = set()
|
|
2920
|
+
self.matches = defaultdict(set)
|
|
2921
|
+
|
|
2922
|
+
# Recreate table for display
|
|
2923
|
+
self.setup_table()
|
|
2924
|
+
|
|
2925
|
+
self.notify(f"Cleared selections for [$success]{row_count}[/] rows", title="Clear")
|
|
2926
|
+
|
|
2927
|
+
# Find & Replace
|
|
2384
2928
|
def find_matches(
|
|
2385
2929
|
self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
|
|
2386
|
-
) -> dict[int, set[
|
|
2930
|
+
) -> dict[int, set[str]]:
|
|
2387
2931
|
"""Find matches for a term in the dataframe.
|
|
2388
2932
|
|
|
2389
2933
|
Args:
|
|
2390
2934
|
term: The search term (can be NULL, expression, or plain text)
|
|
2391
2935
|
cidx: Column index for column-specific search. If None, searches all columns.
|
|
2936
|
+
match_nocase: Whether to perform case-insensitive matching (for string terms)
|
|
2937
|
+
match_whole: Whether to match the whole cell content (for string terms)
|
|
2392
2938
|
|
|
2393
2939
|
Returns:
|
|
2394
2940
|
Dictionary mapping row indices to sets of column indices containing matches.
|
|
@@ -2398,12 +2944,10 @@ class DataFrameTable(DataTable):
|
|
|
2398
2944
|
Raises:
|
|
2399
2945
|
Exception: If expression validation or filtering fails.
|
|
2400
2946
|
"""
|
|
2401
|
-
matches: dict[int, set[
|
|
2947
|
+
matches: dict[int, set[str]] = defaultdict(set)
|
|
2402
2948
|
|
|
2403
2949
|
# Lazyframe for filtering
|
|
2404
|
-
lf = self.df.lazy()
|
|
2405
|
-
if False in self.visible_rows:
|
|
2406
|
-
lf = lf.filter(self.visible_rows)
|
|
2950
|
+
lf = self.df.lazy()
|
|
2407
2951
|
|
|
2408
2952
|
# Determine which columns to search: single column or all columns
|
|
2409
2953
|
if cidx is not None:
|
|
@@ -2420,7 +2964,9 @@ class DataFrameTable(DataTable):
|
|
|
2420
2964
|
try:
|
|
2421
2965
|
expr = validate_expr(term, self.df.columns, col_idx)
|
|
2422
2966
|
except Exception as e:
|
|
2423
|
-
self.notify(
|
|
2967
|
+
self.notify(
|
|
2968
|
+
f"Error validating expression [$error]{term}[/]", title="Find", severity="error", timeout=10
|
|
2969
|
+
)
|
|
2424
2970
|
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
2425
2971
|
return matches
|
|
2426
2972
|
else:
|
|
@@ -2432,14 +2978,14 @@ class DataFrameTable(DataTable):
|
|
|
2432
2978
|
|
|
2433
2979
|
# Get matched row indices
|
|
2434
2980
|
try:
|
|
2435
|
-
matched_ridxs = lf.filter(expr).
|
|
2981
|
+
matched_ridxs = lf.filter(expr).collect()[RID]
|
|
2436
2982
|
except Exception as e:
|
|
2437
|
-
self.notify(f"Error applying filter: {expr}", title="Find", severity="error")
|
|
2983
|
+
self.notify(f"Error applying filter: [$error]{expr}[/]", title="Find", severity="error", timeout=10)
|
|
2438
2984
|
self.log(f"Error applying filter: {str(e)}")
|
|
2439
2985
|
return matches
|
|
2440
2986
|
|
|
2441
2987
|
for ridx in matched_ridxs:
|
|
2442
|
-
matches[ridx].add(
|
|
2988
|
+
matches[ridx].add(col_name)
|
|
2443
2989
|
|
|
2444
2990
|
return matches
|
|
2445
2991
|
|
|
@@ -2485,27 +3031,27 @@ class DataFrameTable(DataTable):
|
|
|
2485
3031
|
try:
|
|
2486
3032
|
matches = self.find_matches(term, cidx, match_nocase, match_whole)
|
|
2487
3033
|
except Exception as e:
|
|
2488
|
-
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
3034
|
+
self.notify(f"Error finding matches for `[$error]{term}[/]`", title="Find", severity="error", timeout=10)
|
|
2489
3035
|
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
2490
3036
|
return
|
|
2491
3037
|
|
|
2492
3038
|
if not matches:
|
|
2493
3039
|
self.notify(
|
|
2494
|
-
f"No matches found for [$
|
|
3040
|
+
f"No matches found for `[$warning]{term}[/]` in current column. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2495
3041
|
title="Find",
|
|
2496
3042
|
severity="warning",
|
|
2497
3043
|
)
|
|
2498
3044
|
return
|
|
2499
3045
|
|
|
2500
3046
|
# Add to history
|
|
2501
|
-
self.add_history(f"Found [$
|
|
3047
|
+
self.add_history(f"Found `[$success]{term}[/]` in column [$accent]{col_name}[/]")
|
|
2502
3048
|
|
|
2503
3049
|
# Add to matches and count total
|
|
2504
|
-
match_count = sum(len(
|
|
2505
|
-
for
|
|
2506
|
-
self.matches[
|
|
3050
|
+
match_count = sum(len(cols) for cols in matches.values())
|
|
3051
|
+
for rid, cols in matches.items():
|
|
3052
|
+
self.matches[rid].update(cols)
|
|
2507
3053
|
|
|
2508
|
-
self.notify(f"Found [$
|
|
3054
|
+
self.notify(f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]`", title="Find")
|
|
2509
3055
|
|
|
2510
3056
|
# Recreate table for display
|
|
2511
3057
|
self.setup_table()
|
|
@@ -2519,28 +3065,29 @@ class DataFrameTable(DataTable):
|
|
|
2519
3065
|
try:
|
|
2520
3066
|
matches = self.find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
|
|
2521
3067
|
except Exception as e:
|
|
2522
|
-
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
3068
|
+
self.notify(f"Error finding matches for `[$error]{term}[/]`", title="Find", severity="error", timeout=10)
|
|
2523
3069
|
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
2524
3070
|
return
|
|
2525
3071
|
|
|
2526
3072
|
if not matches:
|
|
2527
3073
|
self.notify(
|
|
2528
|
-
f"No matches found for [$
|
|
3074
|
+
f"No matches found for `[$warning]{term}[/]` in any column. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2529
3075
|
title="Global Find",
|
|
2530
3076
|
severity="warning",
|
|
2531
3077
|
)
|
|
2532
3078
|
return
|
|
2533
3079
|
|
|
2534
3080
|
# Add to history
|
|
2535
|
-
self.add_history(f"Found [$success]{term}[/] across all columns")
|
|
3081
|
+
self.add_history(f"Found `[$success]{term}[/]` across all columns")
|
|
2536
3082
|
|
|
2537
3083
|
# Add to matches and count total
|
|
2538
|
-
match_count = sum(len(
|
|
2539
|
-
for
|
|
2540
|
-
self.matches[
|
|
3084
|
+
match_count = sum(len(cols) for cols in matches.values())
|
|
3085
|
+
for rid, cols in matches.items():
|
|
3086
|
+
self.matches[rid].update(cols)
|
|
2541
3087
|
|
|
2542
3088
|
self.notify(
|
|
2543
|
-
f"Found [$
|
|
3089
|
+
f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]` across all columns",
|
|
3090
|
+
title="Global Find",
|
|
2544
3091
|
)
|
|
2545
3092
|
|
|
2546
3093
|
# Recreate table for display
|
|
@@ -2598,7 +3145,7 @@ class DataFrameTable(DataTable):
|
|
|
2598
3145
|
|
|
2599
3146
|
def do_next_selected_row(self) -> None:
|
|
2600
3147
|
"""Move cursor to the next selected row."""
|
|
2601
|
-
if not
|
|
3148
|
+
if not self.selected_rows:
|
|
2602
3149
|
self.notify("No selected rows to navigate", title="Next Selected Row", severity="warning")
|
|
2603
3150
|
return
|
|
2604
3151
|
|
|
@@ -2620,7 +3167,7 @@ class DataFrameTable(DataTable):
|
|
|
2620
3167
|
|
|
2621
3168
|
def do_previous_selected_row(self) -> None:
|
|
2622
3169
|
"""Move cursor to the previous selected row."""
|
|
2623
|
-
if not
|
|
3170
|
+
if not self.selected_rows:
|
|
2624
3171
|
self.notify("No selected rows to navigate", title="Previous Selected Row", severity="warning")
|
|
2625
3172
|
return
|
|
2626
3173
|
|
|
@@ -2640,7 +3187,6 @@ class DataFrameTable(DataTable):
|
|
|
2640
3187
|
last_ridx = selected_row_indices[-1]
|
|
2641
3188
|
self.move_cursor_to(last_ridx, self.cursor_col_idx)
|
|
2642
3189
|
|
|
2643
|
-
# Replace
|
|
2644
3190
|
def do_replace(self) -> None:
|
|
2645
3191
|
"""Open replace screen for current column."""
|
|
2646
3192
|
# Push the replace modal screen
|
|
@@ -2690,29 +3236,38 @@ class DataFrameTable(DataTable):
|
|
|
2690
3236
|
|
|
2691
3237
|
# Add to history
|
|
2692
3238
|
self.add_history(
|
|
2693
|
-
f"Replaced [$
|
|
3239
|
+
f"Replaced [$success]{term_find}[/] with [$accent]{term_replace}[/] in column [$success]{col_name}[/]"
|
|
2694
3240
|
)
|
|
2695
3241
|
|
|
2696
3242
|
# Update matches
|
|
2697
|
-
self.matches =
|
|
3243
|
+
self.matches = matches
|
|
2698
3244
|
|
|
2699
3245
|
# Recreate table for display
|
|
2700
3246
|
self.setup_table()
|
|
2701
3247
|
|
|
2702
3248
|
# Store state for interactive replacement using dataclass
|
|
2703
|
-
|
|
3249
|
+
rid2ridx = {rid: ridx for ridx, rid in enumerate(self.df[RID]) if rid in self.matches}
|
|
3250
|
+
|
|
3251
|
+
# Unique columns to replace
|
|
3252
|
+
cols_to_replace = set()
|
|
3253
|
+
for cols in self.matches.values():
|
|
3254
|
+
cols_to_replace.update(cols)
|
|
3255
|
+
|
|
3256
|
+
# Sorted column indices to replace
|
|
3257
|
+
cidx2col = {cidx: col for cidx, col in enumerate(self.df.columns) if col in cols_to_replace}
|
|
3258
|
+
|
|
2704
3259
|
self.replace_state = ReplaceState(
|
|
2705
3260
|
term_find=term_find,
|
|
2706
3261
|
term_replace=term_replace,
|
|
2707
3262
|
match_nocase=match_nocase,
|
|
2708
3263
|
match_whole=match_whole,
|
|
2709
3264
|
cidx=cidx,
|
|
2710
|
-
rows=
|
|
2711
|
-
cols_per_row=[
|
|
3265
|
+
rows=list(rid2ridx.values()),
|
|
3266
|
+
cols_per_row=[[cidx for cidx, col in cidx2col.items() if col in self.matches[rid]] for rid in rid2ridx],
|
|
2712
3267
|
current_rpos=0,
|
|
2713
3268
|
current_cpos=0,
|
|
2714
3269
|
current_occurrence=0,
|
|
2715
|
-
total_occurrence=sum(len(
|
|
3270
|
+
total_occurrence=sum(len(cols) for cols in self.matches.values()),
|
|
2716
3271
|
replaced_occurrence=0,
|
|
2717
3272
|
skipped_occurrence=0,
|
|
2718
3273
|
done=False,
|
|
@@ -2728,9 +3283,10 @@ class DataFrameTable(DataTable):
|
|
|
2728
3283
|
|
|
2729
3284
|
except Exception as e:
|
|
2730
3285
|
self.notify(
|
|
2731
|
-
f"Error replacing [$
|
|
3286
|
+
f"Error replacing [$error]{term_find}[/] with [$accent]{term_replace}[/]",
|
|
2732
3287
|
title="Replace",
|
|
2733
3288
|
severity="error",
|
|
3289
|
+
timeout=10,
|
|
2734
3290
|
)
|
|
2735
3291
|
self.log(f"Error replacing `{term_find}` with `{term_replace}`: {str(e)}")
|
|
2736
3292
|
|
|
@@ -2740,7 +3296,7 @@ class DataFrameTable(DataTable):
|
|
|
2740
3296
|
self.app.push_screen(
|
|
2741
3297
|
ConfirmScreen(
|
|
2742
3298
|
"Replace All",
|
|
2743
|
-
label=f"Replace [$success]{term_find}[/] with [$success]{term_replace
|
|
3299
|
+
label=f"Replace `[$success]{term_find}[/]` with `[$success]{term_replace}[/]` for all [$accent]{state.total_occurrence}[/] occurrences?",
|
|
2744
3300
|
),
|
|
2745
3301
|
callback=self.handle_replace_all_confirmation,
|
|
2746
3302
|
)
|
|
@@ -2795,6 +3351,18 @@ class DataFrameTable(DataTable):
|
|
|
2795
3351
|
pl.when(mask).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
|
|
2796
3352
|
)
|
|
2797
3353
|
|
|
3354
|
+
# Also update the view if applicable
|
|
3355
|
+
if self.df_view is not None:
|
|
3356
|
+
col_updated = f"^_{col_name}_^"
|
|
3357
|
+
lf_updated = self.df.lazy().filter(mask).select(pl.col(col_name).alias(col_updated), pl.col(RID))
|
|
3358
|
+
self.df_view = (
|
|
3359
|
+
self.df_view.lazy()
|
|
3360
|
+
.join(lf_updated, on=RID, how="left")
|
|
3361
|
+
.with_columns(pl.coalesce(pl.col(col_updated), pl.col(col_name)).alias(col_name))
|
|
3362
|
+
.drop(col_updated)
|
|
3363
|
+
.collect()
|
|
3364
|
+
)
|
|
3365
|
+
|
|
2798
3366
|
state.replaced_occurrence += len(ridxs)
|
|
2799
3367
|
|
|
2800
3368
|
# Recreate table for display
|
|
@@ -2806,7 +3374,7 @@ class DataFrameTable(DataTable):
|
|
|
2806
3374
|
|
|
2807
3375
|
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
2808
3376
|
self.notify(
|
|
2809
|
-
f"Replaced [$
|
|
3377
|
+
f"Replaced [$success]{state.replaced_occurrence}[/] of [$success]{state.total_occurrence}[/] in [$accent]{col_name}[/]",
|
|
2810
3378
|
title="Replace",
|
|
2811
3379
|
)
|
|
2812
3380
|
|
|
@@ -2817,9 +3385,10 @@ class DataFrameTable(DataTable):
|
|
|
2817
3385
|
self.show_next_replace_confirmation()
|
|
2818
3386
|
except Exception as e:
|
|
2819
3387
|
self.notify(
|
|
2820
|
-
f"Error replacing [$
|
|
3388
|
+
f"Error replacing [$error]{term_find}[/] with [$accent]{term_replace}[/]",
|
|
2821
3389
|
title="Replace",
|
|
2822
3390
|
severity="error",
|
|
3391
|
+
timeout=10,
|
|
2823
3392
|
)
|
|
2824
3393
|
self.log(f"Error in interactive replace: {str(e)}")
|
|
2825
3394
|
|
|
@@ -2829,7 +3398,7 @@ class DataFrameTable(DataTable):
|
|
|
2829
3398
|
if state.done:
|
|
2830
3399
|
# All done - show final notification
|
|
2831
3400
|
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
2832
|
-
msg = f"Replaced [$
|
|
3401
|
+
msg = f"Replaced [$success]{state.replaced_occurrence}[/] of [$success]{state.total_occurrence}[/] in [$accent]{col_name}[/]"
|
|
2833
3402
|
if state.skipped_occurrence > 0:
|
|
2834
3403
|
msg += f", [$warning]{state.skipped_occurrence}[/] skipped"
|
|
2835
3404
|
self.notify(msg, title="Replace")
|
|
@@ -2847,7 +3416,7 @@ class DataFrameTable(DataTable):
|
|
|
2847
3416
|
state.current_occurrence += 1
|
|
2848
3417
|
|
|
2849
3418
|
# Show confirmation
|
|
2850
|
-
label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] ({state.current_occurrence} of {state.total_occurrence})?"
|
|
3419
|
+
label = f"Replace `[$warning]{state.term_find}[/]` with `[$success]{state.term_replace}[/]` ({state.current_occurrence} of {state.total_occurrence})?"
|
|
2851
3420
|
|
|
2852
3421
|
self.app.push_screen(
|
|
2853
3422
|
ConfirmScreen("Replace", label=label, maybe="Skip"),
|
|
@@ -2864,6 +3433,7 @@ class DataFrameTable(DataTable):
|
|
|
2864
3433
|
cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
|
|
2865
3434
|
col_name = self.df.columns[cidx]
|
|
2866
3435
|
dtype = self.df.dtypes[cidx]
|
|
3436
|
+
rid = self.df[RID][ridx]
|
|
2867
3437
|
|
|
2868
3438
|
# Replace
|
|
2869
3439
|
if result is True:
|
|
@@ -2876,6 +3446,15 @@ class DataFrameTable(DataTable):
|
|
|
2876
3446
|
.otherwise(pl.col(col_name))
|
|
2877
3447
|
.alias(col_name)
|
|
2878
3448
|
)
|
|
3449
|
+
|
|
3450
|
+
# Also update the view if applicable
|
|
3451
|
+
if self.df_view is not None:
|
|
3452
|
+
self.df_view = self.df_view.with_columns(
|
|
3453
|
+
pl.when(pl.col(RID) == rid)
|
|
3454
|
+
.then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
|
|
3455
|
+
.otherwise(pl.col(col_name))
|
|
3456
|
+
.alias(col_name)
|
|
3457
|
+
)
|
|
2879
3458
|
else:
|
|
2880
3459
|
# try to convert replacement value to column dtype
|
|
2881
3460
|
try:
|
|
@@ -2890,6 +3469,12 @@ class DataFrameTable(DataTable):
|
|
|
2890
3469
|
.alias(col_name)
|
|
2891
3470
|
)
|
|
2892
3471
|
|
|
3472
|
+
# Also update the view if applicable
|
|
3473
|
+
if self.df_view is not None:
|
|
3474
|
+
self.df_view = self.df_view.with_columns(
|
|
3475
|
+
pl.when(pl.col(RID) == rid).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
|
|
3476
|
+
)
|
|
3477
|
+
|
|
2893
3478
|
state.replaced_occurrence += 1
|
|
2894
3479
|
|
|
2895
3480
|
# Skip
|
|
@@ -2922,128 +3507,25 @@ class DataFrameTable(DataTable):
|
|
|
2922
3507
|
# Show next confirmation
|
|
2923
3508
|
self.show_next_replace_confirmation()
|
|
2924
3509
|
|
|
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
|
-
|
|
3510
|
+
# View & Filter
|
|
3029
3511
|
def do_view_rows(self) -> None:
|
|
3030
3512
|
"""View rows.
|
|
3031
3513
|
|
|
3032
|
-
If there are selected rows
|
|
3033
|
-
Otherwise, view based on the value
|
|
3514
|
+
If there are selected rows, view those.
|
|
3515
|
+
Otherwise, view based on the cursor value.
|
|
3034
3516
|
"""
|
|
3035
3517
|
|
|
3036
3518
|
cidx = self.cursor_col_idx
|
|
3519
|
+
col_name = self.cursor_col_name
|
|
3037
3520
|
|
|
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
|
-
]
|
|
3521
|
+
# If there are selected rows, use those
|
|
3522
|
+
if self.selected_rows:
|
|
3523
|
+
term = pl.col(RID).is_in(self.selected_rows)
|
|
3043
3524
|
# Otherwise, use the current cell value
|
|
3044
3525
|
else:
|
|
3045
3526
|
ridx = self.cursor_row_idx
|
|
3046
|
-
|
|
3527
|
+
value = self.df.item(ridx, cidx)
|
|
3528
|
+
term = pl.col(col_name).is_null() if value is None else pl.col(col_name) == value
|
|
3047
3529
|
|
|
3048
3530
|
self.view_rows((term, cidx, False, True))
|
|
3049
3531
|
|
|
@@ -3051,34 +3533,46 @@ class DataFrameTable(DataTable):
|
|
|
3051
3533
|
"""Open the filter screen to enter an expression."""
|
|
3052
3534
|
ridx = self.cursor_row_idx
|
|
3053
3535
|
cidx = self.cursor_col_idx
|
|
3054
|
-
cursor_value =
|
|
3536
|
+
cursor_value = self.df.item(ridx, cidx)
|
|
3537
|
+
term = NULL if cursor_value is None else str(cursor_value)
|
|
3055
3538
|
|
|
3056
3539
|
self.app.push_screen(
|
|
3057
|
-
FilterScreen(self.df, cidx,
|
|
3540
|
+
FilterScreen(self.df, cidx, term),
|
|
3058
3541
|
callback=self.view_rows,
|
|
3059
3542
|
)
|
|
3060
3543
|
|
|
3061
3544
|
def view_rows(self, result) -> None:
|
|
3062
|
-
"""
|
|
3545
|
+
"""View selected rows and hide others. Do not modify the dataframe."""
|
|
3063
3546
|
if result is None:
|
|
3064
3547
|
return
|
|
3065
3548
|
term, cidx, match_nocase, match_whole = result
|
|
3066
3549
|
|
|
3067
3550
|
col_name = self.df.columns[cidx]
|
|
3068
3551
|
|
|
3069
|
-
|
|
3070
|
-
|
|
3552
|
+
# Support for polars expression
|
|
3553
|
+
if isinstance(term, pl.Expr):
|
|
3554
|
+
expr = term
|
|
3555
|
+
|
|
3556
|
+
# Support for list of booleans (selected rows)
|
|
3071
3557
|
elif isinstance(term, (list, pl.Series)):
|
|
3072
|
-
# Support for list of booleans (selected rows)
|
|
3073
3558
|
expr = term
|
|
3559
|
+
|
|
3560
|
+
# Null case
|
|
3561
|
+
elif term == NULL:
|
|
3562
|
+
expr = pl.col(col_name).is_null()
|
|
3563
|
+
|
|
3564
|
+
# Support for polars expression in string form
|
|
3074
3565
|
elif tentative_expr(term):
|
|
3075
|
-
# Support for polars expressions
|
|
3076
3566
|
try:
|
|
3077
3567
|
expr = validate_expr(term, self.df.columns, cidx)
|
|
3078
3568
|
except Exception as e:
|
|
3079
|
-
self.notify(
|
|
3569
|
+
self.notify(
|
|
3570
|
+
f"Error validating expression [$error]{term}[/]", title="Filter", severity="error", timeout=10
|
|
3571
|
+
)
|
|
3080
3572
|
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
3081
3573
|
return
|
|
3574
|
+
|
|
3575
|
+
# Type-aware search based on column dtype
|
|
3082
3576
|
else:
|
|
3083
3577
|
dtype = self.df.dtypes[cidx]
|
|
3084
3578
|
if dtype == pl.String:
|
|
@@ -3102,23 +3596,16 @@ class DataFrameTable(DataTable):
|
|
|
3102
3596
|
)
|
|
3103
3597
|
|
|
3104
3598
|
# Lazyframe with row indices
|
|
3105
|
-
lf = self.df.lazy()
|
|
3599
|
+
lf = self.df.lazy()
|
|
3106
3600
|
|
|
3107
|
-
|
|
3108
|
-
if False in self.visible_rows:
|
|
3109
|
-
lf = lf.filter(self.visible_rows)
|
|
3110
|
-
|
|
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)
|
|
3601
|
+
expr_str = "boolean list or series" if isinstance(expr, (list, pl.Series)) else str(expr)
|
|
3115
3602
|
|
|
3116
3603
|
# Apply the filter expression
|
|
3117
3604
|
try:
|
|
3118
3605
|
df_filtered = lf.filter(expr).collect()
|
|
3119
3606
|
except Exception as e:
|
|
3120
|
-
self.
|
|
3121
|
-
self.notify(f"Error applying filter [$error]{expr_str}[/]", title="Filter", severity="error")
|
|
3607
|
+
self.histories_undo.pop() # Remove last history entry
|
|
3608
|
+
self.notify(f"Error applying filter [$error]{expr_str}[/]", title="Filter", severity="error", timeout=10)
|
|
3122
3609
|
self.log(f"Error applying filter `{expr_str}`: {str(e)}")
|
|
3123
3610
|
return
|
|
3124
3611
|
|
|
@@ -3128,19 +3615,83 @@ class DataFrameTable(DataTable):
|
|
|
3128
3615
|
return
|
|
3129
3616
|
|
|
3130
3617
|
# Add to history
|
|
3131
|
-
self.add_history(f"Filtered by expression [$success]{expr_str}[/]"
|
|
3618
|
+
self.add_history(f"Filtered by expression [$success]{expr_str}[/]")
|
|
3619
|
+
|
|
3620
|
+
ok_rids = set(df_filtered[RID])
|
|
3621
|
+
|
|
3622
|
+
# Create a view of self.df as a copy
|
|
3623
|
+
if self.df_view is None:
|
|
3624
|
+
self.df_view = self.df
|
|
3625
|
+
|
|
3626
|
+
# Update dataframe
|
|
3627
|
+
self.df = df_filtered
|
|
3628
|
+
|
|
3629
|
+
# Update selected rows
|
|
3630
|
+
if self.selected_rows:
|
|
3631
|
+
self.selected_rows.intersection_update(ok_rids)
|
|
3632
|
+
|
|
3633
|
+
# Update matches
|
|
3634
|
+
if self.matches:
|
|
3635
|
+
self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
|
|
3636
|
+
|
|
3637
|
+
# Recreate table for display
|
|
3638
|
+
self.setup_table()
|
|
3639
|
+
|
|
3640
|
+
self.notify(f"Filtered to [$success]{matched_count}[/] matching row(s)", title="Filter")
|
|
3641
|
+
|
|
3642
|
+
def do_filter_rows(self) -> None:
|
|
3643
|
+
"""Filter rows.
|
|
3644
|
+
|
|
3645
|
+
If there are selected rows, use those.
|
|
3646
|
+
Otherwise, filter based on the cursor value.
|
|
3647
|
+
"""
|
|
3648
|
+
if self.selected_rows:
|
|
3649
|
+
message = "Filtered to selected rows (other rows removed)"
|
|
3650
|
+
filter_expr = pl.col(RID).is_in(self.selected_rows)
|
|
3651
|
+
else: # Search cursor value in current column
|
|
3652
|
+
message = "Filtered to rows matching cursor value (other rows removed)"
|
|
3653
|
+
cidx = self.cursor_col_idx
|
|
3654
|
+
col_name = self.df.columns[cidx]
|
|
3655
|
+
value = self.cursor_value
|
|
3656
|
+
|
|
3657
|
+
if value is None:
|
|
3658
|
+
filter_expr = pl.col(col_name).is_null()
|
|
3659
|
+
else:
|
|
3660
|
+
filter_expr = pl.col(col_name) == value
|
|
3661
|
+
|
|
3662
|
+
# Add to history
|
|
3663
|
+
self.add_history(message, dirty=True)
|
|
3664
|
+
|
|
3665
|
+
# Apply filter to dataframe with row indices
|
|
3666
|
+
df_filtered = self.df.lazy().filter(filter_expr).collect()
|
|
3667
|
+
ok_rids = set(df_filtered[RID])
|
|
3668
|
+
|
|
3669
|
+
# Update selected rows
|
|
3670
|
+
if self.selected_rows:
|
|
3671
|
+
selected_rows = {rid for rid in self.selected_rows if rid in ok_rids}
|
|
3672
|
+
else:
|
|
3673
|
+
selected_rows = set()
|
|
3674
|
+
|
|
3675
|
+
# Update matches
|
|
3676
|
+
if self.matches:
|
|
3677
|
+
matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
|
|
3678
|
+
else:
|
|
3679
|
+
matches = defaultdict(set)
|
|
3680
|
+
|
|
3681
|
+
# Update dataframe
|
|
3682
|
+
self.reset_df(df_filtered)
|
|
3132
3683
|
|
|
3133
|
-
#
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3684
|
+
# Clear view for filter mode
|
|
3685
|
+
self.df_view = None
|
|
3686
|
+
|
|
3687
|
+
# Restore selected rows and matches
|
|
3688
|
+
self.selected_rows = selected_rows
|
|
3689
|
+
self.matches = matches
|
|
3139
3690
|
|
|
3140
3691
|
# Recreate table for display
|
|
3141
3692
|
self.setup_table()
|
|
3142
3693
|
|
|
3143
|
-
self.notify(f"
|
|
3694
|
+
self.notify(f"{message}. Now showing [$success]{len(self.df)}[/] rows.", title="Filter")
|
|
3144
3695
|
|
|
3145
3696
|
# Copy & Save
|
|
3146
3697
|
def do_copy_to_clipboard(self, content: str, message: str) -> None:
|
|
@@ -3164,22 +3715,26 @@ class DataFrameTable(DataTable):
|
|
|
3164
3715
|
)
|
|
3165
3716
|
self.notify(message, title="Clipboard")
|
|
3166
3717
|
except FileNotFoundError:
|
|
3167
|
-
self.notify("Error copying to clipboard", title="Clipboard", severity="error")
|
|
3718
|
+
self.notify("Error copying to clipboard", title="Clipboard", severity="error", timeout=10)
|
|
3168
3719
|
|
|
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:
|
|
3720
|
+
def do_save_to_file(self, all_tabs: bool | None = None, task_after_save: str | None = None) -> None:
|
|
3172
3721
|
"""Open screen to save file."""
|
|
3173
3722
|
self._task_after_save = task_after_save
|
|
3723
|
+
tab_count = len(self.app.tabs)
|
|
3724
|
+
save_all = tab_count > 1 and all_tabs is not False
|
|
3725
|
+
|
|
3726
|
+
filepath = Path(self.filename)
|
|
3727
|
+
if save_all:
|
|
3728
|
+
ext = filepath.suffix.lower()
|
|
3729
|
+
if ext in (".xlsx", ".xls"):
|
|
3730
|
+
filename = self.filename
|
|
3731
|
+
else:
|
|
3732
|
+
filename = "all-tabs.xlsx"
|
|
3733
|
+
else:
|
|
3734
|
+
filename = str(filepath.with_stem(self.tabname))
|
|
3174
3735
|
|
|
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
3736
|
self.app.push_screen(
|
|
3182
|
-
SaveFileScreen(filename,
|
|
3737
|
+
SaveFileScreen(filename, save_all=save_all, tab_count=tab_count),
|
|
3183
3738
|
callback=self.save_to_file,
|
|
3184
3739
|
)
|
|
3185
3740
|
|
|
@@ -3187,13 +3742,11 @@ class DataFrameTable(DataTable):
|
|
|
3187
3742
|
"""Handle result from SaveFileScreen."""
|
|
3188
3743
|
if result is None:
|
|
3189
3744
|
return
|
|
3190
|
-
filename,
|
|
3191
|
-
|
|
3192
|
-
# Whether to save all tabs (for Excel files)
|
|
3193
|
-
self._all_tabs = all_tabs
|
|
3745
|
+
filename, save_all, overwrite_prompt = result
|
|
3746
|
+
self._save_all = save_all
|
|
3194
3747
|
|
|
3195
3748
|
# Check if file exists
|
|
3196
|
-
if Path(filename).exists():
|
|
3749
|
+
if overwrite_prompt and Path(filename).exists():
|
|
3197
3750
|
self._pending_filename = filename
|
|
3198
3751
|
self.app.push_screen(
|
|
3199
3752
|
ConfirmScreen("File already exists. Overwrite?"),
|
|
@@ -3209,7 +3762,7 @@ class DataFrameTable(DataTable):
|
|
|
3209
3762
|
else:
|
|
3210
3763
|
# Go back to SaveFileScreen to allow user to enter a different name
|
|
3211
3764
|
self.app.push_screen(
|
|
3212
|
-
SaveFileScreen(self._pending_filename),
|
|
3765
|
+
SaveFileScreen(self._pending_filename, save_all=self._save_all),
|
|
3213
3766
|
callback=self.save_to_file,
|
|
3214
3767
|
)
|
|
3215
3768
|
|
|
@@ -3217,62 +3770,78 @@ class DataFrameTable(DataTable):
|
|
|
3217
3770
|
"""Actually save the dataframe to a file."""
|
|
3218
3771
|
filepath = Path(filename)
|
|
3219
3772
|
ext = filepath.suffix.lower()
|
|
3773
|
+
if ext == ".gz":
|
|
3774
|
+
ext = Path(filename).with_suffix("").suffix.lower()
|
|
3220
3775
|
|
|
3221
|
-
|
|
3222
|
-
|
|
3776
|
+
fmt = ext.removeprefix(".")
|
|
3777
|
+
if fmt not in SUPPORTED_FORMATS:
|
|
3778
|
+
self.notify(
|
|
3779
|
+
f"Unsupported file format [$success]{fmt}[/]. Use [$accent]CSV[/] as fallback. Supported formats: {', '.join(SUPPORTED_FORMATS)}",
|
|
3780
|
+
title="Save to File",
|
|
3781
|
+
severity="warning",
|
|
3782
|
+
)
|
|
3783
|
+
fmt = "csv"
|
|
3223
3784
|
|
|
3785
|
+
df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
|
|
3224
3786
|
try:
|
|
3225
|
-
if
|
|
3787
|
+
if fmt == "csv":
|
|
3788
|
+
df.write_csv(filename)
|
|
3789
|
+
elif fmt in ("tsv", "tab"):
|
|
3790
|
+
df.write_csv(filename, separator="\t")
|
|
3791
|
+
elif fmt in ("xlsx", "xls"):
|
|
3226
3792
|
self.save_excel(filename)
|
|
3227
|
-
elif
|
|
3228
|
-
|
|
3229
|
-
elif
|
|
3230
|
-
|
|
3231
|
-
elif
|
|
3232
|
-
|
|
3233
|
-
else:
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
self.filename = filename
|
|
3793
|
+
elif fmt == "json":
|
|
3794
|
+
df.write_json(filename)
|
|
3795
|
+
elif fmt == "ndjson":
|
|
3796
|
+
df.write_ndjson(filename)
|
|
3797
|
+
elif fmt == "parquet":
|
|
3798
|
+
df.write_parquet(filename)
|
|
3799
|
+
else: # Fallback to CSV
|
|
3800
|
+
df.write_csv(filename)
|
|
3801
|
+
|
|
3802
|
+
# Update current filename
|
|
3803
|
+
self.filename = filename
|
|
3238
3804
|
|
|
3239
3805
|
# Reset dirty flag after save
|
|
3240
|
-
if self.
|
|
3806
|
+
if self._save_all:
|
|
3241
3807
|
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
3242
3808
|
for table in tabs.values():
|
|
3243
3809
|
table.dirty = False
|
|
3244
3810
|
else:
|
|
3245
3811
|
self.dirty = False
|
|
3246
3812
|
|
|
3247
|
-
if self
|
|
3248
|
-
self.
|
|
3249
|
-
|
|
3250
|
-
self.
|
|
3813
|
+
if hasattr(self, "_task_after_save"):
|
|
3814
|
+
if self._task_after_save == "close_tab":
|
|
3815
|
+
self.app.do_close_tab()
|
|
3816
|
+
elif self._task_after_save == "quit_app":
|
|
3817
|
+
self.app.exit()
|
|
3251
3818
|
|
|
3252
3819
|
# From ConfirmScreen callback, so notify accordingly
|
|
3253
|
-
if self.
|
|
3820
|
+
if self._save_all:
|
|
3254
3821
|
self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
|
|
3255
3822
|
else:
|
|
3256
3823
|
self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
|
|
3257
3824
|
|
|
3258
3825
|
except Exception as e:
|
|
3259
|
-
self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error")
|
|
3826
|
+
self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error", timeout=10)
|
|
3260
3827
|
self.log(f"Error saving file `{filename}`: {str(e)}")
|
|
3261
3828
|
|
|
3262
3829
|
def save_excel(self, filename: str) -> None:
|
|
3263
3830
|
"""Save to an Excel file."""
|
|
3264
3831
|
import xlsxwriter
|
|
3265
3832
|
|
|
3266
|
-
if not self.
|
|
3833
|
+
if not self._save_all or len(self.app.tabs) == 1:
|
|
3267
3834
|
# Single tab - save directly
|
|
3268
|
-
self.df.
|
|
3835
|
+
df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
|
|
3836
|
+
df.write_excel(filename, worksheet=self.tabname)
|
|
3269
3837
|
else:
|
|
3270
3838
|
# Multiple tabs - use xlsxwriter to create multiple sheets
|
|
3271
3839
|
with xlsxwriter.Workbook(filename) as wb:
|
|
3272
3840
|
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
3273
3841
|
for table in tabs.values():
|
|
3274
3842
|
worksheet = wb.add_worksheet(table.tabname)
|
|
3275
|
-
table.df.
|
|
3843
|
+
df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
|
|
3844
|
+
df.write_excel(workbook=wb, worksheet=worksheet)
|
|
3276
3845
|
|
|
3277
3846
|
# SQL Interface
|
|
3278
3847
|
def do_simple_sql(self) -> None:
|
|
@@ -3316,19 +3885,17 @@ class DataFrameTable(DataTable):
|
|
|
3316
3885
|
sql: The SQL query string to execute.
|
|
3317
3886
|
"""
|
|
3318
3887
|
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3888
|
+
sql = sql.replace("$#", f"(`{RID}` + 1)")
|
|
3889
|
+
if RID not in sql and "*" not in sql:
|
|
3890
|
+
# Ensure RID is selected
|
|
3891
|
+
import re
|
|
3322
3892
|
|
|
3323
|
-
|
|
3893
|
+
RE_FROM_SELF = re.compile(r"\bFROM\s+self\b", re.IGNORECASE)
|
|
3894
|
+
sql = RE_FROM_SELF.sub(f", `{RID}` FROM self", sql)
|
|
3324
3895
|
|
|
3325
3896
|
# Execute the SQL query
|
|
3326
3897
|
try:
|
|
3327
|
-
|
|
3328
|
-
if False in self.visible_rows:
|
|
3329
|
-
lf = lf.filter(self.visible_rows)
|
|
3330
|
-
|
|
3331
|
-
df_filtered = lf.sql(sql).collect()
|
|
3898
|
+
df_filtered = self.df.lazy().sql(sql).collect()
|
|
3332
3899
|
|
|
3333
3900
|
if not len(df_filtered):
|
|
3334
3901
|
self.notify(
|
|
@@ -3336,29 +3903,34 @@ class DataFrameTable(DataTable):
|
|
|
3336
3903
|
)
|
|
3337
3904
|
return
|
|
3338
3905
|
|
|
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
3906
|
except Exception as e:
|
|
3358
3907
|
self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error", timeout=10)
|
|
3359
3908
|
self.log(f"Error executing SQL query `{sql}`: {str(e)}")
|
|
3360
3909
|
return
|
|
3361
3910
|
|
|
3911
|
+
# Add to history
|
|
3912
|
+
self.add_history(f"SQL Query:\n[$success]{sql}[/]", dirty=not view)
|
|
3913
|
+
|
|
3914
|
+
# Create a view of self.df as a copy
|
|
3915
|
+
if view and self.df_view is None:
|
|
3916
|
+
self.df_view = self.df
|
|
3917
|
+
|
|
3918
|
+
# Clear view for filter mode
|
|
3919
|
+
if not view:
|
|
3920
|
+
self.df_view = None
|
|
3921
|
+
|
|
3922
|
+
# Update dataframe
|
|
3923
|
+
self.df = df_filtered
|
|
3924
|
+
ok_rids = set(df_filtered[RID])
|
|
3925
|
+
|
|
3926
|
+
# Update selected rows
|
|
3927
|
+
if self.selected_rows:
|
|
3928
|
+
self.selected_rows.intersection_update(ok_rids)
|
|
3929
|
+
|
|
3930
|
+
# Update matches
|
|
3931
|
+
if self.matches:
|
|
3932
|
+
self.matches = {rid: cols for rid, cols in self.matches.items() if rid in ok_rids}
|
|
3933
|
+
|
|
3362
3934
|
# Recreate table for display
|
|
3363
3935
|
self.setup_table()
|
|
3364
3936
|
|