324 lines
10 KiB
Python
324 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the NavButtons launcher icon and an SVG browser preview.
|
|
|
|
Edit the constants below, run this script, then open:
|
|
|
|
build/launcher-icon-preview.svg
|
|
|
|
The Android VectorDrawable files are generated from the same geometry, so the
|
|
browser preview and app icon stay in sync.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from xml.sax.saxutils import escape
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
FOREGROUND = ROOT / "app/src/main/res/drawable/ic_launcher_foreground.xml"
|
|
BACKGROUND = ROOT / "app/src/main/res/drawable/ic_launcher_background.xml"
|
|
PREVIEW = ROOT / "build/launcher-icon-preview.svg"
|
|
|
|
VIEWPORT = 108
|
|
PREVIEW_SCALE = 4
|
|
|
|
# Horizontal white bar / pill.
|
|
BAR_X = 0
|
|
BAR_Y = 38
|
|
BAR_WIDTH = 108
|
|
BAR_HEIGHT = 32
|
|
BAR_RADIUS = 0
|
|
BAR_FILL = "#EFFFFB"
|
|
BAR_SHADOW_X = 1
|
|
BAR_SHADOW_Y = 2
|
|
BAR_SHADOW_FILL = "#000000"
|
|
BAR_SHADOW_ALPHA = 0.33
|
|
|
|
# Each symbol uses center x/y plus total width/height and stroke width.
|
|
# The Back icon is a hollow triangle, Home is a hollow circle, Recents is a
|
|
# hollow square. Coordinates are in the 108x108 adaptive-icon viewport.
|
|
ALL_SIZE = 20
|
|
|
|
BACK_X = 54-27
|
|
BACK_Y = 54
|
|
BACK_WIDTH = (ALL_SIZE**2 - (ALL_SIZE*0.5)**2)**0.5
|
|
BACK_HEIGHT = ALL_SIZE
|
|
BACK_STROKE = 6
|
|
BACK_X = BACK_X + (ALL_SIZE - BACK_WIDTH)*0.5
|
|
|
|
HOME_X = 54
|
|
HOME_Y = 54
|
|
HOME_WIDTH = ALL_SIZE
|
|
HOME_HEIGHT = ALL_SIZE
|
|
HOME_STROKE = 6
|
|
|
|
RECENTS_X = 54+27
|
|
RECENTS_Y = 54
|
|
RECENTS_WIDTH = ALL_SIZE
|
|
RECENTS_HEIGHT = ALL_SIZE
|
|
RECENTS_STROKE = 6
|
|
|
|
SYMBOL_COLOR = "#1B454A"
|
|
|
|
# Background.
|
|
BG_BASE = "#102B2F"
|
|
BG_CURVE = "#163A40"
|
|
BG_SLASH = "#1E6D70"
|
|
BG_SLASH_ALPHA = 0.55
|
|
BG_WAVE = "#8DE6D5"
|
|
BG_WAVE_ALPHA = 0.16
|
|
|
|
# Adaptive icon safe-zone preview. Android guarantees roughly the central
|
|
# 72x72 area survives masks, so this uses radius 36.
|
|
SAFE_RADIUS = 36
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Style:
|
|
fill: str = "#00000000"
|
|
fill_alpha: float | None = None
|
|
stroke: str | None = None
|
|
stroke_width: float | None = None
|
|
line_cap: str | None = None
|
|
line_join: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class IconPath:
|
|
data: str
|
|
style: Style
|
|
comment: str | None = None
|
|
|
|
|
|
def fmt(value: float) -> str:
|
|
return f"{value:g}"
|
|
|
|
|
|
def path(
|
|
data: str,
|
|
fill: str = "#00000000",
|
|
*,
|
|
fill_alpha: float | None = None,
|
|
stroke: str | None = None,
|
|
stroke_width: float | None = None,
|
|
line_cap: str | None = None,
|
|
line_join: str | None = None,
|
|
comment: str | None = None,
|
|
) -> IconPath:
|
|
return IconPath(data, Style(fill, fill_alpha, stroke, stroke_width, line_cap, line_join), comment)
|
|
|
|
|
|
def rect(x: float, y: float, width: float, height: float) -> str:
|
|
return (
|
|
f"M{fmt(x)},{fmt(y)} "
|
|
f"L{fmt(x + width)},{fmt(y)} "
|
|
f"L{fmt(x + width)},{fmt(y + height)} "
|
|
f"L{fmt(x)},{fmt(y + height)} Z"
|
|
)
|
|
|
|
|
|
def round_rect(x: float, y: float, width: float, height: float, radius: float) -> str:
|
|
radius = min(radius, width / 2, height / 2)
|
|
return (
|
|
f"M{fmt(x + radius)},{fmt(y)} "
|
|
f"L{fmt(x + width - radius)},{fmt(y)} "
|
|
f"A{fmt(radius)},{fmt(radius)} 0,0 1 {fmt(x + width)},{fmt(y + radius)} "
|
|
f"L{fmt(x + width)},{fmt(y + height - radius)} "
|
|
f"A{fmt(radius)},{fmt(radius)} 0,0 1 {fmt(x + width - radius)},{fmt(y + height)} "
|
|
f"L{fmt(x + radius)},{fmt(y + height)} "
|
|
f"A{fmt(radius)},{fmt(radius)} 0,0 1 {fmt(x)},{fmt(y + height - radius)} "
|
|
f"L{fmt(x)},{fmt(y + radius)} "
|
|
f"A{fmt(radius)},{fmt(radius)} 0,0 1 {fmt(x + radius)},{fmt(y)} Z"
|
|
)
|
|
|
|
|
|
def circle(cx: float, cy: float, width: float, height: float) -> str:
|
|
rx = width / 2
|
|
ry = height / 2
|
|
return (
|
|
f"M{fmt(cx - rx)},{fmt(cy)} "
|
|
f"A{fmt(rx)},{fmt(ry)} 0,1 0 {fmt(cx + rx)},{fmt(cy)} "
|
|
f"A{fmt(rx)},{fmt(ry)} 0,1 0 {fmt(cx - rx)},{fmt(cy)}"
|
|
)
|
|
|
|
|
|
def triangle_left(cx: float, cy: float, width: float, height: float) -> str:
|
|
left = cx - width / 2
|
|
right = cx + width / 2
|
|
top = cy - height / 2
|
|
bottom = cy + height / 2
|
|
return f"M{fmt(left)},{fmt(cy)} L{fmt(right)},{fmt(top)} L{fmt(right)},{fmt(bottom)} Z"
|
|
|
|
|
|
def stroke_style(width: float) -> dict[str, object]:
|
|
return {
|
|
"stroke": SYMBOL_COLOR,
|
|
"stroke_width": width,
|
|
"line_cap": "round",
|
|
"line_join": "round",
|
|
}
|
|
|
|
|
|
def background_paths() -> list[IconPath]:
|
|
return [
|
|
path(rect(0, 0, VIEWPORT, VIEWPORT), BG_BASE, comment="Base"),
|
|
path(
|
|
"M0,0h108v44c-18,6 -34,12 -50,24c-18,13 -33,29 -58,34z",
|
|
BG_CURVE,
|
|
comment="Soft curve",
|
|
),
|
|
path(
|
|
"M74,-8h42v124h-72z",
|
|
BG_SLASH,
|
|
fill_alpha=BG_SLASH_ALPHA,
|
|
comment="Diagonal accent",
|
|
),
|
|
path(
|
|
"M0,82c17,-10 29,-14 45,-14c24,0 37,12 63,5v35h-108z",
|
|
BG_WAVE,
|
|
fill_alpha=BG_WAVE_ALPHA,
|
|
comment="Bottom wave",
|
|
),
|
|
]
|
|
|
|
|
|
def foreground_paths() -> list[IconPath]:
|
|
return [
|
|
path(
|
|
round_rect(BAR_X + BAR_SHADOW_X, BAR_Y + BAR_SHADOW_Y, BAR_WIDTH, BAR_HEIGHT, BAR_RADIUS),
|
|
BAR_SHADOW_FILL,
|
|
fill_alpha=BAR_SHADOW_ALPHA,
|
|
comment="Bar shadow",
|
|
),
|
|
path(
|
|
round_rect(BAR_X, BAR_Y, BAR_WIDTH, BAR_HEIGHT, BAR_RADIUS),
|
|
BAR_FILL,
|
|
comment="Bar",
|
|
),
|
|
path(
|
|
triangle_left(BACK_X, BACK_Y, BACK_WIDTH, BACK_HEIGHT),
|
|
comment="Back",
|
|
**stroke_style(BACK_STROKE),
|
|
),
|
|
path(
|
|
circle(HOME_X, HOME_Y, HOME_WIDTH, HOME_HEIGHT),
|
|
comment="Home",
|
|
**stroke_style(HOME_STROKE),
|
|
),
|
|
path(
|
|
rect(
|
|
RECENTS_X - RECENTS_WIDTH / 2,
|
|
RECENTS_Y - RECENTS_HEIGHT / 2,
|
|
RECENTS_WIDTH,
|
|
RECENTS_HEIGHT,
|
|
),
|
|
comment="Recents",
|
|
**stroke_style(RECENTS_STROKE),
|
|
),
|
|
]
|
|
|
|
|
|
def android_path_xml(icon_path: IconPath) -> str:
|
|
attrs = [
|
|
f'android:fillColor="{icon_path.style.fill}"',
|
|
f'android:pathData="{escape(icon_path.data)}"',
|
|
]
|
|
if icon_path.style.fill_alpha is not None:
|
|
attrs.append(f'android:fillAlpha="{fmt(icon_path.style.fill_alpha)}"')
|
|
if icon_path.style.stroke:
|
|
attrs.append(f'android:strokeColor="{icon_path.style.stroke}"')
|
|
if icon_path.style.stroke_width is not None:
|
|
attrs.append(f'android:strokeWidth="{fmt(icon_path.style.stroke_width)}"')
|
|
if icon_path.style.line_cap:
|
|
attrs.append(f'android:strokeLineCap="{icon_path.style.line_cap}"')
|
|
if icon_path.style.line_join:
|
|
attrs.append(f'android:strokeLineJoin="{icon_path.style.line_join}"')
|
|
return " <path\n " + "\n ".join(attrs) + " />"
|
|
|
|
|
|
def write_vector(destination: Path, paths: list[IconPath]) -> None:
|
|
body = "\n".join(
|
|
(f"\n <!-- {escape(item.comment)} -->\n" if item.comment else "\n")
|
|
+ android_path_xml(item)
|
|
for item in paths
|
|
)
|
|
destination.write_text(
|
|
'<?xml version="1.0" encoding="utf-8"?>\n'
|
|
'<vector xmlns:android="http://schemas.android.com/apk/res/android"\n'
|
|
' android:width="108dp"\n'
|
|
' android:height="108dp"\n'
|
|
' android:viewportWidth="108"\n'
|
|
' android:viewportHeight="108">'
|
|
f"{body}\n"
|
|
"</vector>\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def svg_path_xml(icon_path: IconPath) -> str:
|
|
attrs = [f'd="{escape(icon_path.data)}"', f'fill="{icon_path.style.fill}"']
|
|
if icon_path.style.fill_alpha is not None:
|
|
attrs.append(f'fill-opacity="{fmt(icon_path.style.fill_alpha)}"')
|
|
if icon_path.style.stroke:
|
|
attrs.append(f'stroke="{icon_path.style.stroke}"')
|
|
if icon_path.style.stroke_width is not None:
|
|
attrs.append(f'stroke-width="{fmt(icon_path.style.stroke_width)}"')
|
|
if icon_path.style.line_cap:
|
|
attrs.append(f'stroke-linecap="{icon_path.style.line_cap}"')
|
|
if icon_path.style.line_join:
|
|
attrs.append(f'stroke-linejoin="{icon_path.style.line_join}"')
|
|
return f'<path {" ".join(attrs)} />'
|
|
|
|
|
|
def icon_svg_body() -> str:
|
|
return "\n ".join(svg_path_xml(item) for item in background_paths() + foreground_paths())
|
|
|
|
|
|
def write_preview() -> None:
|
|
PREVIEW.parent.mkdir(parents=True, exist_ok=True)
|
|
body = icon_svg_body()
|
|
canvas_width = 360
|
|
canvas_height = 136
|
|
preview_width = canvas_width * PREVIEW_SCALE
|
|
preview_height = canvas_height * PREVIEW_SCALE
|
|
PREVIEW.write_text(
|
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{preview_width}" height="{preview_height}" '
|
|
f'viewBox="0 0 {canvas_width} {canvas_height}">\n'
|
|
" <defs>\n"
|
|
' <clipPath id="rounded-mask"><rect x="126" y="14" width="108" height="108" rx="27" /></clipPath>\n'
|
|
f' <clipPath id="circle-mask"><circle cx="306" cy="68" r="{SAFE_RADIUS}" /></clipPath>\n'
|
|
" </defs>\n"
|
|
' <rect width="360" height="136" fill="#0B1113" />\n'
|
|
' <g transform="translate(0,14)">\n'
|
|
f" {body}\n"
|
|
" </g>\n"
|
|
' <g clip-path="url(#rounded-mask)" transform="translate(126,14)">\n'
|
|
f" {body}\n"
|
|
" </g>\n"
|
|
' <rect x="126" y="14" width="108" height="108" rx="27" fill="none" stroke="#88FFFFFF" stroke-width="0.8" />\n'
|
|
' <g clip-path="url(#circle-mask)" transform="translate(252,14)">\n'
|
|
f" {body}\n"
|
|
" </g>\n"
|
|
f' <circle cx="306" cy="68" r="{SAFE_RADIUS}" fill="none" stroke="#88FFFFFF" stroke-width="0.8" />\n'
|
|
' <text x="54" y="132" fill="#D7E7E3" font-size="5" text-anchor="middle">full square</text>\n'
|
|
' <text x="180" y="132" fill="#D7E7E3" font-size="5" text-anchor="middle">rounded mask</text>\n'
|
|
' <text x="306" y="132" fill="#D7E7E3" font-size="5" text-anchor="middle">safe circle</text>\n'
|
|
"</svg>\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
write_vector(BACKGROUND, background_paths())
|
|
write_vector(FOREGROUND, foreground_paths())
|
|
write_preview()
|
|
print(f"Wrote {BACKGROUND.relative_to(ROOT)}")
|
|
print(f"Wrote {FOREGROUND.relative_to(ROOT)}")
|
|
print(f"Wrote {PREVIEW.relative_to(ROOT)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|