dataframe-textual 0.3.2__py3-none-any.whl → 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
1
  """DataFrame Viewer application and utilities."""
2
2
 
3
3
  import os
4
- import sys
5
4
  from functools import partial
6
5
  from pathlib import Path
7
6
  from textwrap import dedent
@@ -11,9 +10,9 @@ from textual.app import App, ComposeResult
11
10
  from textual.css.query import NoMatches
12
11
  from textual.theme import BUILTIN_THEMES
13
12
  from textual.widgets import TabbedContent, TabPane
14
- from textual.widgets.tabbed_content import ContentTab, ContentTabs
13
+ from textual.widgets.tabbed_content import ContentTabs
15
14
 
16
- from .common import _next
15
+ from .common import Source, get_next_item, load_file
17
16
  from .data_frame_help_panel import DataFrameHelpPanel
18
17
  from .data_frame_table import DataFrameTable
19
18
  from .yes_no_screen import OpenFileScreen, SaveFileScreen
@@ -27,7 +26,7 @@ class DataFrameViewer(App):
27
26
 
28
27
  ## 🎯 File & Tab Management
29
28
  - **Ctrl+O** - 📁 Add a new tab
30
- - **Ctrl+Shift+S** - 💾 Save all tabs
29
+ - **Ctrl+A** - 💾 Save all tabs
31
30
  - **Ctrl+W** - ❌ Close current tab
32
31
  - **>** or **b** - ▶️ Next tab
33
32
  - **<** - ◀️ Previous tab
@@ -35,7 +34,7 @@ class DataFrameViewer(App):
35
34
  - **q** - 🚪 Quit application
36
35
 
37
36
  ## 🎨 View & Settings
38
- - **?** or **h** - ❓ Toggle this help panel
37
+ - **F1** - ❓ Toggle this help panel
39
38
  - **k** - 🌙 Cycle through themes
40
39
 
41
40
  ## ⭐ Features
@@ -52,108 +51,141 @@ class DataFrameViewer(App):
52
51
 
53
52
  BINDINGS = [
54
53
  ("q", "quit", "Quit"),
55
- ("h,?", "toggle_help_panel", "Help"),
54
+ ("f1", "toggle_help_panel", "Help"),
56
55
  ("B", "toggle_tab_bar", "Toggle Tab Bar"),
57
56
  ("ctrl+o", "add_tab", "Add Tab"),
58
- ("ctrl+shift+s", "save_all_tabs", "Save All Tabs"),
57
+ ("ctrl+a", "save_all_tabs", "Save All Tabs"),
59
58
  ("ctrl+w", "close_tab", "Close Tab"),
60
59
  ("greater_than_sign,b", "next_tab(1)", "Next Tab"),
61
60
  ("less_than_sign", "next_tab(-1)", "Prev Tab"),
62
61
  ]
63
62
 
64
63
  CSS = """
65
- TabbedContent {
66
- height: 100%; /* Or a specific value, e.g., 20; */
67
- }
68
64
  TabbedContent > ContentTabs {
69
65
  dock: bottom;
70
66
  }
71
67
  TabbedContent > ContentSwitcher {
72
68
  overflow: auto;
73
- height: 1fr; /* Takes the remaining space below tabs */
69
+ height: 1fr;
74
70
  }
75
-
76
- TabbedContent ContentTab.active {
77
- background: $primary;
78
- color: $text;
71
+ ContentTab.-active {
72
+ background: $block-cursor-background; /* Same as underline */
79
73
  }
80
74
  """
81
75
 
82
- def __init__(self, *filenames):
76
+ def __init__(self, *sources: Source) -> None:
77
+ """Initialize the DataFrame Viewer application.
78
+
79
+ Loads data from provided sources and prepares the tabbed interface.
80
+
81
+ Args:
82
+ sources: sources to load dataframes from, each as a tuple of
83
+ (DataFrame, filename, tabname).
84
+
85
+ Returns:
86
+ None
87
+ """
83
88
  super().__init__()
84
- self.sources = _load_dataframe(filenames)
89
+ self.sources = sources
85
90
  self.tabs: dict[TabPane, DataFrameTable] = {}
86
91
  self.help_panel = None
87
92
 
88
93
  def compose(self) -> ComposeResult:
89
- """Create tabbed interface for multiple files or direct table for single file."""
94
+ """Compose the application widget structure.
95
+
96
+ Creates a tabbed interface with one tab per file/sheet loaded. Each tab
97
+ contains a DataFrameTable widget for displaying and interacting with the data.
98
+
99
+ Yields:
100
+ TabPane: One tab per file or sheet for the tabbed interface.
101
+ """
90
102
  # Tabbed interface
91
103
  self.tabbed = TabbedContent(id="main_tabs")
92
104
  with self.tabbed:
93
105
  seen_names = set()
94
- for idx, (df, filename, tabname) in enumerate(self.sources, start=1):
106
+ for idx, source in enumerate(self.sources, start=1):
107
+ df, filename, tabname = source.frame, source.filename, source.tabname
108
+ tab_id = f"tab_{idx}"
109
+
110
+ if not tabname:
111
+ tabname = Path(filename).stem or tab_id
112
+
95
113
  # Ensure unique tab names
96
- if tabname in seen_names:
97
- tabname = f"{tabname}_{idx}"
114
+ counter = 1
115
+ while tabname in seen_names:
116
+ tabname = f"{tabname}_{counter}"
117
+ counter += 1
98
118
  seen_names.add(tabname)
99
119
 
100
- tab_id = f"tab_{idx}"
101
120
  try:
102
- table = DataFrameTable(
103
- df, filename, name=tabname, id=tab_id, zebra_stripes=True
104
- )
121
+ table = DataFrameTable(df, filename, name=tabname, id=tab_id, zebra_stripes=True)
105
122
  tab = TabPane(tabname, table, name=tabname, id=tab_id)
106
123
  self.tabs[tab] = table
107
124
  yield tab
108
125
  except Exception as e:
109
- self.notify(f"Error loading {tabname}: {e}", severity="error")
126
+ self.notify(
127
+ f"Error loading [$error]{filename}[/]: Try [$accent]-I[/] to disable schema inference",
128
+ severity="error",
129
+ )
130
+ self.log(f"Error loading `{filename}`: {str(e)}")
110
131
 
111
132
  def on_mount(self) -> None:
112
- """Set up the app when it starts."""
133
+ """Set up the application when it starts.
134
+
135
+ Initializes the app by hiding the tab bar for single-file mode and focusing
136
+ the active table widget.
137
+
138
+ Returns:
139
+ None
140
+ """
113
141
  if len(self.tabs) == 1:
114
142
  self.query_one(ContentTabs).display = False
115
143
  self._get_active_table().focus()
116
144
 
117
- def on_key(self, event):
145
+ def on_key(self, event) -> None:
146
+ """Handle key press events at the application level.
147
+
148
+ Currently handles theme cycling with the 'k' key.
149
+
150
+ Args:
151
+ event: The key event object containing key information.
152
+
153
+ Returns:
154
+ None
155
+ """
118
156
  if event.key == "k":
119
- self.theme = _next(list(BUILTIN_THEMES.keys()), self.theme)
120
- self.notify(
121
- f"Switched to theme: [on $primary]{self.theme}[/]", title="Theme"
122
- )
123
-
124
- def on_tabbed_content_tab_activated(
125
- self, event: TabbedContent.TabActivated
126
- ) -> None:
127
- """Handle tab changes (only for multiple tabs)."""
128
- # Only process if we have multiple files
129
- if len(self.tabs) <= 1:
130
- return
157
+ self.theme = get_next_item(list(BUILTIN_THEMES.keys()), self.theme)
158
+ self.notify(f"Switched to theme: [$success]{self.theme}[/]", title="Theme")
131
159
 
132
- # Apply background color to active tab
133
- event.tab.add_class("active")
134
- for tab in self.tabbed.query(ContentTab):
135
- if tab != event.tab:
136
- tab.remove_class("active")
160
+ def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
161
+ """Handle tab activation events.
137
162
 
138
- try:
139
- # Focus the table in the newly activated tab
140
- if table := self._get_active_table():
141
- table.focus()
142
- except NoMatches:
143
- pass
163
+ When a tab is activated, focuses the table widget and loads its data if not already loaded.
164
+ Applies active styling to the clicked tab and removes it from others.
144
165
 
145
- def _get_active_table(self) -> DataFrameTable | None:
146
- """Get the currently active table."""
147
- try:
148
- tabbed: TabbedContent = self.query_one(TabbedContent)
149
- if active_pane := tabbed.active_pane:
150
- return active_pane.query_one(DataFrameTable)
151
- except (NoMatches, AttributeError):
152
- pass
153
- return None
166
+ Args:
167
+ event: The tab activated event containing the activated tab pane.
168
+
169
+ Returns:
170
+ None
171
+ """
172
+ # Focus the table in the newly activated tab
173
+ if table := self._get_active_table():
174
+ table.focus()
175
+ else:
176
+ return
177
+
178
+ if table.loaded_rows == 0:
179
+ table._setup_table()
154
180
 
