dataframe-textual 1.2.0__py3-none-any.whl → 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataframe_textual/__main__.py +42 -20
- dataframe_textual/common.py +280 -72
- dataframe_textual/data_frame_help_panel.py +6 -4
- dataframe_textual/data_frame_table.py +633 -370
- dataframe_textual/data_frame_viewer.py +24 -28
- dataframe_textual/sql_screen.py +202 -0
- dataframe_textual/table_screen.py +31 -20
- dataframe_textual/yes_no_screen.py +12 -8
- {dataframe_textual-1.2.0.dist-info → dataframe_textual-1.4.0.dist-info}/METADATA +149 -13
- dataframe_textual-1.4.0.dist-info/RECORD +14 -0
- {dataframe_textual-1.2.0.dist-info → dataframe_textual-1.4.0.dist-info}/entry_points.txt +1 -0
- dataframe_textual-1.2.0.dist-info/RECORD +0 -13
- {dataframe_textual-1.2.0.dist-info → dataframe_textual-1.4.0.dist-info}/WHEEL +0 -0
- {dataframe_textual-1.2.0.dist-info → dataframe_textual-1.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -9,8 +9,10 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
import polars as pl
|
|
11
11
|
from rich.text import Text
|
|
12
|
+
from textual import work
|
|
12
13
|
from textual.coordinate import Coordinate
|
|
13
14
|
from textual.events import Click
|
|
15
|
+
from textual.render import measure
|
|
14
16
|
from textual.widgets import DataTable, TabPane
|
|
15
17
|
from textual.widgets._data_table import (
|
|
16
18
|
CellDoesNotExist,
|
|
@@ -30,9 +32,11 @@ from .common import (
|
|
|
30
32
|
format_row,
|
|
31
33
|
get_next_item,
|
|
32
34
|
rindex,
|
|
35
|
+
sleep_async,
|
|
33
36
|
tentative_expr,
|
|
34
37
|
validate_expr,
|
|
35
38
|
)
|
|
39
|
+
from .sql_screen import AdvancedSqlScreen, SimpleSqlScreen
|
|
36
40
|
from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
|
|
37
41
|
from .yes_no_screen import (
|
|
38
42
|
AddColumnScreen,
|
|
@@ -47,6 +51,15 @@ from .yes_no_screen import (
|
|
|
47
51
|
SearchScreen,
|
|
48
52
|
)
|
|
49
53
|
|
|
54
|
+
# Color for highlighting selections and matches
|
|
55
|
+
HIGHLIGHT_COLOR = "red"
|
|
56
|
+
|
|
57
|
+
# Warning threshold for loading rows
|
|
58
|
+
WARN_ROWS_THRESHOLD = 50_000
|
|
59
|
+
|
|
60
|
+
# Maximum width for string columns before truncation
|
|
61
|
+
STRING_WIDTH_CAP = 35
|
|
62
|
+
|
|
50
63
|
|
|
51
64
|
@dataclass
|
|
52
65
|
class History:
|
|
@@ -97,6 +110,8 @@ class DataFrameTable(DataTable):
|
|
|
97
110
|
- **↑↓←→** - 🎯 Move cursor (cell/row/column)
|
|
98
111
|
- **g** - ⬆️ Jump to first row
|
|
99
112
|
- **G** - ⬇️ Jump to last row
|
|
113
|
+
- **Ctrl+F** - 📜 Page down
|
|
114
|
+
- **Ctrl+B** - 📜 Page up
|
|
100
115
|
- **PgUp/PgDn** - 📜 Page up/down
|
|
101
116
|
|
|
102
117
|
## 👁️ View & Display
|
|
@@ -106,6 +121,7 @@ class DataFrameTable(DataTable):
|
|
|
106
121
|
- **S** - 📊 Show statistics for entire dataframe
|
|
107
122
|
- **h** - 👁️ Hide current column
|
|
108
123
|
- **H** - 👀 Show all hidden rows/columns
|
|
124
|
+
- **_** - 📏 Expand column to full width
|
|
109
125
|
- **z** - 📌 Freeze rows and columns
|
|
110
126
|
- **~** - 🏷️ Toggle row labels
|
|
111
127
|
- **,** - 🔢 Toggle thousand separator for numeric display
|
|
@@ -121,8 +137,8 @@ class DataFrameTable(DataTable):
|
|
|
121
137
|
- **\\\\** - 🔎 Search in current column using cursor value
|
|
122
138
|
- **/** - 🔎 Find in current column with cursor value
|
|
123
139
|
- **?** - 🔎 Find in current column with expression
|
|
124
|
-
-
|
|
125
|
-
-
|
|
140
|
+
- **;** - 🌐 Global find using cursor value
|
|
141
|
+
- **:** - 🌐 Global find with expression
|
|
126
142
|
- **n** - ⬇️ Go to next match
|
|
127
143
|
- **N** - ⬆️ Go to previous match
|
|
128
144
|
- **v** - 👁️ View/filter rows by cell or selected rows
|
|
@@ -142,6 +158,10 @@ class DataFrameTable(DataTable):
|
|
|
142
158
|
- **"** - 📍 Filter to show only selected rows
|
|
143
159
|
- **T** - 🧹 Clear all selections and matches
|
|
144
160
|
|
|
161
|
+
## 🔍 SQL Interface
|
|
162
|
+
- **l** - 💬 Open simple SQL interface (select columns & WHERE clause)
|
|
163
|
+
- **L** - 🔎 Open advanced SQL interface (full SQL queries)
|
|
164
|
+
|
|
145
165
|
## ✏️ Edit & Modify
|
|
146
166
|
- **Double-click** - ✍️ Edit cell or rename column header
|
|
147
167
|
- **e** - ✍️ Edit current cell
|
|
@@ -153,12 +173,9 @@ class DataFrameTable(DataTable):
|
|
|
153
173
|
- **Ctrl+X** - ❌ Delete row and those above
|
|
154
174
|
- **delete** - ❌ Clear current cell (set to NULL)
|
|
155
175
|
- **-** - ❌ Delete current column
|
|
156
|
-
- **_** - ❌ Delete column and those after
|
|
157
|
-
- **Ctrl+-** - ❌ Delete column and those before
|
|
158
176
|
- **d** - 📋 Duplicate current column
|
|
159
177
|
- **D** - 📋 Duplicate current row
|
|
160
178
|
|
|
161
|
-
|
|
162
179
|
## 🎯 Reorder
|
|
163
180
|
- **Shift+↑↓** - ⬆️⬇️ Move row up/down
|
|
164
181
|
- **Shift+←→** - ⬅️➡️ Move column left/right
|
|
@@ -187,6 +204,8 @@ class DataFrameTable(DataTable):
|
|
|
187
204
|
# Navigation
|
|
188
205
|
("g", "jump_top", "Jump to top"),
|
|
189
206
|
("G", "jump_bottom", "Jump to bottom"),
|
|
207
|
+
("ctrl+f", "forward_page", "Page down"),
|
|
208
|
+
("ctrl+b", "backward_page", "Page up"),
|
|
190
209
|
# Display
|
|
191
210
|
("h", "hide_column", "Hide column"),
|
|
192
211
|
("H", "show_hidden_rows_columns", "Show hidden rows/columns"),
|
|
@@ -194,6 +213,7 @@ class DataFrameTable(DataTable):
|
|
|
194
213
|
("K", "cycle_cursor_type", "Cycle cursor mode"), # `K`
|
|
195
214
|
("z", "freeze_row_column", "Freeze rows/columns"),
|
|
196
215
|
("comma", "show_thousand_separator", "Toggle thousand separator"), # `,`
|
|
216
|
+
("underscore", "expand_column", "Expand column to full width"), # `_`
|
|
197
217
|
# Copy
|
|
198
218
|
("c", "copy_cell", "Copy cell to clipboard"),
|
|
199
219
|
("ctrl+c", "copy_column", "Copy column to clipboard"),
|
|
@@ -208,9 +228,10 @@ class DataFrameTable(DataTable):
|
|
|
208
228
|
# Sort
|
|
209
229
|
("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
|
|
210
230
|
("right_square_bracket", "sort_descending", "Sort descending"), # `]`
|
|
211
|
-
# View
|
|
231
|
+
# View & Filter
|
|
212
232
|
("v", "view_rows", "View rows"),
|
|
213
233
|
("V", "view_rows_expr", "View rows by expression"),
|
|
234
|
+
("quotation_mark", "filter_rows", "Filter selected"), # `"`
|
|
214
235
|
# Search
|
|
215
236
|
("backslash", "search_cursor_value", "Search column with cursor value"), # `\`
|
|
216
237
|
("vertical_line", "search_expr", "Search column with expression"), # `|`
|
|
@@ -219,23 +240,20 @@ class DataFrameTable(DataTable):
|
|
|
219
240
|
# Find
|
|
220
241
|
("slash", "find_cursor_value", "Find in column with cursor value"), # `/`
|
|
221
242
|
("question_mark", "find_expr", "Find in column with expression"), # `?`
|
|
222
|
-
("
|
|
223
|
-
("
|
|
243
|
+
("semicolon", "find_cursor_value('global')", "Global find with cursor value"), # `;`
|
|
244
|
+
("colon", "find_expr('global')", "Global find with expression"), # `:`
|
|
224
245
|
("n", "next_match", "Go to next match"), # `n`
|
|
225
246
|
("N", "previous_match", "Go to previous match"), # `Shift+n`
|
|
226
247
|
# Replace
|
|
227
248
|
("r", "replace", "Replace in column"), # `r`
|
|
228
249
|
("R", "replace_global", "Replace global"), # `Shift+R`
|
|
229
250
|
# Selection
|
|
230
|
-
("apostrophe", "
|
|
251
|
+
("apostrophe", "toggle_row_selection", "Toggle row selection"), # `'`
|
|
231
252
|
("t", "toggle_selections", "Toggle all row selections"),
|
|
232
253
|
("T", "clear_selections_and_matches", "Clear selections"),
|
|
233
|
-
("quotation_mark", "filter_selected_rows", "Filter selected"), # `"`
|
|
234
254
|
# Delete
|
|
235
255
|
("delete", "clear_cell", "Clear cell"),
|
|
236
256
|
("minus", "delete_column", "Delete column"), # `-`
|
|
237
|
-
("underscore", "delete_column_and_after", "Delete column and those after"), # `_`
|
|
238
|
-
("ctrl+minus", "delete_column_and_before", "Delete column and those before"), # `Ctrl+-`
|
|
239
257
|
("x", "delete_row", "Delete row"),
|
|
240
258
|
("X", "delete_row_and_below", "Delete row and those below"),
|
|
241
259
|
("ctrl+x", "delete_row_and_up", "Delete row and those up"),
|
|
@@ -254,11 +272,14 @@ class DataFrameTable(DataTable):
|
|
|
254
272
|
("shift+up", "move_row_up", "Move row up"),
|
|
255
273
|
("shift+down", "move_row_down", "Move row down"),
|
|
256
274
|
# Type Conversion
|
|
257
|
-
("number_sign", "cast_column_dtype('
|
|
258
|
-
("percent_sign", "cast_column_dtype('
|
|
259
|
-
("exclamation_mark", "cast_column_dtype('
|
|
260
|
-
("dollar_sign", "cast_column_dtype('
|
|
275
|
+
("number_sign", "cast_column_dtype('pl.Int64')", "Cast column dtype to integer"), # `#`
|
|
276
|
+
("percent_sign", "cast_column_dtype('pl.Float64')", "Cast column dtype to float"), # `%`
|
|
277
|
+
("exclamation_mark", "cast_column_dtype('pl.Boolean')", "Cast column dtype to bool"), # `!`
|
|
278
|
+
("dollar_sign", "cast_column_dtype('pl.String')", "Cast column dtype to string"), # `$`
|
|
261
279
|
("at", "make_cell_clickable", "Make cell clickable"), # `@`
|
|
280
|
+
# Sql
|
|
281
|
+
("l", "simple_sql", "Simple SQL interface"),
|
|
282
|
+
("L", "advanced_sql", "Advanced SQL interface"),
|
|
262
283
|
# Undo/Redo
|
|
263
284
|
("u", "undo", "Undo"),
|
|
264
285
|
("U", "redo", "Redo"),
|
|
@@ -266,14 +287,14 @@ class DataFrameTable(DataTable):
|
|
|
266
287
|
]
|
|
267
288
|
# fmt: on
|
|
268
289
|
|
|
269
|
-
def __init__(self, df: pl.DataFrame
|
|
290
|
+
def __init__(self, df: pl.DataFrame, filename: str = "", name: str = "", **kwargs) -> None:
|
|
270
291
|
"""Initialize the DataFrameTable with a dataframe and manage all state.
|
|
271
292
|
|
|
272
293
|
Sets up the table widget with display configuration, loads the dataframe, and
|
|
273
294
|
initializes all state tracking variables for row/column operations.
|
|
274
295
|
|
|
275
296
|
Args:
|
|
276
|
-
df: The Polars DataFrame
|
|
297
|
+
df: The Polars DataFrame to display and edit.
|
|
277
298
|
filename: Optional source filename for the data (used in save operations). Defaults to "".
|
|
278
299
|
name: Optional display name for the table tab. Defaults to "" (uses filename stem).
|
|
279
300
|
**kwargs: Additional keyword arguments passed to the parent DataTable widget.
|
|
@@ -284,8 +305,8 @@ class DataFrameTable(DataTable):
|
|
|
284
305
|
super().__init__(name=(name or Path(filename).stem), **kwargs)
|
|
285
306
|
|
|
286
307
|
# DataFrame state
|
|
287
|
-
self.
|
|
288
|
-
self.df =
|
|
308
|
+
self.dataframe = df # Original dataframe
|
|
309
|
+
self.df = df # Internal/working dataframe
|
|
289
310
|
self.filename = filename # Current filename
|
|
290
311
|
|
|
291
312
|
# Pagination & Loading
|
|
@@ -522,6 +543,10 @@ class DataFrameTable(DataTable):
|
|
|
522
543
|
ridx: Row index (0-based) in the dataframe.
|
|
523
544
|
cidx: Column index (0-based) in the dataframe.
|
|
524
545
|
"""
|
|
546
|
+
# Ensure the target row is loaded
|
|
547
|
+
if ridx >= self.loaded_rows:
|
|
548
|
+
self._load_rows(stop=ridx + self.BATCH_SIZE)
|
|
549
|
+
|
|
525
550
|
row_key = str(ridx)
|
|
526
551
|
col_key = self.df.columns[cidx]
|
|
527
552
|
row_idx, col_idx = self.get_cell_coordinate(row_key, col_key)
|
|
@@ -586,8 +611,16 @@ class DataFrameTable(DataTable):
|
|
|
586
611
|
|
|
587
612
|
def action_jump_bottom(self) -> None:
|
|
588
613
|
"""Jump to the bottom of the table."""
|
|
589
|
-
self._load_rows()
|
|
590
|
-
|
|
614
|
+
self._load_rows(move_to_end=True)
|
|
615
|
+
|
|
616
|
+
def action_forward_page(self) -> None:
|
|
617
|
+
"""Scroll down one page."""
|
|
618
|
+
super().action_page_down()
|
|
619
|
+
self._check_and_load_more()
|
|
620
|
+
|
|
621
|
+
def action_backward_page(self) -> None:
|
|
622
|
+
"""Scroll up one page."""
|
|
623
|
+
super().action_page_up()
|
|
591
624
|
|
|
592
625
|
def action_view_row_detail(self) -> None:
|
|
593
626
|
"""View details of the current row."""
|
|
@@ -597,18 +630,14 @@ class DataFrameTable(DataTable):
|
|
|
597
630
|
"""Delete the current column."""
|
|
598
631
|
self._delete_column()
|
|
599
632
|
|
|
600
|
-
def action_delete_column_and_after(self) -> None:
|
|
601
|
-
"""Delete the current column and those after."""
|
|
602
|
-
self._delete_column(more="after")
|
|
603
|
-
|
|
604
|
-
def action_delete_column_and_before(self) -> None:
|
|
605
|
-
"""Delete the current column and those before."""
|
|
606
|
-
self._delete_column(more="before")
|
|
607
|
-
|
|
608
633
|
def action_hide_column(self) -> None:
|
|
609
634
|
"""Hide the current column."""
|
|
610
635
|
self._hide_column()
|
|
611
636
|
|
|
637
|
+
def action_expand_column(self) -> None:
|
|
638
|
+
"""Expand the current column to its full width."""
|
|
639
|
+
self._expand_column()
|
|
640
|
+
|
|
612
641
|
def action_show_hidden_rows_columns(self) -> None:
|
|
613
642
|
"""Show all hidden rows/columns."""
|
|
614
643
|
self._show_hidden_rows_columns()
|
|
@@ -701,17 +730,17 @@ class DataFrameTable(DataTable):
|
|
|
701
730
|
"""Replace values across all columns."""
|
|
702
731
|
self._replace_global()
|
|
703
732
|
|
|
704
|
-
def
|
|
733
|
+
def action_toggle_row_selection(self) -> None:
|
|
705
734
|
"""Toggle selection for the current row."""
|
|
706
|
-
self.
|
|
735
|
+
self._toggle_row_selection()
|
|
707
736
|
|
|
708
737
|
def action_toggle_selections(self) -> None:
|
|
709
738
|
"""Toggle all row selections."""
|
|
710
739
|
self._toggle_selections()
|
|
711
740
|
|
|
712
|
-
def
|
|
741
|
+
def action_filter_rows(self) -> None:
|
|
713
742
|
"""Filter to show only selected rows."""
|
|
714
|
-
self.
|
|
743
|
+
self._filter_rows()
|
|
715
744
|
|
|
716
745
|
def action_delete_row(self) -> None:
|
|
717
746
|
"""Delete the current row."""
|
|
@@ -854,6 +883,14 @@ class DataFrameTable(DataTable):
|
|
|
854
883
|
"""Go to the previous selected row."""
|
|
855
884
|
self._previous_selected_row()
|
|
856
885
|
|
|
886
|
+
def action_simple_sql(self) -> None:
|
|
887
|
+
"""Open the SQL interface screen."""
|
|
888
|
+
self._simple_sql()
|
|
889
|
+
|
|
890
|
+
def action_advanced_sql(self) -> None:
|
|
891
|
+
"""Open the advanced SQL interface screen."""
|
|
892
|
+
self._advanced_sql()
|
|
893
|
+
|
|
857
894
|
def on_mouse_scroll_down(self, event) -> None:
|
|
858
895
|
"""Load more rows when scrolling down with mouse."""
|
|
859
896
|
self._check_and_load_more()
|
|
@@ -865,9 +902,12 @@ class DataFrameTable(DataTable):
|
|
|
865
902
|
Row keys are 0-based indices, which map directly to dataframe row indices.
|
|
866
903
|
Column keys are header names from the dataframe.
|
|
867
904
|
"""
|
|
905
|
+
self.loaded_rows = 0
|
|
906
|
+
self.show_row_labels = True
|
|
907
|
+
|
|
868
908
|
# Reset to original dataframe
|
|
869
909
|
if reset:
|
|
870
|
-
self.df = self.
|
|
910
|
+
self.df = self.dataframe
|
|
871
911
|
self.loaded_rows = 0
|
|
872
912
|
self.sorted_columns = {}
|
|
873
913
|
self.hidden_columns = set()
|
|
@@ -878,35 +918,109 @@ class DataFrameTable(DataTable):
|
|
|
878
918
|
self.matches = defaultdict(set)
|
|
879
919
|
|
|
880
920
|
# Lazy load up to INITIAL_BATCH_SIZE visible rows
|
|
881
|
-
stop, visible_count =
|
|
921
|
+
stop, visible_count = self.INITIAL_BATCH_SIZE, 0
|
|
882
922
|
for row_idx, visible in enumerate(self.visible_rows):
|
|
883
923
|
if not visible:
|
|
884
924
|
continue
|
|
885
925
|
visible_count += 1
|
|
886
|
-
if visible_count
|
|
887
|
-
stop = row_idx +
|
|
926
|
+
if visible_count > self.INITIAL_BATCH_SIZE:
|
|
927
|
+
stop = row_idx + self.BATCH_SIZE
|
|
888
928
|
break
|
|
929
|
+
else:
|
|
930
|
+
stop = row_idx + self.BATCH_SIZE
|
|
931
|
+
|
|
932
|
+
# # Ensure all selected rows or matches are loaded
|
|
933
|
+
# stop = max(stop, rindex(self.selected_rows, True) + 1)
|
|
934
|
+
# stop = max(stop, max(self.matches.keys(), default=0) + 1)
|
|
889
935
|
|
|
890
936
|
# Save current cursor position before clearing
|
|
891
937
|
row_idx, col_idx = self.cursor_coordinate
|
|
892
938
|
|
|
893
939
|
self._setup_columns()
|
|
894
940
|
self._load_rows(stop)
|
|
895
|
-
self._do_highlight()
|
|
896
941
|
|
|
897
942
|
# Restore cursor position
|
|
898
943
|
if row_idx < len(self.rows) and col_idx < len(self.columns):
|
|
899
944
|
self.move_cursor(row=row_idx, column=col_idx)
|
|
900
945
|
|
|
946
|
+
def _determine_column_widths(self) -> dict[str, int]:
|
|
947
|
+
"""Determine optimal width for each column based on data type and content.
|
|
948
|
+
|
|
949
|
+
For String columns:
|
|
950
|
+
- Minimum width: length of column label
|
|
951
|
+
- Ideal width: maximum width of all cells in the column
|
|
952
|
+
- If space constrained: find appropriate width smaller than maximum
|
|
953
|
+
|
|
954
|
+
For non-String columns:
|
|
955
|
+
- Return None to let Textual auto-determine width
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
dict[str, int]: Mapping of column name to width (None for auto-sizing columns).
|
|
959
|
+
"""
|
|
960
|
+
column_widths = {}
|
|
961
|
+
|
|
962
|
+
# Get available width for the table (with some padding for borders/scrollbar)
|
|
963
|
+
available_width = self.size.width - 4 # Account for borders and scrollbar
|
|
964
|
+
|
|
965
|
+
# Calculate how much width we need for string columns first
|
|
966
|
+
string_cols = [col for col, dtype in zip(self.df.columns, self.df.dtypes) if dtype == pl.String]
|
|
967
|
+
|
|
968
|
+
# No string columns, let TextualDataTable auto-size all columns
|
|
969
|
+
if not string_cols:
|
|
970
|
+
return column_widths
|
|
971
|
+
|
|
972
|
+
# Sample a reasonable number of rows to calculate widths (don't scan entire dataframe)
|
|
973
|
+
sample_size = min(self.INITIAL_BATCH_SIZE, len(self.df))
|
|
974
|
+
sample_lf = self.df.lazy().slice(0, sample_size)
|
|
975
|
+
|
|
976
|
+
# Determine widths for each column
|
|
977
|
+
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
978
|
+
if col in self.hidden_columns:
|
|
979
|
+
continue
|
|
980
|
+
|
|
981
|
+
# Get column label width
|
|
982
|
+
# Add padding for sort indicators if any
|
|
983
|
+
label_width = measure(self.app.console, col, 1) + 2
|
|
984
|
+
|
|
985
|
+
try:
|
|
986
|
+
# Get sample values from the column
|
|
987
|
+
sample_values = sample_lf.select(col).collect().get_column(col).to_list()
|
|
988
|
+
|
|
989
|
+
# Find maximum width in sample
|
|
990
|
+
max_cell_width = max(
|
|
991
|
+
(measure(self.app.console, str(val), 1) for val in sample_values if val),
|
|
992
|
+
default=label_width,
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
# Set column width to max of label and sampled data (capped at reasonable max)
|
|
996
|
+
max_width = max(label_width, max_cell_width)
|
|
997
|
+
except Exception:
|
|
998
|
+
# If any error, let Textual auto-size
|
|
999
|
+
max_width = label_width
|
|
1000
|
+
|
|
1001
|
+
if dtype == pl.String:
|
|
1002
|
+
column_widths[col] = max_width
|
|
1003
|
+
|
|
1004
|
+
available_width -= max_width
|
|
1005
|
+
|
|
1006
|
+
# If there's no more available width, auto-size remaining columns
|
|
1007
|
+
if available_width < 0:
|
|
1008
|
+
for col in column_widths:
|
|
1009
|
+
if column_widths[col] > STRING_WIDTH_CAP:
|
|
1010
|
+
column_widths[col] = STRING_WIDTH_CAP # Cap string columns
|
|
1011
|
+
|
|
1012
|
+
return column_widths
|
|
1013
|
+
|
|
901
1014
|
def _setup_columns(self) -> None:
|
|
902
1015
|
"""Clear table and setup columns.
|
|
903
1016
|
|
|
904
1017
|
Column keys are header names from the dataframe.
|
|
905
1018
|
Column labels contain column names from the dataframe, with sort indicators if applicable.
|
|
906
1019
|
"""
|
|
907
|
-
self.loaded_rows = 0
|
|
908
1020
|
self.clear(columns=True)
|
|
909
|
-
|
|
1021
|
+
|
|
1022
|
+
# Get optimal column widths
|
|
1023
|
+
column_widths = self._determine_column_widths()
|
|
910
1024
|
|
|
911
1025
|
# Add columns with justified headers
|
|
912
1026
|
for col, dtype in zip(self.df.columns, self.df.dtypes):
|
|
@@ -924,45 +1038,120 @@ class DataFrameTable(DataTable):
|
|
|
924
1038
|
else: # No break occurred, so column is not sorted
|
|
925
1039
|
cell_value = col
|
|
926
1040
|
|
|
927
|
-
|
|
1041
|
+
# Get the width for this column (None means auto-size)
|
|
1042
|
+
width = column_widths.get(col)
|
|
928
1043
|
|
|
929
|
-
|
|
930
|
-
"""Load a batch of rows into the table.
|
|
1044
|
+
self.add_column(Text(cell_value, justify=DtypeConfig(dtype).justify), key=col, width=width)
|
|
931
1045
|
|
|
932
|
-
|
|
933
|
-
|
|
1046
|
+
def _load_rows(self, stop: int | None = None, move_to_end: bool = False) -> None:
|
|
1047
|
+
"""Load a batch of rows into the table (synchronous wrapper).
|
|
934
1048
|
|
|
935
1049
|
Args:
|
|
936
|
-
stop: Stop loading rows when this index is reached.
|
|
1050
|
+
stop: Stop loading rows when this index is reached.
|
|
1051
|
+
If None, load until the end of the dataframe.
|
|
937
1052
|
"""
|
|
938
1053
|
if stop is None or stop > len(self.df):
|
|
939
1054
|
stop = len(self.df)
|
|
940
1055
|
|
|
1056
|
+
# If already loaded enough rows, just move cursor if needed
|
|
941
1057
|
if stop <= self.loaded_rows:
|
|
1058
|
+
if move_to_end:
|
|
1059
|
+
self.move_cursor(row=self.row_count - 1)
|
|
1060
|
+
|
|
942
1061
|
return
|
|
943
1062
|
|
|
944
|
-
|
|
945
|
-
|
|
1063
|
+
# Warn user if loading a large number of rows
|
|
1064
|
+
elif (nrows := stop - self.loaded_rows) >= WARN_ROWS_THRESHOLD:
|
|
1065
|
+
|
|
1066
|
+
def _continue(result: bool) -> None:
|
|
1067
|
+
if result:
|
|
1068
|
+
self._load_rows_async(stop, move_to_end=move_to_end)
|
|
1069
|
+
|
|
1070
|
+
self.app.push_screen(
|
|
1071
|
+
ConfirmScreen(
|
|
1072
|
+
f"Load {nrows} Rows",
|
|
1073
|
+
label="Loading a large number of rows may cause the application to become unresponsive. Do you want to continue?",
|
|
1074
|
+
),
|
|
1075
|
+
callback=_continue,
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
return
|
|
1079
|
+
|
|
1080
|
+
# Load rows asynchronously
|
|
1081
|
+
self._load_rows_async(stop, move_to_end=move_to_end)
|
|
1082
|
+
|
|
1083
|
+
@work(exclusive=True, description="Loading rows...")
|
|
1084
|
+
async def _load_rows_async(self, stop: int, move_to_end: bool = False) -> None:
|
|
1085
|
+
"""Perform loading with async to avoid blocking.
|
|
1086
|
+
|
|
1087
|
+
Args:
|
|
1088
|
+
stop: Stop loading rows when this index is reached.
|
|
1089
|
+
move_to_end: If True, move cursor to the last loaded row after loading completes.
|
|
1090
|
+
"""
|
|
1091
|
+
# Load rows in smaller chunks to avoid blocking
|
|
1092
|
+
if stop > self.loaded_rows:
|
|
1093
|
+
self.log(f"Async loading up to row {self.loaded_rows = }, {stop = }")
|
|
1094
|
+
# Load incrementally to avoid one big block
|
|
1095
|
+
# Load max BATCH_SIZE rows at a time
|
|
1096
|
+
chunk_size = min(self.BATCH_SIZE, stop - self.loaded_rows)
|
|
1097
|
+
next_stop = min(self.loaded_rows + chunk_size, stop)
|
|
1098
|
+
self._load_rows_batch(next_stop)
|
|
1099
|
+
|
|
1100
|
+
# If there's more to load, yield to event loop with delay
|
|
1101
|
+
if next_stop < stop:
|
|
1102
|
+
await sleep_async(0.05) # 50ms delay to allow UI updates
|
|
1103
|
+
self._load_rows_async(stop, move_to_end=move_to_end)
|
|
1104
|
+
return
|
|
1105
|
+
|
|
1106
|
+
# After loading completes, move cursor to end if requested
|
|
1107
|
+
if move_to_end:
|
|
1108
|
+
self.call_after_refresh(lambda: self.move_cursor(row=self.row_count - 1))
|
|
1109
|
+
|
|
1110
|
+
def _load_rows_batch(self, stop: int) -> None:
|
|
1111
|
+
"""Load a batch of rows into the table.
|
|
1112
|
+
|
|
1113
|
+
Row keys are 0-based indices as strings, which map directly to dataframe row indices.
|
|
1114
|
+
Row labels are 1-based indices as strings.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
stop: Stop loading rows when this index is reached.
|
|
1118
|
+
"""
|
|
1119
|
+
try:
|
|
1120
|
+
start = self.loaded_rows
|
|
1121
|
+
df_slice = self.df.slice(start, stop - start)
|
|
1122
|
+
|
|
1123
|
+
for ridx, row in enumerate(df_slice.rows(), start):
|
|
1124
|
+
if not self.visible_rows[ridx]:
|
|
1125
|
+
continue # Skip hidden rows
|
|
946
1126
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
continue # Skip hidden rows
|
|
1127
|
+
is_selected = self.selected_rows[ridx]
|
|
1128
|
+
match_cols = self.matches.get(ridx, set())
|
|
950
1129
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
vals.append(val)
|
|
956
|
-
dtypes.append(dtype)
|
|
957
|
-
formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
|
|
1130
|
+
vals, dtypes, styles = [], [], []
|
|
1131
|
+
for cidx, (val, col, dtype) in enumerate(zip(row, self.df.columns, self.df.dtypes)):
|
|
1132
|
+
if col in self.hidden_columns:
|
|
1133
|
+
continue # Skip hidden columns
|
|
958
1134
|
|
|
959
|
-
|
|
960
|
-
|
|
1135
|
+
vals.append(val)
|
|
1136
|
+
dtypes.append(dtype)
|
|
961
1137
|
|
|
962
|
-
|
|
963
|
-
|
|
1138
|
+
# Highlight entire row with selection or cells with matches
|
|
1139
|
+
styles.append(HIGHLIGHT_COLOR if is_selected or cidx in match_cols else None)
|
|
964
1140
|
|
|
965
|
-
|
|
1141
|
+
formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
|
|
1142
|
+
|
|
1143
|
+
# Always add labels so they can be shown/hidden via CSS
|
|
1144
|
+
self.add_row(*formatted_row, key=str(ridx), label=str(ridx + 1))
|
|
1145
|
+
|
|
1146
|
+
# Update loaded rows count
|
|
1147
|
+
self.loaded_rows = stop
|
|
1148
|
+
|
|
1149
|
+
# self.notify(f"Loaded [$accent]{self.loaded_rows}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
|
|
1150
|
+
self.log(f"Loaded {self.loaded_rows}/{len(self.df)} rows from `{self.filename or self.name}`")
|
|
1151
|
+
|
|
1152
|
+
except Exception as e:
|
|
1153
|
+
self.notify("Error loading rows", title="Load", severity="error")
|
|
1154
|
+
self.log(f"Error loading rows: {str(e)}")
|
|
966
1155
|
|
|
967
1156
|
def _check_and_load_more(self) -> None:
|
|
968
1157
|
"""Check if we need to load more rows and load them."""
|
|
@@ -977,6 +1166,7 @@ class DataFrameTable(DataTable):
|
|
|
977
1166
|
if bottom_visible_row >= self.loaded_rows - 10:
|
|
978
1167
|
self._load_rows(self.loaded_rows + self.BATCH_SIZE)
|
|
979
1168
|
|
|
1169
|
+
# Highlighting
|
|
980
1170
|
def _do_highlight(self, force: bool = False) -> None:
|
|
981
1171
|
"""Update all rows, highlighting selected ones and restoring others to default.
|
|
982
1172
|
|
|
@@ -1013,7 +1203,7 @@ class DataFrameTable(DataTable):
|
|
|
1013
1203
|
need_update = False
|
|
1014
1204
|
|
|
1015
1205
|
if is_selected or col_idx in match_cols:
|
|
1016
|
-
cell_text.style =
|
|
1206
|
+
cell_text.style = HIGHLIGHT_COLOR
|
|
1017
1207
|
need_update = True
|
|
1018
1208
|
elif force:
|
|
1019
1209
|
# Restore original style based on dtype
|
|
@@ -1062,7 +1252,7 @@ class DataFrameTable(DataTable):
|
|
|
1062
1252
|
self.cursor_coordinate = history.cursor_coordinate
|
|
1063
1253
|
self.matches = {k: v.copy() for k, v in history.matches.items()} if history.matches else defaultdict(set)
|
|
1064
1254
|
|
|
1065
|
-
# Recreate
|
|
1255
|
+
# Recreate table for display
|
|
1066
1256
|
self._setup_table()
|
|
1067
1257
|
|
|
1068
1258
|
def _add_history(self, description: str) -> None:
|
|
@@ -1080,16 +1270,16 @@ class DataFrameTable(DataTable):
|
|
|
1080
1270
|
self.notify("No actions to undo", title="Undo", severity="warning")
|
|
1081
1271
|
return
|
|
1082
1272
|
|
|
1083
|
-
# Save current state for redo
|
|
1084
|
-
self.history = self._create_history("Redo state")
|
|
1085
|
-
|
|
1086
1273
|
# Pop the last history state for undo
|
|
1087
1274
|
history = self.histories.pop()
|
|
1088
1275
|
|
|
1276
|
+
# Save current state for redo
|
|
1277
|
+
self.history = self._create_history(history.description)
|
|
1278
|
+
|
|
1089
1279
|
# Restore state
|
|
1090
1280
|
self._apply_history(history)
|
|
1091
1281
|
|
|
1092
|
-
|
|
1282
|
+
self.notify(f"Reverted: {history.description}", title="Undo")
|
|
1093
1283
|
|
|
1094
1284
|
def _redo(self) -> None:
|
|
1095
1285
|
"""Redo the last undone action."""
|
|
@@ -1097,8 +1287,10 @@ class DataFrameTable(DataTable):
|
|
|
1097
1287
|
self.notify("No actions to redo", title="Redo", severity="warning")
|
|
1098
1288
|
return
|
|
1099
1289
|
|
|
1290
|
+
description = self.history.description
|
|
1291
|
+
|
|
1100
1292
|
# Save current state for undo
|
|
1101
|
-
self._add_history(
|
|
1293
|
+
self._add_history(description)
|
|
1102
1294
|
|
|
1103
1295
|
# Restore state
|
|
1104
1296
|
self._apply_history(self.history)
|
|
@@ -1106,9 +1298,16 @@ class DataFrameTable(DataTable):
|
|
|
1106
1298
|
# Clear redo state
|
|
1107
1299
|
self.history = None
|
|
1108
1300
|
|
|
1109
|
-
|
|
1301
|
+
self.notify(f"Reapplied: {description}", title="Redo")
|
|
1302
|
+
|
|
1303
|
+
# Display
|
|
1304
|
+
def _cycle_cursor_type(self) -> None:
|
|
1305
|
+
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
1306
|
+
next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
|
|
1307
|
+
self.cursor_type = next_type
|
|
1308
|
+
|
|
1309
|
+
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
|
|
1110
1310
|
|
|
1111
|
-
# View
|
|
1112
1311
|
def _view_row_detail(self) -> None:
|
|
1113
1312
|
"""Open a modal screen to view the selected row's details."""
|
|
1114
1313
|
ridx = self.cursor_row_idx
|
|
@@ -1161,11 +1360,127 @@ class DataFrameTable(DataTable):
|
|
|
1161
1360
|
if fixed_columns >= 0:
|
|
1162
1361
|
self.fixed_columns = fixed_columns
|
|
1163
1362
|
|
|
1363
|
+
# self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
|
|
1364
|
+
|
|
1365
|
+
def _hide_column(self) -> None:
|
|
1366
|
+
"""Hide the currently selected column from the table display."""
|
|
1367
|
+
col_key = self.cursor_col_key
|
|
1368
|
+
col_name = col_key.value
|
|
1369
|
+
col_idx = self.cursor_column
|
|
1370
|
+
|
|
1371
|
+
# Add to history
|
|
1372
|
+
self._add_history(f"Hid column [$success]{col_name}[/]")
|
|
1373
|
+
|
|
1374
|
+
# Remove the column from the table display (but keep in dataframe)
|
|
1375
|
+
self.remove_column(col_key)
|
|
1376
|
+
|
|
1377
|
+
# Track hidden columns
|
|
1378
|
+
self.hidden_columns.add(col_name)
|
|
1379
|
+
|
|
1380
|
+
# Move cursor left if we hid the last column
|
|
1381
|
+
if col_idx >= len(self.columns):
|
|
1382
|
+
self.move_cursor(column=len(self.columns) - 1)
|
|
1383
|
+
|
|
1384
|
+
# self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
|
|
1385
|
+
|
|
1386
|
+
def _expand_column(self) -> None:
|
|
1387
|
+
"""Expand the current column to show the widest cell in the loaded data."""
|
|
1388
|
+
col_idx = self.cursor_col_idx
|
|
1389
|
+
col_key = self.cursor_col_key
|
|
1390
|
+
col_name = col_key.value
|
|
1391
|
+
dtype = self.df.dtypes[col_idx]
|
|
1392
|
+
|
|
1393
|
+
# Only expand string columns
|
|
1394
|
+
if dtype != pl.String:
|
|
1395
|
+
return
|
|
1396
|
+
|
|
1397
|
+
# Calculate the maximum width across all loaded rows
|
|
1398
|
+
max_width = len(col_name) + 2 # Start with column name width + padding
|
|
1399
|
+
|
|
1400
|
+
try:
|
|
1401
|
+
# Scan through all loaded rows that are visible to find max width
|
|
1402
|
+
for row_idx in range(self.loaded_rows):
|
|
1403
|
+
if not self.visible_rows[row_idx]:
|
|
1404
|
+
continue # Skip hidden rows
|
|
1405
|
+
cell_value = str(self.df.item(row_idx, col_idx))
|
|
1406
|
+
cell_width = measure(self.app.console, cell_value, 1)
|
|
1407
|
+
max_width = max(max_width, cell_width)
|
|
1408
|
+
|
|
1409
|
+
# Update the column width
|
|
1410
|
+
col = self.columns[col_key]
|
|
1411
|
+
col.width = max_width
|
|
1412
|
+
|
|
1413
|
+
# Force a refresh
|
|
1414
|
+
self._update_count += 1
|
|
1415
|
+
self._require_update_dimensions = True
|
|
1416
|
+
self.refresh(layout=True)
|
|
1417
|
+
|
|
1418
|
+
# self.notify(f"Expanded column [$success]{col_name}[/] to width [$accent]{max_width}[/]", title="Expand")
|
|
1419
|
+
except Exception as e:
|
|
1420
|
+
self.notify("Error expanding column", title="Expand", severity="error")
|
|
1421
|
+
self.log(f"Error expanding column `{col_name}`: {str(e)}")
|
|
1422
|
+
|
|
1423
|
+
def _show_hidden_rows_columns(self) -> None:
|
|
1424
|
+
"""Show all hidden rows/columns by recreating the table."""
|
|
1425
|
+
# Get currently visible columns
|
|
1426
|
+
visible_cols = set(col.key for col in self.ordered_columns)
|
|
1427
|
+
|
|
1428
|
+
hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
|
|
1429
|
+
hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
|
|
1430
|
+
|
|
1431
|
+
if not hidden_row_count and not hidden_col_count:
|
|
1432
|
+
self.notify("No hidden columns or rows to show", title="Show", severity="warning")
|
|
1433
|
+
return
|
|
1434
|
+
|
|
1435
|
+
# Add to history
|
|
1436
|
+
self._add_history("Showed hidden rows/columns")
|
|
1437
|
+
|
|
1438
|
+
# Clear hidden rows/columns tracking
|
|
1439
|
+
self.visible_rows = [True] * len(self.df)
|
|
1440
|
+
self.hidden_columns.clear()
|
|
1441
|
+
|
|
1442
|
+
# Recreate table for display
|
|
1443
|
+
self._setup_table()
|
|
1444
|
+
|
|
1164
1445
|
self.notify(
|
|
1165
|
-
f"
|
|
1166
|
-
title="
|
|
1446
|
+
f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
|
|
1447
|
+
title="Show",
|
|
1167
1448
|
)
|
|
1168
1449
|
|
|
1450
|
+
def _make_cell_clickable(self) -> None:
|
|
1451
|
+
"""Make cells with URLs in the current column clickable.
|
|
1452
|
+
|
|
1453
|
+
Scans all loaded rows in the current column for cells containing URLs
|
|
1454
|
+
(starting with 'http://' or 'https://') and applies Textual link styling
|
|
1455
|
+
to make them clickable. Does not modify the dataframe.
|
|
1456
|
+
|
|
1457
|
+
Returns:
|
|
1458
|
+
None
|
|
1459
|
+
"""
|
|
1460
|
+
cidx = self.cursor_col_idx
|
|
1461
|
+
col_key = self.cursor_col_key
|
|
1462
|
+
dtype = self.df.dtypes[cidx]
|
|
1463
|
+
|
|
1464
|
+
# Only process string columns
|
|
1465
|
+
if dtype != pl.String:
|
|
1466
|
+
return
|
|
1467
|
+
|
|
1468
|
+
# Count how many URLs were made clickable
|
|
1469
|
+
url_count = 0
|
|
1470
|
+
|
|
1471
|
+
# Iterate through all loaded rows and make URLs clickable
|
|
1472
|
+
for row in self.ordered_rows:
|
|
1473
|
+
cell_text: Text = self.get_cell(row.key, col_key)
|
|
1474
|
+
if cell_text.plain.startswith(("http://", "https://")):
|
|
1475
|
+
cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
|
|
1476
|
+
self.update_cell(row.key, col_key, cell_text)
|
|
1477
|
+
url_count += 1
|
|
1478
|
+
|
|
1479
|
+
if url_count:
|
|
1480
|
+
self.notify(
|
|
1481
|
+
f"Use Ctrl/Cmd click to open the links in column [$success]{col_key.value}[/]", title="Hyperlink"
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1169
1484
|
# Delete & Move
|
|
1170
1485
|
def _delete_column(self, more: str = None) -> None:
|
|
1171
1486
|
"""Remove the currently selected column from the table."""
|
|
@@ -1184,7 +1499,7 @@ class DataFrameTable(DataTable):
|
|
|
1184
1499
|
col_names_to_remove.append(col_key.value)
|
|
1185
1500
|
col_keys_to_remove.append(col_key)
|
|
1186
1501
|
|
|
1187
|
-
|
|
1502
|
+
message = f"Removed column [$success]{col_name}[/] and all columns before"
|
|
1188
1503
|
|
|
1189
1504
|
# Remove all columns after the current column
|
|
1190
1505
|
elif more == "after":
|
|
@@ -1193,16 +1508,16 @@ class DataFrameTable(DataTable):
|
|
|
1193
1508
|
col_names_to_remove.append(col_key.value)
|
|
1194
1509
|
col_keys_to_remove.append(col_key)
|
|
1195
1510
|
|
|
1196
|
-
|
|
1511
|
+
message = f"Removed column [$success]{col_name}[/] and all columns after"
|
|
1197
1512
|
|
|
1198
1513
|
# Remove only the current column
|
|
1199
1514
|
else:
|
|
1200
1515
|
col_names_to_remove.append(col_name)
|
|
1201
1516
|
col_keys_to_remove.append(col_key)
|
|
1202
|
-
|
|
1517
|
+
message = f"Removed column [$success]{col_name}[/]"
|
|
1203
1518
|
|
|
1204
1519
|
# Add to history
|
|
1205
|
-
self._add_history(
|
|
1520
|
+
self._add_history(message)
|
|
1206
1521
|
|
|
1207
1522
|
# Remove the columns from the table display using the column names as keys
|
|
1208
1523
|
for ck in col_keys_to_remove:
|
|
@@ -1229,55 +1544,7 @@ class DataFrameTable(DataTable):
|
|
|
1229
1544
|
# Remove from dataframe
|
|
1230
1545
|
self.df = self.df.drop(col_names_to_remove)
|
|
1231
1546
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
def _hide_column(self) -> None:
|
|
1235
|
-
"""Hide the currently selected column from the table display."""
|
|
1236
|
-
col_key = self.cursor_col_key
|
|
1237
|
-
col_name = col_key.value
|
|
1238
|
-
col_idx = self.cursor_column
|
|
1239
|
-
|
|
1240
|
-
# Add to history
|
|
1241
|
-
self._add_history(f"Hid column [$success]{col_name}[/]")
|
|
1242
|
-
|
|
1243
|
-
# Remove the column from the table display (but keep in dataframe)
|
|
1244
|
-
self.remove_column(col_key)
|
|
1245
|
-
|
|
1246
|
-
# Track hidden columns
|
|
1247
|
-
self.hidden_columns.add(col_name)
|
|
1248
|
-
|
|
1249
|
-
# Move cursor left if we hid the last column
|
|
1250
|
-
if col_idx >= len(self.columns):
|
|
1251
|
-
self.move_cursor(column=len(self.columns) - 1)
|
|
1252
|
-
|
|
1253
|
-
# self.notify(f"Hid column [$accent]{col_name}[/]. Press [$success]H[/] to show hidden columns", title="Hide")
|
|
1254
|
-
|
|
1255
|
-
def _show_hidden_rows_columns(self) -> None:
|
|
1256
|
-
"""Show all hidden rows/columns by recreating the table."""
|
|
1257
|
-
# Get currently visible columns
|
|
1258
|
-
visible_cols = set(col.key for col in self.ordered_columns)
|
|
1259
|
-
|
|
1260
|
-
hidden_row_count = sum(0 if visible else 1 for visible in self.visible_rows)
|
|
1261
|
-
hidden_col_count = sum(0 if col in visible_cols else 1 for col in self.df.columns)
|
|
1262
|
-
|
|
1263
|
-
if not hidden_row_count and not hidden_col_count:
|
|
1264
|
-
self.notify("No hidden columns or rows to show", title="Show", severity="warning")
|
|
1265
|
-
return
|
|
1266
|
-
|
|
1267
|
-
# Add to history
|
|
1268
|
-
self._add_history("Showed hidden rows/columns")
|
|
1269
|
-
|
|
1270
|
-
# Clear hidden rows/columns tracking
|
|
1271
|
-
self.visible_rows = [True] * len(self.df)
|
|
1272
|
-
self.hidden_columns.clear()
|
|
1273
|
-
|
|
1274
|
-
# Recreate table for display
|
|
1275
|
-
self._setup_table()
|
|
1276
|
-
|
|
1277
|
-
self.notify(
|
|
1278
|
-
f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] hidden column(s)",
|
|
1279
|
-
title="Show",
|
|
1280
|
-
)
|
|
1547
|
+
self.notify(message, title="Delete")
|
|
1281
1548
|
|
|
1282
1549
|
def _duplicate_column(self) -> None:
|
|
1283
1550
|
"""Duplicate the currently selected column, inserting it right after the current column."""
|
|
@@ -1311,16 +1578,13 @@ class DataFrameTable(DataTable):
|
|
|
1311
1578
|
new_matches[row_idx] = new_cols
|
|
1312
1579
|
self.matches = new_matches
|
|
1313
1580
|
|
|
1314
|
-
# Recreate
|
|
1581
|
+
# Recreate table for display
|
|
1315
1582
|
self._setup_table()
|
|
1316
1583
|
|
|
1317
1584
|
# Move cursor to the new duplicated column
|
|
1318
1585
|
self.move_cursor(column=col_idx + 1)
|
|
1319
1586
|
|
|
1320
|
-
self.notify(
|
|
1321
|
-
f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]",
|
|
1322
|
-
title="Duplicate",
|
|
1323
|
-
)
|
|
1587
|
+
# self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
|
|
1324
1588
|
|
|
1325
1589
|
def _delete_row(self, more: str = None) -> None:
|
|
1326
1590
|
"""Delete rows from the table and dataframe.
|
|
@@ -1381,11 +1645,11 @@ class DataFrameTable(DataTable):
|
|
|
1381
1645
|
# Clear all matches since row indices have changed
|
|
1382
1646
|
self.matches = defaultdict(set)
|
|
1383
1647
|
|
|
1384
|
-
# Recreate
|
|
1648
|
+
# Recreate table for display
|
|
1385
1649
|
self._setup_table()
|
|
1386
1650
|
|
|
1387
1651
|
deleted_count = old_count - len(self.df)
|
|
1388
|
-
if deleted_count >
|
|
1652
|
+
if deleted_count > 0:
|
|
1389
1653
|
self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
|
|
1390
1654
|
|
|
1391
1655
|
def _duplicate_row(self) -> None:
|
|
@@ -1420,7 +1684,7 @@ class DataFrameTable(DataTable):
|
|
|
1420
1684
|
new_matches[row_idx + 1] = cols
|
|
1421
1685
|
self.matches = new_matches
|
|
1422
1686
|
|
|
1423
|
-
# Recreate
|
|
1687
|
+
# Recreate table for display
|
|
1424
1688
|
self._setup_table()
|
|
1425
1689
|
|
|
1426
1690
|
# Move cursor to the new duplicated row
|
|
@@ -1503,7 +1767,7 @@ class DataFrameTable(DataTable):
|
|
|
1503
1767
|
return
|
|
1504
1768
|
swap_idx = row_idx + 1
|
|
1505
1769
|
else:
|
|
1506
|
-
|
|
1770
|
+
# Invalid direction
|
|
1507
1771
|
return
|
|
1508
1772
|
|
|
1509
1773
|
row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
|
|
@@ -1594,7 +1858,7 @@ class DataFrameTable(DataTable):
|
|
|
1594
1858
|
# Update the dataframe
|
|
1595
1859
|
self.df = df_sorted.drop(RIDX)
|
|
1596
1860
|
|
|
1597
|
-
# Recreate
|
|
1861
|
+
# Recreate table for display
|
|
1598
1862
|
self._setup_table()
|
|
1599
1863
|
|
|
1600
1864
|
# Restore cursor position on the sorted column
|
|
@@ -1607,7 +1871,7 @@ class DataFrameTable(DataTable):
|
|
|
1607
1871
|
cidx = self.cursor_col_idx if cidx is None else cidx
|
|
1608
1872
|
col_name = self.df.columns[cidx]
|
|
1609
1873
|
|
|
1610
|
-
#
|
|
1874
|
+
# Add to history
|
|
1611
1875
|
self._add_history(f"Edited cell [$success]({ridx + 1}, {col_name})[/]")
|
|
1612
1876
|
|
|
1613
1877
|
# Push the edit modal screen
|
|
@@ -1653,9 +1917,10 @@ class DataFrameTable(DataTable):
|
|
|
1653
1917
|
col_key = col_name
|
|
1654
1918
|
self.update_cell(row_key, col_key, formatted_value, update_width=True)
|
|
1655
1919
|
|
|
1656
|
-
self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1920
|
+
# self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
|
|
1657
1921
|
except Exception as e:
|
|
1658
|
-
self.notify(
|
|
1922
|
+
self.notify("Error updating cell", title="Edit", severity="error")
|
|
1923
|
+
self.log(f"Error updating cell: {str(e)}")
|
|
1659
1924
|
|
|
1660
1925
|
def _edit_column(self) -> None:
|
|
1661
1926
|
"""Open modal to edit the entire column with an expression."""
|
|
@@ -1682,9 +1947,10 @@ class DataFrameTable(DataTable):
|
|
|
1682
1947
|
# Check if term is a valid expression
|
|
1683
1948
|
elif tentative_expr(term):
|
|
1684
1949
|
try:
|
|
1685
|
-
expr = validate_expr(term, self.df, cidx)
|
|
1950
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
1686
1951
|
except Exception as e:
|
|
1687
|
-
self.notify(f"Error validating expression [$error]{term}[/]
|
|
1952
|
+
self.notify(f"Error validating expression [$error]{term}[/]", title="Edit", severity="error")
|
|
1953
|
+
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
1688
1954
|
return
|
|
1689
1955
|
|
|
1690
1956
|
# Otherwise, treat term as a literal value
|
|
@@ -1695,7 +1961,7 @@ class DataFrameTable(DataTable):
|
|
|
1695
1961
|
expr = pl.lit(value)
|
|
1696
1962
|
except Exception:
|
|
1697
1963
|
self.notify(
|
|
1698
|
-
f"
|
|
1964
|
+
f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
1699
1965
|
title="Edit",
|
|
1700
1966
|
severity="error",
|
|
1701
1967
|
)
|
|
@@ -1708,16 +1974,18 @@ class DataFrameTable(DataTable):
|
|
|
1708
1974
|
# Apply the expression to the column
|
|
1709
1975
|
self.df = self.df.with_columns(expr.alias(col_name))
|
|
1710
1976
|
except Exception as e:
|
|
1711
|
-
self.notify(
|
|
1977
|
+
self.notify(
|
|
1978
|
+
f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
|
|
1979
|
+
title="Edit",
|
|
1980
|
+
severity="error",
|
|
1981
|
+
)
|
|
1982
|
+
self.log(f"Error applying expression `{term}` to column `{col_name}`: {str(e)}")
|
|
1712
1983
|
return
|
|
1713
1984
|
|
|
1714
|
-
# Recreate
|
|
1985
|
+
# Recreate table for display
|
|
1715
1986
|
self._setup_table()
|
|
1716
1987
|
|
|
1717
|
-
self.notify(
|
|
1718
|
-
f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]",
|
|
1719
|
-
title="Edit",
|
|
1720
|
-
)
|
|
1988
|
+
# self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
|
|
1721
1989
|
|
|
1722
1990
|
def _rename_column(self) -> None:
|
|
1723
1991
|
"""Open modal to rename the selected column."""
|
|
@@ -1758,16 +2026,13 @@ class DataFrameTable(DataTable):
|
|
|
1758
2026
|
self.hidden_columns.remove(col_name)
|
|
1759
2027
|
self.hidden_columns.add(new_name)
|
|
1760
2028
|
|
|
1761
|
-
# Recreate
|
|
2029
|
+
# Recreate table for display
|
|
1762
2030
|
self._setup_table()
|
|
1763
2031
|
|
|
1764
2032
|
# Move cursor to the renamed column
|
|
1765
2033
|
self.move_cursor(column=col_idx)
|
|
1766
2034
|
|
|
1767
|
-
self.notify(
|
|
1768
|
-
f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]",
|
|
1769
|
-
title="Column",
|
|
1770
|
-
)
|
|
2035
|
+
# self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
|
|
1771
2036
|
|
|
1772
2037
|
def _clear_cell(self) -> None:
|
|
1773
2038
|
"""Clear the current cell by setting its value to None."""
|
|
@@ -1795,9 +2060,10 @@ class DataFrameTable(DataTable):
|
|
|
1795
2060
|
|
|
1796
2061
|
self.update_cell(row_key, col_key, formatted_value)
|
|
1797
2062
|
|
|
1798
|
-
self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
2063
|
+
# self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
|
|
1799
2064
|
except Exception as e:
|
|
1800
|
-
self.notify(
|
|
2065
|
+
self.notify("Error clearing cell", title="Clear", severity="error")
|
|
2066
|
+
self.log(f"Error clearing cell: {str(e)}")
|
|
1801
2067
|
raise e
|
|
1802
2068
|
|
|
1803
2069
|
def _add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
|
|
@@ -1834,15 +2100,16 @@ class DataFrameTable(DataTable):
|
|
|
1834
2100
|
select_cols = cols_before + [new_name] + cols_after
|
|
1835
2101
|
self.df = self.df.with_columns(new_col).select(select_cols)
|
|
1836
2102
|
|
|
1837
|
-
# Recreate
|
|
2103
|
+
# Recreate table for display
|
|
1838
2104
|
self._setup_table()
|
|
1839
2105
|
|
|
1840
2106
|
# Move cursor to the new column
|
|
1841
2107
|
self.move_cursor(column=cidx + 1)
|
|
1842
2108
|
|
|
1843
|
-
self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
2109
|
+
# self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
|
|
1844
2110
|
except Exception as e:
|
|
1845
|
-
self.notify(
|
|
2111
|
+
self.notify("Error adding column", title="Add Column", severity="error")
|
|
2112
|
+
self.log(f"Error adding column: {str(e)}")
|
|
1846
2113
|
raise e
|
|
1847
2114
|
|
|
1848
2115
|
def _add_column_expr(self) -> None:
|
|
@@ -1876,7 +2143,7 @@ class DataFrameTable(DataTable):
|
|
|
1876
2143
|
select_cols = cols_before + [col_name] + cols_after
|
|
1877
2144
|
self.df = self.df.with_row_index(RIDX).with_columns(new_col).select(select_cols)
|
|
1878
2145
|
|
|
1879
|
-
# Recreate
|
|
2146
|
+
# Recreate table for display
|
|
1880
2147
|
self._setup_table()
|
|
1881
2148
|
|
|
1882
2149
|
# Move cursor to the new column
|
|
@@ -1884,53 +2151,33 @@ class DataFrameTable(DataTable):
|
|
|
1884
2151
|
|
|
1885
2152
|
# self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
|
|
1886
2153
|
except Exception as e:
|
|
1887
|
-
self.notify(
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
def _string_to_polars_dtype(self, dtype_str: str) -> pl.DataType:
|
|
1891
|
-
"""Convert string type name to Polars DataType.
|
|
1892
|
-
|
|
1893
|
-
Args:
|
|
1894
|
-
dtype_str: String representation of the type ("string", "int", "float", "bool")
|
|
1895
|
-
|
|
1896
|
-
Returns:
|
|
1897
|
-
Corresponding Polars DataType
|
|
2154
|
+
self.notify("Error adding column", title="Add Column", severity="error")
|
|
2155
|
+
self.log(f"Error adding column `{col_name}`: {str(e)}")
|
|
1898
2156
|
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
"""
|
|
1902
|
-
dtype_map = {
|
|
1903
|
-
"string": pl.String,
|
|
1904
|
-
"int": pl.Int64,
|
|
1905
|
-
"float": pl.Float64,
|
|
1906
|
-
"bool": pl.Boolean,
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
dtype_lower = dtype_str.lower().strip()
|
|
1910
|
-
return dtype_map.get(dtype_lower)
|
|
1911
|
-
|
|
1912
|
-
def _cast_column_dtype(self, dtype: str | pl.DataType = pl.String) -> None:
|
|
2157
|
+
# Type Casting
|
|
2158
|
+
def _cast_column_dtype(self, dtype: str) -> None:
|
|
1913
2159
|
"""Cast the current column to a different data type.
|
|
1914
2160
|
|
|
1915
2161
|
Args:
|
|
1916
|
-
dtype: Target data type (string
|
|
2162
|
+
dtype: Target data type (string representation, e.g., "pl.String", "pl.Int64")
|
|
1917
2163
|
"""
|
|
1918
2164
|
cidx = self.cursor_col_idx
|
|
1919
2165
|
col_name = self.cursor_col_name
|
|
1920
2166
|
current_dtype = self.df.dtypes[cidx]
|
|
1921
2167
|
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2168
|
+
try:
|
|
2169
|
+
target_dtype = eval(dtype)
|
|
2170
|
+
except Exception:
|
|
2171
|
+
self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error")
|
|
2172
|
+
return
|
|
2173
|
+
|
|
2174
|
+
if current_dtype == target_dtype:
|
|
2175
|
+
self.notify(
|
|
2176
|
+
f"Column [$accent]{col_name}[/] is already of type [$success]{target_dtype}[/]",
|
|
2177
|
+
title="Cast",
|
|
2178
|
+
severity="warning",
|
|
2179
|
+
)
|
|
2180
|
+
return # No change needed
|
|
1934
2181
|
|
|
1935
2182
|
# Add to history
|
|
1936
2183
|
self._add_history(
|
|
@@ -1941,17 +2188,19 @@ class DataFrameTable(DataTable):
|
|
|
1941
2188
|
# Cast the column using Polars
|
|
1942
2189
|
self.df = self.df.with_columns(pl.col(col_name).cast(target_dtype))
|
|
1943
2190
|
|
|
1944
|
-
# Recreate
|
|
2191
|
+
# Recreate table for display
|
|
1945
2192
|
self._setup_table()
|
|
1946
2193
|
|
|
2194
|
+
self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
|
|
2195
|
+
except Exception as e:
|
|
1947
2196
|
self.notify(
|
|
1948
|
-
f"
|
|
2197
|
+
f"Error casting column [$accent]{col_name}[/] to [$error]{target_dtype}[/]",
|
|
1949
2198
|
title="Cast",
|
|
2199
|
+
severity="error",
|
|
1950
2200
|
)
|
|
1951
|
-
|
|
1952
|
-
self.notify(f"Failed to cast column: {str(e)}", title="Cast", severity="error")
|
|
1953
|
-
raise e
|
|
2201
|
+
self.log(f"Error casting column `{col_name}`: {str(e)}")
|
|
1954
2202
|
|
|
2203
|
+
# Search
|
|
1955
2204
|
def _search_cursor_value(self) -> None:
|
|
1956
2205
|
"""Search with cursor value in current column."""
|
|
1957
2206
|
cidx = self.cursor_col_idx
|
|
@@ -1959,7 +2208,7 @@ class DataFrameTable(DataTable):
|
|
|
1959
2208
|
# Get the value of the currently selected cell
|
|
1960
2209
|
term = NULL if self.cursor_value is None else str(self.cursor_value)
|
|
1961
2210
|
|
|
1962
|
-
self._do_search((term, cidx, False,
|
|
2211
|
+
self._do_search((term, cidx, False, True))
|
|
1963
2212
|
|
|
1964
2213
|
def _search_expr(self) -> None:
|
|
1965
2214
|
"""Search by expression."""
|
|
@@ -1978,6 +2227,7 @@ class DataFrameTable(DataTable):
|
|
|
1978
2227
|
"""Search for a term."""
|
|
1979
2228
|
if result is None:
|
|
1980
2229
|
return
|
|
2230
|
+
|
|
1981
2231
|
term, cidx, match_nocase, match_whole = result
|
|
1982
2232
|
col_name = self.df.columns[cidx]
|
|
1983
2233
|
|
|
@@ -1987,13 +2237,10 @@ class DataFrameTable(DataTable):
|
|
|
1987
2237
|
# Support for polars expressions
|
|
1988
2238
|
elif tentative_expr(term):
|
|
1989
2239
|
try:
|
|
1990
|
-
expr = validate_expr(term, self.df, cidx)
|
|
2240
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
1991
2241
|
except Exception as e:
|
|
1992
|
-
self.notify(
|
|
1993
|
-
|
|
1994
|
-
title="Search",
|
|
1995
|
-
severity="error",
|
|
1996
|
-
)
|
|
2242
|
+
self.notify(f"Error validating expression [$error]{term}[/]", title="Search", severity="error")
|
|
2243
|
+
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
1997
2244
|
return
|
|
1998
2245
|
|
|
1999
2246
|
# Perform type-aware search based on column dtype
|
|
@@ -2016,7 +2263,7 @@ class DataFrameTable(DataTable):
|
|
|
2016
2263
|
term = f"(?i){term}"
|
|
2017
2264
|
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
2018
2265
|
self.notify(
|
|
2019
|
-
f"
|
|
2266
|
+
f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
|
|
2020
2267
|
title="Search",
|
|
2021
2268
|
severity="warning",
|
|
2022
2269
|
)
|
|
@@ -2030,17 +2277,14 @@ class DataFrameTable(DataTable):
|
|
|
2030
2277
|
try:
|
|
2031
2278
|
matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
|
|
2032
2279
|
except Exception as e:
|
|
2033
|
-
self.notify(
|
|
2034
|
-
|
|
2035
|
-
title="Search",
|
|
2036
|
-
severity="error",
|
|
2037
|
-
)
|
|
2280
|
+
self.notify(f"Error applying search filter [$error]{term}[/]", title="Search", severity="error")
|
|
2281
|
+
self.log(f"Error applying search filter `{term}`: {str(e)}")
|
|
2038
2282
|
return
|
|
2039
2283
|
|
|
2040
2284
|
match_count = len(matches)
|
|
2041
2285
|
if match_count == 0:
|
|
2042
2286
|
self.notify(
|
|
2043
|
-
f"No matches found for [$
|
|
2287
|
+
f"No matches found for [$accent]{term}[/]. Try [$warning](?i)abc[/] for case-insensitive search.",
|
|
2044
2288
|
title="Search",
|
|
2045
2289
|
severity="warning",
|
|
2046
2290
|
)
|
|
@@ -2053,11 +2297,13 @@ class DataFrameTable(DataTable):
|
|
|
2053
2297
|
for m in matches:
|
|
2054
2298
|
self.selected_rows[m] = True
|
|
2055
2299
|
|
|
2056
|
-
#
|
|
2057
|
-
self._do_highlight()
|
|
2058
|
-
|
|
2300
|
+
# Show notification immediately, then start highlighting
|
|
2059
2301
|
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
|
|
2060
2302
|
|
|
2303
|
+
# Recreate table for display
|
|
2304
|
+
self._setup_table()
|
|
2305
|
+
|
|
2306
|
+
# Find
|
|
2061
2307
|
def _find_matches(
|
|
2062
2308
|
self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
|
|
2063
2309
|
) -> dict[int, set[int]]:
|
|
@@ -2095,9 +2341,11 @@ class DataFrameTable(DataTable):
|
|
|
2095
2341
|
expr = pl.col(col_name).is_null()
|
|
2096
2342
|
elif tentative_expr(term):
|
|
2097
2343
|
try:
|
|
2098
|
-
expr = validate_expr(term, self.df, col_idx)
|
|
2344
|
+
expr = validate_expr(term, self.df.columns, col_idx)
|
|
2099
2345
|
except Exception as e:
|
|
2100
|
-
|
|
2346
|
+
self.notify(f"Error validating expression [$error]{term}[/]", title="Find", severity="error")
|
|
2347
|
+
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
2348
|
+
return matches
|
|
2101
2349
|
else:
|
|
2102
2350
|
if match_whole:
|
|
2103
2351
|
term = f"^{term}$"
|
|
@@ -2109,7 +2357,9 @@ class DataFrameTable(DataTable):
|
|
|
2109
2357
|
try:
|
|
2110
2358
|
matched_ridxs = lf.filter(expr).select(RIDX).collect().to_series().to_list()
|
|
2111
2359
|
except Exception as e:
|
|
2112
|
-
|
|
2360
|
+
self.notify(f"Error applying filter: {expr}", title="Find", severity="error")
|
|
2361
|
+
self.log(f"Error applying filter: {str(e)}")
|
|
2362
|
+
return matches
|
|
2113
2363
|
|
|
2114
2364
|
for ridx in matched_ridxs:
|
|
2115
2365
|
matches[ridx].add(col_idx)
|
|
@@ -2127,9 +2377,9 @@ class DataFrameTable(DataTable):
|
|
|
2127
2377
|
|
|
2128
2378
|
if scope == "column":
|
|
2129
2379
|
cidx = self.cursor_col_idx
|
|
2130
|
-
self._do_find((term, cidx, False,
|
|
2380
|
+
self._do_find((term, cidx, False, True))
|
|
2131
2381
|
else:
|
|
2132
|
-
self._do_find_global((term, None, False,
|
|
2382
|
+
self._do_find_global((term, None, False, True))
|
|
2133
2383
|
|
|
2134
2384
|
def _find_expr(self, scope="column") -> None:
|
|
2135
2385
|
"""Open screen to find by expression.
|
|
@@ -2158,16 +2408,13 @@ class DataFrameTable(DataTable):
|
|
|
2158
2408
|
try:
|
|
2159
2409
|
matches = self._find_matches(term, cidx, match_nocase, match_whole)
|
|
2160
2410
|
except Exception as e:
|
|
2161
|
-
self.notify(
|
|
2162
|
-
|
|
2163
|
-
title="Find",
|
|
2164
|
-
severity="error",
|
|
2165
|
-
)
|
|
2411
|
+
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
2412
|
+
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
2166
2413
|
return
|
|
2167
2414
|
|
|
2168
2415
|
if not matches:
|
|
2169
2416
|
self.notify(
|
|
2170
|
-
f"No matches found for [$
|
|
2417
|
+
f"No matches found for [$accent]{term}[/] in current column. Try [$warning](?i)abc[/] for case-insensitive search.",
|
|
2171
2418
|
title="Find",
|
|
2172
2419
|
severity="warning",
|
|
2173
2420
|
)
|
|
@@ -2181,11 +2428,11 @@ class DataFrameTable(DataTable):
|
|
|
2181
2428
|
for ridx, col_idxs in matches.items():
|
|
2182
2429
|
self.matches[ridx].update(col_idxs)
|
|
2183
2430
|
|
|
2184
|
-
# Highlight matches
|
|
2185
|
-
self._do_highlight()
|
|
2186
|
-
|
|
2187
2431
|
self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
|
|
2188
2432
|
|
|
2433
|
+
# Recreate table for display
|
|
2434
|
+
self._setup_table()
|
|
2435
|
+
|
|
2189
2436
|
def _do_find_global(self, result) -> None:
|
|
2190
2437
|
"""Global find a term across all columns."""
|
|
2191
2438
|
if result is None:
|
|
@@ -2195,16 +2442,13 @@ class DataFrameTable(DataTable):
|
|
|
2195
2442
|
try:
|
|
2196
2443
|
matches = self._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
|
|
2197
2444
|
except Exception as e:
|
|
2198
|
-
self.notify(
|
|
2199
|
-
|
|
2200
|
-
title="Find",
|
|
2201
|
-
severity="error",
|
|
2202
|
-
)
|
|
2445
|
+
self.notify(f"Error finding matches for [$error]{term}[/]", title="Find", severity="error")
|
|
2446
|
+
self.log(f"Error finding matches for `{term}`: {str(e)}")
|
|
2203
2447
|
return
|
|
2204
2448
|
|
|
2205
2449
|
if not matches:
|
|
2206
2450
|
self.notify(
|
|
2207
|
-
f"No matches found for [$
|
|
2451
|
+
f"No matches found for [$accent]{term}[/] in any column. Try [$warning](?i)abc[/] for case-insensitive search.",
|
|
2208
2452
|
title="Global Find",
|
|
2209
2453
|
severity="warning",
|
|
2210
2454
|
)
|
|
@@ -2218,14 +2462,13 @@ class DataFrameTable(DataTable):
|
|
|
2218
2462
|
for ridx, col_idxs in matches.items():
|
|
2219
2463
|
self.matches[ridx].update(col_idxs)
|
|
2220
2464
|
|
|
2221
|
-
# Highlight matches
|
|
2222
|
-
self._do_highlight()
|
|
2223
|
-
|
|
2224
2465
|
self.notify(
|
|
2225
|
-
f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns",
|
|
2226
|
-
title="Global Find",
|
|
2466
|
+
f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns", title="Global Find"
|
|
2227
2467
|
)
|
|
2228
2468
|
|
|
2469
|
+
# Recreate table for display
|
|
2470
|
+
self._setup_table()
|
|
2471
|
+
|
|
2229
2472
|
def _next_match(self) -> None:
|
|
2230
2473
|
"""Move cursor to the next match."""
|
|
2231
2474
|
if not self.matches:
|
|
@@ -2320,11 +2563,12 @@ class DataFrameTable(DataTable):
|
|
|
2320
2563
|
last_ridx = selected_row_indices[-1]
|
|
2321
2564
|
self.move_cursor_to(last_ridx, self.cursor_col_idx)
|
|
2322
2565
|
|
|
2566
|
+
# Replace
|
|
2323
2567
|
def _replace(self) -> None:
|
|
2324
2568
|
"""Open replace screen for current column."""
|
|
2325
2569
|
# Push the replace modal screen
|
|
2326
2570
|
self.app.push_screen(
|
|
2327
|
-
FindReplaceScreen(self),
|
|
2571
|
+
FindReplaceScreen(self, title="Find and Replace in Current Column"),
|
|
2328
2572
|
callback=self._do_replace,
|
|
2329
2573
|
)
|
|
2330
2574
|
|
|
@@ -2336,7 +2580,7 @@ class DataFrameTable(DataTable):
|
|
|
2336
2580
|
"""Open replace screen for all columns."""
|
|
2337
2581
|
# Push the replace modal screen
|
|
2338
2582
|
self.app.push_screen(
|
|
2339
|
-
FindReplaceScreen(self),
|
|
2583
|
+
FindReplaceScreen(self, title="Global Find and Replace"),
|
|
2340
2584
|
callback=self._do_replace_global,
|
|
2341
2585
|
)
|
|
2342
2586
|
|
|
@@ -2364,23 +2608,19 @@ class DataFrameTable(DataTable):
|
|
|
2364
2608
|
matches = self._find_matches(term_find, cidx, match_nocase, match_whole)
|
|
2365
2609
|
|
|
2366
2610
|
if not matches:
|
|
2367
|
-
self.notify(
|
|
2368
|
-
f"No matches found for [$warning]{term_find}[/]",
|
|
2369
|
-
title="Replace",
|
|
2370
|
-
severity="warning",
|
|
2371
|
-
)
|
|
2611
|
+
self.notify(f"No matches found for [$warning]{term_find}[/]", title="Replace", severity="warning")
|
|
2372
2612
|
return
|
|
2373
2613
|
|
|
2374
2614
|
# Add to history
|
|
2375
2615
|
self._add_history(
|
|
2376
|
-
f"
|
|
2616
|
+
f"Replaced [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
|
|
2377
2617
|
)
|
|
2378
2618
|
|
|
2379
2619
|
# Update matches
|
|
2380
|
-
self.matches = {ridx:
|
|
2620
|
+
self.matches = {ridx: col_idxs.copy() for ridx, col_idxs in matches.items()}
|
|
2381
2621
|
|
|
2382
|
-
#
|
|
2383
|
-
self.
|
|
2622
|
+
# Recreate table for display
|
|
2623
|
+
self._setup_table()
|
|
2384
2624
|
|
|
2385
2625
|
# Store state for interactive replacement using dataclass
|
|
2386
2626
|
self._replace_state = ReplaceState(
|
|
@@ -2410,10 +2650,11 @@ class DataFrameTable(DataTable):
|
|
|
2410
2650
|
|
|
2411
2651
|
except Exception as e:
|
|
2412
2652
|
self.notify(
|
|
2413
|
-
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]
|
|
2653
|
+
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
|
|
2414
2654
|
title="Replace",
|
|
2415
2655
|
severity="error",
|
|
2416
2656
|
)
|
|
2657
|
+
self.log(f"Error replacing `{term_find}` with `{term_replace}`: {str(e)}")
|
|
2417
2658
|
|
|
2418
2659
|
def _do_replace_all(self, term_find: str, term_replace: str) -> None:
|
|
2419
2660
|
"""Replace all occurrences."""
|
|
@@ -2466,7 +2707,7 @@ class DataFrameTable(DataTable):
|
|
|
2466
2707
|
|
|
2467
2708
|
state.replaced_occurrence += 1
|
|
2468
2709
|
|
|
2469
|
-
# Recreate
|
|
2710
|
+
# Recreate table for display
|
|
2470
2711
|
self._setup_table()
|
|
2471
2712
|
|
|
2472
2713
|
col_name = "all columns" if state.cidx is None else self.df.columns[state.cidx]
|
|
@@ -2482,10 +2723,11 @@ class DataFrameTable(DataTable):
|
|
|
2482
2723
|
self._show_next_replace_confirmation()
|
|
2483
2724
|
except Exception as e:
|
|
2484
2725
|
self.notify(
|
|
2485
|
-
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]
|
|
2726
|
+
f"Error replacing [$accent]{term_find}[/] with [$error]{term_replace}[/]",
|
|
2486
2727
|
title="Replace",
|
|
2487
2728
|
severity="error",
|
|
2488
2729
|
)
|
|
2730
|
+
self.log(f"Error in interactive replace: {str(e)}")
|
|
2489
2731
|
|
|
2490
2732
|
def _show_next_replace_confirmation(self) -> None:
|
|
2491
2733
|
"""Show confirmation for next replacement."""
|
|
@@ -2502,12 +2744,12 @@ class DataFrameTable(DataTable):
|
|
|
2502
2744
|
# Move cursor to next match
|
|
2503
2745
|
ridx = state.rows[state.current_rpos]
|
|
2504
2746
|
cidx = state.cols_per_row[state.current_rpos][state.current_cpos]
|
|
2505
|
-
self.
|
|
2747
|
+
self.move_cursor_to(ridx, cidx)
|
|
2506
2748
|
|
|
2507
2749
|
state.current_occurrence += 1
|
|
2508
2750
|
|
|
2509
2751
|
# Show confirmation
|
|
2510
|
-
label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] (
|
|
2752
|
+
label = f"Replace [$warning]{state.term_find}[/] with [$success]{state.term_replace}[/] ({state.current_occurrence} of {state.total_occurrence})?"
|
|
2511
2753
|
|
|
2512
2754
|
self.app.push_screen(
|
|
2513
2755
|
ConfirmScreen("Replace", label=label, maybe="Skip"),
|
|
@@ -2572,15 +2814,16 @@ class DataFrameTable(DataTable):
|
|
|
2572
2814
|
if state.current_rpos >= len(state.rows):
|
|
2573
2815
|
state.done = True
|
|
2574
2816
|
|
|
2575
|
-
# Recreate
|
|
2817
|
+
# Recreate table for display
|
|
2576
2818
|
self._setup_table()
|
|
2577
2819
|
|
|
2578
2820
|
# Show next confirmation
|
|
2579
2821
|
self._show_next_replace_confirmation()
|
|
2580
2822
|
|
|
2823
|
+
# Selection & Match
|
|
2581
2824
|
def _toggle_selections(self) -> None:
|
|
2582
2825
|
"""Toggle selected rows highlighting on/off."""
|
|
2583
|
-
#
|
|
2826
|
+
# Add to history
|
|
2584
2827
|
self._add_history("Toggled row selection")
|
|
2585
2828
|
|
|
2586
2829
|
if False in self.visible_rows:
|
|
@@ -2596,34 +2839,34 @@ class DataFrameTable(DataTable):
|
|
|
2596
2839
|
|
|
2597
2840
|
# Check if we're highlighting or un-highlighting
|
|
2598
2841
|
if new_selected_count := self.selected_rows.count(True):
|
|
2599
|
-
self.notify(
|
|
2600
|
-
f"Toggled selection for [$accent]{new_selected_count}[/] rows",
|
|
2601
|
-
title="Toggle",
|
|
2602
|
-
)
|
|
2842
|
+
self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
|
|
2603
2843
|
|
|
2604
|
-
#
|
|
2605
|
-
self.
|
|
2844
|
+
# Recreate table for display
|
|
2845
|
+
self._setup_table()
|
|
2606
2846
|
|
|
2607
|
-
def
|
|
2608
|
-
"""
|
|
2609
|
-
#
|
|
2847
|
+
def _toggle_row_selection(self) -> None:
|
|
2848
|
+
"""Select/deselect current row."""
|
|
2849
|
+
# Add to history
|
|
2610
2850
|
self._add_history("Toggled row selection")
|
|
2611
2851
|
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
for ridx in self.matches.keys():
|
|
2615
|
-
self.selected_rows[ridx] = True
|
|
2616
|
-
else:
|
|
2617
|
-
# No matched cells - select/deselect the current row
|
|
2618
|
-
ridx = self.cursor_row_idx
|
|
2619
|
-
self.selected_rows[ridx] = not self.selected_rows[ridx]
|
|
2852
|
+
ridx = self.cursor_row_idx
|
|
2853
|
+
self.selected_rows[ridx] = not self.selected_rows[ridx]
|
|
2620
2854
|
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2855
|
+
row_key = str(ridx)
|
|
2856
|
+
match_cols = self.matches.get(ridx, set())
|
|
2857
|
+
for col_idx, col in enumerate(self.ordered_columns):
|
|
2858
|
+
col_key = col.key
|
|
2859
|
+
cell_text: Text = self.get_cell(row_key, col_key)
|
|
2860
|
+
|
|
2861
|
+
if self.selected_rows[ridx] or (col_idx in match_cols):
|
|
2862
|
+
cell_text.style = HIGHLIGHT_COLOR
|
|
2863
|
+
else:
|
|
2864
|
+
# Reset to default style based on dtype
|
|
2865
|
+
dtype = self.df.dtypes[col_idx]
|
|
2866
|
+
dc = DtypeConfig(dtype)
|
|
2867
|
+
cell_text.style = dc.style
|
|
2624
2868
|
|
|
2625
|
-
|
|
2626
|
-
self._do_highlight(force=True)
|
|
2869
|
+
self.update_cell(row_key, col_key, cell_text)
|
|
2627
2870
|
|
|
2628
2871
|
def _clear_selections_and_matches(self) -> None:
|
|
2629
2872
|
"""Clear all selected rows and matches without removing them from the dataframe."""
|
|
@@ -2636,50 +2879,61 @@ class DataFrameTable(DataTable):
|
|
|
2636
2879
|
1 if (selected or idx in self.matches) else 0 for idx, selected in enumerate(self.selected_rows)
|
|
2637
2880
|
)
|
|
2638
2881
|
|
|
2639
|
-
#
|
|
2882
|
+
# Add to history
|
|
2640
2883
|
self._add_history("Cleared all selected rows")
|
|
2641
2884
|
|
|
2642
2885
|
# Clear all selections
|
|
2643
2886
|
self.selected_rows = [False] * len(self.df)
|
|
2644
2887
|
self.matches = defaultdict(set)
|
|
2645
2888
|
|
|
2646
|
-
#
|
|
2647
|
-
self.
|
|
2889
|
+
# Recreate table for display
|
|
2890
|
+
self._setup_table()
|
|
2648
2891
|
|
|
2649
2892
|
self.notify(f"Cleared selections for [$accent]{row_count}[/] rows", title="Clear")
|
|
2650
2893
|
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
if
|
|
2655
|
-
self.notify("No rows
|
|
2894
|
+
# Filter & View
|
|
2895
|
+
def _filter_rows(self) -> None:
|
|
2896
|
+
"""Keep only the rows with selections and matches, and remove others."""
|
|
2897
|
+
if not any(self.selected_rows) and not self.matches:
|
|
2898
|
+
self.notify("No rows to filter", title="Filter", severity="warning")
|
|
2656
2899
|
return
|
|
2657
2900
|
|
|
2658
|
-
|
|
2659
|
-
|
|
2901
|
+
filter_expr = [
|
|
2902
|
+
True if (selected or ridx in self.matches) else False for ridx, selected in enumerate(self.selected_rows)
|
|
2903
|
+
]
|
|
2904
|
+
|
|
2905
|
+
# Add to history
|
|
2906
|
+
self._add_history("Filtered to selections and matches")
|
|
2907
|
+
|
|
2908
|
+
# Apply filter to dataframe with row indices
|
|
2909
|
+
df_filtered = self.df.with_row_index(RIDX).filter(filter_expr)
|
|
2910
|
+
|
|
2911
|
+
# Update selections and matches
|
|
2912
|
+
self.selected_rows = [self.selected_rows[ridx] for ridx in df_filtered[RIDX]]
|
|
2913
|
+
self.matches = {
|
|
2914
|
+
idx: self.matches[ridx].copy() for idx, ridx in enumerate(df_filtered[RIDX]) if ridx in self.matches
|
|
2915
|
+
}
|
|
2660
2916
|
|
|
2661
|
-
# Update dataframe
|
|
2662
|
-
self.df =
|
|
2663
|
-
self.selected_rows = [True] * len(self.df)
|
|
2917
|
+
# Update dataframe
|
|
2918
|
+
self.df = df_filtered.drop(RIDX)
|
|
2664
2919
|
|
|
2665
|
-
# Recreate
|
|
2920
|
+
# Recreate table for display
|
|
2666
2921
|
self._setup_table()
|
|
2667
2922
|
|
|
2668
2923
|
self.notify(
|
|
2669
|
-
f"Removed
|
|
2670
|
-
title="Filter",
|
|
2924
|
+
f"Removed rows without selections or matches. Now showing [$accent]{len(self.df)}[/] rows", title="Filter"
|
|
2671
2925
|
)
|
|
2672
2926
|
|
|
2673
2927
|
def _view_rows(self) -> None:
|
|
2674
2928
|
"""View rows.
|
|
2675
2929
|
|
|
2676
|
-
If there are selected rows, view those rows.
|
|
2930
|
+
If there are selected rows or matches, view those rows.
|
|
2677
2931
|
Otherwise, view based on the value of the currently selected cell.
|
|
2678
2932
|
"""
|
|
2679
2933
|
|
|
2680
2934
|
cidx = self.cursor_col_idx
|
|
2681
2935
|
|
|
2682
|
-
# If there are
|
|
2936
|
+
# If there are rows with selections or matches, use those
|
|
2683
2937
|
if any(self.selected_rows) or self.matches:
|
|
2684
2938
|
term = [
|
|
2685
2939
|
True if (selected or idx in self.matches) else False for idx, selected in enumerate(self.selected_rows)
|
|
@@ -2689,7 +2943,7 @@ class DataFrameTable(DataTable):
|
|
|
2689
2943
|
ridx = self.cursor_row_idx
|
|
2690
2944
|
term = str(self.df.item(ridx, cidx))
|
|
2691
2945
|
|
|
2692
|
-
self._do_view_rows((term, cidx, False,
|
|
2946
|
+
self._do_view_rows((term, cidx, False, True))
|
|
2693
2947
|
|
|
2694
2948
|
def _view_rows_expr(self) -> None:
|
|
2695
2949
|
"""Open the filter screen to enter an expression."""
|
|
@@ -2703,7 +2957,7 @@ class DataFrameTable(DataTable):
|
|
|
2703
2957
|
)
|
|
2704
2958
|
|
|
2705
2959
|
def _do_view_rows(self, result) -> None:
|
|
2706
|
-
"""Show only
|
|
2960
|
+
"""Show only rows with selections or matches, and do hide others. Do not modify the dataframe."""
|
|
2707
2961
|
if result is None:
|
|
2708
2962
|
return
|
|
2709
2963
|
term, cidx, match_nocase, match_whole = result
|
|
@@ -2718,11 +2972,10 @@ class DataFrameTable(DataTable):
|
|
|
2718
2972
|
elif tentative_expr(term):
|
|
2719
2973
|
# Support for polars expressions
|
|
2720
2974
|
try:
|
|
2721
|
-
expr = validate_expr(term, self.df, cidx)
|
|
2975
|
+
expr = validate_expr(term, self.df.columns, cidx)
|
|
2722
2976
|
except Exception as e:
|
|
2723
|
-
self.notify(
|
|
2724
|
-
|
|
2725
|
-
)
|
|
2977
|
+
self.notify(f"Error validating expression [$error]{term}[/]", title="Filter", severity="error")
|
|
2978
|
+
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
2726
2979
|
return
|
|
2727
2980
|
else:
|
|
2728
2981
|
dtype = self.df.dtypes[cidx]
|
|
@@ -2743,9 +2996,7 @@ class DataFrameTable(DataTable):
|
|
|
2743
2996
|
term = f"(?i){term}"
|
|
2744
2997
|
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
2745
2998
|
self.notify(
|
|
2746
|
-
f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
|
|
2747
|
-
title="Filter",
|
|
2748
|
-
severity="warning",
|
|
2999
|
+
f"Unknown column type [$warning]{dtype}[/]. Cast to string.", title="Filter", severity="warning"
|
|
2749
3000
|
)
|
|
2750
3001
|
|
|
2751
3002
|
# Lazyframe with row indices
|
|
@@ -2759,17 +3010,14 @@ class DataFrameTable(DataTable):
|
|
|
2759
3010
|
try:
|
|
2760
3011
|
df_filtered = lf.filter(expr).collect()
|
|
2761
3012
|
except Exception as e:
|
|
2762
|
-
self.notify(f"Failed to apply filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
|
|
2763
3013
|
self.histories.pop() # Remove last history entry
|
|
3014
|
+
self.notify(f"Error applying filter [$error]{expr}[/]", title="Filter", severity="error")
|
|
3015
|
+
self.log(f"Error applying filter `{expr}`: {str(e)}")
|
|
2764
3016
|
return
|
|
2765
3017
|
|
|
2766
3018
|
matched_count = len(df_filtered)
|
|
2767
3019
|
if not matched_count:
|
|
2768
|
-
self.notify(
|
|
2769
|
-
f"No rows match the expression: [$success]{expr}[/]",
|
|
2770
|
-
title="Filter",
|
|
2771
|
-
severity="warning",
|
|
2772
|
-
)
|
|
3020
|
+
self.notify(f"No rows match the expression: [$success]{expr}[/]", title="Filter", severity="warning")
|
|
2773
3021
|
return
|
|
2774
3022
|
|
|
2775
3023
|
# Add to history
|
|
@@ -2782,21 +3030,12 @@ class DataFrameTable(DataTable):
|
|
|
2782
3030
|
if ridx not in filtered_row_indices:
|
|
2783
3031
|
self.visible_rows[ridx] = False
|
|
2784
3032
|
|
|
2785
|
-
# Recreate
|
|
3033
|
+
# Recreate table for display
|
|
2786
3034
|
self._setup_table()
|
|
2787
3035
|
|
|
2788
|
-
self.notify(
|
|
2789
|
-
f"Filtered to [$accent]{matched_count}[/] matching rows",
|
|
2790
|
-
title="Filter",
|
|
2791
|
-
)
|
|
2792
|
-
|
|
2793
|
-
def _cycle_cursor_type(self) -> None:
|
|
2794
|
-
"""Cycle through cursor types: cell -> row -> column -> cell."""
|
|
2795
|
-
next_type = get_next_item(CURSOR_TYPES, self.cursor_type)
|
|
2796
|
-
self.cursor_type = next_type
|
|
2797
|
-
|
|
2798
|
-
# self.notify(f"Changed cursor type to [$success]{next_type}[/]", title="Cursor")
|
|
3036
|
+
self.notify(f"Filtered to [$accent]{matched_count}[/] matching rows", title="Filter")
|
|
2799
3037
|
|
|
3038
|
+
# Copy & Save
|
|
2800
3039
|
def _copy_to_clipboard(self, content: str, message: str) -> None:
|
|
2801
3040
|
"""Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
|
|
2802
3041
|
|
|
@@ -2862,6 +3101,9 @@ class DataFrameTable(DataTable):
|
|
|
2862
3101
|
filepath = Path(filename)
|
|
2863
3102
|
ext = filepath.suffix.lower()
|
|
2864
3103
|
|
|
3104
|
+
# Add to history
|
|
3105
|
+
self._add_history(f"Saved dataframe to [$success]{filename}[/]")
|
|
3106
|
+
|
|
2865
3107
|
try:
|
|
2866
3108
|
if ext in (".xlsx", ".xls"):
|
|
2867
3109
|
self._do_save_excel(filename)
|
|
@@ -2874,17 +3116,14 @@ class DataFrameTable(DataTable):
|
|
|
2874
3116
|
else:
|
|
2875
3117
|
self.df.write_csv(filename)
|
|
2876
3118
|
|
|
2877
|
-
self.
|
|
3119
|
+
self.dataframe = self.df # Update original dataframe
|
|
2878
3120
|
self.filename = filename # Update current filename
|
|
2879
3121
|
if not self._all_tabs:
|
|
2880
3122
|
extra = "current tab with " if len(self.app.tabs) > 1 else ""
|
|
2881
|
-
self.notify(
|
|
2882
|
-
f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
|
|
2883
|
-
title="Save",
|
|
2884
|
-
)
|
|
3123
|
+
self.notify(f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save")
|
|
2885
3124
|
except Exception as e:
|
|
2886
|
-
self.notify(f"
|
|
2887
|
-
|
|
3125
|
+
self.notify(f"Error saving [$error]{filename}[/]", title="Save", severity="error")
|
|
3126
|
+
self.log(f"Error saving file `{filename}`: {str(e)}")
|
|
2888
3127
|
|
|
2889
3128
|
def _do_save_excel(self, filename: str) -> None:
|
|
2890
3129
|
"""Save to an Excel file."""
|
|
@@ -2903,47 +3142,71 @@ class DataFrameTable(DataTable):
|
|
|
2903
3142
|
|
|
2904
3143
|
# From ConfirmScreen callback, so notify accordingly
|
|
2905
3144
|
if self._all_tabs is True:
|
|
2906
|
-
self.notify(
|
|
2907
|
-
f"Saved all tabs to [$success]{filename}[/]",
|
|
2908
|
-
title="Save",
|
|
2909
|
-
)
|
|
3145
|
+
self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save")
|
|
2910
3146
|
else:
|
|
2911
3147
|
self.notify(
|
|
2912
|
-
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
|
|
2913
|
-
title="Save",
|
|
3148
|
+
f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save"
|
|
2914
3149
|
)
|
|
2915
3150
|
|
|
2916
|
-
|
|
2917
|
-
|
|
3151
|
+
# SQL Interface
|
|
3152
|
+
def _simple_sql(self) -> None:
|
|
3153
|
+
"""Open the SQL interface screen."""
|
|
3154
|
+
self.app.push_screen(
|
|
3155
|
+
SimpleSqlScreen(self),
|
|
3156
|
+
callback=self._do_simple_sql,
|
|
3157
|
+
)
|
|
2918
3158
|
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
3159
|
+
def _do_simple_sql(self, result) -> None:
|
|
3160
|
+
"""Handle SQL result result from SimpleSqlScreen."""
|
|
3161
|
+
if result is None:
|
|
3162
|
+
return
|
|
3163
|
+
columns, where = result
|
|
2922
3164
|
|
|
2923
|
-
|
|
2924
|
-
|
|
3165
|
+
sql = f"SELECT {columns} FROM self"
|
|
3166
|
+
if where:
|
|
3167
|
+
sql += f" WHERE {where}"
|
|
3168
|
+
|
|
3169
|
+
self._do_sql(sql)
|
|
3170
|
+
|
|
3171
|
+
def _advanced_sql(self) -> None:
|
|
3172
|
+
"""Open the advanced SQL interface screen."""
|
|
3173
|
+
self.app.push_screen(
|
|
3174
|
+
AdvancedSqlScreen(self),
|
|
3175
|
+
callback=self._do_advanced_sql,
|
|
3176
|
+
)
|
|
3177
|
+
|
|
3178
|
+
def _do_advanced_sql(self, result) -> None:
|
|
3179
|
+
"""Handle SQL result result from AdvancedSqlScreen."""
|
|
3180
|
+
if result is None:
|
|
3181
|
+
return
|
|
3182
|
+
|
|
3183
|
+
self._do_sql(result)
|
|
3184
|
+
|
|
3185
|
+
def _do_sql(self, sql: str) -> None:
|
|
3186
|
+
"""Execute a SQL query directly.
|
|
3187
|
+
|
|
3188
|
+
Args:
|
|
3189
|
+
sql: The SQL query string to execute.
|
|
2925
3190
|
"""
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
dtype = self.df.dtypes[cidx]
|
|
3191
|
+
# Add to history
|
|
3192
|
+
self._add_history(f"SQL Query:\n[$accent]{sql}[/]")
|
|
2929
3193
|
|
|
2930
|
-
#
|
|
2931
|
-
|
|
3194
|
+
# Execute the SQL query
|
|
3195
|
+
try:
|
|
3196
|
+
self.df = self.df.sql(sql)
|
|
3197
|
+
except Exception as e:
|
|
3198
|
+
self.notify(f"Error executing SQL query [$error]{sql}[/]", title="SQL Query", severity="error")
|
|
3199
|
+
self.log(f"Error executing SQL query `{sql}`: {str(e)}")
|
|
2932
3200
|
return
|
|
2933
3201
|
|
|
2934
|
-
|
|
2935
|
-
|
|
3202
|
+
if not len(self.df):
|
|
3203
|
+
self.notify(f"SQL query returned no results for [$warning]{sql}[/]", title="SQL Query", severity="warning")
|
|
3204
|
+
return
|
|
2936
3205
|
|
|
2937
|
-
#
|
|
2938
|
-
|
|
2939
|
-
cell_text: Text = self.get_cell(row.key, col_key)
|
|
2940
|
-
if cell_text.plain.startswith(("http://", "https://")):
|
|
2941
|
-
cell_text.style = f"#00afff link {cell_text.plain}" # sky blue
|
|
2942
|
-
self.update_cell(row.key, col_key, cell_text)
|
|
2943
|
-
url_count += 1
|
|
3206
|
+
# Recreate table for display
|
|
3207
|
+
self._setup_table()
|
|
2944
3208
|
|
|
2945
|
-
|
|
2946
|
-
self.
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
)
|
|
3209
|
+
self.notify(
|
|
3210
|
+
f"SQL query executed successfully. Now showing [$accent]{len(self.df)}[/] rows and [$accent]{len(self.df.columns)}[/] columns.",
|
|
3211
|
+
title="SQL Query",
|
|
3212
|
+
)
|