Commit 34b8704d by PLN (Algolia)

devcon: black page, gradient-filled PARVAGUES, D1-D8 faders walk the gradient

parent 430d10a5
...@@ -391,9 +391,12 @@ class Theme(NamedTuple): ...@@ -391,9 +391,12 @@ class Theme(NamedTuple):
# DevCon artwork's graph paper. # DevCon artwork's graph paper.
scan_grid: float scan_grid: float
# The PARVAGUES wordmark (`l`) as the site's hero headline instead of the # The PARVAGUES wordmark (`l`) as the site's hero headline instead of the
# neon: Jersey 15, hero tracking, navy on a baked gradient plate. The # neon: Jersey 15, hero tracking, the letters FILLED with the artwork's
# theme decides, not --word-face, so no other page ever wears the plate. # gradient, no glow. The theme decides, not --word-face.
word_plate: bool word_hero: bool
# Row D's faders, one hue per column (D1..D8), or () for "the row's family
# hue like every other row". All eight up is the whole gradient at once.
fader_hue: tuple[float, ...]
# The dark ink ramp, sampled at levels 1..4, IS the dark surface ramp -- which # The dark ink ramp, sampled at levels 1..4, IS the dark surface ramp -- which
...@@ -413,7 +416,7 @@ THEMES: dict[str, Theme] = { ...@@ -413,7 +416,7 @@ THEMES: dict[str, Theme] = {
scan=((0xa0, 0x60, 0xff, 16), (0xa0, 0x60, 0xff, 9)), scan=((0xa0, 0x60, 0xff, 16), (0xa0, 0x60, 0xff, 9)),
bar_frac=0.22, bold=False, ui_font="mono", bar_frac=0.22, bold=False, ui_font="mono",
fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0, fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0,
word_plate=False, word_hero=False, fader_hue=(),
), ),
# Indoors on a pale desktop. Still a lens, still the violet arc, still a # Indoors on a pale desktop. Still a lens, still the violet arc, still a
# decay gradient the eye can follow -- the ink simply runs the other way. # decay gradient the eye can follow -- the ink simply runs the other way.
...@@ -434,7 +437,7 @@ THEMES: dict[str, Theme] = { ...@@ -434,7 +437,7 @@ THEMES: dict[str, Theme] = {
scan=((0x50, 0x3a, 0x78, 10), (0x50, 0x3a, 0x78, 6)), scan=((0x50, 0x3a, 0x78, 10), (0x50, 0x3a, 0x78, 6)),
bar_frac=0.22, bold=False, ui_font="mono", bar_frac=0.22, bold=False, ui_font="mono",
fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0, fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0,
word_plate=False, word_hero=False, fader_hue=(),
), ),
# Direct sunlight, and built for nothing else. Near-white page, heavy ink # Direct sunlight, and built for nothing else. Near-white page, heavy ink
# at every decay level (a "dim" state outdoors is a blank state), thicker # at every decay level (a "dim" state outdoors is a blank state), thicker
...@@ -449,7 +452,9 @@ THEMES: dict[str, Theme] = { ...@@ -449,7 +452,9 @@ THEMES: dict[str, Theme] = {
# linear hue drift, corail 21 deg down through magenta 296 to cyan -- # linear hue drift, corail 21 deg down through magenta 296 to cyan --
# wrapped, see _build_palette. Family hues stay the orbit identity. # wrapped, see _build_palette. Family hues stay the orbit identity.
"devcon": Theme( "devcon": Theme(
name="devcon", bg=(0x0d, 0x18, 0x33), # Black, not the site's navy (PLN, 2026-09-28: navy "doesnt fit well
# the Rose Rouge bg"): the event's colours on the cockpit's black.
name="devcon", bg=(0x06, 0x06, 0x0a),
# Vivid, not grey (PLN, 2026-09-28: "a bit grey, make this pop"): the # Vivid, not grey (PLN, 2026-09-28: "a bit grey, make this pop"): the
# ink keeps its saturation to the top instead of paling out, and the # ink keeps its saturation to the top instead of paling out, and the
# cell bodies carry the hue at every heat step. The grey was low # cell bodies carry the hue at every heat step. The grey was low
...@@ -472,7 +477,10 @@ THEMES: dict[str, Theme] = { ...@@ -472,7 +477,10 @@ THEMES: dict[str, Theme] = {
# #df96ef, gate = mint #07ffda, gate2 = cyan #46dbee, family = orange # #df96ef, gate = mint #07ffda, gate2 = cyan #46dbee, family = orange
# #ff7700, other = the chrome's blue, note = the artwork's rose. # #ff7700, other = the chrome's blue, note = the artwork's rose.
fam_hue=(223, 16, 290, 171, 191, 28, 216, 350), fam_hue=(223, 16, 290, 171, 191, 28, 216, 350),
tint_over_page=True, scan_grid=14.0, word_plate=True, tint_over_page=True, scan_grid=14.0, word_hero=True,
# corail 16 deg walking DOWN the wheel to cyan 191, through rose,
# magenta, violet and blue: the artwork's sweep, one step per orbit.
fader_hue=tuple((16 - 185 * i / 7) % 360 for i in range(8)),
), ),
# Direct sunlight, and built for nothing else. Near-white page, heavy ink # Direct sunlight, and built for nothing else. Near-white page, heavy ink
# at every decay level (a "dim" state outdoors is a blank state), thicker # at every decay level (a "dim" state outdoors is a blank state), thicker
...@@ -500,7 +508,7 @@ THEMES: dict[str, Theme] = { ...@@ -500,7 +508,7 @@ THEMES: dict[str, Theme] = {
scan=None, scan=None,
bar_frac=0.34, bold=True, ui_font="mono", bar_frac=0.34, bold=True, ui_font="mono",
fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0, fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0,
word_plate=False, word_hero=False, fader_hue=(),
), ),
} }
# Cycle order for the `d` key and the menu: darkest to brightest page, so the # Cycle order for the `d` key and the menu: darkest to brightest page, so the
...@@ -585,17 +593,11 @@ WORD_FACES = ("syne", "jersey") ...@@ -585,17 +593,11 @@ WORD_FACES = ("syne", "jersey")
JERSEY_UI_SIZE = 1.3 # px multiplier when Jersey sets the digits JERSEY_UI_SIZE = 1.3 # px multiplier when Jersey sets the digits
WORD_ALPHA = 0.8 WORD_ALPHA = 0.8
# The DevCon hero, read off algolia.com/devcon (.devcon-hero__headline): Jersey # The DevCon hero, read off algolia.com/devcon (.devcon-hero__headline): Jersey
# 15 at weight 400, letter-spacing -2.72px on 136px (-2%), --devcon-navy. The # 15 at weight 400, letter-spacing -2.72px on 136px (-2%). The site sets it navy
# plate is the artwork's own gradient (corail -> magenta -> cyan, measured on # on its gradient; here the gradient goes INTO the letters (corail -> magenta ->
# the intro loop and the header) with its pale grid, because navy type needs # cyan, measured on the intro loop and the header), because a plate behind
# the bright page the site sets it on. # them hid D1-D3's levels (PLN, 2026-09-28).
PLATE_INK = "#14283f" HERO_STOPS = ((0.0, "#fd956e"), (0.55, "#df96ef"), (1.0, "#46dbee"))
PLATE_STOPS = ((0.0, "#fd956e"), (0.55, "#df96ef"), (1.0, "#46dbee"))
PLATE_GRID = (0xff, 0xff, 0xff, 46)
# Only the gradient is translucent; the letters stay solid. PLN, 2026-09-28:
# "ensure that we still see the D1-3 under the lettering" -- those three cells
# are wired controls, and a sticker that hid them would read as unwired.
PLATE_ALPHA = 0.5
# ── the HL feed ──────────────────────────────────────────────────────────── # ── the HL feed ────────────────────────────────────────────────────────────
# PLN, 2026-09-24: "please do indeed sub from the events, to indeed feed # PLN, 2026-09-24: "please do indeed sub from the events, to indeed feed
...@@ -1480,15 +1482,17 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1480,15 +1482,17 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
for hue in th.fam_hue: for hue in th.fam_hue:
row = [] row = []
for t in th.tint_t: for t in th.tint_t:
val, sat = th.tint.at(t) row.append(self._surface(th, hue, t))
if th.tint_over_page:
c = QtGui.QColor.fromHsvF(hue / 360.0, sat, 1.0)
row.append(QtGui.QColor(
*(round(b + (f - b) * val) for b, f in
zip(th.bg, (c.red(), c.green(), c.blue())))))
else:
row.append(QtGui.QColor.fromHsvF(hue / 360.0, sat, val))
self.tint.append(row) self.tint.append(row)
# Row D's per-column ramps, when the theme has them: same recipe
# as a family, one hue per fader.
self.fader_lut, self.fader_tint = [], []
for hue in th.fader_hue:
self.fader_lut.append([
QtGui.QColor.fromHsvF(hue / 360.0, sat, val)
for val, sat in (th.ink.at(lv / (LEVELS - 1))
for lv in range(LEVELS))])
self.fader_tint.append([self._surface(th, hue, t) for t in th.tint_t])
# Chrome (border, row letters, CC reference digits, the two header # Chrome (border, row letters, CC reference digits, the two header
# controls) is the FAM_OTHER ink ramp on dark — one fewer ramp to # controls) is the FAM_OTHER ink ramp on dark — one fewer ramp to
# keep honest — but needs to be its own near-neutral ink on a light # keep honest — but needs to be its own near-neutral ink on a light
...@@ -1525,6 +1529,17 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1525,6 +1529,17 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# blitted over a white page, once per frame, forever. # blitted over a white page, once per frame, forever.
self._scan = None self._scan = None
@staticmethod
def _surface(th, hue, t):
"""One cell-body shade: an HSV step, or the hue laid over the page
(`tint_over_page`)."""
val, sat = th.tint.at(t)
if th.tint_over_page:
c = QtGui.QColor.fromHsvF(hue / 360.0, sat, 1.0)
return QtGui.QColor(*(round(b + (f - b) * val) for b, f in
zip(th.bg, (c.red(), c.green(), c.blue()))))
return QtGui.QColor.fromHsvF(hue / 360.0, sat, val)
# ── the theme axis ───────────────────────────────────────────────── # ── the theme axis ─────────────────────────────────────────────────
def set_theme(self, name: str, save: bool = True): def set_theme(self, name: str, save: bool = True):
"""Switch palettes. Idempotent, and safe with an unknown name.""" """Switch palettes. Idempotent, and safe with an unknown name."""
...@@ -1879,7 +1894,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1879,7 +1894,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
spacing and kerned sub-advances disagreed, and that arithmetic is spacing and kerned sub-advances disagreed, and that arithmetic is
exactly how "PARVAGU" lost its tail. The bake measures its own exactly how "PARVAGU" lost its tail. The bake measures its own
ink instead (see _word_pixmap).""" ink instead (see _word_pixmap)."""
plate = self.theme.word_plate plate = self.theme.word_hero
face = "jersey" if plate else self._word_face face = "jersey" if plate else self._word_face
key = (face, plate, round(target_w), round(target_h)) key = (face, plate, round(target_w), round(target_h))
if self._word_font_key == key: if self._word_font_key == key:
...@@ -1935,14 +1950,13 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1935,14 +1950,13 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
PARVAGU"), and it must FIT the D1 cell at the cell's margin — PARVAGU"), and it must FIT the D1 cell at the cell's margin —
which means the TYPE shrinks to the width, honestly, rather than which means the TYPE shrinks to the width, honestly, rather than
a squash that would make the letters strangers to themselves.""" a squash that would make the letters strangers to themselves."""
plate = self.theme.word_plate plate = self.theme.word_hero
key = (round(span_w), round(span_h), plate) key = (round(span_w), round(span_h), plate)
if self._word_pm_key == key: if self._word_pm_key == key:
return self._word_pm return self._word_pm
f = self._wave_font(span_w, span_h) f = self._wave_font(span_w, span_h)
if f is None: if f is None:
return None return None
ink_col = QtGui.QColor(PLATE_INK if plate else "#d900ff")
def bake(px, canvas_w, glow=not plate): def bake(px, canvas_w, glow=not plate):
fb = QtGui.QFont(f) fb = QtGui.QFont(f)
...@@ -1970,10 +1984,18 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1970,10 +1984,18 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
qp.translate(math.cos(a) * rad, math.sin(a) * rad) qp.translate(math.cos(a) * rad, math.sin(a) * rad)
self._draw_word_layer(qp, base, pad_b, fmb, glow_pen) self._draw_word_layer(qp, base, pad_b, fmb, glow_pen)
qp.restore() qp.restore()
# the core: --neon-high itself, the site's own pink (or, # the core: --neon-high itself, the site's own pink -- or,
# on the plate, the DevCon navy -- pure, no glow) # for the DevCon hero, the artwork's gradient THROUGH the
self._draw_word_layer(qp, base, pad_b, fmb, # letters, pure, no glow: a fill between the strokes would
QtGui.QPen(ink_col)) # hide D1-D3, and those are wired faders.
if plate:
g = QtGui.QLinearGradient(pad_b, 0, canvas_w - pad_b, 0)
for at, col in HERO_STOPS:
g.setColorAt(at, QtGui.QColor(col))
pen = QtGui.QPen(QtGui.QBrush(g), 1.0)
else:
pen = QtGui.QPen(QtGui.QColor("#d900ff"))
self._draw_word_layer(qp, base, pad_b, fmb, pen)
finally: finally:
qp.end() qp.end()
return img, pad_b return img, pad_b
...@@ -2013,38 +2035,9 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -2013,38 +2035,9 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
min(w_, right - left + 1 + pad), min(w_, right - left + 1 + pad),
img.height()) img.height())
pm = QtGui.QPixmap.fromImage(cropped) pm = QtGui.QPixmap.fromImage(cropped)
if plate:
pm = self._plate_pixmap(span_w, span_h, pm)
self._word_pm, self._word_pm_key = pm, key self._word_pm, self._word_pm_key = pm, key
return pm return pm
def _plate_pixmap(self, span_w, span_h, word_pm):
"""The DevCon hero as one baked picture: gradient, grid, word.
Built once per geometry like the word itself, so a frame is still
one blit and constructs no gradient."""
pw, ph = max(1, round(span_w)), max(1, round(span_h))
pm = QtGui.QPixmap(pw, ph)
pm.fill(QtGui.QColor(0, 0, 0, 0))
qp = QtGui.QPainter(pm)
try:
g = QtGui.QLinearGradient(0, 0, pw, ph)
for at, col in PLATE_STOPS:
g.setColorAt(at, QtGui.QColor(col))
qp.setOpacity(PLATE_ALPHA)
qp.fillRect(0, 0, pw, ph, QtGui.QBrush(g))
qp.setPen(QtGui.QColor(*PLATE_GRID))
step = max(4, round(ph / 5))
for x in range(step, pw, step):
qp.drawLine(x, 0, x, ph)
for y in range(step, ph, step):
qp.drawLine(0, y, pw, y)
qp.setOpacity(1.0)
qp.drawPixmap(round((pw - word_pm.width()) / 2.0),
round((ph - word_pm.height()) / 2.0), word_pm)
finally:
qp.end()
return pm
def _col_has_ink(self, img, x): def _col_has_ink(self, img, x):
return any(img.pixelColor(x, y).alpha() > 8 return any(img.pixelColor(x, y).alpha() > 8
for y in range(img.height())) for y in range(img.height()))
...@@ -2060,8 +2053,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -2060,8 +2053,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if pm is None: if pm is None:
return return
row_y = self.my + 3 * self.ch # PHYSICAL_ORDER index of D row_y = self.my + 3 * self.ch # PHYSICAL_ORDER index of D
# the plate carries its own alpha (see PLATE_ALPHA) p.setOpacity(WORD_ALPHA)
p.setOpacity(1.0 if self.theme.word_plate else WORD_ALPHA)
try: try:
# clamped to the span's outer rect: at the smallest windows # clamped to the span's outer rect: at the smallest windows
# the glow ring can reach 1px past the inner margin, and a # the glow ring can reach 1px past the inner margin, and a
...@@ -2529,36 +2521,42 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -2529,36 +2521,42 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
cwi = max(2, round(wcol) - self.inset) cwi = max(2, round(wcol) - self.inset)
chi = max(2, round(ch) - self.inset) chi = max(2, round(ch) - self.inset)
fam = self._fam_for_cc(cc) fam = self._fam_for_cc(cc)
if row == "D" and self.fader_lut:
ink, surf = self.fader_lut[col - 1], self.fader_tint[col - 1]
digit = ink
else:
ink, surf = self.lut[fam], self.tint[fam]
digit = None
st = self.cc.get(cc) st = self.cc.get(cc)
if st is None: if st is None:
# FLOOR TINT, never black and never invisible: a # FLOOR TINT, never black and never invisible: a
# mapped control that simply has not moved must not # mapped control that simply has not moved must not
# read as unmapped. On a light page that means a wash # read as unmapped. On a light page that means a wash
# BELOW the page white, which is what tint[..][0] is. # BELOW the page white, which is what tint[..][0] is.
p.fillRect(x, y, cwi, chi, self.tint[fam][0]) p.fillRect(x, y, cwi, chi, surf[0])
p.setPen(self.chrome[3]) p.setPen(self.chrome[3])
p.drawText(x + 2, y + chi - 3, "··" if two else "·") p.drawText(x + 2, y + chi - 3, "··" if two else "·")
continue continue
v, t, chn = st v, t, chn = st
lv = self._lv(now - t, floor=2) lv = self._lv(now - t, floor=2)
# heat: the value tints the cell body even when cold # heat: the value tints the cell body even when cold
p.fillRect(x, y, cwi, chi, self.tint[fam][int(3 * v / 127)]) p.fillRect(x, y, cwi, chi, surf[int(3 * v / 127)])
bar_h = max(2, round(chi * self.theme.bar_frac)) bar_h = max(2, round(chi * self.theme.bar_frac))
if cc in grid.BUTTON_CCS: if cc in grid.BUTTON_CCS:
# buttons are latches: filled above half, hollow below # buttons are latches: filled above half, hollow below
if v >= 64: if v >= 64:
p.fillRect(x + 1, y + chi - bar_h - 1, cwi - 2, bar_h, p.fillRect(x + 1, y + chi - bar_h - 1, cwi - 2, bar_h,
self.lut[fam][lv]) ink[lv])
else: else:
p.setPen(self.lut[fam][max(3, lv - 5)]) p.setPen(ink[max(3, lv - 5)])
p.drawRect(x + 1, y + chi - bar_h - 1, p.drawRect(x + 1, y + chi - bar_h - 1,
max(1, cwi - 3), max(1, bar_h - 1)) max(1, cwi - 3), max(1, bar_h - 1))
else: else:
p.fillRect(x + 1, y + chi - bar_h - 1, p.fillRect(x + 1, y + chi - bar_h - 1,
max(1, round((cwi - 2) * v / 127)), bar_h, max(1, round((cwi - 2) * v / 127)), bar_h,
self.lut[fam][lv]) ink[lv])
# the channel shifts the hue of the digits, not of the cell # the channel shifts the hue of the digits, not of the cell
p.setPen(self.lut[(fam + chn) % 8][lv]) p.setPen((digit or self.lut[(fam + chn) % 8])[lv])
p.drawText(x + 2, y + self.m_asc, p.drawText(x + 2, y + self.m_asc,
("%02X" % v) if two else HEX[v >> 3]) ("%02X" % v) if two else HEX[v >> 3])
# The HL edge, LAST so nothing paints over it: a brief # The HL edge, LAST so nothing paints over it: a brief
...@@ -2570,7 +2568,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -2570,7 +2568,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if ht is not None: if ht is not None:
hlv = self._lv(now - ht) hlv = self._lv(now - ht)
if hlv > 0: if hlv > 0:
p.setPen(self.lut[fam][hlv]) p.setPen(ink[hlv])
p.setBrush(Qt.BrushStyle.NoBrush) p.setBrush(Qt.BrushStyle.NoBrush)
p.drawRect(x, y, max(1, cwi - 1), max(1, chi - 1)) p.drawRect(x, y, max(1, cwi - 1), max(1, chi - 1))
......
...@@ -1167,17 +1167,17 @@ def test_devcon_sets_its_digits_in_jersey_and_the_ribbon_keeps_its_grid(): ...@@ -1167,17 +1167,17 @@ def test_devcon_sets_its_digits_in_jersey_and_the_ribbon_keeps_its_grid():
assert "Jersey" not in w.f_main.family() and w._hex_mono is True assert "Jersey" not in w.f_main.family() and w._hex_mono is True
def test_only_devcon_wears_the_hero_plate_and_it_fills_its_span(): def test_only_devcon_wears_the_hero_and_its_faders_walk_the_gradient():
import pytest import pytest
mv, w = _themed("devcon") mv, w = _themed("devcon")
if mv is None: if mv is None:
pytest.skip("no Qt available") pytest.skip("no Qt available")
assert [n for n, th in mv.THEMES.items() if th.word_plate] == ["devcon"] assert [n for n, th in mv.THEMES.items() if th.word_hero] == ["devcon"]
if w._jersey_id < 0: assert [n for n, th in mv.THEMES.items() if th.fader_hue] == ["devcon"]
pytest.skip("Jersey not vendored on this checkout") # eight faders, eight different hottest inks: all up is the whole sweep
pm = w._word_pixmap(300.0, 60.0) assert len(w.fader_lut) == 8 and len(w.fader_tint) == 8
assert (pm.width(), pm.height()) == (300, 60), "the plate is the span, exactly" assert len({lut[15].rgba() for lut in w.fader_lut}) == 8
# a theme switch must not reuse the plate as the neon word for lut in w.fader_lut:
assert len(lut) == mv.LEVELS
w.set_theme("dark", save=False) w.set_theme("dark", save=False)
neon = w._word_pixmap(300.0, 60.0) assert w.fader_lut == [], "a theme switch must drop the gradient faders"
assert neon is not pm
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment