dataframe-textual 2.2.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/__init__.py +60 -0
- dataframe_textual/__main__.py +107 -0
- dataframe_textual/common.py +786 -0
- dataframe_textual/data_frame_help_panel.py +115 -0
- dataframe_textual/data_frame_table.py +3940 -0
- dataframe_textual/data_frame_viewer.py +625 -0
- dataframe_textual/sql_screen.py +238 -0
- dataframe_textual/table_screen.py +527 -0
- dataframe_textual/yes_no_screen.py +752 -0
- dataframe_textual-2.2.1.dist-info/METADATA +846 -0
- dataframe_textual-2.2.1.dist-info/RECORD +14 -0
- dataframe_textual-2.2.1.dist-info/WHEEL +4 -0
- dataframe_textual-2.2.1.dist-info/entry_points.txt +3 -0
- dataframe_textual-2.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
"""DataFrame Viewer application and utilities."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from textwrap import dedent
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.css.query import NoMatches
|
|
10
|
+
from textual.events import Click
|
|
11
|
+
from textual.theme import BUILTIN_THEMES
|
|
12
|
+
from textual.widgets import TabbedContent, TabPane
|
|
13
|
+
from textual.widgets.tabbed_content import ContentTab, ContentTabs
|
|
14
|
+
|
|
15
|
+
from .common import Source, get_next_item, load_file
|
|
16
|
+
from .data_frame_help_panel import DataFrameHelpPanel
|
|
17
|
+
from .data_frame_table import DataFrameTable
|
|
18
|
+
from .yes_no_screen import ConfirmScreen, OpenFileScreen, RenameTabScreen
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DataFrameViewer(App):
|
|
22
|
+
"""A Textual app to interact with multiple Polars DataFrames via tabbed interface."""
|
|
23
|
+
|
|
24
|
+
HELP = dedent("""
|
|
25
|
+
# 📊 DataFrame Viewer - App Controls
|
|
26
|
+
|
|
27
|
+
## 🎯 File & Tab Management
|
|
28
|
+
- **>** - ▶️ Next tab
|
|
29
|
+
- **<** - ◀️ Previous tab
|
|
30
|
+
- **b** - 🔄 Cycle through tabs
|
|
31
|
+
- **B** - 👁️ Toggle tab bar visibility
|
|
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
|
|
42
|
+
|
|
43
|
+
## 🎨 View & Settings
|
|
44
|
+
- **F1** - ❓ Toggle this help panel
|
|
45
|
+
- **k** - 🌙 Cycle through themes
|
|
46
|
+
- **Ctrl+P -> Screenshot** - 📸 Capture terminal view as a SVG image
|
|
47
|
+
|
|
48
|
+
## ⭐ Features
|
|
49
|
+
- **Multi-file support** - 📂 Open multiple CSV/Excel files as tabs
|
|
50
|
+
- **Lazy loading** - ⚡ Large files load on demand
|
|
51
|
+
- **Sticky tabs** - 📌 Tab bar stays visible when scrolling
|
|
52
|
+
- **Unsaved changes** - 🔴 Tabs with unsaved changes have a bright bottom border
|
|
53
|
+
- **Rich formatting** - 🎨 Color-coded data types
|
|
54
|
+
- **Search & filter** - 🔍 Find and filter data quickly
|
|
55
|
+
- **Sort & reorder** - ⬆️ Multi-column sort, reorder rows/columns
|
|
56
|
+
- **Undo/Redo/Reset** - 🔄 Full history of operations
|
|
57
|
+
- **Freeze rows/cols** - 🔒 Pin header rows and columns
|
|
58
|
+
""").strip()
|
|
59
|
+
|
|
60
|
+
BINDINGS = [
|
|
61
|
+
("q", "close_tab", "Close current tab"),
|
|
62
|
+
("Q", "close_all_tabs", "Close all tabs and quit app"),
|
|
63
|
+
("B", "toggle_tab_bar", "Toggle Tab Bar"),
|
|
64
|
+
("f1", "toggle_help_panel", "Help"),
|
|
65
|
+
("ctrl+o", "open_file", "Open File"),
|
|
66
|
+
("ctrl+t", "save_current_tab", "Save Current Tab"),
|
|
67
|
+
("ctrl+a", "save_all_tabs", "Save All Tabs"),
|
|
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"), # '<'
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
CSS = """
|
|
76
|
+
TabbedContent > ContentTabs {
|
|
77
|
+
dock: bottom;
|
|
78
|
+
}
|
|
79
|
+
TabbedContent > ContentSwitcher {
|
|
80
|
+
overflow: auto;
|
|
81
|
+
height: 1fr;
|
|
82
|
+
}
|
|
83
|
+
ContentTab.-active {
|
|
84
|
+
background: $block-cursor-background; /* Same as underline */
|
|
85
|
+
}
|
|
86
|
+
ContentTab.dirty {
|
|
87
|
+
background: $warning-darken-3;
|
|
88
|
+
}
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, *sources: Source) -> None:
|
|
92
|
+
"""Initialize the DataFrame Viewer application.
|
|
93
|
+
|
|
94
|
+
Loads data from provided sources and prepares the tabbed interface.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
sources: sources to load dataframes from, each as a tuple of
|
|
98
|
+
(DataFrame, filename, tabname).
|
|
99
|
+
"""
|
|
100
|
+
super().__init__()
|
|
101
|
+
self.sources = sources
|
|
102
|
+
self.tabs: dict[TabPane, DataFrameTable] = {}
|
|
103
|
+
self.help_panel = None
|
|
104
|
+
|
|
105
|
+
def compose(self) -> ComposeResult:
|
|
106
|
+
"""Compose the application widget structure.
|
|
107
|
+
|
|
108
|
+
Creates a tabbed interface with one tab per file/sheet loaded. Each tab
|
|
109
|
+
contains a DataFrameTable widget for displaying and interacting with the data.
|
|
110
|
+
|
|
111
|
+
Yields:
|
|
112
|
+
TabPane: One tab per file or sheet for the tabbed interface.
|
|
113
|
+
"""
|
|
114
|
+
# Tabbed interface
|
|
115
|
+
self.tabbed = TabbedContent(id="main_tabs")
|
|
116
|
+
with self.tabbed:
|
|
117
|
+
seen_names = set()
|
|
118
|
+
for idx, source in enumerate(self.sources, start=1):
|
|
119
|
+
df, filename, tabname = source.frame, source.filename, source.tabname
|
|
120
|
+
tab_id = f"tab-{idx}"
|
|
121
|
+
|
|
122
|
+
if not tabname:
|
|
123
|
+
tabname = Path(filename).stem or tab_id
|
|
124
|
+
|
|
125
|
+
# Ensure unique tab names
|
|
126
|
+
counter = 1
|
|
127
|
+
while tabname in seen_names:
|
|
128
|
+
tabname = f"{tabname}_{counter}"
|
|
129
|
+
counter += 1
|
|
130
|
+
seen_names.add(tabname)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
table = DataFrameTable(df, filename, tabname=tabname, id=tab_id, zebra_stripes=True)
|
|
134
|
+
tab = TabPane(tabname, table, id=tab_id)
|
|
135
|
+
self.tabs[tab] = table
|
|
136
|
+
yield tab
|
|
137
|
+
except Exception as e:
|
|
138
|
+
self.notify(
|
|
139
|
+
f"Error loading [$error]{filename}[/]: Try [$accent]-I[/] to disable schema inference",
|
|
140
|
+
severity="error",
|
|
141
|
+
)
|
|
142
|
+
self.log(f"Error loading `{filename}`: {str(e)}")
|
|
143
|
+
|
|
144
|
+
def on_mount(self) -> None:
|
|
145
|
+
"""Set up the application when it starts.
|
|
146
|
+
|
|
147
|
+
Initializes the app by hiding the tab bar for single-file mode and focusing
|
|
148
|
+
the active table widget.
|
|
149
|
+
"""
|
|
150
|
+
if len(self.tabs) == 1:
|
|
151
|
+
self.query_one(ContentTabs).display = False
|
|
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
|
|
158
|
+
|
|
159
|
+
def on_key(self, event) -> None:
|
|
160
|
+
"""Handle key press events at the application level.
|
|
161
|
+
|
|
162
|
+
Currently handles theme cycling with the 'k' key.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
event: The key event object containing key information.
|
|
166
|
+
"""
|
|
167
|
+
if event.key == "k":
|
|
168
|
+
self.theme = get_next_item(list(BUILTIN_THEMES.keys()), self.theme)
|
|
169
|
+
self.notify(f"Switched to theme: [$success]{self.theme}[/]", title="Theme")
|
|
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
|
+
|
|
192
|
+
def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
|
|
193
|
+
"""Handle tab activation events.
|
|
194
|
+
|
|
195
|
+
When a tab is activated, focuses the table widget and loads its data if not already loaded.
|
|
196
|
+
Applies active styling to the clicked tab and removes it from others.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
event: The tab activated event containing the activated tab pane.
|
|
200
|
+
"""
|
|
201
|
+
# Focus the table in the newly activated tab
|
|
202
|
+
if table := self.get_active_table():
|
|
203
|
+
table.focus()
|
|
204
|
+
else:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
if table.loaded_rows == 0:
|
|
208
|
+
table.setup_table()
|
|
209
|
+
|
|
210
|
+
def action_toggle_help_panel(self) -> None:
|
|
211
|
+
"""Toggle the help panel on or off.
|
|
212
|
+
|
|
213
|
+
Shows or hides the context-sensitive help panel. Creates it on first use.
|
|
214
|
+
"""
|
|
215
|
+
if self.help_panel:
|
|
216
|
+
self.help_panel.display = not self.help_panel.display
|
|
217
|
+
else:
|
|
218
|
+
self.help_panel = DataFrameHelpPanel()
|
|
219
|
+
self.mount(self.help_panel)
|
|
220
|
+
|
|
221
|
+
def action_open_file(self) -> None:
|
|
222
|
+
"""Open file browser to load a file in a new tab.
|
|
223
|
+
|
|
224
|
+
Displays the file open dialog for the user to select a file to load
|
|
225
|
+
as a new tab in the interface.
|
|
226
|
+
"""
|
|
227
|
+
self.push_screen(OpenFileScreen(), self.do_open_file)
|
|
228
|
+
|
|
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.
|
|
234
|
+
"""
|
|
235
|
+
self.do_close_tab()
|
|
236
|
+
|
|
237
|
+
def action_close_all_tabs(self) -> None:
|
|
238
|
+
"""Close all tabs and exit the app.
|
|
239
|
+
|
|
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()
|
|
244
|
+
|
|
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.
|
|
249
|
+
"""
|
|
250
|
+
if table := self.get_active_table():
|
|
251
|
+
table.do_save_to_file(all_tabs=False)
|
|
252
|
+
|
|
253
|
+
def action_save_all_tabs(self) -> None:
|
|
254
|
+
"""Save all open tabs to their respective files.
|
|
255
|
+
|
|
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"
|
|
276
|
+
|
|
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.
|
|
284
|
+
"""
|
|
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()):
|
|
294
|
+
return
|
|
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
|
|
324
|
+
|
|
325
|
+
def action_next_tab(self, offset: int = 1) -> None:
|
|
326
|
+
"""Switch to the next tab or previous tab.
|
|
327
|
+
|
|
328
|
+
Cycles through tabs by the specified offset. With offset=1, moves to next tab.
|
|
329
|
+
With offset=-1, moves to previous tab. Wraps around when reaching edges.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
offset: Number of tabs to advance (+1 for next, -1 for previous). Defaults to 1.
|
|
333
|
+
"""
|
|
334
|
+
self.do_next_tab(offset)
|
|
335
|
+
|
|
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.
|
|
344
|
+
"""
|
|
345
|
+
if len(self.tabs) <= 1:
|
|
346
|
+
return
|
|
347
|
+
try:
|
|
348
|
+
tabs: list[TabPane] = list(self.tabs.keys())
|
|
349
|
+
next_tab = get_next_item(tabs, self.tabbed.active_pane, offset)
|
|
350
|
+
self.tabbed.active = next_tab.id
|
|
351
|
+
except (NoMatches, ValueError):
|
|
352
|
+
pass
|
|
353
|
+
|
|
354
|
+
def action_toggle_tab_bar(self) -> None:
|
|
355
|
+
"""Toggle the tab bar visibility.
|
|
356
|
+
|
|
357
|
+
Shows or hides the tab bar at the bottom of the window. Useful for maximizing
|
|
358
|
+
screen space in single-tab mode.
|
|
359
|
+
"""
|
|
360
|
+
tabs = self.query_one(ContentTabs)
|
|
361
|
+
tabs.display = not tabs.display
|
|
362
|
+
# status = "shown" if tabs.display else "hidden"
|
|
363
|
+
# self.notify(f"Tab bar [$success]{status}[/]", title="Toggle")
|
|
364
|
+
|
|
365
|
+
def get_active_table(self) -> DataFrameTable | None:
|
|
366
|
+
"""Get the currently active DataFrameTable widget.
|
|
367
|
+
|
|
368
|
+
Retrieves the table from the currently active tab. Returns None if no
|
|
369
|
+
table is found or an error occurs.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
The active DataFrameTable widget, or None if not found.
|
|
373
|
+
"""
|
|
374
|
+
try:
|
|
375
|
+
tabbed: TabbedContent = self.query_one(TabbedContent)
|
|
376
|
+
if active_pane := tabbed.active_pane:
|
|
377
|
+
return active_pane.query_one(DataFrameTable)
|
|
378
|
+
except (NoMatches, AttributeError):
|
|
379
|
+
self.notify("No active table found", title="Locate", severity="error")
|
|
380
|
+
return None
|
|
381
|
+
|
|
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.
|
|
404
|
+
|
|
405
|
+
Loads the specified file and creates one or more tabs for it. For Excel files,
|
|
406
|
+
creates one tab per sheet. For other formats, creates a single tab.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
filename: Path to the file to load and add as tab(s).
|
|
410
|
+
"""
|
|
411
|
+
if filename and os.path.exists(filename):
|
|
412
|
+
try:
|
|
413
|
+
n_tab = 0
|
|
414
|
+
for source in load_file(filename, prefix_sheet=True):
|
|
415
|
+
self.add_tab(source.frame, filename, source.tabname, after=self.tabbed.active_pane)
|
|
416
|
+
n_tab += 1
|
|
417
|
+
# self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
|
|
418
|
+
except Exception as e:
|
|
419
|
+
self.notify(f"Error loading [$error]{filename}[/]: {str(e)}", title="Open", severity="error")
|
|
420
|
+
else:
|
|
421
|
+
self.notify(f"File does not exist: [$warning]{filename}[/]", title="Open", severity="warning")
|
|
422
|
+
|
|
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:
|
|
431
|
+
"""Add new tab for the given DataFrame.
|
|
432
|
+
|
|
433
|
+
Creates and adds a new tab with the provided DataFrame and configuration.
|
|
434
|
+
Ensures unique tab names by appending an index if needed. Shows the tab bar
|
|
435
|
+
if this is no longer the only tab.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
lf: The Polars DataFrame to display in the new tab.
|
|
439
|
+
filename: The source filename for this data (used in table metadata).
|
|
440
|
+
tabname: The display name for the tab.
|
|
441
|
+
"""
|
|
442
|
+
tabname = self.get_unique_tabname(tabname)
|
|
443
|
+
|
|
444
|
+
# Find an available tab index
|
|
445
|
+
tab_idx = f"tab-{len(self.tabs) + 1}"
|
|
446
|
+
for idx in range(len(self.tabs)):
|
|
447
|
+
pending_tab_idx = f"tab-{idx + 1}"
|
|
448
|
+
if any(tab.id == pending_tab_idx for tab in self.tabs):
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
tab_idx = pending_tab_idx
|
|
452
|
+
break
|
|
453
|
+
|
|
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
|
|
475
|
+
|
|
476
|
+
if len(self.tabs) > 1:
|
|
477
|
+
self.query_one(ContentTabs).display = True
|
|
478
|
+
|
|
479
|
+
# Activate the new tab
|
|
480
|
+
self.tabbed.active = tab.id
|
|
481
|
+
table.focus()
|
|
482
|
+
|
|
483
|
+
def do_close_tab(self) -> None:
|
|
484
|
+
"""Close the currently active tab.
|
|
485
|
+
|
|
486
|
+
Removes the active tab from the interface. If only one tab remains and no more
|
|
487
|
+
can be closed, the application exits instead.
|
|
488
|
+
"""
|
|
489
|
+
try:
|
|
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
|
+
)
|
|
519
|
+
else:
|
|
520
|
+
# No unsaved changes - close immediately
|
|
521
|
+
self.close_tab()
|
|
522
|
+
except Exception:
|
|
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")
|