Files
macropad-itprotech/macropad_gui/app.py
Oaken 15f29db185 Merge remote-tracking branch 'upstream/main'
# Conflicts:
#	README.md
#	macropad_gui/app.py
2026-10-08 23:07:55 +01:00

1519 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; }}\n'
f'QLabel[role="message"] {{ font-family: "{t.mono_font}"; '
f'font-size: 12px; font-weight: 600; }}')
return f'QLabel[role="status"] {{ color: {t.ink_dim}; }}'
def _primary(t, tint):
"""The write button: filled by default, outlined where a theme says so."""
if t.outline_primary:
return f"""
QPushButton[role="primary"] {{ background: {t.board}; color: {t.accent};
border: 1px solid {t.accent}; border-radius: {t.button_radius}px;
font-weight: 600; padding: 8px 16px; }}
QPushButton[role="primary"]:hover {{ background: {tint(t.board, t.accent, 0.12)}; }}
QPushButton[role="primary"]:disabled {{ color: {tint(t.window, t.ink_dim, 0.7)};
border-color: {tint(t.window, t.line, 0.7)}; }}"""
return f"""
QPushButton[role="primary"] {{ background: {t.accent}; color: {t.accent_text};
border: none; border-radius: {t.button_radius}px;
font-weight: 600; padding: 8px 16px; }}
QPushButton[role="primary"]:hover {{ background: {tint(t.accent, t.ink, 0.15)}; }}
QPushButton[role="primary"]:disabled {{ background: {tint(t.window, t.line, 0.7)}; color: {tint(t.window, t.ink_dim, 0.7)}; }}"""
def _flat(t):
"""Footer buttons: bare text by default, outlined pills where asked."""
if t.pill_buttons:
return f"""
QPushButton[role="flat"] {{ background: {t.board}; color: {t.ink};
border: 1px solid {t.ink_dim}; border-radius: {t.button_radius}px;
padding: 6px 14px; }}
QPushButton[role="flat"]:hover {{ border-color: {t.ink}; }}
QPushButton[role="flat"]:disabled {{ color: {t.ink_dim}; border-color: {t.line}; }}"""
return f"""
QPushButton[role="flat"] {{ border: none; color: {t.ink_dim}; padding: 6px 8px; }}
QPushButton[role="flat"]:hover {{ color: {t.ink}; }}"""
INSPECTOR_W = 400 # the side panel's width, without its scrollbar
def stylesheet(t):
"""The whole window's look, from one theme."""
tint = themes.mix
return f"""
QWidget {{ color: {t.ink}; background: {t.window}; }}
QLabel[role="title"] {{ font-size: {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)}; }}
"""
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)
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)
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())