Files
macropad-itprotech/macropad_gui/padview.py
Mohamed Mahjoub ed707b1ae7 Read the pad, detect unknown ones, layout editor
added a feature that now can read the inputs off the board, meaning if you already have saved settings and you uninstall / reinstall the app or use it on a new device it can auto detect what each key has saved.

using the above feature i now also do believe it is possible to auto detect the macros pads rough layout / how many keys and dials it has!. hopfully this means that more people can use the app easily without needing to hard code a bunch of new layouts. its not truly universal but it should open the door to much more pads working out of the box right now!

and lastly in case the extra layouts do work i made a little layout editor so you can the app to look like your actual macropad!
2026-09-16 20:39:44 +01:00

543 lines
20 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
from dataclasses import dataclass
from PySide6.QtCore import 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
WINDOW = QColor("#17191b")
BOARD = QColor("#202326")
INK = QColor("#d8d4cb") # silkscreen white, slightly warm
INK_DIM = QColor("#72767b")
HATCH = QColor("#34383c")
TAPE = QColor("#eb8a2f") # Kapton orange
# 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._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.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
if self.orientation == "upright":
# knobs in a row above the keys
span = max(grid_cols * (K + G) - G, lay.knobs * (DIAL_W + G))
step = span / max(lay.knobs, 1)
dial_centres = [QPointF(M + step * (i + 0.5), M + R_OUT)
for i in range(lay.knobs)]
grid_top = M + (R_OUT + stack + 28 if lay.knobs else 0)
grid_left = M
else:
# knobs in a column down the right
grid_top, grid_left = M, M
x = M + grid_cols * (K + G) + 30 + DIAL_W / 2
dial_centres = [QPointF(x, M + 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)
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
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.fillRect(self.rect(), WINDOW)
p.setTransform(self._transform())
board = QPainterPath()
board.addRoundedRect(self.board, 18, 18)
p.fillPath(board, BOARD)
p.setPen(QPen(INK_DIM, 1.2))
p.drawPath(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 (hash(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)
def _outline_pen(self, control, known):
if control == self.current:
return QPen(TAPE, 2.2)
if control == self.hover:
return QPen(INK, 1.6)
return QPen(INK if known else INK_DIM, 1.1)
def _hatch(self, p, path, rect, step=9.0):
p.save()
p.setClipPath(path)
p.setPen(QPen(HATCH, 1.4))
x = rect.left() - rect.height()
while x < rect.right():
p.drawLine(QPointF(x, rect.bottom()),
QPointF(x + rect.height(), rect.top()))
x += step
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)
if not look.known and not look.pending:
self._hatch(p, path, rect)
p.setBrush(Qt.NoBrush)
p.setPen(self._outline_pen(key, look.known or look.pending))
p.drawPath(path)
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 = QFont(self.font())
f.setPixelSize(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 = 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):
# 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._hatch(p, seg, seg.boundingRect(), 7.0)
if control == self.current:
fill = QColor(TAPE)
fill.setAlpha(55)
p.fillPath(seg, fill)
p.setPen(self._outline_pen(control, look.known or look.pending))
p.setBrush(Qt.NoBrush)
p.drawPath(seg)
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._hatch(p, knob, knob.boundingRect(), 7.0)
if push == self.current:
fill = QColor(TAPE)
fill.setAlpha(55)
p.fillPath(knob, fill)
p.setPen(self._outline_pen(push, look.known or look.pending))
p.setBrush(Qt.NoBrush)
p.drawPath(knob)
# knurling, the way a footprint marks a rotary encoder
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 = QFont(self.font())
f.setPixelSize(11)
p.setFont(f)
label = QRectF(c.x() - 21, c.y() - 8, 42, 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, 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 = QFont(self.font())
f.setPixelSize(12)
p.setFont(f)
p.setPen(TAPE if look.pending else
(INK if look.known and not look.quiet else INK_DIM))
text = 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 + "…"