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