shape-plotters 0.1.0__tar.gz → 0.2.0__tar.gz

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 (48) hide show
  1. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/CHANGELOG.md +17 -0
  2. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/PKG-INFO +5 -1
  3. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/README.md +4 -0
  4. shape_plotters-0.2.0/shape_plotters/core/blit.py +103 -0
  5. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/engine.py +126 -4
  6. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/widgets.py +7 -0
  7. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/PKG-INFO +5 -1
  8. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/SOURCES.txt +4 -0
  9. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/scm_file_list.json +4 -0
  10. shape_plotters-0.2.0/tests/dragging.py +82 -0
  11. shape_plotters-0.2.0/tests/test_blit.py +107 -0
  12. shape_plotters-0.2.0/tests/test_engine_drag.py +291 -0
  13. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_perf.py +46 -4
  14. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_widgets.py +31 -0
  15. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/.github/workflows/wheels.yml +0 -0
  16. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/.gitignore +0 -0
  17. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/LICENSE +0 -0
  18. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/conftest.py +0 -0
  19. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/pyproject.toml +0 -0
  20. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/setup.cfg +0 -0
  21. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/__init__.py +0 -0
  22. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/__main__.py +0 -0
  23. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/cli.py +0 -0
  24. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/__init__.py +0 -0
  25. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/energy.py +0 -0
  26. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/fragments.py +0 -0
  27. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/neck.py +0 -0
  28. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/nodes.py +0 -0
  29. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/quadrature.py +0 -0
  30. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/core/result.py +0 -0
  31. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/renders/__init__.py +0 -0
  32. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/renders/beta.py +0 -0
  33. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters/renders/fos.py +0 -0
  34. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/dependency_links.txt +0 -0
  35. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/entry_points.txt +0 -0
  36. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/requires.txt +0 -0
  37. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/shape_plotters.egg-info/top_level.txt +0 -0
  38. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/__init__.py +0 -0
  39. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_beta_render.py +0 -0
  40. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_cli.py +0 -0
  41. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_energy.py +0 -0
  42. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_energy_local.py +0 -0
  43. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_engine.py +0 -0
  44. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_fos_render.py +0 -0
  45. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_fragments.py +0 -0
  46. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_neck.py +0 -0
  47. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_nodes.py +0 -0
  48. {shape_plotters-0.1.0 → shape_plotters-0.2.0}/tests/test_quadrature.py +0 -0
@@ -2,6 +2,23 @@
2
2
 
3
3
  Notable changes to shape-plotters. Versions follow semantic versioning.
4
4
 
5
+ ## 0.2.0
6
+
7
+ Interactive performance. Measured in a real window on the development PC
8
+ (matplotlib 3.11): a drag step drops from about 110 ms to 33-46 ms, a click
9
+ on a slider from about 300 ms to about 105 ms.
10
+
11
+ ### Changed
12
+
13
+ - **Slider drags are blitted.** While a slider is dragged, only the curves,
14
+ the stats text and that slider are redrawn; ticks, legends and the other
15
+ widgets come from a cached background. The axes hold still during the drag
16
+ and refit on release; if the shape outgrows the view, the view jumps out
17
+ once to a looser fit. During a drag the curves draw on top of legends.
18
+ Everything outside a drag, including saved PNGs, is drawn exactly as before.
19
+ - **A click costs one redraw instead of three.** Every mouse press used to
20
+ trigger two extra full redraws, one per Z/N text box.
21
+
5
22
  ## 0.1.0
6
23
 
7
24
  First release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shape-plotters
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Interactive matplotlib plotters for axially symmetric nuclear shapes (beta, FoS)
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/AleksanderAugustyn/shape-plotters
@@ -53,6 +53,10 @@ boxes (R₀ = 1.16 A^(1/3) fm), Reset, and Save (300 dpi PNG named after the
53
53
  parameters, written to the current directory). Red dotted marks on the FoS
54
54
  sliders are practical limits.
55
55
 
56
+ While a slider is dragged the axes hold still, so the shape visibly changes
57
+ instead of the axes rescaling around it; they refit when the mouse is
58
+ released. If the shape outgrows the view mid-drag, the view jumps out once.
59
+
56
60
  ## Requirements
57
61
 
58
62
  - Linux x86-64. The parameterization libraries ship manylinux wheels only;
@@ -33,6 +33,10 @@ boxes (R₀ = 1.16 A^(1/3) fm), Reset, and Save (300 dpi PNG named after the
33
33
  parameters, written to the current directory). Red dotted marks on the FoS
34
34
  sliders are practical limits.
35
35
 
36
+ While a slider is dragged the axes hold still, so the shape visibly changes
37
+ instead of the axes rescaling around it; they refit when the mouse is
38
+ released. If the shape outgrows the view mid-drag, the view jumps out once.
39
+
36
40
  ## Requirements
37
41
 
38
42
  - Linux x86-64. The parameterization libraries ship manylinux wheels only;
