dataframe-textual 1.12.0__py3-none-any.whl → 2.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.
@@ -33,14 +33,17 @@ class DataFrameViewer(App):
33
33
  - **Q** - ❌ Close all tabs (prompts to save unsaved changes)
34
34
  - **Ctrl+Q** - 🚪 Force to quit app (discards unsaved changes)
35
35
  - **Ctrl+T** - 💾 Save current tab to file
36
+ - **w** - 💾 Save current tab to file (overwrite without prompt)
36
37
  - **Ctrl+A** - 💾 Save all tabs to file
38
+ - **W** - 💾 Save all tabs to file (overwrite without prompt)
37
39
  - **Ctrl+D** - 📋 Duplicate current tab
38
40
  - **Ctrl+O** - 📁 Open a file
39
- - **Double-click tab** - ✏️ Rename tab
41
+ - **Double-click** - ✏️ Rename tab
40
42
 
41
43
  ## 🎨 View & Settings
42
44
  - **F1** - ❓ Toggle this help panel
43
45
  - **k** - 🌙 Cycle through themes
46
+ - **Ctrl+P -> Screenshot** - 📸 Capture terminal view as a SVG image
44
47
 
45
48
  ## ⭐ Features
46
49
  - **Multi-file support** - 📂 Open multiple CSV/Excel files as tabs
@@ -62,6 +65,8 @@ class DataFrameViewer(App):
62
65
  ("ctrl+o", "open_file", "Open File"),
63
66
  ("ctrl+t", "save_current_tab", "Save Current Tab"),
64
67
  ("ctrl+a", "save_all_tabs", "Save All Tabs"),
68
+ ("w", "save_current_tab_overwrite", "Save Current Tab (overwrite)"),
69
+ ("W", "save_all_tabs_overwrite", "Save All Tabs (overwrite)"),
65
70
  ("ctrl+d", "duplicate_tab", "Duplicate Tab"),
66
71
  ("greater_than_sign,b", "next_tab(1)", "Next Tab"), # '>' and 'b'
67
72
  ("less_than_sign", "next_tab(-1)", "Prev Tab"), # '<'
@@ -243,7 +248,7 @@ class DataFrameViewer(App):
243
248
  Opens the save dialog for the active tab's DataFrameTable to save its data.
244
249
  """
245
250
  if table := self.get_active_table():
246
- table.do_save_to_file(title="Save Current Tab", all_tabs=False)
251
+ table.do_save_to_file(all_tabs=False)
247
252
 
248
253
  def action_save_all_tabs(self) -> None:
249
254
  """Save all open tabs to their respective files.
@@ -251,7 +256,25 @@ class DataFrameViewer(App):
251
256
  Iterates through all DataFrameTable widgets and opens the save dialog for each.
252
257
  """
253
258
  if table := self.get_active_table():
254
- table.do_save_to_file(title="Save All Tabs", all_tabs=True)
259
+ table.do_save_to_file(all_tabs=True)
260
+
261
+ def action_save_current_tab_overwrite(self) -> None:
262
+ """Save the currently active tab to file, overwriting if it exists."""
263
+ if table := self.get_active_table():
264
+ filepath = Path(table.filename)
265
+ filename = filepath.with_stem(table.tabname)
266
+ table.save_to_file((filename, False, False))
267
+
268
+ def action_save_all_tabs_overwrite(self) -> None:
269
+ """Save all open tabs to their respective files, overwriting if they exist."""
270
+ if table := self.get_active_table():
271
+ filepath = Path(table.filename)
272
+ if filepath.suffix.lower() in [".xlsx", ".xls"]:
273
+ filename = table.filename
274
+ else:
275
+ filename = "all-tabs.xlsx"
276
+
277
+ table.save_to_file((filename, True, False))
255
278
 
256
279
  def action_duplicate_tab(self) -> None:
257
280
  """Duplicate the currently active tab.
@@ -269,7 +292,7 @@ class DataFrameViewer(App):
269
292
 
270
293
  # Create new table with the same dataframe and filename
