dataframe-textual 1.5.0__py3-none-any.whl → 2.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,21 @@
1
1
  """DataFrame Viewer application and utilities."""
2
2
 
3
3
  import os
4
- from functools import partial
5
4
  from pathlib import Path
6
5
  from textwrap import dedent
7
6
 
8
7
  import polars as pl
9
8
  from textual.app import App, ComposeResult
10
9
  from textual.css.query import NoMatches
10
+ from textual.events import Click
11
11
  from textual.theme import BUILTIN_THEMES
12
12
  from textual.widgets import TabbedContent, TabPane
13
- from textual.widgets.tabbed_content import ContentTabs
13
+ from textual.widgets.tabbed_content import ContentTab, ContentTabs
14
14
 
15
15
  from .common import 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 OpenFileScreen, SaveFileScreen
18
+ from .yes_no_screen import ConfirmScreen, OpenFileScreen, RenameTabScreen
19
19
 
20
20
 
21
21
  class DataFrameViewer(App):
@@ -25,39 +25,51 @@ class DataFrameViewer(App):
25
25
  # 📊 DataFrame Viewer - App Controls
26
26
 
27
27
  ## 🎯 File & Tab Management
28
- - **Ctrl+O** - 📁 Add a new tab
29
- - **Ctrl+A** - 💾 Save all tabs
30
- - **Ctrl+W** - ❌ Close current tab
31
- - **>** or **b** - ▶️ Next tab
28
+ - **>** - ▶️ Next tab
32
29
  - **<** - ◀️ Previous tab
30
+ - **b** - 🔄 Cycle through tabs
33
31
  - **B** - 👁️ Toggle tab bar visibility
34
- - **q** - 🚪 Quit application
32
+ - **q** - Close current tab (prompts to save unsaved changes)
33
+ - **Q** - ❌ Close all tabs (prompts to save unsaved changes)
34
+ - **Ctrl+Q** - 🚪 Force to quit app (discards unsaved changes)
35
+ - **Ctrl+T** - 💾 Save current tab to file
36
+ - **w** - 💾 Save current tab to file (overwrite without prompt)
37
+ - **Ctrl+A** - 💾 Save all tabs to file
38
+ - **W** - 💾 Save all tabs to file (overwrite without prompt)
39
+ - **Ctrl+D** - 📋 Duplicate current tab
40
+ - **Ctrl+O** - 📁 Open a file
41
+ - **Double-click** - ✏️ Rename tab
35
42
 
36
43
  ## 🎨 View & Settings
37
44
  - **F1** - ❓ Toggle this help panel
38
45
  - **k** - 🌙 Cycle through themes
46
+ - **Ctrl+P -> Screenshot** - 📸 Capture terminal view as a SVG image
39
47
 
40
48
  ## ⭐ Features
41
49
  - **Multi-file support** - 📂 Open multiple CSV/Excel files as tabs
42
- - **Excel sheets** - 📊 Excel files auto-expand sheets into tabs
43
50
  - **Lazy loading** - ⚡ Large files load on demand
44
51
  - **Sticky tabs** - 📌 Tab bar stays visible when scrolling
52
+ - **Unsaved changes** - 🔴 Tabs with unsaved changes have a bright bottom border
45
53
  - **Rich formatting** - 🎨 Color-coded data types
46
54
  - **Search & filter** - 🔍 Find and filter data quickly
47
- - **Sort & reorder** - ⬆️ Multi-column sort, drag rows/columns
48
- - **Undo/Redo** - 🔄 Full history of operations
55
+ - **Sort & reorder** - ⬆️ Multi-column sort, reorder rows/columns
56
+ - **Undo/Redo/Reset** - 🔄 Full history of operations
49
57
  - **Freeze rows/cols** - 🔒 Pin header rows and columns