@@ -0,0 +1,103 @@
1
+ """Background cache for blitted slider drags.
2
+
3
+ During a drag only a few artists change: the data lines, the stats text and
4
+ the dragged slider. DragBlitter keeps a copy of everything else (the static
5
+ layer) and composes each frame from that copy plus the dynamic artists,
6
+ instead of redrawing the whole figure.
7
+
8
+ It knows pixels, not shapes. What counts as a change to the static layer
9
+ (view limits, titles, legends) is the engine's policy; the engine reports it
10
+ through invalidate().
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+
16
+ from matplotlib.artist import Artist
17
+ from matplotlib.axes import Axes
18
+ from matplotlib.figure import Figure
19
+
20
+
21
+ class DragBlitter:
22
+ """Owns the static background and the ``animated`` flags for one drag.
23
+
24
+ Parameters
25
+ ----------
26
+ fig : Figure
27
+ The figure to compose frames on. Its canvas must support blitting.
28
+ dynamic : Sequence[Artist]
29
+ Artists that change on every frame of a drag.
30
+ """
31
+
32
+ def __init__(self, fig: Figure, dynamic: Sequence[Artist]) -> None:
33
+ self._fig = fig
34
+ self._dynamic = tuple(dynamic)
35
+ self._slider_ax: Axes | None = None
36
+ self._background = None
37
+ fig.canvas.mpl_connect("draw_event", self._on_draw)
38
+ # A window resize only schedules its redraw; a motion event handled
39
+ # before it must not blit the old-size background.
40
+ fig.canvas.mpl_connect("resize_event", lambda _event: self.invalidate())
41
+
42
+ @property
43
+ def active(self) -> bool:
44
+ """True between begin() and end()."""
45
+ return self._slider_ax is not None
46
+
47
+ @property
48
+ def ready(self) -> bool:
49
+ """True when a background matching the current static layer is held."""
50
+ return self._background is not None
51
+
52
+ def begin(self, slider_ax: Axes) -> None:
53
+ """Enter drag mode; full draws now leave the dynamic layer to us.
54
+
55
+ The whole slider axes is animated, not its handle and value text
56
+ individually: no private Slider attributes, and the value text cannot
57
+ overprint the copy baked into the background.
58
+ """
59
+ self._slider_ax = slider_ax
60
+ self._background = None
61
+ for artist in (*self._dynamic, slider_ax):
62
+ artist.set_animated(True)
63
+
64
+ def end(self) -> None:
65
+ """Leave drag mode; the next full draw is an ordinary one."""
66
+ if self._slider_ax is None:
67
+ return
68
+ for artist in (*self._dynamic, self._slider_ax):
69
+ artist.set_animated(False)
70
+ self._slider_ax = None
71
+ self._background = None
72
+
73
+ def invalidate(self) -> None:
74
+ """The static layer changed; the next full draw recaptures it."""
75
+ self._background = None
76
+
77
+ def blit(self) -> None:
78
+ """Compose one frame: the saved background plus the dynamic layer."""
79
+ canvas = self._fig.canvas
80
+ canvas.restore_region(self._background)
81
+ self._draw_dynamic()
82
+ canvas.blit(self._fig.bbox)
83
+
84
+ def _on_draw(self, event) -> None:
85
+ # Runs at the end of every full draw. In drag mode that draw skipped
86
+ # the animated artists, so the canvas holds exactly the static layer.
87
+ if self._slider_ax is None:
88
+ return
89
+ if self._fig.canvas.is_saving():
90
+ # An export (savefig), not a screen draw. It already drew the
91
+ # animated artists, except whole animated axes: add the slider.
92
+ # It may also have re-rendered the canvas at another size or on
93
+ # another canvas class, so the held background is no longer valid.
94
+ self._slider_ax.draw(event.renderer)
95
+ self._background = None
96
+ return
97
+ self._background = self._fig.canvas.copy_from_bbox(self._fig.bbox)
98
+ self._draw_dynamic()
99
+
100
+ def _draw_dynamic(self) -> None:
101
+ for artist in self._dynamic:
102
+ self._fig.draw_artist(artist)
103
+ self._fig.draw_artist(self._slider_ax)
@@ -8,14 +8,24 @@ Artists are created once at build; update() only mutates data, text, and
8
8
  visibility. v0.1 cleared and replotted every axes per slider event — legend
9
9
  and text layout made that the frame-time bottleneck (see
10
10
  docs/superpowers/specs/2026-07-03-engine-v0.2-performance-design.md).
11
+
12
+ A full-figure redraw is still ~100 ms, almost all of it ticks, legends and
13
+ widget axes that do not change. While a slider is dragged, update() therefore
14
+ blits: the static layer comes from a cached background (blit.py) and only the
15
+ dynamic artists and the dragged slider are drawn. Everything outside a drag
16
+ takes the ordinary full-draw path, so a settled figure is pixel-identical to
17
+ one that was never dragged (see
18
+ docs/superpowers/specs/2026-10-03-engine-v0.3-blitting-design.md).
11
19
  """
12
20
  from __future__ import annotations
13
21
 
14
22
  import numpy as np
15
23
  import matplotlib.pyplot as plt
24
+ from matplotlib.axes import Axes
16
25
  from matplotlib.widgets import Button, CheckButtons
17
26
 
18
27
  from shape_plotters.core import energy, fragments, quadrature
28
+ from shape_plotters.core.blit import DragBlitter
19
29
  from shape_plotters.core.result import ShapeResult
20
30
  from shape_plotters.core.widgets import IntTextBox, SliderRow
21
31
 
@@ -33,6 +43,10 @@ UNITS_LABEL = "fm units"
33
43
  # R0 = 1.16 fm equals WMMM's geometric R0; see FORD.md / v0.2 handoff).
34
44
  SCISSION_BAND_FM = (1.2, 1.5)
35
45
 
46
+ # Headroom of a mid-drag refit, as an autoscale margin: when a dragged shape
47
+ # outgrows the held view, the view jumps out this far so the next frames fit.
48
+ DRAG_MARGIN = 0.25
49
+
36
50
 
37
51
  class ShapePlotterApp:
38
52
  def __init__(self, render) -> None:
@@ -47,6 +61,12 @@ class ShapePlotterApp:
47
61
  self._build_figure()
48
62
  self._build_artists()
49
63
  self._build_widgets()
64
+ # Blitted slider drags; None where the canvas cannot blit.
65
+ self._blitter = (DragBlitter(self.fig, self._dynamic)
66
+ if self.fig.canvas.supports_blit else None)
67
+ self._drag_pending = None # slider whose press was drawn the ordinary way
68
+ self._dragged = None # slider the blitter was begun on
69
+ self.fig.canvas.mpl_connect("button_release_event", self._on_release)
50
70
  self.update()
51
71
 
52
72
  # ---------- construction ----------
@@ -66,6 +86,8 @@ class ShapePlotterApp:
66
86
  self.ax_extra = self.fig.add_subplot(gs[2]) if self.render.has_extra_panel else None
67
87
  self.ax_stats = self.fig.add_subplot(gs[ncols])
68
88
  self.ax_stats.axis("off")
89
+ self._data_axes = tuple(
90
+ ax for ax in (self.ax_radius, self.ax_shape, self.ax_extra) if ax is not None)
69
91
 
70
92
  def _build_artists(self) -> None:
71
93
  # fm_units defaults to True; the unit toggle re-texts labels in place.
@@ -132,6 +154,16 @@ class ShapePlotterApp:
132
154
  0.0, 1.0, "", va="top", family="monospace", fontsize=12,
133
155
  transform=self.ax_stats.transAxes)
134
156
 
157
+ # Everything update() mutates per frame. The rest of the figure is the
158
+ # static layer that a blitted drag frame reuses.
159
+ dynamic = [self.r_line, self.dr_line, self.shape_upper, self.shape_lower,
160
+ self.neck_line, self.zcm_point, self.rtheta_upper,
161
+ self.rtheta_lower, self.rtheta_zcm, self.stats_text]
162
+ if self.ax_extra is not None:
163
+ dynamic += [self.extra_rho, self.extra_drho, self.zcm_extra]
164
+ self._dynamic = tuple(dynamic)
165
+ self._rest_margins = {ax: ax.margins() for ax in self._data_axes}
166
+
135
167
  def _build_widgets(self) -> None:
136
168
  self.rows: dict[str, SliderRow] = {}
137
169
  y = 0.06
@@ -229,6 +261,9 @@ class ShapePlotterApp:
229
261
  self.rows[spec.key].slider.set_val(spec.vinit)
230
262
 
231
263
  def _save(self, _event=None) -> None:
264
+ # A drag whose release never arrived must not leak its held view into
265
+ # the file: leave drag mode first.
266
+ self._on_release(None)
232
267
  params = {k: row.slider.val for k, row in self.rows.items()}
