dataframe-textual 0.3.2__py3-none-any.whl → 1.2.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
@@ -13,7 +12,7 @@ from textual.theme import BUILTIN_THEMES
13
12
  from textual.widgets import TabbedContent, TabPane
14
13
  from textual.widgets.tabbed_content import ContentTab, ContentTabs
15
14
 
16
- from .common import _next
15
+ from .common import 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
@@ -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
+ - **Ctrl+H** - ❓ Toggle this help panel
39
38
  - **k** - 🌙 Cycle through themes
40
39
 
41
40
  ## ⭐ Features
@@ -52,7 +51,7 @@ class DataFrameViewer(App):
52
51
 
53
52
  BINDINGS = [
54
53
  ("q", "quit", "Quit"),
55
- ("h,?", "toggle_help_panel", "Help"),
54
+ ("ctrl+h", "toggle_help_panel", "Help"),
56
55
  ("B", "toggle_tab_bar", "Toggle Tab Bar"),
57
56
  ("ctrl+o", "add_tab", "Add Tab"),
58
57
  ("ctrl+shift+s", "save_all_tabs", "Save All Tabs"),
@@ -79,29 +78,51 @@ class DataFrameViewer(App):
79
78
  }
80
79
  """
81
80
 
82
- def __init__(self, *filenames):
81
+ def __init__(self, *sources: str) -> None:
82
+ """Initialize the DataFrame Viewer application.
83
+
84
+ Loads data from provided sources and prepares the tabbed interface.
85
+
86
+ Args:
87
+ sources: sources to load dataframes from, each as a tuple of
88
+ (DataFrame | LazyFrame, filename, tabname).
89
+
90
+ Returns:
91
+ None
92
+ """
83
93
  super().__init__()
84
- self.sources = _load_dataframe(filenames)
94
+ self.sources = sources
85
95
  self.tabs: dict[TabPane, DataFrameTable] = {}
86
96
  self.help_panel = None
87
97
 
88
98
  def compose(self) -> ComposeResult:
89
- """Create tabbed interface for multiple files or direct table for single file."""
99
+ """Compose the application widget structure.
100
+
101
+ Creates a tabbed interface with one tab per file/sheet loaded. Each tab
102
+ contains a DataFrameTable widget for displaying and interacting with the data.
103
+
104
+ Yields:
105
+ TabPane: One tab per file or sheet for the tabbed interface.
106
+ """
90
107
  # Tabbed interface
91
108
  self.tabbed = TabbedContent(id="main_tabs")
92
109
  with self.tabbed:
93
110
  seen_names = set()
94
111
  for idx, (df, filename, tabname) in enumerate(self.sources, start=1):
112
+ tab_id = f"tab_{idx}"
113
+
114
+ if not tabname:
115
+ tabname = Path(filename).stem or tab_id
116
+
95
117
  # Ensure unique tab names
96
- if tabname in seen_names:
97
- tabname = f"{tabname}_{idx}"
118
+ counter = 1
119
+ while tabname in seen_names:
120
+ tabname = f"{tabname}_{counter}"
121
+ counter += 1
98
122
  seen_names.add(tabname)
99
123
 
100
- tab_id = f"tab_{idx}"
101
124
  try:
102
- table = DataFrameTable(
103
- df, filename, name=tabname, id=tab_id, zebra_stripes=True
104
- )
125
+ table = DataFrameTable(df, filename, name=tabname, id=tab_id, zebra_stripes=True)
105
126
  tab = TabPane(tabname, table, name=tabname, id=tab_id)
106
127
  self.tabs[tab] = table
107
128
  yield tab
@@ -109,51 +130,68 @@ class DataFrameViewer(App):
109
130
  self.notify(f"Error loading {tabname}: {e}", severity="error")
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:
157
+ self.theme = get_next_item(list(BUILTIN_THEMES.keys()), self.theme)
158
+ self.notify(f"Switched to theme: [$success]{self.theme}[/]", title="Theme")
159
+
160
+ def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
161
+ """Handle tab activation events.
162
+
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.
165
+
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:
130
176
  return
131
177
 
178
+ if table.loaded_rows == 0:
179
+ table._setup_table()
180
+
132
181
  # Apply background color to active tab
133
182
  event.tab.add_class("active")
134
183
  for tab in self.tabbed.query(ContentTab):
135
184
  if tab != event.tab:
136
185
  tab.remove_class("active")
137
186
 
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
187
+ def action_toggle_help_panel(self) -> None:
188
+ """Toggle the help panel on or off.
144
189
 
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
190
+ Shows or hides the context-sensitive help panel. Creates it on first use.
154
191
 
155
- def action_toggle_help_panel(self) -> None:
156
- """Toggle the HelpPanel on/off."""
192
+ Returns:
193
+ None
194
+ """
157
195
  if self.help_panel:
158
196
  self.help_panel.display = not self.help_panel.display
159
197
  else:
@@ -161,50 +199,135 @@ class DataFrameViewer(App):
161
199
  self.mount(self.help_panel)
162
200
 
163
201
  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)
202
+ """Open file browser to load a file in a new tab.
166
203
 
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")
204
+ Displays the file open dialog for the user to select a file to load
205
+ as a new tab in the interface.
206
+
207
+ Returns:
208
+ None
209
+ """
210
+ self.push_screen(OpenFileScreen(), self._do_add_tab)
178
211
 
179
212
  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)
213
+ """Save all open tabs to a single Excel file.
214
+
215
+ Displays a save dialog to choose filename and location, then saves all
216
+ open tabs as separate sheets in a single Excel workbook.
217
+
218
+ Returns:
219
+ None
220
+ """
221
+ callback = partial(self._get_active_table()._do_save_file, all_tabs=True)
182
222
  self.push_screen(
183
223
  SaveFileScreen("all-tabs.xlsx", title="Save All Tabs"),
184
224
  callback=callback,
185
225
  )
186
226
 
187
227
  def action_close_tab(self) -> None:
188
- """Close current tab (only for multiple files)."""
228
+ """Close the currently active tab.
229
+
230
+ Closes the current tab. If this is the only tab, exits the application instead.
231
+
232
+ Returns:
233
+ None
234
+ """
189
235
  if len(self.tabs) <= 1:
190
236
  self.app.exit()
191
237
  return
192
238
  self._close_tab()
193
239
 
194
- def action_next_tab(self, offset: int = 1) -> str:
195
- """Switch to next tab (only for multiple files)."""
240
+ def action_next_tab(self, offset: int = 1) -> None:
241
+ """Switch to the next tab or previous tab.
242
+
243
+ Cycles through tabs by the specified offset. With offset=1, moves to next tab.
244
+ With offset=-1, moves to previous tab. Wraps around when reaching edges.
245
+
246
+ Args:
247
+ offset: Number of tabs to advance (+1 for next, -1 for previous). Defaults to 1.
248
+
249
+ Returns:
250
+ None
251
+ """
196
252
  if len(self.tabs) <= 1:
197
253
  return
198
254
  try:
199
255
  tabs: list[TabPane] = list(self.tabs.keys())
200
- next_tab = _next(tabs, self.tabbed.active_pane, offset)
256
+ next_tab = get_next_item(tabs, self.tabbed.active_pane, offset)
201
257
  self.tabbed.active = next_tab.id
202
258
  except (NoMatches, ValueError):
203
259
  pass
