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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- 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()
|