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
@@ -0,0 +1,1993 @@
1
+ from math import ceil, floor, isclose, isfinite, log10, ulp
2
+ from os import path
3
+ from types import MethodType
4
+ from typing import TYPE_CHECKING, cast
5
+
6
+ import pyqtgraph as pg
7
+ from PyQt6 import QtCore, QtGui
8
+ from PyQt6 import QtWidgets as qtw
9
+
10
+ from qplot.tools import (
11
+ unpack_param,
12
+ )
13
+
14
+ from . import _plot_axis_scaling
15
+ from ._commands import (
16
+ command_spec,
17
+ command_with_status,
18
+ create_action,
19
+ toolbar_toggle_command_spec,
20
+ )
21
+ from ._config_persistence import persist_config_value
22
+ from ._dataset_handle import DatasetKey, TraceKey
23
+ from ._dragdrop import (
24
+ preview_drop_is_compatible,
25
+ run_preview_payload_from_mime,
26
+ )
27
+ from ._help import add_help_menu
28
+ from ._native_averaging import NativePlotItem, is_native_source_curve
29
+ from ._native_transforms import native_fft_coordinate_error
30
+ from ._plot1d_snap import _line_snap_data
31
+ from ._plot_axis_scaling import (
32
+ PlotAxisScalingMixin,
33
+ _PowerScaledAxisItem,
34
+ )
35
+ from ._plot_export import PlotExportMixin
36
+ from ._plot_feedback import PlotWindowFeedbackMixin
37
+ from ._plot_marquee import PlotMarqueeMixin
38
+ from ._plot_refresh import PlotRefreshMixin
39
+ from ._plot_state import PlotStateOverlay
40
+ from ._plot_transform_labels import native_axis_quantities
41
+ from ._preferences import (
42
+ AXIS_MAJOR_TICK_COUNT_KEY,
43
+ MOUSE_MODE_KEY,
44
+ PreferencesDialog,
45
+ create_preferences_action,
46
+ )
47
+ from ._refresh_interval import (
48
+ connect_refresh_interval_edits,
49
+ refresh_interval_value,
50
+ remember_refresh_interval,
51
+ set_refresh_interval,
52
+ )
53
+ from ._subplots import custom_viewbox
54
+ from ._widgets import (
55
+ QDock_context,
56
+ expandingComboBox,
57
+ operations_widget,
58
+ )
59
+ from ._window_controls import (
60
+ add_application_quit_action,
61
+ add_standard_window_controls,
62
+ main_window_for,
63
+ )
64
+
65
+ if TYPE_CHECKING:
66
+ import qcodes
67
+
68
+ import qplot
69
+
70
+
71
+ _axis_scale_power_text = _plot_axis_scaling._axis_scale_power_text
72
+
73
+ _MAX_TICK_POSITIONS = 2048
74
+
75
+ _A4_LANDSCAPE_PLOT_AREA_SIZE = QtCore.QSize(1123, 794)
76
+ _A4_PORTRAIT_PLOT_AREA_SIZE = QtCore.QSize(794, 1123)
77
+ _POWERPOINT_STANDARD_PLOT_AREA_SIZE = QtCore.QSize(960, 720)
78
+ _POWERPOINT_WIDESCREEN_PLOT_AREA_SIZE = QtCore.QSize(1280, 720)
79
+ _SQUARE_PLOT_AREA_SIZE = QtCore.QSize(850, 850)
80
+ _PLOT_AREA_RESIZE_PRESETS = (
81
+ (
82
+ "A4 Landscape",
83
+ "resizePlotAreaA4LandscapeAction",
84
+ _A4_LANDSCAPE_PLOT_AREA_SIZE,
85
+ ),
86
+ (
87
+ "A4 Portrait",
88
+ "resizePlotAreaA4PortraitAction",
89
+ _A4_PORTRAIT_PLOT_AREA_SIZE,
90
+ ),
91
+ (
92
+ "PowerPoint Standard",
93
+ "resizePlotAreaPowerPointStandardAction",
94
+ _POWERPOINT_STANDARD_PLOT_AREA_SIZE,
95
+ ),
96
+ (
97
+ "PowerPoint Widescreen",
98
+ "resizePlotAreaPowerPointWidescreenAction",
99
+ _POWERPOINT_WIDESCREEN_PLOT_AREA_SIZE,
100
+ ),
101
+ (
102
+ "Square",
103
+ "resizePlotAreaSquareAction",
104
+ _SQUARE_PLOT_AREA_SIZE,
105
+ ),
106
+ )
107
+
108
+
109
+ def _tick_positions(min_val, max_val, spacing, offset):
110
+ """Return the positions on one regular tick lattice inside a range."""
111
+
112
+ min_val, max_val = sorted((min_val, max_val))
113
+ if (
114
+ not all(isfinite(value) for value in (min_val, max_val, spacing, offset))
115
+ or spacing <= 0
116
+ ):
117
+ return []
118
+
119
+ data_range = max_val - min_val
120
+ span_tolerance = data_range * 1e-12 if isfinite(data_range) else 0.0
121
+ coordinate_precision = max(ulp(min_val), ulp(max_val))
122
+ if spacing < coordinate_precision:
123
+ return []
124
+ tolerance = max(
125
+ span_tolerance,
126
+ spacing * 1e-12,
127
+ coordinate_precision,
128
+ ulp(offset),
129
+ )
130
+ ratio_tolerance = tolerance / spacing
131
+ if not isfinite(ratio_tolerance):
132
+ return []
133
+
134
+ if isfinite(data_range):
135
+ estimated_count = (data_range / spacing) + (2.0 * ratio_tolerance) + 2.0
136
+ else:
137
+ estimated_count = (
138
+ (max_val / spacing)
139
+ - (min_val / spacing)
140
+ + (2.0 * ratio_tolerance)
141
+ + 2.0
142
+ )
143
+ if not isfinite(estimated_count) or estimated_count > _MAX_TICK_POSITIONS:
144
+ return []
145
+
146
+ lower_ratio = (min_val - offset) / spacing
147
+ upper_ratio = (max_val - offset) / spacing
148
+ if not isfinite(lower_ratio) or not isfinite(upper_ratio):
149
+ return []
150
+ first_index = ceil(lower_ratio - ratio_tolerance)
151
+ last_index = floor(upper_ratio + ratio_tolerance)
152
+ count = last_index - first_index + 1
153
+ if count <= 0 or count > _MAX_TICK_POSITIONS:
154
+ return []
155
+
156
+ positions = []
157
+ for index in range(first_index, last_index + 1):
158
+ try:
159
+ position = (index * spacing) + offset
160
+ except OverflowError:
161
+ continue
162
+ if not isfinite(position):
163
+ continue
164
+ if position < min_val and min_val - position > tolerance:
165
+ continue
166
+ if position > max_val and position - max_val > tolerance:
167
+ continue
168
+ if not positions or position != positions[-1]:
169
+ positions.append(position)
170
+ return positions
171
+
172
+
173
+ def _visible_horizontal_tick_positions(axis, positions, min_val, max_val, size,
174
+ spacing):
175
+ """Return ticks whose labels fit completely within a horizontal axis."""
176
+
177
+ if getattr(axis, "orientation", None) not in ("bottom", "top") or size <= 0:
178
+ return positions
179
+
180
+ scale = axis.autoSIPrefixScale * axis.scale
181
+ strings = _clean_tick_strings(axis, positions, scale, spacing)
182
+ font = axis.style.get("tickFont") or qtw.QApplication.font()
183
+ metrics = QtGui.QFontMetricsF(font)
184
+ data_range = max_val - min_val
185
+ visible = []
186
+ for position, string in zip(positions, strings, strict=True):
187
+ pixel = ((position - min_val) / data_range) * size
188
+ half_width = metrics.boundingRect(string).width() / 2.0
189
+ if half_width <= pixel <= size - half_width:
190
+ visible.append(position)
191
+ return visible
192
+
193
+
194
+ def _major_tick_spacing(axis, min_val, max_val, size):
195
+ """Choose a zero-aligned 1-2-5 interval nearest the requested tick count."""
196
+
197
+ min_val, max_val = sorted((min_val, max_val))
198
+ if not isfinite(min_val) or not isfinite(max_val):
199
+ return []
200
+ data_range = max_val - min_val
201
+ if data_range == 0:
202
+ return []
203
+
204
+ target = axis._qplot_major_tick_count
205
+ divisor = max(1, target - 1)
206
+ raw_spacing = (max_val / divisor) - (min_val / divisor)
207
+ if not isfinite(raw_spacing):
208
+ raw_spacing = max(abs(min_val), abs(max_val))
209
+ if raw_spacing == 0.0 and isfinite(data_range):
210
+ raw_spacing = data_range
211
+ if not isfinite(raw_spacing) or raw_spacing <= 0.0:
212
+ return []
213
+
214
+ base_exponent = floor(log10(raw_spacing))
215
+ visible_candidates = []
216
+ fallback_candidates = []
217
+ for exponent in range(base_exponent - 2, base_exponent + 3):
218
+ try:
219
+ decade = 10.0 ** exponent
220
+ except OverflowError:
221
+ continue
222
+ if not isfinite(decade) or decade <= 0.0:
223
+ continue
224
+ for factor in (1.0, 2.0, 5.0):
225
+ spacing = factor * decade
226
+ if not isfinite(spacing) or spacing <= 0.0:
227
+ continue
228
+ for offset in (0.0,):
229
+ positions = _tick_positions(min_val, max_val, spacing, offset)
230
+ if not positions:
231
+ continue
232
+ fallback_candidates.append((
233
+ abs(len(positions) - target),
234
+ len(positions) < 2,
235
+ abs(log10(spacing) - log10(raw_spacing)),
236
+ spacing,
237
+ offset,
238
+ factor,
239
+ ))
240
+ scored_positions = _visible_horizontal_tick_positions(
241
+ axis,
242
+ positions,
243
+ min_val,
244
+ max_val,
245
+ size,
246
+ spacing,
247
+ )
248
+ count = len(scored_positions)
249
+ if count == 0:
250
+ continue
251
+ if isfinite(data_range):
252
+ coverage = (
253
+ (scored_positions[-1] - scored_positions[0]) / data_range
254
+ if count > 1
255
+ else 0.0
256
+ )
257
+ centre_error = abs(
258
+ (
259
+ (scored_positions[0] - min_val)
260
+ + (scored_positions[-1] - max_val)
261
+ ) / 2.0
262
+ ) / data_range
263
+ else:
264
+ scale = max(abs(min_val), abs(max_val))
265
+ scaled_range = (max_val / scale) - (min_val / scale)
266
+ coverage = (
267
+ (
268
+ (scored_positions[-1] / scale)
269
+ - (scored_positions[0] / scale)
270
+ ) / scaled_range
271
+ if count > 1
272
+ else 0.0
273
+ )
274
+ tick_centre = (
275
+ (scored_positions[0] / scale) / 2.0
276
+ + (scored_positions[-1] / scale) / 2.0
277
+ )
278
+ range_centre = (
279
+ (min_val / scale) / 2.0
280
+ + (max_val / scale) / 2.0
281
+ )
282
+ centre_error = abs(tick_centre - range_centre) / scaled_range
283
+ visible_candidates.append((
284
+ abs(count - target),
285
+ count < 2,
286
+ -coverage,
287
+ centre_error,
288
+ abs(log10(spacing) - log10(raw_spacing)),
289
+ spacing,
290
+ offset,
291
+ factor,
292
+ ))
293
+
294
+ if visible_candidates:
295
+ _, _, _, _, _, major_spacing, offset, factor = min(visible_candidates)
296
+ elif fallback_candidates:
297
+ _, _, _, major_spacing, offset, factor = min(fallback_candidates)
298
+ else:
299
+ major_spacing = raw_spacing
300
+ offset = 0.0
301
+ factor = 1.0
302
+ subdivisions = {
303
+ 1.0: 5,
304
+ 2.0: 4,
305
+ 5.0: 5,
306
+ }[factor]
307
+ levels = [(major_spacing, offset)]
308
+ minor_spacing = major_spacing / subdivisions
309
+ if isfinite(minor_spacing) and 0.0 < minor_spacing < major_spacing:
310
+ levels.append((minor_spacing, offset))
311
+ return levels
312
+
313
+
314
+ def _clean_tick_strings(axis, values, scale, spacing):
315
+ """Format nice intervals accurately and round-off near zero as zero."""
316
+
317
+ if axis.logMode and spacing is None:
318
+ return axis._qplot_original_tick_strings(
319
+ values,
320
+ scale,
321
+ spacing,
322
+ )
323
+
324
+ # Work in tick coordinates: an absolute floor erases small measurements
325
+ # before SI scaling. Allow floating-point round-off, but never a meaningful
326
+ # fraction of a tick interval, even if distant ticks have coarse precision.
327
+ interval = abs(spacing)
328
+ precision = max((ulp(value) for value in values), default=ulp(interval))
329
+ tolerance = min(interval * 1e-6, max(interval * 1e-10, precision * 4))
330
+ clean_values = [0.0 if abs(value) <= tolerance else value for value in values]
331
+ if axis.logMode:
332
+ return axis._qplot_original_tick_strings(
333
+ clean_values,
334
+ scale,
335
+ spacing,
336
+ )
337
+
338
+ scaled_spacing = abs(spacing * scale)
339
+ places = max(0, ceil(-log10(scaled_spacing)))
340
+ while places < 12 and not isclose(
341
+ scaled_spacing,
342
+ round(scaled_spacing, places),
343
+ rel_tol=1e-12,
344
+ abs_tol=10.0 ** (-places - 12),
345
+ ):
346
+ places += 1
347
+
348
+ strings = []
349
+ for value in clean_values:
350
+ scaled_value = value * scale
351
+ if abs(scaled_value) < 0.001 or abs(scaled_value) >= 10_000:
352
+ strings.append(f"{scaled_value:g}")
353
+ else:
354
+ strings.append(f"{scaled_value:.{places}f}")
355
+ return strings
356
+
357
+
358
+ def _configure_major_ticks(axis, count):
359
+ """Make an AxisItem use qPlot's sparse, range-aware major ticks."""
360
+
361
+ axis._qplot_major_tick_count = count
362
+ if not hasattr(axis, "_qplot_major_tick_spacing"):
363
+ axis._qplot_major_tick_spacing = axis.tickSpacing
364
+ axis.tickSpacing = MethodType(_major_tick_spacing, axis)
365
+ current_tick_strings = axis.tickStrings
366
+ if getattr(current_tick_strings, "__func__", None) is not _clean_tick_strings:
367
+ axis._qplot_original_tick_strings = current_tick_strings
368
+ axis.tickStrings = MethodType(_clean_tick_strings, axis)
369
+ axis.setStyle(maxTickLevel=1, maxTextLevel=0)
370
+ axis.picture = None
371
+ axis.update()
372
+
373
+
374
+ def _plot_area_size_icon(size):
375
+ """
376
+ Builds a small aspect-ratio preview icon for plot-area resize presets.
377
+
378
+ """
379
+ pixmap = QtGui.QPixmap(28, 18)
380
+ pixmap.fill(QtCore.Qt.GlobalColor.transparent)
381
+
382
+ bounds = QtCore.QSizeF(22, 14)
383
+ aspect = size.width() / size.height()
384
+ bounds_aspect = bounds.width() / bounds.height()
385
+ if aspect >= bounds_aspect:
386
+ width = bounds.width()
387
+ height = width / aspect
388
+ else:
389
+ height = bounds.height()
390
+ width = height * aspect
391
+
392
+ rect = QtCore.QRectF(
393
+ (pixmap.width() - width) / 2,
394
+ (pixmap.height() - height) / 2,
395
+ width,
396
+ height,
397
+ )
398
+
399
+ painter = QtGui.QPainter(pixmap)
400
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
401
+ painter.setPen(QtGui.QPen(QtGui.QColor(70, 70, 70), 1))
402
+ painter.setBrush(QtGui.QColor(120, 150, 200, 70))
403
+ painter.drawRect(rect)
404
+ painter.end()
405
+
406
+ return QtGui.QIcon(pixmap)
407
+
408
+
409
+ class plotWidget(
410
+ PlotWindowFeedbackMixin,
411
+ PlotAxisScalingMixin,
412
+ PlotMarqueeMixin,
413
+ PlotExportMixin,
414
+ PlotRefreshMixin,
415
+ qtw.QMainWindow,
416
+ ):
417
+ """
418
+ Base class for plot1d and plot2d.
419
+ Controls common setup and functions for both windows.
420
+
421
+
422
+ Refresh overview:
423
+ > Refresh monitor set at Main window time or 5s if none.
424
+ > On monitor timeout, calls self.refreshWindow() to check if refresh is needed
425
+ > Then produce worker for thread in self.load_data(). And queues to available
426
+ thread in self.threadPool.
427
+ See qplot.tools.worker.loader for more detail.
428
+ > Worker loads from SQL database inside worker and handles data to usable
429
+ form. See qplot.datahandling.LoadFromDB for more detail.
430
+ > On worker finish, worker callback to plot which calls self.refreshPlot().
431
+ plotWidget.refreshPlot() fetches data from worker, plot<1/2>d.refreshPlot()
432
+ then inherits, handles data, and renders as needed.
433
+ """
434
+
435
+ closed = QtCore.pyqtSignal([object])
436
+ database_replaced = QtCore.pyqtSignal(str)
437
+ end_wait = QtCore.pyqtSignal()
438
+ make_ds = QtCore.pyqtSignal([object])
439
+ previewTraceDropRequested = QtCore.pyqtSignal(object, object, str)
440
+ trace_updated = QtCore.pyqtSignal()
441
+
442
+ def _view_range_changed_programmatically(self) -> None:
443
+ """Allow plot types to react to qPlot-driven range changes."""
444
+
445
+
446
+ _label_width = 95 #About the size of 3 s.f. scientific
447
+ def __init__(self,
448
+ dataset_key: DatasetKey,
449
+ param : "qcodes.dataset.ParamSpec",
450
+ config : "qplot.configuration.config.config",
451
+ threadPool : "QtCore.QThreadPool",
452
+ dataset_holder : dict,
453
+ refrate : float=None,
454
+ show : bool=True
455
+ ):
456
+ """
457
+ Initialises window and sets up all required widgets. Also calls functions
458
+ for static plotting and checks for live plotting.
459
+
460
+ Parameters
461
+ ----------
462
+ dataset_key : DatasetKey
463
+ The database-aware identity of the dataset to plot.
464
+ param : qcodes.dataset.ParamSpec
465
+ Which parameter within dataset to plot.
466
+ config : qplot.configuration.config.config
467
+ Holds configuration data, mainly theme and window size.
468
+ threadPool : PyQt6.QtCore.QThreadPool
469
+ A pool of threads for the refresh worker to be placed in.
470
+ dataset_holder : dict[DatasetKey, DatasetHandle]
471
+ Shared map of dataset identities to open-dataset ownership handles.
472
+ refrate : float, optional
473
+ Default value for the refresh timer. The default is None, which
474
+ corresponds to a 5.0s refresh time.
475
+ show : bool, optional
476
+ Whether to display the window or not. The default is True.
477
+ When false reduces produced widgets to reduce workload.
478
+
479
+ """
480
+ super().__init__()
481
+
482
+ ### CORE VARIABLES
483
+ self._dataset_holder = dataset_holder
484
+ self._dataset_key = dataset_key
485
+ self._guid = dataset_key.guid
486
+ self.param = param
487
+ self._qplot_display_synchronized = False
488
+ self._qplot_display_uses_direct_sql = False
489
+ self.name = str(self)
490
+ self.label = f"ID:{self.ds.run_id} {self.param.name}"
491
+ self._trace_key = TraceKey(self._dataset_key, self.param.name)
492
+ self.monitor = QtCore.QTimer()
493
+ self.threadPool = threadPool
494
+ self.last_ds_len = self.ds.number_of_results
495
+ self.config = config
496
+ self.visible = show
497
+ self._closed = False
498
+ self._merged_trace_users = 0
499
+ self.operations = {}
500
+ self._last_error_text = None
501
+ self.show_status("Working, please wait", 0)
502
+
503
+ ### WIDGETS
504
+ self._window_layout = qtw.QVBoxLayout()
505
+ # The graphics widget supplies its own canvas background. Avoid
506
+ # exposing the window background as an unintended surround around it.
507
+ self._window_layout.setContentsMargins(0, 0, 0, 0)
508
+
509
+ self.widget = pg.GraphicsLayoutWidget()
510
+ self.plot_state_overlay = PlotStateOverlay(self.widget)
511
+ self._install_preview_drop_target()
512
+ # Overwrite default viewbox to give more flexibility
513
+ self.vb = custom_viewbox() # Mainly for linking secondary axis
514
+ self.vb.setDefaultPadding(0)
515
+ self.apply_mouse_mode_preference()
516
+ self.plot = NativePlotItem(
517
+ viewBox=self.vb,
518
+ axisItems={
519
+ "bottom": _PowerScaledAxisItem("bottom"),
520
+ "left": _PowerScaledAxisItem("left"),
521
+ "top": _PowerScaledAxisItem("top"),
522
+ "right": _PowerScaledAxisItem("right"),
523
+ },
524
+ )
525
+ self.widget.addItem(self.plot)
526
+ self.apply_axis_major_tick_count_preference()
527
+ self.vb.setParent(self.plot)
528
+ self.vb.set_marquee_owner(self)
529
+ self._init_marquee()
530
+ self._window_layout.addWidget(self.widget)
531
+
532
+ ### CORE INIT FUNCTIONS
533
+ self.initAxes()
534
+ self.initOperations()
535
+ self.initRefresh(refrate)
536
+ self.initFrame() # See plot1d, plot2d
537
+ self._init_native_transform_labels()
538
+
539
+ if self.visible: #dont run non essential GUI functions if not displaying
540
+ self.initLabels()
541
+ self.initContextMenu()
542
+ self.initMenu()
543
+
544
+ ### FORMATING
545
+ self.setWindowTitle(str(self))
546
+
547
+ self.plot.showAxis("right")
548
+ self.plot.showAxis("top")
549
+
550
+ self.plot.getAxis('top').setStyle(showValues=False)
551
+ self.plot.getAxis('right').setStyle(showValues=False)
552
+
553
+ screenrect = qtw.QApplication.primaryScreen().availableGeometry()
554
+ sizeFrac = self.config.get("GUI.plot_frame_fraction")
555
+
556
+ initial_width = int(sizeFrac * screenrect.width())
557
+ initial_height = int(sizeFrac * screenrect.height())
558
+ self.resize(initial_width, initial_height)
559
+
560
+ w = qtw.QFrame()
561
+ w.setLayout(self._window_layout)
562
+ self.setCentralWidget(w)
563
+
564
+ # Keep the timer alive through this plot's own terminal display commit,
565
+ # even if another parameter publishes shared dataset completion first.
566
+ if self._refresh_monitor_required(self.ds):
567
+ self.monitorIntervalChanged(refresh_interval_value(self.spinBox))
568
+
569
+
570
+ def _install_preview_drop_target(self):
571
+ """Receive preview drops at the window, outside pyqtgraph's view.
572
+
573
+ ``GraphicsLayoutWidget`` is a ``QGraphicsView``. Consuming its
574
+ drag-enter event in an event filter prevents the view from recording
575
+ the enter, but it can still receive the matching drag-leave. Besides
576
+ producing Qt's "drag leave received before drag enter" warning, that
577
+ leaves the view's drag state inconsistent. Leave the graphics view
578
+ out of this event sequence and let Qt deliver the drag to this
579
+ accepting parent window instead.
580
+ """
581
+ self.setAcceptDrops(True)
582
+
583
+ viewport = self.widget.viewport() if hasattr(self.widget, "viewport") else None
584
+ self.widget.setAcceptDrops(False)
585
+ if viewport is not None:
586
+ viewport.setAcceptDrops(False)
587
+
588
+
589
+ def _set_param_axis_label(self, axis, param):
590
+ self.plot.setLabel(axis=axis, text=param.label, units=param.unit)
591
+
592
+
593
+ def _set_param_axis_labels(self):
594
+ parameters = self.axis_param
595
+ line = self.__dict__.get("line")
596
+ if line is not None:
597
+ parameters = native_axis_quantities(
598
+ parameters["x"], parameters["y"], line.opts,
599
+ )
600
+ self._set_param_axis_label("bottom", parameters["x"])
601
+ self._set_param_axis_label("left", parameters["y"])
602
+
603
+ def _init_native_transform_labels(self):
604
+ """Coordinate line transforms before any native curve update runs."""
605
+ native_slots = {
606
+ "fftCheck": self.plot.updateSpectrumMode,
607
+ "derivativeCheck": self.plot.updateDerivativeMode,
608
+ "phasemapCheck": self.plot.updatePhasemapMode,
609
+ "subtractMeanCheck": self.plot.updateSubtractMeanMode,
610
+ }
611
+ self._qplot_native_transform_update = False
612
+ for name, slot in native_slots.items():
613
+ control = getattr(self.plot.ctrl, name)
614
+ if self.__dict__.get("line") is None:
615
+ control.toggled.connect(self._native_transform_labels_changed)
616
+ continue
617
+ # PlotItem connected these first. A later label/validation slot is
618
+ # too late: its native setters synchronously rebuild curve data.
619
+ control.toggled.disconnect(slot)
620
+ control.toggled.connect(
621
+ lambda value, name=name: self._native_transform_controls_changed(name, value)
622
+ )
623
+ self._install_native_transform_trace_handler(self.__dict__.get("line"))
624
+
625
+ def _native_transform_controls_changed(self, name, _value):
626
+ if self._qplot_native_transform_update:
627
+ return
628
+ self._qplot_native_transform_update = True
629
+ error = None
630
+ try:
631
+ ctrl = self.plot.ctrl
632
+ # Validate every curve before any synchronous native setter runs.
633
+ # A rejected FFT must also retain an outgoing derivative mode.
634
+ wants_fft = (ctrl.fftCheck.isChecked() and not ctrl.phasemapCheck.isChecked()
635
+ and (not ctrl.derivativeCheck.isChecked() or name == "fftCheck"))
636
+ if wants_fft:
637
+ error = self._native_fft_validation_error()
638
+ if error:
639
+ ctrl.fftCheck.setChecked(False)
640
+ if (not ctrl.phasemapCheck.isChecked()
641
+ and ctrl.fftCheck.isChecked() and ctrl.derivativeCheck.isChecked()):
642
+ # Outside phase map, the newly enabled transform wins. Leaving
643
+ # phase map retains derivative, whose input samples it used.
644
+ incompatible = ctrl.derivativeCheck if name == "fftCheck" else ctrl.fftCheck
645
+ incompatible.setChecked(False)
646
+ for line in tuple(self.plot.items):
647
+ if is_native_source_curve(line):
648
+ self._sync_native_trace_transforms(line)
649
+ self.plot.enableAutoRange()
650
+ self.plot.recomputeAverages()
651
+ finally:
652
+ self._qplot_native_transform_update = False
653
+ self._native_transform_labels_changed()
654
+ if error:
655
+ self._qplot_native_fft_error_text = error
656
+ self.show_status(error, 0)
657
+ elif self.__dict__.get("_qplot_native_fft_error_text"):
658
+ self._qplot_native_fft_error_text = None
659
+ self.show_status("", 0)
660
+
661
+ def _native_fft_validation_error(self):
662
+ for line in tuple(self.plot.items):
663
+ if is_native_source_curve(line):
664
+ error = native_fft_coordinate_error(line.getOriginalDataset()[0])
665
+ if error:
666
+ return error
667
+ return None
668
+
669
+ def _native_transform_labels_changed(self, _value=None):
670
+ if self._qplot_native_transform_update:
671
+ return
672
+ ctrl = self.plot.ctrl
673
+ if (ctrl.fftCheck.isChecked() and not ctrl.phasemapCheck.isChecked()
674
+ and self._native_fft_validation_error()):
675
+ # setData has a safe empty FFT fallback. Its publication signal
676
+ # now restores all curves, controls and labels to non-FFT mode.
677
+ self._native_transform_controls_changed("fftCheck", True)
678
+ return
679
+ if {"x", "y"}.issubset(self.__dict__.get("axis_param", {})):
680
+ self._set_param_axis_labels()
681
+ if (self.__dict__.get("_qplot_native_fft_error_text")
682
+ and self._native_fft_validation_error()):
683
+ # A worker's load-status message may follow the initial rollback
684
+ # during an axis change. Keep its FFT explanation after publication.
685
+ self.show_status(self._qplot_native_fft_error_text, 0)
686
+
687
+ def _install_native_transform_trace_handler(self, line):
688
+ if line is None or getattr(line, "_qplot_transform_label_handler", False):
689
+ return
690
+ line.sigPlotChanged.connect(self._native_transform_labels_changed)
691
+ line._qplot_transform_label_handler = True
692
+
693
+ def _sync_native_trace_transforms(self, line):
694
+ """Apply validated controls through valid intermediate curve states.
695
+
696
+ Native setters update immediately. Clear outgoing FFT/derivative modes
697
+ first, enable phase map before combining them, and disable phase map
698
+ last. This also covers new traces and traces on secondary viewboxes.
699
+ """
700
+ if not is_native_source_curve(line):
701
+ return
702
+ ctrl = self.plot.ctrl
703
+ if (not self._qplot_native_transform_update and ctrl.fftCheck.isChecked()
704
+ and not ctrl.phasemapCheck.isChecked() and self._native_fft_validation_error()):
705
+ # New traces inherit PlotItem's controls during addItem. Include
706
+ # them in a window-wide rollback before syncing individual modes.
707
+ self._native_transform_controls_changed("fftCheck", True)
708
+ return
709
+ fft = ctrl.fftCheck.isChecked()
710
+ derivative = ctrl.derivativeCheck.isChecked()
711
+ phase = ctrl.phasemapCheck.isChecked()
712
+ if not fft:
713
+ line.setFftMode(False)
714
+ if not derivative:
715
+ line.setDerivativeMode(False)
716
+ if phase:
717
+ line.setPhasemapMode(True)
718
+ line.setSubtractMeanMode(ctrl.subtractMeanCheck.isChecked())
719
+ line.setFftMode(fft)
720
+ line.setDerivativeMode(derivative)
721
+ if not phase:
722
+ line.setPhasemapMode(False)
723
+ self._install_native_transform_trace_handler(line)
724
+ if not self._qplot_native_transform_update:
725
+ self.plot.recomputeAverages()
726
+
727
+ def dragEnterEvent(self, event):
728
+ if self._handle_preview_drag_drop(event):
729
+ return
730
+ super().dragEnterEvent(event)
731
+
732
+
733
+ def dragMoveEvent(self, event):
734
+ if self._handle_preview_drag_drop(event):
735
+ return
736
+ super().dragMoveEvent(event)
737
+
738
+
739
+ def dropEvent(self, event):
740
+ if self._handle_preview_drag_drop(event):
741
+ return
742
+ super().dropEvent(event)
743
+
744
+
745
+ def _handle_preview_drag_drop(self, event):
746
+ payload = run_preview_payload_from_mime(event.mimeData())
747
+ if payload is None:
748
+ return False
749
+
750
+ if not self.accepts_preview_trace_drop(payload):
751
+ if event.type() == QtCore.QEvent.Type.Drop:
752
+ self.show_status(self._preview_drop_rejection_message(payload), 5000)
753
+ event.ignore()
754
+ return True
755
+
756
+ if event.type() == QtCore.QEvent.Type.Drop:
757
+ event.setDropAction(QtCore.Qt.DropAction.CopyAction)
758
+ event.accept()
759
+ source_identity = payload["guid"]
760
+ if payload.get("database_path"):
761
+ source_identity = DatasetKey(
762
+ payload["database_path"],
763
+ payload["guid"],
764
+ )
765
+ self.previewTraceDropRequested.emit(
766
+ self,
767
+ source_identity,
768
+ payload["parameter"]
769
+ )
770
+ return True
771
+
772
+ event.acceptProposedAction()
773
+ return True
774
+
775
+
776
+ def accepts_preview_trace_drop(self, payload):
777
+ operation_kind = getattr(self, "operation_kind", None)
778
+ if operation_kind not in {"plot1d", "plot2d"}:
779
+ return False
780
+
781
+ target_axes = tuple(getattr(self.param, "depends_on_", ()))
782
+ return preview_drop_is_compatible(target_axes, payload)
783
+
784
+
785
+ def _preview_drop_rejection_message(self, payload):
786
+ """Explain why a preview cannot be added to this plot."""
787
+
788
+ parameter_name = str(payload.get("parameter") or "this measurement")
789
+ operation_kind = getattr(self, "operation_kind", None)
790
+ if operation_kind == "plot1d":
791
+ return (
792
+ f"Cannot add {parameter_name}; line traces need the same "
793
+ "independent variable."
794
+ )
795
+ if operation_kind == "plot2d":
796
+ return (
797
+ f"Cannot add {parameter_name}; heatmaps need the same two "
798
+ "independent variables."
799
+ )
800
+ return "Drop previews onto a compatible line plot or heatmap."
801
+
802
+
803
+
804
+ def __str__(self):
805
+ filenameStr = path.basename(self._dataset_key.database_path)
806
+ fstr = (f"{filenameStr} | "
807
+ f"Run ID: {self.ds.run_id} | "
808
+ f"{self.param.name} ({self.param.label})"
809
+ )
810
+ return fstr
811
+
812
+
813
+ @property
814
+ def ds(self):
815
+ """
816
+ Returns the window's dataset from the dictionary of stored datasets
817
+
818
+ Returns
819
+ -------
820
+ qcodes.dataset.data_set.dataset
821
+
822
+ """
823
+ # Check dataset exists, produce new one if needed.
824
+ handle = self._dataset_holder.get(self._dataset_key)
825
+ if handle is None:
826
+ self.show_status(f"Dataset {self._guid} not found. Reloading...", 5000)
827
+ self.make_ds.emit(self._dataset_key)
828
+ handle = self._dataset_holder[self._dataset_key]
829
+
830
+ # Check a deletion timer is not active and stop
831
+ else:
832
+ handle.cancel_delete_timer()
833
+
834
+ return handle.dataset
835
+
836
+ ###############################################################################
837
+ # Init functions
838
+
839
+ def initRefresh(self, refrate : float):
840
+ """
841
+ Sets up refresh logic and widgets. Along with top toolbar
842
+
843
+ Parameters
844
+ ----------
845
+ refrate : float
846
+ Default value for the refresh timer.
847
+
848
+ """
849
+ self.toolbarRef = qtw.QToolBar("Refresh Timer")
850
+ self.addToolBar(QtCore.Qt.ToolBarArea.TopToolBarArea, self.toolbarRef)
851
+
852
+ if not self.ds.running:
853
+ self.toolbarRef.hide()
854
+
855
+ self.spinBox = qtw.QDoubleSpinBox()
856
+ self.spinBox.setRange(0.0, 10_000.0)
857
+ self.spinBox.setSingleStep(0.1)
858
+ self.spinBox.setDecimals(1)
859
+
860
+ self.toolbarRef.addWidget(qtw.QLabel("Refresh interval (s): "))
861
+ self.toolbarRef.addWidget(self.spinBox)
862
+
863
+ if refrate is not None and refrate > 0:
864
+ set_refresh_interval(self.spinBox, refrate)
865
+ else:
866
+ set_refresh_interval(
867
+ self.spinBox, self.config.get("user_preference.default_refresh_rate"),
868
+ )
869
+
870
+ connect_refresh_interval_edits(self.spinBox, self.monitorIntervalChanged)
871
+ self.monitor.timeout.connect(self.refreshWindow)
872
+
873
+
874
+ def initLabels(self):
875
+ """
876
+ Sets up bottom toolbar which displays cursor point.
877
+
878
+ """
879
+ self.toolbarCo_ord = qtw.QToolBar("Co-ordinates")
880
+ self.addToolBar(QtCore.Qt.ToolBarArea.BottomToolBarArea, self.toolbarCo_ord)
881
+
882
+ labelWidth = self._label_width #About the size of 3 s.f. scientific
883
+ self.pos_labels = {}
884
+
885
+ posLabelIndex = qtw.QLabel("")
886
+ posLabelIndex.setMinimumWidth(45)
887
+ self.toolbarCo_ord.addWidget(posLabelIndex)
888
+ self.pos_labels["index"] = posLabelIndex
889
+
890
+ posLabelx = qtw.QLabel("x= ")
891
+ posLabelx.setMinimumWidth(labelWidth)
892
+ self.toolbarCo_ord.addWidget(posLabelx)
893
+ self.pos_labels["x"] = posLabelx
894
+
895
+ posLabely = qtw.QLabel("y= ")
896
+ posLabely.setMinimumWidth(labelWidth)
897
+ self.toolbarCo_ord.addWidget(posLabely)
898
+ self.pos_labels["y"] = posLabely
899
+
900
+ self.toolbarCo_ord.addWidget(qtw.QLabel(" "))
901
+
902
+ self.plot.scene().sigMouseMoved.connect(self.mouseMoved)
903
+
904
+
905
+ def initContextMenu(self):
906
+ """
907
+ Adjusts the default plot context menu.
908
+
909
+ """
910
+ self.vbMenu = self.vb.menu
911
+ self.mouseModeAction = self._context_menu_action("Mouse Mode")
912
+ self._connect_mouse_mode_menu_to_preferences()
913
+ self._remove_scene_export_context_menu()
914
+ if getattr(self.plot, "ctrlMenu", None) is not None:
915
+ self.plot.ctrlMenu.setTitle("Options")
916
+ self.plot.ctrlMenu.menuAction().setText("Options")
917
+
918
+ self.exportPlotAction = create_action("plot.export", self)
919
+ self.register_shortcut(
920
+ self.exportPlotAction,
921
+ command_spec("plot.export"),
922
+ )
923
+ self.exportPlotAction.triggered.connect(self.open_export_dialog)
924
+
925
+ self.savePlotPdfAction = QtGui.QAction("Save Plot as &PDF...", self)
926
+ self.savePlotPdfAction.setObjectName("savePlotPdfAction")
927
+ self.savePlotPdfAction.setStatusTip("Save the visible plot area as a PDF")
928
+ self.savePlotPdfAction.triggered.connect(self.save_plot_pdf)
929
+
930
+ self.printPlotAction = create_action("plot.print", self)
931
+ self.register_shortcut(
932
+ self.printPlotAction,
933
+ command_spec("plot.print"),
934
+ )
935
+ self.printPlotAction.triggered.connect(self.print_plot)
936
+
937
+ self.copyPlotImageAction = create_action("plot.copy_image", self)
938
+ self.register_shortcut(
939
+ self.copyPlotImageAction,
940
+ command_spec("plot.copy_image"),
941
+ )
942
+ self.copyPlotImageAction.triggered.connect(self.copy_plot_image)
943
+
944
+ contextAction = create_action(
945
+ "context.show",
946
+ self,
947
+ status_tip="Show plot context menu",
948
+ )
949
+ self.register_shortcut(
950
+ contextAction,
951
+ command_with_status("context.show", "Show plot context menu"),
952
+ )
953
+ contextAction.triggered.connect(self.open_context_menu)
954
+
955
+ actions = self.vbMenu.actions()
956
+ for action in actions:
957
+ if action.text() == "View All":
958
+ self.register_shortcut(action, command_spec("plot.autoscale"))
959
+ action.setText(command_spec("plot.autoscale").text)
960
+ # Line owners install a validated full-view handler before
961
+ # native autoRange mutates the range. Their context callback
962
+ # already applies this guard once; heatmaps retain this hook.
963
+ if getattr(self, "operation_kind", None) not in ("plot1d", "sweeper"):
964
+ action.triggered.connect(
965
+ lambda _checked=False: self.force_all_axes_autoscale()
966
+ )
967
+ break
968
+
969
+ x_action = actions[1]
970
+
971
+ self.autoscaleSep = self.vbMenu.insertSeparator(x_action)
972
+
973
+ self.vbMenu.insertAction(x_action, self.savePlotPdfAction)
974
+ self.vbMenu.insertSeparator(x_action)
975
+ self.vbMenu.insertAction(x_action, self.copyPlotImageAction)
976
+ self.vbMenu.insertSeparator(x_action)
977
+
978
+ # Create visibility
979
+ toggleAction = create_action(
980
+ "plot.toggle_operations",
981
+ self,
982
+ checkable=True,
983
+ )
984
+ self.register_shortcut(toggleAction, command_spec("plot.toggle_operations"))
985
+ toggleAction.triggered.connect(self.oper_dock.setVisible)
986
+ self.oper_dock.visibilityChanged.connect(toggleAction.setChecked)
987
+ self.vbMenu.insertAction(x_action, toggleAction)
988
+ self.vbMenu.insertSeparator(x_action)
989
+
990
+ self._init_axis_scale_dialogs()
991
+
992
+
993
+ def _remove_scene_export_context_menu(self):
994
+ """
995
+ Removes every scene-level generic action and owns its exporter cache.
996
+
997
+ """
998
+ scene = self.widget.scene()
999
+ # PyQtGraph's default list currently contains only its direct Export
1000
+ # action. Clearing the list by identity-independent ownership is safer
1001
+ # than matching translated display text.
1002
+ scene.contextMenu = []
1003
+ install_dialog = getattr(
1004
+ self,
1005
+ "_install_safe_pyqtgraph_export_dialog",
1006
+ None,
1007
+ )
1008
+ if callable(install_dialog):
1009
+ install_dialog()
1010
+
1011
+
1012
+ def _context_menu_action(self, text):
1013
+ """
1014
+ Returns a pyqtgraph context-menu action by display text.
1015
+
1016
+ """
1017
+ for action in self.vbMenu.actions():
1018
+ if action.text().replace("&", "") == text:
1019
+ return action
1020
+ return None
1021
+
1022
+
1023
+ @QtCore.pyqtSlot()
1024
+ def open_context_menu(self):
1025
+ """
1026
+ Opens the plot context menu from the keyboard.
1027
+
1028
+ """
1029
+ self.vbMenu.exec(self.widget.mapToGlobal(self.widget.rect().center()))
1030
+
1031
+
1032
+ def _add_plot_area_resize_menu(self, window_menu):
1033
+ """
1034
+ Adds fixed plot-area resize actions to the Window menu.
1035
+
1036
+ """
1037
+ resize_menu = qtw.QMenu("&Resize", self)
1038
+ resize_menu.setObjectName("plotAreaResizeMenu")
1039
+
1040
+ for label, object_name, size in _PLOT_AREA_RESIZE_PRESETS:
1041
+ action = QtGui.QAction(
1042
+ _plot_area_size_icon(size),
1043
+ f"{label} ({size.width()} x {size.height()} px)",
1044
+ self,
1045
+ )
1046
+ action.setObjectName(object_name)
1047
+ action.setStatusTip(
1048
+ f"Resize the copied plot area to {size.width()} x {size.height()} px"
1049
+ )
1050
+ action.triggered.connect(
1051
+ lambda _checked=False, size=size: self.resize_plot_area(
1052
+ size.width(),
1053
+ size.height(),
1054
+ )
1055
+ )
1056
+ resize_menu.addAction(action)
1057
+
1058
+ resize_menu.addSeparator()
1059
+ custom_action = QtGui.QAction("&Custom...", self)
1060
+ custom_action.setObjectName("resizePlotAreaCustomAction")
1061
+ custom_action.setStatusTip("Resize the copied plot area to a custom pixel size")
1062
+ custom_action.triggered.connect(self.open_custom_plot_area_size_dialog)
1063
+ resize_menu.addAction(custom_action)
1064
+
1065
+ insert_before = next(
1066
+ (
1067
+ action for action in window_menu.actions()
1068
+ if action.text().replace("&", "") == "Minimize"
1069
+ ),
1070
+ None,
1071
+ )
1072
+ if insert_before is None:
1073
+ window_menu.addMenu(resize_menu)
1074
+ return resize_menu
1075
+
1076
+ window_menu.insertMenu(insert_before, resize_menu)
1077
+ window_menu.insertSeparator(insert_before)
1078
+ return resize_menu
1079
+
1080
+
1081
+ @QtCore.pyqtSlot()
1082
+ def open_custom_plot_area_size_dialog(self):
1083
+ """
1084
+ Opens a dialog for resizing the copied plot area to an exact pixel size.
1085
+
1086
+ """
1087
+ current_size = self._current_plot_area_size()
1088
+
1089
+ dialog = qtw.QDialog(self)
1090
+ dialog.setWindowTitle("Custom Plot Area Size")
1091
+
1092
+ form = qtw.QFormLayout(dialog)
1093
+ width_spin = qtw.QSpinBox(dialog)
1094
+ width_spin.setRange(1, 20_000)
1095
+ width_spin.setSuffix(" px")
1096
+ width_spin.setValue(max(1, current_size.width()))
1097
+
1098
+ height_spin = qtw.QSpinBox(dialog)
1099
+ height_spin.setRange(1, 20_000)
1100
+ height_spin.setSuffix(" px")
1101
+ height_spin.setValue(max(1, current_size.height()))
1102
+
1103
+ form.addRow("&Width:", width_spin)
1104
+ form.addRow("&Height:", height_spin)
1105
+
1106
+ buttons = qtw.QDialogButtonBox(
1107
+ qtw.QDialogButtonBox.StandardButton.Ok
1108
+ | qtw.QDialogButtonBox.StandardButton.Cancel,
1109
+ dialog,
1110
+ )
1111
+ buttons.accepted.connect(dialog.accept)
1112
+ buttons.rejected.connect(dialog.reject)
1113
+ form.addRow(buttons)
1114
+
1115
+ if dialog.exec() == qtw.QDialog.DialogCode.Accepted:
1116
+ self.resize_plot_area(width_spin.value(), height_spin.value())
1117
+
1118
+
1119
+ def _current_plot_area_size(self):
1120
+ """
1121
+ Returns the current copied plot-area size.
1122
+
1123
+ """
1124
+ widget = getattr(self, "widget", None)
1125
+ if widget is None:
1126
+ return QtCore.QSize(1, 1)
1127
+ return widget.size()
1128
+
1129
+
1130
+ def resize_plot_area(self, width, height):
1131
+ """
1132
+ Resizes the window so the copied plot area has the requested size.
1133
+
1134
+ """
1135
+ target = QtCore.QSize(int(width), int(height))
1136
+ if target.width() < 1 or target.height() < 1:
1137
+ self.show_status("Plot area size must be at least 1 x 1 px.", 5000)
1138
+ return False
1139
+
1140
+ widget = getattr(self, "widget", None)
1141
+ if widget is None:
1142
+ self.show_status("No plot area available to resize.", 5000)
1143
+ return False
1144
+
1145
+ if self.isMaximized() or self.isFullScreen():
1146
+ self.showNormal()
1147
+
1148
+ self._resize_window_for_plot_area(target)
1149
+ actual = self._current_plot_area_size()
1150
+ if actual == target:
1151
+ self.show_status(
1152
+ f"Plot area resized to {actual.width()} x {actual.height()} px.",
1153
+ 3000,
1154
+ )
1155
+ return True
1156
+
1157
+ self.show_status(
1158
+ "Plot area resized to "
1159
+ f"{actual.width()} x {actual.height()} px "
1160
+ f"(requested {target.width()} x {target.height()} px).",
1161
+ 5000,
1162
+ )
1163
+ return False
1164
+
1165
+
1166
+ def _resize_window_for_plot_area(self, target):
1167
+ """
1168
+ Applies the top-level resize, refining after layouts have updated.
1169
+
1170
+ """
1171
+ for _attempt in range(3):
1172
+ current = self._current_plot_area_size()
1173
+ delta = target - current
1174
+ if delta.isNull():
1175
+ return
1176
+
1177
+ next_size = self.size() + delta
1178
+ next_size.setWidth(max(1, next_size.width()))
1179
+ next_size.setHeight(max(1, next_size.height()))
1180
+ self.resize(next_size)
1181
+
1182
+ app = qtw.QApplication.instance()
1183
+ if app is not None:
1184
+ app.processEvents(
1185
+ QtCore.QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents
1186
+ )
1187
+
1188
+
1189
+ def initAxes(self):
1190
+ """
1191
+ Sets up the Data axes dock.
1192
+ Sets up which axis parameters are placed on for both 1d, 2d and more.
1193
+
1194
+ Refresh fetches the text of the dropdown menu to deciede which data to
1195
+ fetch
1196
+
1197
+ """
1198
+ indep_params = self.param.depends_on_
1199
+
1200
+ self.param_dict = {self.param.name: self.param}
1201
+
1202
+ for param in indep_params:
1203
+ param_spec = unpack_param(self.ds, param)
1204
+ self.param_dict[param_spec.name] = param_spec
1205
+
1206
+ # Use of QDockWidget over QToolbar to allow proper widget placement
1207
+ self.axes_dock = QDock_context("Data axes", self)
1208
+ self.addDockWidget(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, self.axes_dock)
1209
+ self.axes_dock.setVisible(False)
1210
+
1211
+ # Widget production
1212
+ x_layout = self.axes_dock.addLayout()
1213
+ x_layout.addWidget(qtw.QLabel("x axis: "))
1214
+ x_dropdown = expandingComboBox()
1215
+ x_dropdown.addItems(indep_params)
1216
+ x_layout.addWidget(x_dropdown)
1217
+
1218
+ y_layout = self.axes_dock.addLayout()
1219
+ y_layout.addWidget(qtw.QLabel("y axis: "))
1220
+ y_dropdown = expandingComboBox()
1221
+ y_dropdown.addItems(indep_params)
1222
+ y_layout.addWidget(y_dropdown)
1223
+
1224
+ # Store for later use
1225
+ self.axis_dropdown = {"x": x_dropdown, "y": y_dropdown}
1226
+
1227
+ # Add options to menus and place correct axes using dataset.depends_on_.
1228
+ # This was set to match plottr
1229
+ if len(indep_params) == 1: # 1d plot
1230
+ self.axis_dropdown["y"].addItems([self.param.name])
1231
+ self.axis_dropdown["x"].addItems([self.param.name])
1232
+
1233
+ self.axis_dropdown["x"].setCurrentIndex(
1234
+ self.axis_dropdown["x"].findText(indep_params[0])
1235
+ )
1236
+ self.axis_dropdown["y"].setCurrentIndex(
1237
+ self.axis_dropdown["y"].findText(self.param.name)
1238
+ )
1239
+ else:
1240
+ self.axis_dropdown["x"].setCurrentIndex(
1241
+ self.axis_dropdown["x"].findText(indep_params[1])
1242
+ )
1243
+ self.axis_dropdown["y"].setCurrentIndex(
1244
+ self.axis_dropdown["y"].findText(indep_params[0])
1245
+ )
1246
+
1247
+ # Connect slots.
1248
+ for axis in ["x", "y"]:
1249
+ self.axis_dropdown[axis].currentIndexChanged.connect(
1250
+ lambda index, axis=axis: self.change_axis(axis)
1251
+ )
1252
+ # Do not call the overridable ``axis_options`` property while the base
1253
+ # controls are still being constructed. Cut windows add their fixed-
1254
+ # axis picker only after this method returns.
1255
+ self._axis_selection = {
1256
+ axis: dropdown.currentText()
1257
+ for axis, dropdown in self.axis_dropdown.items()
1258
+ }
1259
+
1260
+ # Produce seperations line as QDockWidget as none inbuilt
1261
+ sep = qtw.QFrame()
1262
+ sep.setFrameShape(qtw.QFrame.Shape.HLine)
1263
+ sep.setFrameShadow(qtw.QFrame.Shadow.Sunken)
1264
+
1265
+ self.axes_dock.addWidget(sep)
1266
+
1267
+ if getattr(self, "operation_kind", None) == "plot2d":
1268
+ self.axes_dock.content_layout.addStretch()
1269
+
1270
+
1271
+ def initOperations(self):
1272
+ """
1273
+ Produces a right toolbar for viewing operations to perform during
1274
+ refresh
1275
+
1276
+ see ._widgets.operations for setup
1277
+ and
1278
+ qplot.tools.plot_tools for functions
1279
+
1280
+ """
1281
+ self.oper_dock = QDock_context("Operations", self)
1282
+ self.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, self.oper_dock)
1283
+ self.oper_dock.setVisible(False)# Large window so toggle off by default
1284
+
1285
+ self.oper_widget = operations_widget(self)
1286
+ self.oper_widget.apply_but.clicked.connect(lambda: self.refreshWindow(force=True))
1287
+ self.oper_dock.addWidget(self.oper_widget)
1288
+
1289
+
1290
+ def initMenu(self):
1291
+ """
1292
+ Produces top menu bar.
1293
+ Allows toggle of toolbars and force refresh.
1294
+
1295
+ """
1296
+ menu = self.menuBar()
1297
+
1298
+ file_menu = menu.addMenu("&File")
1299
+
1300
+ export_plot_action = getattr(self, "exportPlotAction", None)
1301
+ if export_plot_action is not None:
1302
+ file_menu.addAction(export_plot_action)
1303
+
1304
+ save_plot_pdf_action = getattr(self, "savePlotPdfAction", None)
1305
+ if save_plot_pdf_action is not None:
1306
+ file_menu.addAction(save_plot_pdf_action)
1307
+
1308
+ print_plot_action = getattr(self, "printPlotAction", None)
1309
+ if print_plot_action is not None:
1310
+ file_menu.addAction(print_plot_action)
1311
+
1312
+ if any(
1313
+ action is not None
1314
+ for action in (
1315
+ export_plot_action,
1316
+ save_plot_pdf_action,
1317
+ print_plot_action,
1318
+ )
1319
+ ):
1320
+ file_menu.addSeparator()
1321
+
1322
+ copy_plot_image_action = getattr(self, "copyPlotImageAction", None)
1323
+ if copy_plot_image_action is not None:
1324
+ edit_menu = menu.addMenu("&Edit")
1325
+ edit_menu.addAction(copy_plot_image_action)
1326
+
1327
+ close_all_plots_action = create_action(
1328
+ "plots.close_all",
1329
+ self,
1330
+ status_tip="Close all open plot windows",
1331
+ )
1332
+ close_all_plots_action.triggered.connect(self.request_close_all_plots)
1333
+ file_menu.addAction(close_all_plots_action)
1334
+
1335
+ closeAction = create_action(
1336
+ "window.close",
1337
+ self,
1338
+ status_tip="Close this plot window",
1339
+ )
1340
+ closeAction.triggered.connect(self.close)
1341
+ file_menu.addAction(closeAction)
1342
+
1343
+ add_application_quit_action(
1344
+ self,
1345
+ file_menu,
1346
+ self.request_application_quit,
1347
+ )
1348
+
1349
+ window_menu = add_standard_window_controls(self)
1350
+ self._add_plot_area_resize_menu(window_menu)
1351
+
1352
+ options_menu = menu.addMenu("&Options")
1353
+ options_menu.addAction(
1354
+ create_preferences_action(self, self.show_preferences_dialog)
1355
+ )
1356
+
1357
+ mouse_mode_action = getattr(self, "mouseModeAction", None)
1358
+ if mouse_mode_action is not None:
1359
+ self.vbMenu.removeAction(mouse_mode_action)
1360
+ options_menu.addSeparator()
1361
+ options_menu.addAction(mouse_mode_action)
1362
+
1363
+ main_menu = menu.addMenu("&View")
1364
+
1365
+ refreshAction = create_action("window.refresh", self)
1366
+ refreshAction.triggered.connect(lambda: self.refreshWindow(force=True))
1367
+ if hasattr(self, "get_mergables"): # Force refresh 1d line options
1368
+ refreshAction.triggered.connect(lambda: self.get_mergables.emit())
1369
+ main_menu.addAction(refreshAction)
1370
+
1371
+ toolbar_menu = self.createPopupMenu()
1372
+ toolbar_menu.setTitle("Toolbars")
1373
+ main_menu.addMenu(toolbar_menu)
1374
+ add_help_menu(self)
1375
+
1376
+ ###############################################################################
1377
+ #Other Methods
1378
+
1379
+ @staticmethod
1380
+ def formatNum(num : float, sf : int=3) -> str:
1381
+ """
1382
+ Formats cursor point value to clean str display for user.
1383
+
1384
+ Parameters
1385
+ ----------
1386
+ num : float
1387
+ Value at cursor point.
1388
+ sf : int, optional
1389
+ Number of significant figures to display. The default is 3.
1390
+ If this is changed, recomand increase labelWidth in initLables.
1391
+
1392
+ Returns
1393
+ -------
1394
+ str
1395
+ Formated string for display.
1396
+
1397
+ """
1398
+ if not isfinite(num):
1399
+ if num != num:
1400
+ return "nan"
1401
+ return "-inf" if num < 0 else "inf"
1402
+
1403
+ try: # Get number of leading/following zeros
1404
+ exponent = floor(log10(abs(num)))
1405
+ except ValueError:
1406
+ return f"{0:.{sf}f}"
1407
+
1408
+ precision = max(sf - 1, 0)
1409
+ if exponent >= sf or exponent < 0:
1410
+ return f"{num:.{precision}e}"
1411
+
1412
+ formatted = f"{num:.{max(sf - exponent - 1, 0)}f}"
1413
+ rounded_exponent = floor(log10(abs(float(formatted))))
1414
+ if rounded_exponent != exponent:
1415
+ return f"{num:.{precision}e}"
1416
+ return formatted
1417
+
1418
+
1419
+ def _set_cursor_index_label(self, text: str) -> None:
1420
+ """
1421
+ Update the cursor index label when the toolbar includes one.
1422
+
1423
+ """
1424
+ label = self.pos_labels.get("index")
1425
+ if label is not None:
1426
+ label.setText(text)
1427
+
1428
+
1429
+ def _cursor_1d_x_data(self):
1430
+ """
1431
+ Return full processed X samples in view coordinates for cursor indices.
1432
+
1433
+ """
1434
+ line = self.__dict__.get("line")
1435
+ if line is not None:
1436
+ data = _line_snap_data(line)
1437
+ return data.x_view if data is not None else None
1438
+
1439
+ return self.__dict__.get("axis_data", {}).get("x")
1440
+
1441
+
1442
+ def _nearest_1d_array_index(self, x_value: float) -> int | None:
1443
+ """
1444
+ Return the zero-based data index nearest to a cursor X coordinate.
1445
+
1446
+ """
1447
+ if not isfinite(x_value):
1448
+ return None
1449
+ x_data = self._cursor_1d_x_data()
1450
+ if x_data is None:
1451
+ return None
1452
+
1453
+ nearest_index = None
1454
+ nearest_distance = None
1455
+ for index, value in enumerate(x_data):
1456
+ try:
1457
+ numeric_value = float(value)
1458
+ except (TypeError, ValueError):
1459
+ continue
1460
+
1461
+ if not isfinite(numeric_value):
1462
+ continue
1463
+
1464
+ distance = abs(numeric_value - x_value)
1465
+ if nearest_distance is None or distance < nearest_distance:
1466
+ nearest_index = index
1467
+ nearest_distance = distance
1468
+
1469
+ return nearest_index
1470
+
1471
+
1472
+ def update_theme(self, config):
1473
+ """
1474
+ Updates theme of window to match main.
1475
+
1476
+ Parameters
1477
+ ----------
1478
+ config : qplot.config
1479
+ Updated config file.
1480
+
1481
+ """
1482
+ self.config = config
1483
+
1484
+ self.setStyleSheet(self.config.theme.main)
1485
+ self.config.theme.style_plotItem(self)
1486
+
1487
+
1488
+ def apply_current_settings(self):
1489
+ """
1490
+ Applies config-backed settings handled directly by plot windows.
1491
+
1492
+ """
1493
+ self.update_theme(self.config)
1494
+ self.apply_axis_major_tick_count_preference()
1495
+ self.apply_mouse_mode_preference()
1496
+ apply_colorbar_width = getattr(self, "apply_colorbar_width_preference", None)
1497
+ if callable(apply_colorbar_width):
1498
+ apply_colorbar_width()
1499
+
1500
+
1501
+ def _configured_mouse_mode(self):
1502
+ return self.config.get(MOUSE_MODE_KEY)
1503
+
1504
+
1505
+ def apply_axis_major_tick_count_preference(self):
1506
+ """Apply the target number of labelled major ticks to every plot axis."""
1507
+
1508
+ for side in ("left", "bottom", "right", "top"):
1509
+ _configure_major_ticks(
1510
+ self.plot.getAxis(side),
1511
+ self.config.get(AXIS_MAJOR_TICK_COUNT_KEY),
1512
+ )
1513
+
1514
+ colorbar = getattr(self, "bar", None)
1515
+ get_axis = getattr(colorbar, "getAxis", None)
1516
+ if callable(get_axis):
1517
+ for side in ("left", "bottom", "right", "top"):
1518
+ axis = get_axis(side)
1519
+ if axis is not None:
1520
+ _configure_major_ticks(
1521
+ axis,
1522
+ self.config.get(AXIS_MAJOR_TICK_COUNT_KEY),
1523
+ )
1524
+
1525
+ self.plot.update()
1526
+ viewport = getattr(self.widget, "viewport", None)
1527
+ if callable(viewport):
1528
+ viewport().update()
1529
+
1530
+
1531
+ def apply_mouse_mode_preference(self):
1532
+ """
1533
+ Applies the configured pyqtgraph mouse mode to this plot view.
1534
+
1535
+ """
1536
+ if hasattr(self, "vb"):
1537
+ self.vb.setLeftButtonAction(self._configured_mouse_mode())
1538
+
1539
+
1540
+ def change_mouse_mode(self, mode):
1541
+ """
1542
+ Persists a mouse mode change made from a plot-window menu.
1543
+
1544
+ """
1545
+ if mode not in {"pan", "rect"}:
1546
+ return False
1547
+
1548
+ main_window = main_window_for(self)
1549
+ main_config = getattr(main_window, "config", None)
1550
+ target_config = self.config
1551
+ if main_config is self.config:
1552
+ target_config = main_config
1553
+
1554
+ current_mode = target_config.get(MOUSE_MODE_KEY)
1555
+
1556
+ def rollback():
1557
+ if hasattr(self, "vb"):
1558
+ self.vb.setLeftButtonAction(current_mode)
1559
+
1560
+ mouse_mode_action = self.__dict__.get("mouseModeAction")
1561
+ menu = (
1562
+ mouse_mode_action.menu()
1563
+ if mouse_mode_action is not None
1564
+ else None
1565
+ )
1566
+ if menu is None:
1567
+ return
1568
+ mode_actions = getattr(
1569
+ self.__dict__.get("vbMenu"),
1570
+ "mouseModes",
1571
+ menu.actions(),
1572
+ )
1573
+ blocked_states = [
1574
+ mode_action.blockSignals(True)
1575
+ for mode_action in mode_actions
1576
+ ]
1577
+ try:
1578
+ for mode_action, action_mode in zip(
1579
+ mode_actions,
1580
+ ("pan", "rect"),
1581
+ strict=False,
1582
+ ):
1583
+ mode_action.setChecked(action_mode == current_mode)
1584
+ finally:
1585
+ for mode_action, signals_were_blocked in zip(
1586
+ mode_actions,
1587
+ blocked_states,
1588
+ strict=True,
1589
+ ):
1590
+ mode_action.blockSignals(signals_were_blocked)
1591
+
1592
+ if current_mode != mode:
1593
+ error_owner = main_window if main_config is target_config else self
1594
+ if not persist_config_value(
1595
+ error_owner,
1596
+ target_config,
1597
+ MOUSE_MODE_KEY,
1598
+ mode,
1599
+ "the mouse mode",
1600
+ rollback,
1601
+ ):
1602
+ return False
1603
+
1604
+ if (
1605
+ main_window is not None
1606
+ and main_window is not self
1607
+ and main_config is target_config
1608
+ and hasattr(main_window, "apply_current_settings")
1609
+ ):
1610
+ main_window.apply_current_settings()
1611
+ else:
1612
+ self.apply_mouse_mode_preference()
1613
+ return True
1614
+
1615
+
1616
+ def _connect_mouse_mode_menu_to_preferences(self):
1617
+ menu = self.mouseModeAction.menu() if self.mouseModeAction is not None else None
1618
+ if menu is None:
1619
+ return
1620
+
1621
+ mode_actions = getattr(
1622
+ self.__dict__.get("vbMenu"),
1623
+ "mouseModes",
1624
+ menu.actions(),
1625
+ )
1626
+ for action, mode in zip(
1627
+ mode_actions,
1628
+ ("pan", "rect"),
1629
+ strict=False,
1630
+ ):
1631
+ action.triggered.connect(
1632
+ lambda _checked=False, mode=mode: self.change_mouse_mode(mode)
1633
+ )
1634
+
1635
+
1636
+ def show_preferences_dialog(self, initial_tab: str | None = None):
1637
+ """
1638
+ Opens the shared preferences dialog from a plot window.
1639
+
1640
+ """
1641
+ main_window = main_window_for(self)
1642
+ owner = main_window if main_window is not None else self
1643
+ config = getattr(owner, "config", self.config)
1644
+
1645
+ if config is not self.config:
1646
+ self.config = config
1647
+
1648
+ dialog = PreferencesDialog(config, self)
1649
+ if initial_tab is not None:
1650
+ tab_widget = dialog.findChild(qtw.QTabWidget)
1651
+ if tab_widget is not None:
1652
+ for index in range(tab_widget.count()):
1653
+ if tab_widget.tabText(index) == initial_tab:
1654
+ tab_widget.setCurrentIndex(index)
1655
+ break
1656
+ if initial_tab == "Runtime":
1657
+ QtCore.QTimer.singleShot(
1658
+ 0,
1659
+ dialog.maxFullHeatmapPointsSpin.setFocus,
1660
+ )
1661
+ if hasattr(owner, "apply_current_settings"):
1662
+ dialog.preferencesApplied.connect(owner.apply_current_settings)
1663
+ else:
1664
+ dialog.preferencesApplied.connect(self.apply_current_settings)
1665
+ dialog.preferencesApplied.connect(
1666
+ lambda: self.show_status("Preferences saved.", 3000)
1667
+ )
1668
+ try:
1669
+ dialog.exec()
1670
+ finally:
1671
+ dialog.deleteLater()
1672
+
1673
+
1674
+ @staticmethod
1675
+ def request_application_quit():
1676
+ """
1677
+ Closes the main window first so its normal quit handling still applies.
1678
+
1679
+ """
1680
+ app = qtw.QApplication.instance()
1681
+ if app is None:
1682
+ return
1683
+
1684
+ for window in app.topLevelWidgets():
1685
+ if window.__class__.__name__ == "MainWindow":
1686
+ quit_application = getattr(window, "quit_application", None)
1687
+ if callable(quit_application):
1688
+ quit_application()
1689
+ else:
1690
+ window.close()
1691
+ return
1692
+
1693
+ app.closeAllWindows()
1694
+
1695
+
1696
+ @staticmethod
1697
+ def request_close_all_plots():
1698
+ """
1699
+ Closes all plot windows through the main window.
1700
+
1701
+ """
1702
+ app = qtw.QApplication.instance()
1703
+ if app is None:
1704
+ return
1705
+
1706
+ for window in app.topLevelWidgets():
1707
+ if hasattr(window, "closeAll"):
1708
+ window.closeAll()
1709
+ return
1710
+
1711
+
1712
+ #Note, this is an overwrite of core QMainWindow function
1713
+ def createPopupMenu(self) -> "qtw.QMenu":
1714
+ """
1715
+ Produces a pop-up/context menu.
1716
+ Displays all toolbars/dockwidgets to allow for toggle on/off
1717
+
1718
+ Returns
1719
+ -------
1720
+ menu : PyQt6.QtWidgets.QMenu
1721
+ Context menu to be displayed.
1722
+
1723
+ """
1724
+ menu = qtw.QMenu(self)
1725
+
1726
+ # Fetching QToolBar and QDockWidget
1727
+ widgets = self.findChildren((qtw.QToolBar, qtw.QDockWidget))
1728
+
1729
+ # Set actions
1730
+ for widget in widgets:
1731
+ action = widget.toggleViewAction()
1732
+ if isinstance(action, QtGui.QAction):
1733
+ command = toolbar_toggle_command_spec(widget.windowTitle())
1734
+ if command is not None:
1735
+ self.register_shortcut(
1736
+ action,
1737
+ command,
1738
+ )
1739
+ menu.addAction(action)
1740
+
1741
+ return menu
1742
+
1743
+ @property
1744
+ def axis_options(self) -> dict:
1745
+ """
1746
+ Returns the currently selected axis in the axis dropdown boxes
1747
+
1748
+ Returns
1749
+ -------
1750
+ dict{str: str}
1751
+ Dictionary in form {axis_name: parameter_name}.
1752
+
1753
+ """
1754
+ return {k: v.currentText() for k, v in self.axis_dropdown.items()}
1755
+
1756
+
1757
+ ###############################################################################
1758
+ #Events
1759
+
1760
+ def keyPressEvent(self, event):
1761
+ if event.key() == QtCore.Qt.Key.Key_Escape and self.__dict__.get("marquee") is not None:
1762
+ self.clear_marquee()
1763
+ event.accept()
1764
+ return
1765
+
1766
+ super().keyPressEvent(event)
1767
+
1768
+
1769
+ @QtCore.pyqtSlot(bool)
1770
+ def closeEvent(self, event):
1771
+ """
1772
+ Handles close admin on close event.
1773
+ Sends signal, closed, to Main window to further handle event.
1774
+
1775
+ Parameters
1776
+ ----------
1777
+ Unused but required by slot.
1778
+
1779
+ """
1780
+ # Qt leaves child windows visible when their parent is merely closed.
1781
+ # Reject editors before releasing the dataset so they cannot act on a
1782
+ # closed plot. Keep their widgets alive for retained overlay sources.
1783
+ for dialog in self.findChildren(qtw.QDialog):
1784
+ dialog.reject()
1785
+ dispose_export_dialog = getattr(
1786
+ self,
1787
+ "_dispose_plot_export_dialog",
1788
+ None,
1789
+ )
1790
+ if callable(dispose_export_dialog):
1791
+ dispose_export_dialog()
1792
+ if self.__dict__.get("_merged_trace_users", 0) <= 0:
1793
+ self.monitor.stop()
1794
+ self._refresh_pending = False
1795
+ self._refresh_pending_force = False
1796
+ worker = self.__dict__.get("worker")
1797
+ cancel = getattr(worker, "cancel", None)
1798
+ if callable(cancel):
1799
+ cancel()
1800
+ self.visible = False
1801
+ self._closed = True
1802
+ self.plot_state_overlay.hide()
1803
+ self.closed.emit(self)
1804
+
1805
+ if (
1806
+ self.__dict__.get("_merged_trace_users", 0) > 0
1807
+ and self._refresh_monitor_required(self.ds)
1808
+ and not self.monitor.isActive()
1809
+ ):
1810
+ self.monitorIntervalChanged(refresh_interval_value(self.spinBox))
1811
+
1812
+
1813
+ @QtCore.pyqtSlot(object)
1814
+ def mouseMoved(self, pos):
1815
+ """
1816
+ Handles event for moving mouse over plot widget. Updates labels defined
1817
+ in self.initLabels().
1818
+
1819
+ Parameters
1820
+ ----------
1821
+ pos : PyQt6.<something?>
1822
+ The cursor position object.
1823
+
1824
+ """
1825
+ # Ignore if not in plot widget
1826
+ if not self.plot.sceneBoundingRect().contains(pos):
1827
+ self._set_cursor_index_label("")
1828
+ if hasattr(self, "hide_hover_pixel_outline"):
1829
+ self.hide_hover_pixel_outline()
1830
+ z_label = self.pos_labels.get("z")
1831
+ if z_label is not None:
1832
+ z_label.setText("z =")
1833
+ return
1834
+
1835
+ # get x, y values.
1836
+ try:
1837
+ viewbox_getter = getattr(self, "_primary_heatmap_viewbox", None)
1838
+ except RuntimeError:
1839
+ viewbox_getter = None
1840
+ coordinate_viewbox = (
1841
+ viewbox_getter() if callable(viewbox_getter) else self.plot.vb
1842
+ )
1843
+ if coordinate_viewbox is None:
1844
+ coordinate_viewbox = self.plot.vb
1845
+ line = self.__dict__.get("line")
1846
+ line_axes = ("x", "y")
1847
+ line_viewbox_getter = getattr(line, "getViewBox", None)
1848
+ if not callable(viewbox_getter) and callable(line_viewbox_getter):
1849
+ try:
1850
+ line_viewbox = line_viewbox_getter()
1851
+ except RuntimeError:
1852
+ line_viewbox = None
1853
+ if line_viewbox is not None:
1854
+ coordinate_viewbox = line_viewbox
1855
+ line_axes = (
1856
+ self._axis_scale_axis_for_line(line, "x"),
1857
+ self._axis_scale_axis_for_line(line, "y"),
1858
+ )
1859
+ mousePoint = coordinate_viewbox.mapSceneToView(pos)
1860
+ try:
1861
+ semantic_axes_getter = getattr(
1862
+ self,
1863
+ "_primary_heatmap_semantic_axes",
1864
+ None,
1865
+ )
1866
+ except RuntimeError:
1867
+ semantic_axes_getter = None
1868
+ semantic_x, semantic_y = (
1869
+ semantic_axes_getter()
1870
+ if callable(semantic_axes_getter)
1871
+ else line_axes
1872
+ )
1873
+ physical_x = cast(float, self.view_to_data(semantic_x, mousePoint.x()))
1874
+ physical_y = cast(float, self.view_to_data(semantic_y, mousePoint.y()))
1875
+
1876
+ # Format text into a easy to read format
1877
+ index_txt = ""
1878
+ x_txt = f"x = {self.formatNum(physical_x)};"
1879
+ y_txt = f"y = {self.formatNum(physical_y)}"
1880
+
1881
+ # For 2d plots.
1882
+ if self.pos_labels.get("z", 0):
1883
+ y_txt += ";"
1884
+ sample_at = getattr(self, "heatmap_sample_at", None)
1885
+ sample = (
1886
+ sample_at(mousePoint.x(), mousePoint.y())
1887
+ if callable(sample_at)
1888
+ else None
1889
+ )
1890
+ if sample is not None:
1891
+ i, j, x, y, z = sample
1892
+ index_txt = f"[{i},{j}]"
1893
+ x_txt = f"x = {self.formatNum(x)};"
1894
+ y_txt = f"y = {self.formatNum(y)};"
1895
+ self.pos_labels["z"].setText(f"z = {self.formatNum(z)}")
1896
+ if hasattr(self, "show_hover_pixel_outline"):
1897
+ self.show_hover_pixel_outline(i, j)
1898
+ else:
1899
+ self.z_index = [i, j]
1900
+ else:
1901
+ self.pos_labels["z"].setText("z =")
1902
+ if hasattr(self, "hide_hover_pixel_outline"):
1903
+ self.hide_hover_pixel_outline()
1904
+ else:
1905
+ self.z_index = None
1906
+ else:
1907
+ index = self._nearest_1d_array_index(mousePoint.x())
1908
+ if index is not None:
1909
+ index_txt = f"[{index}]"
1910
+
1911
+ # Update text
1912
+ self._set_cursor_index_label(index_txt)
1913
+ self.pos_labels["x"].setText(x_txt)
1914
+ self.pos_labels["y"].setText(y_txt)
1915
+
1916
+
1917
+ @QtCore.pyqtSlot(float)
1918
+ def monitorIntervalChanged(self, interval):
1919
+ """
1920
+ Handles event for self.spinBox value change.
1921
+ Updates refresh timer.
1922
+
1923
+ Parameters
1924
+ ----------
1925
+ interval : float
1926
+ Time in seconds to change refresh timer to.
1927
+
1928
+ """
1929
+ remember_refresh_interval(self.spinBox, interval)
1930
+ self.monitor.stop()
1931
+ if interval > 0:
1932
+ self.monitor.start(max(1, round(interval * 1000)))
1933
+
1934
+
1935
+ def add_or_remove_operations(self, key : str, func : callable = None):
1936
+ """
1937
+ Adds a callable function to be passed to the operations for the worker
1938
+
1939
+ Parameters
1940
+ ----------
1941
+ key : str
1942
+ A key to track the function.
1943
+ func : callable, optional
1944
+ Function to be added to the tracker. If None is passed instead of a
1945
+ callable, the key is instead removed from the tracker.
1946
+
1947
+ """
1948
+ # Remove item if func is none
1949
+ if func is None and self.operations.get(key, 0) != 0:
1950
+ self.operations.pop(key)
1951
+ else: # otherwise add to list
1952
+ self.operations[key] = func
1953
+
1954
+ # Force update
1955
+ self.refreshWindow(force=True)
1956
+
1957
+
1958
+ @QtCore.pyqtSlot()
1959
+ def change_axis(self, key : str):
1960
+ """
1961
+ Event handler for axis dropdown menu selection change.
1962
+ Switches the axes based on user selection and calls a forced refresh.
1963
+
1964
+ Parameters
1965
+ ----------
1966
+ key : str
1967
+ The axis label (x or y) which has been changed.
1968
+
1969
+ Raises
1970
+ ------
1971
+ ValueError
1972
+ Error catch for rare cases where dropdown menus fail to correctly
1973
+ update.
1974
+
1975
+ """
1976
+ previous = getattr(self, "_axis_selection", self.axis_options)
1977
+ duplicates = [k for k, v in self.axis_dropdown.items()
1978
+ if self.axis_dropdown[key].currentText() == v.currentText()
1979
+ and k != key
1980
+ ]
1981
+
1982
+ # If both boxes show the same value, switch second box to original value
1983
+ if len(duplicates) == 1:
1984
+ self.axis_dropdown[duplicates[0]].blockSignals(True)
1985
+
1986
+ self.axis_dropdown[duplicates[0]].setCurrentIndex(
1987
+ self.axis_dropdown[duplicates[0]].findText(previous[key])
1988
+ )
1989
+
1990
+ self.axis_dropdown[duplicates[0]].blockSignals(False)
1991
+
1992
+ self._axis_selection = self.axis_options
1993
+ self.refreshWindow(force=True)