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