155
181
  def action_toggle_help_panel(self) -> None:
156
- """Toggle the HelpPanel on/off."""
182
+ """Toggle the help panel on or off.
183
+
184
+ Shows or hides the context-sensitive help panel. Creates it on first use.
185
+
186
+ Returns:
187
+ None
188
+ """
157
189
  if self.help_panel:
158
190
  self.help_panel.display = not self.help_panel.display
159
191
  else:
@@ -161,52 +193,140 @@ class DataFrameViewer(App):
161
193
  self.mount(self.help_panel)
162
194
 
163
195
  def action_add_tab(self) -> None:
164
- """Open file dialog to load file to new tab."""
165
- self.push_screen(OpenFileScreen(), self._handle_file_open)
196
+ """Open file browser to load a file in a new tab.
166
197
 
167
- def _handle_file_open(self, filename: str) -> None:
168
- """Handle file selection from dialog."""
169
- if filename and os.path.exists(filename):
170
- try:
171
- df = pl.read_csv(filename)
172
- self._add_tab(df, filename)
173
- self.notify(
174
- f"Opened: [on $primary]{Path(filename).name}[/]", title="Open"
175
- )
176
- except Exception as e:
177
- self.notify(f"Error: {e}", severity="error")
198
+ Displays the file open dialog for the user to select a file to load
199
+ as a new tab in the interface.
200
+
201
+ Returns:
202
+ None
203
+ """
204
+ self.push_screen(OpenFileScreen(), self._do_add_tab)
178
205
 
179
206
  def action_save_all_tabs(self) -> None:
180
- """Save all tabs to a Excel file."""
181
- callback = partial(self._get_active_table()._on_save_file_screen, all_tabs=True)
207
+ """Save all open tabs to a single Excel file.
208
+
209
+ Displays a save dialog to choose filename and location, then saves all
210
+ open tabs as separate sheets in a single Excel workbook.
211
+
212
+ Returns:
213
+ None
214
+ """
215
+ callback = partial(self._get_active_table()._do_save_file, all_tabs=True)
182
216
  self.push_screen(
183
217
  SaveFileScreen("all-tabs.xlsx", title="Save All Tabs"),
184
218
  callback=callback,
185
219
  )
186
220
 
187
221
  def action_close_tab(self) -> None:
188
- """Close current tab (only for multiple files)."""
222
+ """Close the currently active tab.
223
+
224
+ Closes the current tab. If this is the only tab, exits the application instead.
225
+
226
+ Returns:
227
+ None
228
+ """
189
229
  if len(self.tabs) <= 1:
190
230
  self.app.exit()
191
231
  return
192
232
  self._close_tab()
193
233
 
194
- def action_next_tab(self, offset: int = 1) -> str:
195
- """Switch to next tab (only for multiple files)."""
234
+ def action_next_tab(self, offset: int = 1) -> None:
235
+ """Switch to the next tab or previous tab.
236
+
237
+ Cycles through tabs by the specified offset. With offset=1, moves to next tab.
238
+ With offset=-1, moves to previous tab. Wraps around when reaching edges.
239
+
240
+ Args:
241
+ offset: Number of tabs to advance (+1 for next, -1 for previous). Defaults to 1.
242
+
243
+ Returns:
244
+ None
245
+ """
196
246
  if len(self.tabs) <= 1:
197
247
  return
198
248
  try:
199
249
  tabs: list[TabPane] = list(self.tabs.keys())
200
- next_tab = _next(tabs, self.tabbed.active_pane, offset)
250
+ next_tab = get_next_item(tabs, self.tabbed.active_pane, offset)
201
251
  self.tabbed.active = next_tab.id
202
252
  except (NoMatches, ValueError):
