Add SayoDevice support, 15 key pads and theme styles

Second protocol family (8089:000b) behind a new backend seam, 15 key and 3 knob pads, Knobs North plus the style and colour grid, Windows import fix, window resize fix.
This commit is contained in:
Mohamed Mahjoub
2026-10-08 22:14:31 +01:00
parent 6e25e350e0
commit 6a419eb3b6
34 changed files with 3410 additions and 572 deletions

View File

@@ -16,11 +16,12 @@ import zlib
from dataclasses import dataclass
from PySide6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF,
from PySide6.QtGui import (QRadialGradient, QBrush, QColor, QFont, QFontDatabase, QFontMetricsF,
QPainter, QPainterPath, QPen, QTransform)
from PySide6.QtWidgets import QSizePolicy, QWidget
from . import core, themes
from .vectors import KNOB_ARC
THEME = themes.get(themes.DEFAULT)
@@ -35,12 +36,24 @@ TAPE = QColor(THEME.accent) # Kapton orange, in the default theme
def apply_theme(theme):
global THEME
global THEME, K, G, GY, M, R_OUT, R_IN, R_KNOB, ROW_H, ROW_GAP, ROW_SEP
global DIAL_W, KEY_R, LEGEND_R, RULE_X, LABEL_X
global BYTE_PT, LABEL_PT, ROW_PT, ROW_BYTE_PT, BADGE
THEME = theme
for colour, value in ((WINDOW, theme.window), (BOARD, theme.board),
(INK, theme.ink), (INK_DIM, theme.ink_dim),
(HATCH, theme.hatch), (TAPE, theme.accent)):
colour.setRgba(QColor(value).rgba())
m = theme.metrics
K, G, GY, M = m.key, m.gap_x, m.gap_y, m.margin
R_OUT, R_IN, R_KNOB = m.r_out, m.r_in, m.r_knob
ROW_H, ROW_GAP, ROW_SEP = m.row_h, m.row_gap, m.row_sep
DIAL_W = m.dial_w
KEY_R, LEGEND_R = m.key_radius, m.legend_radius
RULE_X, LABEL_X = m.rule_x, m.label_x
BYTE_PT, LABEL_PT = m.byte_pt, m.label_pt
ROW_PT, ROW_BYTE_PT = m.row_pt, m.row_byte_pt
BADGE = m.badge
_FAMILIES = None
@@ -92,11 +105,19 @@ def _wobble_path(path, amount, seed, step=4.0):
out.lineTo(q)
return out
# Geometry in board units; the view scales it to fit.
K, G, M = 96.0, 16.0, 30.0 # key size, gap, board margin
R_OUT, R_IN, R_KNOB = 54.0, 40.0, 30.0
ROW_H, ROW_GAP = 22.0, 12.0
DIAL_W = 160.0
# Geometry in board units; the view scales it to fit. These come from the
# active theme's Metrics and are refreshed by apply_theme, so a theme can
# change the pad's proportions and not just its colours.
_M = themes.Metrics()
K, G, GY, M = _M.key, _M.gap_x, _M.gap_y, _M.margin
R_OUT, R_IN, R_KNOB = _M.r_out, _M.r_in, _M.r_knob
ROW_H, ROW_GAP, ROW_SEP = _M.row_h, _M.row_gap, _M.row_sep
DIAL_W = _M.dial_w
KEY_R, LEGEND_R = _M.key_radius, _M.legend_radius
RULE_X, LABEL_X = _M.rule_x, _M.label_x
BYTE_PT, LABEL_PT, ROW_PT, ROW_BYTE_PT = (_M.byte_pt, _M.label_pt,
_M.row_pt, _M.row_byte_pt)
BADGE = _M.badge
@dataclass
class Look:
@@ -149,9 +170,16 @@ class PadView(QWidget):
self._wobbler = QTimer(self)
self._wobbler.setInterval(130)
self._wobbler.timeout.connect(self._tick)
self.mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self._sys_mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self._layout()
@property
def mono(self):
"""The theme's monospace face when it ships one, else the system's."""
if THEME.mono_font and THEME.mono_font in _families():
return QFont(THEME.mono_font)
return self._sys_mono
# ------------------------------------------------------------ public
def set_orientation(self, orientation):
@@ -209,7 +237,7 @@ class PadView(QWidget):
left = lay.knob_side == "left" and bool(lay.knobs)
keys_w = grid_cols * (K + G) - G
keys_h = grid_rows * (K + G) - G
keys_h = grid_rows * (K + GY) - GY
# how much room the knobs need across, and down from the first centre
dials_w = lay.knobs * (DIAL_W + G) - G if lay.knobs else 0
dials_h = (R_OUT + (lay.knobs - 1) * (stack + 20 + R_OUT) + stack
@@ -243,17 +271,21 @@ class PadView(QWidget):
for r in range(grid_rows):
for c in range(grid_cols):
self.cells[(r, c)] = QRectF(grid_left + c * (K + G),
grid_top + r * (K + G), K, K)
grid_top + r * (K + GY), K, K)
for key, r, c in _key_grid(lay, self.orientation):
self.keys[key] = self.cells[(r, c)]
for dial, centre in zip(lay.dial_ids, dial_centres):
self.dials[dial] = centre
top = centre.y() + R_OUT + ROW_GAP
# With a boxed legend the rows butt together and the block's own
# colour shows through as a hairline; otherwise they are loose.
inset = 0.0 if THEME.legend_box else 6.0
step = ROW_H + ROW_SEP
for i, act in enumerate(core.DIAL_ACTIONS):
self.rows[f"{dial}-{act}"] = QRectF(
centre.x() - DIAL_W / 2 + 6, top + i * ROW_H,
DIAL_W - 12, ROW_H)
centre.x() - DIAL_W / 2 + inset, top + i * step,
DIAL_W - 2 * inset, ROW_H)
boxes = list(self.cells.values()) + list(self.rows.values())
right = max(r.right() for r in boxes)
@@ -274,13 +306,41 @@ class PadView(QWidget):
# ----------------------------------------------------------- hit test
def _arc_outline(self, c, mirror):
"""
One turn arc, built from the outline in the Figma file. Coordinates
there are multiples of the knob's radius measured from its centre,
so this works at any size. The clockwise arc is the same shape
mirrored.
"""
path = QPainterPath()
s = R_KNOB
for op, a in KNOB_ARC:
pts = [QPointF(c.x() + (-a[i] if mirror else a[i]) * s,
c.y() + a[i + 1] * s)
for i in range(0, len(a), 2)]
if op == "M":
path.moveTo(pts[0])
elif op == "L":
path.lineTo(pts[0])
elif op == "Q":
path.quadTo(pts[0], pts[1])
elif op == "C":
path.cubicTo(pts[0], pts[1], pts[2])
else:
path.closeSubpath()
return path
def _segment(self, dial, act):
"""Clickable area for one dial action, in board units."""
c = self.dials[dial]
path = QPainterPath()
if act == "push":
path.addEllipse(c, R_IN - 2, R_IN - 2)
path.addEllipse(c, (R_KNOB if THEME.knob_art else R_IN) - 2,
(R_KNOB if THEME.knob_art else R_IN) - 2)
return path
if THEME.knob_art == "figma":
return self._arc_outline(c, mirror=(act == "right"))
start, span = (100, 160) if act == "left" else (-80, 160)
outer = QRectF(c.x() - R_OUT, c.y() - R_OUT, 2 * R_OUT, 2 * R_OUT)
inner = QRectF(c.x() - R_IN, c.y() - R_IN, 2 * R_IN, 2 * R_IN)
@@ -494,7 +554,8 @@ class PadView(QWidget):
"""roomy: there's space to spare, as on a keycap; not on a dial legend."""
if THEME.font and THEME.font in _families():
f = QFont(THEME.font)
f.setPixelSize(px + (2 if roomy else 0)) # handwriting reads small
# handwriting reads small; a normal face does not need the bump
f.setPixelSize(px + (2 if roomy and THEME.wobble else 0))
elif THEME.mono_legends:
f = QFont(self.mono)
f.setPixelSize(px - 1)
@@ -616,8 +677,58 @@ class PadView(QWidget):
i += 1
p.restore()
# ----------------------------------------------------------- surfaces
@staticmethod
def _well_brush(rect, radial=False):
"""
The tile and knob gradients. Figma calls the tile one a diamond
gradient, which Qt has no equivalent for; a radial one reaching the
corners is the closest honest approximation and reads the same, as a
face that darkens towards its edge.
"""
if not THEME.well:
return QColor(THEME.board)
c = rect.center()
r = (math.hypot(rect.width(), rect.height()) / 2 if not radial
else max(rect.width(), rect.height()) / 2)
g = QRadialGradient(c, r)
g.setColorAt(0.0, QColor(THEME.board))
g.setColorAt(0.75 if radial else 0.745, QColor(THEME.board))
g.setColorAt(0.9 if radial else 1.0, QColor(THEME.well))
g.setColorAt(1.0, QColor(THEME.well))
return QBrush(g)
def _glow(self, p, path, colour):
"""
A few wide, nearly transparent passes under a stroke, so an accent
edge blooms the way it does in the design.
"""
if not THEME.glow:
return
p.save()
p.setBrush(Qt.NoBrush)
for i, (width, alpha) in enumerate(((THEME.glow, 26),
(THEME.glow * 0.6, 44),
(THEME.glow * 0.3, 70))):
c = QColor(colour)
c.setAlpha(alpha)
pen = QPen(c, width)
pen.setJoinStyle(Qt.RoundJoin)
pen.setCapStyle(Qt.RoundCap)
p.setPen(pen)
p.drawPath(path)
p.restore()
def _tape(self, p, at, length=30.0, width=9.0):
"""A strip of Kapton stuck across a corner."""
"""
The marker for an edit that hasn't reached the pad yet. Normally a
strip of Kapton across the corner; "star" themes stamp a little
asterisk tile instead.
"""
if THEME.badge == "star":
self._star_badge(p, at, max(11.0, width * 1.6))
return
p.save()
p.translate(at)
p.rotate(45)
@@ -628,37 +739,123 @@ class PadView(QWidget):
p.drawRect(QRectF(-length / 2, -width / 2, length, width))
p.restore()
@staticmethod
def _corner_path(rect, tl=0.0, tr=0.0, br=0.0, bl=0.0):
"""
A rectangle with each corner rounded by its own radius, built as one
continuous outline. Adding overlapping sub-paths instead would cancel
under the odd-even fill rule and leave the shape hollow.
"""
path = QPainterPath()
path.moveTo(rect.left() + tl, rect.top())
path.lineTo(rect.right() - tr, rect.top())
if tr:
path.quadTo(rect.right(), rect.top(), rect.right(), rect.top() + tr)
path.lineTo(rect.right(), rect.bottom() - br)
if br:
path.quadTo(rect.right(), rect.bottom(),
rect.right() - br, rect.bottom())
path.lineTo(rect.left() + bl, rect.bottom())
if bl:
path.quadTo(rect.left(), rect.bottom(), rect.left(), rect.bottom() - bl)
path.lineTo(rect.left(), rect.top() + tl)
if tl:
path.quadTo(rect.left(), rect.top(), rect.left() + tl, rect.top())
path.closeSubpath()
return path
def _star_badge(self, p, at, size=16.0):
"""A filled tile with an asterisk on it, for a knob row."""
box = QRectF(at.x() - size / 2, at.y() - size / 2, size, size)
self._flag_shape(p, box, THEME.badge_glyph or "*", QColor(TAPE),
QColor(THEME.accent_text))
def _corner_flag(self, p, rect, glyph, fill, pen_colour, italic=False):
"""
The tab tucked into a key's top right corner: square, with only the
inner corner rounded, so it reads as folded into the tile.
"""
size = min(BADGE, rect.width() * 0.26)
box = QRectF(rect.right() - size - KEY_R * 0.35,
rect.top() + KEY_R * 0.18, size, size)
self._flag_shape(p, box, glyph, fill, pen_colour, italic)
def _flag_shape(self, p, box, glyph, fill, pen_colour, italic=False):
p.save()
r = box.width() * 0.28
# square against the tile's own corner, rounded on the two inner ones
path = self._corner_path(box, tl=r, tr=0.0, br=r, bl=0.0)
p.setPen(Qt.NoPen)
p.setBrush(fill)
p.drawPath(path)
f = QFont(self.mono)
f.setPixelSize(max(7, round(box.height() * 0.95)))
f.setWeight(QFont.ExtraBold)
f.setItalic(italic)
p.setFont(f)
p.setPen(pen_colour)
# the asterisk sits high in the em box; nudge it back to the middle
lift = box.height() * (0.18 if glyph == "*" else 0.0)
p.drawText(box.translated(0, lift), Qt.AlignCenter, glyph)
p.restore()
def _paint_key(self, p, key, rect):
look = self.looks.get(key, Look())
path = QPainterPath()
path.addRoundedRect(rect, 5, 5)
path.addRoundedRect(rect, KEY_R, KEY_R)
unknown = not look.known and not look.pending
if THEME.well:
p.fillPath(path, self._well_brush(rect))
if unknown:
self._unknown_fill(p, path, rect, key)
if key == self.current or look.pending:
self._glow(p, path, TAPE)
self._stroke(p, path, self._outline_pen(key, look.known or look.pending), key)
f = QFont(self.mono)
f.setPixelSize(10)
f.setPixelSize(max(6, round(BYTE_PT)))
if THEME.mono_font:
f.setItalic(True)
f.setWeight(QFont.Bold)
p.setFont(f)
p.setPen(INK_DIM)
p.drawText(rect.adjusted(8, 6, -8, -6), Qt.AlignLeft | Qt.AlignTop,
p.setPen(QColor(THEME.byte_ink) if THEME.byte_ink else INK_DIM)
bi = THEME.metrics.byte_inset
byte_at = (rect.adjusted(bi[0] * rect.width(), bi[1] * rect.height(),
-bi[0] * rect.width(), 0)
if bi else rect.adjusted(6, 4, -6, -4))
p.drawText(byte_at, Qt.AlignLeft | Qt.AlignTop,
f"0x{core.action_byte(key):02x}")
f = self._legend_font(13)
f = self._legend_font(round(LABEL_PT))
p.setFont(f)
if look.pending:
if key == self.current:
p.setPen(TAPE)
elif look.pending:
# the design marks a change with the corner flag, not the words
p.setPen(TAPE if not THEME.badge_glyph else INK)
else:
p.setPen(INK if look.known and not look.quiet else INK_DIM)
text = self._lettering(look.text if (look.known or look.pending) else "Unknown")
body = rect.adjusted(8, 22, -8, -10)
lb = THEME.metrics.label_band
body = (QRectF(rect.left() + lb[0] * rect.width(),
rect.top() + lb[1] * rect.height(),
rect.width() * (1 - lb[0] - lb[2]),
rect.height() * (1 - lb[1] - lb[3]))
if lb else rect.adjusted(8, 22, -8, -10))
# Let 'Meta+Ctrl+Left' break after a '+' instead of being cut off.
text = text.replace("+", "+\u200b")
p.drawText(body, Qt.AlignCenter | Qt.TextWordWrap,
self._fit(text, f, body))
if look.pending:
self._tape(p, QPointF(rect.right() - 7, rect.top() + 7))
if THEME.badge == "star":
self._corner_flag(p, rect, THEME.badge_glyph or "*",
QColor(TAPE), QColor(THEME.accent_text))
else:
self._tape(p, QPointF(rect.right() - 7, rect.top() + 7))
elif unknown and THEME.query_badge:
self._corner_flag(p, rect, "?", QColor(THEME.hatch), INK_DIM,
italic=True)
def _paint_turn_icon(self, p, centre, clockwise, colour):
r = 5.5
@@ -688,12 +885,16 @@ class PadView(QWidget):
look = self.looks.get(control, Look())
seg = self._segment(dial, act)
if act != "push":
if THEME.well:
p.fillPath(seg, self._well_brush(seg.boundingRect()))
if not look.known and not look.pending:
self._unknown_fill(p, seg, seg.boundingRect(), control, 7.0)
if control == self.current:
fill = QColor(TAPE)
fill.setAlpha(55)
fill.setAlpha(55 if not THEME.glow else 28)
p.fillPath(seg, fill)
if control == self.current or look.pending:
self._glow(p, seg, TAPE)
self._stroke(p, seg, self._outline_pen(control, look.known or look.pending),
control)
@@ -701,12 +902,16 @@ class PadView(QWidget):
look = self.looks.get(push, Look())
knob = QPainterPath()
knob.addEllipse(c, R_KNOB, R_KNOB)
if THEME.well:
p.fillPath(knob, self._well_brush(knob.boundingRect(), radial=True))
if not look.known and not look.pending:
self._unknown_fill(p, knob, knob.boundingRect(), push, 7.0)
if push == self.current:
fill = QColor(TAPE)
fill.setAlpha(55)
fill.setAlpha(55 if not THEME.glow else 28)
p.fillPath(knob, fill)
if push == self.current or look.pending:
self._glow(p, knob, TAPE)
self._stroke(p, knob, self._outline_pen(push, look.known or look.pending), push)
# knurling, the way a footprint marks a rotary encoder
if not THEME.wobble:
@@ -726,13 +931,31 @@ class PadView(QWidget):
p.drawText(label, Qt.AlignCenter, self._lettering(f"Dial {dial[4:]}"))
# Legend rows: the silkscreen text next to the part.
if THEME.legend_box:
self._paint_legend_box(p, dial)
for act in core.DIAL_ACTIONS:
control = f"{dial}-{act}"
self._paint_row(p, control, act, self.rows[control])
def _paint_legend_box(self, p, dial):
"""
One knob's three rows sit on a plate the colour of the dividing
lines. The rows are painted over it leaving the plate showing as a
hairline between them, which is how the design draws its separators.
"""
boxes = [self.rows[f"{dial}-{a}"] for a in core.DIAL_ACTIONS]
rect = boxes[0].united(boxes[-1]).adjusted(-ROW_SEP, -ROW_SEP,
ROW_SEP, ROW_SEP)
p.setPen(QPen(QColor(THEME.line), ROW_SEP))
p.setBrush(QColor(THEME.line))
p.drawRoundedRect(rect, LEGEND_R, LEGEND_R)
def _paint_row(self, p, control, act, rect):
look = self.looks.get(control, Look())
is_cur = control == self.current
if THEME.legend_box:
self._paint_boxed_row(p, control, act, rect, look, is_cur)
return
if is_cur or control == self.hover:
bg = QColor(TAPE if is_cur else INK)
bg.setAlpha(40 if is_cur else 18)
@@ -753,20 +976,85 @@ class PadView(QWidget):
mono.setPixelSize(10)
p.setFont(mono)
p.setPen(INK_DIM)
prefix = "0x" if THEME.byte_prefix else ""
p.drawText(rect.adjusted(0, 0, -4, 0), Qt.AlignRight | Qt.AlignVCenter,
f"{core.action_byte(control):02x}")
f"{prefix}{core.action_byte(control):02x}")
f = self._legend_font(12, roomy=False)
p.setFont(f)
p.setPen(TAPE if look.pending else
(INK if look.known and not look.quiet else INK_DIM))
text = self._lettering(look.text if (look.known or look.pending) else "Unknown")
body = rect.adjusted(22, 0, -26, 0)
# the action byte sits on the right; leave room for the wider 0x form
body = rect.adjusted(22, 0, -36 if THEME.byte_prefix else -26, 0)
p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter,
QFontMetricsF(f).elidedText(text, Qt.ElideRight, body.width()))
if look.pending:
self._tape(p, QPointF(rect.left() - 4, rect.top() + 3), 14, 6)
def _paint_boxed_row(self, p, control, act, rect, look, is_cur):
"""
One row of a boxed legend: a filled tile, a turn or press icon, a
vertical rule, the legend, and the action byte on the right.
"""
first = act == core.DIAL_ACTIONS[0]
last = act == core.DIAL_ACTIONS[-1]
r = LEGEND_R
path = self._corner_path(rect,
tl=r if first else 0.0, tr=r if first else 0.0,
br=r if last else 0.0, bl=r if last else 0.0)
p.setPen(Qt.NoPen)
p.setBrush(self._well_brush(rect) if THEME.well else QColor(THEME.board))
p.drawPath(path)
if not look.known and not look.pending:
self._unknown_fill(p, path, rect, control, 7.0)
if is_cur or look.pending:
self._glow(p, path, TAPE)
pen = QPen(QColor(TAPE), ROW_SEP * 1.6)
p.setPen(pen)
p.setBrush(Qt.NoBrush)
p.drawPath(path)
elif control == self.hover:
glow = QColor(INK)
glow.setAlpha(18)
p.setPen(Qt.NoPen)
p.setBrush(glow)
p.drawPath(path)
colour = (QColor(TAPE) if (look.pending or is_cur)
else (INK if look.known and not look.quiet else INK_DIM))
icon_c = QPointF(rect.left() + RULE_X / 2, rect.center().y())
if act == "push":
p.setPen(Qt.NoPen)
p.setBrush(colour)
p.drawEllipse(icon_c, rect.height() * 0.11, rect.height() * 0.11)
else:
self._paint_turn_icon(p, icon_c, act == "right", colour)
p.setPen(QPen(QColor(THEME.line), ROW_SEP))
p.drawLine(QPointF(rect.left() + RULE_X, rect.top()),
QPointF(rect.left() + RULE_X, rect.bottom()))
mono = QFont(self.mono)
mono.setPixelSize(max(6, round(ROW_BYTE_PT)))
mono.setItalic(True)
mono.setWeight(QFont.Bold)
p.setFont(mono)
p.setPen(QColor(THEME.byte_ink or THEME.ink_dim))
p.drawText(rect.adjusted(0, 0, -RULE_X * 0.25, 0),
Qt.AlignRight | Qt.AlignVCenter,
f"0x{core.action_byte(control):02x}")
f = self._legend_font(round(ROW_PT), roomy=False)
p.setFont(f)
p.setPen(colour)
text = self._lettering(look.text if (look.known or look.pending)
else "Unknown")
body = rect.adjusted(LABEL_X, 0, -RULE_X * 1.1, 0)
p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter,
QFontMetricsF(f).elidedText(text, Qt.ElideRight, body.width()))
@staticmethod
def _fit(text, font, rect):
"""Elide to at most two wrapped lines."""