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

119
README.md
View File

@@ -98,6 +98,8 @@ The part that matters is **`1189:8840`**. Ignore "Acer": these pads borrow that
| 📖 **Reads your pad** | Opens and asks the pad what it's actually holding, so you start from the truth. | | 📖 **Reads your pad** | Opens and asks the pad what it's actually holding, so you start from the truth. |
| 🔎 **Identifies unknown pads** | Asks an unrecognised pad about itself, read only, and refuses to write if it doesn't answer properly. | | 🔎 **Identifies unknown pads** | Asks an unrecognised pad about itself, read only, and refuses to write if it doesn't answer properly. |
| 📐 **Layout editor** | Drag the keys around until the drawing matches your pad. | | 📐 **Layout editor** | Drag the keys around until the drawing matches your pad. |
| 🎨 **Themes** | Seven, including an engineering blueprint and a pencil sketch. |
| 🚀 **Actions** | Make a key open a web page, launch an app, type a phrase or run a command. Optional. |
| 🧠 **Remembers what it wrote** | Keeps its own notes too, and marks anything it isn't sure about rather than guessing. | | 🧠 **Remembers what it wrote** | Keeps its own notes too, and marks anything it isn't sure about rather than guessing. |
| 🩺 **Tells you what's wrong** | Unplugged? No permission? Wrong pad? You get a plain explanation and the exact fix, with a copy button. | | 🩺 **Tells you what's wrong** | Unplugged? No permission? Wrong pad? You get a plain explanation and the exact fix, with a copy button. |
| 📥 **Imports from the vendor app** | Set your pad up on Windows before? Bring that setup across. | | 📥 **Imports from the vendor app** | Set your pad up on Windows before? Bring that setup across. |
@@ -129,6 +131,7 @@ Both write straight to the pad's own memory, so you can even use both. Pick whic
| Mouse actions, LED modes, extra layers | Not yet | Yes | | Mouse actions, LED modes, extra layers | Not yet | Yes |
| Record a shortcut by pressing it | Yes | No | | Record a shortcut by pressing it | Yes | No |
| Read what's on the pad | Yes | No | | Read what's on the pad | Yes | No |
| Open web pages, apps, commands, type text | Yes, optional | No |
| Install and permissions | One line does both | AUR, a download or `cargo install`; rule by hand | | Install and permissions | One line does both | AUR, a download or `cargo install`; rule by hand |
**Short version:** new to Linux, or just want to click things? MacroPad. Want every feature, a different pad, or a config file you can keep in git? ch57x-keyboard-tool. **Short version:** new to Linux, or just want to click things? MacroPad. Want every feature, a different pad, or a config file you can keep in git? ch57x-keyboard-tool.
@@ -260,10 +263,39 @@ The bottom bar tells you exactly what happened. Green means it worked.
### 4. Press the key ### 4. Press the key
**Your bindings live on the pad itself**, so they keep working after a reboot, on another computer, even on Windows. Nothing stays running in the background, and you can close the app. **Your bindings live on the pad itself**, so they keep working after a reboot, on another computer, even on Windows. Nothing stays running in the background, and you can close the app. (The one exception is [actions](#-make-a-key-open-a-web-page-an-app-type-text-or-run-a-command), which are opt in.)
--- ---
## 🚀 Make a key open a web page, an app, type text, or run a command
The pad itself can only ever send key presses. It can't hold "open this website". But your computer can, so an action is split in two:
- **On the pad**, the key is set to one of **F13 to F24**. No keyboard you own has those, so nothing else reacts to them.
- **On your computer**, a small listener watches for them and does what you asked.
Click a key, choose **Action**, then pick **Web page**, **App** (from the apps you have installed), **Command**, or **Text**, and write it to the pad. **Test** runs it straight away so you can check it before relying on it.
<p align="center">
<img src="docs/images/action.png" width="820" alt="Key 5 set to open a web page, with background actions switched on">
</p>
Then tick **Run actions in the background**. That starts the listener now and every time you log in. It runs as you, inside your desktop session, so web pages open in your own browser and apps start the way they would from your menu.
A few things worth knowing:
- **It's optional.** With it off, action keys send F13 to F24 and nothing happens. Every other key on the pad carries on working, because those live on the pad.
- **Changing what an action does doesn't touch the pad.** The key keeps its F13 to F24 code; only the computer's half changes.
- **You choose the key, or let it choose.** The **Sends** box shows all twelve, which ones other controls already use, and which ones your desktop is known to act on. Left on automatic, it tries the quiet ones first.
- **Twelve at most**, one per spare key. Plenty for a 12 key pad.
- **Text types a phrase wherever your cursor is**: a sign-off, an address, a reply you send twenty times a day. It can be several lines, and emoji and symbols like £ work on any keyboard layout, because it goes through the clipboard and then presses paste. That does mean it replaces whatever you'd copied. Terminals paste with Ctrl+Shift+V, so there's a tick box for that.
- **Commands run as you, in a shell.** Anything you could type in a terminal works, including scripts. Actions are kept in `~/.config/macropad/actions.json`, so treat that file like a script and don't paste in one from someone you don't trust.
- **No extra permissions.** The listener reads the pad through the same access the rest of the app already has.
Text needs two things the other actions don't: **wl-clipboard** (or xclip on X11) to set the clipboard, and permission to press the paste shortcut for you, which is the same rule Steam installs for its controllers. If either is missing, the Text page says so and gives you the commands to fix it.
The idea of pairing F13 to F24 with a listener comes from [armas01's macropad-controller](https://github.com/armas01/macropad/tree/main/macropad-controller), which does this for the 3 and 6 key pads.
## 🎛️ The knobs ## 🎛️ The knobs
Each knob does three separate things, and each gets its own binding: Each knob does three separate things, and each gets its own binding:
@@ -293,6 +325,32 @@ The boxes along the bottom set the grid size and how many keys and knobs there a
This only changes the picture. It never writes anything to the pad. It's there because the pad reports how many keys it has but not how they're arranged, so on a pad we haven't seen before the first guess can be wrong. This only changes the picture. It never writes anything to the pad. It's there because the pad reports how many keys it has but not how they're arranged, so on a pad we haven't seen before the first guess can be wrong.
## 🎨 Themes
Seven of them, under **Theme** at the bottom of the window. The choice sticks.
<p align="center">
<img src="docs/images/themes.png" width="860" alt="The same pad drawn in all seven themes: Silkscreen, White board, Blueprint, Sketch, Nord, Catppuccin Mocha and Catppuccin Latte">
</p>
- **Silkscreen**, the default: a dark circuit board with white printed legends and Kapton tape for unwritten changes.
- **White board**: the same, on a white board, for anyone who wants a light theme.
- **Nord**, **Catppuccin Mocha** and **Catppuccin Latte**: the popular palettes, as their authors published them.
Two do more than change colour.
**Blueprint** draws the pad as an engineering drawing. White linework on cyanotype blue, over a drawing grid. Unknown keys become dashed hidden lines, the way drawings show edges you can't see. Chain lines cross each knob's centre. Lettering is in capitals, changes are marked up in yellow, and the sheet has a border and a proper title block with the drawing number, scale, layout, and your username as the draughtsman.
<p align="center">
<img src="docs/images/theme-blueprint.png" width="820" alt="The Blueprint theme: the pad as a white-line engineering drawing on blue grid paper, with a title block in the corner">
</p>
**Sketch** is pencil on paper. Every line wanders slightly and is gone over twice, the way a hand draws, and unknown keys are shaded in. The wobble is seeded per key, so each one looks the same every time rather than shimmering as you move the mouse.
<p align="center">
<img src="docs/images/theme-sketch.png" width="820" alt="The Sketch theme: the pad drawn in pencil on off-white paper with handwritten labels">
</p>
## 🔄 Rotate view ## 🔄 Rotate view
Some people stand the pad up with the knobs on top. Some lay it flat with the knobs on the right. Click **Rotate view** at the bottom and the drawing turns to match. It remembers your choice. Some people stand the pad up with the knobs on top. Some lay it flat with the knobs on the right. Click **Rotate view** at the bottom and the drawing turns to match. It remembers your choice.
@@ -376,6 +434,28 @@ You have a sibling pad with a different layout. The app refuses to write to it o
The pad dropped off USB partway through a write, which can happen with a loose cable or a busy hub. Unplug it, plug it back in, and click **Write to pad** again. That key shows as Unknown until you do. The pad dropped off USB partway through a write, which can happen with a loose cable or a busy hub. Unplug it, plug it back in, and click **Write to pad** again. That key shows as Unknown until you do.
### An action key does nothing
Check **Run actions in the background** is ticked, and that the status under it says it's running. If it is and the key still does nothing, run this in a terminal and press the key:
```
macropad actions --debug
```
It prints every report the pad's keyboard sends. If nothing appears, the listener can't see the pad; if something appears but no action runs, the output says which key arrived, which narrows it down. Stop it with `Ctrl+C` and open an issue with what it printed.
### A text key doesn't type anything
Select the key, open its Action page, and look under the text box. If something's missing it says what, with a **Copy fix** button; paste that into a terminal. The permission rule usually needs you to unplug the pad and plug it back in, or log out and back in, before it takes effect.
If nothing's reported as missing, press **Test**, then click into a text box within three seconds. If Test types but the pad key doesn't, check background actions are running.
### An action key also does something else
Linux gives some of the spare keys a meaning of its own, so your desktop can react to them as well as MacroPad. On Plasma 6, **F13** opens System Settings, **F20** mutes the microphone and **F21** toggles the touchpad. **F22** and **F23** are touchpad on and off in the same table.
The easy fix is to pick a different key in the **Sends** box and write it again; the automatic choice already avoids these. If you'd rather keep the key, you can remove its desktop shortcut instead: in System Settings, open **Keyboard**, then **Shortcuts**, search for what it does ("Mute Microphone", "Toggle Touchpad" and so on), and clear the shortcut.
### Record doesn't catch my shortcut ### Record doesn't catch my shortcut
Your desktop keeps some combos for itself (on Plasma, most `Meta` shortcuts), so no app ever sees them. Type it into the box instead, like `meta+ctrl+right`. Your desktop keeps some combos for itself (on Plasma, most `Meta` shortcuts), so no app ever sees them. Type it into the box instead, like `meta+ctrl+right`.
@@ -440,26 +520,32 @@ Siblings come in other sizes, and several share vendor ID `1189`. **They don't a
<img src="docs/images/unsupported.png" width="820" alt="The app explaining that it found a 1189:8890 pad that it doesn't support yet"> <img src="docs/images/unsupported.png" width="820" alt="The app explaining that it found a 1189:8890 pad that it doesn't support yet">
</p> </p>
### Detecting a pad automatically ### Start here: ask the pad
If MacroPad sees a pad it doesn't recognise, it offers to **ask the pad what it is**. That's read only, and it needs the pad to pass two tests before the app will write anything to it: Install MacroPad, plug your pad in, and run:
1. It answers the `0xFB` query with a sensible key and knob count.
2. It then reports a full layer, describing exactly the control slots that count implies.
A pad from a different family fails one of those, and the app leaves it alone rather than guessing. On the command line:
``` ```
macropad detect macropad detect
``` ```
The pad reports how many keys and knobs it has, but not how they're arranged, so the app's first guess at rows and columns may be wrong. Check the drawing against the real thing. On most pads in this family that's the whole job. It asks the pad how many keys and knobs it has, reads back what's currently bound, and prints a report. Paste that into [an issue](https://github.com/Probler-Yt/MacroPad/issues) and your pad gets added to the known list.
If your pad passes, please [open an issue](https://github.com/Probler-Yt/MacroPad/issues) with the output so it can be added to the known list and everyone else gets it working first time. The app offers the same thing: when it sees a pad it doesn't recognise, the panel has an **Ask the pad what it is** button.
### Adding one by hand ### How it decides
If you'd like your pad in MacroPad too, you're exactly who this project needs. It's the same process used for this one. You'll need the vendor software (`MINI_KEYBOARD.exe`) and Wine. Detection is read only, and the pad has to pass two tests before the app will write anything to it:
1. It answers the `0xFB` query with a sensible key and knob count.
2. It then reports a full layer, describing exactly the control slots that count implies.
A pad from a different family fails one of those, and the app leaves it alone rather than guessing.
The pad reports how many keys and knobs it has, but not how they're arranged, so the first guess at rows and columns may be wrong. Fix it with [Edit layout](#-if-the-drawing-doesnt-match-your-pad) and the drawing will match your hardware.
### If it doesn't answer
Some pads in this family speak an older protocol and won't answer. Working those out needs a capture of the vendor software, the same process used for this one. You'll need `MINI_KEYBOARD.exe` and Wine.
#### Capture what the vendor software sends #### Capture what the vendor software sends
@@ -492,6 +578,8 @@ If you'd like your pad in MacroPad too, you're exactly who this project needs. I
Windows already has the vendor app, ugly as it is, and [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool) runs there too. **A friendly app on Linux was the gap, so Linux comes first.** Windows already has the vendor app, ugly as it is, and [ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool) runs there too. **A friendly app on Linux was the gap, so Linux comes first.**
To be precise about what "Linux only" means here: the window is Qt and runs anywhere, and the protocol module is plain Python. The only Linux specific parts are finding the pad and opening it, which read `/sys/class/hidraw` and `/dev/hidraw`. Swapping those for a cross platform HID library is the whole port. If you run it on Windows today it opens and tells you it can't see any devices.
That said, the app is built on Qt, which runs on Windows and macOS too. The only Linux specific parts are the small pieces that talk to USB and check permissions. Swapping it for a cross platform one is on the [roadmap](#-roadmap), and once that's tested on real hardware, one app will work everywhere. If you'd like to help test on Windows, open an issue. That said, the app is built on Qt, which runs on Windows and macOS too. The only Linux specific parts are the small pieces that talk to USB and check permissions. Swapping it for a cross platform one is on the [roadmap](#-roadmap), and once that's tested on real hardware, one app will work everywhere. If you'd like to help test on Windows, open an issue.
--- ---
@@ -692,6 +780,8 @@ macropad.py the protocol and the command line tool (no dependencies
macropad_gui/ macropad_gui/
core.py bindings, the app's notes, the permission check, writing core.py bindings, the app's notes, the permission check, writing
padview.py the drawing of the pad padview.py the drawing of the pad
themes.py palettes and drawing styles
fonts/ the Sketch theme's handwriting font, with its licence
app.py the window app.py the window
keymap.py physical key to HID name, for Record keymap.py physical key to HID name, for Record
macropad-probe.py read only: what is this device? macropad-probe.py read only: what is this device?
@@ -720,6 +810,9 @@ tests/ real captures and the tests that replay them
- [ ] **Windows and macOS** via a cross platform USB layer - [ ] **Windows and macOS** via a cross platform USB layer
- [x] Detecting a pad's key and knob counts by asking it - [x] Detecting a pad's key and knob counts by asking it
- [x] A layout editor, since the pad doesn't report how its keys are arranged - [x] A layout editor, since the pad doesn't report how its keys are arranged
- [x] Actions: open web pages, apps and commands from a key
- [x] Text snippets: type a phrase from a key
- [x] Themes, including Blueprint and Sketch
- [ ] **More pads**: every report sent in gets us closer - [ ] **More pads**: every report sent in gets us closer
--- ---
@@ -728,6 +821,8 @@ tests/ real captures and the tests that replay them
- **[kriomant/ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool)**, the MIT licensed command line tool that has supported these pads since 2023. It does more than MacroPad does today (layers, sequences, mouse, LEDs, more pads, every OS), and its source is the best reference there is. Go give it a star. - **[kriomant/ch57x-keyboard-tool](https://github.com/kriomant/ch57x-keyboard-tool)**, the MIT licensed command line tool that has supported these pads since 2023. It does more than MacroPad does today (layers, sequences, mouse, LEDs, more pads, every OS), and its source is the best reference there is. Go give it a star.
- **[rOzzy1987/MacroPad](https://github.com/rOzzy1987/MacroPad)**, whose Windows project this app's protocol code started from. - **[rOzzy1987/MacroPad](https://github.com/rOzzy1987/MacroPad)**, whose Windows project this app's protocol code started from.
- **[armas01/macropad](https://github.com/armas01/macropad)**, whose macropad-controller showed the F13 to F24 approach to actions.
- **[Nord](https://www.nordtheme.com)** and **[Catppuccin](https://catppuccin.com)** for their palettes (both MIT), and Kimberly Geswein for **Architects Daughter**, the Sketch theme's handwriting, under the SIL Open Font License (`macropad_gui/fonts/OFL.txt`).
- Everyone on forums who documented running a whole VM to change a key. Your suffering was noted. - Everyone on forums who documented running a whole VM to change a key. Your suffering was noted.
## ⚖️ Licence ## ⚖️ Licence

BIN
docs/images/action.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

BIN
docs/images/themes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 359 KiB

View File

@@ -211,6 +211,21 @@ def scene_edit_layout():
w._edit_layout(False, keep=False) w._edit_layout(False, keep=False)
def scene_action():
from macropad_gui import actions as act
b = dict(DEMO)
b["key5"] = B(keys="f14")
b["key9"] = B(keys="f15")
with mock.patch.object(act, "listener_pid", return_value=4242), \
mock.patch.object(act, "autostart_enabled", return_value=True):
w = window(bindings=b)
w.actions.set("f14", act.Action("url", "github.com/Probler-Yt/MacroPad"))
w.actions.set("f15", act.Action("app", "spotify", "Spotify"))
select(w, "key5")
w._say("Key 5 is set: Open github.com.", A.GOOD)
save(w, "action")
def scene_flat(): def scene_flat():
w = window(bindings=DEMO) w = window(bindings=DEMO)
w.pad.set_orientation("flat") w.pad.set_orientation("flat")
@@ -356,6 +371,81 @@ def scene_social():
print("wrote docs/images/social-preview.png") print("wrote docs/images/social-preview.png")
def _demo_with_states():
b = dict(DEMO)
b["key1"] = B(none=True)
return b
def scene_theme_blueprint():
_full_theme("blueprint")
def scene_theme_sketch():
_full_theme("sketch")
def _full_theme(key):
from macropad_gui import themes
A.apply_theme(QApplication.instance(), key)
try:
w = window(bindings=_demo_with_states())
w.state.entries.pop((1, "key9"), None) # show the unknown style
w.desired["key10"] = B(keys="ctrl+p") # and an unwritten change
select(w, "dial1-left")
save(w, f"theme-{key}")
finally:
A.apply_theme(QApplication.instance(), themes.DEFAULT)
def scene_theme_gallery():
"""Every theme side by side: just the pad, same layout."""
from macropad_gui import themes
names, tiles = [], []
try:
for t in themes.THEMES.values():
A.apply_theme(QApplication.instance(), t.key)
pv = padview.PadView()
pv.resize(300, 560)
looks = {c: padview.Look(b.label(), known=True, quiet=b.none)
for c, b in _demo_with_states().items()}
looks["key9"] = padview.Look()
looks["key10"] = padview.Look("Ctrl+P", known=True, pending=True)
pv.looks = looks
pv.current = "dial1-left"
tile = pv.grab().toImage()
tile.setDevicePixelRatio(1) # draw it pixel for pixel
tiles.append(tile)
names.append(t.name)
finally:
A.apply_theme(QApplication.instance(), themes.DEFAULT)
tw = tiles[0].width()
th = tiles[0].height()
gap, label = 14 * 2, 34 * 2
img = QImage(len(tiles) * tw + (len(tiles) - 1) * gap, th + label,
QImage.Format_ARGB32)
img.fill(Qt.transparent)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing)
for i, (tile, name) in enumerate(zip(tiles, names)):
x = i * (tw + gap)
clip = __import__("PySide6.QtGui", fromlist=["QPainterPath"]).QPainterPath()
clip.addRoundedRect(QRectF(x, 0, tw, th), 24, 24)
p.setClipPath(clip)
p.drawImage(x, 0, tile)
p.setClipping(False)
f = QFont(QApplication.font())
f.setPixelSize(26)
p.setFont(f)
p.setPen(QColor("#8a8a8a"))
p.drawText(QRectF(x, th + 8, tw, label - 8), Qt.AlignHCenter | Qt.AlignTop, name)
p.end()
path = OUT / "themes.png"
img.save(str(path))
print("wrote", path.relative_to(ROOT))
def scene_icon(): def scene_icon():
r = QSvgRenderer(str(ROOT / "packaging" / "macropad.svg")) r = QSvgRenderer(str(ROOT / "packaging" / "macropad.svg"))
img = QImage(256, 256, QImage.Format_ARGB32) img = QImage(256, 256, QImage.Format_ARGB32)

View File

@@ -22,7 +22,7 @@ BIN="$HOME/.local/bin"
DESKTOP="$DATA/applications/macropad.desktop" DESKTOP="$DATA/applications/macropad.desktop"
ICON="$DATA/icons/hicolor/scalable/apps/macropad.svg" ICON="$DATA/icons/hicolor/scalable/apps/macropad.svg"
RULE="/etc/udev/rules.d/60-macropad.rules" RULE="/etc/udev/rules.d/60-macropad.rules"
RULE_LINE='KERNEL=="hidraw*", ATTRS{idVendor}=="1189", ATTRS{idProduct}=="8840", TAG+="uaccess"' RULE_LINE='KERNEL=="hidraw*", ATTRS{idVendor}=="1189", TAG+="uaccess"'
if [ -t 1 ]; then if [ -t 1 ]; then
O=$'\e[38;5;208m'; G=$'\e[32m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; X=$'\e[0m' O=$'\e[38;5;208m'; G=$'\e[32m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; X=$'\e[0m'
@@ -170,6 +170,25 @@ command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 >/dev/null 2>&1 || true
rm -f "${XDG_CACHE_HOME:-$HOME/.cache}/icon-cache.kcache" 2>/dev/null || true rm -f "${XDG_CACHE_HOME:-$HOME/.cache}/icon-cache.kcache" 2>/dev/null || true
ok "MacroPad is in your app menu" ok "MacroPad is in your app menu"
# If the user turned background actions on and they're running, restart them
# so they pick up the new code. Identified by the listener's own lock file and
# checked against /proc, never by matching command lines, which would also hit
# an editor or a grep that happens to mention it.
ACTIONS_LOCK="${XDG_RUNTIME_DIR:-$HOME/.cache}/macropad-actions.lock"
ACTIONS_AUTOSTART="${XDG_CONFIG_HOME:-$HOME/.config}/autostart/macropad-actions.desktop"
if [ -f "$ACTIONS_AUTOSTART" ] && [ -f "$ACTIONS_LOCK" ]; then
pid="$(cat "$ACTIONS_LOCK" 2>/dev/null || true)"
case "$pid" in ''|*[!0-9]*) pid="" ;; esac
if [ -n "$pid" ] && tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null \
| grep -q "macropad.*actions"; then
kill "$pid" 2>/dev/null || true
sleep 0.5
setsid -f "$BIN/macropad" actions \
>>"${XDG_RUNTIME_DIR:-$HOME/.cache}/macropad-actions.log" 2>&1 < /dev/null || true
ok "restarted background actions"
fi
fi
# -------------------------------------------------------------- 4. permission # -------------------------------------------------------------- 4. permission
step "Letting the app talk to the pad" step "Letting the app talk to the pad"
if [ -f "$RULE" ] && grep -qF "$RULE_LINE" "$RULE"; then if [ -f "$RULE" ] && grep -qF "$RULE_LINE" "$RULE"; then

View File

@@ -62,6 +62,8 @@ KEYCODES.update({
}) })
for _i in range(1, 13): for _i in range(1, 13):
KEYCODES[f"f{_i}"] = 57 + _i # F1 = 58 .. F12 = 69 KEYCODES[f"f{_i}"] = 57 + _i # F1 = 58 .. F12 = 69
for _i in range(13, 25):
KEYCODES[f"f{_i}"] = 0x68 + _i - 13 # F13 = 0x68 .. F24 = 0x73
# Media keys live on the Consumer usage page, sent as a 16-bit value. # Media keys live on the Consumer usage page, sent as a 16-bit value.
MEDIA_KEYS = { MEDIA_KEYS = {
@@ -247,9 +249,15 @@ LAYERS = 3
def native_info(report_id): def native_info(report_id):
"""Ask the pad what it is. Reply carries the key and knob counts.""" """
Ask the pad what it is. Reply carries the key and knob counts.
The vendor sends the command byte three times over. In the read command
below the bytes after the first carry meaning, so these may too; we
repeat them rather than guess that they are padding.
"""
data = bytearray(REPORT_LEN - 1) data = bytearray(REPORT_LEN - 1)
data[0] = MAGIC_INFO data[0] = data[1] = data[2] = MAGIC_INFO
return bytes([report_id]) + bytes(data) return bytes([report_id]) + bytes(data)

View File

@@ -67,6 +67,12 @@ def cmd_detect(_):
return 1 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): def cmd_write(a):
if a.control not in core.CONTROL_IDS: if a.control not in core.CONTROL_IDS:
print(f"Unknown control. One of: {', '.join(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("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("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)") 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 = sub.add_parser("write", help="write one binding and record it")
w.add_argument("control") w.add_argument("control")
w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'") w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'")
@@ -115,7 +124,7 @@ def main():
return 1 return 1
return run() return run()
return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write, 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__": 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. running afterwards.
""" """
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal 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) QPalette)
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame, from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
QGridLayout, QHBoxLayout, QLabel, QLineEdit, QGridLayout, QHBoxLayout, QLabel, QLineEdit,
QMainWindow, QMessageBox, QPlainTextEdit, 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 from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
GOOD = "#8db36b" # Set by apply_theme; functions read these when they run, so they follow a
BAD = "#e0584f" # theme change without anything else having to know.
LINE = "#3a3e42" GOOD = BAD = LINE = FIELD = ""
FIELD = "#121416"
# Media keys as pairs, so opposites sit side by side. # Media keys as pairs, so opposites sit side by side.
MEDIA_GRID = [("playpause", "stop"), ("prev", "next"), MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
@@ -29,38 +31,50 @@ MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
("brightnessdown", "brightnessup"), ("brightnessdown", "brightnessup"),
("calculator", "mail"), ("browser", "mycomputer")] ("calculator", "mail"), ("browser", "mycomputer")]
STYLE = f""" def stylesheet(t):
QWidget {{ color: {INK.name()}; background: {WINDOW.name()}; }} """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="title"] {{ font-size: 18px; font-weight: 600; }}
QLabel[role="dim"] {{ color: {INK_DIM.name()}; }} QLabel[role="dim"] {{ color: {t.ink_dim}; }}
QLabel[role="caption"] {{ color: {INK_DIM.name()}; padding: 14px 0px 0px 0px; margin: 0px; }} QLabel[role="caption"] {{ color: {t.ink_dim}; padding: 14px 0px 0px 0px; margin: 0px; }}
QLabel[role="value"] {{ font-size: 15px; }} 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; }} border-radius: 4px; padding: 6px 12px; }}
QPushButton:hover {{ border-color: {INK_DIM.name()}; }} QPushButton:hover {{ border-color: {t.ink_dim}; }}
QPushButton:checked {{ border-color: {TAPE.name()}; color: {TAPE.name()}; }} QPushButton:checked {{ border-color: {t.accent}; color: {t.accent}; }}
QPushButton:disabled {{ color: #4a4e52; border-color: #2a2e32; }} QPushButton:disabled {{ color: {tint(t.window, t.ink_dim, 0.55)}; border-color: {tint(t.window, t.line, 0.6)}; }}
QPushButton[role="primary"] {{ background: {TAPE.name()}; color: #1b1206; QPushButton[role="primary"] {{ background: {t.accent}; color: {t.accent_text};
border: none; font-weight: 600; padding: 8px 16px; }} border: none; font-weight: 600; padding: 8px 16px; }}
QPushButton[role="primary"]:hover {{ background: #f39a45; }} QPushButton[role="primary"]:hover {{ background: {tint(t.accent, t.ink, 0.15)}; }}
QPushButton[role="primary"]:disabled {{ background: #2c3034; color: #5a5e62; }} 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: {INK_DIM.name()}; padding: 6px 8px; }} QPushButton[role="flat"] {{ border: none; color: {t.ink_dim}; padding: 6px 8px; }}
QPushButton[role="flat"]:hover {{ color: {INK.name()}; }} QPushButton[role="flat"]:hover {{ color: {t.ink}; }}
QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }} 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; }} padding: 7px 8px; }}
QLineEdit:focus {{ border-color: {INK_DIM.name()}; }} QLineEdit:focus {{ border-color: {t.ink_dim}; }}
QLineEdit[recording="true"] {{ border-color: {TAPE.name()}; background: #2a1d10; }} QLineEdit[recording="true"] {{ border-color: {t.accent}; background: {tint(t.field, t.accent, 0.12)}; }}
QPlainTextEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px; }} QPlainTextEdit {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px; }}
QFrame[role="banner"] {{ border: 1px solid {LINE}; border-left: 3px solid {BAD}; QFrame[role="banner"] {{ border: 1px solid {t.line}; border-left: 3px solid {t.bad};
border-radius: 4px; }} 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="banner"] QLabel, QFrame[role="banner"] QPushButton {{ background: transparent; }}
QFrame[role="footer"] {{ border-top: 1px solid {LINE}; }} QFrame[role="footer"] {{ border-top: 1px solid {t.line}; }}
QFrame[role="editbar"] {{ border-top: 1px solid {TAPE.name()}; background: #1c1a17; }} QFrame[role="editbar"] {{ border-top: 1px solid {t.accent}; background: {tint(t.window, t.accent, 0.06)}; }}
QFrame[role="editbar"] QLabel {{ background: transparent; }} 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; }} 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) changed = Signal(str, object) # control, Binding or None (revert)
write = Signal(str) write = Signal(str)
revert = 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): def __init__(self):
super().__init__() super().__init__()
self.setFixedWidth(360) self.setFixedWidth(400)
self.control = None self.control = None
self._loading = False self._loading = False
self.editing_layout = False self.editing_layout = False
@@ -213,12 +230,15 @@ class Inspector(QWidget):
self.mode_media = _button("Media key", checkable=True) self.mode_media = _button("Media key", checkable=True)
self.mode_none = _button("Nothing", checkable=True) self.mode_none = _button("Nothing", checkable=True)
self.mode_none.setToolTip("Make this control do nothing at all") 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 = QButtonGroup(self)
self.mode_group.setExclusive(True) 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) self.mode_group.addButton(b, i)
modes.addWidget(b) b.setStyleSheet("padding: 6px 8px;")
modes.addStretch() modes.addWidget(b, 1)
ev.addLayout(modes) ev.addLayout(modes)
ev.addSpacing(6) ev.addSpacing(6)
@@ -253,10 +273,71 @@ class Inspector(QWidget):
ml.addWidget(b, r, col) ml.addWidget(b, r, col)
np_ = _label("Pressing or turning it will do nothing. Handy for a key " np_ = _label("Pressing or turning it will do nothing. Handy for a key "
"you keep hitting by accident.", "dim", wrap=True) "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(kp)
ev.addWidget(mp) ev.addWidget(mp)
ev.addWidget(np_) ev.addWidget(np_)
ev.addWidget(ap)
ev.addSpacing(10) ev.addSpacing(10)
actions = QHBoxLayout() actions = QHBoxLayout()
@@ -274,6 +355,16 @@ class Inspector(QWidget):
self.keys_edit.recorded.connect(self._keys_typed) self.keys_edit.recorded.connect(self._keys_typed)
self.record.toggled.connect(self._toggle_record) self.record.toggled.connect(self._toggle_record)
self.media_group.idClicked.connect(self._media_picked) 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.write_btn.clicked.connect(lambda: self.write.emit(self.control))
self.revert_btn.clicked.connect(lambda: self.revert.emit(self.control)) self.revert_btn.clicked.connect(lambda: self.revert.emit(self.control))
@@ -312,7 +403,9 @@ class Inspector(QWidget):
# -------------------------------------------------------------- editor # -------------------------------------------------------------- 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._loading = True
self.control = control self.control = control
if control is None: if control is None:
@@ -340,13 +433,29 @@ class Inspector(QWidget):
self.onpad.setStyleSheet(f"color: {INK_DIM.name()};") self.onpad.setStyleSheet(f"color: {INK_DIM.name()};")
self.onpad_why.setText(entry.reason[:1].upper() + entry.reason[1:] + ".") self.onpad_why.setText(entry.reason[:1].upper() + entry.reason[1:] + ".")
self.onpad_why.show() self.onpad_why.show()
else:
trig = act.trigger_of(entry.binding)
if onpad_action and trig:
self.onpad.setText(onpad_action.label())
self.onpad_why.setText(f"The pad sends {trig.upper()}, and this "
"computer does the rest.")
self.onpad_why.show()
else: else:
self.onpad.setText(entry.binding.label()) self.onpad.setText(entry.binding.label())
self.onpad.setStyleSheet("")
self.onpad_why.hide() self.onpad_why.hide()
self.onpad.setStyleSheet("")
shown = desired or (None if entry.unknown else entry.binding) 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.mode_none.setChecked(True)
self._page(2) self._page(2)
self.keys_edit.clear() self.keys_edit.clear()
@@ -362,7 +471,8 @@ class Inspector(QWidget):
self.keys_edit.setText(shown.keys if shown else "") self.keys_edit.setText(shown.keys if shown else "")
self._clear_media() self._clear_media()
self.keys_hint.setText("") 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 self._loading = False
def update_buttons(self, pending, can_write): def update_buttons(self, pending, can_write):
@@ -385,6 +495,11 @@ class Inspector(QWidget):
self._page(i) self._page(i)
if i == 2 and not self._loading and self.control is not None: if i == 2 and not self._loading and self.control is not None:
self.changed.emit(self.control, core.Binding(none=True)) 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(): if i == 0 and self.record.isChecked():
self.keys_edit.setFocus() self.keys_edit.setFocus()
@@ -420,6 +535,199 @@ class Inspector(QWidget):
self.keys_hint.setText(f"Sends {b.label()}") self.keys_hint.setText(f"Sends {b.label()}")
self.changed.emit(self.control, b) 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): def _media_picked(self, i):
if self._loading or self.control is None: if self._loading or self.control is None:
return return
@@ -436,6 +744,8 @@ class Window(QMainWindow):
self.settings = QSettings("macropad", "macropad") self.settings = QSettings("macropad", "macropad")
self.state = core.State() self.state = core.State()
self.desired = {} # control -> Binding, unwritten edits self.desired = {} # control -> Binding, unwritten edits
self.actions = act.ActionStore() # trigger key -> host action
self.pending_actions = {} # control -> act.Action, unsaved
self.diag = None self.diag = None
# Pads the user has identified by asking them. Remembered so the app # Pads the user has identified by asking them. Remembered so the app
# doesn't go back to calling them unsupported on the next check. # 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.edit_btn.setToolTip("Rearrange the drawing to match your pad")
self.read_btn = _button("Read pad", "flat") self.read_btn = _button("Read pad", "flat")
self.read_btn.setToolTip("Ask the pad what's on it right now") 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.rotate = _button("Rotate view", "flat")
self.import_btn = _button("Import capture…", "flat") self.import_btn = _button("Import capture…", "flat")
self.write_all = _button("", "primary") self.write_all = _button("", "primary")
@@ -505,6 +828,7 @@ class Window(QMainWindow):
fl.addWidget(self.message, 1) fl.addWidget(self.message, 1)
fl.addWidget(self.edit_btn) fl.addWidget(self.edit_btn)
fl.addWidget(self.read_btn) fl.addWidget(self.read_btn)
fl.addWidget(self.theme_btn)
fl.addWidget(self.rotate) fl.addWidget(self.rotate)
fl.addWidget(self.import_btn) fl.addWidget(self.import_btn)
fl.addWidget(self.write_all) fl.addWidget(self.write_all)
@@ -515,6 +839,9 @@ class Window(QMainWindow):
self.inspector.changed.connect(self._changed) self.inspector.changed.connect(self._changed)
self.inspector.revert.connect(self._revert) self.inspector.revert.connect(self._revert)
self.inspector.action.connect(self._identify) 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.inspector.write.connect(lambda c: self._write([c]))
self.write_all.clicked.connect(lambda: self._write(self._pending())) self.write_all.clicked.connect(lambda: self._write(self._pending()))
self.edit_btn.clicked.connect(lambda: self._edit_layout(True)) self.edit_btn.clicked.connect(lambda: self._edit_layout(True))
@@ -523,6 +850,7 @@ class Window(QMainWindow):
self.pad.rearranged.connect(self._rearranged) self.pad.rearranged.connect(self._rearranged)
self.read_btn.clicked.connect(lambda: self._read(quiet=False)) self.read_btn.clicked.connect(lambda: self._read(quiet=False))
self.rotate.clicked.connect(self._rotate) 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.import_btn.clicked.connect(self._import)
self.note = core._keyd_warning() self.note = core._keyd_warning()
@@ -538,14 +866,43 @@ class Window(QMainWindow):
# ------------------------------------------------------------- model # ------------------------------------------------------------- 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): def _pending(self):
out = [] out = []
for c in self.state.layout.control_ids: for c in self.state.layout.control_ids:
b = self.desired.get(c)
if b is None:
continue
e = self.state.get(c) 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) out.append(c)
return out return out
@@ -555,11 +912,15 @@ class Window(QMainWindow):
for c in self.state.layout.control_ids: for c in self.state.layout.control_ids:
e = self.state.get(c) e = self.state.get(c)
if c in pending: 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: elif e.unknown:
looks[c] = Look("", known=False) looks[c] = Look("", known=False)
else: 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) self.pad.set_looks(looks)
n = len(pending) n = len(pending)
@@ -574,22 +935,99 @@ class Window(QMainWindow):
return return
all_unknown = all(self.state.get(x).unknown all_unknown = all(self.state.get(x).unknown
for x in self.state.layout.control_ids) for x in self.state.layout.control_ids)
e = self.state.get(c) if c else None
self.inspector.show_control( self.inspector.show_control(
c, self.state.get(c) if c else None, c, e, self.desired.get(c) if c in pending else None,
self.desired.get(c) if c in pending else None, can_write, all_unknown,
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): def _select(self, control):
self._refresh() self._refresh()
def _changed(self, control, binding): def _changed(self, control, binding):
self.pending_actions.pop(control, None)
if binding is None: if binding is None:
self.desired.pop(control, None) self.desired.pop(control, None)
else: else:
self.desired[control] = binding self.desired[control] = binding
self._refresh(editor=False) 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): def _revert(self, control):
self.pending_actions.pop(control, None)
self.desired.pop(control, None) self.desired.pop(control, None)
self._refresh() self._refresh()
@@ -617,8 +1055,10 @@ class Window(QMainWindow):
"no-interface": "Pad found, config interface missing", "no-interface": "Pad found, config interface missing",
"no-permission": "No permission to write to the pad", "no-permission": "No permission to write to the pad",
"unsupported": "Unsupported pad", "unsupported": "Unsupported pad",
"unsupported-os": "Not supported on this system",
}[self.diag.status]) }[self.diag.status])
self.inspector.show_problem(self.diag, self.note) self.inspector.show_problem(self.diag, self.note)
self.inspector.show_background(bool(act.listener_pid()))
if self.diag.status != old: if self.diag.status != old:
self._refresh(editor=False) self._refresh(editor=False)
if self.diag.ready and old in ("unplugged", "no-interface", None): if self.diag.ready and old in ("unplugged", "no-interface", None):
@@ -627,39 +1067,67 @@ class Window(QMainWindow):
# ------------------------------------------------------------- write # ------------------------------------------------------------- write
def _write(self, controls): 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: if not controls:
return return
self._poll() 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) self._say(self.diag.headline, BAD)
return return
done = [] done, host_changed = [], False
for c in controls: for c in controls:
b = self.desired[c] b = self.desired.get(c)
e = self.state.get(c)
if b is not None and (e.unknown or e.binding != b):
try: try:
core.write_binding(self.diag.device, c, b, self.state) core.write_binding(self.diag.device, c, b, self.state)
except core.WriteError as e: except core.WriteError as err:
what = core.control_name(c) what = core.control_name(c)
tail = (f"{what} is now marked unknown, since part of it reached the pad." tail = (f"{what} is now marked unknown, since part of it "
if e.pad_touched else "Nothing reached the pad.") "reached the pad." if err.pad_touched
why = str(e).split(": ", 1)[-1] else "Nothing reached the pad.")
why = str(err).split(": ", 1)[-1]
self._say(f"Couldn't write {what}: {why}. {tail}", BAD) self._say(f"Couldn't write {what}: {why}. {tail}", BAD)
self._refresh() self._refresh()
return return
self.desired.pop(c, None) 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() 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: if len(done) == 1:
c, b = done[0] c, b, action = done[0]
if b.none: name = core.control_name(c)
self._say(f"{core.control_name(c)} now does nothing.", GOOD) 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: 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: else:
self._say(f"Wrote {len(done)} changes to the pad.", GOOD) self._say(f"Wrote {len(done)} changes.", GOOD)
self._refresh() self._refresh()
def _read(self, quiet=False): def _read(self, quiet=False):
@@ -778,6 +1246,14 @@ class Window(QMainWindow):
self._set_layout(lay) self._set_layout(lay)
self.pad.set_editing(True) 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): def _rotate(self):
o = "flat" if self.pad.orientation == "upright" else "upright" o = "flat" if self.pad.orientation == "upright" else "upright"
self.pad.set_orientation(o) self.pad.set_orientation(o)
@@ -823,16 +1299,26 @@ class Window(QMainWindow):
e.ignore() 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") app.setStyle("Fusion")
pal = QPalette() pal = QPalette()
for role, colour in ((QPalette.Window, WINDOW), (QPalette.Base, QColor(FIELD)), for role, colour in ((QPalette.Window, t.window), (QPalette.Base, t.field),
(QPalette.Text, INK), (QPalette.WindowText, INK), (QPalette.Text, t.ink), (QPalette.WindowText, t.ink),
(QPalette.Button, WINDOW), (QPalette.ButtonText, INK), (QPalette.Button, t.window), (QPalette.ButtonText, t.ink),
(QPalette.Highlight, TAPE), (QPalette.HighlightedText, QColor("#1b1206"))): (QPalette.Highlight, t.accent),
pal.setColor(role, colour) (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.setPalette(pal)
app.setStyleSheet(STYLE) app.setStyleSheet(stylesheet(t))
return t
ICON = Path(__file__).resolve().parent.parent / "packaging" / "macropad.svg" ICON = Path(__file__).resolve().parent.parent / "packaging" / "macropad.svg"
@@ -846,8 +1332,14 @@ def run():
# sessions, and running straight from a git checkout. # sessions, and running straight from a git checkout.
if ICON.exists(): if ICON.exists():
app.setWindowIcon(QIcon(str(ICON))) app.setWindowIcon(QIcon(str(ICON)))
apply_theme(app) apply_theme(app, QSettings("macropad", "macropad").value("theme", themes.DEFAULT))
w = Window() w = Window()
w.resize(1000, 800) w.resize(1000, 800)
w.show() w.show()
return app.exec() 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") RULES_DIRS = ("/etc/udev/rules.d", "/run/udev/rules.d")
SYSTEM_RULES = ("/usr/lib/udev/rules.d", "/lib/udev/rules.d") SYSTEM_RULES = ("/usr/lib/udev/rules.d", "/lib/udev/rules.d")
SUGGESTED_RULE = "60-macropad.rules" SUGGESTED_RULE = "60-macropad.rules"
RULE_LINE = (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", ' # Vendor wide on purpose. Pads in this family have several product ids, and
f'ATTRS{{idProduct}}=="{PID}", TAG+="uaccess"') # 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(): def _uaccess_cutoff():
@@ -569,8 +572,23 @@ def _keyd_warning():
return None return None
def on_linux():
return sys.platform.startswith("linux")
def diagnose(keyd=True): def diagnose(keyd=True):
"""keyd=False skips the keyd check, which shells out - the GUI polls.""" """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] warnings = [w for w in (_keyd_warning() if keyd else None,) if w]
everything = proto.find_devices() everything = proto.find_devices()
found = [d for d in everything if d["pid"] == PID] 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"], dev = Device(path=other["path"], report_id=other["report_id"],
writable=other["writable"], name=other.get("name", ""), writable=other["writable"], name=other.get("name", ""),
vid=other["vid"], pid=other["pid"]) vid=other["vid"], pid=other["pid"])
return Diagnosis( d = Diagnosis(
"unsupported", "unsupported",
f"Found a {other['vid']}:{other['pid']} pad, which isn't one this " f"Found a {other['vid']}:{other['pid']} pad, which isn't one this "
"app knows.", "app knows.",
@@ -591,6 +609,14 @@ def diagnose(keyd=True):
"and if the pad doesn't answer properly nothing else " "and if the pad doesn't answer properly nothing else "
"happens."], "happens."],
warnings=warnings) 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 not found:
if _on_usb_bus(): 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 EVDEV[_start + _i] = _c
for _i in range(1, 11): for _i in range(1, 11):
EVDEV[58 + _i] = f"f{_i}" # F1 = 59 .. F10 = 68 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(): for _name in EVDEV.values():
assert _name in proto.KEYCODES, _name assert _name in proto.KEYCODES, _name

View File

@@ -11,21 +11,86 @@ Visual states:
""" """
import math import math
import random
import zlib
from dataclasses import dataclass 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, from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF,
QPainter, QPainterPath, QPen, QTransform) QPainter, QPainterPath, QPen, QTransform)
from PySide6.QtWidgets import QSizePolicy, QWidget from PySide6.QtWidgets import QSizePolicy, QWidget
from . import core from . import core, themes
WINDOW = QColor("#17191b") THEME = themes.get(themes.DEFAULT)
BOARD = QColor("#202326")
INK = QColor("#d8d4cb") # silkscreen white, slightly warm # The colours everything draws with. Switching theme changes these objects in
INK_DIM = QColor("#72767b") # place, so every widget and module holding one follows along.
HATCH = QColor("#34383c") WINDOW = QColor(THEME.window)
TAPE = QColor("#eb8a2f") # Kapton orange 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. # Geometry in board units; the view scales it to fit.
K, G, M = 96.0, 16.0, 30.0 # key size, gap, board margin 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.drag_at = None # where the pointer is, in board units
self.drop = None # (row, col) it would land in self.drop = None # (row, col) it would land in
self._wobble = 0 self._wobble = 0
self._wobble_cache = {}
self._wobbler = QTimer(self) self._wobbler = QTimer(self)
self._wobbler.setInterval(130) self._wobbler.setInterval(130)
self._wobbler.timeout.connect(self._tick) self._wobbler.timeout.connect(self._tick)
@@ -127,6 +193,7 @@ class PadView(QWidget):
# ------------------------------------------------------------ layout # ------------------------------------------------------------ layout
def _layout(self): def _layout(self):
self._wobble_cache = {}
self.keys = {} # key -> QRectF self.keys = {} # key -> QRectF
self.dials = {} # dial -> centre QPointF self.dials = {} # dial -> centre QPointF
self.rows = {} # dial action -> legend row QRectF self.rows = {} # dial action -> legend row QRectF
@@ -175,6 +242,8 @@ class PadView(QWidget):
def _transform(self): def _transform(self):
pad = 24 pad = 24
avail = QRectF(self.rect()).adjusted(pad, pad, -pad, -pad) 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(), s = min(avail.width() / self.board.width(),
avail.height() / self.board.height()) avail.height() / self.board.height())
s = max(0.4, min(s, 1.6)) s = max(0.4, min(s, 1.6))
@@ -297,17 +366,22 @@ class PadView(QWidget):
# ------------------------------------------------------------ paint # ------------------------------------------------------------ paint
# ------------------------------------------------------------ paint
def paintEvent(self, _): def paintEvent(self, _):
p = QPainter(self) p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.Antialiasing)
p.fillRect(self.rect(), WINDOW) p.fillRect(self.rect(), WINDOW)
if THEME.grid:
self._paint_grid(p)
p.setTransform(self._transform()) p.setTransform(self._transform())
board = QPainterPath() board = QPainterPath()
board.addRoundedRect(self.board, 18, 18) board.addRoundedRect(self.board, 18, 18)
p.fillPath(board, BOARD) p.fillPath(board, BOARD)
p.setPen(QPen(INK_DIM, 1.2)) # On themes where the pad is only an outline, draw that in full ink.
p.drawPath(board) edge = INK if THEME.board == THEME.window else INK_DIM
self._stroke(p, board, QPen(edge, 1.2), "board")
if self.editing: if self.editing:
for spot, rect in self.cells.items(): for spot, rect in self.cells.items():
@@ -326,7 +400,7 @@ class PadView(QWidget):
p.save() p.save()
if self.editing: if self.editing:
# a small alternating tilt, the way icons shake when movable # 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.translate(rect.center())
p.rotate(lean) p.rotate(lean)
p.translate(-rect.center()) p.translate(-rect.center())
@@ -358,22 +432,167 @@ class PadView(QWidget):
p.drawText(QRectF(M, self.board.bottom() - 34, 200, 16), p.drawText(QRectF(M, self.board.bottom() - 34, 200, 16),
Qt.AlignLeft | Qt.AlignVCenter, self.device_id) 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): def _outline_pen(self, control, known):
if control == self.current: if control == self.current:
return QPen(TAPE, 2.2) return QPen(TAPE, 2.2)
if control == self.hover: if control == self.hover:
return QPen(INK, 1.6) 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.save()
p.setClipPath(path) p.setClipPath(path)
p.setPen(QPen(HATCH, 1.4)) pen = QPen(HATCH, 1.4)
x = rect.left() - rect.height() x = rect.left() - rect.height()
i = 0
while x < rect.right(): while x < rect.right():
p.drawLine(QPointF(x, rect.bottom()), a = QPointF(x, rect.bottom())
QPointF(x + rect.height(), rect.top())) 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 x += step
i += 1
p.restore() p.restore()
def _tape(self, p, at, length=30.0, width=9.0): def _tape(self, p, at, length=30.0, width=9.0):
@@ -392,11 +611,10 @@ class PadView(QWidget):
look = self.looks.get(key, Look()) look = self.looks.get(key, Look())
path = QPainterPath() path = QPainterPath()
path.addRoundedRect(rect, 5, 5) path.addRoundedRect(rect, 5, 5)
if not look.known and not look.pending: unknown = not look.known and not look.pending
self._hatch(p, path, rect) if unknown:
p.setBrush(Qt.NoBrush) self._unknown_fill(p, path, rect, key)
p.setPen(self._outline_pen(key, look.known or look.pending)) self._stroke(p, path, self._outline_pen(key, look.known or look.pending), key)
p.drawPath(path)
f = QFont(self.mono) f = QFont(self.mono)
f.setPixelSize(10) f.setPixelSize(10)
@@ -405,14 +623,13 @@ class PadView(QWidget):
p.drawText(rect.adjusted(8, 6, -8, -6), Qt.AlignLeft | Qt.AlignTop, p.drawText(rect.adjusted(8, 6, -8, -6), Qt.AlignLeft | Qt.AlignTop,
f"0x{core.action_byte(key):02x}") f"0x{core.action_byte(key):02x}")
f = QFont(self.font()) f = self._legend_font(13)
f.setPixelSize(13)
p.setFont(f) p.setFont(f)
if look.pending: if look.pending:
p.setPen(TAPE) p.setPen(TAPE)
else: else:
p.setPen(INK if look.known and not look.quiet else INK_DIM) 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) body = rect.adjusted(8, 22, -8, -10)
# Let 'Meta+Ctrl+Left' break after a '+' instead of being cut off. # Let 'Meta+Ctrl+Left' break after a '+' instead of being cut off.
text = text.replace("+", "+\u200b") text = text.replace("+", "+\u200b")
@@ -442,6 +659,8 @@ class PadView(QWidget):
tip.y() - 3.5 * math.sin(a))) tip.y() - 3.5 * math.sin(a)))
def _paint_dial(self, p, dial, c): 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. # Ring segments for turn left / turn right, centre for press.
for act in core.DIAL_ACTIONS: for act in core.DIAL_ACTIONS:
control = f"{dial}-{act}" control = f"{dial}-{act}"
@@ -449,44 +668,41 @@ class PadView(QWidget):
seg = self._segment(dial, act) seg = self._segment(dial, act)
if act != "push": if act != "push":
if not look.known and not look.pending: 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: if control == self.current:
fill = QColor(TAPE) fill = QColor(TAPE)
fill.setAlpha(55) fill.setAlpha(55)
p.fillPath(seg, fill) p.fillPath(seg, fill)
p.setPen(self._outline_pen(control, look.known or look.pending)) self._stroke(p, seg, self._outline_pen(control, look.known or look.pending),
p.setBrush(Qt.NoBrush) control)
p.drawPath(seg)
push = f"{dial}-push" push = f"{dial}-push"
look = self.looks.get(push, Look()) look = self.looks.get(push, Look())
knob = QPainterPath() knob = QPainterPath()
knob.addEllipse(c, R_KNOB, R_KNOB) knob.addEllipse(c, R_KNOB, R_KNOB)
if not look.known and not look.pending: 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: if push == self.current:
fill = QColor(TAPE) fill = QColor(TAPE)
fill.setAlpha(55) fill.setAlpha(55)
p.fillPath(knob, fill) p.fillPath(knob, fill)
p.setPen(self._outline_pen(push, look.known or look.pending)) self._stroke(p, knob, self._outline_pen(push, look.known or look.pending), push)
p.setBrush(Qt.NoBrush)
p.drawPath(knob)
# knurling, the way a footprint marks a rotary encoder # knurling, the way a footprint marks a rotary encoder
if not THEME.wobble:
p.setPen(QPen(INK_DIM, 1.0)) p.setPen(QPen(INK_DIM, 1.0))
for i in range(24): for i in range(24):
a = math.radians(i * 15) a = math.radians(i * 15)
p.drawLine(QPointF(c.x() + (R_KNOB - 4) * math.cos(a), c.y() + (R_KNOB - 4) * math.sin(a)), 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))) QPointF(c.x() + (R_KNOB - 1) * math.cos(a), c.y() + (R_KNOB - 1) * math.sin(a)))
f = QFont(self.font()) f = self._legend_font(11)
f.setPixelSize(11)
p.setFont(f) 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.setPen(Qt.NoPen)
p.setBrush(BOARD) p.setBrush(BOARD)
p.drawRoundedRect(label, 3, 3) p.drawRoundedRect(label, 3, 3)
p.setPen(TAPE if push == self.current else INK_DIM) 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. # Legend rows: the silkscreen text next to the part.
for act in core.DIAL_ACTIONS: 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, p.drawText(rect.adjusted(0, 0, -4, 0), Qt.AlignRight | Qt.AlignVCenter,
f"{core.action_byte(control):02x}") f"{core.action_byte(control):02x}")
f = QFont(self.font()) f = self._legend_font(12, roomy=False)
f.setPixelSize(12)
p.setFont(f) p.setFont(f)
p.setPen(TAPE if look.pending else p.setPen(TAPE if look.pending else
(INK if look.known and not look.quiet else INK_DIM)) (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) body = rect.adjusted(22, 0, -26, 0)
p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter, p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter,
QFontMetricsF(f).elidedText(text, Qt.ElideRight, body.width())) 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

View File

@@ -1,3 +1,3 @@
# MacroPad: let the logged-in user configure 1189:8840 macro pads. # MacroPad: let the logged-in user configure 1189:8840 macro pads.
# Must sort before 73-seat-late.rules, which is where uaccess takes effect. # Must sort before 73-seat-late.rules, which is where uaccess takes effect.
KERNEL=="hidraw*", ATTRS{idVendor}=="1189", ATTRS{idProduct}=="8840", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="1189", TAG+="uaccess"

347
tests/test_actions.py Normal file
View File

@@ -0,0 +1,347 @@
"""
Host actions. The listener can't be tried against a real pad here, so the
parts that touch hardware are tested against the shapes of report the HID
spec allows, and the rest against a fake store and a fake runner.
"""
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import macropad as proto
from macropad_gui import actions as A, core
class Triggers(unittest.TestCase):
def test_f13_to_f24_are_standard_hid_usages(self):
self.assertEqual(proto.KEYCODES["f13"], 0x68)
self.assertEqual(proto.KEYCODES["f24"], 0x73)
self.assertEqual(len(A.TRIGGERS), 12)
def test_a_trigger_binding_writes_like_any_key(self):
config = core.Binding(keys="f17").reports(3, 0x05)[0]
self.assertEqual(config[:14].hex(" "), "03 fd 05 01 01 00 00 00 00 00 01 00 6c 00")
self.assertEqual(core.decode_config(config)[2], core.Binding(keys="f17"))
def test_pick_keeps_a_controls_own_trigger(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
store.set("f15", A.Action("url", "x.com"))
self.assertEqual(A.pick_trigger(store, {"f15"}, current="f15"), "f15")
def test_pick_skips_anything_in_use(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
store.set("f13", A.Action("url", "x.com"))
# f14 is on the pad as a plain key someone chose themselves
self.assertEqual(A.pick_trigger(store, {"f13", "f14"}), "f15")
def test_automatic_pick_leaves_the_troublemakers_till_last(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
order = []
for _ in A.TRIGGERS:
t = A.pick_trigger(store, set())
order.append(t)
store.set(t, A.Action("command", "true"))
self.assertEqual(order[0], "f14")
self.assertEqual(set(order[-5:]), set(A.CLAIMED))
self.assertEqual(set(order), set(A.TRIGGERS))
def test_a_chosen_key_is_honoured_when_free(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
self.assertEqual(A.pick_trigger(store, set(), want="f22"), "f22")
def test_a_chosen_key_that_is_taken_is_not(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
store.set("f22", A.Action("url", "x.com"))
self.assertNotEqual(A.pick_trigger(store, set(), want="f22"), "f22")
# but a control can always keep, or re-choose, its own key
self.assertEqual(A.pick_trigger(store, set(), current="f22", want="f22"), "f22")
def test_moving_an_action_to_another_key(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
store.set("f14", A.Action("url", "x.com"))
self.assertEqual(A.pick_trigger(store, {"f14"}, current="f14", want="f19"), "f19")
def test_pick_runs_out_honestly(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
for t in A.TRIGGERS:
store.set(t, A.Action("command", "true"))
self.assertIsNone(A.pick_trigger(store, set()))
class Actions(unittest.TestCase):
def test_urls_get_a_scheme_when_missing(self):
self.assertEqual(A.Action("url", "github.com").url, "https://github.com")
self.assertEqual(A.Action("url", "http://x.org").url, "http://x.org")
self.assertEqual(A.Action("url", "mailto:a@b.c").url, "mailto:a@b.c")
def test_labels_fit_a_keycap(self):
self.assertEqual(A.Action("url", "https://www.github.com/Probler-Yt").label(),
"Open github.com")
self.assertEqual(A.Action("app", "spotify", "Spotify").label(), "Spotify")
self.assertEqual(A.Action("command", "~/bin/switch-audio.sh --next").label(),
"Run switch-audio.sh")
def test_only_commands_go_through_a_shell(self):
self.assertEqual(A.Action("url", "x.com").argv(), ["xdg-open", "https://x.com"])
self.assertEqual(A.Action("app", "firefox --new-window").argv(),
["firefox", "--new-window"])
self.assertEqual(A.Action("command", "echo hi | wc").argv()[:2], ["sh", "-c"])
def test_nonsense_is_refused(self):
for bad in (A.Action("url", ""), A.Action("url", "not a url"),
A.Action("teleport", "x"), A.Action("command", " ")):
with self.assertRaises(ValueError, msg=bad):
bad.validate()
def test_running_detaches(self):
seen = {}
A.Action("url", "x.com").run(_popen=lambda argv, **kw: seen.update(argv=argv, **kw))
self.assertEqual(seen["argv"], ["xdg-open", "https://x.com"])
self.assertTrue(seen["start_new_session"])
class Store(unittest.TestCase):
def test_round_trip_and_live_reload(self):
path = Path(tempfile.mkdtemp()) / "a.json"
a = A.ActionStore(path)
a.set("f13", A.Action("url", "github.com"))
a.save()
b = A.ActionStore(path)
self.assertEqual(b.get("f13"), A.Action("url", "github.com"))
a.set("f14", A.Action("command", "true"))
a.save()
os.utime(path, (1, 1)) # make sure the mtime visibly moves
b.reload()
self.assertIn("f14", b.by_trigger)
def test_prune_forgets_triggers_no_longer_on_the_pad(self):
s = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
s.set("f13", A.Action("url", "a.com"))
s.set("f14", A.Action("url", "b.com"))
self.assertEqual(s.prune({"f14"}), ["f13"])
self.assertEqual(list(s.by_trigger), ["f14"])
class DesktopApps(unittest.TestCase):
def test_exec_lines_are_cleaned(self):
self.assertEqual(A.clean_exec("firefox %u"), "firefox")
self.assertEqual(A.clean_exec('code --new-window %F'), "code --new-window")
self.assertEqual(
A.clean_exec("/usr/bin/flatpak run --branch=stable --arch=x86_64 "
"--command=brave com.brave.Browser @@u %U @@"),
"/usr/bin/flatpak run --branch=stable --arch=x86_64 "
"--command=brave com.brave.Browser")
def test_listing_apps(self):
d = Path(tempfile.mkdtemp())
(d / "a.desktop").write_text("[Desktop Entry]\nType=Application\nName=Zed\n"
"Name[fr]=Zède\nExec=zed %F\n")
(d / "b.desktop").write_text("[Desktop Entry]\nType=Application\nName=Alpha\n"
"Exec=alpha\n[Desktop Action new]\nExec=alpha --new\n")
(d / "hidden.desktop").write_text("[Desktop Entry]\nType=Application\n"
"Name=Secret\nExec=x\nNoDisplay=true\n")
(d / "link.desktop").write_text("[Desktop Entry]\nType=Link\nName=L\nURL=x\n")
self.assertEqual(A.installed_apps([d]), [("Alpha", "alpha"), ("Zed", "zed")])
class ReadingTheKeyboard(unittest.TestCase):
"""The pad's keyboard interface, in the forms the HID spec allows."""
# a standard boot keyboard descriptor, no report ids
BOOT = bytes.fromhex(
"05010906a101050719e029e71500250175019508810295017508810195067508"
"150025650507190029658100c0")
# the same keyboard behind report id 1, followed by a consumer collection
WITH_ID = bytes.fromhex(
"05010906a1018501050719e029e7150025017501950881029506750815002565"
"0507190029658100c0050c0901a1018502150026ff031900 2aff03 7510 9501 8100c0"
.replace(" ", ""))
VENDOR = bytes([6, 0, 255, 9, 1, 161, 1, 133, 2, 9, 1, 21, 0, 38, 255, 0,
117, 8, 149, 63, 129, 0, 192])
def test_descriptors(self):
self.assertEqual(A._descriptor_keyboard_report_id(self.BOOT), (True, 0))
self.assertEqual(A._descriptor_keyboard_report_id(self.WITH_ID), (True, 1))
self.assertEqual(A._descriptor_keyboard_report_id(self.VENDOR), (False, 0))
def test_reports(self):
f17 = proto.KEYCODES["f17"]
self.assertEqual(A.pressed_codes(bytes([0, 0, f17, 0, 0, 0, 0, 0]), 0), {f17})
self.assertEqual(A.pressed_codes(bytes([1, 0, 0, f17, 0, 0, 0, 0, 0]), 1), {f17})
self.assertIsNone(A.pressed_codes(bytes([2, 0xe9, 0]), 1)) # a media report
self.assertEqual(A.pressed_codes(bytes(8), 0), set()) # all released
class Listening(unittest.TestCase):
def setUp(self):
self.store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
self.store.set("f13", A.Action("url", "github.com"))
self.ran = []
self.logs = []
self.l = A.Listener(self.store, log=self.logs.append, run=self.ran.append)
self.f13, self.f14 = proto.KEYCODES["f13"], proto.KEYCODES["f14"]
def test_fires_once_per_press_however_long_it_is_held(self):
self.l.feed({self.f13})
self.l.feed({self.f13}) # still held: a repeat report
self.l.feed(set()) # released
self.l.feed({self.f13}) # pressed again
self.assertEqual(len(self.ran), 2)
def test_ignores_ordinary_keys_and_unset_triggers(self):
self.l.feed({proto.KEYCODES["a"]})
self.l.feed({self.f14})
self.assertEqual(self.ran, [])
self.assertIn("no action is set", self.logs[-1])
def test_ignores_reports_that_arent_keyboard_ones(self):
self.assertEqual(self.l.feed(None), [])
def test_a_failing_action_doesnt_kill_the_listener(self):
def boom(_):
raise FileNotFoundError(2, "No such file")
l = A.Listener(self.store, log=self.logs.append, run=boom)
self.assertEqual(l.feed({self.f13}), [])
self.assertIn("couldn't run", self.logs[-1])
class Autostart(unittest.TestCase):
def test_toggle_writes_and_removes_an_autostart_entry(self):
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
A.set_autostart(True)
text = A.autostart_path().read_text()
self.assertIn("Exec=", text)
self.assertIn("actions", text)
self.assertTrue(A.autostart_enabled())
A.set_autostart(False)
self.assertFalse(A.autostart_enabled())
def test_never_signals_a_process_that_isnt_ours(self):
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": d}):
(Path(d) / A.LOCK).write_text(str(os.getpid())) # this test runner
self.assertIsNone(A.listener_pid())
if __name__ == "__main__":
unittest.main()
class TypingText(unittest.TestCase):
"""The phrase feature: clipboard, then a virtual keyboard presses paste."""
def fake_keyboard(self):
import struct
self.ioctls, self.writes, self.slept = [], [], []
kb = A.VirtualKeyboard(
_open=lambda path, flags: 99,
_ioctl=lambda fd, req, arg=None: self.ioctls.append((req, arg)),
_write=lambda fd, data: self.writes.append(data),
_sleep=self.slept.append)
return kb, struct
def test_the_text_action(self):
a = A.Action("text", "Kind regards,\nProbler").validate()
self.assertEqual(a.label(), 'Type "Kind regards,"')
self.assertEqual(A.Action.from_json(a.to_json()), a)
long = A.Action("text", "Thanks for reaching out, I'll reply soon")
self.assertLessEqual(len(long.label()), 24)
with self.assertRaises(ValueError):
A.Action("text", "hi", paste="ctrl+p").validate()
def test_the_device_is_set_up_the_way_the_kernel_expects(self):
kb, struct = self.fake_keyboard()
requests = [r for r, _ in self.ioctls]
self.assertEqual(requests[0], A._UI_SET_EVBIT)
self.assertEqual(sorted(a for r, a in self.ioctls if r == A._UI_SET_KEYBIT),
[29, 42, 47]) # ctrl, shift, v only
self.assertEqual(requests[-1], A._UI_DEV_CREATE)
self.assertEqual(len(self.writes[0]), 1116) # struct uinput_user_dev
self.assertIn(0.3, self.slept) # waits to be noticed
def test_ctrl_v_is_pressed_and_released_in_order(self):
kb, struct = self.fake_keyboard()
self.writes.clear()
kb.press("ctrl+v")
events = [struct.unpack("@llHHi", w)[2:] for w in self.writes]
self.assertEqual(events, [(1, 29, 1), (1, 47, 1), (0, 0, 0),
(1, 47, 0), (1, 29, 0), (0, 0, 0)])
def test_terminal_paste_holds_shift_too(self):
kb, struct = self.fake_keyboard()
self.writes.clear()
kb.press("ctrl+shift+v")
downs = [struct.unpack("@llHHi", w)[3] for w in self.writes
if struct.unpack("@llHHi", w)[2:] [0] == 1 and struct.unpack("@llHHi", w)[4] == 1]
self.assertEqual(downs, [29, 42, 47])
def test_clipboard_first_then_paste(self):
order = []
class Kb:
def press(self, combo):
order.append(("press", combo))
A.type_text("hello £ @ 👋", "ctrl+v",
_clip=lambda t: order.append(("clip", t)),
_keyboard_factory=Kb)
self.assertEqual(order, [("clip", "hello £ @ 👋"), ("press", "ctrl+v")])
def test_clipboard_tool_by_session(self):
with mock.patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}), \
mock.patch("shutil.which", side_effect=lambda n: "/usr/bin/" + n):
self.assertEqual(A._clipboard_tool(), ["wl-copy"])
with mock.patch.dict(os.environ, {"WAYLAND_DISPLAY": "", "XDG_SESSION_TYPE": "x11"}), \
mock.patch("shutil.which", side_effect=lambda n: "/usr/bin/xclip" if n == "xclip" else None):
self.assertEqual(A._clipboard_tool(), ["xclip", "-selection", "clipboard"])
def test_problems_come_with_fixes(self):
with mock.patch.object(A.os, "access", return_value=False), \
mock.patch.object(A, "_clipboard_tool", return_value=None):
probs = A.text_problems()
self.assertEqual(len(probs), 2)
self.assertIn("60-macropad-uinput.rules", probs[0][1][0])
with mock.patch.object(A.os, "access", return_value=True), \
mock.patch.object(A, "_clipboard_tool", return_value=["wl-copy"]):
self.assertEqual(A.text_problems(), [])
def test_the_listener_runs_text_actions_too(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
store.set("f16", A.Action("text", "gg ez"))
typed = []
with mock.patch.object(A, "type_text", side_effect=lambda t, p: typed.append((t, p))):
A.Listener(store, log=lambda *_: None).feed({proto.KEYCODES["f16"]})
self.assertEqual(typed, [("gg ez", "ctrl+v")])
class Themes(unittest.TestCase):
def test_every_theme_is_complete_and_valid(self):
import re
from macropad_gui import themes
colours = ("window", "board", "ink", "ink_dim", "hatch", "accent",
"accent_text", "good", "bad", "line", "field")
for t in themes.THEMES.values():
for c in colours:
self.assertRegex(getattr(t, c), r"^#[0-9a-f]{6}$", f"{t.key}.{c}")
self.assertIn(t.unknown, ("hatch", "hidden"))
self.assertIn(themes.DEFAULT, themes.THEMES)
self.assertEqual(themes.get("no such theme").key, themes.DEFAULT)
def test_mixing(self):
from macropad_gui import themes
self.assertEqual(themes.mix("#000000", "#ffffff", 0.5), "#808080")
self.assertEqual(themes.mix("#123456", "#abcdef", 0), "#123456")
def test_the_sketch_font_ships_with_its_licence(self):
from macropad_gui import themes
self.assertTrue((themes.FONTS / "ArchitectsDaughter-Regular.ttf").exists())
self.assertIn("Open Font License", (themes.FONTS / "OFL.txt").read_text())

View File

@@ -143,7 +143,8 @@ class ReadingThePad(unittest.TestCase):
def test_our_requests_match_the_vendors(self): def test_our_requests_match_the_vendors(self):
info, read1, read2, read3 = self.sent info, read1, read2, read3 = self.sent
self.assertEqual(proto.native_info(3)[:2], info[:2]) # the vendor repeats the command byte; match it rather than assume
self.assertEqual(proto.native_info(3)[:4], info[:4])
for layer, sent in ((1, read1), (2, read2), (3, read3)): for layer, sent in ((1, read1), (2, read2), (3, read3)):
# the vendor's trailing bytes are uninitialised memory; ours are zero # the vendor's trailing bytes are uninitialised memory; ours are zero
self.assertEqual(proto.native_read(3, layer)[:6], sent[:6]) self.assertEqual(proto.native_read(3, layer)[:6], sent[:6])
@@ -382,6 +383,33 @@ class Arranging(unittest.TestCase):
self.assertEqual(core.State(path).layout, moved) self.assertEqual(core.State(path).layout, moved)
class OtherSystems(unittest.TestCase):
def test_a_non_linux_system_gets_an_explanation(self):
from unittest import mock
with mock.patch.object(core, "on_linux", return_value=False):
d = core.diagnose()
self.assertEqual(d.status, "unsupported-os")
self.assertFalse(d.ready)
self.assertIsNone(d.device)
def test_the_permission_rule_covers_the_whole_vendor(self):
"""A rule tied to one product id strands everyone with a sibling pad."""
self.assertIn('idVendor}}=="1189"'.replace("}}", "}"), core.RULE_LINE)
self.assertNotIn("idProduct", core.RULE_LINE)
def test_an_unreadable_unknown_pad_is_told_how_to_fix_it(self):
from unittest import mock
other = [{"path": "/dev/hidraw4", "vid": "1189", "pid": "8890",
"report_id": 0, "writable": False, "name": ""}]
with mock.patch.object(proto, "find_devices", return_value=other), \
mock.patch.object(core, "_keyd_warning", return_value=None):
d = core.diagnose()
self.assertEqual(d.status, "unsupported")
self.assertTrue(d.fix)
self.assertIn("60-macropad.rules", d.fix[0])
class Layouts(unittest.TestCase): class Layouts(unittest.TestCase):
def test_action_bytes_agree_with_the_protocol_module(self): def test_action_bytes_agree_with_the_protocol_module(self):
@@ -593,6 +621,7 @@ class GuiSmoke(unittest.TestCase):
except ImportError: except ImportError:
self.skipTest("PySide6 not installed") self.skipTest("PySide6 not installed")
from unittest import mock from unittest import mock
from macropad_gui import actions as act_mod
with tempfile.TemporaryDirectory() as d, \ with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}): mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
qa = QApplication.instance() or QApplication([]) qa = QApplication.instance() or QApplication([])
@@ -626,6 +655,25 @@ class GuiSmoke(unittest.TestCase):
w._edit_layout(False, keep=False) w._edit_layout(False, keep=False)
self.assertEqual(w.state.layout, core.DEFAULT_LAYOUT) self.assertEqual(w.state.layout, core.DEFAULT_LAYOUT)
self.assertFalse(w.pad.editing) self.assertFalse(w.pad.editing)
# an action typed for one key must not leak into the next one
w.pad.select("key2"); w._refresh()
w.inspector.mode_action.click()
w.inspector.action_edit.setText("github.com")
w.inspector._action_edited()
w.pad.select("key3"); w._refresh()
w.inspector.mode_action.click()
self.assertEqual(w.inspector.action_edit.text(), "")
# the picker: automatic avoids F13, and a chosen key is used
w.desired.clear(); w.pending_actions.clear()
w.pad.select("key4"); w._refresh()
w._host_action("key4", act_mod.Action("url", "a.com"))
self.assertEqual(w.desired["key4"].keys, "f14")
w._host_action("key4", act_mod.Action("url", "a.com"), "f22")
self.assertEqual(w.desired["key4"].keys, "f22")
opts = dict(w._trigger_options("key6"))
self.assertEqual(opts["f22"], "Key 4") # taken, shown as whose
self.assertIsNone(opts["f15"])
w.desired.clear(); w.pending_actions.clear()
w._changed("key1", core.Binding(media="mute")) w._changed("key1", core.Binding(media="mute"))
self.assertEqual(w._pending(), ["key1"]) self.assertEqual(w._pending(), ["key1"])
# choosing Nothing marks the control as changed straight away # choosing Nothing marks the control as changed straight away
@@ -636,3 +684,53 @@ class GuiSmoke(unittest.TestCase):
self.assertIn("key5", w._pending()) self.assertIn("key5", w._pending())
w.desired.clear() w.desired.clear()
w.close() w.close()
class ThemedDrawing(unittest.TestCase):
"""Every theme, every shape, both ways up, switched live."""
def test_switching_themes_live(self):
try:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from macropad_gui import app, padview, themes
except ImportError:
self.skipTest("PySide6 not installed")
from unittest import mock
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
qa = QApplication.instance() or QApplication([])
w = app.Window()
w.timer.stop()
for t in themes.THEMES.values():
w._set_theme(t.key)
self.assertEqual(padview.THEME.key, t.key)
self.assertEqual(padview.INK.name(), t.ink) # changed in place
for lay in (core.DEFAULT_LAYOUT, core.Layout(15, 3, 3, 5)):
w.pad.set_layout(lay)
for o in ("upright", "flat"):
w.pad.set_orientation(o)
w.pad.grab()
self.assertEqual(w.settings.value("theme"), list(themes.THEMES)[-1])
w._set_theme(themes.DEFAULT)
w.close()
def test_pencil_lines_are_the_same_every_time(self):
try:
from PySide6.QtGui import QPainterPath
from PySide6.QtCore import QRectF
from macropad_gui import padview
except ImportError:
self.skipTest("PySide6 not installed")
path = QPainterPath()
path.addRoundedRect(QRectF(0, 0, 96, 96), 5, 5)
a = padview._wobble_path(path, 1.6, padview._seed("key4"))
b = padview._wobble_path(path, 1.6, padview._seed("key4"))
c = padview._wobble_path(path, 1.6, padview._seed("key5"))
self.assertEqual([(p.x(), p.y()) for p in a.toFillPolygon()],
[(p.x(), p.y()) for p in b.toFillPolygon()])
self.assertNotEqual([(p.x(), p.y()) for p in a.toFillPolygon()],
[(p.x(), p.y()) for p in c.toFillPolygon()])
# it wanders, but only a little
box = a.boundingRect()
self.assertLess(abs(box.width() - 96), 5)

View File

@@ -15,6 +15,15 @@ ask() {
case "$answer" in [yY]*) return 0 ;; *) return 1 ;; esac case "$answer" in [yY]*) return 0 ;; *) return 1 ;; esac
} }
# stop the actions listener if it's running, and don't start it at login
LOCK="${XDG_RUNTIME_DIR:-$HOME/.cache}/macropad-actions.lock"
pid="$(cat "$LOCK" 2>/dev/null || true)"
case "$pid" in ''|*[!0-9]*) pid="" ;; esac
if [ -n "$pid" ] && tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "macropad.*actions"; then
kill "$pid" 2>/dev/null || true
fi
rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/autostart/macropad-actions.desktop"
rm -rf "$DATA/macropad" rm -rf "$DATA/macropad"
rm -f "$HOME/.local/bin/macropad" "$DATA/applications/macropad.desktop" \ rm -f "$HOME/.local/bin/macropad" "$DATA/applications/macropad.desktop" \
"$DATA/icons/hicolor/scalable/apps/macropad.svg" "$DATA/icons/hicolor/scalable/apps/macropad.svg"