qmlmathplot 0.3.0__py3-none-any.whl → 0.4.1__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.
qmlmathplot/__init__.py CHANGED
@@ -52,34 +52,54 @@ from typing import TYPE_CHECKING
52
52
 
53
53
  from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
54
54
  from .curve import Curve, CurveListModel, ImplicitCurve
55
+ from .figure import Axes, Figure
56
+ from .input import ViewInput
55
57
  from .plot import Plot
58
+ from .qsb import cache_dir, cache_info, clear_cache, prune_cache
56
59
  from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
57
60
 
58
61
  if TYPE_CHECKING: # only for type checkers; at runtime the module-level __getattr__ is used
62
+ from .canvas import FigureCanvasQt
59
63
  from .widget import MathPlotWidget
60
64
 
61
65
  __all__ = [
66
+ "Axes",
67
+ "cache_dir",
68
+ "cache_info",
62
69
  "Camera",
70
+ "clear_cache",
63
71
  "Curve",
64
72
  "CurveListModel",
73
+ "Figure",
74
+ "FigureCanvasQt",
65
75
  "HOME_SIZE",
66
76
  "HOME_VIEW",
67
77
  "ImplicitCurve",
68
78
  "MathPlotWidget",
69
79
  "nice_ticks",
70
80
  "Plot",
81
+ "prune_cache",
71
82
  "qml_component_path",
72
83
  "QML_MAJOR",
73
84
  "QML_MINOR",
74
85
  "QML_URI",
75
86
  "register_qml_types",
87
+ "ViewInput",
76
88
  ]
77
89
 
78
90
 
79
91
  def __getattr__(name: str) -> object:
80
- """``MathPlotWidget`` is imported lazily, so pure-QML use never loads QtWidgets."""
92
+ """QtWidgets-backed names are lazy, so pure-QML use never loads QtWidgets."""
81
93
  if name == "MathPlotWidget":
82
94
  from .widget import MathPlotWidget
83
95
 
84
96
  return MathPlotWidget
97
+ if name == "FigureCanvasQt":
98
+ from .canvas import FigureCanvasQt
99
+
100
+ return FigureCanvasQt
101
+ if name == "pyplot":
102
+ from importlib import import_module
103
+
104
+ return import_module(".pyplot", __name__)
85
105
  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,87 @@
