plotaviz 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.
- plotaviz/__init__.py +19 -0
- plotaviz/core/__init__.py +53 -0
- plotaviz/core/analysis.py +230 -0
- plotaviz/core/codegen.py +482 -0
- plotaviz/core/errors.py +65 -0
- plotaviz/core/exporter.py +145 -0
- plotaviz/core/llm/__init__.py +211 -0
- plotaviz/core/llm/anthropic.py +61 -0
- plotaviz/core/llm/base.py +373 -0
- plotaviz/core/llm/gemini.py +51 -0
- plotaviz/core/llm/ollama.py +113 -0
- plotaviz/core/llm/openai.py +58 -0
- plotaviz/core/loader.py +366 -0
- plotaviz/core/plotter.py +790 -0
- plotaviz/core/preprocess.py +836 -0
- plotaviz/core/profiler.py +509 -0
- plotaviz/core/rules.yaml +190 -0
- plotaviz/core/selector.py +495 -0
- plotaviz/core/session.py +174 -0
- plotaviz/core/spec.py +236 -0
- plotaviz/main.py +329 -0
- plotaviz/ui/__init__.py +23 -0
- plotaviz/ui/export_dialog.py +228 -0
- plotaviz/ui/filter_panel.py +273 -0
- plotaviz/ui/interactive_view.py +210 -0
- plotaviz/ui/main_window.py +859 -0
- plotaviz/ui/nl_query_bar.py +219 -0
- plotaviz/ui/settings_dialog.py +313 -0
- plotaviz/ui/table_model.py +184 -0
- plotaviz/ui/type_override.py +159 -0
- plotaviz/ui/workers.py +222 -0
- plotaviz-0.1.0.dist-info/METADATA +410 -0
- plotaviz-0.1.0.dist-info/RECORD +37 -0
- plotaviz-0.1.0.dist-info/WHEEL +5 -0
- plotaviz-0.1.0.dist-info/entry_points.txt +2 -0
- plotaviz-0.1.0.dist-info/licenses/LICENSE +21 -0
- plotaviz-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
"""The main window — layout, menus, and the wiring between the UI and ``core``.
|
|
2
|
+
|
|
3
|
+
This module holds no analysis logic. Every decision about how to clean, profile, rank, or draw
|
|
4
|
+
lives in :mod:`plotaviz.core`; the window's job is to move data between widgets and that engine,
|
|
5
|
+
run anything slow on a worker thread, and turn :class:`~plotaviz.core.errors.PlotaVizError` into
|
|
6
|
+
a dialog a person can act on. No traceback ever reaches the user.
|
|
7
|
+
|
|
8
|
+
Layout, left to right: the data preview and cleaning report on the left, the chart in the middle
|
|
9
|
+
under the natural-language bar, and the recommendations, type overrides, and filters on the
|
|
10
|
+
right.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from PySide6.QtCore import QSettings, Qt, QUrl
|
|
19
|
+
from PySide6.QtGui import QAction, QDesktopServices, QDragEnterEvent, QDropEvent, QKeySequence
|
|
20
|
+
from PySide6.QtWidgets import (
|
|
21
|
+
QApplication,
|
|
22
|
+
QDockWidget,
|
|
23
|
+
QFileDialog,
|
|
24
|
+
QHBoxLayout,
|
|
25
|
+
QLabel,
|
|
26
|
+
QListWidget,
|
|
27
|
+
QListWidgetItem,
|
|
28
|
+
QMainWindow,
|
|
29
|
+
QMessageBox,
|
|
30
|
+
QProgressBar,
|
|
31
|
+
QPushButton,
|
|
32
|
+
QTableView,
|
|
33
|
+
QTabWidget,
|
|
34
|
+
QTextEdit,
|
|
35
|
+
QVBoxLayout,
|
|
36
|
+
QWidget,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
from .. import __app_name__, __version__
|
|
40
|
+
from ..core import loader
|
|
41
|
+
from ..core.analysis import Analysis
|
|
42
|
+
from ..core.errors import PlotaVizError
|
|
43
|
+
from ..core.llm import LLMAssistant, get_provider
|
|
44
|
+
from ..core.session import SESSION_SUFFIX, Session
|
|
45
|
+
from ..core.spec import ChartSpec
|
|
46
|
+
from .export_dialog import ExportDialog
|
|
47
|
+
from .filter_panel import FilterPanel
|
|
48
|
+
from .interactive_view import ChartView
|
|
49
|
+
from .nl_query_bar import NLQueryBar
|
|
50
|
+
from .settings_dialog import SettingsDialog
|
|
51
|
+
from .table_model import DataFrameModel
|
|
52
|
+
from .type_override import TypeOverridePanel
|
|
53
|
+
from .workers import TaskRunner
|
|
54
|
+
|
|
55
|
+
#: How many recent files to remember.
|
|
56
|
+
MAX_RECENT = 8
|
|
57
|
+
|
|
58
|
+
#: File dialog filter covering everything the loader understands.
|
|
59
|
+
_OPEN_FILTER = (
|
|
60
|
+
"Data files (*.csv *.tsv *.txt *.xlsx *.xls *.json *.ndjson *.jsonl *.parquet);;"
|
|
61
|
+
"CSV (*.csv *.tsv *.txt);;Excel (*.xlsx *.xls);;JSON (*.json *.ndjson *.jsonl);;"
|
|
62
|
+
"Parquet (*.parquet);;All files (*)"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MainWindow(QMainWindow):
|
|
67
|
+
"""PlotaViz's main window.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
parent: Parent widget.
|
|
71
|
+
initial_file: Dataset or ``.pviz`` session to open on launch.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self, parent: QWidget | None = None, *, initial_file: str | Path | None = None
|
|
76
|
+
) -> None:
|
|
77
|
+
super().__init__(parent)
|
|
78
|
+
self.setWindowTitle(__app_name__)
|
|
79
|
+
self.resize(1500, 940)
|
|
80
|
+
self.setAcceptDrops(True)
|
|
81
|
+
|
|
82
|
+
self.settings = QSettings("plotaviz", "plotaviz")
|
|
83
|
+
self.analysis: Analysis | None = None
|
|
84
|
+
self.session_path: Path | None = None
|
|
85
|
+
self.runner = TaskRunner(self)
|
|
86
|
+
|
|
87
|
+
self._build_central()
|
|
88
|
+
self._build_docks()
|
|
89
|
+
self._build_menus()
|
|
90
|
+
self._build_status_bar()
|
|
91
|
+
self._restore_geometry()
|
|
92
|
+
self._refresh_llm_state()
|
|
93
|
+
|
|
94
|
+
if initial_file:
|
|
95
|
+
self.open_path(initial_file)
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------ construction
|
|
98
|
+
|
|
99
|
+
def _build_central(self) -> None:
|
|
100
|
+
"""Chart area with the natural-language bar above it."""
|
|
101
|
+
central = QWidget(self)
|
|
102
|
+
layout = QVBoxLayout(central)
|
|
103
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
104
|
+
layout.setSpacing(0)
|
|
105
|
+
|
|
106
|
+
self.nl_bar = NLQueryBar(central)
|
|
107
|
+
self.nl_bar.question_asked.connect(self._on_question)
|
|
108
|
+
self.nl_bar.spec_edited.connect(self._on_spec_edited)
|
|
109
|
+
layout.addWidget(self.nl_bar)
|
|
110
|
+
|
|
111
|
+
prefer_static = self.settings.value("performance/prefer_static", False, type=bool)
|
|
112
|
+
self.chart_view = ChartView(central, prefer_static=prefer_static)
|
|
113
|
+
layout.addWidget(self.chart_view, stretch=1)
|
|
114
|
+
|
|
115
|
+
self.chart_note = QLabel("", central)
|
|
116
|
+
self.chart_note.setWordWrap(True)
|
|
117
|
+
self.chart_note.setStyleSheet("color: palette(mid); font-size: 11px; padding: 4px 10px;")
|
|
118
|
+
self.chart_note.hide()
|
|
119
|
+
layout.addWidget(self.chart_note)
|
|
120
|
+
|
|
121
|
+
self.setCentralWidget(central)
|
|
122
|
+
|
|
123
|
+
def _build_docks(self) -> None:
|
|
124
|
+
"""The left data dock and the right analysis dock."""
|
|
125
|
+
# --- left: preview + cleaning report
|
|
126
|
+
left = QWidget(self)
|
|
127
|
+
left_layout = QVBoxLayout(left)
|
|
128
|
+
left_layout.setContentsMargins(6, 6, 6, 6)
|
|
129
|
+
|
|
130
|
+
self.preview = QTableView(left)
|
|
131
|
+
self.preview_model = DataFrameModel(parent=self)
|
|
132
|
+
self.preview.setModel(self.preview_model)
|
|
133
|
+
self.preview.setAlternatingRowColors(True)
|
|
134
|
+
self.preview.setSelectionBehavior(QTableView.SelectionBehavior.SelectColumns)
|
|
135
|
+
self.preview.horizontalHeader().setStretchLastSection(True)
|
|
136
|
+
self.preview.setSortingEnabled(False)
|
|
137
|
+
|
|
138
|
+
self.preview_status = QLabel("No data loaded.", left)
|
|
139
|
+
self.preview_status.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
140
|
+
|
|
141
|
+
self.cleaning_report = QTextEdit(left)
|
|
142
|
+
self.cleaning_report.setReadOnly(True)
|
|
143
|
+
self.cleaning_report.setMaximumHeight(190)
|
|
144
|
+
self.cleaning_report.setPlaceholderText("The cleaning report appears here after loading.")
|
|
145
|
+
|
|
146
|
+
data_tabs = QTabWidget(left)
|
|
147
|
+
preview_page = QWidget(data_tabs)
|
|
148
|
+
preview_layout = QVBoxLayout(preview_page)
|
|
149
|
+
preview_layout.setContentsMargins(0, 0, 0, 0)
|
|
150
|
+
preview_layout.addWidget(self.preview)
|
|
151
|
+
preview_layout.addWidget(self.preview_status)
|
|
152
|
+
data_tabs.addTab(preview_page, "Preview")
|
|
153
|
+
data_tabs.addTab(self.cleaning_report, "Cleaning report")
|
|
154
|
+
|
|
155
|
+
left_layout.addWidget(data_tabs)
|
|
156
|
+
|
|
157
|
+
self.data_dock = QDockWidget("Data", self)
|
|
158
|
+
self.data_dock.setWidget(left)
|
|
159
|
+
self.data_dock.setObjectName("data_dock")
|
|
160
|
+
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.data_dock)
|
|
161
|
+
|
|
162
|
+
# --- right: recommendations, types, filters
|
|
163
|
+
self.recommendations = QListWidget(self)
|
|
164
|
+
self.recommendations.itemActivated.connect(self._on_recommendation_chosen)
|
|
165
|
+
self.recommendations.currentItemChanged.connect(self._on_recommendation_chosen)
|
|
166
|
+
|
|
167
|
+
recommend_page = QWidget(self)
|
|
168
|
+
recommend_layout = QVBoxLayout(recommend_page)
|
|
169
|
+
recommend_layout.setContentsMargins(6, 6, 6, 6)
|
|
170
|
+
caption = QLabel(
|
|
171
|
+
"Ranked by how well each chart suits this data. Select one to switch.", recommend_page
|
|
172
|
+
)
|
|
173
|
+
caption.setWordWrap(True)
|
|
174
|
+
caption.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
175
|
+
recommend_layout.addWidget(caption)
|
|
176
|
+
recommend_layout.addWidget(self.recommendations, stretch=1)
|
|
177
|
+
|
|
178
|
+
self.why_label = QTextEdit(recommend_page)
|
|
179
|
+
self.why_label.setReadOnly(True)
|
|
180
|
+
self.why_label.setMaximumHeight(140)
|
|
181
|
+
self.why_label.setPlaceholderText("The reasoning behind the selected chart appears here.")
|
|
182
|
+
recommend_layout.addWidget(self.why_label)
|
|
183
|
+
|
|
184
|
+
self.refine_button = QPushButton("Ask the model for a second opinion", recommend_page)
|
|
185
|
+
self.refine_button.clicked.connect(self._on_refine)
|
|
186
|
+
recommend_layout.addWidget(self.refine_button)
|
|
187
|
+
|
|
188
|
+
self.type_panel = TypeOverridePanel(self)
|
|
189
|
+
self.type_panel.types_changed.connect(self._on_types_changed)
|
|
190
|
+
|
|
191
|
+
self.filter_panel = FilterPanel(self)
|
|
192
|
+
self.filter_panel.filters_changed.connect(self._on_filters_changed)
|
|
193
|
+
|
|
194
|
+
self.analysis_tabs = QTabWidget(self)
|
|
195
|
+
self.analysis_tabs.addTab(recommend_page, "Charts")
|
|
196
|
+
self.analysis_tabs.addTab(self.type_panel, "Types")
|
|
197
|
+
self.analysis_tabs.addTab(self.filter_panel, "Filters")
|
|
198
|
+
|
|
199
|
+
self.analysis_dock = QDockWidget("Analysis", self)
|
|
200
|
+
self.analysis_dock.setWidget(self.analysis_tabs)
|
|
201
|
+
self.analysis_dock.setObjectName("analysis_dock")
|
|
202
|
+
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.analysis_dock)
|
|
203
|
+
|
|
204
|
+
self.resizeDocks(
|
|
205
|
+
[self.data_dock, self.analysis_dock], [430, 400], Qt.Orientation.Horizontal
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _build_menus(self) -> None:
|
|
209
|
+
"""File, View, and Help menus."""
|
|
210
|
+
file_menu = self.menuBar().addMenu("&File")
|
|
211
|
+
|
|
212
|
+
open_action = QAction("&Open dataset…", self)
|
|
213
|
+
open_action.setShortcut(QKeySequence.StandardKey.Open)
|
|
214
|
+
open_action.triggered.connect(self.choose_file)
|
|
215
|
+
file_menu.addAction(open_action)
|
|
216
|
+
|
|
217
|
+
self.recent_menu = file_menu.addMenu("Open &recent")
|
|
218
|
+
self._rebuild_recent_menu()
|
|
219
|
+
|
|
220
|
+
file_menu.addSeparator()
|
|
221
|
+
|
|
222
|
+
open_session = QAction("Open &session…", self)
|
|
223
|
+
open_session.triggered.connect(self.choose_session)
|
|
224
|
+
file_menu.addAction(open_session)
|
|
225
|
+
|
|
226
|
+
self.save_session_action = QAction("&Save session", self)
|
|
227
|
+
self.save_session_action.setShortcut(QKeySequence.StandardKey.Save)
|
|
228
|
+
self.save_session_action.triggered.connect(self.save_session)
|
|
229
|
+
self.save_session_action.setEnabled(False)
|
|
230
|
+
file_menu.addAction(self.save_session_action)
|
|
231
|
+
|
|
232
|
+
self.save_session_as_action = QAction("Save session &as…", self)
|
|
233
|
+
self.save_session_as_action.setShortcut(QKeySequence.StandardKey.SaveAs)
|
|
234
|
+
self.save_session_as_action.triggered.connect(lambda: self.save_session(ask=True))
|
|
235
|
+
self.save_session_as_action.setEnabled(False)
|
|
236
|
+
file_menu.addAction(self.save_session_as_action)
|
|
237
|
+
|
|
238
|
+
file_menu.addSeparator()
|
|
239
|
+
|
|
240
|
+
self.export_action = QAction("&Export…", self)
|
|
241
|
+
self.export_action.setShortcut("Ctrl+E")
|
|
242
|
+
self.export_action.triggered.connect(self.export)
|
|
243
|
+
self.export_action.setEnabled(False)
|
|
244
|
+
file_menu.addAction(self.export_action)
|
|
245
|
+
|
|
246
|
+
file_menu.addSeparator()
|
|
247
|
+
quit_action = QAction("&Quit", self)
|
|
248
|
+
quit_action.setShortcut(QKeySequence.StandardKey.Quit)
|
|
249
|
+
quit_action.triggered.connect(self.close)
|
|
250
|
+
file_menu.addAction(quit_action)
|
|
251
|
+
|
|
252
|
+
view_menu = self.menuBar().addMenu("&View")
|
|
253
|
+
view_menu.addAction(self.data_dock.toggleViewAction())
|
|
254
|
+
view_menu.addAction(self.analysis_dock.toggleViewAction())
|
|
255
|
+
view_menu.addSeparator()
|
|
256
|
+
settings_action = QAction("&Settings…", self)
|
|
257
|
+
settings_action.setShortcut(QKeySequence.StandardKey.Preferences)
|
|
258
|
+
settings_action.triggered.connect(self.open_settings)
|
|
259
|
+
view_menu.addAction(settings_action)
|
|
260
|
+
|
|
261
|
+
help_menu = self.menuBar().addMenu("&Help")
|
|
262
|
+
docs_action = QAction("Documentation", self)
|
|
263
|
+
docs_action.triggered.connect(
|
|
264
|
+
lambda: QDesktopServices.openUrl(QUrl("https://github.com/saadibnainan/plotaviz"))
|
|
265
|
+
)
|
|
266
|
+
help_menu.addAction(docs_action)
|
|
267
|
+
about_action = QAction(f"About {__app_name__}", self)
|
|
268
|
+
about_action.triggered.connect(self.show_about)
|
|
269
|
+
help_menu.addAction(about_action)
|
|
270
|
+
|
|
271
|
+
def _build_status_bar(self) -> None:
|
|
272
|
+
"""Status message, progress bar, and cancel button."""
|
|
273
|
+
self.progress = QProgressBar(self)
|
|
274
|
+
self.progress.setMaximumWidth(200)
|
|
275
|
+
self.progress.setTextVisible(False)
|
|
276
|
+
self.progress.hide()
|
|
277
|
+
|
|
278
|
+
self.cancel_button = QPushButton("Cancel", self)
|
|
279
|
+
self.cancel_button.clicked.connect(self._cancel_task)
|
|
280
|
+
self.cancel_button.hide()
|
|
281
|
+
|
|
282
|
+
container = QWidget(self)
|
|
283
|
+
row = QHBoxLayout(container)
|
|
284
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
285
|
+
row.addWidget(self.progress)
|
|
286
|
+
row.addWidget(self.cancel_button)
|
|
287
|
+
|
|
288
|
+
self.statusBar().addPermanentWidget(container)
|
|
289
|
+
self.statusBar().showMessage("Ready. Open a dataset to begin.")
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------------------------ opening files
|
|
292
|
+
|
|
293
|
+
def choose_file(self) -> None:
|
|
294
|
+
"""Show the open dialog."""
|
|
295
|
+
start = str(self.settings.value("paths/last_dir", str(Path.home())))
|
|
296
|
+
path, _ = QFileDialog.getOpenFileName(self, "Open dataset", start, _OPEN_FILTER)
|
|
297
|
+
if path:
|
|
298
|
+
self.open_path(path)
|
|
299
|
+
|
|
300
|
+
def choose_session(self) -> None:
|
|
301
|
+
"""Show the open dialog for session files."""
|
|
302
|
+
start = str(self.settings.value("paths/last_dir", str(Path.home())))
|
|
303
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
304
|
+
self, "Open session", start, f"PlotaViz sessions (*{SESSION_SUFFIX})"
|
|
305
|
+
)
|
|
306
|
+
if path:
|
|
307
|
+
self.open_path(path)
|
|
308
|
+
|
|
309
|
+
def open_path(self, path: str | Path) -> None:
|
|
310
|
+
"""Open a dataset or a session file, on a worker thread."""
|
|
311
|
+
path = Path(path).expanduser()
|
|
312
|
+
if not path.exists():
|
|
313
|
+
self._error("File not found", f"There is no file at {path}.")
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
self.settings.setValue("paths/last_dir", str(path.parent))
|
|
317
|
+
if path.suffix.lower() == SESSION_SUFFIX:
|
|
318
|
+
self._run(self._load_session_task, path, label=f"Restoring {path.name}…")
|
|
319
|
+
else:
|
|
320
|
+
self._run(self._load_dataset_task, path, label=f"Loading {path.name}…")
|
|
321
|
+
|
|
322
|
+
def _load_dataset_task(self, path: Path, progress: Any = None) -> tuple[Analysis, str | None]:
|
|
323
|
+
"""Worker body: load and clean a dataset."""
|
|
324
|
+
if progress:
|
|
325
|
+
progress(15, "Reading the file…")
|
|
326
|
+
analysis = Analysis.from_file(
|
|
327
|
+
path,
|
|
328
|
+
large_file_mb=float(self.settings.value("performance/large_file_mb", 500)),
|
|
329
|
+
)
|
|
330
|
+
if progress:
|
|
331
|
+
progress(90, "Choosing a chart…")
|
|
332
|
+
return analysis, None
|
|
333
|
+
|
|
334
|
+
def _load_session_task(self, path: Path, progress: Any = None) -> tuple[Analysis, str | None]:
|
|
335
|
+
"""Worker body: restore a session."""
|
|
336
|
+
if progress:
|
|
337
|
+
progress(10, "Reading the session…")
|
|
338
|
+
session = Session.load(path)
|
|
339
|
+
if progress:
|
|
340
|
+
progress(40, "Replaying the cleaning steps…")
|
|
341
|
+
analysis, warning = Analysis.from_session(session)
|
|
342
|
+
self.session_path = path
|
|
343
|
+
return analysis, warning
|
|
344
|
+
|
|
345
|
+
def _on_loaded(self, result: tuple[Analysis, str | None]) -> None:
|
|
346
|
+
"""Populate every panel once loading finishes."""
|
|
347
|
+
analysis, warning = result
|
|
348
|
+
self.analysis = analysis
|
|
349
|
+
|
|
350
|
+
self.preview_model.set_frame(analysis.df, analysis.profile)
|
|
351
|
+
self.preview_status.setText(self.preview_model.status_text())
|
|
352
|
+
self.cleaning_report.setPlainText(
|
|
353
|
+
(analysis.result.cleaning_report() if analysis.result else "")
|
|
354
|
+
+ ("\n\n" + "\n".join(analysis.load_result.notes) if analysis.load_result.notes else "")
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if analysis.profile:
|
|
358
|
+
self.type_panel.set_profile(analysis.profile, analysis.type_overrides)
|
|
359
|
+
self.filter_panel.set_profile(analysis.profile)
|
|
360
|
+
self.nl_bar.set_columns(list(analysis.df.columns))
|
|
361
|
+
|
|
362
|
+
self._populate_recommendations()
|
|
363
|
+
self._render_current()
|
|
364
|
+
|
|
365
|
+
name = analysis.load_result.path.name
|
|
366
|
+
self.setWindowTitle(f"{name} — {__app_name__}")
|
|
367
|
+
self.statusBar().showMessage(analysis.load_result.summary())
|
|
368
|
+
self._remember_recent(analysis.load_result.path)
|
|
369
|
+
|
|
370
|
+
for action in (self.export_action, self.save_session_action, self.save_session_as_action):
|
|
371
|
+
action.setEnabled(True)
|
|
372
|
+
|
|
373
|
+
if warning:
|
|
374
|
+
self._warn("Session restored with differences", warning)
|
|
375
|
+
|
|
376
|
+
# ------------------------------------------------------------------ charting
|
|
377
|
+
|
|
378
|
+
def _populate_recommendations(self) -> None:
|
|
379
|
+
"""Fill the ranked list from the analysis."""
|
|
380
|
+
self.recommendations.blockSignals(True)
|
|
381
|
+
self.recommendations.clear()
|
|
382
|
+
|
|
383
|
+
if self.analysis:
|
|
384
|
+
for spec in self.analysis.recommendations:
|
|
385
|
+
mapping = " · ".join(
|
|
386
|
+
part
|
|
387
|
+
for part in (
|
|
388
|
+
f"x: {spec.x}" if spec.x else "",
|
|
389
|
+
f"y: {spec.y}" if spec.y else "",
|
|
390
|
+
f"colour: {spec.color}" if spec.color else "",
|
|
391
|
+
f"{spec.agg}" if spec.agg else "",
|
|
392
|
+
)
|
|
393
|
+
if part
|
|
394
|
+
)
|
|
395
|
+
item = QListWidgetItem(f"{spec.chart} ({spec.score:.0%})\n{mapping}")
|
|
396
|
+
item.setData(Qt.ItemDataRole.UserRole, spec)
|
|
397
|
+
item.setToolTip(spec.why)
|
|
398
|
+
self.recommendations.addItem(item)
|
|
399
|
+
|
|
400
|
+
self.recommendations.blockSignals(False)
|
|
401
|
+
if self.recommendations.count():
|
|
402
|
+
self.recommendations.setCurrentRow(0)
|
|
403
|
+
self._show_why(self.analysis.spec if self.analysis else None)
|
|
404
|
+
|
|
405
|
+
def _on_recommendation_chosen(self, item: QListWidgetItem | None, *_: object) -> None:
|
|
406
|
+
"""Switch to a chart the user picked from the ranked list."""
|
|
407
|
+
if item is None or self.analysis is None:
|
|
408
|
+
return
|
|
409
|
+
spec = item.data(Qt.ItemDataRole.UserRole)
|
|
410
|
+
if not isinstance(spec, ChartSpec):
|
|
411
|
+
return
|
|
412
|
+
try:
|
|
413
|
+
self.analysis.choose(spec)
|
|
414
|
+
except PlotaVizError as exc:
|
|
415
|
+
self._error("That chart does not fit this data", str(exc))
|
|
416
|
+
return
|
|
417
|
+
self._show_why(spec)
|
|
418
|
+
self._render_current()
|
|
419
|
+
|
|
420
|
+
def _on_spec_edited(self, spec: ChartSpec) -> None:
|
|
421
|
+
"""Redraw after the user tweaks the interpreted spec in the query bar."""
|
|
422
|
+
if self.analysis is None:
|
|
423
|
+
return
|
|
424
|
+
try:
|
|
425
|
+
self.analysis.choose(spec)
|
|
426
|
+
except PlotaVizError as exc:
|
|
427
|
+
self.nl_bar.show_status(str(exc), error=True)
|
|
428
|
+
return
|
|
429
|
+
self._show_why(spec)
|
|
430
|
+
self._render_current()
|
|
431
|
+
|
|
432
|
+
def _show_why(self, spec: ChartSpec | None) -> None:
|
|
433
|
+
"""Display the justification for the current chart."""
|
|
434
|
+
self.why_label.setPlainText(spec.why if spec and spec.why else "")
|
|
435
|
+
|
|
436
|
+
def _render_current(self) -> None:
|
|
437
|
+
"""Draw the active chart, surfacing any preparation notes."""
|
|
438
|
+
if self.analysis is None or self.analysis.spec is None:
|
|
439
|
+
self.chart_view.show_message("No chart is selected yet.")
|
|
440
|
+
return
|
|
441
|
+
try:
|
|
442
|
+
notes = self.chart_view.render_spec(self.analysis.df, self.analysis.spec)
|
|
443
|
+
except PlotaVizError as exc:
|
|
444
|
+
self.chart_view.show_message(str(exc))
|
|
445
|
+
self.statusBar().showMessage("The chart could not be drawn.")
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
if notes:
|
|
449
|
+
self.chart_note.setText(" ".join(notes))
|
|
450
|
+
self.chart_note.show()
|
|
451
|
+
else:
|
|
452
|
+
self.chart_note.hide()
|
|
453
|
+
self.statusBar().showMessage(self.chart_view.renderer_note())
|
|
454
|
+
|
|
455
|
+
# ------------------------------------------------------------------ panel callbacks
|
|
456
|
+
|
|
457
|
+
def _on_types_changed(self, overrides: dict[str, str]) -> None:
|
|
458
|
+
"""Re-run the pipeline with corrected column types."""
|
|
459
|
+
if self.analysis is None:
|
|
460
|
+
return
|
|
461
|
+
self.analysis.type_overrides = dict(overrides)
|
|
462
|
+
for column, role in overrides.items():
|
|
463
|
+
for step in self.analysis.pipeline:
|
|
464
|
+
if hasattr(step, "types"):
|
|
465
|
+
step.types[column] = role
|
|
466
|
+
break
|
|
467
|
+
self._rerun("Applying types…")
|
|
468
|
+
|
|
469
|
+
def _on_filters_changed(self, filters: list[Any]) -> None:
|
|
470
|
+
"""Re-run the pipeline with new filters."""
|
|
471
|
+
if self.analysis is None:
|
|
472
|
+
return
|
|
473
|
+
try:
|
|
474
|
+
self.analysis.set_filters(list(filters))
|
|
475
|
+
except PlotaVizError as exc:
|
|
476
|
+
self.filter_panel.show_query_error(str(exc))
|
|
477
|
+
return
|
|
478
|
+
self._after_rerun()
|
|
479
|
+
|
|
480
|
+
def _rerun(self, label: str) -> None:
|
|
481
|
+
"""Replay the pipeline on a worker thread."""
|
|
482
|
+
if self.analysis is None:
|
|
483
|
+
return
|
|
484
|
+
self._run(self._rerun_task, label=label, on_finished=lambda _: self._after_rerun())
|
|
485
|
+
|
|
486
|
+
def _rerun_task(self, progress: Any = None) -> None:
|
|
487
|
+
"""Worker body: replay the pipeline."""
|
|
488
|
+
if progress:
|
|
489
|
+
progress(30, "Replaying the cleaning steps…")
|
|
490
|
+
if self.analysis:
|
|
491
|
+
self.analysis.rerun()
|
|
492
|
+
|
|
493
|
+
def _after_rerun(self) -> None:
|
|
494
|
+
"""Refresh every panel after a pipeline replay."""
|
|
495
|
+
if self.analysis is None:
|
|
496
|
+
return
|
|
497
|
+
self.preview_model.set_frame(self.analysis.df, self.analysis.profile)
|
|
498
|
+
self.preview_status.setText(self.preview_model.status_text())
|
|
499
|
+
if self.analysis.result:
|
|
500
|
+
self.cleaning_report.setPlainText(self.analysis.result.cleaning_report())
|
|
501
|
+
self.nl_bar.set_columns(list(self.analysis.df.columns))
|
|
502
|
+
self._populate_recommendations()
|
|
503
|
+
self._render_current()
|
|
504
|
+
|
|
505
|
+
# ------------------------------------------------------------------ LLM
|
|
506
|
+
|
|
507
|
+
def _assistant(self) -> LLMAssistant:
|
|
508
|
+
"""Build an assistant from the current settings."""
|
|
509
|
+
name = str(self.settings.value("llm/provider", "") or "")
|
|
510
|
+
if not name:
|
|
511
|
+
return LLMAssistant(None)
|
|
512
|
+
kwargs: dict[str, Any] = {}
|
|
513
|
+
model = str(self.settings.value("llm/model", "") or "")
|
|
514
|
+
if model:
|
|
515
|
+
kwargs["model"] = model
|
|
516
|
+
if name == "ollama":
|
|
517
|
+
kwargs["host"] = str(self.settings.value("llm/ollama_host", "http://localhost:11434"))
|
|
518
|
+
try:
|
|
519
|
+
provider = get_provider(name, **kwargs)
|
|
520
|
+
except PlotaVizError:
|
|
521
|
+
return LLMAssistant(None)
|
|
522
|
+
return LLMAssistant(provider, consent=self.settings.value("llm/consent", False, type=bool))
|
|
523
|
+
|
|
524
|
+
def _refresh_llm_state(self) -> None:
|
|
525
|
+
"""Enable or disable the query bar to match the configured provider."""
|
|
526
|
+
assistant = self._assistant()
|
|
527
|
+
self.nl_bar.set_enabled_for_provider(
|
|
528
|
+
assistant.available, assistant.provider.name if assistant.provider else ""
|
|
529
|
+
)
|
|
530
|
+
self.refine_button.setEnabled(assistant.available)
|
|
531
|
+
|
|
532
|
+
def _on_question(self, question: str) -> None:
|
|
533
|
+
"""Turn a natural-language question into a chart."""
|
|
534
|
+
if self.analysis is None or self.analysis.profile is None:
|
|
535
|
+
self.nl_bar.show_status("Open a dataset first.", error=True)
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
assistant = self._assistant()
|
|
539
|
+
profile = self.analysis.profile
|
|
540
|
+
self.nl_bar.set_busy(True)
|
|
541
|
+
|
|
542
|
+
def task(progress: Any = None) -> Any:
|
|
543
|
+
if progress:
|
|
544
|
+
progress(50, "Asking the model…")
|
|
545
|
+
return assistant.from_question(profile, question)
|
|
546
|
+
|
|
547
|
+
def done(result: Any) -> None:
|
|
548
|
+
self.nl_bar.set_busy(False)
|
|
549
|
+
spec = result.spec
|
|
550
|
+
try:
|
|
551
|
+
self.analysis.choose(spec) if self.analysis else None
|
|
552
|
+
except PlotaVizError as exc:
|
|
553
|
+
self.nl_bar.show_status(str(exc), error=True)
|
|
554
|
+
return
|
|
555
|
+
self.nl_bar.show_spec(spec)
|
|
556
|
+
self._show_why(spec)
|
|
557
|
+
self._render_current()
|
|
558
|
+
|
|
559
|
+
def failed(message: str, _trace: str) -> None:
|
|
560
|
+
self.nl_bar.set_busy(False)
|
|
561
|
+
self.nl_bar.show_status(message, error=True)
|
|
562
|
+
|
|
563
|
+
self._run(task, label="Asking the model…", on_finished=done, on_failed=failed)
|
|
564
|
+
|
|
565
|
+
def _on_refine(self) -> None:
|
|
566
|
+
"""Ask the model to break a tie between close candidates."""
|
|
567
|
+
if self.analysis is None or self.analysis.profile is None:
|
|
568
|
+
return
|
|
569
|
+
assistant = self._assistant()
|
|
570
|
+
profile = self.analysis.profile
|
|
571
|
+
candidates = list(self.analysis.recommendations)
|
|
572
|
+
|
|
573
|
+
def task(progress: Any = None) -> ChartSpec:
|
|
574
|
+
if progress:
|
|
575
|
+
progress(50, "Asking the model…")
|
|
576
|
+
return assistant.refine(profile, candidates)
|
|
577
|
+
|
|
578
|
+
def done(spec: ChartSpec) -> None:
|
|
579
|
+
if self.analysis is None:
|
|
580
|
+
return
|
|
581
|
+
try:
|
|
582
|
+
self.analysis.choose(spec)
|
|
583
|
+
except PlotaVizError as exc:
|
|
584
|
+
self._error("The model suggested a chart that does not fit", str(exc))
|
|
585
|
+
return
|
|
586
|
+
self._show_why(spec)
|
|
587
|
+
self._render_current()
|
|
588
|
+
|
|
589
|
+
self._run(task, label="Asking the model…", on_finished=done)
|
|
590
|
+
|
|
591
|
+
# ------------------------------------------------------------------ export / session
|
|
592
|
+
|
|
593
|
+
def export(self) -> None:
|
|
594
|
+
"""Export the chart as an image, an HTML page, or a Python script."""
|
|
595
|
+
if self.analysis is None or self.analysis.spec is None:
|
|
596
|
+
return
|
|
597
|
+
|
|
598
|
+
source = self.analysis.load_result.path
|
|
599
|
+
dialog = ExportDialog(self, default_name=source.stem or "chart", start_dir=source.parent)
|
|
600
|
+
if dialog.exec() != ExportDialog.DialogCode.Accepted:
|
|
601
|
+
return
|
|
602
|
+
|
|
603
|
+
target = dialog.path()
|
|
604
|
+
mode = dialog.mode
|
|
605
|
+
analysis = self.analysis
|
|
606
|
+
|
|
607
|
+
def task(progress: Any = None) -> Path:
|
|
608
|
+
if progress:
|
|
609
|
+
progress(40, f"Writing {target.name}…")
|
|
610
|
+
if mode == "code":
|
|
611
|
+
from ..core import codegen
|
|
612
|
+
|
|
613
|
+
return codegen.write(target, analysis.generate_code(flavour=dialog.flavour()))
|
|
614
|
+
if mode == "html":
|
|
615
|
+
from ..core import exporter
|
|
616
|
+
|
|
617
|
+
return exporter.export_html(analysis.df, analysis.spec, target) # type: ignore[arg-type]
|
|
618
|
+
from ..core import exporter
|
|
619
|
+
|
|
620
|
+
return exporter.export_image(
|
|
621
|
+
analysis.df,
|
|
622
|
+
analysis.spec,
|
|
623
|
+
target,
|
|
624
|
+
options=dialog.options(), # type: ignore[arg-type]
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
def done(written: Path) -> None:
|
|
628
|
+
self.statusBar().showMessage(f"Saved {written}")
|
|
629
|
+
box = QMessageBox(self)
|
|
630
|
+
box.setIcon(QMessageBox.Icon.Information)
|
|
631
|
+
box.setWindowTitle("Export complete")
|
|
632
|
+
box.setText(f"Saved {written.name}")
|
|
633
|
+
box.setInformativeText(str(written))
|
|
634
|
+
reveal = box.addButton("Show in folder", QMessageBox.ButtonRole.ActionRole)
|
|
635
|
+
box.addButton(QMessageBox.StandardButton.Ok)
|
|
636
|
+
box.exec()
|
|
637
|
+
if box.clickedButton() is reveal:
|
|
638
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(written.parent)))
|
|
639
|
+
|
|
640
|
+
self._run(task, label=f"Exporting {target.name}…", on_finished=done)
|
|
641
|
+
|
|
642
|
+
def save_session(self, *, ask: bool = False) -> None:
|
|
643
|
+
"""Save the current state to a ``.pviz`` file."""
|
|
644
|
+
if self.analysis is None:
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
target = self.session_path
|
|
648
|
+
if target is None or ask:
|
|
649
|
+
suggested = self.analysis.load_result.path.with_suffix(SESSION_SUFFIX)
|
|
650
|
+
chosen, _ = QFileDialog.getSaveFileName(
|
|
651
|
+
self, "Save session", str(suggested), f"PlotaViz sessions (*{SESSION_SUFFIX})"
|
|
652
|
+
)
|
|
653
|
+
if not chosen:
|
|
654
|
+
return
|
|
655
|
+
target = Path(chosen)
|
|
656
|
+
|
|
657
|
+
view = {
|
|
658
|
+
"renderer": self.chart_view.renderer,
|
|
659
|
+
"active_tab": self.analysis_tabs.currentIndex(),
|
|
660
|
+
"geometry": bytes(self.saveGeometry().toBase64().data()).decode(),
|
|
661
|
+
}
|
|
662
|
+
try:
|
|
663
|
+
written = self.analysis.to_session(view).save(target)
|
|
664
|
+
except PlotaVizError as exc:
|
|
665
|
+
self._error("Could not save the session", str(exc))
|
|
666
|
+
return
|
|
667
|
+
|
|
668
|
+
self.session_path = written
|
|
669
|
+
self.statusBar().showMessage(f"Session saved to {written}")
|
|
670
|
+
|
|
671
|
+
# ------------------------------------------------------------------ settings / about
|
|
672
|
+
|
|
673
|
+
def open_settings(self) -> None:
|
|
674
|
+
"""Show the settings dialog and apply anything that changed."""
|
|
675
|
+
dialog = SettingsDialog(self, settings=self.settings)
|
|
676
|
+
if dialog.exec() == SettingsDialog.DialogCode.Accepted:
|
|
677
|
+
self._refresh_llm_state()
|
|
678
|
+
self.chart_view.set_prefer_static(
|
|
679
|
+
self.settings.value("performance/prefer_static", False, type=bool)
|
|
680
|
+
)
|
|
681
|
+
self._render_current()
|
|
682
|
+
|
|
683
|
+
def show_about(self) -> None:
|
|
684
|
+
"""Show version and licensing information."""
|
|
685
|
+
QMessageBox.about(
|
|
686
|
+
self,
|
|
687
|
+
f"About {__app_name__}",
|
|
688
|
+
f"<h3>{__app_name__} {__version__}</h3>"
|
|
689
|
+
"<p>Automatic data analytics and visualization.</p>"
|
|
690
|
+
"<p>PlotaViz is MIT licensed. It uses Qt via PySide6, which is licensed under the "
|
|
691
|
+
"LGPLv3 and is dynamically linked; see THIRD_PARTY_LICENSES for attribution. "
|
|
692
|
+
"Charts are drawn with Plotly and matplotlib.</p>"
|
|
693
|
+
f"<p>Chart renderer in use: <b>{self.chart_view.renderer}</b>.</p>",
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
# ------------------------------------------------------------------ recent files
|
|
697
|
+
|
|
698
|
+
def _remember_recent(self, path: Path) -> None:
|
|
699
|
+
"""Push a path onto the recent-files list."""
|
|
700
|
+
recent = [str(p) for p in self.settings.value("paths/recent", [], type=list)]
|
|
701
|
+
entry = str(path)
|
|
702
|
+
recent = [entry] + [p for p in recent if p != entry]
|
|
703
|
+
self.settings.setValue("paths/recent", recent[:MAX_RECENT])
|
|
704
|
+
self._rebuild_recent_menu()
|
|
705
|
+
|
|
706
|
+
def _rebuild_recent_menu(self) -> None:
|
|
707
|
+
"""Refresh the Open Recent submenu."""
|
|
708
|
+
self.recent_menu.clear()
|
|
709
|
+
recent = [str(p) for p in self.settings.value("paths/recent", [], type=list)]
|
|
710
|
+
if not recent:
|
|
711
|
+
empty = QAction("Nothing yet", self)
|
|
712
|
+
empty.setEnabled(False)
|
|
713
|
+
self.recent_menu.addAction(empty)
|
|
714
|
+
return
|
|
715
|
+
for entry in recent:
|
|
716
|
+
action = QAction(Path(entry).name, self)
|
|
717
|
+
action.setToolTip(entry)
|
|
718
|
+
action.triggered.connect(lambda _=False, p=entry: self.open_path(p))
|
|
719
|
+
self.recent_menu.addAction(action)
|
|
720
|
+
self.recent_menu.addSeparator()
|
|
721
|
+
clear = QAction("Clear list", self)
|
|
722
|
+
clear.triggered.connect(
|
|
723
|
+
lambda: (self.settings.setValue("paths/recent", []), self._rebuild_recent_menu())
|
|
724
|
+
)
|
|
725
|
+
self.recent_menu.addAction(clear)
|
|
726
|
+
|
|
727
|
+
# ------------------------------------------------------------------ drag and drop
|
|
728
|
+
|
|
729
|
+
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
|
730
|
+
"""Accept a drag carrying one file PlotaViz can open."""
|
|
731
|
+
urls = event.mimeData().urls() if event.mimeData().hasUrls() else []
|
|
732
|
+
if len(urls) == 1:
|
|
733
|
+
suffix = Path(urls[0].toLocalFile()).suffix.lower()
|
|
734
|
+
if suffix in loader.SUPPORTED_SUFFIXES or suffix == SESSION_SUFFIX:
|
|
735
|
+
event.acceptProposedAction()
|
|
736
|
+
return
|
|
737
|
+
event.ignore()
|
|
738
|
+
|
|
739
|
+
def dropEvent(self, event: QDropEvent) -> None:
|
|
740
|
+
"""Open a dropped file."""
|
|
741
|
+
urls = event.mimeData().urls()
|
|
742
|
+
if urls:
|
|
743
|
+
self.open_path(urls[0].toLocalFile())
|
|
744
|
+
event.acceptProposedAction()
|
|
745
|
+
|
|
746
|
+
# ------------------------------------------------------------------ task plumbing
|
|
747
|
+
|
|
748
|
+
def _run(
|
|
749
|
+
self,
|
|
750
|
+
fn: Any,
|
|
751
|
+
*args: Any,
|
|
752
|
+
label: str = "Working…",
|
|
753
|
+
on_finished: Any = None,
|
|
754
|
+
on_failed: Any = None,
|
|
755
|
+
) -> None:
|
|
756
|
+
"""Run a callable on the worker thread with progress and cancellation."""
|
|
757
|
+
if self.runner.busy:
|
|
758
|
+
self.statusBar().showMessage("Still finishing the previous task…")
|
|
759
|
+
return
|
|
760
|
+
|
|
761
|
+
self.progress.setRange(0, 100)
|
|
762
|
+
self.progress.setValue(0)
|
|
763
|
+
self.progress.show()
|
|
764
|
+
self.cancel_button.show()
|
|
765
|
+
self.statusBar().showMessage(label)
|
|
766
|
+
|
|
767
|
+
def finished(result: Any) -> None:
|
|
768
|
+
self._end_task()
|
|
769
|
+
handler = on_finished or self._on_loaded
|
|
770
|
+
handler(result)
|
|
771
|
+
|
|
772
|
+
def failed(message: str, trace: str) -> None:
|
|
773
|
+
self._end_task()
|
|
774
|
+
if on_failed:
|
|
775
|
+
on_failed(message, trace)
|
|
776
|
+
else:
|
|
777
|
+
self._error("Something went wrong", message, detail=trace)
|
|
778
|
+
|
|
779
|
+
def progressed(percent: int, message: str) -> None:
|
|
780
|
+
self.progress.setValue(percent)
|
|
781
|
+
if message:
|
|
782
|
+
self.statusBar().showMessage(message)
|
|
783
|
+
|
|
784
|
+
self.runner.start(
|
|
785
|
+
fn,
|
|
786
|
+
*args,
|
|
787
|
+
on_finished=finished,
|
|
788
|
+
on_failed=failed,
|
|
789
|
+
on_progress=progressed,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
def _end_task(self) -> None:
|
|
793
|
+
"""Hide the progress widgets."""
|
|
794
|
+
self.progress.hide()
|
|
795
|
+
self.cancel_button.hide()
|
|
796
|
+
|
|
797
|
+
def _cancel_task(self) -> None:
|
|
798
|
+
"""Ask the running task to stop."""
|
|
799
|
+
self.runner.cancel()
|
|
800
|
+
self.statusBar().showMessage("Cancelling…")
|
|
801
|
+
|
|
802
|
+
# ------------------------------------------------------------------ dialogs
|
|
803
|
+
|
|
804
|
+
def _error(self, title: str, message: str, *, detail: str = "") -> None:
|
|
805
|
+
"""Show an error the user can act on. Tracebacks stay behind "Show details"."""
|
|
806
|
+
box = QMessageBox(self)
|
|
807
|
+
box.setIcon(QMessageBox.Icon.Warning)
|
|
808
|
+
box.setWindowTitle(title)
|
|
809
|
+
parts = message.split("\n\n", 1)
|
|
810
|
+
box.setText(parts[0])
|
|
811
|
+
if len(parts) > 1:
|
|
812
|
+
box.setInformativeText(parts[1])
|
|
813
|
+
if detail:
|
|
814
|
+
box.setDetailedText(detail)
|
|
815
|
+
box.exec()
|
|
816
|
+
|
|
817
|
+
def _warn(self, title: str, message: str) -> None:
|
|
818
|
+
"""Show a non-blocking advisory."""
|
|
819
|
+
QMessageBox.information(self, title, message)
|
|
820
|
+
|
|
821
|
+
# ------------------------------------------------------------------ window state
|
|
822
|
+
|
|
823
|
+
def _restore_geometry(self) -> None:
|
|
824
|
+
"""Restore window size and dock layout from the last session."""
|
|
825
|
+
geometry = self.settings.value("window/geometry")
|
|
826
|
+
state = self.settings.value("window/state")
|
|
827
|
+
if geometry:
|
|
828
|
+
self.restoreGeometry(geometry)
|
|
829
|
+
if state:
|
|
830
|
+
self.restoreState(state)
|
|
831
|
+
|
|
832
|
+
def closeEvent(self, event: Any) -> None:
|
|
833
|
+
"""Persist window state, stop background work, and clean up temp files."""
|
|
834
|
+
self.settings.setValue("window/geometry", self.saveGeometry())
|
|
835
|
+
self.settings.setValue("window/state", self.saveState())
|
|
836
|
+
self.settings.sync()
|
|
837
|
+
self.runner.cancel()
|
|
838
|
+
self.runner.wait()
|
|
839
|
+
self.chart_view.cleanup()
|
|
840
|
+
super().closeEvent(event)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def launch(initial_file: str | Path | None = None) -> int:
|
|
844
|
+
"""Create the application and show the window.
|
|
845
|
+
|
|
846
|
+
Args:
|
|
847
|
+
initial_file: Dataset or session to open on startup.
|
|
848
|
+
|
|
849
|
+
Returns:
|
|
850
|
+
The Qt exit code.
|
|
851
|
+
"""
|
|
852
|
+
app = QApplication.instance() or QApplication([])
|
|
853
|
+
app.setApplicationName(__app_name__)
|
|
854
|
+
app.setApplicationDisplayName(__app_name__)
|
|
855
|
+
app.setOrganizationName("plotaviz")
|
|
856
|
+
|
|
857
|
+
window = MainWindow(initial_file=initial_file)
|
|
858
|
+
window.show()
|
|
859
|
+
return int(app.exec())
|