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.
@@ -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")