dataframe-textual 1.12.0__py3-none-any.whl → 1.16.2__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"), # '<'
@@ -245,6 +250,11 @@ class DataFrameViewer(App):
245
250
  if table := self.get_active_table():
246
251
  table.do_save_to_file(title="Save Current Tab", all_tabs=False)
247
252
 
253
+ def action_save_current_tab_overwrite(self) -> None:
254
+ """Save the currently active tab to file, overwriting if it exists."""
255
+ if table := self.get_active_table():
256
+ table.save_to_file((table.filename, False, False))
257
+
248
258
  def action_save_all_tabs(self) -> None:
249
259
  """Save all open tabs to their respective files.
250
260
 
@@ -253,6 +263,11 @@ class DataFrameViewer(App):
253
263
  if table := self.get_active_table():
254
264
  table.do_save_to_file(title="Save All Tabs", all_tabs=True)
255
265
 
266
+ def action_save_all_tabs_overwrite(self) -> None:
267
+ """Save all open tabs to their respective files, overwriting if they exist."""
268
+ if table := self.get_active_table():
269
+ table.save_to_file((table.filename, True, False))
270
+
256
271
  def action_duplicate_tab(self) -> None:
257
272
  """Duplicate the currently active tab.
258
273
 
@@ -269,7 +284,7 @@ class DataFrameViewer(App):
269
284
 
270
285
  # Create new table with the same dataframe and filename
271
286
  new_table = DataFrameTable(
272
- table.df,
287
+ table.df.clone(),
273
288
  table.filename,
274
289
  tabname=new_tabname,
275
290
  zebra_stripes=True,
@@ -151,9 +151,6 @@ class SimpleSqlScreen(SqlScreen):
151
151
 
152
152
  Args:
153
153
  dftable: Reference to the parent DataFrameTable widget.
154
-
155
- Returns:
156
- None
157
154
  """
158
155
  super().__init__(
159
156
  dftable,
@@ -165,12 +162,12 @@ class SimpleSqlScreen(SqlScreen):
165
162
  """Compose the simple SQL screen widget structure."""
166
163
  with Container(id="sql-container") as container:
167
164
  container.border_title = "SQL Query"
168
- yield Label("Select columns (default to all):", id="select-label")
165
+ yield Label("SELECT columns (all if none selected):", id="select-label")
169
166
  yield SelectionList(
170
167
  *[Selection(col, col) for col in self.df.columns if col not in self.dftable.hidden_columns],
171
168
  id="column-selection",
172
169
  )
173
- yield Label("Where condition (optional)", id="where-label")
170
+ yield Label("WHERE condition (optional)", id="where-label")
174
171
  yield Input(placeholder="e.g., age > 30 and height < 180", id="where-input")
175
172
  yield from super().compose()
176
173
 
@@ -212,9 +209,6 @@ class AdvancedSqlScreen(SqlScreen):
212
209
 
213
210
  Args:
214
211
  dftable: Reference to the parent DataFrameTable widget.
215
-
216
- Returns:
217
- None
218
212
  """
219
213
  super().__init__(
220
214
  dftable,
@@ -227,7 +221,7 @@ class AdvancedSqlScreen(SqlScreen):
227
221
  with Container(id="sql-container") as container:
228
222
  container.border_title = "Advanced SQL Query"
229
223
  yield TextArea.code_editor(
230
- placeholder="Enter SQL query (use `self` as the table name), e.g., \n\nSELECT * \nFROM self \nWHERE age > 30",
224
+ 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
225
  id="sql-textarea",
232
226
  language="sql",
233
227
  )
@@ -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, RIDX, DtypeConfig, format_float
17
17
 
18
18
 
19
19
  class TableScreen(ModalScreen):
@@ -181,14 +181,12 @@ class RowDetailScreen(TableScreen):
181
181
 
182
182
  # Get all columns and values from the dataframe row
183
183
  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
- )
184
+ formatted_row = []
185
+ formatted_row.append(col)
186
+
187
+ dc = DtypeConfig(dtype)
188
+ formatted_row.append(dc.format(val, justify="", thousand_separator=self.thousand_separator))
189
+ self.table.add_row(*formatted_row)
192
190
 
193
191
  self.table.cursor_type = "row"
194
192
 
@@ -209,7 +207,25 @@ class RowDetailScreen(TableScreen):
209
207
  # Filter the main table by the selected value
210
208
  self.filter_or_view_selected_value(self.get_cidx_name_value(), action="filter")
211
209
  event.stop()
212
- elif event.key == "comma":
210
+ elif event.key == "right_curly_bracket": # '}'
211
+ # Move to the next visible row
212
+ ridx = self.ridx + 1
213
+ while ridx < len(self.df) and not self.dftable.visible_rows[ridx]:
214
+ ridx += 1
215
+ if ridx < len(self.df):
216
+ self.ridx = ridx
217
+ self.dftable.move_cursor_to(self.ridx)
218
+ self.build_table()
219
+ event.stop()
220
+ elif event.key == "left_curly_bracket": # '{'
221
+ # Move to the previous visible row
222
+ ridx = self.ridx - 1
223
+ while ridx >= 0 and not self.dftable.visible_rows[ridx]:
224
+ ridx -= 1
225
+ if ridx >= 0:
226
+ self.ridx = ridx
227
+ self.dftable.move_cursor_to(self.ridx)
228
+ self.build_table()
213
229
  event.stop()
214
230
 
215
231
  def get_cidx_name_value(self) -> tuple[int, str, Any] | None:
@@ -275,19 +291,9 @@ class StatisticsScreen(TableScreen):
275
291
  # Add rows
276
292
  for row in stats_df.rows():
277
293
  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
294
  self.table.add_row(
289
- Text(stat_label, justify="left"),
290
- Text(value, style=dc.style, justify=dc.justify),
295
+ stat_label,
296
+ dc.format(stat_value, thousand_separator=self.thousand_separator),
291
297
  )
292
298
 
293
299
  def build_dataframe_stats(self) -> None:
@@ -329,17 +335,7 @@ class StatisticsScreen(TableScreen):
329
335
  col_dtype = stats_df.dtypes[idx]
330
336
  dc = DtypeConfig(col_dtype)
331
337
 
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))
338
+ formatted_row.append(dc.format(stat_value, thousand_separator=self.thousand_separator))
343
339
 