203
253
  pass
204
254
 
205
- def _add_tab(self, df: pl.DataFrame, filename: str) -> None:
206
- """Add new table tab. If single file, replace table; if multiple, add tab."""
207
- tabname = Path(filename).stem
208
- if any(tab.name == tabname for tab in self.tabs):
209
- tabname = f"{tabname}_{len(self.tabs) + 1}"
255
+ def action_toggle_tab_bar(self) -> None:
256
+ """Toggle the tab bar visibility.
257
+
258
+ Shows or hides the tab bar at the bottom of the window. Useful for maximizing
259
+ screen space in single-tab mode.
260
+
261
+ Returns:
262
+ None
263
+ """
264
+ tabs = self.query_one(ContentTabs)
265
+ tabs.display = not tabs.display
266
+ # status = "shown" if tabs.display else "hidden"
267
+ # self.notify(f"Tab bar [$success]{status}[/]", title="Toggle")
268
+
269
+ def _get_active_table(self) -> DataFrameTable | None:
270
+ """Get the currently active DataFrameTable widget.
271
+
272
+ Retrieves the table from the currently active tab. Returns None if no
273
+ table is found or an error occurs.
274
+
275
+ Returns:
276
+ The active DataFrameTable widget, or None if not found.
277
+ """
278
+ try:
279
+ tabbed: TabbedContent = self.query_one(TabbedContent)
280
+ if active_pane := tabbed.active_pane:
281
+ return active_pane.query_one(DataFrameTable)
282
+ except (NoMatches, AttributeError):
283
+ self.notify("No active table found", title="Locate", severity="error")
284
+ return None
285
+
286
+ def _do_add_tab(self, filename: str) -> None:
287
+ """Add a tab for the opened file.
288
+
289
+ Loads the specified file and creates one or more tabs for it. For Excel files,
290
+ creates one tab per sheet. For other formats, creates a single tab.
291
+
292
+ Args:
293
+ filename: Path to the file to load and add as tab(s).
294
+
295
+ Returns:
296
+ None
297
+ """
298
+ if filename and os.path.exists(filename):
299
+ try:
300
+ n_tab = 0
301
+ for lf, filename, tabname in load_file(filename, prefix_sheet=True):
302
+ self._add_tab(lf, filename, tabname)
303
+ n_tab += 1
304
+ # self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
305
+ except Exception as e:
306
+ self.notify(f"Error loading [$error]{filename}[/]: {str(e)}", title="Open", severity="error")
307
+ else:
308
+ self.notify(f"File does not exist: [$warning]{filename}[/]", title="Open", severity="warning")
309
+
310
+ def _add_tab(self, df: pl.DataFrame, filename: str, tabname: str) -> None:
311
+ """Add new tab for the given DataFrame.
312
+
313
+ Creates and adds a new tab with the provided DataFrame and configuration.
314
+ Ensures unique tab names by appending an index if needed. Shows the tab bar
315
+ if this is no longer the only tab.
316
+
317
+ Args:
318
+ lf: The Polars DataFrame to display in the new tab.
319
+ filename: The source filename for this data (used in table metadata).
320
+ tabname: The display name for the tab.
321
+
322
+ Returns:
323
+ None
324
+ """
325
+ # Ensure unique tab names
326
+ counter = 1
327
+ while any(tab.name == tabname for tab in self.tabs):
328
+ tabname = f"{tabname}_{counter}"
329
+ counter += 1
210
330
 
211
331
  # Find an available tab index
212
332
  tab_idx = f"tab_{len(self.tabs) + 1}"
@@ -218,9 +338,7 @@ class DataFrameViewer(App):
218
338
  tab_idx = pending_tab_idx
219
339
  break
220
340
 
221
- table = DataFrameTable(
222
- df, filename, zebra_stripes=True, id=tab_idx, name=tabname
223
- )
341
+ table = DataFrameTable(df, filename, zebra_stripes=True, id=tab_idx, name=tabname)
224
342
  tab = TabPane(tabname, table, name=tabname, id=tab_idx)
225
343
  self.tabbed.add_pane(tab)
226
344
  self.tabs[tab] = table
@@ -233,7 +351,14 @@ class DataFrameViewer(App):
233
351
  table.focus()
234
352
 
235
353
  def _close_tab(self) -> None:
