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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user