271
294
  new_table = DataFrameTable(
272
- table.df,
295
+ table.df.clone(),
273
296
  table.filename,
274
297
  tabname=new_tabname,
275
298
  zebra_stripes=True,
@@ -13,6 +13,8 @@ from textual.screen import ModalScreen
13
13
  from textual.widgets import Button, Input, Label, SelectionList, TextArea
14
14
  from textual.widgets.selection_list import Selection
15
15
 
16
+ from .common import RID
17
+
16
18
 
17
19
  class SqlScreen(ModalScreen):
18
20
  """Base class for modal screens handling SQL query."""
@@ -151,9 +153,6 @@ class SimpleSqlScreen(SqlScreen):
151
153
 
152
154
  Args:
153
155
  dftable: Reference to the parent DataFrameTable widget.
154
-
155
- Returns:
156
- None
157
156
  """
158
157
  super().__init__(
159
158
  dftable,
@@ -165,12 +164,16 @@ class SimpleSqlScreen(SqlScreen):
165
164
  """Compose the simple SQL screen widget structure."""
166
165
  with Container(id="sql-container") as container:
167
166
  container.border_title = "SQL Query"
168
- yield Label("Select columns (default to all):", id="select-label")
167
+ yield Label("SELECT columns (all if none selected):", id="select-label")
169
168
  yield SelectionList(
170
- *[Selection(col, col) for col in self.df.columns if col not in self.dftable.hidden_columns],
169
+ *[
170
+ Selection(col, col)
171
+ for col in self.df.columns
172
+ if col not in self.dftable.hidden_columns and col != RID
173
+ ],
171
174
  id="column-selection",
172
175
  )
173
- yield Label("Where condition (optional)", id="where-label")
176
+ yield Label("WHERE condition (optional)", id="where-label")
174
177
  yield Input(placeholder="e.g., age > 30 and height < 180", id="where-input")
175
178
  yield from super().compose()
176
179
 
@@ -178,7 +181,7 @@ class SimpleSqlScreen(SqlScreen):
178
181
  """Handle Yes button/Enter key press."""
179
182
  selections = self.query_one(SelectionList).selected
180
183
  if not selections:
181
- selections = [col for col in self.df.columns if col not in self.dftable.hidden_columns]
184
+ selections = [col for col in self.df.columns if col not in self.dftable.hidden_columns and col != RID]
182
185
 
183
186
  columns = ", ".join(f"`{s}`" for s in selections)
184
187
  where = self.query_one(Input).value.strip()
@@ -212,9 +215,6 @@ class AdvancedSqlScreen(SqlScreen):
212
215
 
213
216
  Args:
214
217
  dftable: Reference to the parent DataFrameTable widget.
215
-
216
- Returns:
217
- None
218
218
  """
219
219
  super().__init__(
220
220
  dftable,
@@ -227,7 +227,7 @@ class AdvancedSqlScreen(SqlScreen):
227
227
  with Container(id="sql-container") as container:
228
228
  container.border_title = "Advanced SQL Query"
229
229
  yield TextArea.code_editor(
230
- placeholder="Enter SQL query (use `self` as the table name), e.g., \n\nSELECT * \nFROM self \nWHERE age > 30",
230
+ placeholder="Enter SQL query, e.g., \n\nSELECT * \nFROM self \nWHERE age > 30\n\n- use 'self' as the table name\n- use backticks (`) for column names with spaces.",
231
231
  id="sql-textarea",
232
232
  language="sql",
233
233
  )
@@ -13,7 +13,7 @@ from textual.renderables.bar import Bar
13
13
  from textual.screen import ModalScreen
14
14
  from textual.widgets import DataTable
15
15
 
16
- from .common import NULL, NULL_DISPLAY, RIDX, DtypeConfig, format_float, format_row
16
+ from .common import NULL, NULL_DISPLAY, RID, DtypeConfig, format_float
17
17
 
18
18
 
19
19
  class TableScreen(ModalScreen):
@@ -45,9 +45,6 @@ class TableScreen(ModalScreen):
45
45
 
46
46
  Args:
47
47
  dftable: Reference to the parent DataFrameTable widget.
48
-
49
- Returns:
50
- None
51
48
  """
52
49
  super().__init__()
53
50
  self.dftable = dftable # DataFrameTable
@@ -71,9 +68,6 @@ class TableScreen(ModalScreen):
71
68
 
72
69
  Subclasses should implement this method to populate the DataTable
73
70
  with appropriate columns and rows based on the specific screen's purpose.
74
-
75
- Returns:
76
- None
77
71
  """
78
72
  raise NotImplementedError("Subclasses must implement build_table method.")
79
73
 
@@ -85,9 +79,6 @@ class TableScreen(ModalScreen):
85
79
 
86
80
  Args:
87
81
  event: The key event object.
88
-
89
- Returns:
90
- None
91
82
  """
92
83
  if event.key in ("q", "escape"):
93
84
  self.app.pop_screen()
@@ -111,7 +102,7 @@ class TableScreen(ModalScreen):
111
102
  if cidx_name_value is None:
112
103
  return
113
104
  cidx, col_name, col_value = cidx_name_value
114
- self.log(f"Filtering or viewing by {col_name} == {col_value}")
105
+ self.log(f"Filtering or viewing by `{col_name} == {col_value}`")
115
106
 
116
107
  # Handle NULL values
117
108
  if col_value == NULL:
@@ -123,11 +114,11 @@ class TableScreen(ModalScreen):
123
114
  expr = pl.col(col_name) == col_value
124
115
  value_display = f"[$success]{col_value}[/]"
125
116
 
126
- df_filtered = self.dftable.df.with_row_index(RIDX).filter(expr)
117
+ df_filtered = self.dftable.df.lazy().filter(expr).collect()
127
118
  self.log(f"Filtered dataframe has {len(df_filtered)} rows")
128
119
 
129
- matched_indices = set(df_filtered[RIDX].to_list())
130
- if not matched_indices:
120
+ ok_rids = set(df_filtered[RID].to_list())
121
+ if not ok_rids:
131
122
  self.notify(
132
123
  f"No matches found for [$warning]{col_name}[/] == {value_display}",
133
124
  title="No Matches",
@@ -135,18 +126,12 @@ class TableScreen(ModalScreen):
135
126
  )
136
127
  return
137
128
 
138
- # Apply the action
129
+ # Action filter
139
130
  if action == "filter":
140
- # Update selections
141
- for i in range(len(self.dftable.selected_rows)):
142
- self.dftable.selected_rows[i] = i in matched_indices
143
-
144
- # Update main table display
145
131
  self.dftable.do_filter_rows()
146
132
 
147
- else: # action == "view"
148
- # Update visible rows
149
- expr = [i in matched_indices for i in range(len(self.dftable.df))]
133
+ # Action view
134
+ else:
150
135
  self.dftable.view_rows((expr, cidx, False, True))
151
136
 
152
137
  # Dismiss the frequency screen
@@ -167,9 +152,6 @@ class RowDetailScreen(TableScreen):
167
152
 
168
153
  Populates the table with column names and values from the selected row
169
154
  of the main DataFrame. Sets the table cursor type to "row".
170
-
171
- Returns:
172
- None
173
155
  """
174
156
  self.build_table()
175
157
 
@@ -181,14 +163,14 @@ class RowDetailScreen(TableScreen):
181
163
 
182
164
  # Get all columns and values from the dataframe row
183
165
  for col, val, dtype in zip(self.df.columns, self.df.row(self.ridx), self.df.dtypes):
184
- self.table.add_row(
185
- *format_row(
186
- [col, val],
187
- [None, dtype],
188
- apply_justify=False,
189
- thousand_separator=self.thousand_separator,
190
- )
191
- )
166
+ if col == RID:
167
+ continue # Skip RID column
168
+ formatted_row = []
169
+ formatted_row.append(col)
170
+
171
+ dc = DtypeConfig(dtype)
172
+ formatted_row.append(dc.format(val, justify="", thousand_separator=self.thousand_separator))
173
+ self.table.add_row(*formatted_row)
192
174
 
193
175
  self.table.cursor_type = "row"
194
176
 
@@ -209,7 +191,21 @@ class RowDetailScreen(TableScreen):
209
191
  # Filter the main table by the selected value
210
192
  self.filter_or_view_selected_value(self.get_cidx_name_value(), action="filter")
211
193
  event.stop()
212
- elif event.key == "comma":
194
+ elif event.key == "right_curly_bracket": # '}'
195
+ # Move to the next row
196
+ ridx = self.ridx + 1
197
+ if ridx < len(self.df):
198
+ self.ridx = ridx
199
+ self.dftable.move_cursor_to(self.ridx)
200
+ self.build_table()
201
+ event.stop()
202
+ elif event.key == "left_curly_bracket": # '{'
203
+ # Move to the previous row
204
+ ridx = self.ridx - 1
205
+ if ridx >= 0:
206
+ self.ridx = ridx
207
+ self.dftable.move_cursor_to(self.ridx)
208
+ self.build_table()
213
209
  event.stop()
214
210
 
215
211
  def get_cidx_name_value(self) -> tuple[int, str, Any] | None:
@@ -254,12 +250,8 @@ class StatisticsScreen(TableScreen):
254
250
  col_name = self.df.columns[self.col_idx]
255
251
  lf = self.df.lazy()
256
252
 
257
- # Apply only to visible rows
258
- if False in self.dftable.visible_rows:
259
- lf = lf.filter(self.dftable.visible_rows)
260
-
261
253
  # Get column statistics
262
- stats_df = lf.select(pl.col(col_name)).collect().describe()
254
+ stats_df = lf.select(pl.col(col_name)).describe()
263
255
  if len(stats_df) == 0:
264
256
  return
265
257
 
@@ -275,35 +267,21 @@ class StatisticsScreen(TableScreen):
275
267
  # Add rows
276
268
  for row in stats_df.rows():
277
269
  stat_label, stat_value = row
278
- value = stat_value
279
- if stat_value is None:
280
- value = NULL_DISPLAY
281
- elif dc.gtype == "integer" and self.thousand_separator:
282
- value = f"{stat_value:,}"
283
- elif dc.gtype == "float":
284
- value = format_float(stat_value, self.thousand_separator)
285
- else:
286
- value = str(stat_value)
287
-
288
270
  self.table.add_row(
289
- Text(stat_label, justify="left"),
290
- Text(value, style=dc.style, justify=dc.justify),
271
+ stat_label,
272
+ dc.format(stat_value, thousand_separator=self.thousand_separator),
291
273
  )
292
274
 
293
275
  def build_dataframe_stats(self) -> None:
294
276
  """Build statistics for the entire dataframe."""
295
- lf = self.df.lazy()
296
-
297
- # Apply only to visible rows
298
- if False in self.dftable.visible_rows:
299
- lf = lf.filter(self.dftable.visible_rows)
277
+ lf = self.df.lazy().select(pl.exclude(RID))
300
278
 
301
279
  # Apply only to non-hidden columns
302
280
  if self.dftable.hidden_columns:
303
281
  lf = lf.select(pl.exclude(self.dftable.hidden_columns))
304
282
 
305
283
  # Get dataframe statistics
306
- stats_df = lf.collect().describe()
284
+ stats_df = lf.describe()
307
285
 
308
286
  # Add columns for each dataframe column with appropriate styling
309
287
  for idx, (col_name, col_dtype) in enumerate(zip(stats_df.columns, stats_df.dtypes), 0):
@@ -329,17 +307,7 @@ class StatisticsScreen(TableScreen):
329
307
  col_dtype = stats_df.dtypes[idx]
330
308
  dc = DtypeConfig(col_dtype)
331
309
 
332
- value = stat_value
333
- if stat_value is None:
334
- value = NULL_DISPLAY
335
- elif dc.gtype == "integer" and self.thousand_separator:
336
- value = f"{stat_value:,}"
337
- elif dc.gtype == "float":
338
- value = format_float(stat_value, self.thousand_separator)
339
- else:
340
- value = str(stat_value)
341
-
342
- formatted_row.append(Text(value, style=dc.style, justify=dc.justify))
310
+ formatted_row.append(dc.format(stat_value, thousand_separator=self.thousand_separator))
343
311
 
344
312
  self.table.add_row(*formatted_row)
345
313
 
@@ -352,13 +320,11 @@ class FrequencyScreen(TableScreen):
352
320
  def __init__(self, cidx: int, dftable: "DataFrameTable") -> None:
353
321
  super().__init__(dftable)
354
322
  self.cidx = cidx
355
- self.sorted_columns = {
356
- 1: True, # Count
357
- }
323
+ self.sorted_columns = {1: True} # Count sort by default
324
+ self.total_count = len(dftable.df)
358
325
 
359
- df = dftable.df.filter(dftable.visible_rows) if False in dftable.visible_rows else dftable.df
360
- self.total_count = len(df)
361
- self.df: pl.DataFrame = df[df.columns[self.cidx]].value_counts(sort=True).sort("count", descending=True)
326
+ col = dftable.df.columns[self.cidx]
327
+ self.df: pl.DataFrame = dftable.df.lazy().select(pl.col(col).value_counts(sort=True)).unnest(col).collect()
362
328
 
363
329
  def on_mount(self) -> None:
364
330
  """Create the frequency table."""
@@ -412,33 +378,18 @@ class FrequencyScreen(TableScreen):
412
378
  self.table.add_column(Text(header_text, justify=justify), key=key)
413
379
 
414
380
  # Get style config for Int64 and Float64
415
- ds_int = DtypeConfig(pl.Int64)
416
- ds_float = DtypeConfig(pl.Float64)
381
+ dc_int = DtypeConfig(pl.Int64)
382
+ dc_float = DtypeConfig(pl.Float64)
417
383
 
418
384
  # Add rows to the frequency table
419
385
  for row_idx, row in enumerate(self.df.rows()):
420
386
  column, count = row
421
387
  percentage = (count / self.total_count) * 100
422
388
 
423
- if column is None:
424
- value = NULL_DISPLAY
425
- elif dc.gtype == "integer" and self.thousand_separator:
426
- value = f"{column:,}"
427
- elif dc.gtype == "float":
428
- value = format_float(column, self.thousand_separator)
429
- else:
430
- value = str(column)
431
-
432
389
  self.table.add_row(
433
- Text(value, style=dc.style, justify=dc.justify),
434
- Text(
435
- f"{count:,}" if self.thousand_separator else str(count), style=ds_int.style, justify=ds_int.justify
436
- ),
437
- Text(
438
- format_float(percentage, self.thousand_separator),
439
- style=ds_float.style,
440
- justify=ds_float.justify,
441
- ),
390
+ dc.format(column),
391
+ dc_int.format(count, thousand_separator=self.thousand_separator),
392
+ dc_float.format(percentage, thousand_separator=self.thousand_separator),
442
393
  Bar(
443
394
  highlight_range=(0.0, percentage / 100 * 10),
444
395
  width=10,
@@ -455,7 +406,7 @@ class FrequencyScreen(TableScreen):
455
406
  justify="right",
456
407
  ),
457
408
  Text(
458
- format_float(100.0, self.thousand_separator),
409
+ format_float(100.0, self.thousand_separator, precision=-2 if len(self.df) > 1 else 2),
459
410
  style="bold",
460
411
  justify="right",
461
412
  ),
@@ -503,3 +454,74 @@ class FrequencyScreen(TableScreen):
503
454
  col_value = NULL if cell_value.plain == NULL_DISPLAY else DtypeConfig(col_dtype).convert(cell_value.plain)
504
455
 
505
456
  return self.cidx, col_name, col_value
457
+
458
+
459
+ class MetaShape(TableScreen):
460
+ """Modal screen to display metadata about the dataframe."""
461
+
462
+ CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "MetadataScreen")
463
+
464
+ def on_mount(self) -> None:
465
+ """Initialize the metadata screen.
466
+
467
+ Populates the table with metadata information about the dataframe,
468
+ including row and column counts.
469
+ """
470
+ self.build_table()
471
+
472
+ def build_table(self) -> None:
473
+ """Build the metadata table."""
474
+ self.table.clear(columns=True)
475
+ self.table.add_column("")
476
+ self.table.add_column(Text("Count", justify="right"))
477
+
478
+ # Get shape information
479
+ num_rows, num_cols = self.df.shape if self.dftable.df_view is None else self.dftable.df_view.shape
480
+ num_cols -= 1 # Exclude RID column
481
+ dc_int = DtypeConfig(pl.Int64)
482
+
483
+ # Add rows to the table
484
+ self.table.add_row("Row", dc_int.format(num_rows, thousand_separator=self.thousand_separator))
485
+ self.table.add_row("Column", dc_int.format(num_cols, thousand_separator=self.thousand_separator))
486
+
487
+ self.table.cursor_type = "none"
488
+
489
+
490
+ class MetaColumnScreen(TableScreen):
491
+ """Modal screen to display metadata about the columns in the dataframe."""
492
+
493
+ CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "MetaColumnScreen")
494
+
495
+ def on_mount(self) -> None:
496
+ """Initialize the column metadata screen.
497
+
498
+ Populates the table with information about each column in the dataframe,
499
+ including ID (1-based index), Name, and Type.
500
+ """
501
+ self.build_table()
502
+
503
+ def build_table(self) -> None:
504
+ """Build the column metadata table."""
505
+ self.table.clear(columns=True)
506
+ self.table.add_column("Column")
507
+ self.table.add_column("Name")
508
+ self.table.add_column("Type")
509
+
510
+ # Get schema information
511
+ schema = self.df.schema
512
+ dc_int = DtypeConfig(pl.Int64)
513
+ dc_str = DtypeConfig(pl.String)
514
+
515
+ # Add a row for each column
516
+ for idx, (col_name, col_type) in enumerate(schema.items(), 1):
517
+ if col_name == RID:
518
+ continue # Skip RID column
519
+
520
+ dc = DtypeConfig(col_type)
521
+ self.table.add_row(
522
+ dc_int.format(idx, thousand_separator=self.thousand_separator),
523
+ col_name,
524
+ dc_str.format("Datetime" if str(col_type).startswith("Datetime") else col_type, style=dc.style),
525
+ )
526
+
527
+ self.table.cursor_type = "none"
@@ -119,9 +119,6 @@ class YesNoScreen(ModalScreen):
119
119
  maybe: Optional Maybe button text/dict. Defaults to None.
120
120
  no: Text or dict for the No button. If None, hides the No button. Defaults to "No".
121
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
- Returns:
124
- None
125
122
  """
126
123
  super().__init__()
127
124
  self.title = title
@@ -295,32 +292,26 @@ class SaveFileScreen(YesNoScreen):
295
292
 
296
293
  CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "SaveFileScreen")
297
294
 
298
- def __init__(
299
- self, filename: str, title: str = "Save to File", all_tabs: bool | None = None, multi_tab: bool = False
300
- ):
301
- self.all_tabs = all_tabs or (all_tabs is None and multi_tab)
295
+ def __init__(self, filename: str, save_all: bool = False, tab_count: int = 1):
296
+ self.save_all = save_all
302
297
  super().__init__(
303
- title=title,
304
- label="Enter filename",
298
+ title="Save to File",
299
+ label="Filename",
305
300
  input=filename,
306
- yes="Save",
307
- maybe="Save All Tabs" if self.all_tabs else None,
301
+ yes=f"Save {tab_count} Tabs" if self.save_all else "Save Current Tab" if tab_count > 1 else "Save",
308
302
  no="Cancel",
309
303
  on_yes_callback=self.handle_save,
310
- on_maybe_callback=self.handle_save,
311
304
  )
312
305
 
313
306
  def handle_save(self):
314
307
  if self.input:
315
308
  input_filename = self.input.value.strip()
316
309
  if input_filename:
317
- return input_filename, self.all_tabs
310
+ return input_filename, self.save_all, True # Overwrite prompt
318
311
  else:
319
312
  self.notify("Filename cannot be empty", title="Save", severity="error")
320
313
  return None
321
314
 
322
- return None
323
-
324
315
 
325
316
  class ConfirmScreen(YesNoScreen):
326
317
  """Modal screen to ask for confirmation."""
@@ -359,7 +350,7 @@ class EditCellScreen(YesNoScreen):
359
350
 
360
351
  # Input
361
352
  df_value = df.item(ridx, cidx)
362
- self.input_value = "" if df_value is None else str(df_value).strip()
353
+ self.input_value = NULL if df_value is None else str(df_value)
363
354
 
364
355
  super().__init__(
365
356
  title="Edit Cell",
@@ -373,20 +364,20 @@ class EditCellScreen(YesNoScreen):
373
364
 
374
365
  def _validate_input(self) -> None:
375
366
  """Validate and save the edited value."""
376
- new_value_str = self.input.value.strip()
367
+ new_value_str = self.input.value # Do not strip to preserve spaces
377
368
 
378
369
  # Handle empty input
379
370
  if not new_value_str:
380
- new_value = None
371
+ new_value = ""
381
372
  self.notify(
382
- "Empty value provided. If you want to clear the cell, press [$accent]x[/].",
383
- title="Edit",
373
+ "Empty value provided. If you want to clear the cell, press [$accent]Delete[/].",
374
+ title="Edit Cell",
384
375
  severity="warning",
385
376
  )
386
377
  # Check if value changed
387
378
  elif new_value_str == self.input_value:
388
379
  new_value = None
389
- self.notify("No changes made", title="Edit", severity="warning")
380
+ self.notify("No changes made", title="Edit Cell", severity="warning")
390
381
  else:
391
382
  # Parse and validate based on column dtype
392
383
  try:
@@ -394,7 +385,7 @@ class EditCellScreen(YesNoScreen):
394
385
  except Exception as e:
395
386
  self.notify(
396
387
  f"Failed to convert [$accent]{new_value_str}[/] to [$error]{self.dtype}[/]: {str(e)}",
397
- title="Edit",
388
+ title="Edit Cell",
398
389
  severity="error",
399
390
  )
400
391
  return None
@@ -477,7 +468,7 @@ class SearchScreen(YesNoScreen):
477
468
 
478
469
  def _validate_input(self) -> tuple[str, int, bool, bool]:
479
470
  """Validate the input and return it."""
480
- term = self.input.value.strip()
471
+ term = self.input.value # Do not strip to preserve spaces
481
472
 
482
473
  if not term:
483
474
  self.notify("Term cannot be empty", title=self.title, severity="error")
@@ -508,7 +499,7 @@ class FilterScreen(YesNoScreen):
508
499
 
509
500
  def _get_input(self) -> tuple[str, int, bool, bool]:
510
501
  """Get input."""
511
- term = self.input.value.strip()
502
+ term = self.input.value # Do not strip to preserve spaces
512
503
  match_nocase = self.checkbox.value
513
504
  match_whole = self.checkbox2.value
514
505
 
@@ -583,14 +574,14 @@ class EditColumnScreen(YesNoScreen):
583
574
  self.df = df
584
575
  super().__init__(
585
576
  title="Edit Column",
586
- 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())",
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())",
587
578
  input="$_",
588
579
  on_yes_callback=self._get_input,
589
580
  )
590
581
 
591
582
  def _get_input(self) -> tuple[str, int]:
592
583
  """Get input."""
593
- term = self.input.value.strip()
584
+ term = self.input.value # Do not strip to preserve spaces
594
585
  return term, self.cidx
595
586
 
596
587
 
@@ -606,19 +597,19 @@ class AddColumnScreen(YesNoScreen):
606
597
  self.existing_columns = set(df.columns)
607
598
  super().__init__(
608
599
  title="Add Column",
609
- label="Enter column name",
610
- input="Link" if link else "Column name",
611
- label2="Enter link template, e.g., https://example.com/$_/id/$1, PC/compound/$cid"
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"
612
603
  if link
613
- 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'))",
614
- input2="Link template" if link else "Column value or expression",
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",
615
606
  on_yes_callback=self._get_input,
616
607
  )
617
608
 
618
609
  def _get_input(self) -> tuple[int, str, str] | None:
619
610
  """Validate and return the new column configuration."""
620
611
  col_name = self.input.value.strip()
621
- term = self.input2.value.strip()
612
+ term = self.input2.value # Do not strip to preserve spaces
622
613
 
623
614
  # Validate column name
624
615
  if not col_name:
@@ -680,7 +671,11 @@ class FindReplaceScreen(YesNoScreen):
680
671
  CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "ReplaceScreen")
681
672
 
682
673
  def __init__(self, dftable: "DataFrameTable", title: str = "Find and Replace"):
683
- term_find = str(dftable.cursor_value)
674
+ if (cursor_value := dftable.cursor_value) is None:
675
+ term_find = NULL
676
+ else:
677
+ term_find = str(cursor_value)
678
+
684
679
  super().__init__(
685
680
  title=title,
686
681
  label="Find",
@@ -698,8 +693,8 @@ class FindReplaceScreen(YesNoScreen):
698
693
 
699
694
  def _get_input(self) -> tuple[str, str, bool, bool, bool]:
700
695
  """Get input."""
701
- term_find = self.input.value.strip()
702
- term_replace = self.input2.value.strip()
696
+ term_find = self.input.value # Do not strip to preserve spaces
697
+ term_replace = self.input2.value # Do not strip to preserve spaces
703
698
  match_nocase = self.checkbox.value
704
699
  match_whole = self.checkbox2.value
705
700
  replace_all = False
@@ -708,8 +703,8 @@ class FindReplaceScreen(YesNoScreen):
708
703
 
709
704
  def _get_input_replace_all(self) -> tuple[str, str, bool, bool, bool]:
710
705
  """Get input for 'Replace All'."""
711
- term_find = self.input.value.strip()
712
- term_replace = self.input2.value.strip()
706
+ term_find = self.input.value # Do not strip to preserve spaces
707
+ term_replace = self.input2.value # Do not strip to preserve spaces
713
708
  match_nocase = self.checkbox.value
714
709
  match_whole = self.checkbox2.value
715
710
  replace_all = True