dataframe-textual 2.4.3__tar.gz → 2.5.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.5.0}/PKG-INFO +4 -5
  2. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/README.md +3 -4
  3. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/pyproject.toml +1 -1
  4. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/src/dataframe_textual/data_frame_table.py +3 -138
  5. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/src/dataframe_textual/data_frame_viewer.py +196 -61
  6. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/src/dataframe_textual/yes_no_screen.py +1 -1
  7. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/uv.lock +1 -1
  8. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/.gitignore +0 -0
  9. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/1811.csv.gz +0 -0
  10. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/LICENSE +0 -0
  11. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/large_malformed.tsv.gz +0 -0
  12. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/main.py +0 -0
  13. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/src/dataframe_textual/__init__.py +0 -0
  14. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/src/dataframe_textual/__main__.py +0 -0
  15. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/src/dataframe_textual/common.py +0 -0
  16. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/src/dataframe_textual/data_frame_help_panel.py +0 -0
  17. {dataframe_textual-2.4.3 → dataframe_textual-2.5.0}/src/dataframe_textual/sql_screen.py +0 -0
  18. {dataframe_textual-2.4.3 → dataframe_textual-2.5.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.5.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 |
@@ -391,14 +391,13 @@ zcat compressed_data.csv.gz | dv -f csv
391
391
  | `!` | Cast current column to boolean |
392
392
  | `$` | Cast current column to string |
393
393
 
394
- #### Copy & Save
394
+ #### Copy
395
395
 
396
396
  | Key | Action |
397
397
  |-----|--------|
398
398
  | `c` | Copy current cell to clipboard |
399
399
  | `Ctrl+C` | Copy column to clipboard |
400
400
  | `Ctrl+R` | Copy row to clipboard (tab-separated) |
401
- | `Ctrl+S` | Save to file |
402
401
 
403
402
  ## Features in Detail
404
403
 
@@ -820,7 +819,7 @@ Manage multiple files and dataframes simultaneously with tabs.
820
819
  - **`Double-click`** - Rename the tab
821
820
  - **`Ctrl+D`** - Duplicate current tab (creates a copy with same data and state)
822
821
  - **`Ctrl+T`** - Save current tab to file
823
- - **`Ctrl+A`** - Save all tabs in a single Excel file
822
+ - **`Ctrl+S`** - Save all tabs to file
824
823
  - **`w`** - Save current tab to file (overwrite without prompt)
825
824
  - **`W`** - Save all tabs to file (overwrite without prompt)
826
825
  - **`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 |
@@ -352,14 +352,13 @@ zcat compressed_data.csv.gz | dv -f csv
352
352
  | `!` | Cast current column to boolean |
353
353
  | `$` | Cast current column to string |
354
354
 
355
- #### Copy & Save
355
+ #### Copy
356
356
 
357
357
  | Key | Action |
358
358
  |-----|--------|
359
359
  | `c` | Copy current cell to clipboard |
360
360
  | `Ctrl+C` | Copy column to clipboard |
361
361
  | `Ctrl+R` | Copy row to clipboard (tab-separated) |
362
- | `Ctrl+S` | Save to file |
363
362
 
364
363
  ## Features in Detail
365
364
 
@@ -781,7 +780,7 @@ Manage multiple files and dataframes simultaneously with tabs.
781
780
  - **`Double-click`** - Rename the tab
782
781
  - **`Ctrl+D`** - Duplicate current tab (creates a copy with same data and state)
783
782
  - **`Ctrl+T`** - Save current tab to file
784
- - **`Ctrl+A`** - Save all tabs in a single Excel file
783
+ - **`Ctrl+S`** - Save all tabs to file
785
784
  - **`w`** - Save current tab to file (overwrite without prompt)
786
785
  - **`W`** - Save all tabs to file (overwrite without prompt)
787
786
  - **`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.5.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
 
@@ -223,11 +221,10 @@ class DataFrameTable(DataTable):
223
221
  - **!** - ✅ Cast column to boolean
224
222
  - **$** - 📝 Cast column to string
225
223
 
226
- ## 💾 Copy & Save
224
+ ## 💾 Copy
227
225
  - **c** - 📋 Copy cell to clipboard
228
226
  - **Ctrl+c** - 📊 Copy column to clipboard
229
227
  - **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
230
- - **Ctrl+s** - 💾 Save current tab to file
231
228
  """).strip()
232
229
 
233
230
  # fmt: off
@@ -255,8 +252,6 @@ class DataFrameTable(DataTable):
255
252
  ("c", "copy_cell", "Copy cell to clipboard"),
256
253
  ("ctrl+c", "copy_column", "Copy column to clipboard"),
257
254
  ("ctrl+r", "copy_row", "Copy row to clipboard"),
258
- # Save
259
- ("ctrl+s", "save_to_file", "Save to file"),
260
255
  # Metadata, Detail, Frequency, and Statistics
261
256
  ("m", "metadata_shape", "Show metadata for row count and column count"),
262
257
  ("M", "metadata_column", "Show metadata for column"),
@@ -744,10 +739,6 @@ class DataFrameTable(DataTable):
744
739
  """Sort by current column in descending order."""
745
740
  self.do_sort_by_column(descending=True)
746
741
 
747
- def action_save_to_file(self) -> None:
748
- """Save the current dataframe to a file."""
749
- self.do_save_to_file()
750
-
751
742
  def action_show_frequency(self) -> None:
752
743
  """Show frequency distribution for the current column."""
753
744
  self.do_show_frequency()
@@ -3755,7 +3746,7 @@ class DataFrameTable(DataTable):
3755
3746
 
3756
3747
  self.notify(f"{message}. Now showing [$success]{len(self.df)}[/] rows.", title="Filter Rows")
3757
3748
 
3758
- # Copy & Save
3749
+ # Copy
3759
3750
  def do_copy_to_clipboard(self, content: str, message: str) -> None:
3760
3751
  """Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
3761
3752
 
@@ -3779,132 +3770,6 @@ class DataFrameTable(DataTable):
3779
3770
  except FileNotFoundError:
3780
3771
  self.notify("Error copying to clipboard", title="Copy to Clipboard", severity="error", timeout=10)
3781
3772
 
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
3773
  # SQL Interface
3909
3774
  def do_simple_sql(self) -> None:
3910
3775
  """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.5.0"
175
175
  source = { editable = "." }
176
176
  dependencies = [
177
177
  { name = "polars" },