From a6d20cabf8604ee68f4ade271ca76f62cff5fd15 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Tue, 16 Jun 2026 23:06:02 +0000 Subject: [PATCH] Add launcher icon generator --- .../res/drawable/ic_launcher_background.xml | 176 +--------- .../res/drawable/ic_launcher_foreground.xml | 62 ++-- scripts/generate_launcher_icon.py | 323 ++++++++++++++++++ 3 files changed, 378 insertions(+), 183 deletions(-) create mode 100644 scripts/generate_launcher_icon.py diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..d204a4e 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -4,167 +4,25 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> + + android:fillColor="#102B2F" + android:pathData="M0,0 L108,0 L108,108 L0,108 Z" /> + + + android:fillColor="#163A40" + android:pathData="M0,0h108v44c-18,6 -34,12 -50,24c-18,13 -33,29 -58,34z" /> + + + android:fillColor="#1E6D70" + android:pathData="M74,-8h42v124h-72z" + android:fillAlpha="0.55" /> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:fillColor="#8DE6D5" + android:pathData="M0,82c17,-10 29,-14 45,-14c24,0 37,12 63,5v35h-108z" + android:fillAlpha="0.16" /> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d1..1469602 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,44 @@ + - - - - - - - - + - \ No newline at end of file + android:fillColor="#000000" + 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:fillAlpha="0.33" /> + + + + + + + + + + + + + diff --git a/scripts/generate_launcher_icon.py b/scripts/generate_launcher_icon.py new file mode 100644 index 0000000..f0ef5ef --- /dev/null +++ b/scripts/generate_launcher_icon.py @@ -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 " " + + +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()