codimate 0.1.4__tar.gz → 0.1.5__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 (74) hide show
  1. {codimate-0.1.4 → codimate-0.1.5}/Cargo.lock +1 -1
  2. {codimate-0.1.4 → codimate-0.1.5}/PKG-INFO +16 -7
  3. {codimate-0.1.4 → codimate-0.1.5}/README.md +9 -6
  4. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-py/Cargo.toml +1 -1
  5. {codimate-0.1.4 → codimate-0.1.5}/pyproject.toml +11 -2
  6. {codimate-0.1.4 → codimate-0.1.5}/python/codimate/__init__.py +4 -2
  7. {codimate-0.1.4 → codimate-0.1.5}/python/codimate/layout.py +214 -0
  8. {codimate-0.1.4 → codimate-0.1.5}/python/codimate/trace.py +79 -9
  9. {codimate-0.1.4 → codimate-0.1.5}/Cargo.toml +0 -0
  10. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-animation/Cargo.toml +0 -0
  11. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-animation/src/lib.rs +0 -0
  12. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-animation/tests/animation.rs +0 -0
  13. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-animation/tests/parallel.rs +0 -0
  14. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-animation/tests/playable.rs +0 -0
  15. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-animation/tests/sequence.rs +0 -0
  16. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-animation/tests/stagger.rs +0 -0
  17. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/Cargo.toml +0 -0
  18. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/builder.rs +0 -0
  19. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/easing.rs +0 -0
  20. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/glyph.rs +0 -0
  21. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/lib.rs +0 -0
  22. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/manim_palette.rs +0 -0
  23. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/path.rs +0 -0
  24. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/scene/circle.rs +0 -0
  25. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/scene/connection.rs +0 -0
  26. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/scene/mod.rs +0 -0
  27. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/scene/path.rs +0 -0
  28. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/scene/primitive.rs +0 -0
  29. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/scene/pulse.rs +0 -0
  30. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/scene/rect.rs +0 -0
  31. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/scene/text.rs +0 -0
  32. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/scene/transform.rs +0 -0
  33. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/src/value.rs +0 -0
  34. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/anchors.rs +0 -0
  35. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/animated.rs +0 -0
  36. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/circle.rs +0 -0
  37. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/connection.rs +0 -0
  38. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/easing.rs +0 -0
  39. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/manim_palette.rs +0 -0
  40. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/path.rs +0 -0
  41. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/ports.rs +0 -0
  42. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/pulse.rs +0 -0
  43. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/rect.rs +0 -0
  44. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/scene.rs +0 -0
  45. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/scene_effect_helpers.rs +0 -0
  46. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-core/tests/tween.rs +0 -0
  47. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-export/Cargo.toml +0 -0
  48. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-export/src/lib.rs +0 -0
  49. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-export/tests/export.rs +0 -0
  50. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-export/tests/png.rs +0 -0
  51. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-fonts/Cargo.toml +0 -0
  52. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-fonts/build.rs +0 -0
  53. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-fonts/fonts/DejaVuSansMono.ttf +0 -0
  54. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-fonts/fonts/DroidSansFallbackFull.ttf +0 -0
  55. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-fonts/fonts/NotoSansKhmer-Regular.ttf +0 -0
  56. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-fonts/src/lib.rs +0 -0
  57. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-glyph/Cargo.toml +0 -0
  58. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-glyph/src/lib.rs +0 -0
  59. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-layout/Cargo.toml +0 -0
  60. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-layout/src/lib.rs +0 -0
  61. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-layout/tests/layout.rs +0 -0
  62. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-math/Cargo.toml +0 -0
  63. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-math/src/lib.rs +0 -0
  64. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-py/src/lib.rs +0 -0
  65. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-reconcile/Cargo.toml +0 -0
  66. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-reconcile/src/lib.rs +0 -0
  67. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-render/Cargo.toml +0 -0
  68. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-render/src/command.rs +0 -0
  69. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-render/src/lib.rs +0 -0
  70. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-render/src/raster.rs +0 -0
  71. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-render/tests/raster.rs +0 -0
  72. {codimate-0.1.4 → codimate-0.1.5}/crates/codimate-render/tests/render.rs +0 -0
  73. {codimate-0.1.4 → codimate-0.1.5}/python/codimate/explain.py +0 -0
  74. {codimate-0.1.4 → codimate-0.1.5}/python/codimate/scene.py +0 -0