344
340
  self.table.add_row(*formatted_row)
345
341
 
@@ -412,33 +408,18 @@ class FrequencyScreen(TableScreen):
412
408
  self.table.add_column(Text(header_text, justify=justify), key=key)
413
409
 
414
410
  # Get style config for Int64 and Float64
415
- ds_int = DtypeConfig(pl.Int64)
416
- ds_float = DtypeConfig(pl.Float64)
411
+ dc_int = DtypeConfig(pl.Int64)
412
+ dc_float = DtypeConfig(pl.Float64)
417
413
 
418
414
  # Add rows to the frequency table
419
415
  for row_idx, row in enumerate(self.df.rows()):
420
416
  column, count = row
421
417
  percentage = (count / self.total_count) * 100
422
418
 
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
419
  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
- ),
420
+ dc.format(column),
421
+ dc_int.format(count, thousand_separator=self.thousand_separator),
422
+ dc_float.format(percentage, thousand_separator=self.thousand_separator),
442
423
  Bar(
443
424
  highlight_range=(0.0, percentage / 100 * 10),
444
425
  width=10,
@@ -455,7 +436,7 @@ class FrequencyScreen(TableScreen):
455
436
  justify="right",
456
437
  ),
457
438
  Text(
458
- format_float(100.0, self.thousand_separator),
439
+ format_float(100.0, self.thousand_separator, precision=-2 if len(self.df) > 1 else 2),
459
440
  style="bold",
460
441
  justify="right",
461
442
  ),
@@ -503,3 +484,70 @@ class FrequencyScreen(TableScreen):
503
484
  col_value = NULL if cell_value.plain == NULL_DISPLAY else DtypeConfig(col_dtype).convert(cell_value.plain)
504
485
 
505
486
  return self.cidx, col_name, col_value
