#!/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_read(): w = window(bindings=DEMO) w._say("Read 18 controls from the pad.", A.GOOD) select(w, "dial1-left") save(w, "read-pad") 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_nothing(): b = dict(DEMO) b["key1"] = B(none=True) w = window(bindings=b) w.desired["key5"] = B(none=True) select(w, "key5") w._refresh() save(w, "step-nothing") 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_edit_layout(): w = window(bindings=DEMO) w._edit_layout(True) spots = {k: (r, c) for k, r, c in __import__("macropad_gui.padview", fromlist=["padview"]) ._key_grid(w.pad.layout, w.pad.orientation)} w.pad.drag = "key2" w.pad.drag_at = w.pad.keys["key11"].center() w.pad.drop = spots["key11"] save(w, "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") 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.", "ch57x-keyboard-tool, a command line tool, supports several of these " "pads today. To get yours into this app, 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 _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) 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()