1
+ """The one eased-camera animation shared by wheel and keyboard moves.
2
+
3
+ Kept out of :class:`~qmlmathplot.camera.Camera` so the camera stays geometry + ticks: this
4
+ object owns the ``QVariantAnimation`` and the interpolation policy, and calls back with the
5
+ state to apply. Direct manipulation (drag, pinch, direct setters) stops it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ from PySide6.QtCore import QAbstractAnimation, QEasingCurve, QObject, QVariantAnimation
13
+
14
+ __all__ = ["CameraAnimator"]
15
+
16
+ State = tuple[float, float, float, float] # centre x, centre y, scale x, scale y
17
+
18
+
19
+ class CameraAnimator(QObject):
20
+ """Interpolate between two camera states; ``apply`` runs on every frame.
21
+
22
+ Scales interpolate geometrically (constant perceived zoom rate) and the centre linearly;
23
+ OutCubic gives the same short settle for a wheel notch and a keypress.
24
+ """
25
+
26
+ def __init__(self, apply: Callable[[State], None], parent: QObject | None = None) -> None:
27
+ super().__init__(parent)
28
+ self._apply: Callable[[State], None] = apply
29
+ self._from: State | None = None
30
+ self._to: State | None = None
31
+ self._animation: QVariantAnimation = QVariantAnimation(self)
32
+ self._animation.setStartValue(0.0)
33
+ self._animation.setEndValue(1.0)
34
+ self._animation.setEasingCurve(QEasingCurve.Type.OutCubic)
35
+ self._animation.valueChanged.connect(self._on_tick)
36
+ self._animation.finished.connect(self._on_finished)
37
+
38
+ # ------------------------------------------------------------------ state
39
+ @property
40
+ def running(self) -> bool:
41
+ return self._animation.state() == QAbstractAnimation.State.Running
42
+
43
+ def target(self, current: State) -> State:
44
+ """The state an in-flight animation is heading for (accumulate rapid wheel/key
45
+ presses); ``current`` otherwise."""
46
+ if self._to is not None and self.running:
47
+ return self._to
48
+ return current
49
+
50
+ # ------------------------------------------------------------- lifecycle
51
+ def start(self, start: State, end: State, duration: int) -> None:
52
+ duration = int(duration)
53
+ if duration <= 0:
54
+ self.stop()
55
+ self._apply(end)
56
+ return
57
+ if start == end:
58
+ self.stop()
59
+ return
60
+ self._from = start
61
+ self._to = end
62
+ self._animation.stop()
63
+ self._animation.setDuration(duration)
64
+ self._animation.start()
65
+
66
+ def stop(self) -> None:
67
+ if self._animation.state() != QAbstractAnimation.State.Stopped:
68
+ self._animation.stop()
69
+ self._from = None
70
+ self._to = None
71
+
72
+ # ------------------------------------------------------------- callbacks
73
+ def _on_tick(self, value: float) -> None:
74
+ if self._from is None or self._to is None:
75
+ return
76
+ t = float(value)
77
+ fx, fy, fsx, fsy = self._from
78
+ tx, ty, tsx, tsy = self._to
79
+ sx = fsx * (tsx / fsx) ** t
80
+ sy = fsy * (tsy / fsy) ** t
81
+ self._apply((fx + (tx - fx) * t, fy + (ty - fy) * t, sx, sy))
82
+
83
+ def _on_finished(self) -> None:
84
+ if self._to is not None:
85
+ self._apply(self._to)
86
+ self._from = None
87
+ self._to = None
qmlmathplot/camera.py CHANGED
@@ -14,6 +14,9 @@ import math
14
14
  from PySide6.QtCore import Property, QObject, Signal, Slot
15
15
  from PySide6.QtGui import QVector2D
16
16
 
17
+ from .animation import CameraAnimator, State
18
+ from .input import ViewInput
19
+
17
20
  __all__ = ["HOME_SIZE", "HOME_VIEW", "Camera", "nice_ticks"]
18
21
 
19
22
  #: Home view — the classic 12x4 window — as (xmin, xmax, ymin, ymax).
@@ -22,7 +25,7 @@ __all__ = ["HOME_SIZE", "HOME_VIEW", "Camera", "nice_ticks"]
22
25
  #: the first reported size turns the home view into (zoom, y scale) for that widget, and
23
26
  #: the constants below are only the fallback used before any size is known.
24
27
  HOME_VIEW = (-6.0, 6.0, -2.0, 2.0)
25
- #: Reference size for the home view (before the first ``setViewport``).
28
+ #: Reference size for the home view (before the first ``set_viewport``).
26
29
  HOME_SIZE = (900.0, 600.0)
27
30
 
28
31
 
@@ -91,6 +94,7 @@ class Camera(QObject):
91
94
  zoomStepChanged = Signal()
92
95
  panEnabledChanged = Signal()
93
96
  zoomEnabledChanged = Signal()
97
+ animationDurationChanged = Signal()
94
98
 
95
99
  #: Scale limits (world units per pixel), to keep panning/zooming reversible.
96
100
  MIN_SCALE = 1e-12
@@ -109,6 +113,16 @@ class Camera(QObject):
109
113
  self._zoom_step = 0.9
110
114
  self._pan_enabled = True
111
115
  self._zoom_enabled = True
116
+ self._ticks_key: tuple[float, float, float, float] | None = None
117
+ self._ticks_x: list[tuple[float, str]] = []
118
+ self._ticks_y: list[tuple[float, str]] = []
119
+ self._ticks_x_qml: list[list[float | str]] = []
120
+ self._ticks_y_qml: list[list[float | str]] = []
121
+ self._input = ViewInput(self)
122
+ self._animation_duration = 160
123
+ # One animation drives every discrete camera move (a wheel notch, a keypress). Drags
124
+ # and touch gestures are direct manipulation and must never be animated through.
125
+ self._animator = CameraAnimator(self._apply_state, self)
112
126
 
113
127
  # ------------------------------------------------------------- state
114
128
  def _get_centre(self) -> QVector2D:
@@ -118,6 +132,7 @@ class Camera(QObject):
118
132
  x, y = _pair(value)
119
133
  if (x, y) == (self._centre.x(), self._centre.y()):
120
134
  return
135
+ self._stop_animation()
121
136
  self._centre = QVector2D(x, y)
122
137
  self._home = False
123
138
  self._view_changed()
@@ -144,6 +159,7 @@ class Camera(QObject):
144
159
  raise ValueError(f"aspect must be positive, got {value!r}")
145
160
  if value == self._aspect:
146
161
  return
162
+ self._stop_animation()
147
163
  old_y = self.scale_y
148
164
  self._aspect = value
149
165
  self._home = False
@@ -173,6 +189,7 @@ class Camera(QObject):
173
189
  value = min(max(value, self.MIN_SCALE), self.MAX_SCALE)
174
190
  if value == self._zoom:
175
191
  return
192
+ self._stop_animation()
176
193
  factor = value / self._zoom
177
194
  self._zoom = value
178
195
  if keep_ratio and isinstance(self._aspect, str):
@@ -195,6 +212,7 @@ class Camera(QObject):
195
212
  width, _ = self._effective_size()
196
213
  if not hi > lo or width <= 0:
197
214
  return
215
+ self._stop_animation()
198
216
  self._centre = QVector2D(0.5 * (lo + hi), self._centre.y())
199
217
  self._scale_to((hi - lo) / width) # keeps the ratio: y follows the same factor
200
218
 
@@ -208,6 +226,7 @@ class Camera(QObject):
208
226
  _, height = self._effective_size()
209
227
  if not hi > lo or height <= 0:
210
228
  return
229
+ self._stop_animation()
211
230
  self._centre = QVector2D(self._centre.x(), 0.5 * (lo + hi))
212
231
  if isinstance(self._aspect, str):
213
232
  self._y_scale = min(max((hi - lo) / height, self.MIN_SCALE), self.MAX_SCALE)
@@ -227,16 +246,30 @@ class Camera(QObject):
227
246
  viewport: QVector2D = Property("QVariant", _get_viewport, notify=viewChanged)
228
247
 
229
248
  # --------------------------------------------------------------- ticks
249
+ def _ensure_ticks(self) -> None:
250
+ """Recompute both axes once per distinct view; the two properties share the result."""
251
+ xlim, ylim = self._get_xlim(), self._get_ylim()
252
+ key = (xlim.x(), xlim.y(), ylim.x(), ylim.y())
253
+ if key == self._ticks_key:
254
+ return
255
+ self._ticks_key = key
256
+ self._ticks_x = nice_ticks(xlim.x(), xlim.y())
257
+ self._ticks_y = nice_ticks(ylim.x(), ylim.y())
258
+ self._ticks_x_qml = _qml_pairs(self._ticks_x)
259
+ self._ticks_y_qml = _qml_pairs(self._ticks_y)
260
+
230
261
  def tick_values(self) -> tuple[list[tuple[float, str]], list[tuple[float, str]]]:
231
262
  """Ticks for both axes of the visible range, as ``(value, label)`` pairs."""
232
- xlim, ylim = self._get_xlim(), self._get_ylim()
233
- return nice_ticks(xlim.x(), xlim.y()), nice_ticks(ylim.x(), ylim.y())
263
+ self._ensure_ticks()
264
+ return self._ticks_x, self._ticks_y
234
265
 
235
266
  def _get_ticks_x(self) -> list[list[float | str]]:
236
- return _qml_pairs(self.tick_values()[0])
267
+ self._ensure_ticks()
268
+ return self._ticks_x_qml
237
269
 
238
270
  def _get_ticks_y(self) -> list[list[float | str]]:
239
- return _qml_pairs(self.tick_values()[1])
271
+ self._ensure_ticks()
272
+ return self._ticks_y_qml
240
273
 
241
274
  ticks_x: list[tuple[float, str]] = Property("QVariant", _get_ticks_x, notify=ticksChanged)
242
275
  ticks_y: list[tuple[float, str]] = Property("QVariant", _get_ticks_y, notify=ticksChanged)
@@ -283,9 +316,147 @@ class Camera(QObject):
283
316
  bool, _get_zoom_enabled, _set_zoom_enabled, notify=zoomEnabledChanged
284
317
  )
285
318
 
319
+ def _get_input(self) -> ViewInput:
320
+ return self._input
321
+
322
+ #: Keyboard bindings, focus switch and pan step (see :class:`ViewInput`).
323
+ input: ViewInput = Property(ViewInput, _get_input, constant=True)
324
+
325
+ def _get_animation_duration(self) -> int:
326
+ return self._animation_duration
327
+
328
+ def _set_animation_duration(self, value: int) -> None:
329
+ value = int(value)
330
+ if value < 0:
331
+ raise ValueError(f"animationDuration must be >= 0, got {value!r}")
332
+ if value == self._animation_duration:
333
+ return
334
+ self._animation_duration = value
335
+ self.animationDurationChanged.emit()
336
+
337
+ #: Duration of a wheel/keyboard camera move in ms; 0 disables the animation.
338
+ animationDuration: int = Property(
339
+ int, _get_animation_duration, _set_animation_duration, notify=animationDurationChanged
340
+ )
341
+
342
+ # --------------------------------------------------------------- animation
343
+ def _state(self) -> tuple[float, float, float, float]:
344
+ return (self._centre.x(), self._centre.y(), self._zoom, self._get_scale_y())
345
+
346
+ def _clamp_state(
347
+ self, centre_x: float, centre_y: float, scale_x: float, scale_y: float
348
+ ) -> tuple[float, float, float, float]:
349
+ return (
350
+ float(centre_x),
351
+ float(centre_y),
352
+ min(max(float(scale_x), self.MIN_SCALE), self.MAX_SCALE),
353
+ min(max(float(scale_y), self.MIN_SCALE), self.MAX_SCALE),
354
+ )
355
+
356
+ def _apply_view(
357
+ self, centre_x: float, centre_y: float, scale_x: float, scale_y: float
358
+ ) -> None:
359
+ """Apply a state without stopping the animation that produced it."""
360
+ centre_x, centre_y, scale_x, scale_y = self._clamp_state(
361
+ centre_x, centre_y, scale_x, scale_y
362
+ )
363
+ if (centre_x, centre_y, scale_x, scale_y) == self._state():
364
+ return
365
+ self._centre = QVector2D(centre_x, centre_y)
366
+ self._zoom = scale_x
367
+ if isinstance(self._aspect, str):
368
+ self._y_scale = scale_y
369
+ self._home = False
370
+ self._view_changed()
371
+
372
+ def _target_state(self) -> State:
373
+ """The state an in-flight animation is heading for (accumulate rapid wheel/key
374
+ presses); the current state otherwise."""
375
+ return self._animator.target(self._state())
376
+
377
+ def _stop_animation(self) -> None:
378
+ self._animator.stop()
379
+
380
+ @Slot()
381
+ def stop_animation(self) -> None:
382
+ """Cancel an in-flight animated move (called by the view on direct manipulation)."""
383
+ self._animator.stop()
384
+
385
+ def _apply_state(self, state: State) -> None:
386
+ self._apply_view(*state)
387
+
388
+ def _animate_to(self, state: State, duration: int) -> None:
389
+ self._animator.start(self._state(), self._clamp_state(*state), duration)
390
+
391
+ def _zoom_state(
392
+ self,
393
+ base: tuple[float, float, float, float],
394
+ factor: float,
395
+ u: float,
396
+ v: float,
397
+ width: float,
398
+ height: float,
399
+ ) -> tuple[float, float, float, float]:
400
+ """The state after scaling ``base`` by ``factor`` with (u, v) held fixed."""
401
+ bx, by, bsx, bsy = base
402
+ tsx = min(max(bsx * factor, self.MIN_SCALE), self.MAX_SCALE)
403
+ actual = tsx / bsx
404
+ if isinstance(self._aspect, str):
405
+ tsy = min(max(bsy * actual, self.MIN_SCALE), self.MAX_SCALE)
406
+ else:
407
+ tsy = tsx / float(self._aspect)
408
+ if width > 0 and height > 0:
409
+ anchor_x = bx + (u - 0.5) * width * bsx
410
+ anchor_y = by + (0.5 - v) * height * bsy
411
+ tx = anchor_x - (u - 0.5) * width * tsx
412
+ ty = anchor_y - (0.5 - v) * height * tsy
413
+ else:
414
+ tx, ty = bx, by
415
+ return self._clamp_state(tx, ty, tsx, tsy)
416
+
417
+ @Slot(float, float, float, float, float)
418
+ def zoom_at(self, factor: float, u: float, v: float, width: float, height: float) -> None:
419
+ """Magnify the view by ``factor`` (>1 closer, <1 farther) with (u, v) held fixed.
420
+
421
+ The primitive behind a pinch: the gesture supplies the incremental magnification.
422
+ """
423
+ factor = float(factor)
424
+ if not self._zoom_enabled or factor <= 0.0 or factor == 1.0:
425
+ return
426
+ self._stop_animation()
427
+ self._apply_view(*self._zoom_state(self._state(), 1.0 / factor, u, v, width, height))
428
+
429
+ @Slot(float, float, float, float, float, int)
430
+ def animate_zoom(
431
+ self, delta: float, u: float, v: float, width: float, height: float, duration: int
432
+ ) -> None:
433
+ """Animate one wheel/keyboard zoom step (``delta`` wheel units, 120 = one notch)."""
434
+ if not self._zoom_enabled or delta == 0:
435
+ return
436
+ factor = self._zoom_step ** (float(delta) / 120.0)
437
+ self._animate_to(
438
+ self._zoom_state(self._target_state(), factor, u, v, width, height), duration
439
+ )
440
+
441
+ @Slot(float, float, int)
442
+ def animate_pan(self, dx: float, dy: float, duration: int) -> None:
443
+ """Animate a pan by a pixel displacement (screen y points down)."""
444
+ if not self._pan_enabled or (dx == 0.0 and dy == 0.0):
445
+ return
446
+ bx, by, bsx, bsy = self._target_state()
447
+ self._animate_to((bx - dx * bsx, by + dy * bsy, bsx, bsy), duration)
448
+
286
449
  def _view_changed(self) -> None:
450
+ previous_key = self._ticks_key
451
+ previous_x, previous_y = self._ticks_x, self._ticks_y
452
+ self._ensure_ticks()
287
453
  self.viewChanged.emit()
288
- self.ticksChanged.emit()
454
+ # During a pan the tick labels usually stay the same; re-emitting TicksChanged would
455
+ # rebuild QML's tick Repeaters every frame for identical rows.
456
+ if (previous_key is None
457
+ or previous_x != self._ticks_x
458
+ or previous_y != self._ticks_y):
459
+ self.ticksChanged.emit()
289
460
 
290
461
  @Slot(float, float, float, float)
291
462
  def set_view(self, centre_x: float, centre_y: float, scale_x: float, scale_y: float) -> None:
@@ -296,6 +467,7 @@ class Camera(QObject):
296
467
  and restoring it afterwards. ``"auto"`` stays "auto" (the scales are simply set), a
297
468
  numeric aspect is re-derived from the pair so the two scales are exactly as asked.
298
469
  """
