Skip to content

Commit e1a2579

Browse files
czeiclaude
andcommitted
Make brightness a software colour scale, not a hardware property
display.brightness on the MatrixPortal S3 is not a dimmer. It is effectively on/off: 0.0 blanks the panel and 0.15 looks identical to 1.0 (confirmed on hardware -- six live changes, no visible difference). The re-platform had replaced a working software dimmer with it, so the brightness setting has done nothing across its whole range for the entire 3.x line, and a stored "0" left a customer's sign dark for months with a perfectly healthy app behind it. So the panel is pinned to FULL and colours are scaled on their way out, which is what the consuming app's own v1/v2 code did before the port. The scale lives on the display as color_scale, driven from settings at a frame boundary through the existing dirty-flag handoff. set_color_scale() stores the scale, pins the hardware and drops the stale paint-index cache together, so those three can never drift apart -- a display left with a stale scale and an un-pinned panel would run at full brightness, a worse complaint than a dim one. Dimming happens at the point of write, from a RAW cached base. Pre-dimming the base looks cheaper but double-dims CoverAnimator, which builds its overlay colours from base_colors and passes them through the same _make_overlay that dims; keeping the base raw also makes PalettePulseAnimator.detach() the same expression as everywhere else instead of a special case that flashes bright at the hold->fade handoff. Covered: draw_text/draw_text_scaled, the set_pixel/fill paint path, the gradient ramp (with color_scale in the layer cache key, or the scrolling name would keep its old brightness until the content cycled), all five BitmapText palette effects (rebuilt on change, so no per-frame cost), the write-once effect palettes, every icon overlay animator, the pulse, and the three cloned palettes that SpriteLift/FrameCycle/GravityDrip build. The riskiest line is dimming BEFORE the `if label.color != color` reuse guard: dim after it and the stored value never matches the incoming one, so every glyph bitmap rebuilds every frame -- invisible on desktop, a frame-rate collapse on the panel. The regression test counts assignments rather than values, because comparing values passes either way. 1057 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3375e30 commit e1a2579

14 files changed

Lines changed: 444 additions & 55 deletions

‎src/scrollkit/app/base.py‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ class SLDKApp:
103103
# a boot-time hang parked the box forever.
104104
BOOT_WATCHDOG_TIMEOUT = 120
105105

