Skip to content

Commit cf225e2

Browse files
czeiclaude
andcommitted
release: 0.11.0 — working brightness, a pygame-free renderer, pixel art
Seven of this branch's eleven commits never reached the changelog. The [Unreleased] section covered the throttle and virtual-clock work and the WiFi/ settings fixes, and said nothing about the numpy render backend, the measured pixel_write_us, swarm_reveal's cross-version ordering, the pixel-art chapter, or the brightness change — the last of which is the one an upgrader most needs to read, since a stored brightness that has been silently ignored for the whole 3.x line starts taking effect on this release. So the section is rewritten as 0.11.0 with all eleven accounted for, split Added / Changed / Fixed / Docs, and the brightness entry carries an explicit upgrade note. Minor rather than patch: pixel_art, set_color_scale/color_scale, the clock exports and the throttle/virtual_clock parameters are all new public surface. Verified before tagging: 1057 tests pass, lint-errors clean, `python -m build` succeeds, `twine check --strict` passes both artifacts, and the wheel carries the two package-data files the publish workflow greps for plus the three new modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e1a2579 commit cf225e2

3 files changed

Lines changed: 110 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,51 @@ All notable changes to ScrollKit are recorded here. This project loosely follows
55

66
## [Unreleased]
77

8-
Two changes for a caller nobody had before: a **live preview** that wants to run at the
9-
device's speed rather than as fast as it can. Both are opt-in, and neither moves a pixel —
10-
modeled cost is computed from operation counts, never from wall time, so feasibility
11-
reports and per-frame hashes are identical with them on or off.
8+
## [0.11.0] - 2026-08-21
9+
10+
Brightness that actually dims, a panel renderer that no longer needs pygame, and the
11+
pixel-art chapter the docs never had.
1212

1313
### Added
14+
- **A pygame-free rendering backend, and a 3.7x faster panel composite.** pygame is a C
15+
extension over SDL with no wasm build — not in Pyodide's package set, no emscripten
16+
wheels for pygame-ce on PyPI — so six calls (`Surface`, `SRCALPHA`, `draw.circle`,
17+
`transform.smoothscale`, `BLEND_RGB_ADD`, `image.save`) stranded the entire LED cosmetic
18+
layer on the desktop, and a browser preview showed flat squares instead of a panel.
19+
`simulator/core/_surface.py` picks a backend: pygame where importable, numpy where not.
20+
pygame stays primary wherever it exists, so desktop output is untouched — verified by
21+
frame hash, unchanged before and after. The composite also replaces up to 4,096 small
22+
per-LED array ops with a handful of whole-array writes: dots tile exactly on the pitch,
23+
and 20 px glow sprites overlap on an 11 px pitch but not on a two-cell sub-lattice, where
24+
the pitch is 22 ≥ 20 — so four passes cover the panel with no overlap inside any pass.
25+
Additive saturation is order-independent, so the result is bit-identical rather than
26+
merely close, and the test asserts that. 13.6 → 3.67 ms/frame, and measured end to end in
27+
Pyodide, 190.7 → 64.8 ms/frame (5.2 → 15.4 fps), with the paint handoff going 127 → ~1 ms
28+
once the panel composes into a persistent RGBA buffer the host wraps zero-copy.
29+
- **`scrollkit.utils.pixel_art` — `normalize_art()`, `normalize_all()`, `art_problems()`.**
30+
Hand-authored ASCII art has exactly two typos, and both crash from deep inside the
31+
conversion loop with nothing on the panel: a ragged row throws `IndexError`, an unmapped
32+
character throws `KeyError`. Across six documented model-written signs, every
33+
first-attempt failure was one of those two and nothing else — one of them a single row of
34+
25 characters where its two neighbours were 26, in a 598-line program with 34 sprites,
35+
which cost two full regeneration rounds. Short rows now pad with transparent, unmapped
36+
whitespace becomes transparent, and any other unmapped character becomes the first lit
37+
slot, because the author drew something there and substituting transparent would silently
38+
delete the sprite — a worse outcome than the crash. Every repair reports one line naming
39+
the sprite, and clean art passes through untouched. `art_problems()` returns the same
40+
findings as a list for tests that would rather assert than repair.
41+
- **A pixel-art chapter.** `docs/guide/pixel-art.md` covers art as ASCII rows over palette
42+
slots, authoring small and doubling, the width arithmetic for 64x32, converting to a
43+
`Bitmap` once, and then animating with palette writes, tile moves and hidden flags rather
44+
than redrawing. `demos/medium/pixel_wordmark.py` is the worked example: hand-authored
45+
letterforms, a sprite on its own material slots, and four acts through an `ActScheduler`,
46+
at ~9 palette writes a frame. Given the library's own docs, three models each built a
47+
competent multi-scene sign — correct areas, real tool lists, inside the frame budget —
48+
and not one drew a picture. `AGENTS.md` had no pixel-art chapter and led its content
49+
section with `ScrollingText`/`StaticText`, so a text-oriented prompt produced text signs.
50+
- **`UnifiedDisplay.set_color_scale(brightness)` and `.color_scale`** — the software dimmer
51+
behind the brightness change below. Synchronous; `set_brightness()` remains as the async
52+
wrapper for existing callers.
1453
- **`run_headless` / `run_headless_async` take `throttle=None|True|False`.** The harness
1554
forced the performance manager's throttle off on every headless run, which is right for a
1655
test suite and left a preview with no supported path to hardware-speed playback at all.
@@ -31,7 +70,54 @@ reports and per-frame hashes are identical with them on or off.
3170
monotonic run total, kept separate from the bounded `frames` history a clock built on it
3271
would run backwards on.
3372

