dataframe-textual 2.4.2__py3-none-any.whl → 2.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@ import polars as pl
12
12
  from rich.text import Text
13
13
 
14
14
  # Supported file formats
15
- SUPPORTED_FORMATS = ["tsv", "csv", "psv", "excel", "parquet", "json", "ndjson"]
15
+ SUPPORTED_FORMATS = ["tsv", "csv", "psv", "xlsx", "xls", "parquet", "json", "ndjson"]
16
16
 
17
17
 
18
18
  # Boolean string mappings
@@ -531,8 +531,6 @@ def load_dataframe(
531
531
  ext = Path(filename).with_suffix("").suffix.lower()
532
532
 
533
533
  fmt = ext.removeprefix(".")
534
- if fmt in ("xls", "xlsx"):
535
- fmt = "excel"
536
534
 
537
535
  # Default to TSV
538
536
  if not fmt or fmt not in SUPPORTED_FORMATS:
@@ -688,7 +686,7 @@ def load_file(
688
686
  truncate_ragged_lines=truncate_ragged_lines,
689
687
  )
690
688
  data.append(Source(lf, filename, filepath.stem))
691
- elif file_format == "excel":
689
+ elif file_format in ("xlsx", "xls"):
692
690
  if first_sheet:
693
691
  # Read only the first sheet for multiple files
694
692
  lf = pl.read_excel(source).lazy()
@@ -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,38 +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():
266
- filepath = Path(table.filename)
267
- filename = filepath.with_stem(table.tabname)
268
- table.save_to_file((filename, False, False))
270
+ """Save current tab to file, overwrite if exists."""
271
+ if table := self.active_table:
272
+ if len(self.tabs) > 1:
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
280
+ else:
281
+ filename = table.filename
282
+
283
+ self.save_to_file((filename, False, False))
269
284
 
270
285
  def action_save_all_tabs_overwrite(self) -> None:
271
- """Save all open tabs to their respective files, overwriting if they exist."""
272
- if table := self.get_active_table():
273
- filepath = Path(table.filename)
274
- if filepath.suffix.lower() in [".xlsx", ".xls"]:
275
- 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
276
295
  else:
277
- filename = "all-tabs.xlsx"
296
+ filename = table.filename
278
297
 
279
- table.save_to_file((filename, True, False))
298
+ self.save_to_file((filename, True, False))
280
299
 
281
300
  def action_duplicate_tab(self) -> None:
282
301
  """Duplicate the currently active tab.
@@ -292,7 +311,7 @@ class DataFrameViewer(App):
292
311
  Creates a copy of the current tab with the same data and filename.
293
312
  The new tab is named with '_copy' suffix and inserted after the current tab.
294
313
  """
295
- if not (table := self.get_active_table()):
314
+ if not (table := self.active_table):
296
315
  return
297
316
 
298
317
  # Get current tab info
@@ -364,24 +383,6 @@ class DataFrameViewer(App):
364
383
  # status = "shown" if tabs.display else "hidden"
365
384
  # self.notify(f"Tab bar [$success]{status}[/]", title="Toggle Tab Bar")
366
385
 
367
- def get_active_table(self) -> DataFrameTable | None:
368
- """Get the currently active DataFrameTable widget.
369
-
370
- Retrieves the table from the currently active tab. Returns None if no
371
- table is found or an error occurs.
372
-
373
- Returns:
374
- The active DataFrameTable widget, or None if not found.
375
- """
376
- try:
377
- tabbed: TabbedContent = self.query_one(TabbedContent)
378
- if active_pane := tabbed.active_pane:
379
- return active_pane.query_one(DataFrameTable)
380
- except (NoMatches, AttributeError):
381
- self.notify("No active table found", title="Locate Table", severity="error", timeout=10)
382
-
383
- return None
384
-
385
386
  def get_unique_tabname(self, tab_name: str) -> str:
386
387
  """Generate a unique tab name based on the given base name.
387
388
 
@@ -492,17 +493,14 @@ class DataFrameViewer(App):
492
493
  can be closed, the application exits instead.
493
494
  """
494
495
  try:
495
- if not (active_pane := self.tabbed.active_pane):
496
- return
497
-
498
- if not (active_table := self.tabs.get(active_pane)):
496
+ if not (table := self.active_table):
499
497
  return
500
498
 
501
499
  def _on_save_confirm(result: bool) -> None:
502
500
  """Handle the "save before closing?" confirmation."""
503
501
  if result:
504
502
  # User wants to save - close after save dialog opens
505
- 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")
506
504
  elif result is None:
507
505
  # User cancelled - do nothing
508
506
  return
@@ -510,7 +508,7 @@ class DataFrameViewer(App):
510
508
  # User wants to discard - close immediately
511
509
  self.close_tab()
512
510
 
513
- if active_table.dirty:
511
+ if table.dirty:
514
512
  self.push_screen(
515
513
  ConfirmScreen(
516
514
  "Close Tab",
@@ -557,7 +555,7 @@ class DataFrameViewer(App):
557
555
 
558
556
  def _save_and_quit(result: bool) -> None:
559
557
  if result:
560
- 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")
561
559
  elif result is None:
562
560
  # User cancelled - do nothing
563
561
  return
@@ -565,15 +563,17 @@ class DataFrameViewer(App):
565
563
  # User wants to discard - quit immediately
566
564
  self.exit()
567
565
 
566
+ tab_count = len(self.tabs)
568
567
  tab_list = "\n".join(f" - [$warning]{name}[/]" for name in dirty_tabnames)
569
568
  label = (
570
569
  f"The following tabs have unsaved changes:\n\n{tab_list}\n\nSave all changes?"
571
570
  if len(dirty_tabnames) > 1
572
571
  else f"The tab [$warning]{dirty_tabnames[0]}[/] has unsaved changes.\n\nSave changes?"
573
572
  )
573
+
574
574
  self.push_screen(
575
575
  ConfirmScreen(
576
- "Close All Tabs" if len(self.tabs) > 1 else "Close Tab",
576
+ f"Close {tab_count} Tabs" if tab_count > 1 else "Close Tab",
577
577
  label=label,
578
578
  yes="Save",
579
579
  maybe="Discard",
@@ -628,3 +628,141 @@ class DataFrameViewer(App):
628
628
  break
629
629
 
630
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataframe-textual
3
- Version: 2.4.2
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
@@ -167,7 +167,7 @@ When multiple files are opened:
167
167
  ## Command Line Options
168
168
 
169
169
  ```
170
- usage: dv [-h] [-V] [-f {csv,json,excel,ndjson,psv,parquet,tsv}] [-H] [-I] [-t] [-E] [-c [COMMENT_PREFIX]] [-q [QUOTE_CHAR]] [-l SKIP_LINES] [-a SKIP_ROWS_AFTER_HEADER] [-n NULL [NULL ...]] [files ...]
170
+ usage: dv [-h] [-V] [-f {csv,json,xlsx,xls,ndjson,psv,parquet,tsv}] [-H] [-I] [-t] [-E] [-c [COMMENT_PREFIX]] [-q [QUOTE_CHAR]] [-l SKIP_LINES] [-a SKIP_ROWS_AFTER_HEADER] [-n NULL [NULL ...]] [files ...]
171
171
 
172
172
  Interactive terminal based viewer/editor for tabular data (e.g., CSV/Excel).
173
173
 
@@ -177,8 +177,8 @@ positional arguments:
177
177
  options:
178
178
  -h, --help show this help message and exit
179
179
  -V, --version show program's version number and exit
180
- -f, --format {csv,json,excel,ndjson,psv,parquet,tsv}
181
- Specify the format of the input files (csv, excel, tsv etc.)
180
+ -f, --format {csv,json,xlsx,xls,ndjson,psv,parquet,tsv}
181
+ Specify the format of the input files (csv, tsv etc.)
182
182
  -H, --no-header Specify that input files have no header row when reading CSV/TSV
183
183
  -I, --no-inference Do not infer data types when reading CSV/TSV
184
184
  -t, --truncate-ragged-lines
@@ -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)
@@ -1,14 +1,14 @@
1
1
  dataframe_textual/__init__.py,sha256=E53fW1spQRA4jW9grxSqPEmoe9zofzr6twdveMbt_W8,1310
2
2
  dataframe_textual/__main__.py,sha256=xXeUA2EqVhufPkTbvv6MOCt3_ESHBH3PsCE--07a0ww,3613
3
- dataframe_textual/common.py,sha256=8Fqwuco7z4p_3GDCF7Gp8SYGUz24Sncpc457FCyMMWk,28516
3
+ dataframe_textual/common.py,sha256=WwCUnG5MReUz6yfjHPL1mrwgDddCCJmIbqk_hrRduYU,28461
4
4
  dataframe_textual/data_frame_help_panel.py,sha256=UEtj64XsVRdtLzuwOaITfoEQUkAfwFuvpr5Npip5WHs,3381
5
- dataframe_textual/data_frame_table.py,sha256=4I8acfjvnOKOhzZFjhqvaaOYhrz1IZHK5WnUKrPd12M,151387
6
- dataframe_textual/data_frame_viewer.py,sha256=aUjIk9BWYKyMG87PirFxR79iNLzkEcZ-I5XVnXwDEnU,23284
5
+ dataframe_textual/data_frame_table.py,sha256=QjGMJVHO7N9rNKCiIOEFXRMf-3PRueLGxq_qRKmJa90,146027
6
+ dataframe_textual/data_frame_viewer.py,sha256=_VwbCcRBgdTcrZmgS2mRwIJ-cFxOeJ55twDFvQUHMfk,28723
7
7
  dataframe_textual/sql_screen.py,sha256=P3j1Fv45NIKEYo9adb7NPod54FaU-djFIvCUMMHbvjY,7534
8
8
  dataframe_textual/table_screen.py,sha256=XPzJI6FXjwnxtQSMTmluygwkYM-0-Lx3v9o-MuL6bMg,19071
9
9
  dataframe_textual/yes_no_screen.py,sha256=NI7Zt3rETDWYiT5CH_FDy7sIWkZ7d7LquaZZbX79b2g,26400
10
- dataframe_textual-2.4.2.dist-info/METADATA,sha256=4EAHZULXbSWz4Eb5Mf8_VlUIOHdx9L4VY2vIhnanrLc,29482
11
- dataframe_textual-2.4.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
- dataframe_textual-2.4.2.dist-info/entry_points.txt,sha256=R_GoooOxcq6ab4RaHiVoZ4zrZJ-phMcGmlL2rwqncW8,107
13
- dataframe_textual-2.4.2.dist-info/licenses/LICENSE,sha256=AVTg0gk1X-LHI-nnHlAMDQetrwuDZK4eypgSMDO46Yc,1069
14
- dataframe_textual-2.4.2.dist-info/RECORD,,
10
+ dataframe_textual-2.5.0.dist-info/METADATA,sha256=O3m1-ydYqI1ueHTFaMV8frjWFkpc-jq9iUGaPmdOSU0,29423
11
+ dataframe_textual-2.5.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ dataframe_textual-2.5.0.dist-info/entry_points.txt,sha256=R_GoooOxcq6ab4RaHiVoZ4zrZJ-phMcGmlL2rwqncW8,107
13
+ dataframe_textual-2.5.0.dist-info/licenses/LICENSE,sha256=AVTg0gk1X-LHI-nnHlAMDQetrwuDZK4eypgSMDO46Yc,1069
14
+ dataframe_textual-2.5.0.dist-info/RECORD,,