236
- """Close current tab."""
354
+ """Close the currently active tab.
355
+
356
+ Removes the active tab from the interface. If only one tab remains and no more
357
+ can be closed, the application exits instead.
358
+
359
+ Returns:
360
+ None
361
+ """
237
362
  try:
238
363
  if len(self.tabs) == 1:
239
364
  self.app.exit()
@@ -241,95 +366,6 @@ class DataFrameViewer(App):
241
366
  if active_pane := self.tabbed.active_pane:
242
367
  self.tabbed.remove_pane(active_pane.id)
243
368
  self.tabs.pop(active_pane)
244
- self.notify(
245
- f"Closed tab [on $primary]{active_pane.name}[/]", title="Close"
246
- )
369
+ # self.notify(f"Closed tab [$success]{active_pane.name}[/]", title="Close")
247
370
  except NoMatches:
248
371
  pass
249
-
250
- def action_toggle_tab_bar(self) -> None:
251
- """Toggle tab bar visibility."""
252
- tabs = self.query_one(ContentTabs)
253
- tabs.display = not tabs.display
254
- status = "shown" if tabs.display else "hidden"
255
- self.notify(f"Tab bar [on $primary]{status}[/]", title="Toggle")
256
-
257
-
258
- def _load_dataframe(filenames: list[str]) -> list[tuple[pl.DataFrame, str, str]]:
259
- """Load a DataFrame from a file spec.
260
-
261
- Args:
262
- filenames: List of filenames to load. If single filename is "-", read from stdin.
263
-
264
- Returns:
265
- List of tuples of (DataFrame, filename, tabname)
266
- """
267
- sources = []
268
-
269
- # Single file
270
- if len(filenames) == 1:
271
- filename = filenames[0]
272
- filepath = Path(filename)
273
- ext = filepath.suffix.lower()
274
-
275
- # Handle stdin
276
- if filename == "-" or not sys.stdin.isatty():
277
- from io import StringIO
278
-
279
- # Read CSV from stdin into memory first (stdin is not seekable)
280
- stdin_data = sys.stdin.read()
281
- df = pl.read_csv(StringIO(stdin_data))
282
-
283
- # Reopen stdin to /dev/tty for proper terminal interaction
284
- try:
285
- tty = open("/dev/tty")
286
- os.dup2(tty.fileno(), sys.stdin.fileno())
287
- except (OSError, FileNotFoundError):
288
- pass
289
-
290
- sources.append((df, "stdin.csv", "stdin"))
291
- # Handle Excel files with multiple sheets
292
- elif ext in (".xlsx", ".xls"):
293
- sheets = pl.read_excel(filename, sheet_id=0)
294
- for sheet_name, df in sheets.items():
295
- sources.append((df, filename, sheet_name))
296
- # Handle TSV files
297
- elif ext in (".tsv", ".tab"):
298
- df = pl.read_csv(filename, separator="\t")
299
- sources.append((df, filename, filepath.stem))
300
- # Handle JSON files
301
- elif ext == ".json":
302
- df = pl.read_json(filename)
303
- sources.append((df, filename, filepath.stem))
304
- # Handle Parquet files
305
- elif ext == ".parquet":
306
- df = pl.read_parquet(filename)
307
- sources.append((df, filename, filepath.stem))
308
- # Handle regular CSV files
309
- else:
310
- df = pl.read_csv(filename)
311
- sources.append((df, filename, filepath.stem))
312
- # Multiple files
313
- else:
314
- for filename in filenames:
315
- filepath = Path(filename)
316
- ext = filepath.suffix.lower()
317
-
318
- if ext in (".xlsx", ".xls"):
319
- # Read only the first sheet for multiple files
320
- df = pl.read_excel(filename)
321
- sources.append((df, filename, filepath.stem))
322
- elif ext in (".tsv", ".tab"):
323
- df = pl.read_csv(filename, separator="\t")
324
- sources.append((df, filename, filepath.stem))
325
- elif ext == ".json":
326
- df = pl.read_json(filename)
327
- sources.append((df, filename, filepath.stem))
328
- elif ext == ".parquet":
329
- df = pl.read_parquet(filename)
330
- sources.append((df, filename, filepath.stem))
331
- else:
332
- df = pl.read_csv(filename)
333
- sources.append((df, filename, filepath.stem))
334
-
335
- return sources