470
+ self._stop_animation()
299
471
  scale_x = min(max(float(scale_x), self.MIN_SCALE), self.MAX_SCALE)
300
472
  scale_y = min(max(float(scale_y), self.MIN_SCALE), self.MAX_SCALE)
301
473
  self._centre = QVector2D(float(centre_x), float(centre_y))
@@ -310,7 +482,7 @@ class Camera(QObject):
310
482
 
311
483
  # --------------------------------------------------------------- slots
312
484
  @Slot(float, float)
313
- def setViewport(self, width: float, height: float) -> None:
485
+ def set_viewport(self, width: float, height: float) -> None:
314
486
  """Record the widget size (logical pixels) — used by the derived ranges only.
315
487
 
316
488
  Drawing never depends on it: the shader derives its mapping from the camera and its
@@ -322,6 +494,7 @@ class Camera(QObject):
322
494
  size = (float(width), float(height))
323
495
  if size == self._size:
324
496
  return
497
+ self._stop_animation()
325
498
  self._size = size
326
499
  self._resolve_home()
327
500
  self._view_changed()
@@ -352,42 +525,23 @@ class Camera(QObject):
352
525
  """
353
526
  if not self._zoom_enabled or delta == 0:
354
527
  return
355
- target = min(max(self._zoom * self._zoom_step ** (delta / 120.0), self.MIN_SCALE),
356
- self.MAX_SCALE)
357
- factor = target / self._zoom
358
- if factor == 1.0:
359
- return
360
- if width > 0 and height > 0:
361
- anchor_x = self._centre.x() + (u - 0.5) * width * self.scale_x
362
- anchor_y = self._centre.y() + (0.5 - v) * height * self.scale_y
363
- else:
364
- anchor_x, anchor_y = self._centre.x(), self._centre.y()
365
- self._zoom = target
366
- if isinstance(self._aspect, str):
367
- self._y_scale = min(max(self._y_scale * factor, self.MIN_SCALE), self.MAX_SCALE)
368
- if width > 0 and height > 0:
369
- self._centre = QVector2D(
370
- anchor_x - (u - 0.5) * width * self.scale_x,
371
- anchor_y - (0.5 - v) * height * self.scale_y,
372
- )
373
- self._home = False
374
- self._view_changed()
528
+ factor = self._zoom_step ** (float(delta) / 120.0)
529
+ self._stop_animation()
530
+ self._apply_view(*self._zoom_state(self._state(), factor, u, v, width, height))
375
531
 
