Themes, actions, text snippets and a key picker
119
README.md
@@ -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. |
|
||||
| 🔎 **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. |
|
||||
| 🎨 **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. |
|
||||
| 🩺 **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. |
|
||||
@@ -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 |
|
||||
| Record a shortcut by pressing it | 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 |
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
|
||||
## 🎨 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
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
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">
|
||||
</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:
|
||||
|
||||
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:
|
||||
Install MacroPad, plug your pad in, and run:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
@@ -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.**
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -692,6 +780,8 @@ macropad.py the protocol and the command line tool (no dependencies
|
||||
macropad_gui/
|
||||
core.py bindings, the app's notes, the permission check, writing
|
||||
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
|
||||
keymap.py physical key to HID name, for Record
|
||||
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
|
||||
- [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] 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
|
||||
|
||||
---
|
||||
@@ -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.
|
||||
- **[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.
|
||||
|
||||
## ⚖️ Licence
|
||||
|
||||
BIN
docs/images/action.png
Normal file
|
After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 241 KiB |
BIN
docs/images/theme-blueprint.png
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
docs/images/theme-sketch.png
Normal file
|
After Width: | Height: | Size: 455 KiB |
BIN
docs/images/themes.png
Normal file
|
After Width: | Height: | Size: 856 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 359 KiB |
@@ -211,6 +211,21 @@ def scene_edit_layout():
|
||||
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():
|
||||
w = window(bindings=DEMO)
|
||||
w.pad.set_orientation("flat")
|
||||
@@ -356,6 +371,81 @@ def scene_social():
|
||||
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():
|
||||
r = QSvgRenderer(str(ROOT / "packaging" / "macropad.svg"))
|
||||
img = QImage(256, 256, QImage.Format_ARGB32)
|
||||
|
||||
21
install.sh
@@ -22,7 +22,7 @@ BIN="$HOME/.local/bin"
|
||||
DESKTOP="$DATA/applications/macropad.desktop"
|
||||
ICON="$DATA/icons/hicolor/scalable/apps/macropad.svg"
|
||||
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
|
||||
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
|
||||
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
|
||||
step "Letting the app talk to the pad"
|
||||
if [ -f "$RULE" ] && grep -qF "$RULE_LINE" "$RULE"; then
|
||||
|
||||
12
macropad.py
@@ -62,6 +62,8 @@ KEYCODES.update({
|
||||
})
|
||||
for _i in range(1, 13):
|
||||
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 = {
|
||||
@@ -247,9 +249,15 @@ LAYERS = 3
|
||||
|
||||
|
||||
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[0] = MAGIC_INFO
|
||||
data[0] = data[1] = data[2] = MAGIC_INFO
|
||||
return bytes([report_id]) + bytes(data)
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,12 @@ def cmd_detect(_):
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_actions(a):
|
||||
"""The background listener. Normally started by the app or at login."""
|
||||
from . import actions
|
||||
return actions.listen(debug=a.debug)
|
||||
|
||||
|
||||
def cmd_write(a):
|
||||
if a.control not in core.CONTROL_IDS:
|
||||
print(f"Unknown control. One of: {', '.join(core.CONTROL_IDS)}")
|
||||
@@ -100,6 +106,9 @@ def main():
|
||||
sub.add_parser("doctor", help="check the pad is connected and writable")
|
||||
sub.add_parser("state", help="show what this tool believes is on the pad")
|
||||
sub.add_parser("detect", help="ask an unrecognised pad what it is (read only)")
|
||||
ac = sub.add_parser("actions", help="run the listener that makes action keys work")
|
||||
ac.add_argument("--debug", action="store_true",
|
||||
help="print every report the pad's keyboard sends")
|
||||
w = sub.add_parser("write", help="write one binding and record it")
|
||||
w.add_argument("control")
|
||||
w.add_argument("keys", nargs="?", help="e.g. 'ctrl+c' or 'a,b,c'")
|
||||
@@ -115,7 +124,7 @@ def main():
|
||||
return 1
|
||||
return run()
|
||||
return {"doctor": cmd_doctor, "state": cmd_state, "write": cmd_write,
|
||||
"detect": cmd_detect}[a.cmd](a)
|
||||
"detect": cmd_detect, "actions": cmd_actions}[a.cmd](a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
705
macropad_gui/actions.py
Normal file
@@ -0,0 +1,705 @@
|
||||
"""
|
||||
Host actions: make a pad key open a URL, launch an app, or run a command.
|
||||
|
||||
The pad can't do any of that itself. All it can ever send is a key press.
|
||||
So an action is two halves:
|
||||
|
||||
on the pad the control is bound to a spare key, one of F13 to F24,
|
||||
which no normal keyboard has and no desktop binds
|
||||
on the host a small listener watches the pad's keyboard interface and,
|
||||
when it sees one of those keys, runs the action
|
||||
|
||||
The listener is optional and off unless you turn it on. When it isn't
|
||||
running, action keys send F13 to F24 and nothing happens, and every other
|
||||
key on the pad works exactly as before.
|
||||
|
||||
It reads the pad's keyboard interface through /dev/hidraw, the same way the
|
||||
rest of the app talks to the pad, so the udev rule that already exists is
|
||||
all the permission it needs. Reading hidraw doesn't take the key away from
|
||||
the desktop; it just gets a copy.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from . import core
|
||||
|
||||
proto = core.proto
|
||||
|
||||
TRIGGERS = tuple(f"f{i}" for i in range(13, 25))
|
||||
CODE_TO_TRIGGER = {proto.KEYCODES[t]: t for t in TRIGGERS}
|
||||
|
||||
# Linux's keyboard layer gives some of these keys a meaning of their own, so
|
||||
# the desktop acts on them as well as the listener. F13, F20 and F21 were
|
||||
# confirmed on Plasma 6; F22 and F23 are the touchpad on and off keys from
|
||||
# the same table. They can still be chosen, but they're picked last.
|
||||
CLAIMED = {
|
||||
"f13": "often opens System Settings",
|
||||
"f20": "often mutes the microphone",
|
||||
"f21": "often toggles the touchpad",
|
||||
"f22": "may switch the touchpad on",
|
||||
"f23": "may switch the touchpad off",
|
||||
}
|
||||
# the order the automatic pick tries them in
|
||||
PREFERRED = tuple(sorted(TRIGGERS, key=lambda t: (t in CLAIMED, int(t[1:]))))
|
||||
KINDS = ("url", "app", "command", "text")
|
||||
PASTE_KEYS = ("ctrl+v", "ctrl+shift+v")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ action
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
"""What the computer does when a trigger key arrives."""
|
||||
kind: str # url | app | command | text
|
||||
target: str # the URL, the command line, or the text to type
|
||||
name: str = "" # for apps, the name people recognise
|
||||
paste: str = "" # for text: the paste shortcut, ctrl+v unless set
|
||||
|
||||
def validate(self):
|
||||
if self.kind not in KINDS:
|
||||
raise ValueError(f"unknown action kind '{self.kind}'")
|
||||
if not self.target.strip():
|
||||
raise ValueError({"url": "enter a web address",
|
||||
"app": "pick an app",
|
||||
"command": "enter a command",
|
||||
"text": "enter the text to type"}[self.kind])
|
||||
if self.paste and self.paste not in PASTE_KEYS:
|
||||
raise ValueError(f"unknown paste shortcut '{self.paste}'")
|
||||
if self.kind == "url" and " " in self.target.strip():
|
||||
raise ValueError("a web address can't contain spaces")
|
||||
return self
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""URLs without a scheme get https, so 'github.com' just works."""
|
||||
t = self.target.strip()
|
||||
if self.kind != "url" or re.match(r"^[a-z][a-z0-9+.-]*:", t, re.I):
|
||||
return t
|
||||
return "https://" + t
|
||||
|
||||
def label(self):
|
||||
"""Short enough for a keycap."""
|
||||
if self.kind == "url":
|
||||
host = re.sub(r"^[a-z][a-z0-9+.-]*://", "", self.url, flags=re.I)
|
||||
host = host.split("/")[0].removeprefix("www.")
|
||||
return f"Open {host}"
|
||||
if self.kind == "app":
|
||||
return self.name or self.target.split()[0].rsplit("/", 1)[-1]
|
||||
if self.kind == "text":
|
||||
line = self.target.strip().splitlines()[0]
|
||||
short = line if len(line) <= 16 else line[:15].rstrip() + "…"
|
||||
return f'Type "{short}"'
|
||||
first = self.target.strip().split()[0].rsplit("/", 1)[-1]
|
||||
return f"Run {first}"
|
||||
|
||||
def argv(self):
|
||||
"""What to execute. Shell only where the user wrote a shell command."""
|
||||
if self.kind == "url":
|
||||
return ["xdg-open", self.url]
|
||||
if self.kind == "app":
|
||||
try:
|
||||
return shlex.split(self.target)
|
||||
except ValueError:
|
||||
return ["sh", "-c", self.target]
|
||||
return ["sh", "-c", self.target]
|
||||
|
||||
def run(self, _popen=None):
|
||||
"""Start it and walk away. The listener must never block on an app."""
|
||||
if self.kind == "text":
|
||||
return type_text(self.target, self.paste or "ctrl+v")
|
||||
return (_popen or subprocess.Popen)(self.argv(), stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
|
||||
def to_json(self):
|
||||
d = {"kind": self.kind, "target": self.target}
|
||||
if self.name:
|
||||
d["name"] = self.name
|
||||
if self.paste:
|
||||
d["paste"] = self.paste
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, d):
|
||||
return cls(kind=d["kind"], target=d["target"], name=d.get("name", ""),
|
||||
paste=d.get("paste", "")).validate()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- store
|
||||
|
||||
class ActionStore:
|
||||
"""
|
||||
trigger key -> Action, in ~/.config/macropad/actions.json.
|
||||
|
||||
Keyed by trigger rather than by control, because that's what the
|
||||
listener sees: a key, not a position on the pad. It also means reading
|
||||
the pad back still makes sense, since a control bound to F17 can be
|
||||
looked up here.
|
||||
"""
|
||||
|
||||
def __init__(self, path=None):
|
||||
self.path = Path(path) if path else core.config_dir() / "actions.json"
|
||||
self.by_trigger = {}
|
||||
self._mtime = None
|
||||
self.reload()
|
||||
|
||||
def reload(self):
|
||||
try:
|
||||
st = self.path.stat()
|
||||
except FileNotFoundError:
|
||||
self.by_trigger, self._mtime = {}, None
|
||||
return
|
||||
if st.st_mtime == self._mtime:
|
||||
return
|
||||
data = json.loads(self.path.read_text())
|
||||
self.by_trigger = {t: Action.from_json(a)
|
||||
for t, a in data.get("actions", {}).items()
|
||||
if t in TRIGGERS}
|
||||
self._mtime = st.st_mtime
|
||||
|
||||
def save(self):
|
||||
core._atomic_write_json(self.path, {
|
||||
"format": 1,
|
||||
"actions": {t: a.to_json() for t, a in sorted(self.by_trigger.items())}})
|
||||
self._mtime = self.path.stat().st_mtime
|
||||
|
||||
def get(self, trigger):
|
||||
return self.by_trigger.get(trigger)
|
||||
|
||||
def set(self, trigger, action):
|
||||
if trigger not in TRIGGERS:
|
||||
raise ValueError(f"{trigger} isn't a trigger key")
|
||||
self.by_trigger[trigger] = action.validate()
|
||||
|
||||
def remove(self, trigger):
|
||||
self.by_trigger.pop(trigger, None)
|
||||
|
||||
def prune(self, bound_keys):
|
||||
"""
|
||||
Forget actions whose trigger no longer sits on any control. Only
|
||||
call this when every control's binding is known, or it may throw
|
||||
away an action that's still on the pad.
|
||||
"""
|
||||
gone = [t for t in self.by_trigger if t not in bound_keys]
|
||||
for t in gone:
|
||||
del self.by_trigger[t]
|
||||
return gone
|
||||
|
||||
|
||||
def trigger_of(binding):
|
||||
"""The trigger key a binding sends, or None if it isn't one."""
|
||||
if binding and binding.keys and binding.keys.lower() in TRIGGERS:
|
||||
return binding.keys.lower()
|
||||
return None
|
||||
|
||||
|
||||
def trigger_free(trigger, store, used_keys, current=None):
|
||||
"""Can this control use this trigger without clashing with another?"""
|
||||
if trigger == current:
|
||||
return True
|
||||
return trigger not in store.by_trigger and trigger not in used_keys
|
||||
|
||||
|
||||
def pick_trigger(store, used_keys, current=None, want=None):
|
||||
"""
|
||||
A trigger key for a control.
|
||||
|
||||
If the person asked for a particular one and it's free, that. Otherwise
|
||||
keep the one the control already has, so editing an action doesn't need
|
||||
a write to the pad. Otherwise the first free one, trying the keys the
|
||||
desktop leaves alone before the ones it's known to act on. None when all
|
||||
twelve are taken.
|
||||
"""
|
||||
if want in TRIGGERS and trigger_free(want, store, used_keys, current):
|
||||
return want
|
||||
if current in TRIGGERS:
|
||||
return current
|
||||
for t in PREFERRED:
|
||||
if trigger_free(t, store, used_keys):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
# -------------------------------------------------------------- typing text
|
||||
#
|
||||
# Typing a phrase is harder than it sounds on Linux. Keyboards send key
|
||||
# positions, not letters, so typing "£" or "@" key by key means knowing the
|
||||
# person's layout, and emoji can't be typed that way at all. So we do what
|
||||
# text expanders like espanso do for anything longer than a word: put the
|
||||
# text on the clipboard, then press the paste shortcut. That works for any
|
||||
# text in any layout.
|
||||
#
|
||||
# Pressing the shortcut means sending key presses, which on Linux goes
|
||||
# through /dev/uinput: a virtual keyboard. It needs one permission rule,
|
||||
# the same one Steam installs for its controller support.
|
||||
|
||||
UINPUT = "/dev/uinput"
|
||||
UINPUT_RULE = ('KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", '
|
||||
'OPTIONS+="static_node=uinput"')
|
||||
|
||||
_EV_SYN, _EV_KEY, _SYN_REPORT = 0, 1, 0
|
||||
_UI_SET_EVBIT = 0x40045564 # _IOW('U', 100, int)
|
||||
_UI_SET_KEYBIT = 0x40045565 # _IOW('U', 101, int)
|
||||
_UI_DEV_CREATE = 0x5501 # _IO('U', 1)
|
||||
_UI_DEV_DESTROY = 0x5502 # _IO('U', 2)
|
||||
_KEY = {"ctrl": 29, "shift": 42, "v": 47}
|
||||
|
||||
|
||||
class VirtualKeyboard:
|
||||
"""
|
||||
A tiny keyboard that only knows Ctrl, Shift and V. Created once and kept,
|
||||
because a brand new input device takes the compositor a moment to notice,
|
||||
and the first paste would otherwise go missing.
|
||||
"""
|
||||
|
||||
def __init__(self, path=UINPUT, _open=os.open, _ioctl=fcntl.ioctl,
|
||||
_write=os.write, _sleep=time.sleep):
|
||||
import struct
|
||||
self._struct, self._write, self._sleep = struct, _write, _sleep
|
||||
self.fd = _open(path, os.O_WRONLY | os.O_NONBLOCK)
|
||||
_ioctl(self.fd, _UI_SET_EVBIT, _EV_KEY)
|
||||
for code in _KEY.values():
|
||||
_ioctl(self.fd, _UI_SET_KEYBIT, code)
|
||||
name = b"MacroPad virtual keyboard"
|
||||
# struct uinput_user_dev: name, input_id, ff_effects_max, 4 x 64 abs
|
||||
dev = struct.pack("<80sHHHHI256i", name, 0x03, 0x1189, 0x8841, 1, 0,
|
||||
*([0] * 256))
|
||||
_write(self.fd, dev)
|
||||
_ioctl(self.fd, _UI_DEV_CREATE)
|
||||
_sleep(0.3)
|
||||
self._ioctl = _ioctl
|
||||
|
||||
def _emit(self, typ, code, value):
|
||||
self._write(self.fd, self._struct.pack("@llHHi", 0, 0, typ, code, value))
|
||||
|
||||
def press(self, combo):
|
||||
"""Hold the modifiers, tap the key, let go, in that order."""
|
||||
keys = [_KEY[k] for k in combo.split("+")]
|
||||
for code in keys:
|
||||
self._emit(_EV_KEY, code, 1)
|
||||
self._emit(_EV_SYN, _SYN_REPORT, 0)
|
||||
self._sleep(0.02)
|
||||
for code in reversed(keys):
|
||||
self._emit(_EV_KEY, code, 0)
|
||||
self._emit(_EV_SYN, _SYN_REPORT, 0)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._ioctl(self.fd, _UI_DEV_DESTROY)
|
||||
finally:
|
||||
os.close(self.fd)
|
||||
|
||||
|
||||
_keyboard = None
|
||||
|
||||
|
||||
def _virtual_keyboard():
|
||||
global _keyboard
|
||||
if _keyboard is None:
|
||||
_keyboard = VirtualKeyboard()
|
||||
return _keyboard
|
||||
|
||||
|
||||
def on_wayland():
|
||||
return bool(os.environ.get("WAYLAND_DISPLAY")) or \
|
||||
os.environ.get("XDG_SESSION_TYPE") == "wayland"
|
||||
|
||||
|
||||
def _clipboard_tool():
|
||||
"""The program that can set the clipboard in this session, or None."""
|
||||
import shutil
|
||||
if on_wayland():
|
||||
return ["wl-copy"] if shutil.which("wl-copy") else None
|
||||
if shutil.which("xclip"):
|
||||
return ["xclip", "-selection", "clipboard"]
|
||||
if shutil.which("xsel"):
|
||||
return ["xsel", "--clipboard", "--input"]
|
||||
return None
|
||||
|
||||
|
||||
def set_clipboard(text, _run=subprocess.run):
|
||||
tool = _clipboard_tool()
|
||||
if not tool:
|
||||
raise OSError(errno.ENOENT, "no clipboard tool installed")
|
||||
# wl-copy and xclip hand the text to a background copy of themselves and
|
||||
# return straight away, so this doesn't hang.
|
||||
_run(tool, input=text.encode(), check=True, timeout=5,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def type_text(text, paste="ctrl+v", _clip=None, _keyboard_factory=None):
|
||||
"""Put the text on the clipboard and press paste."""
|
||||
(_clip or set_clipboard)(text)
|
||||
time.sleep(0.08) # let the clipboard change land first
|
||||
(_keyboard_factory or _virtual_keyboard)().press(paste)
|
||||
|
||||
|
||||
def _install_hint(package):
|
||||
try:
|
||||
text = Path("/etc/os-release").read_text()
|
||||
except OSError:
|
||||
text = ""
|
||||
family = " ".join(l.split("=", 1)[1].strip('"') for l in text.splitlines()
|
||||
if l.startswith(("ID=", "ID_LIKE=")))
|
||||
if "arch" in family:
|
||||
return f"sudo pacman -S {package}"
|
||||
if "fedora" in family or "rhel" in family:
|
||||
return f"sudo dnf install {package}"
|
||||
if "debian" in family or "ubuntu" in family:
|
||||
return f"sudo apt install {package}"
|
||||
return f"# install the {package} package with your package manager"
|
||||
|
||||
|
||||
def text_problems():
|
||||
"""
|
||||
What stands between this computer and typing text, as
|
||||
[(what's wrong, [commands that fix it])]. Empty means ready.
|
||||
"""
|
||||
out = []
|
||||
if not os.access(UINPUT, os.W_OK):
|
||||
out.append((
|
||||
"MacroPad needs permission to press the paste shortcut for you. "
|
||||
"It's the same permission Steam asks for.",
|
||||
[f"echo '{UINPUT_RULE}' | sudo tee /etc/udev/rules.d/60-macropad-uinput.rules",
|
||||
"sudo modprobe uinput",
|
||||
"sudo udevadm control --reload-rules && sudo udevadm trigger --sysname-match=uinput"]))
|
||||
if not _clipboard_tool():
|
||||
package = "wl-clipboard" if on_wayland() else "xclip"
|
||||
out.append((f"It also needs {package} to put the text on the clipboard.",
|
||||
[_install_hint(package)]))
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------ installed apps
|
||||
|
||||
_FIELD_CODE = re.compile(r"^%[a-zA-Z]$")
|
||||
|
||||
|
||||
def clean_exec(line):
|
||||
"""
|
||||
Turn a .desktop Exec line into a command we can run. Drops the %U style
|
||||
field codes, and Flatpak's @@u / @@ markers, which only make sense to a
|
||||
launcher that is about to pass files.
|
||||
"""
|
||||
try:
|
||||
parts = shlex.split(line)
|
||||
except ValueError:
|
||||
parts = line.split()
|
||||
parts = [p for p in parts if not _FIELD_CODE.match(p) and not p.startswith("@@")]
|
||||
return shlex.join(p.replace("%%", "%") for p in parts)
|
||||
|
||||
|
||||
def _app_dirs():
|
||||
home = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share")
|
||||
dirs = [home / "applications"]
|
||||
for d in (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":"):
|
||||
if d:
|
||||
dirs.append(Path(d) / "applications")
|
||||
dirs += [home / "flatpak/exports/share/applications",
|
||||
Path("/var/lib/flatpak/exports/share/applications")]
|
||||
return dirs
|
||||
|
||||
|
||||
def installed_apps(dirs=None):
|
||||
"""[(name, command)] for launchable apps, sorted, first definition wins."""
|
||||
seen, apps = set(), []
|
||||
for d in dirs or _app_dirs():
|
||||
for f in sorted(glob.glob(str(d / "*.desktop"))):
|
||||
ident = os.path.basename(f)
|
||||
if ident in seen:
|
||||
continue
|
||||
seen.add(ident)
|
||||
entry = _read_desktop_entry(f)
|
||||
if not entry or entry.get("Type") != "Application":
|
||||
continue
|
||||
if entry.get("NoDisplay") == "true" or entry.get("Hidden") == "true":
|
||||
continue
|
||||
if entry.get("Name") and entry.get("Exec"):
|
||||
apps.append((entry["Name"], clean_exec(entry["Exec"])))
|
||||
return sorted(apps, key=lambda a: a[0].lower())
|
||||
|
||||
|
||||
def _read_desktop_entry(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
except OSError:
|
||||
return None
|
||||
entry, inside = {}, False
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
inside = line == "[Desktop Entry]"
|
||||
continue
|
||||
if inside and "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
entry.setdefault(k.strip(), v.strip()) # skip Name[fr] etc.
|
||||
return entry
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- listener
|
||||
|
||||
def _descriptor_keyboard_report_id(raw):
|
||||
"""
|
||||
Does this HID descriptor describe a keyboard, and if so which report id
|
||||
carries it? (True, 0) means a keyboard with no report ids at all.
|
||||
"""
|
||||
i, usage_page, usage, report_id = 0, 0, 0, 0
|
||||
stack, in_kbd = [], False
|
||||
while i < len(raw):
|
||||
b = raw[i]
|
||||
if b == 0xFE: # long item
|
||||
i += 3 + (raw[i + 1] if i + 1 < len(raw) else 0)
|
||||
continue
|
||||
size = b & 3
|
||||
size = 4 if size == 3 else size
|
||||
tag, typ = b >> 4, (b >> 2) & 3
|
||||
data = int.from_bytes(raw[i + 1:i + 1 + size], "little")
|
||||
if typ == 1 and tag == 0:
|
||||
usage_page = data
|
||||
elif typ == 1 and tag == 8:
|
||||
report_id = data
|
||||
elif typ == 2 and tag == 0:
|
||||
usage = data
|
||||
elif typ == 0 and tag == 10: # collection
|
||||
stack.append(in_kbd)
|
||||
if usage_page == 1 and usage == 6:
|
||||
in_kbd = True
|
||||
elif typ == 0 and tag == 12: # end collection
|
||||
in_kbd = stack.pop() if stack else False
|
||||
elif typ == 0 and tag == 8 and in_kbd: # an input item
|
||||
return True, report_id
|
||||
i += 1 + size
|
||||
return False, 0
|
||||
|
||||
|
||||
def find_keyboard_nodes(vid=core.VID):
|
||||
"""[(hidraw path, keyboard report id)] for every pad keyboard interface."""
|
||||
out = []
|
||||
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
|
||||
dev = os.path.join(node, "device")
|
||||
try:
|
||||
uevent = Path(dev, "uevent").read_text()
|
||||
raw = Path(dev, "report_descriptor").read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
hid_id = next((l.split("=", 1)[1] for l in uevent.splitlines()
|
||||
if l.startswith("HID_ID=")), "")
|
||||
if hid_id.count(":") != 2 or hid_id.split(":")[1][-4:].lower() != vid:
|
||||
continue
|
||||
is_kbd, rid = _descriptor_keyboard_report_id(raw)
|
||||
if is_kbd:
|
||||
out.append(("/dev/" + os.path.basename(node), rid))
|
||||
return out
|
||||
|
||||
|
||||
def pressed_codes(report, report_id):
|
||||
"""
|
||||
The keycodes held down in one keyboard report, or None if this report
|
||||
isn't a keyboard one. Standard layout: [id], modifiers, reserved, six
|
||||
key slots.
|
||||
"""
|
||||
if report_id:
|
||||
if not report or report[0] != report_id:
|
||||
return None
|
||||
report = report[1:]
|
||||
if len(report) < 3:
|
||||
return None
|
||||
return {c for c in report[2:8] if c}
|
||||
|
||||
|
||||
class Listener:
|
||||
"""
|
||||
Turns trigger key presses into actions. Fires once per press, on the
|
||||
way down, however long the key is held.
|
||||
"""
|
||||
|
||||
def __init__(self, store, log=print, run=None):
|
||||
self.store = store
|
||||
self.log = log
|
||||
self.run = run or (lambda action: action.run())
|
||||
self.held = set()
|
||||
|
||||
def feed(self, codes):
|
||||
if codes is None:
|
||||
return []
|
||||
fired = []
|
||||
for code in codes - self.held:
|
||||
trigger = CODE_TO_TRIGGER.get(code)
|
||||
if not trigger:
|
||||
continue
|
||||
action = self.store.get(trigger)
|
||||
if not action:
|
||||
self.log(f"{trigger.upper()} pressed, but no action is set for it")
|
||||
continue
|
||||
self.log(f"{trigger.upper()}: {action.label()}")
|
||||
try:
|
||||
self.run(action)
|
||||
fired.append(trigger)
|
||||
except OSError as e:
|
||||
self.log(f"couldn't run {action.label()}: {e.strerror or e}")
|
||||
self.held = set(codes)
|
||||
return fired
|
||||
|
||||
|
||||
def runtime_dir():
|
||||
d = os.environ.get("XDG_RUNTIME_DIR") or str(Path.home() / ".cache")
|
||||
return Path(d)
|
||||
|
||||
|
||||
LOCK = "macropad-actions.lock"
|
||||
|
||||
|
||||
def listen(debug=False, log=print):
|
||||
"""
|
||||
Run until stopped. Survives the pad being unplugged, and picks up edits
|
||||
to actions.json without a restart.
|
||||
"""
|
||||
lock_path = runtime_dir() / LOCK
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock = open(lock_path, "a+")
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
log("Actions are already running.")
|
||||
return 1
|
||||
lock.seek(0)
|
||||
lock.truncate()
|
||||
lock.write(str(os.getpid()))
|
||||
lock.flush()
|
||||
|
||||
stop = {"now": False}
|
||||
signal.signal(signal.SIGTERM, lambda *_: stop.__setitem__("now", True))
|
||||
|
||||
store = ActionStore()
|
||||
listener = Listener(store, log=log)
|
||||
log(f"Listening for F13 to F24 on the pad. {len(store.by_trigger)} action(s) set.")
|
||||
|
||||
fds, waiting_said = {}, False
|
||||
try:
|
||||
while not stop["now"]:
|
||||
if not fds:
|
||||
for path, rid in find_keyboard_nodes():
|
||||
try:
|
||||
fds[os.open(path, os.O_RDONLY | os.O_NONBLOCK)] = (path, rid)
|
||||
except OSError as e:
|
||||
log(f"can't open {path}: {e.strerror}")
|
||||
if fds:
|
||||
log("Pad found: " + ", ".join(p for p, _ in fds.values()))
|
||||
waiting_said = False
|
||||
else:
|
||||
if not waiting_said:
|
||||
log("Waiting for the pad to be plugged in...")
|
||||
waiting_said = True
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
store.reload()
|
||||
ready, _, _ = select.select(list(fds), [], [], 1.0)
|
||||
for fd in ready:
|
||||
path, rid = fds[fd]
|
||||
try:
|
||||
data = os.read(fd, 64)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
except OSError as e:
|
||||
if e.errno in (errno.ENODEV, errno.EIO, errno.ENOENT):
|
||||
log("Pad unplugged.")
|
||||
for f in list(fds):
|
||||
os.close(f)
|
||||
fds.clear()
|
||||
listener.held.clear()
|
||||
break
|
||||
raise
|
||||
if debug:
|
||||
log(f"{path}: {data.hex(' ')}")
|
||||
listener.feed(pressed_codes(data, rid))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
for f in fds:
|
||||
os.close(f)
|
||||
lock.close()
|
||||
log("Stopped.")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- autostart
|
||||
|
||||
def autostart_path():
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
||||
return Path(base) / "autostart" / "macropad-actions.desktop"
|
||||
|
||||
|
||||
def listener_command():
|
||||
"""How to start the listener: the installed launcher if there is one."""
|
||||
wrapper = Path.home() / ".local/bin/macropad"
|
||||
if wrapper.exists():
|
||||
return [str(wrapper), "actions"], None
|
||||
return [sys.executable, "-m", "macropad_gui", "actions"], \
|
||||
str(Path(__file__).resolve().parent.parent)
|
||||
|
||||
|
||||
def autostart_enabled():
|
||||
return autostart_path().exists()
|
||||
|
||||
|
||||
def set_autostart(on):
|
||||
path = autostart_path()
|
||||
if not on:
|
||||
path.unlink(missing_ok=True)
|
||||
return
|
||||
argv, cwd = listener_command()
|
||||
lines = ["[Desktop Entry]", "Type=Application",
|
||||
"Name=MacroPad actions",
|
||||
"Comment=Runs the actions bound to your macro pad's keys",
|
||||
"Exec=" + " ".join(shlex.quote(a) for a in argv),
|
||||
"NoDisplay=true", "Terminal=false",
|
||||
"X-GNOME-Autostart-enabled=true"]
|
||||
if cwd:
|
||||
lines.append(f"Path={cwd}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def listener_pid():
|
||||
"""The running listener's pid, checked against /proc so we never signal a stranger."""
|
||||
try:
|
||||
pid = int((runtime_dir() / LOCK).read_text().strip())
|
||||
cmd = Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return pid if b"actions" in cmd and b"macropad" in cmd else None
|
||||
|
||||
|
||||
def start_listener():
|
||||
if listener_pid():
|
||||
return listener_pid()
|
||||
argv, cwd = listener_command()
|
||||
log = open(runtime_dir() / "macropad-actions.log", "a")
|
||||
p = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.DEVNULL,
|
||||
stdout=log, stderr=log, start_new_session=True)
|
||||
return p.pid
|
||||
|
||||
|
||||
def stop_listener():
|
||||
pid = listener_pid()
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return pid
|
||||
@@ -4,24 +4,26 @@ pad keeps its bindings in its own memory, so nothing here needs to keep
|
||||
running afterwards.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QEvent, QSettings, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QGuiApplication, QIcon,
|
||||
from PySide6.QtGui import (QActionGroup, QColor, QFont, QFontDatabase,
|
||||
QGuiApplication, QIcon,
|
||||
QPalette)
|
||||
from PySide6.QtWidgets import (QApplication, QButtonGroup, QFileDialog, QFrame,
|
||||
QGridLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QMainWindow, QMessageBox, QPlainTextEdit,
|
||||
QPushButton, QSpinBox, QVBoxLayout, QWidget)
|
||||
QPushButton, QSpinBox, QVBoxLayout, QWidget,
|
||||
QCheckBox, QComboBox, QMenu)
|
||||
|
||||
from . import core, keymap
|
||||
from . import actions as act, core, keymap, padview, themes
|
||||
from .padview import INK, INK_DIM, TAPE, WINDOW, Look, PadView
|
||||
|
||||
GOOD = "#8db36b"
|
||||
BAD = "#e0584f"
|
||||
LINE = "#3a3e42"
|
||||
FIELD = "#121416"
|
||||
# Set by apply_theme; functions read these when they run, so they follow a
|
||||
# theme change without anything else having to know.
|
||||
GOOD = BAD = LINE = FIELD = ""
|
||||
|
||||
# Media keys as pairs, so opposites sit side by side.
|
||||
MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
|
||||
@@ -29,38 +31,50 @@ MEDIA_GRID = [("playpause", "stop"), ("prev", "next"),
|
||||
("brightnessdown", "brightnessup"),
|
||||
("calculator", "mail"), ("browser", "mycomputer")]
|
||||
|
||||
STYLE = f"""
|
||||
QWidget {{ color: {INK.name()}; background: {WINDOW.name()}; }}
|
||||
def stylesheet(t):
|
||||
"""The whole window's look, from one theme."""
|
||||
tint = themes.mix
|
||||
return f"""
|
||||
QWidget {{ color: {t.ink}; background: {t.window}; }}
|
||||
QLabel[role="title"] {{ font-size: 18px; font-weight: 600; }}
|
||||
QLabel[role="dim"] {{ color: {INK_DIM.name()}; }}
|
||||
QLabel[role="caption"] {{ color: {INK_DIM.name()}; padding: 14px 0px 0px 0px; margin: 0px; }}
|
||||
QLabel[role="dim"] {{ color: {t.ink_dim}; }}
|
||||
QLabel[role="caption"] {{ color: {t.ink_dim}; padding: 14px 0px 0px 0px; margin: 0px; }}
|
||||
QLabel[role="value"] {{ font-size: 15px; }}
|
||||
QPushButton {{ background: transparent; border: 1px solid {LINE};
|
||||
QPushButton {{ background: transparent; border: 1px solid {t.line};
|
||||
border-radius: 4px; padding: 6px 12px; }}
|
||||
QPushButton:hover {{ border-color: {INK_DIM.name()}; }}
|
||||
QPushButton:checked {{ border-color: {TAPE.name()}; color: {TAPE.name()}; }}
|
||||
QPushButton:disabled {{ color: #4a4e52; border-color: #2a2e32; }}
|
||||
QPushButton[role="primary"] {{ background: {TAPE.name()}; color: #1b1206;
|
||||
QPushButton:hover {{ border-color: {t.ink_dim}; }}
|
||||
QPushButton:checked {{ border-color: {t.accent}; color: {t.accent}; }}
|
||||
QPushButton:disabled {{ color: {tint(t.window, t.ink_dim, 0.55)}; border-color: {tint(t.window, t.line, 0.6)}; }}
|
||||
QPushButton[role="primary"] {{ background: {t.accent}; color: {t.accent_text};
|
||||
border: none; font-weight: 600; padding: 8px 16px; }}
|
||||
QPushButton[role="primary"]:hover {{ background: #f39a45; }}
|
||||
QPushButton[role="primary"]:disabled {{ background: #2c3034; color: #5a5e62; }}
|
||||
QPushButton[role="flat"] {{ border: none; color: {INK_DIM.name()}; padding: 6px 8px; }}
|
||||
QPushButton[role="flat"]:hover {{ color: {INK.name()}; }}
|
||||
QPushButton[role="primary"]:hover {{ background: {tint(t.accent, t.ink, 0.15)}; }}
|
||||
QPushButton[role="primary"]:disabled {{ background: {tint(t.window, t.line, 0.7)}; color: {tint(t.window, t.ink_dim, 0.7)}; }}
|
||||
QPushButton[role="flat"] {{ border: none; color: {t.ink_dim}; padding: 6px 8px; }}
|
||||
QPushButton[role="flat"]:hover {{ color: {t.ink}; }}
|
||||
QPushButton[role="media"] {{ text-align: left; padding: 7px 10px; }}
|
||||
QLineEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px;
|
||||
QLineEdit {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px;
|
||||
padding: 7px 8px; }}
|
||||
QLineEdit:focus {{ border-color: {INK_DIM.name()}; }}
|
||||
QLineEdit[recording="true"] {{ border-color: {TAPE.name()}; background: #2a1d10; }}
|
||||
QPlainTextEdit {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px; }}
|
||||
QFrame[role="banner"] {{ border: 1px solid {LINE}; border-left: 3px solid {BAD};
|
||||
QLineEdit:focus {{ border-color: {t.ink_dim}; }}
|
||||
QLineEdit[recording="true"] {{ border-color: {t.accent}; background: {tint(t.field, t.accent, 0.12)}; }}
|
||||
QPlainTextEdit {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px; }}
|
||||
QFrame[role="banner"] {{ border: 1px solid {t.line}; border-left: 3px solid {t.bad};
|
||||
border-radius: 4px; }}
|
||||
QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {INK_DIM.name()}; }}
|
||||
QFrame[role="banner"][tone="quiet"] {{ border-left: 3px solid {t.ink_dim}; }}
|
||||
QFrame[role="banner"] QLabel, QFrame[role="banner"] QPushButton {{ background: transparent; }}
|
||||
QFrame[role="footer"] {{ border-top: 1px solid {LINE}; }}
|
||||
QFrame[role="editbar"] {{ border-top: 1px solid {TAPE.name()}; background: #1c1a17; }}
|
||||
QFrame[role="footer"] {{ border-top: 1px solid {t.line}; }}
|
||||
QFrame[role="editbar"] {{ border-top: 1px solid {t.accent}; background: {tint(t.window, t.accent, 0.06)}; }}
|
||||
QFrame[role="editbar"] QLabel {{ background: transparent; }}
|
||||
QSpinBox {{ background: {FIELD}; border: 1px solid {LINE}; border-radius: 4px;
|
||||
QSpinBox {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px;
|
||||
padding: 4px 6px; }}
|
||||
QCheckBox {{ spacing: 8px; }}
|
||||
QCheckBox::indicator {{ width: 14px; height: 14px; border: 1px solid {t.ink_dim};
|
||||
border-radius: 3px; background: {t.field}; }}
|
||||
QCheckBox::indicator:checked {{ background: {t.accent}; border-color: {t.accent}; }}
|
||||
QComboBox {{ background: {t.field}; border: 1px solid {t.line}; border-radius: 4px;
|
||||
padding: 6px 8px; }}
|
||||
QMenu {{ background: {t.field}; border: 1px solid {t.line}; padding: 4px; }}
|
||||
QMenu::item {{ padding: 6px 18px; }}
|
||||
QMenu::item:selected {{ background: {tint(t.field, t.accent, 0.25)}; }}
|
||||
"""
|
||||
|
||||
|
||||
@@ -148,11 +162,14 @@ class Inspector(QWidget):
|
||||
changed = Signal(str, object) # control, Binding or None (revert)
|
||||
write = Signal(str)
|
||||
revert = Signal(str)
|
||||
action = Signal()
|
||||
action = Signal() # the banner's button
|
||||
host_action = Signal(str, object, object) # control, Action or None, trigger
|
||||
test_action = Signal(object)
|
||||
background = Signal(bool)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setFixedWidth(360)
|
||||
self.setFixedWidth(400)
|
||||
self.control = None
|
||||
self._loading = False
|
||||
self.editing_layout = False
|
||||
@@ -213,12 +230,15 @@ class Inspector(QWidget):
|
||||
self.mode_media = _button("Media key", checkable=True)
|
||||
self.mode_none = _button("Nothing", checkable=True)
|
||||
self.mode_none.setToolTip("Make this control do nothing at all")
|
||||
self.mode_action = _button("Action", checkable=True)
|
||||
self.mode_action.setToolTip("Open a web page, an app, or run a command")
|
||||
self.mode_group = QButtonGroup(self)
|
||||
self.mode_group.setExclusive(True)
|
||||
for i, b in enumerate((self.mode_keys, self.mode_media, self.mode_none)):
|
||||
for i, b in enumerate((self.mode_keys, self.mode_media, self.mode_none,
|
||||
self.mode_action)):
|
||||
self.mode_group.addButton(b, i)
|
||||
modes.addWidget(b)
|
||||
modes.addStretch()
|
||||
b.setStyleSheet("padding: 6px 8px;")
|
||||
modes.addWidget(b, 1)
|
||||
ev.addLayout(modes)
|
||||
ev.addSpacing(6)
|
||||
|
||||
@@ -253,10 +273,71 @@ class Inspector(QWidget):
|
||||
ml.addWidget(b, r, col)
|
||||
np_ = _label("Pressing or turning it will do nothing. Handy for a key "
|
||||
"you keep hitting by accident.", "dim", wrap=True)
|
||||
self.pages = (kp, mp, np_)
|
||||
ap = QWidget()
|
||||
al = QVBoxLayout(ap)
|
||||
al.setContentsMargins(0, 0, 0, 0)
|
||||
al.setSpacing(6)
|
||||
kinds = QHBoxLayout()
|
||||
kinds.setSpacing(6)
|
||||
self.kind_group = QButtonGroup(self)
|
||||
self.kind_group.setExclusive(True)
|
||||
self.kind_buttons = {}
|
||||
for i, (kind, label) in enumerate((("url", "Web page"), ("app", "App"),
|
||||
("command", "Command"), ("text", "Text"))):
|
||||
b = _button(label, "media", checkable=True)
|
||||
self.kind_group.addButton(b, i)
|
||||
self.kind_buttons[kind] = b
|
||||
kinds.addWidget(b)
|
||||
al.addLayout(kinds)
|
||||
self.action_edit = QLineEdit()
|
||||
self.app_pick = QComboBox()
|
||||
self.app_pick.setMaxVisibleItems(18)
|
||||
self._apps_loaded = False
|
||||
al.addWidget(self.action_edit)
|
||||
al.addWidget(self.app_pick)
|
||||
self.text_edit = QPlainTextEdit()
|
||||
self.text_edit.setPlaceholderText("Kind regards,\nProbler")
|
||||
self.text_edit.setFixedHeight(84)
|
||||
self.paste_terminal = QCheckBox("Paste with Ctrl+Shift+V, for terminals")
|
||||
al.addWidget(self.text_edit)
|
||||
al.addWidget(self.paste_terminal)
|
||||
fix = QHBoxLayout()
|
||||
self.text_fix = _label("", "dim", wrap=True)
|
||||
self.text_fix_btn = _button("Copy fix")
|
||||
self.text_fix_btn.setToolTip("Copies the commands; paste them into a terminal")
|
||||
fix.addWidget(self.text_fix, 1)
|
||||
fix.addWidget(self.text_fix_btn, 0, Qt.AlignTop)
|
||||
al.addLayout(fix)
|
||||
self._text_fix_cmds = []
|
||||
sends = QHBoxLayout()
|
||||
sends.addWidget(_label("Sends", "dim"))
|
||||
self.trigger_pick = QComboBox()
|
||||
self.trigger_pick.setToolTip(
|
||||
"The spare key this control sends to your computer. Pick another "
|
||||
"if this one does something on your desktop already.")
|
||||
sends.addWidget(self.trigger_pick, 1)
|
||||
al.addLayout(sends)
|
||||
self.trigger_note = _label("", "dim", wrap=True)
|
||||
al.addWidget(self.trigger_note)
|
||||
row = QHBoxLayout()
|
||||
self.action_hint = _label("", "dim", wrap=True)
|
||||
self.test_btn = _button("Test")
|
||||
self.test_btn.setToolTip("Run it now, on this computer")
|
||||
row.addWidget(self.action_hint, 1)
|
||||
row.addWidget(self.test_btn, 0, Qt.AlignTop)
|
||||
al.addLayout(row)
|
||||
self.bg_check = QCheckBox("Run actions in the background")
|
||||
self.bg_check.setToolTip("Starts a small listener now and whenever you log in")
|
||||
self.bg_status = _label("", "dim", wrap=True)
|
||||
al.addSpacing(6)
|
||||
al.addWidget(self.bg_check)
|
||||
al.addWidget(self.bg_status)
|
||||
|
||||
self.pages = (kp, mp, np_, ap)
|
||||
ev.addWidget(kp)
|
||||
ev.addWidget(mp)
|
||||
ev.addWidget(np_)
|
||||
ev.addWidget(ap)
|
||||
|
||||
ev.addSpacing(10)
|
||||
actions = QHBoxLayout()
|
||||
@@ -274,6 +355,16 @@ class Inspector(QWidget):
|
||||
self.keys_edit.recorded.connect(self._keys_typed)
|
||||
self.record.toggled.connect(self._toggle_record)
|
||||
self.media_group.idClicked.connect(self._media_picked)
|
||||
self.kind_group.idClicked.connect(self._kind_picked)
|
||||
self.action_edit.textEdited.connect(lambda _: self._action_edited())
|
||||
self.app_pick.activated.connect(lambda _: self._action_edited())
|
||||
self.trigger_pick.activated.connect(lambda _: self._action_edited())
|
||||
self.text_edit.textChanged.connect(self._action_edited)
|
||||
self.paste_terminal.toggled.connect(lambda _: self._action_edited())
|
||||
self.text_fix_btn.clicked.connect(self._copy_text_fix)
|
||||
self.test_btn.clicked.connect(self._test)
|
||||
self.bg_check.toggled.connect(lambda on: None if self._loading
|
||||
else self.background.emit(on))
|
||||
self.write_btn.clicked.connect(lambda: self.write.emit(self.control))
|
||||
self.revert_btn.clicked.connect(lambda: self.revert.emit(self.control))
|
||||
|
||||
@@ -312,7 +403,9 @@ class Inspector(QWidget):
|
||||
|
||||
# -------------------------------------------------------------- editor
|
||||
|
||||
def show_control(self, control, entry, desired, can_write, all_unknown=False):
|
||||
def show_control(self, control, entry, desired, can_write, all_unknown=False,
|
||||
action=None, onpad_action=None, pending=None, bg=None,
|
||||
triggers=None, trigger=None):
|
||||
self._loading = True
|
||||
self.control = control
|
||||
if control is None:
|
||||
@@ -340,13 +433,29 @@ class Inspector(QWidget):
|
||||
self.onpad.setStyleSheet(f"color: {INK_DIM.name()};")
|
||||
self.onpad_why.setText(entry.reason[:1].upper() + entry.reason[1:] + ".")
|
||||
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:
|
||||
self.onpad.setText(entry.binding.label())
|
||||
self.onpad.setStyleSheet("")
|
||||
self.onpad_why.hide()
|
||||
self.onpad.setStyleSheet("")
|
||||
|
||||
shown = desired or (None if entry.unknown else entry.binding)
|
||||
if shown is not None and shown.none:
|
||||
self.show_background(bg)
|
||||
self._clear_action()
|
||||
self.set_triggers(triggers or [], trigger)
|
||||
if action is not None:
|
||||
self.mode_action.setChecked(True)
|
||||
self._page(3)
|
||||
self._show_action(action)
|
||||
self.keys_edit.clear()
|
||||
self._clear_media()
|
||||
elif shown is not None and shown.none:
|
||||
self.mode_none.setChecked(True)
|
||||
self._page(2)
|
||||
self.keys_edit.clear()
|
||||
@@ -362,7 +471,8 @@ class Inspector(QWidget):
|
||||
self.keys_edit.setText(shown.keys if shown else "")
|
||||
self._clear_media()
|
||||
self.keys_hint.setText("")
|
||||
self.update_buttons(desired is not None, can_write)
|
||||
self.update_buttons(desired is not None if pending is None else pending,
|
||||
can_write)
|
||||
self._loading = False
|
||||
|
||||
def update_buttons(self, pending, can_write):
|
||||
@@ -385,6 +495,11 @@ class Inspector(QWidget):
|
||||
self._page(i)
|
||||
if i == 2 and not self._loading and self.control is not None:
|
||||
self.changed.emit(self.control, core.Binding(none=True))
|
||||
if i == 3 and not self._loading:
|
||||
if not self.kind_group.checkedButton():
|
||||
self.kind_buttons["url"].setChecked(True)
|
||||
self._kind_picked(0)
|
||||
self.action_edit.setFocus()
|
||||
if i == 0 and self.record.isChecked():
|
||||
self.keys_edit.setFocus()
|
||||
|
||||
@@ -420,6 +535,199 @@ class Inspector(QWidget):
|
||||
self.keys_hint.setText(f"Sends {b.label()}")
|
||||
self.changed.emit(self.control, b)
|
||||
|
||||
# ------------------------------------------------------------ actions
|
||||
|
||||
_PLACEHOLDER = {"url": "github.com", "command": "~/bin/switch-audio.sh"}
|
||||
|
||||
def _kind(self):
|
||||
b = self.kind_group.checkedButton()
|
||||
return next((k for k, btn in self.kind_buttons.items() if btn is b), "url")
|
||||
|
||||
def set_triggers(self, options, current):
|
||||
"""
|
||||
options: [(trigger, used_by or None)]. Taken keys are shown but can't
|
||||
be picked; keys the desktop is known to act on carry a warning.
|
||||
"""
|
||||
was, self._loading = self._loading, True
|
||||
self.trigger_pick.clear()
|
||||
self.trigger_pick.addItem("Choose automatically", None)
|
||||
model = self.trigger_pick.model()
|
||||
for trig, used_by in options:
|
||||
text = trig.upper()
|
||||
if used_by:
|
||||
text += f" used by {used_by}"
|
||||
elif trig in act.CLAIMED:
|
||||
text += f" {act.CLAIMED[trig]}"
|
||||
self.trigger_pick.addItem(text, trig)
|
||||
if used_by:
|
||||
model.item(self.trigger_pick.count() - 1).setEnabled(False)
|
||||
i = self.trigger_pick.findData(current) if current else 0
|
||||
self.trigger_pick.setCurrentIndex(max(i, 0))
|
||||
self._loading = was
|
||||
self._note_trigger(current)
|
||||
|
||||
def set_trigger(self, trigger):
|
||||
"""Show which key was picked, without it counting as an edit."""
|
||||
was, self._loading = self._loading, True
|
||||
i = self.trigger_pick.findData(trigger)
|
||||
if i >= 0:
|
||||
self.trigger_pick.setCurrentIndex(i)
|
||||
self._loading = was
|
||||
self._note_trigger(trigger)
|
||||
|
||||
def _note_trigger(self, trigger):
|
||||
if trigger in act.CLAIMED:
|
||||
self.trigger_note.setText(
|
||||
f"<span style='color:{TAPE.name()}'>{trigger.upper()} {act.CLAIMED[trigger]} "
|
||||
"on many desktops, so it may do that as well. Pick another if it "
|
||||
"does.</span>")
|
||||
else:
|
||||
self.trigger_note.setText("")
|
||||
|
||||
def _clear_action(self):
|
||||
"""Nothing carries over from the last control you looked at."""
|
||||
self.action_edit.clear()
|
||||
self.text_edit.clear()
|
||||
self.paste_terminal.setChecked(False)
|
||||
if self.app_pick.count():
|
||||
self.app_pick.setCurrentIndex(0)
|
||||
self.action_hint.setText("")
|
||||
self.test_btn.setEnabled(False)
|
||||
self._last_kind = None
|
||||
|
||||
def _kind_picked(self, _i):
|
||||
kind = self._kind()
|
||||
# a URL isn't a command and vice versa, so switching kind starts afresh
|
||||
if not self._loading and getattr(self, "_last_kind", None) not in (None, kind):
|
||||
self.action_edit.clear()
|
||||
self.text_edit.clear()
|
||||
self._last_kind = kind
|
||||
is_app, is_text = kind == "app", kind == "text"
|
||||
self.app_pick.setVisible(is_app)
|
||||
self.action_edit.setVisible(not is_app and not is_text)
|
||||
self.text_edit.setVisible(is_text)
|
||||
self.paste_terminal.setVisible(is_text)
|
||||
self._show_text_problems(is_text)
|
||||
if is_app:
|
||||
self._load_apps()
|
||||
elif is_text:
|
||||
pass
|
||||
else:
|
||||
self.action_edit.setPlaceholderText(self._PLACEHOLDER[kind])
|
||||
self.action_edit.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||
if kind == "command" else self.font())
|
||||
if not self._loading:
|
||||
self._action_edited()
|
||||
|
||||
def _load_apps(self):
|
||||
if self._apps_loaded:
|
||||
return
|
||||
self.app_pick.clear()
|
||||
self.app_pick.addItem("Pick an app...", None)
|
||||
for name, command in act.installed_apps():
|
||||
self.app_pick.addItem(name, (name, command))
|
||||
self._apps_loaded = True
|
||||
|
||||
def _current_action(self):
|
||||
kind = self._kind()
|
||||
if kind == "text":
|
||||
text = self.text_edit.toPlainText()
|
||||
paste = "ctrl+shift+v" if self.paste_terminal.isChecked() else ""
|
||||
return act.Action("text", text, paste=paste) if text.strip() else None
|
||||
if kind == "app":
|
||||
data = self.app_pick.currentData()
|
||||
return act.Action("app", data[1], data[0]) if data else None
|
||||
text = self.action_edit.text().strip()
|
||||
return act.Action(kind, text) if text else None
|
||||
|
||||
def _show_action(self, action):
|
||||
self.kind_buttons[action.kind].setChecked(True)
|
||||
self._kind_picked(0)
|
||||
if action.kind == "text":
|
||||
self.text_edit.setPlainText(action.target)
|
||||
self.paste_terminal.setChecked(action.paste == "ctrl+shift+v")
|
||||
elif action.kind == "app":
|
||||
self._load_apps()
|
||||
i = self.app_pick.findData((action.name, action.target))
|
||||
if i < 0: # not installed any more
|
||||
self.app_pick.addItem(action.label(), (action.name, action.target))
|
||||
i = self.app_pick.count() - 1
|
||||
self.app_pick.setCurrentIndex(i)
|
||||
else:
|
||||
self.action_edit.setText(action.target)
|
||||
self.action_hint.setText(self._describe(action))
|
||||
self.test_btn.setEnabled(True)
|
||||
|
||||
@staticmethod
|
||||
def _describe(action):
|
||||
return {"url": f"Opens {action.url}",
|
||||
"app": f"Starts {action.label()}",
|
||||
"command": "Runs this in a shell, as you",
|
||||
"text": "Types it wherever your cursor is"}[action.kind]
|
||||
|
||||
def _action_edited(self):
|
||||
if self._loading or self.control is None:
|
||||
return
|
||||
action = self._current_action()
|
||||
want = self.trigger_pick.currentData()
|
||||
if action is None:
|
||||
self.action_hint.setText("")
|
||||
self.test_btn.setEnabled(False)
|
||||
self.host_action.emit(self.control, None, None)
|
||||
return
|
||||
try:
|
||||
action.validate()
|
||||
except ValueError as e:
|
||||
self.action_hint.setText(f"<span style='color:{BAD}'>{e}</span>")
|
||||
self.test_btn.setEnabled(False)
|
||||
self.host_action.emit(self.control, None, None)
|
||||
return
|
||||
self.action_hint.setText(self._describe(action))
|
||||
self.test_btn.setEnabled(True)
|
||||
self.host_action.emit(self.control, action, want)
|
||||
|
||||
def _show_text_problems(self, visible):
|
||||
problems = act.text_problems() if visible else []
|
||||
self._text_fix_cmds = [c for _, cmds in problems for c in cmds
|
||||
if not c.startswith("#")]
|
||||
if problems:
|
||||
why = " ".join(w for w, _ in problems)
|
||||
self.text_fix.setText(f"<span style='color:{TAPE.name()}'>{why}</span> "
|
||||
"Copy the fix, paste it into a terminal, then "
|
||||
"come back.")
|
||||
else:
|
||||
self.text_fix.setText("")
|
||||
self.text_fix.setVisible(bool(problems))
|
||||
self.text_fix_btn.setVisible(bool(self._text_fix_cmds))
|
||||
|
||||
def _copy_text_fix(self):
|
||||
QGuiApplication.clipboard().setText("\n".join(self._text_fix_cmds))
|
||||
self.text_fix_btn.setText("Copied")
|
||||
QTimer.singleShot(1500, lambda: self.text_fix_btn.setText("Copy fix"))
|
||||
|
||||
def _test(self):
|
||||
action = self._current_action()
|
||||
if action:
|
||||
self.test_action.emit(action)
|
||||
|
||||
def show_background(self, running):
|
||||
"""running: None unknown, False off, True on."""
|
||||
if running is None:
|
||||
return
|
||||
self._loading, was = True, self._loading
|
||||
self.bg_check.setChecked(act.autostart_enabled())
|
||||
self._loading = was
|
||||
if running:
|
||||
self.bg_status.setText("Running. Action keys work, and it starts "
|
||||
"again when you log in.")
|
||||
elif act.autostart_enabled():
|
||||
self.bg_status.setText("Set to start at login, but not running "
|
||||
"right now. Untick and tick to start it.")
|
||||
else:
|
||||
self.bg_status.setText("Off. Action keys send F13 to F24 and nothing "
|
||||
"happens until this is on. Everything else "
|
||||
"on the pad works as normal.")
|
||||
|
||||
def _media_picked(self, i):
|
||||
if self._loading or self.control is None:
|
||||
return
|
||||
@@ -436,6 +744,8 @@ class Window(QMainWindow):
|
||||
self.settings = QSettings("macropad", "macropad")
|
||||
self.state = core.State()
|
||||
self.desired = {} # control -> Binding, unwritten edits
|
||||
self.actions = act.ActionStore() # trigger key -> host action
|
||||
self.pending_actions = {} # control -> act.Action, unsaved
|
||||
self.diag = None
|
||||
# Pads the user has identified by asking them. Remembered so the app
|
||||
# doesn't go back to calling them unsupported on the next check.
|
||||
@@ -496,6 +806,19 @@ class Window(QMainWindow):
|
||||
self.edit_btn.setToolTip("Rearrange the drawing to match your pad")
|
||||
self.read_btn = _button("Read pad", "flat")
|
||||
self.read_btn.setToolTip("Ask the pad what's on it right now")
|
||||
self.theme_btn = _button("Theme", "flat")
|
||||
self.theme_btn.setToolTip("Change how MacroPad looks")
|
||||
self.theme_menu = QMenu(self)
|
||||
self.theme_group = QActionGroup(self)
|
||||
self.theme_group.setExclusive(True)
|
||||
current = self.settings.value("theme", themes.DEFAULT)
|
||||
for t in themes.THEMES.values():
|
||||
a = self.theme_menu.addAction(t.name)
|
||||
a.setCheckable(True)
|
||||
a.setChecked(t.key == current)
|
||||
a.setData(t.key)
|
||||
self.theme_group.addAction(a)
|
||||
self.theme_btn.setMenu(self.theme_menu)
|
||||
self.rotate = _button("Rotate view", "flat")
|
||||
self.import_btn = _button("Import capture…", "flat")
|
||||
self.write_all = _button("", "primary")
|
||||
@@ -505,6 +828,7 @@ class Window(QMainWindow):
|
||||
fl.addWidget(self.message, 1)
|
||||
fl.addWidget(self.edit_btn)
|
||||
fl.addWidget(self.read_btn)
|
||||
fl.addWidget(self.theme_btn)
|
||||
fl.addWidget(self.rotate)
|
||||
fl.addWidget(self.import_btn)
|
||||
fl.addWidget(self.write_all)
|
||||
@@ -515,6 +839,9 @@ class Window(QMainWindow):
|
||||
self.inspector.changed.connect(self._changed)
|
||||
self.inspector.revert.connect(self._revert)
|
||||
self.inspector.action.connect(self._identify)
|
||||
self.inspector.host_action.connect(self._host_action)
|
||||
self.inspector.test_action.connect(self._test_action)
|
||||
self.inspector.background.connect(self._background)
|
||||
self.inspector.write.connect(lambda c: self._write([c]))
|
||||
self.write_all.clicked.connect(lambda: self._write(self._pending()))
|
||||
self.edit_btn.clicked.connect(lambda: self._edit_layout(True))
|
||||
@@ -523,6 +850,7 @@ class Window(QMainWindow):
|
||||
self.pad.rearranged.connect(self._rearranged)
|
||||
self.read_btn.clicked.connect(lambda: self._read(quiet=False))
|
||||
self.rotate.clicked.connect(self._rotate)
|
||||
self.theme_group.triggered.connect(lambda a: self._set_theme(a.data()))
|
||||
self.import_btn.clicked.connect(self._import)
|
||||
|
||||
self.note = core._keyd_warning()
|
||||
@@ -538,14 +866,43 @@ class Window(QMainWindow):
|
||||
|
||||
# ------------------------------------------------------------- model
|
||||
|
||||
def _binding_now(self, c):
|
||||
"""What the control sends once pending edits are written."""
|
||||
if c in self.desired:
|
||||
return self.desired[c]
|
||||
e = self.state.get(c)
|
||||
return None if e.unknown else e.binding
|
||||
|
||||
def _stored_action(self, binding):
|
||||
trig = act.trigger_of(binding)
|
||||
return self.actions.get(trig) if trig else None
|
||||
|
||||
def _action_for(self, c):
|
||||
return self.pending_actions.get(c) or self._stored_action(self._binding_now(c))
|
||||
|
||||
def _trigger_options(self, control):
|
||||
"""[(trigger, name of the control holding it, or None)] for the picker."""
|
||||
holders = {}
|
||||
for c in self.state.layout.control_ids:
|
||||
if c == control:
|
||||
continue
|
||||
b = self._binding_now(c)
|
||||
if b and b.keys and b.keys.lower() in act.TRIGGERS:
|
||||
holders[b.keys.lower()] = core.control_name(c)
|
||||
for t in self.actions.by_trigger:
|
||||
holders.setdefault(t, "another key")
|
||||
mine = act.trigger_of(self._binding_now(control)) if control else None
|
||||
return [(t, None if t == mine else holders.get(t)) for t in act.TRIGGERS]
|
||||
|
||||
def _pending(self):
|
||||
out = []
|
||||
for c in self.state.layout.control_ids:
|
||||
b = self.desired.get(c)
|
||||
if b is None:
|
||||
continue
|
||||
e = self.state.get(c)
|
||||
if e.unknown or e.binding != b:
|
||||
b = self.desired.get(c)
|
||||
pad = b is not None and (e.unknown or e.binding != b)
|
||||
host = (c in self.pending_actions and
|
||||
self._stored_action(self._binding_now(c)) != self.pending_actions[c])
|
||||
if pad or host:
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
@@ -555,11 +912,15 @@ class Window(QMainWindow):
|
||||
for c in self.state.layout.control_ids:
|
||||
e = self.state.get(c)
|
||||
if c in pending:
|
||||
looks[c] = Look(self.desired[c].label(), known=not e.unknown, pending=True)
|
||||
a = self.pending_actions.get(c)
|
||||
text = a.label() if a else self._binding_now(c).label()
|
||||
looks[c] = Look(text, known=not e.unknown, pending=True)
|
||||
elif e.unknown:
|
||||
looks[c] = Look("", known=False)
|
||||
else:
|
||||
looks[c] = Look(e.binding.label(), known=True, quiet=e.binding.none)
|
||||
a = self._stored_action(e.binding)
|
||||
looks[c] = Look(a.label() if a else e.binding.label(), known=True,
|
||||
quiet=e.binding.none)
|
||||
self.pad.set_looks(looks)
|
||||
|
||||
n = len(pending)
|
||||
@@ -574,22 +935,99 @@ class Window(QMainWindow):
|
||||
return
|
||||
all_unknown = all(self.state.get(x).unknown
|
||||
for x in self.state.layout.control_ids)
|
||||
e = self.state.get(c) if c else None
|
||||
self.inspector.show_control(
|
||||
c, self.state.get(c) if c else None,
|
||||
self.desired.get(c) if c in pending else None,
|
||||
can_write, all_unknown)
|
||||
c, e, self.desired.get(c) if c in pending else None,
|
||||
can_write, all_unknown,
|
||||
action=self._action_for(c) if c else None,
|
||||
onpad_action=self._stored_action(e.binding) if e and not e.unknown else None,
|
||||
pending=c in pending,
|
||||
bg=bool(act.listener_pid()),
|
||||
triggers=self._trigger_options(c) if c else None,
|
||||
trigger=act.trigger_of(self._binding_now(c)) if c else None)
|
||||
|
||||
def _select(self, control):
|
||||
self._refresh()
|
||||
|
||||
def _changed(self, control, binding):
|
||||
self.pending_actions.pop(control, None)
|
||||
if binding is None:
|
||||
self.desired.pop(control, None)
|
||||
else:
|
||||
self.desired[control] = binding
|
||||
self._refresh(editor=False)
|
||||
|
||||
def _host_action(self, control, action, want=None):
|
||||
"""
|
||||
Point a control at a host action. It gets a spare F13 to F24 key on
|
||||
the pad; if it already has one, it keeps it, so changing what the
|
||||
action does never needs a write to the pad.
|
||||
"""
|
||||
if action is None:
|
||||
self.pending_actions.pop(control, None)
|
||||
if act.trigger_of(self.desired.get(control)):
|
||||
self.desired.pop(control, None)
|
||||
self._refresh(editor=False)
|
||||
return
|
||||
|
||||
used = set()
|
||||
for c in self.state.layout.control_ids:
|
||||
b = self._binding_now(c) if c != control else None
|
||||
if b and b.keys:
|
||||
used.add(b.keys.lower())
|
||||
current = act.trigger_of(self._binding_now(control))
|
||||
trig = act.pick_trigger(self.actions, used, current, want)
|
||||
if trig is None:
|
||||
self._say("All twelve action keys, F13 to F24, are taken. Free one "
|
||||
"up by setting another action control to something else.", BAD)
|
||||
return
|
||||
|
||||
want = core.Binding(keys=trig)
|
||||
e = self.state.get(control)
|
||||
if e.unknown or e.binding != want:
|
||||
self.desired[control] = want
|
||||
else:
|
||||
self.desired.pop(control, None)
|
||||
self.pending_actions[control] = action
|
||||
self.inspector.set_trigger(trig)
|
||||
self._refresh(editor=False)
|
||||
|
||||
def _test_action(self, action):
|
||||
if action.kind == "text":
|
||||
problems = act.text_problems()
|
||||
if problems:
|
||||
self._say(problems[0][0], BAD)
|
||||
return
|
||||
# typing into MacroPad itself would be useless, so give time to
|
||||
# click somewhere else first
|
||||
for n in (3, 2, 1):
|
||||
QTimer.singleShot((3 - n) * 1000, lambda n=n: self._say(
|
||||
f"Typing in {n}... click where you want it.", GOOD))
|
||||
QTimer.singleShot(3000, lambda: self._run_test(action))
|
||||
return
|
||||
self._run_test(action)
|
||||
|
||||
def _run_test(self, action):
|
||||
try:
|
||||
action.run()
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
self._say(f"Couldn't run it: {getattr(e, 'strerror', None) or e}", BAD)
|
||||
return
|
||||
self._say(f"Ran it: {action.label()}.", GOOD)
|
||||
|
||||
def _background(self, on):
|
||||
act.set_autostart(on)
|
||||
if on:
|
||||
act.start_listener()
|
||||
self._say("Background actions on. They'll start again when you log in.", GOOD)
|
||||
else:
|
||||
act.stop_listener()
|
||||
self._say("Background actions off.", GOOD)
|
||||
QTimer.singleShot(700, lambda: self.inspector.show_background(
|
||||
bool(act.listener_pid())))
|
||||
|
||||
def _revert(self, control):
|
||||
self.pending_actions.pop(control, None)
|
||||
self.desired.pop(control, None)
|
||||
self._refresh()
|
||||
|
||||
@@ -617,8 +1055,10 @@ class Window(QMainWindow):
|
||||
"no-interface": "Pad found, config interface missing",
|
||||
"no-permission": "No permission to write to the pad",
|
||||
"unsupported": "Unsupported pad",
|
||||
"unsupported-os": "Not supported on this system",
|
||||
}[self.diag.status])
|
||||
self.inspector.show_problem(self.diag, self.note)
|
||||
self.inspector.show_background(bool(act.listener_pid()))
|
||||
if self.diag.status != old:
|
||||
self._refresh(editor=False)
|
||||
if self.diag.ready and old in ("unplugged", "no-interface", None):
|
||||
@@ -627,39 +1067,67 @@ class Window(QMainWindow):
|
||||
# ------------------------------------------------------------- write
|
||||
|
||||
def _write(self, controls):
|
||||
controls = [c for c in controls if c in self.desired]
|
||||
controls = [c for c in controls
|
||||
if c in self.desired or c in self.pending_actions]
|
||||
if not controls:
|
||||
return
|
||||
self._poll()
|
||||
if not self.diag.ready:
|
||||
needs_pad = any(c in self.desired for c in controls)
|
||||
if needs_pad and not self.diag.ready:
|
||||
self._say(self.diag.headline, BAD)
|
||||
return
|
||||
|
||||
done = []
|
||||
done, host_changed = [], False
|
||||
for c in controls:
|
||||
b = self.desired[c]
|
||||
b = self.desired.get(c)
|
||||
e = self.state.get(c)
|
||||
if b is not None and (e.unknown or e.binding != b):
|
||||
try:
|
||||
core.write_binding(self.diag.device, c, b, self.state)
|
||||
except core.WriteError as e:
|
||||
except core.WriteError as err:
|
||||
what = core.control_name(c)
|
||||
tail = (f"{what} is now marked unknown, since part of it reached the pad."
|
||||
if e.pad_touched else "Nothing reached the pad.")
|
||||
why = str(e).split(": ", 1)[-1]
|
||||
tail = (f"{what} is now marked unknown, since part of it "
|
||||
"reached the pad." if err.pad_touched
|
||||
else "Nothing reached the pad.")
|
||||
why = str(err).split(": ", 1)[-1]
|
||||
self._say(f"Couldn't write {what}: {why}. {tail}", BAD)
|
||||
self._refresh()
|
||||
return
|
||||
self.desired.pop(c, None)
|
||||
done.append((c, b))
|
||||
action = self.pending_actions.pop(c, None)
|
||||
if action:
|
||||
trig = act.trigger_of(self._binding_now(c))
|
||||
if trig:
|
||||
self.actions.set(trig, action)
|
||||
host_changed = True
|
||||
done.append((c, b, action))
|
||||
QApplication.processEvents()
|
||||
|
||||
# Forget actions whose key is no longer on the pad, but only when we
|
||||
# know every control; otherwise one might still be sitting there.
|
||||
ids = self.state.layout.control_ids
|
||||
if all(not self.state.get(x).unknown for x in ids):
|
||||
bound = {self.state.get(x).binding.keys.lower()
|
||||
for x in ids if self.state.get(x).binding.keys}
|
||||
if self.actions.prune(bound):
|
||||
host_changed = True
|
||||
if host_changed:
|
||||
self.actions.save()
|
||||
|
||||
if len(done) == 1:
|
||||
c, b = done[0]
|
||||
if b.none:
|
||||
self._say(f"{core.control_name(c)} now does nothing.", GOOD)
|
||||
c, b, action = done[0]
|
||||
name = core.control_name(c)
|
||||
if action:
|
||||
msg = f"{name} is set: {action.label()}."
|
||||
if not act.listener_pid():
|
||||
msg += " Turn on background actions for it to work."
|
||||
elif b is not None and b.none:
|
||||
msg = f"{name} now does nothing."
|
||||
else:
|
||||
self._say(f"{core.control_name(c)} is now {b.label()}.", GOOD)
|
||||
msg = f"{name} is now {b.label()}."
|
||||
self._say(msg, GOOD)
|
||||
else:
|
||||
self._say(f"Wrote {len(done)} changes to the pad.", GOOD)
|
||||
self._say(f"Wrote {len(done)} changes.", GOOD)
|
||||
self._refresh()
|
||||
|
||||
def _read(self, quiet=False):
|
||||
@@ -778,6 +1246,14 @@ class Window(QMainWindow):
|
||||
self._set_layout(lay)
|
||||
self.pad.set_editing(True)
|
||||
|
||||
def _set_theme(self, key):
|
||||
t = apply_theme(QApplication.instance(), key)
|
||||
self.settings.setValue("theme", t.key)
|
||||
self._poll() # redraws the status dot in the new colours
|
||||
self._refresh()
|
||||
self.pad.update()
|
||||
self._say(f"Theme: {t.name}.", GOOD)
|
||||
|
||||
def _rotate(self):
|
||||
o = "flat" if self.pad.orientation == "upright" else "upright"
|
||||
self.pad.set_orientation(o)
|
||||
@@ -823,16 +1299,26 @@ class Window(QMainWindow):
|
||||
e.ignore()
|
||||
|
||||
|
||||
def apply_theme(app):
|
||||
def apply_theme(app, key=None):
|
||||
"""Switch the whole app, window and drawing, to one theme."""
|
||||
global GOOD, BAD, LINE, FIELD
|
||||
t = themes.get(key or themes.DEFAULT)
|
||||
themes.load_fonts()
|
||||
padview.apply_theme(t)
|
||||
GOOD, BAD, LINE, FIELD = t.good, t.bad, t.line, t.field
|
||||
app.setStyle("Fusion")
|
||||
pal = QPalette()
|
||||
for role, colour in ((QPalette.Window, WINDOW), (QPalette.Base, QColor(FIELD)),
|
||||
(QPalette.Text, INK), (QPalette.WindowText, INK),
|
||||
(QPalette.Button, WINDOW), (QPalette.ButtonText, INK),
|
||||
(QPalette.Highlight, TAPE), (QPalette.HighlightedText, QColor("#1b1206"))):
|
||||
pal.setColor(role, colour)
|
||||
for role, colour in ((QPalette.Window, t.window), (QPalette.Base, t.field),
|
||||
(QPalette.Text, t.ink), (QPalette.WindowText, t.ink),
|
||||
(QPalette.Button, t.window), (QPalette.ButtonText, t.ink),
|
||||
(QPalette.Highlight, t.accent),
|
||||
(QPalette.HighlightedText, t.accent_text),
|
||||
(QPalette.ToolTipBase, t.field), (QPalette.ToolTipText, t.ink),
|
||||
(QPalette.PlaceholderText, t.ink_dim)):
|
||||
pal.setColor(role, QColor(colour))
|
||||
app.setPalette(pal)
|
||||
app.setStyleSheet(STYLE)
|
||||
app.setStyleSheet(stylesheet(t))
|
||||
return t
|
||||
|
||||
|
||||
ICON = Path(__file__).resolve().parent.parent / "packaging" / "macropad.svg"
|
||||
@@ -846,8 +1332,14 @@ def run():
|
||||
# sessions, and running straight from a git checkout.
|
||||
if ICON.exists():
|
||||
app.setWindowIcon(QIcon(str(ICON)))
|
||||
apply_theme(app)
|
||||
apply_theme(app, QSettings("macropad", "macropad").value("theme", themes.DEFAULT))
|
||||
w = Window()
|
||||
w.resize(1000, 800)
|
||||
w.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# `python -m macropad_gui.app` lands here. `python -m macropad_gui` is the
|
||||
# documented way in, but people reach for this one, so make it work.
|
||||
sys.exit(run())
|
||||
|
||||
@@ -476,8 +476,11 @@ class Diagnosis:
|
||||
RULES_DIRS = ("/etc/udev/rules.d", "/run/udev/rules.d")
|
||||
SYSTEM_RULES = ("/usr/lib/udev/rules.d", "/lib/udev/rules.d")
|
||||
SUGGESTED_RULE = "60-macropad.rules"
|
||||
RULE_LINE = (f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", '
|
||||
f'ATTRS{{idProduct}}=="{PID}", TAG+="uaccess"')
|
||||
# Vendor wide on purpose. Pads in this family have several product ids, and
|
||||
# a rule tied to one of them leaves everybody else unable to read their own
|
||||
# hardware, which is exactly the point at which they give up.
|
||||
RULE_LINE = f'KERNEL=="hidraw*", ATTRS{{idVendor}}=="{VID}", TAG+="uaccess"'
|
||||
|
||||
|
||||
|
||||
def _uaccess_cutoff():
|
||||
@@ -569,8 +572,23 @@ def _keyd_warning():
|
||||
return None
|
||||
|
||||
|
||||
def on_linux():
|
||||
return sys.platform.startswith("linux")
|
||||
|
||||
|
||||
def diagnose(keyd=True):
|
||||
"""keyd=False skips the keyd check, which shells out - the GUI polls."""
|
||||
if not on_linux():
|
||||
return Diagnosis(
|
||||
"unsupported-os", f"MacroPad can't reach USB devices on {sys.platform}.",
|
||||
detail=["The window works anywhere Qt does, but the part that talks "
|
||||
"to the pad reads Linux's /dev/hidraw, which this system "
|
||||
"doesn't have.",
|
||||
"On Windows, the pad's own vendor software or "
|
||||
"ch57x-keyboard-tool will configure it. Porting this to "
|
||||
"Windows and macOS means swapping one module, and it's on "
|
||||
"the roadmap."])
|
||||
|
||||
warnings = [w for w in (_keyd_warning() if keyd else None,) if w]
|
||||
everything = proto.find_devices()
|
||||
found = [d for d in everything if d["pid"] == PID]
|
||||
@@ -580,7 +598,7 @@ def diagnose(keyd=True):
|
||||
dev = Device(path=other["path"], report_id=other["report_id"],
|
||||
writable=other["writable"], name=other.get("name", ""),
|
||||
vid=other["vid"], pid=other["pid"])
|
||||
return Diagnosis(
|
||||
d = Diagnosis(
|
||||
"unsupported",
|
||||
f"Found a {other['vid']}:{other['pid']} pad, which isn't one this "
|
||||
"app knows.",
|
||||
@@ -591,6 +609,14 @@ def diagnose(keyd=True):
|
||||
"and if the pad doesn't answer properly nothing else "
|
||||
"happens."],
|
||||
warnings=warnings)
|
||||
if not dev.writable:
|
||||
d.detail = ["This pad isn't readable yet. An older permission rule "
|
||||
"may name a single product id; this one covers every "
|
||||
"pad from the same maker.",
|
||||
"Run these, then unplug the pad and plug it back in."]
|
||||
d.fix = [f"echo '{RULE_LINE}' | sudo tee /etc/udev/rules.d/{SUGGESTED_RULE}",
|
||||
"sudo udevadm control --reload-rules && sudo udevadm trigger"]
|
||||
return d
|
||||
|
||||
if not found:
|
||||
if _on_usb_bus():
|
||||
|
||||
BIN
macropad_gui/fonts/ArchitectsDaughter-Regular.ttf
Normal file
93
macropad_gui/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2010, Kimberly Geswein (kimberlygeswein.com)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -31,6 +31,8 @@ for _row, _start in (("qwertyuiop", 16), ("asdfghjkl", 30), ("zxcvbnm", 44)):
|
||||
EVDEV[_start + _i] = _c
|
||||
for _i in range(1, 11):
|
||||
EVDEV[58 + _i] = f"f{_i}" # F1 = 59 .. F10 = 68
|
||||
for _i in range(13, 25):
|
||||
EVDEV[170 + _i] = f"f{_i}" # F13 = 183 .. F24 = 194
|
||||
|
||||
for _name in EVDEV.values():
|
||||
assert _name in proto.KEYCODES, _name
|
||||
|
||||
@@ -11,21 +11,86 @@ Visual states:
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
|
||||
from PySide6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF,
|
||||
QPainter, QPainterPath, QPen, QTransform)
|
||||
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||
|
||||
from . import core
|
||||
from . import core, themes
|
||||
|
||||
WINDOW = QColor("#17191b")
|
||||
BOARD = QColor("#202326")
|
||||
INK = QColor("#d8d4cb") # silkscreen white, slightly warm
|
||||
INK_DIM = QColor("#72767b")
|
||||
HATCH = QColor("#34383c")
|
||||
TAPE = QColor("#eb8a2f") # Kapton orange
|
||||
THEME = themes.get(themes.DEFAULT)
|
||||
|
||||
# The colours everything draws with. Switching theme changes these objects in
|
||||
# place, so every widget and module holding one follows along.
|
||||
WINDOW = QColor(THEME.window)
|
||||
BOARD = QColor(THEME.board)
|
||||
INK = QColor(THEME.ink) # silkscreen white, slightly warm
|
||||
INK_DIM = QColor(THEME.ink_dim)
|
||||
HATCH = QColor(THEME.hatch)
|
||||
TAPE = QColor(THEME.accent) # Kapton orange, in the default theme
|
||||
|
||||
|
||||
def apply_theme(theme):
|
||||
global THEME
|
||||
THEME = theme
|
||||
for colour, value in ((WINDOW, theme.window), (BOARD, theme.board),
|
||||
(INK, theme.ink), (INK_DIM, theme.ink_dim),
|
||||
(HATCH, theme.hatch), (TAPE, theme.accent)):
|
||||
colour.setRgba(QColor(value).rgba())
|
||||
|
||||
|
||||
_FAMILIES = None
|
||||
|
||||
|
||||
def _families():
|
||||
global _FAMILIES
|
||||
if _FAMILIES is None:
|
||||
_FAMILIES = set(QFontDatabase.families())
|
||||
return _FAMILIES
|
||||
|
||||
|
||||
def _seed(tag):
|
||||
"""A stable number for a name. Python's own hash() changes every run."""
|
||||
return zlib.crc32(tag.encode())
|
||||
|
||||
|
||||
def _wobble_path(path, amount, seed, step=4.0):
|
||||
"""
|
||||
A pencil version of a path. Resampled finely, then nudged sideways by
|
||||
two slow waves with random phases, so the line wanders without jitter.
|
||||
Seeded, so a key looks the same every time it's drawn.
|
||||
"""
|
||||
rnd = random.Random(seed)
|
||||
out = QPainterPath()
|
||||
for poly in path.toSubpathPolygons():
|
||||
pts = list(poly)
|
||||
if len(pts) < 2:
|
||||
continue
|
||||
dense = []
|
||||
for a, b in zip(pts, pts[1:]):
|
||||
seg = QLineF(a, b)
|
||||
n = max(1, int(seg.length() / step))
|
||||
dense.extend(seg.pointAt(k / n) for k in range(n))
|
||||
dense.append(pts[-1])
|
||||
f1, f2 = rnd.uniform(0.035, 0.06), rnd.uniform(0.10, 0.16)
|
||||
p1, p2 = rnd.uniform(0, math.tau), rnd.uniform(0, math.tau)
|
||||
dist, moved = 0.0, []
|
||||
for i, pt in enumerate(dense):
|
||||
if i:
|
||||
dist += QLineF(dense[i - 1], pt).length()
|
||||
nxt, prv = dense[min(i + 1, len(dense) - 1)], dense[max(i - 1, 0)]
|
||||
dx, dy = nxt.x() - prv.x(), nxt.y() - prv.y()
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
off = amount * (0.65 * math.sin(dist * f1 + p1) + 0.35 * math.sin(dist * f2 + p2))
|
||||
moved.append(QPointF(pt.x() - dy / length * off, pt.y() + dx / length * off))
|
||||
out.moveTo(moved[0])
|
||||
for q in moved[1:]:
|
||||
out.lineTo(q)
|
||||
return out
|
||||
|
||||
# Geometry in board units; the view scales it to fit.
|
||||
K, G, M = 96.0, 16.0, 30.0 # key size, gap, board margin
|
||||
@@ -80,6 +145,7 @@ class PadView(QWidget):
|
||||
self.drag_at = None # where the pointer is, in board units
|
||||
self.drop = None # (row, col) it would land in
|
||||
self._wobble = 0
|
||||
self._wobble_cache = {}
|
||||
self._wobbler = QTimer(self)
|
||||
self._wobbler.setInterval(130)
|
||||
self._wobbler.timeout.connect(self._tick)
|
||||
@@ -127,6 +193,7 @@ class PadView(QWidget):
|
||||
# ------------------------------------------------------------ layout
|
||||
|
||||
def _layout(self):
|
||||
self._wobble_cache = {}
|
||||
self.keys = {} # key -> QRectF
|
||||
self.dials = {} # dial -> centre QPointF
|
||||
self.rows = {} # dial action -> legend row QRectF
|
||||
@@ -175,6 +242,8 @@ class PadView(QWidget):
|
||||
def _transform(self):
|
||||
pad = 24
|
||||
avail = QRectF(self.rect()).adjusted(pad, pad, -pad, -pad)
|
||||
if THEME.title_block: # keep clear of the title block
|
||||
avail.adjust(0, 0, 0, -92)
|
||||
s = min(avail.width() / self.board.width(),
|
||||
avail.height() / self.board.height())
|
||||
s = max(0.4, min(s, 1.6))
|
||||
@@ -297,17 +366,22 @@ class PadView(QWidget):
|
||||
|
||||
# ------------------------------------------------------------ paint
|
||||
|
||||
# ------------------------------------------------------------ paint
|
||||
|
||||
def paintEvent(self, _):
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.fillRect(self.rect(), WINDOW)
|
||||
if THEME.grid:
|
||||
self._paint_grid(p)
|
||||
p.setTransform(self._transform())
|
||||
|
||||
board = QPainterPath()
|
||||
board.addRoundedRect(self.board, 18, 18)
|
||||
p.fillPath(board, BOARD)
|
||||
p.setPen(QPen(INK_DIM, 1.2))
|
||||
p.drawPath(board)
|
||||
# On themes where the pad is only an outline, draw that in full ink.
|
||||
edge = INK if THEME.board == THEME.window else INK_DIM
|
||||
self._stroke(p, board, QPen(edge, 1.2), "board")
|
||||
|
||||
if self.editing:
|
||||
for spot, rect in self.cells.items():
|
||||
@@ -326,7 +400,7 @@ class PadView(QWidget):
|
||||
p.save()
|
||||
if self.editing:
|
||||
# a small alternating tilt, the way icons shake when movable
|
||||
lean = 0.55 if (hash(key) + self._wobble) % 2 else -0.55
|
||||
lean = 0.55 if (_seed(key) + self._wobble) % 2 else -0.55
|
||||
p.translate(rect.center())
|
||||
p.rotate(lean)
|
||||
p.translate(-rect.center())
|
||||
@@ -358,22 +432,167 @@ class PadView(QWidget):
|
||||
p.drawText(QRectF(M, self.board.bottom() - 34, 200, 16),
|
||||
Qt.AlignLeft | Qt.AlignVCenter, self.device_id)
|
||||
|
||||
if THEME.title_block:
|
||||
p.resetTransform()
|
||||
self._paint_sheet(p)
|
||||
|
||||
# ------------------------------------------------------ theme drawing
|
||||
|
||||
def _stroke(self, p, path, pen, tag):
|
||||
"""
|
||||
Draw an outline. On a pencil theme the line wanders a little and is
|
||||
gone over a second time, lighter, the way a hand draws.
|
||||
"""
|
||||
p.setBrush(Qt.NoBrush)
|
||||
if not THEME.wobble:
|
||||
p.setPen(pen)
|
||||
p.drawPath(path)
|
||||
return
|
||||
p.setPen(pen)
|
||||
p.drawPath(self._wobbled(path, THEME.wobble, tag))
|
||||
again = QPen(pen)
|
||||
c = QColor(pen.color())
|
||||
c.setAlpha(int(c.alpha() * 0.45))
|
||||
again.setColor(c)
|
||||
again.setWidthF(max(0.6, pen.widthF() * 0.7))
|
||||
p.setPen(again)
|
||||
p.drawPath(self._wobbled(path, THEME.wobble * 0.8, tag + "'"))
|
||||
|
||||
def _wobbled(self, path, amount, tag):
|
||||
# Paths are in board units, so they only change when the layout
|
||||
# does; cache them rather than recompute on every hover.
|
||||
b = path.boundingRect()
|
||||
key = (tag, amount, round(b.x(), 1), round(b.y(), 1),
|
||||
round(b.width(), 1), round(b.height(), 1))
|
||||
got = self._wobble_cache.get(key)
|
||||
if got is None:
|
||||
got = self._wobble_cache[key] = _wobble_path(path, amount, _seed(tag))
|
||||
return got
|
||||
|
||||
def _legend_font(self, px, roomy=True):
|
||||
"""roomy: there's space to spare, as on a keycap; not on a dial legend."""
|
||||
if THEME.font and THEME.font in _families():
|
||||
f = QFont(THEME.font)
|
||||
f.setPixelSize(px + (2 if roomy else 0)) # handwriting reads small
|
||||
elif THEME.mono_legends:
|
||||
f = QFont(self.mono)
|
||||
f.setPixelSize(px - 1)
|
||||
else:
|
||||
f = QFont(self.font())
|
||||
f.setPixelSize(px)
|
||||
return f
|
||||
|
||||
@staticmethod
|
||||
def _lettering(text):
|
||||
return text.upper() if THEME.caps else text
|
||||
|
||||
def _paint_grid(self, p):
|
||||
"""Drawing paper: fine lines, and a heavier one every fifth."""
|
||||
r = self.rect()
|
||||
fine, heavy = QColor(HATCH), QColor(HATCH)
|
||||
fine.setAlpha(70)
|
||||
heavy.setAlpha(150)
|
||||
step = 20
|
||||
for i, x in enumerate(range(0, r.width(), step)):
|
||||
p.setPen(QPen(heavy if i % 5 == 0 else fine, 1))
|
||||
p.drawLine(x, 0, x, r.height())
|
||||
for i, y in enumerate(range(0, r.height(), step)):
|
||||
p.setPen(QPen(heavy if i % 5 == 0 else fine, 1))
|
||||
p.drawLine(0, y, r.width(), y)
|
||||
|
||||
def _paint_sheet(self, p):
|
||||
"""A drawing border and a title block in the bottom right corner."""
|
||||
import getpass
|
||||
sheet = QRectF(self.rect()).adjusted(8, 8, -9, -9)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.setPen(QPen(INK, 1.4))
|
||||
p.drawRect(sheet)
|
||||
|
||||
w, h = min(330.0, sheet.width() - 20), 84.0
|
||||
tb = QRectF(sheet.right() - w, sheet.bottom() - h, w, h)
|
||||
p.fillRect(tb, WINDOW)
|
||||
p.setPen(QPen(INK, 1.2))
|
||||
p.drawRect(tb)
|
||||
row = h / 3
|
||||
half = tb.left() + w / 2
|
||||
p.setPen(QPen(INK, 0.8))
|
||||
p.drawLine(QPointF(tb.left(), tb.top() + row), QPointF(tb.right(), tb.top() + row))
|
||||
p.drawLine(QPointF(tb.left(), tb.top() + 2 * row), QPointF(tb.right(), tb.top() + 2 * row))
|
||||
p.drawLine(QPointF(half, tb.top() + row), QPointF(half, tb.bottom()))
|
||||
|
||||
try:
|
||||
who = getpass.getuser()
|
||||
except Exception:
|
||||
who = ""
|
||||
lay = self.layout
|
||||
knobs = f", {lay.knobs} KNOB{'S' if lay.knobs != 1 else ''}" if lay.knobs else ""
|
||||
cells = [
|
||||
(QRectF(tb.left(), tb.top(), w, row), "TITLE", "MACROPAD"),
|
||||
(QRectF(tb.left(), tb.top() + row, w / 2, row), "DWG NO", self.device_id),
|
||||
(QRectF(half, tb.top() + row, w / 2, row), "SCALE", "1:1 SHEET 1 OF 1"),
|
||||
(QRectF(tb.left(), tb.top() + 2 * row, w / 2, row), "DRAWN", who.upper()),
|
||||
(QRectF(half, tb.top() + 2 * row, w / 2, row), "LAYOUT",
|
||||
f"{lay.rows}X{lay.cols}{knobs}"),
|
||||
]
|
||||
small, big = QFont(self.mono), QFont(self.mono)
|
||||
small.setPixelSize(7)
|
||||
big.setPixelSize(11)
|
||||
big.setBold(True)
|
||||
for rect, label, value in cells:
|
||||
p.setFont(small)
|
||||
p.setPen(INK_DIM)
|
||||
p.drawText(rect.adjusted(5, 2, -4, 0), Qt.AlignLeft | Qt.AlignTop, label)
|
||||
p.setFont(big)
|
||||
p.setPen(INK)
|
||||
p.drawText(rect.adjusted(5, 0, -4, -3), Qt.AlignLeft | Qt.AlignBottom,
|
||||
QFontMetricsF(big).elidedText(value, Qt.ElideRight,
|
||||
rect.width() - 10))
|
||||
|
||||
def _centre_lines(self, p, c):
|
||||
"""Chain lines through a round feature's centre: long dash, short dash."""
|
||||
pen = QPen(INK_DIM, 0.8)
|
||||
pen.setDashPattern([14, 3, 3, 3])
|
||||
p.setPen(pen)
|
||||
reach = R_OUT + 10
|
||||
p.drawLine(QPointF(c.x() - reach, c.y()), QPointF(c.x() + reach, c.y()))
|
||||
p.drawLine(QPointF(c.x(), c.y() - reach), QPointF(c.x(), c.y() + reach))
|
||||
|
||||
# ------------------------------------------------------------ pieces
|
||||
|
||||
def _outline_pen(self, control, known):
|
||||
if control == self.current:
|
||||
return QPen(TAPE, 2.2)
|
||||
if control == self.hover:
|
||||
return QPen(INK, 1.6)
|
||||
return QPen(INK if known else INK_DIM, 1.1)
|
||||
pen = QPen(INK if known else INK_DIM, 1.1)
|
||||
if not known and THEME.unknown == "hidden":
|
||||
pen.setDashPattern([5, 3]) # hidden line, drawing convention
|
||||
return pen
|
||||
|
||||
def _hatch(self, p, path, rect, step=9.0):
|
||||
def _unknown_fill(self, p, path, rect, tag, step=9.0):
|
||||
if THEME.unknown == "hatch":
|
||||
self._hatch(p, path, rect, tag, step)
|
||||
|
||||
def _hatch(self, p, path, rect, tag="", step=9.0):
|
||||
p.save()
|
||||
p.setClipPath(path)
|
||||
p.setPen(QPen(HATCH, 1.4))
|
||||
pen = QPen(HATCH, 1.4)
|
||||
x = rect.left() - rect.height()
|
||||
i = 0
|
||||
while x < rect.right():
|
||||
p.drawLine(QPointF(x, rect.bottom()),
|
||||
QPointF(x + rect.height(), rect.top()))
|
||||
a = QPointF(x, rect.bottom())
|
||||
b = QPointF(x + rect.height(), rect.top())
|
||||
if THEME.wobble:
|
||||
line = QPainterPath(a)
|
||||
line.lineTo(b)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.drawPath(self._wobbled(line, THEME.wobble * 0.6, f"{tag}h{i}"))
|
||||
else:
|
||||
p.setPen(pen)
|
||||
p.drawLine(a, b)
|
||||
x += step
|
||||
i += 1
|
||||
p.restore()
|
||||
|
||||
def _tape(self, p, at, length=30.0, width=9.0):
|
||||
@@ -392,11 +611,10 @@ class PadView(QWidget):
|
||||
look = self.looks.get(key, Look())
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(rect, 5, 5)
|
||||
if not look.known and not look.pending:
|
||||
self._hatch(p, path, rect)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.setPen(self._outline_pen(key, look.known or look.pending))
|
||||
p.drawPath(path)
|
||||
unknown = not look.known and not look.pending
|
||||
if unknown:
|
||||
self._unknown_fill(p, path, rect, key)
|
||||
self._stroke(p, path, self._outline_pen(key, look.known or look.pending), key)
|
||||
|
||||
f = QFont(self.mono)
|
||||
f.setPixelSize(10)
|
||||
@@ -405,14 +623,13 @@ class PadView(QWidget):
|
||||
p.drawText(rect.adjusted(8, 6, -8, -6), Qt.AlignLeft | Qt.AlignTop,
|
||||
f"0x{core.action_byte(key):02x}")
|
||||
|
||||
f = QFont(self.font())
|
||||
f.setPixelSize(13)
|
||||
f = self._legend_font(13)
|
||||
p.setFont(f)
|
||||
if look.pending:
|
||||
p.setPen(TAPE)
|
||||
else:
|
||||
p.setPen(INK if look.known and not look.quiet else INK_DIM)
|
||||
text = look.text if (look.known or look.pending) else "Unknown"
|
||||
text = self._lettering(look.text if (look.known or look.pending) else "Unknown")
|
||||
body = rect.adjusted(8, 22, -8, -10)
|
||||
# Let 'Meta+Ctrl+Left' break after a '+' instead of being cut off.
|
||||
text = text.replace("+", "+\u200b")
|
||||
@@ -442,6 +659,8 @@ class PadView(QWidget):
|
||||
tip.y() - 3.5 * math.sin(a)))
|
||||
|
||||
def _paint_dial(self, p, dial, c):
|
||||
if THEME.centre_lines:
|
||||
self._centre_lines(p, c)
|
||||
# Ring segments for turn left / turn right, centre for press.
|
||||
for act in core.DIAL_ACTIONS:
|
||||
control = f"{dial}-{act}"
|
||||
@@ -449,44 +668,41 @@ class PadView(QWidget):
|
||||
seg = self._segment(dial, act)
|
||||
if act != "push":
|
||||
if not look.known and not look.pending:
|
||||
self._hatch(p, seg, seg.boundingRect(), 7.0)
|
||||
self._unknown_fill(p, seg, seg.boundingRect(), control, 7.0)
|
||||
if control == self.current:
|
||||
fill = QColor(TAPE)
|
||||
fill.setAlpha(55)
|
||||
p.fillPath(seg, fill)
|
||||
p.setPen(self._outline_pen(control, look.known or look.pending))
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.drawPath(seg)
|
||||
self._stroke(p, seg, self._outline_pen(control, look.known or look.pending),
|
||||
control)
|
||||
|
||||
push = f"{dial}-push"
|
||||
look = self.looks.get(push, Look())
|
||||
knob = QPainterPath()
|
||||
knob.addEllipse(c, R_KNOB, R_KNOB)
|
||||
if not look.known and not look.pending:
|
||||
self._hatch(p, knob, knob.boundingRect(), 7.0)
|
||||
self._unknown_fill(p, knob, knob.boundingRect(), push, 7.0)
|
||||
if push == self.current:
|
||||
fill = QColor(TAPE)
|
||||
fill.setAlpha(55)
|
||||
p.fillPath(knob, fill)
|
||||
p.setPen(self._outline_pen(push, look.known or look.pending))
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.drawPath(knob)
|
||||
self._stroke(p, knob, self._outline_pen(push, look.known or look.pending), push)
|
||||
# knurling, the way a footprint marks a rotary encoder
|
||||
if not THEME.wobble:
|
||||
p.setPen(QPen(INK_DIM, 1.0))
|
||||
for i in range(24):
|
||||
a = math.radians(i * 15)
|
||||
p.drawLine(QPointF(c.x() + (R_KNOB - 4) * math.cos(a), c.y() + (R_KNOB - 4) * math.sin(a)),
|
||||
QPointF(c.x() + (R_KNOB - 1) * math.cos(a), c.y() + (R_KNOB - 1) * math.sin(a)))
|
||||
|
||||
f = QFont(self.font())
|
||||
f.setPixelSize(11)
|
||||
f = self._legend_font(11)
|
||||
p.setFont(f)
|
||||
label = QRectF(c.x() - 21, c.y() - 8, 42, 16)
|
||||
label = QRectF(c.x() - 23, c.y() - 8, 46, 16)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(BOARD)
|
||||
p.drawRoundedRect(label, 3, 3)
|
||||
p.setPen(TAPE if push == self.current else INK_DIM)
|
||||
p.drawText(label, Qt.AlignCenter, f"Dial {dial[4:]}")
|
||||
p.drawText(label, Qt.AlignCenter, self._lettering(f"Dial {dial[4:]}"))
|
||||
|
||||
# Legend rows: the silkscreen text next to the part.
|
||||
for act in core.DIAL_ACTIONS:
|
||||
@@ -519,12 +735,11 @@ class PadView(QWidget):
|
||||
p.drawText(rect.adjusted(0, 0, -4, 0), Qt.AlignRight | Qt.AlignVCenter,
|
||||
f"{core.action_byte(control):02x}")
|
||||
|
||||
f = QFont(self.font())
|
||||
f.setPixelSize(12)
|
||||
f = self._legend_font(12, roomy=False)
|
||||
p.setFont(f)
|
||||
p.setPen(TAPE if look.pending else
|
||||
(INK if look.known and not look.quiet else INK_DIM))
|
||||
text = look.text if (look.known or look.pending) else "Unknown"
|
||||
text = self._lettering(look.text if (look.known or look.pending) else "Unknown")
|
||||
body = rect.adjusted(22, 0, -26, 0)
|
||||
p.drawText(body, Qt.AlignLeft | Qt.AlignVCenter,
|
||||
QFontMetricsF(f).elidedText(text, Qt.ElideRight, body.width()))
|
||||
|
||||
120
macropad_gui/themes.py
Normal file
@@ -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
|
||||
@@ -1,3 +1,3 @@
|
||||
# MacroPad: let the logged-in user configure 1189:8840 macro pads.
|
||||
# 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
@@ -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())
|
||||
@@ -143,7 +143,8 @@ class ReadingThePad(unittest.TestCase):
|
||||
|
||||
def test_our_requests_match_the_vendors(self):
|
||||
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)):
|
||||
# the vendor's trailing bytes are uninitialised memory; ours are zero
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
|
||||
def test_action_bytes_agree_with_the_protocol_module(self):
|
||||
@@ -593,6 +621,7 @@ class GuiSmoke(unittest.TestCase):
|
||||
except ImportError:
|
||||
self.skipTest("PySide6 not installed")
|
||||
from unittest import mock
|
||||
from macropad_gui import actions as act_mod
|
||||
with tempfile.TemporaryDirectory() as d, \
|
||||
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
|
||||
qa = QApplication.instance() or QApplication([])
|
||||
@@ -626,6 +655,25 @@ class GuiSmoke(unittest.TestCase):
|
||||
w._edit_layout(False, keep=False)
|
||||
self.assertEqual(w.state.layout, core.DEFAULT_LAYOUT)
|
||||
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"))
|
||||
self.assertEqual(w._pending(), ["key1"])
|
||||
# choosing Nothing marks the control as changed straight away
|
||||
@@ -636,3 +684,53 @@ class GuiSmoke(unittest.TestCase):
|
||||
self.assertIn("key5", w._pending())
|
||||
w.desired.clear()
|
||||
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)
|
||||
|
||||
@@ -15,6 +15,15 @@ ask() {
|
||||
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 -f "$HOME/.local/bin/macropad" "$DATA/applications/macropad.desktop" \
|
||||
"$DATA/icons/hicolor/scalable/apps/macropad.svg"
|
||||
|
||||