Confirmed on the MK-09 (8808:660a, 9 keys): the vendor's .NET configurator was decompiled for the wire format and every frame was then written to a real pad and read back. Keys, media keys and multikey sequences are supported; lighting from the command line. The firmware acts on every other output report, so each report is sent twice (as the vendor software does) and every write is verified by reading the key back. The app now keeps one state file per pad and starts with a known layout for a known USB id, and the editor asks the backend whether a binding fits before offering to write it. The actions listener and the udev rule cover every supported vendor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1582 lines
65 KiB
Python
1582 lines
65 KiB
Python
"""
|
|
The window. Open it, change what a control does, write it, close it. The
|
|
pad keeps its bindings in its own memory, so nothing here needs to keep
|
|
running afterwards.
|
|
"""
|
|
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal
|
|
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 __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
|
|
# theme change without anything else having to know.
|
|
GOOD = BAD = LINE = FIELD = ""
|
|
|
|
# Media keys as pairs, so opposites sit side by side.
|
|
MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
|
|
("volumedown", "volumeup"), ("mute", None),
|
|
("brightnessdown", "brightnessup"),
|
|
("calculator", "mail"), ("browser", "mycomputer")]
|
|
|
|
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; font-weight: 850;}}\n'
|
|
f'QLabel[role="message"] {{ font-family: "{t.mono_font}"; '
|
|
f'font-size: 12px; font-weight: 850; }}')
|
|
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: 2px solid {t.accent}; border-radius: {t.button_radius}px;
|
|
font-weight: 600; padding: 2px 5px; }}
|
|
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: 2px solid {t.ink_dim}; border-radius: {t.button_radius}px;
|
|
padding: 2px 5px; }}
|
|
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: {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: {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)}; }}
|
|
{_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: {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; }}
|
|
QFrame[role="banner"] {{ border: 1px solid {t.line}; border-left: 3px solid {t.bad};
|
|
border-radius: 4px; }}
|
|
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;
|
|
padding: 4px 6px; }}
|
|
QCheckBox {{ spacing: 8px; }}
|
|
QCheckBox::indicator {{ width: 14px; height: 14px; border: 1px solid {t.ink_dim};
|
|
border-radius: 3px; background: {t.field}; }}
|
|
QCheckBox::indicator:checked {{ background: {t.accent}; border-color: {t.accent}; }}
|
|
QComboBox {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px;
|
|
padding: 6px 8px; }}
|
|
QMenu {{ background: {t.field}; border: 1px solid {t.line}; padding: 4px; }}
|
|
QMenu::item {{ padding: 6px 18px; }}
|
|
QMenu::item:selected {{ background: {tint(t.field, t.accent, 0.25)}; }}
|
|
|
|
QScrollBar:vertical {{
|
|
background: #00000000;
|
|
width: 12px;
|
|
margin: 0px 0px;
|
|
}}
|
|
QScrollBar::handle:vertical {{
|
|
background: {t.hatch};
|
|
border-radius: 6px;
|
|
margin: 3px 0px;
|
|
}}
|
|
QScrollBar::add-line:vertical {{
|
|
background: #00000000;
|
|
height: 0px;
|
|
subcontrol-position: bottom;
|
|
subcontrol-origin: margin;
|
|
}}
|
|
QScrollBar::sub-line:vertical {{
|
|
background: #00000000;
|
|
height: 0px;
|
|
subcontrol-position: top;
|
|
subcontrol-origin: margin;
|
|
}}
|
|
|
|
"""
|
|
|
|
|
|
def _label(text="", role=None, wrap=False):
|
|
lab = QLabel(text)
|
|
lab.setIndent(0)
|
|
if role:
|
|
lab.setProperty("role", role)
|
|
lab.setWordWrap(wrap)
|
|
return lab
|
|
|
|
|
|
def _button(text, role=None, checkable=False):
|
|
b = QPushButton(text)
|
|
if role:
|
|
b.setProperty("role", role)
|
|
b.setCheckable(checkable)
|
|
b.setCursor(Qt.PointingHandCursor)
|
|
return b
|
|
|
|
|
|
# ------------------------------------------------------------------ recorder
|
|
|
|
class RecorderEdit(QLineEdit):
|
|
"""
|
|
A text field that can also listen for a key combo. Type 'ctrl+shift+n',
|
|
or press Record and then press the combo itself.
|
|
"""
|
|
recorded = Signal(str)
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.recording = False
|
|
self.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
|
|
|
|
def set_recording(self, on):
|
|
self.recording = on
|
|
self.setProperty("recording", "true" if on else "false")
|
|
self.style().unpolish(self)
|
|
self.style().polish(self)
|
|
if on:
|
|
self._before = self.text()
|
|
self.clear()
|
|
self.setPlaceholderText("Press the combo now")
|
|
self.setFocus()
|
|
else:
|
|
self.setPlaceholderText("ctrl+shift+n")
|
|
|
|
def event(self, e):
|
|
# Catch Tab and friends too, which would otherwise move focus.
|
|
if self.recording and e.type() == QEvent.KeyPress:
|
|
self._record(e)
|
|
return True
|
|
if self.recording and e.type() == QEvent.ShortcutOverride:
|
|
e.accept()
|
|
return True
|
|
return super().event(e)
|
|
|
|
def _record(self, e):
|
|
mods = keymap.modifier_names(e)
|
|
if e.key() in keymap.MODIFIER_KEYS:
|
|
self.setText("+".join(mods + ["…"]))
|
|
return
|
|
name = keymap.key_name(e)
|
|
if name is None:
|
|
self.setText("")
|
|
self.setPlaceholderText("The pad can't send that key. Try another.")
|
|
return
|
|
combo = "+".join(mods + [name])
|
|
self.set_recording(False)
|
|
self.setText(combo)
|
|
self.recorded.emit(combo)
|
|
|
|
def focusOutEvent(self, e):
|
|
if self.recording:
|
|
self.set_recording(False)
|
|
self.setText(self._before)
|
|
super().focusOutEvent(e)
|
|
|
|
|
|
# ------------------------------------------------------------------ inspector
|
|
|
|
class Inspector(QWidget):
|
|
"""Right-hand panel: what the selected control does, and what to change it to."""
|
|
changed = Signal(str, object) # control, Binding or None (revert)
|
|
write = Signal(str)
|
|
revert = Signal(str)
|
|
action = Signal() # the banner's button
|
|
host_action = Signal(str, object, object) # control, Action or None, trigger
|
|
test_action = Signal(object)
|
|
background = Signal(bool)
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setFixedWidth(INSPECTOR_W)
|
|
#self.setFixedHeight(800) # This initial fix to stop window resizing caused issues with the new implementation of scroll bars. -Oaken
|
|
self.control = None
|
|
self._loading = False
|
|
self.editing_layout = False
|
|
self.backend = None # the family of the pad plugged in, if any
|
|
|
|
v = QVBoxLayout(self)
|
|
v.setContentsMargins(24, 24, 24, 16)
|
|
v.setSpacing(4)
|
|
|
|
# problem banner (no pad / no permission)
|
|
self.banner = QFrame()
|
|
self.banner.setProperty("role", "banner")
|
|
bl = QVBoxLayout(self.banner)
|
|
bl.setContentsMargins(12, 10, 12, 10)
|
|
self.banner_head = _label("", wrap=True)
|
|
self.banner_head.setStyleSheet("font-weight: 600;")
|
|
self.banner_text = _label("", "dim", wrap=True)
|
|
self.banner_fix = QPlainTextEdit()
|
|
self.banner_fix.setReadOnly(True)
|
|
self.banner_fix.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
|
|
self.banner_fix.setFixedHeight(84)
|
|
self.banner_fix.setLineWrapMode(QPlainTextEdit.NoWrap)
|
|
self.banner_copy = _button("Copy commands")
|
|
self.banner_copy.clicked.connect(self._copy)
|
|
self.banner_action = _button("")
|
|
self.banner_action.clicked.connect(lambda: self.action.emit())
|
|
for w in (self.banner_head, self.banner_text, self.banner_fix,
|
|
self.banner_copy, self.banner_action):
|
|
bl.addWidget(w)
|
|
self.banner_copy.setSizePolicy(self.banner_copy.sizePolicy().horizontalPolicy(),
|
|
self.banner_copy.sizePolicy().verticalPolicy())
|
|
v.addWidget(self.banner)
|
|
v.addSpacing(8)
|
|
|
|
# empty state
|
|
self.empty = _label("", "dim", wrap=True)
|
|
v.addWidget(self.empty)
|
|
|
|
# editor
|
|
self.editor = QWidget()
|
|
ev = QVBoxLayout(self.editor)
|
|
ev.setContentsMargins(0, 0, 0, 0)
|
|
ev.setSpacing(4)
|
|
self.title = _label("", "title")
|
|
self.sub = _label("", "dim")
|
|
ev.addWidget(self.title)
|
|
ev.addWidget(self.sub)
|
|
|
|
ev.addWidget(_label("On the pad now", "caption"))
|
|
self.onpad = _label("", "value", wrap=True)
|
|
self.onpad_why = _label("", "dim", wrap=True)
|
|
ev.addWidget(self.onpad)
|
|
ev.addWidget(self.onpad_why)
|
|
|
|
ev.addWidget(_label("Change it to", "caption"))
|
|
modes = QHBoxLayout()
|
|
modes.setSpacing(6)
|
|
self.mode_keys = _button("Shortcut", checkable=True)
|
|
self.mode_media = _button("Media key", checkable=True)
|
|
self.mode_none = _button("Nothing", checkable=True)
|
|
self.mode_none.setToolTip("Make this control do nothing at all")
|
|
self.mode_action = _button("Action", checkable=True)
|
|
self.mode_action.setToolTip("Open a web page, an app, or run a command")
|
|
self.mode_group = QButtonGroup(self)
|
|
self.mode_group.setExclusive(True)
|
|
for i, b in enumerate((self.mode_keys, self.mode_media, self.mode_none,
|
|
self.mode_action)):
|
|
self.mode_group.addButton(b, i)
|
|
b.setStyleSheet("padding: 6px 8px;")
|
|
modes.addWidget(b, 1)
|
|
ev.addLayout(modes)
|
|
ev.addSpacing(6)
|
|
|
|
# shortcut page
|
|
kp = QWidget()
|
|
kl = QVBoxLayout(kp)
|
|
kl.setContentsMargins(0, 0, 0, 0)
|
|
row = QHBoxLayout()
|
|
self.keys_edit = RecorderEdit()
|
|
self.keys_edit.setPlaceholderText("ctrl+shift+n")
|
|
self.record = _button("Record", checkable=True)
|
|
row.addWidget(self.keys_edit, 1)
|
|
row.addWidget(self.record)
|
|
kl.addLayout(row)
|
|
self.keys_hint = _label("", "dim", wrap=True)
|
|
kl.addWidget(self.keys_hint)
|
|
# media page
|
|
mp = QWidget()
|
|
ml = QGridLayout(mp)
|
|
ml.setContentsMargins(0, 0, 0, 0)
|
|
ml.setSpacing(6)
|
|
self.media_group = QButtonGroup(self)
|
|
self.media_group.setExclusive(True)
|
|
self.media_buttons = {}
|
|
for r, pair in enumerate(MEDIA_GRID):
|
|
for col, mid in enumerate(pair):
|
|
if mid is None:
|
|
continue
|
|
b = _button(core.MEDIA_LABEL[mid], "media", checkable=True)
|
|
self.media_group.addButton(b, [m for m, _ in core.MEDIA].index(mid))
|
|
self.media_buttons[mid] = b
|
|
ml.addWidget(b, r, col)
|
|
np_ = _label("Pressing or turning it will do nothing. Handy for a key "
|
|
"you keep hitting by accident.", "dim", wrap=True)
|
|
# action page
|
|
ap = QWidget()
|
|
al = QVBoxLayout(ap)
|
|
al.setContentsMargins(0, 0, 0, 0)
|
|
al.setSpacing(6)
|
|
kinds = QHBoxLayout()
|
|
kinds.setSpacing(6)
|
|
self.kind_group = QButtonGroup(self)
|
|
self.kind_group.setExclusive(True)
|
|
self.kind_buttons = {}
|
|
for i, (kind, label) in enumerate((("url", "Web page"), ("app", "App"),
|
|
("command", "Command"), ("text", "Text"))):
|
|
b = _button(label, "media", checkable=True)
|
|
self.kind_group.addButton(b, i)
|
|
self.kind_buttons[kind] = b
|
|
kinds.addWidget(b)
|
|
al.addLayout(kinds)
|
|
self.action_edit = QLineEdit()
|
|
self.app_pick = QComboBox()
|
|
self.app_pick.setMaxVisibleItems(18)
|
|
self._apps_loaded = False
|
|
al.addWidget(self.action_edit)
|
|
al.addWidget(self.app_pick)
|
|
self.text_edit = QPlainTextEdit()
|
|
self.text_edit.setPlaceholderText("Kind regards,\nProbler")
|
|
self.text_edit.setFixedHeight(84)
|
|
self.paste_terminal = QCheckBox("Paste with Ctrl+Shift+V, for terminals")
|
|
al.addWidget(self.text_edit)
|
|
al.addWidget(self.paste_terminal)
|
|
fix = QHBoxLayout()
|
|
self.text_fix = _label("", "dim", wrap=True)
|
|
self.text_fix_btn = _button("Copy fix")
|
|
self.text_fix_btn.setToolTip("Copies the commands; paste them into a terminal")
|
|
fix.addWidget(self.text_fix, 1)
|
|
fix.addWidget(self.text_fix_btn, 0, Qt.AlignTop)
|
|
al.addLayout(fix)
|
|
self._text_fix_cmds = []
|
|
sends = QHBoxLayout()
|
|
sends.addWidget(_label("Sends", "dim"))
|
|
self.trigger_pick = QComboBox()
|
|
self.trigger_pick.setToolTip(
|
|
"The spare key this control sends to your computer. Pick another "
|
|
"if this one does something on your desktop already.")
|
|
sends.addWidget(self.trigger_pick, 1)
|
|
al.addLayout(sends)
|
|
self.trigger_note = _label("", "dim", wrap=True)
|
|
al.addWidget(self.trigger_note)
|
|
row = QHBoxLayout()
|
|
self.action_hint = _label("", "dim", wrap=True)
|
|
self.test_btn = _button("Test")
|
|
self.test_btn.setToolTip("Run it now, on this computer")
|
|
row.addWidget(self.action_hint, 1)
|
|
row.addWidget(self.test_btn, 0, Qt.AlignTop)
|
|
al.addLayout(row)
|
|
self.bg_check = QCheckBox("Run actions in the background")
|
|
self.bg_check.setToolTip("Starts a small listener now and whenever you log in")
|
|
self.bg_status = _label("", "dim", wrap=True)
|
|
al.addSpacing(6)
|
|
al.addWidget(self.bg_check)
|
|
al.addWidget(self.bg_status)
|
|
|
|
self.pages = (kp, mp, np_, ap)
|
|
ev.addWidget(kp)
|
|
ev.addWidget(mp)
|
|
ev.addWidget(np_)
|
|
ev.addWidget(ap)
|
|
|
|
ev.addSpacing(10)
|
|
actions = QHBoxLayout()
|
|
self.write_btn = _button("Write to pad", "primary")
|
|
self.revert_btn = _button("Revert", "flat")
|
|
actions.addWidget(self.write_btn)
|
|
actions.addWidget(self.revert_btn)
|
|
actions.addStretch()
|
|
ev.addLayout(actions)
|
|
v.addWidget(self.editor)
|
|
v.addStretch()
|
|
|
|
self.mode_group.idClicked.connect(self._mode)
|
|
self.keys_edit.textEdited.connect(self._keys_typed)
|
|
self.keys_edit.recorded.connect(self._keys_typed)
|
|
self.record.toggled.connect(self._toggle_record)
|
|
self.media_group.idClicked.connect(self._media_picked)
|
|
self.kind_group.idClicked.connect(self._kind_picked)
|
|
self.action_edit.textEdited.connect(lambda _: self._action_edited())
|
|
self.app_pick.activated.connect(lambda _: self._action_edited())
|
|
self.trigger_pick.activated.connect(lambda _: self._action_edited())
|
|
self.text_edit.textChanged.connect(self._action_edited)
|
|
self.paste_terminal.toggled.connect(lambda _: self._action_edited())
|
|
self.text_fix_btn.clicked.connect(self._copy_text_fix)
|
|
self.test_btn.clicked.connect(self._test)
|
|
self.bg_check.toggled.connect(lambda on: None if self._loading
|
|
else self.background.emit(on))
|
|
self.write_btn.clicked.connect(lambda: self.write.emit(self.control))
|
|
self.revert_btn.clicked.connect(lambda: self.revert.emit(self.control))
|
|
|
|
self.show_control(None, None, None, False)
|
|
|
|
# -------------------------------------------------------------- banner
|
|
|
|
def show_problem(self, diag, note=None, offer_detect=False):
|
|
if diag is None or diag.ready:
|
|
if not note:
|
|
self.banner.hide()
|
|
return
|
|
diag = core.Diagnosis("note", "Heads up", detail=[note])
|
|
quiet = diag.status in ("unplugged", "note", "unsupported")
|
|
self.banner.setProperty("tone", "quiet" if quiet else "")
|
|
self.banner.style().unpolish(self.banner)
|
|
self.banner.style().polish(self.banner)
|
|
self.banner_head.setText(diag.headline)
|
|
self.banner_text.setText("\n\n".join(diag.detail))
|
|
cmds = [c for c in diag.fix if not c.startswith("#")]
|
|
self.banner_fix.setPlainText("\n".join(cmds))
|
|
lh = self.banner_fix.fontMetrics().lineSpacing()
|
|
self.banner_fix.setFixedHeight(lh * max(1, len(cmds)) + 30)
|
|
self.banner_fix.setVisible(bool(cmds))
|
|
self.banner_copy.setVisible(bool(cmds))
|
|
self.banner_action.setVisible(diag.status == "unsupported" or offer_detect)
|
|
self.banner_action.setText("Ask the pad what it is")
|
|
if diag.fix and diag.fix[-1].startswith("#"):
|
|
self.banner_text.setText(self.banner_text.text() + "\n\nThen unplug the pad and plug it back in.")
|
|
self.banner.show()
|
|
|
|
def _copy(self):
|
|
QGuiApplication.clipboard().setText(self.banner_fix.toPlainText())
|
|
self.banner_copy.setText("Copied")
|
|
QTimer.singleShot(1500, lambda: self.banner_copy.setText("Copy commands"))
|
|
|
|
# -------------------------------------------------------------- editor
|
|
|
|
def show_control(self, control, entry, desired, can_write, all_unknown=False,
|
|
action=None, onpad_action=None, pending=None, bg=None,
|
|
triggers=None, trigger=None):
|
|
self._loading = True
|
|
self.control = control
|
|
if control is None:
|
|
self.editor.hide()
|
|
self.empty.setText(
|
|
("Drag the keys around until the drawing matches your pad, "
|
|
"then press Done.\n\nUse the boxes below to change the "
|
|
"grid or the number of keys and knobs. This only changes "
|
|
"the picture, never the pad."
|
|
if self.editing_layout else
|
|
"Click a key or dial to change what it does.\n\n")
|
|
+ ("Hatched controls are ones this app hasn't been told about. "
|
|
"Press Read pad to ask the pad itself what it's holding."
|
|
if all_unknown and not self.editing_layout else ""))
|
|
self.empty.show()
|
|
self._loading = False
|
|
return
|
|
self.empty.hide()
|
|
self.editor.show()
|
|
self.title.setText(core.control_name(control))
|
|
self.sub.setText(f"Action byte 0x{core.action_byte(control):02x}")
|
|
|
|
if entry.unknown:
|
|
self.onpad.setText("Unknown")
|
|
self.onpad.setStyleSheet(f"color: {INK_DIM.name()};")
|
|
self.onpad_why.setText(entry.reason[:1].upper() + entry.reason[1:] + ".")
|
|
self.onpad_why.show()
|
|
else:
|
|
trig = act.trigger_of(entry.binding)
|
|
if onpad_action and trig:
|
|
self.onpad.setText(onpad_action.label())
|
|
self.onpad_why.setText(f"The pad sends {trig.upper()}, and this "
|
|
"computer does the rest.")
|
|
self.onpad_why.show()
|
|
else:
|
|
self.onpad.setText(entry.binding.label())
|
|
self.onpad_why.hide()
|
|
self.onpad.setStyleSheet("")
|
|
|
|
shown = desired or (None if entry.unknown else entry.binding)
|
|
self.show_background(bg)
|
|
self._clear_action()
|
|
self.set_triggers(triggers or [], trigger)
|
|
if action is not None:
|
|
self.mode_action.setChecked(True)
|
|
self._page(3)
|
|
self._show_action(action)
|
|
self.keys_edit.clear()
|
|
self._clear_media()
|
|
elif shown is not None and shown.none:
|
|
self.mode_none.setChecked(True)
|
|
self._page(2)
|
|
self.keys_edit.clear()
|
|
self._clear_media()
|
|
elif shown is not None and shown.media:
|
|
self.mode_media.setChecked(True)
|
|
self._page(1)
|
|
self.media_buttons[shown.media].setChecked(True)
|
|
self.keys_edit.clear()
|
|
else:
|
|
self.mode_keys.setChecked(True)
|
|
self._page(0)
|
|
self.keys_edit.setText(shown.keys if shown else "")
|
|
self._clear_media()
|
|
self.keys_hint.setText("")
|
|
self.update_buttons(desired is not None if pending is None else pending,
|
|
can_write)
|
|
self._loading = False
|
|
|
|
def update_buttons(self, pending, can_write):
|
|
self.write_btn.setEnabled(can_write and pending)
|
|
self.revert_btn.setVisible(pending)
|
|
|
|
def _clear_media(self):
|
|
self.media_group.setExclusive(False)
|
|
for b in self.media_buttons.values():
|
|
b.setChecked(False)
|
|
self.media_group.setExclusive(True)
|
|
|
|
def _page(self, i):
|
|
# Plain show/hide rather than QStackedWidget, which always reserves
|
|
# the height of its tallest page.
|
|
for j, page in enumerate(self.pages):
|
|
page.setVisible(j == i)
|
|
|
|
def _mode(self, i):
|
|
self._page(i)
|
|
if i == 2 and not self._loading and self.control is not None:
|
|
self.changed.emit(self.control, core.Binding(none=True))
|
|
if i == 3 and not self._loading:
|
|
if not self.kind_group.checkedButton():
|
|
self.kind_buttons["url"].setChecked(True)
|
|
self._kind_picked(0)
|
|
self.action_edit.setFocus()
|
|
if i == 0 and self.record.isChecked():
|
|
self.keys_edit.setFocus()
|
|
|
|
def _toggle_record(self, on):
|
|
self.keys_edit.set_recording(on)
|
|
self.keys_hint.setText(
|
|
"Plasma keeps some combos for itself, like Meta shortcuts, so the "
|
|
"app never sees them. Type those instead." if on else "")
|
|
|
|
def _keys_typed(self, text):
|
|
if self.record.isChecked() and not self.keys_edit.recording:
|
|
self.record.setChecked(False)
|
|
if self._loading or self.control is None:
|
|
return
|
|
text = core.normalize_keys(text)
|
|
if not text:
|
|
self.keys_hint.setText("")
|
|
self.changed.emit(self.control, None)
|
|
return
|
|
try:
|
|
b = core.Binding(keys=text).validate()
|
|
except ValueError as e:
|
|
self.keys_hint.setText(f"<span style='color:{BAD}'>{e}</span>")
|
|
self.changed.emit(self.control, None)
|
|
return
|
|
sequences_ok = (self.backend.sequences if self.backend is not None
|
|
else core.SEQUENCES_VERIFIED)
|
|
if b.is_sequence and not sequences_ok:
|
|
self.keys_hint.setText(
|
|
f"<span style='color:{BAD}'>Sequences aren't verified on this "
|
|
"pad yet, so they're switched off for now. One shortcut per "
|
|
"control.</span>")
|
|
self.changed.emit(self.control, None)
|
|
return
|
|
why = self.backend.check(b) if self.backend is not None else None
|
|
if why:
|
|
self.keys_hint.setText(f"<span style='color:{BAD}'>{why}</span>")
|
|
self.changed.emit(self.control, None)
|
|
return
|
|
self.keys_hint.setText(f"Sends {b.label()}")
|
|
self.changed.emit(self.control, b)
|
|
|
|
# ------------------------------------------------------------ actions
|
|
|
|
_PLACEHOLDER = {"url": "github.com", "command": "~/bin/switch-audio.sh"}
|
|
|
|
def _kind(self):
|
|
b = self.kind_group.checkedButton()
|
|
return next((k for k, btn in self.kind_buttons.items() if btn is b), "url")
|
|
|
|
def set_triggers(self, options, current):
|
|
"""
|
|
options: [(trigger, used_by or None)]. Taken keys are shown but can't
|
|
be picked; keys the desktop is known to act on carry a warning.
|
|
"""
|
|
was, self._loading = self._loading, True
|
|
self.trigger_pick.clear()
|
|
self.trigger_pick.addItem("Choose automatically", None)
|
|
model = self.trigger_pick.model()
|
|
for trig, used_by in options:
|
|
text = trig.upper()
|
|
if used_by:
|
|
text += f" used by {used_by}"
|
|
elif trig in act.CLAIMED:
|
|
text += f" {act.CLAIMED[trig]}"
|
|
self.trigger_pick.addItem(text, trig)
|
|
if used_by:
|
|
model.item(self.trigger_pick.count() - 1).setEnabled(False)
|
|
i = self.trigger_pick.findData(current) if current else 0
|
|
self.trigger_pick.setCurrentIndex(max(i, 0))
|
|
self._loading = was
|
|
self._note_trigger(current)
|
|
|
|
def set_trigger(self, trigger):
|
|
"""Show which key was picked, without it counting as an edit."""
|
|
was, self._loading = self._loading, True
|
|
i = self.trigger_pick.findData(trigger)
|
|
if i >= 0:
|
|
self.trigger_pick.setCurrentIndex(i)
|
|
self._loading = was
|
|
self._note_trigger(trigger)
|
|
|
|
def _note_trigger(self, trigger):
|
|
if trigger in act.CLAIMED:
|
|
self.trigger_note.setText(
|
|
f"<span style='color:{TAPE.name()}'>{trigger.upper()} {act.CLAIMED[trigger]} "
|
|
"on many desktops, so it may do that as well. Pick another if it "
|
|
"does.</span>")
|
|
else:
|
|
self.trigger_note.setText("")
|
|
|
|
def _clear_action(self):
|
|
"""Nothing carries over from the last control you looked at."""
|
|
self.action_edit.clear()
|
|
self.text_edit.clear()
|
|
self.paste_terminal.setChecked(False)
|
|
if self.app_pick.count():
|
|
self.app_pick.setCurrentIndex(0)
|
|
self.action_hint.setText("")
|
|
self.test_btn.setEnabled(False)
|
|
self._last_kind = None
|
|
|
|
def _kind_picked(self, _i):
|
|
kind = self._kind()
|
|
# a URL isn't a command and vice versa, so switching kind starts afresh
|
|
if not self._loading and getattr(self, "_last_kind", None) not in (None, kind):
|
|
self.action_edit.clear()
|
|
self.text_edit.clear()
|
|
self._last_kind = kind
|
|
is_app, is_text = kind == "app", kind == "text"
|
|
self.app_pick.setVisible(is_app)
|
|
self.action_edit.setVisible(not is_app and not is_text)
|
|
self.text_edit.setVisible(is_text)
|
|
self.paste_terminal.setVisible(is_text)
|
|
self._show_text_problems(is_text)
|
|
if is_app:
|
|
self._load_apps()
|
|
elif is_text:
|
|
pass
|
|
else:
|
|
self.action_edit.setPlaceholderText(self._PLACEHOLDER[kind])
|
|
self.action_edit.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
|
if kind == "command" else self.font())
|
|
if not self._loading:
|
|
self._action_edited()
|
|
|
|
def _load_apps(self):
|
|
if self._apps_loaded:
|
|
return
|
|
self.app_pick.clear()
|
|
self.app_pick.addItem("Pick an app...", None)
|
|
for name, command in act.installed_apps():
|
|
self.app_pick.addItem(name, (name, command))
|
|
self._apps_loaded = True
|
|
|
|
def _current_action(self):
|
|
kind = self._kind()
|
|
if kind == "text":
|
|
text = self.text_edit.toPlainText()
|
|
paste = "ctrl+shift+v" if self.paste_terminal.isChecked() else ""
|
|
return act.Action("text", text, paste=paste) if text.strip() else None
|
|
if kind == "app":
|
|
data = self.app_pick.currentData()
|
|
return act.Action("app", data[1], data[0]) if data else None
|
|
text = self.action_edit.text().strip()
|
|
return act.Action(kind, text) if text else None
|
|
|
|
def _show_action(self, action):
|
|
self.kind_buttons[action.kind].setChecked(True)
|
|
self._kind_picked(0)
|
|
if action.kind == "text":
|
|
self.text_edit.setPlainText(action.target)
|
|
self.paste_terminal.setChecked(action.paste == "ctrl+shift+v")
|
|
elif action.kind == "app":
|
|
self._load_apps()
|
|
i = self.app_pick.findData((action.name, action.target))
|
|
if i < 0: # not installed any more
|
|
self.app_pick.addItem(action.label(), (action.name, action.target))
|
|
i = self.app_pick.count() - 1
|
|
self.app_pick.setCurrentIndex(i)
|
|
else:
|
|
self.action_edit.setText(action.target)
|
|
self.action_hint.setText(self._describe(action))
|
|
self.test_btn.setEnabled(True)
|
|
|
|
@staticmethod
|
|
def _describe(action):
|
|
return {"url": f"Opens {action.url}",
|
|
"app": f"Starts {action.label()}",
|
|
"command": "Runs this in a shell, as you",
|
|
"text": "Types it wherever your cursor is"}[action.kind]
|
|
|
|
def _action_edited(self):
|
|
if self._loading or self.control is None:
|
|
return
|
|
action = self._current_action()
|
|
want = self.trigger_pick.currentData()
|
|
if action is None:
|
|
self.action_hint.setText("")
|
|
self.test_btn.setEnabled(False)
|
|
self.host_action.emit(self.control, None, None)
|
|
return
|
|
try:
|
|
action.validate()
|
|
except ValueError as e:
|
|
self.action_hint.setText(f"<span style='color:{BAD}'>{e}</span>")
|
|
self.test_btn.setEnabled(False)
|
|
self.host_action.emit(self.control, None, None)
|
|
return
|
|
self.action_hint.setText(self._describe(action))
|
|
self.test_btn.setEnabled(True)
|
|
self.host_action.emit(self.control, action, want)
|
|
|
|
def _show_text_problems(self, visible):
|
|
problems = act.text_problems() if visible else []
|
|
self._text_fix_cmds = [c for _, cmds in problems for c in cmds
|
|
if not c.startswith("#")]
|
|
if problems:
|
|
why = " ".join(w for w, _ in problems)
|
|
self.text_fix.setText(f"<span style='color:{TAPE.name()}'>{why}</span> "
|
|
"Copy the fix, paste it into a terminal, then "
|
|
"come back.")
|
|
else:
|
|
self.text_fix.setText("")
|
|
self.text_fix.setVisible(bool(problems))
|
|
self.text_fix_btn.setVisible(bool(self._text_fix_cmds))
|
|
|
|
def _copy_text_fix(self):
|
|
QGuiApplication.clipboard().setText("\n".join(self._text_fix_cmds))
|
|
self.text_fix_btn.setText("Copied")
|
|
QTimer.singleShot(1500, lambda: self.text_fix_btn.setText("Copy fix"))
|
|
|
|
def _test(self):
|
|
action = self._current_action()
|
|
if action:
|
|
self.test_action.emit(action)
|
|
|
|
def show_background(self, running):
|
|
"""running: None unknown, False off, True on."""
|
|
if running is None:
|
|
return
|
|
self._loading, was = True, self._loading
|
|
self.bg_check.setChecked(act.autostart_enabled())
|
|
self._loading = was
|
|
if running:
|
|
self.bg_status.setText("Running. Action keys work, and it starts "
|
|
"again when you log in.")
|
|
elif act.autostart_enabled():
|
|
self.bg_status.setText("Set to start at login, but not running "
|
|
"right now. Untick and tick to start it.")
|
|
else:
|
|
self.bg_status.setText("Off. Action keys send F13 to F24 and nothing "
|
|
"happens until this is on. Everything else "
|
|
"on the pad works as normal.")
|
|
|
|
def _media_picked(self, i):
|
|
if self._loading or self.control is None:
|
|
return
|
|
self.changed.emit(self.control, core.Binding(media=core.MEDIA[i][0]))
|
|
|
|
|
|
# ------------------------------------------------------------------ window
|
|
|
|
class Window(QMainWindow):
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("MacroPad")
|
|
self.settings = QSettings("macropad", "macropad")
|
|
self.state = core.State()
|
|
self.desired = {} # control -> Binding, unwritten edits
|
|
self.actions = act.ActionStore() # trigger key -> host action
|
|
self.pending_actions = {} # control -> act.Action, unsaved
|
|
self.diag = None
|
|
# Pads the user has identified by asking them. Remembered so the app
|
|
# doesn't go back to calling them unsupported on the next check.
|
|
self.adopted = set(self.settings.value("adopted", []) or [])
|
|
# What the pad says its shape is, asked once per pad. A USB id does
|
|
# not pin down a layout: 1189:8840 covers the 12 key pad this was
|
|
# built on and a 15 key, 3 knob sibling.
|
|
self.reported = None # (keys, knobs) or None
|
|
self.shape_checked = set()
|
|
self.shape_note = ""
|
|
|
|
root = QWidget()
|
|
outer = QVBoxLayout(root)
|
|
outer.setContentsMargins(0, 0, 0, 0)
|
|
outer.setSpacing(0)
|
|
|
|
body = QHBoxLayout()
|
|
body.setSpacing(0)
|
|
self.pad = PadView()
|
|
self.pad.set_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.panel)
|
|
outer.addLayout(body, 1)
|
|
|
|
# editor bar, hidden unless you're rearranging the pad
|
|
self.editbar = QFrame()
|
|
self.editbar.setProperty("role", "editbar")
|
|
eb = QHBoxLayout(self.editbar)
|
|
eb.setContentsMargins(16, 8, 16, 8)
|
|
eb.setSpacing(8)
|
|
eb.addWidget(_label("Drag the keys to match your pad.", "dim"))
|
|
eb.addSpacing(12)
|
|
self.spin = {}
|
|
for name, label, lo, hi in (("rows", "Rows", 1, 15),
|
|
("cols", "Columns", 1, 15),
|
|
("keys", "Keys", 1, 15),
|
|
("knobs", "Knobs", 0, 3)):
|
|
eb.addWidget(_label(label, "dim"))
|
|
box = QSpinBox()
|
|
box.setRange(lo, hi)
|
|
box.setFixedWidth(56)
|
|
box.valueChanged.connect(self._resize_layout)
|
|
self.spin[name] = box
|
|
eb.addWidget(box)
|
|
eb.addSpacing(12)
|
|
self.knob_side_btn = _button("Knobs: right", "flat")
|
|
self.knob_side_btn.setToolTip(
|
|
"Which side of the keys your knobs are on")
|
|
self.knob_side_btn.clicked.connect(self._flip_knob_side)
|
|
eb.addWidget(self.knob_side_btn)
|
|
eb.addStretch()
|
|
self.edit_done = _button("Done", "primary")
|
|
self.edit_cancel = _button("Cancel", "flat")
|
|
eb.addWidget(self.edit_cancel)
|
|
eb.addWidget(self.edit_done)
|
|
self.editbar.hide()
|
|
outer.addWidget(self.editbar)
|
|
|
|
footer = QFrame()
|
|
footer.setProperty("role", "footer")
|
|
fl = QHBoxLayout(footer)
|
|
fl.setContentsMargins(16, 8, 16, 8)
|
|
self.dot = QLabel()
|
|
self.dot.setFixedSize(9, 9)
|
|
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.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)
|
|
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(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 pad", "flat")
|
|
self.import_btn = _button("Import capture…", "flat")
|
|
self.write_all = _button("", "primary")
|
|
fl.addWidget(self.dot)
|
|
fl.addWidget(self.status)
|
|
fl.addSpacing(16)
|
|
fl.addWidget(self.message, 1)
|
|
fl.addWidget(self.edit_btn)
|
|
fl.addWidget(self.read_btn)
|
|
fl.addWidget(self.theme_btn)
|
|
fl.addWidget(self.rotate)
|
|
fl.addWidget(self.import_btn)
|
|
fl.addWidget(self.write_all)
|
|
outer.addWidget(footer)
|
|
self.setCentralWidget(root)
|
|
|
|
self.pad.selected.connect(self._select)
|
|
self.inspector.changed.connect(self._changed)
|
|
self.inspector.revert.connect(self._revert)
|
|
self.inspector.action.connect(self._identify)
|
|
self.inspector.host_action.connect(self._host_action)
|
|
self.inspector.test_action.connect(self._test_action)
|
|
self.inspector.background.connect(self._background)
|
|
self.inspector.write.connect(lambda c: self._write([c]))
|
|
self.write_all.clicked.connect(lambda: self._write(self._pending()))
|
|
self.edit_btn.clicked.connect(lambda: self._edit_layout(True))
|
|
self.edit_done.clicked.connect(lambda: self._edit_layout(False, keep=True))
|
|
self.edit_cancel.clicked.connect(lambda: self._edit_layout(False, keep=False))
|
|
self.pad.rearranged.connect(self._rearranged)
|
|
self.read_btn.clicked.connect(lambda: self._read(quiet=False))
|
|
self.rotate.clicked.connect(self._rotate)
|
|
self.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()
|
|
|
|
self.timer = QTimer(self)
|
|
self.timer.timeout.connect(self._poll)
|
|
self.timer.start(2000)
|
|
self._poll()
|
|
self._refresh()
|
|
# The pad can tell us what it holds, so ask rather than assume.
|
|
if self.diag and self.diag.ready:
|
|
QTimer.singleShot(0, lambda: self._read(quiet=True))
|
|
|
|
# ------------------------------------------------------------- model
|
|
|
|
def _binding_now(self, c):
|
|
"""What the control sends once pending edits are written."""
|
|
if c in self.desired:
|
|
return self.desired[c]
|
|
e = self.state.get(c)
|
|
return None if e.unknown else e.binding
|
|
|
|
def _stored_action(self, binding):
|
|
trig = act.trigger_of(binding)
|
|
return self.actions.get(trig) if trig else None
|
|
|
|
def _action_for(self, c):
|
|
return self.pending_actions.get(c) or self._stored_action(self._binding_now(c))
|
|
|
|
def _trigger_options(self, control):
|
|
"""[(trigger, name of the control holding it, or None)] for the picker."""
|
|
holders = {}
|
|
for c in self.state.layout.control_ids:
|
|
if c == control:
|
|
continue
|
|
b = self._binding_now(c)
|
|
if b and b.keys and b.keys.lower() in act.TRIGGERS:
|
|
holders[b.keys.lower()] = core.control_name(c)
|
|
for t in self.actions.by_trigger:
|
|
holders.setdefault(t, "another key")
|
|
mine = act.trigger_of(self._binding_now(control)) if control else None
|
|
return [(t, None if t == mine else holders.get(t)) for t in act.TRIGGERS]
|
|
|
|
def _pending(self):
|
|
out = []
|
|
for c in self.state.layout.control_ids:
|
|
e = self.state.get(c)
|
|
b = self.desired.get(c)
|
|
pad = b is not None and (e.unknown or e.binding != b)
|
|
host = (c in self.pending_actions and
|
|
self._stored_action(self._binding_now(c)) != self.pending_actions[c])
|
|
if pad or host:
|
|
out.append(c)
|
|
return out
|
|
|
|
def _refresh(self, editor=True):
|
|
pending = set(self._pending())
|
|
looks = {}
|
|
for c in self.state.layout.control_ids:
|
|
e = self.state.get(c)
|
|
if c in pending:
|
|
a = self.pending_actions.get(c)
|
|
text = a.label() if a else self._binding_now(c).label()
|
|
looks[c] = Look(text, known=not e.unknown, pending=True)
|
|
elif e.unknown:
|
|
looks[c] = Look("", known=False)
|
|
else:
|
|
a = self._stored_action(e.binding)
|
|
looks[c] = Look(a.label() if a else e.binding.label(), known=True,
|
|
quiet=e.binding.none)
|
|
self.pad.set_looks(looks)
|
|
|
|
n = len(pending)
|
|
self.write_all.setText(f"Write {n} change{'s' if n != 1 else ''}")
|
|
self.write_all.setVisible(n > 1)
|
|
self.write_all.setEnabled(bool(self.diag and self.diag.ready))
|
|
|
|
c = self.pad.current
|
|
can_write = bool(self.diag and self.diag.ready)
|
|
if not editor:
|
|
self.inspector.update_buttons(c in pending, can_write)
|
|
return
|
|
all_unknown = all(self.state.get(x).unknown
|
|
for x in self.state.layout.control_ids)
|
|
e = self.state.get(c) if c else None
|
|
self.inspector.show_control(
|
|
c, e, self.desired.get(c) if c in pending else None,
|
|
can_write, all_unknown,
|
|
action=self._action_for(c) if c else None,
|
|
onpad_action=self._stored_action(e.binding) if e and not e.unknown else None,
|
|
pending=c in pending,
|
|
bg=bool(act.listener_pid()),
|
|
triggers=self._trigger_options(c) if c else None,
|
|
trigger=act.trigger_of(self._binding_now(c)) if c else None)
|
|
|
|
def _select(self, control):
|
|
self._refresh()
|
|
|
|
def _changed(self, control, binding):
|
|
self.pending_actions.pop(control, None)
|
|
if binding is None:
|
|
self.desired.pop(control, None)
|
|
else:
|
|
self.desired[control] = binding
|
|
self._refresh(editor=False)
|
|
|
|
def _host_action(self, control, action, want=None):
|
|
"""
|
|
Point a control at a host action. It gets a spare F13 to F24 key on
|
|
the pad; if it already has one, it keeps it, so changing what the
|
|
action does never needs a write to the pad.
|
|
"""
|
|
if action is None:
|
|
self.pending_actions.pop(control, None)
|
|
if act.trigger_of(self.desired.get(control)):
|
|
self.desired.pop(control, None)
|
|
self._refresh(editor=False)
|
|
return
|
|
|
|
used = set()
|
|
for c in self.state.layout.control_ids:
|
|
b = self._binding_now(c) if c != control else None
|
|
if b and b.keys:
|
|
used.add(b.keys.lower())
|
|
current = act.trigger_of(self._binding_now(control))
|
|
trig = act.pick_trigger(self.actions, used, current, want)
|
|
if trig is None:
|
|
self._say("All twelve action keys, F13 to F24, are taken. Free one "
|
|
"up by setting another action control to something else.", BAD)
|
|
return
|
|
|
|
want = core.Binding(keys=trig)
|
|
e = self.state.get(control)
|
|
if e.unknown or e.binding != want:
|
|
self.desired[control] = want
|
|
else:
|
|
self.desired.pop(control, None)
|
|
self.pending_actions[control] = action
|
|
self.inspector.set_trigger(trig)
|
|
self._refresh(editor=False)
|
|
|
|
def _test_action(self, action):
|
|
if action.kind == "text":
|
|
problems = act.text_problems()
|
|
if problems:
|
|
self._say(problems[0][0], BAD)
|
|
return
|
|
# typing into MacroPad itself would be useless, so give time to
|
|
# click somewhere else first
|
|
for n in (3, 2, 1):
|
|
QTimer.singleShot((3 - n) * 1000, lambda n=n: self._say(
|
|
f"Typing in {n}... click where you want it.", GOOD))
|
|
QTimer.singleShot(3000, lambda: self._run_test(action))
|
|
return
|
|
self._run_test(action)
|
|
|
|
def _run_test(self, action):
|
|
try:
|
|
action.run()
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
self._say(f"Couldn't run it: {getattr(e, 'strerror', None) or e}", BAD)
|
|
return
|
|
self._say(f"Ran it: {action.label()}.", GOOD)
|
|
|
|
def _background(self, on):
|
|
act.set_autostart(on)
|
|
if on:
|
|
act.start_listener()
|
|
self._say("Background actions on. They'll start again when you log in.", GOOD)
|
|
else:
|
|
act.stop_listener()
|
|
self._say("Background actions off.", GOOD)
|
|
QTimer.singleShot(700, lambda: self.inspector.show_background(
|
|
bool(act.listener_pid())))
|
|
|
|
def _revert(self, control):
|
|
self.pending_actions.pop(control, None)
|
|
self.desired.pop(control, None)
|
|
self._refresh()
|
|
|
|
# ------------------------------------------------------------ device
|
|
|
|
def _poll(self):
|
|
old = self.diag.status if self.diag else None
|
|
try:
|
|
self.diag = core.diagnose(keyd=False)
|
|
except Exception as e: # never let polling kill the window
|
|
self._say(f"Couldn't check the pad: {e}", BAD)
|
|
return
|
|
d = self.diag
|
|
if d.status == "unsupported" and d.device and \
|
|
f"{d.device.vid}:{d.device.pid}" in self.adopted:
|
|
self.diag = core.Diagnosis("ready", f"Ready on {d.device.path}.",
|
|
device=d.device, warnings=d.warnings)
|
|
switched = self._follow_device()
|
|
colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD)
|
|
self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;")
|
|
if self.diag.device:
|
|
self.pad.device_id = f"{self.diag.device.vid}:{self.diag.device.pid}"
|
|
self.status.setText({
|
|
"ready": f"Pad ready on {self.diag.device.path}" if self.diag.device else "Pad ready",
|
|
"unplugged": "No pad connected",
|
|
"no-interface": "Pad found, config interface missing",
|
|
"no-permission": "No permission to write to the pad",
|
|
"unsupported": "Unsupported pad",
|
|
"unsupported-os": "Not supported on this system",
|
|
}[self.diag.status])
|
|
self._check_shape()
|
|
self.inspector.show_problem(self.diag, self.note or self.shape_note,
|
|
offer_detect=bool(self.shape_note))
|
|
self.inspector.show_background(bool(act.listener_pid()))
|
|
if self.diag.status != old or switched:
|
|
self._refresh(editor=switched)
|
|
if self.diag.ready and (switched or
|
|
old in ("unplugged", "no-interface", None)):
|
|
self._read(quiet=True)
|
|
|
|
def _follow_device(self):
|
|
"""
|
|
Keep the shadow state and the drawing on the pad that is plugged in.
|
|
|
|
Each family keeps its own state file. The first time a pad with a
|
|
known USB id turns up it is drawn with the layout we know for it,
|
|
so a 9 key pad is never shown as 12 keys and 2 knobs. Returns True
|
|
when the state was switched to another pad.
|
|
"""
|
|
dev = self.diag.device if self.diag else None
|
|
if not dev:
|
|
self.inspector.backend = None
|
|
return False
|
|
self.inspector.backend = core.backend_for(dev)
|
|
ident = f"{dev.vid}:{dev.pid}"
|
|
if ident == self.state.device_id:
|
|
return False
|
|
state = core.State(device_id=ident)
|
|
if not state.path.exists():
|
|
known = core.KNOWN_LAYOUTS.get((dev.vid, dev.pid))
|
|
if known:
|
|
state.layout = known
|
|
self.state = state
|
|
self.desired.clear()
|
|
self.pending_actions.clear()
|
|
self.pad.set_layout(self.state.layout) # drops a stale selection
|
|
return True
|
|
|
|
# ------------------------------------------------------------- write
|
|
|
|
def _write(self, controls):
|
|
controls = [c for c in controls
|
|
if c in self.desired or c in self.pending_actions]
|
|
if not controls:
|
|
return
|
|
self._poll()
|
|
needs_pad = any(c in self.desired for c in controls)
|
|
if needs_pad and not self.diag.ready:
|
|
self._say(self.diag.headline, BAD)
|
|
return
|
|
|
|
done, host_changed = [], False
|
|
for c in controls:
|
|
b = self.desired.get(c)
|
|
e = self.state.get(c)
|
|
if b is not None and (e.unknown or e.binding != b):
|
|
try:
|
|
core.write_binding(self.diag.device, c, b, self.state)
|
|
except core.WriteError as err:
|
|
what = core.control_name(c)
|
|
tail = (f"{what} is now marked unknown, since part of it "
|
|
"reached the pad." if err.pad_touched
|
|
else "Nothing reached the pad.")
|
|
why = str(err).split(": ", 1)[-1]
|
|
self._say(f"Couldn't write {what}: {why}. {tail}", BAD)
|
|
self._refresh()
|
|
return
|
|
self.desired.pop(c, None)
|
|
action = self.pending_actions.pop(c, None)
|
|
if action:
|
|
trig = act.trigger_of(self._binding_now(c))
|
|
if trig:
|
|
self.actions.set(trig, action)
|
|
host_changed = True
|
|
done.append((c, b, action))
|
|
QApplication.processEvents()
|
|
|
|
# Forget actions whose key is no longer on the pad, but only when we
|
|
# know every control; otherwise one might still be sitting there.
|
|
ids = self.state.layout.control_ids
|
|
if all(not self.state.get(x).unknown for x in ids):
|
|
bound = {self.state.get(x).binding.keys.lower()
|
|
for x in ids if self.state.get(x).binding.keys}
|
|
if self.actions.prune(bound):
|
|
host_changed = True
|
|
if host_changed:
|
|
self.actions.save()
|
|
|
|
if len(done) == 1:
|
|
c, b, action = done[0]
|
|
name = core.control_name(c)
|
|
if action:
|
|
msg = f"{name} is set: {action.label()}."
|
|
if not act.listener_pid():
|
|
msg += " Turn on background actions for it to work."
|
|
elif b is not None and b.none:
|
|
msg = f"{name} now does nothing."
|
|
else:
|
|
msg = f"{name} is now {b.label()}."
|
|
self._say(msg, GOOD)
|
|
else:
|
|
self._say(f"Wrote {len(done)} changes.", GOOD)
|
|
self._refresh()
|
|
|
|
def _read(self, quiet=False):
|
|
"""
|
|
Ask the pad what it holds and believe the answer. Anything you have
|
|
changed but not written is left alone, since that is your intent
|
|
rather than the pad's state.
|
|
"""
|
|
if not (self.diag and self.diag.ready):
|
|
if not quiet:
|
|
self._say(self.diag.headline if self.diag else "No pad.", BAD)
|
|
return
|
|
self.read_btn.setEnabled(False)
|
|
self.read_btn.setText("Reading...")
|
|
QApplication.processEvents()
|
|
try:
|
|
got = core.read_into_state(self.diag.device, self.state)
|
|
except core.ReadError as e:
|
|
if not quiet:
|
|
self._say(f"Couldn't read the pad: {e}", BAD)
|
|
else:
|
|
n = len(got)
|
|
self._say(f"Read {n} control{'s' if n != 1 else ''} from the pad.", GOOD)
|
|
finally:
|
|
self.read_btn.setEnabled(True)
|
|
self.read_btn.setText("Read pad")
|
|
self._refresh()
|
|
|
|
def _check_shape(self):
|
|
"""
|
|
Ask a working pad how many keys and knobs it has and say something
|
|
if that disagrees with the drawing.
|
|
|
|
The query runs once per pad; the comparison runs every poll, so
|
|
fixing the layout by hand clears the note straight away.
|
|
"""
|
|
dev = self.diag.device if self.diag else None
|
|
if not self.diag or not self.diag.ready or not dev:
|
|
self.shape_note = ""
|
|
return
|
|
tag = f"{dev.vid}:{dev.pid}@{dev.path}"
|
|
if tag not in self.shape_checked:
|
|
self.shape_checked.add(tag)
|
|
try:
|
|
self.reported = core.read_info(dev)
|
|
except Exception: # a pad that won't answer is fine
|
|
self.reported = None
|
|
if not self.reported:
|
|
self.shape_note = ""
|
|
return
|
|
keys, knobs = self.reported
|
|
lay = self.state.layout
|
|
if (keys, knobs) == (lay.keys, lay.knobs):
|
|
self.shape_note = ""
|
|
return
|
|
self.shape_note = (
|
|
f"This pad says it has {keys} keys and {knobs} "
|
|
f"knob{'s' if knobs != 1 else ''}, but it is drawn with "
|
|
f"{lay.keys} and {lay.knobs}. Ask it what it is and the drawing "
|
|
f"will match.")
|
|
|
|
def _identify(self):
|
|
"""
|
|
Interrogate a pad we don't recognise. Reads only. If it answers
|
|
properly we adopt its shape and carry on; if not we say so and
|
|
leave it alone.
|
|
"""
|
|
dev = self.diag.device if self.diag else None
|
|
if not dev:
|
|
return
|
|
self.inspector.banner_action.setEnabled(False)
|
|
self.inspector.banner_action.setText("Asking...")
|
|
QApplication.processEvents()
|
|
try:
|
|
found = core.detect(dev)
|
|
finally:
|
|
self.inspector.banner_action.setEnabled(True)
|
|
self.inspector.banner_action.setText("Ask the pad what it is")
|
|
|
|
if not found.speaks:
|
|
self._say(f"That pad doesn't speak this protocol: {found.why}", BAD)
|
|
return
|
|
|
|
layout = found.layout
|
|
self.state = core.State(layout=layout, device_id=f"{dev.vid}:{dev.pid}")
|
|
self.state.layout = layout
|
|
for control, binding in found.bindings.items():
|
|
self.state.record(control, binding)
|
|
self.state.save()
|
|
self.pad.set_layout(layout)
|
|
self.desired.clear()
|
|
self.adopted.add(f"{dev.vid}:{dev.pid}")
|
|
self.settings.setValue("adopted", sorted(self.adopted))
|
|
self.reported = (layout.keys, layout.knobs)
|
|
self.shape_note = ""
|
|
self._poll()
|
|
self._say(f"It says {layout.keys} keys and {layout.knobs} "
|
|
f"knob{'s' if layout.knobs != 1 else ''}. Check the drawing "
|
|
f"matches your pad.", GOOD)
|
|
self._refresh()
|
|
|
|
def _say(self, text, colour=None):
|
|
self.message.setText(text)
|
|
self.message.setStyleSheet(f"color: {colour};" if colour else "")
|
|
|
|
# ------------------------------------------------------------- misc
|
|
|
|
def _edit_layout(self, on, keep=False):
|
|
if on:
|
|
self.editing_from = self.state.layout
|
|
for name, value in (("rows", self.state.layout.rows),
|
|
("cols", self.state.layout.cols),
|
|
("keys", self.state.layout.keys),
|
|
("knobs", self.state.layout.knobs)):
|
|
self.spin[name].blockSignals(True)
|
|
self.spin[name].setValue(value)
|
|
self.spin[name].blockSignals(False)
|
|
self.knob_side_btn.setText(f"Knobs: {self.state.layout.knob_side}")
|
|
self.knob_side_btn.setEnabled(bool(self.state.layout.knobs))
|
|
self._say("")
|
|
elif not keep:
|
|
self._set_layout(self.editing_from)
|
|
else:
|
|
self.state.save()
|
|
lay = self.state.layout
|
|
self._say(f"Layout saved: {lay.describe()}.", GOOD)
|
|
self.editbar.setVisible(on)
|
|
self.edit_btn.setEnabled(not on)
|
|
self.read_btn.setEnabled(not on)
|
|
self.inspector.editing_layout = on
|
|
self.pad.select(None if on else self.pad.current)
|
|
self.pad.set_editing(on)
|
|
self._refresh()
|
|
|
|
def _flip_knob_side(self):
|
|
side = "left" if self.state.layout.knob_side == "right" else "right"
|
|
self._set_layout(self.state.layout.with_knobs_on(side))
|
|
self.knob_side_btn.setText(f"Knobs: {side}")
|
|
self.pad.set_editing(True)
|
|
|
|
def _set_layout(self, layout):
|
|
self.state.layout = layout
|
|
self.pad.set_layout(layout)
|
|
if self.pad.current not in layout.control_ids:
|
|
self.pad.current = None
|
|
self._refresh()
|
|
|
|
def _rearranged(self, layout):
|
|
self.state.layout = layout
|
|
|
|
def _resize_layout(self):
|
|
lay = self.state.layout.resized(
|
|
rows=self.spin["rows"].value(), cols=self.spin["cols"].value(),
|
|
keys=self.spin["keys"].value(), knobs=self.spin["knobs"].value())
|
|
if lay.rows != self.spin["rows"].value(): # it had to grow to fit
|
|
self.spin["rows"].blockSignals(True)
|
|
self.spin["rows"].setValue(lay.rows)
|
|
self.spin["rows"].blockSignals(False)
|
|
self._set_layout(lay)
|
|
self.pad.set_editing(True)
|
|
|
|
def _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()
|
|
self._say(f"Theme: {t.name}.", GOOD)
|
|
|
|
def _rotate(self):
|
|
o = "flat" if self.pad.orientation == "upright" else "upright"
|
|
self.pad.set_orientation(o)
|
|
self.settings.setValue("orientation", o)
|
|
|
|
def _import(self):
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self, "Import a capture of the vendor software",
|
|
str(Path.cwd()), "Captures (*.txt);;All files (*)")
|
|
if not path:
|
|
return
|
|
try:
|
|
got = core.import_capture(path, self.state)
|
|
except (OSError, ValueError) as e:
|
|
self._say(f"Couldn't read that capture: {e}", BAD)
|
|
return
|
|
if not got:
|
|
self._say("No saved bindings found in that capture.", BAD)
|
|
return
|
|
for c, _, _ in got:
|
|
self.desired.pop(c, None)
|
|
self._say(f"Imported {len(got)} bindings from {Path(path).name}.", GOOD)
|
|
self._refresh()
|
|
|
|
def closeEvent(self, e):
|
|
n = len(self._pending())
|
|
if not n:
|
|
e.accept()
|
|
return
|
|
box = QMessageBox(self)
|
|
box.setWindowTitle("Unwritten changes")
|
|
box.setText(f"{n} change{'s are' if n != 1 else ' is'} not on the pad yet.")
|
|
write = box.addButton("Write and close", QMessageBox.AcceptRole)
|
|
discard = box.addButton("Close without writing", QMessageBox.DestructiveRole)
|
|
box.addButton("Cancel", QMessageBox.RejectRole)
|
|
box.exec()
|
|
if box.clickedButton() is write:
|
|
self._write(self._pending())
|
|
e.accept() if not self._pending() else e.ignore()
|
|
elif box.clickedButton() is discard:
|
|
e.accept()
|
|
else:
|
|
e.ignore()
|
|
|
|
|
|
def apply_theme(app, key=None):
|
|
"""Switch the whole app, window and drawing, to one theme."""
|
|
global GOOD, BAD, LINE, FIELD
|
|
t = themes.get(key or themes.DEFAULT)
|
|
themes.load_fonts()
|
|
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),
|
|
(QPalette.Button, t.window), (QPalette.ButtonText, t.ink),
|
|
(QPalette.Highlight, t.accent),
|
|
(QPalette.HighlightedText, t.accent_text),
|
|
(QPalette.ToolTipBase, t.field), (QPalette.ToolTipText, t.ink),
|
|
(QPalette.PlaceholderText, t.ink_dim)):
|
|
pal.setColor(role, QColor(colour))
|
|
app.setPalette(pal)
|
|
app.setStyleSheet(stylesheet(t))
|
|
return t
|
|
|
|
|
|
ICON = Path(__file__).resolve().parent.parent / "packaging" / "macropad.svg"
|
|
|
|
|
|
def run():
|
|
app = QApplication(sys.argv)
|
|
app.setApplicationName("macropad")
|
|
app.setDesktopFileName("macropad")
|
|
# The launcher gives Plasma the icon; this covers the window itself, X11
|
|
# sessions, and running straight from a git checkout.
|
|
if ICON.exists():
|
|
app.setWindowIcon(QIcon(str(ICON)))
|
|
apply_theme(app, QSettings("macropad", "macropad").value("theme", themes.DEFAULT))
|
|
w = Window()
|
|
w.resize(1000, 800)
|
|
w.show()
|
|
return app.exec()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# `python -m macropad_gui.app` lands here. `python -m macropad_gui` is the
|
|
# documented way in, but people reach for this one, so make it work.
|
|
sys.exit(run())
|