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.
- dataframe_textual/__init__.py +27 -1
- dataframe_textual/__main__.py +14 -3
- dataframe_textual/common.py +154 -59
- dataframe_textual/data_frame_help_panel.py +0 -3
- dataframe_textual/data_frame_table.py +1910 -1238
- dataframe_textual/data_frame_viewer.py +354 -100
- dataframe_textual/sql_screen.py +56 -20
- dataframe_textual/table_screen.py +164 -144
- dataframe_textual/yes_no_screen.py +90 -34
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-2.2.2.dist-info}/METADATA +275 -416
- dataframe_textual-2.2.2.dist-info/RECORD +14 -0
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-2.2.2.dist-info}/WHEEL +1 -1
- dataframe_textual-1.5.0.dist-info/RECORD +0 -14
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-2.2.2.dist-info}/entry_points.txt +0 -0
- {dataframe_textual-1.5.0.dist-info → dataframe_textual-2.2.2.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,39 +25,51 @@ 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
|
+
- **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,
|
|
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", "
|
|
54
|
-
("
|
|
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
|
-
("
|
|
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
|
-
("
|
|
59
|
-
("
|
|
60
|
-
("
|
|
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"
|
|
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,
|
|
122
|
-
tab = TabPane(tabname, table,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
202
|
-
|
|
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.
|
|
235
|
+
self.do_close_tab()
|
|
205
236
|
|
|
206
|
-
def
|
|
207
|
-
"""
|
|
237
|
+
def action_close_all_tabs(self) -> None:
|
|
238
|
+
"""Close all tabs and exit the app.
|
|
208
239
|
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
222
|
-
"""
|
|
253
|
+
def action_save_all_tabs(self) -> None:
|
|
254
|
+
"""Save all open tabs to their respective files.
|
|
223
255
|
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
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
|
|
287
|
-
"""
|
|
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
|
|
302
|
-
self.
|
|
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
|
|
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
|
-
|
|
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"
|
|
445
|
+
tab_idx = f"tab-{len(self.tabs) + 1}"
|
|
333
446
|
for idx in range(len(self.tabs)):
|
|
334
|
-
pending_tab_idx = f"
|
|
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
|
|
342
|
-
tab = TabPane(tabname, table,
|
|
343
|
-
self.tabbed.add_pane(tab)
|
|
344
|
-
|
|
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
|
|
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
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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")
|