From 7528d81f526f615a45428a801d60ae1ae865826c Mon Sep 17 00:00:00 2001 From: czei <9g0jqglotahgp7741rr0q07p3s2ly1@bots.bitbucket.org> Date: Sat, 29 Aug 2026 05:39:08 -0400 Subject: [PATCH 01/10] Name the function that builds each treatment's partition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every treatment advertises the partition it wants as a NICKNAME — `HaloPulse.PARTITION` is "radial" — and nothing in the library resolved a nickname to a callable. The catalogue emitted the bare string, so a reader got "partition: radial" and had to guess `map_radial`. Twelve of the thirteen are `map_` + the nickname. That is worse than no convention: it is regular enough to be trusted and then guessed, and the one that breaks it is "anchor", whose builder is `map_anchor_distance`. A code-generating agent spent an entire run guessing at exactly that name, building a probe harness out of coloured bars to test argument shapes, and never started designing. So: - `PARTITION_BUILDERS` and `builder_for()` in palette_partition — the inverse of the existing `treatments_for()`. - Every catalogue treatment now carries `partition_call`, rendered from the LIVE signature so it cannot drift: `map_anchor_distance(pixel_slots, anchor_x, n=10)`. - A new `composition` category: the slots -> map -> PalettePartition -> treatment recipe, all ten builders with signatures, ActScheduler, and the transition and treatment lookups. 2,087 characters, less than one panel image. The catalogue named every effect and not one combinator, and a treatment cannot run without a partition — so it documented thirteen effects that could not be built from it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i --- src/scrollkit/dev/capabilities.py | 74 ++++++++++++++++++++- src/scrollkit/effects/palette_partition.py | 36 ++++++++++ test/unit/effects/test_palette_partition.py | 39 +++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/scrollkit/dev/capabilities.py b/src/scrollkit/dev/capabilities.py index 4499445..e592f59 100644 --- a/src/scrollkit/dev/capabilities.py +++ b/src/scrollkit/dev/capabilities.py @@ -252,10 +252,77 @@ def _palette_treatments(): from ..effects.palette_treatments import TREATMENT_CLASSES except ImportError: return out + try: + from ..effects.palette_partition import builder_for + except ImportError: + builder_for = lambda _name: None # noqa: E731 for cls in TREATMENT_CLASSES: - out.append({"name": cls.__name__, "doc": _first_line(cls), - "partition": cls.PARTITION, - "feasibility": getattr(cls, "FEASIBILITY", None)}) + entry = {"name": cls.__name__, "doc": _first_line(cls), + "partition": cls.PARTITION, + "feasibility": getattr(cls, "FEASIBILITY", None)} + # **The nickname alone is a dangling pointer.** "anchor" does not name a + # function; map_anchor_distance does. Resolved through the registry and + # rendered from the LIVE signature, so it cannot drift from the code. + builder = builder_for(cls.PARTITION) + if builder is not None: + try: + entry["partition_call"] = "%s%s" % ( + builder.__name__, inspect.signature(builder)) + except (TypeError, ValueError): + entry["partition_call"] = builder.__name__ + out.append(entry) + return out + + +def _composition(): + """The combinators: how one drawing becomes many acts. + + Catalogued because they were the documented gap. Every EFFECT was listed and + none of the machinery that varies it, so a reader came away with thirteen + treatments and no way to build the partition all thirteen require. + """ + out = {"recipe": [ + "slots = pixel_slots_of_your_art # [(x, y), ...] the lit pixels", + "group_map, n = map_radial(slots, cx, cy) # any PARTITION_BUILDERS entry", + "fx = PalettePartition(displayio, slots, group_map, n)", + "treatment = HaloPulse(fx, ramp) # any treatment whose PARTITION matches", + "# then drive treatment frame by frame; swap fx.tile in while it runs", + ], "why": ( + "One drawing x 10 partitions x 13 treatments is the variation budget, and " + "none of it needs more pixel art. Compose build -> dwell -> exit and the " + "counts multiply again." + )} + try: + from ..effects.palette_partition import PARTITION_BUILDERS + out["partition_builders"] = { + nickname: "%s%s" % (fn.__name__, inspect.signature(fn)) + for nickname, fn in PARTITION_BUILDERS.items() + } + except ImportError: + pass + for label, module, names in ( + ("scheduling", "..utils.scheduler", ("ActScheduler",)), + ("transition_lookup", "..effects.transitions", + ("transition_factory", "supported_names", "transitions_for")), + ("treatment_lookup", "..effects.palette_treatments", ("treatments_for",)), + ): + try: + mod = __import__(module.lstrip("."), globals(), locals(), + ["*"], module.count(".") - 1) + except ImportError: + continue + entries = {} + for name in names: + obj = getattr(mod, name, None) + if obj is None: + continue + try: + entries[name] = "%s%s — %s" % (name, inspect.signature(obj), + _first_line(obj)) + except (TypeError, ValueError): + entries[name] = "%s — %s" % (name, _first_line(obj)) + if entries: + out[label] = entries return out @@ -388,6 +455,7 @@ def capabilities(): ("effects", _effects), ("transitions", _transitions), ("scrolling", _scrolling), ("palette_effects", _palette_effects), ("palette_treatments", _palette_treatments), + ("composition", _composition), ("image_animators", _image_animators), ("text_fills", _text_fills), ("color_utilities", _color_utilities), ("named_colors", _named_colors), diff --git a/src/scrollkit/effects/palette_partition.py b/src/scrollkit/effects/palette_partition.py index 10c4058..6c4b5f4 100644 --- a/src/scrollkit/effects/palette_partition.py +++ b/src/scrollkit/effects/palette_partition.py @@ -292,3 +292,39 @@ def _bfs_depth(pixels): if p not in depth: depth[p] = 0 return depth + + +# --------------------------------------------------------------------------- +# Nickname -> builder +# --------------------------------------------------------------------------- + +#: Partition nickname -> the function that builds it. +#: +#: Every treatment carries a ``PARTITION`` nickname — ``VelvetSweep.PARTITION`` is +#: ``"diagonal"`` — and until this table existed nothing resolved one to a callable. +#: Twelve of the thirteen are ``map_`` + the nickname, which is worse than no +#: convention at all: it is regular enough to be trusted and then guessed, and the +#: one that breaks it is ``"anchor"`` -> :func:`map_anchor_distance`. A generating +#: agent burned an entire run guessing at exactly that name. +PARTITION_BUILDERS = { + "diagonal": map_diagonal, + "anchor": map_anchor_distance, + "radial": map_radial, + "angle": map_angle, + "rain": map_rain, + "checker": map_checker, + "exposure": map_exposure, + "regions": map_regions, + "topology": map_topology, + "route": map_route, +} + + +def builder_for(partition): + """The partition builder named by a treatment's ``PARTITION``, or ``None``. + + The inverse of :func:`scrollkit.effects.palette_treatments.treatments_for`: + that answers "what can I run on this partition", this answers "what builds the + partition this treatment wants". + """ + return PARTITION_BUILDERS.get(partition) diff --git a/test/unit/effects/test_palette_partition.py b/test/unit/effects/test_palette_partition.py index a679bd9..67b99e8 100644 --- a/test/unit/effects/test_palette_partition.py +++ b/test/unit/effects/test_palette_partition.py @@ -111,3 +111,42 @@ async def test_palette_layout_and_identity_isolation(): def test_feasibility_on_class(): assert isinstance(PalettePartition.FEASIBILITY, dict) assert PalettePartition.FEASIBILITY["max_pixel_writes_per_frame"] == 0 + + +# --------------------------------------------------------------------------- +# Nickname -> builder +# --------------------------------------------------------------------------- +# +# Each treatment advertises the partition it wants as a NICKNAME (``PARTITION = +# "radial"``). Nothing resolved one to a callable until PARTITION_BUILDERS existed, +# and the nicknames are regular enough to invite guessing: twelve of thirteen are +# ``map_`` + the nickname. The thirteenth is "anchor", whose builder is +# ``map_anchor_distance`` — and a code-generating agent burned a whole run guessing +# ``map_anchor``. These two tests are that run, written down. + + +def test_every_treatment_partition_resolves_to_a_builder(): + from scrollkit.effects.palette_partition import builder_for + from scrollkit.effects.palette_treatments import TREATMENT_CLASSES + + for cls in TREATMENT_CLASSES: + assert builder_for(cls.PARTITION) is not None, ( + "%s wants partition %r and nothing builds it" + % (cls.__name__, cls.PARTITION) + ) + + +def test_the_irregular_nickname_is_the_one_worth_pinning(): + """"anchor" is the nickname that does NOT follow map_.""" + from scrollkit.effects.palette_partition import ( + PARTITION_BUILDERS, builder_for, map_anchor_distance, + ) + + assert builder_for("anchor") is map_anchor_distance + assert builder_for("no such partition") is None + irregular = [n for n, fn in PARTITION_BUILDERS.items() + if fn.__name__ != "map_" + n] + assert irregular == ["anchor"], ( + "the set of irregular nicknames changed: %r. Every one of these is a name an " + "agent will guess wrong." % (irregular,) + ) From b080512bd9caa4d0727af630749b72e59376205e Mon Sep 17 00:00:00 2001 From: czei <9g0jqglotahgp7741rr0q07p3s2ly1@bots.bitbucket.org> Date: Sat, 29 Aug 2026 13:10:29 -0400 Subject: [PATCH 02/10] Acts a sign can be handed, instead of ones it has to own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dwells were already portable: a palette treatment takes a PalettePartition and nothing else, which is why twelve of a reference sign's fifteen are one-liners. Builds and exits were not — they were written inside the app that owned the mark and reached into its tiles, its layout and its palette directly, so reusing one meant copying it. This is the missing half. An act is handed a context and knows nothing else: ctx.slots the mark's lit cells, (x, y) -> palette index, or bare cells ctx.colors the ramp those indices point into (optional) ctx.display what to start an effect against ctx.running falsy means stop ctx.frame() present one frame; False means the surface went away ctx.show() put the mark in its final place ctx.hide() clear it swarm_build, swarm_unbuild and drip_in, plus act_factory/supported_acts mirroring the transition registry. Duck-typed on purpose: an app already has these under its own names and should not have to inherit anything. **Verified against a real sign.** The three acts drive darkowl-led-logo's actual 228-cell mark with darkowl unmodified — the context is wired straight to its own _show_logo, _hide_all and _logo_slots. drip completes in 35 frames, swarm in 182, unswarm in 189, and unswarm correctly leaves zero tiles visible where the builds leave seven. Two things the port surfaced: - **Slots alone do not describe a mark.** Per-pixel indices point into a ramp, and SwarmReveal raises on an index_map with no text_colors rather than guessing — correctly, since a guessed palette is a sign in colours nobody chose. Hence ctx.colors, and a flat reveal when there is none. - **The two acts differed by accident, not by design.** One checked `running` and the other did not; one bounded at 2000 steps and the other at 2500. The one that ignored `running` would keep a stopping sign on screen for another two thousand frames. Both now share one driver. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i --- src/scrollkit/effects/acts.py | 221 +++++++++++++++++++++++++++++++++ test/unit/effects/test_acts.py | 154 +++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 src/scrollkit/effects/acts.py create mode 100644 test/unit/effects/test_acts.py diff --git a/src/scrollkit/effects/acts.py b/src/scrollkit/effects/acts.py new file mode 100644 index 0000000..4e05a6b --- /dev/null +++ b/src/scrollkit/effects/acts.py @@ -0,0 +1,221 @@ +"""Acts: reveal a mark, hold it, take it away — over art someone else drew. + +An **act** is the unit a sign is actually made of: build -> dwell -> exit. The +treatments in :mod:`scrollkit.effects.palette_treatments` are already portable dwells, +because they take a :class:`~scrollkit.effects.palette_partition.PalettePartition` and +nothing else — which is why twelve of a reference sign's fifteen dwells are one-liners. +Builds and exits were not portable, because they were written inside the app that owned +the mark and reached into its tiles, its layout and its palette directly. + +This module is the missing half: a build or an exit that knows nothing about the mark +except what the context hands it. + +**The context.** Duck-typed on purpose — an app already has these, under its own names, +and should not have to inherit anything to use an act: + +=================== ========================================================= +``ctx.slots`` The mark's lit cells. A mapping of ``(x, y) -> palette + index``, or any iterable of ``(x, y)``. Acts that need + per-pixel colour use the mapping; the rest just need the + positions. +``ctx.colors`` Optional. The ramp those indices point into, low->high. + **Indices are meaningless without it** — that is not a + style preference, it is what SwarmReveal enforces: an + index_map with no ramp raises. A context with slots but + no colours gets a flat reveal, which is correct and is + what a one-tone mark wants anyway. +``ctx.display`` The display to ``start()`` an effect against. +``ctx.running`` Falsy means the sign is stopping; an act must return early. +``await ctx.frame()`` Present one frame. ``False`` means the surface went away + and the act must stop — the return value is not advisory. +``ctx.show()`` Put the mark on screen in its final place. +``ctx.hide()`` Clear the mark and anything layered over it. +=================== ========================================================= + +Every act returns ``True`` if it ran to completion and ``False`` if it stopped early, +and every act detaches whatever it attached — including on the early exit, because the +overlay outlives the act otherwise and the next one starts on a dirty panel. + + from scrollkit.effects.acts import act_factory + ok = await act_factory("drip")(ctx, direction="bottom") + +`SimpleContext` is the smallest thing that satisfies the protocol; an app with its own +tiles and layouts will usually pass itself instead. +""" + +from .drip_splash import DripReveal +from .swarm_reveal import SwarmReveal + +#: Fallback when the context's slots carry no colour information. +_DEFAULT_COLOR = 0xFFB030 + +#: A frame-driven effect that never reports completion has to be stopped by something. +#: These are generous — a swarm is measured in hundreds of frames — because the cap is a +#: runaway guard and not a duration. +_SWARM_MAX_STEPS = 2500 +_DRIP_MAX_STEPS = 2000 + + +class SimpleContext: + """The smallest thing an act will accept. + + Useful for tests and for an app that has no tile machinery of its own. An app that + does should pass itself: the protocol is six names, and inheriting from this would + buy nothing. + """ + + def __init__(self, display, slots, colors=None, show=None, hide=None, frame=None): + self.display = display + self.slots = slots + self.colors = colors + self.running = True + self._show = show + self._hide = hide + self._frame = frame + + async def frame(self): + if self._frame is not None: + return await self._frame() + if await self.display.show() is False: + return False + return True + + def show(self): + if self._show is not None: + self._show() + + def hide(self): + if self._hide is not None: + self._hide() + + +def _positions(slots): + """The mark's cells as a list of ``(x, y)``, from a mapping or a plain iterable.""" + return list(slots) + + +def _index_map(slots): + """``{(x, y): ramp index}`` when the slots carry colour, else ``None``.""" + if hasattr(slots, "items"): + return dict(slots) + return None + + +async def _drive(ctx, effect, max_steps, is_done): + """Step an effect to completion, presenting a frame each time. + + Shared because the loop is where both acts previously differed only by accident: + one checked ``running`` and the other did not, one bounded its steps at 2000 and the + other at 2500. A build that ignores ``running`` keeps a stopping sign on screen for + another two thousand frames. + """ + steps = 0 + while steps < max_steps: + if not ctx.running: + return False + if is_done(effect): + return True + effect.step() + steps += 1 + if await ctx.frame() is False: + return False + return True + + +async def swarm_build(ctx, colors=None, index_map=None, bird_color=0xB0B0B0, + num_birds=48, bird_speed=2.6, reverse=False): + """A flock carries the mark into place, pixel by pixel. + + ``colors`` is a low->high ramp for the assembled image; with ``index_map`` (or + colour-carrying ``ctx.slots``) each cell takes its own stop, which is how a mark + assembles in its true colours rather than one flat tone. + + ``num_birds`` is the hardware-feasibility knob: per-frame cost grows with its + square, because of the boids neighbour pass. + """ + ctx.hide() + pixels = _positions(ctx.slots) + if colors is None: + colors = getattr(ctx, "colors", None) + # **Only with a ramp.** Per-pixel indices point into `colors`, and SwarmReveal + # raises on an index_map without one rather than guessing — correctly, because a + # guessed palette is a sign in colours nobody chose. No ramp means a flat reveal. + if index_map is None and colors is not None: + index_map = _index_map(ctx.slots) + if colors is None: + index_map = None + swarm = SwarmReveal(pixels, bird_color=bird_color, num_birds=num_birds, + bird_speed=bird_speed, text_colors=colors, + index_map=index_map, reverse=reverse) + swarm.start(ctx.display) + try: + ok = await _drive(ctx, swarm, _SWARM_MAX_STEPS, + lambda s: s.is_complete) + if ok and not reverse: + ctx.show() + return ok + finally: + # Detached even when the act stopped early. The overlay outlives the act + # otherwise, and the next one starts on a panel it did not draw. + swarm.detach() + + +async def swarm_unbuild(ctx, colors=None, index_map=None, bird_color=0xB0B0B0, + num_birds=48, bird_speed=2.6): + """The same flock, carrying the mark away. The exit half of :func:`swarm_build`.""" + return await swarm_build(ctx, colors=colors, index_map=index_map, + bird_color=bird_color, num_birds=num_birds, + bird_speed=bird_speed, reverse=True) + + +async def drip_in(ctx, direction="top", color=None, fall_speed=2, stagger=1): + """The mark's pixels rain in from an edge, then the real colours arrive. + + The drip draws in one flat ``color``; :meth:`ctx.show` lifts that overlay onto the + mark's own layers underneath, so the true palette appears at the moment the last + drop lands. ``direction`` is "top", "bottom", "left" or "right". + """ + ctx.hide() + if color is None: + color = _DEFAULT_COLOR + drip = DripReveal(_positions(ctx.slots), color=color, fall_speed=fall_speed, + stagger=stagger, direction=direction) + drip.start(ctx.display) + try: + ok = await _drive(ctx, drip, _DRIP_MAX_STEPS, lambda d: d.is_complete) + if ok: + ctx.show() + return ok + finally: + drip.detach() + + +#: name -> build. Mirrors :func:`scrollkit.effects.transitions.transition_factory`: +#: a name is what an app, a catalogue or a person selecting from a menu can hold. +BUILDS = { + "swarm": swarm_build, + "drip": drip_in, +} + +#: name -> exit. +EXITS = { + "unswarm": swarm_unbuild, +} + + +def act_factory(name): + """The act for a name, or ``None`` if the name is not one. + + ``None`` rather than a raise, matching ``transition_factory``: the caller decides + whether an unknown name is a typo or a feature it does not have yet. + """ + return BUILDS.get(name) or EXITS.get(name) + + +def supported_acts(kind=None): + """Act names, all of them or just ``"build"`` / ``"exit"``.""" + if kind == "build": + return tuple(BUILDS) + if kind == "exit": + return tuple(EXITS) + return tuple(BUILDS) + tuple(EXITS) diff --git a/test/unit/effects/test_acts.py b/test/unit/effects/test_acts.py new file mode 100644 index 0000000..6879522 --- /dev/null +++ b/test/unit/effects/test_acts.py @@ -0,0 +1,154 @@ +"""Acts run over a mark they know nothing about. + +The point of the module under test is portability, so these tests give it the least an +app could possibly hand it — a set of cells and four callbacks — and check that a real +build assembles a real mark. If an act needs anything else, it fails here rather than in +the app that tried to reuse it. +""" + +import os + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + +import asyncio + +import pytest + +pygame = pytest.importorskip("pygame") + +from scrollkit.effects.acts import ( # noqa: E402 + BUILDS, EXITS, SimpleContext, act_factory, drip_in, supported_acts, + swarm_build, swarm_unbuild, +) + +#: A mark, as an app would hand one over: lit cells with a palette index each. +MARK = {(10, 12): 1, (11, 12): 1, (12, 12): 2, (13, 12): 2, + (10, 13): 1, (13, 13): 3, (11, 14): 1, (12, 14): 2} + + +async def _display(): + from scrollkit.display.simulator import SimulatorDisplay + + d = SimulatorDisplay(width=64, height=32) + await d.initialize() + return d + + +def _run(act, **kw): + """Run one act over MARK; return (ok, shown, hidden, frames).""" + async def go(): + d = await _display() + seen = {"show": 0, "hide": 0, "frames": 0} + + async def frame(): + seen["frames"] += 1 + await d.show() + return True + + ctx = SimpleContext( + d, MARK, + show=lambda: seen.__setitem__("show", seen["show"] + 1), + hide=lambda: seen.__setitem__("hide", seen["hide"] + 1), + frame=frame, + ) + ok = await act(ctx, **kw) + return ok, seen["show"], seen["hide"], seen["frames"] + + return asyncio.run(go()) + + +@pytest.mark.parametrize("direction", ["top", "bottom", "left", "right"]) +def test_drip_in_assembles_the_mark_from_any_edge(direction): + ok, shown, hidden, frames = _run(drip_in, direction=direction) + assert ok is True + assert hidden == 1, "the act clears what was there before it starts" + assert shown == 1, "and hands the mark back at the end" + assert frames > 1, "it presented frames rather than snapping" + + +def test_swarm_build_assembles_and_shows(): + ok, shown, hidden, frames = _run(swarm_build, num_birds=12) + assert ok is True + assert (hidden, shown) == (1, 1) + assert frames > 1 + + +def test_an_exit_does_not_show_the_mark_at_the_end(): + """The one asymmetry between a build and an exit, and it has to be in the act. + + A build ends with the mark on screen; an exit ends with it gone. An exit that + called show() would put back exactly what it just carried away. + """ + ok, shown, _hidden, _frames = _run(swarm_unbuild, num_birds=12) + assert ok is True + assert shown == 0 + + +def test_a_stopping_sign_ends_the_act_early(): + """`running` going false has to end the act, not merely be noticed. + + One of the two acts this module replaced checked `running` and the other did not. + The one that did not would keep a stopping sign on screen for another two thousand + frames. + """ + async def go(): + d = await _display() + state = {"frames": 0} + ctx = SimpleContext(d, MARK) + + async def frame(): + state["frames"] += 1 + if state["frames"] == 3: + ctx.running = False + await d.show() + return True + + ctx._frame = frame + ok = await drip_in(ctx) + return ok, state["frames"] + + ok, frames = asyncio.run(go()) + assert ok is False, "an interrupted act reports that it did not finish" + assert frames < 20, "and stops promptly rather than running its cap out" + + +def test_a_dead_surface_ends_the_act(): + """`frame()` returning False is not advisory — the surface is gone.""" + async def go(): + d = await _display() + state = {"n": 0} + + async def frame(): + state["n"] += 1 + return state["n"] < 3 + + ctx = SimpleContext(d, MARK, frame=frame) + return await drip_in(ctx), state["n"] + + ok, n = asyncio.run(go()) + assert ok is False + assert n == 3 + + +def test_an_act_takes_a_plain_iterable_of_cells(): + """Colour information is optional: many marks are one flat tone. + + A mapping gives each cell its own ramp stop; a bare set still has to work, because + an app that has not built a palette yet still has a shape to reveal. + """ + async def go(): + d = await _display() + ctx = SimpleContext(d, set(MARK)) + return await drip_in(ctx) + + assert asyncio.run(go()) is True + + +def test_the_registry_names_every_act(): + assert act_factory("drip") is drip_in + assert act_factory("swarm") is swarm_build + assert act_factory("unswarm") is swarm_unbuild + assert act_factory("no such act") is None + assert set(supported_acts("build")) == set(BUILDS) + assert set(supported_acts("exit")) == set(EXITS) + assert set(supported_acts()) == set(BUILDS) | set(EXITS) From b594418fde3ccbb85bf3f4a249b4d6c4d1973621 Mon Sep 17 00:00:00 2001 From: czei <9g0jqglotahgp7741rr0q07p3s2ly1@bots.bitbucket.org> Date: Sat, 29 Aug 2026 13:23:30 -0400 Subject: [PATCH 03/10] The mark an act reveals, for everything that has no app to own one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every build in acts.py ends the same way: hide the mark, run an overlay showing its pixels arrive, then call ctx.show() to hand the real thing back and drop the overlay. That last step assumes something real is underneath. darkowl has it — glyph tiles it can un-hide — and the portability check borrowed exactly that. Anything else had nothing to borrow: the drops land, the overlay detaches, and the panel goes black while the act cheerfully returns True. PixelMark is the minimal version. Lit cells and their colours in, one bitmap, one palette and one tile out, show() and hide(). No layouts, no glyph placement, no app. mark = PixelMark.from_text(display, "BLUE RIDGE", y=6) mark.attach(display) await drip_in(mark.context(display)) Verified as a picture, not a return value: that call puts BLUE RIDGE on the panel in amber, from a font, with no art authored anywhere. Which is the point — a deck of acts can now be assembled and judged before anything is drawn. Details that are decisions rather than defaults: - **Attached hidden.** Every act's first move is to clear the panel, so attaching visible would flash the finished mark for one frame before the act that assembles it begins. - **Index 0 is not lit.** 0 means "no ink here" in ASCII art and the built palette reserves 0 for transparency, so a cell carrying 0 is skipped. Lighting it would give every mark a rectangular background. - **Off-panel cells are dropped, not raised on.** Art is authored by hand and a wordmark placed one column too far right should not take the sign down. The tests check LIT PIXELS after each act rather than the return value, because returning True over a black panel is the exact failure this prevents. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i --- src/scrollkit/effects/mark.py | 135 ++++++++++++++++++++++++++++++++ test/unit/effects/test_mark.py | 139 +++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 src/scrollkit/effects/mark.py create mode 100644 test/unit/effects/test_mark.py diff --git a/src/scrollkit/effects/mark.py b/src/scrollkit/effects/mark.py new file mode 100644 index 0000000..819cdcc --- /dev/null +++ b/src/scrollkit/effects/mark.py @@ -0,0 +1,135 @@ +"""The mark an act reveals — pixels, a palette, and a layer you can show or hide. + +Every build in :mod:`scrollkit.effects.acts` has the same shape: hide the mark, run an +overlay that shows its pixels arriving, then call ``ctx.show()`` to hand the real thing +back and drop the overlay. **That last step assumes something real is underneath.** An +app that owns its wordmark already has it — a set of glyph tiles it can un-hide — but +without one the drops land, the overlay detaches, and the panel goes black. + +:class:`PixelMark` is the minimal version of that: give it lit cells and their colours +and it builds one bitmap, one palette and one tile, and shows or hides them. No layouts, +no glyph placement, no app. + + mark = PixelMark.from_text(display, "BLUE RIDGE COFFEE", y=12) + mark.attach(display) + ok = await drip_in(mark.context(display)) + +It satisfies the mark half of the act context — ``slots``, ``colors``, ``show``, +``hide`` — and :meth:`context` wires it to the runtime half. An app with its own tiles +should keep them and pass itself instead; this is for everything that has no app. +""" + +from ..display.colors import dim_for as _dim + +#: Used when the cells carry no per-pixel index and no colour was given. +_DEFAULT_COLOR = 0xFFB030 + + +class PixelMark: + """A fixed set of lit cells that can be put on screen and taken off again. + + Args: + cells: ``{(x, y): palette index}`` or any iterable of ``(x, y)``. A mapping + colours each cell from ``colors``; a bare iterable is one flat tone. + colors: The ramp the indices point into, low->high, ``0xRRGGBB`` each. + Index 0 in the built palette is always transparent, so a cell's index + ``i`` takes ``colors[i]`` and an index of 0 means "not lit". + color: The flat tone for cells that carry no index. + """ + + def __init__(self, cells, colors=None, color=None): + self.slots = cells + self.colors = colors + self.color = color if color is not None else _DEFAULT_COLOR + self._display = None + self._tile = None + self._bitmap = None + + @classmethod + def from_text(cls, display, text, x=0, y=0, scale=1, color=None): + """A mark from a line of text, in the display's own font. + + The cheapest possible mark, and the reason it exists: a deck of acts can be + built and judged against a real wordmark before any custom art is drawn. + """ + from ..display.text_pixels import pixels_from_font_text + + cells = pixels_from_font_text(display.font, text, x=x, y=y, scale=scale) + return cls(cells, color=color) + + # -- the layer ---------------------------------------------------------- + + def attach(self, display): + """Build the mark's layer and add it to the display, hidden. + + Hidden rather than visible, because every act's first move is to clear the + panel: attaching visible would flash the finished mark for one frame before + the act that assembles it begins. + """ + gfx = display.gfx + indexed = hasattr(self.slots, "items") + ramp = list(self.colors) if (indexed and self.colors) else None + depth = (len(ramp) + 1) if ramp else 2 + + bitmap = gfx.Bitmap(display.width, display.height, depth) + palette = gfx.Palette(depth) + palette.make_transparent(0) + if ramp: + for i, rgb in enumerate(ramp): + palette[i + 1] = _dim(display, rgb) + else: + palette[1] = _dim(display, self.color) + + for cell in self.slots: + x, y = cell + if not (0 <= x < display.width and 0 <= y < display.height): + continue + if ramp: + # An index of 0 is "not lit" in the source art; shifting by one keeps + # slot 0 of the built palette as transparency. + slot = self.slots[cell] + if slot <= 0 or slot > len(ramp): + continue + bitmap[x, y] = slot + else: + bitmap[x, y] = 1 + + self._bitmap = bitmap + self._tile = gfx.TileGrid(bitmap, pixel_shader=palette) + self._tile.hidden = True + display.add_layer(self._tile) + self._display = display + return self + + def detach(self): + """Remove the layer (no-op if it was never attached, or already gone).""" + if self._display is not None and self._tile is not None: + self._display.remove_layer(self._tile) + self._tile = None + + # -- the act context ---------------------------------------------------- + + def show(self): + if self._tile is not None: + self._tile.hidden = False + + def hide(self): + if self._tile is not None: + self._tile.hidden = True + + def context(self, display=None, frame=None): + """A context an act will accept, wired to this mark. + + The mark supplies ``slots``, ``colors``, ``show`` and ``hide``; the caller + supplies the runtime half — the display, and how a frame gets presented. + """ + from .acts import SimpleContext + + return SimpleContext( + display if display is not None else self._display, + self.slots, + colors=self.colors, + show=self.show, + hide=self.hide, + frame=frame, + ) diff --git a/test/unit/effects/test_mark.py b/test/unit/effects/test_mark.py new file mode 100644 index 0000000..b041706 --- /dev/null +++ b/test/unit/effects/test_mark.py @@ -0,0 +1,139 @@ +"""A mark an act can reveal, without an app to own it. + +The failure this exists to prevent is quiet: `drip_in` completes, the overlay detaches, +`ctx.show()` is called, and nothing appears — because there was never anything under the +overlay. Every test here therefore checks LIT PIXELS after the act, not just that it +returned True. +""" + +import os + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + +import asyncio + +import pytest + +pygame = pytest.importorskip("pygame") + +from scrollkit.effects.acts import drip_in, swarm_build, swarm_unbuild # noqa: E402 +from scrollkit.effects.mark import PixelMark # noqa: E402 + +CELLS = {(10, 12): 1, (11, 12): 1, (12, 12): 2, (13, 12): 2, + (10, 13): 1, (13, 13): 3, (11, 14): 1, (12, 14): 2} +RAMP = (0xB02318, 0xFFB030, 0xFFF1D8) + + +async def _display(): + from scrollkit.display.simulator import SimulatorDisplay + + d = SimulatorDisplay(width=64, height=32) + await d.initialize() + return d + + +def _lit(mark): + """Cells set in the mark's own bitmap — what would be on screen when shown.""" + b = mark._bitmap + return {(x, y) for x in range(64) for y in range(32) if b[x, y]} + + +def test_a_mapping_of_cells_becomes_a_palette_indexed_layer(): + async def go(): + d = await _display() + mark = PixelMark(CELLS, colors=RAMP).attach(d) + return _lit(mark), mark._tile.hidden + + lit, hidden = asyncio.run(go()) + assert lit == set(CELLS), "every lit cell reached the bitmap" + assert hidden is True, "attached hidden, so the mark does not flash before its act" + + +def test_a_bare_iterable_becomes_one_flat_tone(): + """A mark with no palette is still a mark. Most first drafts are one colour.""" + async def go(): + d = await _display() + mark = PixelMark(set(CELLS), color=0x00FF00).attach(d) + return _lit(mark) + + assert asyncio.run(go()) == set(CELLS) + + +def test_index_zero_is_not_lit(): + """0 means "no ink here" in the source art, and the built palette reserves 0 for + transparency — so a cell carrying 0 must not light, or the mark grows a background.""" + async def go(): + d = await _display() + cells = dict(CELLS) + cells[(20, 20)] = 0 + mark = PixelMark(cells, colors=RAMP).attach(d) + return _lit(mark) + + assert (20, 20) not in asyncio.run(go()) + + +def test_cells_off_the_panel_are_dropped_rather_than_raising(): + async def go(): + d = await _display() + cells = dict(CELLS) + cells[(999, 999)] = 1 + cells[(-4, 3)] = 1 + mark = PixelMark(cells, colors=RAMP).attach(d) + return _lit(mark) + + assert asyncio.run(go()) == set(CELLS) + + +def test_from_text_builds_a_wordmark_with_no_art_at_all(): + """The reason a deck of acts can be judged before anything is drawn.""" + async def go(): + d = await _display() + mark = PixelMark.from_text(d, "HELLO", x=2, y=10) + mark.attach(d) + return _lit(mark) + + lit = asyncio.run(go()) + assert len(lit) > 20, "the text lit a plausible number of cells" + + +@pytest.mark.parametrize("act,kw,visible_after", [ + (drip_in, {"direction": "top"}, True), + (swarm_build, {"num_birds": 12}, True), + (swarm_unbuild, {"num_birds": 12}, False), +]) +def test_an_act_hands_the_mark_back_at_the_end(act, kw, visible_after): + """The whole point: after a build the mark is ON SCREEN, not merely 'ok'. + + Before PixelMark this passed its return value and left a black panel, because + ctx.show() had nothing to show. + """ + async def go(): + d = await _display() + mark = PixelMark(CELLS, colors=RAMP).attach(d) + + async def frame(): + await d.show() + return True + + ok = await act(mark.context(d, frame=frame), **kw) + return ok, mark._tile.hidden, _lit(mark) + + ok, hidden, lit = asyncio.run(go()) + assert ok is True + assert hidden is not visible_after, ( + "a build ends with the mark shown; an exit ends with it gone" + ) + assert lit == set(CELLS), "and the mark itself is unchanged by the act" + + +def test_detach_is_safe_twice_and_before_attach(): + mark = PixelMark(CELLS, colors=RAMP) + mark.detach() + + async def go(): + d = await _display() + m = PixelMark(CELLS, colors=RAMP).attach(d) + m.detach() + m.detach() + + asyncio.run(go()) From 8c40cad8d891845b2e1d7baca440428ac663ab73 Mon Sep 17 00:00:00 2001 From: czei <9g0jqglotahgp7741rr0q07p3s2ly1@bots.bitbucket.org> Date: Sun, 30 Aug 2026 16:27:20 -0400 Subject: [PATCH 04/10] Promote the library's own catalogue into the deck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reference sign's thirty-seven acts are mostly not bespoke code: twenty-four are a transition or a palette treatment applied to the mark, chosen by name. So the promotion is not twenty-four functions, it is three. reveal_via(ctx, transition=...) 12 builds hide_via(ctx, transition=...) 12 exits treatment_dwell(ctx, treatment=) 11 dwells Plus wink_in, and the swarm/drip already here. Seven act functions, 39 distinct selections. DWELLS is a third deck because an act is build -> dwell -> exit and the middle one is where the variety lives. The partition builders take different arguments — an anchor column, a centre, nothing — so _partition_for supplies them from the mark's own bounding box. That is the whole trick: a treatment names the partition it wants, the mark knows its own geometry, and nothing in between has to be written per sign. **Two things are refused rather than offered broken**, because a menu entry that cannot work is worse than an absent one and a silent no-op is worse than both: - **RouteCircuit and PacketTrace** want map_route, which needs a mark's glyph stroke paths and terminus pixels. That is app knowledge, not derivable from a set of cells. - **Drop from Sky** is a different thing wearing the same word. It hooks pre_render_hook and animates a content Label's x/y through the display process; its start() never calls the swap callback, so a mark handed to it stays hidden while the act reports success. transitions_available() excludes anything carrying that hook. Three things the tests found, each of which would have shipped silently: - **A treatment theme is EXACTLY five stops** — every class unpacks "base, dim, flat, warm, hot". A mark's palette is however many colours its art needed, so _theme resamples to five, endpoints kept. Refusing instead would mean "your wordmark has six colours so you may not have a heat sweep". - **GradientDwell needs lo and hi.** The ramp's ends are the obvious answer; _treatment_extras reads the signature and excludes any treatment wanting an argument this module cannot invent. - **Not every transition has detach().** DropFromSky does not, and an act must not fail on the way out of a transition that succeeded. The catalogue test runs EVERY advertised name rather than a sample. A menu whose entries do not all work is worse than a shorter menu: the reader cannot tell which half is real, so they stop trusting any of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i --- src/scrollkit/effects/acts.py | 275 ++++++++++++++++++++++- test/unit/effects/test_acts.py | 7 +- test/unit/effects/test_acts_catalogue.py | 143 ++++++++++++ 3 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 test/unit/effects/test_acts_catalogue.py diff --git a/src/scrollkit/effects/acts.py b/src/scrollkit/effects/acts.py index 4e05a6b..acdc0ce 100644 --- a/src/scrollkit/effects/acts.py +++ b/src/scrollkit/effects/acts.py @@ -54,6 +54,17 @@ #: runaway guard and not a duration. _SWARM_MAX_STEPS = 2500 _DRIP_MAX_STEPS = 2000 +_DWELL_MAX_STEPS = 3000 + +#: A treatment theme is EXACTLY five stops, dark to bright: base, dim, flat, warm, hot. +#: Every treatment unpacks it that way (``base, dim, flat, warm, hot = self.theme``), so +#: a ramp of any other length raises from inside the effect on its first step. This is +#: the default when a mark carries no palette of its own. +_DEFAULT_RAMP = (0x70140E, 0x8F1B12, 0xB02318, 0xC93A1E, 0xE65A28) + +#: How many stops a treatment theme has. Named because the number is a contract with +#: every class in palette_treatments, not a taste. +_THEME_STOPS = 5 class SimpleContext: @@ -190,16 +201,272 @@ async def drip_in(ctx, direction="top", color=None, fall_speed=2, stagger=1): drip.detach() +# --------------------------------------------------------------------------- +# The library's own catalogue, as acts +# --------------------------------------------------------------------------- +# +# These three functions are where the menu actually comes from. A reference sign's +# thirty-seven acts are mostly not bespoke code: twenty-four are a transition or a +# palette treatment applied to the mark, selected by name. Wrapping the three +# selection mechanisms turns those into deck entries without writing them out. + + +def _centre(slots): + """The mark's bounding-box centre, for the partitions that need an anchor.""" + xs = [x for (x, _y) in slots] + ys = [y for (_x, y) in slots] + if not xs: + return 0, 0 + return (min(xs) + max(xs)) // 2, (min(ys) + max(ys)) // 2 + + +def _partition_for(nickname, slots, groups): + """``(group_map, n)`` for a treatment's partition, or ``None`` if it needs more. + + The builders take different arguments — an anchor column, a centre, nothing at + all — so this supplies them from the mark's own geometry. ``map_route`` is the one + that cannot be served: it needs the glyph stroke paths and terminus pixels of a + specific mark, which is app knowledge and not derivable from a set of cells. The + two treatments that want it are simply not offered rather than offered broken. + """ + from . import palette_partition as P + + cx, cy = _centre(slots) + if nickname == "diagonal": + return P.map_diagonal(slots, groups) + if nickname == "anchor": + return P.map_anchor_distance(slots, cx, groups) + if nickname == "radial": + return P.map_radial(slots, cx, cy, groups) + if nickname == "angle": + return P.map_angle(slots, cx, cy, max(groups, 14)) + if nickname == "rain": + return P.map_rain(slots, groups) + if nickname == "checker": + return P.map_checker(slots, 4) + if nickname == "exposure": + return P.map_exposure(slots) + if nickname == "regions": + return P.map_regions(slots, groups) + if nickname == "topology": + return P.map_topology(slots) + return None + + +def _theme(colors): + """Exactly five stops, whatever the caller had. + + A mark's palette is however many colours its art needed; a treatment theme is a + five-stop ramp its author unpacks by name. Resampled rather than refused, because + "your wordmark has six colours so you may not have a heat sweep" is not a rule + anyone would accept, and picking five from a ramp is a well-defined thing to do. + """ + colors = list(colors) + if len(colors) == _THEME_STOPS: + return colors + if len(colors) < 2: + return list(_DEFAULT_RAMP) + # Evenly spaced across the ramp, endpoints included, so the darkest and brightest + # the author chose stay the darkest and brightest the treatment sees. + last = len(colors) - 1 + return [colors[round(i * last / (_THEME_STOPS - 1))] for i in range(_THEME_STOPS)] + + +def transitions_available(): + """Transition names that can drive a MARK, which is not all of them. + + A cover-and-swap transition hides the panel, runs a callback while nothing is + visible, and uncovers the result — that is the model an act needs. ``Drop from Sky`` + is a different thing wearing the same word: it hooks ``pre_render_hook`` and + animates a content Label's x/y through the display process, so its ``start`` never + calls the swap callback at all and a mark handed to it stays hidden. + + It is excluded rather than offered and left to fail, for the reason the route + treatments are: a menu entry that cannot work is worse than an absent one. + """ + from .transitions import _TRANSITION_MAP + + return tuple(name for name, cls in _TRANSITION_MAP.items() + if not hasattr(cls, "pre_render_hook")) + + +def _treatment_extras(cls, theme): + """Positional arguments a treatment needs beyond ``(fx, theme)``, or ``None``. + + Only ``lo`` and ``hi`` are servable, and the ramp's ends are the obvious answer for + them — a gradient dwell between the darkest and brightest stop the author chose. + Anything else required is something this module cannot invent, so the treatment is + not offered. + """ + try: + import inspect + + params = list(inspect.signature(cls.__init__).parameters.values())[3:] + except (TypeError, ValueError): # pragma: no cover - builtins + return () + extras = [] + for param in params: + if param.default is not param.empty: + break + if param.name == "lo": + extras.append(theme[0]) + elif param.name == "hi": + extras.append(theme[-1]) + else: + return None + return tuple(extras) + + +def treatments_available(): + """Treatment names this module can drive over an arbitrary mark. + + Not every treatment in the library: the two route-based ones need a mark's stroke + paths, so they are excluded here rather than offered and left to fail. + """ + from .palette_treatments import TREATMENT_CLASSES + + probe = {(0, 0): 1, (1, 1): 1, (2, 2): 1, (3, 1): 1} + names = [] + for cls in TREATMENT_CLASSES: + if _partition_for(cls.PARTITION, probe, 4) is None: + continue + if _treatment_extras(cls, _DEFAULT_RAMP) is None: + continue + names.append(cls.__name__) + return tuple(names) + + +async def treatment_dwell(ctx, treatment="VelvetSweep", ramp=None, groups=10): + """A palette treatment over the mark: the DWELL between a build and an exit. + + The mark is not redrawn. A :class:`PalettePartition` groups its own pixels and the + treatment animates the groups by rewriting palette entries — zero pixel writes a + frame, which is why a reference sign can afford a dozen of these. Name any of + :func:`treatments_available`. + """ + from .palette_partition import PalettePartition + from . import palette_treatments as T + + cls = getattr(T, treatment, None) + if cls is None or cls not in T.TREATMENT_CLASSES: + raise RuntimeError("no treatment named %r. Known: %s" + % (treatment, ", ".join(treatments_available()))) + # **Slot 1 is body; anything above it is identity and stays out of the sweep.** + # That is the convention PalettePartition and the map_ builders share, and it is + # how a reference sign stops a heat treatment recolouring an eye. A mark that + # carries its own slots keeps them; a plain one is all body, because it has no + # such distinction to lose. + slots = (dict(ctx.slots) if hasattr(ctx.slots, "items") + else {cell: 1 for cell in ctx.slots}) + + built = _partition_for(cls.PARTITION, slots, groups) + if built is None: + raise RuntimeError( + "%s wants the %r partition, which needs a mark's own stroke paths" + % (treatment, cls.PARTITION)) + group_map, n = built + + theme = _theme(ramp or getattr(ctx, "colors", None) or _DEFAULT_RAMP) + # The mark's own non-body colours, so an identity pixel keeps looking like itself + # while the body is swept. Slots are 1-based into the ramp, and slot 1 is body. + top = max(slots.values()) + identity = tuple(theme[1:top]) if top > 1 else () + + fx = PalettePartition(ctx.display.gfx, slots, group_map, n, + identity_colors=identity, + width=ctx.display.width, height=ctx.display.height) + ctx.display.add_layer(fx.tile) + try: + ctx.hide() + fx.tile.hidden = False + extras = _treatment_extras(cls, theme) + if extras is None: + raise RuntimeError("%s needs arguments this module cannot supply" + % (treatment,)) + effect = cls(fx, theme, *extras) + if hasattr(effect, "start"): + effect.start(ctx.display) + ok = await _drive(ctx, effect, _DWELL_MAX_STEPS, lambda e: e.is_complete) + fx.tile.hidden = True + if ok: + ctx.show() + return ok + finally: + ctx.display.remove_layer(fx.tile) + + +async def reveal_via(ctx, transition="Iris Snap"): + """A screen transition covers the panel, the mark appears behind it, it uncovers. + + Thirteen builds from one function — every name in + :func:`scrollkit.effects.transitions.supported_names`. + """ + return await _swap_via(ctx, transition, ctx.show, before=ctx.hide) + + +async def hide_via(ctx, transition="Pixel Dissolve"): + """The same, taking the mark away. Thirteen exits from one function.""" + return await _swap_via(ctx, transition, ctx.hide) + + +async def _swap_via(ctx, name, swap, before=None): + """Drive one screen transition: cover, run ``swap`` while hidden, uncover.""" + from .transitions import transition_factory + + if name not in transitions_available(): + raise RuntimeError("no transition named %r that can drive a mark. Known: %s" + % (name, ", ".join(transitions_available()))) + tr = transition_factory(name) + if before is not None: + before() + await tr.start(ctx.display, swap) + try: + while not tr.is_complete: + if not ctx.running: + return False + await tr.render(ctx.display) + if await ctx.frame() is False: + return False + return True + finally: + # Not every transition has one — DropFromSky does not — and an act must not + # fail on the way out of a transition that succeeded. + detach = getattr(tr, "detach", None) + if callable(detach): + detach() + + +async def wink_in(ctx, color=None, off_per_frame=44, hold_seconds=0.6): + """Every LED lights, then everything that is not the mark winks off.""" + from .reveal_splash import show_reveal_splash + + ctx.show() + ok = await show_reveal_splash(ctx.display, _positions(ctx.slots), + color=color if color is not None else _DEFAULT_COLOR, + off_per_frame=off_per_frame, + hold_seconds=hold_seconds) + return ok is not False and bool(ctx.running) + + #: name -> build. Mirrors :func:`scrollkit.effects.transitions.transition_factory`: #: a name is what an app, a catalogue or a person selecting from a menu can hold. BUILDS = { "swarm": swarm_build, "drip": drip_in, + "wink": wink_in, + "reveal": reveal_via, } #: name -> exit. EXITS = { "unswarm": swarm_unbuild, + "hide": hide_via, +} + +#: name -> dwell. The middle of build -> dwell -> exit, and the deck a reference sign +#: has most of: twelve of its fifteen dwells are a treatment over a partition. +DWELLS = { + "treatment": treatment_dwell, } @@ -209,13 +476,15 @@ def act_factory(name): ``None`` rather than a raise, matching ``transition_factory``: the caller decides whether an unknown name is a typo or a feature it does not have yet. """ - return BUILDS.get(name) or EXITS.get(name) + return BUILDS.get(name) or DWELLS.get(name) or EXITS.get(name) def supported_acts(kind=None): - """Act names, all of them or just ``"build"`` / ``"exit"``.""" + """Act names, all of them or one deck: ``"build"``, ``"dwell"``, ``"exit"``.""" if kind == "build": return tuple(BUILDS) + if kind == "dwell": + return tuple(DWELLS) if kind == "exit": return tuple(EXITS) - return tuple(BUILDS) + tuple(EXITS) + return tuple(BUILDS) + tuple(DWELLS) + tuple(EXITS) diff --git a/test/unit/effects/test_acts.py b/test/unit/effects/test_acts.py index 6879522..7f3dd16 100644 --- a/test/unit/effects/test_acts.py +++ b/test/unit/effects/test_acts.py @@ -145,10 +145,15 @@ async def go(): def test_the_registry_names_every_act(): + from scrollkit.effects.acts import DWELLS + assert act_factory("drip") is drip_in assert act_factory("swarm") is swarm_build assert act_factory("unswarm") is swarm_unbuild assert act_factory("no such act") is None assert set(supported_acts("build")) == set(BUILDS) + assert set(supported_acts("dwell")) == set(DWELLS) assert set(supported_acts("exit")) == set(EXITS) - assert set(supported_acts()) == set(BUILDS) | set(EXITS) + # Three decks, because an act is build -> dwell -> exit and the middle one is + # where a reference sign keeps most of its variety. + assert set(supported_acts()) == set(BUILDS) | set(DWELLS) | set(EXITS) diff --git a/test/unit/effects/test_acts_catalogue.py b/test/unit/effects/test_acts_catalogue.py new file mode 100644 index 0000000..61a39ae --- /dev/null +++ b/test/unit/effects/test_acts_catalogue.py @@ -0,0 +1,143 @@ +"""Every act the menu offers actually runs. + +A menu whose entries do not all work is worse than a shorter menu: the reader cannot +tell which half is real, so they stop trusting any of it. These tests therefore run +EVERY name the module advertises — thirteen transitions as builds, the same thirteen as +exits, every driveable treatment, and the bespoke handful — rather than a sample. + +They are also the promotion's proof. `reveal_via`, `hide_via` and `treatment_dwell` +exist so a reference sign's twenty-four library-driven acts reach a deck without being +written out one by one; if a name in the catalogue cannot be driven over a plain mark, +that claim is false for that name. +""" + +import os + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + +import asyncio + +import pytest + +pygame = pytest.importorskip("pygame") + +from scrollkit.effects import acts as A # noqa: E402 +from scrollkit.effects.mark import PixelMark # noqa: E402 +from scrollkit.effects.acts import transitions_available # noqa: E402 + +CELLS = {(x, 12): 1 for x in range(8, 40)} +CELLS.update({(x, 13): 1 for x in range(8, 40)}) +CELLS.update({(10, 14): 2, (12, 14): 2, (14, 14): 3}) +RAMP = (0xB02318, 0xFFB030, 0xFFF1D8, 0xD4481E, 0xE8873C, 0x571007, 0x3A1206) + + +async def _ctx(): + from scrollkit.display.simulator import SimulatorDisplay + + d = SimulatorDisplay(width=64, height=32) + await d.initialize() + mark = PixelMark(CELLS, colors=RAMP).attach(d) + state = {"frames": 0} + + async def frame(): + state["frames"] += 1 + # Bounded hard: a transition or treatment that never completes must fail this + # suite loudly rather than hang it. + if state["frames"] > 4000: + return False + await d.show() + return True + + return mark.context(d, frame=frame), state, mark + + +def _run(act, **kw): + async def go(): + ctx, state, mark = await _ctx() + ok = await act(ctx, **kw) + return ok, state["frames"], mark._tile.hidden + + return asyncio.run(go()) + + +@pytest.mark.parametrize("name", transitions_available()) +def test_every_transition_works_as_a_build(name): + ok, frames, hidden = _run(A.reveal_via, transition=name) + assert ok is True, f"{name} did not complete" + assert frames > 1, f"{name} presented no frames" + assert hidden is False, f"{name} finished without the mark on screen" + + +@pytest.mark.parametrize("name", transitions_available()) +def test_every_transition_works_as_an_exit(name): + ok, frames, hidden = _run(A.hide_via, transition=name) + assert ok is True, f"{name} did not complete" + assert frames > 1 + assert hidden is True, f"{name} finished with the mark still there" + + +@pytest.mark.parametrize("name", A.treatments_available()) +def test_every_offered_treatment_drives_over_a_plain_mark(name): + ok, frames, hidden = _run(A.treatment_dwell, treatment=name) + assert ok is True, f"{name} did not complete" + assert frames > 1 + assert hidden is False, "a dwell hands the mark back" + + +def test_the_route_treatments_are_refused_rather_than_offered_broken(): + """RouteCircuit and PacketTrace need a mark's own stroke paths. + + They are excluded from `treatments_available` and raise a message that says why, + which is the honest handling: a menu entry that cannot work is worse than an + absent one, and a silent no-op is worse than both. + """ + assert "RouteCircuit" not in A.treatments_available() + assert "PacketTrace" not in A.treatments_available() + with pytest.raises(RuntimeError, match="stroke paths"): + _run(A.treatment_dwell, treatment="RouteCircuit") + + +def test_an_unknown_treatment_names_the_ones_that_exist(): + with pytest.raises(RuntimeError, match="VelvetSweep"): + _run(A.treatment_dwell, treatment="VelvetSwep") + + +def test_an_unknown_transition_names_the_ones_that_exist(): + with pytest.raises(RuntimeError, match="Iris Snap"): + _run(A.reveal_via, transition="Iris Snapp") + + +def test_a_content_driven_transition_is_refused_rather_than_offered_broken(): + """Drop from Sky animates a content Label through the display process. + + Its start() never calls the swap callback, so a mark handed to it simply stays + hidden and the act reports success — the worst kind of failure. Excluded from the + menu, and named as absent if asked for by name. + """ + from scrollkit.effects.transitions import supported_names + + assert "Drop from Sky" in supported_names(), "it is still a valid transition_style" + assert "Drop from Sky" not in transitions_available() + with pytest.raises(RuntimeError, match="drive a mark"): + _run(A.reveal_via, transition="Drop from Sky") + + +def test_wink_leaves_the_mark_up(): + ok, _frames, hidden = _run(A.wink_in, hold_seconds=0.05) + assert ok is True + assert hidden is False + + +def test_the_menu_is_bigger_than_the_functions_that_serve_it(): + """The whole point of the promotion, as an assertion. + + Seven act functions, but a visitor choosing from them has 40 distinct selections: + each transition is a build AND an exit, and each treatment is its own dwell. A + reference sign's thirty-seven acts were mostly this, written out longhand. + """ + selectable = (2 * len(transitions_available()) + + len(A.treatments_available()) + + len(A.BUILDS) - 1 # reveal is counted above + + len(A.EXITS) - 1) # hide is counted above + assert len(A.supported_acts()) < 10 + assert selectable >= 35, selectable From c431e65c44f819e68c21f1efdec93714d4b48e24 Mon Sep 17 00:00:00 2001 From: czei <9g0jqglotahgp7741rr0q07p3s2ly1@bots.bitbucket.org> Date: Sun, 30 Aug 2026 17:38:53 -0400 Subject: [PATCH 05/10] A mark from hand-authored art, in the format art is already written in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PixelMark.from_art takes rows of characters and a map from character to colour — the format pixel art is authored in — so a drawing goes on the panel without being converted into anything first. Verified against real generated art rather than a fixture: darkowl_v8's OWL20, 290 cells, revealed by Iris Snap and animated by HaloPulse. Neither act knows anything about owls, and the art knows nothing about either act. Two decisions: - **An unmapped character is a hole, not a guess.** That is how "." and " " become background without being special-cased, and it means a character nobody defined leaves a gap rather than putting a shape on the panel its author never drew. (normalize_art takes the opposite view for a different reason: repairing a typo at import, where substituting transparent would delete the sprite.) - **The palette holds only the colours used**, in first-seen order, so a mark carries what it needs rather than whatever the art module happened to define. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i --- src/scrollkit/effects/mark.py | 36 ++++++++++++++++++++ test/unit/effects/test_mark.py | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/scrollkit/effects/mark.py b/src/scrollkit/effects/mark.py index 819cdcc..7804aa2 100644 --- a/src/scrollkit/effects/mark.py +++ b/src/scrollkit/effects/mark.py @@ -57,6 +57,42 @@ def from_text(cls, display, text, x=0, y=0, scale=1, color=None): cells = pixels_from_font_text(display.font, text, x=x, y=y, scale=scale) return cls(cells, color=color) + @classmethod + def from_art(cls, rows, chars, x=0, y=0): + """A mark from ASCII art: rows of characters, and what colour each one is. + + The format hand-authored pixel art already uses — a tuple of equal-length + strings where a character names a colour — so a drawing goes on the panel + without being converted into anything first. + + Args: + rows: Equal-length strings, one per row. Ragged rows are tolerated; a + short one simply has fewer lit cells, which is what a short row + means. See :func:`scrollkit.utils.pixel_art.normalize_art` for + repairing them properly. + chars: ``{character: 0xRRGGBB}``. A character absent from this map is + NOT LIT — that is how "." and " " become background without being + special-cased, and it means an unmapped character is a hole rather + than a guess. + x, y: Where the art's top-left corner sits on the panel. + + The palette is built from the colours actually used, in first-seen order, so + a mark carries only the entries it needs. + """ + ramp = [] + index = {} + cells = {} + for row_y, row in enumerate(rows): + for row_x, ch in enumerate(row): + color = chars.get(ch) + if color is None: + continue + if ch not in index: + ramp.append(color) + index[ch] = len(ramp) + cells[(x + row_x, y + row_y)] = index[ch] + return cls(cells, colors=ramp) + # -- the layer ---------------------------------------------------------- def attach(self, display): diff --git a/test/unit/effects/test_mark.py b/test/unit/effects/test_mark.py index b041706..4606754 100644 --- a/test/unit/effects/test_mark.py +++ b/test/unit/effects/test_mark.py @@ -137,3 +137,65 @@ async def go(): m.detach() asyncio.run(go()) + + +# --------------------------------------------------------------------------- +# From hand-authored art +# --------------------------------------------------------------------------- + +ART = ( + ".##.", + "#oo#", + "#ww#", + ".##.", +) +ART_CHARS = {"#": 0xB02318, "o": 0xFFB030, "w": 0xFFF1D8} + + +def test_from_art_lights_exactly_the_mapped_characters(): + async def go(): + d = await _display() + mark = PixelMark.from_art(ART, ART_CHARS, x=10, y=5).attach(d) + return _lit(mark), mark.colors + + lit, colors = asyncio.run(go()) + # Eight '#', two 'o', two 'w'; the four '.' are holes. + assert len(lit) == 12 + assert (10, 5) not in lit, "a dot is background, not a colour" + assert (11, 5) in lit + assert set(colors) == set(ART_CHARS.values()) + + +def test_an_unmapped_character_is_a_hole_not_a_guess(): + """A character nobody defined is missing information, and inventing a colour for + it would put a shape on the panel that the author never drew.""" + async def go(): + d = await _display() + mark = PixelMark.from_art(("#?#",), {"#": 0xB02318}).attach(d) + return _lit(mark) + + assert asyncio.run(go()) == {(0, 0), (2, 0)} + + +def test_the_palette_holds_only_what_the_art_uses(): + mark = PixelMark.from_art(ART, dict(ART_CHARS, z=0x00FF00)) + assert len(mark.colors) == 3, "the unused colour is not in the ramp" + + +def test_art_plays_through_an_act(): + """The whole path: hand-authored art, on the panel, revealed by a library act.""" + async def go(): + d = await _display() + mark = PixelMark.from_art(ART, ART_CHARS, x=20, y=10).attach(d) + + async def frame(): + await d.show() + return True + + ok = await drip_in(mark.context(d, frame=frame)) + return ok, mark._tile.hidden, _lit(mark) + + ok, hidden, lit = asyncio.run(go()) + assert ok is True + assert hidden is False + assert len(lit) == 12 From 0b8577683819341899c614557bf2b7bc13cfb8e5 Mon Sep 17 00:00:00 2001 From: czei <9g0jqglotahgp7741rr0q07p3s2ly1@bots.bitbucket.org> Date: Sun, 30 Aug 2026 22:53:31 -0400 Subject: [PATCH 06/10] A whole sign: art, a deck, and a scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit play_sign draws build -> dwell -> exit from ActScheduler and repeats until told to stop. That is what a sign IS in this design: a reference sign's 1,755 acts are 13 builds x 15 dwells x 9 exits drawn from by a picker, not a written sequence, and it does not visibly repeat because the picker leads with the least-recently-seen and never plays two of a family back to back. selectable() is the menu, one entry per CHOICE rather than per function — seven functions, 39 selections — and it lives here now rather than in the caller that displays it. Each entry carries its family, and that is where the judgement is: a treatment's family is its PARTITION, because two treatments animating the same grouping of pixels genuinely do look alike, which is the distinction a viewer makes and the one the picker needs. Nine dwell families across eleven treatments. **A choice is a kind AND a name**, and the test that found this is the one worth keeping. Every transition is both a build and an exit, so selecting by bare name made "Pixel Dissolve" — kept to END on — also open the sign. A bare name still selects every kind, which is the forgiving reading when nobody has said otherwise, but "exit:Pixel Dissolve" says the thing that was meant. A deck with no exit is refused rather than played half: a sign that ended mid-build leaves the panel in a state no act chose. Verified over the generated BLUE RIDGE COFFEE wordmark: four cycles, 1,129 frames, and every sampled moment looks different while the art never changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i --- src/scrollkit/effects/acts.py | 99 +++++++++++++++++ test/unit/effects/test_play_sign.py | 161 ++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 test/unit/effects/test_play_sign.py diff --git a/src/scrollkit/effects/acts.py b/src/scrollkit/effects/acts.py index acdc0ce..ed452ce 100644 --- a/src/scrollkit/effects/acts.py +++ b/src/scrollkit/effects/acts.py @@ -448,6 +448,105 @@ async def wink_in(ctx, color=None, off_per_frame=44, hold_seconds=0.6): return ok is not False and bool(ctx.running) +# --------------------------------------------------------------------------- +# A whole sign: art, a deck, and a scheduler +# --------------------------------------------------------------------------- + + +def selectable(): + """The menu, one entry per CHOICE rather than per function. + + Seven act functions serve about forty selections, because a transition is both a + build and an exit and every driveable treatment is its own dwell. A person picking + from a list picks "Iris Snap", not "reveal_via with transition=Iris Snap", so the + expansion happens here — once, where the acts are — rather than in each caller. + + Each entry is ``(name, kind, family, act, options)``: + + ``family`` What it looks like. Two entries sharing a family are "similar" and + the scheduler will not play them back to back. A treatment's family is + its PARTITION, because two treatments over the same grouping of pixels + genuinely do look alike; a transition is its own family, which is the + honest answer when nobody has grouped them by eye. + """ + from . import palette_treatments as T + + out = [] + for name in transitions_available(): + out.append((name, "build", "t:" + name, reveal_via, {"transition": name})) + for name in treatments_available(): + family = getattr(getattr(T, name), "PARTITION", name) + out.append((name, "dwell", "p:" + str(family), treatment_dwell, + {"treatment": name})) + for name in transitions_available(): + out.append((name, "exit", "t:" + name, hide_via, {"transition": name})) + for kind, deck in (("build", BUILDS), ("exit", EXITS)): + for name in deck: + if name in ("reveal", "hide"): + continue + out.append((name, kind, "a:" + name, deck[name], {})) + return out + + +def _deck(entries, kind): + """Scheduler deck for one kind: ``(name, family, act, options)`` tuples.""" + return [(n, fam, fn, opts) for n, k, fam, fn, opts in entries if k == kind] + + +async def play_sign(ctx, chosen=None, scheduler=None, acts=None): + """Play a sign: build, dwell, exit, again, until told to stop. + + This is what a sign IS — a reference sign's 1,755 acts are 13 builds x 15 dwells x + 9 exits, drawn from by a picker rather than written out as a sequence. So a deck is + chosen and the ORDER is not: `ActScheduler` leads with the least-recently-seen and + never repeats a family twice running, which is why a shipped sign does not visibly + loop. + + Args: + chosen: What to draw from, or ``None`` for everything available. Entries + are ``"kind:name"`` — ``"build:Iris Snap"`` — because **the same + name is often two different choices**: every transition is both a + build and an exit, and someone who kept "Pixel Dissolve" to end on + did not thereby ask for it to open with. A bare ``"name"`` selects + it in every kind it appears in, which is the forgiving reading when + nobody has said otherwise. + + A name that is not on the menu is ignored rather than raising: a + deck is a preference, and one stale entry should not stop a sign. + acts: How many build-dwell-exit cycles to play. ``None`` runs until + ``ctx.running`` goes false, which is what a panel on a wall wants. + + Returns the number of complete cycles played. + """ + from ..utils.scheduler import ActScheduler + + entries = selectable() + if chosen is not None: + wanted = set(chosen) + entries = [e for e in entries + if e[0] in wanted or "%s:%s" % (e[1], e[0]) in wanted] + builds, dwells, exits = (_deck(entries, k) for k in ("build", "dwell", "exit")) + if not (builds and exits): + raise RuntimeError( + "a sign needs at least one build and one exit; got %d and %d" + % (len(builds), len(exits)) + ) + + sched = scheduler or ActScheduler() + played = 0 + avoid = () + while ctx.running and (acts is None or played < acts): + for key, deck in (("build", builds), ("dwell", dwells), ("exit", exits)): + if not deck: + continue # a sign with no dwells is a sign that flashes + name, family, fn, options = sched.pick(deck, key, avoid=avoid) + avoid = (family,) + if not await fn(ctx, **options): + return played + played += 1 + return played + + #: name -> build. Mirrors :func:`scrollkit.effects.transitions.transition_factory`: #: a name is what an app, a catalogue or a person selecting from a menu can hold. BUILDS = { diff --git a/test/unit/effects/test_play_sign.py b/test/unit/effects/test_play_sign.py new file mode 100644 index 0000000..62d548e --- /dev/null +++ b/test/unit/effects/test_play_sign.py @@ -0,0 +1,161 @@ +"""A whole sign: art, a deck, and a scheduler — plan 003 step 6. + +What a sign IS, in this design, is not a written sequence. A reference sign's 1,755 +acts are 13 builds x 15 dwells x 9 exits drawn from by a picker, and it does not +visibly repeat because the picker leads with the least-recently-seen and never plays +two of a family back to back. So the visitor chooses a DECK and the runtime chooses +the order. +""" + +import os + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + +import asyncio + +import pytest + +pygame = pytest.importorskip("pygame") + +from scrollkit.effects import acts as A # noqa: E402 +from scrollkit.effects.mark import PixelMark # noqa: E402 + +CELLS = {(x, 14): 1 for x in range(10, 50)} +CELLS.update({(x, 15): 1 for x in range(10, 50)}) +RAMP = (0xB02318, 0xFFB030, 0xFFF1D8, 0xD4481E, 0xE8873C) + + +async def _ctx(budget=6000): + from scrollkit.display.simulator import SimulatorDisplay + + d = SimulatorDisplay(width=64, height=32) + await d.initialize() + mark = PixelMark(CELLS, colors=RAMP).attach(d) + state = {"frames": 0} + + async def frame(): + state["frames"] += 1 + if state["frames"] > budget: + return False + await d.show() + return True + + return mark.context(d, frame=frame), state, mark + + +def test_the_menu_is_one_entry_per_choice(): + entries = A.selectable() + kinds = {k for _n, k, _f, _fn, _o in entries} + assert kinds == {"build", "dwell", "exit"} + assert len(entries) > 30, "seven functions, about forty choices" + # Every entry is runnable as it stands: a name and the arguments that make it real. + for name, _kind, _family, fn, options in entries: + assert callable(fn), name + assert isinstance(options, dict), name + + +def test_two_treatments_over_the_same_partition_share_a_family(): + """The scheduler's `avoid` is only worth anything if families mean something. + + Treatments are grouped by PARTITION because two treatments animating the same + grouping of pixels genuinely do look alike — that is the judgement a viewer makes, + and it is the one the picker needs. + """ + dwells = A._deck(A.selectable(), "dwell") + families = {} + for name, family, _fn, _o in dwells: + families.setdefault(family, []).append(name) + shared = [names for names in families.values() if len(names) > 1] + assert shared, "some treatments do share a partition" + + +def test_a_sign_plays_build_dwell_exit_and_comes_back_for_more(): + async def go(): + ctx, state, mark = await _ctx() + played = await A.play_sign(ctx, acts=2) + return played, state["frames"], mark._tile.hidden + + played, frames, hidden = asyncio.run(go()) + assert played == 2, "two complete cycles" + assert frames > 50 + # A cycle ends on an exit, so the mark is gone. A sign that ended mid-build would + # leave the panel in a state no act chose. + assert hidden is True + + +def test_a_choice_is_kind_AND_name(): + """The same name is often two different choices. + + Every transition is both a build and an exit, so someone who kept "Pixel Dissolve" + to END on did not thereby ask for it to OPEN with. Selecting by bare name played + it as both, which is a sign the visitor did not choose. + """ + async def go(): + ctx, _state, _mark = await _ctx() + seen = [] + real = A.reveal_via + + async def spy(c, **kw): + seen.append(kw.get("transition")) + return await real(c, **kw) + + A.reveal_via = spy + try: + await A.play_sign( + ctx, + chosen=["build:Iris Snap", "dwell:VelvetSweep", "exit:Pixel Dissolve"], + acts=3) + finally: + A.reveal_via = real + return seen + + seen = asyncio.run(go()) + assert seen and set(seen) == {"Iris Snap"}, seen + + +def test_a_deck_with_no_exit_is_refused_rather_than_played_half(): + async def go(): + ctx, _s, _m = await _ctx() + return await A.play_sign(ctx, chosen=["build:Iris Snap"], acts=1) + + with pytest.raises(RuntimeError, match="at least one build and one exit"): + asyncio.run(go()) + + +def test_a_stopping_sign_stops_between_acts(): + """`running` going false has to end the sign, not merely the current act.""" + async def go(): + ctx, state, _m = await _ctx() + + async def frame(): + state["frames"] += 1 + if state["frames"] > 40: + ctx.running = False + return True + + ctx._frame = frame + return await A.play_sign(ctx), state["frames"] + + played, frames = asyncio.run(go()) + assert frames < 400, "it stopped promptly rather than finishing its deck" + assert played >= 0 + + +def test_names_that_are_not_on_the_menu_are_ignored_not_fatal(): + """A deck is a preference. One stale entry should not stop a sign.""" + async def go(): + ctx, _s, _m = await _ctx() + return await A.play_sign( + ctx, chosen=["Iris Snap", "no such act", "Pixel Dissolve"], acts=1) + + +def test_a_bare_name_is_the_forgiving_reading(): + """It selects every kind with that name — which is what someone means when they + have not said otherwise, and is why the test above has to be explicit.""" + async def go(): + ctx, _s, _m = await _ctx() + return await A.play_sign(ctx, chosen=["Iris Snap"], acts=1) + + assert asyncio.run(go()) == 1, "one name, used as both the build and the exit" + + assert asyncio.run(go()) == 1 From ac90255421a76d39a4e8dc232122d46b38436765 Mon Sep 17 00:00:00 2001 From: czei <9g0jqglotahgp7741rr0q07p3s2ly1@bots.bitbucket.org> Date: Tue, 1 Sep 2026 15:27:55 -0400 Subject: [PATCH 07/10] Two primitives a drawn character needs and a deck of acts cannot supply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deck animates a mark the same way for every customer. A drawn *character* cannot be animated that way — darkowl's owl flies in carrying the letters, and no act can know there is an owl. Both things it needs were written by hand in darkowl_logo.py and missing from the library. PoseCycler advances a list of tiles as one moving subject, exactly one visible at a time. `_fly_pose` is it with two tiles at period 3; `_big_pose` is it with order (0, 1, 2, 1) at period 2 — which is why the beat order is its own argument and not just len(tiles). MotionAnimator gains `point_to_point`: from_xy, to_xy, a frame count and a named curve, computed with easing.interp so a curve behaves here the way the same curve behaves in every transition. traverse_lr crosses and exits; this one LANDS, and like traverse it does not recenter at detach — snapping a subject home would undo the whole move. It also takes `poses`, so the subject can flap while it flies. MOTION_PATHS is exported because a host that lets something else choose a path — a config file, a request, a model — should validate against the animator's own list rather than keep a copy that drifts. A drift-guard test asserts every name in the tuple moves something and that a name outside it does not. PixelMark.tile is the third piece and the least obvious: the animators take a TileGrid, and a host driving a mark along a path had no way to hand them one without reaching into a private attribute. Refusals rather than defaults throughout — point_to_point with no endpoints raises rather than quietly travelling from (0, 0) to (0, 0), because a move that goes nowhere is the failure mode with nothing to read. test/unit: 1137 -> 1148. lint-errors clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JCUCEbWF2nQj85FXPyem4i --- src/scrollkit/effects/image_animators.py | 172 +++++++++++++++++++-- src/scrollkit/effects/mark.py | 13 ++ test/unit/effects/test_image_animators.py | 178 ++++++++++++++++++++++ test/unit/effects/test_mark.py | 15 ++ 4 files changed, 363 insertions(+), 15 deletions(-) diff --git a/src/scrollkit/effects/image_animators.py b/src/scrollkit/effects/image_animators.py index 21aa265..36f1aba 100644 --- a/src/scrollkit/effects/image_animators.py +++ b/src/scrollkit/effects/image_animators.py @@ -41,6 +41,8 @@ import math import random +from . import easing + def _shuffle(lst): """In-place Fisher-Yates (random.shuffle is absent on CircuitPython).""" @@ -220,55 +222,195 @@ def detach(self): self._points = None +class PoseCycler: + """Advance a list of tiles as ONE moving subject: a flap, a walk, a scurry. + + Exactly one tile is visible at a time and it sits where the caller puts it; every + other tile is hidden. That is the whole idea, and it is what a sign otherwise + writes by hand — darkowl-led-logo's ``_fly_pose`` swaps two wing poses every three + frames, and its ``_big_pose`` runs a four-beat UP -> MID -> DOWN -> MID cel at two. + Both are this class with different arguments. + + Args: + tiles: The pose layers, in order, already added to the display. + period: Frames one beat holds before the next. + order: Which tile each beat shows, as indices into ``tiles``. Defaults to + straight through (``0..n-1``). A cel that goes UP -> MID -> DOWN -> MID + is ``(0, 1, 2, 1)`` — which is why the beat order is its own argument + and not simply ``len(tiles)``. + + Position is held here rather than read back off the tiles, so a caller that moves + one axis per frame (a bob that only writes ``y``) does not have to restate the + other. It is integer-only: a TileGrid takes whole pixels, and rounding at the call + site is how a subject ends up drifting half a pixel per pose swap. + """ + + def __init__(self, tiles, period=3, order=None): + self.tiles = list(tiles) + if not self.tiles: + raise ValueError("poses: no tiles") + self.period = max(1, int(period)) + self.order = tuple(order) if order else tuple(range(len(self.tiles))) + if not self.order or max(self.order) >= len(self.tiles) or min(self.order) < 0: + raise ValueError("poses: order names a tile that is not there") + self.x = 0 + self.y = 0 + self.shown = None + + def pose_at(self, frame): + """Which tile index is on screen at ``frame``.""" + return self.order[(frame // self.period) % len(self.order)] + + def step(self, frame, x=None, y=None): + """Show ``frame``'s pose at ``(x, y)``; hide the rest. Returns the index shown. + + ``x``/``y`` of ``None`` mean "leave that axis where it was". + """ + if x is not None: + self.x = int(x) + if y is not None: + self.y = int(y) + index = self.pose_at(frame) + for i, tile in enumerate(self.tiles): + if i == index: + tile.x = self.x + tile.y = self.y + tile.hidden = False + else: + tile.hidden = True + self.shown = index + return index + + def hide(self): + """Take every pose off screen (idempotent).""" + for tile in self.tiles: + tile.hidden = True + self.shown = None + + +#: Every path :class:`MotionAnimator` understands. +#: +#: Exported as a tuple so a host that lets something else choose a path — a config +#: file, a request, a model — validates the choice against the animator's own list +#: instead of keeping a copy that drifts out of date. A path not in here does not +#: raise; the subject simply stands still, which is why a caller that accepts a name +#: from outside should check it against this first. +MOTION_PATHS = ("traverse_lr", "traverse_rl", "rise", "bob", "jiggle", "point_to_point") + + class MotionAnimator(IntroAnimator): - """Move the whole image tile: traverse across, blast off, bob, or jiggle. + """Move the whole image tile: traverse across, blast off, land, bob, or jiggle. ``traverse_lr``/``traverse_rl`` cross the panel starting and ending fully off-screen; ``rise`` launches upward off the top after ``delay`` frames (with a tiny pre-launch - shudder); ``bob``/``jiggle`` oscillate in place and recenter at detach. Traverse/rise - deliberately do NOT recenter — the subject has left, and the fade shows empty sky. + shudder); ``point_to_point`` travels from one coordinate to another along a named + easing curve and STAYS there; ``bob``/``jiggle`` oscillate in place and recenter at + detach. Traverse, rise and point_to_point deliberately do NOT recenter — the first + two have left the panel, and the third has arrived where it was sent, so snapping it + home at detach would undo the whole move. + + Args: + path: One of :data:`MOTION_PATHS`. + amp: Oscillation amplitude for ``bob``/``jiggle``. + bob_amp: Vertical wobble added to a traverse. + delay: ``rise`` only — frames of pre-launch shudder. + from_xy: ``point_to_point`` only — where the subject starts, ``(x, y)``. + to_xy: ``point_to_point`` only — where it lands. + frames: ``point_to_point`` only — how long the move takes. Sets HOLD_FRAMES. + curve: ``point_to_point`` only — a name from + :data:`scrollkit.effects.easing.CURVES`. + poses: Optional tiles to CYCLE as the subject moves, instead of moving the + one tile the host supplied. See :class:`PoseCycler`. + pose_frames / pose_order: that cycler's ``period`` and ``order``. + + ``point_to_point`` exists because ``traverse_lr`` crosses and exits: it has no + destination, so it cannot put a subject down on a slot. This one lands. """ - def __init__(self, path="bob", amp=2, bob_amp=0, delay=0): + def __init__(self, path="bob", amp=2, bob_amp=0, delay=0, + from_xy=None, to_xy=None, frames=None, curve=easing.LINEAR, + poses=None, pose_frames=3, pose_order=None): self._path = path self._amp = amp self._bob_amp = bob_amp self._delay = delay + self._curve = curve + self._frame = 0 if path in ("traverse_lr", "traverse_rl"): self.HOLD_FRAMES = 104 elif path == "rise": self.HOLD_FRAMES = 84 + elif path == "point_to_point": + if from_xy is None or to_xy is None: + # Refused at construction rather than defaulted to (0, 0): a move with + # no endpoints is not a shorter move, it is a subject that never goes + # anywhere, and a silent no-op is the failure mode with nothing to read. + raise ValueError("point_to_point: needs from_xy and to_xy") + if frames is not None: + self.HOLD_FRAMES = max(1, int(frames)) + self._from = (int(from_xy[0]), int(from_xy[1])) if from_xy else None + self._to = (int(to_xy[0]), int(to_xy[1])) if to_xy else None + self._poses = PoseCycler(poses, period=pose_frames, + order=pose_order) if poses else None + + def start(self, display, tile, bitmap, palette, base_colors): + super().start(display, tile, bitmap, palette, base_colors) + if self._poses is not None: + # All of them are on the display and all of them are visible until someone + # says otherwise; the first step() picks one. Hiding here rather than there + # keeps the whole stack from showing at once for the frames before it. + self._poses.hide() def step(self, frame): - tile = self.tile + self._frame = frame p = self._path + x = y = None if p == "traverse_lr" or p == "traverse_rl": span = self.HOLD_FRAMES - 1 t = frame / span if span else 1.0 if t > 1.0: t = 1.0 x0, x1 = (-66, 66) if p == "traverse_lr" else (66, -66) - tile.x = int(round(x0 + (x1 - x0) * t)) + x = int(round(x0 + (x1 - x0) * t)) if self._bob_amp: - tile.y = int(round(self._bob_amp * math.sin(frame * 0.3))) + y = int(round(self._bob_amp * math.sin(frame * 0.3))) elif p == "rise": if frame < self._delay: - tile.x = 1 if (frame // 3) & 1 else 0 # pre-launch shudder + x = 1 if (frame // 3) & 1 else 0 # pre-launch shudder else: - tile.x = 0 + x = 0 t = (frame - self._delay) / float(max(1, self.HOLD_FRAMES - self._delay)) - tile.y = -int(round(40 * t * t)) # ease-in launch, exits the top + y = -int(round(40 * t * t)) # ease-in launch, exits the top + elif p == "point_to_point": + # Integer easing, straight out of the library's own table — the same + # `interp` the transitions use, so a curve named here behaves the way the + # same curve behaves everywhere else, on the device as well as the desktop. + span = self.HOLD_FRAMES - 1 + progress = 255 if span <= 0 else min(255, (frame * 255) // span) + x = easing.interp(self._curve, self._from[0], self._to[0], progress) + y = easing.interp(self._curve, self._from[1], self._to[1], progress) elif p == "bob": - tile.y = int(round(self._amp * math.sin(frame * 0.25))) + y = int(round(self._amp * math.sin(frame * 0.25))) elif p == "jiggle": - tile.x = int(round(self._amp * math.sin(frame * 0.9))) - tile.y = int(round((self._amp * 0.5) * math.sin(frame * 1.3))) + x = int(round(self._amp * math.sin(frame * 0.9))) + y = int(round((self._amp * 0.5) * math.sin(frame * 1.3))) + self._place(frame, x, y) + + def _place(self, frame, x, y): + """Put the subject at ``(x, y)`` — through the pose cycler when there is one.""" + if self._poses is not None: + self._poses.step(frame, x, y) + return + tile = self.tile + if x is not None: + tile.x = x + if y is not None: + tile.y = y def detach(self): if self._path in ("bob", "jiggle"): # in-place motions settle back to center try: - self.tile.x = 0 - self.tile.y = 0 + self._place(self._frame, 0, 0) except Exception: pass diff --git a/src/scrollkit/effects/mark.py b/src/scrollkit/effects/mark.py index 7804aa2..a4e2db4 100644 --- a/src/scrollkit/effects/mark.py +++ b/src/scrollkit/effects/mark.py @@ -137,6 +137,19 @@ def attach(self, display): self._display = display return self + @property + def tile(self): + """The mark's layer, or ``None`` before :meth:`attach`. + + Read-only, and exposed because a mark is a thing that MOVES: the image + animators — :class:`~scrollkit.effects.image_animators.MotionAnimator` and + :class:`~scrollkit.effects.image_animators.PoseCycler` — take a TileGrid, and a + host driving a mark along a path had no way to hand them one without reaching + into a private attribute. Position it by setting ``tile.x`` / ``tile.y``; the + cells themselves are fixed at :meth:`attach` time. + """ + return self._tile + def detach(self): """Remove the layer (no-op if it was never attached, or already gone).""" if self._display is not None and self._tile is not None: diff --git a/test/unit/effects/test_image_animators.py b/test/unit/effects/test_image_animators.py index a46da2d..75b0f80 100644 --- a/test/unit/effects/test_image_animators.py +++ b/test/unit/effects/test_image_animators.py @@ -15,6 +15,7 @@ pygame = pytest.importorskip("pygame") from scrollkit.display.simulator import SimulatorDisplay +from scrollkit.effects import easing from scrollkit.effects import image_animators as ia BASE = [0x000000, 0x336688, 0xAAEEFF, 0xEE4444, 0x88DD55] @@ -62,6 +63,8 @@ def _attach(d, anim, writable=False): lambda: ia.TwinkleAnimator(count=10), lambda: ia.MotionAnimator(path="bob", amp=2), lambda: ia.MotionAnimator(path="traverse_lr", bob_amp=1), + lambda: ia.MotionAnimator(path="point_to_point", from_xy=(-24, 0), to_xy=(18, 6), + frames=30, curve="ease_out_quad"), lambda: ia.EmitterAnimator(box=(30, 8, 34, 10), vy=-0.5), lambda: ia.PalettePulseAnimator(match=(0xEE4444,), tol=8), lambda: ia.RegionShiftAnimator(box=(26, 3, 37, 7), amp=1, period=12), @@ -552,3 +555,178 @@ def test_feasibility_on_classes_only(): assert "hardware_safe" in cls.FEASIBILITY assert not hasattr(ia.copy_to_writable, "FEASIBILITY") assert not hasattr(ia._shuffle, "FEASIBILITY") + + +# -- pose cycling and point_to_point (LogoBox motion files) --------------------- +# +# Two primitives a *drawn character* needs and a fixed deck of acts cannot supply: a +# flap only means something for the specific thing that was drawn, and `traverse_lr` +# crosses the panel and exits rather than landing on a slot. + + +def _pose_tiles(d, n): + """``n`` tiny distinguishable layers on the display, as a subject's poses.""" + gfx = d.gfx + tiles = [] + for i in range(n): + bmp = gfx.Bitmap(64, 32, 2) + pal = gfx.Palette(2) + pal[1] = 0xFFFFFF + pal.make_transparent(0) + bmp[i, 0] = 1 # a different lit cell per pose + tile = gfx.TileGrid(bmp, pixel_shader=pal) + d.add_layer(tile) + tiles.append(tile) + return tiles + + +@pytest.mark.asyncio +async def test_pose_cycler_shows_one_pose_and_moves_it(): + """darkowl's ``_fly_pose``, generalised: one wing pose on screen, at (x, y).""" + d = await _make_display() + tiles = _pose_tiles(d, 3) + cyc = ia.PoseCycler(tiles, period=3) + + for frame, expect in ((0, 0), (2, 0), (3, 1), (5, 1), (6, 2), (9, 0)): + assert cyc.step(frame, x=frame, y=1) == expect + visible = [i for i, t in enumerate(tiles) if not t.hidden] + assert visible == [expect] # exactly one, never two + assert tiles[expect].x == frame and tiles[expect].y == 1 + + cyc.hide() + assert all(t.hidden for t in tiles) + for t in tiles: + d.remove_layer(t) + + +@pytest.mark.asyncio +async def test_pose_cycler_order_can_revisit_a_tile(): + """A cel that goes UP -> MID -> DOWN -> MID is an order, not a tile count.""" + d = await _make_display() + tiles = _pose_tiles(d, 3) + cyc = ia.PoseCycler(tiles, period=2, order=(0, 1, 2, 1)) + assert [cyc.pose_at(f) for f in range(0, 8)] == [0, 0, 1, 1, 2, 2, 1, 1] + assert cyc.pose_at(8) == 0 # and round again + for t in tiles: + d.remove_layer(t) + + +@pytest.mark.asyncio +async def test_pose_cycler_holds_the_axis_it_was_not_given(): + """A bob writes only ``y``; the subject must not snap back to x=0 for it.""" + d = await _make_display() + tiles = _pose_tiles(d, 2) + cyc = ia.PoseCycler(tiles, period=1) + cyc.step(0, x=12, y=5) + cyc.step(1, y=6) # x omitted + assert (tiles[1].x, tiles[1].y) == (12, 6) + for t in tiles: + d.remove_layer(t) + + +@pytest.mark.asyncio +async def test_pose_cycler_refuses_an_order_naming_a_tile_that_is_not_there(): + d = await _make_display() + tiles = _pose_tiles(d, 2) + with pytest.raises(ValueError): + ia.PoseCycler(tiles, order=(0, 1, 2)) + with pytest.raises(ValueError): + ia.PoseCycler([]) + for t in tiles: + d.remove_layer(t) + + +@pytest.mark.asyncio +async def test_point_to_point_lands_on_its_target_and_stays(): + """The whole reason it exists: ``traverse_lr`` exits, this one arrives.""" + d = await _make_display() + anim = ia.MotionAnimator(path="point_to_point", from_xy=(66, 4), to_xy=(20, 9), + frames=26, curve="ease_out_quad") + tile, bmp, pal = _attach(d, anim) + anim.step(0) + assert (tile.x, tile.y) == (66, 4) + anim.step(25) # frames - 1 + assert (tile.x, tile.y) == (20, 9) + anim.step(60) # past the end: clamped, not past + assert (tile.x, tile.y) == (20, 9) + anim.detach() + assert (tile.x, tile.y) == (20, 9) # landed subjects do NOT recenter + d.remove_layer(tile) + + +@pytest.mark.asyncio +async def test_point_to_point_uses_the_library_easing_table(): + """Not its own float math — the same ``interp`` every transition already reads.""" + d = await _make_display() + anim = ia.MotionAnimator(path="point_to_point", from_xy=(0, 0), to_xy=(40, 20), + frames=21, curve=easing.EASE_IN_OUT) + tile, bmp, pal = _attach(d, anim) + for frame in (3, 7, 12, 18): + anim.step(frame) + progress = (frame * 255) // 20 + assert tile.x == easing.interp(easing.EASE_IN_OUT, 0, 40, progress) + assert tile.y == easing.interp(easing.EASE_IN_OUT, 0, 20, progress) + d.remove_layer(tile) + + +def test_point_to_point_refuses_a_move_with_no_endpoints(): + """A silent no-op is the failure mode with nothing to read; this one says so.""" + with pytest.raises(ValueError): + ia.MotionAnimator(path="point_to_point") + with pytest.raises(ValueError): + ia.MotionAnimator(path="point_to_point", from_xy=(0, 0)) + + +@pytest.mark.asyncio +async def test_motion_cycles_poses_while_it_travels(): + """Both primitives together: the subject flaps AS it flies to its slot.""" + d = await _make_display() + poses = _pose_tiles(d, 3) + anim = ia.MotionAnimator(path="point_to_point", from_xy=(60, 2), to_xy=(10, 12), + frames=24, curve="linear", poses=poses, pose_frames=2) + tile, bmp, pal = _attach(d, anim) + assert all(t.hidden for t in poses) # nothing shown before the first step + + seen = [] + xs = [] + for frame in range(0, 24): + anim.step(frame) + visible = [i for i, t in enumerate(poses) if not t.hidden] + assert len(visible) == 1 # exactly one pose, every frame + seen.append(visible[0]) + xs.append(poses[visible[0]].x) + assert len(set(seen)) == 3 # it cycled all three + assert xs[0] == 60 and xs[-1] < xs[0] # and travelled while doing it + assert tile.x == 0 # the host's own tile never moved + + anim.detach() + for t in poses: + d.remove_layer(t) + d.remove_layer(tile) + + +@pytest.mark.asyncio +async def test_motion_paths_tuple_matches_what_step_implements(): + """The drift guard. A host validates a path name against this tuple, so a name in + it that moves nothing — or a branch missing from it — is a lie to that host.""" + d = await _make_display() + assert "point_to_point" in ia.MOTION_PATHS + for name in ia.MOTION_PATHS: + kwargs = {"from_xy": (-30, -4), "to_xy": (12, 8), "frames": 20} \ + if name == "point_to_point" else {} + anim = ia.MotionAnimator(path=name, amp=3, bob_amp=2, delay=2, **kwargs) + tile, bmp, pal = _attach(d, anim) + moved = set() + for frame in range(0, anim.HOLD_FRAMES, 3): + anim.step(frame) + moved.add((tile.x, tile.y)) + assert len(moved) > 1, "%s moves nothing" % name + anim.detach() + d.remove_layer(tile) + + unknown = ia.MotionAnimator(path="sashay") + tile, bmp, pal = _attach(d, unknown) + for frame in range(0, 40, 3): + unknown.step(frame) + assert (tile.x, tile.y) == (0, 0) # not in the tuple, does nothing + d.remove_layer(tile) diff --git a/test/unit/effects/test_mark.py b/test/unit/effects/test_mark.py index 4606754..5c075d2 100644 --- a/test/unit/effects/test_mark.py +++ b/test/unit/effects/test_mark.py @@ -199,3 +199,18 @@ async def frame(): assert ok is True assert hidden is False assert len(lit) == 12 + + +@pytest.mark.asyncio +async def test_a_mark_exposes_its_layer_so_an_animator_can_move_it(): + """A mark is a thing that MOVES. The image animators take a TileGrid, and without + this a host driving a mark along a path had to reach into a private attribute.""" + d = await _display() + mark = PixelMark.from_art(("##", "##"), {"#": 0xFF8800}, x=3, y=4) + assert mark.tile is None # nothing to move before it is attached + mark.attach(d) + assert mark.tile is not None + mark.tile.x, mark.tile.y = 7, 2 # what an animator does every frame + assert (mark.tile.x, mark.tile.y) == (7, 2) + mark.detach() + assert mark.tile is None From 3066d63e8049694a0037eac53532af1952040499 Mon Sep 17 00:00:00 2001 From: czei <9g0jqglotahgp7741rr0q07p3s2ly1@bots.bitbucket.org> Date: Wed, 2 Sep 2026 10:45:29 -0400 Subject: [PATCH 08/10] A mark that fits the panel it was attached to `attach()` dropped an off-panel cell from the BITMAP and kept it in `slots`, so the mark went on describing pixels it never drew. Six of the seven acts survived that. `treatment_dwell` did not: it hands `ctx.slots` straight to a panel-sized PalettePartition, which raised IndexError from inside the paint loop. So a wordmark one column too wide took the sign down on its first dwell, which is the exact failure the drop was there to prevent. `from_text` overflows at about eleven characters and `play_sign` always draws a dwell, so "BLUE RIDGE COFFEE" is enough to hit it, and that string is the whole point of `from_text`: audition a deck against a real wordmark before drawing any art. The narrowing belongs in `attach()` because that is where the panel's bounds are first known. Shape is preserved on purpose: a mapping stays a mapping so per-pixel indices survive, an iterable stays a plain list of cells. Only the cells that reached the bitmap remain, which also means the builds stop animating 94 pixels of a 243-pixel wordmark that were never going to appear. test/unit: three cases, one per way the promise was broken -- the mapping, the bare iterable, and the regression itself (a text mark wider than the panel now completes a dwell). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WzkFnmiBQrxzPEpT3SNyZh --- src/scrollkit/effects/mark.py | 17 ++++++++++++++ test/unit/effects/test_mark.py | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/scrollkit/effects/mark.py b/src/scrollkit/effects/mark.py index a4e2db4..18c9890 100644 --- a/src/scrollkit/effects/mark.py +++ b/src/scrollkit/effects/mark.py @@ -101,6 +101,10 @@ def attach(self, display): Hidden rather than visible, because every act's first move is to clear the panel: attaching visible would flash the finished mark for one frame before the act that assembles it begins. + + This is also where the panel's bounds are first known, so it is where + ``slots`` is narrowed to the cells that actually fit. Art is authored by hand, + and a wordmark placed one column too far right should not take the sign down. """ gfx = display.gfx indexed = hasattr(self.slots, "items") @@ -116,6 +120,14 @@ def attach(self, display): else: palette[1] = _dim(display, self.color) + # **The cells that made it onto the panel BECOME the mark's slots.** Dropping + # an off-panel cell from the bitmap and keeping it here would leave the mark + # describing pixels it never drew, and ``treatment_dwell`` hands ``ctx.slots`` + # straight to a panel-sized PalettePartition — so a wordmark one column too + # wide raised IndexError on its first dwell, which is the exact failure the + # drop was there to prevent. The shape is preserved: a mapping stays a mapping + # so per-pixel indices survive, an iterable stays a plain list of cells. + kept = {} if indexed else [] for cell in self.slots: x, y = cell if not (0 <= x < display.width and 0 <= y < display.height): @@ -129,6 +141,11 @@ def attach(self, display): bitmap[x, y] = slot else: bitmap[x, y] = 1 + if indexed: + kept[cell] = self.slots[cell] + else: + kept.append(cell) + self.slots = kept self._bitmap = bitmap self._tile = gfx.TileGrid(bitmap, pixel_shader=palette) diff --git a/test/unit/effects/test_mark.py b/test/unit/effects/test_mark.py index 5c075d2..67881b9 100644 --- a/test/unit/effects/test_mark.py +++ b/test/unit/effects/test_mark.py @@ -214,3 +214,45 @@ async def test_a_mark_exposes_its_layer_so_an_animator_can_move_it(): assert (mark.tile.x, mark.tile.y) == (7, 2) mark.detach() assert mark.tile is None + + +# -- off-panel cells leave the mark entirely --------------------------------- +# +# attach() always dropped an off-panel cell from the BITMAP, but kept it in `slots`, +# so the mark went on describing pixels it never drew. Six of the seven acts survived +# that; `treatment_dwell` did not, because it hands `ctx.slots` straight to a +# panel-sized PalettePartition. A wordmark one column too wide therefore raised +# IndexError on its first dwell -- the exact failure the drop was there to prevent. + + +@pytest.mark.asyncio +async def test_attach_drops_off_panel_cells_from_the_slots_too(): + d = await _display() + cells = {(60, 5): 1, (63, 5): 1, (64, 5): 1, (99, 5): 1, (10, 40): 1, (-2, 5): 1} + mark = PixelMark(cells, colors=RAMP).attach(d) + assert set(mark.slots) == {(60, 5), (63, 5)} + assert mark.slots[(60, 5)] == 1, "a mapping stays a mapping, indices intact" + + +@pytest.mark.asyncio +async def test_a_bare_iterable_of_cells_keeps_its_shape(): + d = await _display() + mark = PixelMark([(1, 1), (2, 2), (70, 2)]).attach(d) + assert list(mark.slots) == [(1, 1), (2, 2)] + assert not hasattr(mark.slots, "items"), "an iterable must not become a mapping" + + +@pytest.mark.asyncio +async def test_a_wordmark_wider_than_the_panel_still_dwells(): + """The regression: this raised IndexError from inside PalettePartition.""" + from scrollkit.effects.acts import treatment_dwell + + d = await _display() + mark = PixelMark.from_text(d, "BLUE RIDGE COFFEE", y=12).attach(d) + assert max(x for (x, _y) in mark.slots) < d.width + + async def frame(): + await d.show() + return True + + assert await treatment_dwell(mark.context(d, frame=frame)) is True From 80596f7ef49acae314f98e54272a1def4c0bc826 Mon Sep 17 00:00:00 2001 From: czei <9g0jqglotahgp7741rr0q07p3s2ly1@bots.bitbucket.org> Date: Wed, 2 Sep 2026 10:45:40 -0400 Subject: [PATCH 09/10] Bound a self-driving app at the frame count it was given `frames` bounded one of the two program shapes and silently ignored the other. An app whose `setup()` returns is driven by the harness's own loop, which counts to `frames`. An app that never returns from `setup()`, the `while self.running` shape a generated sign uses, never reached that loop at all: `run_headless(app, frames=20)` rendered until something killed the process. The cap counts at `display.show()`, the one call both shapes make exactly once per frame, and raises through it at the limit. Three details are load-bearing: - **The wrapper goes on the display INSTANCE and comes off again.** Wrapping `UnifiedDisplay.show` on the class leaks into every later app in the same process and counts each frame once per wrap. - **The frame signature is read before the raise**, because teardown runs as the exception unwinds and drops the display, the active PerformanceManager and every recorded frame. Read it afterwards and there is nothing left to read. Only the two ends are hashed: `advanced` compares first to last, and hashing every frame in between costs enough wall time to make an unpaced run slower than the hardware it is meant to be outrunning. - **BaseException, not Exception**, since it has to unwind out of arbitrary app code and out of this harness, and both catch `Exception` broadly. It never escapes `run_headless_async`. A self-driving app never goes through `step_frame()`, so its own counter stays at zero however much it painted; the RunResult reports what the cap counted instead. The queue shape returns from `setup()` long before the cap can fire, so its behaviour is unchanged, and a test pins that. The price of stopping a loop that never planned to stop: the unwind goes THROUGH `show()`, so whatever the app's loop does after showing its last frame does not run for that frame. A test pins that too, because it is a real difference and not a rounding error. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WzkFnmiBQrxzPEpT3SNyZh --- src/scrollkit/dev/harness.py | 129 ++++++++++++++++++++++++++++++++-- test/unit/dev/test_harness.py | 65 +++++++++++++++++ 2 files changed, 188 insertions(+), 6 deletions(-) diff --git a/src/scrollkit/dev/harness.py b/src/scrollkit/dev/harness.py index 4c1fae0..bb25c03 100644 --- a/src/scrollkit/dev/harness.py +++ b/src/scrollkit/dev/harness.py @@ -13,6 +13,13 @@ (reproducible compare across edits) and the run finishes fast. ``seconds=S`` is sugar for ``frames = round(S * 20)`` (the display loop targets 20 FPS). +``frames`` bounds both program shapes. An app whose ``setup()`` returns is driven +by the harness's own loop; an app that never returns from ``setup()`` — the +self-driving ``while self.running`` shape — is stopped at the frame boundary once +it has shown that many frames. Either way ``run_headless`` returns. Stopping the +self-driving shape unwinds *through* ``display.show()``, so whatever that app's +loop does after showing its last frame does not run for that frame. + Desktop-only — imported via ``scrollkit.dev``, which raises on CircuitPython. """ @@ -25,6 +32,94 @@ DEFAULT_FRAMES = 120 +class _FrameCapReached(BaseException): + """Stop a self-driving app at its frame cap, from outside. + + ``BaseException`` and not ``Exception`` on purpose: it has to unwind out of + arbitrary app code and out of this harness, and both catch ``Exception`` + broadly. It never escapes :func:`run_headless_async` — it is caught at the + ``setup()`` boundary, while ``app.display`` is still live. + """ + + +class _FrameCap: + """Bound a run at ``frames`` displayed frames, whichever shape the app is. + + Counts at ``display.show()`` — the one call both program shapes make exactly + once per frame — and raises :class:`_FrameCapReached` through it at the cap. + + Two details worth keeping. The wrapper goes on the display *instance* and is + removed on exit; wrapping ``UnifiedDisplay.show`` on the class (as the + LogoBox spike did) leaks into every later app in the same process and counts + each frame once per wrap. And the frame signature is read *before* the raise, + because teardown runs as the exception unwinds and drops the display, the + active ``PerformanceManager`` and every recorded frame — read it afterwards + and there is nothing left to read. + """ + + _MISSING = object() + + def __init__(self, app, frames): + self.app = app + self.frames = frames + self.count = 0 + self.first_sig = None + self.last_sig = None + self._display = None + self._prev = self._MISSING + + def __enter__(self): + display = self.app.display + if display is None or not hasattr(display, "show"): + return self + self._display = display + try: + self._prev = vars(display).get("show", self._MISSING) + except TypeError: # no __dict__ (slots); restore by assignment + self._prev = display.show + original = display.show + + async def show(*a, **kw): + result = await original(*a, **kw) + self.count += 1 + at_cap = self.count >= self.frames + # Only the two ends get a signature: `advanced` compares first to + # last, and hashing the panel on every frame in between costs real + # wall time -- enough of it to make an unpaced run measurably slower + # than the hardware it is supposed to be outrunning. + # + # The one at the cap is read HERE, before the raise, because teardown + # runs as the exception unwinds and drops the display, the active + # PerformanceManager and every recorded frame. + if self.count == 1 or at_cap: + sig = None + if self.app.display is not None: + buf = _metrics.buffer_from_display(self.app.display) + sig = _metrics.signature(buf) if buf is not None else None + if sig is not None: + if self.first_sig is None: + self.first_sig = sig + self.last_sig = sig + if at_cap: + raise _FrameCapReached() + return result + + display.show = show + return self + + def __exit__(self, exc_type, exc, tb): + if self._display is None: + return False + if self._prev is self._MISSING: + try: + del self._display.show + except AttributeError: + pass + else: + self._display.show = self._prev + return False + + class RunResult: """JSON-able summary of a headless run (what the AI inspects).""" @@ -277,10 +372,26 @@ async def run_headless_async(app, frames=None, seconds=None, screenshot=None, app.running = True app._run_start = time.monotonic() if hasattr(time, "monotonic") else None - try: - await app.setup() - except Exception as e: # surface, don't crash the harness - errors.append("setup() failed: %r" % (e,)) + # `frames` has to bound BOTH program shapes, and for a long time it + # bounded only one. The queue shape returns from setup() and is driven by + # the loop below, which counts to `frames`. The self-driving shape never + # returns from setup() — it owns a `while self.running` loop — so the loop + # below was never reached and `frames` was silently ignored: + # run_headless(app, frames=20) rendered until something killed the + # process. Cap it at the frame boundary instead; the queue shape returns + # long before the cap can fire, so its behaviour is unchanged. + cap = _FrameCap(app, frames) + self_driven = False + with cap: + try: + await app.setup() + except _FrameCapReached: + self_driven = True # never returned; it rendered its full quota + except Exception as e: # surface, don't crash the harness + errors.append("setup() failed: %r" % (e,)) + if cap.first_sig is not None: + first_sig = cap.first_sig + last_sig = cap.last_sig if warmup_data: try: @@ -294,7 +405,10 @@ async def run_headless_async(app, frames=None, seconds=None, screenshot=None, # periodic memory report. Never a copy of the loop: the strict gate # must exercise exactly the code path that ships, transitions included. app._reset_frame_state() - for i in range(frames): + # A self-driving app already rendered all `frames` of them inside setup(); + # stepping it again here would render twice what was asked for. + remaining = 0 if self_driven else max(0, frames - cap.count) + for i in range(remaining): try: closed = await app.step_frame() is False if closed: @@ -383,7 +497,10 @@ async def run_headless_async(app, frames=None, seconds=None, screenshot=None, memory = None return RunResult( - frames=app._frame_count, + # A self-driving app never goes through step_frame(), so the app's own + # counter stayed at zero however much it painted; the cap counted the + # frames it actually showed. + frames=cap.count if self_driven else app._frame_count, estimated_hardware_fps=(hw_dict or {}).get("estimated_hardware_fps"), bright_pixels=snap["bright_pixels"], lit_pixels=snap["lit_pixels"], diff --git a/test/unit/dev/test_harness.py b/test/unit/dev/test_harness.py index c510384..bfed457 100644 --- a/test/unit/dev/test_harness.py +++ b/test/unit/dev/test_harness.py @@ -334,3 +334,68 @@ def test_non_strict_run_only_warns_on_heavy_effect(): result = run_headless(_StrictHeavyApp(), frames=30, strict=False) assert result.ok is True assert not any("feasibility" in e.lower() for e in result.errors) + + +# -- the frame cap: `frames` bounds the self-driving shape too ---------------- +# +# An app whose setup() returns is driven by the harness's own loop, which counts +# to `frames`. An app that never returns from setup() -- the `while self.running` +# shape a generated sign uses -- was not bounded by anything: run_headless(frames=20) +# rendered until the process was killed. These pin that it now stops. + +class _SelfDrivingApp(_ScrollApp): + """Owns its loop and never returns from setup(), like a generated sign.""" + + def __init__(self): + super().__init__() + self.shown = 0 + + async def setup(self): + while self.running: + await self.display.clear() + await self.display.set_pixel(self.shown % 64, 4, 0x00FF00) + await self.display.show() + self.shown += 1 + + +def test_frames_bounds_a_self_driving_app(): + # The regression: this call used to never return. + result = run_headless(_SelfDrivingApp(), frames=20, hardware=False) + assert result.frames == 20 + + +def test_a_self_driving_app_stops_at_the_cap_it_was_given(): + app = _SelfDrivingApp() + result = run_headless(app, frames=15, hardware=False) + # Fifteen frames reached the panel, and the app was stopped *inside* the + # fifteenth show() rather than after it: the stop unwinds through the frame + # boundary, so whatever the loop body does after show() -- here `shown += 1` + # -- does not run for that last frame. That is the price of stopping a loop + # that never planned to stop, and it is why the cap counts at the display + # rather than trusting a counter the app keeps. + assert result.frames == 15 + assert app.shown == 14 + + +def test_a_self_driving_app_still_reports_pixels_and_motion(): + # The signature has to be read before the cap raises: teardown unwinds with + # the exception and drops the display, so a metric taken after finds nothing. + result = run_headless(_SelfDrivingApp(), frames=20, hardware=False) + assert result.advanced is True + assert result.is_blank is False + assert result.errors == [] + + +def test_the_cap_does_not_outlive_the_run(): + # The wrapper goes on the instance and comes off again; the class-level + # monkeypatch it replaces leaked into every later app in the process. + app = _SelfDrivingApp() + run_headless(app, frames=10, hardware=False) + assert "show" not in vars(app.display) + + +def test_the_queue_shape_is_unchanged_by_the_cap(): + # The cap must not fire for an app that returns from setup() normally. + result = run_headless(_ScrollApp(), frames=40, hardware=False) + assert result.frames == 40 + assert result.errors == [] From 7eb4575d0a718f89dd1444f119f0053a93806a88 Mon Sep 17 00:00:00 2001 From: czei <9g0jqglotahgp7741rr0q07p3s2ly1@bots.bitbucket.org> Date: Wed, 2 Sep 2026 10:45:57 -0400 Subject: [PATCH 10/10] Document marks and acts, and unteach the pages they made stale Seven commits added 1,933 lines and twenty-one public names, and touched no documentation at all. This is that documentation. `docs/guide/acts.md` is the new chapter: PixelMark's two constructors and the four behaviours that are decisions rather than defaults, the seven acts, the duck-typed context, `selectable()`, `play_sign()`, and a section on what is refused and why. That last one matters most. Drop from Sky, the two map_route treatments, and a treatment wanting arguments the module cannot invent are all absent on purpose, and a reader who does not know that reads a shorter menu as a bug. Three pages were not merely silent but actively teaching the hand-rolled version of code now in the library: - **character-animation.md** taught `_fly_pose` and `_big_pose` as methods you write. They are `PoseCycler` with different arguments, which is what the commit adding it says in as many words. - **AGENTS.md** said "Convert to a Bitmap ONCE (this is the only per-pixel loop you may write)" over the loop that `PixelMark.from_art` now is, listed the reveals under "never write your own" without the acts that exist so an app need not, and hand-wrote an ACTS tuple beside `ActScheduler` where `play_sign` is the whole loop already written. - **palette-treatments.md** left the reader to guess `map_anchor_distance` from the nickname "anchor", which is the exact run a generating agent burned. `AGENTS.md`'s catalogue key list had also gone stale: it omitted `composition`, which is the one an agent building a sign should read first, plus five others. CLAUDE.md now describes acts as a SECOND contract, deliberately distinct from Transition and not a merger of it, so a later session does not read the BUILDS/DWELLS/EXITS dispatch as the plugin architecture that section forbids. `effects/__init__.py`'s import map gains `acts` and `mark`; with no eager imports it is the only discovery path there is. Verified rather than asserted. Every code sample and every count in the new chapter was executed against the live library, and the repo's existing `test_documented_imports` gate picked up 14 new cases from these pages and passes them. Two figures from earlier commit messages did not reproduce and are not repeated here: `capabilities()["composition"]` is 1,762 characters of JSON rather than 2,087, and the darkowl frame counts cannot be checked from this repo, so the changelog cites what the tests actually assert. make test-unit: 1165 passed. lint-errors clean. mkdocs build --strict clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WzkFnmiBQrxzPEpT3SNyZh --- AGENTS.md | 65 ++++++- CHANGELOG.md | 112 +++++++++++ CLAUDE.md | 18 ++ docs/guide/acts.md | 302 ++++++++++++++++++++++++++++++ docs/guide/character-animation.md | 59 ++++-- docs/guide/effects.md | 42 ++++- docs/guide/palette-treatments.md | 25 +++ docs/reference.md | 5 +- mkdocs.yml | 1 + src/scrollkit/effects/__init__.py | 7 + 10 files changed, 612 insertions(+), 24 deletions(-) create mode 100644 docs/guide/acts.md diff --git a/AGENTS.md b/AGENTS.md index 882fd0d..9f43cb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,15 @@ LOGO = normalize_art(LOGO, CHAR_TO_SLOT, name="LOGO") GLYPHS = normalize_all(GLYPHS, CHAR_TO_SLOT) # a whole {name: rows} map # 2. Convert to a Bitmap ONCE (this is the only per-pixel loop you may write). +# OR DON'T WRITE IT AT ALL: PixelMark.from_art does exactly this, takes +# {character: 0xRRGGBB} instead of slot numbers, drops off-panel cells rather +# than crashing, and gives you a mark the acts can build and exit. Prefer it +# unless you need the raw tile. See docs/guide/acts.md. +from scrollkit.effects.mark import PixelMark +mark = PixelMark.from_art(LOGO, {"#": 0xFFB020, "o": 0xE86010}, x=10, y=8) +mark.attach(display) # hidden; mark.show() / mark.hide() / mark.tile + +# The long way, when you want the Bitmap yourself: gfx = display.gfx # displayio on device, sim on desktop bmp = gfx.Bitmap(max(len(r) for r in LOGO), len(LOGO), 3) # longest row, not row 0 for y, row in enumerate(LOGO): @@ -211,6 +220,28 @@ if d.step(): d.detach() # step() per frame, True when assembled # also: SwarmReveal, show_reveal_splash, and the 13 named transitions ``` +**Better: use the acts, which are those reveals already wired to a mark.** An act +takes a duck-typed context and knows nothing else, so it works on YOUR mark without +being copied into your app. Seven functions cover 39 selections, because every +transition is both a build and an exit and every driveable treatment is a dwell: + +```python +from scrollkit.effects.acts import selectable, play_sign, act_factory +from scrollkit.effects.mark import PixelMark + +mark = PixelMark.from_art(LOGO, CHARS, x=10, y=8).attach(display) +await play_sign(mark.context(display)) # build -> dwell -> exit, forever, no repeats + +# or drive one at a time +ctx = mark.context(display) +await act_factory("drip")(ctx, direction="bottom") +selectable() # (name, kind, family, act, options): 15 builds, 11 dwells, 13 exits +``` + +An app that already owns its wordmark passes **itself** as the context: the protocol +is seven names (`slots`, `display`, `running`, `frame()`, `show()`, `hide()`, plus an +optional `colors`), and there is nothing to inherit. Full guide: `docs/guide/acts.md`. + !!! warning "A hand-rolled reveal is how a feasible sign becomes an infeasible one" Writing your own build/exit animation means touching pixels per frame — the one thing the cost model says never to do — during the busiest moment of the @@ -234,6 +265,21 @@ sched = ActScheduler() name, family, run = sched.pick(ACTS, "acts", avoid={self._last_family}) ``` +**`play_sign` is that whole loop, already written.** It draws a build, a dwell and +an exit from `selectable()` through an `ActScheduler` and repeats until +`ctx.running` goes false. Hand-write the deck above only for acts that are genuinely +yours (the anvil, the laser); for everything the library already has, pass a +`chosen=` list and let it schedule: + +```python +await play_sign(ctx, chosen=["swarm", "HaloPulse", "VelvetSweep", "exit:CRT Collapse"]) +``` + +Entries are `"kind:name"` because **the same name is often two different choices**: +every transition is both a build and an exit, so a bare `"Pixel Dissolve"` selects it +in both decks. A deck with no exit is refused outright, since a sign that ended +mid-build leaves the panel in a state no act chose. + Give the sign **one act per thing the organization actually does** — the subject sprite changes, the wordmark stays. That is what makes it a sign for *them*. @@ -259,6 +305,10 @@ library reveal for the build/exit, a `PalettePartition` + treatment on at least one act, and an `ActScheduler` over at least three family-tagged acts. A sign missing the middle two runs fine and looks like a screensaver. +The cheapest way to get all four is `PixelMark` + `play_sign`, which is the library +reveal, the treatment and the scheduler in one call; then add your own acts beside +the ones it schedules. See `docs/guide/acts.md`. + Determinism, because signs are compared frame-for-frame between sim and device: count frames, never read a clock; never derive an *order* from iterating a `set` or `dict` (sort first); never allocate inside a frame. @@ -407,12 +457,21 @@ exceptions, and the hardware stutter/RAM warnings. Treat `errors` as blockers an ```python from scrollkit.dev import capabilities, as_text cat = capabilities() # JSON-able dict, introspected from live code -# cat["content_types"], cat["priorities"], cat["effects"], -# cat["transitions"], cat["scrolling"], cat["palette_effects"], -# cat["image_animators"], cat["named_colors"], cat["display_api"], cat["hardware"] +# cat["panel"], cat["verification"], cat["content_types"], cat["priorities"], +# cat["effects"], cat["transitions"], cat["scrolling"], cat["palette_effects"], +# cat["palette_treatments"], cat["composition"], cat["image_animators"], +# cat["text_fills"], cat["color_utilities"], cat["named_colors"], +# cat["display_api"], cat["hardware"], cat["sensors"], cat["performance"] print(as_text(cat)) # compact human/agent-readable summary ``` +**`cat["composition"]` is the one to read first if you are building a sign.** Every +other key names *effects*; this one names the machinery that varies them: the +`slots → map → PalettePartition → treatment` recipe, all ten partition builders with +their live signatures, `ActScheduler`, and the transition and treatment lookups. A +treatment cannot run without a partition, so a catalogue listing thirteen treatments +and no builders documents thirteen effects you cannot actually build. + Prefer `capabilities()` over guessing class/parameter names — it reflects the installed library exactly (and can't drift from prose docs). diff --git a/CHANGELOG.md b/CHANGELOG.md index 15b0e1e..27b58f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,118 @@ All notable changes to ScrollKit are recorded here. This project loosely follows ## [Unreleased] +Marks and acts: the half of a sign that could not previously be reused. + +### Added +- **`scrollkit.effects.acts`: build → dwell → exit over any mark.** The palette + treatments were already portable dwells, because a treatment takes a + `PalettePartition` and nothing else, which is why twelve of a reference sign's + fifteen dwells are one-liners. Builds and exits were not: they got written inside + the app that owned the mark and reached into its tiles, its layout and its palette, + so reusing one meant copying it. An act is now handed a duck-typed context and + knows nothing else (`ctx.slots`, `.colors`, `.display`, `.running`, `await + .frame()`, `.show()`, `.hide()`), so an app passes *itself* and inherits nothing. + `swarm_build`, `swarm_unbuild`, `drip_in`, `wink_in`, `reveal_via`, `hide_via` and + `treatment_dwell`, with `act_factory` / `supported_acts` mirroring the transition + registry. The tests check LIT PIXELS after each act rather than the return value, + because returning `True` over a black panel is the exact failure this prevents, and + they cover the early exits too: a stopping sign, a dead surface, and an exit that + must leave nothing visible. The port also found that the two original acts differed + by accident rather than design (one checked `running` and one did not; one bounded + at 2,000 steps and the other at 2,500, and the one that ignored `running` would + keep a stopping sign on screen for another two thousand frames). Both now share one + driver. +- **Seven act functions, 39 selections.** Twenty-four of a reference sign's + thirty-seven acts are not bespoke code at all: they are a transition or a palette + treatment applied to the mark, chosen by name. So `reveal_via` and `hide_via` each + wrap 12 transitions and `treatment_dwell` wraps 11 treatments, and `selectable()` + returns the menu one entry per *choice* rather than per function: 15 builds, 11 + dwells, 13 exits. Each entry carries a visual `family`, and a treatment's family is + its **partition**, because two treatments animating the same grouping of pixels + genuinely do look alike. Eleven treatments, nine families. +- **`play_sign()`: a whole sign, not a written sequence.** Draws build, dwell and + exit from an `ActScheduler` and repeats until told to stop, leading with the + least-recently-seen entry and never playing two of a family back to back, which is + why a panel running it for a week does not visibly loop. A choice is a **kind and a + name** (`"exit:Pixel Dissolve"`), because every transition is both a build and an + exit and someone who kept a transition to end on did not thereby ask for it to open + with; a bare name still selects every kind, which is the forgiving reading when + nobody has said otherwise. A deck with no exit is refused rather than played half, + since a sign that ended mid-build leaves the panel in a state no act chose. +- **`scrollkit.effects.mark.PixelMark`: the mark an act reveals.** Every build ends + by calling `ctx.show()` to hand the real thing back and drop its overlay, and that + step assumes something real is underneath. An app that owns its wordmark has it; + anything else had nothing to borrow, so the drops landed, the overlay detached, and + the panel went black while the act cheerfully returned `True`. `PixelMark` is the + minimal version: lit cells and colours in, one bitmap, one palette and one tile + out. `from_text()` builds one from the display's own font, so a deck of acts can be + assembled and judged before any art is drawn; `from_art()` takes rows of characters + plus `{character: colour}`, the format pixel art is already authored in, so a + drawing goes on the panel without being converted into anything first. An unmapped + character is a hole rather than a guess, which is how `.` and space become + background without being special-cased. +- **`PixelMark.tile`.** The image animators take a `TileGrid`, and a host driving a + mark along a path had no way to hand them one without reaching into a private + attribute. +- **`PoseCycler`** (`effects/image_animators`) advances a list of tiles as one + moving subject, exactly one visible at a time. This is what a sign otherwise writes + by hand: darkowl's `_fly_pose` is two tiles at period 3, and its `_big_pose` is the + four-beat `UP → MID → DOWN → MID` cel at period 2, which is why the beat `order` is + its own argument and not just `len(tiles)`. +- **`MotionAnimator(path="point_to_point", ...)` and `MOTION_PATHS`.** `traverse_lr` + crosses the panel and exits, so it has no destination and cannot put a subject down + on a slot. This one **lands**: `from_xy`, `to_xy`, a frame count and a named curve, + computed with `easing.interp` so a curve behaves here the way it behaves in every + transition. Like traverse it does not recenter at detach, because snapping a + subject home would undo the whole move, and it takes `poses` so the subject can flap + while it flies. `MOTION_PATHS` is exported so a host letting something else choose a + path validates against the animator's own list rather than a copy that drifts; + constructing `point_to_point` without endpoints raises rather than quietly + travelling from `(0, 0)` to `(0, 0)`. +- **`PARTITION_BUILDERS` and `builder_for()`** (`effects/palette_partition`) are the + inverse of `treatments_for()`. Every treatment advertises the partition it wants as + a nickname (`HaloPulse.PARTITION` is `"radial"`) and nothing resolved a nickname to + a callable, so the catalogue emitted the bare string and a reader had to guess + `map_radial`. Twelve of the thirteen are `map_` plus the nickname, which is worse + than no convention: regular enough to be trusted and then guessed, and the one that + breaks it is `"anchor"`, whose builder is `map_anchor_distance`. A code-generating + agent spent an entire run guessing at exactly that name. +- **`capabilities()["composition"]`**, a new category for the combinators. The + catalogue named every effect and not one of them, and a treatment cannot run + without a partition, so it documented thirteen effects that could not be built from + it. Now carries the `slots → map → PalettePartition → treatment` recipe, all ten + builders with live signatures, `ActScheduler`, and the transition and treatment + lookups, in under 1.8 KB of JSON: less than one panel image. Every treatment + entry also gains `partition_call`, + rendered from the live signature so it cannot drift: + `map_anchor_distance(pixel_slots, anchor_x, n=10)`. +- **A "Marks & Acts" guide** (`docs/guide/acts.md`), plus `PoseCycler` and + `point_to_point` in the character-animation and effects guides and the + nickname-to-builder resolution in the palette-treatments guide. + +### Fixed +- **`run_headless(app, frames=N)` now bounds a self-driving app.** `frames` bounded + only one of the two program shapes. An app whose `setup()` returns is driven by the + harness's own loop, which counts to `frames`; an app that never returns from + `setup()` (the `while self.running` shape a generated sign uses) never reached that + loop, so `frames` was silently ignored and `run_headless(app, frames=20)` rendered + until something killed the process. The cap now counts at `display.show()`, the one + call both shapes make exactly once per frame, and stops the run at the frame + boundary. The wrapper goes on the display *instance* and comes off again, because + wrapping `UnifiedDisplay.show` on the class leaks into every later app in the same + process and counts each frame once per wrap; the frame signature is read before the + stop unwinds, because teardown drops the display, the performance manager and every + recorded frame on the way out. Stopping a loop that never planned to stop costs one + thing worth knowing: the unwind goes *through* `show()`, so whatever the app's loop + does after showing its last frame does not run for that frame. +- **A mark wider than the panel no longer takes the sign down on its first dwell.** + `PixelMark.attach()` dropped off-panel cells from the bitmap but kept them in + `slots`, so the mark went on describing pixels it never drew. Six of the seven acts + survived that; `treatment_dwell` did not, because it hands `ctx.slots` straight to a + panel-sized `PalettePartition`, which raised `IndexError`. That is the exact failure + the drop was there to prevent. `attach()` now narrows `slots` to the cells that fit, + preserving shape (a mapping stays a mapping, so per-pixel indices survive). + ## [0.11.1] - 2026-08-21 Brightness that actually dims, a panel renderer that no longer needs pygame, and the diff --git a/CLAUDE.md b/CLAUDE.md index 6a32a9f..60b69dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,6 +232,24 @@ are all gone. - **Standalone, orthogonal** (NOT the `Transition` contract): the splash animations (`reveal_splash` / `drip_splash` / `swarm_reveal`), `particles`, and `text_render`. Leave them as-is. +- **Acts are a SECOND contract, deliberately distinct from `Transition`, and not a + merger of it** (`effects/acts.py`). A transition swaps one screen's + *content* for another's; an act is a beat in a sign's show over a **mark**: + build → dwell → exit. Keep the two categories separate: an act *wraps* a + transition (`reveal_via` / `hide_via`), it does not replace or subsume one, and + the splashes and treatments stay their own categories too. The act contract is a + **duck-typed context**, not a base class: `ctx.slots` / `.colors` / `.display` / + `.running` / `await .frame()` / `.show()` / `.hide()`. An app passes *itself*; + there is nothing to inherit, and `SimpleContext` exists only for callers that own + no tiles. `effects/mark.PixelMark` is the minimal mark for anything with no app to + own one. Dispatch mirrors transitions exactly: a literal `BUILDS`/`DWELLS`/`EXITS` + dict plus `act_factory()` / `supported_acts()`, lazily imported, **not** a + registry or plugin loader. `selectable()` expands those seven functions into the + 39 name-level choices a menu shows; `play_sign()` drives them through + `ActScheduler`. Two things are refused rather than offered broken, and that + judgement is the point: `Drop from Sky` (its `pre_render_hook` means `start()` + never calls the swap callback, so a mark handed to it stays hidden) and the two + `map_route` treatments (they need a mark's own stroke paths). - **The safety mechanism for any new effect is the strict gate, not a plugin loader**: `run_headless(app, strict=True)` raises `FeasibilityError` if an effect allocates per frame or busts the ~50 ms (20 fps) budget. The annotated reference diff --git a/docs/guide/acts.md b/docs/guide/acts.md new file mode 100644 index 0000000..267c209 --- /dev/null +++ b/docs/guide/acts.md @@ -0,0 +1,302 @@ +# Marks & Acts + +A sign is not one animation. It is a **mark** (the thing being shown) and a stream +of **acts** over it, where an act is three beats: **build, dwell, exit**. Reveal the +mark, hold it while something interesting happens to it, take it away. Then do it +again with a different three. + +This page covers the two pieces that make that portable: +[`scrollkit.effects.mark`](#the-mark-pixelmark) gives you a mark when your app does +not already own one, and [`scrollkit.effects.acts`](#the-acts) gives you builds, +dwells and exits that work on *any* mark, including yours. + +The palette treatments were already portable dwells, because +[a treatment](palette-treatments.md) takes a `PalettePartition` and nothing else. +Builds and exits were not: they got written inside the app that owned the mark and +reached straight into its tiles, its layout and its palette, so reusing one meant +copying it. `acts` is the missing half. + +## The shortest thing that works + +```python +from scrollkit.effects.mark import PixelMark +from scrollkit.effects.acts import play_sign + +mark = PixelMark.from_text(display, "BLUE RIDGE COFFEE", y=12) +mark.attach(display) +await play_sign(mark.context(display), acts=4) +``` + +Four full build/dwell/exit cycles over a wordmark rendered from the display's own +font, with no art authored anywhere and no act written by you. That is deliberately +the first example: a deck of acts can be assembled and judged against a real +wordmark *before* anyone draws anything. + +## The mark: `PixelMark` + +Every build in `acts` has the same shape. Hide the mark, run an overlay that shows +its pixels arriving, then call `ctx.show()` to hand the real thing back and drop the +overlay. **That last step assumes something real is underneath.** An app that owns +its wordmark already has it, a set of glyph tiles it can un-hide. Without one, the +drops land, the overlay detaches, and the panel goes black while the act cheerfully +returns `True`. + +`PixelMark` is the minimal version: lit cells and their colours in, one bitmap, one +palette and one tile out. + +```python +from scrollkit.effects.mark import PixelMark + +# From the display's own font. +mark = PixelMark.from_text(display, "OPEN", x=4, y=10, color=0xFFB030) + +# Or from ASCII art, in the format pixel art is already authored in. +OWL = ("..###..", + ".#o.o#.", + "..###..") +mark = PixelMark.from_art(OWL, {"#": 0xFFB030, "o": 0x102030}, x=20, y=8) + +mark.attach(display) # builds the layer and adds it, HIDDEN +``` + +| Member | What it does | +|---|---| +| `PixelMark(cells, colors=None, color=None)` | `cells` is `{(x, y): index}` or any iterable of `(x, y)`. A mapping colours each cell from `colors`; a bare iterable is one flat `color`. | +| `.from_text(display, text, x=0, y=0, scale=1, color=None)` | A mark from a line of text in `display.font`. | +| `.from_art(rows, chars, x=0, y=0)` | A mark from rows of characters plus `{character: 0xRRGGBB}`. | +| `.attach(display)` | Build the layer, add it, leave it hidden. Returns `self`. | +| `.tile` | The mark's `TileGrid`, or `None` before `attach`. Read-only. | +| `.show()` / `.hide()` | Flip `tile.hidden`. | +| `.detach()` | Remove the layer. | +| `.context(display=None, frame=None)` | A [context](#the-context) wired to this mark. | + +Four behaviours here are decisions rather than defaults, and each one is the answer +to a way signs actually break: + +- **It attaches hidden.** Every act's first move is to clear the panel, so attaching + visible would flash the finished mark for one frame before the act that assembles + it begins. +- **An unmapped character in `from_art` is a hole, not a guess.** That is how `.` + and the space character become background without being special-cased, and it + means a character nobody defined leaves a gap rather than putting a shape on the + panel its author never drew. (`normalize_art` in [`utils.pixel_art`](pixel-art.md) takes the + opposite view, for a different job: repairing a typo at import, where substituting + transparent would silently delete a sprite.) +- **Index 0 is never lit.** `0` means "no ink here" in ASCII art and slot 0 of the + built palette is reserved for transparency, so a cell carrying 0 is skipped. + Lighting it would give every mark a rectangular background. +- **Off-panel cells are dropped, not raised on.** Art is authored by hand, and a + wordmark placed one column too far right should not take the sign down. + +`from_art` builds the palette from the colours actually used, in first-seen order, +so a mark carries what it needs rather than everything the art module happened to +define. + +### A mark is a thing that moves + +`.tile` is exposed because the [image animators](effects.md#image-animators) take a +`TileGrid`, and a host flying a mark along a path had no way to get one without +reaching into a private attribute: + +```python +from scrollkit.effects.image_animators import MotionAnimator + +fly = MotionAnimator(path="point_to_point", from_xy=(-30, 4), to_xy=(6, 12), + frames=40, curve="ease_out_quad") +fly.start(display, mark.tile, None, None, None) +``` + +Position the mark with `tile.x` / `tile.y`. The cells themselves are fixed at +`attach` time. + +## The acts + +An act is handed a context and knows nothing else. Every one returns `True` if it +ran to completion and `False` if it stopped early, and every one detaches whatever +it attached, **including on the early exit**, because otherwise the overlay outlives +the act and the next one starts on a panel it did not draw. + +| Act | Kind | What it looks like | +|---|---|---| +| `swarm_build(ctx, ...)` | build | A flock carries the mark into place, pixel by pixel. | +| `drip_in(ctx, direction="top", ...)` | build | The mark's pixels rain in from an edge; the true colours arrive as the last drop lands. | +| `wink_in(ctx, ...)` | build | Every LED lights, then everything that is not the mark winks off. | +| `reveal_via(ctx, transition="Iris Snap")` | build | A screen transition covers the panel, the mark appears behind it, it uncovers. | +| `treatment_dwell(ctx, treatment="VelvetSweep", ramp=None, groups=10)` | dwell | A [palette treatment](palette-treatments.md) animates the mark in place. | +| `hide_via(ctx, transition="Pixel Dissolve")` | exit | The same transitions, taking the mark away. | +| `swarm_unbuild(ctx, ...)` | exit | The flock carries the mark off again. | + +Seven functions, and they are not seven choices. `reveal_via` and `hide_via` each +take any of **12** transition names and `treatment_dwell` takes any of **11** +treatments, so the menu is [39 entries](#the-menu-selectable) wide. + +```python +from scrollkit.effects.acts import drip_in, treatment_dwell, hide_via + +ctx = mark.context(display) +await drip_in(ctx, direction="bottom") +await treatment_dwell(ctx, treatment="HaloPulse") +await hide_via(ctx, transition="CRT Collapse") +``` + +Look them up by name the same way you look up a transition: + +```python +from scrollkit.effects.acts import act_factory, supported_acts + +supported_acts() # ('swarm', 'drip', 'wink', 'reveal', 'treatment', 'unswarm', 'hide') +supported_acts("build") # ('swarm', 'drip', 'wink', 'reveal') +ok = await act_factory("drip")(ctx, direction="bottom") +``` + +`act_factory` returns `None` for a name it does not know rather than raising, +matching `transition_factory`: the caller decides whether an unknown name is a typo +or a feature it does not have yet. + +### The context + +Duck-typed on purpose. An app already has these under its own names, and should not +have to inherit anything to use an act. Six are required; `colors` is optional: + +| Name | What it is | +|---|---| +| `ctx.slots` | The mark's lit cells: `{(x, y): palette index}`, or any iterable of `(x, y)`. | +| `ctx.colors` | Optional. The ramp those indices point into, low to high. | +| `ctx.display` | The display to `start()` an effect against. | +| `ctx.running` | Falsy means the sign is stopping, and the act must return early. | +| `await ctx.frame()` | Present one frame. `False` means the surface went away, and the return value is not advisory. | +| `ctx.show()` | Put the mark on screen in its final place. | +| `ctx.hide()` | Clear the mark and anything layered over it. | + +`SimpleContext` is the smallest thing that satisfies it, and `PixelMark.context()` +builds one for you. An app with its own tiles should pass **itself**: + +```python +class MySign: + slots = ... # {(x, y): 1} + colors = (0x70140E, 0xB02318, 0xE65A28) + running = True + + def show(self): self._show_logo() + def hide(self): self._hide_all() + async def frame(self): return await self.display.show() is not False + +await drip_in(self) # the sign IS the context +``` + +!!! warning "Per-pixel indices are meaningless without a ramp" + `SwarmReveal` raises on an `index_map` with no `text_colors` rather than + guessing, and it is right to: a guessed palette is a sign in colours nobody + chose. A context with slots but no `colors` gets a flat reveal, which is correct + and is what a one-tone mark wanted anyway. + +## The menu: `selectable()` + +`selectable()` returns the menu, **one entry per choice rather than per function**. +A person picking from a list picks "Iris Snap", not "reveal_via with +transition=Iris Snap", so the expansion happens once, where the acts are, instead of +in every caller. + +```python +from scrollkit.effects.acts import selectable + +for name, kind, family, act, options in selectable(): + ... +``` + +39 entries: **15 builds** (12 transitions plus swarm, drip and wink), **11 dwells** +(one per driveable treatment), **13 exits** (12 transitions plus unswarm). Compose +one of each and that is 2,145 distinct acts out of seven functions and one drawing. + +`family` is what the entry *looks like*, and it is where the judgement lives. Two +entries sharing a family are similar enough that the +[scheduler](utils.md#actscheduler-090) will not play them back to back. **A treatment's +family is its partition**, because two treatments animating the same grouping of +pixels genuinely do look alike, and that is the distinction a viewer makes. Eleven +treatments collapse to nine families. A transition is its own family, which is the +honest answer when nobody has grouped them by eye. + +## The whole sign: `play_sign()` + +```python +from scrollkit.effects.acts import play_sign + +played = await play_sign(ctx) # until ctx.running goes false +played = await play_sign(ctx, acts=4) # exactly four cycles +played = await play_sign(ctx, chosen=["Iris Snap", "HaloPulse", "exit:CRT Collapse"]) +``` + +`play_sign` draws build, dwell and exit from an `ActScheduler` and repeats. That is +what a sign IS in this design: **a deck is chosen and the order is not.** The +scheduler leads with the least-recently-seen entry and never plays two of a family +back to back, which is why a panel running this for a week does not visibly loop. +It returns the number of complete cycles played. + +Pass your own `scheduler=` to share age bookkeeping with the rest of your show. + +!!! note "A choice is a kind **and** a name" + Every transition is both a build and an exit, so `chosen=["Pixel Dissolve"]` + selects it in *both* decks. Someone who kept "Pixel Dissolve" to end on did not + thereby ask for it to open with. Write `"exit:Pixel Dissolve"` to say the thing + you meant. A bare name still selects every kind it appears in, which is the + forgiving reading when nobody has said otherwise. + + A name that is not on the menu is ignored rather than raising, because a deck is + a preference and one stale entry should not stop a sign. A deck with **no exit** + is refused outright: a sign that ended mid-build leaves the panel in a state no + act chose. + +## What is refused, and why + +A menu entry that cannot work is worse than an absent one, and a silent no-op is +worse than both. Four things are therefore missing on purpose: + +- **`Drop from Sky` is not a driveable transition.** `transitions_available()` + returns 12 of the 13 names in `supported_names()`. Drop from Sky is a different + thing wearing the same word: it hooks `pre_render_hook` and animates a content + `Label`'s x/y through the display process, so its `start()` never calls the swap + callback and a mark handed to it stays hidden while the act reports success. + Anything carrying that hook is excluded. +- **`RouteCircuit` and `PacketTrace` are not driveable dwells.** + `treatments_available()` returns 11 of 13. Both want `map_route`, which needs a + mark's glyph stroke paths and terminus pixels. That is app knowledge, and it is + not derivable from a set of cells. +- **A treatment wanting arguments this module cannot invent is dropped.** Only `lo` + and `hi` are servable (`GradientDwell` wants them, and the ramp's ends are the + obvious answer). The check reads the live `__init__` signature, so it cannot drift. +- **`point_to_point` with no endpoints raises** rather than quietly travelling from + `(0, 0)` to `(0, 0)`. A move that goes nowhere is the failure mode with nothing to + read. + +One thing is resampled rather than refused. **A treatment theme is exactly five +stops**, because every treatment class unpacks `base, dim, flat, warm, hot`. A +mark's palette is however many colours its art needed, so `treatment_dwell` +resamples to five with the endpoints kept. Refusing would mean "your wordmark has +six colours, so you may not have a heat sweep", which is not a rule anyone would +accept. + +## Cost + +The builds and exits walk a prebuilt schedule for a handful of writes a frame, and +`treatment_dwell` does **zero** pixel writes: a `PalettePartition` groups the mark's +own pixels once and the treatment animates the groups by rewriting N palette +entries. That is why a sign can afford a dozen dwells. + +`num_birds` is the one real feasibility knob, on `swarm_build` / `swarm_unbuild`: +per-frame cost grows with its **square**, because of the boids neighbour pass. + +Verify a whole show, not one act, since a scheduled sign shows a different act every +few seconds: + +```python +from scrollkit.dev import run_headless +result = run_headless(app, frames=600, strict=True) # FeasibilityError if it busts +``` + +## See also + +- [Pixel Art](pixel-art.md) for authoring the art a mark is made from. +- [Palette Partitions & Treatments](palette-treatments.md) for what the dwells are. +- [Theatrical Transitions](transitions.md) for the 13 names the builds and exits wrap. +- [Utilities](utils.md#actscheduler-090) for the scheduler `play_sign` draws with. +- `docs/sample-project.md` for a full application built this way. diff --git a/docs/guide/character-animation.md b/docs/guide/character-animation.md index ebae525..b4e1d5b 100644 --- a/docs/guide/character-animation.md +++ b/docs/guide/character-animation.md @@ -83,16 +83,17 @@ From here on, animating the character means writing `tile.x`, `tile.y`, and A character reads as *alive* the moment its silhouette changes while it moves. Two poses are enough for a small sprite. Draw the wing up and the wing -down, then alternate every few frames: +down, then alternate every few frames. + +`PoseCycler` is that, and it is the only piece you need: it advances a list of +tiles as **one moving subject**, showing exactly one at a time at wherever you +put it and hiding the rest. ```python -def _fly_pose(self, tiles, frame, x, y): - """Show one wing pose of the flapping owl at (x, y), hide the other.""" - up = (frame // 3) % 2 == 0 - show, hide = (tiles[0], tiles[1]) if up else (tiles[1], tiles[0]) - show.x, show.y = x, y - show.hidden = False - hide.hidden = True +from scrollkit.effects.image_animators import PoseCycler + +flap = PoseCycler(wing_tiles, period=3) # two poses, swap every 3 frames +flap.step(frame, x, y) # once per frame; returns the pose shown ``` ![Two-pose flap](../assets/reference/characters/owl-flap.gif){ width="300" } @@ -103,27 +104,49 @@ art mirrored (next section).* A bigger sprite deserves a real cel cycle. The big owl has three authored poses — wings UP, MID (glide), DOWN — cycled `UP → MID → DOWN → MID` so the wing passes through the glide position on both the upstroke and the -downstroke, exactly like a hand-drawn flap: +downstroke, exactly like a hand-drawn flap. That is the same class with a beat +order: ```python -def _big_pose(self, tiles, frame, x, y): - """Cel flap for the big owl: UP -> MID -> DOWN -> MID, 2 frames each.""" - pose = (0, 1, 2, 1)[(frame // 2) % 4] - for i, tile in enumerate(tiles): - if i == pose: - tile.x, tile.y = x, y - tile.hidden = False - else: - tile.hidden = True +cel = PoseCycler(owl_tiles, period=2, order=(0, 1, 2, 1)) ``` ![Three-pose cel flap](../assets/reference/characters/owl-cel-flap.gif){ width="300" } *The 24x16 owl's three-pose cel cycle, riding a sine swoop across the panel.* +The beat order is its own argument rather than just `len(tiles)` precisely +because of this case: three poses, four beats. `pose_at(frame)` tells you which +index is up without touching the tiles, and `hide()` takes the whole subject off +screen. + +Two details that are decisions, not defaults. **Position is held on the cycler** +rather than read back off the tiles, so a caller that writes one axis per frame +(a bob that only moves `y`) does not have to restate the other: pass `None` for +"leave that axis alone". And it is **integer-only**, because a `TileGrid` takes +whole pixels and rounding at the call site is how a subject ends up drifting half +a pixel on every pose swap. + Every pose is its own prebuilt TileGrid; "changing pose" is two `hidden` flags. Never rebuild bitmaps to change a frame of animation. +### Flying while flapping + +A pose cycle and a flight path are the same subject, so `MotionAnimator` takes +the poses directly and drives them instead of the one tile it was handed: + +```python +from scrollkit.effects.image_animators import MotionAnimator + +fly = MotionAnimator(path="point_to_point", from_xy=(-24, 2), to_xy=(8, 11), + frames=40, curve="ease_out_quad", + poses=owl_tiles, pose_frames=2, pose_order=(0, 1, 2, 1)) +``` + +`point_to_point` is the path that **lands**: `traverse_lr` crosses the panel and +exits, so it cannot put a character down on a spot. See +[Effects](effects.md#motion-paths-and-landing-on-a-spot) for the full path list. + ## Mirroring for direction A character that only ever flies one way feels like a screensaver. Flip the diff --git a/docs/guide/effects.md b/docs/guide/effects.md index 0575038..a313a65 100644 --- a/docs/guide/effects.md +++ b/docs/guide/effects.md @@ -49,7 +49,9 @@ heavily-annotated reference in `demos/medium/golden_transition.py`. | `scrollkit.display.bitmap_text` | palette-animated bitmap text ([guide](bitmap-text.md)) | | `scrollkit.effects.particles` | standalone particle systems (sparkles, rain, embers, snow) | | `scrollkit.effects.reveal_splash` / `.drip_splash` / `.swarm_reveal` | splash-reveal helpers: `show_reveal_splash`, `show_drip_splash`, `show_swarm_splash` | -| `scrollkit.effects.image_animators` | per-frame animators that decorate a static image already on screen (twinkle, motion, emitter, glow, region-shift, orbit, blink, sprite-lift, cover, vanish, frame-cycle, cel-walk, combo) | +| `scrollkit.effects.image_animators` | per-frame animators that decorate a static image already on screen (twinkle, motion, emitter, glow, region-shift, orbit, blink, sprite-lift, cover, vanish, frame-cycle, cel-walk, combo), plus `PoseCycler` for cycling authored poses as one moving subject | +| `scrollkit.effects.mark` | `PixelMark`: a set of lit cells as a show/hide layer, for an app that owns no wordmark of its own ([guide](acts.md)) | +| `scrollkit.effects.acts` | build → dwell → exit over any mark, and `play_sign` to run a whole sign ([guide](acts.md)) | Effects run with functionally equivalent behaviour on hardware and in the simulator — same effect types and sequencing, though exact pixel timing differs. @@ -190,10 +192,46 @@ The fourteen animators use four motion substrates and compose with `ComboAnimato | Substrate | Animators | What it does | |-----------|-----------|--------------| | **Transparent overlay** above the image (sparse writes cleared by one C `fill`) | `TwinkleAnimator`, `EmitterAnimator`, `OrbiterAnimator`, `BlinkAnimator`, `CoverAnimator` | shimmer, drifting particles, an orbiting sprite, a wink/flicker, a masked-until-cue patch | -| **Move a tile** — the image's own `TileGrid` or a lifted copy of its subject | `MotionAnimator`, `SpriteLiftAnimator` | traverse / rise / bob / jiggle; or lift a subject onto its own layer and cross a fixed scene (the hole row-inpaints) | +| **Move a tile** — the image's own `TileGrid` or a lifted copy of its subject | `MotionAnimator`, `SpriteLiftAnimator` | traverse / rise / point_to_point / bob / jiggle; or lift a subject onto its own layer and cross a fixed scene (the hole row-inpaints) | | **Rewrite the loaded Bitmap** or palette entries | `RegionShiftAnimator`, `RegionRotateAnimator`, `VanishAnimator`, `FrameCycleAnimator`, `PalettePulseAnimator` | wing/flag/jaw motion (sine/ramp/ripple/hinge waves), a true region rotation about a pivot (a head nodding — `exclude` keeps the attached body static), staged erases (a bite), pre-baked ripple frames, a breathing glow | | **Play authored cels** — a tile-indexed sibling spritesheet | `CelWalkAnimator` | swap between distinct authored frames (a true walk cycle) while striding across; frames live in a `_walk.bmp` strip, O(1) per frame (a tile index + an `x` write) | +### Motion paths, and landing on a spot + +`MOTION_PATHS` is every path `MotionAnimator` understands, exported as a tuple so a +host that lets something *else* choose one (a config file, a request, a model) +validates against the animator's own list instead of keeping a copy that drifts. An +unknown path does not raise; the subject simply stands still, which is why a name +arriving from outside should be checked against this first. + +```python +from scrollkit.effects.image_animators import MotionAnimator, MOTION_PATHS +# ('traverse_lr', 'traverse_rl', 'rise', 'bob', 'jiggle', 'point_to_point') +``` + +`traverse_lr` crosses the panel and exits, so it has no destination and cannot put a +subject down on a slot. **`point_to_point` lands.** It travels `from_xy` to `to_xy` +over `frames`, eased by any name in +[`scrollkit.effects.easing.CURVES`](transitions.md), computed with the same +`easing.interp` the transitions use so a curve behaves here exactly as it does +everywhere else: + +```python +fly = MotionAnimator(path="point_to_point", from_xy=(-24, 2), to_xy=(8, 11), + frames=40, curve="ease_out_quad", + poses=wing_tiles, pose_frames=3) # flap while it flies +``` + +Like `traverse` and `rise`, it deliberately does **not** recenter at `detach()`: it +has arrived where it was sent, and snapping it home would undo the whole move. +Constructing it without both endpoints raises, rather than quietly travelling from +`(0, 0)` to `(0, 0)`, because a move that goes nowhere is the failure mode with +nothing to read. + +`poses=` hands the motion to a `PoseCycler`, which advances a list of tiles as one +moving subject with exactly one visible at a time. See +[Character Animation](character-animation.md#pose-cycling-the-flap). + Like the showcase effects, every animator carries a `FEASIBILITY` dict on the **class** (`hardware_safe`, `allocates_per_frame`, `max_pixel_writes_per_frame`, `modeled_frame_ms`) — bounded, bulk writes only, budgeted against the calibrated diff --git a/docs/guide/palette-treatments.md b/docs/guide/palette-treatments.md index addaf41..4099a52 100644 --- a/docs/guide/palette-treatments.md +++ b/docs/guide/palette-treatments.md @@ -46,6 +46,31 @@ Partition builders (each returns `(group_map, n_groups)`): | `map_topology` | endpoints / corners / junctions / runs | stroke anatomy | | `map_route` | BFS order along glyph strokes | crawling packets | +### From a nickname to a builder + +A treatment advertises the partition it wants as a **nickname**: +`HaloPulse.PARTITION` is `"radial"`. That is not the name of a function, and +guessing at it is a trap, because twelve of the thirteen are `map_` plus the +nickname and the one that is not is `"anchor"`, whose builder is +`map_anchor_distance`. Regular enough to be trusted, then wrong. + +So resolve it instead of guessing: + +```python +from scrollkit.effects.palette_partition import PARTITION_BUILDERS, builder_for + +builder_for("anchor") # +builder_for("nonsense") # None +PARTITION_BUILDERS # the whole nickname -> callable table (10 entries) +``` + +`builder_for` is the inverse of `treatments_for`: that one answers "what can I run +on this partition", this one answers "what builds the partition this treatment +wants". `capabilities()` renders each treatment's `partition_call` from the live +signature, so the catalogue shows +`map_anchor_distance(pixel_slots, anchor_x, n=10)` rather than the bare word +`anchor`. + ## The treatments Thirteen frame-driven classes animate a partition; each names its diff --git a/docs/reference.md b/docs/reference.md index 0f24832..b27bc41 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -54,7 +54,10 @@ from scrollkit.display.content import DisplayContent, StaticText, ScrollingText, from scrollkit.effects.transitions import transition_factory, Transition from scrollkit.effects.particles import ParticleEngine from scrollkit.effects.reveal_splash import show_reveal_splash -from scrollkit.effects.image_animators import TwinkleAnimator # + 13 more image animators +from scrollkit.effects.image_animators import TwinkleAnimator, PoseCycler, MOTION_PATHS +from scrollkit.effects.mark import PixelMark +from scrollkit.effects.acts import play_sign, selectable, act_factory, supported_acts +from scrollkit.effects.palette_partition import PARTITION_BUILDERS, builder_for ``` ## Web diff --git a/mkdocs.yml b/mkdocs.yml index bd9f66b..e4e7b13 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,6 +69,7 @@ nav: - Gradient Text: guide/gradient-text.md - Palette-Animated Bitmap Text: guide/bitmap-text.md - Palette Partitions & Treatments: guide/palette-treatments.md + - Marks & Acts: guide/acts.md - Character Animation: guide/character-animation.md - Sensors & Tilt: guide/sensors.md - Web Interface: guide/web.md diff --git a/src/scrollkit/effects/__init__.py b/src/scrollkit/effects/__init__.py index cb3aa9d..83b77e5 100644 --- a/src/scrollkit/effects/__init__.py +++ b/src/scrollkit/effects/__init__.py @@ -30,6 +30,13 @@ - **Palette treatments** (dwell animations on a partition — the gallery) — ``from scrollkit.effects.palette_treatments import VelvetSweep, HaloPulse, TREATMENT_CLASSES, treatments_for`` +- **Marks** (a set of lit cells as a show/hide layer, for an app that owns no + wordmark of its own) — + ``from scrollkit.effects.mark import PixelMark`` +- **Acts** (build -> dwell -> exit over any mark, driven by a duck-typed context — + NOT the ``Transition`` contract) — + ``from scrollkit.effects.acts import play_sign, selectable, act_factory, + supported_acts, drip_in, swarm_build, treatment_dwell, reveal_via, hide_via`` - **Swirl entrance** (sprites spiral in onto their targets) — ``from scrollkit.effects.swirl_in import SwirlIn`` - **Text-rendering helpers** —