← poker-tutor
#!/usr/bin/env python3
"""Draws the app icon, rather than shipping a binary somebody has to open a design tool to change.
Same reasoning as make-sounds.py: the repo stays self-contained and the icon is editable by anybody
who can read Python. Run it and the asset catalogue is rewritten in place.
The mark is a spade in The (Digital) Ad Lab's colours — warm charcoal and olive — because that is
whose app it is, and because a green spade on near-black stays legible at the size an icon is
actually seen at. No text: "Poker Tutor" is unreadable at 40 points and the name sits under the
icon on the home screen anyway.
python3 Scripts/make-icon.py # Poker Tutor: the spade
python3 Scripts/make-icon.py --variant cash # Poker Tutor Cash: the spade with a chip stack
The cash app carries a small stack of chips beside the spade, so the two apps can be told apart
on a home screen where they sit side by side.
"""
from pathlib import Path
import json
import math
import sys
from PIL import Image, ImageDraw
# The (Digital) Ad Lab's palette, taken from the site's stylesheet.
CHARCOAL = (35, 33, 28)
CHARCOAL_LIGHT = (48, 45, 38)
OLIVE = (140, 176, 74)
OLIVE_BRIGHT = (163, 199, 106)
CREAM = (243, 239, 230)
SIZE = 1024
ROOT = Path(__file__).resolve().parent.parent
VARIANT = "cash" if "--variant" in sys.argv and sys.argv[sys.argv.index("--variant") + 1] == "cash" else "tournament"
PHONE_DIR = "PokerTutorCashPhone" if VARIANT == "cash" else "PokerTutorPhone"
ICONSET = ROOT / "App" / PHONE_DIR / "Assets.xcassets" / "AppIcon.appiconset"
FULL_SIZE_COPY = ROOT / "App" / ("app-icon-cash-1024.png" if VARIANT == "cash" else "app-icon-1024.png")
def spade(draw: ImageDraw.ImageDraw, cx: float, cy: float, height: float, fill) -> None:
"""A spade, built from two circles, a triangle and a stem.
Drawn rather than taken from a font: a font glyph would tie the icon to whatever the system
happens to have installed, and the proportions of the character in most faces are too thin to
read at 40 points.
"""
w = height * 0.80
top = cy - height / 2
# The widest point, where the triangle's base sits and both shoulders are centred.
waist = top + height * 0.60
r = w / 4
# Triangle first, base at the waist.
draw.polygon([(cx, top), (cx - w / 2, waist), (cx + w / 2, waist)], fill=fill)
# The shoulders are centred ON that base line, a quarter-width either side of the middle, with
# a radius of the same quarter-width. That is the whole trick: they then reach exactly as wide
# as the triangle and merge into it without the notch a higher centre leaves behind — which is
# what the first two attempts had, and why they read as a tree.
for side in (-1, 1):
centre = cx + side * r
draw.ellipse([centre - r, waist - r, centre + r, waist + r], fill=fill)
# The stem: a narrow wedge that flares at the foot, the way a card suit does.
foot = top + height
half = w * 0.05
flare = w * 0.23
draw.polygon([(cx - half, waist), (cx + half, waist),
(cx + flare, foot), (cx - flare, foot)], fill=fill)
def chip_stack(draw: ImageDraw.ImageDraw, cx: float, cy: float, width: float, fill, edge) -> None:
"""Three chips stacked, seen from just above: the cash app's mark beside the spade.
Each chip is a flattened ellipse with a lighter rim, stacked with a little of each rim showing,
the way a stack reads on a felt. Drawn, like the spade, so nothing here depends on a font.
"""
rx = width / 2
ry = width * 0.19
lift = width * 0.16
for i in range(3):
y = cy - i * lift
# The rim, then the face inset into it.
draw.ellipse([cx - rx, y - ry, cx + rx, y + ry], fill=edge)
draw.ellipse([cx - rx * 0.86, y - ry * 0.72, cx + rx * 0.86, y + ry * 0.72], fill=fill)
def build() -> Image.Image:
# Supersampled, then reduced — the diagonals in the spade alias badly otherwise, and an icon is
# judged at 40 points where every rough edge shows.
scale = 4
n = SIZE * scale
img = Image.new("RGB", (n, n), CHARCOAL)
draw = ImageDraw.Draw(img)
# A soft lift towards the top left, so the mark does not sit on a flat rectangle.
for i in range(n, 0, -max(1, n // 220)):
t = i / n
shade = tuple(int(CHARCOAL[c] + (CHARCOAL_LIGHT[c] - CHARCOAL[c]) * (1 - t) ** 1.6)
for c in range(3))
draw.ellipse([-n * 0.35, -n * 0.45, i * 1.15, i * 1.05], fill=shade)
if VARIANT == "cash":
# The spade moves up and left to make room for the stack, which sits low on the right.
spade(draw, n * 0.42, n * 0.42, n * 0.56, OLIVE)
chip_stack(draw, n * 0.73, n * 0.70, n * 0.30, OLIVE_BRIGHT, CREAM)
else:
spade(draw, n / 2, n * 0.455, n * 0.62, OLIVE)
# The rule under the mark, which is the site's own device — the olive bar under the wordmark.
bar_w, bar_h = n * 0.34, n * 0.035
draw.rounded_rectangle([n / 2 - bar_w / 2, n * 0.815, n / 2 + bar_w / 2, n * 0.815 + bar_h],
radius=bar_h / 2, fill=OLIVE_BRIGHT)
return img.resize((SIZE, SIZE), Image.LANCZOS)
def main() -> None:
ICONSET.mkdir(parents=True, exist_ok=True)
icon = build()
icon.save(ICONSET / "AppIcon.png")
# Xcode 14 and later take a single 1024 square and derive the rest.
contents = {
"images": [{"filename": "AppIcon.png", "idiom": "universal",
"platform": "ios", "size": "1024x1024"}],
"info": {"author": "xcode", "version": 1},
}
(ICONSET / "Contents.json").write_text(json.dumps(contents, indent=2) + "\n")
catalogue = ICONSET.parent / "Contents.json"
if not catalogue.exists():
catalogue.write_text(json.dumps({"info": {"author": "xcode", "version": 1}}, indent=2) + "\n")
# A copy for the store listing and the website, where the icon is shown at full size.
icon.save(FULL_SIZE_COPY)
print(f"wrote {ICONSET/'AppIcon.png'} and {FULL_SIZE_COPY.relative_to(ROOT)}")
if __name__ == "__main__":
main()