dataframe-textual 0.3.0__py3-none-any.whl → 1.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/__main__.py +29 -12
- dataframe_textual/common.py +207 -91
- dataframe_textual/data_frame_help_panel.py +22 -4
- dataframe_textual/data_frame_table.py +1964 -591
- dataframe_textual/data_frame_viewer.py +285 -133
- dataframe_textual/table_screen.py +320 -145
- dataframe_textual/yes_no_screen.py +429 -166
- dataframe_textual-1.0.0.dist-info/METADATA +733 -0
- dataframe_textual-1.0.0.dist-info/RECORD +13 -0
- dataframe_textual-0.3.0.dist-info/METADATA +0 -548
- dataframe_textual-0.3.0.dist-info/RECORD +0 -13
- {dataframe_textual-0.3.0.dist-info → dataframe_textual-1.0.0.dist-info}/WHEEL +0 -0
- {dataframe_textual-0.3.0.dist-info → dataframe_textual-1.0.0.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-0.3.0.dist-info → dataframe_textual-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
"""Modal screens with Yes/No buttons and their specialized variants."""
|
|
2
2
|
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .data_frame_table import DataFrameTable
|
|
7
|
+
|
|
3
8
|
import polars as pl
|
|
4
9
|
from textual.app import ComposeResult
|
|
5
|
-
from textual.containers import Horizontal
|
|
10
|
+
from textual.containers import Container, Horizontal
|
|
6
11
|
from textual.screen import ModalScreen
|
|
7
|
-
from textual.widgets import Button, Input, Label, Static
|
|
12
|
+
from textual.widgets import Button, Checkbox, Input, Label, Static
|
|
8
13
|
|
|
9
|
-
from .common import DtypeConfig,
|
|
14
|
+
from .common import NULL, DtypeConfig, tentative_expr, validate_expr
|
|
10
15
|
|
|
11
16
|
|
|
12
17
|
class YesNoScreen(ModalScreen):
|
|
@@ -25,26 +30,57 @@ class YesNoScreen(ModalScreen):
|
|
|
25
30
|
|
|
26
31
|
YesNoScreen > Static {
|
|
27
32
|
width: auto;
|
|
28
|
-
min-width:
|
|
33
|
+
min-width: 40;
|
|
29
34
|
max-width: 60;
|
|
30
35
|
height: auto;
|
|
31
|
-
border:
|
|
36
|
+
border: heavy $primary;
|
|
37
|
+
border-title-color: $primary-lighten-3;
|
|
32
38
|
background: $surface;
|
|
33
|
-
padding: 2;
|
|
39
|
+
padding: 1 2;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
YesNoScreen Container {
|
|
43
|
+
margin: 1 0 0 0;
|
|
44
|
+
height: auto;
|
|
45
|
+
width: 100%;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
YesNoScreen Label {
|
|
49
|
+
width: 100%;
|
|
50
|
+
text-wrap: wrap;
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
YesNoScreen Input {
|
|
37
|
-
margin: 1 0;
|
|
54
|
+
margin: 1 0 0 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
YesNoScreen Input:blur {
|
|
58
|
+
border: solid $secondary;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
YesNoScreen #checkbox-container {
|
|
62
|
+
margin: 1 0 0 0;
|
|
63
|
+
height: auto;
|
|
64
|
+
align: left middle;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
YesNoScreen Checkbox {
|
|
68
|
+
margin: 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
YesNoScreen Checkbox:blur {
|
|
72
|
+
border: solid $secondary;
|
|
38
73
|
}
|
|
39
74
|
|
|
40
75
|
YesNoScreen #button-container {
|
|
76
|
+
margin: 1 0 0 0;
|
|
41
77
|
width: 100%;
|
|
42
78
|
height: 3;
|
|
43
79
|
align: center middle;
|
|
44
80
|
}
|
|
45
81
|
|
|
46
82
|
YesNoScreen Button {
|
|
47
|
-
margin: 0
|
|
83
|
+
margin: 0 2;
|
|
48
84
|
}
|
|
49
85
|
"""
|
|
50
86
|
|
|
@@ -53,53 +89,126 @@ class YesNoScreen(ModalScreen):
|
|
|
53
89
|
title: str = None,
|
|
54
90
|
label: str | dict | Label = None,
|
|
55
91
|
input: str | dict | Input = None,
|
|
92
|
+
label2: str | dict | Label = None,
|
|
93
|
+
input2: str | dict | Input = None,
|
|
94
|
+
checkbox: str | dict | Checkbox = None,
|
|
95
|
+
checkbox2: str | dict | Checkbox = None,
|
|
56
96
|
yes: str | dict | Button = "Yes",
|
|
97
|
+
maybe: str | dict | Button = None,
|
|
57
98
|
no: str | dict | Button = "No",
|
|
58
99
|
on_yes_callback=None,
|
|
59
|
-
|
|
100
|
+
on_maybe_callback=None,
|
|
101
|
+
) -> None:
|
|
60
102
|
"""Initialize the modal screen.
|
|
61
103
|
|
|
104
|
+
Creates a customizable Yes/No dialog with optional input fields, labels, and checkboxes.
|
|
105
|
+
|
|
62
106
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
107
|
+
title: The title to display in the border. Defaults to None.
|
|
108
|
+
label: Optional label to display below title as a Label. Defaults to None.
|
|
109
|
+
input: Optional input widget or value to pre-fill. If None, no Input is shown. Defaults to None.
|
|
110
|
+
label2: Optional second label widget. Defaults to None.
|
|
111
|
+
input2: Optional second input widget or value. Defaults to None.
|
|
112
|
+
checkbox: Optional checkbox widget or label. Defaults to None.
|
|
113
|
+
checkbox2: Optional second checkbox widget or label. Defaults to None.
|
|
114
|
+
yes: Text or dict for the Yes button. If None, hides the Yes button. Defaults to "Yes".
|
|
115
|
+
maybe: Optional Maybe button text/dict. Defaults to None.
|
|
116
|
+
no: Text or dict for the No button. If None, hides the No button. Defaults to "No".
|
|
117
|
+
on_yes_callback: Optional callable that takes no args and returns the value to dismiss with when Yes is pressed. Defaults to None.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
None
|
|
69
121
|
"""
|
|
70
122
|
super().__init__()
|
|
71
123
|
self.title = title
|
|
72
124
|
self.label = label
|
|
73
125
|
self.input = input
|
|
126
|
+
self.label2 = label2
|
|
127
|
+
self.input2 = input2
|
|
128
|
+
self.checkbox = checkbox
|
|
129
|
+
self.checkbox2 = checkbox2
|
|
74
130
|
self.yes = yes
|
|
131
|
+
self.maybe = maybe
|
|
75
132
|
self.no = no
|
|
76
133
|
self.on_yes_callback = on_yes_callback
|
|
134
|
+
self.on_maybe_callback = on_maybe_callback
|
|
77
135
|
|
|
78
136
|
def compose(self) -> ComposeResult:
|
|
137
|
+
"""Compose the modal screen widget structure.
|
|
138
|
+
|
|
139
|
+
Builds the widget hierarchy with optional title, labels, inputs, checkboxes,
|
|
140
|
+
and action buttons based on initialization parameters.
|
|
141
|
+
|
|
142
|
+
Yields:
|
|
143
|
+
Widget: The components of the modal screen in rendering order.
|
|
144
|
+
"""
|
|
79
145
|
with Static(id="modal-container") as container:
|
|
80
146
|
if self.title:
|
|
81
147
|
container.border_title = self.title
|
|
82
148
|
|
|
83
|
-
if self.label:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
149
|
+
if self.label or self.input:
|
|
150
|
+
with Container(id="input-container"):
|
|
151
|
+
if self.label:
|
|
152
|
+
if isinstance(self.label, Label):
|
|
153
|
+
pass
|
|
154
|
+
elif isinstance(self.label, dict):
|
|
155
|
+
self.label = Label(**self.label)
|
|
156
|
+
else:
|
|
157
|
+
self.label = Label(self.label)
|
|
158
|
+
yield self.label
|
|
159
|
+
|
|
160
|
+
if self.input:
|
|
161
|
+
if isinstance(self.input, Input):
|
|
162
|
+
pass
|
|
163
|
+
elif isinstance(self.input, dict):
|
|
164
|
+
self.input = Input(**self.input)
|
|
165
|
+
else:
|
|
166
|
+
self.input = Input(self.input)
|
|
167
|
+
self.input.select_all()
|
|
168
|
+
yield self.input
|
|
169
|
+
|
|
170
|
+
if self.label2 or self.input2:
|
|
171
|
+
with Container(id="input-container-2"):
|
|
172
|
+
if self.label2:
|
|
173
|
+
if isinstance(self.label2, Label):
|
|
174
|
+
pass
|
|
175
|
+
elif isinstance(self.label2, dict):
|
|
176
|
+
self.label2 = Label(**self.label2)
|
|
177
|
+
else:
|
|
178
|
+
self.label2 = Label(self.label2)
|
|
179
|
+
yield self.label2
|
|
180
|
+
|
|
181
|
+
if self.input2:
|
|
182
|
+
if isinstance(self.input2, Input):
|
|
183
|
+
pass
|
|
184
|
+
elif isinstance(self.input2, dict):
|
|
185
|
+
self.input2 = Input(**self.input2)
|
|
186
|
+
else:
|
|
187
|
+
self.input2 = Input(self.input2)
|
|
188
|
+
self.input2.select_all()
|
|
189
|
+
yield self.input2
|
|
190
|
+
|
|
191
|
+
if self.checkbox or self.checkbox2:
|
|
192
|
+
with Horizontal(id="checkbox-container"):
|
|
193
|
+
if self.checkbox:
|
|
194
|
+
if isinstance(self.checkbox, Checkbox):
|
|
195
|
+
pass
|
|
196
|
+
elif isinstance(self.checkbox, dict):
|
|
197
|
+
self.checkbox = Checkbox(**self.checkbox)
|
|
198
|
+
else:
|
|
199
|
+
self.checkbox = Checkbox(self.checkbox)
|
|
200
|
+
yield self.checkbox
|
|
201
|
+
|
|
202
|
+
if self.checkbox2:
|
|
203
|
+
if isinstance(self.checkbox2, Checkbox):
|
|
204
|
+
pass
|
|
205
|
+
elif isinstance(self.checkbox2, dict):
|
|
206
|
+
self.checkbox2 = Checkbox(**self.checkbox2)
|
|
207
|
+
else:
|
|
208
|
+
self.checkbox2 = Checkbox(self.checkbox2)
|
|
209
|
+
yield self.checkbox2
|
|
210
|
+
|
|
211
|
+
if self.yes or self.no or self.maybe:
|
|
103
212
|
with Horizontal(id="button-container"):
|
|
104
213
|
if self.yes:
|
|
105
214
|
if isinstance(self.yes, Button):
|
|
@@ -110,6 +219,17 @@ class YesNoScreen(ModalScreen):
|
|
|
110
219
|
self.yes = Button(self.yes, id="yes", variant="success")
|
|
111
220
|
|
|
112
221
|
yield self.yes
|
|
222
|
+
|
|
223
|
+
if self.maybe:
|
|
224
|
+
if isinstance(self.maybe, Button):
|
|
225
|
+
pass
|
|
226
|
+
elif isinstance(self.maybe, dict):
|
|
227
|
+
self.maybe = Button(**self.maybe, id="maybe", variant="warning")
|
|
228
|
+
else:
|
|
229
|
+
self.maybe = Button(self.maybe, id="maybe", variant="warning")
|
|
230
|
+
|
|
231
|
+
yield self.maybe
|
|
232
|
+
|
|
113
233
|
if self.no:
|
|
114
234
|
if isinstance(self.no, Button):
|
|
115
235
|
pass
|
|
@@ -123,6 +243,8 @@ class YesNoScreen(ModalScreen):
|
|
|
123
243
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
124
244
|
if event.button.id == "yes":
|
|
125
245
|
self._handle_yes()
|
|
246
|
+
elif event.button.id == "maybe":
|
|
247
|
+
self._handle_maybe()
|
|
126
248
|
elif event.button.id == "no":
|
|
127
249
|
self.dismiss(None)
|
|
128
250
|
|
|
@@ -142,6 +264,14 @@ class YesNoScreen(ModalScreen):
|
|
|
142
264
|
else:
|
|
143
265
|
self.dismiss(True)
|
|
144
266
|
|
|
267
|
+
def _handle_maybe(self) -> None:
|
|
268
|
+
"""Handle Maybe button press."""
|
|
269
|
+
if self.on_maybe_callback:
|
|
270
|
+
result = self.on_maybe_callback()
|
|
271
|
+
self.dismiss(result)
|
|
272
|
+
else:
|
|
273
|
+
self.dismiss(False)
|
|
274
|
+
|
|
145
275
|
|
|
146
276
|
class SaveFileScreen(YesNoScreen):
|
|
147
277
|
"""Modal screen to save the dataframe to a CSV file."""
|
|
@@ -157,9 +287,9 @@ class SaveFileScreen(YesNoScreen):
|
|
|
157
287
|
|
|
158
288
|
def handle_save(self):
|
|
159
289
|
if self.input:
|
|
160
|
-
|
|
161
|
-
if
|
|
162
|
-
return
|
|
290
|
+
input_filename = self.input.value.strip()
|
|
291
|
+
if input_filename:
|
|
292
|
+
return input_filename
|
|
163
293
|
else:
|
|
164
294
|
self.notify("Filename cannot be empty", title="Save", severity="error")
|
|
165
295
|
return None
|
|
@@ -172,14 +302,21 @@ class ConfirmScreen(YesNoScreen):
|
|
|
172
302
|
|
|
173
303
|
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "ConfirmScreen")
|
|
174
304
|
|
|
175
|
-
def __init__(self, title: str):
|
|
305
|
+
def __init__(self, title: str, label=None, yes="Yes", maybe: str = None, no="No"):
|
|
176
306
|
super().__init__(
|
|
177
307
|
title=title,
|
|
178
|
-
|
|
308
|
+
label=label,
|
|
309
|
+
yes=yes,
|
|
310
|
+
maybe=maybe,
|
|
311
|
+
no=no,
|
|
312
|
+
on_yes_callback=self.handle_yes,
|
|
179
313
|
)
|
|
180
314
|
|
|
181
|
-
def
|
|
182
|
-
|
|
315
|
+
def handle_yes(self) -> bool:
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
def handle_maybe(self) -> bool:
|
|
319
|
+
return False
|
|
183
320
|
|
|
184
321
|
|
|
185
322
|
class EditCellScreen(YesNoScreen):
|
|
@@ -187,51 +324,104 @@ class EditCellScreen(YesNoScreen):
|
|
|
187
324
|
|
|
188
325
|
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "EditCellScreen")
|
|
189
326
|
|
|
190
|
-
def __init__(self,
|
|
191
|
-
self.
|
|
192
|
-
self.
|
|
193
|
-
self.
|
|
194
|
-
self.col_dtype = str(df.dtypes[col_idx])
|
|
195
|
-
|
|
196
|
-
row_idx = int(row_key.value) - 1 # Convert to 0-based
|
|
327
|
+
def __init__(self, ridx: int, cidx: int, df: pl.DataFrame):
|
|
328
|
+
self.ridx = ridx
|
|
329
|
+
self.cidx = cidx
|
|
330
|
+
self.dtype = df.dtypes[cidx]
|
|
197
331
|
|
|
198
332
|
# Label
|
|
199
|
-
content = f"[$
|
|
333
|
+
content = f"[$success]{df.columns[cidx]}[/] ([$accent]{self.dtype}[/])"
|
|
200
334
|
|
|
201
335
|
# Input
|
|
202
|
-
df_value = df.item(
|
|
203
|
-
self.input_value =
|
|
336
|
+
df_value = df.item(ridx, cidx)
|
|
337
|
+
self.input_value = "" if df_value is None else str(df_value).strip()
|
|
204
338
|
|
|
205
339
|
super().__init__(
|
|
206
340
|
title="Edit Cell",
|
|
207
341
|
label=content,
|
|
208
342
|
input={
|
|
209
343
|
"value": self.input_value,
|
|
210
|
-
"type": DtypeConfig(self.
|
|
344
|
+
"type": DtypeConfig(self.dtype).itype,
|
|
211
345
|
},
|
|
212
|
-
on_yes_callback=self.
|
|
346
|
+
on_yes_callback=self._validate_input,
|
|
213
347
|
)
|
|
214
348
|
|
|
215
|
-
def
|
|
349
|
+
def _validate_input(self) -> None:
|
|
216
350
|
"""Validate and save the edited value."""
|
|
217
351
|
new_value_str = self.input.value.strip()
|
|
218
352
|
|
|
353
|
+
# Handle empty input
|
|
354
|
+
if not new_value_str:
|
|
355
|
+
new_value = None
|
|
356
|
+
self.notify(
|
|
357
|
+
"Empty value provided. If you want to clear the cell, press [$accent]x[/].",
|
|
358
|
+
title="Edit",
|
|
359
|
+
severity="warning",
|
|
360
|
+
)
|
|
219
361
|
# Check if value changed
|
|
220
|
-
|
|
221
|
-
|
|
362
|
+
elif new_value_str == self.input_value:
|
|
363
|
+
new_value = None
|
|
222
364
|
self.notify("No changes made", title="Edit", severity="warning")
|
|
223
|
-
|
|
365
|
+
else:
|
|
366
|
+
# Parse and validate based on column dtype
|
|
367
|
+
try:
|
|
368
|
+
new_value = DtypeConfig(self.dtype).convert(new_value_str)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
self.notify(
|
|
371
|
+
f"Failed to convert [$accent]{new_value_str}[/] to [$error]{self.dtype}[/]: {str(e)}",
|
|
372
|
+
title="Edit",
|
|
373
|
+
severity="error",
|
|
374
|
+
)
|
|
375
|
+
return None
|
|
224
376
|
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
377
|
+
# New value
|
|
378
|
+
return self.ridx, self.cidx, new_value
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class RenameColumnScreen(YesNoScreen):
|
|
382
|
+
"""Modal screen to rename a column."""
|
|
383
|
+
|
|
384
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "RenameColumnScreen")
|
|
385
|
+
|
|
386
|
+
def __init__(self, col_idx: int, col_name: str, existing_columns: list[str]):
|
|
387
|
+
self.col_idx = col_idx
|
|
388
|
+
self.col_name = col_name
|
|
389
|
+
self.existing_columns = [c for c in existing_columns if c != col_name]
|
|
232
390
|
|
|
233
|
-
#
|
|
234
|
-
|
|
391
|
+
# Label
|
|
392
|
+
content = f"Rename header [$success]{col_name}[/]"
|
|
393
|
+
|
|
394
|
+
super().__init__(
|
|
395
|
+
title="Rename Column",
|
|
396
|
+
label=content,
|
|
397
|
+
input={"value": col_name},
|
|
398
|
+
on_yes_callback=self._validate_input,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def _validate_input(self) -> None:
|
|
402
|
+
"""Validate and save the new column name."""
|
|
403
|
+
new_name = self.input.value.strip()
|
|
404
|
+
|
|
405
|
+
# Check if name is empty
|
|
406
|
+
if not new_name:
|
|
407
|
+
self.notify("Column name cannot be empty", title="Rename", severity="error")
|
|
408
|
+
|
|
409
|
+
# Check if name changed
|
|
410
|
+
elif new_name == self.col_name:
|
|
411
|
+
self.notify("No changes made", title="Rename", severity="warning")
|
|
412
|
+
new_name = None
|
|
413
|
+
|
|
414
|
+
# Check if name already exists
|
|
415
|
+
elif new_name in self.existing_columns:
|
|
416
|
+
self.notify(
|
|
417
|
+
f"Column [$accent]{new_name}[/] already exists",
|
|
418
|
+
title="Rename",
|
|
419
|
+
severity="error",
|
|
420
|
+
)
|
|
421
|
+
new_name = None
|
|
422
|
+
|
|
423
|
+
# Return new name
|
|
424
|
+
return self.col_idx, self.col_name, new_name
|
|
235
425
|
|
|
236
426
|
|
|
237
427
|
class SearchScreen(YesNoScreen):
|
|
@@ -239,34 +429,39 @@ class SearchScreen(YesNoScreen):
|
|
|
239
429
|
|
|
240
430
|
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "SearchScreen")
|
|
241
431
|
|
|
242
|
-
def __init__(
|
|
243
|
-
self
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
432
|
+
def __init__(self, title, term, df: pl.DataFrame, cidx: int):
|
|
433
|
+
self.cidx = cidx
|
|
434
|
+
|
|
435
|
+
EXPR = f"ABC, (?i)abc, ^abc$, {NULL}, $_ > 50, $1 < $HP, $_.str.contains('sub')"
|
|
436
|
+
|
|
437
|
+
if "Search" in title:
|
|
438
|
+
col_name = df.columns[cidx]
|
|
439
|
+
col_dtype = df.dtypes[cidx]
|
|
440
|
+
label = f"{title} in [$success]{col_name}[/] ([$warning]{col_dtype}[/]) with value or Polars expression, e.g., {EXPR}"
|
|
441
|
+
else:
|
|
442
|
+
label = f"{title} by value or Polars expression, e.g., {EXPR}"
|
|
252
443
|
|
|
253
444
|
super().__init__(
|
|
254
|
-
title=
|
|
255
|
-
label=
|
|
256
|
-
input=
|
|
257
|
-
|
|
445
|
+
title=title,
|
|
446
|
+
label=label,
|
|
447
|
+
input=term,
|
|
448
|
+
checkbox="Match Nocase",
|
|
449
|
+
checkbox2="Match Whole",
|
|
450
|
+
on_yes_callback=self._validate_input,
|
|
258
451
|
)
|
|
259
452
|
|
|
260
|
-
def
|
|
261
|
-
"""
|
|
453
|
+
def _validate_input(self) -> tuple[str, int, bool, bool]:
|
|
454
|
+
"""Validate the input and return it."""
|
|
262
455
|
term = self.input.value.strip()
|
|
263
456
|
|
|
264
457
|
if not term:
|
|
265
|
-
self.notify("
|
|
458
|
+
self.notify("Term cannot be empty", title=self.title, severity="error")
|
|
266
459
|
return
|
|
267
460
|
|
|
268
|
-
|
|
269
|
-
|
|
461
|
+
match_nocase = self.checkbox.value
|
|
462
|
+
match_whole = self.checkbox2.value
|
|
463
|
+
|
|
464
|
+
return term, self.cidx, match_nocase, match_whole
|
|
270
465
|
|
|
271
466
|
|
|
272
467
|
class FilterScreen(YesNoScreen):
|
|
@@ -274,49 +469,25 @@ class FilterScreen(YesNoScreen):
|
|
|
274
469
|
|
|
275
470
|
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "FilterScreen")
|
|
276
471
|
|
|
277
|
-
def __init__(
|
|
278
|
-
self,
|
|
279
|
-
df: pl.DataFrame,
|
|
280
|
-
current_col_idx: int | None = None,
|
|
281
|
-
current_cell_value: str | None = None,
|
|
282
|
-
):
|
|
472
|
+
def __init__(self, df: pl.DataFrame, cidx: int, input_value: str | None = None):
|
|
283
473
|
self.df = df
|
|
284
|
-
self.
|
|
474
|
+
self.cidx = cidx
|
|
285
475
|
super().__init__(
|
|
286
476
|
title="Filter by Expression",
|
|
287
|
-
label="e.g., $1 > 50, $name == 'text', $_ > 100, $a < $b",
|
|
288
|
-
input=
|
|
289
|
-
|
|
477
|
+
label="e.g., NULL, $1 > 50, $name == 'text', $_ > 100, $a < $b, $_.str.contains('sub')",
|
|
478
|
+
input=input_value,
|
|
479
|
+
checkbox="Match Nocase",
|
|
480
|
+
checkbox2="Match Whole",
|
|
481
|
+
on_yes_callback=self._get_input,
|
|
290
482
|
)
|
|
291
483
|
|
|
292
|
-
def
|
|
293
|
-
"""
|
|
294
|
-
|
|
484
|
+
def _get_input(self) -> tuple[str, int, bool, bool]:
|
|
485
|
+
"""Get input."""
|
|
486
|
+
term = self.input.value.strip()
|
|
487
|
+
match_nocase = self.checkbox.value
|
|
488
|
+
match_whole = self.checkbox2.value
|
|
295
489
|
|
|
296
|
-
|
|
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)
|
|
490
|
+
return term, self.cidx, match_nocase, match_whole
|
|
320
491
|
|
|
321
492
|
|
|
322
493
|
class FreezeScreen(YesNoScreen):
|
|
@@ -329,58 +500,28 @@ class FreezeScreen(YesNoScreen):
|
|
|
329
500
|
|
|
330
501
|
def __init__(self):
|
|
331
502
|
super().__init__(
|
|
332
|
-
title="Pin Rows
|
|
333
|
-
label="Enter number of fixed rows
|
|
334
|
-
input="
|
|
335
|
-
|
|
503
|
+
title="Pin Rows / Columns",
|
|
504
|
+
label="Enter number of fixed rows",
|
|
505
|
+
input={"value": "0", "type": "number"},
|
|
506
|
+
label2="Enter number of fixed columns",
|
|
507
|
+
input2={"value": "0", "type": "number"},
|
|
508
|
+
on_yes_callback=self._get_input,
|
|
336
509
|
)
|
|
337
510
|
|
|
338
|
-
def
|
|
511
|
+
def _get_input(self) -> tuple[int, int] | None:
|
|
339
512
|
"""Parse and validate the pin input.
|
|
340
513
|
|
|
341
514
|
Returns:
|
|
342
515
|
Tuple of (fixed_rows, fixed_columns) or None if invalid.
|
|
343
516
|
"""
|
|
344
|
-
|
|
517
|
+
fixed_rows = int(self.input.value.strip())
|
|
518
|
+
fixed_cols = int(self.input2.value.strip())
|
|
345
519
|
|
|
346
|
-
if
|
|
347
|
-
self.notify("
|
|
520
|
+
if fixed_rows < 0 or fixed_cols < 0:
|
|
521
|
+
self.notify("Values must be non-negative", title="Pin", severity="error")
|
|
348
522
|
return None
|
|
349
523
|
|
|
350
|
-
|
|
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
|
|
524
|
+
return fixed_rows, fixed_cols
|
|
384
525
|
|
|
385
526
|
|
|
386
527
|
class OpenFileScreen(YesNoScreen):
|
|
@@ -406,4 +547,126 @@ class OpenFileScreen(YesNoScreen):
|
|
|
406
547
|
self.notify("Filename cannot be empty", title="Open", severity="error")
|
|
407
548
|
return None
|
|
408
549
|
|
|
409
|
-
|
|
550
|
+
|
|
551
|
+
class EditColumnScreen(YesNoScreen):
|
|
552
|
+
"""Modal screen to edit an entire column with an expression."""
|
|
553
|
+
|
|
554
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "EditColumnScreen")
|
|
555
|
+
|
|
556
|
+
def __init__(self, cidx: int, df: pl.DataFrame):
|
|
557
|
+
self.cidx = cidx
|
|
558
|
+
self.df = df
|
|
559
|
+
super().__init__(
|
|
560
|
+
title="Edit Column",
|
|
561
|
+
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())",
|
|
562
|
+
input="$_",
|
|
563
|
+
on_yes_callback=self._get_input,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def _get_input(self) -> tuple[str, int]:
|
|
567
|
+
"""Get input."""
|
|
568
|
+
term = self.input.value.strip()
|
|
569
|
+
return term, self.cidx
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class AddColumnScreen(YesNoScreen):
|
|
573
|
+
"""Modal screen to add a new column with an expression."""
|
|
574
|
+
|
|
575
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "AddColumnScreen")
|
|
576
|
+
|
|
577
|
+
def __init__(self, cidx: int, df: pl.DataFrame):
|
|
578
|
+
self.cidx = cidx
|
|
579
|
+
self.df = df
|
|
580
|
+
self.existing_columns = set(df.columns)
|
|
581
|
+
super().__init__(
|
|
582
|
+
title="Add Column",
|
|
583
|
+
label="Enter column name",
|
|
584
|
+
input="column name",
|
|
585
|
+
label2="Enter value or Polars expression, e.g., 123, NULL, $_ * 2",
|
|
586
|
+
input2="column value or expression",
|
|
587
|
+
on_yes_callback=self._get_input,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
def _get_input(self) -> tuple[int, str, str] | None:
|
|
591
|
+
"""Validate and return the new column configuration."""
|
|
592
|
+
col_name = self.input.value.strip()
|
|
593
|
+
term = self.input2.value.strip()
|
|
594
|
+
|
|
595
|
+
# Validate column name
|
|
596
|
+
if not col_name:
|
|
597
|
+
self.notify("Column name cannot be empty", title="Add Column", severity="error")
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
if col_name in self.existing_columns:
|
|
601
|
+
self.notify(
|
|
602
|
+
f"Column [$accent]{col_name}[/] already exists",
|
|
603
|
+
title="Add Column",
|
|
604
|
+
severity="error",
|
|
605
|
+
)
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
if term == NULL:
|
|
609
|
+
return self.cidx, col_name, pl.lit(None)
|
|
610
|
+
elif tentative_expr(term):
|
|
611
|
+
try:
|
|
612
|
+
expr = validate_expr(term, self.df, self.cidx)
|
|
613
|
+
return self.cidx, col_name, expr
|
|
614
|
+
except ValueError as e:
|
|
615
|
+
self.notify(f"Invalid expression [$error]{term}[/]: {str(e)}", title="Add Column", severity="error")
|
|
616
|
+
return None
|
|
617
|
+
else:
|
|
618
|
+
# Treat as literal value
|
|
619
|
+
dtype = self.df.dtypes[self.cidx]
|
|
620
|
+
try:
|
|
621
|
+
value = DtypeConfig(dtype).convert(term)
|
|
622
|
+
return self.cidx, col_name, pl.lit(value)
|
|
623
|
+
except Exception:
|
|
624
|
+
self.notify(
|
|
625
|
+
f"Unable to convert [$accent]{term}[/] to [$warning]{dtype}[/]. Cast to string.",
|
|
626
|
+
title="Add Column",
|
|
627
|
+
severity="warning",
|
|
628
|
+
)
|
|
629
|
+
return self.cidx, col_name, pl.lit(term)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class FindReplaceScreen(YesNoScreen):
|
|
633
|
+
"""Modal screen to replace column values with an expression."""
|
|
634
|
+
|
|
635
|
+
CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "ReplaceScreen")
|
|
636
|
+
|
|
637
|
+
def __init__(self, dftable: DataFrameTable):
|
|
638
|
+
term_find = str(dftable.cursor_value)
|
|
639
|
+
super().__init__(
|
|
640
|
+
title="Find and Replace",
|
|
641
|
+
label="Find",
|
|
642
|
+
input=term_find,
|
|
643
|
+
label2="Replace with",
|
|
644
|
+
input2="new value or expression",
|
|
645
|
+
checkbox="Match Nocase",
|
|
646
|
+
checkbox2="Match Whole",
|
|
647
|
+
yes="Replace",
|
|
648
|
+
maybe="Replace All",
|
|
649
|
+
no="Cancel",
|
|
650
|
+
on_yes_callback=self._get_input,
|
|
651
|
+
on_maybe_callback=self._get_input_replace_all,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def _get_input(self) -> tuple[str, str, bool, bool, bool]:
|
|
655
|
+
"""Get input."""
|
|
656
|
+
term_find = self.input.value.strip()
|
|
657
|
+
term_replace = self.input2.value.strip()
|
|
658
|
+
match_nocase = self.checkbox.value
|
|
659
|
+
match_whole = self.checkbox2.value
|
|
660
|
+
replace_all = False
|
|
661
|
+
|
|
662
|
+
return term_find, term_replace, match_nocase, match_whole, replace_all
|
|
663
|
+
|
|
664
|
+
def _get_input_replace_all(self) -> tuple[str, str, bool, bool, bool]:
|
|
665
|
+
"""Get input for 'Replace All'."""
|
|
666
|
+
term_find = self.input.value.strip()
|
|
667
|
+
term_replace = self.input2.value.strip()
|
|
668
|
+
match_nocase = self.checkbox.value
|
|
669
|
+
match_whole = self.checkbox2.value
|
|
670
|
+
replace_all = True
|
|
671
|
+
|
|
672
|
+
return term_find, term_replace, match_nocase, match_whole, replace_all
|