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:
@@ -35,7 +35,8 @@ def cmd_doctor(_):
|
||||
def cmd_state(_):
|
||||
s = core.State()
|
||||
print(f"{DIM}{s.path}{'' if s.path.exists() else ' (not created yet)'}{O}\n")
|
||||
for c in core.CONTROL_IDS:
|
||||
print(f"{DIM}layout: {s.layout.describe()}{O}\n")
|
||||
for c in s.layout.control_ids:
|
||||
e = s.get(c)
|
||||
byte = f"0x{core.action_byte(c):02x}"
|
||||
if e.unknown:
|
||||
@@ -46,6 +47,26 @@ def cmd_state(_):
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_detect(_):
|
||||
d = core.diagnose(keyd=False)
|
||||
if not d.device:
|
||||
print(f"{R}No pad found at all.{O}")
|
||||
return 1
|
||||
print(f"{B}Asking {d.device.vid}:{d.device.pid} on {d.device.path}{O}")
|
||||
print(f"{DIM}This only reads. Nothing is configured.{O}\n")
|
||||
found = core.detect(d.device)
|
||||
print(core.detection_report(d.device, found))
|
||||
if found.speaks:
|
||||
lay = found.layout
|
||||
print(f"\n{G}It speaks this protocol.{O}")
|
||||
print(f"Best guess at the shape: {lay.describe()}. The pad reports how "
|
||||
f"many\nkeys it has, not how they're arranged, so check that "
|
||||
f"against yours.")
|
||||
return 0
|
||||
print(f"\n{Y}It didn't answer properly, so the app won't write to it.{O}")
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_write(a):
|
||||
if a.control not in core.CONTROL_IDS:
|
||||
print(f"Unknown control. One of: {', '.join(core.CONTROL_IDS)}")
|
||||
@@ -78,6 +99,7 @@ def main():
|
||||
sub = ap.add_subparsers(dest="cmd")
|
||||
sub.add_parser("doctor", help="check the pad is connected and writable")
|
||||
sub.add_parser("state", help="show what this tool believes is on the pad")
|
||||
sub.add_parser("detect", help="ask an unrecognised pad what it is (read only)")
|
||||
w = sub.add_parser("write", help="write one binding and record it")
|
||||
w.add_argument("control")
|
||||
w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'")
|
||||
@@ -92,7 +114,8 @@ def main():
|
||||
print("Install Qt for Python: sudo pacman -S pyside6")
|
||||
return 1
|
||||
return run()
|
||||
return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write}[a.cmd](a)
|
||||
return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write,
|
||||
"detect": cmd_detect}[a.cmd](a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,7 +26,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -42,23 +42,155 @@ except ImportError: # running from inside the package dir
|
||||
VID, PID = "1189", "8840"
|
||||
DEVICE_ID = f"{VID}:{PID}"
|
||||
|
||||
KEYS = tuple(f"key{i}" for i in range(1, 13))
|
||||
DIALS = ("dial1", "dial2")
|
||||
DIAL_ACTIONS = ("left", "push", "right")
|
||||
CONTROL_IDS = KEYS + tuple(f"{d}-{a}" for d in DIALS for a in DIAL_ACTIONS)
|
||||
|
||||
# Action bytes come from macropad.CONTROLS, never from here.
|
||||
for _cid in CONTROL_IDS:
|
||||
assert _cid in proto.CONTROLS, f"macropad.py has no control {_cid}"
|
||||
|
||||
WRITE_GAP = 0.02 # same pause the CLI leaves between reports
|
||||
|
||||
|
||||
def action_byte(control):
|
||||
return proto.CONTROLS[control]
|
||||
"""
|
||||
The byte the pad uses for one control. Keys count from 1; knobs start
|
||||
at 0x10, three apiece. Confirmed on 1189:8840 both by writing and by
|
||||
reading back, and it is what the pad reports for its own slots.
|
||||
"""
|
||||
control = control.lower()
|
||||
if control.startswith("key"):
|
||||
return int(control[3:])
|
||||
dial, act = control.split("-")
|
||||
return proto.KNOB_BASE + (int(dial[4:]) - 1) * 3 + DIAL_ACTIONS.index(act)
|
||||
|
||||
|
||||
CONTROL_FOR_ACTION = {action_byte(c): c for c in CONTROL_IDS}
|
||||
@dataclass(frozen=True)
|
||||
class Layout:
|
||||
"""
|
||||
The shape of one pad: how many keys and knobs, and how the keys are
|
||||
arranged when you look at it.
|
||||
|
||||
keys and knobs come from the pad itself (the 0xFB query). Rows and
|
||||
columns do not: the pad knows how many keys it has, not how they are
|
||||
laid out, so that part is either known from the model or chosen by
|
||||
whoever owns it.
|
||||
|
||||
Rows and columns describe the pad lying flat, the way its keys are
|
||||
printed. Standing it upright just turns the drawing.
|
||||
"""
|
||||
keys: int = 12
|
||||
knobs: int = 2
|
||||
rows: int = 3
|
||||
cols: int = 4
|
||||
name: str = ""
|
||||
places: tuple = () # ((row, col), ...) per key; empty means left to right
|
||||
|
||||
def __post_init__(self):
|
||||
if not 1 <= self.keys <= proto.KEY_SLOTS:
|
||||
raise ValueError(f"a pad can have 1 to {proto.KEY_SLOTS} keys")
|
||||
if not 0 <= self.knobs <= proto.KNOB_SLOTS:
|
||||
raise ValueError(f"a pad can have up to {proto.KNOB_SLOTS} knobs")
|
||||
if self.rows * self.cols < self.keys:
|
||||
raise ValueError(f"{self.rows}x{self.cols} can't hold "
|
||||
f"{self.keys} keys")
|
||||
if self.places:
|
||||
if len(self.places) != self.keys:
|
||||
raise ValueError("places must give a spot for every key")
|
||||
if len(set(self.places)) != len(self.places):
|
||||
raise ValueError("two keys can't share a spot")
|
||||
for r, c in self.places:
|
||||
if not (0 <= r < self.rows and 0 <= c < self.cols):
|
||||
raise ValueError(f"({r}, {c}) is outside {self.rows}x{self.cols}")
|
||||
|
||||
def spots(self):
|
||||
"""[(key_id, row, col)] with the pad lying flat."""
|
||||
if self.places:
|
||||
return [(f"key{i + 1}", r, c) for i, (r, c) in enumerate(self.places)]
|
||||
return [(f"key{i + 1}", i // self.cols, i % self.cols)
|
||||
for i in range(self.keys)]
|
||||
|
||||
def rearranged(self, places):
|
||||
"""The same pad with its keys in different spots."""
|
||||
return replace(self, places=tuple((int(r), int(c)) for r, c in places))
|
||||
|
||||
def resized(self, rows=None, cols=None, keys=None, knobs=None):
|
||||
"""
|
||||
Change the grid, keeping any arrangement that still fits and
|
||||
falling back to left to right for keys that no longer do.
|
||||
"""
|
||||
rows = self.rows if rows is None else rows
|
||||
cols = self.cols if cols is None else cols
|
||||
keys = self.keys if keys is None else keys
|
||||
knobs = self.knobs if knobs is None else knobs
|
||||
if rows * cols < keys: # grow to fit rather than refuse
|
||||
rows = -(-keys // cols)
|
||||
|
||||
taken, places = set(), []
|
||||
old = {i: p for i, p in enumerate(self.places)}
|
||||
free = [(r, c) for r in range(rows) for c in range(cols)]
|
||||
for i in range(keys):
|
||||
spot = old.get(i)
|
||||
if spot and spot[0] < rows and spot[1] < cols and spot not in taken:
|
||||
places.append(spot)
|
||||
taken.add(spot)
|
||||
else:
|
||||
places.append(None)
|
||||
for i, spot in enumerate(places):
|
||||
if spot is None:
|
||||
nxt = next(p for p in free if p not in taken)
|
||||
places[i] = nxt
|
||||
taken.add(nxt)
|
||||
return replace(self, rows=rows, cols=cols, keys=keys, knobs=knobs,
|
||||
places=tuple(places) if self.places else ())
|
||||
|
||||
@property
|
||||
def key_ids(self):
|
||||
return tuple(f"key{i}" for i in range(1, self.keys + 1))
|
||||
|
||||
@property
|
||||
def dial_ids(self):
|
||||
return tuple(f"dial{i}" for i in range(1, self.knobs + 1))
|
||||
|
||||
@property
|
||||
def control_ids(self):
|
||||
return self.key_ids + tuple(f"{d}-{a}" for d in self.dial_ids
|
||||
for a in DIAL_ACTIONS)
|
||||
|
||||
@property
|
||||
def control_for_action(self):
|
||||
return {action_byte(c): c for c in self.control_ids}
|
||||
|
||||
def describe(self):
|
||||
knobs = (f" and {self.knobs} knob{'s' if self.knobs != 1 else ''}"
|
||||
if self.knobs else "")
|
||||
return f"{self.keys} keys in {self.rows}x{self.cols}{knobs}"
|
||||
|
||||
def to_json(self):
|
||||
d = {"keys": self.keys, "knobs": self.knobs,
|
||||
"rows": self.rows, "cols": self.cols, "name": self.name}
|
||||
if self.places:
|
||||
d["places"] = [list(p) for p in self.places]
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, d):
|
||||
return cls(keys=int(d["keys"]), knobs=int(d["knobs"]),
|
||||
rows=int(d["rows"]), cols=int(d["cols"]),
|
||||
name=d.get("name", ""),
|
||||
places=tuple(tuple(p) for p in d.get("places", ())))
|
||||
|
||||
|
||||
# Pads we have laid eyes on. Anything else has to be detected or described
|
||||
# by its owner, because a USB id alone does not pin down a layout: 1189:8840
|
||||
# covers several different pads.
|
||||
KNOWN_LAYOUTS = {
|
||||
("1189", "8840"): Layout(12, 2, 3, 4, "12 keys, 2 knobs"),
|
||||
}
|
||||
DEFAULT_LAYOUT = KNOWN_LAYOUTS[(VID, PID)]
|
||||
|
||||
# Kept so older code and the CLI keep working; the app uses a Layout.
|
||||
KEYS = DEFAULT_LAYOUT.key_ids
|
||||
DIALS = DEFAULT_LAYOUT.dial_ids
|
||||
CONTROL_IDS = DEFAULT_LAYOUT.control_ids
|
||||
CONTROL_FOR_ACTION = DEFAULT_LAYOUT.control_for_action
|
||||
|
||||
for _cid in CONTROL_IDS:
|
||||
assert action_byte(_cid) == proto.CONTROLS[_cid], _cid
|
||||
|
||||
_DIAL_WORDS = {"left": "turn left", "push": "press", "right": "turn right"}
|
||||
|
||||
@@ -246,7 +378,7 @@ def _atomic_write_json(path, data):
|
||||
raise
|
||||
|
||||
|
||||
FORMAT = 1
|
||||
FORMAT = 2 # 1 had no layout, and one build saved rows and cols swapped
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -264,16 +396,23 @@ class State:
|
||||
should call record()/mark_unknown(); the GUI just reads it.
|
||||
"""
|
||||
|
||||
def __init__(self, path=None):
|
||||
def __init__(self, path=None, layout=None):
|
||||
self.path = Path(path) if path else config_dir() / f"state-{VID}-{PID}.json"
|
||||
self.entries = {} # (layer, control) -> Entry
|
||||
self.layout = layout or DEFAULT_LAYOUT
|
||||
if self.path.exists():
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
data = json.loads(self.path.read_text())
|
||||
if data.get("format") != FORMAT:
|
||||
raise ValueError(f"{self.path}: unsupported format {data.get('format')}")
|
||||
version = data.get("format")
|
||||
if version not in (1, FORMAT):
|
||||
raise ValueError(f"{self.path}: unsupported format {version}")
|
||||
# Format 1 files either have no layout or have one from the build that
|
||||
# stored rows and columns the wrong way round. Ignore it either way;
|
||||
# the bindings are still good.
|
||||
if version == FORMAT and data.get("layout"):
|
||||
self.layout = Layout.from_json(data["layout"])
|
||||
for layer, controls in data.get("layers", {}).items():
|
||||
for control, e in controls.items():
|
||||
if e.get("unknown"):
|
||||
@@ -293,6 +432,7 @@ class State:
|
||||
d = {"binding": e.binding.to_json(), "written": e.written}
|
||||
layers.setdefault(str(layer), {})[control] = d
|
||||
_atomic_write_json(self.path, {"format": FORMAT, "device": DEVICE_ID,
|
||||
"layout": self.layout.to_json(),
|
||||
"layers": layers})
|
||||
|
||||
def get(self, control, layer=1):
|
||||
@@ -315,6 +455,8 @@ class Device:
|
||||
report_id: int
|
||||
writable: bool
|
||||
name: str = ""
|
||||
vid: str = VID
|
||||
pid: str = PID
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -435,17 +577,19 @@ def diagnose(keyd=True):
|
||||
|
||||
if not found and everything:
|
||||
other = everything[0]
|
||||
dev = Device(path=other["path"], report_id=other["report_id"],
|
||||
writable=other["writable"], name=other.get("name", ""),
|
||||
vid=other["vid"], pid=other["pid"])
|
||||
return Diagnosis(
|
||||
"unsupported",
|
||||
f"Found a {other['vid']}:{other['pid']} pad, which this app doesn't know yet.",
|
||||
detail=[f"So far only {DEVICE_ID} (12 keys, 2 dials) has been "
|
||||
"worked out. Pads in this family differ in layout and "
|
||||
"protocol, so writing to yours with the wrong one could "
|
||||
"fail or scramble it. The app won't try.",
|
||||
"ch57x-keyboard-tool, a command line tool, supports several "
|
||||
"of these pads today. To get yours into this app, the "
|
||||
"README's \"Other pads\" section shows how to capture what "
|
||||
"the vendor software sends."],
|
||||
f"Found a {other['vid']}:{other['pid']} pad, which isn't one this "
|
||||
"app knows.",
|
||||
device=dev,
|
||||
detail=["Pads in this family differ in both layout and protocol, "
|
||||
"so the app won't write to one it hasn't identified.",
|
||||
"It can ask the pad about itself first. That only reads, "
|
||||
"and if the pad doesn't answer properly nothing else "
|
||||
"happens."],
|
||||
warnings=warnings)
|
||||
|
||||
if not found:
|
||||
@@ -464,7 +608,8 @@ def diagnose(keyd=True):
|
||||
|
||||
d = found[0]
|
||||
dev = Device(path=d["path"], report_id=d["report_id"],
|
||||
writable=d["writable"], name=d.get("name", ""))
|
||||
writable=d["writable"], name=d.get("name", ""),
|
||||
vid=d["vid"], pid=d["pid"])
|
||||
|
||||
if dev.writable:
|
||||
return Diagnosis("ready", f"Ready on {dev.path}.", device=dev,
|
||||
@@ -569,13 +714,20 @@ def write_binding(device, control, binding, state, layer=1, _write=None):
|
||||
|
||||
# --------------------------------------------------------------- decoding
|
||||
|
||||
def decode_info(report):
|
||||
"""(keys, knobs) from a 0xFB reply, or None."""
|
||||
if len(report) < 4 or report[1] != proto.MAGIC_INFO:
|
||||
return None
|
||||
return report[2], report[3]
|
||||
|
||||
|
||||
def decode_config(report):
|
||||
"""
|
||||
The inverse of Binding.reports()[0]: (control, layer, Binding) from a
|
||||
native config report, or None if it isn't one we understand. Used to
|
||||
import captures and to prove round trips in the tests.
|
||||
"""
|
||||
if len(report) < 13 or report[1] != proto.MAGIC_NATIVE:
|
||||
if len(report) < 13 or report[1] not in (proto.MAGIC_NATIVE, proto.MAGIC_READ):
|
||||
return None
|
||||
action, layer, kind = report[2], report[3], report[4]
|
||||
control = CONTROL_FOR_ACTION.get(action)
|
||||
@@ -584,17 +736,17 @@ def decode_config(report):
|
||||
delay = report[5] | (report[6] << 8)
|
||||
count = report[10]
|
||||
|
||||
if kind == proto.KeyType.MULTIMEDIA:
|
||||
if kind == proto.KeyType.MULTIMEDIA and count:
|
||||
media = _MEDIA_BY_USAGE.get(report[11] | (report[12] << 8))
|
||||
return (control, layer, Binding(media=media)) if media else None
|
||||
|
||||
if kind == proto.KeyType.BASIC and count == 0:
|
||||
if count == 0 and kind in (proto.KeyType.NONE, proto.KeyType.BASIC):
|
||||
mods, code = report[11], report[12]
|
||||
if not mods and not code:
|
||||
return control, layer, Binding(none=True)
|
||||
return None
|
||||
|
||||
if kind == proto.KeyType.BASIC and count:
|
||||
if kind in (proto.KeyType.NONE, proto.KeyType.BASIC) and count:
|
||||
steps = []
|
||||
for i in range(count):
|
||||
off = 11 + 2 * i
|
||||
@@ -616,11 +768,21 @@ def is_commit(report):
|
||||
(report[2], report[3]) == proto.TERMINATOR
|
||||
|
||||
|
||||
def read_capture(path):
|
||||
"""Raw report bytes from a macropad-capture.py output file, in order."""
|
||||
def read_capture(path, incoming=False):
|
||||
"""
|
||||
Reports from a macropad-capture.py output file, in order.
|
||||
|
||||
Captures can now hold both directions. By default we return only what
|
||||
was sent TO the pad, since that is what a binding looks like; pass
|
||||
incoming=True for what the pad sent back.
|
||||
"""
|
||||
out = []
|
||||
want = " in " if incoming else " out "
|
||||
keep = False
|
||||
for line in Path(path).read_text().splitlines():
|
||||
if line.startswith(" "):
|
||||
if line.startswith("["):
|
||||
keep = want in f" {line.split(']', 1)[-1].strip()} "
|
||||
elif line.startswith(" ") and keep:
|
||||
try:
|
||||
out.append(bytes.fromhex(line.strip()))
|
||||
except ValueError:
|
||||
@@ -648,3 +810,192 @@ def import_capture(path, state):
|
||||
if done:
|
||||
state.save()
|
||||
return done
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reading
|
||||
|
||||
READ_TIMEOUT = 1.5 # total, per request
|
||||
READ_QUIET = 0.25 # replies have stopped once this much silence passes
|
||||
|
||||
|
||||
class ReadError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _exchange(path, request, want=None):
|
||||
"""
|
||||
Send one request and collect replies until the pad goes quiet.
|
||||
|
||||
Reading is the one place we write to the pad without changing it: both
|
||||
commands here are queries, byte for byte what the vendor software sends.
|
||||
"""
|
||||
import select
|
||||
|
||||
try:
|
||||
fd = os.open(path, os.O_RDWR | os.O_NONBLOCK)
|
||||
except OSError as e:
|
||||
raise ReadError(_explain(e)) from e
|
||||
|
||||
replies = []
|
||||
try:
|
||||
while select.select([fd], [], [], 0)[0]: # drain anything stale
|
||||
try:
|
||||
os.read(fd, 128)
|
||||
except OSError:
|
||||
break
|
||||
try:
|
||||
os.write(fd, request)
|
||||
except OSError as e:
|
||||
raise ReadError(_explain(e)) from e
|
||||
|
||||
deadline = time.monotonic() + READ_TIMEOUT
|
||||
last = time.monotonic()
|
||||
while time.monotonic() < deadline:
|
||||
if not select.select([fd], [], [], 0.05)[0]:
|
||||
if replies and time.monotonic() - last > READ_QUIET:
|
||||
break
|
||||
continue
|
||||
try:
|
||||
data = os.read(fd, 128)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
except OSError as e:
|
||||
raise ReadError(_explain(e)) from e
|
||||
if not data:
|
||||
continue
|
||||
last = time.monotonic()
|
||||
replies.append(data)
|
||||
if want is not None and len(replies) >= want:
|
||||
break
|
||||
finally:
|
||||
os.close(fd)
|
||||
return replies
|
||||
|
||||
|
||||
def read_info(device):
|
||||
"""(keys, knobs) as the pad reports them, or None if it doesn't answer."""
|
||||
for reply in _exchange(device.path, proto.native_info(device.report_id), want=1):
|
||||
got = decode_info(reply)
|
||||
if got:
|
||||
return got
|
||||
return None
|
||||
|
||||
|
||||
def read_layer(device, layer=1):
|
||||
"""{control: Binding} for one layer, as it is on the pad right now."""
|
||||
out = {}
|
||||
for reply in _exchange(device.path, proto.native_read(device.report_id, layer)):
|
||||
decoded = decode_config(reply)
|
||||
if not decoded:
|
||||
continue
|
||||
control, got_layer, binding = decoded
|
||||
if got_layer == layer:
|
||||
out[control] = binding
|
||||
return out
|
||||
|
||||
|
||||
def read_into_state(device, state, layer=1):
|
||||
"""Read the pad and make the shadow state match it. Returns what was read."""
|
||||
bindings = read_layer(device, layer)
|
||||
if not bindings:
|
||||
raise ReadError("the pad didn't answer")
|
||||
for control, binding in bindings.items():
|
||||
state.record(control, binding, layer)
|
||||
state.save()
|
||||
return bindings
|
||||
|
||||
|
||||
# -------------------------------------------------------------- detection
|
||||
|
||||
@dataclass
|
||||
class Detection:
|
||||
"""What a read-only interrogation of an unrecognised pad turned up."""
|
||||
speaks: bool = False # it answered our protocol properly
|
||||
keys: int = 0
|
||||
knobs: int = 0
|
||||
layers: int = 0
|
||||
bindings: dict = field(default_factory=dict)
|
||||
why: str = "" # when speaks is False, what went wrong
|
||||
|
||||
@property
|
||||
def layout(self):
|
||||
"""
|
||||
A first guess at the shape. The pad reports counts, not geometry,
|
||||
so rows and columns are a guess the owner should confirm.
|
||||
"""
|
||||
if not self.speaks:
|
||||
return None
|
||||
cols = 3 if self.keys % 3 == 0 else (4 if self.keys % 4 == 0 else self.keys)
|
||||
rows = -(-self.keys // cols)
|
||||
return Layout(self.keys, self.knobs, rows, cols)
|
||||
|
||||
|
||||
def detect(device):
|
||||
"""
|
||||
Ask an unknown pad what it is, without configuring anything.
|
||||
|
||||
Both commands are queries. We only conclude the pad speaks this
|
||||
protocol if it answers both in the right shape: a well formed info
|
||||
reply, and a layer whose controls are exactly the slots that reply
|
||||
implies. A pad from a different family will fail one of those and we
|
||||
leave it alone.
|
||||
"""
|
||||
try:
|
||||
info = read_info(device)
|
||||
except ReadError as e:
|
||||
return Detection(why=f"it didn't answer: {e}")
|
||||
if not info:
|
||||
return Detection(why="it didn't answer the 'what are you' query")
|
||||
|
||||
keys, knobs = info
|
||||
if not 1 <= keys <= proto.KEY_SLOTS or not 0 <= knobs <= proto.KNOB_SLOTS:
|
||||
return Detection(why=f"it reported {keys} keys and {knobs} knobs, "
|
||||
"which isn't a pad this protocol can describe")
|
||||
|
||||
probe = Layout(keys, knobs, rows=keys, cols=1) # geometry irrelevant here
|
||||
expected = set(probe.control_for_action)
|
||||
|
||||
try:
|
||||
replies = _exchange(device.path, proto.native_read(device.report_id, 1))
|
||||
except ReadError as e:
|
||||
return Detection(why=f"it answered the first query but not the second: {e}")
|
||||
|
||||
seen, bindings = set(), {}
|
||||
for reply in replies:
|
||||
if len(reply) < 13 or reply[1] != proto.MAGIC_READ:
|
||||
continue
|
||||
action, layer = reply[2], reply[3]
|
||||
seen.add(action)
|
||||
if action not in expected or layer != 1:
|
||||
continue
|
||||
control = probe.control_for_action[action]
|
||||
decoded = decode_config(reply)
|
||||
if decoded:
|
||||
bindings[control] = decoded[2]
|
||||
|
||||
missing = expected - seen
|
||||
if missing:
|
||||
return Detection(why=f"it described {keys} keys and {knobs} knobs but "
|
||||
f"didn't report {len(missing)} of them, so this "
|
||||
"isn't the protocol it speaks")
|
||||
|
||||
return Detection(speaks=True, keys=keys, knobs=knobs,
|
||||
layers=proto.LAYERS, bindings=bindings)
|
||||
|
||||
|
||||
def detection_report(device, detected):
|
||||
"""A block of text to paste into an issue when adding a new pad."""
|
||||
lines = [f"Device: {device.vid}:{device.pid}",
|
||||
f"Name: {device.name or 'not reported'}",
|
||||
f"Node: {device.path}, report id {device.report_id}", ""]
|
||||
if not detected.speaks:
|
||||
lines += ["This pad did NOT answer the 1189:8840 protocol.",
|
||||
f"Reason: {detected.why}"]
|
||||
return "\n".join(lines)
|
||||
lines += [f"The pad reports {detected.keys} keys and {detected.knobs} knobs.",
|
||||
f"It answered a layer read for every slot that implies.", "",
|
||||
"Layer 1 as read from the pad:"]
|
||||
for control, binding in sorted(detected.bindings.items(),
|
||||
key=lambda kv: action_byte(kv[0])):
|
||||
lines.append(f" 0x{action_byte(control):02x} {control:<12} {binding.label()}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -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