dataframe-textual 1.4.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 +10 -4
- dataframe_textual/common.py +201 -163
- dataframe_textual/data_frame_table.py +1037 -881
- dataframe_textual/data_frame_viewer.py +321 -104
- dataframe_textual/sql_screen.py +50 -11
- dataframe_textual/table_screen.py +1 -1
- dataframe_textual/yes_no_screen.py +89 -8
- {dataframe_textual-1.4.0.dist-info → dataframe_textual-1.9.0.dist-info}/METADATA +141 -185
- dataframe_textual-1.9.0.dist-info/RECORD +14 -0
- dataframe_textual-1.4.0.dist-info/RECORD +0 -14
- {dataframe_textual-1.4.0.dist-info → dataframe_textual-1.9.0.dist-info}/WHEEL +0 -0
- {dataframe_textual-1.4.0.dist-info → dataframe_textual-1.9.0.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-1.4.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
|
-
from .common import get_next_item, load_file
|
|
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,9 +78,16 @@ 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
|
-
def __init__(self, *sources:
|
|
90
|
+
def __init__(self, *sources: Source) -> None:
|
|
77
91
|
"""Initialize the DataFrame Viewer application.
|
|
78
92
|
|
|
79
93
|
Loads data from provided sources and prepares the tabbed interface.
|
|
@@ -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
|
|
@@ -103,8 +114,9 @@ class DataFrameViewer(App):
|
|
|
103
114
|
self.tabbed = TabbedContent(id="main_tabs")
|
|
104
115
|
with self.tabbed:
|
|
105
116
|
seen_names = set()
|
|
106
|
-
for idx,
|
|
107
|
-
|
|
117
|
+
for idx, source in enumerate(self.sources, start=1):
|
|
118
|
+
df, filename, tabname = source.frame, source.filename, source.tabname
|
|
119
|
+
tab_id = f"tab-{idx}"
|
|
108
120
|
|
|
109
121
|
if not tabname:
|
|
110
122
|
tabname = Path(filename).stem or tab_id
|
|
@@ -117,8 +129,8 @@ class DataFrameViewer(App):
|
|
|
117
129
|
seen_names.add(tabname)
|
|
118
130
|
|
|
119
131
|
try:
|
|
120
|
-
table = DataFrameTable(df, filename,
|
|
121
|
-
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)
|
|
122
134
|
self.tabs[tab] = table
|
|
123
135
|
yield tab
|
|
124
136
|
except Exception as e:
|
|
@@ -133,13 +145,15 @@ class DataFrameViewer(App):
|
|
|
133
145
|
|
|
134
146
|
Initializes the app by hiding the tab bar for single-file mode and focusing
|
|
135
147
|
the active table widget.
|
|
136
|
-
|
|
137
|
-
Returns:
|
|
138
|
-
None
|
|
139
148
|
"""
|
|
140
149
|
if len(self.tabs) == 1:
|
|
141
150
|
self.query_one(ContentTabs).display = False
|
|
142
|
-
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
|
|
143
157
|
|
|
144
158
|
def on_key(self, event) -> None:
|
|
145
159
|
"""Handle key press events at the application level.
|
|
@@ -148,14 +162,32 @@ class DataFrameViewer(App):
|
|
|
148
162
|
|
|
149
163
|
Args:
|
|
150
164
|
event: The key event object containing key information.
|
|
151
|
-
|
|
152
|
-
Returns:
|
|
153
|
-
None
|
|
154
165
|
"""
|
|
155
166
|
if event.key == "k":
|
|
156
167
|
self.theme = get_next_item(list(BUILTIN_THEMES.keys()), self.theme)
|
|
157
168
|
self.notify(f"Switched to theme: [$success]{self.theme}[/]", title="Theme")
|
|
158
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
|
+
|
|
159
191
|
def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
|
|
160
192
|
"""Handle tab activation events.
|
|
161
193
|
|
|
@@ -164,26 +196,20 @@ class DataFrameViewer(App):
|
|
|
164
196
|
|
|
165
197
|
Args:
|
|
166
198
|
event: The tab activated event containing the activated tab pane.
|
|
167
|
-
|
|
168
|
-
Returns:
|
|
169
|
-
None
|
|
170
199
|
"""
|
|
171
200
|
# Focus the table in the newly activated tab
|
|
172
|
-
if table := self.
|
|
201
|
+
if table := self.get_active_table():
|
|
173
202
|
table.focus()
|
|
174
203
|
else:
|
|
175
204
|
return
|
|
176
205
|
|
|
177
206
|
if table.loaded_rows == 0:
|
|
178
|
-
table.
|
|
207
|
+
table.setup_table()
|
|
179
208
|
|
|
180
209
|
def action_toggle_help_panel(self) -> None:
|
|
181
210
|
"""Toggle the help panel on or off.
|
|
182
211
|
|
|
183
212
|
Shows or hides the context-sensitive help panel. Creates it on first use.
|
|
184
|
-
|
|
185
|
-
Returns:
|
|
186
|
-
None
|
|
187
213
|
"""
|
|
188
214
|
if self.help_panel:
|
|
189
215
|
self.help_panel.display = not self.help_panel.display
|
|
@@ -191,44 +217,83 @@ class DataFrameViewer(App):
|
|
|
191
217
|
self.help_panel = DataFrameHelpPanel()
|
|
192
218
|
self.mount(self.help_panel)
|
|
193
219
|
|
|
194
|
-
def
|
|
220
|
+
def action_open_file(self) -> None:
|
|
195
221
|
"""Open file browser to load a file in a new tab.
|
|
196
222
|
|
|
197
223
|
Displays the file open dialog for the user to select a file to load
|
|
198
224
|
as a new tab in the interface.
|
|
225
|
+
"""
|
|
226
|
+
self.push_screen(OpenFileScreen(), self.do_open_file)
|
|
199
227
|
|
|
200
|
-
|
|
201
|
-
|
|
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.
|
|
202
233
|
"""
|
|
203
|
-
self.
|
|
234
|
+
self.do_close_tab()
|
|
204
235
|
|
|
205
|
-
def
|
|
206
|
-
"""
|
|
236
|
+
def action_close_all_tabs(self) -> None:
|
|
237
|
+
"""Close all tabs and exit the app.
|
|
207
238
|
|
|
208
|
-
|
|
209
|
-
|
|
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()
|
|
210
243
|
|
|
211
|
-
|
|
212
|
-
|
|
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.
|
|
213
248
|
"""
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
SaveFileScreen("all-tabs.xlsx", title="Save All Tabs"),
|
|
217
|
-
callback=callback,
|
|
218
|
-
)
|
|
249
|
+
if table := self.get_active_table():
|
|
250
|
+
table.do_save_to_file(title="Save Current Tab", all_tabs=False)
|
|
219
251
|
|
|
220
|
-
def
|
|
221
|
-
"""
|
|
252
|
+
def action_save_all_tabs(self) -> None:
|
|
253
|
+
"""Save all open tabs to their respective files.
|
|
222
254
|
|
|
223
|
-
|
|
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)
|
|
224
259
|
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
227
265
|
"""
|
|
228
|
-
if
|
|
229
|
-
self.app.exit()
|
|
266
|
+
if not (table := self.get_active_table()):
|
|
230
267
|
return
|
|
231
|
-
|
|
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
|
|
232
297
|
|
|
233
298
|
def action_next_tab(self, offset: int = 1) -> None:
|
|
234
299
|
"""Switch to the next tab or previous tab.
|
|
@@ -238,9 +303,6 @@ class DataFrameViewer(App):
|
|
|
238
303
|
|
|
239
304
|
Args:
|
|
240
305
|
offset: Number of tabs to advance (+1 for next, -1 for previous). Defaults to 1.
|
|
241
|
-
|
|
242
|
-
Returns:
|
|
243
|
-
None
|
|
244
306
|
"""
|
|
245
307
|
if len(self.tabs) <= 1:
|
|
246
308
|
return
|
|
@@ -256,16 +318,13 @@ class DataFrameViewer(App):
|
|
|
256
318
|
|
|
257
319
|
Shows or hides the tab bar at the bottom of the window. Useful for maximizing
|
|
258
320
|
screen space in single-tab mode.
|
|
259
|
-
|
|
260
|
-
Returns:
|
|
261
|
-
None
|
|
262
321
|
"""
|
|
263
322
|
tabs = self.query_one(ContentTabs)
|
|
264
323
|
tabs.display = not tabs.display
|
|
265
324
|
# status = "shown" if tabs.display else "hidden"
|
|
266
325
|
# self.notify(f"Tab bar [$success]{status}[/]", title="Toggle")
|
|
267
326
|
|
|
268
|
-
def
|
|
327
|
+
def get_active_table(self) -> DataFrameTable | None:
|
|
269
328
|
"""Get the currently active DataFrameTable widget.
|
|
270
329
|
|
|
271
330
|
Retrieves the table from the currently active tab. Returns None if no
|
|
@@ -282,23 +341,40 @@ class DataFrameViewer(App):
|
|
|
282
341
|
self.notify("No active table found", title="Locate", severity="error")
|
|
283
342
|
return None
|
|
284
343
|
|
|
285
|
-
def
|
|
286
|
-
"""
|
|
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.
|
|
287
366
|
|
|
288
367
|
Loads the specified file and creates one or more tabs for it. For Excel files,
|
|
289
368
|
creates one tab per sheet. For other formats, creates a single tab.
|
|
290
369
|
|
|
291
370
|
Args:
|
|
292
371
|
filename: Path to the file to load and add as tab(s).
|
|
293
|
-
|
|
294
|
-
Returns:
|
|
295
|
-
None
|
|
296
372
|
"""
|
|
297
373
|
if filename and os.path.exists(filename):
|
|
298
374
|
try:
|
|
299
375
|
n_tab = 0
|
|
300
|
-
for
|
|
301
|
-
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)
|
|
302
378
|
n_tab += 1
|
|
303
379
|
# self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
|
|
304
380
|
except Exception as e:
|
|
@@ -306,7 +382,14 @@ class DataFrameViewer(App):
|
|
|
306
382
|
else:
|
|
307
383
|
self.notify(f"File does not exist: [$warning]{filename}[/]", title="Open", severity="warning")
|
|
308
384
|
|
|
309
|
-
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:
|
|
310
393
|
"""Add new tab for the given DataFrame.
|
|
311
394
|
|
|
312
395
|
Creates and adds a new tab with the provided DataFrame and configuration.
|
|
@@ -317,30 +400,40 @@ class DataFrameViewer(App):
|
|
|
317
400
|
lf: The Polars DataFrame to display in the new tab.
|
|
318
401
|
filename: The source filename for this data (used in table metadata).
|
|
319
402
|
tabname: The display name for the tab.
|
|
320
|
-
|
|
321
|
-
Returns:
|
|
322
|
-
None
|
|
323
403
|
"""
|
|
324
|
-
|
|
325
|
-
counter = 1
|
|
326
|
-
while any(tab.name == tabname for tab in self.tabs):
|
|
327
|
-
tabname = f"{tabname}_{counter}"
|
|
328
|
-
counter += 1
|
|
404
|
+
tabname = self.get_unique_tabname(tabname)
|
|
329
405
|
|
|
330
406
|
# Find an available tab index
|
|
331
|
-
tab_idx = f"
|
|
407
|
+
tab_idx = f"tab-{len(self.tabs) + 1}"
|
|
332
408
|
for idx in range(len(self.tabs)):
|
|
333
|
-
pending_tab_idx = f"
|
|
409
|
+
pending_tab_idx = f"tab-{idx + 1}"
|
|
334
410
|
if any(tab.id == pending_tab_idx for tab in self.tabs):
|
|
335
411
|
continue
|
|
336
412
|
|
|
337
413
|
tab_idx = pending_tab_idx
|
|
338
414
|
break
|
|
339
415
|
|
|
340
|
-
table = DataFrameTable(df, filename, zebra_stripes=True, id=tab_idx
|
|
341
|
-
tab = TabPane(tabname, table,
|
|
342
|
-
self.tabbed.add_pane(tab)
|
|
343
|
-
|
|
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
|
|
344
437
|
|
|
345
438
|
if len(self.tabs) > 1:
|
|
346
439
|
self.query_one(ContentTabs).display = True
|
|
@@ -349,22 +442,146 @@ class DataFrameViewer(App):
|
|
|
349
442
|
self.tabbed.active = tab.id
|
|
350
443
|
table.focus()
|
|
351
444
|
|
|
352
|
-
def
|
|
445
|
+
def do_close_tab(self) -> None:
|
|
353
446
|
"""Close the currently active tab.
|
|
354
447
|
|
|
355
448
|
Removes the active tab from the interface. If only one tab remains and no more
|
|
356
449
|
can be closed, the application exits instead.
|
|
357
|
-
|
|
358
|
-
Returns:
|
|
359
|
-
None
|
|
360
450
|
"""
|
|
361
451
|
try:
|
|
362
|
-
if
|
|
363
|
-
|
|
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
|
+
)
|
|
364
481
|
else:
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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)}")
|
|
370
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")
|