233
268
  fname = self.render.filename(self.z_box.value, self.n_box.value, params)
234
269
  self.fig.savefig(fname, dpi=300, bbox_inches="tight")
@@ -281,6 +316,8 @@ class ShapePlotterApp:
281
316
  return R0_FM * float(self.z_box.value + self.n_box.value) ** (1.0 / 3.0)
282
317
 
283
318
  def update(self, _val=None) -> None:
319
+ dragging = self._sync_drag_mode()
320
+ static_before = (self.ax_shape.get_title(), self.shape_legend.get_visible())
284
321
  params = {k: row.slider.val for k, row in self.rows.items()}
285
322
  result = self.render.compute(params, self.toggle_state)
286
323
  self.last_result = result
@@ -382,12 +419,97 @@ class ShapePlotterApp:
382
419
  self._energy_lines = [] # any shape/Z/N/unit change invalidates energies
383
420
  self._refresh_stats()
384
421
 
385
- # visible_only: the hidden neck line keeps stale data by design.
386
- for ax in (self.ax_radius, self.ax_shape, self.ax_extra):
387
- if ax is not None:
422
+ static_changed = static_before != (
423
+ self.ax_shape.get_title(), self.shape_legend.get_visible())
424
+ self._present(dragging, static_changed)
425
+
426
+ # ---------- presenting a frame ----------
427
+
428
+ def _sync_drag_mode(self) -> bool:
429
+ """Match drag mode to the sliders; True while a drag is being blitted.
430
+
431
+ A click is not a drag: the first update of a press is drawn the
432
+ ordinary way and drag mode starts with the next one. A plain click
433
+ then costs one full draw, not a drag-mode draw plus the release refit.
434
+ """
435
+ if self._blitter is None:
436
+ return False
437
+ slider = next((row.slider for row in self.rows.values()
438
+ if row.slider.drag_active), None)
439
+ if slider is None:
440
+ self._drag_pending = None
441
+ if self._blitter.active: # the release event never arrived
442
+ self._end_drag()
443
+ return False
444
+ if self._blitter.active:
445
+ if slider is self._dragged:
446
+ return True
447
+ # A stale drag after a lost release, and another slider pressed:
448
+ # blitting on would keep the new slider frozen in the background.
449
+ self._end_drag()
450
+ if self._drag_pending is not slider:
451
+ self._drag_pending = slider
452
+ return False
453
+ self._dragged = slider
454
+ self._blitter.begin(slider.ax)
455
+ return True
456
+
457
+ def _present(self, dragging: bool, static_changed: bool) -> None:
458
+ """Put the mutated artists on screen: a full draw, or a blitted drag frame.
459
+
460
+ static_changed says whether this update touched the static layer (the
461
+ cross-section title or the overlay legend); such a frame cannot blit.
462
+ """
463
+ if not dragging:
464
+ # visible_only: the hidden neck line keeps stale data by design.
465
+ for ax in self._data_axes:
388
466
  ax.relim(visible_only=True)
389
467
  ax.autoscale_view()
390
- self.fig.canvas.draw_idle()
468
+ self.fig.canvas.draw_idle()
469
+ return
470
+ # Dragging: the view holds still, because rescaling changes the tick
471
+ # labels and with them the cached background.
472
+ for ax in self._data_axes:
473
+ ax.relim(visible_only=True)
474
+ if not all(self._view_holds(ax) for ax in self._data_axes):
475
+ for ax in self._data_axes:
476
+ ax.set_xmargin(DRAG_MARGIN)
477
+ ax.set_ymargin(DRAG_MARGIN)
478
+ ax.autoscale_view()
479
+ static_changed = True
480
+ if static_changed or not self._blitter.ready:
481
+ # Until that draw has run, later motion events land here again and
482
+ # re-request it (coalesced) instead of blitting a stale background.
483
+ self._blitter.invalidate()
484
+ self.fig.canvas.draw_idle()
485
+ else:
486
+ self._blitter.blit()
487
+
488
+ @staticmethod
489
+ def _view_holds(ax: Axes) -> bool:
490
+ """True when the data limits lie inside the view on every autoscaled axis."""
491
+ data, view = ax.dataLim, ax.viewLim
492
+ x0, x1 = sorted(view.intervalx)
493
+ y0, y1 = sorted(view.intervaly)
494
+ holds_x = not ax.get_autoscalex_on() or (x0 <= data.x0 and data.x1 <= x1)
495
+ holds_y = not ax.get_autoscaley_on() or (y0 <= data.y0 and data.y1 <= y1)
496
+ return holds_x and holds_y
497
+
498
+ def _end_drag(self) -> None:
499
+ """Leave drag mode and restore the tight view the non-drag path draws."""
500
+ self._blitter.end()
501
+ for ax in self._data_axes:
502
+ xmargin, ymargin = self._rest_margins[ax]
503
+ ax.set_xmargin(xmargin)
504
+ ax.set_ymargin(ymargin)
505
+ ax.relim(visible_only=True)
506
+ ax.autoscale_view()
507
+
508
+ def _on_release(self, _event) -> None:
509
+ self._drag_pending = None
510
+ if self._blitter is not None and self._blitter.active:
511
+ self._end_drag()
512
+ self.fig.canvas.draw_idle()
391
513
 
392
514
  def _apply_validity(self, ok: bool) -> None:
393
515
  # Color/alpha churn only on the valid<->invalid flip, not per frame.
@@ -52,6 +52,13 @@ class IntTextBox:
52
52
  # keeping the intended stop-typing-on-resize behavior.
53
53
  fig.canvas.mpl_disconnect(self.box._cids[-1])
54
54
  fig.canvas.mpl_connect("resize_event", lambda _e: self.box.stop_typing())
55
+ # TextBox.stop_typing() redraws the whole figure on every mouse press
56
+ # outside the box, typing or not. With the Z and N boxes that is two
57
+ # full redraws per click anywhere in the figure, so only stop when
58
+ # the box is actually being typed in.
59
+ stop_typing = self.box.stop_typing
60
+ self.box.stop_typing = (
61
+ lambda: stop_typing() if self.box.capturekeystrokes else None)
55
62
  self.value = int(initial)
56
63
  self._on_change = on_change
57
64
  self.box.on_submit(self._submit)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shape-plotters
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Interactive matplotlib plotters for axially symmetric nuclear shapes (beta, FoS)
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/AleksanderAugustyn/shape-plotters
@@ -53,6 +53,10 @@ boxes (R₀ = 1.16 A^(1/3) fm), Reset, and Save (300 dpi PNG named after the
53
53
  parameters, written to the current directory). Red dotted marks on the FoS
54
54
  sliders are practical limits.
55
55
 
56
+ While a slider is dragged the axes hold still, so the shape visibly changes
57
+ instead of the axes rescaling around it; they refit when the mouse is
58
+ released. If the shape outgrows the view mid-drag, the view jumps out once.
59
+
56
60
  ## Requirements
