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,2183 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+ from typing import TYPE_CHECKING, Any, cast
4
+
5
+ import pyqtgraph as pg
6
+ from PyQt6 import QtCore, QtGui
7
+ from PyQt6 import QtWidgets as qtw
8
+
9
+ from ._plot_appearance import (
10
+ CenteredPreviewDelegate,
11
+ ReorderAppearanceTable,
12
+ configure_appearance_table,
13
+ )
14
+ from ._plot_refresh import plot_refresh_required
15
+ from ._plot_transform_labels import native_axis_quantities
16
+ from ._refresh_interval import refresh_interval_value
17
+ from ._subplots import subplot1d
18
+ from ._widgets import picker_1d
19
+
20
+ TRACE_COLOR_PALETTE = (
21
+ "#1f77b4",
22
+ "#ff7f0e",
23
+ "#2ca02c",
24
+ "#d62728",
25
+ "#9467bd",
26
+ "#8c564b",
27
+ "#e377c2",
28
+ "#7f7f7f",
29
+ "#bcbd22",
30
+ "#17becf",
31
+ "#000000",
32
+ "#ffffff",
33
+ )
34
+
35
+ if TYPE_CHECKING:
36
+ class _Plot1DTraceBase(qtw.QMainWindow):
37
+ axes_dock: Any
38
+ axis_options: dict[str, str]
39
+ box_count: int
40
+ box_layout: qtw.QVBoxLayout
41
+ config: Any
42
+ get_mergables: Any
43
+ label: str
44
+ line: Any
45
+ lineScroll: qtw.QScrollArea
46
+ lines: dict[Any, Any]
47
+ make_ds: Any
48
+ mergable: Any
49
+ option_boxes: list[Any]
50
+ param: Any
51
+ plot: Any
52
+ remove_dataset: Any
53
+ right_vb: Any
54
+ top_right_vb: Any
55
+ top_vb: Any
56
+ scrollWidget: qtw.QWidget
57
+ spinBox: Any
58
+ vb: Any
59
+
60
+ def initAxes(self) -> None: ...
61
+
62
+ def initMenu(self) -> None: ...
63
+
64
+ def update_theme(self, config: Any) -> None: ...
65
+
66
+ def _marquee_axis_assignment_changed(self) -> None: ...
67
+
68
+ def closeEvent(self, event: object) -> None: ...
69
+
70
+ def add_trace_from_dialog(self, label: str, trace_key: Any) -> None: ...
71
+ else:
72
+ class _Plot1DTraceBase:
73
+ pass
74
+
75
+
76
+ class Plot1DTraceMixin(_Plot1DTraceBase):
77
+ @dataclass
78
+ class _TraceStyle:
79
+ line_enabled: bool = True
80
+ line_color: str = "#1f77b4"
81
+ line_width: float = 2.0
82
+ line_style: str = "Solid"
83
+ dots_enabled: bool = False
84
+ dots_color: str = "#1f77b4"
85
+ dots_size: float = 6.0
86
+ markers_enabled: bool = False
87
+ markers_color: str = "#1f77b4"
88
+ markers_symbol: str = "o"
89
+ markers_size: float = 10.0
90
+ x_axis: str = "Bottom"
91
+ y_axis: str = "Left"
92
+ visible: bool = True
93
+ opacity: float = 1.0
94
+ order: int = 0
95
+
96
+ """Trace controls and secondary-axis handling for 1D plot windows."""
97
+
98
+ _trace_styles: dict[Any, _TraceStyle]
99
+ _trace_controls: dict[Any, Any]
100
+ _trace_appearance_dialog: "_TraceAppearanceDialog | None"
101
+
102
+ def _ensure_trace_styles(self) -> dict[Any, _TraceStyle]:
103
+ styles = self.__dict__.get("_trace_styles")
104
+ if not isinstance(styles, dict):
105
+ styles = {}
106
+ self.__dict__["_trace_styles"] = styles
107
+ return cast(dict[Any, Plot1DTraceMixin._TraceStyle], styles)
108
+
109
+ def _ensure_trace_controls(self) -> dict[Any, Any]:
110
+ controls = self.__dict__.get("_trace_controls")
111
+ if not isinstance(controls, dict):
112
+ controls = {}
113
+ self.__dict__["_trace_controls"] = controls
114
+ return cast(dict[Any, Any], controls)
115
+
116
+ def _initial_trace_style(self, order: int = 0) -> _TraceStyle:
117
+ style = self._TraceStyle(order=order)
118
+ style.line_color = TRACE_COLOR_PALETTE[0]
119
+ style.dots_color = style.line_color
120
+ style.markers_color = style.line_color
121
+ return style
122
+
123
+ @staticmethod
124
+ def _window_trace_key(window: Any) -> Any:
125
+ """Return a database-aware identity, with labels as a legacy fallback."""
126
+
127
+ return getattr(window, "_trace_key", getattr(window, "label", None))
128
+
129
+ @staticmethod
130
+ def _picker_matches_trace(box: Any, trace_key: Any, label: str) -> bool:
131
+ """Return whether a picker row represents the newly added trace."""
132
+
133
+ box_key = box.option_box.currentData()
134
+ return box_key == trace_key or (
135
+ box_key in (None, label)
136
+ and label == box.option_box.currentText()
137
+ )
138
+
139
+ def _stored_trace_key(self, key: Any, line: Any) -> Any:
140
+ """Return the source identity represented by one stored plot line."""
141
+
142
+ if line is self.__dict__.get("line"):
143
+ return getattr(self, "_trace_key", key)
144
+ from_win = getattr(line, "from_win", None)
145
+ return self._window_trace_key(from_win) if from_win is not None else key
146
+
147
+ def _has_trace_window(self, window: Any) -> bool:
148
+ """Return whether ``window`` is already represented on this plot."""
149
+
150
+ trace_key = self._window_trace_key(window)
151
+ return any(
152
+ self._stored_trace_key(stored_key, line) == trace_key
153
+ for stored_key, line in self.__dict__.get("lines", {}).items()
154
+ )
155
+
156
+ def _secondary_lines(self) -> list[Any]:
157
+ """Return secondary traces without relying on their display order."""
158
+
159
+ main_line = self.__dict__.get("line")
160
+ return [
161
+ line
162
+ for line in self.__dict__.get("lines", {}).values()
163
+ if line is not None and line is not main_line
164
+ ]
165
+
166
+ def _ensure_trace_axis_viewboxes(
167
+ self,
168
+ *,
169
+ top: bool = False,
170
+ right: bool = False,
171
+ ) -> None:
172
+ """Create the overlay viewboxes needed by a trace's axis pair."""
173
+
174
+ if "vb" not in self.__dict__ or "plot" not in self.__dict__:
175
+ return
176
+
177
+ if top and right:
178
+ top = right = True
179
+
180
+ if right and self.__dict__.get("right_vb") is None:
181
+ self.right_vb = pg.ViewBox()
182
+ self.right_vb.setDefaultPadding(0)
183
+ self.plot.scene().addItem(self.right_vb)
184
+ self.plot.getAxis("right").linkToView(self.right_vb)
185
+ self.right_vb.setXLink(self.vb)
186
+
187
+ if top and self.__dict__.get("top_vb") is None:
188
+ self.top_vb = pg.ViewBox()
189
+ self.top_vb.setDefaultPadding(0)
190
+ self.plot.scene().addItem(self.top_vb)
191
+ self.plot.getAxis("top").linkToView(self.top_vb)
192
+ self.top_vb.setYLink(self.vb)
193
+
194
+ if (
195
+ top
196
+ and right
197
+ and self.__dict__.get("top_right_vb") is None
198
+ ):
199
+ self.top_right_vb = pg.ViewBox()
200
+ self.top_right_vb.setDefaultPadding(0)
201
+ self.plot.scene().addItem(self.top_right_vb)
202
+ self.top_right_vb.setXLink(self.top_vb)
203
+ self.top_right_vb.setYLink(self.right_vb)
204
+
205
+ if not self.__dict__.get("_trace_axis_viewboxes_connected", False):
206
+ self.vb.main_moved.connect(self.updateViews)
207
+ self.vb.sigResized.connect(self.updateViews)
208
+ self._trace_axis_viewboxes_connected = True
209
+
210
+ install_range_handlers = getattr(
211
+ self,
212
+ "_install_axis_scale_viewbox_range_handlers",
213
+ None,
214
+ )
215
+ if callable(install_range_handlers):
216
+ if self.__dict__.get("right_vb") is not None:
217
+ install_range_handlers(self.right_vb, y_axis="y2")
218
+ if self.__dict__.get("top_vb") is not None:
219
+ install_range_handlers(self.top_vb, x_axis="x2")
220
+
221
+ self.updateViews(None)
222
+
223
+ def _trace_axis_viewbox(self, style: _TraceStyle) -> Any:
224
+ """Return the viewbox representing one horizontal/vertical axis pair."""
225
+
226
+ uses_top = style.x_axis == "Top"
227
+ uses_right = style.y_axis == "Right"
228
+ if uses_top or uses_right:
229
+ self._ensure_trace_axis_viewboxes(top=uses_top, right=uses_right)
230
+ if uses_top and uses_right:
231
+ return self.top_right_vb
232
+ if uses_top:
233
+ return self.top_vb
234
+ if uses_right:
235
+ return self.right_vb
236
+ return self.vb
237
+
238
+ def _move_trace_to_axis_viewbox(self, line: Any, target: Any) -> None:
239
+ """Move a trace to its axis-pair viewbox without changing its data."""
240
+
241
+ get_viewbox = getattr(line, "getViewBox", None)
242
+ current = get_viewbox() if callable(get_viewbox) else None
243
+ if current is target:
244
+ return
245
+
246
+ # PlotItem registration belongs to the measurement, not its axis pair.
247
+ # CSVExporter walks items; native processing controls walk items/curves.
248
+ # Move only the graphics owner so all of those registries stay intact,
249
+ # and returning to the primary axes does not reset processing options.
250
+ if current is not None:
251
+ current.removeItem(line)
252
+ target.addItem(line)
253
+ if line is self.__dict__.get("line"):
254
+ self._marquee_axis_assignment_changed()
255
+
256
+ @QtCore.pyqtSlot()
257
+ def _trace_axis_auto_button_clicked(self) -> None:
258
+ """Mirror the plot auto button to every trace-axis viewbox."""
259
+
260
+ self._axis_scale_full_view_button_clicked()
261
+
262
+ @QtCore.pyqtSlot()
263
+ def _trace_axis_auto_range_requested(self) -> None:
264
+ """Auto-range overlay viewboxes, then merge per-axis trace bounds."""
265
+
266
+ force_autoscale = getattr(self, "force_all_axes_autoscale", None)
267
+ if callable(force_autoscale):
268
+ force_autoscale()
269
+ else:
270
+ for name in ("right_vb", "top_vb", "top_right_vb"):
271
+ viewbox = self.__dict__.get(name)
272
+ if viewbox is not None:
273
+ viewbox.autoRange()
274
+ self._refresh_trace_axis_auto_ranges()
275
+
276
+ def _refresh_trace_axis_auto_ranges(self) -> None:
277
+ """Merge automatic bounds across every viewbox contributing to an axis."""
278
+
279
+ if "vb" not in self.__dict__:
280
+ return
281
+ axis_is_used = getattr(self, "_axis_scale_axis_is_used", None)
282
+ axis_viewbox = getattr(self, "_axis_scale_viewbox", None)
283
+ apply_filtered = getattr(self, "_apply_axis_scale_filtered_auto", None)
284
+ if not all(callable(method) for method in (
285
+ axis_is_used,
286
+ axis_viewbox,
287
+ apply_filtered,
288
+ )):
289
+ return
290
+
291
+ custom_axes = self.__dict__.get("_axis_scale_custom_auto_axes", set())
292
+ for axis in ("x", "y", "x2", "y2"):
293
+ if not axis_is_used(axis):
294
+ custom_axes.discard(axis)
295
+ continue
296
+ viewbox = axis_viewbox(axis)
297
+ axis_number = 0 if axis in ("x", "x2") else 1
298
+ auto_range = viewbox.getState(copy=False)["autoRange"][axis_number]
299
+ if axis in custom_axes or auto_range is not False:
300
+ apply_filtered(axis)
301
+
302
+ def _refresh_top_axis_auto_range(self) -> None:
303
+ """Compatibility wrapper for refreshing all linked trace axes."""
304
+
305
+ self._refresh_trace_axis_auto_ranges()
306
+
307
+ def _trace_axis_parameter(self, trace: Any, display_axis: str) -> Any:
308
+ """Describe this trace's actual native mapping of published samples."""
309
+ return native_axis_quantities(
310
+ self._trace_input_axis_parameter(trace, "x"),
311
+ self._trace_input_axis_parameter(trace, "y"),
312
+ getattr(trace, "opts", {}),
313
+ )[display_axis]
314
+
315
+ def _trace_input_axis_parameter(self, trace: Any, display_axis: str) -> Any:
316
+ """Return metadata for samples before native display transforms."""
317
+
318
+ host_axis_param = self.__dict__.get("axis_param", {})
319
+ if trace is self.__dict__.get("line"):
320
+ return host_axis_param.get(display_axis) or getattr(self, "param", None)
321
+
322
+ # A merged trace owns metadata mapped at its last sample publication.
323
+ # Reading the source's current selection can describe pending data.
324
+ trace_axis_param = getattr(trace, "axis_param", {})
325
+ param = trace_axis_param.get(display_axis)
326
+ if param is not None:
327
+ return param
328
+
329
+ source = getattr(trace, "from_win", None)
330
+ if source is None:
331
+ return host_axis_param.get(display_axis)
332
+
333
+ source_axis = display_axis
334
+ choose_from = getattr(trace, "choose_from", None)
335
+ if choose_from is not None and len(choose_from) == 2:
336
+ source_axis = choose_from[0 if display_axis == "x" else 1]
337
+
338
+ source_axis_param = getattr(source, "axis_param", {})
339
+ if isinstance(source_axis_param, dict):
340
+ param = source_axis_param.get(source_axis)
341
+ if param is not None:
342
+ return param
343
+
344
+ try:
345
+ source_options = source.axis_options
346
+ except (AttributeError, RuntimeError):
347
+ source_options = {}
348
+ source_name = source_options.get(source_axis)
349
+ source_params = getattr(source, "param_dict", {})
350
+ param = source_params.get(source_name) if source_name is not None else None
351
+ if param is not None:
352
+ return param
353
+
354
+ # A regular 1D source's y parameter is its dependent measurement. If
355
+ # metadata is incomplete, a shared display axis is still described by
356
+ # the host parameter rather than by the wrong source measurement.
357
+ if source_axis == "y":
358
+ param = getattr(source, "param", None)
359
+ if param is not None:
360
+ return param
361
+ return host_axis_param.get(display_axis) or getattr(source, "param", None)
362
+
363
+ def _sync_vertical_axis_visibility(self, side: str) -> None:
364
+ """Synchronise one vertical axis with the traces assigned to it."""
365
+
366
+ plot = self.__dict__.get("plot")
367
+ if plot is None:
368
+ return
369
+
370
+ axis_name = side.lower()
371
+ style_side = "Right" if axis_name == "right" else "Left"
372
+ styles = self._ensure_trace_styles()
373
+ traces = [
374
+ (key, line)
375
+ for key, line in self.__dict__.get("lines", {}).items()
376
+ if line is not None
377
+ and styles.get(key, self._initial_trace_style()).y_axis == style_side
378
+ ]
379
+ axis = plot.getAxis(axis_name)
380
+ axis.setStyle(showValues=bool(traces))
381
+ if not traces:
382
+ axis.setLabel(text="", units="")
383
+ else:
384
+ trace_key, trace = traces[0]
385
+ param = self._trace_axis_parameter(trace, "y")
386
+ label = getattr(param, "label", None) or self._trace_measurement_name(
387
+ trace_key,
388
+ trace,
389
+ )
390
+ unit = getattr(param, "unit", "") or ""
391
+ axis.setLabel(text=str(label), units=str(unit))
392
+
393
+ sync_tabs = getattr(self, "_sync_axis_scale_tab_states", None)
394
+ if callable(sync_tabs):
395
+ sync_tabs()
396
+
397
+ def _sync_right_axis_visibility(self) -> None:
398
+ """Synchronise right-axis values and label with its active trace."""
399
+
400
+ self._sync_vertical_axis_visibility("right")
401
+
402
+ def _sync_left_axis_visibility(self) -> None:
403
+ """Synchronise left-axis values and label with its active trace."""
404
+
405
+ self._sync_vertical_axis_visibility("left")
406
+
407
+ def _sync_top_axis_visibility(self) -> None:
408
+ """Synchronise the linked top x-axis with traces assigned to it."""
409
+
410
+ plot = self.__dict__.get("plot")
411
+ if plot is None:
412
+ return
413
+
414
+ styles = self._ensure_trace_styles()
415
+ top_traces = [
416
+ (key, line)
417
+ for key, line in self.__dict__.get("lines", {}).items()
418
+ if line is not None
419
+ and styles.get(key, self._initial_trace_style()).x_axis == "Top"
420
+ ]
421
+ top_axis = plot.getAxis("top")
422
+ top_axis.setStyle(showValues=bool(top_traces))
423
+ if not top_traces:
424
+ top_axis.setLabel(text="", units="")
425
+ sync_tabs = getattr(self, "_sync_axis_scale_tab_states", None)
426
+ if callable(sync_tabs):
427
+ sync_tabs()
428
+ self._refresh_top_axis_auto_range()
429
+ return
430
+
431
+ trace_key, trace = top_traces[0]
432
+ axis_param = self._trace_axis_parameter(trace, "x")
433
+ label = getattr(axis_param, "label", None) or getattr(axis_param, "name", "")
434
+ if not label:
435
+ label = self._trace_measurement_name(trace_key, trace)
436
+ unit = getattr(axis_param, "unit", "") or ""
437
+ top_axis.setLabel(text=str(label), units=str(unit))
438
+ sync_tabs = getattr(self, "_sync_axis_scale_tab_states", None)
439
+ if callable(sync_tabs):
440
+ sync_tabs()
441
+ self._refresh_top_axis_auto_range()
442
+
443
+ def _set_param_axis_labels(self) -> None:
444
+ """Update primary labels, then restore any selected top trace axis."""
445
+
446
+ super()._set_param_axis_labels()
447
+ self._sync_trace_axis_labels()
448
+
449
+ def _sync_trace_axis_labels(self) -> None:
450
+ """Resolve labels from the first trace on each currently assigned axis."""
451
+
452
+ plot = self.__dict__.get("plot")
453
+ if plot is not None and {"x", "y"}.issubset(self.__dict__.get("axis_param", {})):
454
+ styles = self._ensure_trace_styles()
455
+ for key, line in self.__dict__.get("lines", {}).items():
456
+ if line is not None and styles.get(key, self._initial_trace_style()).x_axis == "Bottom":
457
+ parameter = self._trace_axis_parameter(line, "x")
458
+ plot.getAxis("bottom").setLabel(text=parameter.label, units=parameter.unit)
459
+ break
460
+ self._sync_left_axis_visibility()
461
+ self._sync_right_axis_visibility()
462
+ self._sync_top_axis_visibility()
463
+
464
+ def _trace_display_label(self, key: Any, line: Any) -> str:
465
+ """Return the unchanged user-facing label for an internally keyed trace."""
466
+
467
+ if line is self.__dict__.get("line"):
468
+ return self.label
469
+ from_win = getattr(line, "from_win", None)
470
+ return str(getattr(from_win, "label", key))
471
+
472
+ def _register_main_line(self) -> None:
473
+ """
474
+ Keeps the main pyqtgraph line in the trace registry.
475
+
476
+ """
477
+ line = self.__dict__.get("line")
478
+ if "lines" in self.__dict__ and line is not None:
479
+ self.lines[self.label] = line
480
+ self._ensure_trace_styles().setdefault(self.label, self._initial_trace_style())
481
+ if line is not None and callable(getattr(line, "setPen", None)):
482
+ self._apply_trace_style(self.label, line)
483
+ self._install_trace_appearance_click_handler(self.label, line)
484
+ install_axis_handler = getattr(self, "_install_axis_scale_trace_handler", None)
485
+ if callable(install_axis_handler):
486
+ install_axis_handler(line)
487
+
488
+ def _install_trace_appearance_click_handler(self, trace_key: Any, line: Any) -> None:
489
+ """Open Trace Appearance for a trace when it is double-clicked."""
490
+
491
+ if line is None or getattr(line, "_qplot_trace_click_handler", False):
492
+ return
493
+
494
+ set_clickable = getattr(line, "setCurveClickable", None)
495
+ if not callable(set_clickable):
496
+ return
497
+
498
+ set_clickable(True, width=8)
499
+ signal = getattr(line, "sigClicked", None)
500
+ if signal is None or not callable(getattr(signal, "connect", None)):
501
+ return
502
+
503
+ signal.connect(
504
+ lambda _line, event, key=trace_key: self._trace_clicked(key, event)
505
+ )
506
+ line._qplot_trace_click_handler = True
507
+
508
+ def _trace_clicked(self, trace_key: Any, event: Any) -> None:
509
+ """Show the clicked trace's settings only for a left double-click."""
510
+
511
+ is_double_click = getattr(event, "double", lambda: False)()
512
+ is_left_click = (
513
+ getattr(event, "button", lambda: None)()
514
+ == QtCore.Qt.MouseButton.LeftButton
515
+ )
516
+ if is_double_click and is_left_click:
517
+ self.open_trace_appearance_dialog(trace_key)
518
+
519
+ def initMenu(self) -> None:
520
+ super().initMenu()
521
+ view_menu = None
522
+ menu_bar = self.menuBar()
523
+ if menu_bar is None:
524
+ return
525
+ for action in menu_bar.actions():
526
+ if action.text().replace("&", "") == "View":
527
+ view_menu = action.menu()
528
+ break
529
+ if view_menu is None:
530
+ return
531
+ view_menu.addSeparator()
532
+ trace_action = QtGui.QAction("Trace Appearance…", self)
533
+ trace_action.triggered.connect(self.open_trace_appearance_dialog)
534
+ view_menu.addAction(trace_action)
535
+
536
+ def _set_main_line_color(self, color: Any) -> None:
537
+ self._set_trace_line_color(self.label, color)
538
+
539
+ def _set_trace_line_color(self, label: Any, color: Any) -> None:
540
+ style = self._ensure_trace_styles().setdefault(
541
+ label,
542
+ self._initial_trace_style(),
543
+ )
544
+ style.line_color = color.name() if hasattr(color, "name") else str(color)
545
+ self._apply_trace_style(label, self.__dict__.get("lines", {}).get(label))
546
+
547
+ def _set_trace_y_axis(self, label: Any, side: str) -> None:
548
+ style = self._ensure_trace_styles().setdefault(
549
+ label,
550
+ self._initial_trace_style(),
551
+ )
552
+ style.y_axis = "Right" if side.lower() == "right" else "Left"
553
+ self._apply_trace_style(label, self.__dict__.get("lines", {}).get(label))
554
+
555
+ def _sync_trace_control(self, label: Any) -> None:
556
+ control = self._ensure_trace_controls().get(label)
557
+ style = self._ensure_trace_styles().get(label)
558
+ if control is None or style is None:
559
+ return
560
+
561
+ color_box = getattr(control, "color_box", None)
562
+ if color_box is not None:
563
+ color_box.setColor(QtGui.QColor(style.line_color))
564
+
565
+ axis_side = getattr(control, "axis_side", None)
566
+ if axis_side is not None and axis_side.currentText() != style.y_axis:
567
+ previous_blocked = axis_side.blockSignals(True)
568
+ try:
569
+ axis_side.setCurrentText(style.y_axis)
570
+ finally:
571
+ axis_side.blockSignals(previous_blocked)
572
+
573
+
574
+ def initAxes(self) -> None:
575
+ """
576
+ Sets up the hidden 1D axis state and trace controls.
577
+
578
+ A 1D plot only has one independent/dependent axis pair. The visible
579
+ dropdown dock is therefore replaced by the Swap X/Y checkbox in Trace
580
+ Appearance. Two-dimensional plots continue to use the dock.
581
+
582
+ """
583
+ super().initAxes()
584
+
585
+ self.axes_dock.hide()
586
+ self.axes_dock.toggleViewAction().setVisible(False)
587
+
588
+ # Keep the legacy picker widgets only as an internal bridge for
589
+ # preview drops and hidden-source construction. Trace Appearance is
590
+ # the sole visible trace-control surface.
591
+ self.lines = {}
592
+ self._register_main_line()
593
+ self.option_boxes = []
594
+ self._trace_controls = {}
595
+ self.box_count = 1
596
+ self._trace_appearance_dialog = None
597
+
598
+ # Produce scrollable widget to allow viewing of as many lines as needed
599
+ self.lineScroll = qtw.QScrollArea()
600
+ self.lineScroll.setWidgetResizable(True)
601
+ self.lineScroll.setMinimumSize(1, 1)
602
+ self.lineScroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
603
+ self.lineScroll.setSizePolicy(
604
+ qtw.QSizePolicy.Policy.Expanding,
605
+ qtw.QSizePolicy.Policy.Expanding,
606
+ )
607
+ self.lineScroll.hide()
608
+
609
+ # QScrollArea can only take 1 widget. That widget holds the layout.
610
+ self.scrollWidget = qtw.QWidget()
611
+ self.scrollWidget.setSizePolicy(
612
+ qtw.QSizePolicy.Policy.Ignored,
613
+ qtw.QSizePolicy.Policy.Preferred,
614
+ )
615
+ self.lineScroll.setWidget(self.scrollWidget)
616
+
617
+ self.box_layout = qtw.QVBoxLayout()
618
+ self.box_layout.setContentsMargins(0, 0, 0, 0)
619
+ self.scrollWidget.setLayout(self.box_layout)
620
+
621
+ # Main line controller
622
+ main_line = picker_1d(self, self.config, [self.label])
623
+ main_line.option_box.setCurrentIndex(0)
624
+ main_line.option_box.setDisabled(True)
625
+ main_line.del_box.setDisabled(True)
626
+ main_line.axis_side.setDisabled(True)
627
+ main_line.color_box.selectedColor.connect(
628
+ self._set_main_line_color
629
+ )
630
+ self._trace_controls[self.label] = main_line
631
+ self.box_layout.addWidget(main_line)
632
+ main_line.adjustSize()
633
+ self._apply_trace_style(self.label, self.line)
634
+
635
+ # Force to top
636
+ self.box_layout.addStretch()
637
+
638
+ # Add empty box for user to use
639
+ self.add_option_box(options=[])
640
+
641
+
642
+ def _resize_scrollArea(self) -> None:
643
+ """
644
+ Refresh the scroll area's geometry without preventing dock resizing.
645
+
646
+ Trace controls provide a useful preferred size, but the containing dock
647
+ must remain shrinkable on smaller plot windows. A horizontal scrollbar
648
+ exposes any controls that cannot fit at the user's chosen width.
649
+ """
650
+ self.scrollWidget.adjustSize()
651
+ self.lineScroll.setMinimumWidth(1)
652
+ self.lineScroll.updateGeometry()
653
+
654
+
655
+ def add_option_box(self, options: list[str] | None = None) -> None:
656
+ """
657
+ Produces a new box for user to add another line to the plot.
658
+ Boxes are made from a QWidget, see qplot.windows._widgets.dropbox.picker_1d
659
+ for how they are produced.
660
+
661
+ Parameters
662
+ ----------
663
+ options : list[str], optional
664
+ List of item to add to the dropdown menu to pick from.
665
+ The default is None, which adds self.mergable or all valid windows
666
+ which can be added.
667
+
668
+ """
669
+ if options is not None:
670
+ new_option = picker_1d(self, self.config, options)
671
+ else:
672
+ labels = [item.label for item in self.mergable]
673
+ trace_keys = [self._window_trace_key(item) for item in self.mergable]
674
+ new_option = picker_1d(
675
+ self,
676
+ self.config,
677
+ labels,
678
+ item_data=trace_keys,
679
+ )
680
+
681
+ # Connect Slots
682
+ new_option.itemSelected.connect(
683
+ lambda label, option=new_option: self.add_line(
684
+ label,
685
+ option.option_box.currentData(),
686
+ )
687
+ )
688
+ new_option.closed.connect(
689
+ lambda label, option=new_option: self.remove_line(
690
+ label,
691
+ getattr(option, "_trace_key", option.option_box.currentData()),
692
+ )
693
+ )
694
+
695
+ # Adjust apperance
696
+ cols = self.config.theme.colors
697
+ col_ind = self.box_count % len(cols)
698
+ new_option.color_box.setColor(cols[col_ind])
699
+ self.box_count += 1
700
+
701
+ # Add box to tracking array and then to last possition in ScrollWidget
702
+ self.option_boxes.append(new_option)
703
+ self.box_layout.insertWidget(self.box_layout.count() - 1, new_option)
704
+
705
+ # Resize after adding box. This func is also ran after removing a box
706
+ # which is the main reason for it
707
+ self._resize_scrollArea()
708
+
709
+
710
+ def update_line_picker(self, wins: list[Any] | None = None) -> None:
711
+ """
712
+ Refreshes the available options in the box dropdown menus.
713
+
714
+ Parameters
715
+ ----------
716
+ wins : list[plotWidget], optional
717
+ Updates internal save of plots which can be added.
718
+
719
+ """
720
+ if wins is not None:
721
+ self.mergable = wins
722
+
723
+ # Only add options which are not already being plotted
724
+ if self.option_boxes:
725
+ selected_keys = {
726
+ getattr(box, "_trace_key", box.option_box.currentData())
727
+ for box in self.option_boxes
728
+ if box.option_box.currentIndex() >= 0
729
+ }
730
+ available = [
731
+ item for item in self.mergable
732
+ if self._window_trace_key(item) not in selected_keys
733
+ ]
734
+ for box in self.option_boxes:
735
+ if box.option_box.isEnabled():
736
+ box.reset_box(
737
+ [item.label for item in available],
738
+ item_data=[self._window_trace_key(item) for item in available],
739
+ )
740
+ dialog = self.__dict__.get("_trace_appearance_dialog")
741
+ if dialog is not None:
742
+ dialog.refresh_available_traces()
743
+
744
+ def add_trace_from_dialog(self, label: str, trace_key: Any) -> None:
745
+ """Add a trace selected in Trace Appearance."""
746
+
747
+ request = self.__dict__.get("_trace_add_request")
748
+ dataset_key = getattr(trace_key, "dataset_key", None)
749
+ parameter_name = getattr(trace_key, "parameter_name", None)
750
+ if callable(request) and dataset_key is not None and parameter_name:
751
+ request(dataset_key, parameter_name)
752
+ return
753
+
754
+ selected_box = next(
755
+ (
756
+ box
757
+ for box in self.option_boxes
758
+ if box.option_box.isEnabled()
759
+ and box.option_box.currentIndex() < 0
760
+ ),
761
+ None,
762
+ )
763
+ if selected_box is None:
764
+ self.add_option_box()
765
+ selected_box = self.option_boxes[-1]
766
+
767
+ selected_box.option_box.blockSignals(True)
768
+ try:
769
+ selected_box.option_box.clear()
770
+ selected_box.option_box.addItem(label, userData=trace_key)
771
+ selected_box.option_box.setCurrentIndex(0)
772
+ selected_box.option_box.setEnabled(False)
773
+ selected_box.del_box.setEnabled(True)
774
+ finally:
775
+ selected_box.option_box.blockSignals(False)
776
+
777
+ self.add_line(label, trace_key)
778
+
779
+ def available_trace_candidates(self) -> list[tuple[str, Any]]:
780
+ """Return database-backed traces eligible for Trace Appearance."""
781
+
782
+ provider = self.__dict__.get("_trace_candidate_provider")
783
+ if callable(provider):
784
+ return list(provider())
785
+ return [
786
+ (source.label, self._window_trace_key(source))
787
+ for source in getattr(self, "mergable", [])
788
+ ]
789
+
790
+
791
+ def refresh_secondary_lines(self) -> None:
792
+ """
793
+ Refreshes added trace lines and restarts hidden live-trace monitors.
794
+
795
+ """
796
+ for line in self._secondary_lines():
797
+ line.refresh()
798
+ from_win = line.from_win
799
+ if (
800
+ not from_win.visible
801
+ and plot_refresh_required(from_win)
802
+ and not from_win.monitor.isActive()
803
+ ):
804
+ from_win.monitorIntervalChanged(refresh_interval_value(from_win.spinBox))
805
+
806
+
807
+ @QtCore.pyqtSlot(str)
808
+ def add_line(self, label: str, trace_key: Any = None) -> None:
809
+ """
810
+ Produces a secondary plot based on user selection in dropdown menus
811
+
812
+ See both subplot1d and custom_viewbox in:
813
+ qplot.tools.subplots
814
+ for setup and other functions
815
+
816
+ Parameters
817
+ ----------
818
+ label : str
819
+ The label of the chosen plot.
820
+ trace_key : object, optional
821
+ Database-aware source identity supplied by the picker.
822
+
823
+ Returns
824
+ -------
825
+ None.
826
+
827
+ """
828
+
829
+ win = None
830
+
831
+ # Find the exact selected window. Labels remain display-only and may be
832
+ # shared by copied runs opened from different databases.
833
+ if trace_key is not None:
834
+ for item in self.mergable:
835
+ if self._window_trace_key(item) == trace_key:
836
+ win = item
837
+ self.mergable.remove(item)
838
+ break
839
+ if win is None:
840
+ for item in self.mergable:
841
+ if item.label != label:
842
+ continue
843
+ win = item
844
+ self.mergable.remove(item)
845
+ break
846
+
847
+ # Dedug line
848
+ assert win is not None
849
+
850
+ # Secondary traces may be assigned to the right axis immediately.
851
+ self._ensure_trace_axis_viewboxes(right=True)
852
+
853
+ # Create and track new line
854
+ self.make_ds.emit(win._dataset_key)
855
+ trace_key = self._window_trace_key(win)
856
+ try:
857
+ subplot = subplot1d(self, win)
858
+ except Exception:
859
+ self.remove_dataset.emit(win._dataset_key)
860
+ self.mergable.append(win)
861
+ for box in self.option_boxes:
862
+ if not self._picker_matches_trace(box, trace_key, label):
863
+ continue
864
+ box.option_box.setEnabled(True)
865
+ box.del_box.setEnabled(False)
866
+ box.reset_box(
867
+ [item.label for item in self.mergable],
868
+ item_data=[self._window_trace_key(item) for item in self.mergable],
869
+ )
870
+ break
871
+ raise
872
+
873
+ # Produce a new empty box for the next selection only after the line
874
+ # has been constructed successfully.
875
+ self.add_option_box()
876
+ self.lines[trace_key] = subplot
877
+ sync_transforms = getattr(self, "_sync_native_trace_transforms", None)
878
+ if callable(sync_transforms):
879
+ sync_transforms(subplot)
880
+
881
+ # Connect box options to line
882
+ selected_box = None
883
+ for box in self.option_boxes:
884
+ if self._picker_matches_trace(box, trace_key, label):
885
+
886
+ box.color_box.selectedColor.connect(
887
+ lambda color, selected_key=trace_key: self._set_trace_line_color(
888
+ selected_key,
889
+ color,
890
+ )
891
+ )
892
+
893
+ box.axis_side.currentTextChanged.connect(
894
+ lambda side, selected_key=trace_key: self._set_trace_y_axis(
895
+ selected_key,
896
+ side,
897
+ )
898
+ )
899
+ box._trace_key = trace_key
900
+ selected_box = box
901
+ break
902
+
903
+ # debug line
904
+ assert selected_box is not None
905
+
906
+ styles = self._ensure_trace_styles()
907
+ style = styles.setdefault(trace_key, self._initial_trace_style(order=len(styles)))
908
+ style.line_color = selected_box.color_box.color().name()
909
+ style.y_axis = "Right" if selected_box.axis_side.currentText().lower() == "right" else "Left"
910
+ if self.plot_axes_swapped():
911
+ self._transpose_trace_axis_style(style)
912
+ self._ensure_trace_controls()[trace_key] = selected_box
913
+ self._apply_trace_style(trace_key, subplot)
914
+ self._install_trace_appearance_click_handler(trace_key, subplot)
915
+ install_axis_handler = getattr(self, "_install_axis_scale_trace_handler", None)
916
+ if callable(install_axis_handler):
917
+ install_axis_handler(subplot)
918
+
919
+
920
+ @QtCore.pyqtSlot(bool)
921
+ def closeEvent(self, event: object) -> None:
922
+ # Stopped lines as needed
923
+ for line in self._secondary_lines():
924
+ disconnect_source_updates = getattr(
925
+ line,
926
+ "disconnect_source_updates",
927
+ None,
928
+ )
929
+ if callable(disconnect_source_updates):
930
+ disconnect_source_updates()
931
+ self.remove_dataset.emit(line.from_win._dataset_key)
932
+
933
+ main_line = self.__dict__.get("line")
934
+ self.lines = {
935
+ key: line
936
+ for key, line in self.__dict__.get("lines", {}).items()
937
+ if line is main_line
938
+ }
939
+
940
+
941
+ super().closeEvent(event)
942
+
943
+
944
+ @QtCore.pyqtSlot(str)
945
+ def remove_line(self, label: str, trace_key: Any = None) -> None:
946
+ """
947
+ Deletes line connect to box widget.
948
+
949
+ Parameters
950
+ ----------
951
+ label : str
952
+ The label of the chosen plot.
953
+ trace_key : object, optional
954
+ Database-aware identity of the trace to remove.
955
+
956
+ """
957
+ # Find box and remove box
958
+ side = None
959
+ selected_key = trace_key
960
+ for option in self.option_boxes:
961
+ option_key = getattr(option, "_trace_key", option.option_box.currentData())
962
+ if selected_key is not None and option_key != selected_key:
963
+ continue
964
+ if selected_key is None and option.option_box.currentText() != label:
965
+ continue
966
+ selected_key = option_key if option_key is not None else label
967
+ side = option.axis_side.currentText()
968
+ self.option_boxes.remove(option)
969
+ if option.parent() is not None:
970
+ option.setParent(None)
971
+ option.deleteLater()
972
+ break
973
+ assert side is not None
974
+ assert selected_key is not None
975
+
976
+ # Remove line from viewbox
977
+ line = self.lines[selected_key]
978
+ self.lines.pop(selected_key)
979
+ self._trace_styles.pop(selected_key, None)
980
+ self._ensure_trace_controls().pop(selected_key, None)
981
+ disconnect_source_updates = getattr(
982
+ line,
983
+ "disconnect_source_updates",
984
+ None,
985
+ )
986
+ if callable(disconnect_source_updates):
987
+ disconnect_source_updates()
988
+ get_viewbox = getattr(line, "getViewBox", None)
989
+ viewbox = get_viewbox() if callable(get_viewbox) else None
990
+ if viewbox is self.vb:
991
+ self.plot.removeItem(line)
992
+ elif viewbox is not None:
993
+ viewbox.removeItem(line)
994
+ self.plot.removeItem(line)
995
+ else:
996
+ # Compatibility fallback for lightweight test and plugin traces.
997
+ owner = self.plot if side.lower() == "left" else self.right_vb
998
+ owner.removeItem(line)
999
+
1000
+ self._sync_right_axis_visibility()
1001
+ self._sync_left_axis_visibility()
1002
+ self._sync_top_axis_visibility()
1003
+
1004
+ # Remove track of window
1005
+ self.remove_dataset.emit(line.from_win._dataset_key)
1006
+
1007
+ # Update box options
1008
+ self.get_mergables.emit()
1009
+ # Resize dock widget
1010
+ self._resize_scrollArea()
1011
+ dialog = self.__dict__.get("_trace_appearance_dialog")
1012
+ if dialog is not None:
1013
+ dialog.refresh_rows()
1014
+
1015
+ def _default_plot_axis_names(self) -> tuple[str, str] | None:
1016
+ """Return the independent/dependent axis pair for a 1D plot."""
1017
+
1018
+ independent = tuple(getattr(self.param, "depends_on_", ()))
1019
+ dependent = getattr(self.param, "name", "")
1020
+ if len(independent) != 1 or not dependent or independent[0] == dependent:
1021
+ return None
1022
+ return str(independent[0]), str(dependent)
1023
+
1024
+ def can_swap_plot_axes(self) -> bool:
1025
+ """Return whether this window has a valid, reversible 1D axis pair."""
1026
+
1027
+ names = self._default_plot_axis_names()
1028
+ dropdowns = self.__dict__.get("axis_dropdown", {})
1029
+ if names is None or set(dropdowns) != {"x", "y"}:
1030
+ return False
1031
+
1032
+ for dropdown in dropdowns.values():
1033
+ if any(dropdown.findText(name) < 0 for name in names):
1034
+ return False
1035
+
1036
+ axis_data = self.__dict__.get("axis_data")
1037
+ if isinstance(axis_data, dict) and {"x", "y"}.issubset(axis_data):
1038
+ try:
1039
+ if len(axis_data["x"]) != len(axis_data["y"]):
1040
+ return False
1041
+ except TypeError:
1042
+ return False
1043
+ return True
1044
+
1045
+ def plot_axes_swapped(self) -> bool:
1046
+ """Return whether the dependent variable is currently horizontal."""
1047
+
1048
+ names = self._default_plot_axis_names()
1049
+ if names is None:
1050
+ return False
1051
+ independent, dependent = names
1052
+ options = self.axis_options
1053
+ return options.get("x") == dependent and options.get("y") == independent
1054
+
1055
+ def set_plot_axes_swapped(self, swapped: bool) -> bool:
1056
+ """Apply the requested 1D axis orientation and refresh the plot."""
1057
+
1058
+ if not self.can_swap_plot_axes():
1059
+ return False
1060
+
1061
+ names = self._default_plot_axis_names()
1062
+ assert names is not None
1063
+ independent, dependent = names
1064
+ target = (
1065
+ {"x": dependent, "y": independent}
1066
+ if swapped
1067
+ else {"x": independent, "y": dependent}
1068
+ )
1069
+ previous = dict(self.axis_options)
1070
+ if previous == target:
1071
+ self._update_axis_context_message()
1072
+ return True
1073
+
1074
+ for axis, name in target.items():
1075
+ dropdown = self.axis_dropdown[axis]
1076
+ was_blocked = dropdown.blockSignals(True)
1077
+ try:
1078
+ dropdown.setCurrentIndex(dropdown.findText(name))
1079
+ finally:
1080
+ dropdown.blockSignals(was_blocked)
1081
+ self._axis_selection = dict(target)
1082
+
1083
+ if previous == {"x": target["y"], "y": target["x"]}:
1084
+ self._transpose_trace_axis_assignments()
1085
+ self._swap_loaded_plot_axes()
1086
+ self._update_axis_context_message()
1087
+
1088
+ dialog = self.__dict__.get("_trace_appearance_dialog")
1089
+ if dialog is not None:
1090
+ dialog.sync_swap_axes_control()
1091
+ self.refreshWindow(force=True)
1092
+ return True
1093
+
1094
+ @staticmethod
1095
+ def _transpose_trace_axis_style(style: _TraceStyle) -> None:
1096
+ """Transpose a trace's physical axis sides in place."""
1097
+
1098
+ previous_x = style.x_axis
1099
+ previous_y = style.y_axis
1100
+ style.x_axis = "Bottom" if previous_y == "Left" else "Top"
1101
+ style.y_axis = "Left" if previous_x == "Bottom" else "Right"
1102
+
1103
+ def _transpose_trace_axis_assignments(self) -> None:
1104
+ """Keep every trace attached to the equivalent axis after X/Y swap."""
1105
+
1106
+ styles = self._ensure_trace_styles()
1107
+ for style in styles.values():
1108
+ self._transpose_trace_axis_style(style)
1109
+ for trace_key, line in self.__dict__.get("lines", {}).items():
1110
+ self._apply_trace_style(trace_key, line)
1111
+
1112
+ def _swap_loaded_plot_axes(self) -> None:
1113
+ """Swap the displayed 1D arrays and labels before the reload finishes."""
1114
+
1115
+ axis_data = self.__dict__.get("axis_data")
1116
+ axis_param = self.__dict__.get("axis_param")
1117
+ if not (
1118
+ isinstance(axis_data, dict)
1119
+ and isinstance(axis_param, dict)
1120
+ and {"x", "y"}.issubset(axis_data)
1121
+ and {"x", "y"}.issubset(axis_param)
1122
+ ):
1123
+ return
1124
+
1125
+ axis_data["x"], axis_data["y"] = axis_data["y"], axis_data["x"]
1126
+ axis_param["x"], axis_param["y"] = axis_param["y"], axis_param["x"]
1127
+
1128
+ line = self.__dict__.get("line")
1129
+ if line is not None:
1130
+ line.setData(x=axis_data["x"], y=axis_data["y"])
1131
+
1132
+ clear_marquee = getattr(self, "clear_marquee", None)
1133
+ if self.__dict__.get("marquee") is not None and callable(clear_marquee):
1134
+ clear_marquee()
1135
+ hide_snap_marker = getattr(self, "_hide_snap_marker", None)
1136
+ if callable(hide_snap_marker):
1137
+ hide_snap_marker()
1138
+
1139
+ self.refresh_secondary_lines()
1140
+ self._set_param_axis_labels()
1141
+ trace_updated = getattr(self, "trace_updated", None)
1142
+ if trace_updated is not None and callable(getattr(trace_updated, "emit", None)):
1143
+ trace_updated.emit()
1144
+
1145
+ def _update_axis_context_message(self) -> None:
1146
+ """Refresh the persistent axis relationship in the coordinates bar."""
1147
+
1148
+ update = getattr(self, "_update_coordinate_context", None)
1149
+ if callable(update):
1150
+ update()
1151
+
1152
+
1153
+ @QtCore.pyqtSlot(object)
1154
+ def updateViews(self, ev: object | None) -> None:
1155
+ """
1156
+ When moving main viewbox move/scale right viewbox but the same
1157
+ relative amount.
1158
+
1159
+ Parameters
1160
+ ----------
1161
+ ev : PyQt6.<something?>
1162
+
1163
+ """
1164
+ geometry = self.vb.sceneBoundingRect()
1165
+ for name in ("right_vb", "top_vb", "top_right_vb"):
1166
+ viewbox = self.__dict__.get(name)
1167
+ if viewbox is not None:
1168
+ viewbox.setGeometry(geometry)
1169
+ right_vb = self.__dict__.get("right_vb")
1170
+ top_vb = self.__dict__.get("top_vb")
1171
+ constrained_axis = getattr(self.vb, "_main_moved_axis", None)
1172
+ if ev is not None:
1173
+ if ev.__class__.__name__ == "QGraphicsSceneWheelEvent":
1174
+ if right_vb is not None:
1175
+ right_vb.wheelEvent(ev, axis=1)
1176
+ if top_vb is not None:
1177
+ top_vb.wheelEvent(ev, axis=0)
1178
+ elif ev.__class__.__name__ == "MouseDragEvent":
1179
+ if right_vb is not None and constrained_axis in (None, 1):
1180
+ right_vb.mouseDragEvent(ev, axis=1)
1181
+ if top_vb is not None and constrained_axis in (None, 0):
1182
+ top_vb.mouseDragEvent(ev, axis=0)
1183
+
1184
+ # Prevent lines in overlay viewboxes from escaping the plot rectangle.
1185
+ for name in ("right_vb", "top_vb", "top_right_vb"):
1186
+ viewbox = self.__dict__.get(name)
1187
+ if viewbox is not None:
1188
+ viewbox.setGeometry(geometry)
1189
+
1190
+ def open_trace_appearance_dialog(self, trace_key: Any = None) -> None:
1191
+ if self.__dict__.get("_trace_appearance_dialog") is None:
1192
+ self._trace_appearance_dialog = _TraceAppearanceDialog(self)
1193
+ get_mergables = getattr(self, "get_mergables", None)
1194
+ if get_mergables is not None and callable(getattr(get_mergables, "emit", None)):
1195
+ get_mergables.emit()
1196
+ self._trace_appearance_dialog.refresh_rows()
1197
+ if trace_key is not None:
1198
+ self._trace_appearance_dialog.select_trace(trace_key)
1199
+ self._trace_appearance_dialog.show()
1200
+ self._trace_appearance_dialog.raise_()
1201
+ self._trace_appearance_dialog.activateWindow()
1202
+
1203
+ def update_theme(self, config: Any) -> None:
1204
+ super().update_theme(config)
1205
+ for label, line in self.__dict__.get("lines", {}).items():
1206
+ self._apply_trace_style(label, line)
1207
+
1208
+ dialog = self.__dict__.get("_trace_appearance_dialog")
1209
+ if dialog is not None:
1210
+ dialog.refresh_theme()
1211
+
1212
+ def _trace_measurement_name(self, label: Any, line: Any) -> str:
1213
+ if line is self.__dict__.get("line"):
1214
+ return self.param.name
1215
+ from_win = getattr(line, "from_win", None)
1216
+ param = getattr(from_win, "param", None)
1217
+ return getattr(param, "name", str(label))
1218
+
1219
+ def _apply_trace_style(self, label: Any, line: Any) -> None:
1220
+ styles = self._ensure_trace_styles()
1221
+ style = styles.setdefault(label, self._initial_trace_style(order=len(styles)))
1222
+ if style.dots_enabled and style.markers_enabled:
1223
+ style.dots_enabled = False
1224
+ self._sync_trace_control(label)
1225
+ if line is None:
1226
+ return
1227
+ pen_style_map = {
1228
+ "Solid": QtCore.Qt.PenStyle.SolidLine,
1229
+ "Dash": QtCore.Qt.PenStyle.DashLine,
1230
+ "Dot": QtCore.Qt.PenStyle.DotLine,
1231
+ "Dash Dot": QtCore.Qt.PenStyle.DashDotLine,
1232
+ }
1233
+ if style.line_enabled:
1234
+ pen = pg.mkPen(
1235
+ color=QtGui.QColor(style.line_color),
1236
+ width=style.line_width,
1237
+ style=pen_style_map.get(style.line_style, QtCore.Qt.PenStyle.SolidLine),
1238
+ )
1239
+ else:
1240
+ pen = None
1241
+ line.setPen(pen)
1242
+ line.setSymbolPen(pg.mkPen(style.markers_color if style.markers_enabled else style.dots_color))
1243
+ line.setSymbolBrush(
1244
+ pg.mkBrush(style.markers_color if style.markers_enabled else style.dots_color)
1245
+ )
1246
+ line.setSymbolSize(style.markers_size if style.markers_enabled else style.dots_size)
1247
+ line.setSymbol(style.markers_symbol if style.markers_enabled else ("o" if style.dots_enabled else None))
1248
+ style.opacity = min(max(float(style.opacity), 0.0), 1.0)
1249
+ set_opacity = getattr(line, "setOpacity", None)
1250
+ set_graphics_effect = getattr(line, "setGraphicsEffect", None)
1251
+ if callable(set_opacity) and callable(set_graphics_effect):
1252
+ # PlotDataItem draws its curve and symbols as separate child items.
1253
+ # Applying opacity to the parent makes them blend twice where they
1254
+ # overlap, so composite the complete trace before fading it instead.
1255
+ set_opacity(1.0)
1256
+ opacity_effect = getattr(line, "_qplot_trace_opacity_effect", None)
1257
+ if style.opacity < 1.0:
1258
+ if opacity_effect is None:
1259
+ opacity_effect = qtw.QGraphicsOpacityEffect(line)
1260
+ set_graphics_effect(opacity_effect)
1261
+ line._qplot_trace_opacity_effect = opacity_effect
1262
+ opacity_effect.setOpacity(style.opacity)
1263
+ elif opacity_effect is not None:
1264
+ set_graphics_effect(None)
1265
+ line._qplot_trace_opacity_effect = None
1266
+ elif callable(set_opacity):
1267
+ set_opacity(style.opacity)
1268
+ line.setVisible(style.visible)
1269
+
1270
+ target_side = "right" if style.y_axis == "Right" else "left"
1271
+ if hasattr(line, "side"):
1272
+ line.side = target_side
1273
+ if "vb" in self.__dict__ and "plot" in self.__dict__:
1274
+ target_viewbox = self._trace_axis_viewbox(style)
1275
+ self._move_trace_to_axis_viewbox(line, target_viewbox)
1276
+ sync_log_mode = getattr(self, "_sync_axis_scale_line_log_mode", None)
1277
+ if callable(sync_log_mode):
1278
+ sync_log_mode(label, line)
1279
+
1280
+ self._sync_trace_axis_labels()
1281
+
1282
+ z = style.order
1283
+ set_z = getattr(line, "setZValue", None)
1284
+ if callable(set_z):
1285
+ set_z(z)
1286
+
1287
+
1288
+ class _TracePreviewDelegate(CenteredPreviewDelegate):
1289
+ """Backward-compatible name for the shared preview delegate."""
1290
+
1291
+
1292
+ class _TraceTableWidget(ReorderAppearanceTable):
1293
+ """Trace-specific instance of the shared appearance reorder table."""
1294
+
1295
+ _REORDER_MIME_TYPE = "application/x-qplot-trace-reorder"
1296
+
1297
+ def __init__(self, dialog: "_TraceAppearanceDialog"):
1298
+ super().__init__(dialog, mime_type=self._REORDER_MIME_TYPE)
1299
+
1300
+
1301
+ class _TraceAppearanceDialog(qtw.QDialog):
1302
+ _COL_ID = 0
1303
+ _COL_PREVIEW = 1
1304
+ _COL_MEASUREMENT = 2
1305
+ _CUSTOM_COLOR_DATA = "__custom_color__"
1306
+ _LINE_STYLES = (
1307
+ ("Solid", QtCore.Qt.PenStyle.SolidLine),
1308
+ ("Dash", QtCore.Qt.PenStyle.DashLine),
1309
+ ("Dot", QtCore.Qt.PenStyle.DotLine),
1310
+ ("Dash Dot", QtCore.Qt.PenStyle.DashDotLine),
1311
+ )
1312
+ _MARKER_SYMBOLS = ("o", "s", "t", "d", "+", "x")
1313
+
1314
+ def __init__(self, owner: Plot1DTraceMixin):
1315
+ super().__init__(owner)
1316
+ self.owner = owner
1317
+ self.setWindowTitle("Trace Appearance")
1318
+ self.resize(780, 360)
1319
+ self.setMinimumSize(700, 300)
1320
+ self._building = False
1321
+ self.setStyleSheet(
1322
+ self.styleSheet()
1323
+ + """
1324
+ QDoubleSpinBox#traceAppearanceSpin {
1325
+ padding-right: 18px;
1326
+ }
1327
+ QDoubleSpinBox#traceAppearanceSpin::up-button {
1328
+ subcontrol-origin: border;
1329
+ subcontrol-position: top right;
1330
+ width: 15px;
1331
+ height: 10px;
1332
+ border-left: 1px solid palette(mid);
1333
+ border-bottom: 1px solid palette(mid);
1334
+ border-top-right-radius: 4px;
1335
+ background: transparent;
1336
+ }
1337
+ QDoubleSpinBox#traceAppearanceSpin::down-button {
1338
+ subcontrol-origin: border;
1339
+ subcontrol-position: bottom right;
1340
+ width: 15px;
1341
+ height: 10px;
1342
+ border-left: 1px solid palette(mid);
1343
+ border-bottom-right-radius: 4px;
1344
+ background: transparent;
1345
+ }
1346
+ QDoubleSpinBox#traceAppearanceSpin::up-arrow,
1347
+ QDoubleSpinBox#traceAppearanceSpin::down-arrow {
1348
+ width: 7px;
1349
+ height: 7px;
1350
+ }
1351
+ QTableWidget#traceAppearanceTable {
1352
+ border: 1px solid palette(mid);
1353
+ background-color: palette(base);
1354
+ alternate-background-color: palette(alternate-base);
1355
+ gridline-color: palette(midlight);
1356
+ }
1357
+ QTableWidget#traceAppearanceTable::item {
1358
+ padding: 2px 6px;
1359
+ border: none;
1360
+ }
1361
+ QTableWidget#traceAppearanceTable QHeaderView::section {
1362
+ font-weight: normal;
1363
+ }
1364
+ """
1365
+ )
1366
+
1367
+ main = qtw.QVBoxLayout(self)
1368
+ main.setContentsMargins(10, 10, 10, 10)
1369
+ main.setSpacing(8)
1370
+
1371
+ body = qtw.QHBoxLayout()
1372
+ body.setSpacing(10)
1373
+ main.addLayout(body, 1)
1374
+
1375
+ trace_group = qtw.QGroupBox(self)
1376
+ trace_layout = qtw.QVBoxLayout(trace_group)
1377
+ trace_layout.setContentsMargins(8, 8, 8, 8)
1378
+ trace_layout.setSpacing(6)
1379
+
1380
+ self.table = _TraceTableWidget(self)
1381
+ configure_appearance_table(
1382
+ self.table,
1383
+ self,
1384
+ object_name="traceAppearanceTable",
1385
+ item_name="trace",
1386
+ )
1387
+ self.table.itemSelectionChanged.connect(self._sync_controls_from_selection)
1388
+
1389
+ trace_layout.addWidget(self.table)
1390
+ trace_actions = qtw.QHBoxLayout()
1391
+ trace_actions.setContentsMargins(0, 0, 0, 0)
1392
+ trace_actions.setSpacing(6)
1393
+ self.add_trace_combo = qtw.QComboBox(self)
1394
+ self.add_trace_combo.setObjectName("traceAppearanceAddCombo")
1395
+ self.add_trace_combo.setMinimumContentsLength(18)
1396
+ self.add_trace_combo.setSizeAdjustPolicy(
1397
+ qtw.QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon
1398
+ )
1399
+ self.add_trace_combo.setToolTip("Choose an available measurement to add.")
1400
+ self.add_trace_button = qtw.QPushButton("Add Trace", self)
1401
+ self.add_trace_button.setObjectName("traceAppearanceAddButton")
1402
+ self.add_trace_button.setToolTip("Add the selected measurement to this plot.")
1403
+ self.add_trace_button.setEnabled(False)
1404
+ self.remove_trace_button = qtw.QPushButton("Remove Trace", self)
1405
+ self.remove_trace_button.setObjectName("traceAppearanceRemoveButton")
1406
+ self.remove_trace_button.setToolTip(
1407
+ "Remove the selected secondary trace or traces from this plot."
1408
+ )
1409
+ self.remove_trace_button.setEnabled(False)
1410
+ trace_actions.addWidget(self.add_trace_combo, 1)
1411
+ trace_actions.addWidget(self.add_trace_button)
1412
+ trace_actions.addWidget(self.remove_trace_button)
1413
+ trace_layout.addLayout(trace_actions)
1414
+ body.addWidget(trace_group, 5)
1415
+
1416
+ panel = qtw.QWidget(self)
1417
+ panel_layout = qtw.QVBoxLayout(panel)
1418
+ panel_layout.setContentsMargins(0, 0, 0, 0)
1419
+ panel_layout.setSpacing(8)
1420
+
1421
+ self.line_enable = qtw.QCheckBox("Line")
1422
+ self.line_color = qtw.QComboBox(); self._add_color_items(self.line_color, TRACE_COLOR_PALETTE)
1423
+ self.line_width = qtw.QDoubleSpinBox(); self.line_width.setRange(0.5, 15); self.line_width.setValue(2); self.line_width.setDecimals(1); self.line_width.setSingleStep(0.5)
1424
+ self.line_style = qtw.QComboBox(); self._add_line_style_items(self.line_style)
1425
+ self.dots_enable = qtw.QCheckBox("Dots")
1426
+ self.dots_color = qtw.QComboBox(); self._add_color_items(self.dots_color, TRACE_COLOR_PALETTE)
1427
+ self.dots_size = qtw.QDoubleSpinBox(); self.dots_size.setRange(1, 20); self.dots_size.setDecimals(1); self.dots_size.setSingleStep(1)
1428
+ self.marker_enable = qtw.QCheckBox("Markers")
1429
+ self.marker_color = qtw.QComboBox(); self._add_color_items(self.marker_color, TRACE_COLOR_PALETTE)
1430
+ self.marker_symbol = qtw.QComboBox(); self._add_marker_symbol_items(self.marker_symbol)
1431
+ self.marker_size = qtw.QDoubleSpinBox(); self.marker_size.setRange(1, 30); self.marker_size.setValue(10); self.marker_size.setDecimals(1); self.marker_size.setSingleStep(1)
1432
+ self.opacity = qtw.QSpinBox(); self.opacity.setRange(0, 100); self.opacity.setValue(100); self.opacity.setSuffix("%")
1433
+ self.opacity.setObjectName("traceAppearanceOpacity")
1434
+ self.opacity.setToolTip("Trace opacity")
1435
+ self.opacity_slider = qtw.QSlider(QtCore.Qt.Orientation.Horizontal)
1436
+ self.opacity_slider.setObjectName("traceAppearanceOpacitySlider")
1437
+ self.opacity_slider.setRange(0, 100)
1438
+ self.opacity_slider.setValue(100)
1439
+ self.opacity_slider.setToolTip("Trace opacity")
1440
+ self.x_axis = qtw.QComboBox(); self.x_axis.addItems(["Bottom", "Top"])
1441
+ self.y_axis = qtw.QComboBox(); self.y_axis.addItems(["Left", "Right"])
1442
+ self.swap_axes = qtw.QCheckBox("Swap X/Y")
1443
+ self.swap_axes.setToolTip(
1444
+ "Plot the dependent variable horizontally and the independent "
1445
+ "variable vertically."
1446
+ )
1447
+ self.visible = qtw.QCheckBox("Visible")
1448
+ self.visible.setChecked(True)
1449
+
1450
+ for combo in (self.x_axis, self.y_axis):
1451
+ combo.setMinimumContentsLength(4)
1452
+ color_combo_width = 86
1453
+ for combo in (self.line_color, self.dots_color, self.marker_color):
1454
+ combo.setIconSize(QtCore.QSize(42, 16))
1455
+ combo.setFixedWidth(color_combo_width)
1456
+ combo_view = combo.view()
1457
+ if combo_view is not None:
1458
+ combo_view.setMinimumWidth(color_combo_width)
1459
+ for spin in (self.line_width, self.dots_size, self.marker_size):
1460
+ spin.setObjectName("traceAppearanceSpin")
1461
+ spin.setFixedWidth(76)
1462
+ self.opacity.setFixedWidth(68)
1463
+ style_symbol_width = 86
1464
+ self.line_style.setIconSize(QtCore.QSize(58, 16))
1465
+ self.line_style.setFixedWidth(style_symbol_width)
1466
+ line_style_view = self.line_style.view()
1467
+ if line_style_view is not None:
1468
+ line_style_view.setMinimumWidth(style_symbol_width)
1469
+ self.marker_symbol.setIconSize(QtCore.QSize(28, 18))
1470
+ self.marker_symbol.setFixedWidth(style_symbol_width)
1471
+ marker_symbol_view = self.marker_symbol.view()
1472
+ if marker_symbol_view is not None:
1473
+ marker_symbol_view.setMinimumWidth(style_symbol_width)
1474
+ axis_combo_width = 108
1475
+ self.x_axis.setFixedWidth(axis_combo_width)
1476
+ self.y_axis.setFixedWidth(axis_combo_width)
1477
+
1478
+ display_group = qtw.QGroupBox(panel)
1479
+ display_layout = qtw.QGridLayout(display_group)
1480
+ display_layout.setContentsMargins(8, 10, 8, 8)
1481
+ display_layout.setHorizontalSpacing(6)
1482
+ display_layout.setVerticalSpacing(4)
1483
+ self._add_display_row(
1484
+ display_layout,
1485
+ 0,
1486
+ self.line_enable,
1487
+ self.line_color,
1488
+ [("Width", self.line_width), ("", self.line_style)],
1489
+ )
1490
+ self._add_display_row(
1491
+ display_layout,
1492
+ 1,
1493
+ self.dots_enable,
1494
+ self.dots_color,
1495
+ [("Size", self.dots_size)],
1496
+ )
1497
+ self._add_display_row(
1498
+ display_layout,
1499
+ 2,
1500
+ self.marker_enable,
1501
+ self.marker_color,
1502
+ [("Size", self.marker_size), ("", self.marker_symbol)],
1503
+ )
1504
+ display_layout.setColumnStretch(5, 1)
1505
+ panel_layout.addWidget(display_group)
1506
+
1507
+ visibility_layout = qtw.QHBoxLayout()
1508
+ visibility_layout.setContentsMargins(0, 0, 0, 0)
1509
+ visibility_layout.setSpacing(6)
1510
+ visibility_layout.addWidget(self.visible)
1511
+ visibility_layout.addSpacing(16)
1512
+ visibility_layout.addWidget(qtw.QLabel("Opacity"))
1513
+ visibility_layout.addWidget(self.opacity_slider, 1)
1514
+ visibility_layout.addWidget(self.opacity)
1515
+ panel_layout.addLayout(visibility_layout)
1516
+
1517
+ trace_settings = qtw.QGroupBox("Trace axes", panel)
1518
+ trace_grid = qtw.QGridLayout(trace_settings)
1519
+ trace_grid.setContentsMargins(8, 10, 8, 8)
1520
+ trace_grid.setHorizontalSpacing(8)
1521
+ trace_grid.setVerticalSpacing(4)
1522
+ trace_grid.addWidget(qtw.QLabel("Horizontal"), 0, 0)
1523
+ trace_grid.addWidget(self.x_axis, 0, 1)
1524
+ trace_grid.addWidget(qtw.QLabel("Vertical"), 1, 0)
1525
+ trace_grid.addWidget(self.y_axis, 1, 1)
1526
+ trace_grid.setColumnStretch(2, 1)
1527
+ panel_layout.addWidget(trace_settings)
1528
+
1529
+ plot_axes = qtw.QGroupBox("Plot axes", panel)
1530
+ plot_axes_layout = qtw.QVBoxLayout(plot_axes)
1531
+ plot_axes_layout.setContentsMargins(8, 10, 8, 8)
1532
+ plot_axes_layout.addWidget(self.swap_axes)
1533
+ panel_layout.addWidget(plot_axes)
1534
+ panel_layout.addStretch()
1535
+
1536
+ body.addWidget(panel, 4)
1537
+
1538
+ buttons = qtw.QDialogButtonBox(qtw.QDialogButtonBox.StandardButton.Close, self)
1539
+ buttons.rejected.connect(self.close)
1540
+ main.addWidget(buttons)
1541
+
1542
+ self._line_controls = [self.line_color, self.line_width, self.line_style]
1543
+ self._dots_controls = [self.dots_color, self.dots_size]
1544
+ self._marker_controls = [self.marker_color, self.marker_symbol, self.marker_size]
1545
+ self._selection_controls = [
1546
+ self.line_enable,
1547
+ self.dots_enable,
1548
+ self.marker_enable,
1549
+ self.x_axis,
1550
+ self.y_axis,
1551
+ self.visible,
1552
+ self.opacity_slider,
1553
+ self.opacity,
1554
+ *self._line_controls,
1555
+ *self._dots_controls,
1556
+ *self._marker_controls,
1557
+ ]
1558
+ self.dots_enable.toggled.connect(self._dots_enabled_changed)
1559
+ self.marker_enable.toggled.connect(self._markers_enabled_changed)
1560
+ self.opacity_slider.valueChanged.connect(self.opacity.setValue)
1561
+ self.opacity.valueChanged.connect(self.opacity_slider.setValue)
1562
+ self.add_trace_combo.currentIndexChanged.connect(
1563
+ self._add_trace_selection_changed
1564
+ )
1565
+ self.add_trace_button.clicked.connect(self._add_selected_trace)
1566
+ self.remove_trace_button.clicked.connect(self._remove_selected_traces)
1567
+ self.swap_axes.toggled.connect(self._swap_axes_toggled)
1568
+ for _widget, signal in [
1569
+ (self.line_enable, self.line_enable.toggled), (self.line_color, self.line_color.currentIndexChanged),
1570
+ (self.line_width, self.line_width.valueChanged), (self.line_style, self.line_style.currentIndexChanged),
1571
+ (self.dots_enable, self.dots_enable.toggled), (self.dots_color, self.dots_color.currentIndexChanged),
1572
+ (self.dots_size, self.dots_size.valueChanged), (self.marker_enable, self.marker_enable.toggled),
1573
+ (self.marker_color, self.marker_color.currentIndexChanged), (self.marker_symbol, self.marker_symbol.currentIndexChanged),
1574
+ (self.marker_size, self.marker_size.valueChanged), (self.x_axis, self.x_axis.currentTextChanged),
1575
+ (self.y_axis, self.y_axis.currentTextChanged), (self.visible, self.visible.toggled), (self.opacity, self.opacity.valueChanged),
1576
+ ]:
1577
+ signal.connect(self._apply_selection)
1578
+ self._update_control_enabled_states(False)
1579
+ self.sync_swap_axes_control()
1580
+
1581
+ def sync_swap_axes_control(self) -> None:
1582
+ """Synchronise the global Swap X/Y checkbox with the plot window."""
1583
+
1584
+ can_swap = getattr(self.owner, "can_swap_plot_axes", None)
1585
+ is_swapped = getattr(self.owner, "plot_axes_swapped", None)
1586
+ enabled = bool(callable(can_swap) and can_swap())
1587
+ checked = bool(enabled and callable(is_swapped) and is_swapped())
1588
+ was_blocked = self.swap_axes.blockSignals(True)
1589
+ try:
1590
+ self.swap_axes.setEnabled(enabled)
1591
+ self.swap_axes.setChecked(checked)
1592
+ finally:
1593
+ self.swap_axes.blockSignals(was_blocked)
1594
+
1595
+ def _swap_axes_toggled(self, checked: bool) -> None:
1596
+ """Apply a requested plot-axis orientation immediately."""
1597
+
1598
+ setter = getattr(self.owner, "set_plot_axes_swapped", None)
1599
+ if not callable(setter) or not setter(checked):
1600
+ self.sync_swap_axes_control()
1601
+
1602
+ def refresh_available_traces(self) -> None:
1603
+ """Refresh measurements available to the Add Trace control."""
1604
+
1605
+ previous_key = self.add_trace_combo.currentData()
1606
+ plotted_keys = set(self.owner.lines)
1607
+ available = [
1608
+ (label, trace_key)
1609
+ for label, trace_key in self.owner.available_trace_candidates()
1610
+ if trace_key not in plotted_keys
1611
+ ]
1612
+
1613
+ self.add_trace_combo.blockSignals(True)
1614
+ try:
1615
+ self.add_trace_combo.clear()
1616
+ self.add_trace_combo.addItem("Select a trace to add…", userData=None)
1617
+ for label, trace_key in available:
1618
+ self.add_trace_combo.addItem(
1619
+ label,
1620
+ userData=trace_key,
1621
+ )
1622
+ index = self.add_trace_combo.findData(previous_key)
1623
+ self.add_trace_combo.setCurrentIndex(max(index, 0))
1624
+ finally:
1625
+ self.add_trace_combo.blockSignals(False)
1626
+ self._add_trace_selection_changed(self.add_trace_combo.currentIndex())
1627
+
1628
+ def _add_trace_selection_changed(self, _index: int) -> None:
1629
+ self.add_trace_button.setEnabled(
1630
+ self.add_trace_combo.currentData() is not None
1631
+ )
1632
+
1633
+ def _add_selected_trace(self, _checked: bool = False) -> None:
1634
+ trace_key = self.add_trace_combo.currentData()
1635
+ if trace_key is None:
1636
+ return
1637
+ try:
1638
+ self.owner.add_trace_from_dialog(
1639
+ self.add_trace_combo.currentText(),
1640
+ trace_key,
1641
+ )
1642
+ except Exception as error:
1643
+ qtw.QMessageBox.critical(
1644
+ self,
1645
+ "Could Not Add Trace",
1646
+ f"The selected trace could not be added:\n{error}",
1647
+ )
1648
+ self.refresh_available_traces()
1649
+ return
1650
+
1651
+ self.refresh_rows()
1652
+ self.select_trace(trace_key)
1653
+
1654
+ def _remove_selected_traces(self, _checked: bool = False) -> None:
1655
+ primary_line = self.owner.__dict__.get("line")
1656
+ selected = [
1657
+ (trace_key, self.owner.lines.get(trace_key))
1658
+ for trace_key in self._selected_labels()
1659
+ ]
1660
+ removable = [
1661
+ (trace_key, line)
1662
+ for trace_key, line in selected
1663
+ if line is not None and line is not primary_line
1664
+ ]
1665
+ if len(removable) != len(selected) or not removable:
1666
+ return
1667
+
1668
+ for trace_key, line in removable:
1669
+ label = self.owner._trace_display_label(trace_key, line)
1670
+ self.owner.remove_line(label, trace_key)
1671
+ self.refresh_rows()
1672
+
1673
+ def _dots_enabled_changed(self, enabled: bool) -> None:
1674
+ if not enabled:
1675
+ return
1676
+
1677
+ was_building = self._building
1678
+ self._building = True
1679
+ try:
1680
+ self.marker_enable.setChecked(False)
1681
+ finally:
1682
+ self._building = was_building
1683
+
1684
+ def _markers_enabled_changed(self, enabled: bool) -> None:
1685
+ if not enabled:
1686
+ return
1687
+
1688
+ was_building = self._building
1689
+ self._building = True
1690
+ try:
1691
+ self.dots_enable.setChecked(False)
1692
+ finally:
1693
+ self._building = was_building
1694
+
1695
+ def _add_display_row(
1696
+ self,
1697
+ layout: qtw.QGridLayout,
1698
+ row: int,
1699
+ toggle: qtw.QCheckBox,
1700
+ color_combo: qtw.QComboBox,
1701
+ controls: list[tuple[str, qtw.QWidget]],
1702
+ ) -> None:
1703
+ layout.addWidget(toggle, row, 0)
1704
+ layout.addWidget(color_combo, row, 1)
1705
+ column = 2
1706
+ for label, widget in controls:
1707
+ if label:
1708
+ layout.addWidget(qtw.QLabel(label), row, column)
1709
+ layout.addWidget(widget, row, column + 1)
1710
+ column += 2
1711
+ else:
1712
+ layout.addWidget(widget, row, column)
1713
+ column += 1
1714
+
1715
+ def _add_color_items(self, combo: qtw.QComboBox, colors: Sequence[str]) -> None:
1716
+ for color in colors:
1717
+ combo.addItem(self._color_icon(color), "", color)
1718
+ combo.addItem("Custom", self._CUSTOM_COLOR_DATA)
1719
+
1720
+ def _add_line_style_items(self, combo: qtw.QComboBox) -> None:
1721
+ for name, _pen_style in self._LINE_STYLES:
1722
+ combo.addItem(self._line_style_icon(name), "", name)
1723
+
1724
+ def _add_marker_symbol_items(self, combo: qtw.QComboBox) -> None:
1725
+ for symbol in self._MARKER_SYMBOLS:
1726
+ combo.addItem(self._marker_symbol_icon(symbol), "", symbol)
1727
+
1728
+ def refresh_theme(self) -> None:
1729
+ for index, (name, _pen_style) in enumerate(self._LINE_STYLES):
1730
+ self.line_style.setItemIcon(index, self._line_style_icon(name))
1731
+ for index, symbol in enumerate(self._MARKER_SYMBOLS):
1732
+ self.marker_symbol.setItemIcon(
1733
+ index,
1734
+ self._marker_symbol_icon(symbol),
1735
+ )
1736
+ self.refresh_rows()
1737
+
1738
+ def _color_icon(self, color: str) -> QtGui.QIcon:
1739
+ pixmap = QtGui.QPixmap(38, 16)
1740
+ pixmap.fill(QtCore.Qt.GlobalColor.transparent)
1741
+ painter = QtGui.QPainter(pixmap)
1742
+ painter.setPen(QtGui.QPen(QtGui.QColor("#444444")))
1743
+ painter.setBrush(QtGui.QBrush(QtGui.QColor(color)))
1744
+ painter.drawRoundedRect(1, 1, 36, 14, 2, 2)
1745
+ painter.end()
1746
+ return QtGui.QIcon(pixmap)
1747
+
1748
+ def _line_style_icon(self, style: str) -> QtGui.QIcon:
1749
+ pixmap = QtGui.QPixmap(54, 16)
1750
+ pixmap.fill(QtCore.Qt.GlobalColor.transparent)
1751
+ painter = QtGui.QPainter(pixmap)
1752
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
1753
+ pen = QtGui.QPen(self.palette().color(QtGui.QPalette.ColorRole.Text))
1754
+ pen.setWidthF(2.0)
1755
+ pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
1756
+ pen.setStyle(self._line_pen_style(style))
1757
+ painter.setPen(pen)
1758
+ painter.drawLine(4, pixmap.height() // 2, pixmap.width() - 4, pixmap.height() // 2)
1759
+ painter.end()
1760
+ return QtGui.QIcon(pixmap)
1761
+
1762
+ def _marker_symbol_icon(self, symbol: str) -> QtGui.QIcon:
1763
+ pixmap = QtGui.QPixmap(24, 18)
1764
+ pixmap.fill(QtCore.Qt.GlobalColor.transparent)
1765
+ painter = QtGui.QPainter(pixmap)
1766
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
1767
+ color = self.palette().color(QtGui.QPalette.ColorRole.Text)
1768
+ pen = QtGui.QPen(color)
1769
+ pen.setWidthF(1.6)
1770
+ painter.setPen(pen)
1771
+ painter.setBrush(QtGui.QBrush(color))
1772
+ self._draw_marker_symbol(
1773
+ painter,
1774
+ QtCore.QPointF(pixmap.width() / 2, pixmap.height() / 2),
1775
+ 5.0,
1776
+ symbol,
1777
+ )
1778
+ painter.end()
1779
+ return QtGui.QIcon(pixmap)
1780
+
1781
+ def _line_pen_style(self, style: str) -> QtCore.Qt.PenStyle:
1782
+ return dict(self._LINE_STYLES).get(style, QtCore.Qt.PenStyle.SolidLine)
1783
+
1784
+ def _combo_value(self, combo: qtw.QComboBox) -> str:
1785
+ data = combo.itemData(combo.currentIndex(), QtCore.Qt.ItemDataRole.UserRole)
1786
+ if data == self._CUSTOM_COLOR_DATA:
1787
+ return self._selected_style_color(combo)
1788
+ if data is None:
1789
+ return combo.currentText()
1790
+ return str(data)
1791
+
1792
+ def _set_combo_value(self, combo: qtw.QComboBox, value: str) -> None:
1793
+ previous_blocked = combo.blockSignals(True)
1794
+ try:
1795
+ normalized_value = (
1796
+ QtGui.QColor(value).name()
1797
+ if isinstance(value, str) and value.startswith("#")
1798
+ else value
1799
+ )
1800
+ for index in range(combo.count()):
1801
+ data = combo.itemData(index, QtCore.Qt.ItemDataRole.UserRole)
1802
+ if isinstance(data, str) and data.startswith("#"):
1803
+ data = QtGui.QColor(data).name()
1804
+ if combo.itemText(index) == value or data == normalized_value:
1805
+ combo.setCurrentIndex(index)
1806
+ return
1807
+
1808
+ if isinstance(normalized_value, str) and normalized_value.startswith("#"):
1809
+ custom_index = self._custom_color_index(combo)
1810
+ insert_index = custom_index if custom_index >= 0 else combo.count()
1811
+ combo.insertItem(
1812
+ insert_index,
1813
+ self._color_icon(normalized_value),
1814
+ "",
1815
+ normalized_value,
1816
+ )
1817
+ combo.setCurrentIndex(insert_index)
1818
+ return
1819
+
1820
+ combo.setCurrentText(value)
1821
+ finally:
1822
+ combo.blockSignals(previous_blocked)
1823
+
1824
+ def _custom_color_index(self, combo: qtw.QComboBox) -> int:
1825
+ for index in range(combo.count()):
1826
+ if combo.itemData(index, QtCore.Qt.ItemDataRole.UserRole) == self._CUSTOM_COLOR_DATA:
1827
+ return index
1828
+ return -1
1829
+
1830
+ def _selected_style_color(self, combo: qtw.QComboBox) -> str:
1831
+ attr = {
1832
+ self.line_color: "line_color",
1833
+ self.dots_color: "dots_color",
1834
+ self.marker_color: "markers_color",
1835
+ }.get(combo)
1836
+ labels = self._selected_labels()
1837
+ if attr and labels:
1838
+ style = self.owner._trace_styles.get(labels[0])
1839
+ if style is not None:
1840
+ return str(getattr(style, attr))
1841
+ return TRACE_COLOR_PALETTE[0]
1842
+
1843
+ def _resolve_custom_color_selection(self) -> bool:
1844
+ for combo in (self.line_color, self.dots_color, self.marker_color):
1845
+ if combo.itemData(combo.currentIndex(), QtCore.Qt.ItemDataRole.UserRole) != self._CUSTOM_COLOR_DATA:
1846
+ continue
1847
+
1848
+ previous_color = self._selected_style_color(combo)
1849
+ color = qtw.QColorDialog.getColor(
1850
+ QtGui.QColor(previous_color),
1851
+ self,
1852
+ "Select trace color",
1853
+ )
1854
+ if not color.isValid():
1855
+ self._set_combo_value(combo, previous_color)
1856
+ return False
1857
+
1858
+ self._set_combo_value(combo, color.name())
1859
+ return True
1860
+
1861
+ def _trace_pixmap(
1862
+ self,
1863
+ style: Plot1DTraceMixin._TraceStyle,
1864
+ size: QtCore.QSize | None = None,
1865
+ ) -> QtGui.QPixmap:
1866
+ if size is None:
1867
+ size = QtCore.QSize(64, 22)
1868
+ pixmap = QtGui.QPixmap(size)
1869
+ pixmap.fill(QtCore.Qt.GlobalColor.transparent)
1870
+ painter = QtGui.QPainter(pixmap)
1871
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
1872
+ painter.setOpacity(style.opacity * (1.0 if style.visible else 0.35))
1873
+
1874
+ points = [
1875
+ QtCore.QPointF(5, size.height() - 7),
1876
+ QtCore.QPointF(size.width() * 0.38, 7),
1877
+ QtCore.QPointF(size.width() * 0.68, size.height() - 8),
1878
+ QtCore.QPointF(size.width() - 5, 8),
1879
+ ]
1880
+ if style.line_enabled:
1881
+ pen = QtGui.QPen(QtGui.QColor(style.line_color))
1882
+ pen.setWidthF(max(1.0, min(style.line_width, 4.0)))
1883
+ pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
1884
+ pen.setStyle(self._line_pen_style(style.line_style))
1885
+ painter.setPen(pen)
1886
+ painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
1887
+ painter.drawPolyline(QtGui.QPolygonF(points))
1888
+
1889
+ if style.dots_enabled:
1890
+ painter.setPen(QtCore.Qt.PenStyle.NoPen)
1891
+ painter.setBrush(QtGui.QColor(style.dots_color))
1892
+ radius = max(2.0, min(style.dots_size / 2.0, 4.5))
1893
+ for point in points:
1894
+ painter.drawEllipse(point, radius, radius)
1895
+
1896
+ if style.markers_enabled:
1897
+ pen = QtGui.QPen(QtGui.QColor(style.markers_color))
1898
+ pen.setWidthF(1.5)
1899
+ painter.setPen(pen)
1900
+ painter.setBrush(QtGui.QColor(style.markers_color))
1901
+ radius = max(3.0, min(style.markers_size / 2.0, 5.0))
1902
+ for point in points[::2]:
1903
+ self._draw_marker_symbol(painter, point, radius, style.markers_symbol)
1904
+
1905
+ if not (style.line_enabled or style.dots_enabled or style.markers_enabled):
1906
+ pen = QtGui.QPen(self.palette().color(QtGui.QPalette.ColorRole.Mid))
1907
+ pen.setWidthF(1.0)
1908
+ pen.setStyle(QtCore.Qt.PenStyle.DashLine)
1909
+ painter.setPen(pen)
1910
+ painter.drawLine(6, size.height() // 2, size.width() - 6, size.height() // 2)
1911
+
1912
+ painter.end()
1913
+ return pixmap
1914
+
1915
+ def _draw_marker_symbol(
1916
+ self,
1917
+ painter: QtGui.QPainter,
1918
+ point: QtCore.QPointF,
1919
+ radius: float,
1920
+ symbol: str,
1921
+ ) -> None:
1922
+ if symbol == "s":
1923
+ rect = QtCore.QRectF(
1924
+ point.x() - radius,
1925
+ point.y() - radius,
1926
+ radius * 2,
1927
+ radius * 2,
1928
+ )
1929
+ painter.drawRect(rect)
1930
+ elif symbol == "t":
1931
+ polygon = QtGui.QPolygonF([
1932
+ QtCore.QPointF(point.x(), point.y() - radius),
1933
+ QtCore.QPointF(point.x() - radius, point.y() + radius),
1934
+ QtCore.QPointF(point.x() + radius, point.y() + radius),
1935
+ ])
1936
+ painter.drawPolygon(polygon)
1937
+ elif symbol == "d":
1938
+ polygon = QtGui.QPolygonF([
1939
+ QtCore.QPointF(point.x(), point.y() - radius),
1940
+ QtCore.QPointF(point.x() - radius, point.y()),
1941
+ QtCore.QPointF(point.x(), point.y() + radius),
1942
+ QtCore.QPointF(point.x() + radius, point.y()),
1943
+ ])
1944
+ painter.drawPolygon(polygon)
1945
+ elif symbol == "+":
1946
+ painter.drawLine(
1947
+ QtCore.QPointF(point.x() - radius, point.y()),
1948
+ QtCore.QPointF(point.x() + radius, point.y()),
1949
+ )
1950
+ painter.drawLine(
1951
+ QtCore.QPointF(point.x(), point.y() - radius),
1952
+ QtCore.QPointF(point.x(), point.y() + radius),
1953
+ )
1954
+ elif symbol == "x":
1955
+ painter.drawLine(
1956
+ QtCore.QPointF(point.x() - radius, point.y() - radius),
1957
+ QtCore.QPointF(point.x() + radius, point.y() + radius),
1958
+ )
1959
+ painter.drawLine(
1960
+ QtCore.QPointF(point.x() - radius, point.y() + radius),
1961
+ QtCore.QPointF(point.x() + radius, point.y() - radius),
1962
+ )
1963
+ else:
1964
+ painter.drawEllipse(point, radius, radius)
1965
+
1966
+ def _trace_icon(self, style: Plot1DTraceMixin._TraceStyle) -> QtGui.QIcon:
1967
+ return QtGui.QIcon(self._trace_pixmap(style))
1968
+
1969
+ def refresh_rows(self):
1970
+ self.sync_swap_axes_control()
1971
+ selected = set(self._selected_labels())
1972
+ self._sync_plot_order_from_rows()
1973
+ self.table.blockSignals(True)
1974
+ self.table.setRowCount(0)
1975
+ try:
1976
+ for row, (trace_key, line) in enumerate(self.owner.lines.items()):
1977
+ self.table.insertRow(row)
1978
+ label = self.owner._trace_display_label(trace_key, line)
1979
+ trace_id = label.split()[0].replace("ID:", "") if label.startswith("ID:") else str(row + 1)
1980
+ measurement = self.owner._trace_measurement_name(trace_key, line)
1981
+ style = self.owner._trace_styles.setdefault(
1982
+ trace_key,
1983
+ self.owner._initial_trace_style(order=row),
1984
+ )
1985
+
1986
+ values = {
1987
+ self._COL_ID: trace_id,
1988
+ self._COL_MEASUREMENT: measurement,
1989
+ }
1990
+ for col, value in values.items():
1991
+ item = qtw.QTableWidgetItem(value)
1992
+ flags = (
1993
+ item.flags()
1994
+ & ~QtCore.Qt.ItemFlag.ItemIsEditable
1995
+ | QtCore.Qt.ItemFlag.ItemIsDragEnabled
1996
+ )
1997
+ item.setFlags(flags)
1998
+ if col == self._COL_ID:
1999
+ item.setTextAlignment(
2000
+ QtCore.Qt.AlignmentFlag.AlignRight
2001
+ | QtCore.Qt.AlignmentFlag.AlignVCenter
2002
+ )
2003
+ else:
2004
+ item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
2005
+ item.setToolTip(value)
2006
+ self.table.setItem(row, col, item)
2007
+
2008
+ preview_item = qtw.QTableWidgetItem()
2009
+ preview_item.setIcon(self._trace_icon(style))
2010
+ preview_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
2011
+ preview_flags = (
2012
+ preview_item.flags()
2013
+ & ~QtCore.Qt.ItemFlag.ItemIsEditable
2014
+ & ~QtCore.Qt.ItemFlag.ItemIsDragEnabled
2015
+ )
2016
+ preview_item.setFlags(preview_flags)
2017
+ preview_item.setToolTip("Trace preview")
2018
+ self.table.setItem(row, self._COL_PREVIEW, preview_item)
2019
+
2020
+ label_item = self.table.item(row, self._COL_ID)
2021
+ if label_item is not None:
2022
+ label_item.setData(QtCore.Qt.ItemDataRole.UserRole, trace_key)
2023
+ label_item.setToolTip(label)
2024
+ self.table.setRowHeight(row, 28)
2025
+ if trace_key in selected:
2026
+ self.table.selectRow(row)
2027
+ finally:
2028
+ self.table.blockSignals(False)
2029
+
2030
+ if self.table.rowCount() and not self._selected_labels():
2031
+ self.table.selectRow(0)
2032
+ else:
2033
+ self._sync_controls_from_selection()
2034
+ self.refresh_available_traces()
2035
+
2036
+ def _sync_plot_order_from_rows(self) -> None:
2037
+ row_count = len(self.owner.lines)
2038
+ for row, (label, line) in enumerate(self.owner.lines.items()):
2039
+ style = self.owner._trace_styles.setdefault(
2040
+ label,
2041
+ self.owner._initial_trace_style(order=row_count - row - 1),
2042
+ )
2043
+ style.order = row_count - row - 1
2044
+ set_z = getattr(line, "setZValue", None)
2045
+ if callable(set_z):
2046
+ set_z(style.order)
2047
+
2048
+ def _move_rows_to_position(
2049
+ self,
2050
+ source_rows: Sequence[int],
2051
+ destination_row: int,
2052
+ ) -> None:
2053
+ labels = list(self.owner.lines)
2054
+ source_rows = sorted({row for row in source_rows if 0 <= row < len(labels)})
2055
+ if not source_rows:
2056
+ return
2057
+
2058
+ destination_row = max(0, min(destination_row, len(labels)))
2059
+ selected = [labels[row] for row in source_rows]
2060
+ destination_row -= sum(row < destination_row for row in source_rows)
2061
+ remaining = [
2062
+ label for row, label in enumerate(labels)
2063
+ if row not in source_rows
2064
+ ]
2065
+ reordered = (
2066
+ remaining[:destination_row]
2067
+ + selected
2068
+ + remaining[destination_row:]
2069
+ )
2070
+ if reordered == labels:
2071
+ return
2072
+
2073
+ old_lines = self.owner.lines
2074
+ reordered_lines = [(label, old_lines[label]) for label in reordered]
2075
+ old_lines.clear()
2076
+ old_lines.update(reordered_lines)
2077
+ self._sync_plot_order_from_rows()
2078
+ self.refresh_rows()
2079
+
2080
+ def _selected_rows(self) -> list[int]:
2081
+ selection_model = self.table.selectionModel()
2082
+ if selection_model is None:
2083
+ return []
2084
+ rows = sorted({idx.row() for idx in selection_model.selectedRows()})
2085
+ current_row = self.table.currentRow()
2086
+ if not rows and 0 <= current_row < self.table.rowCount():
2087
+ return [current_row]
2088
+ return rows
2089
+
2090
+ def _selected_labels(self) -> list[Any]:
2091
+ labels: list[Any] = []
2092
+ for row in self._selected_rows():
2093
+ item = self.table.item(row, self._COL_ID)
2094
+ if item is not None:
2095
+ label = item.data(QtCore.Qt.ItemDataRole.UserRole)
2096
+ if label in self.owner.lines:
2097
+ labels.append(label)
2098
+ return labels
2099
+
2100
+ def _trace_key_for_row(self, row: int) -> Any:
2101
+ item = self.table.item(row, self._COL_ID)
2102
+ if item is None:
2103
+ return None
2104
+ return item.data(QtCore.Qt.ItemDataRole.UserRole)
2105
+
2106
+ def select_trace(self, trace_key: Any) -> bool:
2107
+ """Select and reveal the row for ``trace_key`` if it is present."""
2108
+
2109
+ for row in range(self.table.rowCount()):
2110
+ if self._trace_key_for_row(row) != trace_key:
2111
+ continue
2112
+ self.table.clearSelection()
2113
+ self.table.selectRow(row)
2114
+ item = self.table.item(row, self._COL_ID)
2115
+ if item is not None:
2116
+ self.table.setCurrentItem(item)
2117
+ self.table.scrollToItem(item)
2118
+ return True
2119
+ return False
2120
+
2121
+ def _sync_controls_from_selection(self):
2122
+ if self._building:
2123
+ return
2124
+ labels = self._selected_labels()
2125
+ if not labels:
2126
+ self._update_control_enabled_states(False)
2127
+ return
2128
+ style = self.owner._trace_styles[labels[0]]
2129
+ self._building = True
2130
+ try:
2131
+ self.line_enable.setChecked(style.line_enabled); self._set_combo_value(self.line_color, style.line_color); self.line_width.setValue(style.line_width); self._set_combo_value(self.line_style, style.line_style)
2132
+ self.dots_enable.setChecked(style.dots_enabled); self._set_combo_value(self.dots_color, style.dots_color); self.dots_size.setValue(style.dots_size)
2133
+ self.marker_enable.setChecked(style.markers_enabled); self._set_combo_value(self.marker_color, style.markers_color); self._set_combo_value(self.marker_symbol, style.markers_symbol); self.marker_size.setValue(style.markers_size)
2134
+ self.x_axis.setCurrentText(style.x_axis); self.y_axis.setCurrentText(style.y_axis); self.visible.setChecked(style.visible); self.opacity.setValue(round(style.opacity * 100))
2135
+ finally:
2136
+ self._building = False
2137
+
2138
+ self._update_control_enabled_states(True)
2139
+
2140
+ def _update_control_enabled_states(self, has_selection: bool) -> None:
2141
+ for widget in self._selection_controls:
2142
+ widget.setEnabled(has_selection)
2143
+ for widget in self._line_controls:
2144
+ widget.setEnabled(has_selection and self.line_enable.isChecked())
2145
+ for widget in self._dots_controls:
2146
+ widget.setEnabled(has_selection and self.dots_enable.isChecked())
2147
+ for widget in self._marker_controls:
2148
+ widget.setEnabled(has_selection and self.marker_enable.isChecked())
2149
+ selected_lines = [
2150
+ self.owner.lines.get(label)
2151
+ for label in self._selected_labels()
2152
+ ] if has_selection else []
2153
+ self.y_axis.setEnabled(has_selection)
2154
+ self.remove_trace_button.setEnabled(
2155
+ bool(selected_lines)
2156
+ and all(
2157
+ line is not None and line is not self.owner.__dict__.get("line")
2158
+ for line in selected_lines
2159
+ )
2160
+ )
2161
+ def _apply_selection(self, *_args):
2162
+ if self._building:
2163
+ return
2164
+ if not self._resolve_custom_color_selection():
2165
+ self._update_control_enabled_states(bool(self._selected_labels()))
2166
+ return
2167
+ self._update_control_enabled_states(bool(self._selected_labels()))
2168
+ for label in self._selected_labels():
2169
+ style = self.owner._trace_styles.setdefault(
2170
+ label,
2171
+ self.owner._initial_trace_style(),
2172
+ )
2173
+ style.line_enabled = self.line_enable.isChecked(); style.line_color = self._combo_value(self.line_color); style.line_width = self.line_width.value(); style.line_style = self._combo_value(self.line_style)
2174
+ style.dots_enabled = self.dots_enable.isChecked(); style.dots_color = self._combo_value(self.dots_color); style.dots_size = self.dots_size.value()
2175
+ style.markers_enabled = self.marker_enable.isChecked(); style.markers_color = self._combo_value(self.marker_color); style.markers_symbol = self._combo_value(self.marker_symbol); style.markers_size = self.marker_size.value()
2176
+ style.x_axis = self.x_axis.currentText()
2177
+ style.y_axis = self.y_axis.currentText()
2178
+ style.visible = self.visible.isChecked()
2179
+ style.opacity = self.opacity.value() / 100
2180
+ line = self.owner.lines.get(label)
2181
+ if line is not None:
2182
+ self.owner._apply_trace_style(label, line)
2183
+ self.refresh_rows()