qmlmathplot 0.4.0__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 (60) hide show
  1. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/PKG-INFO +127 -28
  2. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/README.md +126 -27
  3. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/pyproject.toml +1 -1
  4. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/pyproject.toml.orig +1 -1
  5. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/__init__.py +3 -0
  6. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/curve.py +88 -40
  7. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/export.py +5 -1
  8. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/figure.py +88 -14
  9. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/input.py +22 -0
  10. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/model.py +29 -7
  11. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/mpl.py +67 -6
  12. qmlmathplot-0.4.2/src/qmlmathplot/overlay.py +426 -0
  13. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/plot.py +238 -7
  14. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/pyplot.py +40 -0
  15. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotFurniture.qml +156 -20
  16. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotInput.qml +4 -0
  17. qmlmathplot-0.4.2/src/qmlmathplot/qml/PlotLegend.qml +66 -0
  18. qmlmathplot-0.4.2/src/qmlmathplot/qml/PlotOverlay.qml +111 -0
  19. qmlmathplot-0.4.2/src/qmlmathplot/qml/PlotView.qml +286 -0
  20. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/qsb.py +6 -0
  21. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/widget.py +3 -3
  22. qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotView.qml +0 -134
  23. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/__main__.py +0 -0
  24. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/animation.py +0 -0
  25. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/app.py +0 -0
  26. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/camera.py +0 -0
  27. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/canvas.py +0 -0
  28. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/interval.py +0 -0
  29. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotCurves.qml +0 -0
  30. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
  31. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/README.md +0 -0
  32. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
  33. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
  34. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/classic.mplstyle +0 -0
  35. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
  36. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/default.mplstyle +0 -0
  37. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/fast.mplstyle +0 -0
  38. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
  39. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
  40. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
  41. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
  42. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
  43. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
  44. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
  45. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
  46. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
  47. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
  48. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
  49. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
  50. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
  51. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
  52. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
  53. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
  54. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
  55. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
  56. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
  57. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
  58. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
  59. {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes.py +0 -0
  60. {qmlmathplot-0.4.0 → 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.0
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
- | `ViewInput` | `keyboardEnabled`, `keyPanStep`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
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.
@@ -239,9 +244,16 @@ and `add_implicit` asks for the level-set artist explicitly — also for a funct
239
244
  which is a pair of vertical lines. Only a *third* free symbol is refused: substitute parameters
240
245
  first.
241
246
 
247
+ A sympy `Eq(lhs, rhs)` is always a level set: it is normalized to `lhs - rhs = 0`, and an
248
+ equation with only `x` still means the vertical lines where it holds (`Eq(x**2, 1)`), not
249
+ `y = x**2 - 1`. The canonical `expression` keeps the `Eq(...)` text and `expr` is the normalized
250
+ difference.
251
+
242
252
  ```python
253
+ x, y = sp.symbols("x y")
243
254
  plot.add_curve("x**2 + y**2 - 1") # a circle: {x, y} routes to the level-set artist
244
- plot.add_implicit("x*y - 1") # the same artist, asked for by name
255
+ plot.add_curve(sp.Eq(x**2 + y**2, 1)) # the same circle in equation form
256
+ plot.add_implicit(sp.Eq(x, sp.sin(y))) # asked for by name, equation form
245
257
  plot.curves.at(0).expression = "x**2 + y**2 - 4" # re-routes the curve that is already there
246
258
  plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
247
259
  ```
@@ -305,10 +317,14 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
305
317
 
306
318
  ## Keyboard, touch and animation
307
319
 
308
- Clicking the plot gives it focus; while it has focus the arrow keys pan (10% of the viewport
309
- per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Every command is a property on
310
- the camera's `input` object, so a host can rebind it — the value is a `QKeySequence` string,
311
- and `|` separates alternatives (empty disables the command). `input.keyPanStep`,
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:
323
+ one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
324
+ there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
325
+ moves diagonally at the normal speed. Every command is a property on the camera's `input`
326
+ object, so a host can rebind it — the value is a `QKeySequence` string, and `|` separates
327
+ alternatives (empty disables the command). `input.keyPanStep`, `input.keyPanRepeatInterval`,
312
328
  `input.keyboardEnabled`, `panEnabled` and `zoomEnabled` shape the behaviour the same way.
313
329
 
314
330
  ```python
@@ -318,8 +334,9 @@ input.panLeftShortcut = "A" # rebind one arrow
318
334
  input.panRightShortcut = "D"
319
335
  input.zoomInShortcut = "Ctrl+="
320
336
  input.zoomOutShortcut = "Ctrl+-"
321
- input.keyPanStep = 0.2 # 20% of the viewport per arrow press
322
- camera.animationDuration = 220 # ms; 0 disables easing
337
+ input.keyPanStep = 0.2 # 20% of the viewport per step
338
+ input.keyPanRepeatInterval = 80 # ms per step; smaller = faster pan/zoom
339
+ camera.animationDuration = 220 # ms; tap easing
323
340
  ```
324
341
 
325
342
  Wheel and keyboard moves are **eased** (scale interpolates geometrically, so a zoom feels like
@@ -348,7 +365,7 @@ plot.available_themes # the names, for a host's picker (both dem
348
365
  The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
349
366
  `qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
350
367
  mapped onto this library's property names, and keys describing things this library does not
351
- 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
352
369
  them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
353
370
  theme alone.
354
371
 
@@ -360,13 +377,77 @@ theme alone.
360
377
  `x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
361
378
  axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
362
379
  nearest edge, so its labels are never lost (and a label never leaves the item); the grid
363
- 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).
364
392
  - `camera.viewport` reports the last size the view sent, so a host can size an export from it.
365
393
  - The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
366
394
  `nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
367
395
  and formats them, so a frame never recomputes ticks.
368
396
  - `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
369
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
+
370
451
  ## Export
371
452
 
372
453
  `savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
@@ -476,7 +557,7 @@ The supported path is GeoGebra's column idea implemented on the GPU: sample the
476
557
  sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
477
558
  cannot be represented by a line.
478
559
 
479
- 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**
480
561
  (0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
481
562
  `R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
482
563
  sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
@@ -511,7 +592,7 @@ cannot be represented by a line.
511
592
  viewport bottom; no filled ray.
512
593
  - **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
513
594
  Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
514
- 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
515
596
  dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
516
597
  branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
517
598
  sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
@@ -519,7 +600,7 @@ cannot be represented by a line.
519
600
  never evaluated; a pixel just outside the domain still gets the band, which is centred
520
601
  on the boundary.
521
602
 
522
- 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
523
604
  march, no tangent-foot projection and no column probe on the supported path. Expressions
524
605
  with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
525
606
  legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
@@ -554,6 +635,8 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
554
635
  matplotlib's default style, the limits are forwarded to the camera, and a theme applies
555
636
  (and re-applies as a reset) while an unknown name is rejected.
556
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.
557
640
  - `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
558
641
  files in the temp directory are not touched.
559
642
  - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
@@ -568,11 +651,11 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
568
651
  appearing inside a layout, an expression change / an invalid expression keeping the
569
652
  previous graph, curves added/hidden/removed through the model, **the wheel over a
570
653
  scroll area being consumed by the plot (zoom instead of scrolling the parent)**, the
571
- fixed aspect being undistorted by the widget's shape, and the plot staying out of the
572
- 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
573
656
  needs one, and a process may only have one).
574
657
  - `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
575
- must not steal the pan).
658
+ must not steal the pan), plus Tab focus entering and leaving the QML plot.
576
659
  - `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
577
660
  `adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
578
661
  outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
@@ -666,14 +749,27 @@ Startup to the first frame is about **230 ms**, attributed as follows
666
749
  Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
667
750
  the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
668
751
  default **12.4 ms**, `sin(1/x)` at zoom 90 (worst case) **12.5 ms**. The previous
669
- 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
670
753
  than the march's seed + tangent projection + walk + probe, and the march is now compiled
671
754
  only for the legacy path. The 1-D fragment source is about 21 KB.
672
755
 
673
- **Per-pixel `F` evaluations.** The supported path is fixed: 16 samples for the polyline
674
- (plus two interval evaluations for the column and the stroke footprint); there is no
675
- march budget and no band-window evaluation. The old fixed-grid/march table is superseded
676
- 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.
677
773
 
678
774
  ## Shader cache
679
775
 
@@ -728,6 +824,9 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
728
824
  **device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
729
825
  half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
730
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.
731
830
  - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
732
831
  widget has no such property, so it silently does nothing): inject the model with
