#!/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 " " def write_vector(destination: Path, paths: list[IconPath]) -> None: body = "\n".join( (f"\n \n" if item.comment else "\n") + android_path_xml(item) for item in paths ) destination.write_text( '\n' '' f"{body}\n" "\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'' 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'\n' " \n" ' \n' f' \n' " \n" ' \n' ' \n' f" {body}\n" " \n" ' \n' f" {body}\n" " \n" ' \n' ' \n' f" {body}\n" " \n" f' \n' ' full square\n' ' rounded mask\n' ' safe circle\n' "\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()