qmlmathplot 0.1.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 (43) hide show
  1. qmlmathplot/__init__.py +84 -0
  2. qmlmathplot/app.py +79 -0
  3. qmlmathplot/camera.py +366 -0
  4. qmlmathplot/curve.py +296 -0
  5. qmlmathplot/model.py +541 -0
  6. qmlmathplot/plot.py +213 -0
  7. qmlmathplot/qml/PlotView.qml +247 -0
  8. qmlmathplot/qsb.py +96 -0
  9. qmlmathplot/themes/LICENSE.matplotlib +99 -0
  10. qmlmathplot/themes/README.md +15 -0
  11. qmlmathplot/themes/Solarize_Light2.mplstyle +53 -0
  12. qmlmathplot/themes/bmh.mplstyle +27 -0
  13. qmlmathplot/themes/classic.mplstyle +491 -0
  14. qmlmathplot/themes/dark_background.mplstyle +26 -0
  15. qmlmathplot/themes/fast.mplstyle +11 -0
  16. qmlmathplot/themes/fivethirtyeight.mplstyle +37 -0
  17. qmlmathplot/themes/ggplot.mplstyle +39 -0
  18. qmlmathplot/themes/grayscale.mplstyle +29 -0
  19. qmlmathplot/themes/petroff10.mplstyle +5 -0
  20. qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +3 -0
  21. qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +3 -0
  22. qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +3 -0
  23. qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +30 -0
  24. qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +30 -0
  25. qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +3 -0
  26. qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +3 -0
  27. qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +21 -0
  28. qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +21 -0
  29. qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +3 -0
  30. qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +21 -0
  31. qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +21 -0
  32. qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +30 -0
  33. qmlmathplot/themes/seaborn-v0_8-white.mplstyle +30 -0
  34. qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +30 -0
  35. qmlmathplot/themes/seaborn-v0_8.mplstyle +57 -0
  36. qmlmathplot/themes/tableau-colorblind10.mplstyle +3 -0
  37. qmlmathplot/themes.py +236 -0
  38. qmlmathplot/view.py +55 -0
  39. qmlmathplot/widget.py +183 -0
  40. qmlmathplot-0.1.0.dist-info/METADATA +383 -0
  41. qmlmathplot-0.1.0.dist-info/RECORD +43 -0
  42. qmlmathplot-0.1.0.dist-info/WHEEL +4 -0
  43. qmlmathplot-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,84 @@