487
+
488
+
489
+ class MetaShape(TableScreen):
490
+ """Modal screen to display metadata about the dataframe."""
491
+
492
+ CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "MetadataScreen")
493
+
494
+ def on_mount(self) -> None:
495
+ """Initialize the metadata screen.
496
+
497
+ Populates the table with metadata information about the dataframe,
498
+ including row and column counts.
499
+ """
500
+ self.build_table()
501
+
502
+ def build_table(self) -> None:
503
+ """Build the metadata table."""
504
+ self.table.clear(columns=True)
505
+ self.table.add_column("")
506
+ self.table.add_column(Text("Count", justify="right"))
507
+
508
+ # Get shape information
509
+ num_rows, num_cols = self.df.shape
510
+ dc_int = DtypeConfig(pl.Int64)
511
+
512
+ # Add rows to the table
513
+ self.table.add_row("Row", dc_int.format(num_rows, thousand_separator=self.thousand_separator))
514
+ self.table.add_row("Column", dc_int.format(num_cols, thousand_separator=self.thousand_separator))
515
+
516
+ self.table.cursor_type = "none"
517
+
518
+
519
+ class MetaColumnScreen(TableScreen):
520
+ """Modal screen to display metadata about the columns in the dataframe."""
521
+
522
+ CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "MetaColumnScreen")
523
+
524
+ def on_mount(self) -> None:
525
+ """Initialize the column metadata screen.
526
+
527
+ Populates the table with information about each column in the dataframe,
528
+ including ID (1-based index), Name, and Type.
529
+ """
530
+ self.build_table()
531
+
532
+ def build_table(self) -> None:
533
+ """Build the column metadata table."""
534
+ self.table.clear(columns=True)
535
+ self.table.add_column("Column")
536
+ self.table.add_column("Name")
537
+ self.table.add_column("Type")
538
+
539
+ # Get schema information
540
+ schema = self.df.schema
541
+ dc_int = DtypeConfig(pl.Int64)
542
+ dc_str = DtypeConfig(pl.String)
543
+
544
+ # Add a row for each column
545
+ for idx, (col_name, col_type) in enumerate(schema.items(), 1):
546
+ dc = DtypeConfig(col_type)
547
+ self.table.add_row(
548
+ dc_int.format(idx, thousand_separator=self.thousand_separator),
549
+ col_name,
550
+ dc_str.format(col_type, style=dc.style),
551
+ )
552
+
553
+ self.table.cursor_type = "none"
@@ -314,7 +314,7 @@ class SaveFileScreen(YesNoScreen):
314
314
  if self.input:
315
315
  input_filename = self.input.value.strip()
316
316
  if input_filename:
317
- return input_filename, self.all_tabs
317
+ return input_filename, self.all_tabs, True # Overwrite prompt
318
318
  else:
319
319
  self.notify("Filename cannot be empty", title="Save", severity="error")
320
320
  return None
@@ -359,7 +359,7 @@ class EditCellScreen(YesNoScreen):
359
359
 
360
360
  # Input
361
361
  df_value = df.item(ridx, cidx)
362
- self.input_value = "" if df_value is None else str(df_value).strip()
362
+ self.input_value = NULL if df_value is None else str(df_value)
363
363
 