106+
# Floor for the global software brightness. 0.0 here (the library imposes no
107+
# product policy); an app overrides it to keep its panel readable. A stored
108+
# 0 is applied before the boot splash, so it reads as a sign that flashes
109+
# once and then stays black behind a perfectly healthy app — which is
110+
# exactly the field failure this floor exists to make unreachable.
111+
MIN_BRIGHTNESS = 0.0
112+
106113
# Supervision deadlines (see _supervise_progress). ATTEMPT_STALL_S bounds a
107114
# single update_data() call that never returns (a native call ignoring its
108115
# socket timeout); it must comfortably exceed the longest legitimate
@@ -558,23 +565,29 @@ def _apply_library_settings(self):
558565
559566
Called by the display loop (see _display_process) when a web save sets
560567
_settings_dirty, immediately before on_settings_changed(). Handles
561-
settings the library owns: brightness (pushed to the display hardware)
568+
settings the library owns: brightness (a global software colour scale)
562569
and scroll speed (propagated to every queue item that exposes a .speed
563570
attribute). Runs on the display-loop task, never from the web server.
564571
"""
565572
if self.display is not None:
566573
try:
567574
brightness = float(self.settings.get("brightness_scale", 0.5))
568-
brightness = max(0.0, min(1.0, brightness))
569-
self.display._brightness = brightness
570-
# .display is the underlying displayio display on every path
571-
# (S3 wrapper display, Interstate 75 FramebufferDisplay, or the
572-
# simulator Display). self.display.hardware may be a raw
573-
# rgbmatrix.RGBMatrix with no .display attribute — never reach
574-
# through it.
575-
disp = getattr(self.display, "display", None)
576-
if disp is not None:
577-
disp.brightness = brightness
575+
brightness = max(self.MIN_BRIGHTNESS, min(1.0, brightness))
576+
# set_color_scale stores the scale, pins the hardware property to
577+
# full and drops the stale paint-index cache — all three together
578+
# so they cannot drift. Dimming is done in software because the
579+
# hardware property is on/off on the MatrixPortal S3; see
580+
# UnifiedDisplay.set_color_scale.
581+
setter = getattr(self.display, "set_color_scale", None)
582+
if setter is not None:
583+
setter(brightness)
584+
else:
585+
# A display predating color_scale: keep the old behaviour
586+
# rather than silently ignoring the setting.
587+
self.display._brightness = brightness
588+
disp = getattr(self.display, "display", None)
589+
if disp is not None:
590+
disp.brightness = brightness
578591
except Exception as e:
579592
print("brightness apply error:", e)
580593

‎src/scrollkit/display/_graphics.py‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ class GraphicsMixin:
6464

6565
PAINT_VALUE_COUNT = 256
6666

67+
# Global software brightness: every colour written to the panel is scaled by
68+
# this. A class attribute so it exists BEFORE _init_graphics() runs — boot
69+
# status frames draw on some paths before initialize() has finished.
70+
#
71+
# It is software because the hardware property isn't a dimmer: on the
72+
# MatrixPortal S3 `display.brightness` is effectively on/off (0.0 blanks the
73+
# panel, 0.15 and 1.0 are indistinguishable), which is how a customer's sign
74+
# sat dark for months on a stored "0". See UnifiedDisplay.set_color_scale.
75+
color_scale = 1.0
76+
6777
# --- setup ----------------------------------------------------------------
6878
def _init_graphics(self, displayio_mod, bitmaptools_mod):
6979
"""Create the content/layer sub-groups inside main_group and cache gfx.
@@ -162,6 +172,25 @@ def _ensure_paint(self):
162172
self._paint_colors = {} # 0xRRGGBB -> palette index (0 reserved)
163173
self.add_layer(tile)
164174

175+
def _dim(self, color):
176+
"""``color`` scaled by the global software brightness.
177+
178+
Identity at 1.0, so the common case costs one float compare. The maths
179+
is inlined rather than imported from ``.colors``: this module keeps zero
180+
module-level imports on purpose, and a per-call import in a function the
181+
paint path hits for every colour is not worth the tidiness. Integer
182+
shifts, no float in the arithmetic (colors.scale uses the same form).
183+
"""
184+
f = self.color_scale
185+
if f >= 1.0:
186+
return color
187+
if f <= 0.0:
188+
return 0
189+
q = int(f * 256)
190+
return (((((color >> 16) & 0xFF) * q) >> 8) << 16 |
191+
((((color >> 8) & 0xFF) * q) >> 8) << 8 |
192+
(((color & 0xFF) * q) >> 8))
193+
165194
def _paint_index(self, color):
166195
idx = self._paint_colors.get(color)
167196
if idx is not None:
@@ -172,9 +201,9 @@ def _paint_index(self, color):
172201
# it, so the most recently painted color is always the correct one
173202
# (caching here would alias several colors to one mutable slot).
174203
idx = self.PAINT_VALUE_COUNT - 1
175-
self._paint_palette[idx] = color
204+
self._paint_palette[idx] = self._dim(color)
176205
return idx
177-
self._paint_palette[idx] = color
206+
self._paint_palette[idx] = self._dim(color)
178207
self._paint_colors[color] = idx
179208
return idx
180209

@@ -291,7 +320,7 @@ async def fill(self, color):
291320
if getattr(self, "_gfx", None) is None or self._content_group is None:
292321
return
293322
self._ensure_bg()
294-
self._bg_palette[0] = color
323+
self._bg_palette[0] = self._dim(color)
295324
self._bg_tile.hidden = False
296325

297326
# --- measurement ----------------------------------------------------------