376
532
  @Slot(float, float)
377
533
  def pan_pixels(self, dx: float, dy: float) -> None:
378
534
  """Pan by a pixel displacement (screen y points down, world y points up)."""
379
535
  if not self._pan_enabled or (dx == 0.0 and dy == 0.0):
380
536
  return
381
- self._centre = QVector2D(
382
- self._centre.x() - dx * self.scale_x,
383
- self._centre.y() + dy * self.scale_y,
384
- )
385
- self._home = False
386
- self._view_changed()
537
+ self._stop_animation()
538
+ cx, cy, sx, sy = self._state()
539
+ self._apply_view(cx - dx * sx, cy + dy * sy, sx, sy)
387
540
 
388
541
  @Slot()
389
542
  def reset(self) -> None:
390
543
  """Back to the home view (centre and scales as the camera was configured)."""
544
+ self._stop_animation()
391
545
  self._centre = QVector2D(0.0, 0.0)
392
546
  self._home = True
393
547
  self._resolve_home()
qmlmathplot/canvas.py ADDED
@@ -0,0 +1,107 @@
1
+ """Matplotlib-shaped canvas: a QWidget that hosts one compatibility :class:`Figure`.
2
+
3
+ QtWidgets is imported here, so this module stays lazy in ``qmlmathplot.__init__`` exactly like
4
+ :mod:`qmlmathplot.widget`: a pure-QML app never loads QtWidgets.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from PySide6.QtWidgets import QWidget
12
+
13
+ from .widget import MathPlotWidget
14
+
15
+ if TYPE_CHECKING: # pragma: no cover - typing only
16
+ from .figure import Figure
17
+
18
+ __all__ = ["FigureCanvas", "FigureCanvasQt"]
19
+
20
+
21
+ class FigureCanvasQt(MathPlotWidget):
22
+ """A ``MathPlotWidget`` attached to a compatibility ``Figure``.
23
+
24
+ Matplotlib code constructs canvases and then calls ``draw()``; here the Qt scene is live,
25
+ so ``draw()`` / ``draw_idle()`` are harmless no-ops. ``print_figure`` and ``savefig``
26
+ forward to the same region-based export as :meth:`Figure.savefig`.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ figure: Figure | None = None,
32
+ parent: QWidget | None = None,
33
+ *,
34
+ width: int | None = None,
35
+ height: int | None = None,
36
+ dpi: float | None = None,
37
+ ) -> None:
38
+ from .figure import Figure
39
+
40
+ host = figure if figure is not None else Figure()
41
+ if dpi is not None:
42
+ host.set_dpi(dpi)
43
+ super().__init__(None, parent, plot=host.plot)
44
+ self._figure = host
45
+ host.canvas = self
46
+ target_width, target_height = host.get_size_pixels()
47
+ if width is not None:
48
+ target_width = int(width)
49
+ if height is not None:
50
+ target_height = int(height)
51
+ self.resize(target_width, target_height)
52
+
53
+ @property
54
+ def figure(self) -> "Figure":
55
+ return self._figure
56
+
57
+ @property
58
+ def figure_canvas(self) -> "FigureCanvasQt":
59
+ """Matplotlib's canvas property is called ``figure_canvas`` on some backends; this
60
+ alias keeps both spellings working."""
61
+ return self
62
+
63
+ # ---------------------------------------------------------- live scene
64
+ def draw(self) -> None:
65
+ """No-op: the scene repaints from model signals; there is no deferred renderer."""
66
+ self.update()
67
+
68
+ def draw_idle(self) -> None:
69
+ self.update()
70
+
71
+ def flush_events(self) -> None:
72
+ """No-op: Qt owns the event loop."""
73
+
74
+ # -------------------------------------------------------------- export
75
+ def print_figure(self, filename: str, **kwargs: object) -> None:
76
+ self._figure.savefig(filename, **kwargs)
77
+
78
+ def savefig(self, filename: str, **kwargs: object) -> None:
79
+ self._figure.savefig(filename, **kwargs)
80
+
81
+ # ------------------------------------------------------------- geometry
82
+ def get_width_height(self, physical: bool = False) -> tuple[int, int]:
83
+ if physical:
84
+ ratio = self.devicePixelRatioF()
85
+ return (round(self.width() * ratio), round(self.height() * ratio))
86
+ return (self.width(), self.height())
87
+
88
+ @property
89
+ def device_pixel_ratio(self) -> float:
90
+ return self.devicePixelRatioF()
91
+
92
+ # ------------------------------------------------------ explicitly absent
93
+ def mpl_connect(self, *args: object, **kwargs: object) -> None:
94
+ raise NotImplementedError(
95
+ "mpl_connect is not part of QMLMathPlot: Qt signals are the event system "
96
+ "(camera.viewChanged, camera.aspectChanged, curve.expressionChanged, …)"
97
+ )
98
+
99
+ def blit(self, *args: object, **kwargs: object) -> None:
100
+ raise NotImplementedError(
101
+ "blit is not part of QMLMathPlot: the Qt scene is live, there is no raster "
102
+ "buffer to restore"
103
+ )
104
+
105
+
106
+ #: The shorter spelling used by ``matplotlib.backends.backend_qt*``.
107
+ FigureCanvas = FigureCanvasQt