dataframe-textual 2.4.3__tar.gz → 2.6.0__tar.gz

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.
Files changed (18) hide show
  1. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/PKG-INFO +6 -6
  2. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/README.md +5 -5
  3. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/pyproject.toml +1 -1
  4. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/src/dataframe_textual/data_frame_table.py +98 -189
  5. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/src/dataframe_textual/data_frame_viewer.py +196 -61
  6. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/src/dataframe_textual/yes_no_screen.py +1 -1
  7. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/uv.lock +1 -1
  8. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/.gitignore +0 -0
  9. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/1811.csv.gz +0 -0
  10. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/LICENSE +0 -0
  11. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/large_malformed.tsv.gz +0 -0
  12. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/main.py +0 -0
  13. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/src/dataframe_textual/__init__.py +0 -0
  14. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/src/dataframe_textual/__main__.py +0 -0
  15. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/src/dataframe_textual/common.py +0 -0
  16. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/src/dataframe_textual/data_frame_help_panel.py +0 -0
  17. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/src/dataframe_textual/sql_screen.py +0 -0
  18. {dataframe_textual-2.4.3 → dataframe_textual-2.6.0}/src/dataframe_textual/table_screen.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataframe-textual
3
- Version: 2.4.3
3
+ Version: 2.6.0
4
4
  Summary: Interactive terminal viewer/editor for tabular data
5
5
  Project-URL: Homepage, https://github.com/need47/dataframe-textual
6
6
  Project-URL: Repository, https://github.com/need47/dataframe-textual.git
@@ -250,7 +250,7 @@ zcat compressed_data.csv.gz | dv -f csv
250
250
  | `Q` | Close all tabs and app (prompts to save unsaved changes) |
251
251
  | `Ctrl+Q` | Force to quit app (regardless of unsaved changes) |
252
252
  | `Ctrl+T` | Save current tab to file |
253
- | `Ctrl+A` | Save all tabs to a Excel file |
253
+ | `Ctrl+S` | Save all tabs to file |
254
254
  | `w` | Save current tab to file (overwrite without prompt) |
255
255
  | `W` | Save all tabs to file (overwrite without prompt) |
256
256
  | `Ctrl+D` | Duplicate current tab |
@@ -314,7 +314,8 @@ zcat compressed_data.csv.gz | dv -f csv
314
314
  | Key | Action |
315
315
  |-----|--------|
316
316
  | `Double-click` | Edit cell or rename column header |
317
- | `delete` | Clear current cell (set to NULL) |
317
+ | `Delete` | Clear current cell (set to NULL) |
318
+ | `Shift+Delete` | Clear current column (set matching cells to NULL) |
318
319
  | `e` | Edit current cell (respects data type) |
319
320
  | `E` | Edit entire column with value/expression |
320
321
  | `a` | Add empty column after current |
@@ -391,14 +392,13 @@ zcat compressed_data.csv.gz | dv -f csv
391
392
  | `!` | Cast current column to boolean |
392
393
  | `$` | Cast current column to string |
393
394
 
394
- #### Copy & Save
395
+ #### Copy
395
396
 
396
397
  | Key | Action |
397
398
  |-----|--------|
398
399
  | `c` | Copy current cell to clipboard |
399
400
  | `Ctrl+C` | Copy column to clipboard |
400
401
  | `Ctrl+R` | Copy row to clipboard (tab-separated) |
401
- | `Ctrl+S` | Save to file |
402
402
 
403
403
  ## Features in Detail
404
404
 
@@ -820,7 +820,7 @@ Manage multiple files and dataframes simultaneously with tabs.
820
820
  - **`Double-click`** - Rename the tab
821
821
  - **`Ctrl+D`** - Duplicate current tab (creates a copy with same data and state)
822
822
  - **`Ctrl+T`** - Save current tab to file
823
- - **`Ctrl+A`** - Save all tabs in a single Excel file
823
+ - **`Ctrl+S`** - Save all tabs to file
824
824
  - **`w`** - Save current tab to file (overwrite without prompt)
825
825
  - **`W`** - Save all tabs to file (overwrite without prompt)
826
826
  - **`q`** - Close current tab (closes tab, prompts to save if unsaved changes)
@@ -211,7 +211,7 @@ zcat compressed_data.csv.gz | dv -f csv
211
211
  | `Q` | Close all tabs and app (prompts to save unsaved changes) |
212
212
  | `Ctrl+Q` | Force to quit app (regardless of unsaved changes) |
213
213
  | `Ctrl+T` | Save current tab to file |
214
- | `Ctrl+A` | Save all tabs to a Excel file |
214
+ | `Ctrl+S` | Save all tabs to file |
215
215
  | `w` | Save current tab to file (overwrite without prompt) |
216
216
  | `W` | Save all tabs to file (overwrite without prompt) |
217
217
  | `Ctrl+D` | Duplicate current tab |
@@ -275,7 +275,8 @@ zcat compressed_data.csv.gz | dv -f csv
275
275
  | Key | Action |
276
276
  |-----|--------|
277
277
  | `Double-click` | Edit cell or rename column header |
278
- | `delete` | Clear current cell (set to NULL) |
278
+ | `Delete` | Clear current cell (set to NULL) |
279
+ | `Shift+Delete` | Clear current column (set matching cells to NULL) |
279
280
  | `e` | Edit current cell (respects data type) |
280
281
  | `E` | Edit entire column with value/expression |
281
282
  | `a` | Add empty column after current |
