dataframe-textual 2.4.1__py3-none-any.whl → 2.9.1__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 +24 -4
- dataframe_textual/common.py +12 -4
- dataframe_textual/data_frame_table.py +166 -242
- dataframe_textual/data_frame_viewer.py +199 -61
- dataframe_textual/yes_no_screen.py +1 -7
- {dataframe_textual-2.4.1.dist-info → dataframe_textual-2.9.1.dist-info}/METADATA +28 -27
- dataframe_textual-2.9.1.dist-info/RECORD +14 -0
- dataframe_textual-2.4.1.dist-info/RECORD +0 -14
- {dataframe_textual-2.4.1.dist-info → dataframe_textual-2.9.1.dist-info}/WHEEL +0 -0
- {dataframe_textual-2.4.1.dist-info → dataframe_textual-2.9.1.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-2.4.1.dist-info → dataframe_textual-2.9.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -16,7 +16,7 @@ from textual.coordinate import Coordinate
|
|
|
16
16
|
from textual.events import Click
|
|
17
17
|
from textual.reactive import reactive
|
|
18
18
|
from textual.render import measure
|
|
19
|
-
from textual.widgets import DataTable
|
|
19
|
+
from textual.widgets import DataTable
|
|
20
20
|
from textual.widgets._data_table import (
|
|
21
21
|
CellDoesNotExist,
|
|
22
22
|
CellKey,
|
|
@@ -35,7 +35,6 @@ from .common import (
|
|
|
35
35
|
NULL_DISPLAY,
|
|
36
36
|
RID,
|
|
37
37
|
SUBSCRIPT_DIGITS,
|
|
38
|
-
SUPPORTED_FORMATS,
|
|
39
38
|
DtypeConfig,
|
|
40
39
|
format_row,
|
|
41
40
|
get_next_item,
|
|
@@ -56,7 +55,6 @@ from .yes_no_screen import (
|
|
|
56
55
|
FindReplaceScreen,
|
|
57
56
|
FreezeScreen,
|
|
58
57
|
RenameColumnScreen,
|
|
59
|
-
SaveFileScreen,
|
|
60
58
|
SearchScreen,
|
|
61
59
|
)
|
|
62
60
|
|
|
@@ -163,10 +161,21 @@ class DataFrameTable(DataTable):
|
|
|
163
161
|
- **,** - 🔢 Toggle thousand separator for numeric display
|
|
164
162
|
- **K** - 🔄 Cycle cursor (cell → row → column → cell)
|
|
165
163
|
|
|
166
|
-
##
|
|
167
|
-
- **
|
|
168
|
-
- **
|
|
169
|
-
-
|
|
164
|
+
## ✏️ Editing
|
|
165
|
+
- **Double-click** - ✍️ Edit cell or rename column header
|
|
166
|
+
- **e** - ✍️ Edit current cell
|
|
167
|
+
- **E** - 📊 Edit entire column with expression
|
|
168
|
+
- **a** - ➕ Add empty column after current
|
|
169
|
+
- **A** - ➕ Add column with name and optional expression
|
|
170
|
+
- **@** - 🔗 Add a new link column from template
|
|
171
|
+
- **x** - ❌ Delete current row
|
|
172
|
+
- **X** - ❌ Delete row and those below
|
|
173
|
+
- **Ctrl+X** - ❌ Delete row and those above
|
|
174
|
+
- **delete** - ❌ Clear current cell (set to NULL)
|
|
175
|
+
- **Shift+Delete** - ❌ Clear current column (set matching cells to NULL)
|
|
176
|
+
- **-** - ❌ Delete current column
|
|
177
|
+
- **d** - 📋 Duplicate current column
|
|
178
|
+
- **D** - 📋 Duplicate current row
|
|
170
179
|
|
|
171
180
|
## ✅ Row Selection
|
|
172
181
|
- **\\\\** - ✅ Select rows with cell matches or those matching cursor value in current column
|
|
@@ -190,28 +199,15 @@ class DataFrameTable(DataTable):
|
|
|
190
199
|
- *(Supports case-insensitive & whole-word matching)*
|
|
191
200
|
|
|
192
201
|
## 👁️ View & Filter
|
|
193
|
-
- **"** - 📍 Filter selected rows (
|
|
194
|
-
-
|
|
195
|
-
- **
|
|
202
|
+
- **"** - 📍 Filter selected rows (others removed)
|
|
203
|
+
- **.** - 👁️ View rows with non-null values in current column (others hidden)
|
|
204
|
+
- **v** - 👁️ View selected rows (others hidden)
|
|
205
|
+
- **V** - 🔧 View selected rows matching expression (others hidden)
|
|
196
206
|
|
|
197
|
-
##
|
|
198
|
-
- **
|
|
199
|
-
- **
|
|
200
|
-
|
|
201
|
-
## ✏️ Editing
|
|
202
|
-
- **Double-click** - ✍️ Edit cell or rename column header
|
|
203
|
-
- **e** - ✍️ Edit current cell
|
|
204
|
-
- **E** - 📊 Edit entire column with expression
|
|
205
|
-
- **a** - ➕ Add empty column after current
|
|
206
|
-
- **A** - ➕ Add column with name and optional expression
|
|
207
|
-
- **@** - 🔗 Add a new link column from template
|
|
208
|
-
- **x** - ❌ Delete current row
|
|
209
|
-
- **X** - ❌ Delete row and those below
|
|
210
|
-
- **Ctrl+X** - ❌ Delete row and those above
|
|
211
|
-
- **delete** - ❌ Clear current cell (set to NULL)
|
|
212
|
-
- **-** - ❌ Delete current column
|
|
213
|
-
- **d** - 📋 Duplicate current column
|
|
214
|
-
- **D** - 📋 Duplicate current row
|
|
207
|
+
## ↕️ Sorting
|
|
208
|
+
- **[** - 🔼 Sort column ascending
|
|
209
|
+
- **]** - 🔽 Sort column descending
|
|
210
|
+
- *(Multi-column sort supported)*
|
|
215
211
|
|
|
216
212
|
## 🎯 Reorder
|
|
217
213
|
- **Shift+↑↓** - ⬆️⬇️ Move row up/down
|
|
@@ -223,11 +219,14 @@ class DataFrameTable(DataTable):
|
|
|
223
219
|
- **!** - ✅ Cast column to boolean
|
|
224
220
|
- **$** - 📝 Cast column to string
|
|
225
221
|
|
|
226
|
-
## 💾 Copy
|
|
222
|
+
## 💾 Copy
|
|
227
223
|
- **c** - 📋 Copy cell to clipboard
|
|
228
224
|
- **Ctrl+c** - 📊 Copy column to clipboard
|
|
229
225
|
- **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
|
|
230
|
-
|
|
226
|
+
|
|
227
|
+
## 🔍 SQL Interface
|
|
228
|
+
- **l** - 💬 Open simple SQL interface (select columns & where clause)
|
|
229
|
+
- **L** - 🔎 Open advanced SQL interface (full SQL queries)
|
|
231
230
|
""").strip()
|
|
232
231
|
|
|
233
232
|
# fmt: off
|
|
@@ -255,8 +254,6 @@ class DataFrameTable(DataTable):
|
|
|
255
254
|
("c", "copy_cell", "Copy cell to clipboard"),
|
|
256
255
|
("ctrl+c", "copy_column", "Copy column to clipboard"),
|
|
257
256
|
("ctrl+r", "copy_row", "Copy row to clipboard"),
|
|
258
|
-
# Save
|
|
259
|
-
("ctrl+s", "save_to_file", "Save to file"),
|
|
260
257
|
# Metadata, Detail, Frequency, and Statistics
|
|
261
258
|
("m", "metadata_shape", "Show metadata for row count and column count"),
|
|
262
259
|
("M", "metadata_column", "Show metadata for column"),
|
|
@@ -268,6 +265,7 @@ class DataFrameTable(DataTable):
|
|
|
268
265
|
("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
|
|
269
266
|
("right_square_bracket", "sort_descending", "Sort descending"), # `]`
|
|
270
267
|
# View & Filter
|
|
268
|
+
("full_stop", "view_rows_non_null", "View rows with non-null values in current column"),
|
|
271
269
|
("v", "view_rows", "View selected rows"),
|
|
272
270
|
("V", "view_rows_expr", "View selected rows matching expression"),
|
|
273
271
|
("quotation_mark", "filter_rows", "Filter selected rows"), # `"`
|
|
@@ -290,6 +288,7 @@ class DataFrameTable(DataTable):
|
|
|
290
288
|
("R", "replace_global", "Replace global"), # `Shift+R`
|
|
291
289
|
# Delete
|
|
292
290
|
("delete", "clear_cell", "Clear cell"),
|
|
291
|
+
("shift+delete", "clear_column", "Clear cells in current column that match cursor value"), # `Shift+Delete`
|
|
293
292
|
("minus", "delete_column", "Delete column"), # `-`
|
|
294
293
|
("x", "delete_row", "Delete row"),
|
|
295
294
|
("X", "delete_row_and_below", "Delete row and those below"),
|
|
@@ -744,10 +743,6 @@ class DataFrameTable(DataTable):
|
|
|
744
743
|
"""Sort by current column in descending order."""
|
|
745
744
|
self.do_sort_by_column(descending=True)
|
|
746
745
|
|
|
747
|
-
def action_save_to_file(self) -> None:
|
|
748
|
-
"""Save the current dataframe to a file."""
|
|
749
|
-
self.do_save_to_file()
|
|
750
|
-
|
|
751
746
|
def action_show_frequency(self) -> None:
|
|
752
747
|
"""Show frequency distribution for the current column."""
|
|
753
748
|
self.do_show_frequency()
|
|
@@ -768,6 +763,10 @@ class DataFrameTable(DataTable):
|
|
|
768
763
|
"""Show metadata for the current column."""
|
|
769
764
|
self.do_metadata_column()
|
|
770
765
|
|
|
766
|
+
def action_view_rows_non_null(self) -> None:
|
|
767
|
+
"""View rows with non-null values in the current column."""
|
|
768
|
+
self.do_view_rows_non_null()
|
|
769
|
+
|
|
771
770
|
def action_view_rows(self) -> None:
|
|
772
771
|
"""View rows by current cell value."""
|
|
773
772
|
self.do_view_rows()
|
|
@@ -804,6 +803,10 @@ class DataFrameTable(DataTable):
|
|
|
804
803
|
"""Clear the current cell (set to None)."""
|
|
805
804
|
self.do_clear_cell()
|
|
806
805
|
|
|
806
|
+
def action_clear_column(self) -> None:
|
|
807
|
+
"""Clear cells in the current column that match the cursor value."""
|
|
808
|
+
self.do_clear_column()
|
|
809
|
+
|
|
807
810
|
def action_select_row(self) -> None:
|
|
808
811
|
"""Select rows with cursor value in the current column."""
|
|
809
812
|
self.do_select_row()
|
|
@@ -1758,13 +1761,14 @@ class DataFrameTable(DataTable):
|
|
|
1758
1761
|
max_width = label_width
|
|
1759
1762
|
|
|
1760
1763
|
# Scan through all loaded rows that are visible to find max width
|
|
1761
|
-
for
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
+
for row_start, row_end in self.loaded_ranges:
|
|
1765
|
+
for row_idx in range(row_start, row_end):
|
|
1766
|
+
cell_value = str(self.df.item(row_idx, col_idx))
|
|
1767
|
+
cell_width = measure(self.app.console, cell_value, 1)
|
|
1764
1768
|
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1769
|
+
if cell_width > max_width:
|
|
1770
|
+
need_expand = True
|
|
1771
|
+
max_width = cell_width
|
|
1768
1772
|
|
|
1769
1773
|
if not need_expand:
|
|
1770
1774
|
return
|
|
@@ -2052,25 +2056,10 @@ class DataFrameTable(DataTable):
|
|
|
2052
2056
|
# Also update the view if applicable
|
|
2053
2057
|
# Update the value of col_name in df_view using the value of col_name from df based on RID mapping between them
|
|
2054
2058
|
if self.df_view is not None:
|
|
2055
|
-
# Get updated column from df
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
RID, pl.col(col_name).alias(col_updated), pl.lit(True).alias(col_exists)
|
|
2060
|
-
)
|
|
2061
|
-
# Join and use when/then/otherwise to handle all updates including NULLs
|
|
2062
|
-
self.df_view = (
|
|
2063
|
-
self.df_view.lazy()
|
|
2064
|
-
.join(lf_updated, on=RID, how="left")
|
|
2065
|
-
.with_columns(
|
|
2066
|
-
pl.when(pl.col(col_exists))
|
|
2067
|
-
.then(pl.col(col_updated))
|
|
2068
|
-
.otherwise(pl.col(col_name))
|
|
2069
|
-
.alias(col_name)
|
|
2070
|
-
)
|
|
2071
|
-
.drop(col_updated, col_exists)
|
|
2072
|
-
.collect()
|
|
2073
|
-
)
|
|
2059
|
+
# Get updated column from df
|
|
2060
|
+
lf_updated = self.df.lazy().select(RID, pl.col(col_name))
|
|
2061
|
+
# Update df_view by joining on RID
|
|
2062
|
+
self.df_view = self.df_view.lazy().update(lf_updated, on=RID, include_nulls=True).collect()
|
|
2074
2063
|
except Exception as e:
|
|
2075
2064
|
self.notify(
|
|
2076
2065
|
f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
|
|
@@ -2156,18 +2145,26 @@ class DataFrameTable(DataTable):
|
|
|
2156
2145
|
|
|
2157
2146
|
# Update the cell to None in the dataframe
|
|
2158
2147
|
try:
|
|
2159
|
-
self.df =
|
|
2160
|
-
|
|
2161
|
-
.
|
|
2162
|
-
|
|
2163
|
-
|
|
2148
|
+
self.df = (
|
|
2149
|
+
self.df.lazy()
|
|
2150
|
+
.with_columns(
|
|
2151
|
+
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
2152
|
+
.then(pl.lit(None))
|
|
2153
|
+
.otherwise(pl.col(col_name))
|
|
2154
|
+
.alias(col_name)
|
|
2155
|
+
)
|
|
2156
|
+
.collect()
|
|
2164
2157
|
)
|
|
2165
2158
|
|
|
2166
2159
|
# Also update the view if applicable
|
|
2167
2160
|
if self.df_view is not None:
|
|
2168
2161
|
ridx_view = self.df.item(ridx, self.df.columns.index(RID))
|
|
2169
|
-
self.df_view =
|
|
2170
|
-
|
|
2162
|
+
self.df_view = (
|
|
2163
|
+
self.df_view.lazy()
|
|
2164
|
+
.with_columns(
|
|
2165
|
+
pl.when(pl.col(RID) == ridx_view).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
|
|
2166
|
+
)
|
|
2167
|
+
.collect()
|
|
2171
2168
|
)
|
|
2172
2169
|
|
|
2173
2170
|
# Update the display
|
|
@@ -2186,7 +2183,43 @@ class DataFrameTable(DataTable):
|
|
|
2186
2183
|
timeout=10,
|
|
2187
2184
|
)
|
|
2188
2185
|
self.log(f"Error clearing cell ({ridx}, {col_name}): {str(e)}")
|
|
2189
|
-
|
|
2186
|
+
|
|
2187
|
+
def do_clear_column(self) -> None:
|
|
2188
|
+
"""Clear the current column by setting all its values to None."""
|
|
2189
|
+
col_idx = self.cursor_column
|
|
2190
|
+
col_name = self.cursor_col_name
|
|
2191
|
+
value = self.cursor_value
|
|
2192
|
+
|
|
2193
|
+
# Add to history
|
|
2194
|
+
self.add_history(f"Cleared column [$success]{col_name}[/]", dirty=True)
|
|
2195
|
+
|
|
2196
|
+
try:
|
|
2197
|
+
# Update the entire column to None in the dataframe
|
|
2198
|
+
self.df = (
|
|
2199
|
+
self.df.lazy()
|
|
2200
|
+
.with_columns(
|
|
2201
|
+
pl.when(pl.col(col_name) == value).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
|
|
2202
|
+
)
|
|
2203
|
+
.collect()
|
|
2204
|
+
)
|
|
2205
|
+
|
|
2206
|
+
# Also update the view if applicable
|
|
2207
|
+
if self.df_view is not None:
|
|
2208
|
+
lf_updated = self.df.lazy().select(RID, pl.col(col_name))
|
|
2209
|
+
self.df_view = self.df_view.lazy().update(lf_updated, on=RID, include_nulls=True).collect()
|
|
2210
|
+
|
|
2211
|
+
# Recreate table for display
|
|
2212
|
+
self.setup_table()
|
|
2213
|
+
|
|
2214
|
+
# Move cursor to the cleared column
|
|
2215
|
+
self.move_cursor(column=col_idx)
|
|
2216
|
+
|
|
2217
|
+
# self.notify(f"Cleared column [$success]{col_name}[/]", title="Clear Column")
|
|
2218
|
+
except Exception as e:
|
|
2219
|
+
self.notify(
|
|
2220
|
+
f"Error clearing column [$error]{col_name}[/]", title="Clear Column", severity="error", timeout=10
|
|
2221
|
+
)
|
|
2222
|
+
self.log(f"Error clearing column `{col_name}`: {str(e)}")
|
|
2190
2223
|
|
|
2191
2224
|
def do_add_column(self, col_name: str = None) -> None:
|
|
2192
2225
|
"""Add acolumn after the current column."""
|
|
@@ -2849,7 +2882,7 @@ class DataFrameTable(DataTable):
|
|
|
2849
2882
|
expr = validate_expr(term, self.df.columns, cidx)
|
|
2850
2883
|
except Exception as e:
|
|
2851
2884
|
self.notify(
|
|
2852
|
-
f"Error validating expression [$error]{term}[/]", title="
|
|
2885
|
+
f"Error validating expression [$error]{term}[/]", title="Select Row", severity="error", timeout=10
|
|
2853
2886
|
)
|
|
2854
2887
|
self.log(f"Error validating expression `{term}`: {str(e)}")
|
|
2855
2888
|
return
|
|
@@ -2875,7 +2908,7 @@ class DataFrameTable(DataTable):
|
|
|
2875
2908
|
expr = pl.col(col_name).cast(pl.String).str.contains(term)
|
|
2876
2909
|
self.notify(
|
|
2877
2910
|
f"Error converting [$error]{term}[/] to [$accent]{dtype}[/]. Cast to string.",
|
|
2878
|
-
title="
|
|
2911
|
+
title="Select Row",
|
|
2879
2912
|
severity="warning",
|
|
2880
2913
|
)
|
|
2881
2914
|
|
|
@@ -2887,7 +2920,7 @@ class DataFrameTable(DataTable):
|
|
|
2887
2920
|
ok_rids = set(lf.filter(expr).collect()[RID])
|
|
2888
2921
|
except Exception as e:
|
|
2889
2922
|
self.notify(
|
|
2890
|
-
f"Error applying search filter `[$error]{term}[/]`", title="
|
|
2923
|
+
f"Error applying search filter `[$error]{term}[/]`", title="Select Row", severity="error", timeout=10
|
|
2891
2924
|
)
|
|
2892
2925
|
self.log(f"Error applying search filter `{term}`: {str(e)}")
|
|
2893
2926
|
return
|
|
@@ -2896,7 +2929,7 @@ class DataFrameTable(DataTable):
|
|
|
2896
2929
|
if match_count == 0:
|
|
2897
2930
|
self.notify(
|
|
2898
2931
|
f"No matches found for `[$warning]{term}[/]`. Try [$accent](?i)abc[/] for case-insensitive search.",
|
|
2899
|
-
title="
|
|
2932
|
+
title="Select Row",
|
|
2900
2933
|
severity="warning",
|
|
2901
2934
|
)
|
|
2902
2935
|
return
|
|
@@ -2906,8 +2939,8 @@ class DataFrameTable(DataTable):
|
|
|
2906
2939
|
# Add to history
|
|
2907
2940
|
self.add_history(message)
|
|
2908
2941
|
|
|
2909
|
-
# Update selected rows
|
|
2910
|
-
self.selected_rows
|
|
2942
|
+
# Update selected rows
|
|
2943
|
+
self.selected_rows = ok_rids
|
|
2911
2944
|
|
|
2912
2945
|
# Show notification immediately, then start highlighting
|
|
2913
2946
|
self.notify(message, title="Select Row")
|
|
@@ -2970,10 +3003,10 @@ class DataFrameTable(DataTable):
|
|
|
2970
3003
|
# self.notify("No selections to clear", title="Clear Selections and Matches", severity="warning")
|
|
2971
3004
|
return
|
|
2972
3005
|
|
|
2973
|
-
row_count = len(self.selected_rows | set(self.matches.keys()))
|
|
3006
|
+
# row_count = len(self.selected_rows | set(self.matches.keys()))
|
|
2974
3007
|
|
|
2975
3008
|
# Add to history
|
|
2976
|
-
self.add_history("Cleared all
|
|
3009
|
+
self.add_history("Cleared all selections and matches")
|
|
2977
3010
|
|
|
2978
3011
|
# Clear all selections
|
|
2979
3012
|
self.selected_rows = set()
|
|
@@ -2982,7 +3015,7 @@ class DataFrameTable(DataTable):
|
|
|
2982
3015
|
# Recreate table for display
|
|
2983
3016
|
self.setup_table()
|
|
2984
3017
|
|
|
2985
|
-
self.notify(f"Cleared selections for [$success]{row_count}[/] rows", title="Clear Selections and Matches")
|
|
3018
|
+
# self.notify(f"Cleared selections for [$success]{row_count}[/] rows", title="Clear Selections and Matches")
|
|
2986
3019
|
|
|
2987
3020
|
# Find & Replace
|
|
2988
3021
|
def find_matches(
|
|
@@ -3015,7 +3048,7 @@ class DataFrameTable(DataTable):
|
|
|
3015
3048
|
else:
|
|
3016
3049
|
columns_to_search = list(enumerate(self.df.columns))
|
|
3017
3050
|
|
|
3018
|
-
#
|
|
3051
|
+
# Handle each column consistently
|
|
3019
3052
|
for col_idx, col_name in columns_to_search:
|
|
3020
3053
|
# Build expression based on term type
|
|
3021
3054
|
if term == NULL:
|
|
@@ -3075,8 +3108,9 @@ class DataFrameTable(DataTable):
|
|
|
3075
3108
|
cidx = self.cursor_col_idx if scope == "column" else None
|
|
3076
3109
|
|
|
3077
3110
|
# Push the search modal screen
|
|
3111
|
+
|
|
3078
3112
|
self.app.push_screen(
|
|
3079
|
-
SearchScreen("Find", term, self.df, cidx),
|
|
3113
|
+
SearchScreen("Find" if scope == "column" else "Global Find", term, self.df, cidx),
|
|
3080
3114
|
callback=self.find if scope == "column" else self.find_global,
|
|
3081
3115
|
)
|
|
3082
3116
|
|
|
@@ -3106,10 +3140,9 @@ class DataFrameTable(DataTable):
|
|
|
3106
3140
|
# Add to history
|
|
3107
3141
|
self.add_history(f"Found `[$success]{term}[/]` in column [$accent]{col_name}[/]")
|
|
3108
3142
|
|
|
3109
|
-
#
|
|
3143
|
+
# Update matches and count total
|
|
3110
3144
|
match_count = sum(len(cols) for cols in matches.values())
|
|
3111
|
-
|
|
3112
|
-
self.matches[rid].update(cols)
|
|
3145
|
+
self.matches = matches
|
|
3113
3146
|
|
|
3114
3147
|
self.notify(f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]`", title="Find")
|
|
3115
3148
|
|
|
@@ -3140,10 +3173,9 @@ class DataFrameTable(DataTable):
|
|
|
3140
3173
|
# Add to history
|
|
3141
3174
|
self.add_history(f"Found `[$success]{term}[/]` across all columns")
|
|
3142
3175
|
|
|
3143
|
-
#
|
|
3176
|
+
# Update matches and count total
|
|
3144
3177
|
match_count = sum(len(cols) for cols in matches.values())
|
|
3145
|
-
|
|
3146
|
-
self.matches[rid].update(cols)
|
|
3178
|
+
self.matches = matches
|
|
3147
3179
|
|
|
3148
3180
|
self.notify(
|
|
3149
3181
|
f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]` across all columns",
|
|
@@ -3251,7 +3283,7 @@ class DataFrameTable(DataTable):
|
|
|
3251
3283
|
"""Open replace screen for current column."""
|
|
3252
3284
|
# Push the replace modal screen
|
|
3253
3285
|
self.app.push_screen(
|
|
3254
|
-
FindReplaceScreen(self, title="Find and Replace
|
|
3286
|
+
FindReplaceScreen(self, title="Find and Replace"),
|
|
3255
3287
|
callback=self.replace,
|
|
3256
3288
|
)
|
|
3257
3289
|
|
|
@@ -3394,18 +3426,23 @@ class DataFrameTable(DataTable):
|
|
|
3394
3426
|
# Only applicable to string columns for substring matches
|
|
3395
3427
|
if dtype == pl.String and not state.match_whole:
|
|
3396
3428
|
term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
|
|
3429
|
+
new_value = (
|
|
3430
|
+
pl.lit(None)
|
|
3431
|
+
if state.term_replace == NULL
|
|
3432
|
+
else pl.col(col_name).str.replace_all(term_find, state.term_replace)
|
|
3433
|
+
)
|
|
3397
3434
|
self.df = self.df.with_columns(
|
|
3398
|
-
pl.when(mask)
|
|
3399
|
-
.then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
|
|
3400
|
-
.otherwise(pl.col(col_name))
|
|
3401
|
-
.alias(col_name)
|
|
3435
|
+
pl.when(mask).then(new_value).otherwise(pl.col(col_name)).alias(col_name)
|
|
3402
3436
|
)
|
|
3403
3437
|
else:
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3438
|
+
if state.term_replace == NULL:
|
|
3439
|
+
value = None
|
|
3440
|
+
else:
|
|
3441
|
+
# Try to convert replacement value to column dtype
|
|
3442
|
+
try:
|
|
3443
|
+
value = DtypeConfig(dtype).convert(state.term_replace)
|
|
3444
|
+
except Exception:
|
|
3445
|
+
value = state.term_replace
|
|
3409
3446
|
|
|
3410
3447
|
self.df = self.df.with_columns(
|
|
3411
3448
|
pl.when(mask).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
|
|
@@ -3413,15 +3450,8 @@ class DataFrameTable(DataTable):
|
|
|
3413
3450
|
|
|
3414
3451
|
# Also update the view if applicable
|
|
3415
3452
|
if self.df_view is not None:
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
self.df_view = (
|
|
3419
|
-
self.df_view.lazy()
|
|
3420
|
-
.join(lf_updated, on=RID, how="left")
|
|
3421
|
-
.with_columns(pl.coalesce(pl.col(col_updated), pl.col(col_name)).alias(col_name))
|
|
3422
|
-
.drop(col_updated)
|
|
3423
|
-
.collect()
|
|
3424
|
-
)
|
|
3453
|
+
lf_updated = self.df.lazy().filter(mask).select(pl.col(RID), pl.col(col_name))
|
|
3454
|
+
self.df_view = self.df_view.lazy().update(lf_updated, on=RID, include_nulls=True).collect()
|
|
3425
3455
|
|
|
3426
3456
|
state.replaced_occurrence += len(ridxs)
|
|
3427
3457
|
|
|
@@ -3500,9 +3530,14 @@ class DataFrameTable(DataTable):
|
|
|
3500
3530
|
# Only applicable to string columns for substring matches
|
|
3501
3531
|
if dtype == pl.String and not state.match_whole:
|
|
3502
3532
|
term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
|
|
3533
|
+
new_value = (
|
|
3534
|
+
pl.lit(None)
|
|
3535
|
+
if state.term_replace == NULL
|
|
3536
|
+
else pl.col(col_name).str.replace_all(term_find, state.term_replace)
|
|
3537
|
+
)
|
|
3503
3538
|
self.df = self.df.with_columns(
|
|
3504
3539
|
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
3505
|
-
.then(
|
|
3540
|
+
.then(new_value)
|
|
3506
3541
|
.otherwise(pl.col(col_name))
|
|
3507
3542
|
.alias(col_name)
|
|
3508
3543
|
)
|
|
@@ -3516,11 +3551,14 @@ class DataFrameTable(DataTable):
|
|
|
3516
3551
|
.alias(col_name)
|
|
3517
3552
|
)
|
|
3518
3553
|
else:
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3554
|
+
if state.term_replace == NULL:
|
|
3555
|
+
value = None
|
|
3556
|
+
else:
|
|
3557
|
+
# try to convert replacement value to column dtype
|
|
3558
|
+
try:
|
|
3559
|
+
value = DtypeConfig(dtype).convert(state.term_replace)
|
|
3560
|
+
except Exception:
|
|
3561
|
+
value = state.term_replace
|
|
3524
3562
|
|
|
3525
3563
|
self.df = self.df.with_columns(
|
|
3526
3564
|
pl.when(pl.arange(0, len(self.df)) == ridx)
|
|
@@ -3548,6 +3586,8 @@ class DataFrameTable(DataTable):
|
|
|
3548
3586
|
if not state.done:
|
|
3549
3587
|
# Get the new value of the current cell after replacement
|
|
3550
3588
|
new_cell_value = self.df.item(ridx, cidx)
|
|
3589
|
+
if new_cell_value is None:
|
|
3590
|
+
new_cell_value = NULL_DISPLAY
|
|
3551
3591
|
row_key = str(ridx)
|
|
3552
3592
|
col_key = col_name
|
|
3553
3593
|
self.update_cell(
|
|
@@ -3568,6 +3608,15 @@ class DataFrameTable(DataTable):
|
|
|
3568
3608
|
self.show_next_replace_confirmation()
|
|
3569
3609
|
|
|
3570
3610
|
# View & Filter
|
|
3611
|
+
def do_view_rows_non_null(self) -> None:
|
|
3612
|
+
"""View non-null rows based on the cursor column."""
|
|
3613
|
+
cidx = self.cursor_col_idx
|
|
3614
|
+
col_name = self.cursor_col_name
|
|
3615
|
+
|
|
3616
|
+
term = pl.col(col_name).is_not_null()
|
|
3617
|
+
|
|
3618
|
+
self.view_rows((term, cidx, False, True))
|
|
3619
|
+
|
|
3571
3620
|
def do_view_rows(self) -> None:
|
|
3572
3621
|
"""View rows.
|
|
3573
3622
|
|
|
@@ -3662,6 +3711,9 @@ class DataFrameTable(DataTable):
|
|
|
3662
3711
|
|
|
3663
3712
|
expr_str = "boolean list or series" if isinstance(expr, (list, pl.Series)) else str(expr)
|
|
3664
3713
|
|
|
3714
|
+
# Add to history
|
|
3715
|
+
self.add_history(f"Viewed rows by expression [$success]{expr_str}[/]")
|
|
3716
|
+
|
|
3665
3717
|
# Apply the filter expression
|
|
3666
3718
|
try:
|
|
3667
3719
|
df_filtered = lf.filter(expr).collect()
|
|
@@ -3673,12 +3725,10 @@ class DataFrameTable(DataTable):
|
|
|
3673
3725
|
|
|
3674
3726
|
matched_count = len(df_filtered)
|
|
3675
3727
|
if not matched_count:
|
|
3728
|
+
self.histories_undo.pop() # Remove last history entry
|
|
3676
3729
|
self.notify(f"No rows match the expression: [$success]{expr}[/]", title="View Rows", severity="warning")
|
|
3677
3730
|
return
|
|
3678
3731
|
|
|
3679
|
-
# Add to history
|
|
3680
|
-
self.add_history(f"Filtered by expression [$success]{expr_str}[/]")
|
|
3681
|
-
|
|
3682
3732
|
ok_rids = set(df_filtered[RID])
|
|
3683
3733
|
|
|
3684
3734
|
# Create a view of self.df as a copy
|
|
@@ -3699,7 +3749,7 @@ class DataFrameTable(DataTable):
|
|
|
3699
3749
|
# Recreate table for display
|
|
3700
3750
|
self.setup_table()
|
|
3701
3751
|
|
|
3702
|
-
self.notify(f"
|
|
3752
|
+
self.notify(f"Showing [$success]{matched_count}[/] matching row(s)", title="View Rows")
|
|
3703
3753
|
|
|
3704
3754
|
def do_filter_rows(self) -> None:
|
|
3705
3755
|
"""Filter rows.
|
|
@@ -3755,7 +3805,7 @@ class DataFrameTable(DataTable):
|
|
|
3755
3805
|
|
|
3756
3806
|
self.notify(f"{message}. Now showing [$success]{len(self.df)}[/] rows.", title="Filter Rows")
|
|
3757
3807
|
|
|
3758
|
-
# Copy
|
|
3808
|
+
# Copy
|
|
3759
3809
|
def do_copy_to_clipboard(self, content: str, message: str) -> None:
|
|
3760
3810
|
"""Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
|
|
3761
3811
|
|
|
@@ -3779,132 +3829,6 @@ class DataFrameTable(DataTable):
|
|
|
3779
3829
|
except FileNotFoundError:
|
|
3780
3830
|
self.notify("Error copying to clipboard", title="Copy to Clipboard", severity="error", timeout=10)
|
|
3781
3831
|
|
|
3782
|
-
def do_save_to_file(self, all_tabs: bool | None = None, task_after_save: str | None = None) -> None:
|
|
3783
|
-
"""Open screen to save file."""
|
|
3784
|
-
self._task_after_save = task_after_save
|
|
3785
|
-
tab_count = len(self.app.tabs)
|
|
3786
|
-
save_all = tab_count > 1 and all_tabs is not False
|
|
3787
|
-
|
|
3788
|
-
filepath = Path(self.filename)
|
|
3789
|
-
if save_all:
|
|
3790
|
-
ext = filepath.suffix.lower()
|
|
3791
|
-
if ext in (".xlsx", ".xls"):
|
|
3792
|
-
filename = self.filename
|
|
3793
|
-
else:
|
|
3794
|
-
filename = "all-tabs.xlsx"
|
|
3795
|
-
else:
|
|
3796
|
-
filename = str(filepath.with_stem(self.tabname))
|
|
3797
|
-
|
|
3798
|
-
self.app.push_screen(
|
|
3799
|
-
SaveFileScreen(filename, save_all=save_all, tab_count=tab_count),
|
|
3800
|
-
callback=self.save_to_file,
|
|
3801
|
-
)
|
|
3802
|
-
|
|
3803
|
-
def save_to_file(self, result) -> None:
|
|
3804
|
-
"""Handle result from SaveFileScreen."""
|
|
3805
|
-
if result is None:
|
|
3806
|
-
return
|
|
3807
|
-
filename, save_all, overwrite_prompt = result
|
|
3808
|
-
self._save_all = save_all
|
|
3809
|
-
|
|
3810
|
-
# Check if file exists
|
|
3811
|
-
if overwrite_prompt and Path(filename).exists():
|
|
3812
|
-
self._pending_filename = filename
|
|
3813
|
-
self.app.push_screen(
|
|
3814
|
-
ConfirmScreen("File already exists. Overwrite?"),
|
|
3815
|
-
callback=self.confirm_overwrite,
|
|
3816
|
-
)
|
|
3817
|
-
else:
|
|
3818
|
-
self.save_file(filename)
|
|
3819
|
-
|
|
3820
|
-
def confirm_overwrite(self, should_overwrite: bool) -> None:
|
|
3821
|
-
"""Handle result from ConfirmScreen."""
|
|
3822
|
-
if should_overwrite:
|
|
3823
|
-
self.save_file(self._pending_filename)
|
|
3824
|
-
else:
|
|
3825
|
-
# Go back to SaveFileScreen to allow user to enter a different name
|
|
3826
|
-
self.app.push_screen(
|
|
3827
|
-
SaveFileScreen(self._pending_filename, save_all=self._save_all),
|
|
3828
|
-
callback=self.save_to_file,
|
|
3829
|
-
)
|
|
3830
|
-
|
|
3831
|
-
def save_file(self, filename: str) -> None:
|
|
3832
|
-
"""Actually save the dataframe to a file."""
|
|
3833
|
-
filepath = Path(filename)
|
|
3834
|
-
ext = filepath.suffix.lower()
|
|
3835
|
-
if ext == ".gz":
|
|
3836
|
-
ext = Path(filename).with_suffix("").suffix.lower()
|
|
3837
|
-
|
|
3838
|
-
fmt = ext.removeprefix(".")
|
|
3839
|
-
if fmt not in SUPPORTED_FORMATS:
|
|
3840
|
-
self.notify(
|
|
3841
|
-
f"Unsupported file format [$success]{fmt}[/]. Use [$accent]CSV[/] as fallback. Supported formats: {', '.join(SUPPORTED_FORMATS)}",
|
|
3842
|
-
title="Save to File",
|
|
3843
|
-
severity="warning",
|
|
3844
|
-
)
|
|
3845
|
-
fmt = "csv"
|
|
3846
|
-
|
|
3847
|
-
df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
|
|
3848
|
-
try:
|
|
3849
|
-
if fmt == "csv":
|
|
3850
|
-
df.write_csv(filename)
|
|
3851
|
-
elif fmt in ("tsv", "tab"):
|
|
3852
|
-
df.write_csv(filename, separator="\t")
|
|
3853
|
-
elif fmt in ("xlsx", "xls"):
|
|
3854
|
-
self.save_excel(filename)
|
|
3855
|
-
elif fmt == "json":
|
|
3856
|
-
df.write_json(filename)
|
|
3857
|
-
elif fmt == "ndjson":
|
|
3858
|
-
df.write_ndjson(filename)
|
|
3859
|
-
elif fmt == "parquet":
|
|
3860
|
-
df.write_parquet(filename)
|
|
3861
|
-
else: # Fallback to CSV
|
|
3862
|
-
df.write_csv(filename)
|
|
3863
|
-
|
|
3864
|
-
# Update current filename
|
|
3865
|
-
self.filename = filename
|
|
3866
|
-
|
|
3867
|
-
# Reset dirty flag after save
|
|
3868
|
-
if self._save_all:
|
|
3869
|
-
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
3870
|
-
for table in tabs.values():
|
|
3871
|
-
table.dirty = False
|
|
3872
|
-
else:
|
|
3873
|
-
self.dirty = False
|
|
3874
|
-
|
|
3875
|
-
if hasattr(self, "_task_after_save"):
|
|
3876
|
-
if self._task_after_save == "close_tab":
|
|
3877
|
-
self.app.do_close_tab()
|
|
3878
|
-
elif self._task_after_save == "quit_app":
|
|
3879
|
-
self.app.exit()
|
|
3880
|
-
|
|
3881
|
-
# From ConfirmScreen callback, so notify accordingly
|
|
3882
|
-
if self._save_all:
|
|
3883
|
-
self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
|
|
3884
|
-
else:
|
|
3885
|
-
self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
|
|
3886
|
-
|
|
3887
|
-
except Exception as e:
|
|
3888
|
-
self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error", timeout=10)
|
|
3889
|
-
self.log(f"Error saving file `{filename}`: {str(e)}")
|
|
3890
|
-
|
|
3891
|
-
def save_excel(self, filename: str) -> None:
|
|
3892
|
-
"""Save to an Excel file."""
|
|
3893
|
-
import xlsxwriter
|
|
3894
|
-
|
|
3895
|
-
if not self._save_all or len(self.app.tabs) == 1:
|
|
3896
|
-
# Single tab - save directly
|
|
3897
|
-
df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
|
|
3898
|
-
df.write_excel(filename, worksheet=self.tabname)
|
|
3899
|
-
else:
|
|
3900
|
-
# Multiple tabs - use xlsxwriter to create multiple sheets
|
|
3901
|
-
with xlsxwriter.Workbook(filename) as wb:
|
|
3902
|
-
tabs: dict[TabPane, DataFrameTable] = self.app.tabs
|
|
3903
|
-
for table in tabs.values():
|
|
3904
|
-
worksheet = wb.add_worksheet(table.tabname)
|
|
3905
|
-
df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
|
|
3906
|
-
df.write_excel(workbook=wb, worksheet=worksheet)
|
|
3907
|
-
|
|
3908
3832
|
# SQL Interface
|
|
3909
3833
|
def do_simple_sql(self) -> None:
|
|
3910
3834
|
"""Open the SQL interface screen."""
|