‎src/scrollkit/display/bitmap_text.py‎

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -134,39 +134,73 @@ def _peak_profile(n):
134134
for i in range(n))
135135

136136

137-
class RainbowChase:
137+
class _Dimmable:
138+
"""Global-brightness support for the palette effects.
139+
140+
Each effect rewrites all RAMP slots every frame from a cached ramp, so the
141+
dim belongs in the ramp rather than in the write — the library's rule is to
142+
build a ramp once and never inside a per-frame loop (see display/colors.py).
143+
``_rebuild`` therefore runs only when the scale actually changes, and
144+
``BitmapText._build`` re-runs on every ``start()``, so a brightness change
145+
reaches the effect when its content next begins.
146+
"""
147+
148+
_dim = 1.0
149+
150+
def set_color_scale(self, f):
151+
if f != self._dim:
152+
self._dim = f
153+
self._rebuild()
154+
155+
def _rebuild(self):
156+
"""Recompute the cached ramp from the effect's original colours."""
157+
158+
159+
class RainbowChase(_Dimmable):
138160
"""Palette effect: rotate the colour ramp so a rainbow travels through the
139161
letters. Pure palette rewrites — no glyph rebuild. ``period`` advances the
140162
phase every N frames (>1 = a calmer, slower chase)."""
141163

142164
def __init__(self, step=1, period=1):
143165
self.step = step
144166
self.period = period if period > 1 else 1
167+
self._ramp = _RAINBOW # shared tuple at full brightness: no copy
145168
self._phase = 0
146169
self._tick = 0
147170

171+
def _rebuild(self):
172+
f = self._dim
173+
self._ramp = _RAINBOW if f >= 1.0 else tuple(_scale(c, f) for c in _RAINBOW)
174+
148175
def apply(self, palette):
149176
for i in range(RAMP):
150-
palette[1 + i] = _RAINBOW[(i + self._phase) % RAMP]
177+
palette[1 + i] = self._ramp[(i + self._phase) % RAMP]
151178
self._tick += 1
152179
if self._tick >= self.period:
153180
self._tick = 0
154181
self._phase = (self._phase + self.step) % RAMP
155182

156183

157-
class NeonTubeCrawl:
184+
class NeonTubeCrawl(_Dimmable):
158185
"""A bright pulse crawls along an otherwise-dim neon tube of one colour: one ramp
159186
slot glows at full ``color`` while the rest hold a dimmed version of it. Pass
160187
``glow``/``base`` to set the two shades directly instead. Pure palette rewrites —
161188
no glyph rebuild. ``period`` advances the pulse every N frames."""
162189

163190
def __init__(self, color=0x66FFCC, glow=None, base=None, period=2):
164-
self.glow = glow if glow is not None else color
165-
self.base = base if base is not None else _scale(color, 0.18)
191+
self._glow0 = glow if glow is not None else color
192+
self._base0 = base if base is not None else _scale(color, 0.18)
193+
self.glow = self._glow0
194+
self.base = self._base0
166195
self.period = period if period > 1 else 1
167196
self._phase = 0
168197
self._tick = 0
169198

199+
def _rebuild(self):
200+
f = self._dim
201+
self.glow = _scale(self._glow0, f)
202+
self.base = _scale(self._base0, f)
203+
170204
def apply(self, palette):
171205
for i in range(RAMP):
172206
palette[1 + i] = self.glow if i == self._phase else self.base
@@ -176,7 +210,7 @@ def apply(self, palette):
176210
self._phase = (self._phase + 1) % RAMP
177211

178212

