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