364
364
  super().__init__(
365
365
  title="Edit Cell",
@@ -373,20 +373,20 @@ class EditCellScreen(YesNoScreen):
373
373
 
374
374
  def _validate_input(self) -> None:
375
375
  """Validate and save the edited value."""
376
- new_value_str = self.input.value.strip()
376
+ new_value_str = self.input.value # Do not strip to preserve spaces
377
377
 
378
378
  # Handle empty input
379
379
  if not new_value_str:
380
- new_value = None
380
+ new_value = ""
381
381
  self.notify(
382
- "Empty value provided. If you want to clear the cell, press [$accent]x[/].",
383
- title="Edit",
382
+ "Empty value provided. If you want to clear the cell, press [$accent]Delete[/].",
383
+ title="Edit Cell",
384
384
  severity="warning",
385
385
  )
386
386
  # Check if value changed
387
387
  elif new_value_str == self.input_value:
388
388
  new_value = None
389
- self.notify("No changes made", title="Edit", severity="warning")
389
+ self.notify("No changes made", title="Edit Cell", severity="warning")
390
390
  else:
391
391
  # Parse and validate based on column dtype
392
392
  try:
@@ -394,7 +394,7 @@ class EditCellScreen(YesNoScreen):
394
394
  except Exception as e:
395
395
  self.notify(
396
396
  f"Failed to convert [$accent]{new_value_str}[/] to [$error]{self.dtype}[/]: {str(e)}",
397
- title="Edit",
397
+ title="Edit Cell",
398
398
  severity="error",
399
399
  )
400
400
  return None
@@ -477,7 +477,7 @@ class SearchScreen(YesNoScreen):
477
477
 
478
478
  def _validate_input(self) -> tuple[str, int, bool, bool]:
479
479
  """Validate the input and return it."""
480
- term = self.input.value.strip()
480
+ term = self.input.value # Do not strip to preserve spaces
481
481
 
482
482
  if not term:
483
483
  self.notify("Term cannot be empty", title=self.title, severity="error")
@@ -508,7 +508,7 @@ class FilterScreen(YesNoScreen):
508
508
 
509
509
  def _get_input(self) -> tuple[str, int, bool, bool]:
510
510
  """Get input."""
511
- term = self.input.value.strip()
511
+ term = self.input.value # Do not strip to preserve spaces
512
512
  match_nocase = self.checkbox.value
513
513
  match_whole = self.checkbox2.value
514
514
 
@@ -590,7 +590,7 @@ class EditColumnScreen(YesNoScreen):
590
590
 
591
591
  def _get_input(self) -> tuple[str, int]:
592
592
  """Get input."""
593
- term = self.input.value.strip()
593
+ term = self.input.value # Do not strip to preserve spaces
594
594
  return term, self.cidx
595
595
 
596
596
 
@@ -606,19 +606,19 @@ class AddColumnScreen(YesNoScreen):
606
606
  self.existing_columns = set(df.columns)
607
607
  super().__init__(
608
608
  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"
609
+ label="Column name",
610
+ input="Link" if link else "Name",
611
+ label2="Link template, e.g., https://example.com/$_/id/$1, PC/compound/$cid"
612
612
  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",
613
+ else "Value or Polars expression, e.g., abc, pl.lit(123), NULL, $_ * 2, $1 + $total, $_ + '_suffix', $_.str.to_uppercase()",
614
+ input2="Link template" if link else "Value or expression",
615
615
  on_yes_callback=self._get_input,
616
616
  )
617
617
 
618
618
  def _get_input(self) -> tuple[int, str, str] | None:
619
619
  """Validate and return the new column configuration."""
620
620
  col_name = self.input.value.strip()
621
- term = self.input2.value.strip()
621
+ term = self.input2.value # Do not strip to preserve spaces
622
622
 
623
623
  # Validate column name
624
624
  if not col_name:
@@ -680,7 +680,11 @@ class FindReplaceScreen(YesNoScreen):
680
680
  CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "ReplaceScreen")
681
681
 
682
682
  def __init__(self, dftable: "DataFrameTable", title: str = "Find and Replace"):
683
- term_find = str(dftable.cursor_value)
683
+ if (cursor_value := dftable.cursor_value) is None:
684
+ term_find = NULL
685
+ else:
686
+ term_find = str(cursor_value)
687
+
684
688
  super().__init__(
685
689
  title=title,
686
690
  label="Find",
@@ -698,8 +702,8 @@ class FindReplaceScreen(YesNoScreen):
698
702
 
699
703
  def _get_input(self) -> tuple[str, str, bool, bool, bool]:
700
704
  """Get input."""
701
- term_find = self.input.value.strip()
702
- term_replace = self.input2.value.strip()
705
+ term_find = self.input.value # Do not strip to preserve spaces
706
+ term_replace = self.input2.value # Do not strip to preserve spaces
703
707
  match_nocase = self.checkbox.value
704
708
  match_whole = self.checkbox2.value
705
709
  replace_all = False
@@ -708,8 +712,8 @@ class FindReplaceScreen(YesNoScreen):
708
712
 
709
713
  def _get_input_replace_all(self) -> tuple[str, str, bool, bool, bool]:
710
714
  """Get input for 'Replace All'."""
711
- term_find = self.input.value.strip()
712
- term_replace = self.input2.value.strip()
715
+ term_find = self.input.value # Do not strip to preserve spaces
716
+ term_replace = self.input2.value # Do not strip to preserve spaces
713
717
  match_nocase = self.checkbox.value
714
718
  match_whole = self.checkbox2.value
715
719
  replace_all = True