1
+ """QMLMathPlot — an embeddable function plotter for Qt (Qt Quick and QtWidgets).
2
+
3
+ The plot is an **infinite canvas with a camera**: the camera holds where you are looking
4
+ (centre, zoom, aspect) and the visible range follows from it, so resizing a widget shows more
5
+ or less canvas instead of distorting or re-zooming the curve. Curves are expressions evaluated
6
+ per pixel in a fragment shader, so the cost does not depend on the function's frequency.
7
+
8
+ QtWidgets layout in one line:
9
+
10
+ from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget
11
+ from qmlmathplot import MathPlotWidget
12
+
13
+ app = QApplication([])
14
+ window = QWidget()
15
+ box = QVBoxLayout(window)
16
+ plot = MathPlotWidget("sin(1/x)")
17
+ box.addWidget(plot)
18
+ window.show()
19
+ app.exec()
20
+
21
+ Qt Quick (QML) — the component plus the model:
22
+
23
+ import QmlMathPlot 1.0
24
+ PlotView { plot: myPlot; anchors.fill: parent }
25
+
26
+ from PySide6.QtGui import QGuiApplication
27
+ from PySide6.QtQuick import QQuickView
28
+ from PySide6.QtCore import QUrl
29
+ from qmlmathplot import Plot, qml_component_path, register_qml_types
30
+
31
+ app = QGuiApplication([])
32
+ register_qml_types()
33
+ view = QQuickView()
34
+ view.setSource(QUrl.fromLocalFile(qml_component_path()))
35
+ view.rootObject().setProperty("plot", Plot())
36
+ view.show()
37
+ app.exec()
38
+
39
+ Model, without Qt widgets:
40
+
41
+ plot = Plot()
42
+ plot.xlim, plot.ylim = (-6.0, 6.0), (-2.0, 2.0)
43
+ curve = plot.add_curve("sin(1/x)", label="sin(1/x)")
44
+ curve.expression = "sin(2/x)" # one signal -> re-bake -> the view swaps it
45
+ plot.theme = "ggplot" # Matplotlib's style sheets, see qmlmathplot.themes
46
+
47
+ Demos: ``examples/minimal.py``, ``examples/explorer_qtwidgets.py``,
48
+ ``examples/explorer_qtquick.py``. Design notes: ``docs/api-design.md``.
49
+ """
50
+
51
+ from typing import TYPE_CHECKING
52
+
53
+ from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
54
+ from .curve import Curve, CurveListModel
55
+ from .plot import Plot
56
+ from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
57
+
58
+ if TYPE_CHECKING: # only for type checkers; at runtime the module-level __getattr__ is used
59
+ from .widget import MathPlotWidget
60
+
61
+ __all__ = [
62
+ "Camera",
63
+ "Curve",
64
+ "CurveListModel",
65
+ "HOME_SIZE",
66
+ "HOME_VIEW",
67
+ "MathPlotWidget",
68
+ "nice_ticks",
69
+ "Plot",
70
+ "qml_component_path",
71
+ "QML_MAJOR",
72
+ "QML_MINOR",
73
+ "QML_URI",
74
+ "register_qml_types",
75
+ ]
76
+
77
+
78
+ def __getattr__(name: str) -> object:
79
+ """``MathPlotWidget`` is imported lazily, so pure-QML use never loads QtWidgets."""
80
+ if name == "MathPlotWidget":
81
+ from .widget import MathPlotWidget
82
+
83
+ return MathPlotWidget
84
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
qmlmathplot/app.py ADDED
@@ -0,0 +1,79 @@
1
+ """MVP entry point: wraps the component into a minimal verifiable window (called by `main.py` /
2
+ `uv run qmlmathplot`).
3
+
4
+ python main.py --backend d3d11 "sin(1/x)"
5
+
6
+ The RHI backend comes from the launch argument; without it, Qt's default backend is used. When
7
+ specified it must be written to ``QSG_RHI_BACKEND`` *before* ``QGuiApplication`` — Qt reads it
8
+ during platform initialization, so calling ``QQuickWindow.setGraphicsApi()`` later has no effect
9
+ (the window's surface has already been created for the default backend).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import os
16
+ import sys
17
+
18
+ from PySide6.QtCore import QUrl
19
+ from PySide6.QtGui import QGuiApplication
20
+ from PySide6.QtQuick import QQuickView
21
+
22
+ from .plot import Plot
23
+ from .view import qml_component_path, register_qml_types
24
+
25
+ # Valid QSG_RHI_BACKEND values (Qt 6); unset = Qt default (d3d11 on Windows)
26
+ BACKENDS = ("d3d11", "d3d12", "vulkan", "metal", "opengl", "null")
27
+
28
+ DEFAULT_EXPRESSION = "sin(x)"
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(
33
+ prog="qmlmathplot",
34
+ description="Minimal verifiable window for QMLMathPlot (drag to pan, wheel to zoom).",
35
+ )
36
+ parser.add_argument("expression", nargs="?", default=DEFAULT_EXPRESSION,
37
+ help=f"expression in sympy syntax (default {DEFAULT_EXPRESSION})")
38
+ parser.add_argument("--backend", choices=BACKENDS, default=None,
39
+ help="RHI backend for Qt Quick; Qt's default when not specified")
40
+ parser.add_argument("--width", type=int, default=900, help="window width (default 900)")
41
+ parser.add_argument("--height", type=int, default=600, help="window height (default 600)")
42
+ return parser
43
+
44
+
45
+ def main(argv: list[str] | None = None) -> int:
46
+ args = build_parser().parse_args(sys.argv[1:] if argv is None else argv)
47
+
48
+ if args.backend is not None:
49
+ # An explicitly given backend wins over the environment: the command line overrides all
50
+ os.environ["QSG_RHI_BACKEND"] = args.backend
51
+
52
+ app = QGuiApplication(sys.argv[:1])
53
+ register_qml_types()
54
+
55
+ view = QQuickView()
56
+ view.setResizeMode(QQuickView.ResizeMode.SizeRootObjectToView)
57
+ view.setTitle(f"QMLMathPlot — {args.expression}")
58
+ view.resize(args.width, args.height)
59
+ view.setSource(QUrl.fromLocalFile(qml_component_path()))
60
+ if view.status() is not QQuickView.Status.Ready:
61
+ for err in view.errors():
62
+ print(err.toString(), file=sys.stderr)
63
+ return 1
64
+
65
+ root = view.rootObject()
66
+ if root is None:
67
+ print("failed to create the QML root object", file=sys.stderr)
68
+ return 1
69
+
70
+ # MVVM: the app side owns the model and injects it into the component (which also
71
+ # brings its own when nothing is injected)
72
+ plot = Plot()
73
+ plot.add_curve(args.expression)
74
+ root.setProperty("plot", plot)
75
+
76
+ view.show()
77
+ print(f"RHI backend: {view.graphicsApi()} (QSG_RHI_BACKEND={os.environ.get('QSG_RHI_BACKEND', 'not set')})",
78
+ flush=True)
79
+ return app.exec()
qmlmathplot/camera.py ADDED
@@ -0,0 +1,366 @@
1
+ """Camera layer: where we look at the infinite canvas, plus the nice-number tick algorithm.
2
+
3
+ The camera stores three numbers that do not depend on the widget at all — ``centre``,
4
+ ``zoom`` (world units per *logical* pixel on x) and ``aspect`` (the ratio of the y scale to
5
+ the x scale; ``"auto"`` leaves it to the widget's shape). The visible range (``xlim`` /
6
+ ``ylim``) is *derived* from them and the last reported size, so resizing changes what is
7
+ visible without touching the state — which is what makes an infinite canvas cheap.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+
14
+ from PySide6.QtCore import Property, QObject, Signal, Slot
15
+ from PySide6.QtGui import QVector2D
16
+
17
+ __all__ = ["HOME_SIZE", "HOME_VIEW", "Camera", "nice_ticks"]
18
+
19
+ #: Home view — the classic 12x4 window — as (xmin, xmax, ymin, ymax).
20
+ #:
21
+ #: The home is a *view*, but the camera stores scales, so the two meet at a reference size:
22
+ #: the first reported size turns the home view into (zoom, y scale) for that widget, and
23
+ #: the constants below are only the fallback used before any size is known.
24
+ HOME_VIEW = (-6.0, 6.0, -2.0, 2.0)
25
+ #: Reference size for the home view (before the first ``setViewport``).
26
+ HOME_SIZE = (900.0, 600.0)
27
+
28
+
29
+ def _pair(value: object) -> tuple[float, float]:
30
+ """Two floats from a QVector2D, a QPointF or any 2-sequence."""
31
+ if isinstance(value, (QVector2D,)):
32
+ return (value.x(), value.y())
33
+ x, y = value # type: ignore[misc] # documented: any pair-like value
34
+ return (float(x), float(y))
35
+
36
+
37
+ def nice_ticks(lo: float, hi: float, target: int = 8) -> list[tuple[float, str]]:
38
+ """Ticks covering ``[lo, hi]`` at nice-number steps (1/2/5 x 10^n), about ``target`` of
39
+ them, as ``(value, label)``.
40
+
41
+ Pure and Qt-free: the tick values are decided once per camera change (not per frame) and
42
+ QML only positions the labels it is given.
43
+ """
44
+ if not hi > lo or target < 1:
45
+ return []
46
+ raw = (hi - lo) / target
47
+ magnitude = 10.0 ** math.floor(math.log10(raw))
48
+ for multiple in (1.0, 2.0, 5.0, 10.0):
49
+ if raw <= multiple * magnitude:
50
+ step = multiple * magnitude
51
+ break
52
+ else: # pragma: no cover - the 10.0 branch always matches
53
+ step = 10.0 * magnitude
54
+
55
+ decimals = max(0, -math.floor(math.log10(step) + 1e-9))
56
+ first = math.ceil(lo / step - 1e-9) * step
57
+ out: list[tuple[float, str]] = []
58
+ for i in range(int(math.floor((hi - first) / step + 1e-9)) + 1):
59
+ # Round to the step's own precision: repeated addition accumulates noise (0.6000000000000001)
60
+ # and the label would then disagree with the position.
61
+ value = round(first + i * step, decimals)
62
+ if abs(value) < step * 1e-6:
63
+ value = 0.0 # no "-0" labels
64
+ out.append((value, _tick_label(value, step, decimals)))
65
+ return out
66
+
67
+
68
+ def _tick_label(value: float, step: float, decimals: int) -> str:
69
+ if abs(value) >= 1e7 or (value != 0.0 and abs(step) < 1e-7):
70
+ return f"{value:g}"
71
+ return f"{value:.{decimals}f}"
72
+
73
+
74
+ def _qml_pairs(ticks: list[tuple[float, str]]) -> list[list[float | str]]:
75
+ """Ticks as two-element *lists* for QML: a Python tuple crosses a QVariant as an opaque
76
+ value (QML cannot index it), so the pairs have to be lists to be readable there."""
77
+ return [[value, label] for value, label in ticks]
78
+
79
+
80
+ class Camera(QObject):
81
+ """The view onto the infinite canvas: centre, zoom (units per logical pixel) and aspect.
82
+
83
+ ``aspect`` is the ratio of the y scale to the x scale, so ``1.0`` means square units (a
84
+ world circle is drawn round) and ``"auto"`` keeps the two scales independent, letting the
85
+ widget's shape decide how much canvas is visible.
86
+ """
87
+
88
+ viewChanged = Signal()
89
+ aspectChanged = Signal()
90
+ ticksChanged = Signal()
91
+ zoomStepChanged = Signal()
92
+ panEnabledChanged = Signal()
93
+ zoomEnabledChanged = Signal()
94
+
95
+ #: Scale limits (world units per pixel), to keep panning/zooming reversible.
96
+ MIN_SCALE = 1e-12
97
+ MAX_SCALE = 1e12
98
+
99
+ def __init__(self, parent: QObject | None = None) -> None:
100
+ super().__init__(parent)
101
+ self._centre = QVector2D(0.0, 0.0)
102
+ self._zoom = (HOME_VIEW[1] - HOME_VIEW[0]) / HOME_SIZE[0]
103
+ self._aspect: str | float = "auto"
104
+ self._y_scale = (HOME_VIEW[3] - HOME_VIEW[2]) / HOME_SIZE[1]
105
+ self._size: tuple[float, float] | None = None
106
+ # True while the home *view* has not been resolved for a real size: the first report
107
+ # then sets the scales from it, so a window opened at any size shows the home window.
108
+ self._home = True
109
+ self._zoom_step = 0.9
110
+ self._pan_enabled = True
111
+ self._zoom_enabled = True
112
+
113
+ # ------------------------------------------------------------- state
114
+ def _get_centre(self) -> QVector2D:
115
+ return QVector2D(self._centre)
116
+
117
+ def _set_centre(self, value: object) -> None:
118
+ x, y = _pair(value)
119
+ if (x, y) == (self._centre.x(), self._centre.y()):
120
+ return
121
+ self._centre = QVector2D(x, y)
122
+ self._home = False
123
+ self._view_changed()
124
+
125
+ # "QVariant" rather than QVector2D so a plain ``(x, y)`` from Python is accepted too.
126
+ centre: QVector2D = Property("QVariant", _get_centre, _set_centre, notify=viewChanged)
127
+
128
+ def _get_zoom(self) -> float:
129
+ return self._zoom
130
+
131
+ def _set_zoom(self, value: float) -> None:
132
+ self._scale_to(float(value))
133
+
134
+ zoom: float = Property(float, _get_zoom, _set_zoom, notify=viewChanged)
135
+
136
+ def _get_aspect(self) -> str | float:
137
+ return self._aspect
138
+
139
+ def _set_aspect(self, value: str | float) -> None:
140
+ if isinstance(value, str):
141
+ if value != "auto":
142
+ raise ValueError(f'aspect must be "auto" or a positive number, got {value!r}')
143
+ elif not float(value) > 0:
144
+ raise ValueError(f"aspect must be positive, got {value!r}")
145
+ if value == self._aspect:
146
+ return
147
+ old_y = self.scale_y
148
+ self._aspect = value
149
+ self._home = False
150
+ if isinstance(value, str):
151
+ self._y_scale = old_y # switching to "auto" keeps the current shape
152
+ else:
153
+ # Expand, never crop: a numeric aspect may only show *more* than was visible.
154
+ self._scale_to(max(self._zoom, float(value) * old_y), keep_ratio=False)
155
+ self.aspectChanged.emit()
156
+ self._view_changed()
157
+
158
+ aspect: str | float = Property("QVariant", _get_aspect, _set_aspect, notify=aspectChanged)
159
+
160
+ def _get_scale_x(self) -> float:
161
+ return self._zoom
162
+
163
+ def _get_scale_y(self) -> float:
164
+ if isinstance(self._aspect, str):
165
+ return self._y_scale
166
+ return self._zoom / float(self._aspect)
167
+
168
+ scale_x: float = Property(float, _get_scale_x, notify=viewChanged)
169
+ scale_y: float = Property(float, _get_scale_y, notify=viewChanged)
170
+
171
+ def _scale_to(self, value: float, *, keep_ratio: bool = True) -> None:
172
+ """Set the x scale; in "auto" mode both scales move together (a uniform zoom)."""
173
+ value = min(max(value, self.MIN_SCALE), self.MAX_SCALE)
174
+ if value == self._zoom:
175
+ return
176
+ factor = value / self._zoom
177
+ self._zoom = value
178
+ if keep_ratio and isinstance(self._aspect, str):
179
+ self._y_scale = min(max(self._y_scale * factor, self.MIN_SCALE), self.MAX_SCALE)
180
+ self._home = False
181
+ self._view_changed()
182
+
183
+ # ------------------------------------------------------- derived ranges
184
+ def _effective_size(self) -> tuple[float, float]:
185
+ """Size used for the derived ranges: the last reported one, or the reference."""
186
+ return self._size if self._size is not None else HOME_SIZE
187
+
188
+ def _get_xlim(self) -> QVector2D:
189
+ width, _ = self._effective_size()
190
+ half = 0.5 * width * self.scale_x
191
+ return QVector2D(self._centre.x() - half, self._centre.x() + half)
192
+
193
+ def _set_xlim(self, value: object) -> None:
194
+ lo, hi = _pair(value)
195
+ width, _ = self._effective_size()
196
+ if not hi > lo or width <= 0:
197
+ return
198
+ self._centre = QVector2D(0.5 * (lo + hi), self._centre.y())
199
+ self._scale_to((hi - lo) / width) # keeps the ratio: y follows the same factor
200
+
201
+ def _get_ylim(self) -> QVector2D:
202
+ _, height = self._effective_size()
203
+ half = 0.5 * height * self.scale_y
204
+ return QVector2D(self._centre.y() - half, self._centre.y() + half)
205
+
206
+ def _set_ylim(self, value: object) -> None:
207
+ lo, hi = _pair(value)
208
+ _, height = self._effective_size()
209
+ if not hi > lo or height <= 0:
210
+ return
211
+ self._centre = QVector2D(self._centre.x(), 0.5 * (lo + hi))
212
+ if isinstance(self._aspect, str):
213
+ self._y_scale = min(max((hi - lo) / height, self.MIN_SCALE), self.MAX_SCALE)
214
+ self._home = False
215
+ self._view_changed()
216
+ else:
217
+ self._scale_to((hi - lo) / height * float(self._aspect), keep_ratio=False)
218
+
219
+ xlim: QVector2D = Property("QVariant", _get_xlim, _set_xlim, notify=viewChanged)
220
+ ylim: QVector2D = Property("QVariant", _get_ylim, _set_ylim, notify=viewChanged)
221
+
222
+ # --------------------------------------------------------------- ticks
223
+ def tick_values(self) -> tuple[list[tuple[float, str]], list[tuple[float, str]]]:
224
+ """Ticks for both axes of the visible range, as ``(value, label)`` pairs."""
225
+ xlim, ylim = self._get_xlim(), self._get_ylim()
226
+ return nice_ticks(xlim.x(), xlim.y()), nice_ticks(ylim.x(), ylim.y())
227
+
228
+ def _get_ticks_x(self) -> list[list[float | str]]:
229
+ return _qml_pairs(self.tick_values()[0])
230
+
231
+ def _get_ticks_y(self) -> list[list[float | str]]:
232
+ return _qml_pairs(self.tick_values()[1])
233
+
234
+ ticks_x: list[tuple[float, str]] = Property("QVariant", _get_ticks_x, notify=ticksChanged)
235
+ ticks_y: list[tuple[float, str]] = Property("QVariant", _get_ticks_y, notify=ticksChanged)
236
+
237
+ # ----------------------------------------------------------- behaviour
238
+ def _get_zoom_step(self) -> float:
239
+ return self._zoom_step
240
+
241
+ def _set_zoom_step(self, value: float) -> None:
242
+ value = float(value)
243
+ if not 0.0 < value < 1.0:
244
+ raise ValueError(f"zoomStep must be in (0, 1), got {value!r}")
245
+ if value == self._zoom_step:
246
+ return
247
+ self._zoom_step = value
248
+ self.zoomStepChanged.emit()
249
+
250
+ #: Scale factor of one wheel notch (0.9 = 10% closer per notch).
251
+ zoomStep: float = Property(float, _get_zoom_step, _set_zoom_step, notify=zoomStepChanged)
252
+
253
+ def _get_pan_enabled(self) -> bool:
254
+ return self._pan_enabled
255
+
256
+ def _set_pan_enabled(self, value: bool) -> None:
257
+ value = bool(value)
258
+ if value == self._pan_enabled:
259
+ return
260
+ self._pan_enabled = value
261
+ self.panEnabledChanged.emit()
262
+
263
+ panEnabled: bool = Property(bool, _get_pan_enabled, _set_pan_enabled, notify=panEnabledChanged)
264
+
265
+ def _get_zoom_enabled(self) -> bool:
266
+ return self._zoom_enabled
267
+
268
+ def _set_zoom_enabled(self, value: bool) -> None:
269
+ value = bool(value)
270
+ if value == self._zoom_enabled:
271
+ return
272
+ self._zoom_enabled = value
273
+ self.zoomEnabledChanged.emit()
274
+
275
+ zoomEnabled: bool = Property(
276
+ bool, _get_zoom_enabled, _set_zoom_enabled, notify=zoomEnabledChanged
277
+ )
278
+
279
+ def _view_changed(self) -> None:
280
+ self.viewChanged.emit()
281
+ self.ticksChanged.emit()
282
+
283
+ # --------------------------------------------------------------- slots
284
+ @Slot(float, float)
285
+ def setViewport(self, width: float, height: float) -> None:
286
+ """Record the widget size (logical pixels) — used by the derived ranges only.
287
+
288
+ Drawing never depends on it: the shader derives its mapping from the camera and its
289
+ own size. The first report resolves the home view (see ``HOME_VIEW``); later reports
290
+ keep the scales, so nothing zooms while a window or a splitter is dragged.
291
+ """
292
+ if width <= 0 or height <= 0:
293
+ return
294
+ size = (float(width), float(height))
295
+ if size == self._size:
296
+ return
297
+ self._size = size
298
+ self._resolve_home()
299
+ self._view_changed()
300
+
301
+ def _resolve_home(self) -> None:
302
+ """Turn the home view into scales for the known size (once, while still at home)."""
303
+ if not self._home:
304
+ return
305
+ width, height = self._effective_size()
306
+ x_scale = (HOME_VIEW[1] - HOME_VIEW[0]) / width
307
+ y_scale = (HOME_VIEW[3] - HOME_VIEW[2]) / height
308
+ if isinstance(self._aspect, str):
309
+ self._zoom, self._y_scale = x_scale, y_scale
310
+ else:
311
+ aspect = float(self._aspect)
312
+ self._zoom = max(x_scale, aspect * y_scale) # expand, never crop
313
+ self._y_scale = self._zoom / aspect
314
+ if self._size is not None:
315
+ self._home = False
316
+
317
+ @Slot(float, float, float, float, float)
318
+ def zoom_by(self, delta: float, u: float, v: float, width: float, height: float) -> None:
319
+ """Zoom by ``delta`` wheel units (120 = one notch), anchored at the normalised
320
+ cursor position ``(u, v)`` of a ``width`` x ``height`` viewport.
321
+
322
+ The factor depends only on ``delta``, so scrolling up and down at the same position
323
+ are exact inverses; the world point under the cursor stays put.
324
+ """
325
+ if not self._zoom_enabled or delta == 0:
326
+ return
327
+ target = min(max(self._zoom * self._zoom_step ** (delta / 120.0), self.MIN_SCALE),
328
+ self.MAX_SCALE)
329
+ factor = target / self._zoom
330
+ if factor == 1.0:
331
+ return
332
+ if width > 0 and height > 0:
333
+ anchor_x = self._centre.x() + (u - 0.5) * width * self.scale_x
334
+ anchor_y = self._centre.y() + (0.5 - v) * height * self.scale_y
335
+ else:
336
+ anchor_x, anchor_y = self._centre.x(), self._centre.y()
337
+ self._zoom = target
338
+ if isinstance(self._aspect, str):
339
+ self._y_scale = min(max(self._y_scale * factor, self.MIN_SCALE), self.MAX_SCALE)
340
+ if width > 0 and height > 0:
341
+ self._centre = QVector2D(
342
+ anchor_x - (u - 0.5) * width * self.scale_x,
343
+ anchor_y - (0.5 - v) * height * self.scale_y,
344
+ )
345
+ self._home = False
346
+ self._view_changed()
347
+
348
+ @Slot(float, float)
349
+ def pan_pixels(self, dx: float, dy: float) -> None:
350
+ """Pan by a pixel displacement (screen y points down, world y points up)."""
351
+ if not self._pan_enabled or (dx == 0.0 and dy == 0.0):
352
+ return
353
+ self._centre = QVector2D(
354
+ self._centre.x() - dx * self.scale_x,
355
+ self._centre.y() + dy * self.scale_y,
356
+ )
357
+ self._home = False
358
+ self._view_changed()
359
+
360
+ @Slot()
361
+ def reset(self) -> None:
362
+ """Back to the home view (centre and scales as the camera was configured)."""
363
+ self._centre = QVector2D(0.0, 0.0)
364
+ self._home = True
365
+ self._resolve_home()
366
+ self._view_changed()