From 553d76ade349a1fbba7e0d3e7b8ba50cd63059fd Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:12:05 +0700 Subject: [PATCH] irxmanim: the core mobjects, and a scene that moves them manim's vocabulary, none of manim. Mobjects, a scene, and `play` for one beat of it, rendered as a single inline SVG with a stylesheet in it. manim itself renders video, which wants cairo, ffmpeg and usually a LaTeX install, and none of that fits in the first cell of a Colab notebook or survives being saved into one. The mobjects are Box, Code, Caption, Arrow, Group and the Wash a highlight fades in. Code is a group with one mobject per line, so a pass can be shown doing what a pass does, which is to touch one instruction and leave the rest alone. The animations are fade_in, fade_out, move, highlight, pulse and draw. Three things this closes off deliberately: CSS keyframes, no script. The output goes into a saved notebook, into Colab's output sandbox and into static HTML, and script survives none of those reliably. It also animates as an ordinary . The markup is the last frame. Elements are written out where they end up and the animation walks back to the start, so a renderer with no stylesheet, a printed page, and anybody who asked for less motion get the finished picture rather than a blank one. prefers-reduced-motion is honoured. `frame(t)` gives any other moment as a still. Easing is sampled here rather than handed to CSS, because a keyframe timing function applies per stop and the stops of one mobject end up interleaved with another's. Ninety seconds is the cap from CONTRIBUTING.md, and a scene over it raises rather than rendering. Also: irx.ir grows `pieces()`, which is the tokeniser loop that both irx.ir.highlight and irx.graphs already had a copy of, and now the three things that render IR agree about what a keyword is. docs/diagrams/pass-step.svg is the first scene, and the IR in it is what `opt -passes=dce -S` actually printed. --- docs/diagrams/pass-step.py | 75 ++++++ docs/diagrams/pass-step.svg | 1 + toolkit/README.md | 36 +++ toolkit/irx/graphs.py | 18 +- toolkit/irx/ir.py | 33 ++- toolkit/irxmanim/__init__.py | 86 ++++++ toolkit/irxmanim/mobject.py | 414 ++++++++++++++++++++++++++++ toolkit/irxmanim/scene.py | 479 +++++++++++++++++++++++++++++++++ toolkit/pyproject.toml | 5 +- toolkit/tests/test_irxmanim.py | 287 ++++++++++++++++++++ 10 files changed, 1407 insertions(+), 27 deletions(-) create mode 100644 docs/diagrams/pass-step.py create mode 100644 docs/diagrams/pass-step.svg create mode 100644 toolkit/irxmanim/__init__.py create mode 100644 toolkit/irxmanim/mobject.py create mode 100644 toolkit/irxmanim/scene.py create mode 100644 toolkit/tests/test_irxmanim.py diff --git a/docs/diagrams/pass-step.py b/docs/diagrams/pass-step.py new file mode 100644 index 0000000..aceee10 --- /dev/null +++ b/docs/diagrams/pass-step.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""What a pass does to a function, one instruction at a time. + +The IR in here is real. It went through `opt -passes=dce -S` from the pinned +toolchain, and what comes out is the four lines the animation ends on. The +transcript is in the comment below so that a reader can run it themselves, +which is cheaper than trusting a picture. +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "toolkit")) + +from irxmanim import Box, Caption, Code, Scene, fade_in, fade_out, highlight, move # noqa: E402 +from irxmanim.mobject import LINE_H # noqa: E402 + +OUT = Path(__file__).resolve().parent / "pass-step.svg" + +# $ opt -passes=dce -S <<'EOF' +# define i32 @f(i32 %x) { +# %a = add nsw i32 %x, 1 +# %b = mul nsw i32 %x, 7 +# ret i32 %a +# } +# EOF +# define i32 @f(i32 %x) { +# %a = add nsw i32 %x, 1 +# ret i32 %a +# } +BEFORE = [ + "define i32 @f(i32 %x) {", + " %a = add nsw i32 %x, 1", + " %b = mul nsw i32 %x, 7", + " ret i32 %a", + "}", +] +DEAD = 2 + + +def build() -> Scene: + s = Scene(430, 208, caption="opt -passes=dce deletes the instruction nothing reads") + + title = Caption(24, 20, text="opt -passes=dce", tone="loud") + frame = Box(20, 48, w=390, h=100) + code = Code(x=36, y=58, lines=BEFORE) + asked = Caption(24, 162, text="nothing reads %b, so nothing needs it") + done = Caption(24, 162, text="four instructions became three") + s.add(title, frame, code, asked, done) + + s.play(fade_in(title), fade_in(frame)) + s.play(fade_in(code)) + s.play(highlight(code[DEAD]), fade_in(asked)) + s.play(fade_out(code[DEAD]), run_time=0.4) + # The lines below the deleted one close up, which is the part of a pass + # that a printed before and after cannot show you. + s.play( + move(code[3], dy=-LINE_H), + move(code[4], dy=-LINE_H), + fade_out(asked), + run_time=0.5, + ) + s.play(fade_in(done)) + return s + + +def main() -> None: + s = build() + OUT.write_text(s.svg() + "\n", encoding="utf-8") + print(f"{OUT.relative_to(ROOT)}: {s.duration:.1f}s, {len(s.script)} beats") + + +if __name__ == "__main__": + main() diff --git a/docs/diagrams/pass-step.svg b/docs/diagrams/pass-step.svg new file mode 100644 index 0000000..fa36be3 --- /dev/null +++ b/docs/diagrams/pass-step.svg @@ -0,0 +1 @@ +opt -passes=dcedefine i32 @f(i32 %x) { %a = add nsw i32 %x, 1 %b = mul nsw i32 %x, 7 ret i32 %a}nothing reads %b, so nothing needs itfour instructions became three diff --git a/toolkit/README.md b/toolkit/README.md index d8c8f7e..5e0ea58 100644 --- a/toolkit/README.md +++ b/toolkit/README.md @@ -251,6 +251,42 @@ Nothing here works out what the successors of a block are. That is the whole poi **No Graphviz.** `dot` is not installed on every machine this has to run on, so the layout is here instead: rank each node one below its furthest predecessor, break the cycles first with a depth first walk, and route the two awkward cases, back edges and edges that skip a row, around the margin rather than straight through whatever box is in the way. The output is one inline `` with no script in it, which survives being saved into a notebook and served as static HTML. +## Making it move + +`irxmanim` is the second package in this directory. It borrows manim's vocabulary, mobjects and a scene and `play` for one beat of it, and none of its implementation: manim renders video, which wants cairo, ffmpeg and usually a LaTeX install, and none of that fits in the first cell of a Colab notebook or survives being saved into one. + +```python +from irxmanim import Scene, Box, Code, Caption, fade_in, fade_out, highlight, move + +s = Scene(430, 208, caption="opt -passes=dce deletes the instruction nothing reads") +code = s.add(Code(x=36, y=58, lines=before)) +s.play(fade_in(code)) +s.play(highlight(code[2])) +s.play(fade_out(code[2])) +s.play(move(code[3], dy=-17), move(code[4], dy=-17)) +``` + +![A pass deleting the one instruction nothing reads, and the lines below it closing up](../docs/diagrams/pass-step.svg) + +The mobjects are `Box`, `Code`, which is a group with one mobject per line so a pass can be shown touching one instruction and leaving the rest alone, `Caption`, `Arrow` and `link` for an arrow between two things, and `Group`. The animations are `fade_in`, `fade_out`, `move`, `highlight`, `pulse` and `draw`, which strokes an arrow from its tail to its head. + +Print a scene and you get the storyboard, which is the form a review reads: + +``` +Scene 430x208, 4.6s, looping, 16 mobjects + + 0.0s fade in opt -passes=dce + 1.0s highlight %b = mul nsw i32 %x, 7 + 2.0s fade out %b = mul nsw i32 %x, 7 + 2.4s move ret i32 %a +``` + +**CSS keyframes, no script.** This ends up in a saved notebook, in Colab's output sandbox and in static HTML on the site, and script survives none of those reliably. It also means the picture animates when it is loaded as an ordinary ``, which script would not. + +**The markup is the last frame.** Every element is written out where it ends up, and the animation walks back to the start and forward again. A renderer that drops the stylesheet, a printed page and anybody whose system asks for less motion all get the finished picture rather than an empty box. `scene.frame(t)` gives any other moment as a still, with nothing moving in it. + +**Ninety seconds is the cap**, the one in `CONTRIBUTING.md`, and a scene that goes over it raises rather than rendering. A longer animation is two animations. + ## When a tool fails The default subprocess failure is `CalledProcessError: returned non-zero exit status 1`, which tells a reader who has never run `opt` before absolutely nothing. `irx` raises `ToolError` instead, with the command, the real stderr, and where possible a sentence about what to do: diff --git a/toolkit/irx/graphs.py b/toolkit/irx/graphs.py index f6a8e77..b423a1e 100644 --- a/toolkit/irx/graphs.py +++ b/toolkit/irx/graphs.py @@ -37,7 +37,7 @@ from dataclasses import dataclass, field from pathlib import Path -from .ir import COLOUR, KEYWORDS, TOKEN, Module +from .ir import COLOUR, Module, pieces from .proc import run # The pass name, and the shape of the file it leaves behind. `opt` prints @@ -445,22 +445,8 @@ def _boxes(nodes: list[Node], rank: dict[str, int]) -> list[Box]: def _tspans(line: str, x: float, y: float) -> str: """One line of IR as coloured SVG, using the same palette as the diff view.""" - pieces = [] - position = 0 - for match in TOKEN.finditer(line): - pieces.append((line[position : match.start()], "")) - kind = match.lastgroup or "" - value = match.group() - if kind == "word": - kind = "keyword" if value in KEYWORDS else "" - pieces.append((value, COLOUR.get(kind, ""))) - position = match.end() - pieces.append((line[position:], "")) - spans = [] - for text, colour in pieces: - if not text: - continue + for text, colour in pieces(line): fill = f' fill="{colour}"' if colour else "" weight = ' font-weight="600"' if colour == COLOUR["keyword"] else "" spans.append(f'{html.escape(text)}') diff --git a/toolkit/irx/ir.py b/toolkit/irx/ir.py index 0deb2a0..063acb2 100644 --- a/toolkit/irx/ir.py +++ b/toolkit/irx/ir.py @@ -68,23 +68,36 @@ ) -def highlight(text: str) -> str: - """LLVM IR to coloured HTML. Small on purpose, see the module docstring.""" - out: list[str] = [] +def pieces(text: str) -> list[tuple[str, str]]: + """Split IR into (text, colour) pairs, colour empty for anything uncoloured. + + Three things render IR now: this module into HTML, the graph viewer into + SVG text, and the animation library into SVG text that moves. They agree + about what a keyword is because they all come through here. + """ + out: list[tuple[str, str]] = [] position = 0 for match in TOKEN.finditer(text): - out.append(html.escape(text[position : match.start()])) + out.append((text[position : match.start()], "")) kind = match.lastgroup or "" value = match.group() if kind == "word": kind = "keyword" if value in KEYWORDS else "" - if kind and kind in COLOUR: - weight = ";font-weight:600" if kind == "keyword" else "" - out.append(f'{html.escape(value)}') - else: - out.append(html.escape(value)) + out.append((value, COLOUR.get(kind, ""))) position = match.end() - out.append(html.escape(text[position:])) + out.append((text[position:], "")) + return [(chunk, colour) for chunk, colour in out if chunk] + + +def highlight(text: str) -> str: + """LLVM IR to coloured HTML. Small on purpose, see the module docstring.""" + out: list[str] = [] + for chunk, colour in pieces(text): + if colour: + weight = ";font-weight:600" if colour == COLOUR["keyword"] else "" + out.append(f'{html.escape(chunk)}') + else: + out.append(html.escape(chunk)) return "".join(out) diff --git a/toolkit/irxmanim/__init__.py b/toolkit/irxmanim/__init__.py new file mode 100644 index 0000000..5284c75 --- /dev/null +++ b/toolkit/irxmanim/__init__.py @@ -0,0 +1,86 @@ +"""Short animations for the lessons, with no dependencies and no script. + +The name is borrowed from manim, and so is the vocabulary: mobjects, a scene, +and `play` for one beat of it. None of the implementation is borrowed. manim +renders video, which wants cairo, ffmpeg and usually a LaTeX install, and none +of that fits in the first cell of a Colab notebook or survives being saved into +one. What comes out of here is a single inline `` with a stylesheet in it, +which animates in a notebook, in Colab's output sandbox, on the site, and as an +ordinary ``. + + from irxmanim import Scene, Box, link, fade_in, draw, highlight + + s = Scene(420, 150, caption="A pass reads a function and writes one back") + ir = s.add(Box(20, 40, text="the function", tone="plain")) + pass_ = s.add(Box(230, 40, text="InstCombinePass", tone="flow")) + arrow = s.add(link(ir, pass_)) + s.play(fade_in(ir)) + s.play(draw(arrow)) + s.play(fade_in(pass_)) + s.play(highlight(pass_)) + +Print the scene and you get the storyboard, which is what a review reads. Show +it in a notebook and you get the picture. Call `svg()` and you get a file. + +Everything about how it is drawn is in `mobject`, and everything about how it +moves is in `scene`. +""" + +from __future__ import annotations + +from . import mobject, scene +from .mobject import ( + TONES, + Arrow, + Box, + Caption, + Code, + Group, + Head, + Line, + Mobject, + MobjectError, + Wash, + link, +) +from .scene import ( + MAX_SECONDS, + Anim, + Change, + Scene, + SceneError, + draw, + fade_in, + fade_out, + highlight, + move, + pulse, +) + +__all__ = [ + "MAX_SECONDS", + "TONES", + "Anim", + "Arrow", + "Box", + "Caption", + "Change", + "Code", + "Group", + "Head", + "Line", + "Mobject", + "MobjectError", + "Scene", + "SceneError", + "Wash", + "draw", + "fade_in", + "fade_out", + "highlight", + "link", + "mobject", + "move", + "pulse", + "scene", +] diff --git a/toolkit/irxmanim/mobject.py b/toolkit/irxmanim/mobject.py new file mode 100644 index 0000000..42a7f56 --- /dev/null +++ b/toolkit/irxmanim/mobject.py @@ -0,0 +1,414 @@ +"""The things a scene moves around. + +A mobject knows where it is, how big it is, and how to draw itself once. It +knows nothing about time. Everything that changes over time lives in `scene`, +which is the only place that has to think about keyframes, and that split is +the reason this file is readable. + +Sizes are worked out from character counts rather than measured, because +measuring text needs a font engine and there is not one here. The numbers below +are the widths of the two fonts the rest of the toolkit already uses at the +sizes it already uses them, so a box is a little wider than its text rather +than a little narrower, which is the direction to be wrong in. +""" + +from __future__ import annotations + +import html +import math +from collections.abc import Callable +from dataclasses import dataclass, field + +from irx.ir import COLOUR, pieces + +MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" +SANS = "-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif" +MONO_SIZE = 11.5 +SANS_SIZE = 12.5 +MONO_W = 7.0 +SANS_W = 6.6 +LINE_H = 17.0 +PAD_X, PAD_Y = 10.0, 8.0 + +INK = "#24292f" +QUIET = "#57606a" + +# The wash a highlight fades in. One colour, not a palette, because a +# highlighter is one colour: a reader who sees two different washes will look +# for a meaning in the difference and there is not one. +FLASH = "#fff8c5" + +# Fill, stroke, and the colour of text sitting on it. `gone` is for something +# the pass is about to delete, and it is deliberately the quietest of them. +TONES = { + "plain": ("#ffffff", "#d0d7de", INK), + "flow": ("#ddf4ff", "#0969da", "#0a3069"), + "loop": ("#fbefff", "#8250df", "#3f1a6b"), + "warn": ("#fff8c5", "#d4a72c", "#4d2d00"), + "gone": ("#f6f8fa", "#d8dee4", "#8c959f"), +} + + +class MobjectError(ValueError): + """A mobject was asked for something it cannot be.""" + + +Wrap = Callable[["Mobject"], str] + + +@dataclass +class Mobject: + """Base class. Position is absolute, in user units, from the top left.""" + + x: float = 0.0 + y: float = 0.0 + w: float = 0.0 + h: float = 0.0 + + # Filled in by the scene when the mobject is added. `key` is the CSS class + # the animation hangs off, `name` is what the storyboard calls it. + key: str = "" + name: str = "" + + # Set by whatever can be drawn progressively, which is arrows. + length: float = 0.0 + + # Where the opacity track starts and, if nothing ever animates it, stays. + # A highlight is the reason this is a field rather than always 1: the wash + # is part of the box from the moment the box is drawn, and it is invisible + # until something fades it in. + opacity: float = 1.0 + + @property + def cx(self) -> float: + return self.x + self.w / 2 + + @property + def cy(self) -> float: + return self.y + self.h / 2 + + @property + def bottom(self) -> tuple[float, float]: + return (self.cx, self.y + self.h) + + @property + def top(self) -> tuple[float, float]: + return (self.cx, self.y) + + @property + def left(self) -> tuple[float, float]: + return (self.x, self.cy) + + @property + def right(self) -> tuple[float, float]: + return (self.x + self.w, self.cy) + + def parts(self) -> list[Mobject]: + """Children that can be animated on their own.""" + return [] + + def draw(self, wrap: Wrap) -> str: + """The SVG for this mobject, calling `wrap` wherever a child belongs. + + The children are placed by the mobject rather than appended by the + caller because order is paint order, and a highlight that lands on top + of the text it is highlighting has covered up the thing it points at. + """ + return "" + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self.name or self.key or ''}>".replace(" ", " ") + + +@dataclass +class Wash(Mobject): + """The rectangle a highlight fades in. Invisible until something animates it.""" + + radius: float = 4.0 + + def __post_init__(self) -> None: + self.opacity = 0.0 + + def draw(self, wrap: Wrap) -> str: + return ( + f'' + ) + + +def _text(x: float, y: float, body: str, *, mono: bool, fill: str, anchor: str = "start") -> str: + font = MONO if mono else SANS + size = MONO_SIZE if mono else SANS_SIZE + middle = ' text-anchor="middle"' if anchor == "middle" else "" + return ( + f'{body}' + ) + + +@dataclass +class Box(Mobject): + """A rounded rectangle with a line or two of text in the middle of it.""" + + text: str = "" + tone: str = "plain" + mono: bool = False + + def __post_init__(self) -> None: + if self.tone not in TONES: + raise MobjectError(f"{self.tone!r} is not a tone, try one of {', '.join(TONES)}.") + lines = self.text.split("\n") if self.text else [] + width = max((len(line) for line in lines), default=0) + char = MONO_W if self.mono else SANS_W + self.w = self.w or width * char + 2 * PAD_X + self.h = self.h or max(len(lines), 1) * LINE_H + 2 * PAD_Y + self.wash = Wash(self.x, self.y, self.w, self.h) + + def parts(self) -> list[Mobject]: + return [self.wash] + + def draw(self, wrap: Wrap) -> str: + fill, stroke, ink = TONES[self.tone] + out = [ + f'', + wrap(self.wash), + ] + lines = self.text.split("\n") if self.text else [] + first = self.cy - (len(lines) - 1) * LINE_H / 2 + 4 + for index, line in enumerate(lines): + out.append( + _text( + self.cx, + first + index * LINE_H, + html.escape(line), + mono=self.mono, + fill=ink, + anchor="middle", + ) + ) + return "".join(out) + + +@dataclass +class Line(Mobject): + """One line of code, coloured as IR, with a wash of its own. + + A line is its own mobject so that a pass can be shown doing what a pass + does, which is to touch one instruction and leave the rest alone. + """ + + text: str = "" + ir: bool = True + + def __post_init__(self) -> None: + self.w = self.w or len(self.text) * MONO_W + self.h = self.h or LINE_H + self.wash = Wash(self.x - 4, self.y, self.w + 8, self.h) + + def parts(self) -> list[Mobject]: + return [self.wash] + + def draw(self, wrap: Wrap) -> str: + if self.ir: + spans = [] + for chunk, colour in pieces(self.text): + fill = f' fill="{colour}"' if colour else "" + weight = ' font-weight="600"' if colour == COLOUR["keyword"] else "" + spans.append(f"{html.escape(chunk)}") + body = "".join(spans) + else: + body = html.escape(self.text) + baseline = self.y + LINE_H - 5 + return wrap(self.wash) + _text(self.x, baseline, body, mono=True, fill=INK) + + +@dataclass +class Code(Mobject): + """A block of IR. Every line in it is a mobject, and the block is a group.""" + + lines: list[str] = field(default_factory=list) + ir: bool = True + + def __post_init__(self) -> None: + self.rows = [ + Line(x=self.x, y=self.y + index * LINE_H, text=text, ir=self.ir) + for index, text in enumerate(self.lines) + ] + self.w = self.w or max((row.w for row in self.rows), default=0) + self.h = self.h or len(self.rows) * LINE_H + + def __getitem__(self, index: int) -> Line: + return self.rows[index] + + def parts(self) -> list[Mobject]: + return list(self.rows) + + def draw(self, wrap: Wrap) -> str: + return "".join(wrap(row) for row in self.rows) + + +@dataclass +class Group(Mobject): + """Several mobjects that move as one. Its own geometry is their extent.""" + + members: list[Mobject] = field(default_factory=list) + + def __post_init__(self) -> None: + if not self.members: + raise MobjectError("A group with nothing in it has nothing to move.") + self.x = min(m.x for m in self.members) + self.y = min(m.y for m in self.members) + self.w = max(m.x + m.w for m in self.members) - self.x + self.h = max(m.y + m.h for m in self.members) - self.y + + def parts(self) -> list[Mobject]: + return list(self.members) + + def draw(self, wrap: Wrap) -> str: + return "".join(wrap(m) for m in self.members) + + +@dataclass +class Head(Mobject): + """The triangle on the end of an arrow, drawn rather than a marker. + + A `` cannot be animated separately from the path it sits on, and an + arrow that is being drawn wants its head to arrive last rather than to hang + in the air at the destination for the whole of the stroke. + """ + + angle: float = 0.0 + colour: str = QUIET + + def draw(self, wrap: Wrap) -> str: + size, spread = 8.0, 0.42 + tip = (self.x, self.y) + wings = [ + ( + self.x - size * math.cos(self.angle + turn), + self.y - size * math.sin(self.angle + turn), + ) + for turn in (-spread, spread) + ] + points = " ".join(f"{px:.1f},{py:.1f}" for px, py in [tip, *wings]) + return f'' + + +@dataclass +class Arrow(Mobject): + """A line from one point to another, with a head, and a label if it needs one.""" + + start: tuple[float, float] = (0.0, 0.0) + end: tuple[float, float] = (0.0, 0.0) + label: str = "" + bend: float = 0.0 + colour: str = QUIET + + def __post_init__(self) -> None: + (x0, y0), (x1, y1) = self.start, self.end + # The control point is pushed off the midpoint at a right angle to the + # line, so `bend` is a distance in user units and a sign, which is + # easier to think about than a control point. + mx, my = (x0 + x1) / 2, (y0 + y1) / 2 + span = math.hypot(x1 - x0, y1 - y0) or 1.0 + self.control = (mx - self.bend * (y1 - y0) / span, my + self.bend * (x1 - x0) / span) + self.length = self._measure() + self.x, self.y = min(x0, x1), min(y0, y1) + self.w, self.h = abs(x1 - x0), abs(y1 - y0) + self.head = Head(x=x1, y=y1, angle=self._angle(), colour=self.colour) + + def _point(self, t: float) -> tuple[float, float]: + (x0, y0), (cx, cy), (x1, y1) = self.start, self.control, self.end + u = 1 - t + return ( + u * u * x0 + 2 * u * t * cx + t * t * x1, + u * u * y0 + 2 * u * t * cy + t * t * y1, + ) + + def _measure(self, steps: int = 32) -> float: + """Length by walking the curve, which is what the dash pattern needs. + + A straight line comes out of this exact, and a curve comes out close + enough that the stroke finishes where the head is. + """ + total, previous = 0.0, self._point(0.0) + for step in range(1, steps + 1): + current = self._point(step / steps) + total += math.hypot(current[0] - previous[0], current[1] - previous[1]) + previous = current + return total + + def _angle(self) -> float: + near = self._point(0.98) + return math.atan2(self.end[1] - near[1], self.end[0] - near[0]) + + def parts(self) -> list[Mobject]: + return [self.head] + + def draw(self, wrap: Wrap) -> str: + (x0, y0), (cx, cy), (x1, y1) = self.start, self.control, self.end + path = ( + f'' + ) + out = [path, wrap(self.head)] + if self.label: + lx, ly = self._point(0.5) + offset = 6 if self.bend >= 0 else -6 + out.append( + _text(lx + offset, ly - 4, html.escape(self.label), mono=False, fill=QUIET) + ) + return "".join(out) + + +def link(a: Mobject, b: Mobject, label: str = "", bend: float = 0.0, gap: float = 6.0) -> Arrow: + """An arrow from the edge of one mobject to the edge of another. + + Which edges depends on where they are: side by side gets a horizontal + arrow, stacked gets a vertical one. `gap` keeps the head off the box it is + pointing at, because a head touching a border reads as part of the border. + """ + if abs(b.cy - a.cy) >= abs(b.cx - a.cx): + if b.cy > a.cy: + start, end = (a.cx, a.y + a.h + gap), (b.cx, b.y - gap) + else: + start, end = (a.cx, a.y - gap), (b.cx, b.y + b.h + gap) + elif b.cx > a.cx: + start, end = (a.x + a.w + gap, a.cy), (b.x - gap, b.cy) + else: + start, end = (a.x - gap, a.cy), (b.x + b.w + gap, b.cy) + return Arrow(start=start, end=end, label=label, bend=bend) + + +@dataclass +class Caption(Mobject): + """A line of prose inside the picture, for naming what is happening.""" + + text: str = "" + tone: str = "quiet" + + def __post_init__(self) -> None: + self.w = self.w or len(self.text) * SANS_W + self.h = self.h or LINE_H + + def draw(self, wrap: Wrap) -> str: + fill = QUIET if self.tone == "quiet" else INK + return _text(self.x, self.y + LINE_H - 5, html.escape(self.text), mono=False, fill=fill) + + +__all__ = [ + "TONES", + "Arrow", + "Box", + "Caption", + "Code", + "Group", + "Head", + "Line", + "Mobject", + "MobjectError", + "Wash", + "link", +] diff --git a/toolkit/irxmanim/scene.py b/toolkit/irxmanim/scene.py new file mode 100644 index 0000000..3519de3 --- /dev/null +++ b/toolkit/irxmanim/scene.py @@ -0,0 +1,479 @@ +"""The timeline, and the CSS it turns into. + +A scene holds mobjects and a list of things that happen to them. Rendering +walks each mobject's properties over the whole running time and writes one +`@keyframes` rule per mobject, with a stop wherever that mobject changes. + +Three decisions are worth saying out loud, because each one closes off an +option somebody will reasonably ask about. + +**No script.** Animation is CSS keyframes on an inline ``. The output goes +into a saved notebook, into Colab's output sandbox, and into static HTML on the +site, and script survives none of those reliably. It also means the picture +animates when it is loaded as an ordinary image, which script would not. + +**The markup is the last frame.** Every element is written out at the position +and opacity it ends on, and the animation walks back to the beginning and +forward again. A renderer that drops the stylesheet, a print stylesheet, and +anybody who has asked their system for less motion all get the finished +picture rather than an empty box, and `prefers-reduced-motion` is honoured +below rather than ignored. + +**Easing is sampled here, not asked for.** Each segment is emitted as several +stops with the eased value already worked out, and the CSS between stops is +linear. A keyframe timing function would be shorter to write, but it applies +per stop, and stops belonging to one mobject's motion get interleaved with +stops belonging to another's, which quietly distorts the curve. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass + +from .mobject import Arrow, Box, Caption, Code, Group, Head, Line, Mobject, Wash + +# The cap from CONTRIBUTING.md. A lesson that wants a longer animation wants +# two animations, and the same argument applies here as applies to lesson +# length: "this one is special" is how caps die. +MAX_SECONDS = 90.0 + +DEFAULTS = {"opacity": 1.0, "tx": 0.0, "ty": 0.0, "scale": 1.0, "dash": 0.0} +MOVING = ("tx", "ty", "scale") + +# How far before a jump the previous value is pinned, so that a property told +# to start somewhere other than where it is jumps rather than drifting there +# across whatever gap came before. +EPS = 0.004 + +# Samples per segment. Five stops is a smooth enough curve at these durations +# and keeps the stylesheet small enough to read. +SAMPLES = (0.0, 0.25, 0.5, 0.75, 1.0) + + +class SceneError(ValueError): + """A scene was asked for something it cannot do.""" + + +def _smooth(u: float) -> float: + return u * u * (3 - 2 * u) + + +@dataclass +class Change: + """One property of one mobject, moving from somewhere to somewhere else.""" + + prop: str + to: float + start: float | None = None + relative: bool = False + at: float = 0.0 + over: float | None = None + target: Mobject | None = None + + +@dataclass +class Anim: + """What the reader asked for. The scene turns it into segments.""" + + target: Mobject + verb: str + changes: list[Change] + run_time: float = 0.6 + + @property + def span(self) -> float: + return max( + change.at + (self.run_time if change.over is None else change.over) + for change in self.changes + ) + + +# -- the animations ----------------------------------------------------------- + + +def fade_in(m: Mobject, run_time: float = 0.5) -> Anim: + """Arrive. Whatever fades in is not there before it does.""" + return Anim(m, "fade in", [Change("opacity", 1.0, start=0.0)], run_time) + + +def fade_out(m: Mobject, run_time: float = 0.5) -> Anim: + """Leave. What a pass does to an instruction it deleted.""" + return Anim(m, "fade out", [Change("opacity", 0.0)], run_time) + + +def move(m: Mobject, dx: float = 0.0, dy: float = 0.0, run_time: float = 0.6) -> Anim: + """Shift, relative to wherever it is now.""" + return Anim( + m, + "move", + [Change("tx", dx, relative=True), Change("ty", dy, relative=True)], + run_time, + ) + + +def highlight(m: Mobject, run_time: float = 1.0) -> Anim: + """Wash over it and then take the wash away. + + The mobject has to have a wash, which boxes and lines of code do and arrows + do not, so this is the one animation that can be asked for of something + that cannot do it. Saying so here is better than an arrow that shrugs. + """ + wash = getattr(m, "wash", None) + if wash is None: + raise SceneError( + f"{m.name or type(m).__name__} has nothing to highlight. Boxes and lines " + "of code carry a highlight, arrows do not. Try fading it in instead." + ) + edge = min(0.25, run_time / 3) + return Anim( + m, + "highlight", + [ + Change("opacity", 1.0, start=0.0, over=edge, target=wash), + Change("opacity", 0.0, at=run_time - edge, over=edge, target=wash), + ], + run_time, + ) + + +def pulse(m: Mobject, size: float = 1.05, run_time: float = 0.6) -> Anim: + """A small breath, for pointing at something without covering it.""" + half = run_time / 2 + return Anim( + m, + "pulse", + [ + Change("scale", size, start=1.0, over=half), + Change("scale", 1.0, at=half, over=half), + ], + run_time, + ) + + +def draw(arrow: Arrow, run_time: float = 0.7) -> Anim: + """Stroke an arrow from its tail to its head, with the head arriving last.""" + if not isinstance(arrow, Arrow): + raise SceneError("Only an arrow can be drawn. Everything else fades in.") + return Anim( + arrow, + "draw", + [ + Change("dash", 0.0, start=1.0), + Change("opacity", 1.0, start=0.0, at=run_time * 0.7, over=run_time * 0.3, + target=arrow.head), + ], + run_time, + ) + + +# -- the scene ---------------------------------------------------------------- + + +@dataclass +class Segment: + a: float + b: float + v0: float + v1: float + + +SUFFIX = {Wash: "highlight", Head: "head"} + + +def _derive(m: Mobject, parent: Mobject | None) -> str: + if isinstance(m, Box | Line | Caption) and m.text: + return m.text.split("\n")[0].strip()[:40] + if isinstance(m, Arrow): + return f"arrow {m.label}".strip() + if isinstance(m, Code): + return f"{len(m.rows)} lines" + if isinstance(m, Group): + return "group" + suffix = SUFFIX.get(type(m), type(m).__name__.lower()) + return f"{parent.name} {suffix}" if parent else suffix + + +class Scene: + """A drawing that changes over time. + + Build it the way you would read it out loud: put things on the stage with + `add`, then say what happens with `play`, one beat per call. + """ + + def __init__( + self, + width: float = 640, + height: float = 320, + caption: str = "", + *, + loop: bool = True, + rest: float = 1.2, + ) -> None: + self.width = width + self.height = height + self.caption = caption + self.loop = loop + self.rest = rest + self.cursor = 0.0 + self.roots: list[Mobject] = [] + self.known: dict[str, Mobject] = {} + self.script: list[tuple[float, str, str]] = [] + self.segments: dict[str, dict[str, list[Segment]]] = {} + self.pins: dict[str, set[float]] = {} + self.value: dict[tuple[str, str], float] = {} + + # -- building + + def add(self, *mobjects: Mobject) -> Mobject | tuple[Mobject, ...]: + """Put mobjects on the stage. Returns what it was given, for chaining.""" + for m in mobjects: + self._register(m, None) + self.roots.append(m) + return mobjects[0] if len(mobjects) == 1 else mobjects + + def _register(self, m: Mobject, parent: Mobject | None) -> None: + if m.key: + raise SceneError(f"{m.name} is in this scene already, add it once.") + m.key = f"m{len(self.known)}" + m.name = m.name or _derive(m, parent) + self.known[m.key] = m + for child in m.parts(): + self._register(child, m) + + def play(self, *anims: Anim, run_time: float | None = None) -> None: + """One beat. Everything passed to a single call happens at once.""" + if not anims: + raise SceneError("play() with nothing to play. Use wait() for a pause.") + start = self.cursor + span = 0.0 + for anim in anims: + if run_time is not None: + anim.run_time = run_time + self._schedule(anim, start) + self.script.append((start, anim.verb, anim.target.name)) + span = max(span, anim.span) + self.cursor = start + span + self._check() + + def wait(self, seconds: float = 0.5) -> None: + """Hold. What is on the screen stays there.""" + self.cursor += seconds + self._check() + + def _schedule(self, anim: Anim, start: float) -> None: + for change in anim.changes: + target = change.target or anim.target + if not target.key or self.known.get(target.key) is not target: + raise SceneError( + f"{target.name or _derive(target, None)!r} is not in this scene. " + "Everything that moves has to be add()ed first." + ) + if change.prop not in DEFAULTS: + raise SceneError(f"{change.prop!r} is not an animatable property.") + here = self.value.get( + (target.key, change.prop), self.default(target.key, change.prop) + ) + first = here if change.start is None else change.start + last = here + change.to if change.relative else change.to + a = start + change.at + b = a + (anim.run_time if change.over is None else change.over) + track = self.segments.setdefault(target.key, {}).setdefault(change.prop, []) + track.append(Segment(a, b, first, last)) + if a > EPS: + self.pins.setdefault(target.key, set()).add(a - EPS) + self.value[(target.key, change.prop)] = last + + def _check(self) -> None: + if self.duration > MAX_SECONDS: + raise SceneError( + f"This scene runs {self.duration:.1f} seconds and the cap is " + f"{MAX_SECONDS:.0f}. A longer animation is two animations." + ) + + # -- reading + + @property + def duration(self) -> float: + return max(self.cursor, 0.0) + self.rest + + def default(self, key: str, prop: str) -> float: + """Where a property sits before anything moves it. + + Everything is 1 or 0 except opacity, which the mobject decides. A wash + is the case that matters: it is drawn with its box and stays invisible + until a highlight fades it in, and if it were not invisible by default + every box in the scene would be yellow. + """ + if prop == "opacity": + return self.known[key].opacity + return DEFAULTS[prop] + + def value_at(self, key: str, prop: str, t: float) -> float: + track = self.segments.get(key, {}).get(prop) + if not track: + return self.default(key, prop) + value = track[0].v0 + for seg in track: + if t < seg.a: + break + if t >= seg.b: + value = seg.v1 + else: + u = (t - seg.a) / (seg.b - seg.a) if seg.b > seg.a else 1.0 + value = seg.v0 + (seg.v1 - seg.v0) * _smooth(u) + break + return value + + def __str__(self) -> str: + how = "looping" if self.loop else "once" + lines = [ + f"Scene {self.width:.0f}x{self.height:.0f}, {self.duration:.1f}s, " + f"{how}, {len(self.known)} mobjects", + "", + ] + for at, verb, name in self.script: + lines.append(f" {at:5.1f}s {verb:<10} {name}") + if not self.script: + lines.append(" nothing happens yet") + return "\n".join(lines) + "\n" + + def __repr__(self) -> str: + return f"" + + # -- drawing + + def _body(self, at: float) -> str: + def wrap(m: Mobject) -> str: + inner = m.draw(wrap) + classes = f"{m.key} a" if m.key in self.segments else m.key + # One frame, written into the markup. Rendering picks the last one, + # so that a reader without the stylesheet, and anybody who asked + # for less motion, gets the finished picture rather than a blank. + final = {prop: self.value_at(m.key, prop, at) for prop in DEFAULTS} + attrs = "" + if final["opacity"] != 1.0: + attrs += f' opacity="{final["opacity"]:.3f}"' + if any(final[prop] != DEFAULTS[prop] for prop in MOVING): + attrs += f' transform="{_transform(final, css=False)}"' + if final["dash"]: + attrs += f' stroke-dashoffset="{final["dash"] * m.length:.1f}"' + return f'{inner}' + + return "".join(wrap(m) for m in self.roots) + + def _stops(self, key: str) -> list[float]: + times = {0.0, self.duration} | self.pins.get(key, set()) + for track in self.segments[key].values(): + for seg in track: + for u in SAMPLES: + times.add(seg.a + u * (seg.b - seg.a)) + return sorted({round(min(max(t, 0.0), self.duration), 4) for t in times}) + + def _css(self, ident: str) -> str: + count = "infinite" if self.loop else "1" + rules = [ + f"#{ident} .a{{animation-duration:{self.duration:.2f}s;" + f"animation-timing-function:linear;animation-fill-mode:both;" + f"animation-iteration-count:{count};" + "transform-box:fill-box;transform-origin:center}", + # Somebody who has asked for less motion gets the finished picture, + # which is the frame the markup already holds. + f"@media (prefers-reduced-motion:reduce){{#{ident} .a{{animation:none}}}}", + ] + for key in self.segments: + props = set(self.segments[key]) + mobject = self.known[key] + frames = [] + for t in self._stops(key): + decls = [] + if "opacity" in props: + decls.append(f"opacity:{self.value_at(key, 'opacity', t):.3f}") + if props & set(MOVING): + state = {prop: self.value_at(key, prop, t) for prop in MOVING} + decls.append(f"transform:{_transform(state)}") + if "dash" in props: + hidden = self.value_at(key, "dash", t) * mobject.length + decls.append(f"stroke-dashoffset:{hidden:.1f}") + pct = 100 * t / self.duration + frames.append(f"{pct:.3f}%{{{';'.join(decls)}}}") + rules.append(f"#{ident} .{key}{{animation-name:{ident}{key}}}") + rules.append(f"@keyframes {ident}{key}{{{''.join(frames)}}}") + return "".join(rules) + + def _ident(self, body: str) -> str: + """A content hash, so the same scene renders the same bytes every time.""" + material = body + str(self) + f"{self.duration}{self.loop}" + return "irxm" + hashlib.sha256(material.encode()).hexdigest()[:10] + + def svg(self) -> str: + """The picture on its own, for writing to a file.""" + return self._svg(self.duration, animate=True) + + def frame(self, at: float) -> str: + """One still, at a moment, with nothing moving. + + For a printed page, which cannot animate, and for looking closely at a + beat that goes past too quickly to review by eye. + """ + if not 0 <= at <= self.duration: + raise SceneError(f"This scene runs {self.duration:.1f} seconds, {at} is outside it.") + return self._svg(at, animate=False) + + def _svg(self, at: float, animate: bool) -> str: + body = self._body(at) + ident = self._ident(body) + style = f"" if animate else "" + return ( + f'' + f"{style}" + f'{body}' + ) + + def _repr_html_(self) -> str: + caption = "" + if self.caption: + caption = ( + '

