Add files via upload
This commit is contained in:
1
macropad_gui/__init__.py
Normal file
1
macropad_gui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Linux GUI for Holtek-family USB macro pads. GPL-3.0."""
|
||||
99
macropad_gui/__main__.py
Normal file
99
macropad_gui/__main__.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
python3 -m macropad_gui open the app
|
||||
python3 -m macropad_gui doctor check the pad is connected and writable
|
||||
|
||||
The rest are for poking at the core from a terminal:
|
||||
python3 -m macropad_gui state
|
||||
python3 -m macropad_gui state
|
||||
python3 -m macropad_gui write key1 "ctrl+c"
|
||||
python3 -m macropad_gui write dial1-right --media volumeup
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from . import core
|
||||
|
||||
G = "\033[32m"; R = "\033[31m"; Y = "\033[33m"; B = "\033[1m"; DIM = "\033[2m"; O = "\033[0m"
|
||||
|
||||
|
||||
def cmd_doctor(_):
|
||||
d = core.diagnose()
|
||||
colour = G if d.ready else (Y if d.status == "unplugged" else R)
|
||||
print(f"{colour}{B}{d.headline}{O}")
|
||||
for line in d.detail:
|
||||
print(f" {line}")
|
||||
if d.fix:
|
||||
print(f"\n{B}Fix{O}")
|
||||
for c in d.fix:
|
||||
print(f" {DIM if c.startswith('#') else ''}{c}{O}")
|
||||
for w in d.warnings:
|
||||
print(f"\n{Y}Note:{O} {w}")
|
||||
return 0 if d.ready else 1
|
||||
|
||||
|
||||
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:
|
||||
e = s.get(c)
|
||||
byte = f"0x{core.action_byte(c):02x}"
|
||||
if e.unknown:
|
||||
print(f" {c:<12} {DIM}{byte}{O} {Y}unknown{O} {DIM}{e.reason}{O}")
|
||||
else:
|
||||
print(f" {c:<12} {DIM}{byte}{O} {e.binding.label():<24} "
|
||||
f"{DIM}{e.written}{O}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_write(a):
|
||||
if a.control not in core.CONTROL_IDS:
|
||||
print(f"Unknown control. One of: {', '.join(core.CONTROL_IDS)}")
|
||||
return 1
|
||||
try:
|
||||
b = core.Binding(keys=a.keys or "", media=a.media or "",
|
||||
delay=a.delay).validate()
|
||||
except ValueError as e:
|
||||
print(f"{R}{e}{O}")
|
||||
return 1
|
||||
|
||||
d = core.diagnose()
|
||||
if not d.ready:
|
||||
return cmd_doctor(None)
|
||||
|
||||
state = core.State()
|
||||
try:
|
||||
core.write_binding(d.device, a.control, b, state)
|
||||
except core.WriteError as e:
|
||||
print(f"{R}Write failed:{O} {e}")
|
||||
print(" " + ("The pad was partly written; this control is now marked "
|
||||
"unknown." if e.pad_touched else "Nothing reached the pad."))
|
||||
return 1
|
||||
print(f"{G}Written.{O} {a.control} -> {b.label()} {DIM}{state.path}{O}")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(prog="python3 -m macropad_gui")
|
||||
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")
|
||||
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'")
|
||||
w.add_argument("--media", help="e.g. volumeup")
|
||||
w.add_argument("--delay", type=int, default=0)
|
||||
a = ap.parse_args()
|
||||
if a.cmd is None:
|
||||
try:
|
||||
from .app import run
|
||||
except ImportError as e:
|
||||
print(f"{R}Can't start the app:{O} {e}")
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
648
macropad_gui/app.py
Normal file
648
macropad_gui/app.py
Normal file
@@ -0,0 +1,648 @@
|
||||
"""
|
||||
The window. Open it, change what a control does, write it, close it. The
|
||||
pad keeps its bindings in its own memory, so nothing here needs to keep
|
||||
running afterwards.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QGuiApplication, QPalette
|
||||
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
|
||||
QGridLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QMainWindow, QMessageBox, QPlainTextEdit,
|
||||
QPushButton, QVBoxLayout, QWidget)
|
||||
|
||||
from . import core, keymap
|
||||
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
|
||||
|
||||
GOOD = "#8db36b"
|
||||
BAD = "#e0584f"
|
||||
LINE = "#3a3e42"
|
||||
FIELD = "#121416"
|
||||
|
||||
# Media keys as pairs, so opposites sit side by side.
|
||||
MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
|
||||
("volumedown", "volumeup"), ("mute", None),
|
||||
("brightnessdown", "brightnessup"),
|
||||
("calculator", "mail"), ("browser", "mycomputer")]
|
||||
|
||||
STYLE = f"""
|
||||
QWidget {{ color: {INK.name()}; background: {WINDOW.name()}; }}
|
||||
QLabel[role="title"] {{ font-size: 18px; font-weight: 600; }}
|
||||
QLabel[role="dim"] {{ color: {INK_DIM.name()}; }}
|
||||
QLabel[role="caption"] {{ color: {INK_DIM.name()}; padding: 14px 0px 0px 0px; margin: 0px; }}
|
||||
QLabel[role="value"] {{ font-size: 15px; }}
|
||||
QPushButton {{ background: transparent; border: 1px solid {LINE};
|
||||
border-radius: 4px; padding: 6px 12px; }}
|
||||
QPushButton:hover {{ border-color: {INK_DIM.name()}; }}
|
||||
QPushButton:checked {{ border-color: {TAPE.name()}; color: {TAPE.name()}; }}
|
||||
QPushButton:disabled {{ color: #4a4e52; border-color: #2a2e32; }}
|
||||
QPushButton[role="primary"] {{ background: {TAPE.name()}; color: #1b1206;
|
||||
border: none; font-weight: 600; padding: 8px 16px; }}
|
||||
QPushButton[role="primary"]:hover {{ background: #f39a45; }}
|
||||
QPushButton[role="primary"]:disabled {{ background: #2c3034; color: #5a5e62; }}
|
||||
QPushButton[role="flat"] {{ border: none; color: {INK_DIM.name()}; padding: 6px 8px; }}
|
||||
QPushButton[role="flat"]:hover {{ color: {INK.name()}; }}
|
||||
QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }}
|
||||
QLineEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px;
|
||||
padding: 7px 8px; }}
|
||||
QLineEdit:focus {{ border-color: {INK_DIM.name()}; }}
|
||||
QLineEdit[recording="true"] {{ border-color: {TAPE.name()}; background: #2a1d10; }}
|
||||
QPlainTextEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px; }}
|
||||
QFrame[role="banner"] {{ border: 1px solid {LINE}; border-left: 3px solid {BAD};
|
||||
border-radius: 4px; }}
|
||||
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}; }}
|
||||
"""
|
||||
|
||||
|
||||
def _label(text="", role=None, wrap=False):
|
||||
lab = QLabel(text)
|
||||
lab.setIndent(0)
|
||||
if role:
|
||||
lab.setProperty("role", role)
|
||||
lab.setWordWrap(wrap)
|
||||
return lab
|
||||
|
||||
|
||||
def _button(text, role=None, checkable=False):
|
||||
b = QPushButton(text)
|
||||
if role:
|
||||
b.setProperty("role", role)
|
||||
b.setCheckable(checkable)
|
||||
b.setCursor(Qt.PointingHandCursor)
|
||||
return b
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ recorder
|
||||
|
||||
class RecorderEdit(QLineEdit):
|
||||
"""
|
||||
A text field that can also listen for a key combo. Type 'ctrl+shift+n',
|
||||
or press Record and then press the combo itself.
|
||||
"""
|
||||
recorded = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.recording = False
|
||||
self.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
|
||||
|
||||
def set_recording(self, on):
|
||||
self.recording = on
|
||||
self.setProperty("recording", "true" if on else "false")
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
if on:
|
||||
self._before = self.text()
|
||||
self.clear()
|
||||
self.setPlaceholderText("Press the combo now")
|
||||
self.setFocus()
|
||||
else:
|
||||
self.setPlaceholderText("ctrl+shift+n")
|
||||
|
||||
def event(self, e):
|
||||
# Catch Tab and friends too, which would otherwise move focus.
|
||||
if self.recording and e.type() == QEvent.KeyPress:
|
||||
self._record(e)
|
||||
return True
|
||||
if self.recording and e.type() == QEvent.ShortcutOverride:
|
||||
e.accept()
|
||||
return True
|
||||
return super().event(e)
|
||||
|
||||
def _record(self, e):
|
||||
mods = keymap.modifier_names(e)
|
||||
if e.key() in keymap.MODIFIER_KEYS:
|
||||
self.setText("+".join(mods + ["…"]))
|
||||
return
|
||||
name = keymap.key_name(e)
|
||||
if name is None:
|
||||
self.setText("")
|
||||
self.setPlaceholderText("The pad can't send that key. Try another.")
|
||||
return
|
||||
combo = "+".join(mods + [name])
|
||||
self.set_recording(False)
|
||||
self.setText(combo)
|
||||
self.recorded.emit(combo)
|
||||
|
||||
def focusOutEvent(self, e):
|
||||
if self.recording:
|
||||
self.set_recording(False)
|
||||
self.setText(self._before)
|
||||
super().focusOutEvent(e)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ inspector
|
||||
|
||||
class Inspector(QWidget):
|
||||
"""Right-hand panel: what the selected control does, and what to change it to."""
|
||||
changed = Signal(str, object) # control, Binding or None (revert)
|
||||
write = Signal(str)
|
||||
revert = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setFixedWidth(360)
|
||||
self.control = None
|
||||
self._loading = False
|
||||
|
||||
v = QVBoxLayout(self)
|
||||
v.setContentsMargins(24, 24, 24, 16)
|
||||
v.setSpacing(4)
|
||||
|
||||
# problem banner (no pad / no permission)
|
||||
self.banner = QFrame()
|
||||
self.banner.setProperty("role", "banner")
|
||||
bl = QVBoxLayout(self.banner)
|
||||
bl.setContentsMargins(12, 10, 12, 10)
|
||||
self.banner_head = _label("", wrap=True)
|
||||
self.banner_head.setStyleSheet("font-weight: 600;")
|
||||
self.banner_text = _label("", "dim", wrap=True)
|
||||
self.banner_fix = QPlainTextEdit()
|
||||
self.banner_fix.setReadOnly(True)
|
||||
self.banner_fix.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
|
||||
self.banner_fix.setFixedHeight(84)
|
||||
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):
|
||||
bl.addWidget(w)
|
||||
self.banner_copy.setSizePolicy(self.banner_copy.sizePolicy().horizontalPolicy(),
|
||||
self.banner_copy.sizePolicy().verticalPolicy())
|
||||
v.addWidget(self.banner)
|
||||
v.addSpacing(8)
|
||||
|
||||
# empty state
|
||||
self.empty = _label("", "dim", wrap=True)
|
||||
v.addWidget(self.empty)
|
||||
|
||||
# editor
|
||||
self.editor = QWidget()
|
||||
ev = QVBoxLayout(self.editor)
|
||||
ev.setContentsMargins(0, 0, 0, 0)
|
||||
ev.setSpacing(4)
|
||||
self.title = _label("", "title")
|
||||
self.sub = _label("", "dim")
|
||||
ev.addWidget(self.title)
|
||||
ev.addWidget(self.sub)
|
||||
|
||||
ev.addWidget(_label("On the pad now", "caption"))
|
||||
self.onpad = _label("", "value", wrap=True)
|
||||
self.onpad_why = _label("", "dim", wrap=True)
|
||||
ev.addWidget(self.onpad)
|
||||
ev.addWidget(self.onpad_why)
|
||||
|
||||
ev.addWidget(_label("Change it to", "caption"))
|
||||
modes = QHBoxLayout()
|
||||
modes.setSpacing(6)
|
||||
self.mode_keys = _button("Shortcut", checkable=True)
|
||||
self.mode_media = _button("Media key", checkable=True)
|
||||
self.mode_group = QButtonGroup(self)
|
||||
self.mode_group.setExclusive(True)
|
||||
for i, b in enumerate((self.mode_keys, self.mode_media)):
|
||||
self.mode_group.addButton(b, i)
|
||||
modes.addWidget(b)
|
||||
modes.addStretch()
|
||||
ev.addLayout(modes)
|
||||
ev.addSpacing(6)
|
||||
|
||||
# shortcut page
|
||||
kp = QWidget()
|
||||
kl = QVBoxLayout(kp)
|
||||
kl.setContentsMargins(0, 0, 0, 0)
|
||||
row = QHBoxLayout()
|
||||
self.keys_edit = RecorderEdit()
|
||||
self.keys_edit.setPlaceholderText("ctrl+shift+n")
|
||||
self.record = _button("Record", checkable=True)
|
||||
row.addWidget(self.keys_edit, 1)
|
||||
row.addWidget(self.record)
|
||||
kl.addLayout(row)
|
||||
self.keys_hint = _label("", "dim", wrap=True)
|
||||
kl.addWidget(self.keys_hint)
|
||||
# media page
|
||||
mp = QWidget()
|
||||
ml = QGridLayout(mp)
|
||||
ml.setContentsMargins(0, 0, 0, 0)
|
||||
ml.setSpacing(6)
|
||||
self.media_group = QButtonGroup(self)
|
||||
self.media_group.setExclusive(True)
|
||||
self.media_buttons = {}
|
||||
for r, pair in enumerate(MEDIA_GRID):
|
||||
for col, mid in enumerate(pair):
|
||||
if mid is None:
|
||||
continue
|
||||
b = _button(core.MEDIA_LABEL[mid], "media", checkable=True)
|
||||
self.media_group.addButton(b, [m for m, _ in core.MEDIA].index(mid))
|
||||
self.media_buttons[mid] = b
|
||||
ml.addWidget(b, r, col)
|
||||
self.pages = (kp, mp)
|
||||
ev.addWidget(kp)
|
||||
ev.addWidget(mp)
|
||||
|
||||
ev.addSpacing(10)
|
||||
actions = QHBoxLayout()
|
||||
self.write_btn = _button("Write to pad", "primary")
|
||||
self.revert_btn = _button("Revert", "flat")
|
||||
actions.addWidget(self.write_btn)
|
||||
actions.addWidget(self.revert_btn)
|
||||
actions.addStretch()
|
||||
ev.addLayout(actions)
|
||||
v.addWidget(self.editor)
|
||||
v.addStretch()
|
||||
|
||||
self.mode_group.idClicked.connect(self._mode)
|
||||
self.keys_edit.textEdited.connect(self._keys_typed)
|
||||
self.keys_edit.recorded.connect(self._keys_typed)
|
||||
self.record.toggled.connect(self._toggle_record)
|
||||
self.media_group.idClicked.connect(self._media_picked)
|
||||
self.write_btn.clicked.connect(lambda: self.write.emit(self.control))
|
||||
self.revert_btn.clicked.connect(lambda: self.revert.emit(self.control))
|
||||
|
||||
self.show_control(None, None, None, False)
|
||||
|
||||
# -------------------------------------------------------------- banner
|
||||
|
||||
def show_problem(self, diag, note=None):
|
||||
if diag is None or diag.ready:
|
||||
if not note:
|
||||
self.banner.hide()
|
||||
return
|
||||
diag = core.Diagnosis("note", "Heads up", detail=[note])
|
||||
quiet = diag.status in ("unplugged", "note", "unsupported")
|
||||
self.banner.setProperty("tone", "quiet" if quiet else "")
|
||||
self.banner.style().unpolish(self.banner)
|
||||
self.banner.style().polish(self.banner)
|
||||
self.banner_head.setText(diag.headline)
|
||||
self.banner_text.setText("\n\n".join(diag.detail))
|
||||
cmds = [c for c in diag.fix if not c.startswith("#")]
|
||||
self.banner_fix.setPlainText("\n".join(cmds))
|
||||
lh = self.banner_fix.fontMetrics().lineSpacing()
|
||||
self.banner_fix.setFixedHeight(lh * max(1, len(cmds)) + 30)
|
||||
self.banner_fix.setVisible(bool(cmds))
|
||||
self.banner_copy.setVisible(bool(cmds))
|
||||
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()
|
||||
|
||||
def _copy(self):
|
||||
QGuiApplication.clipboard().setText(self.banner_fix.toPlainText())
|
||||
self.banner_copy.setText("Copied")
|
||||
QTimer.singleShot(1500, lambda: self.banner_copy.setText("Copy commands"))
|
||||
|
||||
# -------------------------------------------------------------- editor
|
||||
|
||||
def show_control(self, control, entry, desired, can_write, all_unknown=False):
|
||||
self._loading = True
|
||||
self.control = control
|
||||
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 ""))
|
||||
self.empty.show()
|
||||
self._loading = False
|
||||
return
|
||||
self.empty.hide()
|
||||
self.editor.show()
|
||||
self.title.setText(core.control_name(control))
|
||||
self.sub.setText(f"Action byte 0x{core.action_byte(control):02x}")
|
||||
|
||||
if entry.unknown:
|
||||
self.onpad.setText("Unknown")
|
||||
self.onpad.setStyleSheet(f"color: {INK_DIM.name()};")
|
||||
self.onpad_why.setText(entry.reason[:1].upper() + entry.reason[1:] + ".")
|
||||
self.onpad_why.show()
|
||||
else:
|
||||
self.onpad.setText(entry.binding.label())
|
||||
self.onpad.setStyleSheet("")
|
||||
self.onpad_why.hide()
|
||||
|
||||
shown = desired or (None if entry.unknown else entry.binding)
|
||||
if shown is not None and shown.media:
|
||||
self.mode_media.setChecked(True)
|
||||
self._page(1)
|
||||
self.media_buttons[shown.media].setChecked(True)
|
||||
self.keys_edit.clear()
|
||||
else:
|
||||
self.mode_keys.setChecked(True)
|
||||
self._page(0)
|
||||
self.keys_edit.setText(shown.keys if shown else "")
|
||||
self._clear_media()
|
||||
self.keys_hint.setText("")
|
||||
self.update_buttons(desired is not None, can_write)
|
||||
self._loading = False
|
||||
|
||||
def update_buttons(self, pending, can_write):
|
||||
self.write_btn.setEnabled(can_write and pending)
|
||||
self.revert_btn.setVisible(pending)
|
||||
|
||||
def _clear_media(self):
|
||||
self.media_group.setExclusive(False)
|
||||
for b in self.media_buttons.values():
|
||||
b.setChecked(False)
|
||||
self.media_group.setExclusive(True)
|
||||
|
||||
def _page(self, i):
|
||||
# Plain show/hide rather than QStackedWidget, which always reserves
|
||||
# the height of its tallest page.
|
||||
for j, page in enumerate(self.pages):
|
||||
page.setVisible(j == i)
|
||||
|
||||
def _mode(self, i):
|
||||
self._page(i)
|
||||
if i == 0 and self.record.isChecked():
|
||||
self.keys_edit.setFocus()
|
||||
|
||||
def _toggle_record(self, on):
|
||||
self.keys_edit.set_recording(on)
|
||||
self.keys_hint.setText(
|
||||
"Plasma keeps some combos for itself, like Meta shortcuts, so the "
|
||||
"app never sees them. Type those instead." if on else "")
|
||||
|
||||
def _keys_typed(self, text):
|
||||
if self.record.isChecked() and not self.keys_edit.recording:
|
||||
self.record.setChecked(False)
|
||||
if self._loading or self.control is None:
|
||||
return
|
||||
text = core.normalize_keys(text)
|
||||
if not text:
|
||||
self.keys_hint.setText("")
|
||||
self.changed.emit(self.control, None)
|
||||
return
|
||||
try:
|
||||
b = core.Binding(keys=text).validate()
|
||||
except ValueError as e:
|
||||
self.keys_hint.setText(f"<span style='color:{BAD}'>{e}</span>")
|
||||
self.changed.emit(self.control, None)
|
||||
return
|
||||
if b.is_sequence and not core.SEQUENCES_VERIFIED:
|
||||
self.keys_hint.setText(
|
||||
f"<span style='color:{BAD}'>Sequences aren't verified on this "
|
||||
"pad yet, so they're switched off for now. One shortcut per "
|
||||
"control.</span>")
|
||||
self.changed.emit(self.control, None)
|
||||
return
|
||||
self.keys_hint.setText(f"Sends {b.label()}")
|
||||
self.changed.emit(self.control, b)
|
||||
|
||||
def _media_picked(self, i):
|
||||
if self._loading or self.control is None:
|
||||
return
|
||||
self.changed.emit(self.control, core.Binding(media=core.MEDIA[i][0]))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ window
|
||||
|
||||
class Window(QMainWindow):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("MacroPad")
|
||||
self.settings = QSettings("macropad", "macropad")
|
||||
self.state = core.State()
|
||||
self.desired = {} # control -> Binding, unwritten edits
|
||||
self.diag = None
|
||||
|
||||
root = QWidget()
|
||||
outer = QVBoxLayout(root)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
|
||||
body = QHBoxLayout()
|
||||
body.setSpacing(0)
|
||||
self.pad = PadView()
|
||||
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)
|
||||
|
||||
footer = QFrame()
|
||||
footer.setProperty("role", "footer")
|
||||
fl = QHBoxLayout(footer)
|
||||
fl.setContentsMargins(16, 8, 16, 8)
|
||||
self.dot = QLabel()
|
||||
self.dot.setFixedSize(9, 9)
|
||||
self.status = _label("", "dim")
|
||||
self.message = _label("")
|
||||
self.rotate = _button("Rotate view", "flat")
|
||||
self.import_btn = _button("Import capture…", "flat")
|
||||
self.write_all = _button("", "primary")
|
||||
fl.addWidget(self.dot)
|
||||
fl.addWidget(self.status)
|
||||
fl.addSpacing(16)
|
||||
fl.addWidget(self.message, 1)
|
||||
fl.addWidget(self.rotate)
|
||||
fl.addWidget(self.import_btn)
|
||||
fl.addWidget(self.write_all)
|
||||
outer.addWidget(footer)
|
||||
self.setCentralWidget(root)
|
||||
|
||||
self.pad.selected.connect(self._select)
|
||||
self.inspector.changed.connect(self._changed)
|
||||
self.inspector.revert.connect(self._revert)
|
||||
self.inspector.write.connect(lambda c: self._write([c]))
|
||||
self.write_all.clicked.connect(lambda: self._write(self._pending()))
|
||||
self.rotate.clicked.connect(self._rotate)
|
||||
self.import_btn.clicked.connect(self._import)
|
||||
|
||||
self.note = core._keyd_warning()
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self._poll)
|
||||
self.timer.start(2000)
|
||||
self._poll()
|
||||
self._refresh()
|
||||
|
||||
# ------------------------------------------------------------- model
|
||||
|
||||
def _pending(self):
|
||||
out = []
|
||||
for c in core.CONTROL_IDS:
|
||||
b = self.desired.get(c)
|
||||
if b is None:
|
||||
continue
|
||||
e = self.state.get(c)
|
||||
if e.unknown or e.binding != b:
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
def _refresh(self, editor=True):
|
||||
pending = set(self._pending())
|
||||
looks = {}
|
||||
for c in core.CONTROL_IDS:
|
||||
e = self.state.get(c)
|
||||
if c in pending:
|
||||
looks[c] = Look(self.desired[c].label(), known=not e.unknown, pending=True)
|
||||
elif e.unknown:
|
||||
looks[c] = Look("", known=False)
|
||||
else:
|
||||
looks[c] = Look(e.binding.label(), known=True)
|
||||
self.pad.set_looks(looks)
|
||||
|
||||
n = len(pending)
|
||||
self.write_all.setText(f"Write {n} change{'s' if n != 1 else ''}")
|
||||
self.write_all.setVisible(n > 1)
|
||||
self.write_all.setEnabled(bool(self.diag and self.diag.ready))
|
||||
|
||||
c = self.pad.current
|
||||
can_write = bool(self.diag and self.diag.ready)
|
||||
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)
|
||||
self.inspector.show_control(
|
||||
c, self.state.get(c) if c else None,
|
||||
self.desired.get(c) if c in pending else None,
|
||||
can_write, all_unknown)
|
||||
|
||||
def _select(self, control):
|
||||
self._refresh()
|
||||
|
||||
def _changed(self, control, binding):
|
||||
if binding is None:
|
||||
self.desired.pop(control, None)
|
||||
else:
|
||||
self.desired[control] = binding
|
||||
self._refresh(editor=False)
|
||||
|
||||
def _revert(self, control):
|
||||
self.desired.pop(control, None)
|
||||
self._refresh()
|
||||
|
||||
# ------------------------------------------------------------ device
|
||||
|
||||
def _poll(self):
|
||||
old = self.diag.status if self.diag else None
|
||||
try:
|
||||
self.diag = core.diagnose(keyd=False)
|
||||
except Exception as e: # never let polling kill the window
|
||||
self._say(f"Couldn't check the pad: {e}", BAD)
|
||||
return
|
||||
colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD)
|
||||
self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;")
|
||||
self.status.setText({
|
||||
"ready": f"Pad ready on {self.diag.device.path}" if self.diag.device else "Pad ready",
|
||||
"unplugged": "No pad connected",
|
||||
"no-interface": "Pad found, config interface missing",
|
||||
"no-permission": "No permission to write to the pad",
|
||||
"unsupported": "Unsupported pad",
|
||||
}[self.diag.status])
|
||||
self.inspector.show_problem(self.diag, self.note)
|
||||
if self.diag.status != old:
|
||||
self._refresh(editor=False)
|
||||
|
||||
# ------------------------------------------------------------- write
|
||||
|
||||
def _write(self, controls):
|
||||
controls = [c for c in controls if c in self.desired]
|
||||
if not controls:
|
||||
return
|
||||
self._poll()
|
||||
if not self.diag.ready:
|
||||
self._say(self.diag.headline, BAD)
|
||||
return
|
||||
|
||||
done = []
|
||||
for c in controls:
|
||||
b = self.desired[c]
|
||||
try:
|
||||
core.write_binding(self.diag.device, c, b, self.state)
|
||||
except core.WriteError as e:
|
||||
what = core.control_name(c)
|
||||
tail = (f"{what} is now marked unknown, since part of it reached the pad."
|
||||
if e.pad_touched else "Nothing reached the pad.")
|
||||
why = str(e).split(": ", 1)[-1]
|
||||
self._say(f"Couldn't write {what}: {why}. {tail}", BAD)
|
||||
self._refresh()
|
||||
return
|
||||
self.desired.pop(c, None)
|
||||
done.append((c, b))
|
||||
QApplication.processEvents()
|
||||
|
||||
if len(done) == 1:
|
||||
c, b = done[0]
|
||||
self._say(f"{core.control_name(c)} is now {b.label()}.", GOOD)
|
||||
else:
|
||||
self._say(f"Wrote {len(done)} changes to the 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 _rotate(self):
|
||||
o = "flat" if self.pad.orientation == "upright" else "upright"
|
||||
self.pad.set_orientation(o)
|
||||
self.settings.setValue("orientation", o)
|
||||
|
||||
def _import(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Import a capture of the vendor software",
|
||||
str(Path.cwd()), "Captures (*.txt);;All files (*)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
got = core.import_capture(path, self.state)
|
||||
except (OSError, ValueError) as e:
|
||||
self._say(f"Couldn't read that capture: {e}", BAD)
|
||||
return
|
||||
if not got:
|
||||
self._say("No saved bindings found in that capture.", BAD)
|
||||
return
|
||||
for c, _, _ in got:
|
||||
self.desired.pop(c, None)
|
||||
self._say(f"Imported {len(got)} bindings from {Path(path).name}.", GOOD)
|
||||
self._refresh()
|
||||
|
||||
def closeEvent(self, e):
|
||||
n = len(self._pending())
|
||||
if not n:
|
||||
e.accept()
|
||||
return
|
||||
box = QMessageBox(self)
|
||||
box.setWindowTitle("Unwritten changes")
|
||||
box.setText(f"{n} change{'s are' if n != 1 else ' is'} not on the pad yet.")
|
||||
write = box.addButton("Write and close", QMessageBox.AcceptRole)
|
||||
discard = box.addButton("Close without writing", QMessageBox.DestructiveRole)
|
||||
box.addButton("Cancel", QMessageBox.RejectRole)
|
||||
box.exec()
|
||||
if box.clickedButton() is write:
|
||||
self._write(self._pending())
|
||||
e.accept() if not self._pending() else e.ignore()
|
||||
elif box.clickedButton() is discard:
|
||||
e.accept()
|
||||
else:
|
||||
e.ignore()
|
||||
|
||||
|
||||
def apply_theme(app):
|
||||
app.setStyle("Fusion")
|
||||
pal = QPalette()
|
||||
for role, colour in ((QPalette.Window, WINDOW), (QPalette.Base, QColor(FIELD)),
|
||||
(QPalette.Text, INK), (QPalette.WindowText, INK),
|
||||
(QPalette.Button, WINDOW), (QPalette.ButtonText, INK),
|
||||
(QPalette.Highlight, TAPE), (QPalette.HighlightedText, QColor("#1b1206"))):
|
||||
pal.setColor(role, colour)
|
||||
app.setPalette(pal)
|
||||
app.setStyleSheet(STYLE)
|
||||
|
||||
|
||||
def run():
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("macropad")
|
||||
app.setDesktopFileName("macropad")
|
||||
apply_theme(app)
|
||||
w = Window()
|
||||
w.resize(1000, 800)
|
||||
w.show()
|
||||
return app.exec()
|
||||
626
macropad_gui/core.py
Normal file
626
macropad_gui/core.py
Normal file
@@ -0,0 +1,626 @@
|
||||
"""
|
||||
Core of the macro pad GUI. No Qt in here: the GUI imports this, and so do
|
||||
the tests, so everything that matters can be checked without a display.
|
||||
|
||||
The pad is write-only, which shapes the whole design. There are two kinds
|
||||
of truth and they live in separate files:
|
||||
|
||||
profile what you *want* on the pad. You edit it; the GUI edits it.
|
||||
state what this tool last wrote *successfully*. Only the writer
|
||||
touches it. It is our shadow copy of the pad's memory.
|
||||
|
||||
A control we never wrote, or whose write failed partway, is UNKNOWN. The
|
||||
pad could hold anything there, and the UI must say so rather than guess.
|
||||
|
||||
Every byte on the wire still comes from macropad.py. This module decides
|
||||
what to send and remembers what was sent; it never builds a report itself.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import macropad as proto
|
||||
except ImportError: # running from inside the package dir
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import macropad as proto
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ device
|
||||
|
||||
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]
|
||||
|
||||
|
||||
CONTROL_FOR_ACTION = {action_byte(c): c for c in CONTROL_IDS}
|
||||
|
||||
_DIAL_WORDS = {"left": "turn left", "push": "press", "right": "turn right"}
|
||||
|
||||
|
||||
def control_name(control):
|
||||
"""'key4' -> 'Key 4', 'dial1-left' -> 'Dial 1, turn left'."""
|
||||
if control.startswith("key"):
|
||||
return f"Key {control[3:]}"
|
||||
dial, act = control.split("-")
|
||||
return f"Dial {dial[4:]}, {_DIAL_WORDS[act]}"
|
||||
|
||||
|
||||
# Media keys the GUI offers, in the order it offers them. Every id must be a
|
||||
# name macropad.MEDIA_KEYS knows; the usage codes themselves live there.
|
||||
MEDIA = [
|
||||
("playpause", "Play / pause"), ("next", "Next track"),
|
||||
("prev", "Previous track"), ("stop", "Stop"),
|
||||
("volumeup", "Volume up"), ("volumedown", "Volume down"), ("mute", "Mute"),
|
||||
("brightnessup", "Brightness up"), ("brightnessdown", "Brightness down"),
|
||||
("calculator", "Calculator"), ("mail", "Email"),
|
||||
("browser", "Browser"), ("mycomputer", "My computer"),
|
||||
]
|
||||
MEDIA_LABEL = dict(MEDIA)
|
||||
for _m, _ in MEDIA:
|
||||
assert _m in proto.MEDIA_KEYS, f"macropad.py has no media key {_m}"
|
||||
_MEDIA_BY_USAGE = {}
|
||||
for _m, _ in MEDIA:
|
||||
_MEDIA_BY_USAGE.setdefault(proto.MEDIA_KEYS[_m], _m)
|
||||
|
||||
# Verified on 1189:8840 so far: single keys and shortcuts (capture + use),
|
||||
# media keys (capture). Sequences and the delay field are upstream's layout
|
||||
# and have not been seen on this pad - the GUI keeps them locked until a
|
||||
# capture confirms them.
|
||||
SEQUENCES_VERIFIED = False
|
||||
|
||||
_MOD_ORDER = [("ctrl", 0x01), ("shift", 0x02), ("alt", 0x04), ("super", 0x08),
|
||||
("rctrl", 0x10), ("rshift", 0x20), ("ralt", 0x40), ("rwin", 0x80)]
|
||||
_MOD_LABEL = {"ctrl": "Ctrl", "shift": "Shift", "alt": "Alt", "super": "Meta",
|
||||
"win": "Meta", "lctrl": "Ctrl", "lshift": "Shift", "lalt": "Alt",
|
||||
"lwin": "Meta", "rctrl": "Right Ctrl", "rshift": "Right Shift",
|
||||
"ralt": "AltGr", "rwin": "Right Meta"}
|
||||
_KEY_LABEL = {"pageup": "Page Up", "pagedown": "Page Down", "printscreen": "Print",
|
||||
"scrolllock": "Scroll Lock", "capslock": "Caps Lock",
|
||||
"backspace": "Backspace", "leftbracket": "[", "rightbracket": "]",
|
||||
"backslash": "\\", "semicolon": ";", "quote": "'", "grave": "`",
|
||||
"comma": ",", "period": ".", "slash": "/", "minus": "-", "equal": "=",
|
||||
"esc": "Esc", "escape": "Esc", "return": "Enter"}
|
||||
_KEYNAME_BY_CODE = {}
|
||||
for _name, _code in proto.KEYCODES.items():
|
||||
_KEYNAME_BY_CODE.setdefault(_code, _name)
|
||||
|
||||
|
||||
def normalize_keys(text):
|
||||
"""' Ctrl + C ,' -> 'ctrl+c'. Doesn't validate; Binding does that."""
|
||||
alias = {"meta": "super"} # Plasma says Meta; macropad.py says super
|
||||
steps = []
|
||||
for chunk in text.lower().split(","):
|
||||
parts = [alias.get(p.strip(), p.strip()) for p in chunk.split("+") if p.strip()]
|
||||
if parts:
|
||||
steps.append("+".join(parts))
|
||||
return ",".join(steps)
|
||||
|
||||
|
||||
def pretty_step(step):
|
||||
parts = []
|
||||
for p in step.split("+"):
|
||||
p = p.strip().lower()
|
||||
if p in _MOD_LABEL:
|
||||
parts.append(_MOD_LABEL[p])
|
||||
else:
|
||||
parts.append(_KEY_LABEL.get(p, p.upper() if len(p) <= 3 else p.title()))
|
||||
return "+".join(parts)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- binding
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
"""
|
||||
What one control does. Stored in the CLI's own syntax, so every entry
|
||||
in a profile is exactly a `macropad.py set <control> <binding>` command.
|
||||
|
||||
Binding(keys="ctrl+c")
|
||||
Binding(keys="a,b,c", delay=50)
|
||||
Binding(media="volumeup")
|
||||
|
||||
The kind is explicit rather than guessed from the string, because
|
||||
'pause' is both a keyboard key and a media key.
|
||||
"""
|
||||
keys: str = ""
|
||||
media: str = ""
|
||||
delay: int = 0
|
||||
|
||||
def validate(self):
|
||||
if bool(self.keys) == bool(self.media):
|
||||
raise ValueError("a binding is either keys or media, not both or neither")
|
||||
if self.media and self.media not in proto.MEDIA_KEYS:
|
||||
raise ValueError(f"unknown media key '{self.media}'")
|
||||
if self.keys:
|
||||
proto.parse_keystroke(self.keys) # raises ValueError
|
||||
if not 0 <= self.delay <= 0xFFFF:
|
||||
raise ValueError("delay must be 0-65535 ms")
|
||||
if self.media and self.delay:
|
||||
raise ValueError("delay only applies to key sequences")
|
||||
return self
|
||||
|
||||
@property
|
||||
def steps(self):
|
||||
return len(proto.parse_keystroke(self.keys)) if self.keys else 1
|
||||
|
||||
def reports(self, report_id, action, layer=1):
|
||||
"""The exact reports to send, built entirely by macropad.py."""
|
||||
self.validate()
|
||||
if self.media:
|
||||
return proto.native_media(report_id, action,
|
||||
proto.MEDIA_KEYS[self.media], layer)
|
||||
return proto.native_key(report_id, action,
|
||||
proto.parse_keystroke(self.keys),
|
||||
layer, self.delay)
|
||||
|
||||
@property
|
||||
def is_sequence(self):
|
||||
return bool(self.keys) and self.steps > 1
|
||||
|
||||
def label(self):
|
||||
"""What a person reads on the key: 'Ctrl+Shift+N', 'Volume up'."""
|
||||
if self.media:
|
||||
return MEDIA_LABEL.get(self.media, self.media)
|
||||
text = ", ".join(pretty_step(s) for s in self.keys.split(",") if s.strip())
|
||||
return text + (f" ({self.delay} ms)" if self.delay else "")
|
||||
|
||||
def to_json(self):
|
||||
if self.media:
|
||||
return {"media": self.media}
|
||||
d = {"keys": self.keys}
|
||||
if self.delay:
|
||||
d["delay"] = self.delay
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, d):
|
||||
return cls(keys=d.get("keys", ""), media=d.get("media", ""),
|
||||
delay=int(d.get("delay", 0))).validate()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- files
|
||||
|
||||
def config_dir():
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
||||
return Path(base) / "macropad"
|
||||
|
||||
|
||||
def _atomic_write_json(path, data):
|
||||
"""Write-then-rename, so a crash mid-save never leaves half a file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
FORMAT = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
"""One control's line in the shadow state."""
|
||||
binding: Binding = None # what we believe is on the pad
|
||||
written: str = "" # ISO timestamp of the successful write
|
||||
unknown: bool = True # True = pad contents not known
|
||||
reason: str = "not written by this app yet"
|
||||
|
||||
|
||||
class State:
|
||||
"""
|
||||
Shadow copy of the pad. Keyed by (layer, control). Only the writer
|
||||
should call record()/mark_unknown(); the GUI just reads it.
|
||||
"""
|
||||
|
||||
def __init__(self, path=None):
|
||||
self.path = Path(path) if path else config_dir() / f"state-{VID}-{PID}.json"
|
||||
self.entries = {} # (layer, control) -> Entry
|
||||
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')}")
|
||||
for layer, controls in data.get("layers", {}).items():
|
||||
for control, e in controls.items():
|
||||
if e.get("unknown"):
|
||||
entry = Entry(unknown=True, reason=e.get("reason", ""))
|
||||
else:
|
||||
entry = Entry(binding=Binding.from_json(e["binding"]),
|
||||
written=e.get("written", ""), unknown=False,
|
||||
reason="")
|
||||
self.entries[(int(layer), control)] = entry
|
||||
|
||||
def save(self):
|
||||
layers = {}
|
||||
for (layer, control), e in sorted(self.entries.items()):
|
||||
if e.unknown:
|
||||
d = {"unknown": True, "reason": e.reason}
|
||||
else:
|
||||
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,
|
||||
"layers": layers})
|
||||
|
||||
def get(self, control, layer=1):
|
||||
return self.entries.get((layer, control), Entry())
|
||||
|
||||
def record(self, control, binding, layer=1):
|
||||
self.entries[(layer, control)] = Entry(
|
||||
binding=binding, unknown=False, reason="",
|
||||
written=datetime.now().isoformat(timespec="seconds"))
|
||||
|
||||
def mark_unknown(self, control, reason, layer=1):
|
||||
self.entries[(layer, control)] = Entry(unknown=True, reason=reason)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- diagnosis
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
path: str
|
||||
report_id: int
|
||||
writable: bool
|
||||
name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Diagnosis:
|
||||
status: str # ready | unplugged | no-interface | no-permission | unsupported
|
||||
headline: str
|
||||
device: Device = None
|
||||
detail: list = field(default_factory=list)
|
||||
fix: list = field(default_factory=list) # works in bash, zsh and fish
|
||||
warnings: list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ready(self):
|
||||
return self.status == "ready"
|
||||
|
||||
|
||||
RULES_DIRS = ("/etc/udev/rules.d", "/run/udev/rules.d")
|
||||
SYSTEM_RULES = ("/usr/lib/udev/rules.d", "/lib/udev/rules.d")
|
||||
SUGGESTED_RULE = "60-macropad.rules"
|
||||
RULE_LINE = (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", '
|
||||
f'ATTRS{{idProduct}}=="{PID}", TAG+="uaccess"')
|
||||
|
||||
|
||||
def _uaccess_cutoff():
|
||||
"""
|
||||
The rules file that turns a uaccess tag into an actual ACL. On systemd
|
||||
this is 73-seat-late.rules. udev runs every rules file in one lexical
|
||||
order by filename, so a rule that adds the tag must sort before this one
|
||||
- 99-foo.rules is tagged but never granted, and so is an unnumbered
|
||||
foo.rules, since letters sort after digits.
|
||||
"""
|
||||
for d in SYSTEM_RULES:
|
||||
for f in sorted(glob.glob(os.path.join(d, "*.rules"))):
|
||||
try:
|
||||
if 'RUN{builtin}+="uaccess"' in open(f).read():
|
||||
return os.path.basename(f)
|
||||
except OSError:
|
||||
continue
|
||||
return "73-seat-late.rules"
|
||||
|
||||
|
||||
def _matching_rules():
|
||||
"""User rule files that mention our vendor id and uaccess."""
|
||||
hits = []
|
||||
for d in RULES_DIRS:
|
||||
for f in sorted(glob.glob(os.path.join(d, "*.rules"))):
|
||||
try:
|
||||
text = open(f).read()
|
||||
except OSError:
|
||||
continue
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("#"):
|
||||
continue
|
||||
if re.search(rf'idVendor\}}=="{VID}"', s, re.I) and "uaccess" in s:
|
||||
hits.append(f)
|
||||
break
|
||||
return hits
|
||||
|
||||
|
||||
def _udev_tags(devpath):
|
||||
try:
|
||||
out = subprocess.run(["udevadm", "info", "--query=property",
|
||||
f"--name={devpath}"],
|
||||
capture_output=True, text=True, timeout=3).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
tags = set()
|
||||
for line in out.splitlines():
|
||||
if line.startswith(("TAGS=", "CURRENT_TAGS=")):
|
||||
tags |= {t for t in line.split("=", 1)[1].split(":") if t}
|
||||
return tags
|
||||
|
||||
|
||||
def _on_usb_bus():
|
||||
for d in glob.glob("/sys/bus/usb/devices/*"):
|
||||
try:
|
||||
v = open(os.path.join(d, "idVendor")).read().strip().lower()
|
||||
p = open(os.path.join(d, "idProduct")).read().strip().lower()
|
||||
except OSError:
|
||||
continue
|
||||
if (v, p) == (VID, PID):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _keyd_warning():
|
||||
"""keyd sits between the pad and the desktop and can rewrite its output."""
|
||||
if not shutil.which("keyd"):
|
||||
return None
|
||||
try:
|
||||
active = subprocess.run(["systemctl", "is-active", "keyd"],
|
||||
capture_output=True, text=True,
|
||||
timeout=3).stdout.strip() == "active"
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if not active:
|
||||
return None
|
||||
for f in glob.glob("/etc/keyd/*.conf"):
|
||||
try:
|
||||
if DEVICE_ID in open(f).read().lower():
|
||||
return (f"keyd, a key remapping service, is running and {f} "
|
||||
"lists this pad. It can change keys on their way from "
|
||||
"the pad to your desktop, so a key may not do exactly "
|
||||
"what you set here. If you only set keyd up for this "
|
||||
"pad, you can switch it off with: "
|
||||
"sudo systemctl disable --now keyd")
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def diagnose(keyd=True):
|
||||
"""keyd=False skips the keyd check, which shells out - the GUI polls."""
|
||||
warnings = [w for w in (_keyd_warning() if keyd else None,) if w]
|
||||
everything = proto.find_devices()
|
||||
found = [d for d in everything if d["pid"] == PID]
|
||||
|
||||
if not found and everything:
|
||||
other = everything[0]
|
||||
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.",
|
||||
"You can help add it: the README's \"Other pads\" section "
|
||||
"shows how to capture what the vendor software sends."],
|
||||
warnings=warnings)
|
||||
|
||||
if not found:
|
||||
if _on_usb_bus():
|
||||
return Diagnosis(
|
||||
"no-interface",
|
||||
"Pad is plugged in, but its configuration interface is missing.",
|
||||
detail=["The USB device is present, but no hidraw node with the "
|
||||
"vendor-defined config interface was found.",
|
||||
"Usually a replug fixes this. If not, run "
|
||||
"macropad-probe.py and check the interface list."],
|
||||
warnings=warnings)
|
||||
return Diagnosis("unplugged", "No pad connected.",
|
||||
detail=[f"Looking for {DEVICE_ID} on USB."],
|
||||
warnings=warnings)
|
||||
|
||||
d = found[0]
|
||||
dev = Device(path=d["path"], report_id=d["report_id"],
|
||||
writable=d["writable"], name=d.get("name", ""))
|
||||
|
||||
if dev.writable:
|
||||
return Diagnosis("ready", f"Ready on {dev.path}.", device=dev,
|
||||
detail=[f"report id {dev.report_id}"], warnings=warnings)
|
||||
|
||||
# Not writable: work out which of the three usual causes it is.
|
||||
cutoff = _uaccess_cutoff()
|
||||
rules = _matching_rules()
|
||||
late = [r for r in rules if os.path.basename(r) >= cutoff]
|
||||
early = [r for r in rules if os.path.basename(r) < cutoff]
|
||||
tags = _udev_tags(dev.path)
|
||||
tagged = bool(tags and "uaccess" in tags)
|
||||
|
||||
diag = Diagnosis("no-permission", f"{dev.path} isn't writable.",
|
||||
device=dev, warnings=warnings)
|
||||
|
||||
if late and not early:
|
||||
bad = late[0]
|
||||
new = os.path.join(os.path.dirname(bad), SUGGESTED_RULE)
|
||||
diag.detail = [
|
||||
f"{bad} matches the pad, but it runs after {cutoff}, which is "
|
||||
"where access is actually granted. The device gets tagged, "
|
||||
"then nothing acts on the tag.",
|
||||
"Rename it so it sorts earlier."]
|
||||
diag.fix = [f"sudo mv {bad} {new}",
|
||||
"sudo udevadm control --reload-rules && sudo udevadm trigger",
|
||||
"# then unplug and replug the pad"]
|
||||
elif early and not tagged:
|
||||
diag.detail = [f"{early[0]} looks right, but the device hasn't picked "
|
||||
"it up yet."]
|
||||
diag.fix = ["sudo udevadm control --reload-rules && sudo udevadm trigger",
|
||||
"# then unplug and replug the pad"]
|
||||
elif early and tagged:
|
||||
diag.detail = [
|
||||
"The rule matched and the device is tagged, but no access was "
|
||||
"granted. That happens when you aren't in an active local desktop "
|
||||
"session - for example over SSH, or in a shell started with sudo."]
|
||||
else:
|
||||
diag.detail = ["No udev rule lets you write to the pad yet. Running "
|
||||
"the installer again adds it, or paste these commands "
|
||||
"into a terminal:"]
|
||||
diag.fix = [f"echo '{RULE_LINE}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}",
|
||||
"sudo udevadm control --reload-rules && sudo udevadm trigger",
|
||||
"# then unplug and replug the pad"]
|
||||
return diag
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ writer
|
||||
|
||||
class WriteError(Exception):
|
||||
"""
|
||||
pad_touched is the thing the UI cares about: False means the pad is
|
||||
exactly as it was; True means some reports landed and the control's
|
||||
contents are now unknown.
|
||||
"""
|
||||
def __init__(self, message, pad_touched, cause=None):
|
||||
super().__init__(message)
|
||||
self.pad_touched = pad_touched
|
||||
self.cause = cause
|
||||
|
||||
|
||||
def _explain(err):
|
||||
return {
|
||||
errno.ENOENT: "the pad was unplugged",
|
||||
errno.ENODEV: "the pad was unplugged",
|
||||
errno.EACCES: "no permission to write to the pad",
|
||||
errno.EPERM: "no permission to write to the pad",
|
||||
errno.EPIPE: "the pad rejected the report",
|
||||
errno.EIO: "the pad stopped responding (it may have reset)",
|
||||
errno.ETIMEDOUT: "the pad didn't accept the report in time",
|
||||
}.get(err.errno, err.strerror or str(err))
|
||||
|
||||
|
||||
def write_binding(device, control, binding, state, layer=1, _write=None):
|
||||
"""
|
||||
Send one binding (config report + commit) and update the shadow state.
|
||||
Raises WriteError; on success the state file is already saved.
|
||||
"""
|
||||
write = _write or proto.write_report
|
||||
reports = binding.reports(device.report_id, action_byte(control), layer)
|
||||
|
||||
sent = 0
|
||||
try:
|
||||
for r in reports:
|
||||
write(device.path, r)
|
||||
sent += 1
|
||||
time.sleep(WRITE_GAP)
|
||||
except OSError as e:
|
||||
why = _explain(e)
|
||||
# If even the first write failed on open, nothing reached the pad.
|
||||
untouched = sent == 0 and e.errno in (errno.ENOENT, errno.ENODEV,
|
||||
errno.EACCES, errno.EPERM)
|
||||
if not untouched:
|
||||
state.mark_unknown(control, f"write interrupted after {sent} of "
|
||||
f"{len(reports)} reports: {why}", layer)
|
||||
state.save()
|
||||
raise WriteError(f"{control}: {why}", pad_touched=not untouched, cause=e)
|
||||
|
||||
state.record(control, binding, layer)
|
||||
state.save()
|
||||
|
||||
|
||||
# --------------------------------------------------------------- decoding
|
||||
|
||||
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:
|
||||
return None
|
||||
action, layer, kind = report[2], report[3], report[4]
|
||||
control = CONTROL_FOR_ACTION.get(action)
|
||||
if control is None:
|
||||
return None
|
||||
delay = report[5] | (report[6] << 8)
|
||||
count = report[10]
|
||||
|
||||
if kind == proto.KeyType.MULTIMEDIA:
|
||||
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:
|
||||
steps = []
|
||||
for i in range(count):
|
||||
off = 11 + 2 * i
|
||||
if off + 1 >= len(report):
|
||||
return None
|
||||
mods, code = report[off], report[off + 1]
|
||||
names = [n for n, bit in _MOD_ORDER if mods & bit]
|
||||
if code:
|
||||
if code not in _KEYNAME_BY_CODE:
|
||||
return None
|
||||
names.append(_KEYNAME_BY_CODE[code])
|
||||
steps.append("+".join(names))
|
||||
return control, layer, Binding(keys=",".join(steps), delay=delay)
|
||||
return None
|
||||
|
||||
|
||||
def is_commit(report):
|
||||
return len(report) >= 4 and report[1] == proto.MAGIC_NATIVE and \
|
||||
(report[2], report[3]) == proto.TERMINATOR
|
||||
|
||||
|
||||
def read_capture(path):
|
||||
"""Raw report bytes from a macropad-capture.py output file, in order."""
|
||||
out = []
|
||||
for line in Path(path).read_text().splitlines():
|
||||
if line.startswith(" "):
|
||||
try:
|
||||
out.append(bytes.fromhex(line.strip()))
|
||||
except ValueError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def import_capture(path, state):
|
||||
"""
|
||||
Record what a vendor-software session wrote, so the GUI knows what's on
|
||||
the pad. Only bindings followed by a commit count - the vendor app can
|
||||
send many configs and save once. Returns [(control, layer, Binding)].
|
||||
"""
|
||||
pending, done = [], []
|
||||
for report in read_capture(path):
|
||||
if is_commit(report):
|
||||
done.extend(pending)
|
||||
pending = []
|
||||
continue
|
||||
decoded = decode_config(report)
|
||||
if decoded:
|
||||
pending = [p for p in pending if p[:2] != decoded[:2]] + [decoded]
|
||||
for control, layer, binding in done:
|
||||
state.record(control, binding, layer)
|
||||
if done:
|
||||
state.save()
|
||||
return done
|
||||
69
macropad_gui/keymap.py
Normal file
69
macropad_gui/keymap.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Turn a key press in the GUI into a name macropad.py understands.
|
||||
|
||||
The pad sends HID usage codes, which identify a physical key position, not
|
||||
a character - your keyboard layout decides what that position types. So the
|
||||
recorder uses the physical key too: on Linux (X11 and Wayland) Qt reports
|
||||
the xkb keycode, which is the evdev code + 8. That keeps 'record' correct on
|
||||
UK, German, AZERTY and so on. Letters and digits fall back to Qt's key
|
||||
value if the scancode is missing.
|
||||
"""
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
import macropad as proto
|
||||
|
||||
# evdev KEY_* code -> macropad.KEYCODES name. Only keys macropad.py can send.
|
||||
EVDEV = {
|
||||
1: "esc", 14: "backspace", 15: "tab", 28: "enter", 57: "space",
|
||||
12: "minus", 13: "equal", 26: "leftbracket", 27: "rightbracket",
|
||||
43: "backslash", 39: "semicolon", 40: "quote", 41: "grave",
|
||||
51: "comma", 52: "period", 53: "slash", 58: "capslock",
|
||||
99: "printscreen", 70: "scrolllock", 119: "pause",
|
||||
110: "insert", 102: "home", 104: "pageup", 111: "delete", 107: "end",
|
||||
109: "pagedown", 106: "right", 105: "left", 108: "down", 103: "up",
|
||||
87: "f11", 88: "f12",
|
||||
}
|
||||
for _i, _c in enumerate("1234567890"):
|
||||
EVDEV[2 + _i] = _c
|
||||
for _row, _start in (("qwertyuiop", 16), ("asdfghjkl", 30), ("zxcvbnm", 44)):
|
||||
for _i, _c in enumerate(_row):
|
||||
EVDEV[_start + _i] = _c
|
||||
for _i in range(1, 11):
|
||||
EVDEV[58 + _i] = f"f{_i}" # F1 = 59 .. F10 = 68
|
||||
|
||||
for _name in EVDEV.values():
|
||||
assert _name in proto.KEYCODES, _name
|
||||
|
||||
MODIFIER_KEYS = {Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt, Qt.Key_Meta,
|
||||
Qt.Key_Super_L, Qt.Key_Super_R, Qt.Key_AltGr}
|
||||
|
||||
_QT_FALLBACK = {}
|
||||
for _c in "abcdefghijklmnopqrstuvwxyz":
|
||||
_QT_FALLBACK[getattr(Qt, f"Key_{_c.upper()}")] = _c
|
||||
for _c in "0123456789":
|
||||
_QT_FALLBACK[getattr(Qt, f"Key_{_c}")] = _c
|
||||
for _i in range(1, 13):
|
||||
_QT_FALLBACK[getattr(Qt, f"Key_F{_i}")] = f"f{_i}"
|
||||
|
||||
|
||||
def key_name(event):
|
||||
"""HID key name for a QKeyEvent, or None if the pad can't send it."""
|
||||
code = event.nativeScanCode() - 8
|
||||
if code in EVDEV:
|
||||
return EVDEV[code]
|
||||
return _QT_FALLBACK.get(Qt.Key(event.key()))
|
||||
|
||||
|
||||
def modifier_names(event):
|
||||
m = event.modifiers()
|
||||
out = []
|
||||
if m & Qt.ControlModifier:
|
||||
out.append("ctrl")
|
||||
if m & Qt.ShiftModifier:
|
||||
out.append("shift")
|
||||
if m & Qt.AltModifier:
|
||||
out.append("alt")
|
||||
if m & Qt.MetaModifier:
|
||||
out.append("super")
|
||||
return out
|
||||
398
macropad_gui/padview.py
Normal file
398
macropad_gui/padview.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
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, 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
|
||||
|
||||
# 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
|
||||
known: bool = False
|
||||
pending: bool = False
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class PadView(QWidget):
|
||||
selected = Signal(str)
|
||||
|
||||
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.looks = {c: Look() for c in core.CONTROL_IDS}
|
||||
self.current = None
|
||||
self.hover = None
|
||||
self.mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||
self._layout()
|
||||
|
||||
# ------------------------------------------------------------ public
|
||||
|
||||
def set_orientation(self, orientation):
|
||||
self.orientation = orientation
|
||||
self._layout()
|
||||
self.update()
|
||||
|
||||
def set_looks(self, looks):
|
||||
self.looks = looks
|
||||
self.update()
|
||||
|
||||
def select(self, control):
|
||||
self.current = control
|
||||
self.update()
|
||||
|
||||
# ------------------------------------------------------------ layout
|
||||
|
||||
def _layout(self):
|
||||
self.keys = {} # key -> QRectF
|
||||
self.dials = {} # dial -> centre QPointF
|
||||
self.rows = {} # dial action -> legend row QRectF
|
||||
|
||||
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
|
||||
grid_left = M
|
||||
else:
|
||||
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)]
|
||||
|
||||
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)
|
||||
|
||||
for dial, centre in zip(DIALS_IN_ORDER, 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)
|
||||
|
||||
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()])
|
||||
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 mouseMoveEvent(self, e):
|
||||
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):
|
||||
h = self._hit(e.position())
|
||||
if h and e.button() == Qt.LeftButton:
|
||||
self.current = h
|
||||
self.selected.emit(h)
|
||||
self.update()
|
||||
|
||||
# ------------------------------------------------------------ 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)
|
||||
|
||||
for key, rect in self.keys.items():
|
||||
self._paint_key(p, key, rect)
|
||||
for dial, centre in self.dials.items():
|
||||
self._paint_dial(p, dial, centre)
|
||||
|
||||
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)
|
||||
|
||||
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 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 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 + "…"
|
||||
Reference in New Issue
Block a user