Themes, actions, text snippets and a key picker
This commit is contained in:
@@ -67,6 +67,12 @@ def cmd_detect(_):
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_actions(a):
|
||||
"""The background listener. Normally started by the app or at login."""
|
||||
from . import actions
|
||||
return actions.listen(debug=a.debug)
|
||||
|
||||
|
||||
def cmd_write(a):
|
||||
if a.control not in core.CONTROL_IDS:
|
||||
print(f"Unknown control. One of: {', '.join(core.CONTROL_IDS)}")
|
||||
@@ -100,6 +106,9 @@ def main():
|
||||
sub.add_parser("doctor", help="check the pad is connected and writable")
|
||||
sub.add_parser("state", help="show what this tool believes is on the pad")
|
||||
sub.add_parser("detect", help="ask an unrecognised pad what it is (read only)")
|
||||
ac = sub.add_parser("actions", help="run the listener that makes action keys work")
|
||||
ac.add_argument("--debug", action="store_true",
|
||||
help="print every report the pad's keyboard sends")
|
||||
w = sub.add_parser("write", help="write one binding and record it")
|
||||
w.add_argument("control")
|
||||
w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'")
|
||||
@@ -115,7 +124,7 @@ def main():
|
||||
return 1
|
||||
return run()
|
||||
return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write,
|
||||
"detect": cmd_detect}[a.cmd](a)
|
||||
"detect": cmd_detect, "actions": cmd_actions}[a.cmd](a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
705
macropad_gui/actions.py
Normal file
705
macropad_gui/actions.py
Normal file
@@ -0,0 +1,705 @@
|
||||
"""
|
||||
Host actions: make a pad key open a URL, launch an app, or run a command.
|
||||
|
||||
The pad can't do any of that itself. All it can ever send is a key press.
|
||||
So an action is two halves:
|
||||
|
||||
on the pad the control is bound to a spare key, one of F13 to F24,
|
||||
which no normal keyboard has and no desktop binds
|
||||
on the host a small listener watches the pad's keyboard interface and,
|
||||
when it sees one of those keys, runs the action
|
||||
|
||||
The listener is optional and off unless you turn it on. When it isn't
|
||||
running, action keys send F13 to F24 and nothing happens, and every other
|
||||
key on the pad works exactly as before.
|
||||
|
||||
It reads the pad's keyboard interface through /dev/hidraw, the same way the
|
||||
rest of the app talks to the pad, so the udev rule that already exists is
|
||||
all the permission it needs. Reading hidraw doesn't take the key away from
|
||||
the desktop; it just gets a copy.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from . import core
|
||||
|
||||
proto = core.proto
|
||||
|
||||
TRIGGERS = tuple(f"f{i}" for i in range(13, 25))
|
||||
CODE_TO_TRIGGER = {proto.KEYCODES[t]: t for t in TRIGGERS}
|
||||
|
||||
# Linux's keyboard layer gives some of these keys a meaning of their own, so
|
||||
# the desktop acts on them as well as the listener. F13, F20 and F21 were
|
||||
# confirmed on Plasma 6; F22 and F23 are the touchpad on and off keys from
|
||||
# the same table. They can still be chosen, but they're picked last.
|
||||
CLAIMED = {
|
||||
"f13": "often opens System Settings",
|
||||
"f20": "often mutes the microphone",
|
||||
"f21": "often toggles the touchpad",
|
||||
"f22": "may switch the touchpad on",
|
||||
"f23": "may switch the touchpad off",
|
||||
}
|
||||
# the order the automatic pick tries them in
|
||||
PREFERRED = tuple(sorted(TRIGGERS, key=lambda t: (t in CLAIMED, int(t[1:]))))
|
||||
KINDS = ("url", "app", "command", "text")
|
||||
PASTE_KEYS = ("ctrl+v", "ctrl+shift+v")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ action
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
"""What the computer does when a trigger key arrives."""
|
||||
kind: str # url | app | command | text
|
||||
target: str # the URL, the command line, or the text to type
|
||||
name: str = "" # for apps, the name people recognise
|
||||
paste: str = "" # for text: the paste shortcut, ctrl+v unless set
|
||||
|
||||
def validate(self):
|
||||
if self.kind not in KINDS:
|
||||
raise ValueError(f"unknown action kind '{self.kind}'")
|
||||
if not self.target.strip():
|
||||
raise ValueError({"url": "enter a web address",
|
||||
"app": "pick an app",
|
||||
"command": "enter a command",
|
||||
"text": "enter the text to type"}[self.kind])
|
||||
if self.paste and self.paste not in PASTE_KEYS:
|
||||
raise ValueError(f"unknown paste shortcut '{self.paste}'")
|
||||
if self.kind == "url" and " " in self.target.strip():
|
||||
raise ValueError("a web address can't contain spaces")
|
||||
return self
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""URLs without a scheme get https, so 'github.com' just works."""
|
||||
t = self.target.strip()
|
||||
if self.kind != "url" or re.match(r"^[a-z][a-z0-9+.-]*:", t, re.I):
|
||||
return t
|
||||
return "https://" + t
|
||||
|
||||
def label(self):
|
||||
"""Short enough for a keycap."""
|
||||
if self.kind == "url":
|
||||
host = re.sub(r"^[a-z][a-z0-9+.-]*://", "", self.url, flags=re.I)
|
||||
host = host.split("/")[0].removeprefix("www.")
|
||||
return f"Open {host}"
|
||||
if self.kind == "app":
|
||||
return self.name or self.target.split()[0].rsplit("/", 1)[-1]
|
||||
if self.kind == "text":
|
||||
line = self.target.strip().splitlines()[0]
|
||||
short = line if len(line) <= 16 else line[:15].rstrip() + "…"
|
||||
return f'Type "{short}"'
|
||||
first = self.target.strip().split()[0].rsplit("/", 1)[-1]
|
||||
return f"Run {first}"
|
||||
|
||||
def argv(self):
|
||||
"""What to execute. Shell only where the user wrote a shell command."""
|
||||
if self.kind == "url":
|
||||
return ["xdg-open", self.url]
|
||||
if self.kind == "app":
|
||||
try:
|
||||
return shlex.split(self.target)
|
||||
except ValueError:
|
||||
return ["sh", "-c", self.target]
|
||||
return ["sh", "-c", self.target]
|
||||
|
||||
def run(self, _popen=None):
|
||||
"""Start it and walk away. The listener must never block on an app."""
|
||||
if self.kind == "text":
|
||||
return type_text(self.target, self.paste or "ctrl+v")
|
||||
return (_popen or subprocess.Popen)(self.argv(), stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
|
||||
def to_json(self):
|
||||
d = {"kind": self.kind, "target": self.target}
|
||||
if self.name:
|
||||
d["name"] = self.name
|
||||
if self.paste:
|
||||
d["paste"] = self.paste
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, d):
|
||||
return cls(kind=d["kind"], target=d["target"], name=d.get("name", ""),
|
||||
paste=d.get("paste", "")).validate()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- store
|
||||
|
||||
class ActionStore:
|
||||
"""
|
||||
trigger key -> Action, in ~/.config/macropad/actions.json.
|
||||
|
||||
Keyed by trigger rather than by control, because that's what the
|
||||
listener sees: a key, not a position on the pad. It also means reading
|
||||
the pad back still makes sense, since a control bound to F17 can be
|
||||
looked up here.
|
||||
"""
|
||||
|
||||
def __init__(self, path=None):
|
||||
self.path = Path(path) if path else core.config_dir() / "actions.json"
|
||||
self.by_trigger = {}
|
||||
self._mtime = None
|
||||
self.reload()
|
||||
|
||||
def reload(self):
|
||||
try:
|
||||
st = self.path.stat()
|
||||
except FileNotFoundError:
|
||||
self.by_trigger, self._mtime = {}, None
|
||||
return
|
||||
if st.st_mtime == self._mtime:
|
||||
return
|
||||
data = json.loads(self.path.read_text())
|
||||
self.by_trigger = {t: Action.from_json(a)
|
||||
for t, a in data.get("actions", {}).items()
|
||||
if t in TRIGGERS}
|
||||
self._mtime = st.st_mtime
|
||||
|
||||
def save(self):
|
||||
core._atomic_write_json(self.path, {
|
||||
"format": 1,
|
||||
"actions": {t: a.to_json() for t, a in sorted(self.by_trigger.items())}})
|
||||
self._mtime = self.path.stat().st_mtime
|
||||
|
||||
def get(self, trigger):
|
||||
return self.by_trigger.get(trigger)
|
||||
|
||||
def set(self, trigger, action):
|
||||
if trigger not in TRIGGERS:
|
||||
raise ValueError(f"{trigger} isn't a trigger key")
|
||||
self.by_trigger[trigger] = action.validate()
|
||||
|
||||
def remove(self, trigger):
|
||||
self.by_trigger.pop(trigger, None)
|
||||
|
||||
def prune(self, bound_keys):
|
||||
"""
|
||||
Forget actions whose trigger no longer sits on any control. Only
|
||||
call this when every control's binding is known, or it may throw
|
||||
away an action that's still on the pad.
|
||||
"""
|
||||
gone = [t for t in self.by_trigger if t not in bound_keys]
|
||||
for t in gone:
|
||||
del self.by_trigger[t]
|
||||
return gone
|
||||
|
||||
|
||||
def trigger_of(binding):
|
||||
"""The trigger key a binding sends, or None if it isn't one."""
|
||||
if binding and binding.keys and binding.keys.lower() in TRIGGERS:
|
||||
return binding.keys.lower()
|
||||
return None
|
||||
|
||||
|
||||
def trigger_free(trigger, store, used_keys, current=None):
|
||||
"""Can this control use this trigger without clashing with another?"""
|
||||
if trigger == current:
|
||||
return True
|
||||
return trigger not in store.by_trigger and trigger not in used_keys
|
||||
|
||||
|
||||
def pick_trigger(store, used_keys, current=None, want=None):
|
||||
"""
|
||||
A trigger key for a control.
|
||||
|
||||
If the person asked for a particular one and it's free, that. Otherwise
|
||||
keep the one the control already has, so editing an action doesn't need
|
||||
a write to the pad. Otherwise the first free one, trying the keys the
|
||||
desktop leaves alone before the ones it's known to act on. None when all
|
||||
twelve are taken.
|
||||
"""
|
||||
if want in TRIGGERS and trigger_free(want, store, used_keys, current):
|
||||
return want
|
||||
if current in TRIGGERS:
|
||||
return current
|
||||
for t in PREFERRED:
|
||||
if trigger_free(t, store, used_keys):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
# -------------------------------------------------------------- typing text
|
||||
#
|
||||
# Typing a phrase is harder than it sounds on Linux. Keyboards send key
|
||||
# positions, not letters, so typing "£" or "@" key by key means knowing the
|
||||
# person's layout, and emoji can't be typed that way at all. So we do what
|
||||
# text expanders like espanso do for anything longer than a word: put the
|
||||
# text on the clipboard, then press the paste shortcut. That works for any
|
||||
# text in any layout.
|
||||
#
|
||||
# Pressing the shortcut means sending key presses, which on Linux goes
|
||||
# through /dev/uinput: a virtual keyboard. It needs one permission rule,
|
||||
# the same one Steam installs for its controller support.
|
||||
|
||||
UINPUT = "/dev/uinput"
|
||||
UINPUT_RULE = ('KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", '
|
||||
'OPTIONS+="static_node=uinput"')
|
||||
|
||||
_EV_SYN, _EV_KEY, _SYN_REPORT = 0, 1, 0
|
||||
_UI_SET_EVBIT = 0x40045564 # _IOW('U', 100, int)
|
||||
_UI_SET_KEYBIT = 0x40045565 # _IOW('U', 101, int)
|
||||
_UI_DEV_CREATE = 0x5501 # _IO('U', 1)
|
||||
_UI_DEV_DESTROY = 0x5502 # _IO('U', 2)
|
||||
_KEY = {"ctrl": 29, "shift": 42, "v": 47}
|
||||
|
||||
|
||||
class VirtualKeyboard:
|
||||
"""
|
||||
A tiny keyboard that only knows Ctrl, Shift and V. Created once and kept,
|
||||
because a brand new input device takes the compositor a moment to notice,
|
||||
and the first paste would otherwise go missing.
|
||||
"""
|
||||
|
||||
def __init__(self, path=UINPUT, _open=os.open, _ioctl=fcntl.ioctl,
|
||||
_write=os.write, _sleep=time.sleep):
|
||||
import struct
|
||||
self._struct, self._write, self._sleep = struct, _write, _sleep
|
||||
self.fd = _open(path, os.O_WRONLY | os.O_NONBLOCK)
|
||||
_ioctl(self.fd, _UI_SET_EVBIT, _EV_KEY)
|
||||
for code in _KEY.values():
|
||||
_ioctl(self.fd, _UI_SET_KEYBIT, code)
|
||||
name = b"MacroPad virtual keyboard"
|
||||
# struct uinput_user_dev: name, input_id, ff_effects_max, 4 x 64 abs
|
||||
dev = struct.pack("<80sHHHHI256i", name, 0x03, 0x1189, 0x8841, 1, 0,
|
||||
*([0] * 256))
|
||||
_write(self.fd, dev)
|
||||
_ioctl(self.fd, _UI_DEV_CREATE)
|
||||
_sleep(0.3)
|
||||
self._ioctl = _ioctl
|
||||
|
||||
def _emit(self, typ, code, value):
|
||||
self._write(self.fd, self._struct.pack("@llHHi", 0, 0, typ, code, value))
|
||||
|
||||
def press(self, combo):
|
||||
"""Hold the modifiers, tap the key, let go, in that order."""
|
||||
keys = [_KEY[k] for k in combo.split("+")]
|
||||
for code in keys:
|
||||
self._emit(_EV_KEY, code, 1)
|
||||
self._emit(_EV_SYN, _SYN_REPORT, 0)
|
||||
self._sleep(0.02)
|
||||
for code in reversed(keys):
|
||||
self._emit(_EV_KEY, code, 0)
|
||||
self._emit(_EV_SYN, _SYN_REPORT, 0)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._ioctl(self.fd, _UI_DEV_DESTROY)
|
||||
finally:
|
||||
os.close(self.fd)
|
||||
|
||||
|
||||
_keyboard = None
|
||||
|
||||
|
||||
def _virtual_keyboard():
|
||||
global _keyboard
|
||||
if _keyboard is None:
|
||||
_keyboard = VirtualKeyboard()
|
||||
return _keyboard
|
||||
|
||||
|
||||
def on_wayland():
|
||||
return bool(os.environ.get("WAYLAND_DISPLAY")) or \
|
||||
os.environ.get("XDG_SESSION_TYPE") == "wayland"
|
||||
|
||||
|
||||
def _clipboard_tool():
|
||||
"""The program that can set the clipboard in this session, or None."""
|
||||
import shutil
|
||||
if on_wayland():
|
||||
return ["wl-copy"] if shutil.which("wl-copy") else None
|
||||
if shutil.which("xclip"):
|
||||
return ["xclip", "-selection", "clipboard"]
|
||||
if shutil.which("xsel"):
|
||||
return ["xsel", "--clipboard", "--input"]
|
||||
return None
|
||||
|
||||
|
||||
def set_clipboard(text, _run=subprocess.run):
|
||||
tool = _clipboard_tool()
|
||||
if not tool:
|
||||
raise OSError(errno.ENOENT, "no clipboard tool installed")
|
||||
# wl-copy and xclip hand the text to a background copy of themselves and
|
||||
# return straight away, so this doesn't hang.
|
||||
_run(tool, input=text.encode(), check=True, timeout=5,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def type_text(text, paste="ctrl+v", _clip=None, _keyboard_factory=None):
|
||||
"""Put the text on the clipboard and press paste."""
|
||||
(_clip or set_clipboard)(text)
|
||||
time.sleep(0.08) # let the clipboard change land first
|
||||
(_keyboard_factory or _virtual_keyboard)().press(paste)
|
||||
|
||||
|
||||
def _install_hint(package):
|
||||
try:
|
||||
text = Path("/etc/os-release").read_text()
|
||||
except OSError:
|
||||
text = ""
|
||||
family = " ".join(l.split("=", 1)[1].strip('"') for l in text.splitlines()
|
||||
if l.startswith(("ID=", "ID_LIKE=")))
|
||||
if "arch" in family:
|
||||
return f"sudo pacman -S {package}"
|
||||
if "fedora" in family or "rhel" in family:
|
||||
return f"sudo dnf install {package}"
|
||||
if "debian" in family or "ubuntu" in family:
|
||||
return f"sudo apt install {package}"
|
||||
return f"# install the {package} package with your package manager"
|
||||
|
||||
|
||||
def text_problems():
|
||||
"""
|
||||
What stands between this computer and typing text, as
|
||||
[(what's wrong, [commands that fix it])]. Empty means ready.
|
||||
"""
|
||||
out = []
|
||||
if not os.access(UINPUT, os.W_OK):
|
||||
out.append((
|
||||
"MacroPad needs permission to press the paste shortcut for you. "
|
||||
"It's the same permission Steam asks for.",
|
||||
[f"echo '{UINPUT_RULE}' | sudo tee /etc/udev/rules.d/60-macropad-uinput.rules",
|
||||
"sudo modprobe uinput",
|
||||
"sudo udevadm control --reload-rules && sudo udevadm trigger --sysname-match=uinput"]))
|
||||
if not _clipboard_tool():
|
||||
package = "wl-clipboard" if on_wayland() else "xclip"
|
||||
out.append((f"It also needs {package} to put the text on the clipboard.",
|
||||
[_install_hint(package)]))
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------ installed apps
|
||||
|
||||
_FIELD_CODE = re.compile(r"^%[a-zA-Z]$")
|
||||
|
||||
|
||||
def clean_exec(line):
|
||||
"""
|
||||
Turn a .desktop Exec line into a command we can run. Drops the %U style
|
||||
field codes, and Flatpak's @@u / @@ markers, which only make sense to a
|
||||
launcher that is about to pass files.
|
||||
"""
|
||||
try:
|
||||
parts = shlex.split(line)
|
||||
except ValueError:
|
||||
parts = line.split()
|
||||
parts = [p for p in parts if not _FIELD_CODE.match(p) and not p.startswith("@@")]
|
||||
return shlex.join(p.replace("%%", "%") for p in parts)
|
||||
|
||||
|
||||
def _app_dirs():
|
||||
home = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share")
|
||||
dirs = [home / "applications"]
|
||||
for d in (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":"):
|
||||
if d:
|
||||
dirs.append(Path(d) / "applications")
|
||||
dirs += [home / "flatpak/exports/share/applications",
|
||||
Path("/var/lib/flatpak/exports/share/applications")]
|
||||
return dirs
|
||||
|
||||
|
||||
def installed_apps(dirs=None):
|
||||
"""[(name, command)] for launchable apps, sorted, first definition wins."""
|
||||
seen, apps = set(), []
|
||||
for d in dirs or _app_dirs():
|
||||
for f in sorted(glob.glob(str(d / "*.desktop"))):
|
||||
ident = os.path.basename(f)
|
||||
if ident in seen:
|
||||
continue
|
||||
seen.add(ident)
|
||||
entry = _read_desktop_entry(f)
|
||||
if not entry or entry.get("Type") != "Application":
|
||||
continue
|
||||
if entry.get("NoDisplay") == "true" or entry.get("Hidden") == "true":
|
||||
continue
|
||||
if entry.get("Name") and entry.get("Exec"):
|
||||
apps.append((entry["Name"], clean_exec(entry["Exec"])))
|
||||
return sorted(apps, key=lambda a: a[0].lower())
|
||||
|
||||
|
||||
def _read_desktop_entry(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
except OSError:
|
||||
return None
|
||||
entry, inside = {}, False
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
inside = line == "[Desktop Entry]"
|
||||
continue
|
||||
if inside and "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
entry.setdefault(k.strip(), v.strip()) # skip Name[fr] etc.
|
||||
return entry
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- listener
|
||||
|
||||
def _descriptor_keyboard_report_id(raw):
|
||||
"""
|
||||
Does this HID descriptor describe a keyboard, and if so which report id
|
||||
carries it? (True, 0) means a keyboard with no report ids at all.
|
||||
"""
|
||||
i, usage_page, usage, report_id = 0, 0, 0, 0
|
||||
stack, in_kbd = [], False
|
||||
while i < len(raw):
|
||||
b = raw[i]
|
||||
if b == 0xFE: # long item
|
||||
i += 3 + (raw[i + 1] if i + 1 < len(raw) else 0)
|
||||
continue
|
||||
size = b & 3
|
||||
size = 4 if size == 3 else size
|
||||
tag, typ = b >> 4, (b >> 2) & 3
|
||||
data = int.from_bytes(raw[i + 1:i + 1 + size], "little")
|
||||
if typ == 1 and tag == 0:
|
||||
usage_page = data
|
||||
elif typ == 1 and tag == 8:
|
||||
report_id = data
|
||||
elif typ == 2 and tag == 0:
|
||||
usage = data
|
||||
elif typ == 0 and tag == 10: # collection
|
||||
stack.append(in_kbd)
|
||||
if usage_page == 1 and usage == 6:
|
||||
in_kbd = True
|
||||
elif typ == 0 and tag == 12: # end collection
|
||||
in_kbd = stack.pop() if stack else False
|
||||
elif typ == 0 and tag == 8 and in_kbd: # an input item
|
||||
return True, report_id
|
||||
i += 1 + size
|
||||
return False, 0
|
||||
|
||||
|
||||
def find_keyboard_nodes(vid=core.VID):
|
||||
"""[(hidraw path, keyboard report id)] for every pad keyboard interface."""
|
||||
out = []
|
||||
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
|
||||
dev = os.path.join(node, "device")
|
||||
try:
|
||||
uevent = Path(dev, "uevent").read_text()
|
||||
raw = Path(dev, "report_descriptor").read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
hid_id = next((l.split("=", 1)[1] for l in uevent.splitlines()
|
||||
if l.startswith("HID_ID=")), "")
|
||||
if hid_id.count(":") != 2 or hid_id.split(":")[1][-4:].lower() != vid:
|
||||
continue
|
||||
is_kbd, rid = _descriptor_keyboard_report_id(raw)
|
||||
if is_kbd:
|
||||
out.append(("/dev/" + os.path.basename(node), rid))
|
||||
return out
|
||||
|
||||
|
||||
def pressed_codes(report, report_id):
|
||||
"""
|
||||
The keycodes held down in one keyboard report, or None if this report
|
||||
isn't a keyboard one. Standard layout: [id], modifiers, reserved, six
|
||||
key slots.
|
||||
"""
|
||||
if report_id:
|
||||
if not report or report[0] != report_id:
|
||||
return None
|
||||
report = report[1:]
|
||||
if len(report) < 3:
|
||||
return None
|
||||
return {c for c in report[2:8] if c}
|
||||
|
||||
|
||||
class Listener:
|
||||
"""
|
||||
Turns trigger key presses into actions. Fires once per press, on the
|
||||
way down, however long the key is held.
|
||||
"""
|
||||
|
||||
def __init__(self, store, log=print, run=None):
|
||||
self.store = store
|
||||
self.log = log
|
||||
self.run = run or (lambda action: action.run())
|
||||
self.held = set()
|
||||
|
||||
def feed(self, codes):
|
||||
if codes is None:
|
||||
return []
|
||||
fired = []
|
||||
for code in codes - self.held:
|
||||
trigger = CODE_TO_TRIGGER.get(code)
|
||||
if not trigger:
|
||||
continue
|
||||
action = self.store.get(trigger)
|
||||
if not action:
|
||||
self.log(f"{trigger.upper()} pressed, but no action is set for it")
|
||||
continue
|
||||
self.log(f"{trigger.upper()}: {action.label()}")
|
||||
try:
|
||||
self.run(action)
|
||||
fired.append(trigger)
|
||||
except OSError as e:
|
||||
self.log(f"couldn't run {action.label()}: {e.strerror or e}")
|
||||
self.held = set(codes)
|
||||
return fired
|
||||
|
||||
|
||||
def runtime_dir():
|
||||
d = os.environ.get("XDG_RUNTIME_DIR") or str(Path.home() / ".cache")
|
||||
return Path(d)
|
||||
|
||||
|
||||
LOCK = "macropad-actions.lock"
|
||||
|
||||
|
||||
def listen(debug=False, log=print):
|
||||
"""
|
||||
Run until stopped. Survives the pad being unplugged, and picks up edits
|
||||
to actions.json without a restart.
|
||||
"""
|
||||
lock_path = runtime_dir() / LOCK
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock = open(lock_path, "a+")
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
log("Actions are already running.")
|
||||
return 1
|
||||
lock.seek(0)
|
||||
lock.truncate()
|
||||
lock.write(str(os.getpid()))
|
||||
lock.flush()
|
||||
|
||||
stop = {"now": False}
|
||||
signal.signal(signal.SIGTERM, lambda *_: stop.__setitem__("now", True))
|
||||
|
||||
store = ActionStore()
|
||||
listener = Listener(store, log=log)
|
||||
log(f"Listening for F13 to F24 on the pad. {len(store.by_trigger)} action(s) set.")
|
||||
|
||||
fds, waiting_said = {}, False
|
||||
try:
|
||||
while not stop["now"]:
|
||||
if not fds:
|
||||
for path, rid in find_keyboard_nodes():
|
||||
try:
|
||||
fds[os.open(path, os.O_RDONLY | os.O_NONBLOCK)] = (path, rid)
|
||||
except OSError as e:
|
||||
log(f"can't open {path}: {e.strerror}")
|
||||
if fds:
|
||||
log("Pad found: " + ", ".join(p for p, _ in fds.values()))
|
||||
waiting_said = False
|
||||
else:
|
||||
if not waiting_said:
|
||||
log("Waiting for the pad to be plugged in...")
|
||||
waiting_said = True
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
store.reload()
|
||||
ready, _, _ = select.select(list(fds), [], [], 1.0)
|
||||
for fd in ready:
|
||||
path, rid = fds[fd]
|
||||
try:
|
||||
data = os.read(fd, 64)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
except OSError as e:
|
||||
if e.errno in (errno.ENODEV, errno.EIO, errno.ENOENT):
|
||||
log("Pad unplugged.")
|
||||
for f in list(fds):
|
||||
os.close(f)
|
||||
fds.clear()
|
||||
listener.held.clear()
|
||||
break
|
||||
raise
|
||||
if debug:
|
||||
log(f"{path}: {data.hex(' ')}")
|
||||
listener.feed(pressed_codes(data, rid))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
for f in fds:
|
||||
os.close(f)
|
||||
lock.close()
|
||||
log("Stopped.")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- autostart
|
||||
|
||||
def autostart_path():
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
||||
return Path(base) / "autostart" / "macropad-actions.desktop"
|
||||
|
||||
|
||||
def listener_command():
|
||||
"""How to start the listener: the installed launcher if there is one."""
|
||||
wrapper = Path.home() / ".local/bin/macropad"
|
||||
if wrapper.exists():
|
||||
return [str(wrapper), "actions"], None
|
||||
return [sys.executable, "-m", "macropad_gui", "actions"], \
|
||||
str(Path(__file__).resolve().parent.parent)
|
||||
|
||||
|
||||
def autostart_enabled():
|
||||
return autostart_path().exists()
|
||||
|
||||
|
||||
def set_autostart(on):
|
||||
path = autostart_path()
|
||||
if not on:
|
||||
path.unlink(missing_ok=True)
|
||||
return
|
||||
argv, cwd = listener_command()
|
||||
lines = ["[Desktop Entry]", "Type=Application",
|
||||
"Name=MacroPad actions",
|
||||
"Comment=Runs the actions bound to your macro pad's keys",
|
||||
"Exec=" + " ".join(shlex.quote(a) for a in argv),
|
||||
"NoDisplay=true", "Terminal=false",
|
||||
"X-GNOME-Autostart-enabled=true"]
|
||||
if cwd:
|
||||
lines.append(f"Path={cwd}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def listener_pid():
|
||||
"""The running listener's pid, checked against /proc so we never signal a stranger."""
|
||||
try:
|
||||
pid = int((runtime_dir() / LOCK).read_text().strip())
|
||||
cmd = Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return pid if b"actions" in cmd and b"macropad" in cmd else None
|
||||
|
||||
|
||||
def start_listener():
|
||||
if listener_pid():
|
||||
return listener_pid()
|
||||
argv, cwd = listener_command()
|
||||
log = open(runtime_dir() / "macropad-actions.log", "a")
|
||||
p = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.DEVNULL,
|
||||
stdout=log, stderr=log, start_new_session=True)
|
||||
return p.pid
|
||||
|
||||
|
||||
def stop_listener():
|
||||
pid = listener_pid()
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return pid
|
||||
@@ -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())
|
||||
|
||||
@@ -476,8 +476,11 @@ class Diagnosis:
|
||||
RULES_DIRS = ("/etc/udev/rules.d", "/run/udev/rules.d")
|
||||
SYSTEM_RULES = ("/usr/lib/udev/rules.d", "/lib/udev/rules.d")
|
||||
SUGGESTED_RULE = "60-macropad.rules"
|
||||
RULE_LINE = (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", '
|
||||
f'ATTRS{{idProduct}}=="{PID}", TAG+="uaccess"')
|
||||
# Vendor wide on purpose. Pads in this family have several product ids, and
|
||||
# a rule tied to one of them leaves everybody else unable to read their own
|
||||
# hardware, which is exactly the point at which they give up.
|
||||
RULE_LINE = f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", TAG+="uaccess"'
|
||||
|
||||
|
||||
|
||||
def _uaccess_cutoff():
|
||||
@@ -569,8 +572,23 @@ def _keyd_warning():
|
||||
return None
|
||||
|
||||
|
||||
def on_linux():
|
||||
return sys.platform.startswith("linux")
|
||||
|
||||
|
||||
def diagnose(keyd=True):
|
||||
"""keyd=False skips the keyd check, which shells out - the GUI polls."""
|
||||
if not on_linux():
|
||||
return Diagnosis(
|
||||
"unsupported-os", f"MacroPad can't reach USB devices on {sys.platform}.",
|
||||
detail=["The window works anywhere Qt does, but the part that talks "
|
||||
"to the pad reads Linux's /dev/hidraw, which this system "
|
||||
"doesn't have.",
|
||||
"On Windows, the pad's own vendor software or "
|
||||
"ch57x-keyboard-tool will configure it. Porting this to "
|
||||
"Windows and macOS means swapping one module, and it's on "
|
||||
"the roadmap."])
|
||||
|
||||
warnings = [w for w in (_keyd_warning() if keyd else None,) if w]
|
||||
everything = proto.find_devices()
|
||||
found = [d for d in everything if d["pid"] == PID]
|
||||
@@ -580,7 +598,7 @@ def diagnose(keyd=True):
|
||||
dev = Device(path=other["path"], report_id=other["report_id"],
|
||||
writable=other["writable"], name=other.get("name", ""),
|
||||
vid=other["vid"], pid=other["pid"])
|
||||
return Diagnosis(
|
||||
d = Diagnosis(
|
||||
"unsupported",
|
||||
f"Found a {other['vid']}:{other['pid']} pad, which isn't one this "
|
||||
"app knows.",
|
||||
@@ -591,6 +609,14 @@ def diagnose(keyd=True):
|
||||
"and if the pad doesn't answer properly nothing else "
|
||||
"happens."],
|
||||
warnings=warnings)
|
||||
if not dev.writable:
|
||||
d.detail = ["This pad isn't readable yet. An older permission rule "
|
||||
"may name a single product id; this one covers every "
|
||||
"pad from the same maker.",
|
||||
"Run these, then unplug the pad and plug it back in."]
|
||||
d.fix = [f"echo '{RULE_LINE}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}",
|
||||
"sudo udevadm control --reload-rules && sudo udevadm trigger"]
|
||||
return d
|
||||
|
||||
if not found:
|
||||
if _on_usb_bus():
|
||||
|
||||
BIN
macropad_gui/fonts/ArchitectsDaughter-Regular.ttf
Normal file
BIN
macropad_gui/fonts/ArchitectsDaughter-Regular.ttf
Normal file
Binary file not shown.
93
macropad_gui/fonts/OFL.txt
Normal file
93
macropad_gui/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2010, Kimberly Geswein (kimberlygeswein.com)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -31,6 +31,8 @@ for _row, _start in (("qwertyuiop", 16), ("asdfghjkl", 30), ("zxcvbnm", 44)):
|
||||
EVDEV[_start + _i] = _c
|
||||
for _i in range(1, 11):
|
||||
EVDEV[58 + _i] = f"f{_i}" # F1 = 59 .. F10 = 68
|
||||
for _i in range(13, 25):
|
||||
EVDEV[170 + _i] = f"f{_i}" # F13 = 183 .. F24 = 194
|
||||
|
||||
for _name in EVDEV.values():
|
||||
assert _name in proto.KEYCODES, _name
|
||||
|
||||
@@ -11,21 +11,86 @@ Visual states:
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
|
||||
from PySide6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF,
|
||||
QPainter, QPainterPath, QPen, QTransform)
|
||||
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||
|
||||
from . import core
|
||||
from . import core, themes
|
||||
|
||||
WINDOW = QColor("#17191b")
|
||||
BOARD = QColor("#202326")
|
||||
INK = QColor("#d8d4cb") # silkscreen white, slightly warm
|
||||
INK_DIM = QColor("#72767b")
|
||||
HATCH = QColor("#34383c")
|
||||
TAPE = QColor("#eb8a2f") # Kapton orange
|
||||
THEME = themes.get(themes.DEFAULT)
|
||||
|
||||
# The colours everything draws with. Switching theme changes these objects in
|
||||
# place, so every widget and module holding one follows along.
|
||||
WINDOW = QColor(THEME.window)
|
||||
BOARD = QColor(THEME.board)
|
||||
INK = QColor(THEME.ink) # silkscreen white, slightly warm
|
||||
INK_DIM = QColor(THEME.ink_dim)
|
||||
HATCH = QColor(THEME.hatch)
|
||||
TAPE = QColor(THEME.accent) # Kapton orange, in the default theme
|
||||
|
||||
|
||||
def apply_theme(theme):
|
||||
global THEME
|
||||
THEME = theme
|
||||
for colour, value in ((WINDOW, theme.window), (BOARD, theme.board),
|
||||
(INK, theme.ink), (INK_DIM, theme.ink_dim),
|
||||
(HATCH, theme.hatch), (TAPE, theme.accent)):
|
||||
colour.setRgba(QColor(value).rgba())
|
||||
|
||||
|
||||
_FAMILIES = None
|
||||
|
||||
|
||||
def _families():
|
||||
global _FAMILIES
|
||||
if _FAMILIES is None:
|
||||
_FAMILIES = set(QFontDatabase.families())
|
||||
return _FAMILIES
|
||||
|
||||
|
||||
def _seed(tag):
|
||||
"""A stable number for a name. Python's own hash() changes every run."""
|
||||
return zlib.crc32(tag.encode())
|
||||
|
||||
|
||||
def _wobble_path(path, amount, seed, step=4.0):
|
||||
"""
|
||||
A pencil version of a path. Resampled finely, then nudged sideways by
|
||||
two slow waves with random phases, so the line wanders without jitter.
|
||||
Seeded, so a key looks the same every time it's drawn.
|
||||
"""
|
||||
rnd = random.Random(seed)
|
||||
out = QPainterPath()
|
||||
for poly in path.toSubpathPolygons():
|
||||
pts = list(poly)
|
||||
if len(pts) < 2:
|
||||
continue
|
||||
dense = []
|
||||
for a, b in zip(pts, pts[1:]):
|
||||
seg = QLineF(a, b)
|
||||
n = max(1, int(seg.length() / step))
|
||||
dense.extend(seg.pointAt(k / n) for k in range(n))
|
||||
dense.append(pts[-1])
|
||||
f1, f2 = rnd.uniform(0.035, 0.06), rnd.uniform(0.10, 0.16)
|
||||
p1, p2 = rnd.uniform(0, math.tau), rnd.uniform(0, math.tau)
|
||||
dist, moved = 0.0, []
|
||||
for i, pt in enumerate(dense):
|
||||
if i:
|
||||
dist += QLineF(dense[i - 1], pt).length()
|
||||
nxt, prv = dense[min(i + 1, len(dense) - 1)], dense[max(i - 1, 0)]
|
||||
dx, dy = nxt.x() - prv.x(), nxt.y() - prv.y()
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
off = amount * (0.65 * math.sin(dist * f1 + p1) + 0.35 * math.sin(dist * f2 + p2))
|
||||
moved.append(QPointF(pt.x() - dy / length * off, pt.y() + dx / length * off))
|
||||
out.moveTo(moved[0])
|
||||
for q in moved[1:]:
|
||||
out.lineTo(q)
|
||||
return out
|
||||
|
||||
# Geometry in board units; the view scales it to fit.
|
||||
K, G, M = 96.0, 16.0, 30.0 # key size, gap, board margin
|
||||
@@ -80,6 +145,7 @@ class PadView(QWidget):
|
||||
self.drag_at = None # where the pointer is, in board units
|
||||
self.drop = None # (row, col) it would land in
|
||||
self._wobble = 0
|
||||
self._wobble_cache = {}
|
||||
self._wobbler = QTimer(self)
|
||||
self._wobbler.setInterval(130)
|
||||
self._wobbler.timeout.connect(self._tick)
|
||||
@@ -127,6 +193,7 @@ class PadView(QWidget):
|
||||
# ------------------------------------------------------------ layout
|
||||
|
||||
def _layout(self):
|
||||
self._wobble_cache = {}
|
||||
self.keys = {} # key -> QRectF
|
||||
self.dials = {} # dial -> centre QPointF
|
||||
self.rows = {} # dial action -> legend row QRectF
|
||||
@@ -175,6 +242,8 @@ class PadView(QWidget):
|
||||
def _transform(self):
|
||||
pad = 24
|
||||
avail = QRectF(self.rect()).adjusted(pad, pad, -pad, -pad)
|
||||
if THEME.title_block: # keep clear of the title block
|
||||
avail.adjust(0, 0, 0, -92)
|
||||
s = min(avail.width() / self.board.width(),
|
||||
avail.height() / self.board.height())
|
||||
s = max(0.4, min(s, 1.6))
|
||||
@@ -297,17 +366,22 @@ class PadView(QWidget):
|
||||
|
||||
# ------------------------------------------------------------ paint
|
||||
|
||||
# ------------------------------------------------------------ paint
|
||||
|
||||
def paintEvent(self, _):
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.fillRect(self.rect(), WINDOW)
|
||||
if THEME.grid:
|
||||
self._paint_grid(p)
|
||||
p.setTransform(self._transform())
|
||||
|
||||
board = QPainterPath()
|
||||
board.addRoundedRect(self.board, 18, 18)
|
||||
p.fillPath(board, BOARD)
|
||||
p.setPen(QPen(INK_DIM, 1.2))
|
||||
p.drawPath(board)
|
||||
# On themes where the pad is only an outline, draw that in full ink.
|
||||
edge = INK if THEME.board == THEME.window else INK_DIM
|
||||
self._stroke(p, board, QPen(edge, 1.2), "board")
|
||||
|
||||
if self.editing:
|
||||
for spot, rect in self.cells.items():
|
||||
@@ -326,7 +400,7 @@ class PadView(QWidget):
|
||||
p.save()
|
||||
if self.editing:
|
||||
# a small alternating tilt, the way icons shake when movable
|
||||
lean = 0.55 if (hash(key) + self._wobble) % 2 else -0.55
|
||||
lean = 0.55 if (_seed(key) + self._wobble) % 2 else -0.55
|
||||
p.translate(rect.center())
|
||||
p.rotate(lean)
|
||||
p.translate(-rect.center())
|
||||
@@ -358,22 +432,167 @@ class PadView(QWidget):
|
||||
p.drawText(QRectF(M, self.board.bottom() - 34, 200, 16),
|
||||
Qt.AlignLeft | Qt.AlignVCenter, self.device_id)
|
||||
|
||||
if THEME.title_block:
|
||||
p.resetTransform()
|
||||
self._paint_sheet(p)
|
||||
|
||||
# ------------------------------------------------------ theme drawing
|
||||
|
||||
def _stroke(self, p, path, pen, tag):
|
||||
"""
|
||||
Draw an outline. On a pencil theme the line wanders a little and is
|
||||
gone over a second time, lighter, the way a hand draws.
|
||||
"""
|
||||
p.setBrush(Qt.NoBrush)
|
||||
if not THEME.wobble:
|
||||
p.setPen(pen)
|
||||
p.drawPath(path)
|
||||
return
|
||||
p.setPen(pen)
|
||||
p.drawPath(self._wobbled(path, THEME.wobble, tag))
|
||||
again = QPen(pen)
|
||||
c = QColor(pen.color())
|
||||
c.setAlpha(int(c.alpha() * 0.45))
|
||||
again.setColor(c)
|
||||
again.setWidthF(max(0.6, pen.widthF() * 0.7))
|
||||
p.setPen(again)
|
||||
p.drawPath(self._wobbled(path, THEME.wobble * 0.8, tag + "'"))
|
||||
|
||||
def _wobbled(self, path, amount, tag):
|
||||
# Paths are in board units, so they only change when the layout
|
||||
# does; cache them rather than recompute on every hover.
|
||||
b = path.boundingRect()
|
||||
key = (tag, amount, round(b.x(), 1), round(b.y(), 1),
|
||||
round(b.width(), 1), round(b.height(), 1))
|
||||
got = self._wobble_cache.get(key)
|
||||
if got is None:
|
||||
got = self._wobble_cache[key] = _wobble_path(path, amount, _seed(tag))
|
||||
return got
|
||||
|
||||
def _legend_font(self, px, roomy=True):
|
||||
"""roomy: there's space to spare, as on a keycap; not on a dial legend."""
|
||||
if THEME.font and THEME.font in _families():
|
||||
f = QFont(THEME.font)
|
||||
f.setPixelSize(px + (2 if roomy else 0)) # handwriting reads small
|
||||
elif THEME.mono_legends:
|
||||
f = QFont(self.mono)
|
||||
f.setPixelSize(px - 1)
|
||||
else:
|
||||
f = QFont(self.font())
|
||||
f.setPixelSize(px)
|
||||
return f
|
||||
|
||||
@staticmethod
|
||||
def _lettering(text):
|
||||
return text.upper() if THEME.caps else text
|
||||
|
||||
def _paint_grid(self, p):
|
||||
"""Drawing paper: fine lines, and a heavier one every fifth."""
|
||||
r = self.rect()
|
||||
fine, heavy = QColor(HATCH), QColor(HATCH)
|
||||
fine.setAlpha(70)
|
||||
heavy.setAlpha(150)
|
||||
step = 20
|
||||
for i, x in enumerate(range(0, r.width(), step)):
|
||||
p.setPen(QPen(heavy if i % 5 == 0 else fine, 1))
|
||||
p.drawLine(x, 0, x, r.height())
|
||||
for i, y in enumerate(range(0, r.height(), step)):
|
||||
p.setPen(QPen(heavy if i % 5 == 0 else fine, 1))
|
||||
p.drawLine(0, y, r.width(), y)
|
||||
|
||||
def _paint_sheet(self, p):
|
||||
"""A drawing border and a title block in the bottom right corner."""
|
||||
import getpass
|
||||
sheet = QRectF(self.rect()).adjusted(8, 8, -9, -9)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.setPen(QPen(INK, 1.4))
|
||||
p.drawRect(sheet)
|
||||
|
||||
w, h = min(330.0, sheet.width() - 20), 84.0
|
||||
tb = QRectF(sheet.right() - w, sheet.bottom() - h, w, h)
|
||||
p.fillRect(tb, WINDOW)
|
||||
p.setPen(QPen(INK, 1.2))
|
||||
p.drawRect(tb)
|
||||
row = h / 3
|
||||
half = tb.left() + w / 2
|
||||
p.setPen(QPen(INK, 0.8))
|
||||
p.drawLine(QPointF(tb.left(), tb.top() + row), QPointF(tb.right(), tb.top() + row))
|
||||
p.drawLine(QPointF(tb.left(), tb.top() + 2 * row), QPointF(tb.right(), tb.top() + 2 * row))
|
||||
p.drawLine(QPointF(half, tb.top() + row), QPointF(half, tb.bottom()))
|
||||
|
||||
try:
|
||||
who = getpass.getuser()
|
||||
except Exception:
|
||||
who = ""
|
||||
lay = self.layout
|
||||
knobs = f", {lay.knobs} KNOB{'S' if lay.knobs != 1 else ''}" if lay.knobs else ""
|
||||
cells = [
|
||||
(QRectF(tb.left(), tb.top(), w, row), "TITLE", "MACROPAD"),
|
||||
(QRectF(tb.left(), tb.top() + row, w / 2, row), "DWG NO", self.device_id),
|
||||
(QRectF(half, tb.top() + row, w / 2, row), "SCALE", "1:1 SHEET 1 OF 1"),
|
||||
(QRectF(tb.left(), tb.top() + 2 * row, w / 2, row), "DRAWN", who.upper()),
|
||||
(QRectF(half, tb.top() + 2 * row, w / 2, row), "LAYOUT",
|
||||
f"{lay.rows}X{lay.cols}{knobs}"),
|
||||
]
|
||||
small, big = QFont(self.mono), QFont(self.mono)
|
||||
small.setPixelSize(7)
|
||||
big.setPixelSize(11)
|
||||
big.setBold(True)
|
||||
for rect, label, value in cells:
|
||||
p.setFont(small)
|
||||
p.setPen(INK_DIM)
|
||||
p.drawText(rect.adjusted(5, 2, -4, 0), Qt.AlignLeft | Qt.AlignTop, label)
|
||||
p.setFont(big)
|
||||
p.setPen(INK)
|
||||
p.drawText(rect.adjusted(5, 0, -4, -3), Qt.AlignLeft | Qt.AlignBottom,
|
||||
QFontMetricsF(big).elidedText(value, Qt.ElideRight,
|
||||
rect.width() - 10))
|
||||
|
||||
def _centre_lines(self, p, c):
|
||||
"""Chain lines through a round feature's centre: long dash, short dash."""
|
||||
pen = QPen(INK_DIM, 0.8)
|
||||
pen.setDashPattern([14, 3, 3, 3])
|
||||
p.setPen(pen)
|
||||
reach = R_OUT + 10
|
||||
p.drawLine(QPointF(c.x() - reach, c.y()), QPointF(c.x() + reach, c.y()))
|
||||
p.drawLine(QPointF(c.x(), c.y() - reach), QPointF(c.x(), c.y() + reach))
|
||||
|
||||
# ------------------------------------------------------------ pieces
|
||||
|
||||
def _outline_pen(self, control, known):
|
||||
if control == self.current:
|
||||
return QPen(TAPE, 2.2)
|
||||
if control == self.hover:
|
||||
return QPen(INK, 1.6)
|
||||
return QPen(INK if known else INK_DIM, 1.1)
|
||||
pen = QPen(INK if known else INK_DIM, 1.1)
|
||||
if not known and THEME.unknown == "hidden":
|
||||
pen.setDashPattern([5, 3]) # hidden line, drawing convention
|
||||
return pen
|
||||
|
||||
def _hatch(self, p, path, rect, step=9.0):
|
||||
def _unknown_fill(self, p, path, rect, tag, step=9.0):
|
||||
if THEME.unknown == "hatch":
|
||||
self._hatch(p, path, rect, tag, step)
|
||||
|
||||
def _hatch(self, p, path, rect, tag="", step=9.0):
|
||||
p.save()
|
||||
p.setClipPath(path)
|
||||
p.setPen(QPen(HATCH, 1.4))
|
||||
pen = QPen(HATCH, 1.4)
|
||||
x = rect.left() - rect.height()
|
||||
i = 0
|
||||
while x < rect.right():
|
||||
p.drawLine(QPointF(x, rect.bottom()),
|
||||
QPointF(x + rect.height(), rect.top()))
|
||||
a = QPointF(x, rect.bottom())
|
||||
b = QPointF(x + rect.height(), rect.top())
|
||||
if THEME.wobble:
|
||||
line = QPainterPath(a)
|
||||
line.lineTo(b)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.drawPath(self._wobbled(line, THEME.wobble * 0.6, f"{tag}h{i}"))
|
||||
else:
|
||||
p.setPen(pen)
|
||||
p.drawLine(a, b)
|
||||
x += step
|
||||
i += 1
|
||||
p.restore()
|
||||
|
||||
def _tape(self, p, at, length=30.0, width=9.0):
|
||||
@@ -392,11 +611,10 @@ class PadView(QWidget):
|
||||
look = self.looks.get(key, Look())
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(rect, 5, 5)
|
||||
if not look.known and not look.pending:
|
||||
self._hatch(p, path, rect)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.setPen(self._outline_pen(key, look.known or look.pending))
|
||||
p.drawPath(path)
|
||||
unknown = not look.known and not look.pending
|
||||
if unknown:
|
||||
self._unknown_fill(p, path, rect, key)
|
||||
self._stroke(p, path, self._outline_pen(key, look.known or look.pending), key)
|
||||
|
||||
f = QFont(self.mono)
|
||||
f.setPixelSize(10)
|
||||
@@ -405,14 +623,13 @@ class PadView(QWidget):
|
||||
p.drawText(rect.adjusted(8, 6, -8, -6), Qt.AlignLeft | Qt.AlignTop,
|
||||
f"0x{core.action_byte(key):02x}")
|
||||
|
||||
f = QFont(self.font())
|
||||
f.setPixelSize(13)
|
||||
f = self._legend_font(13)
|
||||
p.setFont(f)
|
||||
if look.pending:
|
||||
p.setPen(TAPE)
|
||||
else:
|
||||
p.setPen(INK if look.known and not look.quiet else INK_DIM)
|
||||
text = look.text if (look.known or look.pending) else "Unknown"
|
||||
text = self._lettering(look.text if (look.known or look.pending) else "Unknown")
|
||||
body = rect.adjusted(8, 22, -8, -10)
|
||||
# Let 'Meta+Ctrl+Left' break after a '+' instead of being cut off.
|
||||
text = text.replace("+", "+\u200b")
|
||||
@@ -442,6 +659,8 @@ class PadView(QWidget):
|
||||
tip.y() - 3.5 * math.sin(a)))
|
||||
|
||||
def _paint_dial(self, p, dial, c):
|
||||
if THEME.centre_lines:
|
||||
self._centre_lines(p, c)
|
||||
# Ring segments for turn left / turn right, centre for press.
|
||||
for act in core.DIAL_ACTIONS:
|
||||
control = f"{dial}-{act}"
|
||||
@@ -449,44 +668,41 @@ class PadView(QWidget):
|
||||
seg = self._segment(dial, act)
|
||||
if act != "push":
|
||||
if not look.known and not look.pending:
|
||||
self._hatch(p, seg, seg.boundingRect(), 7.0)
|
||||
self._unknown_fill(p, seg, seg.boundingRect(), control, 7.0)
|
||||
if control == self.current:
|
||||
fill = QColor(TAPE)
|
||||
fill.setAlpha(55)
|
||||
p.fillPath(seg, fill)
|
||||
p.setPen(self._outline_pen(control, look.known or look.pending))
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.drawPath(seg)
|
||||
self._stroke(p, seg, self._outline_pen(control, look.known or look.pending),
|
||||
control)
|
||||
|
||||
push = f"{dial}-push"
|
||||
look = self.looks.get(push, Look())
|
||||
knob = QPainterPath()
|
||||
knob.addEllipse(c, R_KNOB, R_KNOB)
|
||||
if not look.known and not look.pending:
|
||||
self._hatch(p, knob, knob.boundingRect(), 7.0)
|
||||
self._unknown_fill(p, knob, knob.boundingRect(), push, 7.0)
|
||||
if push == self.current:
|
||||
fill = QColor(TAPE)
|
||||
fill.setAlpha(55)
|
||||
p.fillPath(knob, fill)
|
||||
p.setPen(self._outline_pen(push, look.known or look.pending))
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.drawPath(knob)
|
||||
self._stroke(p, knob, self._outline_pen(push, look.known or look.pending), push)
|
||||
# knurling, the way a footprint marks a rotary encoder
|
||||
p.setPen(QPen(INK_DIM, 1.0))
|
||||
for i in range(24):
|
||||
a = math.radians(i * 15)
|
||||
p.drawLine(QPointF(c.x() + (R_KNOB - 4) * math.cos(a), c.y() + (R_KNOB - 4) * math.sin(a)),
|
||||
QPointF(c.x() + (R_KNOB - 1) * math.cos(a), c.y() + (R_KNOB - 1) * math.sin(a)))
|
||||
if not THEME.wobble:
|
||||
p.setPen(QPen(INK_DIM, 1.0))
|
||||
for i in range(24):
|
||||
a = math.radians(i * 15)
|
||||
p.drawLine(QPointF(c.x() + (R_KNOB - 4) * math.cos(a), c.y() + (R_KNOB - 4) * math.sin(a)),
|
||||
QPointF(c.x() + (R_KNOB - 1) * math.cos(a), c.y() + (R_KNOB - 1) * math.sin(a)))
|
||||
|
||||
f = QFont(self.font())
|
||||
f.setPixelSize(11)
|
||||
f = self._legend_font(11)
|
||||
p.setFont(f)
|
||||
label = QRectF(c.x() - 21, c.y() - 8, 42, 16)
|
||||
label = QRectF(c.x() - 23, c.y() - 8, 46, 16)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(BOARD)
|
||||
p.drawRoundedRect(label, 3, 3)
|
||||
p.setPen(TAPE if push == self.current else INK_DIM)
|
||||
p.drawText(label, Qt.AlignCenter, f"Dial {dial[4:]}")
|
||||
p.drawText(label, Qt.AlignCenter, self._lettering(f"Dial {dial[4:]}"))
|
||||
|
||||
# Legend rows: the silkscreen text next to the part.
|
||||
for act in core.DIAL_ACTIONS:
|
||||
@@ -519,12 +735,11 @@ class PadView(QWidget):
|
||||
p.drawText(rect.adjusted(0, 0, -4, 0), Qt.AlignRight | Qt.AlignVCenter,
|
||||
f"{core.action_byte(control):02x}")
|
||||
|
||||
f = QFont(self.font())
|
||||
f.setPixelSize(12)
|
||||
f = self._legend_font(12, roomy=False)
|
||||
p.setFont(f)
|
||||
p.setPen(TAPE if look.pending else
|
||||
(INK if look.known and not look.quiet else INK_DIM))
|
||||
text = look.text if (look.known or look.pending) else "Unknown"
|
||||
text = self._lettering(look.text if (look.known or look.pending) else "Unknown")
|
||||
body = rect.adjusted(22, 0, -26, 0)
|
||||
p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter,
|
||||
QFontMetricsF(f).elidedText(text, Qt.ElideRight, body.width()))
|
||||
|
||||
120
macropad_gui/themes.py
Normal file
120
macropad_gui/themes.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Themes: a palette, and a drawing style for the pad.
|
||||
|
||||
Most themes only change colours. Two change how the pad is drawn:
|
||||
|
||||
Blueprint an engineering drawing: white linework on cyanotype blue, a
|
||||
faint grid, unknown keys as dashed hidden lines, chain lines
|
||||
through the knob centres, and a title block in the corner
|
||||
Sketch pencil on paper: lines that wander a little and are gone over
|
||||
twice, as a hand would, in a handwriting font
|
||||
|
||||
Nord and Catppuccin use their published palettes, both MIT licensed.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Theme:
|
||||
key: str
|
||||
name: str
|
||||
dark: bool
|
||||
|
||||
# palette, as hex
|
||||
window: str # behind everything
|
||||
board: str # the pad's face
|
||||
ink: str # lines and legends
|
||||
ink_dim: str # secondary text, unknown outlines
|
||||
hatch: str # hatching, grid, quiet lines
|
||||
accent: str # selection and unwritten changes
|
||||
accent_text: str # text on an accent filled button
|
||||
good: str
|
||||
bad: str
|
||||
line: str # widget borders
|
||||
field: str # text boxes
|
||||
|
||||
# drawing style
|
||||
unknown: str = "hatch" # hatch, or "hidden": dashed outline
|
||||
grid: bool = False # a faint drawing grid behind the pad
|
||||
title_block: bool = False # sheet border and title block
|
||||
centre_lines: bool = False # chain lines through knob centres
|
||||
wobble: float = 0.0 # how far a pencil line wanders, board units
|
||||
caps: bool = False # legends in capitals, like drawing lettering
|
||||
font: str = "" # legend font; empty means the system font
|
||||
mono_legends: bool = False # draw legends in the monospace font
|
||||
notes: tuple = field(default=())
|
||||
|
||||
|
||||
SILKSCREEN = Theme(
|
||||
"silkscreen", "Silkscreen", True,
|
||||
window="#17191b", board="#202326", ink="#d8d4cb", ink_dim="#72767b",
|
||||
hatch="#34383c", accent="#eb8a2f", accent_text="#1b1206",
|
||||
good="#8db36b", bad="#e0584f", line="#3a3e42", field="#121416")
|
||||
|
||||
WHITE_BOARD = Theme(
|
||||
"white-board", "White board", False,
|
||||
window="#e4e2dc", board="#f7f6f2", ink="#232323", ink_dim="#86847e",
|
||||
hatch="#d6d2c8", accent="#cf6a17", accent_text="#ffffff",
|
||||
good="#3f7d20", bad="#c0392b", line="#cbc7bd", field="#ffffff")
|
||||
|
||||
BLUEPRINT = Theme(
|
||||
"blueprint", "Blueprint", True,
|
||||
window="#123b6d", board="#123b6d", ink="#f2f5f8", ink_dim="#93b3d8",
|
||||
hatch="#285a92", accent="#ffd166", accent_text="#0b2545",
|
||||
good="#a8e6a1", bad="#ff9a8f", line="#3d6aa3", field="#0d2e57",
|
||||
unknown="hidden", grid=True, title_block=True, centre_lines=True,
|
||||
caps=True, mono_legends=True)
|
||||
|
||||
SKETCH = Theme(
|
||||
"sketch", "Sketch", False,
|
||||
window="#f3efe6", board="#f3efe6", ink="#2e2b28", ink_dim="#8c857a",
|
||||
hatch="#b9b1a3", accent="#c8553d", accent_text="#ffffff",
|
||||
good="#4f7a36", bad="#b03a2e", line="#cfc6b6", field="#fbf9f4",
|
||||
wobble=1.6, font="Architects Daughter")
|
||||
|
||||
NORD = Theme(
|
||||
"nord", "Nord", True,
|
||||
window="#2e3440", board="#3b4252", ink="#eceff4", ink_dim="#7b88a1",
|
||||
hatch="#434c5e", accent="#88c0d0", accent_text="#2e3440",
|
||||
good="#a3be8c", bad="#bf616a", line="#4c566a", field="#292e39")
|
||||
|
||||
MOCHA = Theme(
|
||||
"catppuccin-mocha", "Catppuccin Mocha", True,
|
||||
window="#181825", board="#1e1e2e", ink="#cdd6f4", ink_dim="#7f849c",
|
||||
hatch="#313244", accent="#cba6f7", accent_text="#1e1e2e",
|
||||
good="#a6e3a1", bad="#f38ba8", line="#45475a", field="#11111b")
|
||||
|
||||
LATTE = Theme(
|
||||
"catppuccin-latte", "Catppuccin Latte", False,
|
||||
window="#e6e9ef", board="#eff1f5", ink="#4c4f69", ink_dim="#8c8fa1",
|
||||
hatch="#ccd0da", accent="#8839ef", accent_text="#eff1f5",
|
||||
good="#40a02b", bad="#d20f39", line="#bcc0cc", field="#dce0e8")
|
||||
|
||||
THEMES = {t.key: t for t in (SILKSCREEN, WHITE_BOARD, BLUEPRINT, SKETCH,
|
||||
NORD, MOCHA, LATTE)}
|
||||
DEFAULT = SILKSCREEN.key
|
||||
|
||||
FONTS = Path(__file__).resolve().parent / "fonts"
|
||||
|
||||
|
||||
def get(key):
|
||||
return THEMES.get(key, THEMES[DEFAULT])
|
||||
|
||||
|
||||
def mix(a, b, t):
|
||||
"""Blend two hex colours; t = 0 gives a, 1 gives b."""
|
||||
a, b = a.lstrip("#"), b.lstrip("#")
|
||||
ca = [int(a[i:i + 2], 16) for i in (0, 2, 4)]
|
||||
cb = [int(b[i:i + 2], 16) for i in (0, 2, 4)]
|
||||
return "#" + "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(ca, cb))
|
||||
|
||||
|
||||
def load_fonts():
|
||||
"""Register the fonts shipped with the app. Safe to call more than once."""
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from . import padview
|
||||
for f in sorted(FONTS.glob("*.ttf")):
|
||||
QFontDatabase.addApplicationFont(str(f))
|
||||
padview._FAMILIES = None
|
||||
Reference in New Issue
Block a user