qplotter 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +67 -0
  4. qplot/_build_info.json +1 -0
  5. qplot/_metadata.py +75 -0
  6. qplot/_repair.py +14 -0
  7. qplot/_shutdown_supervisor.py +4049 -0
  8. qplot/_version.py +12 -0
  9. qplot/_windows_shutdown_job.py +1166 -0
  10. qplot/configuration/__init__.py +3 -0
  11. qplot/configuration/config.py +578 -0
  12. qplot/configuration/config_schema.json +357 -0
  13. qplot/configuration/fonts.py +3 -0
  14. qplot/configuration/scripts.py +255 -0
  15. qplot/configuration/themes/__init__.py +13 -0
  16. qplot/configuration/themes/_base.py +592 -0
  17. qplot/configuration/themes/blank.py +17 -0
  18. qplot/configuration/themes/dark.py +54 -0
  19. qplot/configuration/themes/light.py +54 -0
  20. qplot/datahandling/LoadFromDB.py +224 -0
  21. qplot/datahandling/__init__.py +47 -0
  22. qplot/datahandling/_trusted_live_helper.py +812 -0
  23. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  24. qplot/datahandling/database.py +2340 -0
  25. qplot/datahandling/dimensions.py +43 -0
  26. qplot/datahandling/file_identity.py +461 -0
  27. qplot/datahandling/parameter_data.py +149 -0
  28. qplot/datahandling/plot_progress.py +17 -0
  29. qplot/datahandling/qcodes_cache.py +277 -0
  30. qplot/datahandling/qcodes_compat.py +14 -0
  31. qplot/datahandling/readSQL.py +2442 -0
  32. qplot/datahandling/readonly.py +2566 -0
  33. qplot/datahandling/source_activity.py +21 -0
  34. qplot/datahandling/trusted_array_preview.py +148 -0
  35. qplot/datahandling/trusted_derived_cache.py +1605 -0
  36. qplot/datahandling/trusted_derived_rendering.py +876 -0
  37. qplot/datahandling/trusted_heatmap.py +246 -0
  38. qplot/datahandling/trusted_live.py +2808 -0
  39. qplot/datahandling/trusted_live_queries.py +5907 -0
  40. qplot/datahandling/trusted_live_service.py +1919 -0
  41. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  42. qplot/datahandling/trusted_plot.py +283 -0
  43. qplot/datahandling/trusted_presentation.py +1065 -0
  44. qplot/datahandling/trusted_snapshot.py +1132 -0
  45. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  46. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  47. qplot/diagnostics.py +241 -0
  48. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  49. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  50. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  51. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  52. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  53. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  54. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  55. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  56. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  57. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  58. qplot/testdata.py +1804 -0
  59. qplot/tools/__init__.py +13 -0
  60. qplot/tools/finite_means.py +123 -0
  61. qplot/tools/general.py +139 -0
  62. qplot/tools/heatmap_geometry.py +391 -0
  63. qplot/tools/operation_registry.py +179 -0
  64. qplot/tools/plot_tools.py +650 -0
  65. qplot/tools/sample_statistics.py +111 -0
  66. qplot/tools/worker.py +2838 -0
  67. qplot/windows/__init__.py +9 -0
  68. qplot/windows/_colorbar.py +422 -0
  69. qplot/windows/_commands.py +523 -0
  70. qplot/windows/_config_persistence.py +123 -0
  71. qplot/windows/_database_actions.py +4933 -0
  72. qplot/windows/_dataset_handle.py +221 -0
  73. qplot/windows/_display_precision.py +48 -0
  74. qplot/windows/_dragdrop.py +70 -0
  75. qplot/windows/_export_paths.py +1167 -0
  76. qplot/windows/_help.py +210 -0
  77. qplot/windows/_mixed_appearance.py +303 -0
  78. qplot/windows/_mixed_plots.py +461 -0
  79. qplot/windows/_native_averaging.py +323 -0
  80. qplot/windows/_native_transforms.py +551 -0
  81. qplot/windows/_plot1d_snap.py +612 -0
  82. qplot/windows/_plot1d_traces.py +2236 -0
  83. qplot/windows/_plot2d_colorbar.py +1127 -0
  84. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  85. qplot/windows/_plot2d_layers.py +2291 -0
  86. qplot/windows/_plot2d_sweeps.py +712 -0
  87. qplot/windows/_plotWin.py +2082 -0
  88. qplot/windows/_plot_actions.py +2703 -0
  89. qplot/windows/_plot_annotation.py +186 -0
  90. qplot/windows/_plot_appearance.py +283 -0
  91. qplot/windows/_plot_axis_scaling.py +1521 -0
  92. qplot/windows/_plot_data_viewbox.py +94 -0
  93. qplot/windows/_plot_drop_axes.py +224 -0
  94. qplot/windows/_plot_export.py +1445 -0
  95. qplot/windows/_plot_feedback.py +112 -0
  96. qplot/windows/_plot_marquee.py +659 -0
  97. qplot/windows/_plot_refresh.py +950 -0
  98. qplot/windows/_plot_source_promotion.py +178 -0
  99. qplot/windows/_plot_state.py +237 -0
  100. qplot/windows/_plot_status.py +115 -0
  101. qplot/windows/_plot_tiling.py +35 -0
  102. qplot/windows/_plot_transform_labels.py +60 -0
  103. qplot/windows/_preferences.py +724 -0
  104. qplot/windows/_refresh_interval.py +172 -0
  105. qplot/windows/_run_controls.py +789 -0
  106. qplot/windows/_shortcuts.py +47 -0
  107. qplot/windows/_subplots/__init__.py +8 -0
  108. qplot/windows/_subplots/subplot1d.py +532 -0
  109. qplot/windows/_subplots/subplot2d.py +919 -0
  110. qplot/windows/_svg_export.py +209 -0
  111. qplot/windows/_trusted_derived_qt.py +1759 -0
  112. qplot/windows/_widgets/__init__.py +23 -0
  113. qplot/windows/_widgets/_run_formatting.py +387 -0
  114. qplot/windows/_widgets/details_tables.py +1119 -0
  115. qplot/windows/_widgets/dropbox.py +290 -0
  116. qplot/windows/_widgets/operations.py +460 -0
  117. qplot/windows/_widgets/preview.py +2997 -0
  118. qplot/windows/_widgets/run_list_items.py +488 -0
  119. qplot/windows/_widgets/toolbar.py +111 -0
  120. qplot/windows/_widgets/treeWidgets.py +2572 -0
  121. qplot/windows/_window_controls.py +414 -0
  122. qplot/windows/main.py +1632 -0
  123. qplot/windows/plot1d.py +313 -0
  124. qplot/windows/plot2d.py +1935 -0
  125. qplotter-1.6.0.dist-info/METADATA +322 -0
  126. qplotter-1.6.0.dist-info/RECORD +130 -0
  127. qplotter-1.6.0.dist-info/WHEEL +5 -0
  128. qplotter-1.6.0.dist-info/entry_points.txt +4 -0
  129. qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
  130. qplotter-1.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,178 @@
