qplotter 1.6.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,656 @@
1
+ from collections.abc import Callable
2
+ from typing import TYPE_CHECKING, Any, Literal, TypedDict
3
+
4
+ import pyqtgraph as pg
5
+ from PyQt6 import QtCore, QtGui
6
+ from PyQt6 import QtWidgets as qtw
7
+
8
+ from qplot.tools.sample_statistics import finite_mean, finite_standard_deviation
9
+
10
+ from ._widgets import CopyableTableWidget
11
+
12
+ _MarqueeHandle = Literal["nw", "n", "ne", "e", "se", "s", "sw", "w"]
13
+ _MarqueeDragMode = Literal["new", "nw", "n", "ne", "e", "se", "s", "sw", "w"]
14
+ _MarqueeAction = Callable[[], bool]
15
+
16
+
17
+ class _MarqueeDragState(TypedDict):
18
+ anchor: QtCore.QPointF
19
+ handle_offset: QtCore.QPointF
20
+ mode: _MarqueeDragMode
21
+ rect: QtCore.QRectF | None
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ class _PlotMarqueeBase(qtw.QMainWindow):
26
+ _marquee_drag_state: _MarqueeDragState | None
27
+ _marquee_stats_dialog: qtw.QDialog
28
+ marquee: QtCore.QRectF | None
29
+ marquee_handles: Any
30
+ marquee_highlight: qtw.QGraphicsRectItem
31
+ marquee_outline: qtw.QGraphicsRectItem
32
+ plot: Any
33
+ vb: Any
34
+
35
+ def formatNum(self, value: float) -> str: ...
36
+ def _view_range_changed_programmatically(self) -> None: ...
37
+ else:
38
+ class _PlotMarqueeBase:
39
+ pass
40
+
41
+
42
+ class PlotMarqueeMixin(_PlotMarqueeBase):
43
+ """
44
+ Shared marquee selection behavior for plot windows.
45
+
46
+ Plot type subclasses can customise selection snapping, statistics, and
47
+ context-menu entries by overriding the small hook methods near the end of
48
+ this mixin.
49
+ """
50
+
51
+ def _init_marquee(self) -> None:
52
+ """
53
+ Create the reusable marquee graphics shown after Alt-dragging.
54
+
55
+ """
56
+ self.marquee = None
57
+ self._marquee_drag_state = None
58
+
59
+ self.marquee_highlight = qtw.QGraphicsRectItem()
60
+ highlight_pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 135))
61
+ highlight_pen.setWidthF(3)
62
+ highlight_pen.setCosmetic(True)
63
+ self.marquee_highlight.setPen(highlight_pen)
64
+ self.marquee_highlight.setBrush(QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush))
65
+ self.marquee_highlight.setZValue(18)
66
+ self.marquee_highlight.hide()
67
+ self.marquee_highlight.setAcceptedMouseButtons(QtCore.Qt.MouseButton.NoButton)
68
+ self._add_plot_overlay(self.marquee_highlight)
69
+
70
+ self.marquee_outline = qtw.QGraphicsRectItem()
71
+ pen = QtGui.QPen(QtGui.QColor(65, 65, 65, 220))
72
+ pen.setWidthF(1.2)
73
+ pen.setCosmetic(True)
74
+ pen.setStyle(QtCore.Qt.PenStyle.DashLine)
75
+ self.marquee_outline.setPen(pen)
76
+ self.marquee_outline.setBrush(QtGui.QBrush(QtGui.QColor(40, 40, 40, 24)))
77
+ self.marquee_outline.setZValue(19)
78
+ self.marquee_outline.hide()
79
+ self.marquee_outline.setAcceptedMouseButtons(QtCore.Qt.MouseButton.NoButton)
80
+ self._add_plot_overlay(self.marquee_outline)
81
+
82
+ self.marquee_handles = pg.ScatterPlotItem(
83
+ symbol="s",
84
+ size=6,
85
+ pen=pg.mkPen((20, 20, 20, 230), width=1, cosmetic=True),
86
+ brush=pg.mkBrush(245, 245, 245, 230),
87
+ )
88
+ self.marquee_handles.setZValue(20)
89
+ self.marquee_handles.hide()
90
+ self.marquee_handles.setAcceptedMouseButtons(QtCore.Qt.MouseButton.NoButton)
91
+ self._add_plot_overlay(self.marquee_handles)
92
+
93
+ def _add_plot_overlay(self, item: Any, viewbox: Any = None) -> None:
94
+ """Attach decorations without registering plot data or affecting bounds."""
95
+
96
+ viewbox = viewbox if viewbox is not None else self._marquee_viewbox()
97
+ item._qplot_ui_overlay = True
98
+ viewbox.addItem(item, ignoreBounds=True)
99
+ self.__dict__.setdefault("_plot_overlay_viewboxes", {})[id(item)] = viewbox
100
+
101
+ def _marquee_viewbox(self) -> Any:
102
+ """Return the coordinate owner for marquee graphics and navigation."""
103
+
104
+ try:
105
+ heatmap_viewbox = getattr(self, "_primary_heatmap_viewbox", None)
106
+ except RuntimeError:
107
+ heatmap_viewbox = None
108
+ if callable(heatmap_viewbox):
109
+ viewbox = heatmap_viewbox()
110
+ if viewbox is not None:
111
+ return viewbox
112
+ return self.__dict__.get("vb") or self.plot.vb
113
+
114
+ def _marquee_zoom_viewbox(self, axis: Literal["x", "y"]) -> Any:
115
+ """Return the range owner for one dimension of the selection."""
116
+
117
+ return self._marquee_viewbox()
118
+
119
+ def _marquee_axis_assignment_changed(self) -> None:
120
+ """Discard old coordinates and move decorations to their new owner."""
121
+
122
+ self.finish_marquee_drag()
123
+ if "marquee_outline" not in self.__dict__:
124
+ return
125
+ self.clear_marquee()
126
+ target = self._marquee_viewbox()
127
+ overlays = self.__dict__.get("_plot_overlay_viewboxes", {})
128
+ for item in (self.marquee_highlight, self.marquee_outline, self.marquee_handles):
129
+ current = overlays.get(id(item))
130
+ if current is not target:
131
+ if current is not None:
132
+ current.removeItem(item)
133
+ self._add_plot_overlay(item, target)
134
+
135
+ def is_marquee_dragging(self) -> bool:
136
+ return self._marquee_drag_state is not None
137
+
138
+ def current_marquee_drag_mode(self) -> _MarqueeDragMode | None:
139
+ if self._marquee_drag_state is None:
140
+ return None
141
+
142
+ return self._marquee_drag_state["mode"]
143
+
144
+ def begin_marquee_drag(
145
+ self,
146
+ start: QtCore.QPointF,
147
+ mode: _MarqueeDragMode | None = None,
148
+ ) -> None:
149
+ """
150
+ Start creating or resizing a marquee in plot coordinates.
151
+
152
+ """
153
+ start = QtCore.QPointF(start)
154
+ if mode is None:
155
+ mode = "new"
156
+
157
+ handle_offset = QtCore.QPointF()
158
+ if mode != "new" and self.marquee is not None:
159
+ handle_point = self._marquee_handle_points_for_rect(self.marquee)[mode]
160
+ handle_offset = start - handle_point
161
+
162
+ self._marquee_drag_state = {
163
+ "anchor": QtCore.QPointF(start),
164
+ "handle_offset": QtCore.QPointF(handle_offset),
165
+ "mode": mode,
166
+ "rect": QtCore.QRectF(self.marquee) if self.marquee is not None else None,
167
+ }
168
+
169
+ if mode == "new":
170
+ self.set_marquee_rect(QtCore.QRectF(start, start))
171
+
172
+ def drag_marquee_to(
173
+ self,
174
+ point: QtCore.QPointF,
175
+ modifiers: QtCore.Qt.KeyboardModifier = (
176
+ QtCore.Qt.KeyboardModifier.NoModifier
177
+ ),
178
+ ) -> None:
179
+ """
180
+ Update the marquee during an active drag.
181
+
182
+ """
183
+ point = QtCore.QPointF(point)
184
+ state = self._marquee_drag_state
185
+ if state is None:
186
+ return
187
+
188
+ if state["mode"] == "new" or state["rect"] is None:
189
+ rect = QtCore.QRectF(state["anchor"], point)
190
+ else:
191
+ rect = QtCore.QRectF(state["rect"])
192
+ offset = state.get("handle_offset", QtCore.QPointF())
193
+ point = QtCore.QPointF(
194
+ point.x() - offset.x(),
195
+ point.y() - offset.y(),
196
+ )
197
+ self._resize_marquee_rect(rect, state["mode"], point, modifiers)
198
+
199
+ self.set_marquee_rect(rect)
200
+
201
+ def finish_marquee_drag(self) -> None:
202
+ self._marquee_drag_state = None
203
+
204
+ def marquee_contains_scene_pos(self, scene_pos: QtCore.QPointF) -> bool:
205
+ """
206
+ Return whether a scene position is inside the current marquee.
207
+
208
+ """
209
+ if self.marquee is None:
210
+ return False
211
+
212
+ point = self._marquee_viewbox().mapSceneToView(scene_pos)
213
+ return self.marquee.normalized().contains(point)
214
+
215
+ def open_marquee_context_menu(
216
+ self,
217
+ scene_pos: QtCore.QPointF,
218
+ global_pos: QtCore.QPoint | QtCore.QPointF | None = None,
219
+ ) -> bool:
220
+ """
221
+ Open the marquee context menu when a right-click lands inside it.
222
+
223
+ """
224
+ if not self.marquee_contains_scene_pos(scene_pos):
225
+ return False
226
+
227
+ menu = self._new_marquee_context_menu()
228
+ if global_pos is None:
229
+ global_pos = QtGui.QCursor.pos()
230
+ elif isinstance(global_pos, QtCore.QPointF):
231
+ global_pos = global_pos.toPoint()
232
+
233
+ menu.exec(global_pos)
234
+ return True
235
+
236
+ def _new_marquee_context_menu(self) -> qtw.QMenu:
237
+ menu = qtw.QMenu()
238
+ if hasattr(menu, "setToolTipsVisible"):
239
+ menu.setToolTipsVisible(True)
240
+
241
+ self._add_marquee_context_action(
242
+ menu,
243
+ "Zoom",
244
+ lambda: self.zoom_marquee("xy"),
245
+ )
246
+ self._add_marquee_context_action(
247
+ menu,
248
+ "Zoom X",
249
+ lambda: self.zoom_marquee("x"),
250
+ )
251
+ self._add_marquee_context_action(
252
+ menu,
253
+ "Zoom Y",
254
+ lambda: self.zoom_marquee("y"),
255
+ )
256
+ self._add_marquee_color_context_action(menu)
257
+ self._add_marquee_stats_context_action(menu)
258
+ return menu
259
+
260
+ def _add_marquee_context_action(
261
+ self,
262
+ menu: qtw.QMenu,
263
+ text: str,
264
+ callback: _MarqueeAction,
265
+ ) -> QtGui.QAction:
266
+ action = menu.addAction(text)
267
+ if action is None:
268
+ raise RuntimeError("Marquee context action could not be created.")
269
+ action.triggered.connect(
270
+ lambda _checked=False, callback=callback: self._execute_marquee_action(
271
+ callback
272
+ )
273
+ )
274
+ return action
275
+
276
+ def _add_marquee_color_context_action(
277
+ self,
278
+ menu: qtw.QMenu,
279
+ ) -> QtGui.QAction | None:
280
+ return None
281
+
282
+ def _add_marquee_stats_context_action(self, menu: qtw.QMenu) -> QtGui.QAction:
283
+ stats_text = self._marquee_stats_text()
284
+
285
+ def open_stats_dialog(stats_text: str | None = stats_text) -> bool:
286
+ return self.show_marquee_stats_dialog(stats_text)
287
+
288
+ action = self._add_marquee_context_action(
289
+ menu,
290
+ "Stats...",
291
+ open_stats_dialog,
292
+ )
293
+ if stats_text is None:
294
+ action.setEnabled(False)
295
+ action.setToolTip("No data points inside the marquee.")
296
+ else:
297
+ action.setToolTip("Show statistics for the marquee selection.")
298
+ action.setStatusTip("Show statistics for the marquee selection.")
299
+ return action
300
+
301
+ def _execute_marquee_action(self, callback: _MarqueeAction) -> None:
302
+ if callback():
303
+ self.clear_marquee()
304
+
305
+ def zoom_marquee(self, axes: str) -> bool:
306
+ rect = self.marquee.normalized() if self.marquee is not None else None
307
+ if rect is None:
308
+ return False
309
+
310
+ if "x" in axes:
311
+ self._marquee_zoom_viewbox("x").setXRange(
312
+ rect.left(),
313
+ rect.right(),
314
+ padding=0,
315
+ )
316
+ if "y" in axes:
317
+ self._marquee_zoom_viewbox("y").setYRange(
318
+ rect.top(),
319
+ rect.bottom(),
320
+ padding=0,
321
+ )
322
+ self._view_range_changed_programmatically()
323
+ return True
324
+
325
+ def _marquee_stats_text(self) -> str | None:
326
+ return None
327
+
328
+ def show_marquee_stats_dialog(self, stats_text: str | None = None) -> bool:
329
+ if stats_text is None:
330
+ stats_text = self._marquee_stats_text()
331
+ if stats_text is None:
332
+ return False
333
+
334
+ dialog = self._new_marquee_stats_dialog(stats_text)
335
+ self._marquee_stats_dialog = dialog
336
+ dialog.show()
337
+ dialog.raise_()
338
+ dialog.activateWindow()
339
+ return True
340
+
341
+ def _new_marquee_stats_dialog(self, stats_text: str) -> qtw.QDialog:
342
+ dialog = qtw.QDialog(self)
343
+ dialog.setWindowTitle("Marquee stats")
344
+
345
+ layout = qtw.QVBoxLayout(dialog)
346
+ stats_table = self._new_marquee_stats_table(stats_text)
347
+ layout.addWidget(stats_table)
348
+
349
+ buttons = qtw.QDialogButtonBox(qtw.QDialogButtonBox.StandardButton.Close)
350
+ copy_button = buttons.addButton("Copy", qtw.QDialogButtonBox.ButtonRole.ActionRole)
351
+ if copy_button is None:
352
+ raise RuntimeError("Marquee stats copy button is not available.")
353
+
354
+ def copy_stats(_checked: bool = False) -> None:
355
+ self.copy_marquee_stats_to_clipboard(stats_text)
356
+
357
+ copy_button.clicked.connect(copy_stats)
358
+ buttons.rejected.connect(dialog.close)
359
+ layout.addWidget(buttons)
360
+
361
+ return dialog
362
+
363
+ def _new_marquee_stats_table(self, stats_text: str) -> CopyableTableWidget:
364
+ rows = self._marquee_stats_table_rows(stats_text)
365
+ stats_table = CopyableTableWidget()
366
+ stats_table.setObjectName("detailsTable")
367
+ stats_table.setColumnCount(2)
368
+ stats_table.setHorizontalHeaderLabels(["Field", "Value"])
369
+ stats_table.setRowCount(len(rows))
370
+ stats_table.setAlternatingRowColors(True)
371
+ stats_table.setEditTriggers(qtw.QAbstractItemView.EditTrigger.NoEditTriggers)
372
+ stats_table.setSelectionBehavior(qtw.QAbstractItemView.SelectionBehavior.SelectRows)
373
+ stats_table.setSelectionMode(qtw.QAbstractItemView.SelectionMode.ExtendedSelection)
374
+ stats_table.setTextElideMode(QtCore.Qt.TextElideMode.ElideNone)
375
+ stats_table.setWordWrap(False)
376
+ vertical_header = stats_table.verticalHeader()
377
+ horizontal_header = stats_table.horizontalHeader()
378
+ if vertical_header is None or horizontal_header is None:
379
+ raise RuntimeError("Marquee stats table headers are not available.")
380
+
381
+ vertical_header.hide()
382
+ vertical_header.setMinimumSectionSize(16)
383
+ vertical_header.setDefaultSectionSize(20)
384
+ horizontal_header.setFixedHeight(22)
385
+ horizontal_header.setStretchLastSection(True)
386
+ horizontal_header.setSectionResizeMode(
387
+ 0,
388
+ qtw.QHeaderView.ResizeMode.ResizeToContents
389
+ )
390
+ horizontal_header.setSectionResizeMode(
391
+ 1,
392
+ qtw.QHeaderView.ResizeMode.Stretch
393
+ )
394
+ stats_table.setMinimumWidth(280)
395
+ stats_table.setMinimumHeight(170)
396
+
397
+ for row, (field, value) in enumerate(rows):
398
+ field_item = qtw.QTableWidgetItem(field)
399
+ value_item = qtw.QTableWidgetItem(value)
400
+ stats_table.setItem(row, 0, field_item)
401
+ stats_table.setItem(row, 1, value_item)
402
+
403
+ return stats_table
404
+
405
+ def _marquee_stats_table_rows(self, stats_text: str) -> list[tuple[str, str]]:
406
+ rows: list[tuple[str, str]] = []
407
+ for line in stats_text.splitlines():
408
+ line = line.strip()
409
+ if not line:
410
+ continue
411
+
412
+ if ":" in line:
413
+ field, value = line.split(":", 1)
414
+ rows.append((field.strip(), value.strip()))
415
+ else:
416
+ rows.append(("Selection", line))
417
+
418
+ return rows
419
+
420
+ def _format_marquee_stats_text(
421
+ self,
422
+ size_text: str,
423
+ values: Any,
424
+ rect: QtCore.QRectF | None = None,
425
+ *,
426
+ x_axis: str = "x",
427
+ y_axis: str = "y",
428
+ ) -> str:
429
+ marquee = self.marquee
430
+ if rect is None and marquee is not None:
431
+ rect = marquee.normalized()
432
+
433
+ lines = [size_text]
434
+ if rect is not None:
435
+ view_to_data = getattr(self, "view_to_data", None)
436
+ if callable(view_to_data):
437
+ x_low = view_to_data(x_axis, rect.left())
438
+ x_high = view_to_data(x_axis, rect.right())
439
+ y_low = view_to_data(y_axis, rect.top())
440
+ y_high = view_to_data(y_axis, rect.bottom())
441
+ else:
442
+ x_low, x_high = rect.left(), rect.right()
443
+ y_low, y_high = rect.top(), rect.bottom()
444
+ lines.extend((
445
+ f"X range: {self.formatNum(x_low)} to {self.formatNum(x_high)}",
446
+ f"Y range: {self.formatNum(y_low)} to {self.formatNum(y_high)}",
447
+ ))
448
+
449
+ lines.extend((
450
+ f"Average: {self.formatNum(finite_mean(values))}",
451
+ f"Standard deviation: {self.formatNum(finite_standard_deviation(values))}",
452
+ f"Max: {self.formatNum(float(values.max()))}",
453
+ f"Min: {self.formatNum(float(values.min()))}",
454
+ ))
455
+
456
+ return "\n".join(lines)
457
+
458
+ def copy_marquee_stats_to_clipboard(self, stats_text: str | None = None) -> bool:
459
+ if stats_text is None:
460
+ stats_text = self._marquee_stats_text()
461
+ if stats_text is None:
462
+ return False
463
+
464
+ clipboard = qtw.QApplication.clipboard()
465
+ if clipboard is None:
466
+ return False
467
+
468
+ clipboard.setText(stats_text)
469
+ return True
470
+
471
+ def _resize_marquee_rect(
472
+ self,
473
+ rect: QtCore.QRectF,
474
+ handle: _MarqueeHandle,
475
+ point: QtCore.QPointF,
476
+ modifiers: QtCore.Qt.KeyboardModifier = (
477
+ QtCore.Qt.KeyboardModifier.NoModifier
478
+ ),
479
+ ) -> None:
480
+ symmetric = bool(modifiers & QtCore.Qt.KeyboardModifier.AltModifier)
481
+ asymmetric = bool(modifiers & QtCore.Qt.KeyboardModifier.ShiftModifier) and not symmetric
482
+ original = QtCore.QRectF(rect)
483
+ anchor = self._marquee_handle_points_for_rect(original)[handle]
484
+
485
+ if "w" in handle:
486
+ rect.setLeft(point.x())
487
+ if "e" in handle:
488
+ rect.setRight(point.x())
489
+ if "s" in handle:
490
+ rect.setTop(point.y())
491
+ if "n" in handle:
492
+ rect.setBottom(point.y())
493
+
494
+ dx = point.x() - anchor.x()
495
+ dy = point.y() - anchor.y()
496
+
497
+ if ("w" in handle or "e" in handle) and (symmetric or asymmetric):
498
+ offset = -dx if symmetric else dx
499
+ if "w" in handle:
500
+ rect.setRight(original.right() + offset)
501
+ else:
502
+ rect.setLeft(original.left() + offset)
503
+
504
+ if ("n" in handle or "s" in handle) and (symmetric or asymmetric):
505
+ offset = -dy if symmetric else dy
506
+ if "s" in handle:
507
+ rect.setBottom(original.bottom() + offset)
508
+ else:
509
+ rect.setTop(original.top() + offset)
510
+
511
+ if asymmetric:
512
+ self._snap_translated_marquee_rect(rect, original, handle)
513
+
514
+ def _snap_translated_marquee_rect(
515
+ self,
516
+ rect: QtCore.QRectF,
517
+ original: QtCore.QRectF,
518
+ handle: _MarqueeHandle,
519
+ ) -> None:
520
+ snapped = self._snap_marquee_rect(QtCore.QRectF(rect).normalized())
521
+ if snapped is None:
522
+ return
523
+
524
+ adjusted = QtCore.QRectF(snapped)
525
+ if "w" in handle or "e" in handle:
526
+ width = original.width()
527
+ if "w" in handle:
528
+ adjusted.setLeft(snapped.left())
529
+ adjusted.setRight(snapped.left() + width)
530
+ else:
531
+ adjusted.setRight(snapped.right())
532
+ adjusted.setLeft(snapped.right() - width)
533
+
534
+ if "n" in handle or "s" in handle:
535
+ height = original.height()
536
+ if "s" in handle:
537
+ adjusted.setTop(snapped.top())
538
+ adjusted.setBottom(snapped.top() + height)
539
+ else:
540
+ adjusted.setBottom(snapped.bottom())
541
+ adjusted.setTop(snapped.bottom() - height)
542
+
543
+ rect.setRect(adjusted.left(), adjusted.top(), adjusted.width(), adjusted.height())
544
+
545
+ def set_marquee_rect(self, rect: QtCore.QRectF) -> None:
546
+ """
547
+ Snap, store, and draw the marquee rectangle.
548
+
549
+ """
550
+ snapped = self._snap_marquee_rect(rect.normalized())
551
+ if snapped is None or snapped.width() <= 0 or snapped.height() <= 0:
552
+ self.clear_marquee()
553
+ return
554
+
555
+ rect = snapped
556
+ self.marquee = QtCore.QRectF(rect)
557
+ self.marquee_highlight.setRect(rect)
558
+ self.marquee_outline.setRect(rect)
559
+ self.marquee_highlight.show()
560
+ self.marquee_outline.show()
561
+ self._update_marquee_handles()
562
+
563
+ def clear_marquee(self) -> None:
564
+ self.marquee = None
565
+ self.marquee_highlight.hide()
566
+ self.marquee_outline.hide()
567
+ self.marquee_handles.hide()
568
+
569
+ def _snap_marquee_rect(self, rect: QtCore.QRectF) -> QtCore.QRectF | None:
570
+ return rect
571
+
572
+ def marquee_drag_mode_at(self, scene_pos: QtCore.QPointF) -> _MarqueeHandle | None:
573
+ """
574
+ Return the resize handle under a scene position, if there is one.
575
+
576
+ """
577
+ if self.marquee is None or not self.marquee_handles.isVisible():
578
+ return None
579
+
580
+ threshold = 8
581
+ for handle, point in self._marquee_handle_points().items():
582
+ handle_scene_pos = self._marquee_viewbox().mapViewToScene(point)
583
+ distance = (
584
+ (handle_scene_pos.x() - scene_pos.x()) ** 2
585
+ + (handle_scene_pos.y() - scene_pos.y()) ** 2
586
+ )
587
+ if distance <= threshold ** 2:
588
+ return handle
589
+
590
+ return None
591
+
592
+ def marquee_cursor_shape_at(
593
+ self,
594
+ scene_pos: QtCore.QPointF,
595
+ modifiers: QtCore.Qt.KeyboardModifier = (
596
+ QtCore.Qt.KeyboardModifier.NoModifier
597
+ ),
598
+ ) -> QtCore.Qt.CursorShape | None:
599
+ mode = self.current_marquee_drag_mode()
600
+ if mode == "new":
601
+ return QtCore.Qt.CursorShape.CrossCursor
602
+ if mode is not None:
603
+ return self._marquee_cursor_shape_for_handle(mode)
604
+
605
+ handle = self.marquee_drag_mode_at(scene_pos)
606
+ if handle is not None:
607
+ return self._marquee_cursor_shape_for_handle(handle)
608
+ if modifiers & QtCore.Qt.KeyboardModifier.AltModifier:
609
+ return QtCore.Qt.CursorShape.CrossCursor
610
+
611
+ return None
612
+
613
+ def _marquee_cursor_shape_for_handle(
614
+ self,
615
+ handle: _MarqueeDragMode,
616
+ ) -> QtCore.Qt.CursorShape:
617
+ if handle in ("e", "w"):
618
+ return QtCore.Qt.CursorShape.SizeHorCursor
619
+ if handle in ("n", "s"):
620
+ return QtCore.Qt.CursorShape.SizeVerCursor
621
+ if handle in ("nw", "se"):
622
+ return QtCore.Qt.CursorShape.SizeFDiagCursor
623
+ if handle in ("ne", "sw"):
624
+ return QtCore.Qt.CursorShape.SizeBDiagCursor
625
+
626
+ return QtCore.Qt.CursorShape.CrossCursor
627
+
628
+ def _update_marquee_handles(self) -> None:
629
+ points = list(self._marquee_handle_points().values())
630
+ self.marquee_handles.setData(
631
+ [point.x() for point in points],
632
+ [point.y() for point in points],
633
+ )
634
+ self.marquee_handles.show()
635
+
636
+ def _marquee_handle_points(self) -> dict[_MarqueeHandle, QtCore.QPointF]:
637
+ if self.marquee is None:
638
+ return {}
639
+ return self._marquee_handle_points_for_rect(self.marquee)
640
+
641
+ def _marquee_handle_points_for_rect(
642
+ self,
643
+ rect: QtCore.QRectF,
644
+ ) -> dict[_MarqueeHandle, QtCore.QPointF]:
645
+ centre_x = rect.center().x()
646
+ centre_y = rect.center().y()
647
+ return {
648
+ "nw": QtCore.QPointF(rect.left(), rect.bottom()),
649
+ "n": QtCore.QPointF(centre_x, rect.bottom()),
650
+ "ne": QtCore.QPointF(rect.right(), rect.bottom()),
651
+ "e": QtCore.QPointF(rect.right(), centre_y),
652
+ "se": QtCore.QPointF(rect.right(), rect.top()),
653
+ "s": QtCore.QPointF(centre_x, rect.top()),
654
+ "sw": QtCore.QPointF(rect.left(), rect.top()),
655
+ "w": QtCore.QPointF(rect.left(), centre_y),
656
+ }