qmlmathplot 0.4.1__tar.gz → 0.4.2__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 (59) hide show
  1. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/PKG-INFO +110 -22
  2. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/README.md +109 -21
  3. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/pyproject.toml +1 -1
  4. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/pyproject.toml.orig +1 -1
  5. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/__init__.py +3 -0
  6. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/curve.py +5 -0
  7. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/export.py +5 -1
  8. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/figure.py +88 -14
  9. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/model.py +29 -7
  10. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/mpl.py +67 -6
  11. qmlmathplot-0.4.2/src/qmlmathplot/overlay.py +426 -0
  12. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/plot.py +232 -3
  13. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/pyplot.py +40 -0
  14. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotFurniture.qml +156 -20
  15. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotInput.qml +4 -0
  16. qmlmathplot-0.4.2/src/qmlmathplot/qml/PlotLegend.qml +66 -0
  17. qmlmathplot-0.4.2/src/qmlmathplot/qml/PlotOverlay.qml +111 -0
  18. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotView.qml +14 -3
  19. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/qsb.py +6 -0
  20. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/widget.py +3 -3
  21. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/__main__.py +0 -0
  22. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/animation.py +0 -0
  23. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/app.py +0 -0
  24. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/camera.py +0 -0
  25. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/canvas.py +0 -0
  26. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/input.py +0 -0
  27. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/interval.py +0 -0
  28. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotCurves.qml +0 -0
  29. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
  30. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/README.md +0 -0
  31. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
  32. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
  33. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/classic.mplstyle +0 -0
  34. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
  35. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/default.mplstyle +0 -0
  36. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/fast.mplstyle +0 -0
  37. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
  38. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
  39. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
  40. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
  41. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
  42. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
  43. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
  44. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
  45. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
  46. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
  47. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
  48. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
  49. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
  50. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
  51. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
  52. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
  53. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
  54. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
  55. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
  56. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
  57. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
  58. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes.py +0 -0
  59. {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: qmlmathplot
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)
5
5
  Author: I-AM-A-NOOB
6
6
  Author-email: I-AM-A-NOOB <suzc08@outlook.com>
@@ -143,11 +143,13 @@ The model:
143
143
 
144
144
  | Object | Members |
145
145
  |---|---|
146
- | `Plot` | `camera`, `curves`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `add_implicit()` / `remove_curve()`, `available_themes`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `xlabel`, `ylabel`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
146
+ | `Plot` | `camera`, `curves`, `annotations`, `guides`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `add_implicit()` / `remove_curve()`, `annotate()` / `add_text()` / `add_hline()` / `add_vline()`, `set_xticks()` / `set_yticks()`, `available_themes`, and the style properties: `background`, `grid`, `grid_x`, `grid_y`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `tick_width`, `tick_pad`, `ticks_top`, `ticks_right`, `minor_ticks`, `spine_top` / `spine_bottom` / `spine_left` / `spine_right`, `font_size`, `tick_font_size`, `title`, `xlabel`, `ylabel`, `title_color`, `title_font_size`, `title_bold`, `legend`, `legend_position`, `ticks_visible`, `theme` |
147
147
  | `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, `animationDuration`, `input` (a `ViewInput`), slots `set_viewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `zoom_at(factor, u, v, w, h)` / `pan_pixels(dx, dy)` / `animate_zoom(...)` / `animate_pan(dx, dy, ms)` / `stop_animation()` / `reset()` |
148
148
  | `ViewInput` | `keyboardEnabled`, `keyPanStep`, `keyPanRepeatInterval`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
149
149
  | `Curve` | `expression` (string or sympy expression), `expr` (the parsed expression, read-only), `implicit`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
150
- | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
150
+ | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `label`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
151
+ | `Annotation` | `text`, `xy` (world), `xytext` (pixel offset), `arrow`, `color`, `fontSize`, `visible` |
152
+ | `Guide` | `kind` (`"h"`/`"v"`), `value`, `color`, `width`, `style`, `visible` |
151
153
 
152
154
  Every setter is a Qt `Property` with a notify signal, so the same calls work from QML, from a
153
155
  Qt Designer slot or from a `QTimer`:
@@ -210,8 +212,10 @@ plt.show() # blocks in a script; use block=False in
210
212
  ```
211
213
 
212
214
  What maps: one figure/one axes (`subplots()` and `subplot(111)`), `plot(expression)`,
213
- `xlim`/`ylim`, `title`/`xlabel`/`ylabel`, `grid`, `aspect`, `savefig`/`to_image`, themes.
214
- `plt.gcf()`/`gca()`/`sca()`/`close()`/`show()` follow Matplotlib's current-figure shape.
215
+ `xlim`/`ylim`, `title`/`xlabel`/`ylabel`, `grid(axis=...)`, `aspect`, `savefig`/`to_image`,
216
+ themes, `legend`, `annotate`/`text`, `axhline`/`axvline`, `set_xticks`/`set_yticks` and
217
+ `tick_params`. `plt.gcf()`/`gca()`/`sca()`/`close()`/`show()` follow Matplotlib's
218
+ current-figure shape.
215
219
 
216
220
  What is deliberately different:
217
221
 
@@ -224,8 +228,9 @@ What is deliberately different:
224
228
  deferred renderer or blitting.
225
229
  - **`savefig` is a region at a pixel size**, not a page at a dpi. `width`/`height` are device
226
230
  pixels and `dpi` is a multiplier; `backend="matplotlib"` is the vector route.
227
- - **No legends, annotations, log axes or manual ticks yet.** `legend()`, `annotate()`,
228
- `set_xscale()` and `set_xticks()` raise with the reason; chrome belongs to the host.
231
+ - **Log axes and full annotation styling are not implemented.** `set_xscale()`/`set_yscale()`
232
+ raise with the reason; annotations support text + a straight arrow + pixel offset, not
233
+ Matplotlib's bbox/connectionstyle engine.
229
234
  - **Themes are per figure**, not `rcParams`; `plt` has no global style state beyond the current
230
235
  figure.
231
236
  - **`mpl_connect`/`blit` are absent.** Qt signals are the event system.
@@ -312,8 +317,9 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
312
317
 
313
318
  ## Keyboard, touch and animation
314
319
 
315
- Clicking the plot gives it focus; while it has focus the arrow keys pan (10% of the viewport
316
- per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Held keys advance continuously:
320
+ Clicking the plot or pressing Tab while it is in the focus chain gives it focus; while it has
321
+ focus the arrow keys pan (10% of the viewport per press by default), `+`/`=` zooms in and
322
+ `-`/`_` zooms out. Held keys advance continuously:
317
323
  one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
318
324
  there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
319
325
  moves diagonally at the normal speed. Every command is a property on the camera's `input`
@@ -359,7 +365,7 @@ plot.available_themes # the names, for a host's picker (both dem
359
365
  The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
360
366
  `qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
361
367
  mapped onto this library's property names, and keys describing things this library does not
