dataframe-textual 0.1.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.
Potentially problematic release.
This version of dataframe-textual might be problematic. Click here for more details.
- dataframe_textual-0.1.0.dist-info/METADATA +522 -0
- dataframe_textual-0.1.0.dist-info/RECORD +13 -0
- dataframe_textual-0.1.0.dist-info/WHEEL +4 -0
- dataframe_textual-0.1.0.dist-info/entry_points.txt +2 -0
- dataframe_textual-0.1.0.dist-info/licenses/LICENSE +21 -0
- dataframe_viewer/__init__.py +35 -0
- dataframe_viewer/__main__.py +48 -0
- dataframe_viewer/common.py +204 -0
- dataframe_viewer/data_frame_help_panel.py +98 -0
- dataframe_viewer/data_frame_table.py +1395 -0
- dataframe_viewer/data_frame_viewer.py +320 -0
- dataframe_viewer/table_screen.py +311 -0
- dataframe_viewer/yes_no_screen.py +409 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""DataFrame Viewer application and utilities."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from functools import partial
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from textwrap import dedent
|
|
8
|
+
|
|
9
|
+
import polars as pl
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.css.query import NoMatches
|
|
12
|
+
from textual.theme import BUILTIN_THEMES
|
|
13
|
+
from textual.widgets import TabbedContent, TabPane
|
|
14
|
+
from textual.widgets.tabbed_content import ContentTab, ContentTabs
|
|
15
|
+
|
|
16
|
+
from .common import _next
|
|
17
|
+
from .data_frame_help_panel import DataFrameHelpPanel
|
|
18
|
+
from .data_frame_table import DataFrameTable
|
|
19
|
+
from .yes_no_screen import OpenFileScreen, SaveFileScreen
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DataFrameViewer(App):
|
|
23
|
+
"""A Textual app to interact with multiple Polars DataFrames via tabbed interface."""
|
|
24
|
+
|
|
25
|
+
HELP = dedent("""
|
|
26
|
+
# 📊 DataFrame Viewer - App Controls
|
|
27
|
+
|
|
28
|
+
## 🎯 File & Tab Management
|
|
29
|
+
- **Ctrl+O** - 📁 Add a new tab
|
|
30
|
+
- **Ctrl+Shift+S** - 💾 Save all tabs
|
|
31
|
+
- **Ctrl+W** - ❌ Close current tab
|
|
32
|
+
- **>** or **b** - ▶️ Next tab
|
|
33
|
+
- **<** - ◀️ Previous tab
|
|
34
|
+
- **B** - 👁️ Toggle tab bar visibility
|
|
35
|
+
- **q** - 🚪 Quit application
|
|
36
|
+
|
|
37
|
+
## 🎨 View & Settings
|
|
38
|
+
- **?** or **h** - ❓ Toggle this help panel
|
|
39
|
+
- **k** - 🌙 Cycle through themes
|
|
40
|
+
|
|
41
|
+
## ⭐ Features
|
|
42
|
+
- **Multi-file support** - 📂 Open multiple CSV/Excel files as tabs
|
|
43
|
+
- **Excel sheets** - 📊 Excel files auto-expand sheets into tabs
|
|
44
|
+
- **Lazy loading** - ⚡ Large files load on demand
|
|
45
|
+
- **Sticky tabs** - 📌 Tab bar stays visible when scrolling
|
|
46
|
+
- **Rich formatting** - 🎨 Color-coded data types
|
|
47
|
+
- **Search & filter** - 🔍 Find and filter data quickly
|
|
48
|
+
- **Sort & reorder** - ⬆️ Multi-column sort, drag rows/columns
|
|
49
|
+
- **Undo/Redo** - 🔄 Full history of operations
|
|
50
|
+
- **Freeze rows/cols** - 🔒 Pin header rows and columns
|
|
51
|
+
""").strip()
|
|
52
|
+
|
|
53
|
+
BINDINGS = [
|
|
54
|
+
("q", "quit", "Quit"),
|
|
55
|
+
("h,?", "toggle_help_panel", "Help"),
|
|
56
|
+
("B", "toggle_tab_bar", "Toggle Tab Bar"),
|
|
57
|
+
("ctrl+o", "add_tab", "Add Tab"),
|
|
58
|
+
("ctrl+shift+s", "save_all_tabs", "Save All Tabs"),
|
|
59
|
+
("ctrl+w", "close_tab", "Close Tab"),
|
|
60
|
+
("greater_than_sign,b", "next_tab(1)", "Next Tab"),
|
|
61
|
+
("less_than_sign", "next_tab(-1)", "Prev Tab"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
CSS = """
|
|
65
|
+
TabbedContent {
|
|
66
|
+
height: 100%; /* Or a specific value, e.g., 20; */
|
|
67
|
+
}
|
|
68
|
+
TabbedContent > ContentTabs {
|
|
69
|
+
dock: bottom;
|
|
70
|
+
}
|
|
71
|
+
TabbedContent > ContentSwitcher {
|
|
72
|
+
overflow: auto;
|
|
73
|
+
height: 1fr; /* Takes the remaining space below tabs */
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
TabbedContent ContentTab.active {
|
|
77
|
+
background: $primary;
|
|
78
|
+
color: $text;
|
|
79
|
+
}
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, *filenames):
|
|
83
|
+
super().__init__()
|
|
84
|
+
self.sources = _load_dataframe(filenames)
|
|
85
|
+
self.tabs: dict[TabPane, DataFrameTable] = {}
|
|
86
|
+
self.help_panel = None
|
|
87
|
+
|
|
88
|
+
def compose(self) -> ComposeResult:
|
|
89
|
+
"""Create tabbed interface for multiple files or direct table for single file."""
|
|
90
|
+
# Tabbed interface
|
|
91
|
+
self.tabbed = TabbedContent(id="main_tabs")
|
|
92
|
+
with self.tabbed:
|
|
93
|
+
seen_names = set()
|
|
94
|
+
for idx, (df, filename, tabname) in enumerate(self.sources, start=1):
|
|
95
|
+
# Ensure unique tab names
|
|
96
|
+
if tabname in seen_names:
|
|
97
|
+
tabname = f"{tabname}_{idx}"
|
|
98
|
+
seen_names.add(tabname)
|
|
99
|
+
|
|
100
|
+
tab_id = f"tab_{idx}"
|
|
101
|
+
try:
|
|
102
|
+
table = DataFrameTable(
|
|
103
|
+
df, filename, name=tabname, id=tab_id, zebra_stripes=True
|
|
104
|
+
)
|
|
105
|
+
tab = TabPane(tabname, table, name=tabname, id=tab_id)
|
|
106
|
+
self.tabs[tab] = table
|
|
107
|
+
yield tab
|
|
108
|
+
except Exception as e:
|
|
109
|
+
self.notify(f"Error loading {tabname}: {e}", severity="error")
|
|
110
|
+
|
|
111
|
+
def on_mount(self) -> None:
|
|
112
|
+
"""Set up the app when it starts."""
|
|
113
|
+
if len(self.tabs) == 1:
|
|
114
|
+
self.query_one(ContentTabs).display = False
|
|
115
|
+
self._get_active_table().focus()
|
|
116
|
+
|
|
117
|
+
def on_key(self, event):
|
|
118
|
+
if event.key == "k":
|
|
119
|
+
self.theme = _next(list(BUILTIN_THEMES.keys()), self.theme)
|
|
120
|
+
self.notify(f"Switched to theme: [$primary]{self.theme}[/]", title="Theme")
|
|
121
|
+
|
|
122
|
+
def on_tabbed_content_tab_activated(
|
|
123
|
+
self, event: TabbedContent.TabActivated
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Handle tab changes (only for multiple tabs)."""
|
|
126
|
+
# Only process if we have multiple files
|
|
127
|
+
if len(self.tabs) <= 1:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Apply background color to active tab
|
|
131
|
+
event.tab.add_class("active")
|
|
132
|
+
for tab in self.tabbed.query(ContentTab):
|
|
133
|
+
if tab != event.tab:
|
|
134
|
+
tab.remove_class("active")
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
# Focus the table in the newly activated tab
|
|
138
|
+
if table := self._get_active_table():
|
|
139
|
+
table.focus()
|
|
140
|
+
except NoMatches:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
def _get_active_table(self) -> DataFrameTable | None:
|
|
144
|
+
"""Get the currently active table."""
|
|
145
|
+
try:
|
|
146
|
+
tabbed: TabbedContent = self.query_one(TabbedContent)
|
|
147
|
+
if active_pane := tabbed.active_pane:
|
|
148
|
+
return active_pane.query_one(DataFrameTable)
|
|
149
|
+
except (NoMatches, AttributeError):
|
|
150
|
+
pass
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def action_toggle_help_panel(self) -> None:
|
|
154
|
+
"""Toggle the HelpPanel on/off."""
|
|
155
|
+
if self.help_panel:
|
|
156
|
+
self.help_panel.display = not self.help_panel.display
|
|
157
|
+
else:
|
|
158
|
+
self.help_panel = DataFrameHelpPanel()
|
|
159
|
+
self.mount(self.help_panel)
|
|
160
|
+
|
|
161
|
+
def action_add_tab(self) -> None:
|
|
162
|
+
"""Open file dialog to load file to new tab."""
|
|
163
|
+
self.push_screen(OpenFileScreen(), self._handle_file_open)
|
|
164
|
+
|
|
165
|
+
def _handle_file_open(self, filename: str) -> None:
|
|
166
|
+
"""Handle file selection from dialog."""
|
|
167
|
+
if filename and os.path.exists(filename):
|
|
168
|
+
try:
|
|
169
|
+
df = pl.read_csv(filename)
|
|
170
|
+
self._add_tab(df, filename)
|
|
171
|
+
self.notify(
|
|
172
|
+
f"Opened: [on $primary]{Path(filename).name}[/]", title="Open"
|
|
173
|
+
)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
self.notify(f"Error: {e}", severity="error")
|
|
176
|
+
|
|
177
|
+
def action_save_all_tabs(self) -> None:
|
|
178
|
+
"""Save all tabs to a Excel file."""
|
|
179
|
+
callback = partial(self._get_active_table()._on_save_file_screen, all_tabs=True)
|
|
180
|
+
self.push_screen(
|
|
181
|
+
SaveFileScreen("all-tabs.xlsx", title="Save All Tabs"),
|
|
182
|
+
callback=callback,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def action_close_tab(self) -> None:
|
|
186
|
+
"""Close current tab (only for multiple files)."""
|
|
187
|
+
if len(self.tabs) <= 1:
|
|
188
|
+
self.app.exit()
|
|
189
|
+
return
|
|
190
|
+
self._close_tab()
|
|
191
|
+
|
|
192
|
+
def action_next_tab(self, offset: int = 1) -> str:
|
|
193
|
+
"""Switch to next tab (only for multiple files)."""
|
|
194
|
+
if len(self.tabs) <= 1:
|
|
195
|
+
return
|
|
196
|
+
try:
|
|
197
|
+
tabs: list[TabPane] = list(self.tabs.keys())
|
|
198
|
+
next_tab = _next(tabs, self.tabbed.active_pane, offset)
|
|
199
|
+
self.tabbed.active = next_tab.id
|
|
200
|
+
except (NoMatches, ValueError):
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
def _add_tab(self, df: pl.DataFrame, filename: str) -> None:
|
|
204
|
+
"""Add new table tab. If single file, replace table; if multiple, add tab."""
|
|
205
|
+
table = DataFrameTable(df, filename, zebra_stripes=True)
|
|
206
|
+
tabname = Path(filename).stem
|
|
207
|
+
if any(tab.name == tabname for tab in self.tabs):
|
|
208
|
+
tabname = f"{tabname}_{len(self.tabs) + 1}"
|
|
209
|
+
|
|
210
|
+
tab = TabPane(tabname, table, name=tabname, id=f"tab_{len(self.tabs) + 1}")
|
|
211
|
+
self.tabbed.add_pane(tab)
|
|
212
|
+
self.tabs[tab] = table
|
|
213
|
+
|
|
214
|
+
if len(self.tabs) > 1:
|
|
215
|
+
self.query_one(ContentTabs).display = True
|
|
216
|
+
|
|
217
|
+
# Activate the new tab
|
|
218
|
+
self.tabbed.active = tab.id
|
|
219
|
+
table.focus()
|
|
220
|
+
|
|
221
|
+
def _close_tab(self) -> None:
|
|
222
|
+
"""Close current tab."""
|
|
223
|
+
try:
|
|
224
|
+
if len(self.tabs) == 1:
|
|
225
|
+
self.app.exit()
|
|
226
|
+
else:
|
|
227
|
+
if active_pane := self.tabbed.active_pane:
|
|
228
|
+
self.tabbed.remove_pane(active_pane.id)
|
|
229
|
+
self.notify(
|
|
230
|
+
f"Closed tab [on $primary]{active_pane.name}[/]", title="Close"
|
|
231
|
+
)
|
|
232
|
+
except NoMatches:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
def action_toggle_tab_bar(self) -> None:
|
|
236
|
+
"""Toggle tab bar visibility."""
|
|
237
|
+
tabs = self.query_one(ContentTabs)
|
|
238
|
+
tabs.display = not tabs.display
|
|
239
|
+
status = "shown" if tabs.display else "hidden"
|
|
240
|
+
self.notify(f"Tab bar [on $primary]{status}[/]", title="Toggle")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _load_dataframe(filenames: list[str]) -> list[tuple[pl.DataFrame, str, str]]:
|
|
244
|
+
"""Load a DataFrame from a file spec.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
filenames: List of filenames to load. If single filename is "-", read from stdin.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
List of tuples of (DataFrame, filename, tabname)
|
|
251
|
+
"""
|
|
252
|
+
sources = []
|
|
253
|
+
|
|
254
|
+
# Single file
|
|
255
|
+
if len(filenames) == 1:
|
|
256
|
+
filename = filenames[0]
|
|
257
|
+
filepath = Path(filename)
|
|
258
|
+
ext = filepath.suffix.lower()
|
|
259
|
+
|
|
260
|
+
# Handle stdin
|
|
261
|
+
if filename == "-" or not sys.stdin.isatty():
|
|
262
|
+
from io import StringIO
|
|
263
|
+
|
|
264
|
+
# Read CSV from stdin into memory first (stdin is not seekable)
|
|
265
|
+
stdin_data = sys.stdin.read()
|
|
266
|
+
df = pl.read_csv(StringIO(stdin_data))
|
|
267
|
+
|
|
268
|
+
# Reopen stdin to /dev/tty for proper terminal interaction
|
|
269
|
+
try:
|
|
270
|
+
tty = open("/dev/tty")
|
|
271
|
+
os.dup2(tty.fileno(), sys.stdin.fileno())
|
|
272
|
+
except (OSError, FileNotFoundError):
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
sources.append((df, "stdin.csv", "stdin"))
|
|
276
|
+
# Handle Excel files with multiple sheets
|
|
277
|
+
elif ext in (".xlsx", ".xls"):
|
|
278
|
+
sheets = pl.read_excel(filename, sheet_id=0)
|
|
279
|
+
for sheet_name, df in sheets.items():
|
|
280
|
+
sources.append((df, filename, sheet_name))
|
|
281
|
+
# Handle TSV files
|
|
282
|
+
elif ext in (".tsv", ".tab"):
|
|
283
|
+
df = pl.read_csv(filename, separator="\t")
|
|
284
|
+
sources.append((df, filename, filepath.stem))
|
|
285
|
+
# Handle JSON files
|
|
286
|
+
elif ext == ".json":
|
|
287
|
+
df = pl.read_json(filename)
|
|
288
|
+
sources.append((df, filename, filepath.stem))
|
|
289
|
+
# Handle Parquet files
|
|
290
|
+
elif ext == ".parquet":
|
|
291
|
+
df = pl.read_parquet(filename)
|
|
292
|
+
sources.append((df, filename, filepath.stem))
|
|
293
|
+
# Handle regular CSV files
|
|
294
|
+
else:
|
|
295
|
+
df = pl.read_csv(filename)
|
|
296
|
+
sources.append((df, filename, filepath.stem))
|
|
297
|
+
# Multiple files
|
|
298
|
+
else:
|
|
299
|
+
for filename in filenames:
|
|
300
|
+
filepath = Path(filename)
|
|
301
|
+
ext = filepath.suffix.lower()
|
|
302
|
+
|
|
303
|
+
if ext in (".xlsx", ".xls"):
|
|
304
|
+
# Read only the first sheet for multiple files
|
|
305
|
+
df = pl.read_excel(filename)
|
|
306
|
+
sources.append((df, filename, filepath.stem))
|
|
307
|
+
elif ext in (".tsv", ".tab"):
|
|
308
|
+
df = pl.read_csv(filename, separator="\t")
|
|
309
|
+
sources.append((df, filename, filepath.stem))
|
|
310
|
+
elif ext == ".json":
|
|
311
|
+
df = pl.read_json(filename)
|
|
312
|
+
sources.append((df, filename, filepath.stem))
|
|
313
|
+
elif ext == ".parquet":
|
|
314
|
+
df = pl.read_parquet(filename)
|
|
315
|
+
sources.append((df, filename, filepath.stem))
|
|
316
|
+
else:
|
|
317
|
+
df = pl.read_csv(filename)
|
|
318
|
+
sources.append((df, filename, filepath.stem))
|
|
319
|
+
|
|
320
|
+
return sources
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Modal screens for displaying data in tables (row details and frequency)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.coordinate import Coordinate
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import DataTable
|
|
11
|
+
|
|
12
|
+
from .common import BOOLS, DtypeConfig, _format_row
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TableScreen(ModalScreen):
|
|
16
|
+
"""Base class for modal screens displaying data in a DataTable.
|
|
17
|
+
|
|
18
|
+
Provides common functionality for screens that show tabular data with
|
|
19
|
+
keyboard shortcuts and styling.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
DEFAULT_CSS = """
|
|
23
|
+
TableScreen {
|
|
24
|
+
align: center middle;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
TableScreen > DataTable {
|
|
28
|
+
width: auto;
|
|
29
|
+
min-width: 30;
|
|
30
|
+
height: auto;
|
|
31
|
+
border: solid $primary;
|
|
32
|
+
}
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, df: pl.DataFrame, id: str | None = None):
|
|
36
|
+
super().__init__()
|
|
37
|
+
self.df = df
|
|
38
|
+
self.id = id
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
"""Create the table. Must be overridden by subclasses."""
|
|
42
|
+
self.table = DataTable(zebra_stripes=True, id=self.id)
|
|
43
|
+
yield self.table
|
|
44
|
+
|
|
45
|
+
def on_key(self, event):
|
|
46
|
+
if event.key in ("q", "escape"):
|
|
47
|
+
self.app.pop_screen()
|
|
48
|
+
event.stop()
|
|
49
|
+
# Prevent key events from propagating to parent screen,
|
|
50
|
+
# except for the following default key bindings for DataTable
|
|
51
|
+
elif event.key not in (
|
|
52
|
+
"up",
|
|
53
|
+
"down",
|
|
54
|
+
"right",
|
|
55
|
+
"left",
|
|
56
|
+
"pageup",
|
|
57
|
+
"pagedown",
|
|
58
|
+
"ctrl+home",
|
|
59
|
+
"ctrl+end",
|
|
60
|
+
"home",
|
|
61
|
+
"end",
|
|
62
|
+
):
|
|
63
|
+
event.stop()
|
|
64
|
+
|
|
65
|
+
def _filter_or_highlight_selected_value(
|
|
66
|
+
self, col_name_value: tuple[str, str] | None, action: str = "filter"
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Apply filter or highlight action by the selected value from the frequency table.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
col_name: The name of the column to filter/highlight.
|
|
72
|
+
col_value: The value to filter/highlight by.
|
|
73
|
+
action: Either "filter" to filter visible rows, or "highlight" to select matching rows.
|
|
74
|
+
"""
|
|
75
|
+
if col_name_value is None:
|
|
76
|
+
return
|
|
77
|
+
col_name, col_value = col_name_value
|
|
78
|
+
|
|
79
|
+
# Handle NULL values
|
|
80
|
+
if col_value == "-":
|
|
81
|
+
# Create expression for NULL values
|
|
82
|
+
expr = pl.col(col_name).is_null()
|
|
83
|
+
value_display = "[on $primary]NULL[/]"
|
|
84
|
+
else:
|
|
85
|
+
# Create expression for the selected value
|
|
86
|
+
expr = pl.col(col_name) == col_value
|
|
87
|
+
value_display = f"[on $primary]{col_value}[/]"
|
|
88
|
+
|
|
89
|
+
app = self.app
|
|
90
|
+
matched_indices = set(
|
|
91
|
+
app.df.with_row_index("__rid__").filter(expr)["__rid__"].to_list()
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Apply the action
|
|
95
|
+
if action == "filter":
|
|
96
|
+
# Update visible_rows to reflect the filter
|
|
97
|
+
for i in range(len(app.visible_rows)):
|
|
98
|
+
app.visible_rows[i] = i in matched_indices
|
|
99
|
+
title = "Filter"
|
|
100
|
+
message = f"Filtered by [on $primary]{col_name}[/] = {value_display}"
|
|
101
|
+
else: # action == "highlight"
|
|
102
|
+
# Update selected_rows to reflect the highlights
|
|
103
|
+
for i in range(len(app.selected_rows)):
|
|
104
|
+
app.selected_rows[i] = i in matched_indices
|
|
105
|
+
title = "Highlight"
|
|
106
|
+
message = f"Highlighted [on $primary]{col_name}[/] = {value_display}"
|
|
107
|
+
|
|
108
|
+
# Recreate the table display with updated data in the main app
|
|
109
|
+
app._setup_table()
|
|
110
|
+
|
|
111
|
+
# Dismiss the frequency screen
|
|
112
|
+
self.app.pop_screen()
|
|
113
|
+
|
|
114
|
+
self.notify(message, title=title)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RowDetailScreen(TableScreen):
|
|
118
|
+
"""Modal screen to display a single row's details."""
|
|
119
|
+
|
|
120
|
+
CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "RowDetailScreen")
|
|
121
|
+
|
|
122
|
+
def __init__(self, row_idx: int, df: pl.DataFrame):
|
|
123
|
+
super().__init__(df, id="row-detail-table")
|
|
124
|
+
self.row_idx = row_idx
|
|
125
|
+
|
|
126
|
+
def on_mount(self) -> None:
|
|
127
|
+
"""Create the detail table."""
|
|
128
|
+
self.table.add_column("Column")
|
|
129
|
+
self.table.add_column("Value")
|
|
130
|
+
|
|
131
|
+
# Get all columns and values from the dataframe row
|
|
132
|
+
for col, val, dtype in zip(
|
|
133
|
+
self.df.columns, self.df.row(self.row_idx), self.df.dtypes
|
|
134
|
+
):
|
|
135
|
+
self.table.add_row(
|
|
136
|
+
*_format_row([col, val], [None, dtype], apply_justify=False)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def on_key(self, event):
|
|
140
|
+
if event.key == "v":
|
|
141
|
+
# Filter the main table by the selected value
|
|
142
|
+
self._filter_or_highlight_selected_value(
|
|
143
|
+
self._get_col_name_value(), action="filter"
|
|
144
|
+
)
|
|
145
|
+
event.stop()
|
|
146
|
+
elif event.key == "quotation_mark": # '"'
|
|
147
|
+
# Highlight the main table by the selected value
|
|
148
|
+
self._filter_or_highlight_selected_value(
|
|
149
|
+
self._get_col_name_value(), action="highlight"
|
|
150
|
+
)
|
|
151
|
+
event.stop()
|
|
152
|
+
|
|
153
|
+
def _get_col_name_value(self) -> tuple[str, Any] | None:
|
|
154
|
+
row_idx = self.table.cursor_row
|
|
155
|
+
if row_idx >= len(self.df.columns):
|
|
156
|
+
return None # Invalid row
|
|
157
|
+
|
|
158
|
+
col_name = self.df.columns[row_idx]
|
|
159
|
+
col_value = self.df.item(self.row_idx, row_idx)
|
|
160
|
+
|
|
161
|
+
return col_name, col_value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class FrequencyScreen(TableScreen):
|
|
165
|
+
"""Modal screen to display frequency of values in a column."""
|
|
166
|
+
|
|
167
|
+
CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "FrequencyScreen")
|
|
168
|
+
|
|
169
|
+
def __init__(self, col_idx: int, df: pl.DataFrame):
|
|
170
|
+
super().__init__(df, id="frequency-table")
|
|
171
|
+
self.col_idx = col_idx
|
|
172
|
+
self.sorted_columns = {
|
|
173
|
+
1: True, # Count
|
|
174
|
+
2: True, # %
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def on_mount(self) -> None:
|
|
178
|
+
"""Create the frequency table."""
|
|
179
|
+
column = self.df.columns[self.col_idx]
|
|
180
|
+
dtype = str(self.df.dtypes[self.col_idx])
|
|
181
|
+
dc = DtypeConfig(dtype)
|
|
182
|
+
|
|
183
|
+
# Calculate frequencies using Polars
|
|
184
|
+
freq_df = self.df[column].value_counts(sort=True).sort("count", descending=True)
|
|
185
|
+
total_count = len(self.df)
|
|
186
|
+
|
|
187
|
+
# Create frequency table
|
|
188
|
+
self.table.add_column(Text(column, justify=dc.justify), key=column)
|
|
189
|
+
self.table.add_column(Text("Count", justify="right"), key="Count")
|
|
190
|
+
self.table.add_column(Text("%", justify="right"), key="%")
|
|
191
|
+
|
|
192
|
+
# Get style config for Int64 and Float64
|
|
193
|
+
ds_int = DtypeConfig("Int64")
|
|
194
|
+
ds_float = DtypeConfig("Float64")
|
|
195
|
+
|
|
196
|
+
# Add rows to the frequency table
|
|
197
|
+
for row_idx, row in enumerate(freq_df.rows()):
|
|
198
|
+
value, count = row
|
|
199
|
+
percentage = (count / total_count) * 100
|
|
200
|
+
|
|
201
|
+
self.table.add_row(
|
|
202
|
+
Text(
|
|
203
|
+
"-" if value is None else str(value),
|
|
204
|
+
style=dc.style,
|
|
205
|
+
justify=dc.justify,
|
|
206
|
+
),
|
|
207
|
+
Text(
|
|
208
|
+
str(count),
|
|
209
|
+
style=ds_int.style,
|
|
210
|
+
justify=ds_int.justify,
|
|
211
|
+
),
|
|
212
|
+
Text(
|
|
213
|
+
f"{percentage:.2f}",
|
|
214
|
+
style=ds_float.style,
|
|
215
|
+
justify=ds_float.justify,
|
|
216
|
+
),
|
|
217
|
+
key=str(row_idx + 1),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Add a total row
|
|
221
|
+
self.table.add_row(
|
|
222
|
+
Text("Total", style="bold", justify=dc.justify),
|
|
223
|
+
Text(f"{total_count:,}", style="bold", justify="right"),
|
|
224
|
+
Text("100.00", style="bold", justify="right"),
|
|
225
|
+
key="total",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def on_key(self, event):
|
|
229
|
+
if event.key == "left_square_bracket": # '['
|
|
230
|
+
# Sort by current column in ascending order
|
|
231
|
+
self._sort_by_column(descending=False)
|
|
232
|
+
event.stop()
|
|
233
|
+
elif event.key == "right_square_bracket": # ']'
|
|
234
|
+
# Sort by current column in descending order
|
|
235
|
+
self._sort_by_column(descending=True)
|
|
236
|
+
event.stop()
|
|
237
|
+
elif event.key == "v":
|
|
238
|
+
# Filter the main table by the selected value
|
|
239
|
+
self._filter_or_highlight_selected_value(
|
|
240
|
+
self._get_col_name_value(), action="filter"
|
|
241
|
+
)
|
|
242
|
+
event.stop()
|
|
243
|
+
elif event.key == "quotation_mark": # '"'
|
|
244
|
+
# Highlight the main table by the selected value
|
|
245
|
+
self._filter_or_highlight_selected_value(
|
|
246
|
+
self._get_col_name_value(), action="highlight"
|
|
247
|
+
)
|
|
248
|
+
event.stop()
|
|
249
|
+
|
|
250
|
+
def _sort_by_column(self, descending: bool) -> None:
|
|
251
|
+
"""Sort the dataframe by the selected column and refresh the main table."""
|
|
252
|
+
freq_table = self.query_one(DataTable)
|
|
253
|
+
|
|
254
|
+
col_idx = freq_table.cursor_column
|
|
255
|
+
col_dtype = "String"
|
|
256
|
+
|
|
257
|
+
sort_dir = self.sorted_columns.get(col_idx)
|
|
258
|
+
if sort_dir is not None:
|
|
259
|
+
# If already sorted in the same direction, do nothing
|
|
260
|
+
if sort_dir == descending:
|
|
261
|
+
self.notify(
|
|
262
|
+
"Already sorted in that order", title="Sort", severity="warning"
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
self.sorted_columns.clear()
|
|
267
|
+
self.sorted_columns[col_idx] = descending
|
|
268
|
+
|
|
269
|
+
if col_idx == 0:
|
|
270
|
+
col_name = self.df.columns[self.col_idx]
|
|
271
|
+
col_dtype = str(self.df.dtypes[self.col_idx])
|
|
272
|
+
elif col_idx == 1:
|
|
273
|
+
col_name = "Count"
|
|
274
|
+
col_dtype = "Int64"
|
|
275
|
+
elif col_idx == 2:
|
|
276
|
+
col_name = "%"
|
|
277
|
+
col_dtype = "Float64"
|
|
278
|
+
|
|
279
|
+
def key_fun(freq_col):
|
|
280
|
+
col_value = freq_col.plain
|
|
281
|
+
|
|
282
|
+
if col_dtype == "Int64":
|
|
283
|
+
return int(col_value)
|
|
284
|
+
elif col_dtype == "Float64":
|
|
285
|
+
return float(col_value)
|
|
286
|
+
elif col_dtype == "Boolean":
|
|
287
|
+
return BOOLS[col_value]
|
|
288
|
+
else:
|
|
289
|
+
return col_value
|
|
290
|
+
|
|
291
|
+
# Sort the table
|
|
292
|
+
freq_table.sort(
|
|
293
|
+
col_name, key=lambda freq_col: key_fun(freq_col), reverse=descending
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Notify the user
|
|
297
|
+
order = "desc" if descending else "asc"
|
|
298
|
+
self.notify(f"Sorted by [on $primary]{col_name}[/] ({order})", title="Sort")
|
|
299
|
+
|
|
300
|
+
def _get_col_name_value(self) -> tuple[str, str] | None:
|
|
301
|
+
row_idx = self.table.cursor_row
|
|
302
|
+
if row_idx >= len(self.df.columns):
|
|
303
|
+
return None # Skip total row
|
|
304
|
+
|
|
305
|
+
col_name = self.df.columns[self.col_idx]
|
|
306
|
+
col_dtype = self.df.dtypes[self.col_idx]
|
|
307
|
+
|
|
308
|
+
cell_value = self.table.get_cell_at(Coordinate(row_idx, 0))
|
|
309
|
+
col_value = cell_value.plain
|
|
310
|
+
|
|
311
|
+
return col_name, DtypeConfig(col_dtype).convert(col_value)
|