204
260
 
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
261
+ def action_toggle_tab_bar(self) -> None:
262
+ """Toggle the tab bar visibility.
263
+
264
+ Shows or hides the tab bar at the bottom of the window. Useful for maximizing
265
+ screen space in single-tab mode.
266
+
267
+ Returns:
268
+ None
269
+ """
270
+ tabs = self.query_one(ContentTabs)
271
+ tabs.display = not tabs.display
272
+ # status = "shown" if tabs.display else "hidden"
273
+ # self.notify(f"Tab bar [$success]{status}[/]", title="Toggle")
274
+
275
+ def _get_active_table(self) -> DataFrameTable | None:
276
+ """Get the currently active DataFrameTable widget.
277
+
278
+ Retrieves the table from the currently active tab. Returns None if no
279
+ table is found or an error occurs.
280
+
281
+ Returns:
282
+ The active DataFrameTable widget, or None if not found.
283
+ """
284
+ try:
285
+ tabbed: TabbedContent = self.query_one(TabbedContent)
286
+ if active_pane := tabbed.active_pane:
287
+ return active_pane.query_one(DataFrameTable)
288
+ except (NoMatches, AttributeError):
289
+ self.notify("No active table found", title="Locate", severity="error")
290
+ return None
291
+
292
+ def _do_add_tab(self, filename: str) -> None:
293
+ """Add a tab for the opened file.
294
+
295
+ Loads the specified file and creates one or more tabs for it. For Excel files,
296
+ creates one tab per sheet. For other formats, creates a single tab.
297
+
298
+ Args:
299
+ filename: Path to the file to load and add as tab(s).
300
+
301
+ Returns:
302
+ None
303
+ """
304
+ if filename and os.path.exists(filename):
305
+ try:
306
+ n_tab = 0
307
+ for lf, filename, tabname in load_file(filename, prefix_sheet=True):
308
+ self._add_tab(lf.collect(), filename, tabname)
309
+ n_tab += 1
310
+ self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
311
+ except Exception as e:
312
+ self.notify(f"Error: {e}", title="Open", severity="error")
313
+ else:
314
+ self.notify(f"File does not exist: [$warning]{filename}[/]", title="Open", severity="warning")
315
+
316
+ def _add_tab(self, df: pl.DataFrame, filename: str, tabname: str) -> None:
317
+ """Add new tab for the given DataFrame.
318
+
319
+ Creates and adds a new tab with the provided DataFrame and configuration.
320
+ Ensures unique tab names by appending an index if needed. Shows the tab bar
321
+ if this is no longer the only tab.
322
+
323
+ Args:
324
+ df: The Polars DataFrame to display in the new tab.
325
+ filename: The source filename for this data (used in table metadata).
326
+ tabname: The display name for the tab.
327
+
328
+ Returns:
329
+ None
330
+ """
208
331
  if any(tab.name == tabname for tab in self.tabs):
209
332
  tabname = f"{tabname}_{len(self.tabs) + 1}"
210
333
 
@@ -218,9 +341,7 @@ class DataFrameViewer(App):
218
341
  tab_idx = pending_tab_idx
219
342
  break
220
343
 
221
- table = DataFrameTable(
222
- df, filename, zebra_stripes=True, id=tab_idx, name=tabname
223
- )
344
+ table = DataFrameTable(df, filename, zebra_stripes=True, id=tab_idx, name=tabname)
224
345
  tab = TabPane(tabname, table, name=tabname, id=tab_idx)
225
346
  self.tabbed.add_pane(tab)
226
347
  self.tabs[tab] = table
@@ -233,7 +354,14 @@ class DataFrameViewer(App):
233
354
  table.focus()
234
355
 
235
356
  def _close_tab(self) -> None:
236
- """Close current tab."""
357
+ """Close the currently active tab.
358
+
359
+ Removes the active tab from the interface. If only one tab remains and no more
360
+ can be closed, the application exits instead.
361
+
362
+ Returns:
363
+ None
364
+ """
237
365
  try:
238
366
  if len(self.tabs) == 1:
239
367
  self.app.exit()
@@ -241,95 +369,6 @@ class DataFrameViewer(App):
241
369
  if active_pane := self.tabbed.active_pane:
242
370
  self.tabbed.remove_pane(active_pane.id)
243
371
  self.tabs.pop(active_pane)
244
- self.notify(
245
- f"Closed tab [on $primary]{active_pane.name}[/]", title="Close"
246
- )
372
+ self.notify(f"Closed tab [$success]{active_pane.name}[/]", title="Close")
247
373
  except NoMatches:
248
374
  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