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.
@@ -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, parse_filter_expression
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: 30;
33
+ min-width: 40;
29
34
  max-width: 60;
30
35
  height: auto;
31
- border: solid $primary;
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 1;
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 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
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
- 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:
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
- filename_input = self.input.value.strip()
161
- if filename_input:
162
- return filename_input
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
- on_yes_callback=self.handle_confirm,
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 handle_confirm(self) -> None:
182
- self.dismiss(True)
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, 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
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"[$primary]{self.col_name}[/] ([$accent]{self.col_dtype}[/])"
333
+ content = f"[$success]{df.columns[cidx]}[/] ([$accent]{self.dtype}[/])"
200
334
 
201
335
  # Input
202
- df_value = df.item(row_idx, col_idx)
203
- self.input_value = str(df_value) if df_value is not None else ""
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.col_dtype).itype,
344
+ "type": DtypeConfig(self.dtype).itype,
211
345
  },
212
- on_yes_callback=self._save_edit,
346
+ on_yes_callback=self._validate_input,
213
347
  )
214
348
 
215
- def _save_edit(self) -> None:
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
- if new_value_str == self.input_value:
221
- self.dismiss(None)
362
+ elif new_value_str == self.input_value:
363
+ new_value = None
222
364
  self.notify("No changes made", title="Edit", severity="warning")
223
- return
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
- # 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
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
- # Dismiss with the new value
234
- self.dismiss((self.row_key, self.col_idx, new_value))
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
- 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
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="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,
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 _do_search(self) -> None:
261
- """Perform the search."""
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("Search term cannot be empty", title="Search", severity="error")
458
+ self.notify("Term cannot be empty", title=self.title, severity="error")
266
459
  return
267
460
 
268
- # Dismiss with the search term
269
- self.dismiss((term, self.col_dtype, self.col_name))
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.current_col_idx = current_col_idx
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=f"$_ == {current_cell_value}",
289
- on_yes_callback=self._validate_filter,
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 _validate_filter(self) -> pl.Expr | None:
293
- """Validate and return the filter expression."""
294
- expression = self.input.value.strip()
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
- 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)
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 and Columns",
333
- label="Enter number of fixed rows and columns (space-separated)",
334
- input="1",
335
- on_yes_callback=self._parse_pin_input,
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 _parse_pin_input(self) -> tuple[int, int] | None:
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
- input_str = self.input.value.strip()
517
+ fixed_rows = int(self.input.value.strip())
518
+ fixed_cols = int(self.input2.value.strip())
345
519
 
346
- if not input_str:
347
- self.notify("Input cannot be empty", title="Pin", severity="error")
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
- 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
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
- return None
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