codimate 0.1.0__cp39-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codimate/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ """Codimate — turn a running algorithm into an explainer video.
2
+
3
+ You write four things, and never a keyframe:
4
+
5
+ algorithm your normal code, with emit() where something happens
6
+ view what one moment looks like
7
+ motion how things travel between moments
8
+ timing how long each moment lasts
9
+
10
+ Codimate pairs shapes between moments **by name** and turns the differences
11
+ into movement. Everything per-frame — diffing, interpolation, drawing,
12
+ encoding — happens in Rust (ADR 0008).
13
+
14
+ ## What your algorithm did
15
+
16
+ - `trace` — mark a function so Codimate can watch it run
17
+ - `emit` — say that something worth showing just happened
18
+ - `items` — a list whose entries keep their identity when they move
19
+ - `Item`, `Event`, `Trace`, `Frame` — what your view is handed
20
+
21
+ ## What a moment looks like
22
+
23
+ - `Scene` — one picture: `rect`, `circle`, `polygon`, `arrow`, `text`, `line`, `formula`
24
+ - `ngon`, `star` — corners for a polygon, so you do not compute them
25
+ - `Group` — several shapes that move together
26
+ - `Handle` — what a shape call returns: `.fill()`, `.round()`, `.turn()`,
27
+ `.grow()`, `.on()`, `.write()`, chained
28
+
29
+ ## Where things sit
30
+
31
+ - `canvas`, `width`, `height` — the frame
32
+ - `row`, `column`, `Slot` — divide it up, without coordinates
33
+ - `measure`, `measure_math` — how big text or a formula will actually be
34
+
35
+ ## How it moves, and for how long
36
+
37
+ - `Rule` — the path a shape travels
38
+ - `Timing` — how long each event lasts
39
+ - `ease` — the curve the Engine uses, if you need to draw it
40
+
41
+ ## Running it
42
+
43
+ - `explain` — gather algorithm, view, motion and timing
44
+ - `Explanation.render` — write the video
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from .explain import Explanation, Rule, Timing, ease, explain
50
+ from .layout import (Place, Slot, at, canvas, column, height, measure, measure_math,
51
+ ngon, row, star, width)
52
+ from .scene import Group, Handle, Scene
53
+ from .trace import Event, Frame, Item, Trace, emit, items, trace
54
+
55
+ __all__ = [
56
+ # what happened
57
+ "emit",
58
+ "trace",
59
+ "items",
60
+ "Item",
61
+ "Event",
62
+ "Trace",
63
+ "Frame",
64
+ # what it looks like
65
+ "Scene",
66
+ "Group",
67
+ "Handle",
68
+ # where things sit
69
+ "canvas",
70
+ "measure",
71
+ "measure_math",
72
+ "width",
73
+ "height",
74
+ "Slot",
75
+ "Place",
76
+ "at",
77
+ "row",
78
+ "column",
79
+ "ngon",
80
+ "star",
81
+ # putting it together
82
+ "Rule",
83
+ "Timing",
84
+ "ease",
85
+ "explain",
86
+ "Explanation",
87
+ ]
codimate/_codimate.pyd ADDED
Binary file
codimate/explain.py ADDED
@@ -0,0 +1,269 @@
1
+ """Putting it together: motion rules, timing, and the render call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .layout import height, width
6
+ from .scene import Scene, _key
7
+ from typing import Callable
8
+
9
+ from .trace import Event, Frame, Trace
10
+
11
+
12
+ View = Callable[[Frame], Scene]
13
+
14
+ PATHS = ("straight", "linear", "fall", "lift_carry_drop")
15
+
16
+
17
+ class Rule:
18
+ """How things matching ``pattern`` travel. First matching rule wins.
19
+
20
+ cm.Rule("*", position="lift_carry_drop", clearance=90)
21
+
22
+ ``pattern`` matches a shape's full name with ``*`` and ``?``. A shape
23
+ inside a group is named ``group/child``, so ``"3/*"`` targets one group and
24
+ ``"*"`` targets everything.
25
+
26
+ Paths:
27
+
28
+ * ``straight`` — a straight line, easing in and out. The default, and what
29
+ you want when each event is a distinct step.
30
+ * ``linear`` — a straight line at constant speed. Use it when a thing is
31
+ mid-journey at every event, like something turning: easing would make it
32
+ accelerate and stop inside each segment.
33
+ * ``fall`` — a parabola: sideways at a constant rate, downwards
34
+ accelerating. What a dropped thing does, and what each hop of a falling
35
+ ball needs, since an eased path would settle gently instead of arriving.
36
+ * ``lift_carry_drop`` — arcs up and over, then falls. Takes ``clearance``.
37
+ """
38
+
39
+ def __init__(self, pattern, position: str = "straight", **options: float) -> None:
40
+ if position not in PATHS:
41
+ raise ValueError(
42
+ f"unknown path {position!r} — use one of {', '.join(PATHS)}")
43
+ self.pattern = _key(pattern) if isinstance(pattern, tuple) else str(pattern)
44
+ self.position = position
45
+ self.options = {k: float(v) for k, v in options.items()}
46
+
47
+ def _payload(self):
48
+ return (self.pattern, self.position, self.options)
49
+
50
+ def __repr__(self):
51
+ return f"Rule({self.pattern!r}, position={self.position!r}, **{self.options})"
52
+
53
+
54
+ class Timing:
55
+ """How long each event lasts, in seconds."""
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ default: float = 0.6,
61
+ events: "dict[str, float] | None" = None,
62
+ opening: float = 0.8,
63
+ final_hold: float = 1.2,
64
+ ) -> None:
65
+ self.default = default
66
+ self.events = events or {}
67
+ self.opening = opening
68
+ self.final_hold = final_hold
69
+
70
+ def for_event(self, event: Event) -> float:
71
+ """How long ``event`` lasts: its own entry, or ``default``.
72
+
73
+ A name with no entry takes ``default`` silently — which is what a
74
+ default is for, but it means a misspelled event name costs you the
75
+ default duration rather than an error.
76
+ """
77
+ return self.events.get(event.name, self.default)
78
+
79
+
80
+ def ease(t: float) -> float:
81
+ """The easing curve the Engine applies between two moments.
82
+
83
+ cm.ease(0.5) -> 0.5
84
+
85
+ This calls into the Engine, so it is the same curve your animation is
86
+ actually using — not a copy of it.
87
+ """
88
+ from . import _codimate
89
+
90
+ return _codimate.ease(float(t))
91
+
92
+
93
+ class Explanation:
94
+ """A trace, a view and a timing, ready to render.
95
+
96
+ Built by :func:`explain` rather than directly. Holds one Scene per Trace
97
+ Event and the gap between each pair; :meth:`render` hands all of it to the
98
+ Engine once, and everything per-frame happens in there.
99
+ """
100
+ def __init__(
101
+ self,
102
+ *,
103
+ trace: Trace,
104
+ view: View,
105
+ motion: "list[Rule] | None" = None,
106
+ timing: "Timing | None" = None,
107
+ ) -> None:
108
+ self.timing = timing or Timing()
109
+ self.motion = motion or []
110
+ self.trace = trace
111
+
112
+ # The view runs once per event, not per frame — this is the whole
113
+ # reason Python is fast enough to be the authoring language.
114
+ self.scenes = [view(Frame(state=trace.initial, event=None))]
115
+ for event in trace.events:
116
+ self.scenes.append(view(Frame(state=event.state, event=event)))
117
+
118
+ self.durations = [self.timing.for_event(e) for e in trace.events]
119
+
120
+ # A held opening and a held ending, expressed as segments that go
121
+ # nowhere. No special case in the Engine.
122
+ self.scenes.insert(0, self.scenes[0])
123
+ self.durations.insert(0, self.timing.opening)
124
+ self.scenes.append(self.scenes[-1])
125
+ self.durations.append(self.timing.final_hold)
126
+
127
+ @property
128
+ def duration(self) -> float:
129
+ return sum(self.durations)
130
+
131
+ def render(self, output: str, *, fps: float = 30, scale: float = 1.0) -> str:
132
+ """Draw every frame and write the video.
133
+
134
+ Coordinates always mean what `cm.canvas()` says — `scale` only changes
135
+ how many pixels each one becomes, so nothing in your view has to move:
136
+
137
+ .render("out.mp4", fps=60, scale=1.5) # 1080p60 from the default
138
+
139
+ Frames are rasterized at the larger size rather than upscaled
140
+ afterwards, so 1080p is genuinely drawn at 1080p.
141
+
142
+ The folder is created if it does not exist, so `render("results/x.mp4")`
143
+ works on a fresh clone.
144
+ """
145
+ from pathlib import Path
146
+
147
+ from . import _codimate # imported here so the pure Python is testable
148
+
149
+ _find_encoder()
150
+ Path(output).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
151
+
152
+ _codimate.render(
153
+ scenes=[s._payload() for s in self.scenes],
154
+ cameras=[s._camera() for s in self.scenes],
155
+ rules=[r._payload() for r in self.motion],
156
+ durations=self.durations,
157
+ output=output,
158
+ width=width(),
159
+ height=height(),
160
+ fps=float(fps),
161
+ scale=float(scale),
162
+ )
163
+ return output
164
+
165
+ def frame_at(self, seconds: float, output: str = "frame.png",
166
+ scale: float = 1.0) -> str:
167
+ """Save a single moment as a PNG, without rendering the video.
168
+
169
+ cm.explain(...).frame_at(12.5, "check.png")
170
+
171
+ The same scenes, timing and arithmetic as :meth:`render`, resolved at
172
+ one instant. Checking a frame by rendering the whole video and seeking
173
+ into it costs a minute to look at one second.
174
+
175
+ ``scale`` matches ``render``'s, so the debug frame is rasterized the
176
+ way the video is — worth passing when you are checking text, which is
177
+ the thing that has historically differed between the two.
178
+ """
179
+ from pathlib import Path
180
+
181
+ from . import _codimate
182
+
183
+ Path(output).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
184
+ _codimate.render_frame_png(
185
+ scenes=[s._payload() for s in self.scenes],
186
+ cameras=[s._camera() for s in self.scenes],
187
+ rules=[r._payload() for r in self.motion],
188
+ durations=self.durations,
189
+ seconds=float(seconds),
190
+ output=output,
191
+ width=width(),
192
+ height=height(),
193
+ scale=float(scale),
194
+ )
195
+ return output
196
+
197
+ def timeline(self) -> "list[tuple[float, float, str]]":
198
+ """Every beat as ``(start, duration, event name)``, in seconds.
199
+
200
+ for start, length, name in cm.explain(...).timeline():
201
+ print(f"{start:6.2f} {length:4.2f} {name}")
202
+
203
+ What is on screen at 0:42, and how long each beat actually lasts —
204
+ the two questions you have when a video feels wrong. Pair it with
205
+ :meth:`frame_at` to look at the moment you find.
206
+ """
207
+ # Read from `durations`, which already carries the held opening and
208
+ # ending, rather than recomputing them — a second copy of that
209
+ # arithmetic is how a timeline starts disagreeing with the video.
210
+ names = ["(opening)"] + [e.name for e in self.trace.events] + ["(final hold)"]
211
+ out, at = [], 0.0
212
+ for name, length in zip(names, self.durations):
213
+ out.append((round(at, 3), length, name))
214
+ at += length
215
+ return out
216
+
217
+
218
+ def _find_encoder() -> None:
219
+ """Make sure the engine can find an ffmpeg to run.
220
+
221
+ Rendering needs ffmpeg, which pip cannot install. Rather than let
222
+ `pip install codimate` succeed and then fail on someone's first render —
223
+ the worst moment to learn about a prerequisite — fall back to the static
224
+ build that ships with `imageio-ffmpeg`.
225
+
226
+ A system ffmpeg is preferred: it is usually newer, hardware-accelerated,
227
+ and the one the author already expects to be used. The bundled copy is a
228
+ safety net, not the default.
229
+
230
+ Silent when nothing is found; the engine raises its own error naming both
231
+ the install and the override.
232
+ """
233
+ import os
234
+ import shutil
235
+
236
+ if os.environ.get("CODIMATE_FFMPEG") or shutil.which("ffmpeg"):
237
+ return
238
+
239
+ try:
240
+ import imageio_ffmpeg
241
+ except ImportError:
242
+ return
243
+
244
+ try:
245
+ os.environ["CODIMATE_FFMPEG"] = imageio_ffmpeg.get_ffmpeg_exe()
246
+ except Exception: # noqa: BLE001 — a broken fallback must not mask the real error
247
+ pass
248
+
249
+
250
+ def explain(
251
+ *,
252
+ trace: Trace,
253
+ view: View,
254
+ motion: "list[Rule] | None" = None,
255
+ timing: "Timing | None" = None,
256
+ ) -> Explanation:
257
+ """Gather an algorithm, a view and a timing into something renderable.
258
+
259
+ ``trace`` is what a ``@cm.trace()``-marked function returns: the moments
260
+ your algorithm passed through. ``view`` is called once per moment and
261
+ returns the picture of it. ``motion`` and ``timing`` are optional —
262
+ without them every shape travels in a straight line and every event lasts
263
+ the same.
264
+
265
+ cm.explain(trace=flip(tally), view=view).render("results/coins.mp4")
266
+
267
+ Nothing is computed here; the work happens in :meth:`Explanation.render`.
268
+ """
269
+ return Explanation(trace=trace, view=view, motion=motion, timing=timing)
codimate/layout.py ADDED
@@ -0,0 +1,308 @@
1
+ """Where things sit: the canvas, Slots, and the helpers that divide it up."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ # ponytail: module-level so a view function can lay out without being handed a
9
+ # canvas. `render()` reads the same values, so there is one source of truth.
10
+ _CANVAS = [1280.0, 720.0]
11
+
12
+
13
+ def measure(text: str, size: float = 16.0) -> tuple[float, float]:
14
+ """How wide and tall ``text`` will be at ``size``: ``(w, h)``.
15
+
16
+ For drawing a box around a label without guessing::
17
+
18
+ w, h = cm.measure(label, size=30)
19
+ scene.rect("box", x=x, y=y, w=w + 24, h=h + 12, radius=6)
20
+ scene.text("label", label, x=x, y=y, size=30)
21
+
22
+ Measured by the engine with the real fonts, including fallback, so it is
23
+ right for Khmer and anything else that is not plain ASCII — which is why
24
+ estimating ``len(text) * size * k`` is not good enough.
25
+
26
+ The height is the line height, the same for "cat" and "Qgy", so a row of
27
+ boxes lines up instead of jittering with whatever letters it holds.
28
+ """
29
+ from . import _codimate # imported here so the pure Python is testable
30
+
31
+ return _codimate.measure(str(text), float(size))
32
+
33
+
34
+ def ngon(sides: int, r: float, at=(0.0, 0.0), turn: float = 0.0) -> list:
35
+ """The corners of a regular polygon, for :meth:`Scene.polygon`.
36
+
37
+ scene.polygon("tri", cm.ngon(3, r=60, at=(640, 360)))
38
+
39
+ A triangle is three sides, a hexagon six. ``turn`` rotates it in degrees —
40
+ the first corner otherwise points straight up.
41
+
42
+ Returns points rather than drawing, so it composes: you can shift them,
43
+ hand them to `polygon`, or measure them yourself.
44
+ """
45
+ import math
46
+
47
+ if sides < 3:
48
+ raise ValueError(f"a polygon needs at least 3 sides, got {sides}")
49
+ step = 2 * math.pi / sides
50
+ start = math.radians(turn) - math.pi / 2
51
+ return [
52
+ (at[0] + r * math.cos(start + step * i), at[1] + r * math.sin(start + step * i))
53
+ for i in range(sides)
54
+ ]
55
+
56
+
57
+ def star(points: int, r: float, inner: float = None, at=(0.0, 0.0),
58
+ turn: float = 0.0) -> list:
59
+ """The corners of a star, for :meth:`Scene.polygon`.
60
+
61
+ scene.polygon("s", cm.star(5, r=80, at=(640, 360)), color="yellow")
62
+
63
+ ``inner`` is the radius of the valleys; it defaults to a proportion that
64
+ looks like a star rather than a gear.
65
+ """
66
+ import math
67
+
68
+ inner = r * 0.42 if inner is None else inner
69
+ step = math.pi / points
70
+ start = math.radians(turn) - math.pi / 2
71
+ return [
72
+ (
73
+ at[0] + (r if i % 2 == 0 else inner) * math.cos(start + step * i),
74
+ at[1] + (r if i % 2 == 0 else inner) * math.sin(start + step * i),
75
+ )
76
+ for i in range(points * 2)
77
+ ]
78
+
79
+
80
+ def measure_math(latex: str, size: float = 16.0) -> tuple[float, float]:
81
+ """How wide and tall a LaTeX formula will be at ``size``: ``(w, h)``.
82
+
83
+ The counterpart of :func:`measure`, so a formula can be laid out beside
84
+ words — a caption that mixes prose and mathematics needs both.
85
+ """
86
+ from . import _codimate
87
+
88
+ return _codimate.measure_formula(str(latex), float(size))
89
+
90
+
91
+ def canvas(w: float, h: float) -> None:
92
+ """Set the size of the video. Defaults to 1280x720."""
93
+ _CANVAS[:] = [float(w), float(h)]
94
+
95
+
96
+ def width() -> float:
97
+ """The canvas width. ``cm.width() / 2`` is the horizontal centre."""
98
+ return _CANVAS[0]
99
+
100
+
101
+ def height() -> float:
102
+ """The canvas height."""
103
+ return _CANVAS[1]
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class Place:
108
+ """Where a shape goes, as one value.
109
+
110
+ Built by :func:`at`. Exists so a shape takes one placement argument
111
+ instead of six — the six were one idea wearing a disguise.
112
+ """
113
+
114
+ x: "float | None" = None
115
+ y: "float | None" = None
116
+ top: "float | None" = None
117
+ bottom: "float | None" = None
118
+
119
+
120
+ def at(x=None, y=None, top=None, bottom=None) -> Place:
121
+ """A place, for a shape's ``at=``.
122
+
123
+ scene.rect("bar", h=40, at=cm.at(bottom=0))
124
+ scene.text("l", "hi", at=cm.at(x=col, top=y + 22))
125
+
126
+ Give one value per axis: ``x`` or nothing for horizontal, and ``y``,
127
+ ``top`` or ``bottom`` for vertical. A plain ``(x, y)`` or a `Slot` works
128
+ wherever a Place does, so you only need this for edges.
129
+ """
130
+ if y is not None and (top is not None or bottom is not None):
131
+ raise ValueError("give one of y=, top= or bottom=")
132
+ return Place(x=x, y=y, top=top, bottom=bottom)
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class Slot:
137
+ """A place to put something. Not a shape — nothing draws a Slot.
138
+
139
+ `row` and `column` hand you these; you rarely build one.
140
+
141
+ - `x`, `y` — its centre
142
+ - `w`, `h` — its size
143
+ - `left`, `right`, `top`, `bottom` — its edges
144
+ - `anchor` — which edge things placed here line up on
145
+ """
146
+
147
+ x: float
148
+ y: float
149
+ w: float
150
+ h: float
151
+ anchor: str = "center"
152
+
153
+ @property
154
+ def left(self) -> float:
155
+ return self.x - self.w / 2
156
+
157
+ @property
158
+ def right(self) -> float:
159
+ return self.x + self.w / 2
160
+
161
+ @property
162
+ def top(self) -> float:
163
+ return self.y - self.h / 2
164
+
165
+ @property
166
+ def bottom(self) -> float:
167
+ return self.y + self.h / 2
168
+
169
+ def point(self, anchor: "str | None" = None) -> "tuple[float, float]":
170
+ """The single point this Slot anchors things at."""
171
+ anchor = anchor or self.anchor
172
+ if anchor == "center":
173
+ return (self.x, self.y)
174
+ if anchor == "bottom":
175
+ return (self.x, self.bottom)
176
+ if anchor == "top":
177
+ return (self.x, self.top)
178
+ raise ValueError(f"unknown anchor {anchor!r} — use center, top or bottom")
179
+
180
+
181
+ def _within(box) -> "Slot":
182
+ """The region a run of Slots divides up — the whole canvas by default."""
183
+ if box is not None:
184
+ return box
185
+ return Slot(x=width() / 2, y=height() / 2, w=width(), h=height())
186
+
187
+
188
+ def _spread(items, gap, size, extent, centre):
189
+ """Evenly space things of `size` along one axis, centred on `centre`.
190
+
191
+ The shared half of `row` and `column`: normalise the input, pick a default
192
+ size that fills most of `extent`, and lay out the positions.
193
+ """
194
+ sequence = list(range(items)) if isinstance(items, int) else list(items)
195
+ count = len(sequence)
196
+ if count == 0:
197
+ return [], [], 0.0
198
+
199
+ if size is None:
200
+ # Fill 70% of the canvas by default, so a run always looks deliberate.
201
+ size = max((extent * 0.7 - gap * (count - 1)) / count, 1.0)
202
+
203
+ span = count * size + (count - 1) * gap
204
+ first = centre - span / 2 + size / 2
205
+ return sequence, [first + i * (size + gap) for i in range(count)], size
206
+
207
+
208
+ def _size(size):
209
+ """A Slot size: one number for both sides, or `(w, h)`."""
210
+ if size is None:
211
+ return None, None
212
+ if isinstance(size, (int, float)):
213
+ return float(size), None
214
+ return size
215
+
216
+
217
+ def row(
218
+ items,
219
+ *,
220
+ gap: float = 40.0,
221
+ size=None,
222
+ at: "Place | None" = None,
223
+ within: "Slot | None" = None,
224
+ ):
225
+ """One Slot per item, evenly spaced and centred on the canvas.
226
+
227
+ for slot, item in cm.row(values, gap=40, size=190):
228
+ ...
229
+
230
+ ``size`` is one number for the Slot's width (its height follows), or
231
+ ``(w, h)`` for both.
232
+
233
+ A row lays things out on a shared baseline, so its Slots anchor at
234
+ bottom-centre — hand one straight to ``scene.group()``.
235
+
236
+ Pass ``within=slot`` to divide up part of the canvas instead of all of it,
237
+ so a chart can live in its own corner without any arithmetic of yours.
238
+
239
+ Yields ``(slot, item)`` pairs, or bare Slots if you passed a count.
240
+ """
241
+ box = _within(within)
242
+ place = at or Place()
243
+ w, h = _size(size)
244
+ sequence, xs, w = _spread(items, gap, w, box.w, box.x)
245
+ if h is None:
246
+ h = w
247
+ # A row sits on a baseline near the bottom of its box unless told otherwise.
248
+ floor = box.bottom - box.h * 0.22 if place.bottom is None else place.bottom
249
+ y = place.y if place.y is not None else floor - h / 2
250
+
251
+ for x, item in zip(xs, sequence):
252
+ slot = Slot(x=x, y=y, w=w, h=h, anchor="bottom")
253
+ yield slot if isinstance(items, int) else (slot, item)
254
+
255
+
256
+ def column(
257
+ items,
258
+ *,
259
+ gap: float = 40.0,
260
+ size=None,
261
+ at: "Place | None" = None,
262
+ within: "Slot | None" = None,
263
+ ):
264
+ """One Slot per item, stacked vertically and centred on the canvas.
265
+
266
+ for slot in cm.column(4, gap=40, at=cm.at(x=640)):
267
+ ...
268
+
269
+ A column stacks things around a centre line, so its Slots anchor at their
270
+ centre. ``x`` places the column horizontally; it defaults to mid-canvas.
271
+
272
+ Pass ``within=slot`` to stack inside part of the canvas instead of all
273
+ of it.
274
+
275
+ Yields ``(slot, item)`` pairs, or bare Slots if you passed a count.
276
+ """
277
+ box = _within(within)
278
+ place = at or Place()
279
+ w, h = _size(size)
280
+ centre = box.y if place.y is None else place.y
281
+ sequence, ys, h = _spread(items, gap, h, box.h, centre)
282
+ if w is None:
283
+ w = h
284
+ x = box.x if place.x is None else place.x
285
+
286
+ for cy, item in zip(ys, sequence):
287
+ slot = Slot(x=x, y=cy, w=w, h=h, anchor="center")
288
+ yield slot if isinstance(items, int) else (slot, item)
289
+
290
+
291
+ _UNSET = object()
292
+
293
+
294
+ def _resolve(given, half, names, default=_UNSET):
295
+ """Turn whichever anchor was given into a centre coordinate."""
296
+ centre, low, high = given
297
+ given = [v for v in given if v is not None]
298
+ if len(given) > 1:
299
+ raise ValueError(f"give only one of {', '.join(f'{n}=' for n in names)}")
300
+ if not given:
301
+ if default is _UNSET:
302
+ raise ValueError(f"give one of {', '.join(f'{n}=' for n in names)}")
303
+ return float(default)
304
+ if centre is not None:
305
+ return float(centre)
306
+ if low is not None:
307
+ return float(low) + half
308
+ return float(high) - half