qplotter 1.6.0b3__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.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
qplot/windows/main.py ADDED
@@ -0,0 +1,1635 @@
1
+ import os
2
+ import sys
3
+ import threading
4
+ from time import monotonic, perf_counter
5
+ from typing import cast
6
+
7
+ from PyQt6 import (
8
+ QtCore,
9
+ QtGui,
10
+ )
11
+ from PyQt6 import (
12
+ QtWidgets as qtw,
13
+ )
14
+
15
+ from qplot import config
16
+ from qplot.datahandling.database import (
17
+ DatabaseDetailWorker as DatabaseDetailWorker,
18
+ )
19
+ from qplot.datahandling.database import (
20
+ DatabaseExpensiveDetailWorker as DatabaseExpensiveDetailWorker,
21
+ )
22
+ from qplot.datahandling.database import (
23
+ DatabaseLoadWorker as DatabaseLoadWorker,
24
+ )
25
+ from qplot.datahandling.database import (
26
+ DatabaseRefreshWorker as DatabaseRefreshWorker,
27
+ )
28
+ from qplot.datahandling.database import (
29
+ DatabaseSelectedRunWorker as DatabaseSelectedRunWorker,
30
+ )
31
+ from qplot.datahandling.database import (
32
+ database_info_report as database_info_report,
33
+ )
34
+ from qplot.datahandling.database import (
35
+ database_path_from_mime_data as database_path_from_mime_data,
36
+ )
37
+ from qplot.diagnostics import get_logger, log_bounded_shutdown, log_user_error
38
+
39
+ from ._commands import create_action
40
+ from ._config_persistence import (
41
+ persist_config_action,
42
+ persist_config_value,
43
+ set_widget_value_without_signals,
44
+ )
45
+ from ._database_actions import DatabaseActionsMixin, TestDatabaseGenerationWorker
46
+ from ._dataset_handle import DatasetHandle, DatasetKey
47
+ from ._help import add_help_menu
48
+ from ._plot_actions import PlotActionsMixin
49
+ from ._preferences import (
50
+ PreferencesDialog,
51
+ create_preferences_action,
52
+ )
53
+ from ._run_controls import AUTO_PLOT_KEY, RunControlsMixin
54
+ from ._trusted_derived_qt import TrustedDerivedQtBridge
55
+ from ._window_controls import (
56
+ CONFIRM_CLOSE_ALL_KEY,
57
+ CONFIRM_QUIT_KEY,
58
+ add_application_quit_action,
59
+ add_restore_defaults_option,
60
+ add_standard_window_controls,
61
+ ask_confirmation_with_dont_ask_again,
62
+ close_all_warning_enabled,
63
+ )
64
+
65
+ MAIN_WINDOW_READABLE_WIDTH = 780
66
+ _APPLICATION_SHUTDOWN_TIMEOUT_SECONDS = 15.0
67
+ _APPLICATION_SHUTDOWN_DIAGNOSTIC_GRACE_SECONDS = 0.25
68
+ _APPLICATION_FORCED_SHUTDOWN_EXIT_CODE = 70
69
+ _RETIRED_SERVICE_REAPER_INTERVAL_MS = 100
70
+
71
+
72
+ def _flush_shutdown_diagnostics():
73
+ """Flush qPlot's persistent diagnostics and process text streams."""
74
+
75
+ for handler in tuple(get_logger().handlers):
76
+ try:
77
+ handler.flush()
78
+ stream = getattr(handler, "stream", None)
79
+ if stream is not None:
80
+ os.fsync(stream.fileno())
81
+ except BaseException:
82
+ pass
83
+ for stream in (sys.stderr, sys.stdout):
84
+ try:
85
+ stream.flush()
86
+ except BaseException:
87
+ pass
88
+
89
+
90
+ def _persist_shutdown_diagnostics(*, started_at, total_timeout, diagnostics):
91
+ """Persist an exact bounded-shutdown snapshot before a hard process exit."""
92
+
93
+ elapsed = max(0.0, monotonic() - started_at)
94
+ if not diagnostics:
95
+ diagnostics = ("background work remained active without diagnostics",)
96
+ details = f"elapsed={elapsed:.3f}s\n" + "\n".join(diagnostics)
97
+ try:
98
+ log_bounded_shutdown(
99
+ "Bounded Application Shutdown: "
100
+ f"the {total_timeout:g}-second monotonic shutdown deadline was "
101
+ "exhausted; qPlot will terminate at the process boundary if "
102
+ "Qt-owned work still cannot be destroyed.",
103
+ details,
104
+ logger_name=__name__,
105
+ )
106
+ finally:
107
+ _flush_shutdown_diagnostics()
108
+
109
+
110
+ class _ProcessShutdownFailSafe:
111
+ """Pair a direct-parent supervisor with a deadline-only local backstop."""
112
+
113
+ def __init__(
114
+ self,
115
+ supervisor_client=None,
116
+ *,
117
+ startup_diagnostic=None,
118
+ force_exit=os._exit,
119
+ ):
120
+ self._supervisor_client = supervisor_client
121
+ self._startup_diagnostic = (
122
+ None if startup_diagnostic is None else str(startup_diagnostic)
123
+ )
124
+ self._force_exit = force_exit
125
+ self._lock = threading.Lock()
126
+ self._persistence_io_lock = threading.Lock()
127
+ self._cancelled = threading.Event()
128
+ self._fallback_diagnostic_started = threading.Event()
129
+ self._fallback_diagnostic_completed = threading.Event()
130
+ self._armed = False
131
+ self._started_at = 0.0
132
+ self._diagnostic_deadline = 0.0
133
+ self._hard_deadline = 0.0
134
+ self._total_timeout = 0.0
135
+ self._diagnostics: tuple[str, ...] = ()
136
+ self._supervisor_diagnostics: tuple[str, ...] = ()
137
+ self._diagnostic_version = 0
138
+ self._persisted_version = -1
139
+ self._supervisor_acknowledged = False
140
+ self._fallback_active = False
141
+ self._persistence_active = False
142
+
143
+ @property
144
+ def armed(self):
145
+ with self._lock:
146
+ return self._armed
147
+
148
+ def arm(self, *, started_at, diagnostic_deadline, hard_deadline):
149
+ """Arm once after shutdown confirmation; later calls never extend it."""
150
+
151
+ with self._lock:
152
+ if self._armed:
153
+ return None
154
+ self._armed = True
155
+ self._started_at = started_at
156
+ self._diagnostic_deadline = diagnostic_deadline
157
+ self._hard_deadline = hard_deadline
158
+ self._total_timeout = max(0.0, hard_deadline - started_at)
159
+ self._diagnostics = ()
160
+ self._supervisor_diagnostics = ()
161
+ self._diagnostic_version = 0
162
+ self._persisted_version = -1
163
+ self._supervisor_acknowledged = False
164
+ self._fallback_active = False
165
+ self._persistence_active = False
166
+ self._cancelled.clear()
167
+ self._fallback_diagnostic_started.clear()
168
+ self._fallback_diagnostic_completed.clear()
169
+
170
+ if self._startup_diagnostic is not None:
171
+ self._append_supervisor_diagnostic(self._startup_diagnostic)
172
+ client = self._supervisor_client
173
+ if client is None:
174
+ self._activate_fallback()
175
+ diagnostic = self._startup_diagnostic
176
+ if diagnostic is None:
177
+ diagnostic = (
178
+ "process shutdown supervisor is unavailable; "
179
+ "only the in-process deadline fallback is armed"
180
+ )
181
+ self._append_supervisor_diagnostic(diagnostic)
182
+ return diagnostic
183
+
184
+ try:
185
+ arm_diagnostic = client.arm(hard_deadline)
186
+ except BaseException as error:
187
+ arm_diagnostic = (
188
+ "process shutdown supervisor ARM raised "
189
+ f"{type(error).__name__}: {error}"
190
+ )
191
+ # The direct parent has either installed its immutable deadline or
192
+ # returned an exact failure by this point. Install the independent
193
+ # GUI-local backstop before doing any diagnostic persistence.
194
+ self._activate_fallback()
195
+ if arm_diagnostic:
196
+ exact = str(arm_diagnostic)
197
+ self._append_supervisor_diagnostic(exact)
198
+ return exact
199
+ with self._lock:
200
+ self._supervisor_acknowledged = True
201
+ return self._latest_supervisor_diagnostic()
202
+
203
+ def update_diagnostics(self, diagnostics):
204
+ """Publish an in-memory liveness snapshot without doing diagnostic I/O."""
205
+
206
+ exact = tuple(str(item) for item in diagnostics)
207
+ with self._lock:
208
+ if exact == self._diagnostics:
209
+ return
210
+ self._diagnostics = exact
211
+ self._diagnostic_version += 1
212
+
213
+ def persist_now(self):
214
+ """Persist the newest exact snapshot, once per diagnostic version."""
215
+
216
+ with self._persistence_io_lock:
217
+ with self._lock:
218
+ if not self._armed:
219
+ return
220
+ version = self._diagnostic_version
221
+ if version <= self._persisted_version:
222
+ return
223
+ started_at = self._started_at
224
+ total_timeout = self._total_timeout
225
+ diagnostics = self._effective_diagnostics_locked()
226
+ _persist_shutdown_diagnostics(
227
+ started_at=started_at,
228
+ total_timeout=total_timeout,
229
+ diagnostics=diagnostics,
230
+ )
231
+ with self._lock:
232
+ self._persisted_version = max(self._persisted_version, version)
233
+
234
+ def persist_async(self):
235
+ """Persist on a daemon thread so diagnostic I/O cannot delay exit."""
236
+
237
+ with self._lock:
238
+ if not self._armed or self._persistence_active:
239
+ return
240
+ self._persistence_active = True
241
+ try:
242
+ threading.Thread(
243
+ target=self._persist_async_worker,
244
+ name="qplot-shutdown-diagnostic-persistence",
245
+ daemon=True,
246
+ ).start()
247
+ except BaseException as error:
248
+ with self._lock:
249
+ self._persistence_active = False
250
+ self._append_supervisor_diagnostic(
251
+ "process shutdown diagnostic thread setup raised "
252
+ f"{type(error).__name__}: {error}"
253
+ )
254
+
255
+ def _persist_async_worker(self):
256
+ try:
257
+ while True:
258
+ self.persist_now()
259
+ with self._lock:
260
+ if (
261
+ not self._armed
262
+ or self._persisted_version >= self._diagnostic_version
263
+ ):
264
+ self._persistence_active = False
265
+ return
266
+ finally:
267
+ with self._lock:
268
+ self._persistence_active = False
269
+
270
+ def disarm(self):
271
+ """Cancel only the GUI-local fallback after quiescent Qt teardown."""
272
+
273
+ # There is deliberately no supervisor DISARM message. Once ARM was
274
+ # accepted, the direct parent remains armed until it reaps this process.
275
+ with self._lock:
276
+ self._armed = False
277
+ self._cancelled.set()
278
+
279
+ def wait_for_forced_exit(self):
280
+ """Reach the process boundary before any possibly blocking destructor."""
281
+
282
+ with self._lock:
283
+ hard_deadline = self._hard_deadline
284
+ armed = self._armed
285
+ if not armed:
286
+ return
287
+ if self._cancelled.wait(max(0.0, hard_deadline - monotonic())):
288
+ return
289
+ self._force_exit(_APPLICATION_FORCED_SHUTDOWN_EXIT_CODE)
290
+
291
+ def watchdog_operational(self):
292
+ """Return whether the direct parent acknowledged immutable ARM."""
293
+
294
+ with self._lock:
295
+ return self._armed and self._supervisor_acknowledged
296
+
297
+ def _fallback_diagnostic_watchdog(self):
298
+ self._fallback_diagnostic_started.set()
299
+ if self._cancelled.wait(max(0.0, self._diagnostic_deadline - monotonic())):
300
+ return
301
+ # This watchdog already runs away from the GUI thread. Persist here
302
+ # instead of scheduling a second daemon so the hard-deadline watchdog
303
+ # cannot overtake diagnostic publication merely because that extra
304
+ # thread has not yet been scheduled.
305
+ try:
306
+ self.persist_now()
307
+ finally:
308
+ self._fallback_diagnostic_completed.set()
309
+
310
+ def _fallback_termination_watchdog(self):
311
+ # Prefer a completed diagnostic snapshot before termination without
312
+ # ever allowing slow or blocked diagnostic I/O to extend the immutable
313
+ # hard deadline. This also prevents the two fallback threads racing at
314
+ # the deadline after ordinary, fast persistence has already started.
315
+ self._fallback_diagnostic_completed.wait(
316
+ max(0.0, self._hard_deadline - monotonic())
317
+ )
318
+ if self._cancelled.wait(max(0.0, self._hard_deadline - monotonic())):
319
+ return
320
+ self._force_exit(_APPLICATION_FORCED_SHUTDOWN_EXIT_CODE)
321
+
322
+ def _activate_fallback(self):
323
+ with self._lock:
324
+ if self._fallback_active or not self._armed:
325
+ return
326
+ self._fallback_active = True
327
+ for name, target in (
328
+ (
329
+ "qplot-shutdown-diagnostic-fallback",
330
+ self._fallback_diagnostic_watchdog,
331
+ ),
332
+ (
333
+ "qplot-shutdown-process-fallback",
334
+ self._fallback_termination_watchdog,
335
+ ),
336
+ ):
337
+ try:
338
+ threading.Thread(target=target, name=name, daemon=True).start()
339
+ if target == self._fallback_diagnostic_watchdog:
340
+ readiness_deadline = min(
341
+ self._diagnostic_deadline,
342
+ self._hard_deadline,
343
+ )
344
+ self._fallback_diagnostic_started.wait(
345
+ max(0.0, readiness_deadline - monotonic())
346
+ )
347
+ except BaseException as error:
348
+ self._append_supervisor_diagnostic(
349
+ "process shutdown local fallback setup raised "
350
+ f"{type(error).__name__}: {error}"
351
+ )
352
+
353
+ def _append_supervisor_diagnostic(self, diagnostic):
354
+ exact = str(diagnostic)
355
+ with self._lock:
356
+ if exact in self._supervisor_diagnostics:
357
+ return
358
+ self._supervisor_diagnostics = (*self._supervisor_diagnostics, exact)
359
+ self._diagnostic_version += 1
360
+
361
+ def _latest_supervisor_diagnostic(self):
362
+ with self._lock:
363
+ if not self._supervisor_diagnostics:
364
+ return None
365
+ return self._supervisor_diagnostics[-1]
366
+
367
+ def _effective_diagnostics_locked(self):
368
+ return tuple(dict.fromkeys((*self._supervisor_diagnostics, *self._diagnostics)))
369
+
370
+
371
+ class DatabasePathLineEdit(qtw.QLineEdit):
372
+ """
373
+ Read-only database path field that accepts dropped QCoDeS database files.
374
+
375
+ """
376
+ databaseDropped = QtCore.pyqtSignal(str)
377
+
378
+ def __init__(self, *args, **kwargs):
379
+ super().__init__(*args, **kwargs)
380
+
381
+ self._database_path = ""
382
+ self.setAcceptDrops(True)
383
+
384
+ def setText(self, text):
385
+ self._database_path = str(text or "")
386
+
387
+ if not self._database_path:
388
+ super().setText("")
389
+ self.setToolTip("Current database path. Drop a QCoDeS .db file here to load it.")
390
+ return
391
+
392
+ super().setText(os.path.basename(self._database_path) or self._database_path)
393
+ self.setCursorPosition(0)
394
+ self.setToolTip(
395
+ "Current database:\n"
396
+ f"{self._database_path}\n\n"
397
+ "Drop a QCoDeS .db file here to load it."
398
+ )
399
+
400
+ def text(self):
401
+ return self._database_path
402
+
403
+ def dragEnterEvent(self, event):
404
+ if database_path_from_mime_data(event.mimeData()) is not None:
405
+ event.acceptProposedAction()
406
+ else:
407
+ event.ignore()
408
+
409
+ def dragMoveEvent(self, event):
410
+ self.dragEnterEvent(event)
411
+
412
+ def dropEvent(self, event):
413
+ path = database_path_from_mime_data(event.mimeData())
414
+ if path is None:
415
+ event.ignore()
416
+ return
417
+
418
+ event.acceptProposedAction()
419
+ self.databaseDropped.emit(os.path.abspath(path))
420
+
421
+
422
+ class MainWindow(
423
+ DatabaseActionsMixin,
424
+ PlotActionsMixin,
425
+ RunControlsMixin,
426
+ qtw.QMainWindow,
427
+ ):
428
+ """
429
+ The Main application which connects/initialises QCoDeS database, displays
430
+ available options plots to open, and opens windows.
431
+
432
+ This window can be opened by calling qplot.run()
433
+
434
+ Holds a shallow copy of all other open windows to prevent deletion by
435
+ python's garbarge collector
436
+ """
437
+
438
+ def __init__(self, startup_database_path=None):
439
+ startup_start = perf_counter()
440
+ super().__init__()
441
+
442
+ #vars
443
+ self.config = config() # Connect to config.json in :/users/<user>/.qplot/
444
+ self.windows = [] # prevent auto delete of windows
445
+ self.ds = None
446
+ self._selected_dataset_key: DatasetKey | None = None
447
+ self.preview_size = self._configured_preview_size()
448
+ self.dataset_holder: dict[DatasetKey, DatasetHandle] = {}
449
+ self.monitor = QtCore.QTimer()
450
+ self.threadPool = QtCore.QThreadPool()
451
+ self.threadPool.setMaxThreadCount(self.config.get("runtime_settings.max_threads"))
452
+ self._plot_workers: set[object] = set()
453
+ self.threadPool._qplot_workers = self._plot_workers # type: ignore[attr-defined]
454
+ self.databaseLoadThreadPool = QtCore.QThreadPool(self)
455
+ self.databaseLoadThreadPool.setMaxThreadCount(1)
456
+ self.databaseDetailThreadPool = QtCore.QThreadPool(self)
457
+ self.databaseDetailThreadPool.setMaxThreadCount(1)
458
+ self.databaseExpensiveDetailThreadPool = QtCore.QThreadPool(self)
459
+ self.databaseExpensiveDetailThreadPool.setMaxThreadCount(1)
460
+ self.databaseRefreshThreadPool = QtCore.QThreadPool(self)
461
+ self.databaseRefreshThreadPool.setMaxThreadCount(1)
462
+ self.databaseSelectedRunThreadPool = QtCore.QThreadPool(self)
463
+ self.databaseSelectedRunThreadPool.setMaxThreadCount(1)
464
+ self.testDatabaseGenerationThreadPool = QtCore.QThreadPool(self)
465
+ self.testDatabaseGenerationThreadPool.setMaxThreadCount(1)
466
+ self._database_load_generation = 0
467
+ self._database_load_active = False
468
+ self._database_load_state = None
469
+ self._database_load_worker = None
470
+ self._database_info_generation = 0
471
+ self._database_info_worker = None
472
+ self._database_info_instance = None
473
+ self._database_info_active = False
474
+ self._loaded_database_identity = None
475
+ self._loaded_database_instance = None # type: ignore[assignment]
476
+ self._database_detail_generation = 0
477
+ self._database_detail_active = False
478
+ self._database_detail_worker = None
479
+ self._database_detail_instance = None
480
+ self._database_expensive_detail_generation = 0
481
+ self._database_expensive_detail_active = False
482
+ self._database_expensive_detail_worker = None
483
+ self._database_expensive_detail_instance = None
484
+ self._database_refresh_generation = 0
485
+ self._database_refresh_active = False
486
+ self._database_refresh_pending = False
487
+ self._database_refresh_worker: DatabaseRefreshWorker | None = None
488
+ self._database_refresh_identity = None
489
+ self._database_refresh_instance = None
490
+ self._database_refresh_staged_new_runs = {}
491
+ self._database_refresh_publication_active = False
492
+ self._database_selected_run_generation = 0
493
+ self._database_selected_run_worker: DatabaseSelectedRunWorker | None = None
494
+ self._database_selected_run_instance = None
495
+ self._database_selected_run_mode = None
496
+ self._restoring_selected_run_publication = False
497
+ self._selected_run_restore_pending = False
498
+ self._selected_run_guid = None
499
+ self._selected_run_detail_cache = {}
500
+ self._selected_run_partial_detail_keys = set()
501
+ self._snapshot_setpoint_summary_cache = {}
502
+ self._trusted_read_service = None
503
+ self._pending_trusted_read_services = {}
504
+ self._retired_trusted_read_services = set()
505
+ self._retired_service_reap_diagnostics: dict[int, str] = {}
506
+ self._database_access_mode = None
507
+ self._database_fallback_reason = None
508
+ self._test_database_generation_active = False
509
+ self._test_database_generation_worker: TestDatabaseGenerationWorker | None = None
510
+ self._test_database_replacement_state = None
511
+ self._database_view_released_for_generation = False
512
+ self._shutdown_started = False
513
+ self._shutdown_ready = False
514
+ self._shutdown_started_at = None
515
+ self._shutdown_deadline = None
516
+ self._shutdown_hard_deadline = None
517
+ self._shutdown_cleanup_escalated = False
518
+ self._shutdown_escalation_diagnostics: tuple[str, ...] = ()
519
+ self._shutdown_liveness_diagnostics: tuple[str, ...] = ()
520
+ self._shutdown_last_diagnostics: tuple[str, ...] = ()
521
+ self._shutdown_diagnostics: tuple[str, ...] = ()
522
+ self._shutdown_deadline_exhausted = False
523
+ # The process entry point installs and owns the fail-safe. Keeping an
524
+ # ordinary embedded/test MainWindow inert avoids an unexpected hard
525
+ # exit in a host process that owns its own Qt lifecycle.
526
+ self._shutdown_process_fail_safe = None
527
+ self._shutdown_timer = QtCore.QTimer(self)
528
+ self._shutdown_timer.setInterval(25)
529
+ self._shutdown_timer.timeout.connect(self._finish_deferred_shutdown)
530
+ self._retired_service_reaper_timer = QtCore.QTimer(self)
531
+ self._retired_service_reaper_timer.setInterval(
532
+ _RETIRED_SERVICE_REAPER_INTERVAL_MS
533
+ )
534
+ self._retired_service_reaper_timer.timeout.connect(
535
+ self._reap_retired_trusted_read_services
536
+ )
537
+ self._next_plot_x = 0
538
+ self._next_plot_y = 0
539
+ self.localLastFile = None
540
+ self.startup_database_path = startup_database_path
541
+
542
+ # Set GUI color and style from user choice in qplot.configuration.themes
543
+ self.setStyleSheet(self.config.theme.main)
544
+
545
+ #widgets
546
+ self.l = qtw.QVBoxLayout()
547
+ self.l.setContentsMargins(8, 8, 8, 4)
548
+ self.l.setSpacing(6)
549
+
550
+ #Core initialisation functions
551
+ self.initRefresh()
552
+ self.initMenu()
553
+ self.initFile()
554
+ self.initRunDisplay()
555
+ self._trusted_derived_bridge = TrustedDerivedQtBridge(self)
556
+ self.initShortcuts()
557
+ self.startupDatabaseTimer = QtCore.QTimer(self)
558
+ self.startupDatabaseTimer.setSingleShot(True)
559
+ self.startupDatabaseTimer.timeout.connect(self.load_startup_database)
560
+
561
+ #Final Setup
562
+ w = qtw.QFrame()
563
+ w.setLayout(self.l)
564
+ self.setCentralWidget(w)
565
+
566
+ # Fetch window size from config.json, but keep the run list readable.
567
+ configured_width, configured_height = self.config.get("GUI.main_frame_size")
568
+ self.resize(
569
+ max(configured_width, MAIN_WINDOW_READABLE_WIDTH),
570
+ configured_height,
571
+ )
572
+ self.setWindowTitle("qPlot")
573
+ startup_elapsed = perf_counter() - startup_start
574
+ self.show_status(f"Ready - QPlot opened in {startup_elapsed:.2f} s")
575
+
576
+ # Get user's window dimensions to control new window position
577
+ primary_screen = cast(QtGui.QScreen, qtw.QApplication.primaryScreen())
578
+ self.screenrect = primary_screen.availableGeometry()
579
+ self._next_plot_x = self.screenrect.left()
580
+ self._next_plot_y = self.screenrect.top()
581
+
582
+ # Try to bring window to top
583
+ self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowType.WindowStaysOnTopHint)
584
+ self.show()
585
+ self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowType.WindowStaysOnTopHint)
586
+ self.show()
587
+ startup_warning = getattr(self.config, "startup_warning", None)
588
+ if startup_warning:
589
+ self.show_status(startup_warning, 10_000)
590
+ self.startupDatabaseTimer.start(0)
591
+
592
+
593
+ def initMenu(self):
594
+ """
595
+ Produces the menu bar and all menu's contained at the top of the window
596
+
597
+ """
598
+ menu = cast(qtw.QMenuBar, self.menuBar())
599
+ # First dropdown menu
600
+ fileMenu = cast(qtw.QMenu, menu.addMenu("&File")) # Not sure why these all have &, but they do
601
+
602
+ # Load database file
603
+ loadAction = create_action("database.load", self)
604
+ loadAction.triggered.connect(self.getfile)
605
+ fileMenu.addAction(loadAction)
606
+
607
+ self.recentDatabaseMenu = cast(qtw.QMenu, fileMenu.addMenu("Load &Recent Database"))
608
+ self.refresh_recent_database_menu()
609
+
610
+ open_folder_action = create_action(
611
+ "database.open_folder",
612
+ self,
613
+ )
614
+ open_folder_action.triggered.connect(self.open_database_location)
615
+ fileMenu.addAction(open_folder_action)
616
+
617
+ self.closeDatabaseAction = create_action("database.close", self)
618
+ self.closeDatabaseAction.triggered.connect(self.close_current_database)
619
+ fileMenu.addAction(self.closeDatabaseAction)
620
+
621
+ # Force update check on database
622
+ refreshAction = create_action("window.refresh", self)
623
+ refreshAction.triggered.connect(self.refreshMain)
624
+ fileMenu.addAction(refreshAction)
625
+
626
+ fileMenu.addSeparator()
627
+
628
+ test_data_menu = cast(qtw.QMenu, fileMenu.addMenu("Generate &Test Data"))
629
+ create_csv_action = create_action("testdata.create_csv", self)
630
+ create_csv_action.triggered.connect(self.create_test_database_csv)
631
+ test_data_menu.addAction(create_csv_action)
632
+ export_collection_action = create_action(
633
+ "testdata.export_collection",
634
+ self,
635
+ )
636
+ export_collection_action.triggered.connect(
637
+ self.export_test_database_csv_collection
638
+ )
639
+ test_data_menu.addAction(export_collection_action)
640
+ self.generateTestDatabaseAction = create_action(
641
+ "testdata.generate_database",
642
+ self,
643
+ )
644
+ self.generateTestDatabaseAction.triggered.connect(
645
+ self.generate_test_database_from_csv
646
+ )
647
+ test_data_menu.addAction(self.generateTestDatabaseAction)
648
+
649
+ fileMenu.addSeparator()
650
+
651
+ self.closeAllPlotsAction = create_action(
652
+ "plots.close_all",
653
+ self,
654
+ status_tip="Close all open plot windows",
655
+ )
656
+ self.closeAllPlotsAction.triggered.connect(self.closeAll)
657
+ fileMenu.addAction(self.closeAllPlotsAction)
658
+
659
+ closeAction = create_action(
660
+ "window.close",
661
+ self,
662
+ status_tip="Close the main qPlot window",
663
+ )
664
+ closeAction.triggered.connect(self.close)
665
+ fileMenu.addAction(closeAction)
666
+
667
+ add_application_quit_action(self, fileMenu, self.quit_application)
668
+
669
+ add_standard_window_controls(self)
670
+
671
+ # Second dropdown menu
672
+ prefMenu = cast(qtw.QMenu, menu.addMenu("&Options"))
673
+
674
+ prefMenu.addAction(
675
+ create_preferences_action(self, self.show_preferences_dialog)
676
+ )
677
+ prefMenu.addSeparator()
678
+ add_restore_defaults_option(self, prefMenu)
679
+ add_help_menu(self)
680
+
681
+ def initFile(self):
682
+ """
683
+ Display text box for current selected database
684
+
685
+ """
686
+ self.targetLayout = qtw.QHBoxLayout()
687
+ style = cast(qtw.QStyle, self.style())
688
+ self.targetLayout.setContentsMargins(8, 2, 8, 2)
689
+ self.targetLayout.setSpacing(6)
690
+
691
+ database_label = qtw.QLabel("Database:")
692
+ database_label.setToolTip("Current QCoDeS database")
693
+ self.targetLayout.addWidget(database_label)
694
+
695
+ self.fileTextbox = DatabasePathLineEdit()
696
+ self.fileTextbox.setObjectName("databasePathField")
697
+ self.fileTextbox.setReadOnly(True)
698
+ self.fileTextbox.setPlaceholderText("Drop a QCoDeS .db file here or use File -> Load")
699
+ self.fileTextbox.setToolTip(
700
+ "Current database path. Drop a QCoDeS .db file here to load it."
701
+ )
702
+ self.fileTextbox.databaseDropped.connect(self.load_database_path)
703
+ self.targetLayout.addWidget(self.fileTextbox, 1)
704
+
705
+ self.copyDatabasePathButton = qtw.QToolButton()
706
+ self.copyDatabasePathButton.setObjectName("databaseIconButton")
707
+ self.copyDatabasePathButton.setIcon(
708
+ style.standardIcon(qtw.QStyle.StandardPixmap.SP_FileDialogDetailedView)
709
+ )
710
+ self.copyDatabasePathButton.setToolTip("Copy the full database path")
711
+ self.copyDatabasePathButton.setAccessibleName("Copy database path")
712
+ self.copyDatabasePathButton.setFixedSize(28, 26)
713
+ self.copyDatabasePathButton.clicked.connect(self.copy_database_path)
714
+ self.targetLayout.addWidget(self.copyDatabasePathButton)
715
+
716
+ self.databaseInfoButton = qtw.QToolButton()
717
+ self.databaseInfoButton.setObjectName("databaseIconButton")
718
+ self.databaseInfoButton.setIcon(
719
+ style.standardIcon(qtw.QStyle.StandardPixmap.SP_MessageBoxInformation)
720
+ )
721
+ self.databaseInfoButton.setToolTip("Show database information")
722
+ self.databaseInfoButton.setAccessibleName("Show database information")
723
+ self.databaseInfoButton.setFixedSize(28, 26)
724
+ self.databaseInfoButton.clicked.connect(self.show_database_info)
725
+ self.targetLayout.addWidget(self.databaseInfoButton)
726
+
727
+ self.loadDatabaseButton = qtw.QToolButton()
728
+ self.loadDatabaseButton.setObjectName("databaseIconButton")
729
+ self.loadDatabaseButton.setIcon(
730
+ style.standardIcon(qtw.QStyle.StandardPixmap.SP_DialogOpenButton)
731
+ )
732
+ self.loadDatabaseButton.setToolTip("Load a QCoDeS .db database (Ctrl+L)")
733
+ self.loadDatabaseButton.setAccessibleName("Load database")
734
+ self.loadDatabaseButton.setFixedSize(28, 26)
735
+ self.loadDatabaseButton.clicked.connect(self.getfile)
736
+ self.targetLayout.addWidget(self.loadDatabaseButton)
737
+
738
+ self.openDatabaseFolderButton = qtw.QToolButton()
739
+ self.openDatabaseFolderButton.setObjectName("databaseIconButton")
740
+ self.openDatabaseFolderButton.setIcon(
741
+ style.standardIcon(qtw.QStyle.StandardPixmap.SP_DirOpenIcon)
742
+ )
743
+ self.openDatabaseFolderButton.setToolTip(
744
+ "Open the folder containing the current database (Ctrl+Shift+D)"
745
+ )
746
+ self.openDatabaseFolderButton.setAccessibleName("Open database folder")
747
+ self.openDatabaseFolderButton.setFixedSize(28, 26)
748
+ self.openDatabaseFolderButton.clicked.connect(self.open_database_location)
749
+ self.targetLayout.addWidget(self.openDatabaseFolderButton)
750
+
751
+ self.targetLayout.addStretch()
752
+ self.targetLayout.addSpacing(18)
753
+ self.targetLayout.addWidget(self.closeAllPlotsButton)
754
+
755
+ self.databaseLoadFrame = qtw.QFrame()
756
+ self.databaseLoadFrame.setObjectName("databaseLoadFrame")
757
+ database_load_layout = qtw.QHBoxLayout(self.databaseLoadFrame)
758
+ database_load_layout.setContentsMargins(8, 0, 8, 2)
759
+ database_load_layout.setSpacing(6)
760
+
761
+ self.databaseLoadProgress = qtw.QProgressBar()
762
+ self.databaseLoadProgress.setObjectName("databaseLoadProgress")
763
+ self.databaseLoadProgress.setRange(0, 0)
764
+ self.databaseLoadProgress.setTextVisible(False)
765
+ self.databaseLoadProgress.setFixedWidth(120)
766
+ self.databaseLoadProgress.setMaximumHeight(16)
767
+ self.databaseLoadProgress.setAccessibleName("Database load progress")
768
+ database_load_layout.addWidget(self.databaseLoadProgress)
769
+
770
+ self.databaseLoadLabel = qtw.QLabel("")
771
+ self.databaseLoadLabel.setObjectName("databaseLoadLabel")
772
+ self.databaseLoadLabel.setSizePolicy(
773
+ qtw.QSizePolicy.Policy.Expanding,
774
+ qtw.QSizePolicy.Policy.Preferred,
775
+ )
776
+ database_load_layout.addWidget(self.databaseLoadLabel, 1)
777
+
778
+ self.databaseLoadCancelButton = qtw.QToolButton()
779
+ self.databaseLoadCancelButton.setObjectName("databaseIconButton")
780
+ self.databaseLoadCancelButton.setIcon(
781
+ style.standardIcon(qtw.QStyle.StandardPixmap.SP_DialogCancelButton)
782
+ )
783
+ self.databaseLoadCancelButton.setText("Cancel")
784
+ self.databaseLoadCancelButton.setToolButtonStyle(
785
+ QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon
786
+ )
787
+ self.databaseLoadCancelButton.setToolTip("Cancel the current database load")
788
+ self.databaseLoadCancelButton.setAccessibleName("Cancel database load")
789
+ self.databaseLoadCancelButton.setFixedSize(78, 24)
790
+ self.databaseLoadCancelButton.clicked.connect(self.cancel_database_load)
791
+ database_load_layout.addWidget(self.databaseLoadCancelButton)
792
+ self.databaseLoadFrame.setVisible(False)
793
+
794
+ ###############################################################################
795
+ #Open/Close events
796
+
797
+ @QtCore.pyqtSlot()
798
+ def close_current_database(self):
799
+ """
800
+ Closes plot windows before releasing the current database.
801
+
802
+ """
803
+ if not self.fileTextbox.text():
804
+ self.show_status("No database is loaded.", 3000)
805
+ return
806
+
807
+ if not self.close_plot_windows(confirm=True, status=False):
808
+ self.show_status("Database close cancelled.", 3000)
809
+ return
810
+
811
+ self.close_database()
812
+
813
+
814
+ @QtCore.pyqtSlot()
815
+ def quit_application(self):
816
+ """
817
+ Routes the Quit command through the main window's shutdown handler.
818
+
819
+ """
820
+ self.close()
821
+
822
+
823
+ @QtCore.pyqtSlot(bool)
824
+ def closeEvent(self, event):
825
+ """
826
+ Event handler for closing Main Window.
827
+
828
+ Also handles some closing admin
829
+
830
+ """
831
+ if getattr(self, "_shutdown_ready", False):
832
+ event.accept()
833
+ return
834
+ if getattr(self, "_shutdown_started", False):
835
+ event.ignore()
836
+ return
837
+
838
+ # Confirm exit
839
+ if self.config.get(CONFIRM_QUIT_KEY):
840
+ reply = ask_confirmation_with_dont_ask_again(
841
+ self,
842
+ "Confirm Exit",
843
+ "Are you sure you want to exit?",
844
+ CONFIRM_QUIT_KEY,
845
+ )
846
+ if reply != qtw.QMessageBox.StandardButton.Yes:
847
+ event.ignore()
848
+ return
849
+
850
+ # One monotonic total bound covers cancellation, cleanup escalation,
851
+ # diagnostic persistence, and hard process termination. The final
852
+ # grace is inside that bound, never appended to it.
853
+ shutdown_started_at = monotonic()
854
+ total_timeout = max(0.0, _APPLICATION_SHUTDOWN_TIMEOUT_SECONDS)
855
+ diagnostic_grace = min(
856
+ _APPLICATION_SHUTDOWN_DIAGNOSTIC_GRACE_SECONDS,
857
+ total_timeout * 0.25,
858
+ )
859
+ hard_deadline = shutdown_started_at + total_timeout
860
+ self._shutdown_started_at = shutdown_started_at
861
+ self._shutdown_deadline = hard_deadline - diagnostic_grace
862
+ self._shutdown_hard_deadline = hard_deadline
863
+ self._shutdown_cleanup_escalated = False
864
+ self._shutdown_escalation_diagnostics = ()
865
+ self._shutdown_liveness_diagnostics = ()
866
+ self._shutdown_last_diagnostics = ()
867
+ self._shutdown_diagnostics = ()
868
+ self._shutdown_deadline_exhausted = False
869
+ process_fail_safe = getattr(self, "_shutdown_process_fail_safe", None)
870
+ if process_fail_safe is not None:
871
+ launch_diagnostic = process_fail_safe.arm(
872
+ started_at=shutdown_started_at,
873
+ diagnostic_deadline=self._shutdown_deadline,
874
+ hard_deadline=hard_deadline,
875
+ )
876
+ if launch_diagnostic:
877
+ self._shutdown_escalation_diagnostics = (launch_diagnostic,)
878
+ MainWindow._publish_shutdown_diagnostics(self)
879
+
880
+ # Mark the refresh lifecycle as shut down before cancelling workers.
881
+ # This also invalidates a QTimer timeout that was already queued.
882
+ self._automatic_refresh_shutdown = True
883
+ stop_refresh_timer = getattr(self, "_stop_automatic_refresh_timer", None)
884
+ if callable(stop_refresh_timer):
885
+ stop_refresh_timer()
886
+
887
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
888
+ if derived_bridge is not None:
889
+ derived_bridge.shutdown()
890
+ preview = getattr(getattr(self, "infoBox", None), "preview", None)
891
+ if preview is not None:
892
+ preview.shutdown()
893
+ self.startupDatabaseTimer.stop()
894
+ worker = getattr(self, "_database_load_worker", None)
895
+ if worker is not None:
896
+ worker.cancel()
897
+ detail_worker = getattr(self, "_database_detail_worker", None)
898
+ if detail_worker is not None:
899
+ detail_worker.cancel()
900
+ expensive_detail_worker = getattr(
901
+ self,
902
+ "_database_expensive_detail_worker",
903
+ None,
904
+ )
905
+ if expensive_detail_worker is not None:
906
+ expensive_detail_worker.cancel()
907
+ refresh_worker = getattr(self, "_database_refresh_worker", None)
908
+ if refresh_worker is not None:
909
+ refresh_worker.cancel()
910
+ selected_run_worker = getattr(self, "_database_selected_run_worker", None)
911
+ if selected_run_worker is not None:
912
+ selected_run_worker.cancel()
913
+ DatabaseActionsMixin._cancel_database_info(self)
914
+ generation_worker = getattr(self, "_test_database_generation_worker", None)
915
+ if generation_worker is not None:
916
+ generation_worker.cancel()
917
+ MainWindow._cancel_plot_work(self)
918
+ self._database_load_generation += 1
919
+ self._database_load_active = False
920
+ self._database_load_state = None
921
+ self._database_load_worker = None
922
+ self._database_detail_generation = (
923
+ getattr(self, "_database_detail_generation", 0) + 1
924
+ )
925
+ self._database_detail_active = False
926
+ self._database_detail_worker = None
927
+ self._database_detail_instance = None
928
+ self._database_expensive_detail_generation = (
929
+ getattr(self, "_database_expensive_detail_generation", 0) + 1
930
+ )
931
+ self._database_expensive_detail_active = False
932
+ self._database_expensive_detail_worker = None
933
+ self._database_expensive_detail_instance = None
934
+ self._database_refresh_generation = (
935
+ getattr(self, "_database_refresh_generation", 0) + 1
936
+ )
937
+ self._database_refresh_active = False
938
+ self._database_refresh_pending = False
939
+ self._database_refresh_worker = None
940
+ self._database_refresh_identity = None
941
+ self._database_refresh_instance = None
942
+ self._database_selected_run_generation = (
943
+ getattr(self, "_database_selected_run_generation", 0) + 1
944
+ )
945
+ self._database_selected_run_worker = None
946
+ self._database_selected_run_instance = None
947
+ self._database_selected_run_mode = None
948
+ self._restoring_selected_run_publication = False
949
+ self._selected_run_restore_pending = False
950
+ self._test_database_generation_active = False
951
+ self._test_database_generation_worker = None
952
+ self._test_database_replacement_state = None
953
+ self._database_view_released_for_generation = False
954
+ stop_refresh_timer = getattr(self, "_stop_automatic_refresh_timer", None)
955
+ if callable(stop_refresh_timer):
956
+ stop_refresh_timer()
957
+ else:
958
+ self.monitor.stop()
959
+ self.close_plot_windows(confirm=False, status=False)
960
+ self.close_database(status=False)
961
+
962
+ if MainWindow._shutdown_background_work_active(self):
963
+ self._shutdown_started = True
964
+ event.ignore()
965
+ hide = getattr(self, "hide", None)
966
+ if callable(hide):
967
+ hide()
968
+ shutdown_timer = getattr(self, "_shutdown_timer", None)
969
+ if shutdown_timer is not None:
970
+ shutdown_timer.start()
971
+ else:
972
+ QtCore.QTimer.singleShot(25, self._finish_deferred_shutdown)
973
+ return
974
+
975
+ self._shutdown_ready = True
976
+ event.accept()
977
+ qtw.QApplication.closeAllWindows()
978
+
979
+
980
+ def _cancel_plot_work(self):
981
+ """Cancel running plot loads and remove work that has not started."""
982
+
983
+ workers = set(getattr(self, "_plot_workers", ()))
984
+ for window in list(getattr(self, "windows", ())):
985
+ worker = getattr(window, "worker", None)
986
+ if worker is not None:
987
+ workers.add(worker)
988
+
989
+ for worker in workers:
990
+ cancel = getattr(worker, "cancel", None)
991
+ if callable(cancel):
992
+ cancel()
993
+
994
+ pool = getattr(self, "threadPool", None)
995
+ clear = getattr(pool, "clear", None)
996
+ if callable(clear):
997
+ clear()
998
+
999
+
1000
+ def _publish_shutdown_diagnostics(self):
1001
+ """Merge durable escalation errors with the newest liveness scan."""
1002
+
1003
+ diagnostics = tuple(
1004
+ (
1005
+ *getattr(self, "_shutdown_escalation_diagnostics", ()),
1006
+ *getattr(self, "_shutdown_liveness_diagnostics", ()),
1007
+ )
1008
+ )
1009
+ self._shutdown_last_diagnostics = diagnostics
1010
+ process_fail_safe = getattr(self, "_shutdown_process_fail_safe", None)
1011
+ if process_fail_safe is not None:
1012
+ process_fail_safe.update_diagnostics(diagnostics)
1013
+
1014
+
1015
+ def _shutdown_background_work_active(self):
1016
+ """
1017
+ Reports whether a qPlot worker still needs the Qt event loop.
1018
+
1019
+ """
1020
+ diagnostics = []
1021
+ active = False
1022
+ pool_names = (
1023
+ "threadPool",
1024
+ "databaseLoadThreadPool",
1025
+ "databaseDetailThreadPool",
1026
+ "databaseExpensiveDetailThreadPool",
1027
+ "databaseRefreshThreadPool",
1028
+ "databaseSelectedRunThreadPool",
1029
+ "testDatabaseGenerationThreadPool",
1030
+ )
1031
+ for pool_name in pool_names:
1032
+ pool = getattr(self, pool_name, None)
1033
+ if pool is None:
1034
+ continue
1035
+ try:
1036
+ count = pool.activeThreadCount()
1037
+ except BaseException as error:
1038
+ active = True
1039
+ diagnostics.append(
1040
+ f"pool {pool_name}: liveness raised "
1041
+ f"{type(error).__name__}: {error}"
1042
+ )
1043
+ continue
1044
+ if count > 0:
1045
+ active = True
1046
+ diagnostics.append(f"pool {pool_name}: active_threads={count}")
1047
+
1048
+ reap_services = getattr(self, "_reap_retired_trusted_read_services", None)
1049
+ if callable(reap_services):
1050
+ try:
1051
+ reap_services()
1052
+ except BaseException as error:
1053
+ active = True
1054
+ diagnostics.append(
1055
+ "retired-service reaper raised "
1056
+ f"{type(error).__name__}: {error}"
1057
+ )
1058
+ diagnostics.extend(
1059
+ getattr(self, "_retired_service_reap_diagnostics", {}).values()
1060
+ )
1061
+ services = set(getattr(self, "_retired_trusted_read_services", ()))
1062
+ active_service = getattr(self, "_trusted_read_service", None)
1063
+ if active_service is not None:
1064
+ services.add(active_service)
1065
+ services.update(
1066
+ getattr(self, "_pending_trusted_read_services", {}).values()
1067
+ )
1068
+ for service in services:
1069
+ service_label = (
1070
+ f"{type(service).__module__}.{type(service).__qualname__}"
1071
+ f"@{id(service):x}"
1072
+ )
1073
+ service_errors = []
1074
+ for error_name in ("fatal_error", "close_error"):
1075
+ try:
1076
+ service_error = getattr(service, error_name, None)
1077
+ except BaseException as error:
1078
+ active = True
1079
+ service_errors.append(
1080
+ f"{error_name} probe raised {type(error).__name__}: {error}"
1081
+ )
1082
+ continue
1083
+ if service_error is not None:
1084
+ service_errors.append(
1085
+ f"{error_name}={type(service_error).__name__}: {service_error}"
1086
+ )
1087
+ try:
1088
+ liveness = service.liveness()
1089
+ except BaseException as error:
1090
+ active = True
1091
+ diagnostics.append(
1092
+ f"service {service_label}: liveness raised "
1093
+ f"{type(error).__name__}: {error}"
1094
+ + ("; " + "; ".join(service_errors) if service_errors else "")
1095
+ )
1096
+ continue
1097
+ field_names = (
1098
+ "dispatcher_alive",
1099
+ "control_alive",
1100
+ "helper_alive",
1101
+ "helper_pid",
1102
+ "receiver_alive",
1103
+ "open_supervisor_endpoints",
1104
+ "unreaped_incarnations",
1105
+ "resource_cleanup_pending",
1106
+ "outstanding_requests",
1107
+ "closing",
1108
+ "closed",
1109
+ )
1110
+ values = {
1111
+ field_name: getattr(liveness, field_name, None)
1112
+ for field_name in field_names
1113
+ }
1114
+ service_active = bool(
1115
+ values["dispatcher_alive"]
1116
+ or values["control_alive"]
1117
+ or values["helper_alive"]
1118
+ or values["receiver_alive"]
1119
+ or values["open_supervisor_endpoints"]
1120
+ or values["unreaped_incarnations"]
1121
+ or values["resource_cleanup_pending"]
1122
+ or values["outstanding_requests"]
1123
+ or not values["closed"]
1124
+ )
1125
+ if service_active:
1126
+ active = True
1127
+ diagnostics.append(
1128
+ f"service {service_label}: "
1129
+ + ", ".join(
1130
+ f"{field_name}={values[field_name]!r}"
1131
+ for field_name in field_names
1132
+ )
1133
+ + ("; " + "; ".join(service_errors) if service_errors else "")
1134
+ )
1135
+
1136
+ preview = getattr(getattr(self, "infoBox", None), "preview", None)
1137
+ preview_workers = getattr(preview, "_workers", {})
1138
+ if preview_workers:
1139
+ active = True
1140
+ diagnostics.append(f"preview_workers={len(preview_workers)}")
1141
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
1142
+ if derived_bridge is not None:
1143
+ try:
1144
+ if derived_bridge.background_active():
1145
+ active = True
1146
+ diagnostic = derived_bridge.shutdown_diagnostic()
1147
+ if diagnostic:
1148
+ diagnostics.append(diagnostic)
1149
+ except BaseException as error:
1150
+ active = True
1151
+ diagnostics.append(
1152
+ "trusted derived bridge liveness raised "
1153
+ f"{type(error).__name__}: {error}"
1154
+ )
1155
+ self._shutdown_liveness_diagnostics = tuple(diagnostics)
1156
+ MainWindow._publish_shutdown_diagnostics(self)
1157
+ return active
1158
+
1159
+
1160
+ def _escalate_shutdown_cleanup(self):
1161
+ """Repeat cancellation and zero-wait cleanup once during shutdown."""
1162
+
1163
+ if getattr(self, "_shutdown_cleanup_escalated", False):
1164
+ return
1165
+ self._shutdown_cleanup_escalated = True
1166
+ escalation_diagnostics = []
1167
+
1168
+ try:
1169
+ MainWindow._cancel_plot_work(self)
1170
+ except BaseException as error:
1171
+ escalation_diagnostics.append(
1172
+ f"plot cancellation raised {type(error).__name__}: {error}"
1173
+ )
1174
+
1175
+ for pool_name in (
1176
+ "threadPool",
1177
+ "databaseLoadThreadPool",
1178
+ "databaseDetailThreadPool",
1179
+ "databaseExpensiveDetailThreadPool",
1180
+ "databaseRefreshThreadPool",
1181
+ "databaseSelectedRunThreadPool",
1182
+ "testDatabaseGenerationThreadPool",
1183
+ ):
1184
+ pool = getattr(self, pool_name, None)
1185
+ clear = getattr(pool, "clear", None)
1186
+ if not callable(clear):
1187
+ continue
1188
+ try:
1189
+ clear()
1190
+ except BaseException as error:
1191
+ escalation_diagnostics.append(
1192
+ f"pool {pool_name} clear raised {type(error).__name__}: {error}"
1193
+ )
1194
+
1195
+ preview = getattr(getattr(self, "infoBox", None), "preview", None)
1196
+ shutdown = getattr(preview, "shutdown", None)
1197
+ if callable(shutdown):
1198
+ try:
1199
+ shutdown()
1200
+ except BaseException as error:
1201
+ escalation_diagnostics.append(
1202
+ f"preview shutdown raised {type(error).__name__}: {error}"
1203
+ )
1204
+
1205
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
1206
+ if derived_bridge is not None:
1207
+ try:
1208
+ derived_bridge.escalate_cleanup()
1209
+ except BaseException as error:
1210
+ escalation_diagnostics.append(
1211
+ "trusted derived cleanup raised "
1212
+ f"{type(error).__name__}: {error}"
1213
+ )
1214
+
1215
+ services = set(getattr(self, "_retired_trusted_read_services", ()))
1216
+ active_service = getattr(self, "_trusted_read_service", None)
1217
+ if active_service is not None:
1218
+ services.add(active_service)
1219
+ services.update(
1220
+ getattr(self, "_pending_trusted_read_services", {}).values()
1221
+ )
1222
+ for service in services:
1223
+ escalate = getattr(service, "escalate_cleanup_async", None)
1224
+ if not callable(escalate):
1225
+ escalate = getattr(service, "close_async", None)
1226
+ if not callable(escalate):
1227
+ continue
1228
+ try:
1229
+ escalate()
1230
+ except BaseException as error:
1231
+ escalation_diagnostics.append(
1232
+ "service cleanup escalation raised "
1233
+ f"{type(error).__name__}: {error}"
1234
+ )
1235
+
1236
+ reap_services = getattr(self, "_reap_retired_trusted_read_services", None)
1237
+ if callable(reap_services):
1238
+ try:
1239
+ reap_services()
1240
+ except BaseException as error:
1241
+ escalation_diagnostics.append(
1242
+ "retired-service escalation reap raised "
1243
+ f"{type(error).__name__}: {error}"
1244
+ )
1245
+ if escalation_diagnostics:
1246
+ self._shutdown_escalation_diagnostics = tuple(
1247
+ (
1248
+ *getattr(self, "_shutdown_escalation_diagnostics", ()),
1249
+ *escalation_diagnostics,
1250
+ )
1251
+ )
1252
+ MainWindow._publish_shutdown_diagnostics(self)
1253
+
1254
+
1255
+ def _complete_deferred_shutdown(self, *, deadline_exhausted=False):
1256
+ """Stop shutdown polling and make the next close event unconditional."""
1257
+
1258
+ shutdown_timer = getattr(self, "_shutdown_timer", None)
1259
+ if shutdown_timer is not None:
1260
+ shutdown_timer.stop()
1261
+ reaper_timer = getattr(self, "_retired_service_reaper_timer", None)
1262
+ if reaper_timer is not None:
1263
+ reaper_timer.stop()
1264
+
1265
+ if deadline_exhausted:
1266
+ diagnostics = tuple(getattr(self, "_shutdown_last_diagnostics", ()))
1267
+ if not diagnostics:
1268
+ diagnostics = ("background work remained active without diagnostics",)
1269
+ self._shutdown_diagnostics = diagnostics
1270
+ process_fail_safe = getattr(self, "_shutdown_process_fail_safe", None)
1271
+ if process_fail_safe is not None:
1272
+ process_fail_safe.update_diagnostics(diagnostics)
1273
+ process_fail_safe.persist_async()
1274
+ else:
1275
+ started_at = getattr(self, "_shutdown_started_at", None)
1276
+ if started_at is None:
1277
+ started_at = monotonic()
1278
+ _persist_shutdown_diagnostics(
1279
+ started_at=started_at,
1280
+ total_timeout=_APPLICATION_SHUTDOWN_TIMEOUT_SECONDS,
1281
+ diagnostics=diagnostics,
1282
+ )
1283
+ self._shutdown_deadline_exhausted = bool(deadline_exhausted)
1284
+
1285
+ self._shutdown_started = False
1286
+ self._shutdown_ready = True
1287
+ qtw.QApplication.closeAllWindows()
1288
+ application = qtw.QApplication.instance()
1289
+ if application is not None and isinstance(self, qtw.QWidget):
1290
+ application.quit()
1291
+
1292
+
1293
+ @QtCore.pyqtSlot()
1294
+ def _finish_deferred_shutdown(self):
1295
+ """
1296
+ Finishes Quit after cancelled background workers have returned.
1297
+
1298
+ """
1299
+ if not getattr(self, "_shutdown_started", False):
1300
+ return
1301
+ active = MainWindow._shutdown_background_work_active(self)
1302
+ if active and not getattr(self, "_shutdown_cleanup_escalated", False):
1303
+ MainWindow._escalate_shutdown_cleanup(self)
1304
+ active = MainWindow._shutdown_background_work_active(self)
1305
+
1306
+ deadline = getattr(self, "_shutdown_deadline", None)
1307
+ if deadline is None:
1308
+ deadline = monotonic() + _APPLICATION_SHUTDOWN_TIMEOUT_SECONDS
1309
+ self._shutdown_deadline = deadline
1310
+ if active and monotonic() < deadline:
1311
+ return
1312
+
1313
+ MainWindow._complete_deferred_shutdown(
1314
+ self,
1315
+ deadline_exhausted=active,
1316
+ )
1317
+
1318
+
1319
+ @QtCore.pyqtSlot()
1320
+ def closeAll(self):
1321
+ """
1322
+ Event handler for close all menu button.
1323
+ Closes all windows other than the main window.
1324
+
1325
+ """
1326
+ self.close_plot_windows(confirm=True, status=True)
1327
+
1328
+
1329
+ def close_plot_windows(self, confirm=True, status=True):
1330
+ """
1331
+ Closes all plot windows, optionally asking for confirmation.
1332
+
1333
+ """
1334
+ plot_windows = self.windows.copy()
1335
+ if not plot_windows:
1336
+ if status:
1337
+ self.show_status("No plot windows to close.", 3000)
1338
+ return True
1339
+
1340
+ if confirm and close_all_warning_enabled(self.config):
1341
+ count = len(plot_windows)
1342
+ noun = "window" if count == 1 else "windows"
1343
+ reply = ask_confirmation_with_dont_ask_again(
1344
+ self,
1345
+ "Close All Plot Windows",
1346
+ f"Close {count} plot {noun}?",
1347
+ CONFIRM_CLOSE_ALL_KEY,
1348
+ qtw.QMessageBox.StandardButton.No,
1349
+ )
1350
+ if reply != qtw.QMessageBox.StandardButton.Yes:
1351
+ if status:
1352
+ self.show_status("Close all plot windows cancelled.", 3000)
1353
+ return False
1354
+
1355
+ if status:
1356
+ self.show_status("Closing plot windows...", 3000)
1357
+ for win in plot_windows:
1358
+ win.close()
1359
+ return True
1360
+
1361
+
1362
+ def change_theme(self, theme, action):
1363
+ """
1364
+ Event handler for changing style/theme.
1365
+ Updates Main Window theme and all other Plot windows.
1366
+
1367
+ Parameters
1368
+ ----------
1369
+ theme : str
1370
+ Name of the theme to change to.
1371
+ action : PyQt6.QtWidgets.QAction
1372
+ Button which sent the signal for the action.
1373
+
1374
+ """
1375
+ if self.config.get("user_preference.theme") == theme: #already selected
1376
+ set_widget_value_without_signals(action, action.setChecked, True)
1377
+ self.show_status(f"{theme.title()} theme already selected.", 3000)
1378
+ return True
1379
+
1380
+ def sync_theme_actions():
1381
+ current_theme = self.config.get("user_preference.theme")
1382
+ actions = list(getattr(self, "themes", []))
1383
+ if action not in actions:
1384
+ actions.append(action)
1385
+ blocked_states = [
1386
+ theme_action.blockSignals(True)
1387
+ for theme_action in actions
1388
+ ]
1389
+ try:
1390
+ for theme_action in actions:
1391
+ action_theme = theme_action.text().replace("&", "").lower()
1392
+ theme_action.setChecked(action_theme == current_theme)
1393
+ finally:
1394
+ for theme_action, signals_were_blocked in zip(
1395
+ actions,
1396
+ blocked_states,
1397
+ strict=True,
1398
+ ):
1399
+ theme_action.blockSignals(signals_were_blocked)
1400
+
1401
+ if not persist_config_value(
1402
+ self,
1403
+ self.config,
1404
+ "user_preference.theme",
1405
+ theme,
1406
+ "the theme preference",
1407
+ sync_theme_actions,
1408
+ ):
1409
+ return False
1410
+
1411
+ sync_theme_actions()
1412
+
1413
+ # Update all windows.
1414
+ self.setStyleSheet(self.config.theme.main)
1415
+ for win in self.windows:
1416
+ win.update_theme(self.config)
1417
+ self.show_status(f"Theme changed to {theme}.", 2000)
1418
+ return True
1419
+
1420
+
1421
+ def change_preview_size(self, preview_size):
1422
+ """
1423
+ Updates preview image size and regenerates preview thumbnails.
1424
+
1425
+ """
1426
+ preview_size = int(preview_size)
1427
+ if preview_size == self.preview_size:
1428
+ return True
1429
+
1430
+ if not self._save_preview_size(preview_size):
1431
+ return False
1432
+
1433
+ self._apply_preview_size(preview_size)
1434
+ self.show_status(f"Preview size set to {preview_size} px.", 3000)
1435
+ return True
1436
+
1437
+
1438
+ @QtCore.pyqtSlot()
1439
+ def restore_default_settings(self):
1440
+ """
1441
+ Confirms and resets all user settings to schema defaults.
1442
+
1443
+ """
1444
+ reply = qtw.QMessageBox.question(
1445
+ self,
1446
+ "Reset All Settings",
1447
+ "Reset all qPlot settings to their defaults? "
1448
+ "This will also close the current database and all plot windows.",
1449
+ qtw.QMessageBox.StandardButton.Yes | qtw.QMessageBox.StandardButton.No,
1450
+ qtw.QMessageBox.StandardButton.No,
1451
+ )
1452
+ if reply != qtw.QMessageBox.StandardButton.Yes:
1453
+ self.show_status("Settings reset cancelled.", 3000)
1454
+ return
1455
+
1456
+ if not persist_config_action(
1457
+ self,
1458
+ self.config.reset_to_defaults,
1459
+ "the default settings",
1460
+ ):
1461
+ return False
1462
+
1463
+ self.close_plot_windows(confirm=False, status=False)
1464
+ self.apply_current_settings()
1465
+ self.close_database(status=False)
1466
+ self.show_status("Settings reset to defaults.", 5000)
1467
+ return True
1468
+
1469
+
1470
+ def show_preferences_dialog(self):
1471
+ """
1472
+ Opens the preferences dialog.
1473
+
1474
+ """
1475
+ dialog = PreferencesDialog(self.config, self)
1476
+ dialog.preferencesApplied.connect(self.apply_current_settings)
1477
+ dialog.preferencesApplied.connect(
1478
+ lambda: self.show_status("Preferences saved.", 3000)
1479
+ )
1480
+ try:
1481
+ dialog.exec()
1482
+ finally:
1483
+ dialog.deleteLater()
1484
+
1485
+
1486
+ def apply_current_settings(self):
1487
+ """
1488
+ Applies config-backed settings that can be updated in open windows.
1489
+
1490
+ """
1491
+ self._sync_theme_actions()
1492
+ self._sync_preview_size_actions()
1493
+ self._sync_refresh_interval()
1494
+ set_widget_value_without_signals(
1495
+ self.autoPlotBox,
1496
+ self.autoPlotBox.setChecked,
1497
+ self.config.get(AUTO_PLOT_KEY),
1498
+ )
1499
+ self._sync_thread_pool_settings()
1500
+ run_list = getattr(self, "RunList", None)
1501
+ if run_list is not None:
1502
+ if hasattr(run_list, "apply_configured_column_widths"):
1503
+ run_list.apply_configured_column_widths()
1504
+ if hasattr(run_list, "apply_configured_column_visibility"):
1505
+ run_list.apply_configured_column_visibility()
1506
+ self.setStyleSheet(self.config.theme.main)
1507
+ for win in self.windows:
1508
+ win.update_theme(self.config)
1509
+ apply_major_ticks = getattr(
1510
+ win,
1511
+ "apply_axis_major_tick_count_preference",
1512
+ None,
1513
+ )
1514
+ if callable(apply_major_ticks):
1515
+ apply_major_ticks()
1516
+ apply_colorbar_width = getattr(win, "apply_colorbar_width_preference", None)
1517
+ if callable(apply_colorbar_width):
1518
+ apply_colorbar_width()
1519
+ self._sync_mouse_mode_settings()
1520
+
1521
+
1522
+ def _sync_theme_actions(self):
1523
+ current_theme = self.config.get("user_preference.theme")
1524
+ for action in getattr(self, "themes", []):
1525
+ action.blockSignals(True)
1526
+ action.setChecked(action.text().replace("&", "").lower() == current_theme)
1527
+ action.blockSignals(False)
1528
+
1529
+
1530
+ def _sync_preview_size_actions(self):
1531
+ self._apply_preview_size(self._configured_preview_size())
1532
+
1533
+
1534
+ def _apply_preview_size(self, preview_size):
1535
+ """Apply a persisted preview size to the live UI once.
1536
+
1537
+ Persistence is intentionally kept outside this method. This lets menu
1538
+ actions, Preferences, and resetting settings share the same runtime
1539
+ cache invalidation and visible-row regeneration path.
1540
+ """
1541
+ preview_size = int(preview_size)
1542
+ size_changed = preview_size != self.preview_size
1543
+ self.preview_size = preview_size
1544
+ for action in getattr(self, "previewSizeActions", []):
1545
+ action.blockSignals(True)
1546
+ action.setChecked(action.data() == self.preview_size)
1547
+ action.blockSignals(False)
1548
+
1549
+ if size_changed and hasattr(self, "infoBox"):
1550
+ self.infoBox.set_preview_size(self.preview_size)
1551
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
1552
+ if derived_bridge is not None:
1553
+ derived_bridge.update_preview_size(self.preview_size)
1554
+ prioritize_previews = getattr(self, "_prioritize_preview_runs", None)
1555
+ if callable(prioritize_previews):
1556
+ prioritize_previews()
1557
+ if hasattr(self, "runInfoSplitter"):
1558
+ self.runInfoSplitter.setSizes([380, self._details_pane_height()])
1559
+
1560
+ return size_changed
1561
+
1562
+
1563
+ def _sync_thread_pool_settings(self):
1564
+ if hasattr(self, "threadPool"):
1565
+ self.threadPool.setMaxThreadCount(
1566
+ self.config.get("runtime_settings.max_threads")
1567
+ )
1568
+
1569
+
1570
+ def _sync_mouse_mode_settings(self):
1571
+ for win in getattr(self, "windows", []):
1572
+ if hasattr(win, "apply_mouse_mode_preference"):
1573
+ win.apply_mouse_mode_preference()
1574
+
1575
+
1576
+ def _save_preview_size(self, preview_size):
1577
+ previous_size = self._configured_preview_size()
1578
+
1579
+ def rollback():
1580
+ actions = list(getattr(self, "previewSizeActions", []))
1581
+ blocked_states = [action.blockSignals(True) for action in actions]
1582
+ try:
1583
+ for action in actions:
1584
+ action.setChecked(action.data() == previous_size)
1585
+ finally:
1586
+ for action, signals_were_blocked in zip(
1587
+ actions,
1588
+ blocked_states,
1589
+ strict=True,
1590
+ ):
1591
+ action.blockSignals(signals_were_blocked)
1592
+
1593
+ return persist_config_value(
1594
+ self,
1595
+ self.config,
1596
+ "GUI.preview_size",
1597
+ int(preview_size),
1598
+ "the preview size",
1599
+ rollback,
1600
+ )
1601
+
1602
+
1603
+ ###############################################################################
1604
+ #Other funcs
1605
+
1606
+ def show_status(self, message : str, timeout : int = 5000):
1607
+ """
1608
+ Shows a short message in the main window status bar.
1609
+
1610
+ """
1611
+ status_bar = cast(qtw.QStatusBar, self.statusBar())
1612
+ status_bar.showMessage(message, timeout)
1613
+
1614
+ # A preview-drop action is initiated in a separate plot window, which
1615
+ # can cover the main window and its status bar. Mirror feedback to
1616
+ # that plot for the duration of the action so rejected drops have an
1617
+ # immediately visible explanation.
1618
+ target = getattr(self, "_preview_drop_feedback_window", None)
1619
+ target_show_status = getattr(target, "show_status", None)
1620
+ if callable(target_show_status):
1621
+ target_show_status(message, timeout)
1622
+
1623
+
1624
+ def show_error(self, title : str, message : str, details : str | None = None):
1625
+ """
1626
+ Shows an error both in the status bar and in a message box.
1627
+
1628
+ """
1629
+ log_user_error(title, message, details, __name__)
1630
+ self.show_status(message, 10_000)
1631
+
1632
+ box = qtw.QMessageBox(qtw.QMessageBox.Icon.Warning, title, message, parent=self)
1633
+ if details:
1634
+ box.setDetailedText(details)
1635
+ box.exec()