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 @@ from PySide6.QtGui import (QColor, QFont, QFontDatabase, QGuiApplication, QIcon,
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
QGridLayout, QHBoxLayout, QLabel, QLineEdit,
QMainWindow, QMessageBox, QPlainTextEdit,
QPushButton, QVBoxLayout, QWidget)
QPushButton, QSpinBox, QVBoxLayout, QWidget)
from . import core, keymap
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
@@ -57,6 +57,10 @@ QFrame[role="banner"] {{ border: 1px solid {LINE}; border-left: 3px solid {BAD};
QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {INK_DIM.name()}; }}
QFrame[role="banner"] QLabel, QFrame[role="banner"] QPushButton {{ background: transparent; }}
QFrame[role="footer"] {{ border-top: 1px solid {LINE}; }}
QFrame[role="editbar"] {{ border-top: 1px solid {TAPE.name()}; background: #1c1a17; }}
QFrame[role="editbar"] QLabel {{ background: transparent; }}
QSpinBox {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px;
padding: 4px 6px; }}
"""
@@ -144,12 +148,14 @@ class Inspector(QWidget):
changed = Signal(str, object) # control, Binding or None (revert)
write = Signal(str)
revert = Signal(str)
action = Signal()
def __init__(self):
super().__init__()
self.setFixedWidth(360)
self.control = None
self._loading = False
self.editing_layout = False
v = QVBoxLayout(self)
v.setContentsMargins(24, 24, 24, 16)
@@ -170,7 +176,10 @@ class Inspector(QWidget):
self.banner_fix.setLineWrapMode(QPlainTextEdit.NoWrap)
self.banner_copy = _button("Copy commands")
self.banner_copy.clicked.connect(self._copy)
for w in (self.banner_head, self.banner_text, self.banner_fix, self.banner_copy):
self.banner_action = _button("")
self.banner_action.clicked.connect(lambda: self.action.emit())
for w in (self.banner_head, self.banner_text, self.banner_fix,
self.banner_copy, self.banner_action):
bl.addWidget(w)
self.banner_copy.setSizePolicy(self.banner_copy.sizePolicy().horizontalPolicy(),
self.banner_copy.sizePolicy().verticalPolicy())
@@ -290,6 +299,8 @@ class Inspector(QWidget):
self.banner_fix.setFixedHeight(lh * max(1, len(cmds)) + 30)
self.banner_fix.setVisible(bool(cmds))
self.banner_copy.setVisible(bool(cmds))
self.banner_action.setVisible(diag.status == "unsupported")
self.banner_action.setText("Ask the pad what it is")
if diag.fix and diag.fix[-1].startswith("#"):
self.banner_text.setText(self.banner_text.text() + "\n\nThen unplug the pad and plug it back in.")
self.banner.show()
@@ -307,11 +318,15 @@ class Inspector(QWidget):
if control is None:
self.editor.hide()
self.empty.setText(
"Click a key or dial to change what it does.\n\n"
+ ("The pad can't report what's on it, so this app only knows "
"what it has written itself. Hatched controls are unknown. "
"If you set the pad up with the vendor software, you can "
"import that capture instead." if all_unknown else ""))
("Drag the keys around until the drawing matches your pad, "
"then press Done.\n\nUse the boxes below to change the "
"grid or the number of keys and knobs. This only changes "
"the picture, never the pad."
if self.editing_layout else
"Click a key or dial to change what it does.\n\n")
+ ("Hatched controls are ones this app hasn't been told about. "
"Press Read pad to ask the pad itself what it's holding."
if all_unknown and not self.editing_layout else ""))
self.empty.show()
self._loading = False
return
@@ -422,6 +437,9 @@ class Window(QMainWindow):
self.state = core.State()
self.desired = {} # control -> Binding, unwritten edits
self.diag = None
# Pads the user has identified by asking them. Remembered so the app
# doesn't go back to calling them unsupported on the next check.
self.adopted = set(self.settings.value("adopted", []) or [])
root = QWidget()
outer = QVBoxLayout(root)
@@ -431,12 +449,41 @@ class Window(QMainWindow):
body = QHBoxLayout()
body.setSpacing(0)
self.pad = PadView()
self.pad.set_layout(self.state.layout)
self.pad.set_orientation(self.settings.value("orientation", "upright"))
self.inspector = Inspector()
body.addWidget(self.pad, 1)
body.addWidget(self.inspector)
outer.addLayout(body, 1)
# editor bar, hidden unless you're rearranging the pad
self.editbar = QFrame()
self.editbar.setProperty("role", "editbar")
eb = QHBoxLayout(self.editbar)
eb.setContentsMargins(16, 8, 16, 8)
eb.setSpacing(8)
eb.addWidget(_label("Drag the keys to match your pad.", "dim"))
eb.addSpacing(12)
self.spin = {}
for name, label, lo, hi in (("rows", "Rows", 1, 15),
("cols", "Columns", 1, 15),
("keys", "Keys", 1, 15),
("knobs", "Knobs", 0, 3)):
eb.addWidget(_label(label, "dim"))
box = QSpinBox()
box.setRange(lo, hi)
box.setFixedWidth(56)
box.valueChanged.connect(self._resize_layout)
self.spin[name] = box
eb.addWidget(box)
eb.addStretch()
self.edit_done = _button("Done", "primary")
self.edit_cancel = _button("Cancel", "flat")
eb.addWidget(self.edit_cancel)
eb.addWidget(self.edit_done)
self.editbar.hide()
outer.addWidget(self.editbar)
footer = QFrame()
footer.setProperty("role", "footer")
fl = QHBoxLayout(footer)
@@ -445,6 +492,10 @@ class Window(QMainWindow):
self.dot.setFixedSize(9, 9)
self.status = _label("", "dim")
self.message = _label("")
self.edit_btn = _button("Edit layout", "flat")
self.edit_btn.setToolTip("Rearrange the drawing to match your pad")
self.read_btn = _button("Read pad", "flat")
self.read_btn.setToolTip("Ask the pad what's on it right now")
self.rotate = _button("Rotate view", "flat")
self.import_btn = _button("Import capture…", "flat")
self.write_all = _button("", "primary")
@@ -452,6 +503,8 @@ class Window(QMainWindow):
fl.addWidget(self.status)
fl.addSpacing(16)
fl.addWidget(self.message, 1)
fl.addWidget(self.edit_btn)
fl.addWidget(self.read_btn)
fl.addWidget(self.rotate)
fl.addWidget(self.import_btn)
fl.addWidget(self.write_all)
@@ -461,8 +514,14 @@ class Window(QMainWindow):
self.pad.selected.connect(self._select)
self.inspector.changed.connect(self._changed)
self.inspector.revert.connect(self._revert)
self.inspector.action.connect(self._identify)
self.inspector.write.connect(lambda c: self._write([c]))
self.write_all.clicked.connect(lambda: self._write(self._pending()))
self.edit_btn.clicked.connect(lambda: self._edit_layout(True))
self.edit_done.clicked.connect(lambda: self._edit_layout(False, keep=True))
self.edit_cancel.clicked.connect(lambda: self._edit_layout(False, keep=False))
self.pad.rearranged.connect(self._rearranged)
self.read_btn.clicked.connect(lambda: self._read(quiet=False))
self.rotate.clicked.connect(self._rotate)
self.import_btn.clicked.connect(self._import)
@@ -473,12 +532,15 @@ class Window(QMainWindow):
self.timer.start(2000)
self._poll()
self._refresh()
# The pad can tell us what it holds, so ask rather than assume.
if self.diag and self.diag.ready:
QTimer.singleShot(0, lambda: self._read(quiet=True))
# ------------------------------------------------------------- model
def _pending(self):
out = []
for c in core.CONTROL_IDS:
for c in self.state.layout.control_ids:
b = self.desired.get(c)
if b is None:
continue
@@ -490,7 +552,7 @@ class Window(QMainWindow):
def _refresh(self, editor=True):
pending = set(self._pending())
looks = {}
for c in core.CONTROL_IDS:
for c in self.state.layout.control_ids:
e = self.state.get(c)
if c in pending:
looks[c] = Look(self.desired[c].label(), known=not e.unknown, pending=True)
@@ -510,7 +572,8 @@ class Window(QMainWindow):
if not editor:
self.inspector.update_buttons(c in pending, can_write)
return
all_unknown = all(self.state.get(x).unknown for x in core.CONTROL_IDS)
all_unknown = all(self.state.get(x).unknown
for x in self.state.layout.control_ids)
self.inspector.show_control(
c, self.state.get(c) if c else None,
self.desired.get(c) if c in pending else None,
@@ -539,8 +602,15 @@ class Window(QMainWindow):
except Exception as e: # never let polling kill the window
self._say(f"Couldn't check the pad: {e}", BAD)
return
d = self.diag
if d.status == "unsupported" and d.device and \
f"{d.device.vid}:{d.device.pid}" in self.adopted:
self.diag = core.Diagnosis("ready", f"Ready on {d.device.path}.",
device=d.device, warnings=d.warnings)
colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD)
self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;")
if self.diag.device:
self.pad.device_id = f"{self.diag.device.vid}:{self.diag.device.pid}"
self.status.setText({
"ready": f"Pad ready on {self.diag.device.path}" if self.diag.device else "Pad ready",
"unplugged": "No pad connected",
@@ -551,6 +621,8 @@ class Window(QMainWindow):
self.inspector.show_problem(self.diag, self.note)
if self.diag.status != old:
self._refresh(editor=False)
if self.diag.ready and old in ("unplugged", "no-interface", None):
self._read(quiet=True)
# ------------------------------------------------------------- write
@@ -590,12 +662,122 @@ class Window(QMainWindow):
self._say(f"Wrote {len(done)} changes to the pad.", GOOD)
self._refresh()
def _read(self, quiet=False):
"""
Ask the pad what it holds and believe the answer. Anything you have
changed but not written is left alone, since that is your intent
rather than the pad's state.
"""
if not (self.diag and self.diag.ready):
if not quiet:
self._say(self.diag.headline if self.diag else "No pad.", BAD)
return
self.read_btn.setEnabled(False)
self.read_btn.setText("Reading...")
QApplication.processEvents()
try:
got = core.read_into_state(self.diag.device, self.state)
except core.ReadError as e:
if not quiet:
self._say(f"Couldn't read the pad: {e}", BAD)
else:
n = len(got)
self._say(f"Read {n} control{'s' if n != 1 else ''} from the pad.", GOOD)
finally:
self.read_btn.setEnabled(True)
self.read_btn.setText("Read pad")
self._refresh()
def _identify(self):
"""
Interrogate a pad we don't recognise. Reads only. If it answers
properly we adopt its shape and carry on; if not we say so and
leave it alone.
"""
dev = self.diag.device if self.diag else None
if not dev:
return
self.inspector.banner_action.setEnabled(False)
self.inspector.banner_action.setText("Asking...")
QApplication.processEvents()
try:
found = core.detect(dev)
finally:
self.inspector.banner_action.setEnabled(True)
self.inspector.banner_action.setText("Ask the pad what it is")
if not found.speaks:
self._say(f"That pad doesn't speak this protocol: {found.why}", BAD)
return
layout = found.layout
self.state = core.State(layout=layout)
self.state.layout = layout
for control, binding in found.bindings.items():
self.state.record(control, binding)
self.state.save()
self.pad.set_layout(layout)
self.desired.clear()
self.adopted.add(f"{dev.vid}:{dev.pid}")
self.settings.setValue("adopted", sorted(self.adopted))
self._poll()
self._say(f"It says {layout.keys} keys and {layout.knobs} "
f"knob{'s' if layout.knobs != 1 else ''}. Check the drawing "
f"matches your pad.", GOOD)
self._refresh()
def _say(self, text, colour=None):
self.message.setText(text)
self.message.setStyleSheet(f"color: {colour};" if colour else "")
# ------------------------------------------------------------- misc
def _edit_layout(self, on, keep=False):
if on:
self.editing_from = self.state.layout
for name, value in (("rows", self.state.layout.rows),
("cols", self.state.layout.cols),
("keys", self.state.layout.keys),
("knobs", self.state.layout.knobs)):
self.spin[name].blockSignals(True)
self.spin[name].setValue(value)
self.spin[name].blockSignals(False)
self._say("")
elif not keep:
self._set_layout(self.editing_from)
else:
self.state.save()
lay = self.state.layout
self._say(f"Layout saved: {lay.describe()}.", GOOD)
self.editbar.setVisible(on)
self.edit_btn.setEnabled(not on)
self.read_btn.setEnabled(not on)
self.inspector.editing_layout = on
self.pad.select(None if on else self.pad.current)
self.pad.set_editing(on)
self._refresh()
def _set_layout(self, layout):
self.state.layout = layout
self.pad.set_layout(layout)
if self.pad.current not in layout.control_ids:
self.pad.current = None
self._refresh()
def _rearranged(self, layout):
self.state.layout = layout
def _resize_layout(self):
lay = self.state.layout.resized(
rows=self.spin["rows"].value(), cols=self.spin["cols"].value(),
keys=self.spin["keys"].value(), knobs=self.spin["knobs"].value())
if lay.rows != self.spin["rows"].value(): # it had to grow to fit
self.spin["rows"].blockSignals(True)
self.spin["rows"].setValue(lay.rows)
self.spin["rows"].blockSignals(False)
self._set_layout(lay)
self.pad.set_editing(True)
def _rotate(self):
o = "flat" if self.pad.orientation == "upright" else "upright"
self.pad.set_orientation(o)