@@ -352,14 +353,13 @@ zcat compressed_data.csv.gz | dv -f csv
352
353
  | `!` | Cast current column to boolean |
353
354
  | `$` | Cast current column to string |
354
355
 
355
- #### Copy & Save
356
+ #### Copy
356
357
 
357
358
  | Key | Action |
358
359
  |-----|--------|
359
360
  | `c` | Copy current cell to clipboard |
360
361
  | `Ctrl+C` | Copy column to clipboard |
361
362
  | `Ctrl+R` | Copy row to clipboard (tab-separated) |
362
- | `Ctrl+S` | Save to file |
363
363
 
364
364
  ## Features in Detail
365
365
 
@@ -781,7 +781,7 @@ Manage multiple files and dataframes simultaneously with tabs.
781
781
  - **`Double-click`** - Rename the tab
782
782
  - **`Ctrl+D`** - Duplicate current tab (creates a copy with same data and state)
783
783
  - **`Ctrl+T`** - Save current tab to file
784
- - **`Ctrl+A`** - Save all tabs in a single Excel file
784
+ - **`Ctrl+S`** - Save all tabs to file
785
785
  - **`w`** - Save current tab to file (overwrite without prompt)
786
786
  - **`W`** - Save all tabs to file (overwrite without prompt)
787
787
  - **`q`** - Close current tab (closes tab, prompts to save if unsaved changes)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dataframe-textual"
7
- version = "2.4.3"
7
+ version = "2.6.0"
8
8
  description = "Interactive terminal viewer/editor for tabular data"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -16,7 +16,7 @@ from textual.coordinate import Coordinate
16
16
  from textual.events import Click
17
17
  from textual.reactive import reactive
18
18
  from textual.render import measure
19
- from textual.widgets import DataTable, TabPane
19
+ from textual.widgets import DataTable
20
20
  from textual.widgets._data_table import (
21
21
  CellDoesNotExist,
22
22
  CellKey,
@@ -35,7 +35,6 @@ from .common import (
35
35
  NULL_DISPLAY,
36
36
  RID,
37
37
  SUBSCRIPT_DIGITS,
38
- SUPPORTED_FORMATS,
39
38
  DtypeConfig,
40
39
  format_row,
41
40
  get_next_item,
@@ -56,7 +55,6 @@ from .yes_no_screen import (
56
55
  FindReplaceScreen,
57
56
  FreezeScreen,
58
57
  RenameColumnScreen,
59
- SaveFileScreen,
60
58
  SearchScreen,
61
59
  )
62
60
 
@@ -209,6 +207,7 @@ class DataFrameTable(DataTable):
209
207
  - **X** - ❌ Delete row and those below
210
208
  - **Ctrl+X** - ❌ Delete row and those above
211
209
  - **delete** - ❌ Clear current cell (set to NULL)
210
+ - **Shift+Delete** - ❌ Clear current column (set matching cells to NULL)
212
211
  - **-** - ❌ Delete current column
213
212
  - **d** - 📋 Duplicate current column
214
213
  - **D** - 📋 Duplicate current row
@@ -223,11 +222,10 @@ class DataFrameTable(DataTable):
223
222
  - **!** - ✅ Cast column to boolean
224
223
  - **$** - 📝 Cast column to string
225
224
 
226
- ## 💾 Copy & Save
225
+ ## 💾 Copy
227
226
  - **c** - 📋 Copy cell to clipboard
228
227
  - **Ctrl+c** - 📊 Copy column to clipboard
229
228
  - **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
230
- - **Ctrl+s** - 💾 Save current tab to file
231
229
  """).strip()
232
230
 
233
231
  # fmt: off
@@ -255,8 +253,6 @@ class DataFrameTable(DataTable):
255
253
  ("c", "copy_cell", "Copy cell to clipboard"),
256
254
  ("ctrl+c", "copy_column", "Copy column to clipboard"),
257
255
  ("ctrl+r", "copy_row", "Copy row to clipboard"),
258
- # Save
259
- ("ctrl+s", "save_to_file", "Save to file"),
260
256
  # Metadata, Detail, Frequency, and Statistics
261
257
  ("m", "metadata_shape", "Show metadata for row count and column count"),
262
258
  ("M", "metadata_column", "Show metadata for column"),
@@ -290,6 +286,7 @@ class DataFrameTable(DataTable):
290
286
  ("R", "replace_global", "Replace global"), # `Shift+R`
291
287
  # Delete
292
288
  ("delete", "clear_cell", "Clear cell"),
289
+ ("shift+delete", "clear_column", "Clear cells in current column that match cursor value"), # `Shift+Delete`
293
290
  ("minus", "delete_column", "Delete column"), # `-`
294
291
  ("x", "delete_row", "Delete row"),
295
292
  ("X", "delete_row_and_below", "Delete row and those below"),
@@ -744,10 +741,6 @@ class DataFrameTable(DataTable):
744
741
  """Sort by current column in descending order."""
745
742
  self.do_sort_by_column(descending=True)
746
743
 
747
- def action_save_to_file(self) -> None:
748
- """Save the current dataframe to a file."""
749
- self.do_save_to_file()
750
-
751
744
  def action_show_frequency(self) -> None:
752
745
  """Show frequency distribution for the current column."""
753
746
  self.do_show_frequency()
@@ -804,6 +797,10 @@ class DataFrameTable(DataTable):
804
797
  """Clear the current cell (set to None)."""
805
798
  self.do_clear_cell()
806
799
 
800
+ def action_clear_column(self) -> None:
801
+ """Clear cells in the current column that match the cursor value."""
802
+ self.do_clear_column()
803
+
807
804
  def action_select_row(self) -> None:
808
805
  """Select rows with cursor value in the current column."""
809
806
  self.do_select_row()
@@ -2052,25 +2049,10 @@ class DataFrameTable(DataTable):
2052
2049
  # Also update the view if applicable
2053
2050
  # Update the value of col_name in df_view using the value of col_name from df based on RID mapping between them
2054
2051
  if self.df_view is not None:
2055
- # Get updated column from df for rows that exist in df_view
2056
- col_updated = f"^_{col_name}_^"
2057
- col_exists = "^_exists_^"
2058
- lf_updated = self.df.lazy().select(
2059
- RID, pl.col(col_name).alias(col_updated), pl.lit(True).alias(col_exists)
2060
- )
2061
- # Join and use when/then/otherwise to handle all updates including NULLs
2062
- self.df_view = (
2063
- self.df_view.lazy()
2064
- .join(lf_updated, on=RID, how="left")
2065
- .with_columns(
2066
- pl.when(pl.col(col_exists))
2067
- .then(pl.col(col_updated))
2068
- .otherwise(pl.col(col_name))
2069
- .alias(col_name)
2070
- )
2071
- .drop(col_updated, col_exists)
2072
- .collect()
2073
- )
2052
+ # Get updated column from df
2053
+ lf_updated = self.df.lazy().select(RID, pl.col(col_name))
2054
+ # Update df_view by joining on RID
2055
+ self.df_view = self.df_view.lazy().update(lf_updated, on=RID, include_nulls=True).collect()
2074
2056
  except Exception as e:
2075
2057
  self.notify(
2076
2058
  f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
@@ -2156,18 +2138,26 @@ class DataFrameTable(DataTable):
2156
2138
 
2157
2139
  # Update the cell to None in the dataframe
2158
2140
  try:
2159
- self.df = self.df.with_columns(
2160
- pl.when(pl.arange(0, len(self.df)) == ridx)
2161
- .then(pl.lit(None))
2162
- .otherwise(pl.col(col_name))
2163
- .alias(col_name)
2141
+ self.df = (
2142
+ self.df.lazy()
2143
+ .with_columns(
2144
+ pl.when(pl.arange(0, len(self.df)) == ridx)
2145
+ .then(pl.lit(None))
2146
+ .otherwise(pl.col(col_name))
2147
+ .alias(col_name)
2148
+ )
2149
+ .collect()
2164
2150
  )
2165
2151
 
2166
2152
  # Also update the view if applicable
2167
2153
  if self.df_view is not None:
2168
2154
  ridx_view = self.df.item(ridx, self.df.columns.index(RID))
2169
- self.df_view = self.df_view.with_columns(
2170
- pl.when(pl.col(RID) == ridx_view).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
2155
+ self.df_view = (
2156
+ self.df_view.lazy()
2157
+ .with_columns(
2158
+ pl.when(pl.col(RID) == ridx_view).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
2159
+ )
2160
+ .collect()
2171
2161
  )
2172
2162
 
2173
2163
  # Update the display
@@ -2186,7 +2176,44 @@ class DataFrameTable(DataTable):
2186
2176
  timeout=10,
2187
2177
  )
2188
2178
  self.log(f"Error clearing cell ({ridx}, {col_name}): {str(e)}")
2189
- raise e
2179
+
2180
+ def do_clear_column(self) -> None:
2181
+ """Clear the current column by setting all its values to None."""
2182
+ col_idx = self.cursor_column
2183
+ col_name = self.cursor_col_name
2184
+ value = self.cursor_value
2185
+
2186
+ # Add to history
2187
+ self.add_history(f"Cleared column [$success]{col_name}[/]", dirty=True)
2188
+
2189
+ try:
2190
+ # Update the entire column to None in the dataframe
2191
+ self.df = (
2192
+ self.df.lazy()
2193
+ .with_columns(
2194
+ pl.when(pl.col(col_name) == value).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
2195
+ )
2196
+ .collect()
2197
+ )
2198
+
2199
+ # Also update the view if applicable
2200
+ if self.df_view is not None:
2201
+ self.df_view = self.df_view.with_columns(
2202
+ pl.when(pl.col(col_name) == value).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
2203
+ )
2204
+
2205
+ # Recreate table for display
2206
+ self.setup_table()
2207
+
2208
+ # Move cursor to the cleared column
2209
+ self.move_cursor(column=col_idx)
2210
+
2211
+ # self.notify(f"Cleared column [$success]{col_name}[/]", title="Clear Column")
2212
+ except Exception as e:
2213
+ self.notify(
2214
+ f"Error clearing column [$error]{col_name}[/]", title="Clear Column", severity="error", timeout=10
2215
+ )
2216
+ self.log(f"Error clearing column `{col_name}`: {str(e)}")
2190
2217
 
2191
2218
  def do_add_column(self, col_name: str = None) -> None:
2192
2219
  """Add acolumn after the current column."""
@@ -3394,18 +3421,23 @@ class DataFrameTable(DataTable):
3394
3421
  # Only applicable to string columns for substring matches
3395
3422
  if dtype == pl.String and not state.match_whole:
3396
3423
  term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
3424
+ new_value = (
3425
+ pl.lit(None)
3426
+ if state.term_replace == NULL
3427
+ else pl.col(col_name).str.replace_all(term_find, state.term_replace)
3428
+ )
3397
3429
  self.df = self.df.with_columns(
3398
- pl.when(mask)
3399
- .then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
3400
- .otherwise(pl.col(col_name))
3401
- .alias(col_name)
3430
+ pl.when(mask).then(new_value).otherwise(pl.col(col_name)).alias(col_name)
3402
3431
  )
3403
3432
  else:
3404
- # Try to convert replacement value to column dtype
3405
- try:
3406
- value = DtypeConfig(dtype).convert(state.term_replace)
3407
- except Exception:
3408
- value = state.term_replace
3433
+ if state.term_replace == NULL:
3434
+ value = None
3435
+ else:
3436
+ # Try to convert replacement value to column dtype
3437
+ try:
3438
+ value = DtypeConfig(dtype).convert(state.term_replace)
3439
+ except Exception:
3440
+ value = state.term_replace
3409
3441
 
3410
3442
  self.df = self.df.with_columns(
3411
3443
  pl.when(mask).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
@@ -3413,15 +3445,8 @@ class DataFrameTable(DataTable):
3413
3445
 
3414
3446
  # Also update the view if applicable
3415
3447
  if self.df_view is not None:
3416
- col_updated = f"^_{col_name}_^"
3417
- lf_updated = self.df.lazy().filter(mask).select(pl.col(col_name).alias(col_updated), pl.col(RID))
3418
- self.df_view = (
3419
- self.df_view.lazy()
3420
- .join(lf_updated, on=RID, how="left")
3421
- .with_columns(pl.coalesce(pl.col(col_updated), pl.col(col_name)).alias(col_name))
3422
- .drop(col_updated)
3423
- .collect()
3424
- )
3448
+ lf_updated = self.df.lazy().filter(mask).select(pl.col(RID), pl.col(col_name))
3449
+ self.df_view = self.df_view.lazy().update(lf_updated, on=RID, include_nulls=True).collect()
3425
3450
 
3426
3451
  state.replaced_occurrence += len(ridxs)
3427
3452
 
@@ -3500,9 +3525,14 @@ class DataFrameTable(DataTable):
3500
3525
  # Only applicable to string columns for substring matches
3501
3526
  if dtype == pl.String and not state.match_whole:
3502
3527
  term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
3528
+ new_value = (
3529
+ pl.lit(None)
3530
+ if state.term_replace == NULL
3531
+ else pl.col(col_name).str.replace_all(term_find, state.term_replace)
3532
+ )
3503
3533
  self.df = self.df.with_columns(
3504
3534
  pl.when(pl.arange(0, len(self.df)) == ridx)
3505
- .then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
3535
+ .then(new_value)
3506
3536
  .otherwise(pl.col(col_name))
3507
3537
  .alias(col_name)
3508
3538
  )
@@ -3516,11 +3546,14 @@ class DataFrameTable(DataTable):
3516
3546
  .alias(col_name)
3517
3547
  )
3518
3548
  else:
3519
- # try to convert replacement value to column dtype
3520
- try:
3521
- value = DtypeConfig(dtype).convert(state.term_replace)
3522
- except Exception:
3523
- value = state.term_replace
3549
+ if state.term_replace == NULL:
3550
+ value = None
3551
+ else:
3552
+ # try to convert replacement value to column dtype
3553
+ try:
3554
+ value = DtypeConfig(dtype).convert(state.term_replace)
3555
+ except Exception:
3556
+ value = state.term_replace
3524
3557
 
3525
3558
  self.df = self.df.with_columns(
3526
3559
  pl.when(pl.arange(0, len(self.df)) == ridx)
@@ -3548,6 +3581,8 @@ class DataFrameTable(DataTable):
3548
3581
  if not state.done:
3549
3582
  # Get the new value of the current cell after replacement
3550
3583
  new_cell_value = self.df.item(ridx, cidx)
3584
+ if new_cell_value is None:
3585
+ new_cell_value = NULL_DISPLAY
3551
3586
  row_key = str(ridx)
3552
3587
  col_key = col_name
3553
3588
  self.update_cell(
@@ -3755,7 +3790,7 @@ class DataFrameTable(DataTable):
3755
3790
 
3756
3791
  self.notify(f"{message}. Now showing [$success]{len(self.df)}[/] rows.", title="Filter Rows")
3757
3792
 
3758
- # Copy & Save
3793
+ # Copy
3759
3794
  def do_copy_to_clipboard(self, content: str, message: str) -> None:
3760
3795
  """Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
3761
3796
 
@@ -3779,132 +3814,6 @@ class DataFrameTable(DataTable):
3779
3814
  except FileNotFoundError:
3780
3815
  self.notify("Error copying to clipboard", title="Copy to Clipboard", severity="error", timeout=10)
3781
3816
 
3782
- def do_save_to_file(self, all_tabs: bool | None = None, task_after_save: str | None = None) -> None:
3783
- """Open screen to save file."""
3784
- self._task_after_save = task_after_save
3785
- tab_count = len(self.app.tabs)
3786
- save_all = all_tabs is not False
3787
-
3788
- filepath = Path(self.filename)
3789
- if save_all:
3790
- ext = filepath.suffix.lower()
3791
- if ext in (".xlsx", ".xls"):
3792
- filename = self.filename
3793
- else:
3794
- filename = "all-tabs.xlsx"
3795
- else:
3796
- filename = str(filepath.with_stem(self.tabname))
3797
-
3798
- self.app.push_screen(
3799
- SaveFileScreen(filename, save_all=save_all, tab_count=tab_count),
3800
- callback=self.save_to_file,
3801
- )
3802
-
3803
- def save_to_file(self, result) -> None:
3804
- """Handle result from SaveFileScreen."""
3805
- if result is None:
3806
- return
3807
- filename, save_all, overwrite_prompt = result
3808
- self._save_all = save_all
3809
-
3810
- # Check if file exists
3811
- if overwrite_prompt and Path(filename).exists():
3812
- self._pending_filename = filename
3813
- self.app.push_screen(
3814
- ConfirmScreen("File already exists. Overwrite?"),
3815
- callback=self.confirm_overwrite,
3816
- )
3817
- else:
3818
- self.save_file(filename)
3819
-
3820
- def confirm_overwrite(self, should_overwrite: bool) -> None:
3821
- """Handle result from ConfirmScreen."""
3822
- if should_overwrite:
3823
- self.save_file(self._pending_filename)
3824
- else:
3825
- # Go back to SaveFileScreen to allow user to enter a different name
3826
- self.app.push_screen(
3827
- SaveFileScreen(self._pending_filename, save_all=self._save_all),
3828
- callback=self.save_to_file,
3829
- )
3830
-
3831
- def save_file(self, filename: str) -> None:
3832
- """Actually save the dataframe to a file."""
3833
- filepath = Path(filename)
3834
- ext = filepath.suffix.lower()
3835
- if ext == ".gz":
3836
- ext = Path(filename).with_suffix("").suffix.lower()
3837
-
3838
- fmt = ext.removeprefix(".")
3839
- if fmt not in SUPPORTED_FORMATS:
3840
- self.notify(
3841
- f"Unsupported file format [$success]{fmt}[/]. Use [$accent]CSV[/] as fallback. Supported formats: {', '.join(SUPPORTED_FORMATS)}",
3842
- title="Save to File",
3843
- severity="warning",
3844
- )
3845
- fmt = "csv"
3846
-
3847
- df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
3848
- try:
3849
- if fmt == "csv":
3850
- df.write_csv(filename)
3851
- elif fmt in ("tsv", "tab"):
3852
- df.write_csv(filename, separator="\t")
3853
- elif fmt in ("xlsx", "xls"):
3854
- self.save_excel(filename)
3855
- elif fmt == "json":
3856
- df.write_json(filename)
3857
- elif fmt == "ndjson":
3858
- df.write_ndjson(filename)
3859
- elif fmt == "parquet":
3860
- df.write_parquet(filename)
3861
- else: # Fallback to CSV
3862
- df.write_csv(filename)
3863
-
3864
- # Update current filename
3865
- self.filename = filename
3866
-
3867
- # Reset dirty flag after save
3868
- if self._save_all:
3869
- tabs: dict[TabPane, DataFrameTable] = self.app.tabs
3870
- for table in tabs.values():
3871
- table.dirty = False
3872
- else:
3873
- self.dirty = False
3874
-
3875
- if hasattr(self, "_task_after_save"):
3876
- if self._task_after_save == "close_tab":
3877
- self.app.do_close_tab()
3878
- elif self._task_after_save == "quit_app":
3879
- self.app.exit()
3880
-
3881
- # From ConfirmScreen callback, so notify accordingly
3882
- if self._save_all:
3883
- self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
3884
- else:
3885
- self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
3886
-
3887
- except Exception as e:
3888
- self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error", timeout=10)
3889
- self.log(f"Error saving file `{filename}`: {str(e)}")
3890
-
3891
- def save_excel(self, filename: str) -> None:
3892
- """Save to an Excel file."""
3893
- import xlsxwriter
3894
-
3895
- if not self._save_all or len(self.app.tabs) == 1:
3896
- # Single tab - save directly
3897
- df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
3898
- df.write_excel(filename, worksheet=self.tabname)
3899
- else:
3900
- # Multiple tabs - use xlsxwriter to create multiple sheets
3901
- with xlsxwriter.Workbook(filename) as wb:
3902
- tabs: dict[TabPane, DataFrameTable] = self.app.tabs
3903
- for table in tabs.values():
3904
- worksheet = wb.add_worksheet(table.tabname)
3905
- df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
3906
- df.write_excel(workbook=wb, worksheet=worksheet)
3907
-
3908
3817
  # SQL Interface
3909
3818
  def do_simple_sql(self) -> None:
3910
3819
  """Open the SQL interface screen."""
@@ -12,10 +12,10 @@ from textual.theme import BUILTIN_THEMES
12
12
  from textual.widgets import TabbedContent, TabPane
13
13
  from textual.widgets.tabbed_content import ContentTab, ContentTabs
14
14
 
15
- from .common import Source, get_next_item, load_file
15
+ from .common import RID, SUPPORTED_FORMATS, Source, get_next_item, load_file
16
16
  from .data_frame_help_panel import DataFrameHelpPanel
17
17
  from .data_frame_table import DataFrameTable
18
- from .yes_no_screen import ConfirmScreen, OpenFileScreen, RenameTabScreen
18
+ from .yes_no_screen import ConfirmScreen, OpenFileScreen, RenameTabScreen, SaveFileScreen
19
19
 
20
20
 
21
21
  class DataFrameViewer(App):
@@ -34,7 +34,7 @@ class DataFrameViewer(App):
34
34
  - **Ctrl+Q** - 🚪 Force to quit app (discards unsaved changes)
35
35
  - **Ctrl+T** - 💾 Save current tab to file
36
36
  - **w** - 💾 Save current tab to file (overwrite without prompt)
37
- - **Ctrl+A** - 💾 Save all tabs to file
37
+ - **Ctrl+S** - 💾 Save all tabs to file
38
38
  - **W** - 💾 Save all tabs to file (overwrite without prompt)
39
39
  - **Ctrl+D** - 📋 Duplicate current tab
40
40
  - **Ctrl+O** - 📁 Open a file
@@ -64,7 +64,7 @@ class DataFrameViewer(App):
64
64
  ("f1", "toggle_help_panel", "Help"),
65
65
  ("ctrl+o", "open_file", "Open File"),
66
66
  ("ctrl+t", "save_current_tab", "Save Current Tab"),
67
- ("ctrl+a", "save_all_tabs", "Save All Tabs"),
67
+ ("ctrl+s", "save_all_tabs", "Save All Tabs"),
68
68
  ("w", "save_current_tab_overwrite", "Save Current Tab (overwrite)"),
69
69
  ("W", "save_all_tabs_overwrite", "Save All Tabs (overwrite)"),
70
70
  ("ctrl+d", "duplicate_tab", "Duplicate Tab"),
@@ -102,6 +102,22 @@ class DataFrameViewer(App):
102
102
  self.tabs: dict[TabPane, DataFrameTable] = {}
103
103
  self.help_panel = None
104
104
 
105
+ @property
106
+ def active_table(self) -> DataFrameTable | None:
107
+ """Get the currently active DataFrameTable widget.
108
+
109
+ Returns:
110
+ The active DataFrameTable widget, or None if not found.
111
+ """
112
+ try:
113
+ tabbed: TabbedContent = self.query_one(TabbedContent)
114
+ if active_pane := tabbed.active_pane:
115
+ return active_pane.query_one(DataFrameTable)
116
+ except (NoMatches, AttributeError):
117
+ self.notify("No active table found", title="Locate Table", severity="error", timeout=10)
118
+
119
+ return None
120
+
105
121
  def compose(self) -> ComposeResult:
106
122
  """Compose the application widget structure.
107
123
 
@@ -151,7 +167,7 @@ class DataFrameViewer(App):
151
167
  """
152
168
  if len(self.tabs) == 1:
153
169
  self.query_one(ContentTabs).display = False
154
- self.get_active_table().focus()
170
+ self.active_table.focus()
155
171
 
156
172
  def on_ready(self) -> None:
157
173
  """Called when the app is ready."""
@@ -201,13 +217,11 @@ class DataFrameViewer(App):
201
217
  event: The tab activated event containing the activated tab pane.
202
218
  """
203
219
  # Focus the table in the newly activated tab
204
- if table := self.get_active_table():
220
+ if table := self.active_table:
205
221
  table.focus()
206
- else:
207
- return
208
222
 
209
- if table.loaded_rows == 0:
210
- table.setup_table()
223
+ if table.loaded_rows == 0:
224
+ table.setup_table()
211
225
 
212
226
  def action_toggle_help_panel(self) -> None:
213
227
  """Toggle the help panel on or off.
@@ -245,41 +259,43 @@ class DataFrameViewer(App):
245
259
  self.do_close_all_tabs()
246
260
 
247
261
  def action_save_current_tab(self) -> None:
248
- """Save the currently active tab to file.
249
-
250
- Opens the save dialog for the active tab's DataFrameTable to save its data.
251
- """
252
- if table := self.get_active_table():
253
- table.do_save_to_file(all_tabs=False)
262
+ """Open a save dialog to save current tab to file."""
263
+ self.do_save_to_file(all_tabs=False)
254
264
 
255
265
  def action_save_all_tabs(self) -> None:
256
- """Save all open tabs to their respective files.
257
-
258
- Iterates through all DataFrameTable widgets and opens the save dialog for each.
259
- """
260
- if table := self.get_active_table():
261
- table.do_save_to_file(all_tabs=True)
266
+ """Open a save dialog to save all tabs to file."""
267
+ self.do_save_to_file(all_tabs=True)
262
268
 
263
269
  def action_save_current_tab_overwrite(self) -> None:
264
- """Save the currently active tab to file, overwriting if it exists."""
265
- if table := self.get_active_table():
270
+ """Save current tab to file, overwrite if exists."""
271
+ if table := self.active_table:
266
272
  if len(self.tabs) > 1:
267
- filepath = Path(table.filename)
268
- filename = filepath.with_stem(table.tabname)
273
+ filenames = {t.filename for t in self.tabs.values()}
274
+ if len(filenames) > 1:
275
+ # Different filenames across tabs
276
+ filepath = Path(table.filename)
277
+ filename = filepath.with_stem(table.tabname)
278
+ else:
279
+ filename = table.filename
269
280
  else:
270
281
  filename = table.filename
271
- table.save_to_file((filename, False, False))
282
+
283
+ self.save_to_file((filename, False, False))
272
284
 
273
285
  def action_save_all_tabs_overwrite(self) -> None:
274
- """Save all open tabs to their respective files, overwriting if they exist."""
275
- if table := self.get_active_table():
276
- filepath = Path(table.filename)
277
- if filepath.suffix.lower() in [".xlsx", ".xls"]:
278
- filename = table.filename
286
+ """Save all tabs to file, overwrite if exists."""
287
+ if table := self.active_table:
288
+ if len(self.tabs) > 1:
289
+ filenames = {t.filename for t in self.tabs.values()}
290
+ if len(filenames) > 1:
291
+ # Different filenames across tabs - use generic name
292
+ filename = "all-tabs.xlsx"
293
+ else:
294
+ filename = table.filename
279
295
  else:
280
- filename = "all-tabs.xlsx"
296
+ filename = table.filename
281
297
 
282
- table.save_to_file((filename, True, False))
298
+ self.save_to_file((filename, True, False))
283
299
 
284
300
  def action_duplicate_tab(self) -> None:
285
301
  """Duplicate the currently active tab.
@@ -295,7 +311,7 @@ class DataFrameViewer(App):
295
311
  Creates a copy of the current tab with the same data and filename.
296
312
  The new tab is named with '_copy' suffix and inserted after the current tab.
297
313
  """
298
- if not (table := self.get_active_table()):
314
+ if not (table := self.active_table):
299
315
  return
300
316
 
301
317
  # Get current tab info
@@ -367,24 +383,6 @@ class DataFrameViewer(App):
367
383
  # status = "shown" if tabs.display else "hidden"
368
384
  # self.notify(f"Tab bar [$success]{status}[/]", title="Toggle Tab Bar")
369
385
 
370
- def get_active_table(self) -> DataFrameTable | None:
371
- """Get the currently active DataFrameTable widget.
372
-
373
- Retrieves the table from the currently active tab. Returns None if no
374
- table is found or an error occurs.
375
-
376
- Returns:
377
- The active DataFrameTable widget, or None if not found.
378
- """
379
- try:
380
- tabbed: TabbedContent = self.query_one(TabbedContent)
381
- if active_pane := tabbed.active_pane:
382
- return active_pane.query_one(DataFrameTable)
383
- except (NoMatches, AttributeError):
384
- self.notify("No active table found", title="Locate Table", severity="error", timeout=10)
385
-
386
- return None
387
-
388
386
  def get_unique_tabname(self, tab_name: str) -> str:
389
387
  """Generate a unique tab name based on the given base name.
390
388
 
@@ -495,17 +493,14 @@ class DataFrameViewer(App):
495
493
  can be closed, the application exits instead.
496
494
  """
497
495
  try:
498
- if not (active_pane := self.tabbed.active_pane):
499
- return
500
-
501
- if not (active_table := self.tabs.get(active_pane)):
496
+ if not (table := self.active_table):
502
497
  return
503
498
 
504
499
  def _on_save_confirm(result: bool) -> None:
505
500
  """Handle the "save before closing?" confirmation."""
506
501
  if result:
507
502
  # User wants to save - close after save dialog opens
508
- active_table.do_save_to_file(task_after_save="close_tab")
503
+ self.do_save_to_file(all_tabs=False, task_after_save="close_tab")
509
504
  elif result is None:
510
505
  # User cancelled - do nothing
511
506
  return
@@ -513,7 +508,7 @@ class DataFrameViewer(App):
513
508
  # User wants to discard - close immediately
514
509
  self.close_tab()
515
510
 
516
- if active_table.dirty:
511
+ if table.dirty:
517
512
  self.push_screen(
518
513
  ConfirmScreen(
519
514
  "Close Tab",
@@ -560,7 +555,7 @@ class DataFrameViewer(App):
560
555
 
561
556
  def _save_and_quit(result: bool) -> None:
562
557
  if result:
563
- self.get_active_table()._save_to_file(task_after_save="quit_app")
558
+ self.do_save_to_file(all_tabs=True, task_after_save="quit_app")
564
559
  elif result is None:
565
560
  # User cancelled - do nothing
566
561
  return
@@ -568,15 +563,17 @@ class DataFrameViewer(App):
568
563
  # User wants to discard - quit immediately
569
564
  self.exit()
570
565
 
566
+ tab_count = len(self.tabs)
571
567
  tab_list = "\n".join(f" - [$warning]{name}[/]" for name in dirty_tabnames)
572
568
  label = (
573
569
  f"The following tabs have unsaved changes:\n\n{tab_list}\n\nSave all changes?"
574
570
  if len(dirty_tabnames) > 1
575
571
  else f"The tab [$warning]{dirty_tabnames[0]}[/] has unsaved changes.\n\nSave changes?"
576
572
  )
573
+
577
574
  self.push_screen(
578
575
  ConfirmScreen(
579
- "Close All Tabs" if len(self.tabs) > 1 else "Close Tab",
576
+ f"Close {tab_count} Tabs" if tab_count > 1 else "Close Tab",
580
577
  label=label,
581
578
  yes="Save",
582
579
  maybe="Discard",
@@ -631,3 +628,141 @@ class DataFrameViewer(App):
631
628
  break
632
629
 
633
630
  # self.notify(f"Renamed tab [$accent]{old_name}[/] to [$success]{new_name}[/]", title="Rename Tab")
631
+
632
+ def do_save_to_file(self, all_tabs: bool = True, task_after_save: str | None = None) -> None:
633
+ """Open screen to save file."""
634
+ if not (table := self.active_table):
635
+ return
636
+
637
+ self._task_after_save = task_after_save
638
+ tab_count = len(self.tabs)
639
+ save_all = all_tabs is True and tab_count > 1
640
+
641
+ if save_all:
642
+ filenames = {t.filename for t in self.tabs.values()}
643
+ if len(filenames) > 1:
644
+ # Different filenames across tabs - use generic name
645
+ filename = "all-tabs.xlsx"
646
+ else:
647
+ filename = table.filename
648
+ elif tab_count == 1:
649
+ filename = table.filename
650
+ else:
651
+ filepath = Path(table.filename)
652
+ filename = str(filepath.with_stem(table.tabname))
653
+
654
+ self.push_screen(
655
+ SaveFileScreen(filename, save_all=save_all, tab_count=tab_count),
656
+ callback=self.save_to_file,
657
+ )
658
+
659
+ def save_to_file(self, result) -> None:
660
+ """Handle result from SaveFileScreen."""
661
+ if result is None:
662
+ return
663
+ filename, save_all, overwrite_prompt = result
664
+ self._save_all = save_all
665
+
666
+ # Check if file exists
667
+ if overwrite_prompt and Path(filename).exists():
668
+ self._pending_filename = filename
669
+ self.push_screen(
670
+ ConfirmScreen("File already exists. Overwrite?"),
671
+ callback=self.confirm_overwrite,
672
+ )
673
+ else:
674
+ self.save_file(filename)
675
+
676
+ def confirm_overwrite(self, should_overwrite: bool) -> None:
677
+ """Handle result from ConfirmScreen."""
678
+ if should_overwrite:
679
+ self.save_file(self._pending_filename)
680
+ else:
681
+ # Go back to SaveFileScreen to allow user to enter a different name
682
+ self.push_screen(
683
+ SaveFileScreen(self._pending_filename, save_all=self._save_all),
684
+ callback=self.save_to_file,
685
+ )
686
+
687
+ def save_file(self, filename: str) -> None:
688
+ """Actually save to a file."""
689
+ if not (table := self.active_table):
690
+ return
691
+
692
+ filepath = Path(filename)
693
+ ext = filepath.suffix.lower()
694
+ if ext == ".gz":
695
+ ext = Path(filename).with_suffix("").suffix.lower()
696
+
697
+ fmt = ext.removeprefix(".")
698
+ if fmt not in SUPPORTED_FORMATS:
699
+ self.notify(
700
+ f"Unsupported file format [$success]{fmt}[/]. Use [$accent]CSV[/] as fallback. Supported formats: {', '.join(SUPPORTED_FORMATS)}",
701
+ title="Save to File",
702
+ severity="warning",
703
+ )
704
+ fmt = "csv"
705
+
706
+ df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
707
+ try:
708
+ if fmt == "csv":
709
+ df.write_csv(filename)
710
+ elif fmt in ("tsv", "tab"):
711
+ df.write_csv(filename, separator="\t")
712
+ elif fmt == "psv":
713
+ df.write_csv(filename, separator="|")
714
+ elif fmt in ("xlsx", "xls"):
715
+ self.save_excel(filename)
716
+ elif fmt == "json":
717
+ df.write_json(filename)
718
+ elif fmt == "ndjson":
719
+ df.write_ndjson(filename)
720
+ elif fmt == "parquet":
721
+ df.write_parquet(filename)
722
+ else: # Fallback to CSV
723
+ df.write_csv(filename)
724
+
725
+ # Reset dirty flag and update filename after save
726
+ if self._save_all:
727
+ for table in self.tabs.values():
728
+ table.dirty = False
729
+ table.filename = filename
730
+ else:
731
+ table.dirty = False
732
+ table.filename = filename
733
+
734
+ # From ConfirmScreen callback, so notify accordingly
735
+ if self._save_all:
736
+ self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
737
+ else:
738
+ self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
739
+
740
+ if hasattr(self, "_task_after_save"):
741
+ if self._task_after_save == "close_tab":
742
+ self.do_close_tab()
743
+ elif self._task_after_save == "quit_app":
744
+ self.exit()
745
+
746
+ except Exception as e:
747
+ self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error", timeout=10)
748
+ self.log(f"Error saving file `{filename}`: {str(e)}")
749
+
750
+ def save_excel(self, filename: str) -> None:
751
+ """Save to an Excel file."""
752
+ import xlsxwriter
753
+
754
+ if not self._save_all or len(self.tabs) == 1:
755
+ # Single tab - save directly
756
+ if not (table := self.active_table):
757
+ return
758
+
759
+ df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
760
+ df.write_excel(filename, worksheet=table.tabname)
761
+ else:
762
+ # Multiple tabs - use xlsxwriter to create multiple sheets
763
+ with xlsxwriter.Workbook(filename) as wb:
764
+ tabs: dict[TabPane, DataFrameTable] = self.tabs
765
+ for table in tabs.values():
766
+ worksheet = wb.add_worksheet(table.tabname)
767
+ df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
768
+ df.write_excel(workbook=wb, worksheet=worksheet)
@@ -298,7 +298,7 @@ class SaveFileScreen(YesNoScreen):
298
298
  title="Save to File",
299
299
  label="Filename",
300
300
  input=filename,
301
- yes=f"Save {tab_count} Tab(s)" if self.save_all else "Save Current Tab" if tab_count > 1 else "Save",
301
+ yes=f"Save {tab_count} Tabs" if self.save_all else "Save Current Tab" if tab_count > 1 else "Save",
302
302
  no="Cancel",
303
303
  on_yes_callback=self.handle_save,
304
304
  )
@@ -171,7 +171,7 @@ wheels = [
171
171
 
172
172
  [[package]]
173
173
  name = "dataframe-textual"
174
- version = "2.4.3"
174
+ version = "2.6.0"
175
175
  source = { editable = "." }
176
176
  dependencies = [
177
177
  { name = "polars" },