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/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/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/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** — diff --git a/src/scrollkit/effects/acts.py b/src/scrollkit/effects/acts.py new file mode 100644 index 0000000..ed452ce --- /dev/null +++ b/src/scrollkit/effects/acts.py @@ -0,0 +1,589 @@ +"""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 +_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: + """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() + + +# --------------------------------------------------------------------------- +# 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) + + +# --------------------------------------------------------------------------- +# 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 = { + "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, +} + + +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 DWELLS.get(name) or EXITS.get(name) + + +def supported_acts(kind=None): + """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(DWELLS) + tuple(EXITS) 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 new file mode 100644 index 0000000..18c9890 --- /dev/null +++ b/src/scrollkit/effects/mark.py @@ -0,0 +1,201 @@ +"""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) + + @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): + """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. + + 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") + 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) + + # **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): + 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 + 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) + self._tile.hidden = True + display.add_layer(self._tile) + 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: + 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/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/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 == [] diff --git a/test/unit/effects/test_acts.py b/test/unit/effects/test_acts.py new file mode 100644 index 0000000..7f3dd16 --- /dev/null +++ b/test/unit/effects/test_acts.py @@ -0,0 +1,159 @@ +"""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(): + 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) + # 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 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 new file mode 100644 index 0000000..67881b9 --- /dev/null +++ b/test/unit/effects/test_mark.py @@ -0,0 +1,258 @@ +"""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 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 + + +@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 + + +# -- 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 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,) + ) 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