Add SayoDevice support, 15 key pads and theme styles
Second protocol family (8089:000b) behind a new backend seam, 15 key and 3 knob pads, Knobs North plus the style and colour grid, Windows import fix, window resize fix.
This commit is contained in:
@@ -1 +1,13 @@
|
||||
"""Linux GUI for Holtek-family USB macro pads. GPL-3.0."""
|
||||
|
||||
# The one place the version is written down. The CLI's --version, the app's
|
||||
# About box and the bug report template all read it from here, so a release
|
||||
# is one line to change and the tag can never disagree with what ships.
|
||||
#
|
||||
# Semver, and the rule used so far: major when the idea of how the app works
|
||||
# changes, minor for new hardware or features, patch for fixes.
|
||||
#
|
||||
# 1.x write only, because these pads were assumed to be unreadable
|
||||
# 2.0.0 they can be read, plus detection, actions and themes
|
||||
# 2.1.0 a second protocol family (SayoDevice), 15 key pads, theme styles
|
||||
__version__ = "2.1.0"
|
||||
|
||||
@@ -20,7 +20,6 @@ the desktop; it just gets a copy.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
@@ -31,6 +30,14 @@ import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
# Windows has none. Everything that needs it is Linux only anyway, and
|
||||
# each use checks. Importing this module must not fail, because app.py
|
||||
# imports it to build the window.
|
||||
fcntl = None
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -265,9 +272,17 @@ class VirtualKeyboard:
|
||||
and the first paste would otherwise go missing.
|
||||
"""
|
||||
|
||||
def __init__(self, path=UINPUT, _open=os.open, _ioctl=fcntl.ioctl,
|
||||
def __init__(self, path=UINPUT, _open=os.open, _ioctl=None,
|
||||
_write=os.write, _sleep=time.sleep):
|
||||
import struct
|
||||
# Resolved here, not in the signature: a default argument is
|
||||
# evaluated when the module is imported, which on Windows would
|
||||
# crash before the window ever got a chance to open.
|
||||
if _ioctl is None:
|
||||
if fcntl is None:
|
||||
raise OSError("a virtual keyboard needs /dev/uinput, which "
|
||||
"this system hasn't got")
|
||||
_ioctl = fcntl.ioctl
|
||||
self._struct, self._write, self._sleep = struct, _write, _sleep
|
||||
self.fd = _open(path, os.O_WRONLY | os.O_NONBLOCK)
|
||||
_ioctl(self.fd, _UI_SET_EVBIT, _EV_KEY)
|
||||
@@ -570,6 +585,10 @@ def listen(debug=False, log=print):
|
||||
"""
|
||||
lock_path = runtime_dir() / LOCK
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if fcntl is None:
|
||||
log("Background actions are Linux only for now: they need /dev/uinput "
|
||||
"to type, and a file lock to keep one copy running.")
|
||||
return 1
|
||||
lock = open(lock_path, "a+")
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
@@ -13,12 +13,13 @@ from PySide6.QtGui import (QActionGroup, QColor, QFont, QFontDatabase,
|
||||
QGuiApplication, QIcon,
|
||||
QPalette)
|
||||
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
|
||||
QScrollArea, QStyle,
|
||||
QGridLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QMainWindow, QMessageBox, QPlainTextEdit,
|
||||
QPushButton, QSpinBox, QVBoxLayout, QWidget,
|
||||
QCheckBox, QComboBox, QMenu)
|
||||
|
||||
from . import actions as act, core, keymap, padview, themes
|
||||
from . import __version__, actions as act, core, keymap, padview, themes
|
||||
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
|
||||
|
||||
# Set by apply_theme; functions read these when they run, so they follow a
|
||||
@@ -31,29 +32,71 @@ MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
|
||||
("brightnessdown", "brightnessup"),
|
||||
("calculator", "mail"), ("browser", "mycomputer")]
|
||||
|
||||
def _status(t):
|
||||
"""The footer's device line and its last message."""
|
||||
if t.mono_status:
|
||||
return (f'QLabel[role="status"] {{ color: {t.ink}; '
|
||||
f'font-family: "{t.mono_font}"; font-size: 12px; }}\n'
|
||||
f'QLabel[role="message"] {{ font-family: "{t.mono_font}"; '
|
||||
f'font-size: 12px; font-weight: 600; }}')
|
||||
return f'QLabel[role="status"] {{ color: {t.ink_dim}; }}'
|
||||
|
||||
|
||||
def _primary(t, tint):
|
||||
"""The write button: filled by default, outlined where a theme says so."""
|
||||
if t.outline_primary:
|
||||
return f"""
|
||||
QPushButton[role="primary"] {{ background: {t.board}; color: {t.accent};
|
||||
border: 1px solid {t.accent}; border-radius: {t.button_radius}px;
|
||||
font-weight: 600; padding: 8px 16px; }}
|
||||
QPushButton[role="primary"]:hover {{ background: {tint(t.board, t.accent, 0.12)}; }}
|
||||
QPushButton[role="primary"]:disabled {{ color: {tint(t.window, t.ink_dim, 0.7)};
|
||||
border-color: {tint(t.window, t.line, 0.7)}; }}"""
|
||||
return f"""
|
||||
QPushButton[role="primary"] {{ background: {t.accent}; color: {t.accent_text};
|
||||
border: none; border-radius: {t.button_radius}px;
|
||||
font-weight: 600; padding: 8px 16px; }}
|
||||
QPushButton[role="primary"]:hover {{ background: {tint(t.accent, t.ink, 0.15)}; }}
|
||||
QPushButton[role="primary"]:disabled {{ background: {tint(t.window, t.line, 0.7)}; color: {tint(t.window, t.ink_dim, 0.7)}; }}"""
|
||||
|
||||
|
||||
def _flat(t):
|
||||
"""Footer buttons: bare text by default, outlined pills where asked."""
|
||||
if t.pill_buttons:
|
||||
return f"""
|
||||
QPushButton[role="flat"] {{ background: {t.board}; color: {t.ink};
|
||||
border: 1px solid {t.ink_dim}; border-radius: {t.button_radius}px;
|
||||
padding: 6px 14px; }}
|
||||
QPushButton[role="flat"]:hover {{ border-color: {t.ink}; }}
|
||||
QPushButton[role="flat"]:disabled {{ color: {t.ink_dim}; border-color: {t.line}; }}"""
|
||||
return f"""
|
||||
QPushButton[role="flat"] {{ border: none; color: {t.ink_dim}; padding: 6px 8px; }}
|
||||
QPushButton[role="flat"]:hover {{ color: {t.ink}; }}"""
|
||||
|
||||
|
||||
INSPECTOR_W = 400 # the side panel's width, without its scrollbar
|
||||
|
||||
|
||||
def stylesheet(t):
|
||||
"""The whole window's look, from one theme."""
|
||||
tint = themes.mix
|
||||
return f"""
|
||||
QWidget {{ color: {t.ink}; background: {t.window}; }}
|
||||
QLabel[role="title"] {{ font-size: 18px; font-weight: 600; }}
|
||||
QLabel[role="title"] {{ font-size: {t.title_pt}px; font-weight: 600; }}
|
||||
{_status(t)}
|
||||
QLabel[role="dim"] {{ color: {t.ink_dim}; }}
|
||||
QLabel[role="caption"] {{ color: {t.ink_dim}; padding: 14px 0px 0px 0px; margin: 0px; }}
|
||||
QLabel[role="value"] {{ font-size: 15px; }}
|
||||
QPushButton {{ background: transparent; border: 1px solid {t.line};
|
||||
border-radius: 4px; padding: 6px 12px; }}
|
||||
border-radius: {t.button_radius}px; padding: 6px 12px; }}
|
||||
QPushButton:hover {{ border-color: {t.ink_dim}; }}
|
||||
QPushButton:checked {{ border-color: {t.accent}; color: {t.accent}; }}
|
||||
QPushButton:disabled {{ color: {tint(t.window, t.ink_dim, 0.55)}; border-color: {tint(t.window, t.line, 0.6)}; }}
|
||||
QPushButton[role="primary"] {{ background: {t.accent}; color: {t.accent_text};
|
||||
border: none; font-weight: 600; padding: 8px 16px; }}
|
||||
QPushButton[role="primary"]:hover {{ background: {tint(t.accent, t.ink, 0.15)}; }}
|
||||
QPushButton[role="primary"]:disabled {{ background: {tint(t.window, t.line, 0.7)}; color: {tint(t.window, t.ink_dim, 0.7)}; }}
|
||||
QPushButton[role="flat"] {{ border: none; color: {t.ink_dim}; padding: 6px 8px; }}
|
||||
QPushButton[role="flat"]:hover {{ color: {t.ink}; }}
|
||||
{_primary(t, tint)}
|
||||
{_flat(t)}
|
||||
QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }}
|
||||
QLineEdit {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px;
|
||||
padding: 7px 8px; }}
|
||||
QLineEdit {{ background: {t.field}; border: 1px solid {t.line};
|
||||
border-radius: {t.button_radius}px; padding: 7px 8px; }}
|
||||
QLineEdit:focus {{ border-color: {t.ink_dim}; }}
|
||||
QLineEdit[recording="true"] {{ border-color: {t.accent}; background: {tint(t.field, t.accent, 0.12)}; }}
|
||||
QPlainTextEdit {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px; }}
|
||||
@@ -62,6 +105,8 @@ QFrame[role="banner"] {{ border: 1px solid {t.line}; border-left: 3px solid {t.b
|
||||
QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {t.ink_dim}; }}
|
||||
QFrame[role="banner"] QLabel, QFrame[role="banner"] QPushButton {{ background: transparent; }}
|
||||
QFrame[role="footer"] {{ border-top: 1px solid {t.line}; }}
|
||||
QScrollArea[role="panel"], QWidget[role="panel"] {{ background: {t.window};
|
||||
border: none; }}
|
||||
QFrame[role="editbar"] {{ border-top: 1px solid {t.accent}; background: {tint(t.window, t.accent, 0.06)}; }}
|
||||
QFrame[role="editbar"] QLabel {{ background: transparent; }}
|
||||
QSpinBox {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px;
|
||||
@@ -169,7 +214,7 @@ class Inspector(QWidget):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setFixedWidth(400)
|
||||
self.setFixedWidth(INSPECTOR_W)
|
||||
self.control = None
|
||||
self._loading = False
|
||||
self.editing_layout = False
|
||||
@@ -768,8 +813,22 @@ class Window(QMainWindow):
|
||||
self.pad.set_layout(self.state.layout)
|
||||
self.pad.set_orientation(self.settings.value("orientation", "upright"))
|
||||
self.inspector = Inspector()
|
||||
# The inspector can get taller than the window: a long banner, a full
|
||||
# media list, a theme with bigger type. Left to itself it pushes the
|
||||
# window's minimum height past the screen, and clicking the first key
|
||||
# makes the window jump to full height. In a scroll area it keeps its
|
||||
# own height to itself and everything stays reachable.
|
||||
self.panel = QScrollArea()
|
||||
self.panel.setWidget(self.inspector)
|
||||
self.panel.setWidgetResizable(True)
|
||||
self.panel.setFrameShape(QFrame.NoFrame)
|
||||
self.panel.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.panel.setProperty("role", "panel")
|
||||
self.panel.viewport().setProperty("role", "panel")
|
||||
bar = self.style().pixelMetric(QStyle.PM_ScrollBarExtent)
|
||||
self.panel.setFixedWidth(INSPECTOR_W + bar)
|
||||
body.addWidget(self.pad, 1)
|
||||
body.addWidget(self.inspector)
|
||||
body.addWidget(self.panel)
|
||||
outer.addLayout(body, 1)
|
||||
|
||||
# editor bar, hidden unless you're rearranging the pad
|
||||
@@ -812,26 +871,53 @@ class Window(QMainWindow):
|
||||
fl.setContentsMargins(16, 8, 16, 8)
|
||||
self.dot = QLabel()
|
||||
self.dot.setFixedSize(9, 9)
|
||||
self.status = _label("", "dim")
|
||||
self.message = _label("")
|
||||
self.status = _label("", "status")
|
||||
self.message = _label("", "message")
|
||||
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.theme_btn = _button("Theme", "flat")
|
||||
self.theme_btn.setToolTip("Change how MacroPad looks")
|
||||
# Style decides how the pad is drawn, colours only recolour it, so
|
||||
# the menu offers them separately rather than as one long list.
|
||||
self.theme_menu = QMenu(self)
|
||||
self.theme_group = QActionGroup(self)
|
||||
self.theme_group.setExclusive(True)
|
||||
self.style_group = QActionGroup(self)
|
||||
self.style_group.setExclusive(True)
|
||||
self.colour_group = QActionGroup(self)
|
||||
self.colour_group.setExclusive(True)
|
||||
self.colour_actions = {}
|
||||
current = self.settings.value("theme", themes.DEFAULT)
|
||||
for t in themes.THEMES.values():
|
||||
a = self.theme_menu.addAction(t.name)
|
||||
style_now, palette_now = themes.parts(current)
|
||||
|
||||
head = self.theme_menu.addAction("Style")
|
||||
head.setEnabled(False)
|
||||
for key, (name, _base, _allowed) in themes.STYLES.items():
|
||||
a = self.theme_menu.addAction(" " + name)
|
||||
a.setCheckable(True)
|
||||
a.setChecked(t.key == current)
|
||||
a.setData(t.key)
|
||||
self.theme_group.addAction(a)
|
||||
a.setChecked(key == style_now)
|
||||
a.setData(("style", key))
|
||||
self.style_group.addAction(a)
|
||||
self.theme_menu.addSeparator()
|
||||
head = self.theme_menu.addAction("Colours")
|
||||
head.setEnabled(False)
|
||||
for key, (name, _colours) in themes.PALETTES.items():
|
||||
a = self.theme_menu.addAction(" " + name)
|
||||
a.setCheckable(True)
|
||||
a.setChecked(key == palette_now)
|
||||
a.setData(("palette", key))
|
||||
self.colour_group.addAction(a)
|
||||
self.colour_actions[key] = a
|
||||
self._sync_theme_menu(style_now, palette_now)
|
||||
# The version sits at the foot of the Theme menu rather than in a
|
||||
# menu bar, because there isn't one. It is here to be read off and
|
||||
# quoted in a bug report, so it is deliberately selectable text in
|
||||
# the one menu everybody opens.
|
||||
self.theme_menu.addSeparator()
|
||||
stamp = self.theme_menu.addAction(f"MacroPad {__version__}")
|
||||
stamp.setEnabled(False)
|
||||
self.theme_btn.setMenu(self.theme_menu)
|
||||
self.rotate = _button("Rotate view", "flat")
|
||||
self.rotate = _button("Rotate pad", "flat")
|
||||
self.import_btn = _button("Import capture…", "flat")
|
||||
self.write_all = _button("", "primary")
|
||||
fl.addWidget(self.dot)
|
||||
@@ -862,7 +948,8 @@ class Window(QMainWindow):
|
||||
self.pad.rearranged.connect(self._rearranged)
|
||||
self.read_btn.clicked.connect(lambda: self._read(quiet=False))
|
||||
self.rotate.clicked.connect(self._rotate)
|
||||
self.theme_group.triggered.connect(lambda a: self._set_theme(a.data()))
|
||||
self.style_group.triggered.connect(lambda a: self._set_theme(a.data()))
|
||||
self.colour_group.triggered.connect(lambda a: self._set_theme(a.data()))
|
||||
self.import_btn.clicked.connect(self._import)
|
||||
|
||||
self.note = core._keyd_warning()
|
||||
@@ -1303,9 +1390,30 @@ class Window(QMainWindow):
|
||||
self._set_layout(lay)
|
||||
self.pad.set_editing(True)
|
||||
|
||||
def _set_theme(self, key):
|
||||
t = apply_theme(QApplication.instance(), key)
|
||||
def _sync_theme_menu(self, style, palette):
|
||||
"""Grey out colours the chosen style does not offer."""
|
||||
allowed = themes.STYLES[style][2]
|
||||
for key, action in self.colour_actions.items():
|
||||
action.setEnabled(key in allowed)
|
||||
action.setChecked(key == palette)
|
||||
|
||||
def _set_theme(self, data):
|
||||
"""data is a theme key, or ("style"|"palette", key) from the menu."""
|
||||
current = self.settings.value("theme", themes.DEFAULT)
|
||||
style, palette = themes.parts(current)
|
||||
if isinstance(data, tuple):
|
||||
which, key = data
|
||||
if which == "style":
|
||||
style = key
|
||||
if palette not in themes.STYLES[style][2]:
|
||||
palette = themes.STYLES[style][2][0]
|
||||
else:
|
||||
palette = key
|
||||
else:
|
||||
style, palette = themes.parts(data)
|
||||
t = apply_theme(QApplication.instance(), themes.compose(style, palette).key)
|
||||
self.settings.setValue("theme", t.key)
|
||||
self._sync_theme_menu(style, palette)
|
||||
self._poll() # redraws the status dot in the new colours
|
||||
self._refresh()
|
||||
self.pad.update()
|
||||
@@ -1364,6 +1472,13 @@ def apply_theme(app, key=None):
|
||||
padview.apply_theme(t)
|
||||
GOOD, BAD, LINE, FIELD = t.good, t.bad, t.line, t.field
|
||||
app.setStyle("Fusion")
|
||||
if t.ui_font and t.ui_font in padview._families():
|
||||
f = QFont(t.ui_font)
|
||||
if t.ui_pt:
|
||||
f.setPixelSize(t.ui_pt)
|
||||
app.setFont(f)
|
||||
else:
|
||||
app.setFont(QFont())
|
||||
pal = QPalette()
|
||||
for role, colour in ((QPalette.Window, t.window), (QPalette.Base, t.field),
|
||||
(QPalette.Text, t.ink), (QPalette.WindowText, t.ink),
|
||||
|
||||
34
macropad_gui/backends/__init__.py
Normal file
34
macropad_gui/backends/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
One device family per backend.
|
||||
|
||||
Everything above this line works in terms of a Layout and a set of control
|
||||
ids and doesn't care what the hardware speaks. Everything below it is one
|
||||
pad family's protocol. Adding a family means writing the six methods in
|
||||
base.py and listing the class here.
|
||||
"""
|
||||
|
||||
from .ch57x import CH57x
|
||||
from .sayo import Sayo
|
||||
|
||||
REGISTRY = (CH57x(), Sayo()) # tried in order; first to claim a device wins
|
||||
DEFAULT = REGISTRY[0]
|
||||
|
||||
|
||||
def for_vid_pid(vid, pid):
|
||||
for backend in REGISTRY:
|
||||
if backend.claims(str(vid).lower(), str(pid).lower()):
|
||||
return backend
|
||||
return None
|
||||
|
||||
|
||||
def for_device(device):
|
||||
# Falls back rather than returning None, so callers that only ever see
|
||||
# one family stay simple. Use for_vid_pid when "nobody owns this" is an
|
||||
# answer you need to act on.
|
||||
if device is None:
|
||||
return DEFAULT
|
||||
return for_vid_pid(device.vid, device.pid) or DEFAULT
|
||||
|
||||
|
||||
def known_ids():
|
||||
return [(b.vid, b.pids, b.name) for b in REGISTRY]
|
||||
64
macropad_gui/backends/base.py
Normal file
64
macropad_gui/backends/base.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
What a device family has to provide.
|
||||
|
||||
These raise rather than returning something vague, so a half written backend
|
||||
fails at the call it can't answer instead of writing wrong bytes to somebody's
|
||||
hardware.
|
||||
"""
|
||||
|
||||
|
||||
class Backend:
|
||||
key = "" # short id, used in settings and messages
|
||||
name = "" # what a person would call this family
|
||||
vid = "" # USB vendor id, lowercase hex, no 0x
|
||||
pids = () # product ids this backend is CONFIRMED on
|
||||
layers = 1
|
||||
max_keys = 0
|
||||
max_knobs = 0
|
||||
|
||||
def claims(self, vid, pid):
|
||||
"""True only for ids this backend has been confirmed on."""
|
||||
if vid.lower() != self.vid.lower():
|
||||
return False
|
||||
return not self.pids or pid.lower() in {p.lower() for p in self.pids}
|
||||
|
||||
def could_claim(self, vid, pid):
|
||||
# Siblings share a vendor id but not always a protocol. The scan and
|
||||
# the udev rule cover the whole vendor so an unconfirmed sibling is
|
||||
# still found and offered detection; claims() stays strict so it is
|
||||
# never written to on the strength of its vendor id alone.
|
||||
return vid.lower() == self.vid.lower()
|
||||
|
||||
def udev_rule(self):
|
||||
return (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{self.vid}", '
|
||||
f'TAG+="uaccess"')
|
||||
|
||||
def adopt(self, layout):
|
||||
"""Where a family can say more about a shape than detection found."""
|
||||
return layout
|
||||
|
||||
def supports(self, keys, knobs):
|
||||
return 1 <= keys <= self.max_keys and 0 <= knobs <= self.max_knobs
|
||||
|
||||
# The six a family must answer.
|
||||
|
||||
def action_byte(self, control):
|
||||
"""The slot number this family uses for a control id."""
|
||||
raise NotImplementedError
|
||||
|
||||
def info(self, device):
|
||||
"""(keys, knobs) as the pad reports them, or None."""
|
||||
raise NotImplementedError
|
||||
|
||||
def read_layer(self, device, layer=1, layout=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def write(self, device, control, binding, state, layer=1, _write=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def detect(self, device):
|
||||
"""Read only. Must say no rather than guess."""
|
||||
raise NotImplementedError
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{type(self).__name__} {self.vid}:{'/'.join(self.pids) or '*'}>"
|
||||
216
macropad_gui/backends/ch57x.py
Normal file
216
macropad_gui/backends/ch57x.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
The CH57x family: 1189:8840 and its relatives.
|
||||
|
||||
Reverse engineered from USB captures of the vendor's own software, and every
|
||||
packet in tests/captures is replayed by the test suite. The wire format lives
|
||||
in macropad.py and is not to be edited without a capture to back it up.
|
||||
|
||||
Two things about this firmware shape the code:
|
||||
|
||||
* It keeps 15 key slots and 3 knob slots whatever hardware is soldered on,
|
||||
so a layer read always answers 24 slots. Only the 0xFB query says how
|
||||
many of those are real.
|
||||
* There is no addressing by name. A control is a slot number: keys are
|
||||
1 to 15, knobs start at 0x10 and take three apiece.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import time
|
||||
|
||||
import macropad as proto
|
||||
|
||||
from .base import Backend
|
||||
|
||||
WRITE_GAP = 0.02 # the same pause the CLI leaves between reports
|
||||
DIAL_ACTIONS = ("left", "push", "right")
|
||||
|
||||
|
||||
class CH57x(Backend):
|
||||
key = "ch57x"
|
||||
name = "CH57x macro pad"
|
||||
vid = "1189"
|
||||
pids = ("8840",) # confirmed; siblings share the vendor id, not the
|
||||
# protocol, so they go through detect() first
|
||||
layers = proto.LAYERS
|
||||
max_keys = proto.KEY_SLOTS
|
||||
max_knobs = proto.KNOB_SLOTS
|
||||
|
||||
# ------------------------------------------------------------ protocol
|
||||
|
||||
def action_byte(self, control):
|
||||
"""
|
||||
Keys count from 1; knobs start at 0x10, three apiece. Confirmed on
|
||||
1189:8840 both by writing and by reading back, and it matches the
|
||||
slots the pad reports for itself.
|
||||
"""
|
||||
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)
|
||||
|
||||
# -------------------------------------------------------------- decode
|
||||
|
||||
def decode_info(self, 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(self, report, layout=None):
|
||||
"""
|
||||
(control, layer, Binding) from a config report, or None.
|
||||
|
||||
layout decides which slots count as real controls, and it matters
|
||||
more than it looks: decode a 15 key pad against a 12 key layout and
|
||||
keys 13 to 15 and the third knob vanish without a word.
|
||||
"""
|
||||
from .. import core
|
||||
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]
|
||||
controls = (layout or core.DEFAULT_LAYOUT).control_for_action
|
||||
control = controls.get(action)
|
||||
if control is None:
|
||||
return None
|
||||
delay = report[5] | (report[6] << 8)
|
||||
count = report[10]
|
||||
|
||||
if kind == proto.KeyType.MULTIMEDIA and count:
|
||||
media = core._MEDIA_BY_USAGE.get(report[11] | (report[12] << 8))
|
||||
return (control, layer, core.Binding(media=media)) if media else None
|
||||
|
||||
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, core.Binding(none=True)
|
||||
return None
|
||||
|
||||
if kind in (proto.KeyType.NONE, 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 core._MOD_ORDER if mods & bit]
|
||||
if code:
|
||||
if code not in core._KEYNAME_BY_CODE:
|
||||
return None
|
||||
names.append(core._KEYNAME_BY_CODE[code])
|
||||
steps.append("+".join(names))
|
||||
return control, layer, core.Binding(keys=",".join(steps), delay=delay)
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------- read
|
||||
|
||||
def info(self, device):
|
||||
from .. import core
|
||||
for reply in core._exchange(device.path,
|
||||
proto.native_info(device.report_id), want=1):
|
||||
got = self.decode_info(reply)
|
||||
if got:
|
||||
return got
|
||||
return None
|
||||
|
||||
def read_layer(self, device, layer=1, layout=None):
|
||||
from .. import core
|
||||
out = {}
|
||||
request = proto.native_read(device.report_id, layer)
|
||||
for reply in core._exchange(device.path, request):
|
||||
decoded = self.decode_config(reply, layout)
|
||||
if not decoded:
|
||||
continue
|
||||
control, got_layer, binding = decoded
|
||||
if got_layer == layer:
|
||||
out[control] = binding
|
||||
return out
|
||||
|
||||
# --------------------------------------------------------------- write
|
||||
|
||||
def write(self, device, control, binding, state, layer=1, _write=None):
|
||||
"""
|
||||
Send one binding (config report, then commit) and update the shadow
|
||||
state. On success the state file is already saved.
|
||||
"""
|
||||
from .. import core
|
||||
write = _write or proto.write_report
|
||||
reports = binding.reports(device.report_id, self.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 = core._explain(e)
|
||||
# if the very 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 core.WriteError(f"{control}: {why}",
|
||||
pad_touched=not untouched, cause=e)
|
||||
state.record(control, binding, layer)
|
||||
state.save()
|
||||
|
||||
# -------------------------------------------------------------- detect
|
||||
|
||||
def detect(self, 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 another family fails one of those and we leave
|
||||
it alone.
|
||||
"""
|
||||
from .. import core
|
||||
try:
|
||||
info = self.info(device)
|
||||
except core.ReadError as e:
|
||||
return core.Detection(why=f"it didn't answer: {e}")
|
||||
if not info:
|
||||
return core.Detection(why="it didn't answer the 'what are you' query")
|
||||
|
||||
keys, knobs = info
|
||||
if not self.supports(keys, knobs):
|
||||
return core.Detection(why=f"it reported {keys} keys and {knobs} "
|
||||
"knobs, which isn't a pad this protocol "
|
||||
"can describe")
|
||||
|
||||
probe = core.Layout(keys, knobs, rows=keys, cols=1) # geometry unused
|
||||
expected = set(probe.control_for_action)
|
||||
try:
|
||||
replies = core._exchange(device.path,
|
||||
proto.native_read(device.report_id, 1))
|
||||
except core.ReadError as e:
|
||||
return core.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
|
||||
decoded = self.decode_config(reply, probe)
|
||||
if decoded:
|
||||
bindings[probe.control_for_action[action]] = decoded[2]
|
||||
|
||||
missing = expected - seen
|
||||
if missing:
|
||||
return core.Detection(
|
||||
why=f"it described {keys} keys and {knobs} knobs but didn't "
|
||||
f"report {len(missing)} of them, so this isn't the "
|
||||
"protocol it speaks")
|
||||
|
||||
return core.Detection(speaks=True, keys=keys, knobs=knobs,
|
||||
layers=self.layers, bindings=bindings)
|
||||
287
macropad_gui/backends/sayo.py
Normal file
287
macropad_gui/backends/sayo.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
SayoDevice backend. Complete but never run against real hardware.
|
||||
|
||||
Drop this and sayo_proto.py in macropad_gui/backends/ and add one line to
|
||||
REGISTRY in backends/__init__.py:
|
||||
|
||||
from .sayo import Sayo
|
||||
REGISTRY = (CH57x(), Sayo())
|
||||
|
||||
Until it is in REGISTRY the app cannot see it, so this file sitting here
|
||||
changes nothing for anyone.
|
||||
|
||||
The protocol is in sayo_proto.py, decoded from WebHID captures of
|
||||
sayodevice.com and checked against them line by line. Three things about
|
||||
it shape the code here:
|
||||
|
||||
* Reads and writes are the same command, 0x12. A four byte payload asks
|
||||
for a row of a table, a sixty byte payload is the row coming back or
|
||||
going in. Table 0x10 is the keys.
|
||||
* The pad says how many keys it has. Walk the index up and it answers
|
||||
with a no-such-index flag, so the key count is discovered rather than
|
||||
configured. It reports where each key physically is, too, so the grid
|
||||
is known rather than guessed.
|
||||
* Several bytes of a key's record drift on their own between reads, so
|
||||
a write is read-modify-write: read the record, change the two binding
|
||||
bytes, send it back. The vendor's own page does the same, which one
|
||||
capture caught it doing.
|
||||
|
||||
The pad echoes a write back about 100ms later, which is a real
|
||||
acknowledgement. The CH57x says nothing, so this backend can confirm a
|
||||
write where the other one can only hope.
|
||||
"""
|
||||
|
||||
import errno
|
||||
|
||||
from . import sayo_proto as wire
|
||||
from .base import Backend
|
||||
|
||||
REPORT_ID = wire.REPORT_ID
|
||||
MAX_KEYS = 64 # a sanity stop in case a pad never says no
|
||||
|
||||
|
||||
class Sayo(Backend):
|
||||
key = "sayo"
|
||||
name = "SayoDevice"
|
||||
vid = "8089"
|
||||
pids = ("000b",) # the 2x4; other Sayo pids are untested
|
||||
layers = 1 # the firmware has more, we expose one for now
|
||||
max_keys = MAX_KEYS
|
||||
max_knobs = 0
|
||||
|
||||
# ------------------------------------------------------------ protocol
|
||||
|
||||
def action_byte(self, control):
|
||||
"""Keys are their own index, counting from zero. No knobs."""
|
||||
if control.startswith("key"):
|
||||
return int(control[3:]) - 1
|
||||
raise ValueError(f"{self.name} has no knobs: {control}")
|
||||
|
||||
def _request(self, table, index=0):
|
||||
return wire.report(wire.read_request(table, index))
|
||||
|
||||
def _ask(self, device, table, index=0):
|
||||
"""
|
||||
One row of one table, or None if the pad has no such row.
|
||||
|
||||
Heartbeats arrive unasked about once a second and have to be
|
||||
stepped over, or the first one to land looks like the answer.
|
||||
"""
|
||||
from .. import core
|
||||
for reply in core._exchange(device.path, self._request(table, index),
|
||||
want=4):
|
||||
if len(reply) < 2 or reply[0] != REPORT_ID:
|
||||
continue
|
||||
payload = reply[1:]
|
||||
if wire.is_heartbeat(payload):
|
||||
continue
|
||||
try:
|
||||
got = wire.parse_reply(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if got["table"] != table or got["index"] != index:
|
||||
continue
|
||||
return None if got["end"] else payload
|
||||
return None
|
||||
|
||||
def _records(self, device, limit=MAX_KEYS):
|
||||
"""Every key's record, in order, stopping where the pad says to."""
|
||||
out = []
|
||||
for i in range(limit):
|
||||
rec = self._ask(device, wire.TABLE_KEYS, i)
|
||||
if rec is None or not wire.is_key_record(rec):
|
||||
break
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
def _binding(self, record):
|
||||
"""
|
||||
core.Binding for one key, or None if we cannot say what it does.
|
||||
|
||||
None is the honest answer for a key type no capture covers, and
|
||||
it matters: three keys on one real pad are type 0x80, and reading
|
||||
those bytes as a modifier made them look like a bare "ctrl+shift"
|
||||
that nobody had set. The app shows None as unknown, which is true,
|
||||
and write() then refuses to overwrite it.
|
||||
"""
|
||||
from .. import core
|
||||
kind, a, b = wire.read_binding(record)
|
||||
|
||||
if kind == "consumer":
|
||||
return core.Binding(media=b) if b else None
|
||||
|
||||
if kind != "keyboard":
|
||||
return None
|
||||
|
||||
mods, code = a, b
|
||||
if not mods and not code:
|
||||
return core.Binding(none=True)
|
||||
names = [n for n, bit in core._MOD_ORDER if mods & bit]
|
||||
if code:
|
||||
name = core._KEYNAME_BY_CODE.get(code)
|
||||
if name is None:
|
||||
return None
|
||||
names.append(name)
|
||||
return core.Binding(keys="+".join(names))
|
||||
|
||||
# --------------------------------------------------------------- read
|
||||
|
||||
def info(self, device):
|
||||
"""
|
||||
(keys, knobs) as the pad reports them, or None if it goes quiet.
|
||||
|
||||
None matters: it means "did not answer", and the app then treats
|
||||
the pad as unsupported rather than writing to it.
|
||||
"""
|
||||
records = self._records(device)
|
||||
return (len(records), 0) if records else None
|
||||
|
||||
def model(self, device):
|
||||
"""The name the pad calls itself, for the window title. May be ''."""
|
||||
reply = self._ask(device, wire.TABLE_NAME)
|
||||
return wire.device_name(reply) if reply else ""
|
||||
|
||||
def read_layer(self, device, layer=1, layout=None):
|
||||
out = {}
|
||||
for i, rec in enumerate(self._records(device)):
|
||||
binding = self._binding(rec)
|
||||
if binding is not None:
|
||||
out[f"key{i + 1}"] = binding
|
||||
return out
|
||||
|
||||
# -------------------------------------------------------------- write
|
||||
|
||||
def write(self, device, control, binding, state, layer=1, _write=None):
|
||||
"""
|
||||
Put one binding on the pad.
|
||||
|
||||
Read the key's record first, because most of it is the pad's own
|
||||
and some of it moves between reads. If the read fails nothing has
|
||||
been sent, which is the one case where the app can promise the old
|
||||
binding is still there.
|
||||
"""
|
||||
from .. import core
|
||||
import macropad as proto
|
||||
|
||||
index = self.action_byte(control)
|
||||
|
||||
try:
|
||||
record = self._ask(device, wire.TABLE_KEYS, index)
|
||||
except core.ReadError as e:
|
||||
raise core.WriteError(f"{control}: couldn't read the key first, so "
|
||||
f"nothing was changed ({e})",
|
||||
pad_touched=False, cause=e)
|
||||
if record is None or not wire.is_key_record(record):
|
||||
raise core.WriteError(f"{control}: the pad has no key {index + 1}",
|
||||
pad_touched=False)
|
||||
|
||||
# Refuse to write over a key whose current type no capture covers.
|
||||
# Overwriting it would leave a key marked as one kind holding
|
||||
# another kind's bytes, and what the firmware does then is anyone's
|
||||
# guess. Better to say so and leave the pad alone.
|
||||
kind = wire.kind_of(record)
|
||||
if kind == "unknown":
|
||||
raise core.WriteError(
|
||||
f"{control}: this key is set to something this app hasn't "
|
||||
f"decoded yet (type 0x{record[wire.KIND_AT]:02x}), so it was "
|
||||
f"left alone. Clear it in the SayoDevice configurator first "
|
||||
f"and it becomes editable here.", pad_touched=False)
|
||||
|
||||
payload = self._payload(record, binding)
|
||||
write = _write or proto.write_report
|
||||
try:
|
||||
write(device.path, wire.report(payload))
|
||||
except OSError as e:
|
||||
why = core._explain(e)
|
||||
untouched = e.errno in (errno.ENOENT, errno.ENODEV,
|
||||
errno.EACCES, errno.EPERM)
|
||||
if not untouched:
|
||||
state.mark_unknown(control, f"write interrupted: {why}", layer)
|
||||
state.save()
|
||||
raise core.WriteError(f"{control}: {why}",
|
||||
pad_touched=not untouched, cause=e)
|
||||
state.record(control, binding, layer)
|
||||
state.save()
|
||||
|
||||
def _payload(self, record, binding):
|
||||
"""The sealed write for this binding, on this key's own record."""
|
||||
from .. import core
|
||||
|
||||
if binding.media:
|
||||
if binding.media not in wire.CONSUMER_BY_NAME:
|
||||
known = ", ".join(sorted(wire.CONSUMER_BY_NAME))
|
||||
raise core.WriteError(
|
||||
f"{self.name}: {binding.media} isn't captured yet. This "
|
||||
f"pad numbers media keys its own way, not by HID code, so "
|
||||
f"each one needs seeing once. Known so far: {known}.",
|
||||
pad_touched=False)
|
||||
return wire.patch_consumer(record, binding.media)
|
||||
return wire.patch_binding(record, *self._encode(binding))
|
||||
|
||||
def _encode(self, binding):
|
||||
"""
|
||||
(mods, usage) for one keyboard binding, or a WriteError saying why
|
||||
not.
|
||||
|
||||
Parsing goes through macropad.MODIFIERS and macropad.KEYCODES, the
|
||||
same tables the CLI uses, so a binding typed once means the same
|
||||
thing on either family of pad. That is most of the point of having
|
||||
a backend seam at all.
|
||||
"""
|
||||
from .. import core
|
||||
import macropad as proto
|
||||
|
||||
if binding.none:
|
||||
return 0, 0
|
||||
if "," in binding.keys:
|
||||
raise core.WriteError(
|
||||
f"{self.name}: this pad holds one keystroke per key, not a "
|
||||
"sequence", pad_touched=False)
|
||||
mods, code = 0, 0
|
||||
for part in binding.keys.lower().split("+"):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if part in proto.MODIFIERS:
|
||||
mods |= proto.MODIFIERS[part]
|
||||
elif part in proto.KEYCODES:
|
||||
if code:
|
||||
raise core.WriteError(
|
||||
f"{self.name}: this pad holds one key plus modifiers, "
|
||||
f"so {binding.keys!r} won't fit", pad_touched=False)
|
||||
code = proto.KEYCODES[part]
|
||||
else:
|
||||
raise core.WriteError(f"{self.name}: don't know the key "
|
||||
f"{part!r}", pad_touched=False)
|
||||
return mods, code
|
||||
|
||||
# ------------------------------------------------------------- detect
|
||||
|
||||
def detect(self, device):
|
||||
"""
|
||||
Ask an unknown pad what it is, reading only.
|
||||
|
||||
Two things have to line up before we call it a Sayo: a key table
|
||||
that answers in the right shape, and a model name. A pad sharing
|
||||
the vendor id but not the protocol fails one of them and is left
|
||||
alone.
|
||||
"""
|
||||
from .. import core
|
||||
try:
|
||||
records = self._records(device)
|
||||
except core.ReadError as e:
|
||||
return core.Detection(why=f"it didn't answer: {e}")
|
||||
if not records:
|
||||
return core.Detection(why="it didn't answer the key table query")
|
||||
|
||||
rows, cols = wire.grid_from_records(records)
|
||||
if rows * cols != len(records):
|
||||
rows, cols = 1, len(records) # odd shape; one row is honest
|
||||
bindings = {}
|
||||
for i, rec in enumerate(records):
|
||||
got = self._binding(rec)
|
||||
if got is not None:
|
||||
bindings[f"key{i + 1}"] = got
|
||||
return core.Detection(speaks=True, keys=len(records), knobs=0,
|
||||
layers=self.layers, bindings=bindings,
|
||||
grid=(rows, cols), model=self.model(device))
|
||||
631
macropad_gui/backends/sayo_proto.py
Normal file
631
macropad_gui/backends/sayo_proto.py
Normal file
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
SayoDevice 8089:000b wire format.
|
||||
|
||||
Worked out from WebHID captures of sayodevice.com. Everything here is
|
||||
checked against those captures by the self test at the bottom: run this
|
||||
file directly and it rebuilds every captured packet byte for byte.
|
||||
|
||||
python3 sayo_proto.py
|
||||
|
||||
A report is 64 bytes: report id 0x21, then 63 bytes of payload. One
|
||||
command byte, 0x12, covers both directions. A four byte payload is a
|
||||
question, a sixty byte payload is an answer or a write.
|
||||
|
||||
payload
|
||||
0 0x12 the only command we have seen
|
||||
1-2 checksum, little endian (see checksum())
|
||||
3 how many bytes from byte 4 on carry meaning. 0x04 for a
|
||||
read request, 0x3c for a key record
|
||||
4 flags. 0x00 asking, 0x40 in a reply means no such index
|
||||
5 which table (see TABLES)
|
||||
6 index within that table
|
||||
7+ the data
|
||||
|
||||
Table 0x10 is the keys, one record per key, and a record looks like this:
|
||||
|
||||
7 0x01
|
||||
8-10 0x00
|
||||
11-18 x, y, width, height on the pad face, four little endian 16
|
||||
bit numbers. Key 1 of an 8 key pad is at 1250,1250 and is
|
||||
1800 square, giving a 4 by 2 grid with a pitch of 1900
|
||||
19 0x64 100
|
||||
23 what kind of key this is (see KINDS). It decides what bytes
|
||||
27 and 28 mean, and the configurator writes it
|
||||
27-28 the binding, read according to byte 23
|
||||
44 a counter of some sort, drifts on its own
|
||||
51-52 also drifts on its own, probably a press count or timestamp
|
||||
|
||||
Byte 23 took two captures to pin down. A capture of the page setting
|
||||
volume and brightness keys shows it going from 0x01 to 0x03 and byte 27
|
||||
carrying a small code, while an earlier capture of the page setting a
|
||||
plain letter left it alone. So it is the key's type, not drifting state:
|
||||
|
||||
0x00, 0x01 a keyboard shortcut. 27 is the modifier bitmask,
|
||||
standard HID, and 28 is the usage code. The low bit
|
||||
appears either way and is something else, so it is
|
||||
preserved rather than understood
|
||||
0x03 volume and brightness. 27 is a code of Sayo's own
|
||||
(see CONSUMER), 28 is zero
|
||||
anything a kind we have not captured. On one real pad, 0x80
|
||||
else is three media transport keys. Read as unknown and
|
||||
never written over
|
||||
|
||||
Writing is read-modify-write: read the key's record, set bytes 23, 27
|
||||
and 28, reseal. The vendor's own page does exactly that, which one
|
||||
capture caught in the act. Nothing else in the record is ours to touch,
|
||||
because bytes 44 and 51-52 really do move on their own.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPORT_ID = 0x21
|
||||
CMD = 0x12 # both directions, both reads and writes
|
||||
KEY_AT = 6
|
||||
KIND_AT = 23 # what sort of key it is, which decides 27 and 28
|
||||
MOD_AT = 27
|
||||
KEYCODE_AT = 28
|
||||
CHECK_AT = 1 # payload bytes 1 and 2, little endian
|
||||
LEN_AT = 3
|
||||
FLAG_AT = 4
|
||||
TABLE_AT = 5
|
||||
DATA_AT = 7
|
||||
|
||||
RECORD_LEN = 0x3C # byte 3 of a key record
|
||||
REQUEST_LEN = 0x04 # byte 3 of a read request
|
||||
NO_SUCH_INDEX = 0x40 # byte 4 of a reply, meaning you ran off the end
|
||||
|
||||
# Byte 5. The pad lists the ones it has in table 0x00; these are the ones
|
||||
# we have a use for.
|
||||
TABLE_DIRECTORY = 0x00
|
||||
TABLE_NAME = 0x01 # the model name, UTF-16LE
|
||||
TABLE_INFO = 0x02 # vid and pid at bytes 19 to 22, and uptime
|
||||
TABLE_KEYS = 0x10 # one record per key, the ones we care about
|
||||
|
||||
# Table 0x01 and 0x19 want 0x0c in the flag byte rather than 0x00. Why is
|
||||
# not clear, but the page is consistent about it and so are we.
|
||||
FLAGS = {TABLE_NAME: 0x0C}
|
||||
|
||||
CMD_WRITE_KEY = CMD # old name, kept so nothing outside here breaks
|
||||
|
||||
# Byte 23. KEYBOARD_KINDS are the two values seen alongside a real
|
||||
# keyboard shortcut; the low bit is some other flag we carry along.
|
||||
KIND_KEYBOARD = 0x00
|
||||
KIND_CONSUMER = 0x03
|
||||
KEYBOARD_KINDS = (0x00, 0x01)
|
||||
KINDS = {KIND_KEYBOARD: "keyboard", 0x01: "keyboard", KIND_CONSUMER: "consumer"}
|
||||
|
||||
# Byte 27 when byte 23 is 0x03. Sayo's own numbering, not HID consumer
|
||||
# codes, so every one of these comes from a capture and no others are
|
||||
# guessed. The names match macropad.MEDIA_KEYS so a media binding means
|
||||
# the same thing on either family of pad.
|
||||
CONSUMER = {
|
||||
0x01: "brightnessup",
|
||||
0x02: "brightnessdown",
|
||||
0x0A: "volumeup",
|
||||
0x0B: "volumedown",
|
||||
}
|
||||
CONSUMER_BY_NAME = {v: k for k, v in CONSUMER.items()}
|
||||
|
||||
# Captured records, each the exact 63 bytes sayodevice.com sent, taken from
|
||||
# CAPTURED below so the two can never drift apart. Byte 6 is the key index,
|
||||
# so these cover keys 1, 2 and 8 of an 8 key pad.
|
||||
CAPTURED = [
|
||||
"1256683c00100701000000261b4e0c080708076400000000000000000400000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"1256693c00100701000000261b4e0c080708076400000000000000000500000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"1256773c00100701000000261b4e0c080708076400000000000000001300000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"1258643c00100701000000261b4e0c080708076400000000000000020000000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"125e643c00100701000000261b4e0c080708076400000000000000080000000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"1256643c00100701000000261b4e0c080708076400000000000000000000000000000000000000000000000015000000000000000000000000000000000000",
|
||||
"12c17e3c00100001000000e204e20408070807640000000000000000040000030000000e000000000000000000000000000000000000000100000009500000",
|
||||
"122a863c001001010000004e0ce20408070807640000000000000000040000030000000c0000000000000000000000000000000000000000000000094f0000",
|
||||
]
|
||||
|
||||
# A second capture, of the page starting up, which is where the read
|
||||
# command came from. Request then reply, in the order they happened.
|
||||
STARTUP = [
|
||||
# the directory: which tables this pad has
|
||||
("out", "122512040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
|
||||
("in", "1250e11f00000007007b000000004b000004040102030d0e101112161718191a1e1f0000000000000000000000000000000000000000000000000000000000"),
|
||||
# the model name
|
||||
("out", "12261e040c01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
|
||||
("in", "129124340c01005300610079006f00440065007600690063006500200032007800340056002000520047004200000000000000000000000000000000000000"),
|
||||
# the eight keys, asked for one at a time
|
||||
("out", "123512040010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
|
||||
("in", "12c2d13c00100001000000e204e20408070807640000000000000001570000030000000e000000000000000000000000000000000000000100000009500000"),
|
||||
("in", "12ad823c001001010000004e0ce20408070807640000008000000003000000030000000c0000000000000000000000000000000000000000000000094f0000"),
|
||||
("in", "1296863c00100201000000ba13e20408070807640000008000000004000000030000000f000000810000000000000000000000014a00000000000000000000"),
|
||||
("in", "126f8f3c00100301000000261be204080708076400000080000000010000000000000000000000000000000000000000000000044b00000000000000000000"),
|
||||
("in", "1214a93c00100401000000e2044e0c08070807640000000000000001560000010000000000000000000000001d000000000000000000000000000000000000"),
|
||||
("in", "1286883c001005010000004e0c4e0c080708076400000001000000072f0000000000000000000000000000001b000000000000000000000000000000000000"),
|
||||
("in", "12f5c83c00100601000000ba134e0c080708076400000000000000073000000000000000000000000000000006000000000000044c00000000000000000000"),
|
||||
("in", "125bb23c00100701000000261b4e0c080708076400000001000000000000000000000000000000000000000015000000000000044e00000000000000000000"),
|
||||
# asking for a ninth key, and being told there isn't one
|
||||
("out", "12351a040010080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
|
||||
("in", "12355a044010080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"),
|
||||
# and then the page set key 8 to p, by sending back the record it had
|
||||
# just read with one byte changed. Which is what we do.
|
||||
("out", "125bc53c00100701000000261b4e0c080708076400000001000000001300000000000000000000000000000015000000000000044e00000000000000000000"),
|
||||
]
|
||||
|
||||
LIVE = [bytes.fromhex(h) for d, h in STARTUP if d == "in" and
|
||||
bytes.fromhex(h)[TABLE_AT] == 0x10 and bytes.fromhex(h)[LEN_AT] == 0x3C]
|
||||
|
||||
# A third capture: the page setting key 8 to each of four volume and
|
||||
# brightness keys in turn, with the log cleared first. All four differ
|
||||
# from each other in byte 27 and nothing else, and all four set byte 23
|
||||
# to 0x03 where the key had been 0x01. That pair of facts is what makes
|
||||
# byte 23 the key type rather than drifting state.
|
||||
CONSUMER_CAPTURE = {
|
||||
"volumeup": "1267b23c00100701000000261b4e0c0807080764000000030000000a0000000000000000000000000000000015000000000000044e00000000000000000000",
|
||||
"volumedown": "1268b23c00100701000000261b4e0c0807080764000000030000000b0000000000000000000000000000000015000000000000044e00000000000000000000",
|
||||
"brightnessup": "125eb23c00100701000000261b4e0c080708076400000003000000010000000000000000000000000000000015000000000000044e00000000000000000000",
|
||||
"brightnessdown": "125fb23c00100701000000261b4e0c080708076400000003000000020000000000000000000000000000000015000000000000044e00000000000000000000",
|
||||
}
|
||||
|
||||
|
||||
def _records():
|
||||
out = {}
|
||||
for h in CAPTURED:
|
||||
b = bytes.fromhex(h)
|
||||
out.setdefault(b[KEY_AT], b) # first sighting of each key
|
||||
return out
|
||||
|
||||
|
||||
RECORDS = _records()
|
||||
|
||||
USAGES = {c: 4 + i for i, c in enumerate("abcdefghijklmnopqrstuvwxyz")}
|
||||
USAGES.update({str(d): 30 + i for i, d in enumerate("1234567890")})
|
||||
USAGES.update({
|
||||
"enter": 40, "esc": 41, "backspace": 42, "tab": 43, "space": 44,
|
||||
"minus": 45, "equal": 46, "leftbracket": 47, "rightbracket": 48,
|
||||
"backslash": 49, "semicolon": 51, "quote": 52, "grave": 53,
|
||||
"comma": 54, "period": 55, "slash": 56, "capslock": 57,
|
||||
"printscreen": 70, "insert": 73, "home": 74, "pageup": 75,
|
||||
"delete": 76, "end": 77, "pagedown": 78,
|
||||
"right": 79, "left": 80, "down": 81, "up": 82,
|
||||
"numlock": 83, "numpadslash": 84, "numpadstar": 85,
|
||||
"numpadminus": 86, "numpadplus": 87, "numpadenter": 88, "numpaddot": 99,
|
||||
})
|
||||
for _i in range(1, 13):
|
||||
USAGES[f"f{_i}"] = 57 + _i
|
||||
for _i in range(1, 10):
|
||||
USAGES[f"numpad{_i}"] = 0x58 + _i
|
||||
USAGES["numpad0"] = 0x62
|
||||
|
||||
# Standard HID modifier bits. The pad uses them unchanged.
|
||||
MODIFIERS = {"ctrl": 0x01, "shift": 0x02, "alt": 0x04, "meta": 0x08,
|
||||
"super": 0x08, "win": 0x08,
|
||||
"rctrl": 0x10, "rshift": 0x20, "ralt": 0x40, "rmeta": 0x80}
|
||||
|
||||
|
||||
def parse_combo(text):
|
||||
"""'ctrl+shift+a' -> (0x03, 0x04). A bare modifier gives keycode 0."""
|
||||
mods, usage = 0, 0
|
||||
for part in text.lower().replace(" ", "").split("+"):
|
||||
if not part:
|
||||
continue
|
||||
if part in MODIFIERS:
|
||||
mods |= MODIFIERS[part]
|
||||
elif part in USAGES:
|
||||
usage = USAGES[part]
|
||||
else:
|
||||
raise KeyError(f"don't know {part!r}")
|
||||
return mods, usage
|
||||
|
||||
|
||||
def checksum(payload):
|
||||
"""
|
||||
Sum the whole 64 byte report as little endian 16 bit words with the
|
||||
checksum field zeroed. No magic constant: this reproduces all ten
|
||||
captured packets exactly.
|
||||
"""
|
||||
raw = bytearray([REPORT_ID]) + bytearray(payload)
|
||||
raw[2] = raw[3] = 0 # the checksum's own two bytes
|
||||
if len(raw) % 2:
|
||||
raw.append(0)
|
||||
total = sum(raw[i] | (raw[i + 1] << 8) for i in range(0, len(raw), 2))
|
||||
return total & 0xFFFF
|
||||
|
||||
|
||||
def seal(payload):
|
||||
"""Put the right checksum into a payload and hand it back."""
|
||||
out = bytearray(payload)
|
||||
c = checksum(out)
|
||||
out[CHECK_AT] = c & 0xFF
|
||||
out[CHECK_AT + 1] = (c >> 8) & 0xFF
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def verify(payload):
|
||||
return payload[CHECK_AT] | (payload[CHECK_AT + 1] << 8) == checksum(payload)
|
||||
|
||||
|
||||
def kind_of(record):
|
||||
"""What sort of key this is: 'keyboard', 'consumer', or 'unknown'."""
|
||||
return KINDS.get(record[KIND_AT], "unknown")
|
||||
|
||||
|
||||
def read_binding(record):
|
||||
"""
|
||||
(kind, a, b) from a record, where what a and b mean follows the kind.
|
||||
|
||||
keyboard (modifier bitmask, HID usage)
|
||||
consumer (consumer code, name or None)
|
||||
unknown (byte 23, byte 27) and nothing else to say
|
||||
|
||||
Returning the kind rather than guessing is the whole point: three keys
|
||||
on one real pad are type 0x80, and reading those as a modifier made
|
||||
them look like a bare "ctrl+shift" that nobody had set.
|
||||
"""
|
||||
kind = kind_of(record)
|
||||
if kind == "keyboard":
|
||||
return kind, record[MOD_AT], record[KEYCODE_AT]
|
||||
if kind == "consumer":
|
||||
code = record[MOD_AT]
|
||||
return kind, code, CONSUMER.get(code)
|
||||
return kind, record[KIND_AT], record[MOD_AT]
|
||||
|
||||
|
||||
def patch_binding(record, mods, usage):
|
||||
"""
|
||||
One key's record holding a keyboard shortcut instead, resealed.
|
||||
|
||||
record must be that key's own record, because bytes 11 to 18 are where
|
||||
that key sits on the pad face and other bytes are its own state. Pass
|
||||
another key's record and you move this key on top of that one.
|
||||
|
||||
Byte 23 is set to the keyboard kind. If the key was already a keyboard
|
||||
key its low bit is kept, since that bit shows up either way and the
|
||||
vendor's page leaves it alone when it changes a letter.
|
||||
"""
|
||||
out = bytearray(record)
|
||||
was = out[KIND_AT]
|
||||
out[KIND_AT] = (was & 0x01) if was in KEYBOARD_KINDS else KIND_KEYBOARD
|
||||
out[MOD_AT] = mods
|
||||
out[KEYCODE_AT] = usage
|
||||
return seal(out)
|
||||
|
||||
|
||||
def patch_consumer(record, code):
|
||||
"""
|
||||
One key's record holding a volume or brightness key, resealed.
|
||||
|
||||
code is a key of CONSUMER, or one of its names. Byte 23 becomes 0x03
|
||||
and byte 28 goes to zero, which is exactly what the capture of the
|
||||
page doing this shows.
|
||||
"""
|
||||
if isinstance(code, str):
|
||||
if code not in CONSUMER_BY_NAME:
|
||||
raise KeyError(f"no captured code for {code!r}; known: "
|
||||
f"{', '.join(sorted(CONSUMER_BY_NAME))}")
|
||||
code = CONSUMER_BY_NAME[code]
|
||||
out = bytearray(record)
|
||||
out[KIND_AT] = KIND_CONSUMER
|
||||
out[MOD_AT] = code
|
||||
out[KEYCODE_AT] = 0
|
||||
return seal(out)
|
||||
|
||||
|
||||
def patch_keycode(record, usage):
|
||||
"""A plain key with no modifier."""
|
||||
return patch_binding(record, 0, usage)
|
||||
|
||||
|
||||
def is_heartbeat(payload):
|
||||
"""The status report the pad sends unasked, about once a second."""
|
||||
return bool(payload) and payload[0] == 0x00
|
||||
|
||||
|
||||
def report(payload):
|
||||
"""The 64 bytes to hand to a hidraw write."""
|
||||
return bytes([REPORT_ID]) + bytes(payload)
|
||||
|
||||
|
||||
# ------------------------------------------------------ reading the pad
|
||||
#
|
||||
# A capture of the configurator starting up gave this away. Ask with a
|
||||
# four byte payload naming a table and an index, get the row back. Walk
|
||||
# the index up from zero and the pad tells you when to stop, so the key
|
||||
# count does not have to be guessed or configured anywhere.
|
||||
|
||||
|
||||
def read_request(table, index=0):
|
||||
"""The payload that asks for one row of one table."""
|
||||
out = bytearray(63)
|
||||
out[0] = CMD
|
||||
out[LEN_AT] = REQUEST_LEN
|
||||
out[FLAG_AT] = FLAGS.get(table, 0x00)
|
||||
out[TABLE_AT] = table
|
||||
out[KEY_AT] = index
|
||||
return seal(out)
|
||||
|
||||
|
||||
def parse_reply(payload):
|
||||
"""
|
||||
{table, index, flags, data, end} from anything the pad sends back.
|
||||
|
||||
data is only the meaningful part: byte 3 says how far it runs. end is
|
||||
True when the index does not exist, which is how you find out how many
|
||||
keys a pad has.
|
||||
"""
|
||||
p = bytes(payload)
|
||||
if len(p) < DATA_AT or p[0] != CMD:
|
||||
raise ValueError("not a 0x12 report")
|
||||
size = p[LEN_AT]
|
||||
return {"table": p[TABLE_AT], "index": p[KEY_AT], "flags": p[FLAG_AT],
|
||||
"data": p[FLAG_AT:FLAG_AT + size], "payload": p,
|
||||
"end": bool(p[FLAG_AT] & NO_SUCH_INDEX)}
|
||||
|
||||
|
||||
def is_key_record(payload):
|
||||
"""Is this 63 bytes a key record, rather than a short reply or noise?"""
|
||||
p = bytes(payload)
|
||||
return (len(p) >= DATA_AT and p[0] == CMD and p[TABLE_AT] == TABLE_KEYS
|
||||
and p[LEN_AT] == RECORD_LEN and not p[FLAG_AT] & NO_SUCH_INDEX)
|
||||
|
||||
|
||||
def key_geometry(record):
|
||||
"""
|
||||
(x, y, w, h) of one key on the pad face, from its record.
|
||||
|
||||
The unit is arbitrary but consistent. What it is good for is working
|
||||
out the shape of a pad nobody has told us about: group the keys by y
|
||||
to get the rows.
|
||||
"""
|
||||
u16 = lambda o: record[o] | (record[o + 1] << 8)
|
||||
return u16(11), u16(13), u16(15), u16(17)
|
||||
|
||||
|
||||
def grid_from_records(records):
|
||||
"""
|
||||
(rows, cols) worked out from where the keys actually are.
|
||||
|
||||
Beats guessing from the key count, which cannot tell a 4 by 2 from a
|
||||
2 by 4 or an 8 by 1.
|
||||
"""
|
||||
rows = sorted({key_geometry(r)[1] for r in records})
|
||||
cols = sorted({key_geometry(r)[0] for r in records})
|
||||
return len(rows) or 1, len(cols) or 1
|
||||
|
||||
|
||||
def device_name(payload):
|
||||
"""
|
||||
The model name out of a table 0x01 reply. UTF-16LE, zero padded.
|
||||
|
||||
The declared length can land on an odd byte, which would leave half a
|
||||
character on the end, so trim to even and then cut at the first NUL
|
||||
rather than trusting the padding.
|
||||
"""
|
||||
p = bytes(payload)
|
||||
size = max(0, p[LEN_AT] - 3)
|
||||
raw = p[DATA_AT:DATA_AT + size - (size % 2)]
|
||||
return raw.decode("utf-16-le", "replace").split("\x00")[0].strip()
|
||||
|
||||
|
||||
def tables_offered(payload):
|
||||
"""
|
||||
Which tables a pad admits to having, from a table 0x00 reply.
|
||||
|
||||
Only used to sanity check that 0x10 is there before trusting the rest.
|
||||
"""
|
||||
p = bytes(payload)
|
||||
body = p[FLAG_AT:FLAG_AT + p[LEN_AT]]
|
||||
seen, out = set(), []
|
||||
for b in body[10:]: # the list sits after a header
|
||||
if b and b not in seen:
|
||||
seen.add(b)
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------- backup files
|
||||
#
|
||||
# The configurator's "backup" button writes a .sayobak. Inside it, from
|
||||
# offset 102, are the pad's eight key records, 62 bytes each. They are the
|
||||
# same layout as a write payload from byte 11 on, which is the part we
|
||||
# cannot invent, so a backup is a perfectly good substitute for reading the
|
||||
# records off the pad.
|
||||
#
|
||||
# Rebuilding key 1's captured write from its backup record comes out byte
|
||||
# for byte identical, which is what makes this trustworthy.
|
||||
|
||||
BACKUP_MAGIC = b"Sayo Device Backup File"
|
||||
BACKUP_START = 102
|
||||
BACKUP_STRIDE = 62
|
||||
KEYS_IN_BACKUP = 8
|
||||
|
||||
|
||||
def read_backup(data):
|
||||
"""
|
||||
[{key, x, y, w, h, mods, usage, record}] from a .sayobak, one per key.
|
||||
|
||||
x and y are the key's position on the pad face and w and h its size, in
|
||||
whatever unit the firmware uses. On an 8 key pad they come out as a 4 by
|
||||
2 grid with a pitch of 1900 and a size of 1800.
|
||||
"""
|
||||
if hasattr(data, "read_bytes"):
|
||||
data = data.read_bytes()
|
||||
elif isinstance(data, str):
|
||||
data = Path(data).read_bytes()
|
||||
if not data.startswith(BACKUP_MAGIC):
|
||||
raise ValueError("not a Sayo backup file")
|
||||
out = []
|
||||
for i in range(KEYS_IN_BACKUP):
|
||||
off = BACKUP_START + i * BACKUP_STRIDE
|
||||
rec = data[off:off + BACKUP_STRIDE]
|
||||
if len(rec) < BACKUP_STRIDE:
|
||||
break
|
||||
u16 = lambda o: rec[o] | (rec[o + 1] << 8)
|
||||
out.append({"key": i, "x": u16(11), "y": u16(13),
|
||||
"w": u16(15), "h": u16(17),
|
||||
"mods": rec[MOD_AT], "usage": rec[KEYCODE_AT],
|
||||
"record": rec})
|
||||
return out
|
||||
|
||||
|
||||
def payload_from_record(record, key, mods, usage):
|
||||
"""
|
||||
A write payload for one key, built from that key's backup record.
|
||||
|
||||
The header is fixed, bytes 11 onward come from the record, and only the
|
||||
binding changes. Checked against the captures: key 1 comes out byte for
|
||||
byte identical to what the vendor's page sent.
|
||||
"""
|
||||
out = bytearray(63)
|
||||
out[0] = CMD_WRITE_KEY
|
||||
out[3] = 0x3C
|
||||
out[5] = 0x10
|
||||
out[6] = key
|
||||
out[7] = 0x01
|
||||
out[11:62] = record[11:62]
|
||||
return patch_binding(out, mods, usage)
|
||||
|
||||
|
||||
def describe_record(record):
|
||||
"""What one key does, in words, whatever kind of key it is."""
|
||||
kind, a, b = read_binding(record)
|
||||
if kind == "keyboard":
|
||||
return describe(a, b)
|
||||
if kind == "consumer":
|
||||
return b or f"an uncaptured volume or brightness code, 0x{a:02x}"
|
||||
return f"a key type we haven't decoded, 0x{a:02x} with 0x{b:02x}"
|
||||
|
||||
|
||||
def describe(mods, usage):
|
||||
"""'ctrl+shift+a' from a modifier bitmask and a usage code."""
|
||||
names = [n for n, bit in (("ctrl", 0x01), ("shift", 0x02), ("alt", 0x04),
|
||||
("meta", 0x08), ("rctrl", 0x10), ("rshift", 0x20),
|
||||
("ralt", 0x40), ("rmeta", 0x80)) if mods & bit]
|
||||
by_code = {v: k for k, v in sorted(USAGES.items())}
|
||||
if usage:
|
||||
names.append(by_code.get(usage, f"0x{usage:02x}"))
|
||||
return "+".join(names) or "nothing"
|
||||
|
||||
|
||||
# ------------------------------------------------------------- self test
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
bad = 0
|
||||
for h in CAPTURED:
|
||||
b = bytes.fromhex(h)
|
||||
if seal(b) != b or not verify(b):
|
||||
bad += 1
|
||||
print(f" MISMATCH key={b[KEY_AT]} stored={b[1]:02x}{b[2]:02x} "
|
||||
f"computed={seal(b)[1]:02x}{seal(b)[2]:02x}")
|
||||
print(f"{len(CAPTURED) - bad}/{len(CAPTURED)} captured packets reproduced exactly")
|
||||
|
||||
for key, rec in sorted(RECORDS.items()):
|
||||
assert len(rec) == 63, f"key {key} record is {len(rec)} bytes, want 63"
|
||||
assert verify(rec), f"stored record for key {key} fails its own checksum"
|
||||
got = patch_keycode(rec, USAGES["z"])
|
||||
assert verify(got) and got[KEYCODE_AT] == USAGES["z"]
|
||||
print(f"{len(RECORDS)} stored key records verify and re-seal cleanly")
|
||||
|
||||
# the two modifier captures, rebuilt from key 8's record
|
||||
for combo, want in (("shift", "1258643c"), ("meta", "125e643c")):
|
||||
mods, usage = parse_combo(combo)
|
||||
built = patch_binding(RECORDS[7], mods, usage)
|
||||
assert built.hex().startswith(want), f"{combo}: {built[:4].hex()} != {want}"
|
||||
print("left shift and meta captures rebuilt from key 8's record")
|
||||
|
||||
for combo in ("ctrl+a", "ctrl+shift+t", "meta+w", "alt+a"):
|
||||
mods, usage = parse_combo(combo)
|
||||
out = patch_binding(RECORDS[0], mods, usage)
|
||||
assert verify(out)
|
||||
print("modifier combinations build and verify")
|
||||
|
||||
# ------------------------------------------------ the startup capture
|
||||
|
||||
for direction, h in STARTUP:
|
||||
b = bytes.fromhex(h)
|
||||
assert len(b) == 63, f"{h[:8]} is {len(b)} bytes"
|
||||
assert verify(b), f"{h[:8]} fails its own checksum"
|
||||
print(f"{len(STARTUP)} startup packets verify")
|
||||
|
||||
# our read_request() against the three the page actually sent
|
||||
for table, index, want in ((TABLE_DIRECTORY, 0, "122512040000"),
|
||||
(TABLE_NAME, 0, "12261e040c01"),
|
||||
(TABLE_KEYS, 0, "123512040010"),
|
||||
(TABLE_KEYS, 8, "12351a040010")):
|
||||
got = read_request(table, index).hex()
|
||||
assert got.startswith(want), f"table {table:#04x}: {got[:12]} != {want}"
|
||||
print("read requests match the ones the vendor's page sends")
|
||||
|
||||
end = parse_reply(bytes.fromhex(
|
||||
"12355a044010080000000000000000000000000000000000000000000000000000"
|
||||
"0000000000000000000000000000000000000000000000000000000000000000"[:126]))
|
||||
assert end["end"] and end["index"] == 8
|
||||
assert len(LIVE) == 8 and not any(parse_reply(r)["end"] for r in LIVE)
|
||||
print(f"the pad reports {len(LIVE)} keys and says so when asked for a ninth")
|
||||
|
||||
name = device_name(bytes.fromhex(
|
||||
[h for d, h in STARTUP if d == "in" and bytes.fromhex(h)[TABLE_AT] == 1][0]))
|
||||
assert name == "SayoDevice 2x4V RGB", repr(name)
|
||||
print(f"model name reads back as {name!r}")
|
||||
|
||||
assert TABLE_KEYS in tables_offered(bytes.fromhex(STARTUP[1][1]))
|
||||
rows, cols = grid_from_records(LIVE)
|
||||
assert (rows, cols) == (2, 4), (rows, cols)
|
||||
print(f"geometry puts the keys in a {cols} by {rows} grid")
|
||||
|
||||
# the page read key 8 then wrote it. Rebuild that write from that read.
|
||||
rebuilt = payload_from_record(LIVE[7], 7, 0x00, USAGES["p"])
|
||||
assert rebuilt == bytes.fromhex(STARTUP[-1][1]), "read-modify-write diverged"
|
||||
print("the page's own write rebuilt byte for byte from the record it read")
|
||||
|
||||
# ------------------------------------------- volume and brightness
|
||||
|
||||
before = LIVE[7]
|
||||
assert before[KIND_AT] == 0x01, f"key 8 started at {before[KIND_AT]:#04x}"
|
||||
for name, want in CONSUMER_CAPTURE.items():
|
||||
built = patch_consumer(before, name)
|
||||
assert built == bytes.fromhex(want), (
|
||||
f"{name}: {[n for n in range(63) if built[n] != bytes.fromhex(want)[n]]}")
|
||||
print(f"all {len(CONSUMER_CAPTURE)} volume and brightness writes rebuilt exactly")
|
||||
|
||||
kinds = {bytes.fromhex(h)[KIND_AT] for h in CONSUMER_CAPTURE.values()}
|
||||
assert kinds == {KIND_CONSUMER}, kinds
|
||||
print("and each one set byte 23 to 0x03 where the key had been 0x01, "
|
||||
"so byte 23 is the key type")
|
||||
|
||||
for name, h in CONSUMER_CAPTURE.items():
|
||||
kind, code, got = read_binding(bytes.fromhex(h))
|
||||
assert (kind, got) == ("consumer", name), (kind, got, name)
|
||||
print("they read back as the names they were set to")
|
||||
|
||||
# a kind we have never captured must not read as a keyboard shortcut
|
||||
odd = bytearray(LIVE[0])
|
||||
odd[KIND_AT] = 0x80
|
||||
assert kind_of(odd) == "unknown"
|
||||
assert "haven't decoded" in describe_record(odd)
|
||||
print("an uncaptured key type reads as unknown rather than a bare modifier")
|
||||
|
||||
# switching a key back to a shortcut must clear the consumer type
|
||||
back = patch_binding(bytes.fromhex(CONSUMER_CAPTURE["volumeup"]), 0x01,
|
||||
USAGES["c"])
|
||||
assert back[KIND_AT] == KIND_KEYBOARD and verify(back)
|
||||
assert read_binding(back) == ("keyboard", 0x01, USAGES["c"])
|
||||
print("and setting a shortcut on a volume key clears the type back")
|
||||
|
||||
for i, rec in enumerate(LIVE):
|
||||
x, y, w, h = key_geometry(rec)
|
||||
print(f" key {i+1}: at ({x:>5},{y:>5}) size {w}x{h} "
|
||||
f"[{kind_of(rec)}] -> {describe_record(rec)}")
|
||||
|
||||
# a backup, if one is sitting next to this file
|
||||
import glob
|
||||
for path in sorted(glob.glob("*.sayobak")) + sorted(glob.glob("backup.bin")):
|
||||
keys = read_backup(Path(path))
|
||||
print(f"\n{path}: {len(keys)} key records")
|
||||
cols = len({k["x"] for k in keys})
|
||||
print(f" pad is {cols} across by {len(keys)//cols} down")
|
||||
for k in keys:
|
||||
print(f" key {k['key']+1}: at ({k['x']:>5},{k['y']:>5}) "
|
||||
f"size {k['w']}x{k['h']} [{kind_of(k['record'])}] -> "
|
||||
f"{describe_record(k['record'])}")
|
||||
rebuilt = payload_from_record(keys[0]["record"], 0, 0, USAGES["a"])
|
||||
assert verify(rebuilt)
|
||||
assert rebuilt == RECORDS[0], "key 1 should rebuild to its captured write"
|
||||
print(" key 1 rebuilt from the backup matches its captured write exactly")
|
||||
break
|
||||
@@ -46,17 +46,30 @@ DIAL_ACTIONS = ("left", "push", "right")
|
||||
WRITE_GAP = 0.02 # same pause the CLI leaves between reports
|
||||
|
||||
|
||||
def action_byte(control):
|
||||
def backends():
|
||||
"""
|
||||
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.
|
||||
The backend registry. Imported late because the backends import this
|
||||
module for its data types, and a top level import either way would be
|
||||
circular.
|
||||
"""
|
||||
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)
|
||||
from . import backends as registry
|
||||
return registry
|
||||
|
||||
|
||||
def backend_for(device):
|
||||
"""Whichever family owns this device; the default when there is none."""
|
||||
return backends().for_device(device)
|
||||
|
||||
|
||||
def action_byte(control, backend=None):
|
||||
"""
|
||||
The slot number one control lives in.
|
||||
|
||||
Slot numbering belongs to the protocol, so this is the backend's answer.
|
||||
The default is the CH57x family, which is what every caller that does
|
||||
not have a device in hand wants.
|
||||
"""
|
||||
return (backend or backends().DEFAULT).action_byte(control)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -414,8 +427,13 @@ class State:
|
||||
should call record()/mark_unknown(); the GUI just reads it.
|
||||
"""
|
||||
|
||||
def __init__(self, path=None, layout=None):
|
||||
self.path = Path(path) if path else config_dir() / f"state-{VID}-{PID}.json"
|
||||
def __init__(self, path=None, layout=None, device_id=None):
|
||||
# The default name is the one the app has always used, so upgrading
|
||||
# never orphans somebody's bindings. A second family would pass its
|
||||
# own id and get its own file.
|
||||
self.device_id = device_id or DEVICE_ID
|
||||
default = config_dir() / f"state-{self.device_id.replace(':', '-')}.json"
|
||||
self.path = Path(path) if path else default
|
||||
self.entries = {} # (layer, control) -> Entry
|
||||
self.layout = layout or DEFAULT_LAYOUT
|
||||
if self.path.exists():
|
||||
@@ -449,7 +467,7 @@ class State:
|
||||
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,
|
||||
_atomic_write_json(self.path, {"format": FORMAT, "device": self.device_id,
|
||||
"layout": self.layout.to_json(),
|
||||
"layers": layers})
|
||||
|
||||
@@ -497,7 +515,26 @@ SUGGESTED_RULE = "60-macropad.rules"
|
||||
# Vendor wide on purpose. Pads in this family have several product ids, and
|
||||
# a rule tied to one of them leaves everybody else unable to read their own
|
||||
# hardware, which is exactly the point at which they give up.
|
||||
RULE_LINE = f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", TAG+="uaccess"'
|
||||
def rule_lines():
|
||||
"""One uaccess rule per vendor we support."""
|
||||
return [b.udev_rule() for b in backends().REGISTRY]
|
||||
|
||||
|
||||
RULE_LINE = (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", '
|
||||
f'TAG+="uaccess"')
|
||||
|
||||
|
||||
def rule_command():
|
||||
"""
|
||||
The shell line that installs access rules for every family we support.
|
||||
One vendor today, so it reads the same as it always has; a second
|
||||
backend adds its own line here rather than needing the text edited.
|
||||
"""
|
||||
lines = rule_lines()
|
||||
if len(lines) == 1:
|
||||
return f"echo '{lines[0]}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}"
|
||||
body = "\\n".join(lines)
|
||||
return f"printf '{body}\\n' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}"
|
||||
|
||||
|
||||
|
||||
@@ -552,14 +589,24 @@ def _udev_tags(devpath):
|
||||
return tags
|
||||
|
||||
|
||||
def vendors():
|
||||
"""Every USB vendor id any backend knows about."""
|
||||
return {b.vid.lower() for b in backends().REGISTRY}
|
||||
|
||||
|
||||
def discover():
|
||||
"""Config-capable hidraw nodes belonging to any family we support."""
|
||||
return proto.find_devices(vendors())
|
||||
|
||||
|
||||
def _on_usb_bus():
|
||||
want = vendors()
|
||||
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):
|
||||
if v in want:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -608,8 +655,12 @@ def diagnose(keyd=True):
|
||||
"the roadmap."])
|
||||
|
||||
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]
|
||||
everything = discover()
|
||||
# A device is "known" only when some backend is confirmed on its exact
|
||||
# id. A sibling under the same vendor shows up as unsupported, which is
|
||||
# what puts the detect button in front of its owner.
|
||||
found = [d for d in everything
|
||||
if backends().for_vid_pid(d["vid"], d["pid"])]
|
||||
|
||||
if not found and everything:
|
||||
other = everything[0]
|
||||
@@ -632,7 +683,7 @@ def diagnose(keyd=True):
|
||||
"may name a single product id; this one covers every "
|
||||
"pad from the same maker.",
|
||||
"Run these, then unplug the pad and plug it back in."]
|
||||
d.fix = [f"echo '{RULE_LINE}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}",
|
||||
d.fix = [rule_command(),
|
||||
"sudo udevadm control --reload-rules && sudo udevadm trigger"]
|
||||
return d
|
||||
|
||||
@@ -695,7 +746,7 @@ def diagnose(keyd=True):
|
||||
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}",
|
||||
diag.fix = [rule_command(),
|
||||
"sudo udevadm control --reload-rules && sudo udevadm trigger",
|
||||
"# then unplug and replug the pad"]
|
||||
return diag
|
||||
@@ -729,88 +780,27 @@ def _explain(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.
|
||||
Send one binding 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()
|
||||
return backend_for(device).write(device, control, binding, state, layer,
|
||||
_write)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- 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_info(report, backend=None):
|
||||
"""(keys, knobs) from an info reply, or None."""
|
||||
return (backend or backends().DEFAULT).decode_info(report)
|
||||
|
||||
|
||||
def decode_config(report, layout=None):
|
||||
def decode_config(report, layout=None, backend=None):
|
||||
"""
|
||||
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.
|
||||
|
||||
layout decides which slots count as real controls, and it matters more
|
||||
than it looks. The firmware answers for 15 keys and 3 knobs whatever
|
||||
the pad actually has, so the caller has to say how big the pad is.
|
||||
Decode a 15 key pad against the default 12 key layout and keys 13 to 15
|
||||
and the whole third knob are dropped on the floor without a word.
|
||||
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] not in (proto.MAGIC_NATIVE, proto.MAGIC_READ):
|
||||
return None
|
||||
action, layer, kind = report[2], report[3], report[4]
|
||||
control = (layout or DEFAULT_LAYOUT).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 and count:
|
||||
media = _MEDIA_BY_USAGE.get(report[11] | (report[12] << 8))
|
||||
return (control, layer, Binding(media=media)) if media else None
|
||||
|
||||
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 in (proto.KeyType.NONE, 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
|
||||
return (backend or backends().DEFAULT).decode_config(report, layout)
|
||||
|
||||
|
||||
def is_commit(report):
|
||||
@@ -924,24 +914,12 @@ def _exchange(path, request, want=None):
|
||||
|
||||
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
|
||||
return backend_for(device).info(device)
|
||||
|
||||
|
||||
def read_layer(device, layer=1, layout=None):
|
||||
"""{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, layout)
|
||||
if not decoded:
|
||||
continue
|
||||
control, got_layer, binding = decoded
|
||||
if got_layer == layer:
|
||||
out[control] = binding
|
||||
return out
|
||||
return backend_for(device).read_layer(device, layer, layout)
|
||||
|
||||
|
||||
def read_into_state(device, state, layer=1, layout=None):
|
||||
@@ -966,85 +944,53 @@ class Detection:
|
||||
layers: int = 0
|
||||
bindings: dict = field(default_factory=dict)
|
||||
why: str = "" # when speaks is False, what went wrong
|
||||
grid: tuple = () # (rows, cols) when the pad reports geometry
|
||||
model: str = "" # what the pad calls itself, if it says
|
||||
|
||||
@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.
|
||||
The shape to draw. Use the pad's own geometry when it reports any,
|
||||
and guess from the key count when it doesn't.
|
||||
|
||||
Pads in this family are wider than they are tall, so the guess is
|
||||
the squarest split that keeps rows no greater than columns: 6 keys
|
||||
give 2x3, 12 give 3x4, 15 give 3x5. The old version had this the
|
||||
wrong way round and drew every pad on its side.
|
||||
The CH57x only reports counts, so for that family this is a guess
|
||||
the owner should confirm. Pads in it are wider than they are tall,
|
||||
so the guess is the squarest split that keeps rows no greater than
|
||||
columns: 6 keys give 2x3, 12 give 3x4, 15 give 3x5. The old
|
||||
version had this the wrong way round and drew every pad on its
|
||||
side.
|
||||
"""
|
||||
if not self.speaks:
|
||||
return None
|
||||
if self.grid and self.grid[0] * self.grid[1] == self.keys:
|
||||
rows, cols = self.grid
|
||||
return Layout(self.keys, self.knobs, rows, cols, self.model)
|
||||
rows = max(r for r in range(1, int(self.keys ** 0.5) + 1)
|
||||
if self.keys % r == 0)
|
||||
return Layout(self.keys, self.knobs, rows, self.keys // rows)
|
||||
return Layout(self.keys, self.knobs, rows, self.keys // rows,
|
||||
self.model)
|
||||
|
||||
|
||||
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.
|
||||
Ask an unknown pad what it is, without configuring anything. Which
|
||||
questions get asked is the backend's business; what comes back is a
|
||||
Detection either way.
|
||||
"""
|
||||
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, probe)
|
||||
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)
|
||||
return backend_for(device).detect(device)
|
||||
|
||||
|
||||
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}",
|
||||
from . import __version__
|
||||
backend = backend_for(device)
|
||||
lines = [f"MacroPad {__version__}",
|
||||
f"Device: {device.vid}:{device.pid}",
|
||||
f"Name: {device.name or 'not reported'}",
|
||||
f"Node: {device.path}, report id {device.report_id}", ""]
|
||||
f"Node: {device.path}, report id {device.report_id}",
|
||||
f"Tried: {backend.name} ({backend.key})", ""]
|
||||
if not detected.speaks:
|
||||
lines += ["This pad did NOT answer the 1189:8840 protocol.",
|
||||
lines += [f"This pad did NOT answer the {backend.name} protocol.",
|
||||
f"Reason: {detected.why}"]
|
||||
return "\n".join(lines)
|
||||
lines += [f"The pad reports {detected.keys} keys and {detected.knobs} knobs.",
|
||||
|
||||
BIN
macropad_gui/fonts/Harmattan-Bold.ttf
Normal file
BIN
macropad_gui/fonts/Harmattan-Bold.ttf
Normal file
Binary file not shown.
BIN
macropad_gui/fonts/Harmattan-Medium.ttf
Normal file
BIN
macropad_gui/fonts/Harmattan-Medium.ttf
Normal file
Binary file not shown.
BIN
macropad_gui/fonts/Harmattan-Regular.ttf
Normal file
BIN
macropad_gui/fonts/Harmattan-Regular.ttf
Normal file
Binary file not shown.
BIN
macropad_gui/fonts/Harmattan-SemiBold.ttf
Normal file
BIN
macropad_gui/fonts/Harmattan-SemiBold.ttf
Normal file
Binary file not shown.
BIN
macropad_gui/fonts/JetBrainsMonoNL-Bold.ttf
Normal file
BIN
macropad_gui/fonts/JetBrainsMonoNL-Bold.ttf
Normal file
Binary file not shown.
BIN
macropad_gui/fonts/JetBrainsMonoNL-BoldItalic.ttf
Normal file
BIN
macropad_gui/fonts/JetBrainsMonoNL-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
macropad_gui/fonts/JetBrainsMonoNL-ExtraBold.ttf
Normal file
BIN
macropad_gui/fonts/JetBrainsMonoNL-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
macropad_gui/fonts/JetBrainsMonoNL-ExtraBoldItalic.ttf
Normal file
BIN
macropad_gui/fonts/JetBrainsMonoNL-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
macropad_gui/fonts/JetBrainsMonoNL-Regular.ttf
Normal file
BIN
macropad_gui/fonts/JetBrainsMonoNL-Regular.ttf
Normal file
Binary file not shown.
BIN
macropad_gui/fonts/JetBrainsMonoNL-SemiBold.ttf
Normal file
BIN
macropad_gui/fonts/JetBrainsMonoNL-SemiBold.ttf
Normal file
Binary file not shown.
103
macropad_gui/fonts/OFL-Harmattan.txt
Normal file
103
macropad_gui/fonts/OFL-Harmattan.txt
Normal file
@@ -0,0 +1,103 @@
|
||||
Copyright (c) 2007-2008, The C&MA Guinea Fulbe Team;
|
||||
|
||||
Copyright renewed 2011-2012, George W. Nuss (http://www.fulbefouta.com),
|
||||
with the Reserved Font Name "Fouta".
|
||||
|
||||
Copyright (c) 2004-2025, SIL Global (https://www.sil.org),
|
||||
with Reserved Font Names 'Andika' and 'SIL'.
|
||||
|
||||
Copyright (c) 2014-2025, SIL Global (https://www.sil.org/).
|
||||
with Reserved Font Names "Harmattan" and "SIL".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1. This license is copied below, and is also available with
|
||||
an FAQ at: https://openfontlicense.org/
|
||||
|
||||
|
||||
----------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
----------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
93
macropad_gui/fonts/OFL-JetBrainsMono.txt
Normal file
93
macropad_gui/fonts/OFL-JetBrainsMono.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -16,11 +16,12 @@ import zlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PySide6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF,
|
||||
from PySide6.QtGui import (QRadialGradient, QBrush, QColor, QFont, QFontDatabase, QFontMetricsF,
|
||||
QPainter, QPainterPath, QPen, QTransform)
|
||||
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||
|
||||
from . import core, themes
|
||||
from .vectors import KNOB_ARC
|
||||
|
||||
THEME = themes.get(themes.DEFAULT)
|
||||
|
||||
@@ -35,12 +36,24 @@ TAPE = QColor(THEME.accent) # Kapton orange, in the default theme
|
||||
|
||||
|
||||
def apply_theme(theme):
|
||||
global THEME
|
||||
global THEME, K, G, GY, M, R_OUT, R_IN, R_KNOB, ROW_H, ROW_GAP, ROW_SEP
|
||||
global DIAL_W, KEY_R, LEGEND_R, RULE_X, LABEL_X
|
||||
global BYTE_PT, LABEL_PT, ROW_PT, ROW_BYTE_PT, BADGE
|
||||
THEME = theme
|
||||
for colour, value in ((WINDOW, theme.window), (BOARD, theme.board),
|
||||
(INK, theme.ink), (INK_DIM, theme.ink_dim),
|
||||
(HATCH, theme.hatch), (TAPE, theme.accent)):
|
||||
colour.setRgba(QColor(value).rgba())
|
||||
m = theme.metrics
|
||||
K, G, GY, M = m.key, m.gap_x, m.gap_y, m.margin
|
||||
R_OUT, R_IN, R_KNOB = m.r_out, m.r_in, m.r_knob
|
||||
ROW_H, ROW_GAP, ROW_SEP = m.row_h, m.row_gap, m.row_sep
|
||||
DIAL_W = m.dial_w
|
||||
KEY_R, LEGEND_R = m.key_radius, m.legend_radius
|
||||
RULE_X, LABEL_X = m.rule_x, m.label_x
|
||||
BYTE_PT, LABEL_PT = m.byte_pt, m.label_pt
|
||||
ROW_PT, ROW_BYTE_PT = m.row_pt, m.row_byte_pt
|
||||
BADGE = m.badge
|
||||
|
||||
|
||||
_FAMILIES = None
|
||||
@@ -92,11 +105,19 @@ def _wobble_path(path, amount, seed, step=4.0):
|
||||
out.lineTo(q)
|
||||
return out
|
||||
|
||||
# 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
|
||||
# Geometry in board units; the view scales it to fit. These come from the
|
||||
# active theme's Metrics and are refreshed by apply_theme, so a theme can
|
||||
# change the pad's proportions and not just its colours.
|
||||
_M = themes.Metrics()
|
||||
K, G, GY, M = _M.key, _M.gap_x, _M.gap_y, _M.margin
|
||||
R_OUT, R_IN, R_KNOB = _M.r_out, _M.r_in, _M.r_knob
|
||||
ROW_H, ROW_GAP, ROW_SEP = _M.row_h, _M.row_gap, _M.row_sep
|
||||
DIAL_W = _M.dial_w
|
||||
KEY_R, LEGEND_R = _M.key_radius, _M.legend_radius
|
||||
RULE_X, LABEL_X = _M.rule_x, _M.label_x
|
||||
BYTE_PT, LABEL_PT, ROW_PT, ROW_BYTE_PT = (_M.byte_pt, _M.label_pt,
|
||||
_M.row_pt, _M.row_byte_pt)
|
||||
BADGE = _M.badge
|
||||
|
||||
@dataclass
|
||||
class Look:
|
||||
@@ -149,9 +170,16 @@ class PadView(QWidget):
|
||||
self._wobbler = QTimer(self)
|
||||
self._wobbler.setInterval(130)
|
||||
self._wobbler.timeout.connect(self._tick)
|
||||
self.mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||
self._sys_mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||
self._layout()
|
||||
|
||||
@property
|
||||
def mono(self):
|
||||
"""The theme's monospace face when it ships one, else the system's."""
|
||||
if THEME.mono_font and THEME.mono_font in _families():
|
||||
return QFont(THEME.mono_font)
|
||||
return self._sys_mono
|
||||
|
||||
# ------------------------------------------------------------ public
|
||||
|
||||
def set_orientation(self, orientation):
|
||||
@@ -209,7 +237,7 @@ class PadView(QWidget):
|
||||
left = lay.knob_side == "left" and bool(lay.knobs)
|
||||
|
||||
keys_w = grid_cols * (K + G) - G
|
||||
keys_h = grid_rows * (K + G) - G
|
||||
keys_h = grid_rows * (K + GY) - GY
|
||||
# how much room the knobs need across, and down from the first centre
|
||||
dials_w = lay.knobs * (DIAL_W + G) - G if lay.knobs else 0
|
||||
dials_h = (R_OUT + (lay.knobs - 1) * (stack + 20 + R_OUT) + stack
|
||||
@@ -243,17 +271,21 @@ class PadView(QWidget):
|
||||
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)
|
||||
grid_top + r * (K + GY), K, K)
|
||||
for key, r, c in _key_grid(lay, self.orientation):
|
||||
self.keys[key] = self.cells[(r, c)]
|
||||
|
||||
for dial, centre in zip(lay.dial_ids, dial_centres):
|
||||
self.dials[dial] = centre
|
||||
top = centre.y() + R_OUT + ROW_GAP
|
||||
# With a boxed legend the rows butt together and the block's own
|
||||
# colour shows through as a hairline; otherwise they are loose.
|
||||
inset = 0.0 if THEME.legend_box else 6.0
|
||||
step = ROW_H + ROW_SEP
|
||||
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)
|
||||
centre.x() - DIAL_W / 2 + inset, top + i * step,
|
||||
DIAL_W - 2 * inset, ROW_H)
|
||||
|
||||
boxes = list(self.cells.values()) + list(self.rows.values())
|
||||
right = max(r.right() for r in boxes)
|
||||
@@ -274,13 +306,41 @@ class PadView(QWidget):
|
||||
|
||||
# ----------------------------------------------------------- hit test
|
||||
|
||||
def _arc_outline(self, c, mirror):
|
||||
"""
|
||||
One turn arc, built from the outline in the Figma file. Coordinates
|
||||
there are multiples of the knob's radius measured from its centre,
|
||||
so this works at any size. The clockwise arc is the same shape
|
||||
mirrored.
|
||||
"""
|
||||
path = QPainterPath()
|
||||
s = R_KNOB
|
||||
for op, a in KNOB_ARC:
|
||||
pts = [QPointF(c.x() + (-a[i] if mirror else a[i]) * s,
|
||||
c.y() + a[i + 1] * s)
|
||||
for i in range(0, len(a), 2)]
|
||||
if op == "M":
|
||||
path.moveTo(pts[0])
|
||||
elif op == "L":
|
||||
path.lineTo(pts[0])
|
||||
elif op == "Q":
|
||||
path.quadTo(pts[0], pts[1])
|
||||
elif op == "C":
|
||||
path.cubicTo(pts[0], pts[1], pts[2])
|
||||
else:
|
||||
path.closeSubpath()
|
||||
return path
|
||||
|
||||
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)
|
||||
path.addEllipse(c, (R_KNOB if THEME.knob_art else R_IN) - 2,
|
||||
(R_KNOB if THEME.knob_art else R_IN) - 2)
|
||||
return path
|
||||
if THEME.knob_art == "figma":
|
||||
return self._arc_outline(c, mirror=(act == "right"))
|
||||
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)
|
||||
@@ -494,7 +554,8 @@ class PadView(QWidget):
|
||||
"""roomy: there's space to spare, as on a keycap; not on a dial legend."""
|
||||
if THEME.font and THEME.font in _families():
|
||||
f = QFont(THEME.font)
|
||||
f.setPixelSize(px + (2 if roomy else 0)) # handwriting reads small
|
||||
# handwriting reads small; a normal face does not need the bump
|
||||
f.setPixelSize(px + (2 if roomy and THEME.wobble else 0))
|
||||
elif THEME.mono_legends:
|
||||
f = QFont(self.mono)
|
||||
f.setPixelSize(px - 1)
|
||||
@@ -616,8 +677,58 @@ class PadView(QWidget):
|
||||
i += 1
|
||||
p.restore()
|
||||
|
||||
# ----------------------------------------------------------- surfaces
|
||||
|
||||
@staticmethod
|
||||
def _well_brush(rect, radial=False):
|
||||
"""
|
||||
The tile and knob gradients. Figma calls the tile one a diamond
|
||||
gradient, which Qt has no equivalent for; a radial one reaching the
|
||||
corners is the closest honest approximation and reads the same, as a
|
||||
face that darkens towards its edge.
|
||||
"""
|
||||
if not THEME.well:
|
||||
return QColor(THEME.board)
|
||||
c = rect.center()
|
||||
r = (math.hypot(rect.width(), rect.height()) / 2 if not radial
|
||||
else max(rect.width(), rect.height()) / 2)
|
||||
g = QRadialGradient(c, r)
|
||||
g.setColorAt(0.0, QColor(THEME.board))
|
||||
g.setColorAt(0.75 if radial else 0.745, QColor(THEME.board))
|
||||
g.setColorAt(0.9 if radial else 1.0, QColor(THEME.well))
|
||||
g.setColorAt(1.0, QColor(THEME.well))
|
||||
return QBrush(g)
|
||||
|
||||
def _glow(self, p, path, colour):
|
||||
"""
|
||||
A few wide, nearly transparent passes under a stroke, so an accent
|
||||
edge blooms the way it does in the design.
|
||||
"""
|
||||
if not THEME.glow:
|
||||
return
|
||||
p.save()
|
||||
p.setBrush(Qt.NoBrush)
|
||||
for i, (width, alpha) in enumerate(((THEME.glow, 26),
|
||||
(THEME.glow * 0.6, 44),
|
||||
(THEME.glow * 0.3, 70))):
|
||||
c = QColor(colour)
|
||||
c.setAlpha(alpha)
|
||||
pen = QPen(c, width)
|
||||
pen.setJoinStyle(Qt.RoundJoin)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.drawPath(path)
|
||||
p.restore()
|
||||
|
||||
def _tape(self, p, at, length=30.0, width=9.0):
|
||||
"""A strip of Kapton stuck across a corner."""
|
||||
"""
|
||||
The marker for an edit that hasn't reached the pad yet. Normally a
|
||||
strip of Kapton across the corner; "star" themes stamp a little
|
||||
asterisk tile instead.
|
||||
"""
|
||||
if THEME.badge == "star":
|
||||
self._star_badge(p, at, max(11.0, width * 1.6))
|
||||
return
|
||||
p.save()
|
||||
p.translate(at)
|
||||
p.rotate(45)
|
||||
@@ -628,37 +739,123 @@ class PadView(QWidget):
|
||||
p.drawRect(QRectF(-length / 2, -width / 2, length, width))
|
||||
p.restore()
|
||||
|
||||
@staticmethod
|
||||
def _corner_path(rect, tl=0.0, tr=0.0, br=0.0, bl=0.0):
|
||||
"""
|
||||
A rectangle with each corner rounded by its own radius, built as one
|
||||
continuous outline. Adding overlapping sub-paths instead would cancel
|
||||
under the odd-even fill rule and leave the shape hollow.
|
||||
"""
|
||||
path = QPainterPath()
|
||||
path.moveTo(rect.left() + tl, rect.top())
|
||||
path.lineTo(rect.right() - tr, rect.top())
|
||||
if tr:
|
||||
path.quadTo(rect.right(), rect.top(), rect.right(), rect.top() + tr)
|
||||
path.lineTo(rect.right(), rect.bottom() - br)
|
||||
if br:
|
||||
path.quadTo(rect.right(), rect.bottom(),
|
||||
rect.right() - br, rect.bottom())
|
||||
path.lineTo(rect.left() + bl, rect.bottom())
|
||||
if bl:
|
||||
path.quadTo(rect.left(), rect.bottom(), rect.left(), rect.bottom() - bl)
|
||||
path.lineTo(rect.left(), rect.top() + tl)
|
||||
if tl:
|
||||
path.quadTo(rect.left(), rect.top(), rect.left() + tl, rect.top())
|
||||
path.closeSubpath()
|
||||
return path
|
||||
|
||||
def _star_badge(self, p, at, size=16.0):
|
||||
"""A filled tile with an asterisk on it, for a knob row."""
|
||||
box = QRectF(at.x() - size / 2, at.y() - size / 2, size, size)
|
||||
self._flag_shape(p, box, THEME.badge_glyph or "*", QColor(TAPE),
|
||||
QColor(THEME.accent_text))
|
||||
|
||||
def _corner_flag(self, p, rect, glyph, fill, pen_colour, italic=False):
|
||||
"""
|
||||
The tab tucked into a key's top right corner: square, with only the
|
||||
inner corner rounded, so it reads as folded into the tile.
|
||||
"""
|
||||
size = min(BADGE, rect.width() * 0.26)
|
||||
box = QRectF(rect.right() - size - KEY_R * 0.35,
|
||||
rect.top() + KEY_R * 0.18, size, size)
|
||||
self._flag_shape(p, box, glyph, fill, pen_colour, italic)
|
||||
|
||||
def _flag_shape(self, p, box, glyph, fill, pen_colour, italic=False):
|
||||
p.save()
|
||||
r = box.width() * 0.28
|
||||
# square against the tile's own corner, rounded on the two inner ones
|
||||
path = self._corner_path(box, tl=r, tr=0.0, br=r, bl=0.0)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(fill)
|
||||
p.drawPath(path)
|
||||
f = QFont(self.mono)
|
||||
f.setPixelSize(max(7, round(box.height() * 0.95)))
|
||||
f.setWeight(QFont.ExtraBold)
|
||||
f.setItalic(italic)
|
||||
p.setFont(f)
|
||||
p.setPen(pen_colour)
|
||||
# the asterisk sits high in the em box; nudge it back to the middle
|
||||
lift = box.height() * (0.18 if glyph == "*" else 0.0)
|
||||
p.drawText(box.translated(0, lift), Qt.AlignCenter, glyph)
|
||||
p.restore()
|
||||
|
||||
def _paint_key(self, p, key, rect):
|
||||
look = self.looks.get(key, Look())
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(rect, 5, 5)
|
||||
path.addRoundedRect(rect, KEY_R, KEY_R)
|
||||
unknown = not look.known and not look.pending
|
||||
if THEME.well:
|
||||
p.fillPath(path, self._well_brush(rect))
|
||||
if unknown:
|
||||
self._unknown_fill(p, path, rect, key)
|
||||
if key == self.current or look.pending:
|
||||
self._glow(p, path, TAPE)
|
||||
self._stroke(p, path, self._outline_pen(key, look.known or look.pending), key)
|
||||
|
||||
f = QFont(self.mono)
|
||||
f.setPixelSize(10)
|
||||
f.setPixelSize(max(6, round(BYTE_PT)))
|
||||
if THEME.mono_font:
|
||||
f.setItalic(True)
|
||||
f.setWeight(QFont.Bold)
|
||||
p.setFont(f)
|
||||
p.setPen(INK_DIM)
|
||||
p.drawText(rect.adjusted(8, 6, -8, -6), Qt.AlignLeft | Qt.AlignTop,
|
||||
p.setPen(QColor(THEME.byte_ink) if THEME.byte_ink else INK_DIM)
|
||||
bi = THEME.metrics.byte_inset
|
||||
byte_at = (rect.adjusted(bi[0] * rect.width(), bi[1] * rect.height(),
|
||||
-bi[0] * rect.width(), 0)
|
||||
if bi else rect.adjusted(6, 4, -6, -4))
|
||||
p.drawText(byte_at, Qt.AlignLeft | Qt.AlignTop,
|
||||
f"0x{core.action_byte(key):02x}")
|
||||
|
||||
f = self._legend_font(13)
|
||||
f = self._legend_font(round(LABEL_PT))
|
||||
p.setFont(f)
|
||||
if look.pending:
|
||||
if key == self.current:
|
||||
p.setPen(TAPE)
|
||||
elif look.pending:
|
||||
# the design marks a change with the corner flag, not the words
|
||||
p.setPen(TAPE if not THEME.badge_glyph else INK)
|
||||
else:
|
||||
p.setPen(INK if look.known and not look.quiet else INK_DIM)
|
||||
text = self._lettering(look.text if (look.known or look.pending) else "Unknown")
|
||||
body = rect.adjusted(8, 22, -8, -10)
|
||||
lb = THEME.metrics.label_band
|
||||
body = (QRectF(rect.left() + lb[0] * rect.width(),
|
||||
rect.top() + lb[1] * rect.height(),
|
||||
rect.width() * (1 - lb[0] - lb[2]),
|
||||
rect.height() * (1 - lb[1] - lb[3]))
|
||||
if lb else 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))
|
||||
if THEME.badge == "star":
|
||||
self._corner_flag(p, rect, THEME.badge_glyph or "*",
|
||||
QColor(TAPE), QColor(THEME.accent_text))
|
||||
else:
|
||||
self._tape(p, QPointF(rect.right() - 7, rect.top() + 7))
|
||||
elif unknown and THEME.query_badge:
|
||||
self._corner_flag(p, rect, "?", QColor(THEME.hatch), INK_DIM,
|
||||
italic=True)
|
||||
|
||||
def _paint_turn_icon(self, p, centre, clockwise, colour):
|
||||
r = 5.5
|
||||
@@ -688,12 +885,16 @@ class PadView(QWidget):
|
||||
look = self.looks.get(control, Look())
|
||||
seg = self._segment(dial, act)
|
||||
if act != "push":
|
||||
if THEME.well:
|
||||
p.fillPath(seg, self._well_brush(seg.boundingRect()))
|
||||
if not look.known and not look.pending:
|
||||
self._unknown_fill(p, seg, seg.boundingRect(), control, 7.0)
|
||||
if control == self.current:
|
||||
fill = QColor(TAPE)
|
||||
fill.setAlpha(55)
|
||||
fill.setAlpha(55 if not THEME.glow else 28)
|
||||
p.fillPath(seg, fill)
|
||||
if control == self.current or look.pending:
|
||||
self._glow(p, seg, TAPE)
|
||||
self._stroke(p, seg, self._outline_pen(control, look.known or look.pending),
|
||||
control)
|
||||
|
||||
@@ -701,12 +902,16 @@ class PadView(QWidget):
|
||||
look = self.looks.get(push, Look())
|
||||
knob = QPainterPath()
|
||||
knob.addEllipse(c, R_KNOB, R_KNOB)
|
||||
if THEME.well:
|
||||
p.fillPath(knob, self._well_brush(knob.boundingRect(), radial=True))
|
||||
if not look.known and not look.pending:
|
||||
self._unknown_fill(p, knob, knob.boundingRect(), push, 7.0)
|
||||
if push == self.current:
|
||||
fill = QColor(TAPE)
|
||||
fill.setAlpha(55)
|
||||
fill.setAlpha(55 if not THEME.glow else 28)
|
||||
p.fillPath(knob, fill)
|
||||
if push == self.current or look.pending:
|
||||
self._glow(p, knob, TAPE)
|
||||
self._stroke(p, knob, self._outline_pen(push, look.known or look.pending), push)
|
||||
# knurling, the way a footprint marks a rotary encoder
|
||||
if not THEME.wobble:
|
||||
@@ -726,13 +931,31 @@ class PadView(QWidget):
|
||||
p.drawText(label, Qt.AlignCenter, self._lettering(f"Dial {dial[4:]}"))
|
||||
|
||||
# Legend rows: the silkscreen text next to the part.
|
||||
if THEME.legend_box:
|
||||
self._paint_legend_box(p, dial)
|
||||
for act in core.DIAL_ACTIONS:
|
||||
control = f"{dial}-{act}"
|
||||
self._paint_row(p, control, act, self.rows[control])
|
||||
|
||||
def _paint_legend_box(self, p, dial):
|
||||
"""
|
||||
One knob's three rows sit on a plate the colour of the dividing
|
||||
lines. The rows are painted over it leaving the plate showing as a
|
||||
hairline between them, which is how the design draws its separators.
|
||||
"""
|
||||
boxes = [self.rows[f"{dial}-{a}"] for a in core.DIAL_ACTIONS]
|
||||
rect = boxes[0].united(boxes[-1]).adjusted(-ROW_SEP, -ROW_SEP,
|
||||
ROW_SEP, ROW_SEP)
|
||||
p.setPen(QPen(QColor(THEME.line), ROW_SEP))
|
||||
p.setBrush(QColor(THEME.line))
|
||||
p.drawRoundedRect(rect, LEGEND_R, LEGEND_R)
|
||||
|
||||
def _paint_row(self, p, control, act, rect):
|
||||
look = self.looks.get(control, Look())
|
||||
is_cur = control == self.current
|
||||
if THEME.legend_box:
|
||||
self._paint_boxed_row(p, control, act, rect, look, is_cur)
|
||||
return
|
||||
if is_cur or control == self.hover:
|
||||
bg = QColor(TAPE if is_cur else INK)
|
||||
bg.setAlpha(40 if is_cur else 18)
|
||||
@@ -753,20 +976,85 @@ class PadView(QWidget):
|
||||
mono.setPixelSize(10)
|
||||
p.setFont(mono)
|
||||
p.setPen(INK_DIM)
|
||||
prefix = "0x" if THEME.byte_prefix else ""
|
||||
p.drawText(rect.adjusted(0, 0, -4, 0), Qt.AlignRight | Qt.AlignVCenter,
|
||||
f"{core.action_byte(control):02x}")
|
||||
f"{prefix}{core.action_byte(control):02x}")
|
||||
|
||||
f = self._legend_font(12, roomy=False)
|
||||
p.setFont(f)
|
||||
p.setPen(TAPE if look.pending else
|
||||
(INK if look.known and not look.quiet else INK_DIM))
|
||||
text = self._lettering(look.text if (look.known or look.pending) else "Unknown")
|
||||
body = rect.adjusted(22, 0, -26, 0)
|
||||
# the action byte sits on the right; leave room for the wider 0x form
|
||||
body = rect.adjusted(22, 0, -36 if THEME.byte_prefix else -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)
|
||||
|
||||
def _paint_boxed_row(self, p, control, act, rect, look, is_cur):
|
||||
"""
|
||||
One row of a boxed legend: a filled tile, a turn or press icon, a
|
||||
vertical rule, the legend, and the action byte on the right.
|
||||
"""
|
||||
first = act == core.DIAL_ACTIONS[0]
|
||||
last = act == core.DIAL_ACTIONS[-1]
|
||||
r = LEGEND_R
|
||||
path = self._corner_path(rect,
|
||||
tl=r if first else 0.0, tr=r if first else 0.0,
|
||||
br=r if last else 0.0, bl=r if last else 0.0)
|
||||
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(self._well_brush(rect) if THEME.well else QColor(THEME.board))
|
||||
p.drawPath(path)
|
||||
if not look.known and not look.pending:
|
||||
self._unknown_fill(p, path, rect, control, 7.0)
|
||||
if is_cur or look.pending:
|
||||
self._glow(p, path, TAPE)
|
||||
pen = QPen(QColor(TAPE), ROW_SEP * 1.6)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.drawPath(path)
|
||||
elif control == self.hover:
|
||||
glow = QColor(INK)
|
||||
glow.setAlpha(18)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(glow)
|
||||
p.drawPath(path)
|
||||
|
||||
colour = (QColor(TAPE) if (look.pending or is_cur)
|
||||
else (INK if look.known and not look.quiet else INK_DIM))
|
||||
icon_c = QPointF(rect.left() + RULE_X / 2, rect.center().y())
|
||||
if act == "push":
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(colour)
|
||||
p.drawEllipse(icon_c, rect.height() * 0.11, rect.height() * 0.11)
|
||||
else:
|
||||
self._paint_turn_icon(p, icon_c, act == "right", colour)
|
||||
|
||||
p.setPen(QPen(QColor(THEME.line), ROW_SEP))
|
||||
p.drawLine(QPointF(rect.left() + RULE_X, rect.top()),
|
||||
QPointF(rect.left() + RULE_X, rect.bottom()))
|
||||
|
||||
mono = QFont(self.mono)
|
||||
mono.setPixelSize(max(6, round(ROW_BYTE_PT)))
|
||||
mono.setItalic(True)
|
||||
mono.setWeight(QFont.Bold)
|
||||
p.setFont(mono)
|
||||
p.setPen(QColor(THEME.byte_ink or THEME.ink_dim))
|
||||
p.drawText(rect.adjusted(0, 0, -RULE_X * 0.25, 0),
|
||||
Qt.AlignRight | Qt.AlignVCenter,
|
||||
f"0x{core.action_byte(control):02x}")
|
||||
|
||||
f = self._legend_font(round(ROW_PT), roomy=False)
|
||||
p.setFont(f)
|
||||
p.setPen(colour)
|
||||
text = self._lettering(look.text if (look.known or look.pending)
|
||||
else "Unknown")
|
||||
body = rect.adjusted(LABEL_X, 0, -RULE_X * 1.1, 0)
|
||||
p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter,
|
||||
QFontMetricsF(f).elidedText(text, Qt.ElideRight, body.width()))
|
||||
|
||||
@staticmethod
|
||||
def _fit(text, font, rect):
|
||||
"""Elide to at most two wrapped lines."""
|
||||
|
||||
@@ -12,10 +12,46 @@ Most themes only change colours. Two change how the pad is drawn:
|
||||
Nord and Catppuccin use their published palettes, both MIT licensed.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Metrics:
|
||||
"""
|
||||
The pad's proportions, in board units. The view scales the whole board
|
||||
to fit, so only the ratios between these matter.
|
||||
|
||||
Knobs North's values come from its Figma file: each one is the design's
|
||||
measurement divided by the design's 291.67 unit key and multiplied by
|
||||
our 96, so the proportions are exactly the designer's.
|
||||
"""
|
||||
key: float = 96.0 # a key tile, square
|
||||
gap_x: float = 16.0 # between key columns
|
||||
gap_y: float = 16.0 # between key rows
|
||||
margin: float = 30.0 # board edge to anything
|
||||
key_radius: float = 5.0
|
||||
r_out: float = 54.0 # outer reach of a knob's arcs
|
||||
r_in: float = 40.0
|
||||
r_knob: float = 30.0 # the knob body
|
||||
row_h: float = 22.0 # one legend row
|
||||
row_gap: float = 12.0 # knob bottom to first legend row
|
||||
row_sep: float = 0.0 # hairline between rows, 0 for none
|
||||
dial_w: float = 160.0 # legend block width
|
||||
legend_radius: float = 3.0
|
||||
rule_x: float = 0.0 # vertical rule inside the legend block
|
||||
label_x: float = 22.0 # where a legend's text starts
|
||||
byte_pt: float = 10.0 # action byte type size
|
||||
label_pt: float = 13.0 # key legend type size
|
||||
row_pt: float = 12.0 # knob legend type size
|
||||
row_byte_pt: float = 10.0
|
||||
badge: float = 13.0 # the changed / unknown corner flag
|
||||
# Where a key's legend sits, as fractions of the tile: left, top, right,
|
||||
# bottom. Empty keeps the fixed insets that suit a 96 unit key.
|
||||
label_band: tuple = ()
|
||||
byte_inset: tuple = () # same, for the action byte
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Theme:
|
||||
key: str
|
||||
@@ -44,6 +80,24 @@ class Theme:
|
||||
caps: bool = False # legends in capitals, like drawing lettering
|
||||
font: str = "" # legend font; empty means the system font
|
||||
mono_legends: bool = False # draw legends in the monospace font
|
||||
badge: str = "tape" # pending marker: "tape" strip or "star"
|
||||
legend_box: bool = False # a border around each knob's three rows
|
||||
query_badge: bool = False # a "?" in the corner of an unknown key
|
||||
byte_prefix: bool = False # knob rows read 0x13 rather than 13
|
||||
badge_glyph: str = "" # a character stamped in the corner flag
|
||||
byte_ink: str = "" # action byte grey; empty means ink_dim
|
||||
well: str = "" # gradient's far colour; empty means flat
|
||||
glow: float = 0.0 # neon bloom on accent strokes, board units
|
||||
knob_art: str = "" # "figma" draws the outline from vectors.py
|
||||
button_radius: int = 4 # window buttons and fields
|
||||
pill_buttons: bool = False # footer buttons outlined rather than bare
|
||||
outline_primary: bool = False # the write button outlined, not filled
|
||||
ui_pt: int = 0 # base type size; 0 keeps Qt's own
|
||||
title_pt: int = 18 # the inspector's heading
|
||||
mono_status: bool = False # footer status in the mono face
|
||||
metrics: Metrics = field(default_factory=Metrics)
|
||||
ui_font: str = "" # window chrome; empty means the system font
|
||||
mono_font: str = "" # window chrome, monospace parts
|
||||
notes: tuple = field(default=())
|
||||
|
||||
|
||||
@@ -92,15 +146,141 @@ LATTE = Theme(
|
||||
hatch="#ccd0da", accent="#8839ef", accent_text="#eff1f5",
|
||||
good="#40a02b", bad="#d20f39", line="#bcc0cc", field="#dce0e8")
|
||||
|
||||
THEMES = {t.key: t for t in (SILKSCREEN, WHITE_BOARD, BLUEPRINT, SKETCH,
|
||||
NORD, MOCHA, LATTE)}
|
||||
DEFAULT = SILKSCREEN.key
|
||||
# Designed in Figma by a member of the community. The palette is Silkscreen's
|
||||
# own window and board with a hotter orange and neutral rather than warm greys,
|
||||
# so what makes it its own theme is mostly the drawing switches below.
|
||||
# Scale factor from the Figma file: its key tile is 291.67 units, ours is 96.
|
||||
_F = 96.0 / 291.67
|
||||
|
||||
KNOBS_NORTH = Theme(
|
||||
"knobs-north", "Knobs North", True,
|
||||
window="#17191b", board="#202326", ink="#ffffff", ink_dim="#b5b5b5",
|
||||
hatch="#2f2f2f", accent="#ff7700", accent_text="#ffffff",
|
||||
good="#87df9a", bad="#e0584f", line="#6e6e6e", field="#202326",
|
||||
badge="star", badge_glyph="*", legend_box=True, query_badge=True,
|
||||
byte_prefix=True, byte_ink="#505050",
|
||||
# tile and knob gradients, both #202326 falling to #131619 in the file
|
||||
well="#131619", glow=3.2, knob_art="figma",
|
||||
font="Harmattan", mono_font="JetBrains Mono NL", ui_font="Harmattan",
|
||||
button_radius=7, pill_buttons=True, outline_primary=True, ui_pt=15,
|
||||
title_pt=28, mono_status=True,
|
||||
metrics=Metrics(
|
||||
key=96.0,
|
||||
gap_x=88.0 * _F, gap_y=60.58 * _F, margin=74.0 * _F,
|
||||
key_radius=20.0 * _F,
|
||||
r_out=128.58 * _F, r_in=110.0 * _F, r_knob=92.5 * _F,
|
||||
row_h=75.0 * _F, row_gap=40.0 * _F, row_sep=2.0 * _F,
|
||||
dial_w=512.0 * _F, legend_radius=15.0 * _F,
|
||||
rule_x=73.0 * _F, label_x=94.0 * _F,
|
||||
byte_pt=24.0 * _F, label_pt=40.0 * _F,
|
||||
row_pt=40.0 * _F, row_byte_pt=20.0 * _F,
|
||||
badge=64.29 * _F,
|
||||
# The design sets line height to exactly the type size; Qt's is
|
||||
# looser, so the band is taller than the design's 100/300 while
|
||||
# staying centred on the same line.
|
||||
label_band=(38 / 300, 0.18, 37 / 300, 0.18),
|
||||
byte_inset=(19 / 300, 10 / 300)),
|
||||
notes=("Designed in Figma by a member of the community.",
|
||||
"Knobs along the top. Use Rotate view if the drawing is sideways."))
|
||||
|
||||
# --------------------------------------------------------------- palettes
|
||||
#
|
||||
# A palette is colour and nothing else. Anything that decides how the pad is
|
||||
# drawn - proportions, badges, knob art, typefaces - belongs to the style.
|
||||
|
||||
PALETTE_FIELDS = ("window", "board", "ink", "ink_dim", "hatch", "accent",
|
||||
"accent_text", "good", "bad", "line", "field", "well",
|
||||
"byte_ink", "dark")
|
||||
|
||||
|
||||
def _palette(theme, **over):
|
||||
"""Pull a palette out of a theme written the old way."""
|
||||
out = {f: getattr(theme, f) for f in PALETTE_FIELDS}
|
||||
out.update(over)
|
||||
return out
|
||||
|
||||
|
||||
PALETTES = {
|
||||
"graphite": ("Graphite", _palette(SILKSCREEN)),
|
||||
"paper": ("Paper", _palette(WHITE_BOARD)),
|
||||
"nord": ("Nord", _palette(NORD)),
|
||||
"mocha": ("Catppuccin Mocha", _palette(MOCHA)),
|
||||
"latte": ("Catppuccin Latte", _palette(LATTE)),
|
||||
"neon": ("Neon", _palette(KNOBS_NORTH)),
|
||||
"blueprint": ("Blueprint", _palette(BLUEPRINT)),
|
||||
"graph": ("Graph paper", _palette(SKETCH)),
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------- styles
|
||||
#
|
||||
# recolour lists the palettes a style is offered with. Blueprint and Sketch
|
||||
# are left alone: a blueprint that isn't blue, or a pencil sketch that isn't
|
||||
# on paper, is not the same idea.
|
||||
|
||||
OPEN = ("graphite", "paper", "nord", "mocha", "latte", "neon")
|
||||
|
||||
STYLES = {
|
||||
"silkscreen": ("Silkscreen", SILKSCREEN, OPEN),
|
||||
"knobs-north": ("Knobs North", KNOBS_NORTH, OPEN),
|
||||
"blueprint": ("Blueprint", BLUEPRINT, ("blueprint",)),
|
||||
"sketch": ("Hand drawn", SKETCH, ("graph",)),
|
||||
}
|
||||
|
||||
# Keys as they were before styles and palettes were separated, so a saved
|
||||
# setting from an older build still finds its theme.
|
||||
LEGACY = {
|
||||
"silkscreen": ("silkscreen", "graphite"),
|
||||
"white-board": ("silkscreen", "paper"),
|
||||
"nord": ("silkscreen", "nord"),
|
||||
"catppuccin-mocha": ("silkscreen", "mocha"),
|
||||
"catppuccin-latte": ("silkscreen", "latte"),
|
||||
"blueprint": ("blueprint", "blueprint"),
|
||||
"sketch": ("sketch", "graph"),
|
||||
"knobs-north": ("knobs-north", "neon"),
|
||||
}
|
||||
_LEGACY_BY_PAIR = {v: k for k, v in LEGACY.items()}
|
||||
|
||||
|
||||
def compose(style_key, palette_key):
|
||||
"""One style wearing one palette."""
|
||||
style_name, base, _allowed = STYLES[style_key]
|
||||
palette_name, colours = PALETTES[palette_key]
|
||||
key = _LEGACY_BY_PAIR.get((style_key, palette_key),
|
||||
f"{style_key}/{palette_key}")
|
||||
name = (style_name if len(STYLES[style_key][2]) == 1
|
||||
else f"{style_name} / {palette_name}")
|
||||
return replace(base, key=key, name=name, **colours)
|
||||
|
||||
|
||||
THEMES = {}
|
||||
for _s, (_sn, _base, _allowed) in STYLES.items():
|
||||
for _p in _allowed:
|
||||
_t = compose(_s, _p)
|
||||
THEMES[_t.key] = _t
|
||||
|
||||
DEFAULT = "silkscreen"
|
||||
|
||||
|
||||
def parts(key):
|
||||
"""(style, palette) for a theme key, old style or new."""
|
||||
if key in LEGACY:
|
||||
return LEGACY[key]
|
||||
if "/" in key:
|
||||
s, p = key.split("/", 1)
|
||||
if s in STYLES and p in PALETTES:
|
||||
return s, p
|
||||
return LEGACY[DEFAULT]
|
||||
|
||||
FONTS = Path(__file__).resolve().parent / "fonts"
|
||||
|
||||
|
||||
def get(key):
|
||||
return THEMES.get(key, THEMES[DEFAULT])
|
||||
if key in THEMES:
|
||||
return THEMES[key]
|
||||
style, palette = parts(key or DEFAULT)
|
||||
if palette not in STYLES[style][2]:
|
||||
palette = STYLES[style][2][0]
|
||||
return THEMES[compose(style, palette).key]
|
||||
|
||||
|
||||
def mix(a, b, t):
|
||||
|
||||
26
macropad_gui/vectors.py
Normal file
26
macropad_gui/vectors.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Knob arc outline, lifted from Mo-MacroPad.fig (frame "Knob: Written").
|
||||
|
||||
Coordinates are multiples of the knob's radius, measured from its
|
||||
centre, so the shape survives any scaling. The clockwise arc is this
|
||||
same outline mirrored in x.
|
||||
"""
|
||||
|
||||
KNOB_ARC = (
|
||||
("M", (-0.48235, -1.28433)),
|
||||
("C", (-0.47805, -1.30123, -0.49448, -1.31592, -0.51064, -1.30935)),
|
||||
("C", (-0.75489, -1.21012, -0.96658, -1.04405, -1.1212, -0.82982)),
|
||||
("C", (-1.28387, -0.60444, -1.37606, -0.33587, -1.3861, -0.05809)),
|
||||
("C", (-1.39614, 0.21969, -1.32359, 0.49421, -1.17761, 0.73076)),
|
||||
("C", (-1.03163, 0.96731, -0.81879, 1.15525, -0.566, 1.27083)),
|
||||
("L", (-0.35379, 1.23286)),
|
||||
("C", (-0.33893, 1.23021, -0.3313, 1.21352, -0.33901, 1.20054)),
|
||||
("L", (-0.44912, 1.0152)),
|
||||
("C", (-0.65136, 0.92274, -0.82163, 0.77238, -0.93841, 0.58314)),
|
||||
("C", (-1.05519, 0.39391, -1.11324, 0.17429, -1.10521, -0.04793)),
|
||||
("C", (-1.09717, -0.27016, -1.02342, -0.48501, -0.89328, -0.66532)),
|
||||
("C", (-0.7712, -0.83446, -0.60464, -0.96608, -0.41247, -1.04581)),
|
||||
("C", (-0.39636, -1.0525, -0.39361, -1.07455, -0.40792, -1.08452)),
|
||||
("L", (-0.5143, -1.15866)),
|
||||
("L", (-0.48235, -1.28433)),
|
||||
("Z", ()),
|
||||
)
|
||||
Reference in New Issue
Block a user