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.
@@ -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())