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