Addedd support for 5x3 key layouts aswell as 3 knobs. aswell as fixed a bug in the capturing script that would omit anything past 12 keys / 2 knobs showing up in the report.
779 lines
29 KiB
Python
779 lines
29 KiB
Python
"""
|
|
The pad, drawn the way a circuit board is labelled: outlines and printed
|
|
legends on a dark board, each control marked with its action byte. It's the
|
|
one place the app has any personality; everything else is plain Qt.
|
|
|
|
Visual states:
|
|
written solid outline, legend in board ink
|
|
unknown hatched, like a no-go zone - we genuinely don't know
|
|
changed a strip of orange tape on the corner: edited, not yet written
|
|
selected orange outline
|
|
"""
|
|
|
|
import math
|
|
import random
|
|
import zlib
|
|
from dataclasses import dataclass
|
|
|
|
from PySide6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer, Signal
|
|
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF,
|
|
QPainter, QPainterPath, QPen, QTransform)
|
|
from PySide6.QtWidgets import QSizePolicy, QWidget
|
|
|
|
from . import core, themes
|
|
|
|
THEME = themes.get(themes.DEFAULT)
|
|
|
|
# The colours everything draws with. Switching theme changes these objects in
|
|
# place, so every widget and module holding one follows along.
|
|
WINDOW = QColor(THEME.window)
|
|
BOARD = QColor(THEME.board)
|
|
INK = QColor(THEME.ink) # silkscreen white, slightly warm
|
|
INK_DIM = QColor(THEME.ink_dim)
|
|
HATCH = QColor(THEME.hatch)
|
|
TAPE = QColor(THEME.accent) # Kapton orange, in the default theme
|
|
|
|
|
|
def apply_theme(theme):
|
|
global THEME
|
|
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())
|
|
|
|
|
|
_FAMILIES = None
|
|
|
|
|
|
def _families():
|
|
global _FAMILIES
|
|
if _FAMILIES is None:
|
|
_FAMILIES = set(QFontDatabase.families())
|
|
return _FAMILIES
|
|
|
|
|
|
def _seed(tag):
|
|
"""A stable number for a name. Python's own hash() changes every run."""
|
|
return zlib.crc32(tag.encode())
|
|
|
|
|
|
def _wobble_path(path, amount, seed, step=4.0):
|
|
"""
|
|
A pencil version of a path. Resampled finely, then nudged sideways by
|
|
two slow waves with random phases, so the line wanders without jitter.
|
|
Seeded, so a key looks the same every time it's drawn.
|
|
"""
|
|
rnd = random.Random(seed)
|
|
out = QPainterPath()
|
|
for poly in path.toSubpathPolygons():
|
|
pts = list(poly)
|
|
if len(pts) < 2:
|
|
continue
|
|
dense = []
|
|
for a, b in zip(pts, pts[1:]):
|
|
seg = QLineF(a, b)
|
|
n = max(1, int(seg.length() / step))
|
|
dense.extend(seg.pointAt(k / n) for k in range(n))
|
|
dense.append(pts[-1])
|
|
f1, f2 = rnd.uniform(0.035, 0.06), rnd.uniform(0.10, 0.16)
|
|
p1, p2 = rnd.uniform(0, math.tau), rnd.uniform(0, math.tau)
|
|
dist, moved = 0.0, []
|
|
for i, pt in enumerate(dense):
|
|
if i:
|
|
dist += QLineF(dense[i - 1], pt).length()
|
|
nxt, prv = dense[min(i + 1, len(dense) - 1)], dense[max(i - 1, 0)]
|
|
dx, dy = nxt.x() - prv.x(), nxt.y() - prv.y()
|
|
length = math.hypot(dx, dy) or 1.0
|
|
off = amount * (0.65 * math.sin(dist * f1 + p1) + 0.35 * math.sin(dist * f2 + p2))
|
|
moved.append(QPointF(pt.x() - dy / length * off, pt.y() + dx / length * off))
|
|
out.moveTo(moved[0])
|
|
for q in moved[1:]:
|
|
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
|
|
|
|
@dataclass
|
|
class Look:
|
|
text: str = "" # what's on the pad, or will be once written
|
|
known: bool = False
|
|
pending: bool = False
|
|
quiet: bool = False # known, but does nothing: legend drawn dimmed
|
|
|
|
|
|
def _key_grid(layout, orientation):
|
|
"""
|
|
[(key_id, row, col)] as drawn, with row 0 at the top.
|
|
|
|
The layout describes the pad lying flat. Upright is the same pad turned
|
|
a quarter turn anticlockwise, so a key at (r, c) in an R by C grid ends
|
|
up at (C - 1 - c, r) in a C by R one.
|
|
"""
|
|
flat = layout.spots()
|
|
if orientation != "upright":
|
|
return flat
|
|
return [(k, layout.cols - 1 - c, r) for k, r, c in flat]
|
|
|
|
|
|
def _grid_size(layout, orientation):
|
|
return ((layout.cols, layout.rows) if orientation == "upright"
|
|
else (layout.rows, layout.cols))
|
|
|
|
|
|
class PadView(QWidget):
|
|
selected = Signal(str)
|
|
rearranged = Signal(object)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setMouseTracking(True)
|
|
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
self.setMinimumSize(360, 420)
|
|
self.orientation = "upright"
|
|
self.layout = core.DEFAULT_LAYOUT
|
|
self.device_id = core.DEVICE_ID
|
|
self.looks = {c: Look() for c in self.layout.control_ids}
|
|
self.current = None
|
|
self.hover = None
|
|
self.editing = False
|
|
self.drag = None # key being moved
|
|
self.drag_at = None # where the pointer is, in board units
|
|
self.drop = None # (row, col) it would land in
|
|
self._wobble = 0
|
|
self._wobble_cache = {}
|
|
self._wobbler = QTimer(self)
|
|
self._wobbler.setInterval(130)
|
|
self._wobbler.timeout.connect(self._tick)
|
|
self.mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
|
self._layout()
|
|
|
|
# ------------------------------------------------------------ public
|
|
|
|
def set_orientation(self, orientation):
|
|
self.orientation = orientation
|
|
self._layout()
|
|
self.update()
|
|
|
|
def set_layout(self, layout):
|
|
"""Redraw for a pad of a different shape."""
|
|
self.layout = layout
|
|
if self.current not in layout.control_ids:
|
|
self.current = None
|
|
self._layout()
|
|
self.update()
|
|
|
|
def set_looks(self, looks):
|
|
self.looks = looks
|
|
self.update()
|
|
|
|
def select(self, control):
|
|
self.current = control
|
|
self.update()
|
|
|
|
def set_editing(self, on):
|
|
"""
|
|
Edit mode: keys can be dragged between cells. They wobble, which is
|
|
the clearest way anyone has found to say "these can be moved".
|
|
"""
|
|
self.editing = on
|
|
self.drag = self.drop = self.drag_at = None
|
|
self._wobbler.start() if on else self._wobbler.stop()
|
|
self.setCursor(Qt.ArrowCursor)
|
|
self.update()
|
|
|
|
def _tick(self):
|
|
self._wobble = 1 - self._wobble
|
|
self.update()
|
|
|
|
# ------------------------------------------------------------ layout
|
|
|
|
def _layout(self):
|
|
self._wobble_cache = {}
|
|
self.keys = {} # key -> QRectF
|
|
self.dials = {} # dial -> centre QPointF
|
|
self.rows = {} # dial action -> legend row QRectF
|
|
|
|
lay = self.layout
|
|
grid_rows, grid_cols = _grid_size(lay, self.orientation)
|
|
# from a knob's centre down to the bottom of its legend rows
|
|
stack = R_OUT + ROW_GAP + len(core.DIAL_ACTIONS) * ROW_H
|
|
|
|
# Sides are named for the pad lying flat, so standing it upright
|
|
# turns them: knobs on the right go along the top, knobs on the
|
|
# left along the bottom.
|
|
left = lay.knob_side == "left" and bool(lay.knobs)
|
|
|
|
keys_w = grid_cols * (K + G) - G
|
|
keys_h = grid_rows * (K + G) - G
|
|
# 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
|
|
if lay.knobs else 0)
|
|
|
|
if self.orientation == "upright":
|
|
# knobs in a row, above the keys or below them. Whichever of the
|
|
# two is narrower gets centred against the other.
|
|
span = max(keys_w, dials_w)
|
|
step = span / max(lay.knobs, 1)
|
|
band = (R_OUT + stack + 28) if lay.knobs else 0
|
|
grid_left = M + max(0.0, (span - keys_w) / 2)
|
|
grid_top = M if left else M + band
|
|
keys_bottom = grid_top + keys_h
|
|
y = (keys_bottom + 28 + R_OUT) if left else (M + R_OUT)
|
|
dial_centres = [QPointF(M + step * (i + 0.5), y)
|
|
for i in range(lay.knobs)]
|
|
else:
|
|
# knobs in a column, down whichever side the pad wears them.
|
|
# Three knobs of legends are taller than three rows of keys, so
|
|
# centre the shorter one rather than leaving a hole in the board.
|
|
grid_top = M + max(0.0, (dials_h - keys_h) / 2)
|
|
grid_left = M + (DIAL_W + 30 if left else 0)
|
|
x = (M + DIAL_W / 2) if left else \
|
|
(grid_left + keys_w + G + 30 + DIAL_W / 2)
|
|
top = M + max(0.0, (keys_h - dials_h) / 2)
|
|
dial_centres = [QPointF(x, top + R_OUT + i * (stack + 20 + R_OUT))
|
|
for i in range(lay.knobs)]
|
|
|
|
self.cells = {}
|
|
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)
|
|
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
|
|
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)
|
|
|
|
boxes = list(self.cells.values()) + list(self.rows.values())
|
|
right = max(r.right() for r in boxes)
|
|
bottom = max(r.bottom() for r in boxes)
|
|
self.board = QRectF(0, 0, right + M, bottom + 46)
|
|
|
|
def _transform(self):
|
|
pad = 24
|
|
avail = QRectF(self.rect()).adjusted(pad, pad, -pad, -pad)
|
|
if THEME.title_block: # keep clear of the title block
|
|
avail.adjust(0, 0, 0, -92)
|
|
s = min(avail.width() / self.board.width(),
|
|
avail.height() / self.board.height())
|
|
s = max(0.4, min(s, 1.6))
|
|
dx = avail.left() + (avail.width() - self.board.width() * s) / 2
|
|
dy = avail.top() + (avail.height() - self.board.height() * s) / 2
|
|
return QTransform(s, 0, 0, s, dx, dy)
|
|
|
|
# ----------------------------------------------------------- hit test
|
|
|
|
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)
|
|
return path
|
|
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)
|
|
path.arcMoveTo(outer, start)
|
|
path.arcTo(outer, start, span)
|
|
path.arcTo(inner, start + span, -span)
|
|
path.closeSubpath()
|
|
return path
|
|
|
|
def _hit(self, pos):
|
|
inv, ok = self._transform().inverted()
|
|
if not ok:
|
|
return None
|
|
p = inv.map(QPointF(pos))
|
|
for key, rect in self.keys.items():
|
|
if rect.contains(p):
|
|
return key
|
|
for control, rect in self.rows.items():
|
|
if rect.contains(p):
|
|
return control
|
|
for dial in self.dials:
|
|
for act in core.DIAL_ACTIONS:
|
|
if self._segment(dial, act).contains(p):
|
|
return f"{dial}-{act}"
|
|
return None
|
|
|
|
def _board_pos(self, pos):
|
|
inv, ok = self._transform().inverted()
|
|
return inv.map(QPointF(pos)) if ok else None
|
|
|
|
def _cell_at(self, point):
|
|
for spot, rect in self.cells.items():
|
|
if rect.adjusted(-G / 2, -G / 2, G / 2, G / 2).contains(point):
|
|
return spot
|
|
return None
|
|
|
|
def mouseMoveEvent(self, e):
|
|
if self.editing:
|
|
if self.drag:
|
|
self.drag_at = self._board_pos(e.position())
|
|
self.drop = self._cell_at(self.drag_at) if self.drag_at else None
|
|
self.update()
|
|
else:
|
|
over = self._hit(e.position())
|
|
key = over if over and over.startswith("key") else None
|
|
self.setCursor(Qt.OpenHandCursor if key else Qt.ArrowCursor)
|
|
return
|
|
h = self._hit(e.position())
|
|
if h != self.hover:
|
|
self.hover = h
|
|
self.setCursor(Qt.PointingHandCursor if h else Qt.ArrowCursor)
|
|
self.update()
|
|
|
|
def leaveEvent(self, e):
|
|
self.hover = None
|
|
self.update()
|
|
|
|
def mousePressEvent(self, e):
|
|
if e.button() != Qt.LeftButton:
|
|
return
|
|
h = self._hit(e.position())
|
|
if self.editing:
|
|
if h and h.startswith("key"):
|
|
self.drag = h
|
|
self.drag_at = self._board_pos(e.position())
|
|
self.setCursor(Qt.ClosedHandCursor)
|
|
self.update()
|
|
return
|
|
if h:
|
|
self.current = h
|
|
self.selected.emit(h)
|
|
self.update()
|
|
|
|
def mouseReleaseEvent(self, e):
|
|
if not (self.editing and self.drag):
|
|
return
|
|
moved, target = self.drag, self.drop
|
|
self.drag = self.drag_at = self.drop = None
|
|
self.setCursor(Qt.OpenHandCursor)
|
|
if target:
|
|
self._place(moved, target)
|
|
self.update()
|
|
|
|
def _place(self, key, target):
|
|
"""
|
|
Drop a key into a cell. If another key is already there the two
|
|
swap, so a drag never loses one.
|
|
"""
|
|
spots = {k: (r, c) for k, r, c in _key_grid(self.layout, self.orientation)}
|
|
if spots.get(key) == target:
|
|
return
|
|
for other, spot in spots.items():
|
|
if spot == target:
|
|
spots[other] = spots[key]
|
|
break
|
|
spots[key] = target
|
|
|
|
if self.orientation == "upright": # back to the flat arrangement
|
|
spots = {k: (c, self.layout.cols - 1 - r) for k, (r, c) in spots.items()}
|
|
places = [spots[f"key{i + 1}"] for i in range(self.layout.keys)]
|
|
self.layout = self.layout.rearranged(places)
|
|
self._layout()
|
|
self.rearranged.emit(self.layout)
|
|
|
|
# ------------------------------------------------------------ paint
|
|
|
|
# ------------------------------------------------------------ paint
|
|
|
|
def paintEvent(self, _):
|
|
p = QPainter(self)
|
|
p.setRenderHint(QPainter.Antialiasing)
|
|
p.fillRect(self.rect(), WINDOW)
|
|
if THEME.grid:
|
|
self._paint_grid(p)
|
|
p.setTransform(self._transform())
|
|
|
|
board = QPainterPath()
|
|
board.addRoundedRect(self.board, 18, 18)
|
|
p.fillPath(board, BOARD)
|
|
# On themes where the pad is only an outline, draw that in full ink.
|
|
edge = INK if THEME.board == THEME.window else INK_DIM
|
|
self._stroke(p, board, QPen(edge, 1.2), "board")
|
|
|
|
if self.editing:
|
|
for spot, rect in self.cells.items():
|
|
if spot == self.drop or not any(r is rect for r in self.keys.values()):
|
|
path = QPainterPath()
|
|
path.addRoundedRect(rect, 5, 5)
|
|
pen = QPen(TAPE if spot == self.drop else HATCH, 1.4)
|
|
pen.setStyle(Qt.DashLine)
|
|
p.setPen(pen)
|
|
p.setBrush(Qt.NoBrush)
|
|
p.drawPath(path)
|
|
|
|
for key, rect in self.keys.items():
|
|
if key == self.drag:
|
|
continue
|
|
p.save()
|
|
if self.editing:
|
|
# a small alternating tilt, the way icons shake when movable
|
|
lean = 0.55 if (_seed(key) + self._wobble) % 2 else -0.55
|
|
p.translate(rect.center())
|
|
p.rotate(lean)
|
|
p.translate(-rect.center())
|
|
self._paint_key(p, key, rect)
|
|
p.restore()
|
|
|
|
for dial, centre in self.dials.items():
|
|
self._paint_dial(p, dial, centre)
|
|
|
|
if self.drag and self.drag_at:
|
|
rect = QRectF(0, 0, K, K)
|
|
rect.moveCenter(self.drag_at)
|
|
p.save()
|
|
p.setOpacity(0.92)
|
|
shadow = QPainterPath()
|
|
shadow.addRoundedRect(rect.adjusted(2, 4, 2, 4), 5, 5)
|
|
p.fillPath(shadow, QColor(0, 0, 0, 90))
|
|
p.fillPath(shadow, QColor(0, 0, 0, 60))
|
|
fill = QPainterPath()
|
|
fill.addRoundedRect(rect, 5, 5)
|
|
p.fillPath(fill, BOARD)
|
|
self._paint_key(p, self.drag, rect)
|
|
p.restore()
|
|
|
|
f = QFont(self.mono)
|
|
f.setPixelSize(10)
|
|
p.setFont(f)
|
|
p.setPen(INK_DIM)
|
|
p.drawText(QRectF(M, self.board.bottom() - 34, 200, 16),
|
|
Qt.AlignLeft | Qt.AlignVCenter, self.device_id)
|
|
|
|
if THEME.title_block:
|
|
p.resetTransform()
|
|
self._paint_sheet(p)
|
|
|
|
# ------------------------------------------------------ theme drawing
|
|
|
|
def _stroke(self, p, path, pen, tag):
|
|
"""
|
|
Draw an outline. On a pencil theme the line wanders a little and is
|
|
gone over a second time, lighter, the way a hand draws.
|
|
"""
|
|
p.setBrush(Qt.NoBrush)
|
|
if not THEME.wobble:
|
|
p.setPen(pen)
|
|
p.drawPath(path)
|
|
return
|
|
p.setPen(pen)
|
|
p.drawPath(self._wobbled(path, THEME.wobble, tag))
|
|
again = QPen(pen)
|
|
c = QColor(pen.color())
|
|
c.setAlpha(int(c.alpha() * 0.45))
|
|
again.setColor(c)
|
|
again.setWidthF(max(0.6, pen.widthF() * 0.7))
|
|
p.setPen(again)
|
|
p.drawPath(self._wobbled(path, THEME.wobble * 0.8, tag + "'"))
|
|
|
|
def _wobbled(self, path, amount, tag):
|
|
# Paths are in board units, so they only change when the layout
|
|
# does; cache them rather than recompute on every hover.
|
|
b = path.boundingRect()
|
|
key = (tag, amount, round(b.x(), 1), round(b.y(), 1),
|
|
round(b.width(), 1), round(b.height(), 1))
|
|
got = self._wobble_cache.get(key)
|
|
if got is None:
|
|
got = self._wobble_cache[key] = _wobble_path(path, amount, _seed(tag))
|
|
return got
|
|
|
|
def _legend_font(self, px, roomy=True):
|
|
"""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
|
|
elif THEME.mono_legends:
|
|
f = QFont(self.mono)
|
|
f.setPixelSize(px - 1)
|
|
else:
|
|
f = QFont(self.font())
|
|
f.setPixelSize(px)
|
|
return f
|
|
|
|
@staticmethod
|
|
def _lettering(text):
|
|
return text.upper() if THEME.caps else text
|
|
|
|
def _paint_grid(self, p):
|
|
"""Drawing paper: fine lines, and a heavier one every fifth."""
|
|
r = self.rect()
|
|
fine, heavy = QColor(HATCH), QColor(HATCH)
|
|
fine.setAlpha(70)
|
|
heavy.setAlpha(150)
|
|
step = 20
|
|
for i, x in enumerate(range(0, r.width(), step)):
|
|
p.setPen(QPen(heavy if i % 5 == 0 else fine, 1))
|
|
p.drawLine(x, 0, x, r.height())
|
|
for i, y in enumerate(range(0, r.height(), step)):
|
|
p.setPen(QPen(heavy if i % 5 == 0 else fine, 1))
|
|
p.drawLine(0, y, r.width(), y)
|
|
|
|
def _paint_sheet(self, p):
|
|
"""A drawing border and a title block in the bottom right corner."""
|
|
import getpass
|
|
sheet = QRectF(self.rect()).adjusted(8, 8, -9, -9)
|
|
p.setBrush(Qt.NoBrush)
|
|
p.setPen(QPen(INK, 1.4))
|
|
p.drawRect(sheet)
|
|
|
|
w, h = min(330.0, sheet.width() - 20), 84.0
|
|
tb = QRectF(sheet.right() - w, sheet.bottom() - h, w, h)
|
|
p.fillRect(tb, WINDOW)
|
|
p.setPen(QPen(INK, 1.2))
|
|
p.drawRect(tb)
|
|
row = h / 3
|
|
half = tb.left() + w / 2
|
|
p.setPen(QPen(INK, 0.8))
|
|
p.drawLine(QPointF(tb.left(), tb.top() + row), QPointF(tb.right(), tb.top() + row))
|
|
p.drawLine(QPointF(tb.left(), tb.top() + 2 * row), QPointF(tb.right(), tb.top() + 2 * row))
|
|
p.drawLine(QPointF(half, tb.top() + row), QPointF(half, tb.bottom()))
|
|
|
|
try:
|
|
who = getpass.getuser()
|
|
except Exception:
|
|
who = ""
|
|
lay = self.layout
|
|
knobs = f", {lay.knobs} KNOB{'S' if lay.knobs != 1 else ''}" if lay.knobs else ""
|
|
cells = [
|
|
(QRectF(tb.left(), tb.top(), w, row), "TITLE", "MACROPAD"),
|
|
(QRectF(tb.left(), tb.top() + row, w / 2, row), "DWG NO", self.device_id),
|
|
(QRectF(half, tb.top() + row, w / 2, row), "SCALE", "1:1 SHEET 1 OF 1"),
|
|
(QRectF(tb.left(), tb.top() + 2 * row, w / 2, row), "DRAWN", who.upper()),
|
|
(QRectF(half, tb.top() + 2 * row, w / 2, row), "LAYOUT",
|
|
f"{lay.rows}X{lay.cols}{knobs}"),
|
|
]
|
|
small, big = QFont(self.mono), QFont(self.mono)
|
|
small.setPixelSize(7)
|
|
big.setPixelSize(11)
|
|
big.setBold(True)
|
|
for rect, label, value in cells:
|
|
p.setFont(small)
|
|
p.setPen(INK_DIM)
|
|
p.drawText(rect.adjusted(5, 2, -4, 0), Qt.AlignLeft | Qt.AlignTop, label)
|
|
p.setFont(big)
|
|
p.setPen(INK)
|
|
p.drawText(rect.adjusted(5, 0, -4, -3), Qt.AlignLeft | Qt.AlignBottom,
|
|
QFontMetricsF(big).elidedText(value, Qt.ElideRight,
|
|
rect.width() - 10))
|
|
|
|
def _centre_lines(self, p, c):
|
|
"""Chain lines through a round feature's centre: long dash, short dash."""
|
|
pen = QPen(INK_DIM, 0.8)
|
|
pen.setDashPattern([14, 3, 3, 3])
|
|
p.setPen(pen)
|
|
reach = R_OUT + 10
|
|
p.drawLine(QPointF(c.x() - reach, c.y()), QPointF(c.x() + reach, c.y()))
|
|
p.drawLine(QPointF(c.x(), c.y() - reach), QPointF(c.x(), c.y() + reach))
|
|
|
|
# ------------------------------------------------------------ pieces
|
|
|
|
def _outline_pen(self, control, known):
|
|
if control == self.current:
|
|
return QPen(TAPE, 2.2)
|
|
if control == self.hover:
|
|
return QPen(INK, 1.6)
|
|
pen = QPen(INK if known else INK_DIM, 1.1)
|
|
if not known and THEME.unknown == "hidden":
|
|
pen.setDashPattern([5, 3]) # hidden line, drawing convention
|
|
return pen
|
|
|
|
def _unknown_fill(self, p, path, rect, tag, step=9.0):
|
|
if THEME.unknown == "hatch":
|
|
self._hatch(p, path, rect, tag, step)
|
|
|
|
def _hatch(self, p, path, rect, tag="", step=9.0):
|
|
p.save()
|
|
p.setClipPath(path)
|
|
pen = QPen(HATCH, 1.4)
|
|
x = rect.left() - rect.height()
|
|
i = 0
|
|
while x < rect.right():
|
|
a = QPointF(x, rect.bottom())
|
|
b = QPointF(x + rect.height(), rect.top())
|
|
if THEME.wobble:
|
|
line = QPainterPath(a)
|
|
line.lineTo(b)
|
|
p.setPen(pen)
|
|
p.setBrush(Qt.NoBrush)
|
|
p.drawPath(self._wobbled(line, THEME.wobble * 0.6, f"{tag}h{i}"))
|
|
else:
|
|
p.setPen(pen)
|
|
p.drawLine(a, b)
|
|
x += step
|
|
i += 1
|
|
p.restore()
|
|
|
|
def _tape(self, p, at, length=30.0, width=9.0):
|
|
"""A strip of Kapton stuck across a corner."""
|
|
p.save()
|
|
p.translate(at)
|
|
p.rotate(45)
|
|
tape = QColor(TAPE)
|
|
tape.setAlpha(225)
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(tape)
|
|
p.drawRect(QRectF(-length / 2, -width / 2, length, width))
|
|
p.restore()
|
|
|
|
def _paint_key(self, p, key, rect):
|
|
look = self.looks.get(key, Look())
|
|
path = QPainterPath()
|
|
path.addRoundedRect(rect, 5, 5)
|
|
unknown = not look.known and not look.pending
|
|
if unknown:
|
|
self._unknown_fill(p, path, rect, key)
|
|
self._stroke(p, path, self._outline_pen(key, look.known or look.pending), key)
|
|
|
|
f = QFont(self.mono)
|
|
f.setPixelSize(10)
|
|
p.setFont(f)
|
|
p.setPen(INK_DIM)
|
|
p.drawText(rect.adjusted(8, 6, -8, -6), Qt.AlignLeft | Qt.AlignTop,
|
|
f"0x{core.action_byte(key):02x}")
|
|
|
|
f = self._legend_font(13)
|
|
p.setFont(f)
|
|
if look.pending:
|
|
p.setPen(TAPE)
|
|
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)
|
|
# 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))
|
|
|
|
def _paint_turn_icon(self, p, centre, clockwise, colour):
|
|
r = 5.5
|
|
box = QRectF(centre.x() - r, centre.y() - r, 2 * r, 2 * r)
|
|
pen = QPen(colour, 1.4)
|
|
pen.setCapStyle(Qt.RoundCap)
|
|
p.setPen(pen)
|
|
p.setBrush(Qt.NoBrush)
|
|
start, span = (120, 270) if not clockwise else (60, -270)
|
|
p.drawArc(box, int(start * 16), int(span * 16))
|
|
end = math.radians(start + span)
|
|
tip = QPointF(centre.x() + r * math.cos(end), centre.y() - r * math.sin(end))
|
|
# arrowhead along the tangent at the end of the arc
|
|
d = 1 if not clockwise else -1
|
|
tangent = end + d * math.pi / 2
|
|
for spread in (0.5, -0.5):
|
|
a = tangent + math.pi + spread
|
|
p.drawLine(tip, QPointF(tip.x() + 3.5 * math.cos(a),
|
|
tip.y() - 3.5 * math.sin(a)))
|
|
|
|
def _paint_dial(self, p, dial, c):
|
|
if THEME.centre_lines:
|
|
self._centre_lines(p, c)
|
|
# Ring segments for turn left / turn right, centre for press.
|
|
for act in core.DIAL_ACTIONS:
|
|
control = f"{dial}-{act}"
|
|
look = self.looks.get(control, Look())
|
|
seg = self._segment(dial, act)
|
|
if act != "push":
|
|
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)
|
|
p.fillPath(seg, fill)
|
|
self._stroke(p, seg, self._outline_pen(control, look.known or look.pending),
|
|
control)
|
|
|
|
push = f"{dial}-push"
|
|
look = self.looks.get(push, Look())
|
|
knob = QPainterPath()
|
|
knob.addEllipse(c, R_KNOB, R_KNOB)
|
|
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)
|
|
p.fillPath(knob, fill)
|
|
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:
|
|
p.setPen(QPen(INK_DIM, 1.0))
|
|
for i in range(24):
|
|
a = math.radians(i * 15)
|
|
p.drawLine(QPointF(c.x() + (R_KNOB - 4) * math.cos(a), c.y() + (R_KNOB - 4) * math.sin(a)),
|
|
QPointF(c.x() + (R_KNOB - 1) * math.cos(a), c.y() + (R_KNOB - 1) * math.sin(a)))
|
|
|
|
f = self._legend_font(11)
|
|
p.setFont(f)
|
|
label = QRectF(c.x() - 23, c.y() - 8, 46, 16)
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(BOARD)
|
|
p.drawRoundedRect(label, 3, 3)
|
|
p.setPen(TAPE if push == self.current else INK_DIM)
|
|
p.drawText(label, Qt.AlignCenter, self._lettering(f"Dial {dial[4:]}"))
|
|
|
|
# Legend rows: the silkscreen text next to the part.
|
|
for act in core.DIAL_ACTIONS:
|
|
control = f"{dial}-{act}"
|
|
self._paint_row(p, control, act, self.rows[control])
|
|
|
|
def _paint_row(self, p, control, act, rect):
|
|
look = self.looks.get(control, Look())
|
|
is_cur = control == self.current
|
|
if is_cur or control == self.hover:
|
|
bg = QColor(TAPE if is_cur else INK)
|
|
bg.setAlpha(40 if is_cur else 18)
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(bg)
|
|
p.drawRoundedRect(rect, 3, 3)
|
|
|
|
colour = TAPE if (look.pending or is_cur) else (INK if look.known else INK_DIM)
|
|
icon_c = QPointF(rect.left() + 10, rect.center().y())
|
|
if act == "push":
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(colour)
|
|
p.drawEllipse(icon_c, 2.6, 2.6)
|
|
else:
|
|
self._paint_turn_icon(p, icon_c, act == "right", colour)
|
|
|
|
mono = QFont(self.mono)
|
|
mono.setPixelSize(10)
|
|
p.setFont(mono)
|
|
p.setPen(INK_DIM)
|
|
p.drawText(rect.adjusted(0, 0, -4, 0), Qt.AlignRight | Qt.AlignVCenter,
|
|
f"{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)
|
|
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)
|
|
|
|
@staticmethod
|
|
def _fit(text, font, rect):
|
|
"""Elide to at most two wrapped lines."""
|
|
fm = QFontMetricsF(font)
|
|
if fm.boundingRect(rect, Qt.TextWordWrap, text).height() <= rect.height():
|
|
return text
|
|
while text and fm.boundingRect(rect, Qt.TextWordWrap, text + "…").height() > rect.height():
|
|
text = text[:-1]
|
|
return text + "…"
|