@@ -217,7 +217,7 @@ dependencies = [
217
217
 
218
218
  [[package]]
219
219
  name = "codimate-py"
220
- version = "0.1.4"
220
+ version = "0.1.5"
221
221
  dependencies = [
222
222
  "codimate-animation",
223
223
  "codimate-core",
@@ -1,8 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codimate
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.9
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
6
12
  Classifier: Topic :: Multimedia :: Video
7
13
  Classifier: Topic :: Education
8
14
  Requires-Dist: imageio-ffmpeg>=0.4.9
@@ -15,6 +21,10 @@ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
15
21
  [![PyPI](https://img.shields.io/pypi/v/codimate)](https://pypi.org/project/codimate/)
16
22
  [![Python](https://img.shields.io/pypi/pyversions/codimate)](https://pypi.org/project/codimate/)
17
23
 
24
+ ![Planets tracing helices around the Sun's path through the galaxy](docs/media/helical_solar_system.gif)
25
+
26
+ *Two seconds of [`helical_solar_system/`](python/examples/helical_solar_system/)*
27
+
18
28
  Turn a running algorithm into an explainer video.
19
29
 
20
30
  You write your algorithm as normal Python and say what each step looks like.
@@ -23,14 +33,13 @@ Codimate works out the motion, the timing, and every frame.
23
33
  ```python
24
34
  import codimate as cm
25
35
 
26
- @cm.trace()
27
- def bubble_sort(values):
36
+ def bubble_sort(values, emit):
28
37
  for i in range(len(values)):
29
38
  for j in range(len(values) - 1 - i):
30
- cm.emit("compare", items=[values[j], values[j + 1]])
39
+ emit("compare", items=[values[j], values[j + 1]])
31
40
  if values[j] > values[j + 1]:
32
41
  values[j], values[j + 1] = values[j + 1], values[j]
33
- cm.emit("swap", items=[values[j], values[j + 1]])
42
+ emit("swap", items=[values[j], values[j + 1]])
34
43
 
35
44
  def bars(frame):
36
45
  scene = cm.Scene()
@@ -45,7 +54,7 @@ def bars(frame):
45
54
  return scene
46
55
 
47
56
  cm.explain(
48
- trace=bubble_sort(cm.items([3, 1, 4, 2])),
57
+ trace=cm.trace(bubble_sort, cm.items([3, 1, 4, 2])),
49
58
  view=bars,
50
59
  motion=[cm.Rule("*", position="lift_carry_drop", clearance=90)],
51
60
  timing=cm.Timing(default=0.55, events={"swap": 0.9}),
@@ -335,7 +344,7 @@ with `python docs/build_site.py`.
335
344
  authored, and the one decision you have to make.
336
345
  4. [Reference](docs/reference.md) — every call and parameter, on one page.
337
346
 
338
- Then [`python/examples/`](python/examples/), eight worked examples with notes, and
347
+ Then [`python/examples/`](python/examples/), eleven worked examples with notes, and
339
348
  [the decisions](docs/adr/) behind the design.
340
349
 
341
350
  ```bash
@@ -3,6 +3,10 @@
3
3
  [![PyPI](https://img.shields.io/pypi/v/codimate)](https://pypi.org/project/codimate/)
4
4
  [![Python](https://img.shields.io/pypi/pyversions/codimate)](https://pypi.org/project/codimate/)
5
5
 
6
+ ![Planets tracing helices around the Sun's path through the galaxy](docs/media/helical_solar_system.gif)
7
+
8
+ *Two seconds of [`helical_solar_system/`](python/examples/helical_solar_system/)*
9
+
6
10
  Turn a running algorithm into an explainer video.
7
11
 
8
12
  You write your algorithm as normal Python and say what each step looks like.
@@ -11,14 +15,13 @@ Codimate works out the motion, the timing, and every frame.
11
15
  ```python
12
16
  import codimate as cm
13
17
 
14
- @cm.trace()
15
- def bubble_sort(values):
18
+ def bubble_sort(values, emit):
16
19
  for i in range(len(values)):
17
20
  for j in range(len(values) - 1 - i):
18
- cm.emit("compare", items=[values[j], values[j + 1]])
21
+ emit("compare", items=[values[j], values[j + 1]])
19
22
  if values[j] > values[j + 1]:
20
23
  values[j], values[j + 1] = values[j + 1], values[j]
21
- cm.emit("swap", items=[values[j], values[j + 1]])
24
+ emit("swap", items=[values[j], values[j + 1]])
22
25
 
23
26
  def bars(frame):
24
27
  scene = cm.Scene()
@@ -33,7 +36,7 @@ def bars(frame):
33
36
  return scene
34
37
 
35
38
  cm.explain(
36
- trace=bubble_sort(cm.items([3, 1, 4, 2])),
39
+ trace=cm.trace(bubble_sort, cm.items([3, 1, 4, 2])),
37
40
  view=bars,
38
41
  motion=[cm.Rule("*", position="lift_carry_drop", clearance=90)],
39
42
  timing=cm.Timing(default=0.55, events={"swap": 0.9}),
@@ -323,7 +326,7 @@ with `python docs/build_site.py`.
323
326
  authored, and the one decision you have to make.
324
327
  4. [Reference](docs/reference.md) — every call and parameter, on one page.
325
328
 
326
- Then [`python/examples/`](python/examples/), eight worked examples with notes, and
329
+ Then [`python/examples/`](python/examples/), eleven worked examples with notes, and
327
330
  [the decisions](docs/adr/) behind the design.
328
331
 
329
332
  ```bash
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "codimate-py"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "codimate"
7
- version = "0.1.4"
7
+ version = "0.1.5"
8
8
  description = "Turn a running algorithm into an explainer video"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -13,9 +13,19 @@ requires-python = ">=3.9"
13
13
  # own — so `pip install codimate` is enough on a bare machine. `typst`, which
14
14
  # only `scene.formula` needs, stays a documented prerequisite.
15
15
  dependencies = ["imageio-ffmpeg>=0.4.9"]
16
+ # The per-version entries are not decoration: `requires-python` above is what
17
+ # pip enforces, but PyPI's sidebar and the shields.io badge both read these
18
+ # instead, and without them the badge reads "missing". One abi3-py39 wheel
19
+ # serves every version listed.
16
20
  classifiers = [
17
21
  "Programming Language :: Rust",
18
22
  "Programming Language :: Python :: Implementation :: CPython",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.9",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
19
29
  "Topic :: Multimedia :: Video",
20
30
  "Topic :: Education",
21
31
  ]
@@ -41,7 +51,6 @@ select = ["E", "W", "F", "PLR0913"]
41
51
  ignore = ["E741"]
42
52
 
43
53
  [tool.ruff.lint.per-file-ignores]
44
- "python/examples/attention/weights.py" = ["E501"]
45
54
 
46
55
  [tool.ruff.lint.pylint]
47
56
  max-args = 5
@@ -48,8 +48,8 @@ encoding — happens in Rust (ADR 0008).
48
48
  from __future__ import annotations
49
49
 
50
50
  from .explain import Explanation, Rule, Timing, ease, explain
51
- from .layout import (Place, Slot, at, canvas, column, height, measure, measure_math,
52
- ngon, row, star, width)
51
+ from .layout import (Axes, Place, Slot, at, axes, canvas, column, height, measure,
52
+ measure_math, ngon, row, star, width)
53
53
  from .scene import Group, Handle, Scene
54
54
  from .trace import Event, Frame, Item, Trace, emit, items, trace
55
55
 
@@ -77,6 +77,8 @@ __all__ = [
77
77
  "at",
78
78
  "row",
79
79
  "column",
80
+ "axes",
81
+ "Axes",
80
82
  "ngon",
81
83
  "star",
82
84
  # putting it together
@@ -306,3 +306,217 @@ def _resolve(given, half, names, default=_UNSET):
306
306
  if low is not None:
307
307
  return float(low) + half
308
308
  return float(high) - half
309
+
310
+
311
+ # --------------------------------------------------------------------------
312
+ # Axes: a coordinate map, not a drawing (ADR 0016)
313
+ # --------------------------------------------------------------------------
314
+
315
+
316
+ def _nice_step(span: float, about: int) -> float:
317
+ """A round step near ``span / about``, from the 1-2-5 sequence.
318
+
319
+ Round steps are not a nicety. A tick is a named shape, so the set of them
320
+ is an identity that survives from one Scene to the next; a step that
321
+ drifted with every frame would rename every tick every frame, and a still
322
+ picture would flicker. 1-2-5 holds steady until the range really changes.
323
+ """
324
+ import math
325
+
326
+ if span <= 0:
327
+ raise ValueError(f"a range needs width, got {span}")
328
+ raw = span / max(int(about), 1)
329
+ power = 10.0 ** math.floor(math.log10(raw))
330
+ for nice in (1.0, 2.0, 5.0):
331
+ if raw <= nice * power:
332
+ return nice * power
333
+ return 10.0 * power
334
+
335
+
336
+ def _places(step: float) -> int:
337
+ """How many decimals a label needs so that two ticks never read alike."""
338
+ import math
339
+
340
+ return max(0, -math.floor(math.log10(step) + 1e-9))
341
+
342
+
343
+ @dataclass(frozen=True)
344
+ class Axes:
345
+ """A map from your numbers to pixels. Built by :func:`axes`.
346
+
347
+ It draws nothing you did not ask it to and owns none of your names — see
348
+ :meth:`at`. Its own shapes (frame, ticks, labels) are drawn by
349
+ :meth:`draw`, under names you choose the prefix of.
350
+ """
351
+
352
+ x0: float
353
+ x1: float
354
+ y0: float
355
+ y1: float
356
+ left: float
357
+ top: float
358
+ w: float
359
+ h: float
360
+ ink: str = "#8b96a8"
361
+ label: float = 20.0
362
+ layer: int = 0
363
+
364
+ def looks(self, ink: str = None, label: float = None,
365
+ layer: int = None) -> "Axes":
366
+ """Restyle the frame, ticks and labels. Returns a new Axes.
367
+
368
+ plot = cm.axes(x=(0, 10), y=(0, 5)).looks(ink="#334", label=16)
369
+
370
+ A separate call rather than more arguments on :meth:`draw`, which the
371
+ shapes made the same choice about: no call in this library takes more
372
+ than five things.
373
+ """
374
+ import dataclasses
375
+
376
+ return dataclasses.replace(
377
+ self,
378
+ ink=self.ink if ink is None else str(ink),
379
+ label=self.label if label is None else float(label),
380
+ layer=self.layer if layer is None else int(layer))
381
+
382
+ @property
383
+ def right(self) -> float:
384
+ return self.left + self.w
385
+
386
+ @property
387
+ def bottom(self) -> float:
388
+ return self.top + self.h
389
+
390
+ def at(self, x: float, y: float) -> tuple[float, float]:
391
+ """One data point as a pixel pair, for any shape's ``at=``.
392
+
393
+ scene.circle("dot", r=8, at=plot.at(2.0, 4.0))
394
+
395
+ Handing back pixels rather than drawing is the whole design: what you
396
+ do with them is an ordinary shape with a name of yours, so it tweens,
397
+ `focus` frames it, and a motion Rule can be aimed at it.
398
+ """
399
+ across = (float(x) - self.x0) / (self.x1 - self.x0)
400
+ up = (float(y) - self.y0) / (self.y1 - self.y0)
401
+ return (self.left + across * self.w, self.bottom - up * self.h)
402
+
403
+ def line(self, f, steps: int = 200, over=None) -> list:
404
+ """``steps + 1`` points along ``y = f(x)``, as pixels.
405
+
406
+ scene.curve("f", plot.line(lambda x: x * x), w=4).fill("red")
407
+
408
+ ``over=(lo, hi)`` samples part of the range instead of all of it.
409
+
410
+ An open `curve` is *drawn* rather than filled, so its colour is
411
+ ``fill()`` and its thickness is ``w=`` — ``fill("none", edge=...)``
412
+ draws nothing at all.
413
+
414
+ Every sample is returned, including any that fall outside the box.
415
+ Dropping them would be prettier and is wrong: two curves with different
416
+ point counts do not interpolate (ADR 0010), so a curve that shed a
417
+ point as it left the frame would stop animating. Keep the count and
418
+ clamp the range with ``over=`` if you need it inside.
419
+ """
420
+ lo, hi = (self.x0, self.x1) if over is None else (float(over[0]),
421
+ float(over[1]))
422
+ steps = max(int(steps), 1)
423
+ return [self.at(x, f(x))
424
+ for x in (lo + (hi - lo) * i / steps for i in range(steps + 1))]
425
+
426
+ def ticks(self, axis: str = "x", about: int = 6) -> list:
427
+ """``(n, value, label)`` per tick, where ``n`` is the step multiple.
428
+
429
+ ``n`` is what a tick is named after, not the value. A float that drifts
430
+ by one part in a billion is a different name, and a renamed shape
431
+ leaves and re-enters — which is a fade, on a picture that did not move.
432
+ An integer count of steps cannot drift.
433
+ """
434
+ import math
435
+
436
+ lo, hi = (self.x0, self.x1) if axis == "x" else (self.y0, self.y1)
437
+ step = _nice_step(hi - lo, about)
438
+ digits = _places(step)
439
+ first = math.ceil(lo / step - 1e-9)
440
+ last = math.floor(hi / step + 1e-9)
441
+ out = []
442
+ for n in range(int(first), int(last) + 1):
443
+ value = n * step
444
+ label = f"{value:.{digits}f}"
445
+ out.append((n, value, "0" if label.lstrip("-").strip("0.") == ""
446
+ else label))
447
+ return out
448
+
449
+ def draw(self, scene, name="plot", *, about: int = 6,
450
+ grid: bool = False) -> "Axes":
451
+ """Draw the frame, ticks and labels. Returns the Axes, for chaining.
452
+
453
+ plot = cm.axes(x=(-4, 4), y=(-2, 6)).draw(scene)
454
+
455
+ Every shape is named ``(name, ...)``, so two plots on one canvas do not
456
+ collide and you can restyle or omit any of them by drawing your own.
457
+ Colours and sizes come from :meth:`looks`.
458
+ """
459
+ ink, layer = self.ink, self.layer
460
+ zero_y = self.y0 <= 0.0 <= self.y1
461
+ zero_x = self.x0 <= 0.0 <= self.x1
462
+ base = self.at(self.x0, 0.0)[1] if zero_y else self.bottom
463
+ spine = self.at(0.0, self.y0)[0] if zero_x else self.left
464
+
465
+ scene.line((name, "x-axis"), start=(self.left, base),
466
+ end=(self.right, base), w=1.6).fill(ink).on(layer=layer)
467
+ scene.line((name, "y-axis"), start=(spine, self.top),
468
+ end=(spine, self.bottom), w=1.6).fill(ink).on(layer=layer)
469
+
470
+ for axis, along in (("x", True), ("y", False)):
471
+ for n, value, words in self.ticks(axis, about):
472
+ if n == 0 and (zero_x if along else zero_y):
473
+ continue # the other axis already draws through it
474
+ x, y = (self.at(value, 0.0)[0], base) if along else \
475
+ (spine, self.at(0.0, value)[1])
476
+ if grid:
477
+ ends = ((x, self.top), (x, self.bottom)) if along else \
478
+ ((self.left, y), (self.right, y))
479
+ scene.line((name, "grid", axis, n), start=ends[0],
480
+ end=ends[1], w=1.0).fill(ink) \
481
+ .on(layer=layer - 1, opacity=0.18)
482
+ mark = ((x, y - 5), (x, y + 5)) if along else \
483
+ ((x - 5, y), (x + 5, y))
484
+ scene.line((name, "tick", axis, n), start=mark[0], end=mark[1],
485
+ w=1.6).fill(ink).on(layer=layer)
486
+ where = at(x=x, top=y + 10) if along else at(x=x - 18, y=y)
487
+ scene.text((name, "label", axis, n), words, size=self.label,
488
+ at=where).fill(ink).on(layer=layer)
489
+ return self
490
+
491
+
492
+ def axes(*, x, y, at=None, size=None, within: "Slot | None" = None) -> Axes:
493
+ """A coordinate map from your numbers to pixels.
494
+
495
+ plot = cm.axes(x=(-4, 4), y=(-2, 6), size=(760, 420)).draw(scene)
496
+ scene.curve("f", plot.line(lambda t: t * t), w=4).fill("orange")
497
+ scene.circle("dot", r=8, at=plot.at(t, t * t))
498
+
499
+ ``x`` and ``y`` are ``(low, high)``. ``size`` is ``(w, h)`` in pixels, or
500
+ one number for a square; it defaults to most of the canvas. ``at`` places
501
+ the box's centre, and ``within=slot`` fits it to part of the canvas.
502
+
503
+ It returns points rather than drawing, so what you plot is a shape with a
504
+ name of yours — which is what lets it tween, be framed by `focus`, and be
505
+ aimed at by a motion Rule. See ADR 0016.
506
+ """
507
+ box = _within(within)
508
+ w, h = _size(size)
509
+ if w is None:
510
+ w = box.w * 0.72
511
+ if h is None:
512
+ h = box.h * 0.66
513
+ place = at or Place()
514
+ cx = box.x if place.x is None else place.x
515
+ cy = _resolve((place.y, place.top, place.bottom), h / 2.0,
516
+ ("y", "top", "bottom"), box.y)
517
+ x0, x1 = (float(v) for v in x)
518
+ y0, y1 = (float(v) for v in y)
519
+ if x0 == x1 or y0 == y1:
520
+ raise ValueError(f"a range needs width, got x={x!r} y={y!r}")
521
+ return Axes(x0=x0, x1=x1, y0=y0, y1=y1,
522
+ left=cx - w / 2.0, top=cy - h / 2.0, w=float(w), h=float(h))
@@ -99,15 +99,75 @@ def emit(name: str, **data: Any) -> None:
99
99
  rec.events.append(Event(name=name, state=rec.snapshot(), data=data))
100
100
 
101
101
 
102
- def trace(*, snapshot: Callable[..., Any] = None):
103
- """Turn a normal, state-mutating function into a Trace.
102
+ _MISSING = object()
104
103
 
105
- ``snapshot`` receives the same arguments as your function and returns a
106
- copy of the data worth showing. Defaults to a deep copy of the first
107
- argument — Items keep their identity through it.
104
+
105
+ def _emitter(rec: "_Recorder") -> Callable[..., None]:
106
+ """The `emit` handed to an algorithm: record that something happened."""
107
+
108
+ def emit(name: str, **data: Any) -> None:
109
+ rec.events.append(Event(name=name, state=rec.snapshot(), data=data))
110
+
111
+ emit.__doc__ = globals()["emit"].__doc__
112
+ return emit
113
+
114
+
115
+ def trace(fn=_MISSING, state=_MISSING, /, *, snapshot=None) -> "Trace":
116
+ """Run `fn` over `state` and record what happened.
117
+
118
+ def bubble_sort(values, emit):
119
+ ...
120
+ emit("compare", items=[a, b])
121
+
122
+ cm.explain(trace=cm.trace(bubble_sort, cm.items([3, 1, 4, 2])), ...)
123
+
124
+ Your function is handed the state and an `emit`. Call `emit` *after*
125
+ changing the state — Codimate snapshots the result for you.
126
+
127
+ `emit` is an argument rather than something ambient, so the answer to
128
+ "where did that come from?" is the line above it, and so the function
129
+ stays ordinary Python: `bubble_sort(values, print)` runs it and prints the
130
+ events.
131
+
132
+ `snapshot(state)` returns the part worth showing, if a deep copy of the
133
+ whole state is not what you want. Items keep their identity through the
134
+ default copy.
135
+
136
+ The older form — `@cm.trace()` on a function that calls the module-level
137
+ `cm.emit` — still works and warns. It goes in 0.2.
108
138
  """
139
+ if fn is _MISSING or state is _MISSING:
140
+ return _decorator(snapshot) if fn is _MISSING else _decorator(snapshot)(fn)
141
+
142
+ def capture():
143
+ return snapshot(state) if snapshot is not None else copy.deepcopy(state)
144
+
145
+ rec = _Recorder(snapshot=capture, events=[])
146
+ initial = capture()
147
+
148
+ # The ContextVar is still set, so a helper that has not been moved over
149
+ # yet and still calls `cm.emit` keeps working during the transition.
150
+ token = _recorder.set(rec)
151
+ try:
152
+ fn(state, _emitter(rec))
153
+ finally:
154
+ _recorder.reset(token)
155
+
156
+ return Trace(initial=initial, events=tuple(rec.events))
157
+
158
+
159
+ def _decorator(snapshot):
160
+ """The older `@cm.trace()`, kept working until 0.2."""
161
+ import warnings
162
+
163
+ def decorate(fn):
164
+ warnings.warn(
165
+ "@cm.trace() and the ambient cm.emit are removed in 0.2. Take `emit` "
166
+ "as an argument and call cm.trace(fn, state) instead:\n"
167
+ f" def {fn.__name__}(state, emit): ...\n"
168
+ f" cm.explain(trace=cm.trace({fn.__name__}, state), ...)",
169
+ DeprecationWarning, stacklevel=3)
109
170
 
110
- def decorator(fn):
111
171
  def run(*args, **kwargs) -> Trace:
112
172
  def capture():
113
173
  if snapshot is not None:
@@ -116,20 +176,30 @@ def trace(*, snapshot: Callable[..., Any] = None):
116
176
 
117
177
  rec = _Recorder(snapshot=capture, events=[])
118
178
  initial = capture()
119
-
120
179
  token = _recorder.set(rec)
121
180
  try:
122
181
  fn(*args, **kwargs)
123
182
  finally:
124
183
  _recorder.reset(token)
125
-
126
184
  return Trace(initial=initial, events=tuple(rec.events))
127
185
 
128
186
  run.__name__ = fn.__name__
129
187
  run.__doc__ = fn.__doc__
130
188
  return run
131
189
 
132
- return decorator
190
+ return decorate
191
+
192
+
193
+ def items(self, key: str = "items") -> list:
194
+ """The things this event named, e.g. ``frame.items()``.
195
+
196
+ Use it when you emitted a list: ``cm.emit("compare", items=[a, b])``.
197
+ Empty for the opening moment.
198
+ """
199
+ if self.event is None:
200
+ return []
201
+ return list(self.event.data.get(key, ()))
202
+
133
203
 
134
204
  @dataclass(frozen=True)
135
205
  class Frame:
File without changes