dataframe-textual 0.2.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.
Potentially problematic release.
This version of dataframe-textual might be problematic. Click here for more details.
- dataframe_textual/__init__.py +35 -0
- dataframe_textual/__main__.py +48 -0
- dataframe_textual/common.py +204 -0
- dataframe_textual/data_frame_help_panel.py +98 -0
- dataframe_textual/data_frame_table.py +1395 -0
- dataframe_textual/data_frame_viewer.py +320 -0
- dataframe_textual/table_screen.py +311 -0
- dataframe_textual/yes_no_screen.py +409 -0
- dataframe_textual-0.2.1.dist-info/METADATA +549 -0
- dataframe_textual-0.2.1.dist-info/RECORD +13 -0
- dataframe_textual-0.2.1.dist-info/WHEEL +4 -0
- dataframe_textual-0.2.1.dist-info/entry_points.txt +2 -0
- dataframe_textual-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Modal screens with Yes/No buttons and their specialized variants."""
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Horizontal
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Button, Input, Label, Static
|
|
8
|
+
|
|
9
|
+
from .common import DtypeConfig, parse_filter_expression
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class YesNoScreen(ModalScreen):
|
|
13
|
+
"""Reusable modal screen with Yes/No buttons and customizable label and input.
|
|
14
|
+
|
|
15
|
+
This widget handles:
|
|
16
|
+
- Yes/No button responses
|
|
17
|
+
- Enter key for Yes, Escape for No
|
|
18
|
+
- Optional callback function for Yes action
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
DEFAULT_CSS = """
|
|
22
|
+
YesNoScreen {
|
|
23
|
+
align: center middle;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
YesNoScreen > Static {
|
|
27
|
+
width: auto;
|
|
28
|
+
min-width: 30;
|
|
29
|
+
max-width: 60;
|
|
30
|
+
height: auto;
|
|
31
|
+
border: solid $primary;
|
|
32
|
+
background: $surface;
|
|
33
|
+
padding: 2;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
YesNoScreen Input {
|
|
37
|
+
margin: 1 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
YesNoScreen #button-container {
|
|
41
|
+
width: 100%;
|
|
42
|
+
height: 3;
|
|
43
|
+
align: center middle;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
YesNoScreen Button {
|
|
47
|
+
margin: 0 1;
|
|
48
|
+
}
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
title: str = None,
|
|
54
|
+
label: str | dict | Label = None,
|
|
55
|
+
input: str | dict | Input = None,
|
|
56
|
+
yes: str | dict | Button = "Yes",
|
|
57
|
+
no: str | dict | Button = "No",
|
|
58
|
+
on_yes_callback=None,
|
|
59
|
+
):
|
|
60
|
+
"""Initialize the modal screen.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
title: The title to display in the border
|
|
64
|
+
label: Optional label to display below title as a Label
|
|
65
|
+
input: Optional input value to pre-fill an Input widget. If None, no Input is shown. If it is a 2-value tuple, the first value is the pre-filled input, and the second value is the type of input (e.g., "integer", "number", "text")
|
|
66
|
+
yes: Text for the Yes button. If None, hides the Yes button
|
|
67
|
+
no: Text for the No button. If None, hides the No button
|
|
68
|
+
on_yes_callback: Optional callable that takes no args and returns the value to dismiss with
|
|
69
|
+
"""
|
|
70
|
+
super().__init__()
|
|
71
|
+
self.title = title
|
|
72
|
+
self.label = label
|
|
73
|
+
self.input = input
|
|
74
|
+
self.yes = yes
|
|
75
|
+
self.no = no
|
|
76
|
+
self.on_yes_callback = on_yes_callback
|
|
77
|
+
|
|
78
|
+
def compose(self) -> ComposeResult:
|
|
79
|
+
with Static(id="modal-container") as container:
|
|
80
|
+
if self.title:
|
|
81
|
+
container.border_title = self.title
|
|
82
|
+
|
|
83
|
+
if self.label:
|
|
84
|
+
if isinstance(self.label, Label):
|
|
85
|
+
pass
|
|
86
|
+
elif isinstance(self.label, dict):
|
|
87
|
+
self.label = Label(**self.label)
|
|
88
|
+
else:
|
|
89
|
+
self.label = Label(self.label)
|
|
90
|
+
yield self.label
|
|
91
|
+
|
|
92
|
+
if self.input:
|
|
93
|
+
if isinstance(self.input, Input):
|
|
94
|
+
pass
|
|
95
|
+
elif isinstance(self.input, dict):
|
|
96
|
+
self.input = Input(**self.input)
|
|
97
|
+
else:
|
|
98
|
+
self.input = Input(self.input)
|
|
99
|
+
self.input.select_all()
|
|
100
|
+
yield self.input
|
|
101
|
+
|
|
102
|
+
if self.yes or self.no:
|
|
103
|
+
with Horizontal(id="button-container"):
|
|
104
|
+
if self.yes:
|
|
105
|
+
if isinstance(self.yes, Button):
|
|
106
|
+
pass
|
|
107
|
+
elif isinstance(self.yes, dict):
|
|
108
|
+
self.yes = Button(**self.yes, id="yes", variant="success")
|
|
109
|
+
else:
|
|
110
|
+
self.yes = Button(self.yes, id="yes", variant="success")
|
|
111
|
+
|
|
112
|
+
yield self.yes
|
|
113
|
+
if self.no:
|
|
114
|
+
if isinstance(self.no, Button):
|
|
115
|
+
pass
|
|
116
|
+
elif isinstance(self.no, dict):
|
|
117
|
+
self.no = Button(**self.no, id="no", variant="error")
|
|
118
|
+
else:
|
|
119
|
+
self.no = Button(self.no, id="no", variant="error")
|
|
120
|
+
|
|
121
|
+
yield self.no
|
|
122
|
+
|
|
123
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
124
|
+
if event.button.id == "yes":
|
|
125
|
+
self._handle_yes()
|
|
126
|
+
elif event.button.id == "no":
|
|
127
|
+
self.dismiss(None)
|
|
128
|
+
|
|
129
|
+
def on_key(self, event) -> None:
|
|
130
|
+
if event.key == "enter":
|
|
131
|
+
self._handle_yes()
|
|
132
|
+
event.stop()
|
|
133
|
+
elif event.key == "escape":
|
|
134
|
+
self.dismiss(None)
|
|
135
|
+
event.stop()
|
|
136
|
+
|
|
137
|
+
def _handle_yes(self) -> None:
|
|
138
|
+
"""Handle Yes button/Enter key press."""
|
|
139
|
+
if self.on_yes_callback:
|
|
140
|
+
result = self.on_yes_callback()
|
|
141
|
+
self.dismiss(result)
|
|
142
|
+
else:
|
|
143
|
+
self.dismiss(True)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SaveFileScreen(YesNoScreen):
|
|
147
|
+
"""Modal screen to save the dataframe to a CSV file."""
|
|
148
|
+
|
|
149
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "SaveFileScreen")
|
|
150
|
+
|
|
151
|
+
def __init__(self, filename: str, title="Save Tab"):
|
|
152
|
+
super().__init__(
|
|
153
|
+
title=title,
|
|
154
|
+
input=filename,
|
|
155
|
+
on_yes_callback=self.handle_save,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def handle_save(self):
|
|
159
|
+
if self.input:
|
|
160
|
+
filename_input = self.input.value.strip()
|
|
161
|
+
if filename_input:
|
|
162
|
+
return filename_input
|
|
163
|
+
else:
|
|
164
|
+
self.notify("Filename cannot be empty", title="Save", severity="error")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ConfirmScreen(YesNoScreen):
|
|
171
|
+
"""Modal screen to confirm file overwrite."""
|
|
172
|
+
|
|
173
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "ConfirmScreen")
|
|
174
|
+
|
|
175
|
+
def __init__(self, title: str):
|
|
176
|
+
super().__init__(
|
|
177
|
+
title=title,
|
|
178
|
+
on_yes_callback=self.handle_confirm,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def handle_confirm(self) -> None:
|
|
182
|
+
self.dismiss(True)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class EditCellScreen(YesNoScreen):
|
|
186
|
+
"""Modal screen to edit a single cell value."""
|
|
187
|
+
|
|
188
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "EditCellScreen")
|
|
189
|
+
|
|
190
|
+
def __init__(self, row_key: str, col_idx: int, df: pl.DataFrame):
|
|
191
|
+
self.row_key = row_key
|
|
192
|
+
self.col_idx = col_idx
|
|
193
|
+
self.col_name = df.columns[col_idx]
|
|
194
|
+
self.col_dtype = str(df.dtypes[col_idx])
|
|
195
|
+
|
|
196
|
+
row_idx = int(row_key.value) - 1 # Convert to 0-based
|
|
197
|
+
|
|
198
|
+
# Label
|
|
199
|
+
content = f"[$primary]{self.col_name}[/] ([$accent]{self.col_dtype}[/])"
|
|
200
|
+
|
|
201
|
+
# Input
|
|
202
|
+
df_value = df.item(row_idx, col_idx)
|
|
203
|
+
self.input_value = str(df_value) if df_value is not None else ""
|
|
204
|
+
|
|
205
|
+
super().__init__(
|
|
206
|
+
title="Edit Cell",
|
|
207
|
+
label=content,
|
|
208
|
+
input={
|
|
209
|
+
"value": self.input_value,
|
|
210
|
+
"type": DtypeConfig(self.col_dtype).itype,
|
|
211
|
+
},
|
|
212
|
+
on_yes_callback=self._save_edit,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _save_edit(self) -> None:
|
|
216
|
+
"""Validate and save the edited value."""
|
|
217
|
+
new_value_str = self.input.value.strip()
|
|
218
|
+
|
|
219
|
+
# Check if value changed
|
|
220
|
+
if new_value_str == self.input_value:
|
|
221
|
+
self.dismiss(None)
|
|
222
|
+
self.notify("No changes made", title="Edit", severity="warning")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Parse and validate based on column dtype
|
|
226
|
+
try:
|
|
227
|
+
new_value = DtypeConfig(self.col_dtype).convert(new_value_str)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
self.dismiss(None) # Dismiss without changes
|
|
230
|
+
self.notify(f"Invalid value: {str(e)}", title="Edit", severity="error")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
# Dismiss with the new value
|
|
234
|
+
self.dismiss((self.row_key, self.col_idx, new_value))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class SearchScreen(YesNoScreen):
|
|
238
|
+
"""Modal screen to search for values in a column."""
|
|
239
|
+
|
|
240
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "SearchScreen")
|
|
241
|
+
|
|
242
|
+
def __init__(
|
|
243
|
+
self,
|
|
244
|
+
term,
|
|
245
|
+
col_dtype: pl.DataType,
|
|
246
|
+
col_name: str | None = None,
|
|
247
|
+
):
|
|
248
|
+
col_dtype = col_dtype if col_name else pl.String
|
|
249
|
+
label = f"Search [$primary]{term}[/] ([$accent]{col_dtype}[/])"
|
|
250
|
+
self.col_name = col_name
|
|
251
|
+
self.col_dtype = col_dtype
|
|
252
|
+
|
|
253
|
+
super().__init__(
|
|
254
|
+
title="Search" if col_name else "Global Search",
|
|
255
|
+
label=f"{label} in [$primary]{col_name}[/]" if col_name else label,
|
|
256
|
+
input={"value": term, "type": DtypeConfig(col_dtype).itype},
|
|
257
|
+
on_yes_callback=self._do_search,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _do_search(self) -> None:
|
|
261
|
+
"""Perform the search."""
|
|
262
|
+
term = self.input.value.strip()
|
|
263
|
+
|
|
264
|
+
if not term:
|
|
265
|
+
self.notify("Search term cannot be empty", title="Search", severity="error")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
# Dismiss with the search term
|
|
269
|
+
self.dismiss((term, self.col_dtype, self.col_name))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class FilterScreen(YesNoScreen):
|
|
273
|
+
"""Modal screen to filter rows by column expression."""
|
|
274
|
+
|
|
275
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "FilterScreen")
|
|
276
|
+
|
|
277
|
+
def __init__(
|
|
278
|
+
self,
|
|
279
|
+
df: pl.DataFrame,
|
|
280
|
+
current_col_idx: int | None = None,
|
|
281
|
+
current_cell_value: str | None = None,
|
|
282
|
+
):
|
|
283
|
+
self.df = df
|
|
284
|
+
self.current_col_idx = current_col_idx
|
|
285
|
+
super().__init__(
|
|
286
|
+
title="Filter by Expression",
|
|
287
|
+
label="e.g., $1 > 50, $name == 'text', $_ > 100, $a < $b",
|
|
288
|
+
input=f"$_ == {current_cell_value}",
|
|
289
|
+
on_yes_callback=self._validate_filter,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def _validate_filter(self) -> pl.Expr | None:
|
|
293
|
+
"""Validate and return the filter expression."""
|
|
294
|
+
expression = self.input.value.strip()
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
# Try to parse the expression to ensure it's valid
|
|
298
|
+
expr_str = parse_filter_expression(
|
|
299
|
+
expression, self.df, self.current_col_idx
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
# Test the expression by evaluating it
|
|
304
|
+
expr = eval(expr_str, {"pl": pl})
|
|
305
|
+
|
|
306
|
+
# Dismiss with the expression
|
|
307
|
+
self.dismiss((expr, expr_str))
|
|
308
|
+
except Exception as e:
|
|
309
|
+
self.notify(
|
|
310
|
+
f"Error evaluating expression: {str(e)}",
|
|
311
|
+
title="Filter",
|
|
312
|
+
severity="error",
|
|
313
|
+
)
|
|
314
|
+
self.dismiss(None)
|
|
315
|
+
except ValueError as ve:
|
|
316
|
+
self.notify(
|
|
317
|
+
f"Invalid expression: {str(ve)}", title="Filter", severity="error"
|
|
318
|
+
)
|
|
319
|
+
self.dismiss(None)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class FreezeScreen(YesNoScreen):
|
|
323
|
+
"""Modal screen to pin rows and columns.
|
|
324
|
+
|
|
325
|
+
Accepts one value for fixed rows, or two space-separated values for fixed rows and columns.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "PinScreen")
|
|
329
|
+
|
|
330
|
+
def __init__(self):
|
|
331
|
+
super().__init__(
|
|
332
|
+
title="Pin Rows and Columns",
|
|
333
|
+
label="Enter number of fixed rows and columns (space-separated)",
|
|
334
|
+
input="1",
|
|
335
|
+
on_yes_callback=self._parse_pin_input,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def _parse_pin_input(self) -> tuple[int, int] | None:
|
|
339
|
+
"""Parse and validate the pin input.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Tuple of (fixed_rows, fixed_columns) or None if invalid.
|
|
343
|
+
"""
|
|
344
|
+
input_str = self.input.value.strip()
|
|
345
|
+
|
|
346
|
+
if not input_str:
|
|
347
|
+
self.notify("Input cannot be empty", title="Pin", severity="error")
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
parts = input_str.split()
|
|
351
|
+
|
|
352
|
+
if len(parts) == 1:
|
|
353
|
+
# Only fixed rows provided
|
|
354
|
+
try:
|
|
355
|
+
fixed_rows = int(parts[0])
|
|
356
|
+
if fixed_rows < 0:
|
|
357
|
+
raise ValueError("must be non-negative")
|
|
358
|
+
return (fixed_rows, 0)
|
|
359
|
+
except ValueError as e:
|
|
360
|
+
self.notify(
|
|
361
|
+
f"Invalid fixed rows value: {str(e)}", title="Pin", severity="error"
|
|
362
|
+
)
|
|
363
|
+
return None
|
|
364
|
+
elif len(parts) == 2:
|
|
365
|
+
# Both fixed rows and columns provided
|
|
366
|
+
try:
|
|
367
|
+
fixed_rows = int(parts[0])
|
|
368
|
+
fixed_cols = int(parts[1])
|
|
369
|
+
if fixed_rows < 0 or fixed_cols < 0:
|
|
370
|
+
raise ValueError("values must be non-negative")
|
|
371
|
+
return (fixed_rows, fixed_cols)
|
|
372
|
+
except ValueError as e:
|
|
373
|
+
self.notify(
|
|
374
|
+
f"Invalid input values: {str(e)}", title="Pin", severity="error"
|
|
375
|
+
)
|
|
376
|
+
return None
|
|
377
|
+
else:
|
|
378
|
+
self.notify(
|
|
379
|
+
"Provide one or two space-separated integers",
|
|
380
|
+
title="Pin",
|
|
381
|
+
severity="error",
|
|
382
|
+
)
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class OpenFileScreen(YesNoScreen):
|
|
387
|
+
"""Modal screen to open a CSV file."""
|
|
388
|
+
|
|
389
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "OpenFileScreen")
|
|
390
|
+
|
|
391
|
+
def __init__(self):
|
|
392
|
+
super().__init__(
|
|
393
|
+
title="Open File",
|
|
394
|
+
input="Enter relative or absolute file path",
|
|
395
|
+
yes="Open",
|
|
396
|
+
no="Cancel",
|
|
397
|
+
on_yes_callback=self.handle_open,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def handle_open(self):
|
|
401
|
+
if self.input:
|
|
402
|
+
filename_input = self.input.value.strip()
|
|
403
|
+
if filename_input:
|
|
404
|
+
return filename_input
|
|
405
|
+
else:
|
|
406
|
+
self.notify("Filename cannot be empty", title="Open", severity="error")
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
return None
|