Themes, actions, text snippets and a key picker
This commit is contained in:
@@ -4,24 +4,26 @@ 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 (QColor, QFont, QFontDatabase, QGuiApplication, QIcon,
|
||||
from PySide6.QtGui import (QActionGroup, QColor, QFont, QFontDatabase,
|
||||
QGuiApplication, QIcon,
|
||||
QPalette)
|
||||
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
|
||||
QGridLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QMainWindow, QMessageBox, QPlainTextEdit,
|
||||
QPushButton, QSpinBox, QVBoxLayout, QWidget)
|
||||
QPushButton, QSpinBox, QVBoxLayout, QWidget,
|
||||
QCheckBox, QComboBox, QMenu)
|
||||
|
||||
from . import core, keymap
|
||||
from . import actions as act, core, keymap, padview, themes
|
||||
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
|
||||
|
||||
GOOD = "#8db36b"
|
||||
BAD = "#e0584f"
|
||||
LINE = "#3a3e42"
|
||||
FIELD = "#121416"
|
||||
# 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"),
|
||||
@@ -29,38 +31,50 @@ MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
|
||||
("brightnessdown", "brightnessup"),
|
||||
("calculator", "mail"), ("browser", "mycomputer")]
|
||||
|
||||
STYLE = f"""
|
||||
QWidget {{ color: {INK.name()}; background: {WINDOW.name()}; }}
|
||||
def stylesheet(t):
|
||||
"""The whole window's look, from one theme."""
|
||||
tint = themes.mix
|
||||
return f"""
|
||||
QWidget {{ color: {t.ink}; background: {t.window}; }}
|
||||
QLabel[role="title"] {{ font-size: 18px; font-weight: 600; }}
|
||||
QLabel[role="dim"] {{ color: {INK_DIM.name()}; }}
|
||||
QLabel[role="caption"] {{ color: {INK_DIM.name()}; padding: 14px 0px 0px 0px; margin: 0px; }}
|
||||
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 {LINE};
|
||||
QPushButton {{ background: transparent; border: 1px solid {t.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;
|
||||
QPushButton:hover {{ border-color: {t.ink_dim}; }}
|
||||
QPushButton:checked {{ border-color: {t.accent}; color: {t.accent}; }}
|
||||
QPushButton:disabled {{ color: {tint(t.window, t.ink_dim, 0.55)}; border-color: {tint(t.window, t.line, 0.6)}; }}
|
||||
QPushButton[role="primary"] {{ background: {t.accent}; color: {t.accent_text};
|
||||
border: none; font-weight: 600; padding: 8px 16px; }}
|
||||
QPushButton[role="primary"]:hover {{ background: #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="primary"]:hover {{ background: {tint(t.accent, t.ink, 0.15)}; }}
|
||||
QPushButton[role="primary"]:disabled {{ background: {tint(t.window, t.line, 0.7)}; color: {tint(t.window, t.ink_dim, 0.7)}; }}
|
||||
QPushButton[role="flat"] {{ border: none; color: {t.ink_dim}; padding: 6px 8px; }}
|
||||
QPushButton[role="flat"]:hover {{ color: {t.ink}; }}
|
||||
QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }}
|
||||
QLineEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px;
|
||||
QLineEdit {{ background: {t.field}; border: 1px solid {t.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};
|
||||
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 {INK_DIM.name()}; }}
|
||||
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 {LINE}; }}
|
||||
QFrame[role="editbar"] {{ border-top: 1px solid {TAPE.name()}; background: #1c1a17; }}
|
||||
QFrame[role="footer"] {{ border-top: 1px solid {t.line}; }}
|
||||
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: {FIELD}; border: 1px solid {LINE}; border-radius: 4px;
|
||||
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)}; }}
|
||||
"""
|
||||
|
||||
|
||||
@@ -148,11 +162,14 @@ class Inspector(QWidget):
|
||||
changed = Signal(str, object) # control, Binding or None (revert)
|
||||
write = Signal(str)
|
||||
revert = Signal(str)
|
||||
action = Signal()
|
||||
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(360)
|
||||
self.setFixedWidth(400)
|
||||
self.control = None
|
||||
self._loading = False
|
||||
self.editing_layout = False
|
||||
@@ -213,12 +230,15 @@ class Inspector(QWidget):
|
||||
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)):
|
||||
for i, b in enumerate((self.mode_keys, self.mode_media, self.mode_none,
|
||||
self.mode_action)):
|
||||
self.mode_group.addButton(b, i)
|
||||
modes.addWidget(b)
|
||||
modes.addStretch()
|
||||
b.setStyleSheet("padding: 6px 8px;")
|
||||
modes.addWidget(b, 1)
|
||||
ev.addLayout(modes)
|
||||
ev.addSpacing(6)
|
||||
|
||||
@@ -253,10 +273,71 @@ class Inspector(QWidget):
|
||||
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_)
|
||||
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()
|
||||
@@ -274,6 +355,16 @@ class Inspector(QWidget):
|
||||
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))
|
||||
|
||||
@@ -312,7 +403,9 @@ class Inspector(QWidget):
|
||||
|
||||
# -------------------------------------------------------------- editor
|
||||
|
||||
def show_control(self, control, entry, desired, can_write, all_unknown=False):
|
||||
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:
|
||||
@@ -341,12 +434,28 @@ class Inspector(QWidget):
|
||||
self.onpad_why.setText(entry.reason[:1].upper() + entry.reason[1:] + ".")
|
||||
self.onpad_why.show()
|
||||
else:
|
||||
self.onpad.setText(entry.binding.label())
|
||||
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("")
|
||||
self.onpad_why.hide()
|
||||
|
||||
shown = desired or (None if entry.unknown else entry.binding)
|
||||
if shown is not None and shown.none:
|
||||
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()
|
||||
@@ -362,7 +471,8 @@ class Inspector(QWidget):
|
||||
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.update_buttons(desired is not None if pending is None else pending,
|
||||
can_write)
|
||||
self._loading = False
|
||||
|
||||
def update_buttons(self, pending, can_write):
|
||||
@@ -385,6 +495,11 @@ class Inspector(QWidget):
|
||||
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()
|
||||
|
||||
@@ -420,6 +535,199 @@ class Inspector(QWidget):
|
||||
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
|
||||
@@ -436,6 +744,8 @@ class Window(QMainWindow):
|
||||
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.
|
||||
@@ -496,6 +806,19 @@ class Window(QMainWindow):
|
||||
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")
|
||||
self.theme_menu = QMenu(self)
|
||||
self.theme_group = QActionGroup(self)
|
||||
self.theme_group.setExclusive(True)
|
||||
current = self.settings.value("theme", themes.DEFAULT)
|
||||
for t in themes.THEMES.values():
|
||||
a = self.theme_menu.addAction(t.name)
|
||||
a.setCheckable(True)
|
||||
a.setChecked(t.key == current)
|
||||
a.setData(t.key)
|
||||
self.theme_group.addAction(a)
|
||||
self.theme_btn.setMenu(self.theme_menu)
|
||||
self.rotate = _button("Rotate view", "flat")
|
||||
self.import_btn = _button("Import capture…", "flat")
|
||||
self.write_all = _button("", "primary")
|
||||
@@ -505,6 +828,7 @@ class Window(QMainWindow):
|
||||
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)
|
||||
@@ -515,6 +839,9 @@ class Window(QMainWindow):
|
||||
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))
|
||||
@@ -523,6 +850,7 @@ class Window(QMainWindow):
|
||||
self.pad.rearranged.connect(self._rearranged)
|
||||
self.read_btn.clicked.connect(lambda: self._read(quiet=False))
|
||||
self.rotate.clicked.connect(self._rotate)
|
||||
self.theme_group.triggered.connect(lambda a: self._set_theme(a.data()))
|
||||
self.import_btn.clicked.connect(self._import)
|
||||
|
||||
self.note = core._keyd_warning()
|
||||
@@ -538,14 +866,43 @@ class Window(QMainWindow):
|
||||
|
||||
# ------------------------------------------------------------- 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:
|
||||
b = self.desired.get(c)
|
||||
if b is None:
|
||||
continue
|
||||
e = self.state.get(c)
|
||||
if e.unknown or e.binding != b:
|
||||
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
|
||||
|
||||
@@ -555,11 +912,15 @@ class Window(QMainWindow):
|
||||
for c in self.state.layout.control_ids:
|
||||
e = self.state.get(c)
|
||||
if c in pending:
|
||||
looks[c] = Look(self.desired[c].label(), known=not e.unknown, pending=True)
|
||||
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:
|
||||
looks[c] = Look(e.binding.label(), known=True, quiet=e.binding.none)
|
||||
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)
|
||||
@@ -574,22 +935,99 @@ class Window(QMainWindow):
|
||||
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, self.state.get(c) if c else None,
|
||||
self.desired.get(c) if c in pending else None,
|
||||
can_write, all_unknown)
|
||||
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()
|
||||
|
||||
@@ -617,8 +1055,10 @@ class Window(QMainWindow):
|
||||
"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.inspector.show_problem(self.diag, self.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):
|
||||
@@ -627,39 +1067,67 @@ class Window(QMainWindow):
|
||||
# ------------------------------------------------------------- write
|
||||
|
||||
def _write(self, controls):
|
||||
controls = [c for c in controls if c in self.desired]
|
||||
controls = [c for c in controls
|
||||
if c in self.desired or c in self.pending_actions]
|
||||
if not controls:
|
||||
return
|
||||
self._poll()
|
||||
if not self.diag.ready:
|
||||
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 = []
|
||||
done, host_changed = [], False
|
||||
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
|
||||
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)
|
||||
done.append((c, b))
|
||||
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 = done[0]
|
||||
if b.none:
|
||||
self._say(f"{core.control_name(c)} now does nothing.", GOOD)
|
||||
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:
|
||||
self._say(f"{core.control_name(c)} is now {b.label()}.", GOOD)
|
||||
msg = f"{name} is now {b.label()}."
|
||||
self._say(msg, GOOD)
|
||||
else:
|
||||
self._say(f"Wrote {len(done)} changes to the pad.", GOOD)
|
||||
self._say(f"Wrote {len(done)} changes.", GOOD)
|
||||
self._refresh()
|
||||
|
||||
def _read(self, quiet=False):
|
||||
@@ -778,6 +1246,14 @@ class Window(QMainWindow):
|
||||
self._set_layout(lay)
|
||||
self.pad.set_editing(True)
|
||||
|
||||
def _set_theme(self, key):
|
||||
t = apply_theme(QApplication.instance(), key)
|
||||
self.settings.setValue("theme", t.key)
|
||||
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)
|
||||
@@ -823,16 +1299,26 @@ class Window(QMainWindow):
|
||||
e.ignore()
|
||||
|
||||
|
||||
def apply_theme(app):
|
||||
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")
|
||||
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)
|
||||
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(STYLE)
|
||||
app.setStyleSheet(stylesheet(t))
|
||||
return t
|
||||
|
||||
|
||||
ICON = Path(__file__).resolve().parent.parent / "packaging" / "macropad.svg"
|
||||
@@ -846,8 +1332,14 @@ def run():
|
||||
# sessions, and running straight from a git checkout.
|
||||
if ICON.exists():
|
||||
app.setWindowIcon(QIcon(str(ICON)))
|
||||
apply_theme(app)
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user