Themes, actions, text snippets and a key picker

This commit is contained in:
Mohamed Mahjoub
2026-09-22 17:16:17 +01:00
committed by GitHub
parent ed707b1ae7
commit 8a18015c34
35 changed files with 2470 additions and 142 deletions

View File

@@ -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
View 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

View File

@@ -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())

View File

@@ -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():

Binary file not shown.

View 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.

View File

@@ -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

View File

@@ -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
View 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