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