""" The window. Open it, change what a control does, write it, close it. The pad keeps its bindings in its own memory, so nothing here needs to keep running afterwards. """ import sys from pathlib import Path from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal from PySide6.QtGui import (QColor, QFont, QFontDatabase, QGuiApplication, QIcon, QPalette) from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget) from . import core, keymap from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView GOOD = "#8db36b" BAD = "#e0584f" LINE = "#3a3e42" FIELD = "#121416" # Media keys as pairs, so opposites sit side by side. MEDIA_GRID = [("playpause", "stop"), ("prev", "next"), ("volumedown", "volumeup"), ("mute", None), ("brightnessdown", "brightnessup"), ("calculator", "mail"), ("browser", "mycomputer")] STYLE = f""" QWidget {{ color: {INK.name()}; background: {WINDOW.name()}; }} QLabel[role="title"] {{ font-size: 18px; font-weight: 600; }} QLabel[role="dim"] {{ color: {INK_DIM.name()}; }} QLabel[role="caption"] {{ color: {INK_DIM.name()}; padding: 14px 0px 0px 0px; margin: 0px; }} QLabel[role="value"] {{ font-size: 15px; }} QPushButton {{ background: transparent; border: 1px solid {LINE}; border-radius: 4px; padding: 6px 12px; }} QPushButton:hover {{ border-color: {INK_DIM.name()}; }} QPushButton:checked {{ border-color: {TAPE.name()}; color: {TAPE.name()}; }} QPushButton:disabled {{ color: #4a4e52; border-color: #2a2e32; }} QPushButton[role="primary"] {{ background: {TAPE.name()}; color: #1b1206; border: none; font-weight: 600; padding: 8px 16px; }} QPushButton[role="primary"]:hover {{ background: #f39a45; }} QPushButton[role="primary"]:disabled {{ background: #2c3034; color: #5a5e62; }} QPushButton[role="flat"] {{ border: none; color: {INK_DIM.name()}; padding: 6px 8px; }} QPushButton[role="flat"]:hover {{ color: {INK.name()}; }} QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }} QLineEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px; padding: 7px 8px; }} QLineEdit:focus {{ border-color: {INK_DIM.name()}; }} QLineEdit[recording="true"] {{ border-color: {TAPE.name()}; background: #2a1d10; }} QPlainTextEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px; }} QFrame[role="banner"] {{ border: 1px solid {LINE}; border-left: 3px solid {BAD}; border-radius: 4px; }} QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {INK_DIM.name()}; }} QFrame[role="banner"] QLabel, QFrame[role="banner"] QPushButton {{ background: transparent; }} QFrame[role="footer"] {{ border-top: 1px solid {LINE}; }} """ def _label(text="", role=None, wrap=False): lab = QLabel(text) lab.setIndent(0) if role: lab.setProperty("role", role) lab.setWordWrap(wrap) return lab def _button(text, role=None, checkable=False): b = QPushButton(text) if role: b.setProperty("role", role) b.setCheckable(checkable) b.setCursor(Qt.PointingHandCursor) return b # ------------------------------------------------------------------ recorder class RecorderEdit(QLineEdit): """ A text field that can also listen for a key combo. Type 'ctrl+shift+n', or press Record and then press the combo itself. """ recorded = Signal(str) def __init__(self): super().__init__() self.recording = False self.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) def set_recording(self, on): self.recording = on self.setProperty("recording", "true" if on else "false") self.style().unpolish(self) self.style().polish(self) if on: self._before = self.text() self.clear() self.setPlaceholderText("Press the combo now") self.setFocus() else: self.setPlaceholderText("ctrl+shift+n") def event(self, e): # Catch Tab and friends too, which would otherwise move focus. if self.recording and e.type() == QEvent.KeyPress: self._record(e) return True if self.recording and e.type() == QEvent.ShortcutOverride: e.accept() return True return super().event(e) def _record(self, e): mods = keymap.modifier_names(e) if e.key() in keymap.MODIFIER_KEYS: self.setText("+".join(mods + ["…"])) return name = keymap.key_name(e) if name is None: self.setText("") self.setPlaceholderText("The pad can't send that key. Try another.") return combo = "+".join(mods + [name]) self.set_recording(False) self.setText(combo) self.recorded.emit(combo) def focusOutEvent(self, e): if self.recording: self.set_recording(False) self.setText(self._before) super().focusOutEvent(e) # ------------------------------------------------------------------ inspector class Inspector(QWidget): """Right-hand panel: what the selected control does, and what to change it to.""" changed = Signal(str, object) # control, Binding or None (revert) write = Signal(str) revert = Signal(str) def __init__(self): super().__init__() self.setFixedWidth(360) self.control = None self._loading = False v = QVBoxLayout(self) v.setContentsMargins(24, 24, 24, 16) v.setSpacing(4) # problem banner (no pad / no permission) self.banner = QFrame() self.banner.setProperty("role", "banner") bl = QVBoxLayout(self.banner) bl.setContentsMargins(12, 10, 12, 10) self.banner_head = _label("", wrap=True) self.banner_head.setStyleSheet("font-weight: 600;") self.banner_text = _label("", "dim", wrap=True) self.banner_fix = QPlainTextEdit() self.banner_fix.setReadOnly(True) self.banner_fix.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) self.banner_fix.setFixedHeight(84) self.banner_fix.setLineWrapMode(QPlainTextEdit.NoWrap) self.banner_copy = _button("Copy commands") self.banner_copy.clicked.connect(self._copy) for w in (self.banner_head, self.banner_text, self.banner_fix, self.banner_copy): bl.addWidget(w) self.banner_copy.setSizePolicy(self.banner_copy.sizePolicy().horizontalPolicy(), self.banner_copy.sizePolicy().verticalPolicy()) v.addWidget(self.banner) v.addSpacing(8) # empty state self.empty = _label("", "dim", wrap=True) v.addWidget(self.empty) # editor self.editor = QWidget() ev = QVBoxLayout(self.editor) ev.setContentsMargins(0, 0, 0, 0) ev.setSpacing(4) self.title = _label("", "title") self.sub = _label("", "dim") ev.addWidget(self.title) ev.addWidget(self.sub) ev.addWidget(_label("On the pad now", "caption")) self.onpad = _label("", "value", wrap=True) self.onpad_why = _label("", "dim", wrap=True) ev.addWidget(self.onpad) ev.addWidget(self.onpad_why) ev.addWidget(_label("Change it to", "caption")) modes = QHBoxLayout() modes.setSpacing(6) self.mode_keys = _button("Shortcut", checkable=True) self.mode_media = _button("Media key", checkable=True) self.mode_none = _button("Nothing", checkable=True) self.mode_none.setToolTip("Make this control do nothing at all") 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_group.addButton(b, i) modes.addWidget(b) modes.addStretch() ev.addLayout(modes) ev.addSpacing(6) # shortcut page kp = QWidget() kl = QVBoxLayout(kp) kl.setContentsMargins(0, 0, 0, 0) row = QHBoxLayout() self.keys_edit = RecorderEdit() self.keys_edit.setPlaceholderText("ctrl+shift+n") self.record = _button("Record", checkable=True) row.addWidget(self.keys_edit, 1) row.addWidget(self.record) kl.addLayout(row) self.keys_hint = _label("", "dim", wrap=True) kl.addWidget(self.keys_hint) # media page mp = QWidget() ml = QGridLayout(mp) ml.setContentsMargins(0, 0, 0, 0) ml.setSpacing(6) self.media_group = QButtonGroup(self) self.media_group.setExclusive(True) self.media_buttons = {} for r, pair in enumerate(MEDIA_GRID): for col, mid in enumerate(pair): if mid is None: continue b = _button(core.MEDIA_LABEL[mid], "media", checkable=True) self.media_group.addButton(b, [m for m, _ in core.MEDIA].index(mid)) self.media_buttons[mid] = b ml.addWidget(b, r, col) np_ = _label("Pressing or turning it will do nothing. Handy for a key " "you keep hitting by accident.", "dim", wrap=True) self.pages = (kp, mp, np_) ev.addWidget(kp) ev.addWidget(mp) ev.addWidget(np_) ev.addSpacing(10) actions = QHBoxLayout() self.write_btn = _button("Write to pad", "primary") self.revert_btn = _button("Revert", "flat") actions.addWidget(self.write_btn) actions.addWidget(self.revert_btn) actions.addStretch() ev.addLayout(actions) v.addWidget(self.editor) v.addStretch() self.mode_group.idClicked.connect(self._mode) self.keys_edit.textEdited.connect(self._keys_typed) self.keys_edit.recorded.connect(self._keys_typed) self.record.toggled.connect(self._toggle_record) self.media_group.idClicked.connect(self._media_picked) self.write_btn.clicked.connect(lambda: self.write.emit(self.control)) self.revert_btn.clicked.connect(lambda: self.revert.emit(self.control)) self.show_control(None, None, None, False) # -------------------------------------------------------------- banner def show_problem(self, diag, note=None): if diag is None or diag.ready: if not note: self.banner.hide() return diag = core.Diagnosis("note", "Heads up", detail=[note]) quiet = diag.status in ("unplugged", "note", "unsupported") self.banner.setProperty("tone", "quiet" if quiet else "") self.banner.style().unpolish(self.banner) self.banner.style().polish(self.banner) self.banner_head.setText(diag.headline) self.banner_text.setText("\n\n".join(diag.detail)) cmds = [c for c in diag.fix if not c.startswith("#")] self.banner_fix.setPlainText("\n".join(cmds)) lh = self.banner_fix.fontMetrics().lineSpacing() self.banner_fix.setFixedHeight(lh * max(1, len(cmds)) + 30) self.banner_fix.setVisible(bool(cmds)) self.banner_copy.setVisible(bool(cmds)) if diag.fix and diag.fix[-1].startswith("#"): self.banner_text.setText(self.banner_text.text() + "\n\nThen unplug the pad and plug it back in.") self.banner.show() def _copy(self): QGuiApplication.clipboard().setText(self.banner_fix.toPlainText()) self.banner_copy.setText("Copied") QTimer.singleShot(1500, lambda: self.banner_copy.setText("Copy commands")) # -------------------------------------------------------------- editor def show_control(self, control, entry, desired, can_write, all_unknown=False): self._loading = True self.control = control if control is None: self.editor.hide() self.empty.setText( "Click a key or dial to change what it does.\n\n" + ("The pad can't report what's on it, so this app only knows " "what it has written itself. Hatched controls are unknown. " "If you set the pad up with the vendor software, you can " "import that capture instead." if all_unknown else "")) self.empty.show() self._loading = False return self.empty.hide() self.editor.show() self.title.setText(core.control_name(control)) self.sub.setText(f"Action byte 0x{core.action_byte(control):02x}") if entry.unknown: self.onpad.setText("Unknown") self.onpad.setStyleSheet(f"color: {INK_DIM.name()};") self.onpad_why.setText(entry.reason[:1].upper() + entry.reason[1:] + ".") self.onpad_why.show() else: self.onpad.setText(entry.binding.label()) self.onpad.setStyleSheet("") self.onpad_why.hide() shown = desired or (None if entry.unknown else entry.binding) if shown is not None and shown.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, 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 == 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"{e}") self.changed.emit(self.control, None) return if b.is_sequence and not core.SEQUENCES_VERIFIED: self.keys_hint.setText( f"Sequences aren't verified on this " "pad yet, so they're switched off for now. One shortcut per " "control.") self.changed.emit(self.control, None) return self.keys_hint.setText(f"Sends {b.label()}") self.changed.emit(self.control, b) def _media_picked(self, i): if self._loading or self.control is None: return self.changed.emit(self.control, core.Binding(media=core.MEDIA[i][0])) # ------------------------------------------------------------------ window class Window(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("MacroPad") self.settings = QSettings("macropad", "macropad") self.state = core.State() self.desired = {} # control -> Binding, unwritten edits self.diag = None root = QWidget() outer = QVBoxLayout(root) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(0) body = QHBoxLayout() body.setSpacing(0) self.pad = PadView() self.pad.set_orientation(self.settings.value("orientation", "upright")) self.inspector = Inspector() body.addWidget(self.pad, 1) body.addWidget(self.inspector) outer.addLayout(body, 1) footer = QFrame() footer.setProperty("role", "footer") fl = QHBoxLayout(footer) fl.setContentsMargins(16, 8, 16, 8) self.dot = QLabel() self.dot.setFixedSize(9, 9) self.status = _label("", "dim") self.message = _label("") self.rotate = _button("Rotate view", "flat") self.import_btn = _button("Import capture…", "flat") self.write_all = _button("", "primary") fl.addWidget(self.dot) fl.addWidget(self.status) fl.addSpacing(16) fl.addWidget(self.message, 1) fl.addWidget(self.rotate) fl.addWidget(self.import_btn) fl.addWidget(self.write_all) outer.addWidget(footer) self.setCentralWidget(root) self.pad.selected.connect(self._select) self.inspector.changed.connect(self._changed) self.inspector.revert.connect(self._revert) self.inspector.write.connect(lambda c: self._write([c])) self.write_all.clicked.connect(lambda: self._write(self._pending())) self.rotate.clicked.connect(self._rotate) self.import_btn.clicked.connect(self._import) self.note = core._keyd_warning() self.timer = QTimer(self) self.timer.timeout.connect(self._poll) self.timer.start(2000) self._poll() self._refresh() # ------------------------------------------------------------- model def _pending(self): out = [] for c in core.CONTROL_IDS: b = self.desired.get(c) if b is None: continue e = self.state.get(c) if e.unknown or e.binding != b: out.append(c) return out def _refresh(self, editor=True): pending = set(self._pending()) looks = {} for c in core.CONTROL_IDS: e = self.state.get(c) if c in pending: looks[c] = Look(self.desired[c].label(), known=not e.unknown, pending=True) elif e.unknown: looks[c] = Look("", known=False) else: looks[c] = Look(e.binding.label(), known=True, 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 core.CONTROL_IDS) self.inspector.show_control( c, self.state.get(c) if c else None, self.desired.get(c) if c in pending else None, can_write, all_unknown) def _select(self, control): self._refresh() def _changed(self, control, binding): if binding is None: self.desired.pop(control, None) else: self.desired[control] = binding self._refresh(editor=False) def _revert(self, control): self.desired.pop(control, None) self._refresh() # ------------------------------------------------------------ device def _poll(self): old = self.diag.status if self.diag else None try: self.diag = core.diagnose(keyd=False) except Exception as e: # never let polling kill the window self._say(f"Couldn't check the pad: {e}", BAD) return colour = {"ready": GOOD, "unplugged": INK_DIM.name()}.get(self.diag.status, BAD) self.dot.setStyleSheet(f"background: {colour}; border-radius: 4px;") self.status.setText({ "ready": f"Pad ready on {self.diag.device.path}" if self.diag.device else "Pad ready", "unplugged": "No pad connected", "no-interface": "Pad found, config interface missing", "no-permission": "No permission to write to the pad", "unsupported": "Unsupported pad", }[self.diag.status]) self.inspector.show_problem(self.diag, self.note) if self.diag.status != old: self._refresh(editor=False) # ------------------------------------------------------------- write def _write(self, controls): controls = [c for c in controls if c in self.desired] if not controls: return self._poll() if not self.diag.ready: self._say(self.diag.headline, BAD) return done = [] for c in controls: b = self.desired[c] try: core.write_binding(self.diag.device, c, b, self.state) except core.WriteError as e: what = core.control_name(c) tail = (f"{what} is now marked unknown, since part of it reached the pad." if e.pad_touched else "Nothing reached the pad.") why = str(e).split(": ", 1)[-1] self._say(f"Couldn't write {what}: {why}. {tail}", BAD) self._refresh() return self.desired.pop(c, None) done.append((c, b)) QApplication.processEvents() if len(done) == 1: c, b = done[0] if b.none: self._say(f"{core.control_name(c)} now does nothing.", GOOD) else: self._say(f"{core.control_name(c)} is now {b.label()}.", GOOD) else: self._say(f"Wrote {len(done)} changes to the pad.", GOOD) self._refresh() def _say(self, text, colour=None): self.message.setText(text) self.message.setStyleSheet(f"color: {colour};" if colour else "") # ------------------------------------------------------------- misc def _rotate(self): o = "flat" if self.pad.orientation == "upright" else "upright" self.pad.set_orientation(o) self.settings.setValue("orientation", o) def _import(self): path, _ = QFileDialog.getOpenFileName( self, "Import a capture of the vendor software", str(Path.cwd()), "Captures (*.txt);;All files (*)") if not path: return try: got = core.import_capture(path, self.state) except (OSError, ValueError) as e: self._say(f"Couldn't read that capture: {e}", BAD) return if not got: self._say("No saved bindings found in that capture.", BAD) return for c, _, _ in got: self.desired.pop(c, None) self._say(f"Imported {len(got)} bindings from {Path(path).name}.", GOOD) self._refresh() def closeEvent(self, e): n = len(self._pending()) if not n: e.accept() return box = QMessageBox(self) box.setWindowTitle("Unwritten changes") box.setText(f"{n} change{'s are' if n != 1 else ' is'} not on the pad yet.") write = box.addButton("Write and close", QMessageBox.AcceptRole) discard = box.addButton("Close without writing", QMessageBox.DestructiveRole) box.addButton("Cancel", QMessageBox.RejectRole) box.exec() if box.clickedButton() is write: self._write(self._pending()) e.accept() if not self._pending() else e.ignore() elif box.clickedButton() is discard: e.accept() else: e.ignore() def apply_theme(app): app.setStyle("Fusion") pal = QPalette() for role, colour in ((QPalette.Window, WINDOW), (QPalette.Base, QColor(FIELD)), (QPalette.Text, INK), (QPalette.WindowText, INK), (QPalette.Button, WINDOW), (QPalette.ButtonText, INK), (QPalette.Highlight, TAPE), (QPalette.HighlightedText, QColor("#1b1206"))): pal.setColor(role, colour) app.setPalette(pal) app.setStyleSheet(STYLE) 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) w = Window() w.resize(1000, 800) w.show() return app.exec()