57
61
 
58
62
  - Linux x86-64. The parameterization libraries ship manylinux wheels only;
@@ -16,6 +16,7 @@ shape_plotters.egg-info/requires.txt
16
16
  shape_plotters.egg-info/scm_file_list.json
17
17
  shape_plotters.egg-info/top_level.txt
18
18
  shape_plotters/core/__init__.py
19
+ shape_plotters/core/blit.py
19
20
  shape_plotters/core/energy.py
20
21
  shape_plotters/core/engine.py
21
22
  shape_plotters/core/fragments.py
@@ -28,11 +29,14 @@ shape_plotters/renders/__init__.py
28
29
  shape_plotters/renders/beta.py
29
30
  shape_plotters/renders/fos.py
30
31
  tests/__init__.py
32
+ tests/dragging.py
31
33
  tests/test_beta_render.py
34
+ tests/test_blit.py
32
35
  tests/test_cli.py
33
36
  tests/test_energy.py
34
37
  tests/test_energy_local.py
35
38
  tests/test_engine.py
39
+ tests/test_engine_drag.py
36
40
  tests/test_fos_render.py
37
41
  tests/test_fragments.py
38
42
  tests/test_neck.py
@@ -11,6 +11,7 @@
11
11
  "shape_plotters/__main__.py",
12
12
  "shape_plotters/cli.py",
13
13
  "shape_plotters/core/__init__.py",
14
+ "shape_plotters/core/blit.py",
14
15
  "shape_plotters/core/energy.py",
15
16
  "shape_plotters/core/engine.py",
16
17
  "shape_plotters/core/fragments.py",
@@ -23,11 +24,14 @@
23
24
  "shape_plotters/renders/beta.py",
24
25
  "shape_plotters/renders/fos.py",
25
26
  "tests/__init__.py",
27
+ "tests/dragging.py",
26
28
  "tests/test_beta_render.py",
29
+ "tests/test_blit.py",
27
30
  "tests/test_cli.py",
28
31
  "tests/test_energy.py",
29
32
  "tests/test_energy_local.py",
30
33
  "tests/test_engine.py",
34
+ "tests/test_engine_drag.py",
31
35
  "tests/test_fos_render.py",
32
36
  "tests/test_fragments.py",
33
37
  "tests/test_neck.py",