362
- have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
368
+ have (figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
363
369
  them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
364
370
  theme alone.
365
371
 
@@ -371,13 +377,77 @@ theme alone.
371
377
  `x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
372
378
  axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
373
379
  nearest edge, so its labels are never lost (and a label never leaves the item); the grid
374
- stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
380
+ stays where it is. `plot.axes_position = "edge"` keeps the labels at the edges without
381
+ drawing axis lines.
382
+ - `plot.axes_position = "box"` draws the four Matplotlib-style **spines** around the viewport
383
+ (same `axis_color` / `axis_width`), with ticks and labels on the bottom/left edges. It is a
384
+ property, not a separate theme, so it composes with any theme:
385
+ ```python
386
+ plot.axes_position = "box"
387
+ plot.grid = True
388
+ ```
389
+ - Furniture is part of the same QML scene, so `to_image()` / `savefig()` capture the box,
390
+ grid, ticks, labels and title exactly as the live view renders them (there is an export
391
+ regression test for the four spines).
375
392
  - `camera.viewport` reports the last size the view sent, so a host can size an export from it.
376
393
  - The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
377
394
  `nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
378
395
  and formats them, so a frame never recomputes ticks.
379
396
  - `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
380
397
 
398
+ ## Annotations, legend and guides
399
+
400
+ Annotation elements are **model-driven**, not host QML: the same objects render in the live view
401
+ and in `to_image()` / `savefig()`.
402
+
403
+ ```python
404
+ plot.annotate("pole", xy=(0.0, 0.0), xytext=(24, -18), arrowprops={"color": "#333333"})
405
+ plot.text(2.0, 1.0, "f(2)=1", color="#333333", font_size=12)
406
+ plot.add_hline(0.5, color="#888888", style="--") # horizontal guide
407
+ plot.add_vline(1.0, color="#888888", style=":") # vertical guide
408
+
409
+ plot.add_curve("sin(x)", label="sin")
410
+ plot.add_curve("cos(x)", label="cos")
411
+ plot.legend = True
412
+ plot.legend_position = "upper right" # best | upper left | lower right | lower left
413
+
414
+ plot.set_xticks([-3, 0, 3], ["-3", "0", "3"]) # explicit positions + labels
415
+ plot.set_yticks([-1, 1])
416
+ plot.grid_x = True
417
+ plot.grid_y = False # per-axis grid
418
+ plot.tick_width = 1.5
419
+ plot.tick_pad = 4.0
420
+ plot.ticks_top = True
421
+ plot.ticks_right = True
422
+ plot.minor_ticks = True # four minor marks per major interval
423
+ plot.spine_top = False # "box" mode only
424
+ ```
425
+
426
+ `xy` is in world coordinates and `xytext` is a **logical-pixel offset** from it, so annotations
427
+ stay attached to the data while the label keeps a stable screen offset. `Annotation` and `Guide`
428
+ are also exposed as objects (`plot.annotations.at(i)`, `plot.guides.at(i)`) for per-item edits.
429
+
430
+ The Matplotlib facade forwards the same surface:
431
+
432
+ ```python
433
+ ax.annotate("pole", xy=(0, 0), xytext=(24, -18), arrowprops={"color": "#333"})
434
+ ax.text(2, 1, "f(2)=1")
435
+ ax.legend(loc="lower right")
436
+ ax.set_xticks([-3, 0, 3], labels=["-3", "0", "3"])
437
+ ax.tick_params(axis="x", length=6, width=1.5, top=True)
438
+ ax.grid(True, axis="x")
439
+ ax.axhline(0.5, color="#888888")
440
+ ax.axvline(1.0, color="#888888")
441
+ ```
442
+
443
+ `plt.annotate` / `plt.text` / `plt.legend` / `plt.xticks` / `plt.yticks` /
444
+ `plt.tick_params` / `plt.axhline` / `plt.axvline` forward to the current axes. The
445
+ `to_matplotlib()` hand-off maps labels, legend, annotations, guides and explicit ticks as well.
446
+
447
+ The annotation/guide/legend layers are part of the QML scene. The export path now drains the
448
+ queued Canvas paints before the synchronous readback, so the grid, annotations and guides are
449
+ present in screenshots too (previously a Canvas-painted grid could miss the grab).
450
+
381
451
  ## Export
382
452
 
383
453
  `savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
@@ -487,7 +557,7 @@ The supported path is GeoGebra's column idea implemented on the GPU: sample the
487
557
  sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
488
558
  cannot be represented by a line.
489
559
 
490
- 1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **16 samples over ±2 columns**
560
+ 1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **14 samples over ±2 columns**
491
561
  (0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
492
562
  `R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
493
563
  sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
@@ -522,7 +592,7 @@ cannot be represented by a line.
522
592
  viewport bottom; no filled ray.
523
593
  - **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
524
594
  Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
525
- 16-sample polyline supersamples GGB's one interval per column for resolved stretches; a
595
+ 14-sample polyline supersamples GGB's one interval per column for resolved stretches; a
526
596
  dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
527
597
  branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
528
598
  sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
@@ -530,7 +600,7 @@ cannot be represented by a line.
530
600
  never evaluated; a pixel just outside the domain still gets the band, which is centred
531
601
  on the boundary.
532
602
 
533
- An ordinary pixel costs **16 `F` evaluations** plus two interval evaluations; there is no
603
+ An ordinary pixel costs **14 `F` evaluations** plus two interval evaluations; there is no
534
604
  march, no tangent-foot projection and no column probe on the supported path. Expressions
535
605
  with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
536
606
  legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
@@ -565,6 +635,8 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
565
635
  matplotlib's default style, the limits are forwarded to the camera, and a theme applies
566
636
  (and re-applies as a reset) while an unknown name is rejected.
567
637
  - `test_themes.py`: the vendored style sheets resolve to this library's properties.
638
+ - `test_overlay.py`: annotations (`annotate`/`text`), horizontal/vertical guides, legend and
639
+ custom ticks, the `Axes`/`pyplot` forwarding, and the matplotlib hand-off mapping.
568
640
  - `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
569
641
  files in the temp directory are not touched.
570
642
  - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
@@ -579,11 +651,11 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
579
651
  appearing inside a layout, an expression change / an invalid expression keeping the
580
652
  previous graph, curves added/hidden/removed through the model, **the wheel over a
581
653
  scroll area being consumed by the plot (zoom instead of scrolling the parent)**, the
582
- fixed aspect being undistorted by the widget's shape, and the plot staying out of the
583
- Tab focus chain. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
654
+ fixed aspect being undistorted by the widget's shape, and Tab reaching the plot and leaving
655
+ it again. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
584
656
  needs one, and a process may only have one).
585
657
  - `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
586
- must not steal the pan).
658
+ must not steal the pan), plus Tab focus entering and leaving the QML plot.
587
659
  - `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
588
660
  `adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
589
661
  outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
@@ -677,14 +749,27 @@ Startup to the first frame is about **230 ms**, attributed as follows
677
749
  Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
678
750
  the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
679
751
  default **12.4 ms**, `sin(1/x)` at zoom 90 (worst case) **12.5 ms**. The previous
680
- march/interval renderer measured 27/34/33 ms on the same machine: 16 samples are cheaper
752
+ march/interval renderer measured 27/34/33 ms on the same machine: 14 samples are cheaper
681
753
  than the march's seed + tangent projection + walk + probe, and the march is now compiled
682
754
  only for the legacy path. The 1-D fragment source is about 21 KB.
683
755
 
684
- **Per-pixel `F` evaluations.** The supported path is fixed: 16 samples for the polyline
685
- (plus two interval evaluations for the column and the stroke footprint); there is no
686
- march budget and no band-window evaluation. The old fixed-grid/march table is superseded
687
- and no longer describes the shipped path.
756
+ **Per-pixel `F` evaluations.** The supported path uses 14 samples for the polyline in the live
757
+ view and in exports; two interval evaluations cover the column and the stroke footprint. There
758
+ is no march budget and no band-window evaluation. Anti-aliasing comes from the analytic
759
+ coverage ramp, not from the sample count; the samples only approximate the polyline. Sampling
760
+ was tuned by the render tests: 8 samples overfills the `sin(1/x)` transition and breaks the
761
+ `log` descent, 12 still overfills slightly, 14 passes.
762
+
763
+ An interval early-out over ±2 columns rejects background pixels before the sample loop: if the
764
+ pixel is farther than the stroke radius from the curve's y range in that window, it returns
765
+ transparent without evaluating `F`. Measured by alternating two live 900×600 views (15 pans
766
+ each, `grabToImage`, including readback): `sin(x)` 23.7 → 22.2 ms, `sin(1/x)` 23.6 → 21.0 ms,
767
+ `x**2` 22.7 → 20.4 ms. Whole/infinite columns keep the full path, so `tan`/`1/x` near a pole
768
+ are unchanged.
769
+
770
+ The stroke-footprint interval is evaluated only when the column interval is empty (the domain
771
+ boundary's outer half). On the common path this is two interval evaluations (`wide`, `own`)
772
+ instead of three: another 17.0 → 16.4 ms on `sin(x)` in the same two-view measurement.
688
773
 
689
774
  ## Shader cache
690
775
 
@@ -739,6 +824,9 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
739
824
  **device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
740
825
  half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
741
826
  between one and two rows as it drifted with the zoom.
827
+ - `qsb.exe` is a **console-subsystem** program. A windowed/frozen host (`pythonw`,
828
+ PyInstaller `--windowed`) would flash a cmd window on every cache-miss bake; `qsb.bake()`
829
+ passes `CREATE_NO_WINDOW` on Windows. `capture_output=True` alone does not prevent it.
742
830
  - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
743
831
  widget has no such property, so it silently does nothing): inject the model with
744
832
  `widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
@@ -130,11 +130,13 @@ The model:
130
130
 
131
131
  | Object | Members |
132
132
  |---|---|
133
- | `Plot` | `camera`, `curves`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `add_implicit()` / `remove_curve()`, `available_themes`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `xlabel`, `ylabel`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
133
+ | `Plot` | `camera`, `curves`, `annotations`, `guides`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `add_implicit()` / `remove_curve()`, `annotate()` / `add_text()` / `add_hline()` / `add_vline()`, `set_xticks()` / `set_yticks()`, `available_themes`, and the style properties: `background`, `grid`, `grid_x`, `grid_y`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `tick_width`, `tick_pad`, `ticks_top`, `ticks_right`, `minor_ticks`, `spine_top` / `spine_bottom` / `spine_left` / `spine_right`, `font_size`, `tick_font_size`, `title`, `xlabel`, `ylabel`, `title_color`, `title_font_size`, `title_bold`, `legend`, `legend_position`, `ticks_visible`, `theme` |
134
134
  | `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, `animationDuration`, `input` (a `ViewInput`), slots `set_viewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `zoom_at(factor, u, v, w, h)` / `pan_pixels(dx, dy)` / `animate_zoom(...)` / `animate_pan(dx, dy, ms)` / `stop_animation()` / `reset()` |
135
135
  | `ViewInput` | `keyboardEnabled`, `keyPanStep`, `keyPanRepeatInterval`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
136
136
  | `Curve` | `expression` (string or sympy expression), `expr` (the parsed expression, read-only), `implicit`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
137
- | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
137
+ | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `label`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
138
+ | `Annotation` | `text`, `xy` (world), `xytext` (pixel offset), `arrow`, `color`, `fontSize`, `visible` |
139
+ | `Guide` | `kind` (`"h"`/`"v"`), `value`, `color`, `width`, `style`, `visible` |
138
140
 
139
141
  Every setter is a Qt `Property` with a notify signal, so the same calls work from QML, from a
140
142
  Qt Designer slot or from a `QTimer`:
@@ -197,8 +199,10 @@ plt.show() # blocks in a script; use block=False in
197
199
  ```
198
200
 
199
201
  What maps: one figure/one axes (`subplots()` and `subplot(111)`), `plot(expression)`,
200
- `xlim`/`ylim`, `title`/`xlabel`/`ylabel`, `grid`, `aspect`, `savefig`/`to_image`, themes.
201
- `plt.gcf()`/`gca()`/`sca()`/`close()`/`show()` follow Matplotlib's current-figure shape.
202
+ `xlim`/`ylim`, `title`/`xlabel`/`ylabel`, `grid(axis=...)`, `aspect`, `savefig`/`to_image`,
203
+ themes, `legend`, `annotate`/`text`, `axhline`/`axvline`, `set_xticks`/`set_yticks` and
204
+ `tick_params`. `plt.gcf()`/`gca()`/`sca()`/`close()`/`show()` follow Matplotlib's
205
+ current-figure shape.
202
206
 
203
207
  What is deliberately different:
204
208
 
@@ -211,8 +215,9 @@ What is deliberately different:
211
215
  deferred renderer or blitting.
212
216
  - **`savefig` is a region at a pixel size**, not a page at a dpi. `width`/`height` are device
213
217
  pixels and `dpi` is a multiplier; `backend="matplotlib"` is the vector route.
214
- - **No legends, annotations, log axes or manual ticks yet.** `legend()`, `annotate()`,
215
- `set_xscale()` and `set_xticks()` raise with the reason; chrome belongs to the host.
218
+ - **Log axes and full annotation styling are not implemented.** `set_xscale()`/`set_yscale()`
219
+ raise with the reason; annotations support text + a straight arrow + pixel offset, not
220
+ Matplotlib's bbox/connectionstyle engine.
216
221
  - **Themes are per figure**, not `rcParams`; `plt` has no global style state beyond the current
217
222
  figure.
218
223
  - **`mpl_connect`/`blit` are absent.** Qt signals are the event system.
@@ -299,8 +304,9 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
299
304
 
300
305
  ## Keyboard, touch and animation
301
306
 
302
- Clicking the plot gives it focus; while it has focus the arrow keys pan (10% of the viewport
303
- per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Held keys advance continuously:
307
+ Clicking the plot or pressing Tab while it is in the focus chain gives it focus; while it has
308
+ focus the arrow keys pan (10% of the viewport per press by default), `+`/`=` zooms in and
309
+ `-`/`_` zooms out. Held keys advance continuously:
304
310
  one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
305
311
  there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
306
312
  moves diagonally at the normal speed. Every command is a property on the camera's `input`
@@ -346,7 +352,7 @@ plot.available_themes # the names, for a host's picker (both dem
346
352
  The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
347
353
  `qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
348
354
  mapped onto this library's property names, and keys describing things this library does not
349
- have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
355
+ have (figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
350
356
  them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
351
357
  theme alone.
352
358
 
@@ -358,13 +364,77 @@ theme alone.
358
364
  `x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
359
365
  axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
360
366
  nearest edge, so its labels are never lost (and a label never leaves the item); the grid
361
- stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
367
+ stays where it is. `plot.axes_position = "edge"` keeps the labels at the edges without
368
+ drawing axis lines.
369
+ - `plot.axes_position = "box"` draws the four Matplotlib-style **spines** around the viewport
370
+ (same `axis_color` / `axis_width`), with ticks and labels on the bottom/left edges. It is a
371
+ property, not a separate theme, so it composes with any theme:
372
+ ```python
373
+ plot.axes_position = "box"
374
+ plot.grid = True
375
+ ```
376
+ - Furniture is part of the same QML scene, so `to_image()` / `savefig()` capture the box,
377
+ grid, ticks, labels and title exactly as the live view renders them (there is an export
378
+ regression test for the four spines).
362
379
  - `camera.viewport` reports the last size the view sent, so a host can size an export from it.
363
380
  - The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
364
381
  `nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
365
382
  and formats them, so a frame never recomputes ticks.
366
383
  - `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
367
384
 
385
+ ## Annotations, legend and guides
386
+
387
+ Annotation elements are **model-driven**, not host QML: the same objects render in the live view
388
+ and in `to_image()` / `savefig()`.
389
+
390
+ ```python
391
+ plot.annotate("pole", xy=(0.0, 0.0), xytext=(24, -18), arrowprops={"color": "#333333"})
392
+ plot.text(2.0, 1.0, "f(2)=1", color="#333333", font_size=12)
393
+ plot.add_hline(0.5, color="#888888", style="--") # horizontal guide
394
+ plot.add_vline(1.0, color="#888888", style=":") # vertical guide
395
+
396
+ plot.add_curve("sin(x)", label="sin")
397
+ plot.add_curve("cos(x)", label="cos")
398
+ plot.legend = True
399
+ plot.legend_position = "upper right" # best | upper left | lower right | lower left
400
+
401
+ plot.set_xticks([-3, 0, 3], ["-3", "0", "3"]) # explicit positions + labels
402
+ plot.set_yticks([-1, 1])
403
+ plot.grid_x = True
404
+ plot.grid_y = False # per-axis grid
405
+ plot.tick_width = 1.5
406
+ plot.tick_pad = 4.0
407
+ plot.ticks_top = True
408
+ plot.ticks_right = True
409
+ plot.minor_ticks = True # four minor marks per major interval
410
+ plot.spine_top = False # "box" mode only
411
+ ```
412
+
413
+ `xy` is in world coordinates and `xytext` is a **logical-pixel offset** from it, so annotations
414
+ stay attached to the data while the label keeps a stable screen offset. `Annotation` and `Guide`
415
+ are also exposed as objects (`plot.annotations.at(i)`, `plot.guides.at(i)`) for per-item edits.
416
+
417
+ The Matplotlib facade forwards the same surface:
418
+
419
+ ```python
420
+ ax.annotate("pole", xy=(0, 0), xytext=(24, -18), arrowprops={"color": "#333"})
421
+ ax.text(2, 1, "f(2)=1")
422
+ ax.legend(loc="lower right")
423
+ ax.set_xticks([-3, 0, 3], labels=["-3", "0", "3"])
424
+ ax.tick_params(axis="x", length=6, width=1.5, top=True)
425
+ ax.grid(True, axis="x")
426
+ ax.axhline(0.5, color="#888888")
427
+ ax.axvline(1.0, color="#888888")
428
+ ```
429
+
430
+ `plt.annotate` / `plt.text` / `plt.legend` / `plt.xticks` / `plt.yticks` /
431
+ `plt.tick_params` / `plt.axhline` / `plt.axvline` forward to the current axes. The
432
+ `to_matplotlib()` hand-off maps labels, legend, annotations, guides and explicit ticks as well.
433
+
434
+ The annotation/guide/legend layers are part of the QML scene. The export path now drains the
435
+ queued Canvas paints before the synchronous readback, so the grid, annotations and guides are
436
+ present in screenshots too (previously a Canvas-painted grid could miss the grab).
437
+
368
438
  ## Export
369
439
 
370
440
  `savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
@@ -474,7 +544,7 @@ The supported path is GeoGebra's column idea implemented on the GPU: sample the
474
544
  sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
475
545
  cannot be represented by a line.
476
546
 
477
- 1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **16 samples over ±2 columns**
547
+ 1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **14 samples over ±2 columns**
478
548
  (0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
479
549
  `R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
480
550
  sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
@@ -509,7 +579,7 @@ cannot be represented by a line.
509
579
  viewport bottom; no filled ray.
510
580
  - **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
511
581
  Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
512
- 16-sample polyline supersamples GGB's one interval per column for resolved stretches; a
582
+ 14-sample polyline supersamples GGB's one interval per column for resolved stretches; a
513
583
  dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
514
584
  branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
515
585
  sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
@@ -517,7 +587,7 @@ cannot be represented by a line.
517
587
  never evaluated; a pixel just outside the domain still gets the band, which is centred
518
588
  on the boundary.
519
589
 
520
- An ordinary pixel costs **16 `F` evaluations** plus two interval evaluations; there is no
590
+ An ordinary pixel costs **14 `F` evaluations** plus two interval evaluations; there is no
521
591
  march, no tangent-foot projection and no column probe on the supported path. Expressions
522
592
  with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
523
593
  legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
@@ -552,6 +622,8 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
552
622
  matplotlib's default style, the limits are forwarded to the camera, and a theme applies
553
623
  (and re-applies as a reset) while an unknown name is rejected.
554
624
  - `test_themes.py`: the vendored style sheets resolve to this library's properties.
625
+ - `test_overlay.py`: annotations (`annotate`/`text`), horizontal/vertical guides, legend and
626
+ custom ticks, the `Axes`/`pyplot` forwarding, and the matplotlib hand-off mapping.
555
627
  - `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
556
628
  files in the temp directory are not touched.
557
629
  - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
@@ -566,11 +638,11 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
566
638
  appearing inside a layout, an expression change / an invalid expression keeping the
567
639
  previous graph, curves added/hidden/removed through the model, **the wheel over a
568
640
  scroll area being consumed by the plot (zoom instead of scrolling the parent)**, the
569
- fixed aspect being undistorted by the widget's shape, and the plot staying out of the
570
- Tab focus chain. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
641
+ fixed aspect being undistorted by the widget's shape, and Tab reaching the plot and leaving
642
+ it again. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
571
643
  needs one, and a process may only have one).
572
644
  - `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
573
- must not steal the pan).
645
+ must not steal the pan), plus Tab focus entering and leaving the QML plot.
574
646
  - `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
575
647
  `adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
576
648
  outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
@@ -664,14 +736,27 @@ Startup to the first frame is about **230 ms**, attributed as follows
664
736
  Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
665
737
  the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
666
738
  default **12.4 ms**, `sin(1/x)` at zoom 90 (worst case) **12.5 ms**. The previous
667
- march/interval renderer measured 27/34/33 ms on the same machine: 16 samples are cheaper
739
+ march/interval renderer measured 27/34/33 ms on the same machine: 14 samples are cheaper
668
740
  than the march's seed + tangent projection + walk + probe, and the march is now compiled
669
741
  only for the legacy path. The 1-D fragment source is about 21 KB.
670
742
 
671
- **Per-pixel `F` evaluations.** The supported path is fixed: 16 samples for the polyline
672
- (plus two interval evaluations for the column and the stroke footprint); there is no
673
- march budget and no band-window evaluation. The old fixed-grid/march table is superseded
674
- and no longer describes the shipped path.
743
+ **Per-pixel `F` evaluations.** The supported path uses 14 samples for the polyline in the live
744
+ view and in exports; two interval evaluations cover the column and the stroke footprint. There
745
+ is no march budget and no band-window evaluation. Anti-aliasing comes from the analytic
746
+ coverage ramp, not from the sample count; the samples only approximate the polyline. Sampling
747
+ was tuned by the render tests: 8 samples overfills the `sin(1/x)` transition and breaks the
748
+ `log` descent, 12 still overfills slightly, 14 passes.
749
+
750
+ An interval early-out over ±2 columns rejects background pixels before the sample loop: if the
751
+ pixel is farther than the stroke radius from the curve's y range in that window, it returns
752
+ transparent without evaluating `F`. Measured by alternating two live 900×600 views (15 pans
753
+ each, `grabToImage`, including readback): `sin(x)` 23.7 → 22.2 ms, `sin(1/x)` 23.6 → 21.0 ms,
754
+ `x**2` 22.7 → 20.4 ms. Whole/infinite columns keep the full path, so `tan`/`1/x` near a pole
755
+ are unchanged.
756
+
757
+ The stroke-footprint interval is evaluated only when the column interval is empty (the domain
758
+ boundary's outer half). On the common path this is two interval evaluations (`wide`, `own`)
759
+ instead of three: another 17.0 → 16.4 ms on `sin(x)` in the same two-view measurement.
675
760
 
676
761
  ## Shader cache
677
762
 
@@ -726,6 +811,9 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
726
811
  **device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
727
812
  half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
728
813
  between one and two rows as it drifted with the zoom.
814
+ - `qsb.exe` is a **console-subsystem** program. A windowed/frozen host (`pythonw`,
815
+ PyInstaller `--windowed`) would flash a cmd window on every cache-miss bake; `qsb.bake()`
816
+ passes `CREATE_NO_WINDOW` on Windows. `capture_output=True` alone does not prevent it.
729
817
  - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
730
818
  widget has no such property, so it silently does nothing): inject the model with
731
819
  `widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -54,6 +54,7 @@ from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
54
54
  from .curve import Curve, CurveListModel, ImplicitCurve
55
55
  from .figure import Axes, Figure
56
56
  from .input import ViewInput
57
+ from .overlay import Annotation, Guide
57
58
  from .plot import Plot
58
59
  from .qsb import cache_dir, cache_info, clear_cache, prune_cache
59
60
  from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
@@ -63,6 +64,7 @@ if TYPE_CHECKING: # only for type checkers; at runtime the module-level __getat
63
64
  from .widget import MathPlotWidget
64
65
 
65
66
  __all__ = [
67
+ "Annotation",
66
68
  "Axes",
67
69
  "cache_dir",
68
70
  "cache_info",
@@ -72,6 +74,7 @@ __all__ = [
72
74
  "CurveListModel",
73
75
  "Figure",
74
76
  "FigureCanvasQt",
77
+ "Guide",
75
78
  "HOME_SIZE",
76
79
  "HOME_VIEW",
77
80
  "ImplicitCurve",
@@ -431,6 +431,7 @@ class CurveListModel(QAbstractListModel):
431
431
  VisibleRole = CurveRole + 4
432
432
  VertexShaderRole = CurveRole + 5
433
433
  FragmentShaderRole = CurveRole + 6
434
+ LabelRole = CurveRole + 7
434
435
 
435
436
  _ROLES = {
436
437
  CurveRole: b"curve",
@@ -440,6 +441,7 @@ class CurveListModel(QAbstractListModel):
440
441
  VisibleRole: b"visible",
441
442
  VertexShaderRole: b"vertexShader",
442
443
  FragmentShaderRole: b"fragmentShader",
444
+ LabelRole: b"label",
443
445
  }
444
446
 
445
447
  def __init__(
@@ -478,6 +480,8 @@ class CurveListModel(QAbstractListModel):
478
480
  return curve.vertexShader
479
481
  if role == self.FragmentShaderRole:
480
482
  return curve.fragmentShader
483
+ if role == self.LabelRole:
484
+ return curve.label
481
485
  return None
482
486
 
483
487
  def _notify(self, curve: Curve, roles: tuple[int, ...]) -> None:
@@ -498,6 +502,7 @@ class CurveListModel(QAbstractListModel):
498
502
  lambda c=curve: self._notify(c, (self.ColorRole, self.LineWidthRole))
499
503
  )
500
504
  curve.visibleChanged.connect(lambda c=curve: self._notify(c, (self.VisibleRole,)))
505
+ curve.labelChanged.connect(lambda c=curve: self._notify(c, (self.LabelRole,)))
501
506
 
502
507
  # ---------------------------------------------------------------- public
503
508
  def _defaults(self, color: object, line_width: object) -> tuple[QColor, float]:
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  from collections.abc import Sequence
15
15
  from typing import TYPE_CHECKING
16
16
 
17
- from PySide6.QtCore import QSize, Qt, QUrl
17
+ from PySide6.QtCore import QCoreApplication, QSize, Qt, QUrl
18
18
  from PySide6.QtGui import QColor, QImage
19
19
 
20
20
  if TYPE_CHECKING: # pragma: no cover - typing only; the real imports stay lazy
@@ -155,6 +155,10 @@ def render(
155
155
 
156
156
  camera.set_viewport(logical[0], logical[1])
157
157
  camera.set_view(centre[0], centre[1], scale[0], scale[1])
158
+ # Let QML's Canvas-based furniture (grid, guides) handle its queued paint before the
159
+ # synchronous readback; the scene-graph items are already ready, Canvas is not.
160
+ for _ in range(3):
161
+ QCoreApplication.processEvents()
158
162
  image = widget.grabFramebuffer()
159
163
  finally:
160
164
  # Restore before the event loop runs again, so no live view repaints the export state.