1
+ """Promote the bottom eligible dataset when a plot's source is removed."""
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from PyQt6 import QtWidgets as qtw
8
+
9
+ from ._plot_drop_axes import DropAxisPlan
10
+ from ._refresh_interval import refresh_interval_value
11
+
12
+
13
+ @dataclass
14
+ class _DatasetAppearance:
15
+ kind: str
16
+ key: Any
17
+ source: Any
18
+ plan: DropAxisPlan
19
+ style: Any = None
20
+ opacity: float = 1.0
21
+ visible: bool = True
22
+
23
+
24
+ def _remaining_datasets(owner, selected):
25
+ datasets = []
26
+ for kind, key in owner._plot_item_order:
27
+ if (kind, key) in selected:
28
+ continue
29
+ if kind == "trace":
30
+ item = owner.lines[key]
31
+ style = deepcopy(owner._ensure_trace_styles()[key])
32
+ order = item.source_axis_order() or ("x", "y")
33
+ plan = DropAxisPlan(order, style.x_axis, style.y_axis, ())
34
+ datasets.append(_DatasetAppearance(kind, key, item.from_win, plan, style))
35
+ else:
36
+ item = owner.heatmaps[key]
37
+ sides = owner._heatmap_axis_sides(item)
38
+ plan = DropAxisPlan(item.source_axis_order() or ("x", "y"), sides[0], sides[1], ())
39
+ datasets.append(_DatasetAppearance(
40
+ kind, key, item.from_win, plan,
41
+ opacity=owner._heatmap_opacity(item), visible=owner._heatmap_is_visible(item),
42
+ ))
43
+ return datasets
44
+
45
+
46
+ def _copy_operations(source, target):
47
+ options = {target.list_options.item(index).operation_row.label: target.list_options.item(index)
48
+ for index in range(target.list_options.count())}
49
+ for index in range(source.list_order.count()):
50
+ row = source.list_order.item(index)
51
+ if row.isHidden():
52
+ continue
53
+ option = options[row.label]
54
+ option.input.setChecked(True)
55
+ old_input, new_input = row.input, option.operation_row.input
56
+ if isinstance(old_input, qtw.QLineEdit):
57
+ new_input.setText(old_input.text())
58
+ elif isinstance(old_input, qtw.QComboBox):
59
+ new_input.setCurrentText(old_input.currentText())
60
+ elif isinstance(old_input, qtw.QCheckBox):
61
+ new_input.setChecked(old_input.isChecked())
62
+ target._apply_generation = source._apply_generation
63
+
64
+
65
+ def _copy_view(owner, replacement):
66
+ for axis in ("x", "y", "x2", "y2"):
67
+ if not replacement._axis_scale_axis_is_used(axis):
68
+ continue
69
+ old = owner._axis_scale_viewbox(axis)
70
+ new = replacement._axis_scale_viewbox(axis)
71
+ dimension = 0 if axis in ("x", "x2") else 1
72
+ replacement._axis_scale_log_toggled(axis, owner._axis_scale_log_mode(axis))
73
+ new.setRange(**{"xRange" if dimension == 0 else "yRange": old.viewRange()[dimension]}, padding=0)
74
+ new.enableAutoRange(dimension, old.state["autoRange"][dimension])
75
+ dimension_name = "x" if dimension == 0 else "y"
76
+ new.setAutoVisible(**{dimension_name: old.state["autoVisibleOnly"][dimension]})
77
+ new.setAutoPan(**{dimension_name: old.state["autoPan"][dimension]})
78
+ new.setMouseEnabled(**{"x" if dimension == 0 else "y": old.state["mouseEnabled"][dimension]})
79
+ if dimension == 0:
80
+ new.invertX(old.state["xInverted"])
81
+ else:
82
+ new.invertY(old.state["yInverted"])
83
+ replacement._axis_scale_custom_auto_axes = set(owner._axis_scale_custom_auto_axes)
84
+
85
+
86
+ def _copy_window_layout(owner, replacement):
87
+ for window in (owner, replacement):
88
+ for widget_type in (qtw.QDockWidget, qtw.QToolBar):
89
+ for widget in window.findChildren(widget_type):
90
+ widget.setObjectName(f"{widget_type.__name__}:{widget.windowTitle()}")
91
+ replacement.restoreState(owner.saveState())
92
+ replacement.setGeometry(owner.geometry())
93
+
94
+
95
+ def promote_plot_source(main: Any, owner: Any, selected: list[tuple[str, Any]]) -> Any:
96
+ """Rebuild the source-specific controls and release the old plot owner.
97
+
98
+ Construct the replacement before closing the old owner, so every surviving
99
+ dataset stays retained and a construction failure leaves the plot intact.
100
+ Rebuilding also supports a change between trace and heatmap source types.
101
+ """
102
+ datasets = _remaining_datasets(owner, selected)
103
+ # Cuts have their own linked controls and cannot host a mixed plot. Keep
104
+ # their overlays in place while choosing the bottom eligible full source.
105
+ primary = next((dataset for dataset in reversed(datasets)
106
+ if dataset.source.operation_kind in {"plot1d", "plot2d"}), None)
107
+ if primary is None:
108
+ raise ValueError("Keep a full line or heatmap dataset in this plot; cuts cannot be its primary source.")
109
+ source = primary.source
110
+ dialog = owner.__dict__.get("_plot_appearance_dialog")
111
+ show_dialog = dialog is not None and dialog.isVisible()
112
+ dialog_geometry = dialog.saveGeometry() if show_dialog else None
113
+ previous_windows = set(main.windows)
114
+ next_position = main._plot_tile_grid, main._next_plot_tile
115
+ replacement = None
116
+ try:
117
+ main.openWin(type(source), source._dataset_key, source.param,
118
+ refrate=refresh_interval_value(owner.spinBox), show=True)
119
+ replacement = next(win for win in main.windows if win not in previous_windows)
120
+ _copy_window_layout(owner, replacement)
121
+ _copy_operations(source.oper_widget, replacement.oper_widget)
122
+ order = primary.plan.axis_order
123
+ for dimension, source_axis in zip(("x", "y"), order, strict=True):
124
+ combo = replacement.axis_dropdown[dimension]
125
+ blocked = combo.blockSignals(True)
126
+ combo.setCurrentText(source.axis_options[source_axis])
127
+ combo.blockSignals(blocked)
128
+ replacement._axis_selection = replacement.axis_options
129
+ remapped_order: list[tuple[str, Any]] = []
130
+ for dataset in reversed(datasets):
131
+ if dataset is primary:
132
+ key = replacement.label if dataset.kind == "trace" else replacement._primary_heatmap_key
133
+ item = replacement.line if dataset.kind == "trace" else replacement
134
+ else:
135
+ key = dataset.key
136
+ if dataset.kind == "trace":
137
+ replacement.add_preview_trace(dataset.source, dataset.plan)
138
+ item = replacement.lines[key]
139
+ else:
140
+ replacement.add_heatmap(dataset.source, axis_plan=dataset.plan)
141
+ item = replacement.heatmaps[key]
142
+ if dataset.kind == "trace":
143
+ replacement._ensure_trace_styles()[key] = dataset.style
144
+ replacement._apply_trace_style(key, item)
145
+ else:
146
+ replacement._set_layer_axes(item, dataset.plan.x_side, dataset.plan.y_side)
147
+ replacement._set_layer_opacity(item, dataset.opacity)
148
+ replacement._set_layer_visibility(item, dataset.visible)
149
+ remapped_order.insert(0, (dataset.kind, key))
150
+ replacement._plot_item_order = remapped_order
151
+ replacement._sync_plot_item_order()
152
+ for name in ("subtractMeanCheck", "phasemapCheck", "fftCheck", "derivativeCheck"):
153
+ getattr(replacement.plot.ctrl, name).setChecked(getattr(owner.plot.ctrl, name).isChecked())
154
+ if "_colorbar_colormap_name" in owner.__dict__:
155
+ replacement._colorbar_colormap_name = owner._colorbar_colormap_name
156
+ bar = replacement.__dict__.get("bar")
157
+ if bar is not None:
158
+ bar.setColorMap(replacement._colorbar_colormap())
159
+ replacement._colorbar_manual_levels = owner.__dict__.get("_colorbar_manual_levels")
160
+ replacement._sync_heatmap_colorbar_items(rescale=True)
161
+ _copy_view(owner, replacement)
162
+ replacement.refreshWindow(force=True)
163
+ except Exception:
164
+ for win in list(main.windows):
165
+ if win not in previous_windows:
166
+ win.close()
167
+ win.deleteLater()
168
+ raise
169
+ finally:
170
+ main._plot_tile_grid, main._next_plot_tile = next_position
171
+ owner.close()
172
+ if owner._merged_trace_users <= 0:
173
+ owner.deleteLater()
174
+ replacement.show()
175
+ if show_dialog:
176
+ replacement.open_plot_appearance_dialog()
177
+ replacement._plot_appearance_dialog.restoreGeometry(dialog_geometry)
178
+ return replacement
@@ -0,0 +1,237 @@
1
+ from time import perf_counter
2
+ from typing import Any
3
+
4
+ from PyQt6 import QtCore
5
+ from PyQt6 import QtWidgets as qtw
6
+
7
+ from qplot.datahandling.plot_progress import PlotProgress
8
+
9
+ from ._plot_status import PlotStatusWidget
10
+
11
+
12
+ class PlotStateOverlay(QtCore.QObject):
13
+ """
14
+ Keep empty/error explanations in the plot and loading progress in its toolbar.
15
+ """
16
+
17
+ _styles: dict[str, str] = {
18
+ "info": (
19
+ "background-color: rgba(250, 252, 255, 235);"
20
+ "border: 1px solid rgba(119, 135, 153, 190);"
21
+ "color: #1f2933;"
22
+ ),
23
+ "loading": (
24
+ "background-color: rgba(246, 251, 255, 238);"
25
+ "border: 1px solid rgba(70, 130, 180, 190);"
26
+ "color: #102a43;"
27
+ ),
28
+ "empty": (
29
+ "background-color: rgba(255, 251, 235, 238);"
30
+ "border: 1px solid rgba(180, 132, 35, 190);"
31
+ "color: #3b2f12;"
32
+ ),
33
+ "error": (
34
+ "background-color: rgba(255, 245, 245, 240);"
35
+ "border: 1px solid rgba(190, 72, 72, 200);"
36
+ "color: #3b0d0c;"
37
+ ),
38
+ }
39
+
40
+ def __init__(self, target: qtw.QWidget, feedback: PlotStatusWidget) -> None:
41
+ super().__init__(target)
42
+ self.feedback = feedback
43
+ self.owner: qtw.QWidget = target
44
+ target_obj: Any = target
45
+ viewport = target_obj.viewport() if hasattr(target_obj, "viewport") else None
46
+ self.target: qtw.QWidget = viewport or target
47
+
48
+ self.frame = qtw.QFrame(self.target)
49
+ self.frame.setObjectName("plotStateOverlay")
50
+ self.frame.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
51
+ self.frame.setAutoFillBackground(False)
52
+
53
+ layout = qtw.QVBoxLayout(self.frame)
54
+ layout.setContentsMargins(14, 10, 14, 10)
55
+ layout.setSpacing(4)
56
+
57
+ self.title_label = qtw.QLabel(self.frame)
58
+ self.title_label.setObjectName("plotStateOverlayTitle")
59
+ title_font = self.title_label.font()
60
+ title_font.setBold(True)
61
+ self.title_label.setFont(title_font)
62
+ self.title_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
63
+ layout.addWidget(self.title_label)
64
+
65
+ self.detail_label = qtw.QLabel(self.frame)
66
+ self.detail_label.setObjectName("plotStateOverlayDetail")
67
+ self.detail_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
68
+ self.detail_label.setWordWrap(True)
69
+ layout.addWidget(self.detail_label)
70
+
71
+ self.progress_bar = feedback.progress_bar
72
+ self.elapsed_label = feedback.elapsed_label
73
+ self._worker: Any = None
74
+ self._last_progress: PlotProgress | None = None
75
+ self._started_at: float | None = None
76
+ self._rendering = False
77
+ self.progress_timer = QtCore.QTimer(self)
78
+ self.progress_timer.setInterval(100)
79
+ self.progress_timer.timeout.connect(self._poll_progress)
80
+
81
+ self.target.installEventFilter(self)
82
+ if self.owner is not self.target:
83
+ self.owner.installEventFilter(self)
84
+ self.hide()
85
+
86
+ def show(
87
+ self,
88
+ title: object,
89
+ detail: object | None = None,
90
+ kind: str = "info",
91
+ ) -> None:
92
+ if kind != "loading":
93
+ self._stop_progress()
94
+ self.title_label.setText(str(title or ""))
95
+ self.detail_label.setText(str(detail or ""))
96
+ self.detail_label.setVisible(bool(detail))
97
+ if kind == "loading":
98
+ self.frame.hide()
99
+ return
100
+ self.feedback.show_message(" — ".join(str(value) for value in (title, detail) if value))
101
+ self.frame.setStyleSheet(self._stylesheet(kind))
102
+ self._sync_geometry()
103
+ self.frame.show()
104
+ self.frame.raise_()
105
+
106
+ def hide(self) -> None:
107
+ self._stop_progress()
108
+ self.frame.hide()
109
+
110
+ def track(self, worker: Any) -> None:
111
+ """Poll one latest value, never queue an event per reader chunk."""
112
+ self._worker = worker
113
+ self._last_progress = None
114
+ self._started_at = getattr(worker, "started_at", perf_counter())
115
+ self._rendering = False
116
+ self.feedback.start_progress()
117
+ self._poll_progress()
118
+ if self._worker is worker:
119
+ self.progress_timer.start()
120
+
121
+ def _stop_progress(self) -> None:
122
+ self.progress_timer.stop()
123
+ self._worker = None
124
+ self._last_progress = None
125
+ self._started_at = None
126
+ self._rendering = False
127
+ self.progress_bar.hide()
128
+ self.elapsed_label.hide()
129
+ self.feedback.finish_progress()
130
+
131
+ def _update_elapsed(self) -> None:
132
+ if self._started_at is None:
133
+ return
134
+ seconds = max(0, int(perf_counter() - self._started_at))
135
+ minutes, seconds = divmod(seconds, 60)
136
+ hours, minutes = divmod(minutes, 60)
137
+ duration = (f"{hours}h {minutes:02d}m {seconds:02d}s" if hours else
138
+ f"{minutes}m {seconds:02d}s" if minutes else f"{seconds}s")
139
+ text = duration
140
+ if self.elapsed_label.text() != text:
141
+ self.elapsed_label.setText(text)
142
+ self.elapsed_label.show()
143
+
144
+ def _poll_progress(self) -> None:
145
+ worker = self._worker
146
+ if worker is None:
147
+ return
148
+ if worker.is_cancelled():
149
+ self.show("Plot load cancelled")
150
+ return
151
+ self._update_elapsed()
152
+ self.feedback.show_progress()
153
+ if self._rendering:
154
+ return
155
+ progress = worker.progress
156
+ if progress == self._last_progress:
157
+ return
158
+ self._last_progress = progress
159
+ self.feedback.set_progress_details(progress, self.title_label.text())
160
+ self.detail_label.setText(progress.title)
161
+ self.detail_label.show()
162
+ if progress.total is None or progress.total <= 0:
163
+ self.progress_bar.setRange(0, 0)
164
+ self.progress_bar.setFormat("")
165
+ else:
166
+ self.progress_bar.setRange(0, 1000)
167
+ self.progress_bar.setValue(min(1000, max(0, progress.completed * 1000 // progress.total)))
168
+ self.progress_bar.setFormat("%p%")
169
+ self.progress_bar.show()
170
+
171
+ def rendering(self, worker: Any) -> None:
172
+ if worker is not self._worker:
173
+ return
174
+ self._rendering = True
175
+ self._update_elapsed()
176
+ progress = PlotProgress("Rendering plot", stage=2)
177
+ self.detail_label.setText(progress.title)
178
+ self.feedback.set_progress_details(progress, self.title_label.text())
179
+ self.feedback.show_progress()
180
+ self.progress_bar.setRange(0, 0)
181
+ self.progress_bar.setFormat("")
182
+
183
+ def finish(self, worker: Any) -> None:
184
+ if worker is self._worker:
185
+ # Only a loading state is removed. Empty/error explanations stay.
186
+ self.hide()
187
+
188
+ def eventFilter(
189
+ self,
190
+ source: QtCore.QObject | None,
191
+ event: QtCore.QEvent | None,
192
+ ) -> bool:
193
+ if (
194
+ event is not None
195
+ and (source is self.owner or source is self.target)
196
+ and event.type() in (QtCore.QEvent.Type.Resize, QtCore.QEvent.Type.Show)
197
+ ):
198
+ self._sync_geometry()
199
+ return super().eventFilter(source, event)
200
+
201
+ def _stylesheet(self, kind: str) -> str:
202
+ panel_style = self._styles.get(kind, self._styles["info"])
203
+ return (
204
+ "QFrame#plotStateOverlay {"
205
+ f"{panel_style}"
206
+ "border-radius: 6px;"
207
+ "}"
208
+ "QLabel#plotStateOverlayTitle {"
209
+ "background: transparent;"
210
+ "font-size: 10pt;"
211
+ "}"
212
+ "QLabel#plotStateOverlayDetail {"
213
+ "background: transparent;"
214
+ "font-size: 8pt;"
215
+ "}"
216
+ "QLabel#plotStateOverlayElapsed {"
217
+ "background: transparent;"
218
+ "font-size: 8pt;"
219
+ "}"
220
+ )
221
+
222
+ def _sync_geometry(self) -> None:
223
+ if not self.frame.isVisible() and not self.title_label.text():
224
+ return
225
+
226
+ target_rect = self.target.rect()
227
+ margin = 24
228
+ max_width = max(160, min(420, target_rect.width() - (2 * margin)))
229
+ self.frame.setMaximumWidth(max_width)
230
+ self.frame.adjustSize()
231
+
232
+ size = self.frame.sizeHint()
233
+ width = min(max_width, max(160, size.width()))
234
+ height = size.height()
235
+ x = target_rect.left() + max(margin, (target_rect.width() - width) // 2)
236
+ y = target_rect.top() + max(margin, (target_rect.height() - height) // 2)
237
+ self.frame.setGeometry(x, y, width, height)
@@ -0,0 +1,115 @@
1
+ """Compact, persistent feedback for the refresh toolbar."""
2
+
3
+ from datetime import datetime
4
+
5
+ from PyQt6 import QtCore, QtGui
6
+ from PyQt6 import QtWidgets as qtw
7
+
8
+
9
+ class _StatusLabel(qtw.QLabel):
10
+ def minimumSizeHint(self):
11
+ return QtCore.QSize(0, super().minimumSizeHint().height())
12
+
13
+ def paintEvent(self, event):
14
+ painter = QtGui.QPainter(self)
15
+ self.style().drawItemText(
16
+ painter, self.contentsRect(),
17
+ QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter,
18
+ self.palette(), self.isEnabled(),
19
+ self.fontMetrics().elidedText(
20
+ self.text(), QtCore.Qt.TextElideMode.ElideRight, self.contentsRect().width(),
21
+ ),
22
+ QtGui.QPalette.ColorRole.WindowText,
23
+ )
24
+
25
+
26
+ class PlotStatusWidget(qtw.QWidget):
27
+ """Use the available toolbar space for a message or current-stage progress."""
28
+
29
+ def __init__(self, parent=None):
30
+ super().__init__(parent)
31
+ self.setObjectName("plotToolbarStatus")
32
+ self.setSizePolicy(qtw.QSizePolicy.Policy.Expanding, qtw.QSizePolicy.Policy.Preferred)
33
+ self.message = ""
34
+ self._progress_active = False
35
+
36
+ self.status_label = _StatusLabel(self)
37
+ self.status_label.setObjectName("plotStatusMessage")
38
+ self.status_label.setTextFormat(QtCore.Qt.TextFormat.PlainText)
39
+ self.status_label.setAccessibleName("Plot status")
40
+ self.status_label.setSizePolicy(qtw.QSizePolicy.Policy.Ignored, qtw.QSizePolicy.Policy.Preferred)
41
+
42
+ self.progress_panel = qtw.QWidget(self)
43
+ row = qtw.QHBoxLayout(self.progress_panel)
44
+ row.setContentsMargins(0, 0, 0, 0)
45
+ row.setSpacing(8)
46
+ self.progress_bar = qtw.QProgressBar(self.progress_panel)
47
+ self.progress_bar.setAccessibleName("Plot loading progress for the current stage")
48
+ self.progress_bar.setMinimumWidth(40)
49
+ self.progress_bar.setMaximumWidth(240)
50
+ self.progress_bar.setFormat("%p%")
51
+ self.elapsed_label = qtw.QLabel(self.progress_panel)
52
+ self.elapsed_label.setAccessibleName("Elapsed plot loading time")
53
+ self.stage_label = qtw.QLabel(self.progress_panel)
54
+ row.addStretch()
55
+ row.addWidget(self.progress_bar, 1)
56
+ row.addWidget(self.elapsed_label)
57
+ row.addWidget(self.stage_label)
58
+
59
+ self.stack = qtw.QStackedLayout()
60
+ # QStackedLayout fills its geometry without applying content margins.
61
+ # An outer layout reserves real space beside both status views.
62
+ layout = qtw.QHBoxLayout(self)
63
+ layout.setContentsMargins(12, 0, 16, 0)
64
+ layout.addLayout(self.stack)
65
+ # Long status text must not enlarge the toolbar or hide its controls.
66
+ self.stack.setSizeConstraint(qtw.QLayout.SizeConstraint.SetNoConstraint)
67
+ layout.setSizeConstraint(qtw.QLayout.SizeConstraint.SetNoConstraint)
68
+ self.stack.addWidget(self.status_label)
69
+ self.stack.addWidget(self.progress_panel)
70
+
71
+ def minimumSizeHint(self):
72
+ return QtCore.QSize(0, self.stack.minimumSize().height())
73
+
74
+ def sizeHint(self):
75
+ return QtCore.QSize(320, self.stack.sizeHint().height())
76
+
77
+ def show_message(self, message: str) -> None:
78
+ self.message = message
79
+ self.status_label.setText(message)
80
+ if message:
81
+ created_at = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
82
+ self.status_label.setToolTip(f"{message}\nCreated: {created_at}")
83
+ else:
84
+ self.status_label.setToolTip("")
85
+ self.stack.setCurrentWidget(self.status_label)
86
+
87
+ def start_progress(self) -> None:
88
+ # Never restore an earlier operation's message when this load ends.
89
+ self.message = ""
90
+ self._progress_active = True
91
+ self.show_progress()
92
+
93
+ def show_progress(self) -> None:
94
+ self.stack.setCurrentWidget(self.progress_panel)
95
+
96
+ def finish_progress(self) -> None:
97
+ if self._progress_active:
98
+ self._progress_active = False
99
+ if self.message:
100
+ # Restore the message with its original creation timestamp.
101
+ self.stack.setCurrentWidget(self.status_label)
102
+ else:
103
+ self.show_message("Plot load complete.")
104
+
105
+ def set_progress_details(self, progress, context: str) -> None:
106
+ self.stage_label.setText(f"Stage {progress.stage}/2")
107
+ details = f"{context}\n{progress.title}\n"
108
+ if progress.total is not None and progress.total > 0:
109
+ details += f"{progress.completed:,} / {progress.total:,} ({progress.unit})\n"
110
+ details += (
111
+ "Progress is for the current stage. Scan progress covers the captured "
112
+ "source range, including partial array records."
113
+ )
114
+ self.progress_panel.setToolTip(details)
115
+ self.progress_bar.setAccessibleDescription(details)
@@ -0,0 +1,35 @@
1
+ """Shared preferences and geometry for newly opened plot windows."""
2
+
3
+ PLOT_TILE_ROWS_KEY = "user_preference.plot_tile_rows"
4
+ PLOT_TILE_COLUMNS_KEY = "user_preference.plot_tile_columns"
5
+
6
+
7
+ def plot_tile_grid(config):
8
+ """Return the configured (rows, columns) grid."""
9
+ return config.get(PLOT_TILE_ROWS_KEY), config.get(PLOT_TILE_COLUMNS_KEY)
10
+
11
+
12
+ def plot_tile_size(config, screenrect):
13
+ """Size plots to their grid cells, retaining the configured frame margin."""
14
+ rows, columns = plot_tile_grid(config)
15
+ fraction = min(1.0, 2 * config.get("GUI.plot_frame_fraction"))
16
+ return (
17
+ max(1, int(fraction * screenrect.width() / columns)),
18
+ max(1, int(fraction * screenrect.height() / rows)),
19
+ )
20
+
21
+
22
+ def place_plot_window(owner, window):
23
+ """Fill grid cells in row order and wrap; restart when the grid changes."""
24
+ grid = plot_tile_grid(owner.config)
25
+ if getattr(owner, "_plot_tile_grid", None) != grid:
26
+ owner._plot_tile_grid = grid
27
+ owner._next_plot_tile = 0
28
+ rows, columns = grid
29
+ row, column = divmod(owner._next_plot_tile, columns)
30
+ rect = owner.screenrect
31
+ window.move(
32
+ rect.left() + column * rect.width() // columns,
33
+ rect.top() + row * rect.height() // rows,
34
+ )
35
+ owner._next_plot_tile = (owner._next_plot_tile + 1) % (rows * columns)
@@ -0,0 +1,60 @@
1
+ """Display quantities for PyQtGraph's native PlotDataItem mappings.
2
+
3
+ Input parameters describe samples after qPlot operations. These immutable
4
+ descriptions never replace measurement or operation metadata.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class AxisQuantity:
13
+ label: str
14
+ unit: str
15
+
16
+ @classmethod
17
+ def from_parameter(cls, parameter: Any) -> "AxisQuantity":
18
+ return cls(
19
+ str(getattr(parameter, "label", None) or getattr(parameter, "name", "")),
20
+ str(getattr(parameter, "unit", "") or ""),
21
+ )
22
+
23
+
24
+ def _unit_operand(unit: str) -> str:
25
+ return f"({unit})" if any(char in unit for char in "/ *") else unit
26
+
27
+
28
+ def _quotient_unit(numerator: str, denominator: str) -> str:
29
+ if not denominator:
30
+ return numerator
31
+ return f"{_unit_operand(numerator) if numerator else '1'}/{_unit_operand(denominator)}"
32
+
33
+
34
+ def native_axis_quantities(
35
+ x_parameter: Any, y_parameter: Any, options: dict[str, Any],
36
+ ) -> dict[str, AxisQuantity]:
37
+ """Mirror native mapping order, including mappings that reset to input.
38
+
39
+ PyQtGraph 0.14 subtracts the mean, then computes abs(rfft(y)/N).
40
+ Derivative mode replaces Y with diff(input Y)/diff(input X), retaining
41
+ the current X. Phase-map mode replaces both coordinates with input Y
42
+ and its derivative. Log ticking still describes these physical quantities.
43
+ """
44
+ input_x = AxisQuantity.from_parameter(x_parameter)
45
+ input_y = AxisQuantity.from_parameter(y_parameter)
46
+ x, y = input_x, input_y
47
+ if options.get("subtractMeanMode"):
48
+ y = AxisQuantity(f"{y.label} - mean({y.label})", y.unit)
49
+ if options.get("fftMode"):
50
+ x = AxisQuantity(f"Frequency of {input_x.label}", _quotient_unit("", input_x.unit))
51
+ fft_input = f"({y.label})" if options.get("subtractMeanMode") else y.label
52
+ y = AxisQuantity(f"FFT magnitude of {fft_input}", y.unit)
53
+ if options.get("derivativeMode") or options.get("phasemapMode"):
54
+ y = AxisQuantity(
55
+ f"d({input_y.label})/d({input_x.label})",
56
+ _quotient_unit(input_y.unit, input_x.unit),
57
+ )
58
+ if options.get("phasemapMode"):
59
+ x = input_y
60
+ return {"x": x, "y": y}