dataframe-textual 1.0.0__py3-none-any.whl → 1.4.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.
- dataframe_textual/__init__.py +1 -2
- dataframe_textual/__main__.py +48 -23
- dataframe_textual/common.py +372 -23
- dataframe_textual/data_frame_help_panel.py +6 -4
- dataframe_textual/data_frame_table.py +893 -449
- dataframe_textual/data_frame_viewer.py +39 -141
- dataframe_textual/sql_screen.py +202 -0
- dataframe_textual/table_screen.py +45 -28
- dataframe_textual/yes_no_screen.py +12 -8
- {dataframe_textual-1.0.0.dist-info → dataframe_textual-1.4.0.dist-info}/METADATA +205 -46
- dataframe_textual-1.4.0.dist-info/RECORD +14 -0
- {dataframe_textual-1.0.0.dist-info → dataframe_textual-1.4.0.dist-info}/entry_points.txt +1 -0
- dataframe_textual-1.0.0.dist-info/RECORD +0 -13
- {dataframe_textual-1.0.0.dist-info → dataframe_textual-1.4.0.dist-info}/WHEEL +0 -0
- {dataframe_textual-1.0.0.dist-info → dataframe_textual-1.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
13
|
+
from textual.widgets.tabbed_content import ContentTabs
|
|
15
14
|
|
|
16
|
-
from .common import get_next_item
|
|
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
|
|
@@ -27,7 +26,7 @@ class DataFrameViewer(App):
|
|
|
27
26
|
|
|
28
27
|
## 🎯 File & Tab Management
|
|
29
28
|
- **Ctrl+O** - 📁 Add a new tab
|
|
30
|
-
- **Ctrl+
|
|
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
|
-
- **
|
|
37
|
+
- **F1** - ❓ Toggle this help panel
|
|
39
38
|
- **k** - 🌙 Cycle through themes
|
|
40
39
|
|
|
41
40
|
## ⭐ Features
|
|
@@ -52,48 +51,42 @@ class DataFrameViewer(App):
|
|
|
52
51
|
|
|
53
52
|
BINDINGS = [
|
|
54
53
|
("q", "quit", "Quit"),
|
|
55
|
-
("
|
|
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+
|
|
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;
|
|
69
|
+
height: 1fr;
|
|
74
70
|
}
|
|
75
|
-
|
|
76
|
-
|
|
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, *
|
|
76
|
+
def __init__(self, *sources: str) -> None:
|
|
83
77
|
"""Initialize the DataFrame Viewer application.
|
|
84
78
|
|
|
85
|
-
Loads
|
|
79
|
+
Loads data from provided sources and prepares the tabbed interface.
|
|
86
80
|
|
|
87
81
|
Args:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
has_header: Whether the input files have a header row. Defaults to True.
|
|
82
|
+
sources: sources to load dataframes from, each as a tuple of
|
|
83
|
+
(DataFrame, filename, tabname).
|
|
91
84
|
|
|
92
85
|
Returns:
|
|
93
86
|
None
|
|
94
87
|
"""
|
|
95
88
|
super().__init__()
|
|
96
|
-
self.sources =
|
|
89
|
+
self.sources = sources
|
|
97
90
|
self.tabs: dict[TabPane, DataFrameTable] = {}
|
|
98
91
|
self.help_panel = None
|
|
99
92
|
|
|
@@ -111,19 +104,29 @@ class DataFrameViewer(App):
|
|
|
111
104
|
with self.tabbed:
|
|
112
105
|
seen_names = set()
|
|
113
106
|
for idx, (df, filename, tabname) in enumerate(self.sources, start=1):
|
|
107
|
+
tab_id = f"tab_{idx}"
|
|
108
|
+
|
|
109
|
+
if not tabname:
|
|
110
|
+
tabname = Path(filename).stem or tab_id
|
|
111
|
+
|
|
114
112
|
# Ensure unique tab names
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
counter = 1
|
|
114
|
+
while tabname in seen_names:
|
|
115
|
+
tabname = f"{tabname}_{counter}"
|
|
116
|
+
counter += 1
|
|
117
117
|
seen_names.add(tabname)
|
|
118
118
|
|
|
119
|
-
tab_id = f"tab_{idx}"
|
|
120
119
|
try:
|
|
121
120
|
table = DataFrameTable(df, filename, name=tabname, id=tab_id, zebra_stripes=True)
|
|
122
121
|
tab = TabPane(tabname, table, name=tabname, id=tab_id)
|
|
123
122
|
self.tabs[tab] = table
|
|
124
123
|
yield tab
|
|
125
124
|
except Exception as e:
|
|
126
|
-
self.notify(
|
|
125
|
+
self.notify(
|
|
126
|
+
f"Error loading [$error]{filename}[/]: Try [$accent]-I[/] to disable schema inference",
|
|
127
|
+
severity="error",
|
|
128
|
+
)
|
|
129
|
+
self.log(f"Error loading `{filename}`: {str(e)}")
|
|
127
130
|
|
|
128
131
|
def on_mount(self) -> None:
|
|
129
132
|
"""Set up the application when it starts.
|
|
@@ -174,12 +177,6 @@ class DataFrameViewer(App):
|
|
|
174
177
|
if table.loaded_rows == 0:
|
|
175
178
|
table._setup_table()
|
|
176
179
|
|
|
177
|
-
# Apply background color to active tab
|
|
178
|
-
event.tab.add_class("active")
|
|
179
|
-
for tab in self.tabbed.query(ContentTab):
|
|
180
|
-
if tab != event.tab:
|
|
181
|
-
tab.remove_class("active")
|
|
182
|
-
|
|
183
180
|
def action_toggle_help_panel(self) -> None:
|
|
184
181
|
"""Toggle the help panel on or off.
|
|
185
182
|
|
|
@@ -300,12 +297,12 @@ class DataFrameViewer(App):
|
|
|
300
297
|
if filename and os.path.exists(filename):
|
|
301
298
|
try:
|
|
302
299
|
n_tab = 0
|
|
303
|
-
for lf, filename, tabname in
|
|
304
|
-
self._add_tab(lf
|
|
300
|
+
for lf, filename, tabname in load_file(filename, prefix_sheet=True):
|
|
301
|
+
self._add_tab(lf, filename, tabname)
|
|
305
302
|
n_tab += 1
|
|
306
|
-
self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
|
|
303
|
+
# self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
|
|
307
304
|
except Exception as e:
|
|
308
|
-
self.notify(f"Error: {e}", title="Open", severity="error")
|
|
305
|
+
self.notify(f"Error loading [$error]{filename}[/]: {str(e)}", title="Open", severity="error")
|
|
309
306
|
else:
|
|
310
307
|
self.notify(f"File does not exist: [$warning]{filename}[/]", title="Open", severity="warning")
|
|
311
308
|
|
|
@@ -317,15 +314,18 @@ class DataFrameViewer(App):
|
|
|
317
314
|
if this is no longer the only tab.
|
|
318
315
|
|
|
319
316
|
Args:
|
|
320
|
-
|
|
317
|
+
lf: The Polars DataFrame to display in the new tab.
|
|
321
318
|
filename: The source filename for this data (used in table metadata).
|
|
322
319
|
tabname: The display name for the tab.
|
|
323
320
|
|
|
324
321
|
Returns:
|
|
325
322
|
None
|
|
326
323
|
"""
|
|
327
|
-
|
|
328
|
-
|
|
324
|
+
# Ensure unique tab names
|
|
325
|
+
counter = 1
|
|
326
|
+
while any(tab.name == tabname for tab in self.tabs):
|
|
327
|
+
tabname = f"{tabname}_{counter}"
|
|
328
|
+
counter += 1
|
|
329
329
|
|
|
330
330
|
# Find an available tab index
|
|
331
331
|
tab_idx = f"tab_{len(self.tabs) + 1}"
|
|
@@ -365,108 +365,6 @@ class DataFrameViewer(App):
|
|
|
365
365
|
if active_pane := self.tabbed.active_pane:
|
|
366
366
|
self.tabbed.remove_pane(active_pane.id)
|
|
367
367
|
self.tabs.pop(active_pane)
|
|
368
|
-
self.notify(f"Closed tab [$success]{active_pane.name}[/]", title="Close")
|
|
368
|
+
# self.notify(f"Closed tab [$success]{active_pane.name}[/]", title="Close")
|
|
369
369
|
except NoMatches:
|
|
370
370
|
pass
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def _load_dataframe(
|
|
374
|
-
filenames: list[str], file_format: str | None = None, has_header: bool = True
|
|
375
|
-
) -> list[tuple[pl.LazyFrame, str, str]]:
|
|
376
|
-
"""Load DataFrames from file specifications.
|
|
377
|
-
|
|
378
|
-
Handles loading from multiple files, single files, or stdin. For Excel files,
|
|
379
|
-
loads all sheets as separate entries. For other formats, loads as single file.
|
|
380
|
-
|
|
381
|
-
Args:
|
|
382
|
-
filenames: List of filenames to load. If single filename is "-", read from stdin.
|
|
383
|
-
file_format: Optional format specifier for input files (e.g., 'csv', 'excel').
|
|
384
|
-
has_header: Whether the input files have a header row. Defaults to True.
|
|
385
|
-
|
|
386
|
-
Returns:
|
|
387
|
-
List of tuples of (LazyFrame, filename, tabname) ready for display.
|
|
388
|
-
"""
|
|
389
|
-
sources = []
|
|
390
|
-
|
|
391
|
-
prefix_sheet = len(filenames) > 1
|
|
392
|
-
|
|
393
|
-
for filename in filenames:
|
|
394
|
-
sources.extend(_load_file(filename, prefix_sheet=prefix_sheet, file_format=file_format, has_header=has_header))
|
|
395
|
-
return sources
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
def _load_file(
|
|
399
|
-
filename: str,
|
|
400
|
-
first_sheet: bool = False,
|
|
401
|
-
prefix_sheet: bool = False,
|
|
402
|
-
file_format: str | None = None,
|
|
403
|
-
has_header: bool = True,
|
|
404
|
-
) -> list[tuple[pl.LazyFrame, str, str]]:
|
|
405
|
-
"""Load a single file and return list of sources.
|
|
406
|
-
|
|
407
|
-
For Excel files, when `first_sheet` is True, returns only the first sheet. Otherwise, returns one entry per sheet.
|
|
408
|
-
For other files or multiple files, returns one entry per file.
|
|
409
|
-
|
|
410
|
-
Args:
|
|
411
|
-
filename: Path to file to load.
|
|
412
|
-
first_sheet: If True, only load first sheet for Excel files. Defaults to False.
|
|
413
|
-
prefix_sheet: If True, prefix filename to sheet name as the tab name for Excel files. Defaults to False.
|
|
414
|
-
file_format: Optional format specifier for input files (e.g., 'csv', 'excel', 'tsv', 'parquet', 'json', 'ndjson').
|
|
415
|
-
|
|
416
|
-
Returns:
|
|
417
|
-
List of tuples of (LazyFrame, filename, tabname).
|
|
418
|
-
"""
|
|
419
|
-
sources = []
|
|
420
|
-
|
|
421
|
-
if filename == "-":
|
|
422
|
-
from io import StringIO
|
|
423
|
-
|
|
424
|
-
# Read from stdin into memory first (stdin is not seekable)
|
|
425
|
-
stdin_data = sys.stdin.read()
|
|
426
|
-
lf = pl.scan_csv(StringIO(stdin_data), has_header=has_header, separator="," if file_format == "csv" else "\t")
|
|
427
|
-
|
|
428
|
-
# Reopen stdin to /dev/tty for proper terminal interaction
|
|
429
|
-
try:
|
|
430
|
-
tty = open("/dev/tty")
|
|
431
|
-
os.dup2(tty.fileno(), sys.stdin.fileno())
|
|
432
|
-
except (OSError, FileNotFoundError):
|
|
433
|
-
pass
|
|
434
|
-
|
|
435
|
-
sources.append((lf, "stdin.tsv" if file_format == "tsv" else "stdin.csv", "stdin"))
|
|
436
|
-
return sources
|
|
437
|
-
|
|
438
|
-
filepath = Path(filename)
|
|
439
|
-
ext = filepath.suffix.lower()
|
|
440
|
-
|
|
441
|
-
if file_format == "csv" or ext == ".csv":
|
|
442
|
-
lf = pl.scan_csv(filename, has_header=has_header)
|
|
443
|
-
sources.append((lf, filename, filepath.stem))
|
|
444
|
-
elif file_format == "excel" or ext in (".xlsx", ".xls"):
|
|
445
|
-
if first_sheet:
|
|
446
|
-
# Read only the first sheet for multiple files
|
|
447
|
-
lf = pl.read_excel(filename).lazy()
|
|
448
|
-
sources.append((lf, filename, filepath.stem))
|
|
449
|
-
else:
|
|
450
|
-
# For single file, expand all sheets
|
|
451
|
-
sheets = pl.read_excel(filename, sheet_id=0)
|
|
452
|
-
for sheet_name, df in sheets.items():
|
|
453
|
-
tabname = f"{filepath.stem}_{sheet_name}" if prefix_sheet else sheet_name
|
|
454
|
-
sources.append((df.lazy(), filename, tabname))
|
|
455
|
-
elif file_format == "tsv" or ext in (".tsv", ".tab"):
|
|
456
|
-
lf = pl.scan_csv(filename, has_header=has_header, separator="\t")
|
|
457
|
-
sources.append((lf, filename, filepath.stem))
|
|
458
|
-
elif file_format == "parquet" or ext == ".parquet":
|
|
459
|
-
lf = pl.scan_parquet(filename)
|
|
460
|
-
sources.append((lf, filename, filepath.stem))
|
|
461
|
-
elif file_format == "json" or ext == ".json":
|
|
462
|
-
df = pl.read_json(filename)
|
|
463
|
-
sources.append((df, filename, filepath.stem))
|
|
464
|
-
elif file_format == "ndjson" or ext == ".ndjson":
|
|
465
|
-
lf = pl.scan_ndjson(filename)
|
|
466
|
-
sources.append((lf, filename, filepath.stem))
|
|
467
|
-
else:
|
|
468
|
-
# Treat other formats as TSV
|
|
469
|
-
lf = pl.scan_csv(filename, has_header=has_header, separator="\t")
|
|
470
|
-
sources.append((lf, filename, filepath.stem))
|
|
471
|
-
|
|
472
|
-
return sources
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Modal screens for Polars sql manipulation"""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .data_frame_table import DataFrameTable
|
|
7
|
+
|
|
8
|
+
import polars as pl
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.containers import Container, Horizontal
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Button, Input, Label, SelectionList, TextArea
|
|
13
|
+
from textual.widgets.selection_list import Selection
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SqlScreen(ModalScreen):
|
|
17
|
+
"""Base class for modal screens handling SQL query."""
|
|
18
|
+
|
|
19
|
+
DEFAULT_CSS = """
|
|
20
|
+
SqlScreen {
|
|
21
|
+
align: center middle;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
SqlScreen > Container {
|
|
25
|
+
width: auto;
|
|
26
|
+
height: auto;
|
|
27
|
+
border: heavy $accent;
|
|
28
|
+
border-title-color: $accent;
|
|
29
|
+
border-title-background: $panel;
|
|
30
|
+
border-title-style: bold;
|
|
31
|
+
background: $background;
|
|
32
|
+
padding: 1 2;
|
|
33
|
+
overflow: auto;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#button-container {
|
|
37
|
+
width: auto;
|
|
38
|
+
margin: 1 0 0 0;
|
|
39
|
+
height: 3;
|
|
40
|
+
align: center middle;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Button {
|
|
44
|
+
margin: 0 2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, dftable: "DataFrameTable", on_yes_callback=None) -> None:
|
|
50
|
+
"""Initialize the SQL screen."""
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.dftable = dftable # DataFrameTable
|
|
53
|
+
self.df: pl.DataFrame = dftable.df # Polars DataFrame
|
|
54
|
+
self.on_yes_callback = on_yes_callback
|
|
55
|
+
|
|
56
|
+
def compose(self) -> ComposeResult:
|
|
57
|
+
"""Compose the SQL screen widget structure."""
|
|
58
|
+
# Shared by subclasses
|
|
59
|
+
with Horizontal(id="button-container"):
|
|
60
|
+
yield Button("Apply", id="yes", variant="success")
|
|
61
|
+
yield Button("Cancel", id="no", variant="error")
|
|
62
|
+
|
|
63
|
+
def on_key(self, event) -> None:
|
|
64
|
+
"""Handle key press events in the SQL screen"""
|
|
65
|
+
if event.key in ("q", "escape"):
|
|
66
|
+
self.app.pop_screen()
|
|
67
|
+
event.stop()
|
|
68
|
+
elif event.key == "enter":
|
|
69
|
+
self._handle_yes()
|
|
70
|
+
event.stop()
|
|
71
|
+
elif event.key == "escape":
|
|
72
|
+
self.dismiss(None)
|
|
73
|
+
event.stop()
|
|
74
|
+
|
|
75
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
76
|
+
"""Handle button press events in the SQL screen."""
|
|
77
|
+
if event.button.id == "yes":
|
|
78
|
+
self._handle_yes()
|
|
79
|
+
elif event.button.id == "no":
|
|
80
|
+
self.dismiss(None)
|
|
81
|
+
|
|
82
|
+
def _handle_yes(self) -> None:
|
|
83
|
+
"""Handle Yes button/Enter key press."""
|
|
84
|
+
if self.on_yes_callback:
|
|
85
|
+
result = self.on_yes_callback()
|
|
86
|
+
self.dismiss(result)
|
|
87
|
+
else:
|
|
88
|
+
self.dismiss(True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SimpleSqlScreen(SqlScreen):
|
|
92
|
+
"""Simple SQL query screen."""
|
|
93
|
+
|
|
94
|
+
DEFAULT_CSS = SqlScreen.DEFAULT_CSS.replace("SqlScreen", "SimpleSqlScreen")
|
|
95
|
+
|
|
96
|
+
CSS = """
|
|
97
|
+
SimpleSqlScreen SelectionList {
|
|
98
|
+
width: auto;
|
|
99
|
+
min-width: 40;
|
|
100
|
+
margin: 1 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
SimpleSqlScreen SelectionList:blur {
|
|
104
|
+
border: solid $secondary;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
SimpleSqlScreen Label {
|
|
108
|
+
width: auto;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
SimpleSqlScreen Input {
|
|
112
|
+
width: auto;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
SimpleSqlScreen Input:blur {
|
|
116
|
+
border: solid $secondary;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#button-container {
|
|
120
|
+
min-width: 40;
|
|
121
|
+
}
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, dftable: "DataFrameTable") -> None:
|
|
125
|
+
"""Initialize the simple SQL screen.
|
|
126
|
+
|
|
127
|
+
Sets up the modal screen with reference to the main DataFrameTable widget
|
|
128
|
+
and stores the DataFrame for display.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
dftable: Reference to the parent DataFrameTable widget.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
None
|
|
135
|
+
"""
|
|
136
|
+
super().__init__(dftable, on_yes_callback=self._handle_simple)
|
|
137
|
+
|
|
138
|
+
def compose(self) -> ComposeResult:
|
|
139
|
+
"""Compose the simple SQL screen widget structure."""
|
|
140
|
+
with Container(id="sql-container") as container:
|
|
141
|
+
container.border_title = "SQL Query"
|
|
142
|
+
yield Label("Select columns (default to all):", id="select-label")
|
|
143
|
+
yield SelectionList(*[Selection(col, col) for col in self.df.columns], id="column-selection")
|
|
144
|
+
yield Label("Where condition (optional)", id="where-label")
|
|
145
|
+
yield Input(placeholder="e.g., age > 30 and height < 180", id="where-input")
|
|
146
|
+
yield from super().compose()
|
|
147
|
+
|
|
148
|
+
def _handle_simple(self) -> None:
|
|
149
|
+
"""Handle Yes button/Enter key press."""
|
|
150
|
+
selections = self.query_one(SelectionList).selected
|
|
151
|
+
columns = ", ".join(f"`{s}`" for s in selections) if selections else "*"
|
|
152
|
+
where = self.query_one(Input).value.strip()
|
|
153
|
+
|
|
154
|
+
return columns, where
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class AdvancedSqlScreen(SqlScreen):
|
|
158
|
+
"""Advanced SQL query screen."""
|
|
159
|
+
|
|
160
|
+
DEFAULT_CSS = SqlScreen.DEFAULT_CSS.replace("SqlScreen", "AdvancedSqlScreen")
|
|
161
|
+
|
|
162
|
+
CSS = """
|
|
163
|
+
AdvancedSqlScreen TextArea {
|
|
164
|
+
width: auto;
|
|
165
|
+
min-width: 60;
|
|
166
|
+
height: auto;
|
|
167
|
+
min-height: 10;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#button-container {
|
|
171
|
+
min-width: 60;
|
|
172
|
+
}
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, dftable: "DataFrameTable") -> None:
|
|
176
|
+
"""Initialize the simple SQL screen.
|
|
177
|
+
|
|
178
|
+
Sets up the modal screen with reference to the main DataFrameTable widget
|
|
179
|
+
and stores the DataFrame for display.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
dftable: Reference to the parent DataFrameTable widget.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
None
|
|
186
|
+
"""
|
|
187
|
+
super().__init__(dftable, on_yes_callback=self._handle_advanced)
|
|
188
|
+
|
|
189
|
+
def compose(self) -> ComposeResult:
|
|
190
|
+
"""Compose the advanced SQL screen widget structure."""
|
|
191
|
+
with Container(id="sql-container") as container:
|
|
192
|
+
container.border_title = "Advanced SQL Query"
|
|
193
|
+
yield TextArea.code_editor(
|
|
194
|
+
placeholder="Enter SQL query (use `self` as the table name), e.g., \n\nSELECT * \nFROM self \nWHERE age > 30",
|
|
195
|
+
id="sql-textarea",
|
|
196
|
+
language="sql",
|
|
197
|
+
)
|
|
198
|
+
yield from super().compose()
|
|
199
|
+
|
|
200
|
+
def _handle_advanced(self) -> None:
|
|
201
|
+
"""Handle Yes button/Enter key press."""
|
|
202
|
+
return self.query_one(TextArea).text.strip()
|
|
@@ -13,7 +13,7 @@ from textual.renderables.bar import Bar
|
|
|
13
13
|
from textual.screen import ModalScreen
|
|
14
14
|
from textual.widgets import DataTable
|
|
15
15
|
|
|
16
|
-
from .common import NULL, NULL_DISPLAY, RIDX, DtypeConfig, format_row
|
|
16
|
+
from .common import NULL, NULL_DISPLAY, RIDX, DtypeConfig, format_float, format_row
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class TableScreen(ModalScreen):
|
|
@@ -30,12 +30,14 @@ class TableScreen(ModalScreen):
|
|
|
30
30
|
|
|
31
31
|
TableScreen > DataTable {
|
|
32
32
|
width: auto;
|
|
33
|
-
|
|
33
|
+
height: auto;
|
|
34
34
|
border: solid $primary;
|
|
35
|
+
max-width: 100%;
|
|
36
|
+
overflow: auto;
|
|
35
37
|
}
|
|
36
38
|
"""
|
|
37
39
|
|
|
38
|
-
def __init__(self, dftable: DataFrameTable) -> None:
|
|
40
|
+
def __init__(self, dftable: "DataFrameTable") -> None:
|
|
39
41
|
"""Initialize the table screen.
|
|
40
42
|
|
|
41
43
|
Sets up the base modal screen with reference to the main DataFrameTable widget
|
|
@@ -48,8 +50,8 @@ class TableScreen(ModalScreen):
|
|
|
48
50
|
None
|
|
49
51
|
"""
|
|
50
52
|
super().__init__()
|
|
51
|
-
self.df: pl.DataFrame = dftable.df # Polars DataFrame
|
|
52
53
|
self.dftable = dftable # DataFrameTable
|
|
54
|
+
self.df: pl.DataFrame = dftable.df # Polars DataFrame
|
|
53
55
|
self.thousand_separator = False # Whether to use thousand separators in numbers
|
|
54
56
|
|
|
55
57
|
def compose(self) -> ComposeResult:
|
|
@@ -179,7 +181,12 @@ class RowDetailScreen(TableScreen):
|
|
|
179
181
|
# Get all columns and values from the dataframe row
|
|
180
182
|
for col, val, dtype in zip(self.df.columns, self.df.row(self.ridx), self.df.dtypes):
|
|
181
183
|
self.table.add_row(
|
|
182
|
-
*format_row(
|
|
184
|
+
*format_row(
|
|
185
|
+
[col, val],
|
|
186
|
+
[None, dtype],
|
|
187
|
+
apply_justify=False,
|
|
188
|
+
thousand_separator=self.thousand_separator,
|
|
189
|
+
)
|
|
183
190
|
)
|
|
184
191
|
|
|
185
192
|
self.table.cursor_type = "row"
|
|
@@ -223,7 +230,7 @@ class StatisticsScreen(TableScreen):
|
|
|
223
230
|
|
|
224
231
|
CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "StatisticsScreen")
|
|
225
232
|
|
|
226
|
-
def __init__(self, dftable: DataFrameTable, col_idx: int | None = None):
|
|
233
|
+
def __init__(self, dftable: "DataFrameTable", col_idx: int | None = None):
|
|
227
234
|
super().__init__(dftable)
|
|
228
235
|
self.col_idx = col_idx # None for dataframe statistics, otherwise column index
|
|
229
236
|
|
|
@@ -238,9 +245,11 @@ class StatisticsScreen(TableScreen):
|
|
|
238
245
|
if self.col_idx is None:
|
|
239
246
|
# Dataframe statistics
|
|
240
247
|
self._build_dataframe_stats()
|
|
248
|
+
self.table.cursor_type = "column"
|
|
241
249
|
else:
|
|
242
250
|
# Column statistics
|
|
243
251
|
self._build_column_stats()
|
|
252
|
+
self.table.cursor_type = "row"
|
|
244
253
|
|
|
245
254
|
def _build_column_stats(self) -> None:
|
|
246
255
|
"""Build statistics for a single column."""
|
|
@@ -271,10 +280,10 @@ class StatisticsScreen(TableScreen):
|
|
|
271
280
|
value = stat_value
|
|
272
281
|
if stat_value is None:
|
|
273
282
|
value = NULL_DISPLAY
|
|
274
|
-
elif dc.gtype == "
|
|
283
|
+
elif dc.gtype == "integer" and self.thousand_separator:
|
|
275
284
|
value = f"{stat_value:,}"
|
|
276
285
|
elif dc.gtype == "float":
|
|
277
|
-
value =
|
|
286
|
+
value = format_float(stat_value, self.thousand_separator)
|
|
278
287
|
else:
|
|
279
288
|
value = str(stat_value)
|
|
280
289
|
|
|
@@ -291,6 +300,10 @@ class StatisticsScreen(TableScreen):
|
|
|
291
300
|
if False in self.dftable.visible_rows:
|
|
292
301
|
lf = lf.filter(self.dftable.visible_rows)
|
|
293
302
|
|
|
303
|
+
# Apply only to non-hidden columns
|
|
304
|
+
if self.dftable.hidden_columns:
|
|
305
|
+
lf = lf.select(pl.exclude(self.dftable.hidden_columns))
|
|
306
|
+
|
|
294
307
|
# Get dataframe statistics
|
|
295
308
|
stats_df = lf.collect().describe()
|
|
296
309
|
|
|
@@ -321,10 +334,10 @@ class StatisticsScreen(TableScreen):
|
|
|
321
334
|
value = stat_value
|
|
322
335
|
if stat_value is None:
|
|
323
336
|
value = NULL_DISPLAY
|
|
324
|
-
elif dc.gtype == "
|
|
337
|
+
elif dc.gtype == "integer" and self.thousand_separator:
|
|
325
338
|
value = f"{stat_value:,}"
|
|
326
339
|
elif dc.gtype == "float":
|
|
327
|
-
value =
|
|
340
|
+
value = format_float(stat_value, self.thousand_separator)
|
|
328
341
|
else:
|
|
329
342
|
value = str(stat_value)
|
|
330
343
|
|
|
@@ -338,15 +351,16 @@ class FrequencyScreen(TableScreen):
|
|
|
338
351
|
|
|
339
352
|
CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "FrequencyScreen")
|
|
340
353
|
|
|
341
|
-
def __init__(self, col_idx: int, dftable: DataFrameTable):
|
|
354
|
+
def __init__(self, col_idx: int, dftable: "DataFrameTable") -> None:
|
|
342
355
|
super().__init__(dftable)
|
|
343
356
|
self.col_idx = col_idx
|
|
344
357
|
self.sorted_columns = {
|
|
345
358
|
1: True, # Count
|
|
346
359
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
)
|
|
360
|
+
|
|
361
|
+
df = dftable.df.filter(dftable.visible_rows) if False in dftable.visible_rows else dftable.df
|
|
362
|
+
self.total_count = len(df)
|
|
363
|
+
self.df: pl.DataFrame = df[df.columns[self.col_idx]].value_counts(sort=True).sort("count", descending=True)
|
|
350
364
|
|
|
351
365
|
def on_mount(self) -> None:
|
|
352
366
|
"""Create the frequency table."""
|
|
@@ -379,9 +393,6 @@ class FrequencyScreen(TableScreen):
|
|
|
379
393
|
dtype = self.dftable.df.dtypes[self.col_idx]
|
|
380
394
|
dc = DtypeConfig(dtype)
|
|
381
395
|
|
|
382
|
-
# Calculate frequencies using Polars
|
|
383
|
-
total_count = len(self.dftable.df)
|
|
384
|
-
|
|
385
396
|
# Add column headers with sort indicators
|
|
386
397
|
columns = [
|
|
387
398
|
(column, "Value", 0),
|
|
@@ -409,14 +420,14 @@ class FrequencyScreen(TableScreen):
|
|
|
409
420
|
# Add rows to the frequency table
|
|
410
421
|
for row_idx, row in enumerate(self.df.rows()):
|
|
411
422
|
column, count = row
|
|
412
|
-
percentage = (count / total_count) * 100
|
|
423
|
+
percentage = (count / self.total_count) * 100
|
|
413
424
|
|
|
414
425
|
if column is None:
|
|
415
426
|
value = NULL_DISPLAY
|
|
416
|
-
elif dc.gtype == "
|
|
427
|
+
elif dc.gtype == "integer" and self.thousand_separator:
|
|
417
428
|
value = f"{column:,}"
|
|
418
429
|
elif dc.gtype == "float":
|
|
419
|
-
value =
|
|
430
|
+
value = format_float(column, self.thousand_separator)
|
|
420
431
|
else:
|
|
421
432
|
value = str(column)
|
|
422
433
|
|
|
@@ -426,7 +437,7 @@ class FrequencyScreen(TableScreen):
|
|
|
426
437
|
f"{count:,}" if self.thousand_separator else str(count), style=ds_int.style, justify=ds_int.justify
|
|
427
438
|
),
|
|
428
439
|
Text(
|
|
429
|
-
|
|
440
|
+
format_float(percentage, self.thousand_separator),
|
|
430
441
|
style=ds_float.style,
|
|
431
442
|
justify=ds_float.justify,
|
|
432
443
|
),
|
|
@@ -440,8 +451,16 @@ class FrequencyScreen(TableScreen):
|
|
|
440
451
|
# Add a total row
|
|
441
452
|
self.table.add_row(
|
|
442
453
|
Text("Total", style="bold", justify=dc.justify),
|
|
443
|
-
Text(
|
|
444
|
-
|
|
454
|
+
Text(
|
|
455
|
+
f"{self.total_count:,}" if self.thousand_separator else str(self.total_count),
|
|
456
|
+
style="bold",
|
|
457
|
+
justify="right",
|
|
458
|
+
),
|
|
459
|
+
Text(
|
|
460
|
+
format_float(100.0, self.thousand_separator),
|
|
461
|
+
style="bold",
|
|
462
|
+
justify="right",
|
|
463
|
+
),
|
|
445
464
|
Bar(
|
|
446
465
|
highlight_range=(0.0, 10),
|
|
447
466
|
width=10,
|
|
@@ -454,12 +473,10 @@ class FrequencyScreen(TableScreen):
|
|
|
454
473
|
row_idx, col_idx = self.table.cursor_coordinate
|
|
455
474
|
col_sort = col_idx if col_idx == 0 else 1
|
|
456
475
|
|
|
457
|
-
|
|
458
|
-
if sort_dir is not None:
|
|
476
|
+
if self.sorted_columns.get(col_sort) == descending:
|
|
459
477
|
# If already sorted in the same direction, do nothing
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
return
|
|
478
|
+
# self.notify("Already sorted in that order", title="Sort", severity="warning")
|
|
479
|
+
return
|
|
463
480
|
|
|
464
481
|
self.sorted_columns.clear()
|
|
465
482
|
self.sorted_columns[col_sort] = descending
|