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