73+
### Changed
74+
- **Brightness is a software colour scale now, not a hardware property.** `display.brightness`
75+
on the MatrixPortal S3 is not a dimmer — it is effectively on/off: 0.0 blanks the panel and
76+
0.15 looks identical to 1.0 (confirmed on hardware, six live changes, no visible
77+
difference). The re-platform had replaced a working software dimmer with it, so the
78+
brightness setting did nothing across its whole range for the entire 3.x line, and a
79+
stored "0" left a customer's sign dark for months with a perfectly healthy app behind it.
80+
The panel is now pinned to FULL and colours are scaled on their way out, from a RAW
81+
cached base — pre-dimming the base looks cheaper but double-dims `CoverAnimator`, which
82+
builds its overlay colours from `base_colors` and passes them through the same
83+
`_make_overlay` that dims. Covered: `draw_text`/`draw_text_scaled`, the `set_pixel`/`fill`
84+
paint path, the gradient ramp (with `color_scale` in the layer cache key, or a scrolling
85+
name would keep its old brightness until the content cycled), all five `BitmapText`
86+
palette effects, the write-once effect palettes, every icon overlay animator, the pulse,
87+
and the three cloned palettes `SpriteLift`/`FrameCycle`/`GravityDrip` build. **Note for
88+
upgraders:** a stored brightness that has been silently ignored will now take effect, so
89+
a sign configured low will visibly dim on this release.
90+
- **`pixel_write_us` is measured now, not guessed.** The feasibility gate false-rejected a
91+
field-proven act at 17.7 fps against a 20 fps target, and the whole error was one
92+
constant: `pixel_writes` decided the verdict (92% of that act's frame) and the term is
93+
`set_pixel_calls * 3 * profile.pixel_write_us`, where `pixel_write_us` was a hardcoded
94+
5.0 that never came from a device — inside a profile reporting
95+
`confidence: CALIBRATED_FROM_DEVICE`. `calibrate_device.py` now measures it (and takes
96+
`--out`, so a run can be inspected without overwriting the shipped baseline). On a
97+
MatrixPortal S3 it is 4.333 µs, of which the write is 3.36 µs and 74% of per-pixel cost
98+
is Python loop overhead — which is why a per-pixel effect is expensive on this board
99+
regardless of what it writes. The board measured runs CircuitPython 10.2.1 while the rest
100+
of the baseline is 9.1.0; frame-time terms are stable across the two (bitmap_rebuild
101+
+0.4%, full_refresh +0.7%), which is why this one is mixed in, while memory is *not*
102+
(usable_ram −24.6% on 10.2.1) and is deliberately left at 9.1.0. The baseline records
103+
that in `_pixel_write_us_source`.
104+
34105
### Fixed
106+
- **A browser preview locked the page and painted nothing.** `unified.py`'s no-pygame
107+
branch skipped the await, so the whole run blocked until it finished; it now refreshes,
108+
records and yields like the pygame path. `save_surface_png` opened with `import pygame`
109+
and returned `None` on `ImportError`, which left the public `display.screenshot()` broken
110+
in exactly the environment the numpy backend exists for, while
111+
`matrix.save_screenshot()` worked. `capture_frame` and `save_surface_png` handle both
112+
backends now.
113+
- **`swarm_reveal` was not reproducible across Python versions.** Running 35 acts in
114+
CPython 3.12 and again in Pyodide's 3.13, 34 matched frame for frame and `swarm` diverged
115+
from frame 0 — not flakiness, since two runs with the same seed are identical. The queue
116+
came from `list(self._remaining)` over a set, and `_shuffle()` permutes whatever order it
117+
is handed; set iteration order is stable within one Python build but is not guaranteed
118+
across versions, so seeding the RNG was not sufficient. `sorted()` makes the seeded
119+
shuffle the only source of order. Worth stating as a general rule for anything compared
120+
across runtimes: never derive an ORDER from iterating a set or dict.
35121
- **Throttled pacing overshot every frame.** It slept the whole modeled frame cost *after*
36122
the frame had already rendered, so a frame took `real_work + modeled` rather than
37123
`max(real_work, modeled)` and a host faster than the device still ran slower than it, by
@@ -68,6 +154,24 @@ reports and per-frame hashes are identical with them on or off.
68154
and every health signal read green. It is still caught, but recorded to `error_log` and
69155
exposed as `display_init_error`.
70156

157+
### Docs
158+
- **The reveals table had no signatures.** It showed `DripReveal`'s call with `color=BRAND`
159+
and then listed `SwarmReveal`, `show_reveal_splash` and the transitions with none at all.
160+
A model reading that generalised the keyword it had seen and wrote
161+
`SwarmReveal(pixels, color=...)`, which died at frame 1 with
162+
`TypeError: unexpected keyword argument 'color'` — `SwarmReveal` takes `text_color=` and
163+
`bird_color=` and has no `color=`. Reasonable inference from what the page showed; the
164+
page was the problem. The table now carries each constructor's actual colour arguments
165+
and says plainly that they are not shared.
166+
- **The `random()` availability list was written from recollection.** The warning box
167+
asserted seven functions including `getrandbits` and `seed`, neither of which is called
168+
anywhere in this library or in the reference sign — the same failure mode as a profile
169+
reporting `CALIBRATED_FROM_DEVICE` for a term nobody measured. It now states the five the
170+
shipped library and the field-proven sign actually call (`random`, `uniform`, `randint`,
171+
`randrange`, `choice`) as the working set, with the provenance attached, and says what
172+
`ActScheduler` does instead of shuffling, since "use the scheduler" without the reason
173+
invites a hand-rolled deck anyway.
174+
71175
## [0.10.0] - 2026-08-02
72176

73177
A sensor layer, and the network work from three field failures on a fielded

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "scrollkit"
7-
version = "0.10.0"
7+
version = "0.11.0"
88
description = "LED Matrix Display Framework for CircuitPython and Desktop"
99
readme = "README.md"
1010
requires-python = ">=3.8"

‎src/scrollkit/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
from scrollkit.display.unified import UnifiedDisplay
1414
"""
1515

16-
__version__ = "0.10.0"
16+
__version__ = "0.11.0"

0 commit comments

Comments
 (0)