@@ -0,0 +1,82 @@
1
+ """Simulated slider drags for tests: real MouseEvents on the slider axes.
2
+
3
+ Under Agg, draw_idle() draws immediately, so counting draw_event callbacks
4
+ counts full-figure redraws.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+ from matplotlib.backend_bases import MouseEvent
10
+
11
+ from shape_plotters.core.engine import ShapePlotterApp
12
+
13
+
14
+ def steps(start: float, stop: float, step: float) -> list[float]:
15
+ """Inclusive slider values from start to stop, on the 0.01 slider grid."""
16
+ count = round((stop - start) / step)
17
+ return [round(start + i * step, 2) for i in range(count + 1)]
18
+
19
+
20
+ def _mouse(app: ShapePlotterApp, name: str, key: str, value: float) -> None:
21
+ slider = app.rows[key].slider
22
+ x, y = slider.ax.transData.transform((value, 0.5))
23
+ canvas = app.fig.canvas
24
+ canvas.callbacks.process(name, MouseEvent(name, canvas, x, y, button=1))
25
+
26
+
27
+ def press(app: ShapePlotterApp, key: str, value: float) -> None:
28
+ _mouse(app, "button_press_event", key, value)
29
+
30
+
31
+ def move(app: ShapePlotterApp, key: str, value: float) -> None:
32
+ _mouse(app, "motion_notify_event", key, value)
33
+
34
+
35
+ def release(app: ShapePlotterApp, key: str) -> None:
36
+ _mouse(app, "button_release_event", key, app.rows[key].slider.val)
37
+
38
+
39
+ def begin_drag(app: ShapePlotterApp, key: str, values: list[float]) -> list[float]:
40
+ """Press and move until frames are being blitted; returns the unused values.
41
+
42
+ The press and the first motion are either drawn the ordinary way or start
43
+ drag mode, depending on whether the press itself moved the slider. After
44
+ two motions the background is captured either way.
45
+ """
46
+ press(app, key, values[0])
47
+ move(app, key, values[1])
48
+ move(app, key, values[2])
49
+ assert app._blitter.active and app._blitter.ready
50
+ return values[3:]
51
+
52
+
53
+ def pixels(app: ShapePlotterApp) -> np.ndarray:
54
+ return np.asarray(app.fig.canvas.buffer_rgba()).copy()
55
+
56
+
57
+ def count_draws(app: ShapePlotterApp) -> list[int]:
58
+ """A list that grows by one on every full-figure redraw from now on."""
59
+ draws: list[int] = []
60
+ app.fig.canvas.mpl_connect("draw_event", lambda _event: draws.append(1))
61
+ return draws
62
+
63
+
64
+ def animated(app: ShapePlotterApp) -> list:
65
+ """Artists still flagged animated: must be empty outside a drag."""
66
+ candidates = (*app._dynamic, *(row.slider.ax for row in app.rows.values()))
67
+ return [artist for artist in candidates if artist.get_animated()]
68
+
69
+
70
+ def data_inside_view(app: ShapePlotterApp) -> bool:
71
+ """True when every visible line on the data panels lies inside its axes limits."""
72
+ for ax in app._data_axes:
73
+ x0, x1 = sorted(ax.get_xlim())
74
+ y0, y1 = sorted(ax.get_ylim())
75
+ for line in ax.lines:
76
+ x = np.asarray(line.get_xdata(), dtype=float)
77
+ y = np.asarray(line.get_ydata(), dtype=float)
78
+ if not line.get_visible() or x.size == 0:
79
+ continue
80
+ if x.min() < x0 or x.max() > x1 or y.min() < y0 or y.max() > y1:
81
+ return False
82
+ return True
@@ -0,0 +1,107 @@
1
+ """DragBlitter on a bare figure: background capture, frame composition, cleanup."""
2
+ import io
3
+
4
+ import matplotlib.pyplot as plt
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from shape_plotters.core.blit import DragBlitter
9
+
10
+
11
+ def _pixels(fig) -> np.ndarray:
12
+ return np.asarray(fig.canvas.buffer_rgba()).copy()
13
+
14
+
15
+ @pytest.fixture
16
+ def scene():
17
+ """A plot axes with one dynamic line, and a small axes standing in for a slider."""
18
+ fig = plt.figure(figsize=(6, 4))
19
+ ax = fig.add_axes((0.1, 0.3, 0.8, 0.6))
20
+ (line,) = ax.plot([0.0, 1.0], [0.0, 1.0], lw=2)
21
+ ax.set_xlim(0.0, 1.0)
22
+ ax.set_ylim(-1.5, 1.5)
23
+ knob_ax = fig.add_axes((0.1, 0.1, 0.8, 0.05))
24
+ (knob,) = knob_ax.plot([0.2], [0.5], marker="o")
25
+ knob_ax.set_xlim(0.0, 1.0)
26
+ knob_ax.set_ylim(0.0, 1.0)
27
+ blitter = DragBlitter(fig, [line])
28
+ fig.canvas.draw()
29
+ yield fig, ax, line, knob_ax, knob, blitter
30
+ plt.close(fig)
31
+
32
+
33
+ def test_blitted_frame_equals_full_redraw(scene) -> None:
34
+ fig, _ax, line, knob_ax, knob, blitter = scene
35
+ blitter.begin(knob_ax)
36
+ fig.canvas.draw() # captures the static background
37
+ before = _pixels(fig)
38
+ line.set_ydata([1.0, -1.0]) # the dynamic layer changes
39
+ knob.set_xdata([0.8]) # and so does the dragged control
40
+ blitter.blit()
41
+ blitted = _pixels(fig)
42
+ fig.canvas.draw()
43
+ assert (blitted != before).any() # the frame really changed
44
+ assert np.array_equal(blitted, _pixels(fig))
45
+
46
+
47
+ def test_background_exists_only_after_a_draw_in_drag_mode(scene) -> None:
48
+ fig, _ax, _line, knob_ax, _knob, blitter = scene
49
+ assert not blitter.active and not blitter.ready # the fixture's draw captured nothing
50
+ blitter.begin(knob_ax)
51
+ assert blitter.active and not blitter.ready
52
+ fig.canvas.draw()
53
+ assert blitter.ready
54
+ blitter.invalidate()
55
+ assert not blitter.ready
56
+ fig.canvas.draw()
57
+ assert blitter.ready
58
+
59
+
60
+ def test_foreign_draw_mid_drag_recaptures_background(scene) -> None:
61
+ fig, ax, line, knob_ax, _knob, blitter = scene
62
+ blitter.begin(knob_ax)
63
+ fig.canvas.draw()
64
+ ax.set_title("static change") # stands in for a resize or expose
65
+ fig.canvas.draw() # a full draw the blitter did not ask for
66
+ line.set_ydata([1.0, -1.0])
67
+ blitter.blit()
68
+ blitted = _pixels(fig)
69
+ fig.canvas.draw()
70
+ assert np.array_equal(blitted, _pixels(fig)) # the title is in the blitted frame
71
+
72
+
73
+ def test_end_restores_ordinary_drawing(scene) -> None:
74
+ fig, _ax, line, knob_ax, _knob, blitter = scene
75
+ reference = _pixels(fig) # drawn before any drag
76
+ blitter.begin(knob_ax)
77
+ fig.canvas.draw()
78
+ blitter.end()
79
+ fig.canvas.draw()
80
+ assert not blitter.active and not blitter.ready
81
+ assert not line.get_animated() and not knob_ax.get_animated()
82
+ assert np.array_equal(_pixels(fig), reference)
83
+
84
+
85
+ def test_export_mid_drag_is_complete_and_drops_the_background(scene) -> None:
86
+ # savefig can run while a drag is active (the toolbar's save key, or a
87
+ # Save click after a lost release). It is not a screen draw: it must not
88
+ # become the background, and the exported file must not miss anything.
89
+ fig, _ax, line, knob_ax, _knob, blitter = scene
90
+
91
+ def export(fmt: str) -> bytes:
92
+ buffer = io.BytesIO()
93
+ fig.savefig(buffer, format=fmt, dpi=100)
94
+ return buffer.getvalue()
95
+
96
+ reference = export("rgba") # saved before any drag
97
+ blitter.begin(knob_ax)
98
+ fig.canvas.draw()
99
+ assert export("rgba") == reference # line and slider axes present, each drawn once
100
+ export("pdf") # a canvas that cannot copy regions
101
+ assert not blitter.ready # the export replaced the canvas contents
102
+ fig.canvas.draw()
103
+ line.set_ydata([1.0, -1.0])
104
+ blitter.blit()
105
+ blitted = _pixels(fig)
106
+ fig.canvas.draw()
107
+ assert np.array_equal(blitted, _pixels(fig))
@@ -0,0 +1,291 @@
1
+ """Slider drags: blitted frames, the held view, and the return to ordinary drawing."""
2
+ import io
3
+
4
+ import matplotlib.pyplot as plt
5
+ import numpy as np
6
+ import pytest
7
+ from matplotlib.backend_bases import ResizeEvent
8
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
9
+
10
+ from shape_plotters.core.engine import ShapePlotterApp
11
+ from shape_plotters.renders.beta import BetaRender
12
+ from shape_plotters.renders.fos import FoSRender
13
+ from tests.dragging import (animated, begin_drag, count_draws, data_inside_view, move,
14
+ pixels, press, release, steps)
15
+
16
+ # Drags that shrink the shape: the data never leaves the view and neither the
17
+ # title nor the overlay legend changes, so every frame after the start can blit.
18
+ SHRINKING = [
19
+ pytest.param(BetaRender, {"beta2": 1.0}, "beta2", (1.0, 0.85, -0.01), id="beta"),
20
+ pytest.param(FoSRender, {"c": 1.6, "a4": 0.3}, "a4", (0.3, 0.15, -0.01), id="fos"),
21
+ ]
22
+
23
+
24
+ @pytest.fixture
25
+ def make_app():
26
+ apps: list[ShapePlotterApp] = []
27
+
28
+ def make(render_cls, **presets: float) -> ShapePlotterApp:
29
+ app = ShapePlotterApp(render_cls())
30
+ for key, value in presets.items():
31
+ app.rows[key].slider.set_val(value)
32
+ app.fig.canvas.draw()
33
+ apps.append(app)
34
+ return app
35
+
36
+ yield make
37
+ for app in apps:
38
+ plt.close(app.fig)
39
+
40
+
41
+ # ---------- a click is not a drag ----------
42
+
43
+ def test_click_costs_one_full_draw_and_is_not_a_drag(make_app) -> None:
44
+ app = make_app(BetaRender)
45
+ draws = count_draws(app)
46
+ press(app, "beta2", 0.3)
47
+ release(app, "beta2")
48
+ assert len(draws) == 1
49
+ assert not app._blitter.active and animated(app) == []
50
+ reference = make_app(BetaRender, beta2=0.3)
51
+ assert np.array_equal(pixels(app), pixels(reference))
52
+
53
+
54
+ # ---------- blitted frames ----------
55
+
56
+ @pytest.mark.parametrize("render_cls, presets, key, span", SHRINKING)
57
+ def test_drag_frames_are_blitted_not_redrawn(make_app, render_cls, presets, key, span) -> None:
58
+ app = make_app(render_cls, **presets)
59
+ rest = begin_drag(app, key, steps(*span))
60
+ draws = count_draws(app)
61
+ for value in rest:
62
+ move(app, key, value)
63
+ assert draws == [] # no full-figure redraw
64
+ assert app.rows[key].slider.val == pytest.approx(span[1])
65
+ assert np.array_equal(app.r_line.get_ydata(),
66
+ app.last_result.radius * app._scale())
67
+
68
+
69
+ @pytest.mark.parametrize("render_cls, presets, key, span", SHRINKING + [
70
+ # The neck line disappears on the way: a dynamic artist turning invisible.
71
+ pytest.param(BetaRender, {"beta2": 1.2}, "beta2", (1.2, 0.4, -0.04),
72
+ id="beta-neck-vanishes"),
73
+ ])
74
+ def test_every_blitted_frame_equals_a_full_redraw(
75
+ make_app, render_cls, presets, key, span) -> None:
76
+ app = make_app(render_cls, **presets)
77
+ for value in begin_drag(app, key, steps(*span)):
78
+ move(app, key, value)
79
+ blitted = pixels(app)
80
+ app.fig.canvas.draw()
81
+ assert np.array_equal(blitted, pixels(app)), f"frame at {key}={value}"
82
+
83
+
84
+ def test_widgets_look_the_same_in_blitted_frames(make_app) -> None:
85
+ # Everything below the panels: the dragged slider (handle, fill, value
86
+ # text), the other sliders, buttons and the check marks, which CheckButtons
87
+ # draws through its own blit machinery. A blitted frame must show them
88
+ # exactly as the full redraw after the release does.
89
+ app = make_app(BetaRender, beta2=1.0)
90
+ for value in begin_drag(app, "beta2", steps(1.0, 0.9, -0.01)):
91
+ move(app, "beta2", value)
92
+ panels = (*app._data_axes, app.ax_stats)
93
+ widgets_top = max(ax.bbox.y1 for ax in app.fig.axes if ax not in panels)
94
+ height = pixels(app).shape[0]
95
+ widget_rows = slice(height - int(widgets_top), height) # image rows run top to bottom
96
+ during = pixels(app)[widget_rows]
97
+ release(app, "beta2")
98
+ assert np.array_equal(during, pixels(app)[widget_rows])
99
+
100
+
101
+ # ---------- leaving drag mode ----------
102
+
103
+ @pytest.mark.parametrize("render_cls, presets, key, span", SHRINKING)
104
+ def test_release_restores_ordinary_drawing(make_app, render_cls, presets, key, span) -> None:
105
+ app = make_app(render_cls, **presets)
106
+ for value in begin_drag(app, key, steps(*span)):
107
+ move(app, key, value)
108
+ release(app, key)
109
+ assert not app._blitter.active and animated(app) == []
110
+ reference = make_app(render_cls, **{**presets, key: span[1]})
111
+ assert np.array_equal(pixels(app), pixels(reference))
112
+
113
+
114
+ def test_updates_outside_a_drag_never_enter_drag_mode(make_app) -> None:
115
+ app = make_app(BetaRender)
116
+ app.rows["beta2"].slider.set_val(0.4) # programmatic
117
+ app.rows["beta2"]._nudge(+0.01) # nudge button
118
+ app._on_check("fm units") # unit toggle
119
+ app._reset() # Reset button
120
+ assert not app._blitter.active and animated(app) == []
121
+
122
+
123
+ # ---------- the held view ----------
124
+
125
+ GROWING = [
126
+ pytest.param(BetaRender, "beta2", (0.0, 1.5, 0.01), id="beta"),
127
+ pytest.param(FoSRender, "c", (1.0, 2.5, 0.01), id="fos"),
128
+ ]
129
+
130
+
131
+ @pytest.mark.parametrize("render_cls, key, span", GROWING)
132
+ def test_growing_drag_refits_with_headroom(make_app, render_cls, key, span) -> None:
133
+ app = make_app(render_cls)
134
+ draws = count_draws(app)
135
+ values = steps(*span)
136
+ press(app, key, values[0])
137
+ for value in values[1:]:
138
+ move(app, key, value)
139
+ assert data_inside_view(app), f"shape clipped at {key}={value}"
140
+ # 150 steps of growth: a handful of refits, not one redraw per step.
141
+ assert 3 <= len(draws) <= 10
142
+ release(app, key)
143
+ reference = make_app(render_cls, **{key: span[1]})
144
+ assert ([ax.margins() for ax in app._data_axes]
145
+ == [ax.margins() for ax in reference._data_axes])
146
+ assert np.array_equal(pixels(app), pixels(reference))
147
+
148
+
149
+ @pytest.mark.parametrize("presets, key, span", [
150
+ # The invalid-shape banner (the cross-section title) appears at beta4 = -1.2.
151
+ pytest.param({"beta4": -1.0}, "beta4", (-1.0, -1.3, -0.02), id="title"),
152
+ # The overlay legend hides at beta3 = 0 and comes back at 0.01.
153
+ pytest.param({"beta2": 0.5, "beta3": -0.05}, "beta3", (-0.05, 0.05, 0.01), id="legend"),
154
+ ])
155
+ def test_static_change_mid_drag_reaches_the_frame(make_app, presets, key, span) -> None:
156
+ app = make_app(BetaRender, **presets)
157
+
158
+ def static_state() -> tuple[str, bool]:
159
+ return app.ax_shape.get_title(), app.shape_legend.get_visible()
160
+
161
+ seen = {static_state()}
162
+ for value in begin_drag(app, key, steps(*span)):
163
+ move(app, key, value)
164
+ seen.add(static_state())
165
+ frame = pixels(app)
166
+ app.fig.canvas.draw()
167
+ assert np.array_equal(frame, pixels(app)), f"stale static layer at {key}={value}"
168
+ assert len(seen) > 1 # the fixture really changed the title or the legend
169
+
170
+
171
+ def test_drag_ending_on_an_invalid_shape_releases_cleanly(make_app) -> None:
172
+ app = make_app(BetaRender, beta4=-1.0)
173
+ for value in begin_drag(app, "beta4", steps(-1.0, -1.3, -0.02)):
174
+ move(app, "beta4", value)
175
+ assert not app.last_result.ok
176
+ release(app, "beta4")
177
+ reference = make_app(BetaRender, beta4=-1.3)
178
+ assert np.array_equal(pixels(app), pixels(reference))
179
+
180
+
181
+ # ---------- robustness ----------
182
+
183
+ def test_lost_release_ends_drag_mode_on_the_next_update(make_app) -> None:
184
+ app = make_app(BetaRender, beta2=1.0)
185
+ for value in begin_drag(app, "beta2", steps(1.0, 0.9, -0.01)):
186
+ move(app, "beta2", value)
187
+ app.rows["beta2"].slider.drag_active = False # the release event never arrived
188
+ app.rows["beta3"].slider.set_val(0.1) # any later update
189
+ assert not app._blitter.active and animated(app) == []
190
+ reference = make_app(BetaRender, beta2=0.9, beta3=0.1)
191
+ assert np.array_equal(pixels(app), pixels(reference))
192
+
193
+
194
+ def test_drag_on_another_slider_after_a_lost_release_moves_that_slider(make_app) -> None:
195
+ # beta2's release never arrived, so it is still drag_active when beta3 is
196
+ # pressed. Drag mode must follow beta3: blitting with beta2's axes
197
+ # animated would leave beta3's handle frozen in the background.
198
+ app = make_app(BetaRender, beta2=1.0)
199
+ for value in begin_drag(app, "beta2", steps(1.0, 0.9, -0.01)):
200
+ move(app, "beta2", value)
201
+ values = steps(0.0, 0.1, 0.01)
202
+ press(app, "beta3", values[0])
203
+ for value in values[1:]:
204
+ move(app, "beta3", value)
205
+ frame = pixels(app)
206
+ app.fig.canvas.draw()
207
+ assert np.array_equal(frame, pixels(app)), f"frame at beta3={value}"
208
+
209
+
210
+ def test_resize_mid_drag_never_blits_the_old_background(make_app) -> None:
211
+ # A Tk window resize changes the figure size and fires resize_event, but
212
+ # its redraw is only scheduled; a motion event can arrive first and must
213
+ # not blit the old-size background into the new-size canvas.
214
+ app = make_app(BetaRender, beta2=1.0)
215
+ rest = begin_drag(app, "beta2", steps(1.0, 0.9, -0.01))
216
+ width, height = app.fig.get_size_inches()
217
+ app.fig.set_size_inches(width * 1.2, height * 1.2, forward=False)
218
+ ResizeEvent("resize_event", app.fig.canvas)._process()
219
+ for value in rest:
220
+ move(app, "beta2", value)
221
+ frame = pixels(app)
222
+ app.fig.canvas.draw()
223
+ assert np.array_equal(frame, pixels(app)), f"frame at beta2={value}"
224
+
225
+
226
+ def test_export_mid_drag_does_not_poison_later_frames(make_app) -> None:
227
+ app = make_app(BetaRender, beta2=1.0)
228
+ rest = begin_drag(app, "beta2", steps(1.0, 0.9, -0.01))
229
+ app.fig.savefig(io.BytesIO(), format="rgba", dpi=100) # e.g. the toolbar's save key
230
+ for value in rest:
231
+ move(app, "beta2", value)
232
+ frame = pixels(app)
233
+ app.fig.canvas.draw()
234
+ assert np.array_equal(frame, pixels(app)), f"frame at beta2={value}"
235
+
236
+
237
+ def test_save_button_in_a_stale_drag_saves_the_ordinary_figure(
238
+ make_app, tmp_path, monkeypatch) -> None:
239
+ monkeypatch.chdir(tmp_path) # _save() writes into the cwd
240
+ app = make_app(BetaRender, beta2=1.0)
241
+ for value in begin_drag(app, "beta2", steps(1.0, 0.9, -0.01)):
242
+ move(app, "beta2", value)
243
+ app.rows["beta2"].slider.drag_active = False # the release event never arrived
244
+ app._save()
245
+ assert not app._blitter.active and animated(app) == []
246
+ (png,) = tmp_path.glob("*.png")
247
+ after_drag = png.read_bytes()
248
+ reference = make_app(BetaRender, beta2=0.9)
249
+ reference._save() # same parameters: same file name
250
+ assert after_drag == png.read_bytes()
251
+
252
+
253
+ def test_canvas_without_blitting_redraws_every_frame(make_app, monkeypatch) -> None:
254
+ monkeypatch.setattr(FigureCanvasAgg, "supports_blit", False)
255
+ app = make_app(BetaRender, beta2=1.0)
256
+ assert app._blitter is None
257
+ draws = count_draws(app)
258
+ values = steps(1.0, 0.9, -0.01)
259
+ press(app, "beta2", values[0]) # on the handle: no value change
260
+ for value in values[1:]:
261
+ move(app, "beta2", value)
262
+ assert len(draws) == len(values) - 1 # one full redraw per motion
263
+ release(app, "beta2")
264
+ assert animated(app) == []
265
+
266
+
267
+ def test_dragging_past_the_slider_end_stops_at_the_limit(make_app) -> None:
268
+ app = make_app(BetaRender, beta2=3.9)
269
+ press(app, "beta2", 3.9)
270
+ for value in (3.95, 4.0, 4.3, 4.8): # the last two are off the slider
271
+ move(app, "beta2", value)
272
+ assert app.rows["beta2"].slider.val == pytest.approx(4.0)
273
+ release(app, "beta2")
274
+ assert not app._blitter.active and animated(app) == []
275
+ reference = make_app(BetaRender, beta2=4.0)
276
+ assert np.array_equal(pixels(app), pixels(reference))
277
+
278
+
279
+ def test_save_after_a_drag_matches_an_ordinary_save(make_app) -> None:
280
+ app = make_app(BetaRender, beta2=1.0)
281
+ for value in begin_drag(app, "beta2", steps(1.0, 0.9, -0.01)):
282
+ move(app, "beta2", value)
283
+ release(app, "beta2")
284
+ reference = make_app(BetaRender, beta2=0.9)
285
+
286
+ def saved(target: ShapePlotterApp) -> bytes:
287
+ buffer = io.BytesIO()
288
+ target.fig.savefig(buffer, format="rgba", dpi=100)
289
+ return buffer.getvalue()
290
+
291
+ assert saved(app) == saved(reference)
@@ -1,6 +1,11 @@
1
1
  """Engine update-loop budgets under Agg (default ext4 venv — see venv caveat).
2
2
 
3
- Two tiers:
3
+ Each test prints its figure; `python -m pytest tests/test_perf.py -s` is the
4
+ one-command measurement on any machine. The budgets are calibrated for the
5
+ development PC and may fail on a slower machine; the printed figures are
6
+ still the measurement.
7
+
8
+ Three tiers:
4
9
 
5
10
  - Artist mutation (draw neutered): the clear-and-replot regression guard.
6
11
  v0.1 rebuilt every artist per frame: ~15/20 ms (beta/fos). v0.2 persistent
@@ -9,14 +14,19 @@ Two tiers:
9
14
  every call resolves from params; the repeated vector is no longer served
10
15
  from stored state.
11
16
  - One-draw frame (update() alone; under Agg its draw_idle draws immediately):
12
- loose interactivity backstop. Full-figure rasterization (~35 axes) costs
13
- ~73-95 ms and dominates any engine improvement short of blitting; measured
14
- v0.1 ~104/135 ms, v0.2 ~77/95 ms. Budget 250 ms.
17
+ loose interactivity backstop for everything that is not a drag. Full-figure
18
+ rasterization (~35 axes) costs ~73-95 ms; measured v0.1 ~104/135 ms,
19
+ v0.2 ~77/95 ms. Budget 250 ms.
20
+ - Blitted drag step (one slider motion event during a drag): the static layer
21
+ is restored from a cached background and only the data lines, the stats
22
+ text and the dragged slider are drawn. Measured 2026-10-03: ~20-35 ms on
23
+ matplotlib 3.11 (its text rendering dominates), ~10 ms on 3.10. Budget 60 ms.
15
24
 
