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.
@@ -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,59 @@ 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;
32
- background: $surface;
33
- padding: 2;
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 1;
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 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 when Yes is pressed
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
- 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:
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
- filename_input = self.input.value.strip()
161
- if filename_input:
162
- return filename_input
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 confirm file overwrite."""
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
- on_yes_callback=self.handle_confirm,
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 handle_confirm(self) -> None:
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, 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
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"[$primary]{self.col_name}[/] ([$accent]{self.col_dtype}[/])"
337
+ content = f"[$success]{df.columns[cidx]}[/] ([$accent]{self.dtype}[/])"
200
338
 
201
339
  # Input
202
- df_value = df.item(row_idx, col_idx)
203
- self.input_value = str(df_value) if df_value is not None else ""
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.col_dtype).itype,
348
+ "type": DtypeConfig(self.dtype).itype,
211
349
  },
212
- on_yes_callback=self._save_edit,
350
+ on_yes_callback=self._validate_input,
213
351
  )
214
352
 
215
- def _save_edit(self) -> None:
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
- if new_value_str == self.input_value:
366
+ elif new_value_str == self.input_value:
367
+ new_value = None
221
368
  self.notify("No changes made", title="Edit", severity="warning")
222
- return None
223
-
224
- # Parse and validate based on column dtype
225
- try:
226
- new_value = DtypeConfig(self.col_dtype).convert(new_value_str)
227
- except Exception as e:
228
- self.notify(f"Invalid value: {str(e)}", title="Edit", severity="error")
229
- return None
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.row_key, self.col_idx, new_value
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
- term,
243
- col_dtype: pl.DataType,
244
- col_name: str | None = None,
245
- ):
246
- col_dtype = col_dtype if col_name else pl.String
247
- label = f"Search [$primary]{term}[/] ([$accent]{col_dtype}[/])"
248
- self.col_name = col_name
249
- self.col_dtype = col_dtype
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="Search" if col_name else "Global Search",
253
- label=f"{label} in [$primary]{col_name}[/]" if col_name else label,
254
- input={"value": term, "type": DtypeConfig(col_dtype).itype},
255
- on_yes_callback=self._do_search,
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 _do_search(self) -> None:
259
- """Perform the search."""
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("Search term cannot be empty", title="Search", severity="error")
462
+ self.notify("Term cannot be empty", title=self.title, severity="error")
264
463
  return
265
464
 
266
- # Search term
267
- return term, self.col_dtype, self.col_name
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.current_col_idx = current_col_idx
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=f"$_ == {current_cell_value}",
287
- on_yes_callback=self._validate_filter,
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 _validate_filter(self) -> pl.Expr | None:
291
- """Validate and return the filter expression."""
292
- expression = self.input.value.strip()
293
-
294
- try:
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
- try:
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 and Columns",
331
- label="Enter number of fixed rows and columns (space-separated)",
332
- input="1",
333
- on_yes_callback=self._parse_pin_input,
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 _parse_pin_input(self) -> tuple[int, int] | None:
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
- input_str = self.input.value.strip()
521
+ fixed_rows = int(self.input.value.strip())
522
+ fixed_cols = int(self.input2.value.strip())
343
523
 
344
- if not input_str:
345
- self.notify("Input cannot be empty", title="Pin", severity="error")
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
- parts = input_str.split()
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
- return None
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