50
58
  """).strip()
51
59
 
52
60
  BINDINGS = [
53
- ("q", "quit", "Quit"),
54
- ("f1", "toggle_help_panel", "Help"),
61
+ ("q", "close_tab", "Close current tab"),
62
+ ("Q", "close_all_tabs", "Close all tabs and quit app"),
55
63
  ("B", "toggle_tab_bar", "Toggle Tab Bar"),
56
- ("ctrl+o", "add_tab", "Add Tab"),
64
+ ("f1", "toggle_help_panel", "Help"),
65
+ ("ctrl+o", "open_file", "Open File"),
66
+ ("ctrl+t", "save_current_tab", "Save Current Tab"),
57
67
  ("ctrl+a", "save_all_tabs", "Save All Tabs"),
58
- ("ctrl+w", "close_tab", "Close Tab"),
59
- ("greater_than_sign,b", "next_tab(1)", "Next Tab"),
60
- ("less_than_sign", "next_tab(-1)", "Prev Tab"),
68
+ ("w", "save_current_tab_overwrite", "Save Current Tab (overwrite)"),
69
+ ("W", "save_all_tabs_overwrite", "Save All Tabs (overwrite)"),
70
+ ("ctrl+d", "duplicate_tab", "Duplicate Tab"),
71
+ ("greater_than_sign,b", "next_tab(1)", "Next Tab"), # '>' and 'b'
72
+ ("less_than_sign", "next_tab(-1)", "Prev Tab"), # '<'
61
73
  ]
62
74
 
63
75
  CSS = """
@@ -71,6 +83,9 @@ class DataFrameViewer(App):
71
83
  ContentTab.-active {
72
84
  background: $block-cursor-background; /* Same as underline */
73
85
  }
86
+ ContentTab.dirty {
87
+ background: $warning-darken-3;
88
+ }
74
89
  """
75
90
 
76
91
  def __init__(self, *sources: Source) -> None:
@@ -81,9 +96,6 @@ class DataFrameViewer(App):
81
96
  Args:
82
97
  sources: sources to load dataframes from, each as a tuple of
83
98
  (DataFrame, filename, tabname).
84
-
85
- Returns:
86
- None
87
99
  """
88
100
  super().__init__()
89
101
  self.sources = sources
@@ -105,7 +117,7 @@ class DataFrameViewer(App):
105
117
  seen_names = set()
106
118
  for idx, source in enumerate(self.sources, start=1):
107
119
  df, filename, tabname = source.frame, source.filename, source.tabname
108
- tab_id = f"tab_{idx}"
120
+ tab_id = f"tab-{idx}"
109
121
 
110
122
  if not tabname:
111
123
  tabname = Path(filename).stem or tab_id
@@ -118,8 +130,8 @@ class DataFrameViewer(App):
118
130
  seen_names.add(tabname)
119
131
 
120
132
  try:
121
- table = DataFrameTable(df, filename, name=tabname, id=tab_id, zebra_stripes=True)
122
- tab = TabPane(tabname, table, name=tabname, id=tab_id)
133
+ table = DataFrameTable(df, filename, tabname=tabname, id=tab_id, zebra_stripes=True)
134
+ tab = TabPane(tabname, table, id=tab_id)
123
135
  self.tabs[tab] = table
124
136
  yield tab
125
137
  except Exception as e:
@@ -134,13 +146,15 @@ class DataFrameViewer(App):
134
146
 
135
147
  Initializes the app by hiding the tab bar for single-file mode and focusing
136
148
  the active table widget.
137
-
138
- Returns:
139
- None
140
149
  """
141
150
  if len(self.tabs) == 1:
142
151
  self.query_one(ContentTabs).display = False
143
- self._get_active_table().focus()
152
+ self.get_active_table().focus()
153
+
154
+ def on_ready(self) -> None:
155
+ """Called when the app is ready."""
156
+ # self.log(self.tree)
157
+ pass
144
158
 
145
159
  def on_key(self, event) -> None:
146
160
  """Handle key press events at the application level.
@@ -149,14 +163,32 @@ class DataFrameViewer(App):
149
163
 
150
164
  Args:
151
165
  event: The key event object containing key information.
