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!
This commit is contained in:
Mohamed Mahjoub
2026-09-16 20:39:44 +01:00
committed by GitHub
parent 5b7bf9dadd
commit ed707b1ae7
26 changed files with 1740 additions and 174 deletions

View File

@@ -13,7 +13,7 @@ Visual states:
import math
from dataclasses import dataclass
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
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
@@ -33,11 +33,6 @@ R_OUT, R_IN, R_KNOB = 54.0, 40.0, 30.0
ROW_H, ROW_GAP = 22.0, 12.0
DIAL_W = 160.0
# Which dial is dial 1 (action bytes 0x10-0x12). Upright, dials on top:
# assumed left. Flat (the vendor app's view): assumed top.
DIALS_IN_ORDER = ("dial1", "dial2")
@dataclass
class Look:
text: str = "" # what's on the pad, or will be once written
@@ -46,22 +41,28 @@ class Look:
quiet: bool = False # known, but does nothing: legend drawn dimmed
def _key_grid(orientation):
"""[(key_id, row, col)] with row 0 at the top."""
out = []
if orientation == "upright": # 4 rows x 3 cols, dials above
for r in range(4):
for c in range(3):
out.append((f"key{(3 - r) + 1 + c * 4}", r, c))
else: # 3 rows x 4 cols, dials right
for r in range(3):
for c in range(4):
out.append((f"key{r * 4 + c + 1}", r, c))
return out
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)
@@ -69,9 +70,19 @@ class PadView(QWidget):
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMinimumSize(360, 420)
self.orientation = "upright"
self.looks = {c: Look() for c in core.CONTROL_IDS}
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()
@@ -82,6 +93,14 @@ class PadView(QWidget):
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()
@@ -90,6 +109,21 @@ class PadView(QWidget):
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):
@@ -97,23 +131,35 @@ class PadView(QWidget):
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":
dial_centres = [QPointF(M + DIAL_W / 2, M + R_OUT),
QPointF(M + DIAL_W * 1.5, M + R_OUT)]
grid_top = M + 2 * R_OUT + ROW_GAP + 3 * ROW_H + 28
# 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 + 4 * K + 3 * G + 30 + DIAL_W / 2
first = M + R_OUT
second = first + R_OUT + ROW_GAP + 3 * ROW_H + 20 + R_OUT
dial_centres = [QPointF(x, first), QPointF(x, second)]
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)]
for key, r, c in _key_grid(self.orientation):
self.keys[key] = QRectF(grid_left + c * (K + G),
grid_top + r * (K + G), K, K)
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(DIALS_IN_ORDER, dial_centres):
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):
@@ -121,10 +167,9 @@ class PadView(QWidget):
centre.x() - DIAL_W / 2 + 6, top + i * ROW_H,
DIAL_W - 12, ROW_H)
right = max([r.right() for r in self.keys.values()] +
[r.right() for r in self.rows.values()])
bottom = max([r.bottom() for r in self.keys.values()] +
[r.bottom() for r in self.rows.values()])
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):
@@ -172,7 +217,27 @@ class PadView(QWidget):
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
@@ -184,12 +249,52 @@ class PadView(QWidget):
self.update()
def mousePressEvent(self, e):
if e.button() != Qt.LeftButton:
return
h = self._hit(e.position())
if h and e.button() == Qt.LeftButton:
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, _):
@@ -204,17 +309,54 @@ class PadView(QWidget):
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, core.DEVICE_ID)
Qt.AlignLeft | Qt.AlignVCenter, self.device_id)
def _outline_pen(self, control, known):
if control == self.current: