dataframe-textual 0.1.0__py3-none-any.whl → 1.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,672 @@
1
+ """Modal screens with Yes/No buttons and their specialized variants."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from .data_frame_table import DataFrameTable
7
+
8
+ import polars as pl
9
+ from textual.app import ComposeResult
10
+ from textual.containers import Container, Horizontal
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Button, Checkbox, Input, Label, Static
13
+
14
+ from .common import NULL, DtypeConfig, tentative_expr, validate_expr
15
+
16
+
17
+ class YesNoScreen(ModalScreen):
18
+ """Reusable modal screen with Yes/No buttons and customizable label and input.
19
+
20
+ This widget handles:
21
+ - Yes/No button responses
22
+ - Enter key for Yes, Escape for No
23
+ - Optional callback function for Yes action
24
+ """
25
+
26
+ DEFAULT_CSS = """
27
+ YesNoScreen {
28
+ align: center middle;
29
+ }
30
+
31
+ YesNoScreen > Static {
32
+ width: auto;
33
+ min-width: 40;
34
+ max-width: 60;
35
+ height: auto;
36
+ border: heavy $primary;
37
+ border-title-color: $primary-lighten-3;
38
+ background: $surface;
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;
51
+ }
52
+
53
+ YesNoScreen Input {
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;
73
+ }
74
+
75
+ YesNoScreen #button-container {
76
+ margin: 1 0 0 0;
77
+ width: 100%;
78
+ height: 3;
79
+ align: center middle;
80
+ }
81
+
82
+ YesNoScreen Button {
83
+ margin: 0 2;
84
+ }
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ title: str = None,
90
+ label: str | dict | Label = None,
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,
96
+ yes: str | dict | Button = "Yes",
97
+ maybe: str | dict | Button = None,
98
+ no: str | dict | Button = "No",
99
+ on_yes_callback=None,
100
+ on_maybe_callback=None,
101
+ ) -> None:
102
+ """Initialize the modal screen.
103
+
104
+ Creates a customizable Yes/No dialog with optional input fields, labels, and checkboxes.
105
+
106
+ Args:
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
121
+ """
122
+ super().__init__()
123
+ self.title = title
124
+ self.label = label
125
+ self.input = input
126
+ self.label2 = label2
127
+ self.input2 = input2
128
+ self.checkbox = checkbox
129
+ self.checkbox2 = checkbox2
130
+ self.yes = yes
131
+ self.maybe = maybe
132
+ self.no = no
133
+ self.on_yes_callback = on_yes_callback
134
+ self.on_maybe_callback = on_maybe_callback
135
+
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
+ """
145
+ with Static(id="modal-container") as container:
146
+ if self.title:
147
+ container.border_title = self.title
148
+
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:
212
+ with Horizontal(id="button-container"):
213
+ if self.yes:
214
+ if isinstance(self.yes, Button):
215
+ pass
216
+ elif isinstance(self.yes, dict):
217
+ self.yes = Button(**self.yes, id="yes", variant="success")
218
+ else:
219
+ self.yes = Button(self.yes, id="yes", variant="success")
220
+
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
+
233
+ if self.no:
234
+ if isinstance(self.no, Button):
235
+ pass
236
+ elif isinstance(self.no, dict):
237
+ self.no = Button(**self.no, id="no", variant="error")
238
+ else:
239
+ self.no = Button(self.no, id="no", variant="error")
240
+
241
+ yield self.no
242
+
243
+ def on_button_pressed(self, event: Button.Pressed) -> None:
244
+ if event.button.id == "yes":
245
+ self._handle_yes()
246
+ elif event.button.id == "maybe":
247
+ self._handle_maybe()
248
+ elif event.button.id == "no":
249
+ self.dismiss(None)
250
+
251
+ def on_key(self, event) -> None:
252
+ if event.key == "enter":
253
+ self._handle_yes()
254
+ event.stop()
255
+ elif event.key == "escape":
256
+ self.dismiss(None)
257
+ event.stop()
258
+
259
+ def _handle_yes(self) -> None:
260
+ """Handle Yes button/Enter key press."""
261
+ if self.on_yes_callback:
262
+ result = self.on_yes_callback()
263
+ self.dismiss(result)
264
+ else:
265
+ self.dismiss(True)
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
+
275
+
276
+ class SaveFileScreen(YesNoScreen):
277
+ """Modal screen to save the dataframe to a CSV file."""
278
+
279
+ CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "SaveFileScreen")
280
+
281
+ def __init__(self, filename: str, title="Save Tab"):
282
+ super().__init__(
283
+ title=title,
284
+ input=filename,
285
+ on_yes_callback=self.handle_save,
286
+ )
287
+
288
+ def handle_save(self):
289
+ if self.input:
290
+ input_filename = self.input.value.strip()
291
+ if input_filename:
292
+ return input_filename
293
+ else:
294
+ self.notify("Filename cannot be empty", title="Save", severity="error")
295
+ return None
296
+
297
+ return None
298
+
299
+
300
+ class ConfirmScreen(YesNoScreen):
301
+ """Modal screen to confirm file overwrite."""
302
+
303
+ CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "ConfirmScreen")
304
+
305
+ def __init__(self, title: str, label=None, yes="Yes", maybe: str = None, no="No"):
306
+ super().__init__(
307
+ title=title,
308
+ label=label,
309
+ yes=yes,
310
+ maybe=maybe,
311
+ no=no,
312
+ on_yes_callback=self.handle_yes,
313
+ )
314
+
315
+ def handle_yes(self) -> bool:
316
+ return True
317
+
318
+ def handle_maybe(self) -> bool:
319
+ return False
320
+
321
+
322
+ class EditCellScreen(YesNoScreen):
323
+ """Modal screen to edit a single cell value."""
324
+
325
+ CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "EditCellScreen")
326
+
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]
331
+
332
+ # Label
333
+ content = f"[$success]{df.columns[cidx]}[/] ([$accent]{self.dtype}[/])"
334
+
335
+ # Input
336
+ df_value = df.item(ridx, cidx)
337
+ self.input_value = "" if df_value is None else str(df_value).strip()
338
+
339
+ super().__init__(
340
+ title="Edit Cell",
341
+ label=content,
342
+ input={
343
+ "value": self.input_value,
344
+ "type": DtypeConfig(self.dtype).itype,
345
+ },
346
+ on_yes_callback=self._validate_input,
347
+ )
348
+
349
+ def _validate_input(self) -> None:
350
+ """Validate and save the edited value."""
351
+ new_value_str = self.input.value.strip()
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
+ )
361
+ # Check if value changed
362
+ elif new_value_str == self.input_value:
363
+ new_value = None
364
+ self.notify("No changes made", title="Edit", severity="warning")
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
376
+
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]
390
+
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
425
+
426
+
427
+ class SearchScreen(YesNoScreen):
428
+ """Modal screen to search for values in a column."""
429
+
430
+ CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "SearchScreen")
431
+
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}"
443
+
444
+ super().__init__(
445
+ title=title,
446
+ label=label,
447
+ input=term,
448
+ checkbox="Match Nocase",
449
+ checkbox2="Match Whole",
450
+ on_yes_callback=self._validate_input,
451
+ )
452
+
453
+ def _validate_input(self) -> tuple[str, int, bool, bool]:
454
+ """Validate the input and return it."""
455
+ term = self.input.value.strip()
456
+
457
+ if not term:
458
+ self.notify("Term cannot be empty", title=self.title, severity="error")
459
+ return
460
+
461
+ match_nocase = self.checkbox.value
462
+ match_whole = self.checkbox2.value
463
+
464
+ return term, self.cidx, match_nocase, match_whole
465
+
466
+
467
+ class FilterScreen(YesNoScreen):
468
+ """Modal screen to filter rows by column expression."""
469
+
470
+ CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "FilterScreen")
471
+
472
+ def __init__(self, df: pl.DataFrame, cidx: int, input_value: str | None = None):
473
+ self.df = df
474
+ self.cidx = cidx
475
+ super().__init__(
476
+ title="Filter by Expression",
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,
482
+ )
483
+
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
489
+
490
+ return term, self.cidx, match_nocase, match_whole
491
+
492
+
493
+ class FreezeScreen(YesNoScreen):
494
+ """Modal screen to pin rows and columns.
495
+
496
+ Accepts one value for fixed rows, or two space-separated values for fixed rows and columns.
497
+ """
498
+
499
+ CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "PinScreen")
500
+
501
+ def __init__(self):
502
+ super().__init__(
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,
509
+ )
510
+
511
+ def _get_input(self) -> tuple[int, int] | None:
512
+ """Parse and validate the pin input.
513
+
514
+ Returns:
515
+ Tuple of (fixed_rows, fixed_columns) or None if invalid.
516
+ """
517
+ fixed_rows = int(self.input.value.strip())
518
+ fixed_cols = int(self.input2.value.strip())
519
+
520
+ if fixed_rows < 0 or fixed_cols < 0:
521
+ self.notify("Values must be non-negative", title="Pin", severity="error")
522
+ return None
523
+
524
+ return fixed_rows, fixed_cols
525
+
526
+
527
+ class OpenFileScreen(YesNoScreen):
528
+ """Modal screen to open a CSV file."""
529
+
530
+ CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "OpenFileScreen")
531
+
532
+ def __init__(self):
533
+ super().__init__(
534
+ title="Open File",
535
+ input="Enter relative or absolute file path",
536
+ yes="Open",
537
+ no="Cancel",
538
+ on_yes_callback=self.handle_open,
539
+ )
540
+
541
+ def handle_open(self):
542
+ if self.input:
543
+ filename_input = self.input.value.strip()
544
+ if filename_input:
545
+ return filename_input
546
+ else:
547
+ self.notify("Filename cannot be empty", title="Open", severity="error")
548
+ return None
549
+
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