dataframe-textual 0.1.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.

Potentially problematic release.


This version of dataframe-textual might be problematic. Click here for more details.

@@ -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