152
-
153
- Returns:
154
- None
155
166
  """
156
167
  if event.key == "k":
157
168
  self.theme = get_next_item(list(BUILTIN_THEMES.keys()), self.theme)
158
169
  self.notify(f"Switched to theme: [$success]{self.theme}[/]", title="Theme")
159
170
 
171
+ def on_click(self, event: Click) -> None:
172
+ """Handle mouse click events on tabs.
173
+
174
+ Detects double-clicks on tab headers and opens the rename screen.
175
+
176
+ Args:
177
+ event: The click event containing position information.
178
+ """
179
+ # Check if this is a double-click (chain > 1) on a tab header
180
+ if event.chain > 1:
181
+ try:
182
+ # Get the widget that was clicked
183
+ content_tab = event.widget
184
+
185
+ # Check if it's a ContentTab (tab header)
186
+ if isinstance(content_tab, ContentTab):
187
+ self.do_rename_tab(content_tab)
188
+ except Exception as e:
189
+ self.log(f"Error handling tab rename click: {str(e)}")
190
+ pass
191
+
160
192
  def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
161
193
  """Handle tab activation events.
162
194
 
@@ -165,26 +197,20 @@ class DataFrameViewer(App):
165
197
 
166
198
  Args:
167
199
  event: The tab activated event containing the activated tab pane.
168
-
169
- Returns:
170
- None
171
200
  """
172
201
  # Focus the table in the newly activated tab
173
- if table := self._get_active_table():
202
+ if table := self.get_active_table():
174
203
  table.focus()
175
204
  else:
176
205
  return
177
206
 
178
207
  if table.loaded_rows == 0:
179
- table._setup_table()
208
+ table.setup_table()
180
209
 
181
210
  def action_toggle_help_panel(self) -> None:
182
211
  """Toggle the help panel on or off.
183
212
 
184
213
  Shows or hides the context-sensitive help panel. Creates it on first use.
185
-
186
- Returns:
187
- None
188
214
  """
189
215
  if self.help_panel:
190
216
  self.help_panel.display = not self.help_panel.display
@@ -192,44 +218,109 @@ class DataFrameViewer(App):
192
218
  self.help_panel = DataFrameHelpPanel()
193
219
  self.mount(self.help_panel)
194
220
 
195
- def action_add_tab(self) -> None:
221
+ def action_open_file(self) -> None:
196
222
  """Open file browser to load a file in a new tab.
197
223
 
198
224
  Displays the file open dialog for the user to select a file to load
199
225
  as a new tab in the interface.
226
+ """
227
+ self.push_screen(OpenFileScreen(), self.do_open_file)
200
228
 
201
- Returns:
202
- None
229
+ def action_close_tab(self) -> None:
230
+ """Close the current tab.
231
+
232
+ Checks for unsaved changes and prompts the user to save if needed.
233
+ If this is the last tab, exits the app.
203
234
  """
204
- self.push_screen(OpenFileScreen(), self._do_add_tab)
235
+ self.do_close_tab()
205
236
 
206
- def action_save_all_tabs(self) -> None:
207
- """Save all open tabs to a single Excel file.
237
+ def action_close_all_tabs(self) -> None:
238
+ """Close all tabs and exit the app.
208
239
 
209
- Displays a save dialog to choose filename and location, then saves all
210
- open tabs as separate sheets in a single Excel workbook.
240
+ Checks if any tabs have unsaved changes. If yes, opens a confirmation dialog.
241
+ Otherwise, quits immediately.
242
+ """
243
+ self.do_close_all_tabs()
211
244
 
212
- Returns:
213
- None
245
+ def action_save_current_tab(self) -> None:
246
+ """Save the currently active tab to file.
247
+
248
+ Opens the save dialog for the active tab's DataFrameTable to save its data.
214
249
  """
215
- callback = partial(self._get_active_table()._do_save_file, all_tabs=True)
216
- self.push_screen(
217
- SaveFileScreen("all-tabs.xlsx", title="Save All Tabs"),
218
- callback=callback,
219
- )
250
+ if table := self.get_active_table():
251
+ table.do_save_to_file(all_tabs=False)
220
252
 
