qplotter 1.6.0__py3-none-any.whl

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