#!/usr/bin/env python3
"""Render notification layouts as a phone-width PNG mockup.
Pushover renders the body in a proportional font, so space-padded columns do
not line up on a phone. These mockups use a proportional font on purpose so
the preview matches what the user actually sees.
By default the cards are rendered from the real ``build_summary()`` output, so
the preview is ground truth rather than a hand-written approximation. Pass
``--mock`` to render the built-in candidate sketches instead.
Usage: python3 docs/notify-preview.py [--mock] [out.png]
"""
import datetime
import html
import os
import subprocess
import sys
import tempfile
from collections import Counter
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
STATS = {
"total": 42,
"removed": 7,
"orphaned": 4,
"ignored": 5,
"premature": 3,
"protected": 10,
"non_protected": 20,
"runtime": "12s",
}
EMOJI = {
"removed": "\U0001F5D1\uFE0F",
"orphaned": "\U0001F47B",
"ignored": "\u23ED\uFE0F",
"premature": "\U0001F423",
"protected": "\U0001F6E1\uFE0F",
"non_protected": "\U0001F310",
}
def b(text):
return f"{text}"
# --- candidate layouts -----------------------------------------------------
# Each returns (title, body_html). Body uses Pushover's HTML subset.
def current_table(s):
"""Today's layout: space-padded columns."""
rows = [
("Removed", s["removed"]),
("Orphaned", s["orphaned"]),
("Ignored", s["ignored"]),
("Premature", s["premature"]),
]
tail = [("Protected", s["protected"]), ("Non-protected", s["non_protected"])]
out = [f"Scanned {s['total']} torrents in {s['runtime']}", ""]
out += [f"{b(lbl)}{' ' * max(14 - len(lbl), 1)}{val:>4}" for lbl, val in rows]
out.append("")
out += [f"{b(lbl)}{' ' * max(14 - len(lbl), 1)}{val:>4}" for lbl, val in tail]
return "qbit-maid: 7 torrents removed", "\n".join(out)
def compact_inline(s):
"""One line per group, middle-dot separated."""
out = [
f"Scanned {s['total']} torrents in {s['runtime']}",
"",
f"{b('Removed')} {s['removed']} \u00b7 {b('Orphaned')} {s['orphaned']}",
f"{b('Ignored')} {s['ignored']} \u00b7 {b('Premature')} {s['premature']}",
"",
f"{b('Protected')} {s['protected']} \u00b7 {b('Non-protected')} {s['non_protected']}",
]
return "qbit-maid: 7 torrents removed", "\n".join(out)
def emoji_rows(s):
"""Emoji-led rows, no padding."""
out = [f"Scanned {s['total']} torrents in {s['runtime']}", ""]
for key, label in (("removed", "Removed"), ("orphaned", "Orphaned"),
("ignored", "Ignored"), ("premature", "Premature")):
out.append(f"{EMOJI[key]} {b(label)} {s[key]}")
out.append("")
for key, label in (("protected", "Protected"), ("non_protected", "Non-protected")):
out.append(f"{EMOJI[key]} {b(label)} {s[key]}")
return "qbit-maid: 7 torrents removed", "\n".join(out)
def hero_line(s):
"""Actionable numbers up front, everything else compact below."""
out = [
f"{b(str(s['removed']))} removed \u00b7 {b(str(s['orphaned']))} orphaned",
f"{s['total']} torrents scanned in {s['runtime']}",
"",
f"Ignored {s['ignored']} \u00b7 Premature {s['premature']}",
f"Protected {s['protected']} \u00b7 Non-protected {s['non_protected']}",
]
return "qbit-maid: 7 torrents removed", "\n".join(out)
def emoji_inline(s):
"""Compact inline, emoji instead of bold labels."""
out = [
f"Scanned {s['total']} torrents in {s['runtime']}",
"",
f"{EMOJI['removed']} {s['removed']} removed \u00b7 {EMOJI['orphaned']} {s['orphaned']} orphaned",
f"{EMOJI['ignored']} {s['ignored']} ignored \u00b7 {EMOJI['premature']} {s['premature']} premature",
"",
f"{EMOJI['protected']} {s['protected']} protected \u00b7 {EMOJI['non_protected']} {s['non_protected']} public",
]
return "qbit-maid: 7 torrents removed", "\n".join(out)
MOCK_CANDIDATES = [
("A \u2014 current (padded table)", current_table),
("B \u2014 compact inline", compact_inline),
("C \u2014 emoji rows", emoji_rows),
("D \u2014 hero line", hero_line),
("E \u2014 emoji inline", emoji_inline),
]
class Sample:
"""Minimal stand-in for the Qbt object, holding only what notifiers read."""
def __init__(self, **overrides):
self.config = {"pushover": {}}
self.total_torrents = STATS["total"]
self.torrent_hash_delete_list = ["a"] * STATS["removed"]
self.up_tor_counter = STATS["orphaned"]
self.ignored_counter = STATS["ignored"]
self.preme_tor_counter = STATS["premature"]
self.tracker_protected_tag = "ipt"
self.tracker_non_protected_tag = "public"
self.c = Counter({"ipt": STATS["protected"],
"public": STATS["non_protected"]})
self.delete_torrents = True
self.st = datetime.datetime(2026, 10, 27, 9, 54, 0)
self.et = datetime.datetime(2026, 10, 27, 9, 54, 12)
for key, value in overrides.items():
setattr(self, key, value)
def real_candidates():
"""Render the actual build_summary() output for each supported style."""
from qlogging import build_summary
out = []
for style in ("plain", "html"):
for emoji in (False, True):
title, body = build_summary(
Sample(), style=style, use_emoji=emoji, include_hostname=False
)
label = f"{style}" + (" + emoji" if emoji else "")
out.append((label, title, body))
return out
def card(name, title, body):
body_html = "
".join(body.split("\n"))
return f"""