dataframe-textual 1.12.0__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataframe_textual/__init__.py +27 -1
- dataframe_textual/__main__.py +9 -2
- dataframe_textual/common.py +118 -50
- dataframe_textual/data_frame_help_panel.py +0 -3
- dataframe_textual/data_frame_table.py +1149 -644
- dataframe_textual/data_frame_viewer.py +27 -4
- dataframe_textual/sql_screen.py +11 -11
- dataframe_textual/table_screen.py +118 -96
- dataframe_textual/yes_no_screen.py +32 -37
- {dataframe_textual-1.12.0.dist-info → dataframe_textual-2.0.0.dist-info}/METADATA +143 -141
- dataframe_textual-2.0.0.dist-info/RECORD +14 -0
- {dataframe_textual-1.12.0.dist-info → dataframe_textual-2.0.0.dist-info}/WHEEL +1 -1
- dataframe_textual-1.12.0.dist-info/RECORD +0 -14
- {dataframe_textual-1.12.0.dist-info → dataframe_textual-2.0.0.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-1.12.0.dist-info → dataframe_textual-2.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -33,14 +33,17 @@ class DataFrameViewer(App):
|
|
|
33
33
|
- **Q** - ❌ Close all tabs (prompts to save unsaved changes)
|
|
34
34
|
- **Ctrl+Q** - 🚪 Force to quit app (discards unsaved changes)
|
|
35
35
|
- **Ctrl+T** - 💾 Save current tab to file
|
|
36
|
+
- **w** - 💾 Save current tab to file (overwrite without prompt)
|
|
36
37
|
- **Ctrl+A** - 💾 Save all tabs to file
|
|
38
|
+
- **W** - 💾 Save all tabs to file (overwrite without prompt)
|
|
37
39
|
- **Ctrl+D** - 📋 Duplicate current tab
|
|
38
40
|
- **Ctrl+O** - 📁 Open a file
|
|
39
|
-
- **Double-click
|
|
41
|
+
- **Double-click** - ✏️ Rename tab
|
|
40
42
|
|
|
41
43
|
## 🎨 View & Settings
|
|
42
44
|
- **F1** - ❓ Toggle this help panel
|
|
43
45
|
- **k** - 🌙 Cycle through themes
|
|
46
|
+
- **Ctrl+P -> Screenshot** - 📸 Capture terminal view as a SVG image
|
|
44
47
|
|
|
45
48
|
## ⭐ Features
|
|
46
49
|
- **Multi-file support** - 📂 Open multiple CSV/Excel files as tabs
|
|
@@ -62,6 +65,8 @@ class DataFrameViewer(App):
|
|
|
62
65
|
("ctrl+o", "open_file", "Open File"),
|
|
63
66
|
("ctrl+t", "save_current_tab", "Save Current Tab"),
|
|
64
67
|
("ctrl+a", "save_all_tabs", "Save All Tabs"),
|
|
68
|
+
("w", "save_current_tab_overwrite", "Save Current Tab (overwrite)"),
|
|
69
|
+
("W", "save_all_tabs_overwrite", "Save All Tabs (overwrite)"),
|
|
65
70
|
("ctrl+d", "duplicate_tab", "Duplicate Tab"),
|
|
66
71
|
("greater_than_sign,b", "next_tab(1)", "Next Tab"), # '>' and 'b'
|
|
67
72
|
("less_than_sign", "next_tab(-1)", "Prev Tab"), # '<'
|
|
@@ -243,7 +248,7 @@ class DataFrameViewer(App):
|
|
|
243
248
|
Opens the save dialog for the active tab's DataFrameTable to save its data.
|
|
244
249
|
"""
|
|
245
250
|
if table := self.get_active_table():
|
|
246
|
-
table.do_save_to_file(
|
|
251
|
+
table.do_save_to_file(all_tabs=False)
|
|
247
252
|
|
|
248
253
|
def action_save_all_tabs(self) -> None:
|
|
249
254
|
"""Save all open tabs to their respective files.
|
|
@@ -251,7 +256,25 @@ class DataFrameViewer(App):
|
|
|
251
256
|
Iterates through all DataFrameTable widgets and opens the save dialog for each.
|
|
252
257
|
"""
|
|
253
258
|
if table := self.get_active_table():
|
|
254
|
-
table.do_save_to_file(
|
|
259
|
+
table.do_save_to_file(all_tabs=True)
|
|
260
|
+
|
|
261
|
+
def action_save_current_tab_overwrite(self) -> None:
|
|
262
|
+
"""Save the currently active tab to file, overwriting if it exists."""
|
|
263
|
+
if table := self.get_active_table():
|
|
264
|
+
filepath = Path(table.filename)
|
|
265
|
+
filename = filepath.with_stem(table.tabname)
|
|
266
|
+
table.save_to_file((filename, False, False))
|
|
267
|
+
|
|
268
|
+
def action_save_all_tabs_overwrite(self) -> None:
|
|
269
|
+
"""Save all open tabs to their respective files, overwriting if they exist."""
|
|
270
|
+
if table := self.get_active_table():
|
|
271
|
+
filepath = Path(table.filename)
|
|
272
|
+
if filepath.suffix.lower() in [".xlsx", ".xls"]:
|
|
273
|
+
filename = table.filename
|
|
274
|
+
else:
|
|
275
|
+
filename = "all-tabs.xlsx"
|
|
276
|
+
|
|
277
|
+
table.save_to_file((filename, True, False))
|
|
255
278
|
|
|
256
279
|
def action_duplicate_tab(self) -> None:
|
|
257
280
|
"""Duplicate the currently active tab.
|
|
@@ -269,7 +292,7 @@ class DataFrameViewer(App):
|
|
|
269
292
|
|
|
270
293
|
# Create new table with the same dataframe and filename
|
|
271
294
|
new_table = DataFrameTable(
|
|
272
|
-
table.df,
|
|
295
|
+
table.df.clone(),
|
|
273
296
|
table.filename,
|
|
274
297
|
tabname=new_tabname,
|
|
275
298
|
zebra_stripes=True,
|
dataframe_textual/sql_screen.py
CHANGED
|
@@ -13,6 +13,8 @@ from textual.screen import ModalScreen
|
|
|
13
13
|
from textual.widgets import Button, Input, Label, SelectionList, TextArea
|
|
14
14
|
from textual.widgets.selection_list import Selection
|
|
15
15
|
|
|
16
|
+
from .common import RID
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
class SqlScreen(ModalScreen):
|
|
18
20
|
"""Base class for modal screens handling SQL query."""
|
|
@@ -151,9 +153,6 @@ class SimpleSqlScreen(SqlScreen):
|
|
|
151
153
|
|
|
152
154
|
Args:
|
|
153
155
|
dftable: Reference to the parent DataFrameTable widget.
|
|
154
|
-
|
|
155
|
-
Returns:
|
|
156
|
-
None
|
|
157
156
|
"""
|
|
158
157
|
super().__init__(
|
|
159
158
|
dftable,
|
|
@@ -165,12 +164,16 @@ class SimpleSqlScreen(SqlScreen):
|
|
|
165
164
|
"""Compose the simple SQL screen widget structure."""
|
|
166
165
|
with Container(id="sql-container") as container:
|
|
167
166
|
container.border_title = "SQL Query"
|
|
168
|
-
yield Label("
|
|
167
|
+
yield Label("SELECT columns (all if none selected):", id="select-label")
|
|
169
168
|
yield SelectionList(
|
|
170
|
-
*[
|
|
169
|
+
*[
|
|
170
|
+
Selection(col, col)
|
|
171
|
+
for col in self.df.columns
|
|
172
|
+
if col not in self.dftable.hidden_columns and col != RID
|
|
173
|
+
],
|
|
171
174
|
id="column-selection",
|
|
172
175
|
)
|
|
173
|
-
yield Label("
|
|
176
|
+
yield Label("WHERE condition (optional)", id="where-label")
|
|
174
177
|
yield Input(placeholder="e.g., age > 30 and height < 180", id="where-input")
|
|
175
178
|
yield from super().compose()
|
|
176
179
|
|
|
@@ -178,7 +181,7 @@ class SimpleSqlScreen(SqlScreen):
|
|
|
178
181
|
"""Handle Yes button/Enter key press."""
|
|
179
182
|
selections = self.query_one(SelectionList).selected
|
|
180
183
|
if not selections:
|
|
181
|
-
selections = [col for col in self.df.columns if col not in self.dftable.hidden_columns]
|
|
184
|
+
selections = [col for col in self.df.columns if col not in self.dftable.hidden_columns and col != RID]
|
|
182
185
|
|
|
183
186
|
columns = ", ".join(f"`{s}`" for s in selections)
|
|
184
187
|
where = self.query_one(Input).value.strip()
|
|
@@ -212,9 +215,6 @@ class AdvancedSqlScreen(SqlScreen):
|
|
|
212
215
|
|
|
213
216
|
Args:
|
|
214
217
|
dftable: Reference to the parent DataFrameTable widget.
|
|
215
|
-
|
|
216
|
-
Returns:
|
|
217
|
-
None
|
|
218
218
|
"""
|
|
219
219
|
super().__init__(
|
|
220
220
|
dftable,
|
|
@@ -227,7 +227,7 @@ class AdvancedSqlScreen(SqlScreen):
|
|
|
227
227
|
with Container(id="sql-container") as container:
|
|
228
228
|
container.border_title = "Advanced SQL Query"
|
|
229
229
|
yield TextArea.code_editor(
|
|
230
|
-
placeholder="Enter SQL query
|
|
230
|
+
placeholder="Enter SQL query, e.g., \n\nSELECT * \nFROM self \nWHERE age > 30\n\n- use 'self' as the table name\n- use backticks (`) for column names with spaces.",
|
|
231
231
|
id="sql-textarea",
|
|
232
232
|
language="sql",
|
|
233
233
|
)
|
|
@@ -13,7 +13,7 @@ from textual.renderables.bar import Bar
|
|
|
13
13
|
from textual.screen import ModalScreen
|
|
14
14
|
from textual.widgets import DataTable
|
|
15
15
|
|
|
16
|
-
from .common import NULL, NULL_DISPLAY,
|
|
16
|
+
from .common import NULL, NULL_DISPLAY, RID, DtypeConfig, format_float
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class TableScreen(ModalScreen):
|
|
@@ -45,9 +45,6 @@ class TableScreen(ModalScreen):
|
|
|
45
45
|
|
|
46
46
|
Args:
|
|
47
47
|
dftable: Reference to the parent DataFrameTable widget.
|
|
48
|
-
|
|
49
|
-
Returns:
|
|
50
|
-
None
|
|
51
48
|
"""
|
|
52
49
|
super().__init__()
|
|
53
50
|
self.dftable = dftable # DataFrameTable
|
|
@@ -71,9 +68,6 @@ class TableScreen(ModalScreen):
|
|
|
71
68
|
|
|
72
69
|
Subclasses should implement this method to populate the DataTable
|
|
73
70
|
with appropriate columns and rows based on the specific screen's purpose.
|
|
74
|
-
|
|
75
|
-
Returns:
|
|
76
|
-
None
|
|
77
71
|
"""
|
|
78
72
|
raise NotImplementedError("Subclasses must implement build_table method.")
|
|
79
73
|
|
|
@@ -85,9 +79,6 @@ class TableScreen(ModalScreen):
|
|
|
85
79
|
|
|
86
80
|
Args:
|
|
87
81
|
event: The key event object.
|
|
88
|
-
|
|
89
|
-
Returns:
|
|
90
|
-
None
|
|
91
82
|
"""
|
|
92
83
|
if event.key in ("q", "escape"):
|
|
93
84
|
self.app.pop_screen()
|
|
@@ -111,7 +102,7 @@ class TableScreen(ModalScreen):
|
|
|
111
102
|
if cidx_name_value is None:
|
|
112
103
|
return
|
|
113
104
|
cidx, col_name, col_value = cidx_name_value
|
|
114
|
-
self.log(f"Filtering or viewing by {col_name} == {col_value}")
|
|
105
|
+
self.log(f"Filtering or viewing by `{col_name} == {col_value}`")
|
|
115
106
|
|
|
116
107
|
# Handle NULL values
|
|
117
108
|
if col_value == NULL:
|
|
@@ -123,11 +114,11 @@ class TableScreen(ModalScreen):
|
|
|
123
114
|
expr = pl.col(col_name) == col_value
|
|
124
115
|
value_display = f"[$success]{col_value}[/]"
|
|
125
116
|
|
|
126
|
-
df_filtered = self.dftable.df.
|
|
117
|
+
df_filtered = self.dftable.df.lazy().filter(expr).collect()
|
|
127
118
|
self.log(f"Filtered dataframe has {len(df_filtered)} rows")
|
|
128
119
|
|
|
129
|
-
|
|
130
|
-
if not
|
|
120
|
+
ok_rids = set(df_filtered[RID].to_list())
|
|
121
|
+
if not ok_rids:
|
|
131
122
|
self.notify(
|
|
132
123
|
f"No matches found for [$warning]{col_name}[/] == {value_display}",
|
|
133
124
|
title="No Matches",
|
|
@@ -135,18 +126,12 @@ class TableScreen(ModalScreen):
|
|
|
135
126
|
)
|
|
136
127
|
return
|
|
137
128
|
|
|
138
|
-
#
|
|
129
|
+
# Action filter
|
|
139
130
|
if action == "filter":
|
|
140
|
-
# Update selections
|
|
141
|
-
for i in range(len(self.dftable.selected_rows)):
|
|
142
|
-
self.dftable.selected_rows[i] = i in matched_indices
|
|
143
|
-
|
|
144
|
-
# Update main table display
|
|
145
131
|
self.dftable.do_filter_rows()
|
|
146
132
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
expr = [i in matched_indices for i in range(len(self.dftable.df))]
|
|
133
|
+
# Action view
|
|
134
|
+
else:
|
|
150
135
|
self.dftable.view_rows((expr, cidx, False, True))
|
|
151
136
|
|
|
152
137
|
# Dismiss the frequency screen
|
|
@@ -167,9 +152,6 @@ class RowDetailScreen(TableScreen):
|
|
|
167
152
|
|
|
168
153
|
Populates the table with column names and values from the selected row
|
|
169
154
|
of the main DataFrame. Sets the table cursor type to "row".
|
|
170
|
-
|
|
171
|
-
Returns:
|
|
172
|
-
None
|
|
173
155
|
"""
|
|
174
156
|
self.build_table()
|
|
175
157
|
|
|
@@ -181,14 +163,14 @@ class RowDetailScreen(TableScreen):
|
|
|
181
163
|
|
|
182
164
|
# Get all columns and values from the dataframe row
|
|
183
165
|
for col, val, dtype in zip(self.df.columns, self.df.row(self.ridx), self.df.dtypes):
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
)
|
|
166
|
+
if col == RID:
|
|
167
|
+
continue # Skip RID column
|
|
168
|
+
formatted_row = []
|
|
169
|
+
formatted_row.append(col)
|
|
170
|
+
|
|
171
|
+
dc = DtypeConfig(dtype)
|
|
172
|
+
formatted_row.append(dc.format(val, justify="", thousand_separator=self.thousand_separator))
|
|
173
|
+
self.table.add_row(*formatted_row)
|
|
192
174
|
|
|
193
175
|
self.table.cursor_type = "row"
|
|
194
176
|
|
|
@@ -209,7 +191,21 @@ class RowDetailScreen(TableScreen):
|
|
|
209
191
|
# Filter the main table by the selected value
|
|
210
192
|
self.filter_or_view_selected_value(self.get_cidx_name_value(), action="filter")
|
|
211
193
|
event.stop()
|
|
212
|
-
elif event.key == "
|
|
194
|
+
elif event.key == "right_curly_bracket": # '}'
|
|
195
|
+
# Move to the next row
|
|
196
|
+
ridx = self.ridx + 1
|
|
197
|
+
if ridx < len(self.df):
|
|
198
|
+
self.ridx = ridx
|
|
199
|
+
self.dftable.move_cursor_to(self.ridx)
|
|
200
|
+
self.build_table()
|
|
201
|
+
event.stop()
|
|
202
|
+
elif event.key == "left_curly_bracket": # '{'
|
|
203
|
+
# Move to the previous row
|
|
204
|
+
ridx = self.ridx - 1
|
|
205
|
+
if ridx >= 0:
|
|
206
|
+
self.ridx = ridx
|
|
207
|
+
self.dftable.move_cursor_to(self.ridx)
|
|
208
|
+
self.build_table()
|
|
213
209
|
event.stop()
|
|
214
210
|
|
|
215
211
|
def get_cidx_name_value(self) -> tuple[int, str, Any] | None:
|
|
@@ -254,12 +250,8 @@ class StatisticsScreen(TableScreen):
|
|
|
254
250
|
col_name = self.df.columns[self.col_idx]
|
|
255
251
|
lf = self.df.lazy()
|
|
256
252
|
|
|
257
|
-
# Apply only to visible rows
|
|
258
|
-
if False in self.dftable.visible_rows:
|
|
259
|
-
lf = lf.filter(self.dftable.visible_rows)
|
|
260
|
-
|
|
261
253
|
# Get column statistics
|
|
262
|
-
stats_df = lf.select(pl.col(col_name)).
|
|
254
|
+
stats_df = lf.select(pl.col(col_name)).describe()
|
|
263
255
|
if len(stats_df) == 0:
|
|
264
256
|
return
|
|
265
257
|
|
|
@@ -275,35 +267,21 @@ class StatisticsScreen(TableScreen):
|
|
|
275
267
|
# Add rows
|
|
276
268
|
for row in stats_df.rows():
|
|
277
269
|
stat_label, stat_value = row
|
|
278
|
-
value = stat_value
|
|
279
|
-
if stat_value is None:
|
|
280
|
-
value = NULL_DISPLAY
|
|
281
|
-
elif dc.gtype == "integer" and self.thousand_separator:
|
|
282
|
-
value = f"{stat_value:,}"
|
|
283
|
-
elif dc.gtype == "float":
|
|
284
|
-
value = format_float(stat_value, self.thousand_separator)
|
|
285
|
-
else:
|
|
286
|
-
value = str(stat_value)
|
|
287
|
-
|
|
288
270
|
self.table.add_row(
|
|
289
|
-
|
|
290
|
-
|
|
271
|
+
stat_label,
|
|
272
|
+
dc.format(stat_value, thousand_separator=self.thousand_separator),
|
|
291
273
|
)
|
|
292
274
|
|
|
293
275
|
def build_dataframe_stats(self) -> None:
|
|
294
276
|
"""Build statistics for the entire dataframe."""
|
|
295
|
-
lf = self.df.lazy()
|
|
296
|
-
|
|
297
|
-
# Apply only to visible rows
|
|
298
|
-
if False in self.dftable.visible_rows:
|
|
299
|
-
lf = lf.filter(self.dftable.visible_rows)
|
|
277
|
+
lf = self.df.lazy().select(pl.exclude(RID))
|
|
300
278
|
|
|
301
279
|
# Apply only to non-hidden columns
|
|
302
280
|
if self.dftable.hidden_columns:
|
|
303
281
|
lf = lf.select(pl.exclude(self.dftable.hidden_columns))
|
|
304
282
|
|
|
305
283
|
# Get dataframe statistics
|
|
306
|
-
stats_df = lf.
|
|
284
|
+
stats_df = lf.describe()
|
|
307
285
|
|
|
308
286
|
# Add columns for each dataframe column with appropriate styling
|
|
309
287
|
for idx, (col_name, col_dtype) in enumerate(zip(stats_df.columns, stats_df.dtypes), 0):
|
|
@@ -329,17 +307,7 @@ class StatisticsScreen(TableScreen):
|
|
|
329
307
|
col_dtype = stats_df.dtypes[idx]
|
|
330
308
|
dc = DtypeConfig(col_dtype)
|
|
331
309
|
|
|
332
|
-
|
|
333
|
-
if stat_value is None:
|
|
334
|
-
value = NULL_DISPLAY
|
|
335
|
-
elif dc.gtype == "integer" and self.thousand_separator:
|
|
336
|
-
value = f"{stat_value:,}"
|
|
337
|
-
elif dc.gtype == "float":
|
|
338
|
-
value = format_float(stat_value, self.thousand_separator)
|
|
339
|
-
else:
|
|
340
|
-
value = str(stat_value)
|
|
341
|
-
|
|
342
|
-
formatted_row.append(Text(value, style=dc.style, justify=dc.justify))
|
|
310
|
+
formatted_row.append(dc.format(stat_value, thousand_separator=self.thousand_separator))
|
|
343
311
|
|
|
344
312
|
self.table.add_row(*formatted_row)
|
|
345
313
|
|
|
@@ -352,13 +320,11 @@ class FrequencyScreen(TableScreen):
|
|
|
352
320
|
def __init__(self, cidx: int, dftable: "DataFrameTable") -> None:
|
|
353
321
|
super().__init__(dftable)
|
|
354
322
|
self.cidx = cidx
|
|
355
|
-
self.sorted_columns = {
|
|
356
|
-
|
|
357
|
-
}
|
|
323
|
+
self.sorted_columns = {1: True} # Count sort by default
|
|
324
|
+
self.total_count = len(dftable.df)
|
|
358
325
|
|
|
359
|
-
|
|
360
|
-
self.
|
|
361
|
-
self.df: pl.DataFrame = df[df.columns[self.cidx]].value_counts(sort=True).sort("count", descending=True)
|
|
326
|
+
col = dftable.df.columns[self.cidx]
|
|
327
|
+
self.df: pl.DataFrame = dftable.df.lazy().select(pl.col(col).value_counts(sort=True)).unnest(col).collect()
|
|
362
328
|
|
|
363
329
|
def on_mount(self) -> None:
|
|
364
330
|
"""Create the frequency table."""
|
|
@@ -412,33 +378,18 @@ class FrequencyScreen(TableScreen):
|
|
|
412
378
|
self.table.add_column(Text(header_text, justify=justify), key=key)
|
|
413
379
|
|
|
414
380
|
# Get style config for Int64 and Float64
|
|
415
|
-
|
|
416
|
-
|
|
381
|
+
dc_int = DtypeConfig(pl.Int64)
|
|
382
|
+
dc_float = DtypeConfig(pl.Float64)
|
|
417
383
|
|
|
418
384
|
# Add rows to the frequency table
|
|
419
385
|
for row_idx, row in enumerate(self.df.rows()):
|
|
420
386
|
column, count = row
|
|
421
387
|
percentage = (count / self.total_count) * 100
|
|
422
388
|
|
|
423
|
-
if column is None:
|
|
424
|
-
value = NULL_DISPLAY
|
|
425
|
-
elif dc.gtype == "integer" and self.thousand_separator:
|
|
426
|
-
value = f"{column:,}"
|
|
427
|
-
elif dc.gtype == "float":
|
|
428
|
-
value = format_float(column, self.thousand_separator)
|
|
429
|
-
else:
|
|
430
|
-
value = str(column)
|
|
431
|
-
|
|
432
389
|
self.table.add_row(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
),
|
|
437
|
-
Text(
|
|
438
|
-
format_float(percentage, self.thousand_separator),
|
|
439
|
-
style=ds_float.style,
|
|
440
|
-
justify=ds_float.justify,
|
|
441
|
-
),
|
|
390
|
+
dc.format(column),
|
|
391
|
+
dc_int.format(count, thousand_separator=self.thousand_separator),
|
|
392
|
+
dc_float.format(percentage, thousand_separator=self.thousand_separator),
|
|
442
393
|
Bar(
|
|
443
394
|
highlight_range=(0.0, percentage / 100 * 10),
|
|
444
395
|
width=10,
|
|
@@ -455,7 +406,7 @@ class FrequencyScreen(TableScreen):
|
|
|
455
406
|
justify="right",
|
|
456
407
|
),
|
|
457
408
|
Text(
|
|
458
|
-
format_float(100.0, self.thousand_separator),
|
|
409
|
+
format_float(100.0, self.thousand_separator, precision=-2 if len(self.df) > 1 else 2),
|
|
459
410
|
style="bold",
|
|
460
411
|
justify="right",
|
|
461
412
|
),
|
|
@@ -503,3 +454,74 @@ class FrequencyScreen(TableScreen):
|
|
|
503
454
|
col_value = NULL if cell_value.plain == NULL_DISPLAY else DtypeConfig(col_dtype).convert(cell_value.plain)
|
|
504
455
|
|
|
505
456
|
return self.cidx, col_name, col_value
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class MetaShape(TableScreen):
|
|
460
|
+
"""Modal screen to display metadata about the dataframe."""
|
|
461
|
+
|
|
462
|
+
CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "MetadataScreen")
|
|
463
|
+
|
|
464
|
+
def on_mount(self) -> None:
|
|
465
|
+
"""Initialize the metadata screen.
|
|
466
|
+
|
|
467
|
+
Populates the table with metadata information about the dataframe,
|
|
468
|
+
including row and column counts.
|
|
469
|
+
"""
|
|
470
|
+
self.build_table()
|
|
471
|
+
|
|
472
|
+
def build_table(self) -> None:
|
|
473
|
+
"""Build the metadata table."""
|
|
474
|
+
self.table.clear(columns=True)
|
|
475
|
+
self.table.add_column("")
|
|
476
|
+
self.table.add_column(Text("Count", justify="right"))
|
|
477
|
+
|
|
478
|
+
# Get shape information
|
|
479
|
+
num_rows, num_cols = self.df.shape if self.dftable.df_view is None else self.dftable.df_view.shape
|
|
480
|
+
num_cols -= 1 # Exclude RID column
|
|
481
|
+
dc_int = DtypeConfig(pl.Int64)
|
|
482
|
+
|
|
483
|
+
# Add rows to the table
|
|
484
|
+
self.table.add_row("Row", dc_int.format(num_rows, thousand_separator=self.thousand_separator))
|
|
485
|
+
self.table.add_row("Column", dc_int.format(num_cols, thousand_separator=self.thousand_separator))
|
|
486
|
+
|
|
487
|
+
self.table.cursor_type = "none"
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class MetaColumnScreen(TableScreen):
|
|
491
|
+
"""Modal screen to display metadata about the columns in the dataframe."""
|
|
492
|
+
|
|
493
|
+
CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "MetaColumnScreen")
|
|
494
|
+
|
|
495
|
+
def on_mount(self) -> None:
|
|
496
|
+
"""Initialize the column metadata screen.
|
|
497
|
+
|
|
498
|
+
Populates the table with information about each column in the dataframe,
|
|
499
|
+
including ID (1-based index), Name, and Type.
|
|
500
|
+
"""
|
|
501
|
+
self.build_table()
|
|
502
|
+
|
|
503
|
+
def build_table(self) -> None:
|
|
504
|
+
"""Build the column metadata table."""
|
|
505
|
+
self.table.clear(columns=True)
|
|
506
|
+
self.table.add_column("Column")
|
|
507
|
+
self.table.add_column("Name")
|
|
508
|
+
self.table.add_column("Type")
|
|
509
|
+
|
|
510
|
+
# Get schema information
|
|
511
|
+
schema = self.df.schema
|
|
512
|
+
dc_int = DtypeConfig(pl.Int64)
|
|
513
|
+
dc_str = DtypeConfig(pl.String)
|
|
514
|
+
|
|
515
|
+
# Add a row for each column
|
|
516
|
+
for idx, (col_name, col_type) in enumerate(schema.items(), 1):
|
|
517
|
+
if col_name == RID:
|
|
518
|
+
continue # Skip RID column
|
|
519
|
+
|
|
520
|
+
dc = DtypeConfig(col_type)
|
|
521
|
+
self.table.add_row(
|
|
522
|
+
dc_int.format(idx, thousand_separator=self.thousand_separator),
|
|
523
|
+
col_name,
|
|
524
|
+
dc_str.format("Datetime" if str(col_type).startswith("Datetime") else col_type, style=dc.style),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
self.table.cursor_type = "none"
|
|
@@ -119,9 +119,6 @@ class YesNoScreen(ModalScreen):
|
|
|
119
119
|
maybe: Optional Maybe button text/dict. Defaults to None.
|
|
120
120
|
no: Text or dict for the No button. If None, hides the No button. Defaults to "No".
|
|
121
121
|
on_yes_callback: Optional callable that takes no args and returns the value to dismiss with when Yes is pressed. Defaults to None.
|
|
122
|
-
|
|
123
|
-
Returns:
|
|
124
|
-
None
|
|
125
122
|
"""
|
|
126
123
|
super().__init__()
|
|
127
124
|
self.title = title
|
|
@@ -295,32 +292,26 @@ class SaveFileScreen(YesNoScreen):
|
|
|
295
292
|
|
|
296
293
|
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "SaveFileScreen")
|
|
297
294
|
|
|
298
|
-
def __init__(
|
|
299
|
-
self
|
|
300
|
-
):
|
|
301
|
-
self.all_tabs = all_tabs or (all_tabs is None and multi_tab)
|
|
295
|
+
def __init__(self, filename: str, save_all: bool = False, tab_count: int = 1):
|
|
296
|
+
self.save_all = save_all
|
|
302
297
|
super().__init__(
|
|
303
|
-
title=
|
|
304
|
-
label="
|
|
298
|
+
title="Save to File",
|
|
299
|
+
label="Filename",
|
|
305
300
|
input=filename,
|
|
306
|
-
yes="Save",
|
|
307
|
-
maybe="Save All Tabs" if self.all_tabs else None,
|
|
301
|
+
yes=f"Save {tab_count} Tabs" if self.save_all else "Save Current Tab" if tab_count > 1 else "Save",
|
|
308
302
|
no="Cancel",
|
|
309
303
|
on_yes_callback=self.handle_save,
|
|
310
|
-
on_maybe_callback=self.handle_save,
|
|
311
304
|
)
|
|
312
305
|
|
|
313
306
|
def handle_save(self):
|
|
314
307
|
if self.input:
|
|
315
308
|
input_filename = self.input.value.strip()
|
|
316
309
|
if input_filename:
|
|
317
|
-
return input_filename, self.
|
|
310
|
+
return input_filename, self.save_all, True # Overwrite prompt
|
|
318
311
|
else:
|
|
319
312
|
self.notify("Filename cannot be empty", title="Save", severity="error")
|
|
320
313
|
return None
|
|
321
314
|
|
|
322
|
-
return None
|
|
323
|
-
|
|
324
315
|
|
|
325
316
|
class ConfirmScreen(YesNoScreen):
|
|
326
317
|
"""Modal screen to ask for confirmation."""
|
|
@@ -359,7 +350,7 @@ class EditCellScreen(YesNoScreen):
|
|
|
359
350
|
|
|
360
351
|
# Input
|
|
361
352
|
df_value = df.item(ridx, cidx)
|
|
362
|
-
self.input_value =
|
|
353
|
+
self.input_value = NULL if df_value is None else str(df_value)
|
|
363
354
|
|
|
364
355
|
super().__init__(
|
|
365
356
|
title="Edit Cell",
|
|
@@ -373,20 +364,20 @@ class EditCellScreen(YesNoScreen):
|
|
|
373
364
|
|
|
374
365
|
def _validate_input(self) -> None:
|
|
375
366
|
"""Validate and save the edited value."""
|
|
376
|
-
new_value_str = self.input.value
|
|
367
|
+
new_value_str = self.input.value # Do not strip to preserve spaces
|
|
377
368
|
|
|
378
369
|
# Handle empty input
|
|
379
370
|
if not new_value_str:
|
|
380
|
-
new_value =
|
|
371
|
+
new_value = ""
|
|
381
372
|
self.notify(
|
|
382
|
-
"Empty value provided. If you want to clear the cell, press [$accent]
|
|
383
|
-
title="Edit",
|
|
373
|
+
"Empty value provided. If you want to clear the cell, press [$accent]Delete[/].",
|
|
374
|
+
title="Edit Cell",
|
|
384
375
|
severity="warning",
|
|
385
376
|
)
|
|
386
377
|
# Check if value changed
|
|
387
378
|
elif new_value_str == self.input_value:
|
|
388
379
|
new_value = None
|
|
389
|
-
self.notify("No changes made", title="Edit", severity="warning")
|
|
380
|
+
self.notify("No changes made", title="Edit Cell", severity="warning")
|
|
390
381
|
else:
|
|
391
382
|
# Parse and validate based on column dtype
|
|
392
383
|
try:
|
|
@@ -394,7 +385,7 @@ class EditCellScreen(YesNoScreen):
|
|
|
394
385
|
except Exception as e:
|
|
395
386
|
self.notify(
|
|
396
387
|
f"Failed to convert [$accent]{new_value_str}[/] to [$error]{self.dtype}[/]: {str(e)}",
|
|
397
|
-
title="Edit",
|
|
388
|
+
title="Edit Cell",
|
|
398
389
|
severity="error",
|
|
399
390
|
)
|
|
400
391
|
return None
|
|
@@ -477,7 +468,7 @@ class SearchScreen(YesNoScreen):
|
|
|
477
468
|
|
|
478
469
|
def _validate_input(self) -> tuple[str, int, bool, bool]:
|
|
479
470
|
"""Validate the input and return it."""
|
|
480
|
-
term = self.input.value
|
|
471
|
+
term = self.input.value # Do not strip to preserve spaces
|
|
481
472
|
|
|
482
473
|
if not term:
|
|
483
474
|
self.notify("Term cannot be empty", title=self.title, severity="error")
|
|
@@ -508,7 +499,7 @@ class FilterScreen(YesNoScreen):
|
|
|
508
499
|
|
|
509
500
|
def _get_input(self) -> tuple[str, int, bool, bool]:
|
|
510
501
|
"""Get input."""
|
|
511
|
-
term = self.input.value
|
|
502
|
+
term = self.input.value # Do not strip to preserve spaces
|
|
512
503
|
match_nocase = self.checkbox.value
|
|
513
504
|
match_whole = self.checkbox2.value
|
|
514
505
|
|
|
@@ -583,14 +574,14 @@ class EditColumnScreen(YesNoScreen):
|
|
|
583
574
|
self.df = df
|
|
584
575
|
super().__init__(
|
|
585
576
|
title="Edit Column",
|
|
586
|
-
label=f"
|
|
577
|
+
label=f"By value or Polars expression, e.g., abc, pl.lit(7), {NULL}, $_ * 2, $1 + $2, $_.str.to_uppercase(), pl.arange(0, pl.len())",
|
|
587
578
|
input="$_",
|
|
588
579
|
on_yes_callback=self._get_input,
|
|
589
580
|
)
|
|
590
581
|
|
|
591
582
|
def _get_input(self) -> tuple[str, int]:
|
|
592
583
|
"""Get input."""
|
|
593
|
-
term = self.input.value
|
|
584
|
+
term = self.input.value # Do not strip to preserve spaces
|
|
594
585
|
return term, self.cidx
|
|
595
586
|
|
|
596
587
|
|
|
@@ -606,19 +597,19 @@ class AddColumnScreen(YesNoScreen):
|
|
|
606
597
|
self.existing_columns = set(df.columns)
|
|
607
598
|
super().__init__(
|
|
608
599
|
title="Add Column",
|
|
609
|
-
label="
|
|
610
|
-
input="Link" if link else "
|
|
611
|
-
label2="
|
|
600
|
+
label="Column name",
|
|
601
|
+
input="Link" if link else "New column",
|
|
602
|
+
label2="Link template, e.g., https://example.com/$1/id/$_, PC/compound/$cid"
|
|
612
603
|
if link
|
|
613
|
-
else "
|
|
614
|
-
input2="Link template" if link else "
|
|
604
|
+
else "Value or Polars expression, e.g., abc, pl.lit(123), NULL, $_ * 2, $1 + $total, $_ + '_suffix', $_.str.to_uppercase()",
|
|
605
|
+
input2="Link template" if link else "Value or expression",
|
|
615
606
|
on_yes_callback=self._get_input,
|
|
616
607
|
)
|
|
617
608
|
|
|
618
609
|
def _get_input(self) -> tuple[int, str, str] | None:
|
|
619
610
|
"""Validate and return the new column configuration."""
|
|
620
611
|
col_name = self.input.value.strip()
|
|
621
|
-
term = self.input2.value
|
|
612
|
+
term = self.input2.value # Do not strip to preserve spaces
|
|
622
613
|
|
|
623
614
|
# Validate column name
|
|
624
615
|
if not col_name:
|
|
@@ -680,7 +671,11 @@ class FindReplaceScreen(YesNoScreen):
|
|
|
680
671
|
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "ReplaceScreen")
|
|
681
672
|
|
|
682
673
|
def __init__(self, dftable: "DataFrameTable", title: str = "Find and Replace"):
|
|
683
|
-
|
|
674
|
+
if (cursor_value := dftable.cursor_value) is None:
|
|
675
|
+
term_find = NULL
|
|
676
|
+
else:
|
|
677
|
+
term_find = str(cursor_value)
|
|
678
|
+
|
|
684
679
|
super().__init__(
|
|
685
680
|
title=title,
|
|
686
681
|
label="Find",
|
|
@@ -698,8 +693,8 @@ class FindReplaceScreen(YesNoScreen):
|
|
|
698
693
|
|
|
699
694
|
def _get_input(self) -> tuple[str, str, bool, bool, bool]:
|
|
700
695
|
"""Get input."""
|
|
701
|
-
term_find = self.input.value
|
|
702
|
-
term_replace = self.input2.value
|
|
696
|
+
term_find = self.input.value # Do not strip to preserve spaces
|
|
697
|
+
term_replace = self.input2.value # Do not strip to preserve spaces
|
|
703
698
|
match_nocase = self.checkbox.value
|
|
704
699
|
match_whole = self.checkbox2.value
|
|
705
700
|
replace_all = False
|
|
@@ -708,8 +703,8 @@ class FindReplaceScreen(YesNoScreen):
|
|
|
708
703
|
|
|
709
704
|
def _get_input_replace_all(self) -> tuple[str, str, bool, bool, bool]:
|
|
710
705
|
"""Get input for 'Replace All'."""
|
|
711
|
-
term_find = self.input.value
|
|
712
|
-
term_replace = self.input2.value
|
|
706
|
+
term_find = self.input.value # Do not strip to preserve spaces
|
|
707
|
+
term_replace = self.input2.value # Do not strip to preserve spaces
|
|
713
708
|
match_nocase = self.checkbox.value
|
|
714
709
|
match_whole = self.checkbox2.value
|
|
715
710
|
replace_all = True
|