221
- def action_close_tab(self) -> None:
222
- """Close the currently active tab.
253
+ def action_save_all_tabs(self) -> None:
254
+ """Save all open tabs to their respective files.
223
255
 
224
- Closes the current tab. If this is the only tab, exits the application instead.
256
+ Iterates through all DataFrameTable widgets and opens the save dialog for each.
257
+ """
258
+ if table := self.get_active_table():
259
+ table.do_save_to_file(all_tabs=True)
260
+
261
+ def action_save_current_tab_overwrite(self) -> None:
262
+ """Save the currently active tab to file, overwriting if it exists."""
263
+ if table := self.get_active_table():
264
+ filepath = Path(table.filename)
265
+ filename = filepath.with_stem(table.tabname)
266
+ table.save_to_file((filename, False, False))
267
+
268
+ def action_save_all_tabs_overwrite(self) -> None:
269
+ """Save all open tabs to their respective files, overwriting if they exist."""
270
+ if table := self.get_active_table():
271
+ filepath = Path(table.filename)
272
+ if filepath.suffix.lower() in [".xlsx", ".xls"]:
273
+ filename = table.filename
274
+ else:
275
+ filename = "all-tabs.xlsx"
225
276
 
226
- Returns:
227
- None
277
+ table.save_to_file((filename, True, False))
278
+
279
+ def action_duplicate_tab(self) -> None:
280
+ """Duplicate the currently active tab.
281
+
282
+ Creates a copy of the current tab with the same data and filename.
283
+ The new tab is named with '_copy' suffix and inserted after the current tab.
228
284
  """
229
- if len(self.tabs) <= 1:
230
- self.app.exit()
285
+ self.do_duplicate_tab()
286
+
287
+ def do_duplicate_tab(self) -> None:
288
+ """Duplicate the currently active tab.
289
+
290
+ Creates a copy of the current tab with the same data and filename.
291
+ The new tab is named with '_copy' suffix and inserted after the current tab.
292
+ """
293
+ if not (table := self.get_active_table()):
231
294
  return
232
- self._close_tab()
295
+
296
+ # Get current tab info
297
+ current_tabname = table.tabname
298
+ new_tabname = f"{current_tabname}_copy"
299
+ new_tabname = self.get_unique_tabname(new_tabname)
300
+
301
+ # Create new table with the same dataframe and filename
302
+ new_table = DataFrameTable(
303
+ table.df.clone(),
304
+ table.filename,
305
+ tabname=new_tabname,
306
+ zebra_stripes=True,
307
+ id=f"tab-{len(self.tabs) + 1}",
308
+ )
309
+ new_pane = TabPane(new_tabname, new_table, id=new_table.id)
310
+
311
+ # Add the new tab
312
+ active_pane = self.tabbed.active_pane
313
+ self.tabbed.add_pane(new_pane, after=active_pane)
314
+ self.tabs[new_pane] = new_table
315
+
316
+ # Show tab bar if needed
317
+ if len(self.tabs) > 1:
318
+ self.query_one(ContentTabs).display = True
319
+
320
+ # Activate and focus the new tab
321
+ self.tabbed.active = new_pane.id
322
+ new_table.focus()
323
+ new_table.dirty = True # Mark as dirty since it's a new unsaved tab
233
324
 
234
325
  def action_next_tab(self, offset: int = 1) -> None:
235
326
  """Switch to the next tab or previous tab.
@@ -239,9 +330,17 @@ class DataFrameViewer(App):
239
330
 
240
331
  Args:
241
332
  offset: Number of tabs to advance (+1 for next, -1 for previous). Defaults to 1.
333
+ """
334
+ self.do_next_tab(offset)
242
335
 
243
- Returns:
244
- None
336
+ def do_next_tab(self, offset: int = 1) -> None:
337
+ """Switch to the next tab or previous tab.
338
+
339
+ Cycles through tabs by the specified offset. With offset=1, moves to next tab.
340
+ With offset=-1, moves to previous tab. Wraps around when reaching edges.
341
+
342
+ Args:
343
+ offset: Number of tabs to advance (+1 for next, -1 for previous). Defaults to 1.
245
344
  """
246
345
  if len(self.tabs) <= 1:
247
346
  return
@@ -257,16 +356,13 @@ class DataFrameViewer(App):
257
356
 
258
357
  Shows or hides the tab bar at the bottom of the window. Useful for maximizing
259
358
  screen space in single-tab mode.
260
-
261
- Returns:
262
- None
263
359
  """
264
360
  tabs = self.query_one(ContentTabs)
265
361
  tabs.display = not tabs.display
266
362
  # status = "shown" if tabs.display else "hidden"
267
363
  # self.notify(f"Tab bar [$success]{status}[/]", title="Toggle")
268
364
 
269
- def _get_active_table(self) -> DataFrameTable | None:
365
+ def get_active_table(self) -> DataFrameTable | None:
270
366
  """Get the currently active DataFrameTable widget.
271
367
 
272
368
  Retrieves the table from the currently active tab. Returns None if no
@@ -283,23 +379,40 @@ class DataFrameViewer(App):
283
379
  self.notify("No active table found", title="Locate", severity="error")
284
380
  return None
285
381
 
286
- def _do_add_tab(self, filename: str) -> None:
287
- """Add a tab for the opened file.
382
+ def get_unique_tabname(self, tab_name: str) -> str:
383
+ """Generate a unique tab name based on the given base name.
384
+
385
+ If the base name already exists among current tabs, appends an index
386
+ to make it unique.
387
+
388
+ Args:
389
+ tab_name: The desired base name for the tab.
390
+
391
+ Returns:
392
+ A unique tab name.
393
+ """
394
+ tabname = tab_name
395
+ counter = 1
396
+ while any(table.tabname == tabname for table in self.tabs.values()):
397
+ tabname = f"{tab_name}_{counter}"
398
+ counter += 1
399
+
400
+ return tabname
401
+
402
+ def do_open_file(self, filename: str) -> None:
403
+ """Open a file.
288
404
 
289
405
  Loads the specified file and creates one or more tabs for it. For Excel files,
290
406
  creates one tab per sheet. For other formats, creates a single tab.
291
407
 
292
408
  Args:
293
409
  filename: Path to the file to load and add as tab(s).
294
-
295
- Returns:
296
- None
297
410
  """
298
411
  if filename and os.path.exists(filename):
299
412
  try:
300
413
  n_tab = 0
301
- for lf, filename, tabname in load_file(filename, prefix_sheet=True):
302
- self._add_tab(lf, filename, tabname)
414
+ for source in load_file(filename, prefix_sheet=True):
415
+ self.add_tab(source.frame, filename, source.tabname, after=self.tabbed.active_pane)
303
416
  n_tab += 1
304
417
  # self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
305
418
  except Exception as e:
@@ -307,7 +420,14 @@ class DataFrameViewer(App):
307
420
  else:
308
421
  self.notify(f"File does not exist: [$warning]{filename}[/]", title="Open", severity="warning")
309
422
 
310
- def _add_tab(self, df: pl.DataFrame, filename: str, tabname: str) -> None:
423
+ def add_tab(
424
+ self,
425
+ df: pl.DataFrame,
426
+ filename: str,
427
+ tabname: str,
428
+ before: TabPane | str | None = None,
429
+ after: TabPane | str | None = None,
430
+ ) -> None:
311
431
  """Add new tab for the given DataFrame.
312
432
 
313
433
  Creates and adds a new tab with the provided DataFrame and configuration.
@@ -318,30 +438,40 @@ class DataFrameViewer(App):
318
438
  lf: The Polars DataFrame to display in the new tab.
319
439
  filename: The source filename for this data (used in table metadata).
320
440
  tabname: The display name for the tab.
321
-
322
- Returns:
323
- None
324
441
  """