16
25
  Venv caveat (measured 2026-07-03): identical code is ~5x slower under the
17
26
  project-local .venv on /mnt/c (WSL2 9P filesystem). The default ext4 venv
18
27
  (/home/alex/.virtualenvs/default) is authoritative for these budgets.
19
28
  """
29
+ import statistics
20
30
  import time
21
31
 
22
32
  import pytest
@@ -24,9 +34,11 @@ import pytest
24
34
  from shape_plotters.core.engine import ShapePlotterApp
25
35
  from shape_plotters.renders.beta import BetaRender
26
36
  from shape_plotters.renders.fos import FoSRender
37
+ from tests.dragging import begin_drag, move, release, steps
27
38
 
28
39
  MUTATION_BUDGET_MS = 5.0
29
40
  FRAME_BUDGET_MS = 250.0
41
+ DRAG_STEP_BUDGET_MS = 60.0
30
42
 
31
43
 
32
44
  def _mean_ms(fn, n: int = 10) -> float:
@@ -45,6 +57,7 @@ def test_artist_mutation_under_budget(render_cls) -> None:
45
57
  app.fig.canvas.draw() # warm-up: font cache + first layout
46
58
  app.fig.canvas.draw_idle = lambda: None # isolate mutation from rasterization
47
59
  ms = _mean_ms(app.update)
60
+ print(f"\nPERF artist mutation [{app.render.name}]: {ms:.2f} ms")
48
61
  assert ms < MUTATION_BUDGET_MS, f"{ms:.1f} ms/update >= {MUTATION_BUDGET_MS} ms budget"
49
62
  finally:
50
63
  plt.close(app.fig)
@@ -59,6 +72,35 @@ def test_frame_time_under_budget(render_cls) -> None:
59
72
  app.update()
60
73
  app.fig.canvas.draw() # warm-up: font cache + first layout
61
74
  ms = _mean_ms(app.update) # one full draw per call (immediate under Agg)
75
+ print(f"\nPERF full frame [{app.render.name}]: {ms:.1f} ms")
62
76
  assert ms < FRAME_BUDGET_MS, f"{ms:.0f} ms/frame >= {FRAME_BUDGET_MS:.0f} ms budget"
63
77
  finally:
64
78
  plt.close(app.fig)
79
+
80
+
81
+ @pytest.mark.parametrize("render_cls, presets, key, span", [
82
+ # Shrinking drags: no refits, so every timed step is a blitted frame.
83
+ pytest.param(BetaRender, {"beta2": 1.0}, "beta2", (1.0, 0.6, -0.01), id="beta"),
84
+ pytest.param(FoSRender, {"c": 1.6, "a4": 0.3}, "a4", (0.3, -0.1, -0.01), id="fos"),
85
+ ])
86
+ def test_drag_step_under_budget(render_cls, presets, key, span) -> None:
87
+ import matplotlib.pyplot as plt
88
+
89
+ app = ShapePlotterApp(render_cls())
90
+ try:
91
+ for name, value in presets.items():
92
+ app.rows[name].slider.set_val(value)
93
+ app.fig.canvas.draw() # warm-up: font cache + first layout
94
+ times_ms = []
95
+ for value in begin_drag(app, key, steps(*span)):
96
+ t0 = time.perf_counter()
97
+ move(app, key, value)
98
+ times_ms.append((time.perf_counter() - t0) * 1000.0)
99
+ release(app, key)
100
+ ms = statistics.median(times_ms)
101
+ print(f"\nPERF blitted drag step [{app.render.name}]: {ms:.1f} ms "
102
+ f"(median of {len(times_ms)})")
103
+ assert ms < DRAG_STEP_BUDGET_MS, (
104
+ f"{ms:.1f} ms/step >= {DRAG_STEP_BUDGET_MS:.0f} ms budget")
105
+ finally:
106
+ plt.close(app.fig)
@@ -1,6 +1,7 @@
1
1
  """Widget helpers under the Agg backend (conftest sets MPLBACKEND)."""
2
2
  import matplotlib.pyplot as plt
3
3
  import pytest
4
+ from matplotlib.backend_bases import MouseEvent
4
5
 
5
6
  from shape_plotters.core.widgets import IntTextBox, SliderRow
6
7
 
@@ -12,6 +13,14 @@ def fig():
12
13
  plt.close(f)
13
14
 
14
15
 
16
+ def _press(fig, x_frac: float, y_frac: float) -> None:
17
+ """Deliver a left-button press at a position given in figure fractions."""
18
+ width, height = fig.canvas.get_width_height()
19
+ event = MouseEvent("button_press_event", fig.canvas,
20
+ x_frac * width, y_frac * height, button=1)
21
+ fig.canvas.callbacks.process("button_press_event", event)
22
+
23
+
15
24
  def test_slider_row_nudges_within_range(fig) -> None:
16
25
  row = SliderRow(fig, 0.5, "b2", 0.0, 4.0, 0.0, 0.01)
17
26
  row._nudge(+0.01)
@@ -48,3 +57,25 @@ def test_int_textbox_accepts_and_reverts(fig) -> None:
48
57
  assert box.value == 96 # reverted
49
58
  box._submit("-3")
50
59
  assert box.value == 96 # positive ints only
60
+
61
+
62
+ def test_press_elsewhere_does_not_redraw_when_not_typing(fig) -> None:
63
+ # TextBox.stop_typing() does a full canvas.draw() on every press outside
64
+ # the box. With the Z and N boxes that was two full redraws per click
65
+ # anywhere in the figure.
66
+ IntTextBox(fig, (0.1, 0.1, 0.1, 0.05), "Z", 92, lambda: None)
67
+ fig.canvas.draw()
68
+ draws: list[int] = []
69
+ fig.canvas.mpl_connect("draw_event", lambda _event: draws.append(1))
70
+ _press(fig, 0.8, 0.8) # far from the box
71
+ assert draws == []
72
+
73
+
74
+ def test_press_elsewhere_still_ends_typing(fig) -> None:
75
+ calls: list[int] = []
76
+ box = IntTextBox(fig, (0.1, 0.1, 0.1, 0.05), "Z", 92, lambda: calls.append(1))
77
+ fig.canvas.draw()
78
+ box.box.begin_typing()
79
+ _press(fig, 0.8, 0.8)
80
+ assert not box.box.capturekeystrokes # typing ended
81
+ assert calls # and the text was submitted
File without changes
File without changes