1544 lines
63 KiB
Python
1544 lines
63 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
|
|
|
|
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
|
|
if b.is_sequence and not core.SEQUENCES_VERIFIED:
|
|
self.keys_hint.setText(
|
|
f"<span style='color:{BAD}'>Sequences aren't verified on this "
|
|
"pad yet, so they're switched off for now. One shortcut per "
|
|
"control.</span>")
|
|
self.changed.emit(self.control, None)
|
|
return
|
|
self.keys_hint.setText(f"Sends {b.label()}")
|
|
self.changed.emit(self.control, b)
|
|
|
|
# ------------------------------------------------------------ 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)
|
|
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:
|
|
self._refresh(editor=False)
|
|
if self.diag.ready and old in ("unplugged", "no-interface", None):
|
|
self._read(quiet=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)
|
|
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())
|