"""Per-app glyph artwork for the shared icon family.
A glyph is a raw SVG fragment drawn in the 1024x1024 master canvas. It does not
need to be perfectly positioned: :mod:`python_pkg.app_icons.render` measures the
rendered ink and recentres the fragment, so every icon in the family sits at the
same optical height.
Fragments inherit ``fill="none"``, ``stroke``, ``stroke-width`` and round caps
from the wrapping group. A fragment that needs a filled silhouette instead
overrides those locally and uses :data:`~python_pkg.app_icons.style.ACCENT_MARKER`
for its colour.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
@dataclass(frozen=True)
class Glyph:
"""One app's artwork.
Parameters:
name (str): Stable identifier, used as the ``--glyph`` CLI value.
description (str): What the glyph depicts, for ``--list`` output.
body (str): SVG fragment drawn in the 1024x1024 master canvas.
"""
name: str
description: str
body: str
# A cloud silhouette outline with a download arrow underneath: a client that
# pulls files off a remote dufs server.
_CLOUD_DOWN = """\
"""
# Bar, two plates and two collars. Plate spacing is well above the minimum
# negative space so the bar does not fill in at launcher size.
_BARBELL = """\
"""
# Plain clock face. Deliberately no bell "ears": at launcher size they read as
# noise rather than as an alarm clock.
_CLOCK = """\
"""
# Shield outline guarding filled cutlery. The fork is paired with a knife on
# purpose: a lone three-tine fork reads as a trident at small sizes.
_SHIELD_CUTLERY = """\
"""
# Two ticked-off list rows. More specific to a notes/todo app than a lone
# checkmark, and still legible at 48dp.
_CHECKLIST = """\
"""
# A branching decision tree: one node splitting into two. Reads as "decompose
# a problem into parts", the move every one of the 25 thinking tools makes in
# some form -- an issue tree most literally, but also a fishbone's ribs and a
# 2x2's quadrants.
#
# Nodes are hollow circles rather than filled dots: at 48dp a filled dot and
# the 72px stroke merge into a blob, while the ring keeps visible negative
# space (r=78 leaves 156-72=84px of interior).
#
# Two levels of branching were tried first (7 nodes) and rejected: the
# generator's safe-box check flagged it as overflowing by ~170px, and at icon
# size the doubled connectors closed up into a solid mass. One clean split
# survives the launcher mask and still reads as a tree rather than a Y-fork,
# because the square shoulders and vertical drops keep the hierarchy legible.
_DECISION_TREE = """\
"""
# Two interlocking stadium (pill-ring) outlines, both rotated -45 deg and
# offset along the anti-diagonal so their strokes overlap in the middle --
# reads as both "habit-stacking" (linked items) and "don't break the chain".
# Ring height is tall enough that the 72px stroke leaves a genuinely hollow
# interior (height - 2*stroke-width well above MIN_NEGATIVE_SPACE); a
# shorter ring's stroke fills the interior solid and reads as a blob.
_CHAIN_LINK = """\
"""
# An isometric storage carton: hexagonal silhouette plus the three edges that
# meet at the near-top corner (one down the front, two up the top face).
#
# A front-on rectangle with a lid seam was tried first and rejected — a seam
# plus a divider reads as a browser window or a table header, not a box. The
# isometric form has no such collision anywhere else in the family, and it is
# the shape people already recognise as "a package of stuff".
#
# Geometry: a regular hexagon, centre (512,512), circumradius 240, pointy top
# and bottom. Opposite faces sit 208px apart, leaving 136px of hollow once the
# 72px stroke is drawn — comfortably past MIN_NEGATIVE_SPACE, so no face fills
# in at 48dp. Rendered ink is 488x552, inside SAFE_BOX on both axes.
_STORAGE_BOX = """\
"""
# A till receipt with a torn bottom edge and a perforation down the middle:
# one bill, two shares.
#
# Filled rather than stroked, which is what makes the tear possible at all. A
# stroked receipt cannot have a fine ragged edge: at the family's 72px stroke,
# a zigzag needs ~200px pitch to keep adjacent up-strokes MIN_NEGATIVE_SPACE
# apart, and two teeth that coarse read as a ribbon or a pair of bookmarks,
# not as a torn receipt. A filled silhouette is not bound by that rule, so the
# teeth can be small enough to read as a real tear — the "prefer a filled
# silhouette when a stroked outline reads ambiguously" case.
#
# The split is a perforation, not a divider. A full-height divider cuts the
# silhouette into two separate shapes and the receipt read is lost (the same
# bookmarks failure); a dotted tear line says "split here" while leaving one
# intact receipt. Drawn with fill-rule="evenodd" so the dots are true holes,
# not charcoal-painted ones — icon_foreground and icon_monochrome are rendered
# on transparency, where a painted hole would show as a blob and, on the
# monochrome layer, in the wrong colour entirely.
#
# Geometry: body x 312..712, y 282..752, six teeth 46px deep off a root at
# y=706. Four holes of r=30 on the centreline at y=350/446/542/638. Every gap
# is at the family budget: 36px of accent between holes (96px pitch, 60px
# diameter), 38px above the first and below the last. Ink is 400x470, inside
# SAFE_BOX on both axes.
_RECEIPT_SPLIT = """\
"""
# A dip-pen nib: the character sheet is a thing you write on, and the game is
# set in the Romantic era, so the nib carries both the app and the period.
#
# Filled rather than stroked. A stroked almond outline plus a stroked vent hole
# leaves only ~20px of charcoal between the two strokes at the family's 72px
# weight — under MIN_NEGATIVE_SPACE, so the nib fills in solid at 48dp. Filled,
# the vent and the slit become true holes and the budget is spent on gaps
# instead of on stroke.
#
# A d20 was the obvious alternative and was rejected: this system rolls k6, and
# a 20-sided outline is a mess of short strokes at launcher size. A front-on
# die with pips collides with `storage-box` (both read as "boxy thing with
# marks"). Nothing else in the family is a tall pointed shape.
#
# The top edge is flat, not pointed. A symmetric almond — pointed at both ends
# — with a round hole in the upper half is a map pin, which is exactly what the
# first draft rendered as. The flat shoulder is where a real nib clips into the
# holder, and it is what stops the map-pin read.
#
# Geometry: shoulder (420,312)-(604,312), waist bowing out to ~x=400/624 around
# y=456, converging to the tip at (512,812) — ink 224x500, inside SAFE_BOX on
# both axes. Vent circle r=40 at (512,470) clears the outline by ~61px; the
# slit stops at y=700, leaving ~41px to the outline near the tip and 70px to
# the vent above. Every gap is over the 36px budget. fill-rule="evenodd" keeps
# both holes true holes — icon_foreground and icon_monochrome render on
# transparency, where a painted hole would show as a blob and in the wrong
# colour.
_QUILL_NIB = """\
"""
# A blacksmith's anvil: OctoForge is a GitHub client, and the forge is the
# name. Filled rather than stroked, for the same reason as the nib — a stroked
# anvil outline is a long thin horn plus a narrow waist, and at the family's
# 72px weight the waist closes up solid at 48dp.
#
# The obvious alternatives were rejected: an octopus (the Octocat) is both
# GitHub trade dress and a tangle of thin curves that blurs at launcher size,
# and a hammer alone collides with nothing in the family but reads as a generic
# tool rather than a forge. The anvil silhouette is unmistakable and shares no
# shape language with the other nine glyphs.
#
# Geometry: the body is a face slab (x=336..768, y=316..420), a waist
# (x=444..596, y=420..592) and a flared foot (x=304..716, y=592..708) — total
# ink 512x392 including the horn, inside SAFE_BOX once the renderer scales it.
#
# The horn is what makes this an anvil rather than an I-beam, so it is a
# separate wedge projecting left off the face, not a rounded corner on it: it
# leaves the slab at full 104px depth (x=336) and tapers to a 40px blunt tip
# at x=256, dropping the underside from y=420 to y=392 on the way. A sharp
# point would disappear at 48dp; 40px is above MIN_NEGATIVE_SPACE so it
# survives. The first two drafts folded the taper into the slab's left end and
# both read as a bump.
_ANVIL = """\
"""
# Three rising bars with a plotted point above the tallest -- a day-by-day log
# that adds up to a trend, which is what a life tracker is. The bars are
# strokes rather than filled rects so the glyph keeps one stroke weight
# throughout; at a 168px pitch the 72px strokes leave 96px gaps, well above
# MIN_NEGATIVE_SPACE. The marker sits on its own row clear of the bar caps,
# and is hollow so it reads as a data point rather than a full stop.
_TRACK_BARS = """\
"""
GLYPHS: Final[dict[str, Glyph]] = {
glyph.name: glyph
for glyph in (
Glyph("cloud-down", "Cloud with a download arrow", _CLOUD_DOWN),
Glyph("barbell", "Barbell with plates and collars", _BARBELL),
Glyph("clock", "Clock face with hands", _CLOCK),
Glyph("shield-cutlery", "Shield guarding a fork and knife", _SHIELD_CUTLERY),
Glyph("checklist", "Two ticked-off list rows", _CHECKLIST),
Glyph("chain-link", "Two interlocking chain links", _CHAIN_LINK),
Glyph("storage-box", "Isometric storage carton", _STORAGE_BOX),
Glyph("receipt-split", "Torn receipt with a perforated split", _RECEIPT_SPLIT),
Glyph("quill-nib", "Dip-pen nib with vent hole and slit", _QUILL_NIB),
Glyph("anvil", "Blacksmith's anvil with horn and flared foot", _ANVIL),
Glyph("track-bars", "Rising log bars with a plotted trend point", _TRACK_BARS),
Glyph(
"decision-tree",
"Node branching into two levels of child nodes",
_DECISION_TREE,
),
)
}
def get_glyph(name: str) -> Glyph:
"""Look up a glyph by name.
Parameters:
name (str): Glyph identifier, e.g. ``"barbell"``.
Returns:
Glyph: The matching glyph.
Raises:
KeyError: If no glyph with that name exists. The message lists the
available names, since this is usually a CLI typo.
"""
try:
return GLYPHS[name]
except KeyError:
available = ", ".join(sorted(GLYPHS))
msg = f"unknown glyph {name!r}; available: {available}"
raise KeyError(msg) from None