325
- # Ensure unique tab names
326
- counter = 1
327
- while any(tab.name == tabname for tab in self.tabs):
328
- tabname = f"{tabname}_{counter}"
329
- counter += 1
442
+ tabname = self.get_unique_tabname(tabname)
330
443
 
331
444
  # Find an available tab index
332
- tab_idx = f"tab_{len(self.tabs) + 1}"
445
+ tab_idx = f"tab-{len(self.tabs) + 1}"
333
446
  for idx in range(len(self.tabs)):
334
- pending_tab_idx = f"tab_{idx + 1}"
447
+ pending_tab_idx = f"tab-{idx + 1}"
335
448
  if any(tab.id == pending_tab_idx for tab in self.tabs):
336
449
  continue
337
450
 
338
451
  tab_idx = pending_tab_idx
339
452
  break
340
453
 
341
- table = DataFrameTable(df, filename, zebra_stripes=True, id=tab_idx, name=tabname)
342
- tab = TabPane(tabname, table, name=tabname, id=tab_idx)
343
- self.tabbed.add_pane(tab)
344
- self.tabs[tab] = table
454
+ table = DataFrameTable(df, filename, tabname=tabname, zebra_stripes=True, id=tab_idx)
455
+ tab = TabPane(tabname, table, id=tab_idx)
456
+ self.tabbed.add_pane(tab, before=before, after=after)
457
+
458
+ # Insert tab at specified position
459
+ tabs = list(self.tabs.keys())
460
+
461
+ if before and (idx := tabs.index(before)) != -1:
462
+ self.tabs = {
463
+ **{tab: self.tabs[tab] for tab in tabs[:idx]},
464
+ tab: table,
465
+ **{tab: self.tabs[tab] for tab in tabs[idx:]},
466
+ }
467
+ elif after and (idx := tabs.index(after)) != -1:
468
+ self.tabs = {
469
+ **{tab: self.tabs[tab] for tab in tabs[: idx + 1]},
470
+ tab: table,
471
+ **{tab: self.tabs[tab] for tab in tabs[idx + 1 :]},
472
+ }
473
+ else:
474
+ self.tabs[tab] = table
345
475
 
346
476
  if len(self.tabs) > 1:
347
477
  self.query_one(ContentTabs).display = True
@@ -350,22 +480,146 @@ class DataFrameViewer(App):
350
480
  self.tabbed.active = tab.id
351
481
  table.focus()
352
482
 
353
- def _close_tab(self) -> None:
483
+ def do_close_tab(self) -> None:
354
484
  """Close the currently active tab.
355
485
 
356
486
  Removes the active tab from the interface. If only one tab remains and no more
357
487
  can be closed, the application exits instead.
358
-
359
- Returns:
360
- None
361
488
  """
362
489
  try:
363
- if len(self.tabs) == 1:
364
- self.app.exit()
490
+ if not (active_pane := self.tabbed.active_pane):
491
+ return
492
+
493
+ if not (active_table := self.tabs.get(active_pane)):
494
+ return
495
+
496
+ def _on_save_confirm(result: bool) -> None:
497
+ """Handle the "save before closing?" confirmation."""
498
+ if result:
499
+ # User wants to save - close after save dialog opens
500
+ active_table.do_save_to_file(task_after_save="close_tab")
501
+ elif result is None:
502
+ # User cancelled - do nothing
503
+ return
504
+ else:
505
+ # User wants to discard - close immediately
506
+ self.close_tab()
507
+
508
+ if active_table.dirty:
509
+ self.push_screen(
510
+ ConfirmScreen(
511
+ "Close Tab",
512
+ label="This tab has unsaved changes. Save changes?",
513
+ yes="Save",
514
+ maybe="Discard",
515
+ no="Cancel",
516
+ ),
517
+ callback=_on_save_confirm,
518
+ )
365
519
  else:
366
- if active_pane := self.tabbed.active_pane:
367
- self.tabbed.remove_pane(active_pane.id)
368
- self.tabs.pop(active_pane)
369
- # self.notify(f"Closed tab [$success]{active_pane.name}[/]", title="Close")
370
- except NoMatches:
520
+ # No unsaved changes - close immediately
521
+ self.close_tab()
522
+ except Exception:
371
523
  pass
524
+
525
+ def close_tab(self) -> None:
526
+ """Actually close the tab."""
527
+ try:
528
+ if not (active_pane := self.tabbed.active_pane):
529
+ return
530
+
531
+ self.tabbed.remove_pane(active_pane.id)
532
+ self.tabs.pop(active_pane)
533
+
534
+ # Quit app if no tabs remain
535
+ if len(self.tabs) == 0:
536
+ self.exit()
537
+ except Exception:
538
+ pass
539
+
540
+ def do_close_all_tabs(self) -> None:
541
+ """Close all tabs and quit the app.
542
+
543
+ Checks if any tabs have unsaved changes. If yes, opens a confirmation dialog.
544
+ Otherwise, quits immediately.
545
+ """
546
+ try:
547
+ # Check for dirty tabs
548
+ dirty_tabnames = [table.tabname for table in self.tabs.values() if table.dirty]
549
+ if not dirty_tabnames:
550
+ self.exit()
551
+ return
552
+
553
+ def _save_and_quit(result: bool) -> None:
554
+ if result:
555
+ self.get_active_table()._save_to_file(task_after_save="quit_app")
556
+ elif result is None:
557
+ # User cancelled - do nothing
558
+ return
559
+ else:
560
+ # User wants to discard - quit immediately
561
+ self.exit()
562
+
563
+ tab_list = "\n".join(f" - [$warning]{name}[/]" for name in dirty_tabnames)
564
+ label = (
565
+ f"The following tabs have unsaved changes:\n\n{tab_list}\n\nSave all changes?"
566
+ if len(dirty_tabnames) > 1
567
+ else f"The tab [$warning]{dirty_tabnames[0]}[/] has unsaved changes.\n\nSave changes?"
568
+ )
569
+ self.push_screen(
570
+ ConfirmScreen(
571
+ "Close All Tabs" if len(self.tabs) > 1 else "Close Tab",
572
+ label=label,
573
+ yes="Save",
574
+ maybe="Discard",
575
+ no="Cancel",
576
+ ),
577
+ callback=_save_and_quit,
578
+ )
579
+
580
+ except Exception as e:
581
+ self.log(f"Error quitting all tabs: {str(e)}")
582
+ pass
583
+
584
+ def do_rename_tab(self, content_tab: ContentTab) -> None:
585
+ """Open the rename tab screen.
586
+
587
+ Allows the user to rename the current tab and updates the table name accordingly.
588
+
589
+ Args:
590
+ content_tab: The ContentTab to rename.
591
+ """
592
+ if content_tab is None:
593
+ return
594
+
595
+ # Get list of existing tab names (excluding current tab)
596
+ existing_tabs = self.tabs.keys()
597
+
598
+ # Push the rename screen
599
+ self.push_screen(
600
+ RenameTabScreen(content_tab, existing_tabs),
601
+ callback=self.rename_tab,
602
+ )
603
+
604
+ def rename_tab(self, result) -> None:
605
+ """Handle result from RenameTabScreen."""
606
+ if result is None:
607
+ return
608
+
609
+ content_tab: ContentTab
610
+ content_tab, new_name = result
611
+
612
+ # Update the tab name
613
+ old_name = content_tab.label_text
614
+ content_tab.label = new_name
615
+
616
+ # Mark tab as dirty to indicate name change
617
+ tab_id = content_tab.id.removeprefix("--content-tab-")
618
+ for tab, table in self.tabs.items():
619
+ if tab.id == tab_id:
620
+ table.tabname = new_name
621
+ table.dirty = True
622
+ table.focus()
623
+ break
624
+
625
+ self.notify(f"Renamed tab [$accent]{old_name}[/] to [$success]{new_name}[/]", title="Rename")