Add files via upload

This commit is contained in:
Mohamed Mahjoub
2026-09-11 20:30:34 +01:00
committed by GitHub
parent 07f3af3441
commit c7c02edb60
35 changed files with 3975 additions and 13 deletions

347
docs/make_screenshots.py Normal file
View File

@@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
Regenerate every image in docs/images from the real app, offscreen.
QT_QPA_PLATFORM=offscreen QT_SCALE_FACTOR=2 python3 docs/make_screenshots.py
Uses a fake pad and a throwaway config folder, so it never touches your real
pad or your saved state. Looks best with Noto Sans and Hack installed, which
are Plasma's defaults.
"""
import os
import sys
import tempfile
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "docs" / "images"
sys.path.insert(0, str(ROOT))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["XDG_CONFIG_HOME"] = tempfile.mkdtemp()
from PySide6.QtCore import QPointF, QRectF, Qt # noqa: E402
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QImage, # noqa: E402
QLinearGradient, QPainter, QPen)
from PySide6.QtSvg import QSvgRenderer # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402
from macropad_gui import app as A, core, padview # noqa: E402
try:
from PIL import Image, ImageDraw, ImageFilter
except ImportError:
Image = None
B = core.Binding
DEMO = {
"key4": B(keys="super+ctrl+left"), "key8": B(keys="super+w"),
"key12": B(keys="super+ctrl+right"),
"key3": B(media="prev"), "key7": B(media="playpause"), "key11": B(media="next"),
"key2": B(keys="ctrl+z"), "key6": B(keys="ctrl+shift+z"), "key10": B(keys="ctrl+s"),
"key1": B(keys="printscreen"), "key5": B(keys="ctrl+alt+t"), "key9": B(media="mycomputer"),
"dial1-left": B(media="volumedown"), "dial1-push": B(media="mute"),
"dial1-right": B(media="volumeup"),
"dial2-left": B(media="brightnessdown"), "dial2-push": B(media="calculator"),
"dial2-right": B(media="brightnessup"),
}
READY = core.Diagnosis("ready", "Ready on /dev/hidraw10.",
device=core.Device("/dev/hidraw10", 3, True))
def fonts(app):
ui = QFont("Noto Sans", 10)
if "Noto Sans" in QFontDatabase.families():
app.setFont(ui)
if "Hack" in QFontDatabase.families():
hack = QFont("Hack", 10)
real = QFontDatabase.systemFont
QFontDatabase.systemFont = staticmethod(
lambda which: hack if which == QFontDatabase.FixedFont else real(which))
def window(diag=READY, bindings=None, note=None):
with mock.patch.object(core, "_keyd_warning", return_value=note), \
mock.patch.object(core, "diagnose", return_value=diag):
w = A.Window()
w.timer.stop()
w.diag = diag
w.note = note
w.state = core.State(Path(tempfile.mkdtemp()) / "state.json")
for c, b in (bindings or {}).items():
w.state.record(c, b)
w.resize(1000, 800)
w._poll_fake = diag
w.inspector.show_problem(diag, note)
status_colour = {"ready": A.GOOD, "unplugged": padview.INK_DIM.name()}.get(diag.status, A.BAD)
w.dot.setStyleSheet(f"background: {status_colour}; border-radius: 4px;")
w.status.setText({"ready": "Pad ready on /dev/hidraw10",
"unplugged": "No pad connected",
"no-permission": "No permission to write to the pad",
"unsupported": "Unsupported pad"}[diag.status])
w._refresh()
return w
def select(w, control):
w.pad.select(control)
w._refresh()
def save(w, name, frame=True):
w.show()
QApplication.processEvents()
img = w.grab().toImage()
path = OUT / f"{name}.png"
img.save(str(path))
if frame and Image:
_frame(path)
w.desired.clear()
w.close()
print("wrote", path.relative_to(ROOT))
def _frame(path, radius=20, pad=56):
"""Rounded corners, a hairline edge and a soft shadow, on transparency."""
shot = Image.open(path).convert("RGBA")
w, h = shot.size
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, w - 1, h - 1], radius, fill=255)
shot.putalpha(mask)
edge = Image.new("RGBA", (w, h), (0, 0, 0, 0))
ImageDraw.Draw(edge).rounded_rectangle([0, 0, w - 1, h - 1], radius,
outline=(255, 255, 255, 34), width=2)
shot = Image.alpha_composite(shot, edge)
canvas = Image.new("RGBA", (w + 2 * pad, h + 2 * pad), (0, 0, 0, 0))
shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
ImageDraw.Draw(shadow).rounded_rectangle(
[pad, pad + 14, pad + w, pad + h + 14], radius, fill=(0, 0, 0, 120))
shadow = shadow.filter(ImageFilter.GaussianBlur(22))
canvas = Image.alpha_composite(canvas, shadow)
canvas.alpha_composite(shot, (pad, pad))
canvas.save(path, optimize=True)
# ------------------------------------------------------------------ scenes
def scene_hero():
w = window(bindings=DEMO)
select(w, "key12")
w._say("Wrote 3 changes to the pad.", A.GOOD)
save(w, "hero")
def scene_first_launch():
w = window()
save(w, "first-launch")
def scene_select_and_type():
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("dial")})
select(w, "key2")
w.inspector.keys_edit.setText("ctrl+z")
w.inspector._keys_typed("ctrl+z")
save(w, "step-type")
def scene_record():
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("dial")})
select(w, "key6")
w.inspector.record.setChecked(True)
w.inspector.keys_edit.setText("ctrl+shift+…")
save(w, "step-record")
def scene_media():
w = window(bindings={k: v for k, v in DEMO.items() if k.startswith("key")})
select(w, "dial1-left")
w.inspector.mode_media.click()
w.inspector.media_buttons["volumedown"].click()
save(w, "step-media")
def scene_pending():
w = window(bindings={k: v for k, v in DEMO.items()
if k not in ("key2", "key6", "key10")})
for k in ("key2", "key6", "key10"):
w.desired[k] = DEMO[k]
select(w, "key10")
w.inspector.keys_hint.setText("Sends Ctrl+S")
w._refresh(editor=False)
save(w, "step-pending")
def scene_written():
w = window(bindings=DEMO)
select(w, "key10")
w._say("Wrote 3 changes to the pad.", A.GOOD)
save(w, "step-written")
def scene_flat():
w = window(bindings=DEMO)
w.pad.set_orientation("flat")
select(w, "dial2-push")
save(w, "flat-view")
def scene_permission():
d = core.Diagnosis(
"no-permission", "/dev/hidraw10 isn't writable.",
device=core.Device("/dev/hidraw10", 3, False),
detail=["No udev rule lets you write to the pad yet. Running the installer "
"again adds it, or paste these commands into a terminal:"],
fix=[f"echo '{core.RULE_LINE}' | sudo tee /etc/udev/rules.d/60-macropad.rules",
"sudo udevadm control --reload-rules && sudo udevadm trigger",
"# then unplug and replug the pad"])
w = window(diag=d, bindings=DEMO)
select(w, "key4")
save(w, "permission")
def scene_keyd():
note = ("keyd, a key remapping service, is running and /etc/keyd/default.conf "
"lists this pad. It can change keys on their way from the pad to your "
"desktop, so a key may not do exactly what you set here. If you only "
"set keyd up for this pad, you can switch it off with: "
"sudo systemctl disable --now keyd")
w = window(bindings=DEMO, note=note)
save(w, "keyd-note")
def scene_unsupported():
d = core.Diagnosis(
"unsupported", "Found a 1189:8890 pad, which this app doesn't know yet.",
detail=["So far only 1189:8840 (12 keys, 2 dials) has been worked out. "
"Pads in this family differ in layout and protocol, so writing to "
"yours with the wrong one could fail or scramble it. The app won't try.",
"You can help add it: the README's \"Other pads\" section shows how "
"to capture what the vendor software sends."])
w = window(diag=d)
save(w, "unsupported")
def scene_legend():
"""Four keycaps showing every state the drawing uses."""
scale = 2
names = [("Written", "on the pad", padview.Look("Ctrl+C", known=True), None),
("Unknown", "could be anything", padview.Look(), None),
("Changed", "not written yet", padview.Look("Mute", known=True, pending=True), None),
("Selected", "being edited", padview.Look("Ctrl+S", known=True), "sel")]
cell, gap, top = 132, 26, 24
width = len(names) * cell + (len(names) - 1) * gap + 2 * 30
img = QImage(width * scale, 210 * scale, QImage.Format_ARGB32)
img.fill(padview.BOARD)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing)
p.scale(scale, scale)
pv = padview.PadView()
for i, (title, sub, look, sel) in enumerate(names):
x = 30 + i * (cell + gap)
rect = QRectF(x + (cell - 96) / 2, top, 96, 96)
pv.looks = {f"key{i + 1}": look}
pv.current = f"key{i + 1}" if sel else None
pv._paint_key(p, f"key{i + 1}", rect)
f = QFont(QApplication.font())
f.setPixelSize(15)
f.setWeight(QFont.DemiBold)
p.setFont(f)
p.setPen(padview.TAPE if look.pending or sel else padview.INK)
p.drawText(QRectF(x, top + 110, cell, 22), Qt.AlignHCenter, title)
f.setWeight(QFont.Normal)
f.setPixelSize(12)
p.setFont(f)
p.setPen(padview.INK_DIM)
p.drawText(QRectF(x, top + 134, cell, 20), Qt.AlignHCenter, sub)
p.end()
path = OUT / "legend.png"
img.save(str(path))
if Image:
_frame(path, radius=24, pad=40)
print("wrote", path.relative_to(ROOT))
def scene_social():
"""1280x640: GitHub's social preview, which Reddit and Discord show for links."""
W, H = 1280, 640
img = QImage(W, H, QImage.Format_ARGB32)
img.fill(padview.WINDOW)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing)
# faint board-grid, like a PCB's copper pour
p.setPen(QPen(QColor(255, 255, 255, 10), 1))
for x in range(0, W, 32):
p.drawLine(x, 0, x, H)
for y in range(0, H, 32):
p.drawLine(0, y, W, y)
pv = padview.PadView()
pv.resize(460, 640)
pv.looks = {c: padview.Look(b.label(), known=True) for c, b in DEMO.items()}
pv.looks["key12"] = padview.Look("Meta+Ctrl+Right", known=True, pending=True)
pv.current = "key12"
p.save()
p.translate(70, 0)
p.setTransform(pv._transform(), True)
board = QRectF(pv.board)
path = __import__("PySide6.QtGui", fromlist=["QPainterPath"]).QPainterPath()
path.addRoundedRect(board, 18, 18)
p.fillPath(path, padview.BOARD)
p.setPen(QPen(padview.INK_DIM, 1.2))
p.drawPath(path)
for key, rect in pv.keys.items():
pv._paint_key(p, key, rect)
for dial, centre in pv.dials.items():
pv._paint_dial(p, dial, centre)
p.restore()
x0 = 600
f = QFont(QApplication.font())
f.setPixelSize(92)
f.setWeight(QFont.Bold)
p.setFont(f)
p.setPen(padview.INK)
p.drawText(QRectF(x0, 150, 640, 110), Qt.AlignLeft | Qt.AlignVCenter, "MacroPad")
p.fillRect(QRectF(x0 + 4, 268, 120, 6), padview.TAPE)
f.setPixelSize(34)
f.setWeight(QFont.Normal)
p.setFont(f)
p.drawText(QRectF(x0, 300, 640, 140), Qt.AlignLeft | Qt.TextWordWrap,
"Set up your 12 key, 2 knob USB macro pad on Linux.")
f.setPixelSize(26)
p.setFont(f)
p.setPen(padview.INK_DIM)
p.drawText(QRectF(x0, 430, 640, 40), Qt.AlignLeft, "No Windows. No VM. No Wine.")
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
mono.setPixelSize(22)
p.setFont(mono)
p.drawText(QRectF(x0, 540, 640, 30), Qt.AlignLeft, "github.com/Probler-Yt/MacroPad")
p.end()
img.save(str(OUT / "social-preview.png"))
print("wrote docs/images/social-preview.png")
def scene_icon():
r = QSvgRenderer(str(ROOT / "packaging" / "macropad.svg"))
img = QImage(256, 256, QImage.Format_ARGB32)
img.fill(Qt.transparent)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing)
r.render(p, QRectF(0, 0, 256, 256))
p.end()
img.save(str(OUT / "icon.png"))
print("wrote docs/images/icon.png")
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
app = QApplication(sys.argv)
fonts(app)
A.apply_theme(app)
for name, fn in list(globals().items()):
if name.startswith("scene_"):
fn()