179-
class ChromeSheen:
213+
class ChromeSheen(_Dimmable):
180214
"""A metallic sheen: a dark→bright ramp of ``color`` (default silver/white) with a
181215
highlight band that sweeps across the letters as the ramp rotates. Pass
182216
``highlight`` to make the sheen a smooth TWO-colour gradient (dim ``color`` → full
@@ -191,13 +225,21 @@ def __init__(self, color=0xFFFFFF, highlight=None, period=1):
191225
self.color = color
192226
self.highlight = highlight
193227
self.period = period if period > 1 else 1
228+
self._rebuild()
229+
self._phase = 0
230+
self._tick = 0
231+
232+
def _rebuild(self):
233+
# One multiply, not two truncations: fold the dim into the profile
234+
# rather than scaling an already-scaled ramp.
235+
d = self._dim
236+
color, highlight = self.color, self.highlight
194237
if highlight is None:
195-
self._ramp = tuple(_scale(color, f) for f in self._PROFILE)
238+
self._ramp = tuple(_scale(color, f * d) for f in self._PROFILE)
196239
else:
197240
# dim base of `color` -> full `highlight`: a smooth two-colour sheen ramp.
198-
self._ramp = gradient(_scale(color, self._PROFILE[0]), highlight, RAMP)
199-
self._phase = 0
200-
self._tick = 0
241+
self._ramp = gradient(_scale(color, self._PROFILE[0] * d),
242+
_scale(highlight, d), RAMP)
201243

202244
def apply(self, palette):
203245
for i in range(RAMP):
@@ -208,18 +250,24 @@ def apply(self, palette):
208250
self._phase = (self._phase + 1) % RAMP
209251

210252

211-
class HazardStripes:
253+
class HazardStripes(_Dimmable):
212254
"""Marching hazard stripes: an accent ``color`` alternating with a ``dark`` ground,
213255
shifting one slot each step. Pass ``a``/``b`` to set the two colours directly.
214256
Pure palette rewrites — no glyph rebuild."""
215257

216258
def __init__(self, color=0xFFCC00, dark=0x101010, a=None, b=None, period=2):
217-
self.a = a if a is not None else color
218-
self.b = b if b is not None else dark
259+
self._a0 = a if a is not None else color
260+
self._b0 = b if b is not None else dark
261+
self.a, self.b = self._a0, self._b0
219262
self.period = period if period > 1 else 1
220263
self._phase = 0
221264
self._tick = 0
222265

266+
def _rebuild(self):
267+
f = self._dim
268+
self.a = _scale(self._a0, f)
269+
self.b = _scale(self._b0, f)
270+
223271
def apply(self, palette):
224272
for i in range(RAMP):
225273
palette[1 + i] = self.a if ((i + self._phase) % 2 == 0) else self.b
@@ -229,7 +277,7 @@ def apply(self, palette):
229277
self._phase = (self._phase + 1) % 2
230278

231279

232-
class MonoChase:
280+
class MonoChase(_Dimmable):
233281
"""A single bright band of one ``color`` chases through the letters — RainbowChase,
234282
but monochrome (one hue at varying brightness). Pure palette rewrites — no glyph
235283
rebuild. ``period`` advances the chase every N frames."""
@@ -241,10 +289,14 @@ class MonoChase:
241289
def __init__(self, color=0xFFFFFF, period=1):
242290
self.color = color
243291
self.period = period if period > 1 else 1
244-
self._ramp = tuple(_scale(color, f) for f in self._PROFILE)
292+
self._rebuild()
245293
self._phase = 0
246294
self._tick = 0
247295

296+
def _rebuild(self):
297+
d = self._dim
298+
self._ramp = tuple(_scale(self.color, f * d) for f in self._PROFILE)
299+
248300
def apply(self, palette):
249301
for i in range(RAMP):
250302
palette[1 + i] = self._ramp[(i + self._phase) % RAMP]
@@ -321,8 +373,14 @@ def _build(self, display):
321373
bitmap = gfx.Bitmap(width, GLYPH_H, RAMP + 1)
322374
palette = gfx.Palette(RAMP + 1)
323375
palette.make_transparent(0) # background transparent
376+
# _build re-runs on every start(), so this is where a brightness change
377+
# reaches the effect's cached ramp.
378+
dim = getattr(display, "color_scale", 1.0)
379+
setter = getattr(self.palette_effect, "set_color_scale", None)
380+
if setter is not None:
381+
setter(dim)
324382
for i in range(RAMP):
325-
palette[1 + i] = _RAINBOW[i]
383+
palette[1 + i] = _scale(_RAINBOW[i], dim) if dim < 1.0 else _RAINBOW[i]
326384
# Render every glyph ONCE; each lit pixel's palette index is its column
327385
# position in the ramp, so rotating the palette makes the colour chase.
328386
cx = 0

‎src/scrollkit/display/colors.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"""
1818

