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,1485 @@
1
+ from collections.abc import Callable, Iterator
2
+ from contextlib import contextmanager
3
+ from math import isclose, isfinite, log10
4
+ from typing import TYPE_CHECKING, Any, Literal
5
+
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+ import pyqtgraph as pg
9
+ from PyQt6 import QtCore
10
+ from PyQt6 import QtWidgets as qtw
11
+ from pyqtgraph.graphicsItems.ViewBox import axisCtrlTemplate_generic
12
+
13
+ _AxisName = Literal["x", "y", "x2", "y2"]
14
+ _AXIS_MENU_ITEMS: tuple[tuple[_AxisName, str], ...] = (
15
+ ("x", "X axis"),
16
+ ("y", "Y axis"),
17
+ )
18
+ _AXIS_SPECS: tuple[tuple[_AxisName, str, str], ...] = (
19
+ ("x", "X", "bottom"),
20
+ ("y", "Y", "left"),
21
+ ("x2", "X2", "top"),
22
+ ("y2", "Y2", "right"),
23
+ )
24
+ _AXIS_DIMENSIONS: dict[_AxisName, Literal["x", "y"]] = {
25
+ "x": "x",
26
+ "y": "y",
27
+ "x2": "x",
28
+ "y2": "y",
29
+ }
30
+ _AXIS_SIDES: dict[_AxisName, str] = {
31
+ "x": "bottom",
32
+ "y": "left",
33
+ "x2": "top",
34
+ "y2": "right",
35
+ }
36
+ _AXIS_ALIASES: dict[str, _AxisName] = {
37
+ "x": "x",
38
+ "bottom": "x",
39
+ "y": "y",
40
+ "left": "y",
41
+ "x2": "x2",
42
+ "top": "x2",
43
+ "y2": "y2",
44
+ "right": "y2",
45
+ }
46
+
47
+ _NumericValue = float | npt.ArrayLike
48
+
49
+ if TYPE_CHECKING:
50
+ class _PlotAxisScalingBase(qtw.QMainWindow):
51
+ _axis_scale_controls: dict[_AxisName, Any]
52
+ _axis_scale_dialog: qtw.QDialog | None
53
+ _axis_scale_tabs: qtw.QTabWidget | None
54
+ plot: Any
55
+ right_vb: Any
56
+ vb: Any
57
+ vbMenu: Any
58
+
59
+ def _context_menu_action(self, text: str) -> Any | None: ...
60
+ def _view_range_changed_programmatically(self) -> None: ...
61
+ else:
62
+ class _PlotAxisScalingBase:
63
+ pass
64
+
65
+
66
+ def _manual_axis_view_limits_are_usable(
67
+ viewbox: Any,
68
+ axis_number: int,
69
+ values: tuple[float, float] | list[float],
70
+ ) -> bool:
71
+ """Preflight PyQtGraph's range arithmetic before it changes view state."""
72
+ lower, upper = values
73
+ span = upper - lower
74
+ endpoint_sum = lower + upper
75
+ if (
76
+ not all(isfinite(value) for value in (lower, upper, span, endpoint_sum))
77
+ or span <= 0
78
+ ):
79
+ return False
80
+ # ViewBox expands narrow positive-offset ranges by this quantization
81
+ # allowance before applying its limits. Check the same arithmetic without
82
+ # disabling Auto or publishing an intermediate invalid target range.
83
+ allowance = endpoint_sum * 1.5e-15
84
+ if span < 2 * allowance:
85
+ lower -= allowance
86
+ upper += allowance
87
+ if not all(isfinite(value) for value in (lower, upper, upper - lower)):
88
+ return False
89
+ effective_limits = getattr(viewbox, "_effectiveLimits", None)
90
+ if callable(effective_limits):
91
+ minimum, maximum = effective_limits()[axis_number]
92
+ if minimum is not None and lower < minimum:
93
+ return False
94
+ if maximum is not None and upper > maximum:
95
+ return False
96
+ return True
97
+
98
+
99
+ def _disable_native_axis_auto(viewbox: Any, axis_constant: int) -> None:
100
+ """Let qPlot replace pending native auto bounds without applying them."""
101
+ pending = viewbox._autoRangeNeedsUpdate
102
+ viewbox._autoRangeNeedsUpdate = False
103
+ try:
104
+ viewbox.enableAutoRange(axis_constant, False)
105
+ finally:
106
+ viewbox._autoRangeNeedsUpdate = pending and any(
107
+ mode is not False for mode in viewbox.autoRangeEnabled()
108
+ )
109
+
110
+
111
+ @contextmanager
112
+ def _axis_bounds_without_dynamic_clipping(
113
+ viewbox: Any, items: list[Any] | None,
114
+ ) -> Iterator[None]:
115
+ """Measure processed curves without clipping them to the previous Y view."""
116
+ changed = []
117
+ try:
118
+ for item in items if items is not None else viewbox.addedItems:
119
+ if not isinstance(item, pg.PlotDataItem):
120
+ continue
121
+ limit = item.opts.get("dynamicRangeLimit")
122
+ if limit is None:
123
+ continue
124
+ changed.append((item, limit))
125
+ item.opts["dynamicRangeLimit"] = None
126
+ item._datasetDisplay = None
127
+ item.updateItems()
128
+ yield
129
+ finally:
130
+ for item, limit in reversed(changed):
131
+ item.opts["dynamicRangeLimit"] = limit
132
+ item._datasetDisplay = None
133
+ item.updateItems()
134
+
135
+
136
+ def _axis_scale_power_text(scale: float) -> str:
137
+ """
138
+ Return a compact HTML power-of-ten label for an axis display scale.
139
+
140
+ """
141
+ if not isfinite(scale) or scale <= 0 or isclose(scale, 1.0):
142
+ return ""
143
+
144
+ exponent = round(log10(scale))
145
+ if isclose(scale, 10**exponent, rel_tol=1e-9, abs_tol=0.0):
146
+ return f"10<sup>{exponent}</sup>"
147
+
148
+ return f"{scale:g}"
149
+
150
+
151
+ def _flush_axis_draw_specs(axis: pg.AxisItem, specs: Any) -> Any:
152
+ """Move pyqtgraph's axis line onto the edge of its linked view."""
153
+
154
+ if specs is None:
155
+ return None
156
+
157
+ endpoint_offsets = {
158
+ "left": (QtCore.QPointF(1.0, 1.0), QtCore.QPointF(1.0, -1.0)),
159
+ "right": (QtCore.QPointF(-1.0, 1.0), QtCore.QPointF(-1.0, -1.0)),
160
+ "top": (QtCore.QPointF(1.0, 1.0), QtCore.QPointF(-1.0, 1.0)),
161
+ "bottom": (QtCore.QPointF(1.0, -1.0), QtCore.QPointF(-1.0, -1.0)),
162
+ }
163
+ offsets = endpoint_offsets.get(axis.orientation)
164
+ if offsets is None:
165
+ return specs
166
+
167
+ axis_spec, tick_specs, text_specs = specs
168
+ pen, start, end = axis_spec
169
+ start_offset, end_offset = offsets
170
+ flush_axis_spec = (
171
+ pen,
172
+ QtCore.QPointF(start) + start_offset,
173
+ QtCore.QPointF(end) + end_offset,
174
+ )
175
+ return flush_axis_spec, tick_specs, text_specs
176
+
177
+
178
+ def _install_flush_axis_draw_specs(axis: pg.AxisItem) -> None:
179
+ """Remove the one-pixel frame standoff from an existing axis item."""
180
+
181
+ if getattr(axis, "_qplot_flush_axis_draw_specs", False):
182
+ return
183
+
184
+ original_generate_draw_specs = axis.generateDrawSpecs
185
+
186
+ def generate_draw_specs(painter: Any) -> Any:
187
+ return _flush_axis_draw_specs(
188
+ axis,
189
+ original_generate_draw_specs(painter),
190
+ )
191
+
192
+ axis.generateDrawSpecs = generate_draw_specs
193
+ axis._qplot_flush_axis_draw_specs = True
194
+ axis.picture = None
195
+ axis.update()
196
+
197
+
198
+ class _PowerScaledAxisItem(pg.AxisItem):
199
+ """
200
+ Display pyqtgraph's auto SI scaling as powers of ten in the axis unit.
201
+
202
+ """
203
+
204
+ def labelString(self) -> str:
205
+ if self.autoSIPrefix and not isclose(self.autoSIPrefixScale, 1.0):
206
+ unit_scale = 1.0 / self.autoSIPrefixScale
207
+ else:
208
+ unit_scale = 1.0
209
+
210
+ scale_text = _axis_scale_power_text(unit_scale)
211
+ if self.labelUnits == "":
212
+ units = f"({scale_text})" if scale_text else ""
213
+ elif scale_text:
214
+ units = f"({scale_text} {self.labelUnits})"
215
+ else:
216
+ units = f"({self.labelUnitPrefix}{self.labelUnits})"
217
+
218
+ text = f"{self.labelText} {units}"
219
+ style = ";".join([f"{k}: {self.labelStyle[k]}" for k in self.labelStyle])
220
+ return f"<span style='{style}'>{text}</span>"
221
+
222
+
223
+ class _AutoLimitsButton(qtw.QToolButton):
224
+ """Refresh its prospective-limit tooltip immediately before display."""
225
+
226
+ def __init__(self, refresh_tooltip: Callable[[], None], parent: qtw.QWidget):
227
+ super().__init__(parent)
228
+ self._refresh_tooltip = refresh_tooltip
229
+
230
+ def event(self, event: QtCore.QEvent) -> bool:
231
+ if event.type() == QtCore.QEvent.Type.ToolTip:
232
+ self._refresh_tooltip()
233
+ return super().event(event)
234
+
235
+
236
+ class PlotAxisScalingMixin(_PlotAxisScalingBase):
237
+ """
238
+ Axis scaling dialogs and controls shared by plot windows.
239
+
240
+ This mixin adapts pyqtgraph's embedded ViewBox controls into qPlot dialogs
241
+ opened by double-clicking the plot axes.
242
+ """
243
+
244
+ def _init_axis_scale_dialogs(self) -> None:
245
+ """
246
+ Move pyqtgraph's axis scaling controls into one tabbed dialog.
247
+
248
+ """
249
+ self._axis_scale_controls = {}
250
+ self._axis_scale_dialog = None
251
+ self._axis_scale_tabs = None
252
+ self._axis_scale_custom_auto_axes: set[_AxisName] = set()
253
+ self._axis_scale_programmatic_change_depth = 0
254
+ self._axis_scale_range_connections: list[tuple[Any, Any]] = []
255
+
256
+ for _axis, menu_text in _AXIS_MENU_ITEMS:
257
+ action = self._context_menu_action(menu_text)
258
+ if action is None or action.menu() is None:
259
+ continue
260
+
261
+ self.vbMenu.removeAction(action)
262
+
263
+ # Log controls now live with the other per-axis controls. The original
264
+ # PlotItem checkboxes are global to both sides and cannot represent X2
265
+ # and Y2 independently.
266
+ plot_controls = getattr(self.plot, "ctrl", None)
267
+ for name in ("logXCheck", "logYCheck"):
268
+ control = getattr(plot_controls, name, None)
269
+ if control is not None:
270
+ control.hide()
271
+
272
+ self._install_axis_scale_viewbox_range_handlers(
273
+ self.vb,
274
+ x_axis="x",
275
+ y_axis="y",
276
+ )
277
+ self._install_axis_scale_double_click_handlers()
278
+ if getattr(self, "operation_kind", None) in ("plot1d", "sweeper"):
279
+ # Full-view requests must reach qPlot before native bounds derived
280
+ # from drawing-clipped coordinates mutate the current view.
281
+ self.plot.autoBtn.clicked.disconnect(self.plot.autoBtnClicked)
282
+ self.plot.autoBtn.clicked.connect(self._axis_scale_full_view_button_clicked)
283
+ self.vb.set_auto_range_handler(lambda: self.force_all_axes_autoscale())
284
+
285
+ def _axis_scale_full_view_button_clicked(self) -> None:
286
+ """Apply full-view Auto with the same range guard as axis controls."""
287
+ if self.plot.autoBtn.mode == "auto":
288
+ self.force_all_axes_autoscale()
289
+ self.plot.autoBtn.hide()
290
+ self.plot.sigRangeChangedManually.emit(self.vb.mouseEnabled()[:])
291
+ return
292
+ self.__dict__.get("_axis_scale_custom_auto_axes", set()).clear()
293
+ self.plot.disableAutoRange()
294
+ for name in ("right_vb", "top_vb", "top_right_vb"):
295
+ viewbox = self.__dict__.get(name)
296
+ if viewbox is not None:
297
+ viewbox.disableAutoRange()
298
+ for axis in self.__dict__.get("_axis_scale_controls", {}):
299
+ self._sync_axis_scale_controls(axis)
300
+
301
+ def _install_axis_scale_viewbox_range_handlers(
302
+ self,
303
+ viewbox: Any,
304
+ *,
305
+ x_axis: _AxisName | None = None,
306
+ y_axis: _AxisName | None = None,
307
+ ) -> None:
308
+ """Keep dialog fields synchronized with semantic ViewBox dimensions."""
309
+
310
+ installed = self.__dict__.setdefault(
311
+ "_axis_scale_range_handler_keys",
312
+ set(),
313
+ )
314
+ connections = self.__dict__.setdefault(
315
+ "_axis_scale_range_connections",
316
+ [],
317
+ )
318
+ for signal_name, axis in (
319
+ ("sigXRangeChanged", x_axis),
320
+ ("sigYRangeChanged", y_axis),
321
+ ):
322
+ if axis is None:
323
+ continue
324
+ key = (id(viewbox), axis)
325
+ if key in installed:
326
+ continue
327
+ signal = getattr(viewbox, signal_name, None)
328
+ if signal is None or not callable(getattr(signal, "connect", None)):
329
+ continue
330
+ slot = lambda *_args, axis=axis: self._axis_scale_range_changed(axis)
331
+ signal.connect(slot)
332
+ connections.append((signal, slot))
333
+ installed.add(key)
334
+
335
+ def _axis_scale_range_changed(self, axis: _AxisName) -> None:
336
+ """Synchronize controls and refresh Auto axes using this visible range."""
337
+
338
+ if self.__dict__.get("_axis_scale_programmatic_change_depth", 0) == 0:
339
+ self.__dict__.get("_axis_scale_custom_auto_axes", set()).discard(axis)
340
+ if axis in self.__dict__.get("_axis_scale_controls", {}):
341
+ self._sync_axis_scale_controls(axis)
342
+ if (
343
+ self.__dict__.get("_axis_scale_custom_auto_axes")
344
+ and not self.__dict__.get("_axis_scale_visible_refresh_active", False)
345
+ ):
346
+ self.__dict__.setdefault("_axis_scale_changed_axes", set()).add(axis)
347
+ timer = self.__dict__.get("_axis_scale_visible_refresh_timer")
348
+ if timer is None:
349
+ timer = QtCore.QTimer(self)
350
+ timer.setSingleShot(True)
351
+ timer.timeout.connect(self._refresh_axis_scale_visible_auto)
352
+ self._axis_scale_visible_refresh_timer = timer
353
+ # Linked axis-pair ViewBoxes may receive the same signal after us.
354
+ # Read their final ranges once the current signal delivery finishes.
355
+ timer.start(0)
356
+
357
+ def _refresh_axis_scale_visible_auto(self) -> None:
358
+ """Apply one bounded refresh of dependent semantic axes after a change."""
359
+
360
+ changed_axes = self.__dict__.pop("_axis_scale_changed_axes", set())
361
+ limits_by_axis: dict[_AxisName, tuple[float, float]] = {}
362
+ for axis, _label, _side in _AXIS_SPECS:
363
+ if axis not in self.__dict__.get("_axis_scale_custom_auto_axes", set()):
364
+ continue
365
+ number = self._axis_scale_axis_number(axis)
366
+ state = self._axis_scale_viewbox(axis).getState(copy=False)
367
+ if not state["autoVisibleOnly"][number]:
368
+ continue
369
+ for bounds_viewbox, _items in self._axis_scale_bound_item_groups(axis):
370
+ # The containing ViewBox identifies the perpendicular axis of
371
+ # these assigned items, independently of the tab's owner.
372
+ if number == 0:
373
+ perpendicular: _AxisName = (
374
+ "y2" if bounds_viewbox in (
375
+ self.__dict__.get("right_vb"),
376
+ self.__dict__.get("top_right_vb"),
377
+ ) else "y"
378
+ )
379
+ else:
380
+ perpendicular = (
381
+ "x2" if bounds_viewbox in (
382
+ self.__dict__.get("top_vb"),
383
+ self.__dict__.get("top_right_vb"),
384
+ ) else "x"
385
+ )
386
+ if perpendicular in changed_axes:
387
+ limits = self._axis_scale_auto_limits(axis)
388
+ if limits is not None:
389
+ limits_by_axis[axis] = limits
390
+ break
391
+
392
+ # Calculate every range before applying any of them. Mutually dependent
393
+ # axes then use the same visible region, without chasing each other's
394
+ # padding or auto-pan updates across successive event-loop turns.
395
+ self._axis_scale_visible_refresh_active = True
396
+ try:
397
+ for axis, limits in limits_by_axis.items():
398
+ self._apply_axis_scale_filtered_auto(axis, limits=limits)
399
+ finally:
400
+ self._axis_scale_visible_refresh_active = False
401
+
402
+ def force_all_axes_autoscale(self) -> None:
403
+ """Return every active plot axis to automatic scaling mode."""
404
+
405
+ for axis, _label, _side in _AXIS_SPECS:
406
+ if not self._axis_scale_axis_is_used(axis):
407
+ continue
408
+ if self._axis_scale_uses_filtered_auto(axis):
409
+ self._apply_axis_scale_filtered_auto(axis)
410
+ else:
411
+ self._axis_scale_viewbox(axis).enableAutoRange(
412
+ self._axis_scale_axis_constant(axis),
413
+ True,
414
+ )
415
+ if axis in self.__dict__.get("_axis_scale_controls", {}):
416
+ self._sync_axis_scale_controls(axis)
417
+
418
+ def _menu_control_widget(self, menu: qtw.QMenu) -> qtw.QWidget | None:
419
+ """
420
+ Returns the embedded control widget from a QWidgetAction menu.
421
+
422
+ """
423
+ for action in menu.actions():
424
+ if isinstance(action, qtw.QWidgetAction):
425
+ return action.defaultWidget()
426
+ return None
427
+
428
+ def _install_axis_scale_double_click_handlers(self) -> None:
429
+ """
430
+ Open the relevant axis scale dialog when an axis is double-clicked.
431
+
432
+ """
433
+ for axis, _label, side in _AXIS_SPECS:
434
+ axis_item = self.plot.getAxis(side)
435
+ if axis_item is None:
436
+ continue
437
+
438
+ previous_handler = getattr(axis_item, "mouseDoubleClickEvent", None)
439
+
440
+ def mouse_double_click(
441
+ event: Any,
442
+ axis: _AxisName = axis,
443
+ previous_handler: Any = previous_handler,
444
+ ) -> None:
445
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
446
+ self.open_axis_scale_dialog(axis)
447
+ event.accept()
448
+ return
449
+
450
+ if previous_handler is not None:
451
+ previous_handler(event)
452
+
453
+ axis_item.mouseDoubleClickEvent = mouse_double_click
454
+
455
+ def _axis_scale_dialog_title(self) -> str:
456
+ return "Axis scaling"
457
+
458
+ def _axis_scale_dimension(self, axis: _AxisName) -> Literal["x", "y"]:
459
+ return _AXIS_DIMENSIONS[axis]
460
+
461
+ @staticmethod
462
+ def _axis_scale_normalise_axis(axis: str) -> _AxisName:
463
+ """Return the semantic axis represented by a name or physical side."""
464
+
465
+ semantic_axis = _AXIS_ALIASES.get(axis.lower())
466
+ if semantic_axis is None:
467
+ raise ValueError(f"Unknown plot axis: {axis!r}")
468
+ return semantic_axis
469
+
470
+ @staticmethod
471
+ def _axis_scale_transform_result(
472
+ source: _NumericValue,
473
+ result: npt.NDArray[np.float64],
474
+ ) -> float | npt.NDArray[np.float64]:
475
+ """Keep scalar transforms scalar while supporting array-like inputs."""
476
+
477
+ if np.ndim(source) == 0:
478
+ return float(result)
479
+ return result
480
+
481
+ def data_to_view(
482
+ self,
483
+ axis: str,
484
+ values: _NumericValue,
485
+ ) -> float | npt.NDArray[np.float64]:
486
+ """Convert physical data values to coordinates used by a ViewBox.
487
+
488
+ Linear axes are unchanged. Log axes match ``PlotDataItem``: only
489
+ positive finite samples have display coordinates; nonpositive and
490
+ non-finite raw samples map to NaN and therefore do not participate in
491
+ geometry or hit testing.
492
+ """
493
+
494
+ semantic_axis = self._axis_scale_normalise_axis(axis)
495
+ numeric = np.asarray(values, dtype=float)
496
+ if not self._axis_scale_log_mode(semantic_axis):
497
+ result = numeric.copy()
498
+ else:
499
+ result = np.full(numeric.shape, np.nan, dtype=float)
500
+ valid = np.isfinite(numeric) & (numeric > 0)
501
+ with np.errstate(divide="ignore", invalid="ignore"):
502
+ np.log10(numeric, out=result, where=valid)
503
+ return self._axis_scale_transform_result(values, result)
504
+
505
+ def view_to_data(
506
+ self,
507
+ axis: str,
508
+ values: _NumericValue,
509
+ ) -> float | npt.NDArray[np.float64]:
510
+ """Convert ViewBox coordinates to physical data values.
511
+
512
+ For log axes this is the extended-real inverse of ``log10``: NaN
513
+ remains NaN, positive infinity remains infinity, and negative
514
+ infinity maps to zero. Finite overflow deliberately becomes infinity.
515
+ """
516
+
517
+ semantic_axis = self._axis_scale_normalise_axis(axis)
518
+ numeric = np.asarray(values, dtype=float)
519
+ if not self._axis_scale_log_mode(semantic_axis):
520
+ result = numeric.copy()
521
+ else:
522
+ with np.errstate(over="ignore", invalid="ignore"):
523
+ result = np.asarray(np.power(10.0, numeric), dtype=float)
524
+ return self._axis_scale_transform_result(values, result)
525
+
526
+ def _axis_scale_axis_for_line(
527
+ self,
528
+ line: Any,
529
+ dimension: Literal["x", "y"],
530
+ ) -> _AxisName:
531
+ """Return the semantic axis assigned to one plotted line dimension."""
532
+
533
+ styles = self.__dict__.get("_trace_styles")
534
+ lines = self.__dict__.get("lines")
535
+ style = None
536
+ if isinstance(styles, dict) and isinstance(lines, dict):
537
+ trace_key = next(
538
+ (key for key, candidate in lines.items() if candidate is line),
539
+ None,
540
+ )
541
+ if trace_key is not None:
542
+ style = styles.get(trace_key)
543
+
544
+ if dimension == "x":
545
+ return "x2" if getattr(style, "x_axis", "Bottom") == "Top" else "x"
546
+ return "y2" if getattr(style, "y_axis", "Left") == "Right" else "y"
547
+
548
+ def _axis_scale_viewbox(self, axis: _AxisName) -> Any:
549
+ if axis == "x2":
550
+ top_vb = self.__dict__.get("top_vb")
551
+ if top_vb is not None:
552
+ return top_vb
553
+ if axis == "y2":
554
+ right_vb = self.__dict__.get("right_vb")
555
+ if right_vb is not None:
556
+ return right_vb
557
+ return self.vb
558
+
559
+ def _axis_scale_axis_number(self, axis: _AxisName) -> int:
560
+ return 0 if self._axis_scale_dimension(axis) == "x" else 1
561
+
562
+ def _axis_scale_axis_constant(self, axis: _AxisName) -> int:
563
+ if self._axis_scale_dimension(axis) == "x":
564
+ return pg.ViewBox.XAxis
565
+ return pg.ViewBox.YAxis
566
+
567
+ def _new_axis_scale_controls(self, axis: _AxisName) -> qtw.QWidget:
568
+ """
569
+ Build a fresh copy of pyqtgraph's axis scaling controls for a dialog.
570
+
571
+ """
572
+ widget = qtw.QWidget()
573
+ ui: Any = axisCtrlTemplate_generic.Ui_Form()
574
+ ui.setupUi(widget)
575
+ widget.setFixedWidth(280)
576
+
577
+ ui.mouseCheck.setText("Allow Zoom/Pan")
578
+ ui.mouseCheck.setToolTip(
579
+ "Allow mouse-wheel zooming and drag-panning for this axis."
580
+ )
581
+ ui.invertCheck.setText("Reverse Axis")
582
+ ui.invertCheck.setToolTip(
583
+ "Reverse this axis so values increase in the opposite direction."
584
+ )
585
+ ui.invertCheck.setMinimumWidth(ui.invertCheck.sizeHint().width())
586
+ ui.mouseCheck.setMinimumWidth(ui.mouseCheck.sizeHint().width())
587
+ ui.visibleOnlyCheck.setText("Autoscale from Visible Data")
588
+ ui.visibleOnlyCheck.setToolTip(
589
+ "When autoscaling, use only data visible within the other axis's "
590
+ "current range."
591
+ )
592
+ ui.autoPanCheck.setText("Follow New Data, Keep Span")
593
+ ui.autoPanCheck.setToolTip(
594
+ "Follow new data by moving this axis without changing its displayed span."
595
+ )
596
+ ui.logCheck = qtw.QCheckBox("Log Scale", widget)
597
+ ui.logCheck.setObjectName("axisScaleLogCheck")
598
+ ui.logCheck.setToolTip(
599
+ "Display this axis on a base-10 logarithmic scale. Values at or "
600
+ "below zero are not shown."
601
+ )
602
+ ui.autoPercentSpin.setToolTip(
603
+ "Percentage of the data range included during autoscaling. Lower "
604
+ "values can reduce the influence of extreme spikes."
605
+ )
606
+ ui.linkCombo.setToolTip(
607
+ "Match this axis's displayed range to the corresponding axis in "
608
+ "another linked plot."
609
+ )
610
+
611
+ # The upstream form is intentionally very dense and relies on the
612
+ # order of two unlabelled edits to communicate minimum/maximum. Keep
613
+ # its controls and behaviour, but arrange them more clearly for the
614
+ # standalone qPlot dialog.
615
+ while ui.gridLayout.takeAt(0) is not None:
616
+ pass
617
+ ui.gridLayout.setContentsMargins(8, 8, 8, 8)
618
+ ui.gridLayout.setHorizontalSpacing(6)
619
+ ui.gridLayout.setVerticalSpacing(5)
620
+
621
+ ui.minimumLabel = qtw.QLabel("Minimum", widget)
622
+ ui.minimumLabel.setObjectName("axisScaleMinimumLabel")
623
+ ui.maximumLabel = qtw.QLabel("Maximum", widget)
624
+ ui.maximumLabel.setObjectName("axisScaleMaximumLabel")
625
+ ui.minimumLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
626
+ ui.maximumLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
627
+
628
+ ui.copyAutoLimitsButton = _AutoLimitsButton(
629
+ lambda axis=axis: self._update_axis_scale_auto_limits_tooltip(axis),
630
+ widget,
631
+ )
632
+ ui.copyAutoLimitsButton.setObjectName("copyAutoLimitsButton")
633
+ ui.copyAutoLimitsButton.setAccessibleName("Use auto limits as manual limits")
634
+ ui.copyAutoLimitsButton.setFixedSize(26, 24)
635
+ style = qtw.QApplication.style()
636
+ if style is not None:
637
+ ui.copyAutoLimitsButton.setIcon(
638
+ style.standardIcon(qtw.QStyle.StandardPixmap.SP_BrowserReload)
639
+ )
640
+
641
+ ui.rangeSeparator = qtw.QFrame(widget)
642
+ ui.rangeSeparator.setObjectName("axisScaleRangeSeparator")
643
+ ui.rangeSeparator.setFrameShape(qtw.QFrame.Shape.HLine)
644
+ ui.rangeSeparator.setFrameShadow(qtw.QFrame.Shadow.Sunken)
645
+ ui.linkSeparator = qtw.QFrame(widget)
646
+ ui.linkSeparator.setObjectName("axisScaleLinkSeparator")
647
+ ui.linkSeparator.setFrameShape(qtw.QFrame.Shape.HLine)
648
+ ui.linkSeparator.setFrameShadow(qtw.QFrame.Shadow.Sunken)
649
+
650
+ ui.gridLayout.addWidget(ui.minimumLabel, 0, 1)
651
+ ui.gridLayout.addWidget(ui.maximumLabel, 0, 2)
652
+ ui.gridLayout.addWidget(ui.manualRadio, 1, 0)
653
+ ui.gridLayout.addWidget(ui.minText, 1, 1)
654
+ ui.gridLayout.addWidget(ui.maxText, 1, 2)
655
+ ui.gridLayout.addWidget(ui.copyAutoLimitsButton, 1, 3)
656
+ ui.gridLayout.addWidget(ui.autoRadio, 2, 0)
657
+ ui.gridLayout.addWidget(ui.autoPercentSpin, 2, 1, 1, 2)
658
+ ui.gridLayout.addWidget(ui.visibleOnlyCheck, 3, 1, 1, 3)
659
+ ui.gridLayout.addWidget(ui.autoPanCheck, 4, 1, 1, 3)
660
+ ui.gridLayout.addWidget(ui.rangeSeparator, 5, 0, 1, 4)
661
+ ui.gridLayout.addWidget(ui.logCheck, 6, 0, 1, 4)
662
+ ui.gridLayout.addWidget(ui.invertCheck, 7, 0, 1, 2)
663
+ ui.gridLayout.addWidget(ui.mouseCheck, 7, 2, 1, 2)
664
+ ui.gridLayout.addWidget(ui.linkSeparator, 8, 0, 1, 4)
665
+ ui.gridLayout.addWidget(ui.label, 9, 0)
666
+ ui.gridLayout.addWidget(ui.linkCombo, 9, 1, 1, 3)
667
+ ui.gridLayout.setColumnStretch(1, 1)
668
+ ui.gridLayout.setColumnStretch(2, 1)
669
+ self._axis_scale_controls[axis] = ui
670
+
671
+ ui.mouseCheck.toggled.connect(
672
+ lambda checked, axis=axis: self._axis_scale_mouse_toggled(axis, checked)
673
+ )
674
+ ui.manualRadio.clicked.connect(
675
+ lambda _checked=False, axis=axis: self._axis_scale_manual_clicked(axis)
676
+ )
677
+ ui.minText.editingFinished.connect(
678
+ lambda axis=axis: self._axis_scale_range_text_changed(axis)
679
+ )
680
+ ui.maxText.editingFinished.connect(
681
+ lambda axis=axis: self._axis_scale_range_text_changed(axis)
682
+ )
683
+ ui.autoRadio.clicked.connect(
684
+ lambda _checked=False, axis=axis: self._axis_scale_auto_clicked(axis)
685
+ )
686
+ ui.autoPercentSpin.valueChanged.connect(
687
+ lambda value, axis=axis: self._axis_scale_auto_spin_changed(axis, value)
688
+ )
689
+ ui.linkCombo.currentIndexChanged.connect(
690
+ lambda _index, axis=axis: self._axis_scale_link_changed(axis)
691
+ )
692
+ ui.autoPanCheck.toggled.connect(
693
+ lambda checked, axis=axis: self._axis_scale_auto_pan_toggled(axis, checked)
694
+ )
695
+ ui.visibleOnlyCheck.toggled.connect(
696
+ lambda checked, axis=axis: self._axis_scale_visible_only_toggled(axis, checked)
697
+ )
698
+ ui.invertCheck.toggled.connect(
699
+ lambda checked, axis=axis: self._axis_scale_invert_toggled(axis, checked)
700
+ )
701
+ ui.logCheck.toggled.connect(
702
+ lambda checked, axis=axis: self._axis_scale_log_toggled(axis, checked)
703
+ )
704
+ ui.copyAutoLimitsButton.clicked.connect(
705
+ lambda _checked=False, axis=axis: self._axis_scale_copy_auto_limits(axis)
706
+ )
707
+
708
+ return widget
709
+
710
+ def _sync_axis_scale_controls(self, axis: _AxisName) -> None:
711
+ """
712
+ Update a dialog's controls from the current view state.
713
+
714
+ """
715
+ ui = self._axis_scale_controls.get(axis)
716
+ if ui is None:
717
+ return
718
+
719
+ axis_number = self._axis_scale_axis_number(axis)
720
+ viewbox = self._axis_scale_viewbox(axis)
721
+ state = viewbox.getState(copy=False)
722
+
723
+ for widget in (
724
+ ui.minText,
725
+ ui.maxText,
726
+ ui.manualRadio,
727
+ ui.autoRadio,
728
+ ui.autoPercentSpin,
729
+ ui.linkCombo,
730
+ ui.autoPanCheck,
731
+ ui.visibleOnlyCheck,
732
+ ui.invertCheck,
733
+ ui.logCheck,
734
+ ui.mouseCheck,
735
+ ):
736
+ widget.blockSignals(True)
737
+
738
+ try:
739
+ target_range = state["targetRange"][axis_number]
740
+ data_range = self.view_to_data(axis, target_range)
741
+ # Editable limits must round-trip doubles, even for a narrow range
742
+ # at a large offset. Tick-label precision is insufficient here.
743
+ ui.minText.setText(f"{data_range[0]:.17g}")
744
+ ui.maxText.setText(f"{data_range[1]:.17g}")
745
+
746
+ auto_range = (
747
+ True
748
+ if axis in self.__dict__.get("_axis_scale_custom_auto_axes", set())
749
+ else state["autoRange"][axis_number]
750
+ )
751
+ ui.autoRadio.setChecked(auto_range is not False)
752
+ ui.manualRadio.setChecked(auto_range is False)
753
+ if auto_range is not False and auto_range is not True:
754
+ ui.autoPercentSpin.setValue(int(auto_range * 100))
755
+
756
+ ui.mouseCheck.setChecked(state["mouseEnabled"][axis_number])
757
+ ui.autoPanCheck.setChecked(state["autoPan"][axis_number])
758
+ ui.visibleOnlyCheck.setChecked(state["autoVisibleOnly"][axis_number])
759
+ dimension = self._axis_scale_dimension(axis)
760
+ ui.invertCheck.setChecked(state.get(dimension + "Inverted", False))
761
+ axis_item = self.plot.getAxis(_AXIS_SIDES[axis])
762
+ ui.logCheck.setChecked(bool(getattr(axis_item, "logMode", False)))
763
+ log_supported = self._axis_scale_log_is_supported(axis)
764
+ ui.logCheck.setEnabled(log_supported)
765
+ if log_supported:
766
+ ui.logCheck.setToolTip(
767
+ "Display this axis on a base-10 logarithmic scale. Values "
768
+ "at or below zero are not shown."
769
+ )
770
+ else:
771
+ ui.logCheck.setToolTip(
772
+ "Log scaling is available for line-plot axes."
773
+ )
774
+ self._sync_axis_scale_link_combo(axis)
775
+ self._update_axis_scale_auto_limits_tooltip(axis)
776
+ finally:
777
+ for widget in (
778
+ ui.minText,
779
+ ui.maxText,
780
+ ui.manualRadio,
781
+ ui.autoRadio,
782
+ ui.autoPercentSpin,
783
+ ui.linkCombo,
784
+ ui.autoPanCheck,
785
+ ui.visibleOnlyCheck,
786
+ ui.invertCheck,
787
+ ui.logCheck,
788
+ ui.mouseCheck,
789
+ ):
790
+ widget.blockSignals(False)
791
+
792
+ def _sync_axis_scale_link_combo(self, axis: _AxisName) -> None:
793
+ """
794
+ Mirror pyqtgraph's available linked views into the dialog link combo.
795
+
796
+ """
797
+ ui = self._axis_scale_controls[axis]
798
+ axis_number = self._axis_scale_axis_number(axis)
799
+ viewbox = self._axis_scale_viewbox(axis)
800
+ menu = self.vbMenu if viewbox is self.vb else getattr(viewbox, "menu", None)
801
+ source_combo = getattr(menu, "ctrl", [None, None])[axis_number]
802
+ source_combo = getattr(source_combo, "linkCombo", None)
803
+ current = viewbox.getState(copy=False)["linkedViews"][axis_number] or ""
804
+
805
+ ui.linkCombo.clear()
806
+ if source_combo is None:
807
+ return
808
+ for index in range(source_combo.count()):
809
+ ui.linkCombo.addItem(source_combo.itemText(index))
810
+
811
+ index = ui.linkCombo.findText(current)
812
+ ui.linkCombo.setCurrentIndex(max(index, 0))
813
+
814
+ def _axis_scale_bound_item_groups(
815
+ self,
816
+ axis: _AxisName,
817
+ ) -> list[tuple[Any, list[Any] | None]]:
818
+ """Group plot items assigned to an axis by their containing ViewBox."""
819
+
820
+ styles = self.__dict__.get("_trace_styles")
821
+ lines = self.__dict__.get("lines")
822
+ if not isinstance(styles, dict) or not isinstance(lines, dict):
823
+ assignments = self.__dict__.get("_heatmap_axis_assignments")
824
+ heatmaps = self.__dict__.get("heatmaps")
825
+ axis_sides = getattr(self, "_heatmap_axis_sides", None)
826
+ render_items = getattr(self, "_heatmap_render_items", None)
827
+ axis_viewbox = getattr(self, "_heatmap_axis_viewbox", None)
828
+ if (
829
+ isinstance(assignments, dict)
830
+ and isinstance(heatmaps, dict)
831
+ and callable(axis_sides)
832
+ and callable(render_items)
833
+ and callable(axis_viewbox)
834
+ ):
835
+ dimension = self._axis_scale_dimension(axis)
836
+ expected = {
837
+ "x": "Bottom",
838
+ "x2": "Top",
839
+ "y": "Left",
840
+ "y2": "Right",
841
+ }[axis]
842
+ heatmap_groups: dict[Any, list[Any]] = {}
843
+ for layer in heatmaps.values():
844
+ x_side, y_side = axis_sides(layer)
845
+ selected = x_side if dimension == "x" else y_side
846
+ if selected != expected:
847
+ continue
848
+ viewbox = axis_viewbox(layer)
849
+ heatmap_groups.setdefault(viewbox, []).extend(
850
+ render_items(layer)
851
+ )
852
+ return list(heatmap_groups.items())
853
+ return [(self._axis_scale_viewbox(axis), None)]
854
+
855
+ attribute, value = {
856
+ "x": ("x_axis", "Bottom"),
857
+ "y": ("y_axis", "Left"),
858
+ "x2": ("x_axis", "Top"),
859
+ "y2": ("y_axis", "Right"),
860
+ }[axis]
861
+ grouped: dict[Any, list[Any]] = {}
862
+ for trace_key, line in lines.items():
863
+ style = styles.get(trace_key)
864
+ if (
865
+ line is None
866
+ or getattr(style, attribute, None) != value
867
+ ):
868
+ continue
869
+ get_viewbox = getattr(line, "getViewBox", None)
870
+ viewbox = get_viewbox() if callable(get_viewbox) else None
871
+ if viewbox is None:
872
+ uses_top = getattr(style, "x_axis", "Bottom") == "Top"
873
+ uses_right = getattr(style, "y_axis", "Left") == "Right"
874
+ if uses_top and uses_right:
875
+ viewbox = self.__dict__.get("top_right_vb")
876
+ elif uses_top:
877
+ viewbox = self.__dict__.get("top_vb")
878
+ elif uses_right:
879
+ viewbox = self.__dict__.get("right_vb")
880
+ viewbox = viewbox or self.vb
881
+ grouped.setdefault(viewbox, []).append(line)
882
+ return list(grouped.items())
883
+
884
+ def _axis_scale_auto_limits(self, axis: _AxisName) -> tuple[float, float] | None:
885
+ """Calculate this tab's auto range without changing the viewbox."""
886
+
887
+ ui = self.__dict__.get("_axis_scale_controls", {}).get(axis)
888
+ viewbox = self._axis_scale_viewbox(axis)
889
+ axis_number = self._axis_scale_axis_number(axis)
890
+ state = viewbox.getState(copy=False)
891
+ current_ranges = viewbox.viewRange()
892
+ fractions = [1.0, 1.0]
893
+ auto_range = state["autoRange"][axis_number]
894
+ default_fraction = (
895
+ float(auto_range)
896
+ if isinstance(auto_range, (int, float))
897
+ and not isinstance(auto_range, bool)
898
+ else 1.0
899
+ )
900
+ fractions[axis_number] = (
901
+ ui.autoPercentSpin.value() * 0.01
902
+ if ui is not None
903
+ else default_fraction
904
+ )
905
+ visible_only = state["autoVisibleOnly"][axis_number]
906
+ auto_pan = state["autoPan"][axis_number]
907
+ ranges: list[list[float]] = []
908
+ for bounds_viewbox, items in self._axis_scale_bound_item_groups(axis):
909
+ orthogonal_ranges: list[list[float] | None] = [None, None]
910
+ if visible_only:
911
+ orthogonal_ranges[axis_number] = bounds_viewbox.viewRange()[
912
+ 1 - axis_number
913
+ ]
914
+ with _axis_bounds_without_dynamic_clipping(bounds_viewbox, items):
915
+ for item in items if items is not None else bounds_viewbox.addedItems:
916
+ if not isinstance(item, pg.PlotDataItem) or not item.isVisible():
917
+ continue
918
+ sample_bounds = item.dataBounds(
919
+ axis_number, fractions[axis_number], orthogonal_ranges[axis_number],
920
+ )
921
+ if sample_bounds[0] is not None and sample_bounds[1] is not None:
922
+ sample_lower, sample_upper = (float(value) for value in sample_bounds)
923
+ if (
924
+ isfinite(sample_lower) and isfinite(sample_upper)
925
+ and not all(isfinite(value) for value in (
926
+ sample_upper - sample_lower, sample_upper + sample_lower,
927
+ ))
928
+ ):
929
+ # Qt rectangles cannot represent this finite pair;
930
+ # preserve it for explicit rejection by the guard.
931
+ return sample_lower, sample_upper
932
+ bounds = bounds_viewbox.childrenBounds(
933
+ frac=fractions,
934
+ orthoRange=orthogonal_ranges,
935
+ items=items,
936
+ )[axis_number]
937
+ if bounds is not None:
938
+ if not all(isfinite(value) for value in bounds):
939
+ # Native pixel padding can overflow finite sample bounds.
940
+ # Keep unsupported geometry distinct from absent data so
941
+ # the common preflight disables retries and reports it.
942
+ return float(bounds[0]), float(bounds[1])
943
+ ranges.append(bounds)
944
+ if not ranges:
945
+ return None
946
+
947
+ lower = min(float(bounds[0]) for bounds in ranges)
948
+ upper = max(float(bounds[1]) for bounds in ranges)
949
+ current_span = current_ranges[axis_number][1] - current_ranges[axis_number][0]
950
+ if auto_pan or lower == upper:
951
+ if not isfinite(current_span) or current_span <= 0:
952
+ current_span = 1.0
953
+ center = lower * 0.5 + upper * 0.5
954
+ # A narrow span at a large offset may collapse to one double.
955
+ # Retain at least the backend's quantization allowance so that
956
+ # unsupported constant/auto-pan geometry reaches our preflight.
957
+ half_span = max(current_span * 0.5, abs(center) * 1.5e-15)
958
+ lower = center - half_span
959
+ upper = center + half_span
960
+ else:
961
+ padding = viewbox.suggestPadding(axis_number)
962
+ extra = (upper - lower) * padding
963
+ lower -= extra
964
+ upper += extra
965
+
966
+ # Existing bounds can produce unsupported geometry after padding or
967
+ # centering. Preserve that result for the common range preflight;
968
+ # None means no bounds, and would bypass rejection and mode recovery.
969
+ return lower, upper
970
+
971
+ def _update_axis_scale_auto_limits_tooltip(self, axis: _AxisName) -> None:
972
+ """Describe the range that the copy-auto button would apply."""
973
+
974
+ button = self._axis_scale_controls[axis].copyAutoLimitsButton
975
+ limits = self._axis_scale_auto_limits(axis)
976
+ data_limits = (
977
+ None
978
+ if limits is None
979
+ else tuple(float(value) for value in self.view_to_data(axis, limits))
980
+ )
981
+ usable = (
982
+ data_limits is not None
983
+ and all(isfinite(value) for value in data_limits)
984
+ and data_limits[0] < data_limits[1]
985
+ )
986
+ button.setEnabled(usable)
987
+ if not usable:
988
+ button.setToolTip("Auto limits are unavailable for this axis.")
989
+ return
990
+ assert data_limits is not None
991
+ button.setToolTip(
992
+ f"Set manual limits to {data_limits[0]:.17g} and {data_limits[1]:.17g}."
993
+ )
994
+
995
+ def _axis_scale_copy_auto_limits(self, axis: _AxisName) -> None:
996
+ """Freeze the prospective auto range as this axis's manual range."""
997
+
998
+ limits = self._axis_scale_auto_limits(axis)
999
+ if limits is None:
1000
+ return
1001
+ data_limits = tuple(
1002
+ float(value) for value in self.view_to_data(axis, limits)
1003
+ )
1004
+ if (
1005
+ not all(isfinite(value) for value in data_limits)
1006
+ or data_limits[0] >= data_limits[1]
1007
+ ):
1008
+ self._update_axis_scale_auto_limits_tooltip(axis)
1009
+ return
1010
+ # Auto limits already use ViewBox coordinates. Do not pass them through
1011
+ # editable text or a log -> physical -> log conversion before applying.
1012
+ self._apply_axis_scale_manual_limits(axis, limits)
1013
+ self._sync_axis_scale_controls(axis)
1014
+
1015
+ def _axis_scale_mouse_toggled(self, axis: _AxisName, checked: bool) -> None:
1016
+ viewbox = self._axis_scale_viewbox(axis)
1017
+ if self._axis_scale_dimension(axis) == "x":
1018
+ viewbox.setMouseEnabled(x=checked)
1019
+ else:
1020
+ viewbox.setMouseEnabled(y=checked)
1021
+
1022
+ def _axis_scale_manual_clicked(self, axis: _AxisName) -> None:
1023
+ self.__dict__.get("_axis_scale_custom_auto_axes", set()).discard(axis)
1024
+ self._axis_scale_viewbox(axis).enableAutoRange(
1025
+ self._axis_scale_axis_constant(axis),
1026
+ False,
1027
+ )
1028
+
1029
+ def _axis_scale_range_text_changed(self, axis: _AxisName) -> None:
1030
+ ui = self._axis_scale_controls[axis]
1031
+ axis_number = self._axis_scale_axis_number(axis)
1032
+ viewbox = self._axis_scale_viewbox(axis)
1033
+ previous_view_values = list(viewbox.viewRange()[axis_number])
1034
+ previous_values = np.asarray(self.view_to_data(axis, previous_view_values))
1035
+ try:
1036
+ values = [float(ui.minText.text()), float(ui.maxText.text())]
1037
+ except ValueError:
1038
+ values = []
1039
+
1040
+ if (
1041
+ len(values) != 2
1042
+ or not all(isfinite(value) for value in values)
1043
+ or values[0] >= values[1]
1044
+ ):
1045
+ ui.minText.setText(f"{previous_values[0]:.17g}")
1046
+ ui.maxText.setText(f"{previous_values[1]:.17g}")
1047
+ show_status = getattr(self, "show_status", None)
1048
+ if callable(show_status):
1049
+ show_status(
1050
+ "Axis limits must be finite numbers with minimum below maximum.",
1051
+ 5000,
1052
+ )
1053
+ return
1054
+
1055
+ if self._axis_scale_log_mode(axis) and any(value <= 0 for value in values):
1056
+ ui.minText.setText(f"{previous_values[0]:.17g}")
1057
+ ui.maxText.setText(f"{previous_values[1]:.17g}")
1058
+ show_status = getattr(self, "show_status", None)
1059
+ if callable(show_status):
1060
+ show_status(
1061
+ "Log-scale axis limits must be greater than zero.",
1062
+ 5000,
1063
+ )
1064
+ return
1065
+
1066
+ # Return may emit editingFinished twice. After a rejected edit restores
1067
+ # these fields, the second emission must not switch Auto to Manual.
1068
+ # Unchanged fields also retain their exact original log coordinates.
1069
+ if values == list(previous_values):
1070
+ return
1071
+ view_values = [float(value) for value in self.data_to_view(axis, values)]
1072
+ if not _manual_axis_view_limits_are_usable(viewbox, axis_number, view_values):
1073
+ ui.minText.setText(f"{previous_values[0]:.17g}")
1074
+ ui.maxText.setText(f"{previous_values[1]:.17g}")
1075
+ show_status = getattr(self, "show_status", None)
1076
+ if callable(show_status):
1077
+ show_status("These axis limits exceed the supported plot range.", 5000)
1078
+ return
1079
+ self._apply_axis_scale_manual_limits(axis, view_values)
1080
+
1081
+ def _apply_axis_scale_manual_limits(
1082
+ self,
1083
+ axis: _AxisName,
1084
+ view_values: tuple[float, float] | list[float],
1085
+ ) -> None:
1086
+ """Apply validated numeric ViewBox limits without parsing UI text."""
1087
+
1088
+ ui = self._axis_scale_controls[axis]
1089
+ viewbox = self._axis_scale_viewbox(axis)
1090
+ if not _manual_axis_view_limits_are_usable(
1091
+ viewbox, self._axis_scale_axis_number(axis), view_values,
1092
+ ):
1093
+ self._sync_axis_scale_controls(axis)
1094
+ show_status = getattr(self, "show_status", None)
1095
+ if callable(show_status):
1096
+ show_status("These axis limits exceed the supported plot range.", 5000)
1097
+ return
1098
+ ui.manualRadio.setChecked(True)
1099
+ self.__dict__.get("_axis_scale_custom_auto_axes", set()).discard(axis)
1100
+ if self._axis_scale_dimension(axis) == "x":
1101
+ viewbox.setXRange(*view_values, padding=0)
1102
+ else:
1103
+ viewbox.setYRange(*view_values, padding=0)
1104
+ self._view_range_changed_programmatically()
1105
+
1106
+ def _axis_scale_uses_filtered_auto(self, axis: _AxisName) -> bool:
1107
+ """Return whether auto-ranging must respect per-item axis assignment."""
1108
+
1109
+ return (
1110
+ getattr(self, "operation_kind", None) == "sweeper"
1111
+ or (
1112
+ isinstance(self.__dict__.get("_trace_styles"), dict)
1113
+ and isinstance(self.__dict__.get("lines"), dict)
1114
+ )
1115
+ or (
1116
+ isinstance(
1117
+ self.__dict__.get("_heatmap_axis_assignments"),
1118
+ dict,
1119
+ )
1120
+ and isinstance(self.__dict__.get("heatmaps"), dict)
1121
+ )
1122
+ )
1123
+
1124
+ def _apply_axis_scale_filtered_auto(
1125
+ self,
1126
+ axis: _AxisName,
1127
+ *,
1128
+ limits: tuple[float, float] | None = None,
1129
+ ) -> None:
1130
+ """Apply an item-filtered auto range while retaining Auto mode in the UI."""
1131
+
1132
+ if limits is None:
1133
+ limits = self._axis_scale_auto_limits(axis)
1134
+ if limits is None:
1135
+ return
1136
+ viewbox = self._axis_scale_viewbox(axis)
1137
+ axis_constant = self._axis_scale_axis_constant(axis)
1138
+ axis_number = self._axis_scale_axis_number(axis)
1139
+ bound_groups = self._axis_scale_bound_item_groups(axis)
1140
+ if not _manual_axis_view_limits_are_usable(viewbox, axis_number, limits):
1141
+ if viewbox.autoRangeEnabled()[axis_number] is not False or any(
1142
+ bounds_viewbox.autoRangeEnabled()[axis_number] is not False
1143
+ for bounds_viewbox, _items in bound_groups
1144
+ ):
1145
+ # Initial/native Auto must not retry the rejected geometry on
1146
+ # paint. Retain qPlot's Auto mode so a later supported source
1147
+ # update can recover automatically with the previous view intact.
1148
+ for bounds_viewbox, _items in bound_groups:
1149
+ _disable_native_axis_auto(bounds_viewbox, axis_constant)
1150
+ if all(bounds_viewbox is not viewbox for bounds_viewbox, _items in bound_groups):
1151
+ _disable_native_axis_auto(viewbox, axis_constant)
1152
+ self.__dict__.setdefault("_axis_scale_custom_auto_axes", set()).add(axis)
1153
+ if axis in self.__dict__.get("_axis_scale_controls", {}):
1154
+ self._sync_axis_scale_controls(axis)
1155
+ show_status = getattr(self, "show_status", None)
1156
+ if callable(show_status):
1157
+ show_status("Automatic axis limits exceed the supported plot range.", 5000)
1158
+ return
1159
+ self._axis_scale_programmatic_change_depth = (
1160
+ self.__dict__.get("_axis_scale_programmatic_change_depth", 0) + 1
1161
+ )
1162
+ try:
1163
+ for bounds_viewbox, _items in bound_groups:
1164
+ _disable_native_axis_auto(bounds_viewbox, axis_constant)
1165
+ if self._axis_scale_dimension(axis) == "x":
1166
+ viewbox.setXRange(*limits, padding=0)
1167
+ else:
1168
+ viewbox.setYRange(*limits, padding=0)
1169
+ finally:
1170
+ self._axis_scale_programmatic_change_depth -= 1
1171
+ self.__dict__.setdefault("_axis_scale_custom_auto_axes", set()).add(axis)
1172
+ ui = self.__dict__.get("_axis_scale_controls", {}).get(axis)
1173
+ if ui is not None:
1174
+ ui.autoRadio.setChecked(True)
1175
+ self._view_range_changed_programmatically()
1176
+
1177
+ def _install_axis_scale_trace_handler(self, line: Any) -> None:
1178
+ """Keep filtered auto ranges current when a trace publishes new data."""
1179
+
1180
+ if line is None or getattr(line, "_qplot_axis_scale_handler", False):
1181
+ return
1182
+ signal = getattr(line, "sigPlotChanged", None)
1183
+ if signal is None or not callable(getattr(signal, "connect", None)):
1184
+ return
1185
+ signal.connect(self._axis_scale_trace_data_changed)
1186
+ line._qplot_axis_scale_handler = True
1187
+
1188
+ def _axis_scale_trace_data_changed(self, _line: Any = None) -> None:
1189
+ """Reapply active trace-filtered auto ranges after a data update."""
1190
+
1191
+ axes = set(self.__dict__.get("_axis_scale_custom_auto_axes", set()))
1192
+ for axis, _label, _side in _AXIS_SPECS:
1193
+ if (self._axis_scale_axis_is_used(axis)
1194
+ and self._axis_scale_uses_filtered_auto(axis)
1195
+ and self._axis_scale_viewbox(axis).autoRangeEnabled()[
1196
+ self._axis_scale_axis_number(axis)] is not False):
1197
+ axes.add(axis)
1198
+ for axis in tuple(axes):
1199
+ if self._axis_scale_axis_is_used(axis):
1200
+ self._apply_axis_scale_filtered_auto(axis)
1201
+
1202
+ def _axis_scale_auto_clicked(self, axis: _AxisName) -> None:
1203
+ ui = self._axis_scale_controls[axis]
1204
+ if self._axis_scale_uses_filtered_auto(axis):
1205
+ self._apply_axis_scale_filtered_auto(axis)
1206
+ return
1207
+ self._axis_scale_viewbox(axis).enableAutoRange(
1208
+ self._axis_scale_axis_constant(axis),
1209
+ ui.autoPercentSpin.value() * 0.01,
1210
+ )
1211
+
1212
+ def _axis_scale_auto_spin_changed(self, axis: _AxisName, value: float) -> None:
1213
+ ui = self._axis_scale_controls[axis]
1214
+ ui.autoRadio.setChecked(True)
1215
+ if self._axis_scale_uses_filtered_auto(axis):
1216
+ self._apply_axis_scale_filtered_auto(axis)
1217
+ return
1218
+ self._axis_scale_viewbox(axis).enableAutoRange(
1219
+ self._axis_scale_axis_constant(axis),
1220
+ value * 0.01,
1221
+ )
1222
+
1223
+ def _axis_scale_link_changed(self, axis: _AxisName) -> None:
1224
+ ui = self._axis_scale_controls[axis]
1225
+ viewbox = self._axis_scale_viewbox(axis)
1226
+ if self._axis_scale_dimension(axis) == "x":
1227
+ viewbox.setXLink(str(ui.linkCombo.currentText()))
1228
+ else:
1229
+ viewbox.setYLink(str(ui.linkCombo.currentText()))
1230
+
1231
+ def _axis_scale_auto_pan_toggled(self, axis: _AxisName, checked: bool) -> None:
1232
+ # Store the option on its semantic axis even when qPlot, rather than
1233
+ # native autoRange, owns the range calculation and refreshes.
1234
+ viewbox = self._axis_scale_viewbox(axis)
1235
+ if self._axis_scale_dimension(axis) == "x":
1236
+ viewbox.setAutoPan(x=checked)
1237
+ else:
1238
+ viewbox.setAutoPan(y=checked)
1239
+ if axis in self.__dict__.get("_axis_scale_custom_auto_axes", set()):
1240
+ self._apply_axis_scale_filtered_auto(axis)
1241
+
1242
+ def _axis_scale_visible_only_toggled(self, axis: _AxisName, checked: bool) -> None:
1243
+ viewbox = self._axis_scale_viewbox(axis)
1244
+ if self._axis_scale_dimension(axis) == "x":
1245
+ viewbox.setAutoVisible(x=checked)
1246
+ else:
1247
+ viewbox.setAutoVisible(y=checked)
1248
+ if axis in self.__dict__.get("_axis_scale_custom_auto_axes", set()):
1249
+ self._apply_axis_scale_filtered_auto(axis)
1250
+
1251
+ def _axis_scale_invert_toggled(self, axis: _AxisName, checked: bool) -> None:
1252
+ viewbox = self._axis_scale_viewbox(axis)
1253
+ if self._axis_scale_dimension(axis) == "x":
1254
+ viewbox.invertX(checked)
1255
+ else:
1256
+ viewbox.invertY(checked)
1257
+
1258
+ def _axis_scale_log_is_supported(self, axis: _AxisName) -> bool:
1259
+ """Return whether this axis contains data items that support log mode."""
1260
+
1261
+ if getattr(self, "operation_kind", None) == "plot2d":
1262
+ return False
1263
+ for _viewbox, items in self._axis_scale_bound_item_groups(axis):
1264
+ candidates = items
1265
+ if candidates is None:
1266
+ candidates = list(getattr(self.plot, "dataItems", []))
1267
+ if any(callable(getattr(item, "setLogMode", None)) for item in candidates):
1268
+ return True
1269
+ return False
1270
+
1271
+ def _axis_scale_log_mode(self, axis: _AxisName) -> bool:
1272
+ """Return the current log state from the displayed axis item."""
1273
+
1274
+ plot = self.__dict__.get("plot")
1275
+ get_axis = getattr(plot, "getAxis", None)
1276
+ if not callable(get_axis):
1277
+ return False
1278
+ axis_item = get_axis(_AXIS_SIDES[axis])
1279
+ return bool(getattr(axis_item, "logMode", False))
1280
+
1281
+ def _sync_axis_scale_line_log_mode(self, trace_key: Any, line: Any) -> None:
1282
+ """Apply the log modes of a trace's assigned axes to that trace."""
1283
+
1284
+ set_log_mode = getattr(line, "setLogMode", None)
1285
+ if not callable(set_log_mode):
1286
+ return
1287
+ style = self.__dict__.get("_trace_styles", {}).get(trace_key)
1288
+ x_axis: _AxisName = (
1289
+ "x2" if getattr(style, "x_axis", "Bottom") == "Top" else "x"
1290
+ )
1291
+ y_axis: _AxisName = (
1292
+ "y2" if getattr(style, "y_axis", "Left") == "Right" else "y"
1293
+ )
1294
+ set_log_mode(
1295
+ self._axis_scale_log_mode(x_axis),
1296
+ self._axis_scale_log_mode(y_axis),
1297
+ )
1298
+
1299
+ def _axis_scale_log_toggled(self, axis: _AxisName, checked: bool) -> None:
1300
+ """Apply a tab's log state to its ticks and assigned line traces."""
1301
+
1302
+ if not self._axis_scale_log_is_supported(axis):
1303
+ self._sync_axis_scale_controls(axis)
1304
+ return
1305
+
1306
+ self.plot.getAxis(_AXIS_SIDES[axis]).setLogMode(checked)
1307
+ if axis in ("x", "y"):
1308
+ control_name = "logXCheck" if axis == "x" else "logYCheck"
1309
+ plot_control = getattr(
1310
+ getattr(self.plot, "ctrl", None),
1311
+ control_name,
1312
+ None,
1313
+ )
1314
+ if plot_control is not None:
1315
+ plot_control.blockSignals(True)
1316
+ try:
1317
+ plot_control.setChecked(checked)
1318
+ finally:
1319
+ plot_control.blockSignals(False)
1320
+ styles = self.__dict__.get("_trace_styles")
1321
+ lines = self.__dict__.get("lines")
1322
+ if isinstance(styles, dict) and isinstance(lines, dict):
1323
+ for trace_key, line in lines.items():
1324
+ style = styles.get(trace_key)
1325
+ if self._axis_scale_dimension(axis) == "x":
1326
+ assigned_axis = (
1327
+ "x2"
1328
+ if getattr(style, "x_axis", "Bottom") == "Top"
1329
+ else "x"
1330
+ )
1331
+ else:
1332
+ assigned_axis = (
1333
+ "y2"
1334
+ if getattr(style, "y_axis", "Left") == "Right"
1335
+ else "y"
1336
+ )
1337
+ if assigned_axis == axis:
1338
+ self._sync_axis_scale_line_log_mode(trace_key, line)
1339
+ else:
1340
+ axis_number = self._axis_scale_axis_number(axis)
1341
+ for item in getattr(self.plot, "dataItems", []):
1342
+ set_log_mode = getattr(item, "setLogMode", None)
1343
+ if not callable(set_log_mode):
1344
+ continue
1345
+ current = list(getattr(item, "opts", {}).get("logMode", (False, False)))
1346
+ current[axis_number] = checked
1347
+ set_log_mode(bool(current[0]), bool(current[1]))
1348
+
1349
+ if self._axis_scale_uses_filtered_auto(axis):
1350
+ self._apply_axis_scale_filtered_auto(axis)
1351
+ else:
1352
+ self._axis_scale_viewbox(axis).enableAutoRange(
1353
+ self._axis_scale_axis_constant(axis),
1354
+ True,
1355
+ )
1356
+ self._sync_axis_scale_controls(axis)
1357
+ self._view_range_changed_programmatically()
1358
+
1359
+ def _axis_scale_axis_is_used(self, axis: _AxisName) -> bool:
1360
+ """Return whether the plot currently has an item on this axis."""
1361
+
1362
+ styles = self.__dict__.get("_trace_styles")
1363
+ lines = self.__dict__.get("lines")
1364
+ if not isinstance(styles, dict) or not isinstance(lines, dict):
1365
+ assignments = self.__dict__.get("_heatmap_axis_assignments")
1366
+ if isinstance(assignments, dict):
1367
+ attribute, value = {
1368
+ "x": ("x", "Bottom"),
1369
+ "y": ("y", "Left"),
1370
+ "x2": ("x", "Top"),
1371
+ "y2": ("y", "Right"),
1372
+ }[axis]
1373
+ return any(
1374
+ assignment.get(attribute) == value
1375
+ for assignment in assignments.values()
1376
+ )
1377
+ return axis in ("x", "y")
1378
+
1379
+ attribute, value = {
1380
+ "x": ("x_axis", "Bottom"),
1381
+ "y": ("y_axis", "Left"),
1382
+ "x2": ("x_axis", "Top"),
1383
+ "y2": ("y_axis", "Right"),
1384
+ }[axis]
1385
+ return any(
1386
+ line is not None
1387
+ and getattr(styles.get(trace_key), attribute, None) == value
1388
+ for trace_key, line in lines.items()
1389
+ )
1390
+
1391
+ def _create_axis_scale_dialog(self) -> qtw.QDialog:
1392
+ """Create the shared four-tab scaling dialog."""
1393
+
1394
+ dialog = qtw.QDialog(self)
1395
+ dialog.setWindowTitle(self._axis_scale_dialog_title())
1396
+ layout = qtw.QVBoxLayout(dialog)
1397
+ layout.setContentsMargins(12, 12, 12, 12)
1398
+ layout.setSpacing(10)
1399
+ tabs = qtw.QTabWidget(dialog)
1400
+ tabs.setObjectName("axisScaleTabs")
1401
+ tabs.setFixedWidth(300)
1402
+ tabs.tabBar().setExpanding(True)
1403
+ tabs.tabBar().setUsesScrollButtons(False)
1404
+ tabs.setStyleSheet(
1405
+ "QTabBar::tab { min-width: 57px; padding-left: 8px; "
1406
+ "padding-right: 8px; } "
1407
+ "QTabBar::tab:selected { background: palette(highlight); "
1408
+ "color: palette(highlighted-text); font-weight: 600; }"
1409
+ )
1410
+ tab_tooltips = {
1411
+ "x": "Bottom horizontal axis",
1412
+ "y": "Left vertical axis",
1413
+ "x2": "Top horizontal axis",
1414
+ "y2": "Right vertical axis",
1415
+ }
1416
+ for axis, label, _side in _AXIS_SPECS:
1417
+ index = tabs.addTab(self._new_axis_scale_controls(axis), label)
1418
+ tabs.setTabToolTip(index, tab_tooltips[axis])
1419
+ tabs.currentChanged.connect(self._axis_scale_current_tab_changed)
1420
+ layout.addWidget(tabs)
1421
+
1422
+ buttons = qtw.QDialogButtonBox(qtw.QDialogButtonBox.StandardButton.Close)
1423
+ buttons.setContentsMargins(0, 0, 28, 0)
1424
+ buttons.rejected.connect(dialog.close)
1425
+ layout.addWidget(buttons)
1426
+ layout.setSizeConstraint(qtw.QLayout.SizeConstraint.SetFixedSize)
1427
+
1428
+ self._axis_scale_tabs = tabs
1429
+ self._axis_scale_dialog = dialog
1430
+ return dialog
1431
+
1432
+ def _axis_scale_current_tab_changed(self, index: int) -> None:
1433
+ """Refresh a tab whenever the user switches to it."""
1434
+
1435
+ if not 0 <= index < len(_AXIS_SPECS):
1436
+ return
1437
+ axis = _AXIS_SPECS[index][0]
1438
+ if self._axis_scale_axis_is_used(axis):
1439
+ self._sync_axis_scale_controls(axis)
1440
+
1441
+ def _sync_axis_scale_tab_states(self) -> None:
1442
+ """Grey out tabs for axes without any assigned trace."""
1443
+
1444
+ tabs = self.__dict__.get("_axis_scale_tabs")
1445
+ if tabs is None:
1446
+ return
1447
+ for index, (axis, _label, _side) in enumerate(_AXIS_SPECS):
1448
+ enabled = self._axis_scale_axis_is_used(axis)
1449
+ tabs.setTabEnabled(index, enabled)
1450
+ if enabled and axis in self._axis_scale_controls:
1451
+ self._sync_axis_scale_controls(axis)
1452
+
1453
+ @QtCore.pyqtSlot(str)
1454
+ def open_axis_scale_dialog(self, axis: str) -> None:
1455
+ """
1456
+ Open the shared scaling dialog on the requested axis tab.
1457
+
1458
+ """
1459
+ if hasattr(self.vb, "updateViewLists"):
1460
+ self.vb.updateViewLists()
1461
+
1462
+ if hasattr(self.vbMenu, "updateState"):
1463
+ self.vbMenu.updateState()
1464
+
1465
+ axis_name = _AXIS_ALIASES.get(axis.lower())
1466
+ if axis_name is None:
1467
+ return
1468
+
1469
+ dialog = self.__dict__.get("_axis_scale_dialog")
1470
+ if dialog is None:
1471
+ dialog = self._create_axis_scale_dialog()
1472
+
1473
+ self._sync_axis_scale_tab_states()
1474
+
1475
+ tabs = self._axis_scale_tabs
1476
+ requested_index = next(
1477
+ index
1478
+ for index, (current_axis, _label, _side) in enumerate(_AXIS_SPECS)
1479
+ if current_axis == axis_name
1480
+ )
1481
+ if tabs.isTabEnabled(requested_index):
1482
+ tabs.setCurrentIndex(requested_index)
1483
+ dialog.show()
1484
+ dialog.raise_()
1485
+ dialog.activateWindow()