Add launcher icon generator

This commit is contained in:
ajp_anton
2026-06-16 23:06:02 +00:00
parent 7239c28284
commit a6d20cabf8
3 changed files with 378 additions and 183 deletions
@@ -4,167 +4,25 @@
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Base -->
<path <path
android:fillColor="#3DDC84" android:fillColor="#102B2F"
android:pathData="M0,0h108v108h-108z" /> android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
<!-- Soft curve -->
<path <path
android:fillColor="#00000000" android:fillColor="#163A40"
android:pathData="M9,0L9,108" android:pathData="M0,0h108v44c-18,6 -34,12 -50,24c-18,13 -33,29 -58,34z" />
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" /> <!-- Diagonal accent -->
<path <path
android:fillColor="#00000000" android:fillColor="#1E6D70"
android:pathData="M19,0L19,108" android:pathData="M74,-8h42v124h-72z"
android:strokeWidth="0.8" android:fillAlpha="0.55" />
android:strokeColor="#33FFFFFF" />
<!-- Bottom wave -->
<path <path
android:fillColor="#00000000" android:fillColor="#8DE6D5"
android:pathData="M29,0L29,108" android:pathData="M0,82c17,-10 29,-14 45,-14c24,0 37,12 63,5v35h-108z"
android:strokeWidth="0.8" android:fillAlpha="0.16" />
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector> </vector>
@@ -1,30 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> <!-- Bar shadow -->
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path <path
android:fillColor="#FFFFFF" android:fillColor="#000000"
android:fillType="nonZero" android:pathData="M1,40 L109,40 A0,0 0,0 1 109,40 L109,72 A0,0 0,0 1 109,72 L1,72 A0,0 0,0 1 1,72 L1,40 A0,0 0,0 1 1,40 Z"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" android:fillAlpha="0.33" />
android:strokeWidth="1"
android:strokeColor="#00000000" /> <!-- Bar -->
</vector> <path
android:fillColor="#EFFFFB"
android:pathData="M0,38 L108,38 A0,0 0,0 1 108,38 L108,70 A0,0 0,0 1 108,70 L0,70 A0,0 0,0 1 0,70 L0,38 A0,0 0,0 1 0,38 Z" />
<!-- Back -->
<path
android:fillColor="#00000000"
android:pathData="M19.6795,54 L37,44 L37,64 Z"
android:strokeColor="#1B454A"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!-- Home -->
<path
android:fillColor="#00000000"
android:pathData="M44,54 A10,10 0,1 0 64,54 A10,10 0,1 0 44,54"
android:strokeColor="#1B454A"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!-- Recents -->
<path
android:fillColor="#00000000"
android:pathData="M71,44 L91,44 L91,64 L71,64 Z"
android:strokeColor="#1B454A"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
+323
View File
@@ -0,0 +1,323 @@
#!/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()