' + f"{_attr(self.caption)}

" + ) + return f'
{caption}{self.svg()}
' + + +def _transform(state: dict[str, float], css: bool = True) -> str: + """The same transform in the two syntaxes SVG has for it. + + The `transform` attribute is SVG's own, and its lengths are user units with + no unit written. The `transform` CSS property is CSS's, and a bare number + there is invalid, so the animation needs `px`. Writing one where the other + is expected fails silently, which is a long afternoon. + """ + unit = "px" if css else "" + out = f"translate({state['tx']:.1f}{unit},{state['ty']:.1f}{unit})" + if state.get("scale", 1.0) != 1.0: + out += f" scale({state['scale']:.3f})" + return out + + +def _attr(text: str) -> str: + return ( + text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + ) + + +__all__ = [ + "MAX_SECONDS", + "Anim", + "Change", + "Scene", + "SceneError", + "draw", + "fade_in", + "fade_out", + "highlight", + "move", + "pulse", +] diff --git a/toolkit/pyproject.toml b/toolkit/pyproject.toml index 58cbe53..1760b9f 100644 --- a/toolkit/pyproject.toml +++ b/toolkit/pyproject.toml @@ -32,7 +32,10 @@ Homepage = "https://github.com/tamnd/llvm-internals" Issues = "https://github.com/tamnd/llvm-internals/issues" [tool.setuptools] -packages = ["irx"] +# Two packages, one distribution. `irxmanim` is the animation library, it +# imports `irx` for the IR colouring and nothing else, and it ships here rather +# than separately because a reader installs one thing in the first cell. +packages = ["irx", "irxmanim"] [tool.setuptools.package-data] irx = ["pin.json"] diff --git a/toolkit/tests/test_irxmanim.py b/toolkit/tests/test_irxmanim.py new file mode 100644 index 0000000..c862ca0 --- /dev/null +++ b/toolkit/tests/test_irxmanim.py @@ -0,0 +1,287 @@ +"""Tests for irxmanim. + +Nothing in here needs LLVM. The library draws and animates, and the only thing +it borrows from the toolkit is the IR colouring, so the whole file runs on a +machine with no compiler on it. + +The tests that look at the generated SVG are looking for two things in +particular: that the markup on its own is the last frame, because that is what +a reader without the stylesheet sees, and that the same scene renders the same +bytes, because these files are committed and a picture that churns makes every +diff unreadable. +""" + +from __future__ import annotations + +import unittest +import xml.etree.ElementTree as ET + +from irx.ir import COLOUR +from irxmanim import ( + Arrow, + Box, + Caption, + Code, + Group, + Scene, + SceneError, + draw, + fade_in, + fade_out, + highlight, + link, + move, + pulse, +) +from irxmanim.mobject import LINE_H, MobjectError + +SVG = "{http://www.w3.org/2000/svg}" + + +def simple() -> tuple[Scene, Box, Box, Arrow]: + s = Scene(400, 160) + a = Box(20, 40, text="before") + b = Box(240, 40, text="after") + arrow = link(a, b) + s.add(a, b, arrow) + return s, a, b, arrow + + +class TestGeometry(unittest.TestCase): + def test_a_box_is_wider_than_its_text(self): + box = Box(0, 0, text="define i32 @f()") + self.assertGreater(box.w, len("define i32 @f()") * 6) + + def test_a_tone_that_does_not_exist_is_an_error_at_the_point_of_the_typo(self): + with self.assertRaises(MobjectError) as caught: + Box(0, 0, text="x", tone="blue") + self.assertIn("plain", str(caught.exception)) + + def test_a_link_between_boxes_side_by_side_goes_sideways(self): + a, b = Box(0, 0, text="a"), Box(200, 0, text="b") + arrow = link(a, b) + self.assertEqual(arrow.start[1], arrow.end[1]) + self.assertGreater(arrow.end[0], arrow.start[0]) + + def test_a_link_between_stacked_boxes_goes_down(self): + a, b = Box(0, 0, text="a"), Box(0, 120, text="b") + arrow = link(a, b) + self.assertEqual(arrow.start[0], arrow.end[0]) + self.assertGreater(arrow.end[1], arrow.start[1]) + + def test_a_straight_arrow_measures_its_own_length(self): + # The dash pattern is the length, so a wrong measurement is a stroke + # that stops short of the head or overshoots it. + arrow = Arrow(start=(0, 0), end=(30, 40)) + self.assertAlmostEqual(arrow.length, 50.0, places=3) + + def test_a_group_is_as_big_as_the_things_in_it(self): + group = Group(members=[Box(10, 10, w=50, h=20), Box(100, 40, w=50, h=20)]) + self.assertEqual((group.x, group.y, group.w, group.h), (10, 10, 140, 50)) + + def test_an_empty_group_says_so(self): + with self.assertRaises(MobjectError): + Group(members=[]) + + def test_every_line_of_code_is_its_own_mobject(self): + code = Code(x=0, y=0, lines=["define i32 @f() {", " ret i32 1", "}"]) + self.assertEqual(len(code.parts()), 3) + self.assertEqual(code[1].text, " ret i32 1") + self.assertEqual(code[1].y - code[0].y, LINE_H) + + +class TestBuilding(unittest.TestCase): + def test_adding_the_same_mobject_twice_is_an_error(self): + s = Scene() + box = Box(0, 0, text="a") + s.add(box) + with self.assertRaises(SceneError): + s.add(box) + + def test_animating_something_that_was_never_added_says_which(self): + s = Scene() + with self.assertRaises(SceneError) as caught: + s.play(fade_in(Box(0, 0, text="stray"))) + self.assertIn("stray", str(caught.exception)) + + def test_a_beat_with_nothing_in_it_is_an_error(self): + with self.assertRaises(SceneError): + Scene().play() + + def test_everything_in_one_call_starts_at_the_same_moment(self): + s, a, b, _ = simple() + s.play(fade_in(a), fade_in(b)) + self.assertEqual([at for at, _, _ in s.script], [0.0, 0.0]) + + def test_each_call_follows_the_one_before_it(self): + s, a, b, _ = simple() + s.play(fade_in(a), run_time=0.5) + s.wait(0.25) + s.play(fade_in(b), run_time=0.5) + self.assertEqual([round(at, 2) for at, _, _ in s.script], [0.0, 0.75]) + + def test_a_scene_longer_than_the_cap_is_refused(self): + # The cap is in CONTRIBUTING.md and applies to animation in lessons. + s = Scene() + box = s.add(Box(0, 0, text="a")) + with self.assertRaises(SceneError) as caught: + s.play(fade_in(box, run_time=200)) + self.assertIn("two animations", str(caught.exception)) + + def test_highlighting_an_arrow_says_what_can_be_highlighted(self): + s, _, _, arrow = simple() + with self.assertRaises(SceneError) as caught: + s.play(highlight(arrow)) + self.assertIn("arrows do not", str(caught.exception)) + + def test_only_an_arrow_can_be_drawn(self): + s, a, _, _ = simple() + with self.assertRaises(SceneError): + s.play(draw(a)) + + +class TestTimeline(unittest.TestCase): + def test_a_fade_in_is_invisible_before_it_starts(self): + s, a, _, _ = simple() + s.wait(1.0) + s.play(fade_in(a, run_time=1.0)) + self.assertEqual(s.value_at(a.key, "opacity", 0.5), 0.0) + self.assertEqual(s.value_at(a.key, "opacity", 1.0), 0.0) + self.assertAlmostEqual(s.value_at(a.key, "opacity", 1.5), 0.5) + self.assertEqual(s.value_at(a.key, "opacity", 2.0), 1.0) + + def test_a_value_holds_between_the_things_that_change_it(self): + s, a, _, _ = simple() + s.play(fade_in(a, run_time=0.5)) + s.wait(2.0) + s.play(fade_out(a, run_time=0.5)) + self.assertEqual(s.value_at(a.key, "opacity", 1.6), 1.0) + + def test_a_move_is_relative_to_wherever_it_is_now(self): + s, a, _, _ = simple() + s.play(move(a, dy=-10, run_time=0.5)) + s.play(move(a, dy=-10, run_time=0.5)) + self.assertEqual(s.value_at(a.key, "ty", 2.0), -20.0) + + def test_a_highlight_comes_and_goes(self): + s, a, _, _ = simple() + s.play(highlight(a, run_time=1.0)) + self.assertEqual(s.value_at(a.wash.key, "opacity", 0.0), 0.0) + self.assertEqual(s.value_at(a.wash.key, "opacity", 0.5), 1.0) + self.assertEqual(s.value_at(a.wash.key, "opacity", 1.0), 0.0) + + def test_the_head_of_an_arrow_arrives_after_the_stroke_starts(self): + s, _, _, arrow = simple() + s.play(draw(arrow, run_time=1.0)) + self.assertEqual(s.value_at(arrow.key, "dash", 0.0), 1.0) + self.assertEqual(s.value_at(arrow.key, "dash", 1.0), 0.0) + self.assertEqual(s.value_at(arrow.head.key, "opacity", 0.5), 0.0) + self.assertEqual(s.value_at(arrow.head.key, "opacity", 1.0), 1.0) + + def test_the_storyboard_reads_in_order(self): + s, a, b, arrow = simple() + s.play(fade_in(a)) + s.play(draw(arrow)) + s.play(fade_in(b)) + lines = str(s).splitlines() + self.assertIn("fade in", lines[2]) + self.assertIn("draw", lines[3]) + self.assertIn("before", lines[2]) + + +class TestSvg(unittest.TestCase): + def setUp(self): + self.scene, self.a, self.b, self.arrow = simple() + self.scene.play(fade_in(self.a)) + self.scene.play(draw(self.arrow)) + self.scene.play(fade_in(self.b), pulse(self.b)) + self.scene.play(highlight(self.a)) + self.svg = self.scene.svg() + + def test_it_is_well_formed_xml(self): + root = ET.fromstring(self.svg) + self.assertTrue(root.tag.endswith("svg")) + + def test_nothing_in_it_is_a_script(self): + # It ends up in a saved notebook, in Colab's output sandbox and in + # static HTML on the site. None of those should be running anything. + self.assertNotIn("