1919

20-
__all__ = ['wheel', 'spectrum', 'lerp', 'gradient', 'multi_gradient', 'scale', 'depth_palette', 'hsv']
20+
__all__ = ['wheel', 'spectrum', 'lerp', 'gradient', 'multi_gradient', 'scale', 'dim_for', 'depth_palette', 'hsv']
2121

2222
def _clamp8(v):
2323
if v < 0:
@@ -119,6 +119,19 @@ def scale(color, factor):
119119
return (r << 16) | (g << 8) | b
120120

121121

122+
def dim_for(display, color):
123+
"""``color`` scaled by ``display``'s global software brightness.
124+
125+
The one-liner every effect needs, so none of them has to remember the
126+
``getattr`` default. That default matters: it keeps effects working against
127+
a display that predates ``color_scale`` (an older library build, a test
128+
double, a mock), where the right answer is "don't dim" rather than
129+
AttributeError.
130+
"""
131+
f = getattr(display, "color_scale", 1.0)
132+
return color if f >= 1.0 else scale(color, f)
133+
134+
122135
def depth_palette(color, strength=0.4, steps=2):
123136
"""Two (or more) close shades of ``color`` for subtle "lit from above" depth.
124137

‎src/scrollkit/display/content.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,14 @@ def _grad_active(self, display) -> bool:
191191
def _ensure_grad(self, display) -> None:
192192
"""Build (or rebuild on change) the gradient layer; idempotent per frame."""
193193
from .gradient_text import GradientTextLayer # lazy: gradient path only
194+
# color_scale is in the key because GradientTextLayer bakes the global
195+
# software brightness into its palette at build time and never rewrites
196+
# it. Without this, moving the brightness slider would leave the
197+
# scrolling ride name — the default path, and the largest thing on the
198+
# panel — sitting at the old brightness until the content cycled.
194199
key = (self.text, self.palette, self.direction, self.palette_steps,
195-
self.y, id(getattr(display, "font", None)))
200+
self.y, id(getattr(display, "font", None)),
201+
getattr(display, "color_scale", 1.0))
196202
if (self._grad is not None and self._grad_key == key
197203
and self._grad_display is display):
198204
return

‎src/scrollkit/display/gradient_text.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"""
1919

2020
from .colors import gradient, multi_gradient
21+
from .colors import scale as _scale
2122
from .text_fill import clamp_palette_steps, normalize_direction
2223
from .text_pixels import font_text_ascent, font_text_width, pixels_from_font_text
2324

@@ -104,8 +105,18 @@ def build(self, display):
104105
bitmap = gfx.Bitmap(width, height, n + 1)
105106
palette = gfx.Palette(n + 1)
106107
palette.make_transparent(0) # index 0 = transparent ground
107-
for i in range(n):
108-
palette[1 + i] = ramp[i]
108+
# Global software brightness. This palette is built ONCE and never
109+
# rewritten per frame, so the dim has to be baked in here — and the
110+
# owner's cache key must include the scale, or a brightness change would
111+
# keep serving this layer at the old brightness (see
112+
# _GradientFillMixin._ensure_grad).
113+
dim = getattr(display, "color_scale", 1.0)
114+
if dim >= 1.0:
115+
for i in range(n):
116+
palette[1 + i] = ramp[i]
117+
else:
118+
for i in range(n):
119+
palette[1 + i] = _scale(ramp[i], dim)
109120

110121
# Each lit pixel's palette index is its position along the axis (NO modulo,
111122
# so the ramp spans the whole word end-to-end rather than tiling). One O(1)

0 commit comments

Comments
 (0)