733
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
- | `ViewInput` | `keyboardEnabled`, `keyPanStep`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
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.
@@ -226,9 +231,16 @@ and `add_implicit` asks for the level-set artist explicitly — also for a funct
226
231
  which is a pair of vertical lines. Only a *third* free symbol is refused: substitute parameters
227
232
  first.
228
233
 
234
+ A sympy `Eq(lhs, rhs)` is always a level set: it is normalized to `lhs - rhs = 0`, and an
235
+ equation with only `x` still means the vertical lines where it holds (`Eq(x**2, 1)`), not
236
+ `y = x**2 - 1`. The canonical `expression` keeps the `Eq(...)` text and `expr` is the normalized
237
+ difference.
238
+
229
239
  ```python
240
+ x, y = sp.symbols("x y")
230
241
  plot.add_curve("x**2 + y**2 - 1") # a circle: {x, y} routes to the level-set artist
231
- plot.add_implicit("x*y - 1") # the same artist, asked for by name
242
+ plot.add_curve(sp.Eq(x**2 + y**2, 1)) # the same circle in equation form
243
+ plot.add_implicit(sp.Eq(x, sp.sin(y))) # asked for by name, equation form
232
244
  plot.curves.at(0).expression = "x**2 + y**2 - 4" # re-routes the curve that is already there
233
245
  plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
234
246
  ```
@@ -292,10 +304,14 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
292
304
 
293
305
  ## Keyboard, touch and animation
294
306
 
295
- Clicking the plot gives it focus; while it has focus the arrow keys pan (10% of the viewport
296
- per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Every command is a property on
297
- the camera's `input` object, so a host can rebind it — the value is a `QKeySequence` string,
298
- and `|` separates alternatives (empty disables the command). `input.keyPanStep`,
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:
310
+ one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
311
+ there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
312
+ moves diagonally at the normal speed. Every command is a property on the camera's `input`
313
+ object, so a host can rebind it — the value is a `QKeySequence` string, and `|` separates
314
+ alternatives (empty disables the command). `input.keyPanStep`, `input.keyPanRepeatInterval`,
299
315
  `input.keyboardEnabled`, `panEnabled` and `zoomEnabled` shape the behaviour the same way.
300
316
 
301
317
  ```python
@@ -305,8 +321,9 @@ input.panLeftShortcut = "A" # rebind one arrow
305
321
  input.panRightShortcut = "D"
306
322
  input.zoomInShortcut = "Ctrl+="
307
323
  input.zoomOutShortcut = "Ctrl+-"
308
- input.keyPanStep = 0.2 # 20% of the viewport per arrow press
309
- camera.animationDuration = 220 # ms; 0 disables easing
324
+ input.keyPanStep = 0.2 # 20% of the viewport per step
325
+ input.keyPanRepeatInterval = 80 # ms per step; smaller = faster pan/zoom
326
+ camera.animationDuration = 220 # ms; tap easing
310
327
  ```
311
328
 
312
329
  Wheel and keyboard moves are **eased** (scale interpolates geometrically, so a zoom feels like
@@ -335,7 +352,7 @@ plot.available_themes # the names, for a host's picker (both dem
335
352
  The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
336
353
  `qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
337
354
  mapped onto this library's property names, and keys describing things this library does not
338
- 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
339
356
  them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
340
357
  theme alone.
341
358
 
@@ -347,13 +364,77 @@ theme alone.
347
364
  `x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
348
365
  axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
349
366
  nearest edge, so its labels are never lost (and a label never leaves the item); the grid
350
- 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).
351
379
  - `camera.viewport` reports the last size the view sent, so a host can size an export from it.
352
380
  - The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
353
381
  `nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
354
382
  and formats them, so a frame never recomputes ticks.
355
383
  - `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
356
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
+
357
438
  ## Export
358
439
 
359
440
  `savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
@@ -463,7 +544,7 @@ The supported path is GeoGebra's column idea implemented on the GPU: sample the
463
544
  sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
464
545
  cannot be represented by a line.
465
546
 
466
- 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**
467
548
  (0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
468
549
  `R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
469
550
  sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
@@ -498,7 +579,7 @@ cannot be represented by a line.
498
579
  viewport bottom; no filled ray.
499
580
  - **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
500
581
  Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
501
- 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
502
583
  dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
503
584
  branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
504
585
  sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
@@ -506,7 +587,7 @@ cannot be represented by a line.
506
587
  never evaluated; a pixel just outside the domain still gets the band, which is centred
507
588
  on the boundary.
508
589
 
509
- 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
510
591
  march, no tangent-foot projection and no column probe on the supported path. Expressions
511
592
  with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
512
593
  legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
@@ -541,6 +622,8 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
541
622
  matplotlib's default style, the limits are forwarded to the camera, and a theme applies
542
623
  (and re-applies as a reset) while an unknown name is rejected.
543
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.
544
627
  - `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
545
628
  files in the temp directory are not touched.
546
629
  - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
@@ -555,11 +638,11 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
555
638
  appearing inside a layout, an expression change / an invalid expression keeping the
556
639
  previous graph, curves added/hidden/removed through the model, **the wheel over a
557
640
  scroll area being consumed by the plot (zoom instead of scrolling the parent)**, the
558
- fixed aspect being undistorted by the widget's shape, and the plot staying out of the
559
- 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
560
643
  needs one, and a process may only have one).
561
644
  - `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
562
- must not steal the pan).
645
+ must not steal the pan), plus Tab focus entering and leaving the QML plot.
563
646
  - `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
564
647
  `adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
565
648
  outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
@@ -653,14 +736,27 @@ Startup to the first frame is about **230 ms**, attributed as follows
653
736
  Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
654
737
  the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
655
738
  default **12.4 ms**, `sin(1/x)` at zoom 90 (worst case) **12.5 ms**. The previous
656
- 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
657
740
  than the march's seed + tangent projection + walk + probe, and the march is now compiled
658
741
  only for the legacy path. The 1-D fragment source is about 21 KB.
659
742
 
660
- **Per-pixel `F` evaluations.** The supported path is fixed: 16 samples for the polyline
661
- (plus two interval evaluations for the column and the stroke footprint); there is no
662
- march budget and no band-window evaluation. The old fixed-grid/march table is superseded
663
- 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.
664
760
 
665
761
  ## Shader cache
666
762
 
@@ -715,6 +811,9 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
715
811
  **device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
716
812
  half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
717
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.
718
817
  - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
719
818
  widget has no such property, so it silently does nothing): inject the model with
720
819
  `widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.4.0"
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.0"
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",