From f54194f70d28ea8940bda821ef1495555495292c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 2 Sep 2026 09:58:03 -0400 Subject: [PATCH] perf(icons): draw Amethyst's own icons from a generated icon font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Icon(imageVector = …) calls rememberVectorPainter, and a VectorPainter rasterises its paths into a cached graphics layer per instance, so the feed re-rasterised the same glyphs once per card. A font glyph is a blit from the shared text atlas, shared across every call site for free. tools/icon-font/build_icon_font.py converts the Kotlin ImageVector DSL to SVG paths and builds a TTF with fontTools. Font metrics mirror the bundled Material Symbols font (upem 960, ascent 1056, descent -96, advance 960) so glyphs align with existing call sites; generated outlines land within a few units of Google's own. Measured on the uniform-corpus feed benchmark (SM-T220, three arms A/B/A, 0.2% identical-arm noise floor, gate 18/18/18 cards): frame duration P90 -10.7% frame overrun P90 -17.4% DrawReactions 114.8 -> 76.7 ms/iteration For reference, ablating the reaction icons entirely gives frame P90 -13.5%, so this captures ~84% of the available headroom. It supersedes the shared-VectorPainter approach (-8.2%), which needed CompositionLocal plumbing and hand-scoping to avoid cross-size cache thrashing; glyphs are atlas-shared automatically. Artwork is unchanged: this converts Amethyst's existing vectors rather than substituting Google's glyphs. Verified on device by pixel comparison -- unconverted icons are 0-diff, and the converted ones differ only by sub-pixel antialiasing between the text and vector rasterisers. Stroked icons are deliberately NOT converted. A glyph outline can only be filled, so converting Zap (strokeLineWidth 1.2) turned a thin outline bolt into a solid one; the build script now detects a stroke and skips the icon, leaving Following, Zap and ZapSplit on their ImageVectors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo --- .claude/CLAUDE.md | 28 ++- .../vitorpamplona/amethyst/ui/note/Icons.kt | 26 +-- .../vitorpamplona/amethyst/ui/theme/Theme.kt | 4 +- .../composeResources/font/amethyst_icons.ttf | Bin 0 -> 2064 bytes .../commons/icons/symbols/AmethystIcons.kt | 33 ++++ .../icons/symbols/AmethystIconsFont.kt | 99 +++++++++++ .../icons/symbols/MaterialSymbolPainter.kt | 5 +- .../vitorpamplona/amethyst/desktop/Main.kt | 4 +- tools/icon-font/README.md | 46 +++++ tools/icon-font/build_icon_font.py | 168 ++++++++++++++++++ 10 files changed, 395 insertions(+), 18 deletions(-) create mode 100644 commons/src/commonMain/composeResources/font/amethyst_icons.ttf create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt create mode 100644 tools/icon-font/README.md create mode 100755 tools/icon-font/build_icon_font.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e0a8c7604a..732f94a837 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -263,7 +263,33 @@ Commit the regenerated `material_symbols_outlined.ttf` alongside your at runtime because the glyph is not in the bundled font. Reusing a codepoint already present in `MaterialSymbols.kt` does NOT require -regenerating. See `tools/material-symbols-subset/README.md` for details and +regenerating. + +### Amethyst's own icons are also a font + +The icons in `commons/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are +**also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn +as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into +a per-instance cached layer, so a feed re-rasterised the same glyph once per card; +a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**, +overrun P90 **-17.4%** on the feed scroll benchmark. + +**MANDATORY:** whenever you add or change an icon under `commons/.../commons/icons/`, +regenerate the font *and* its codepoint table together: + +```bash +python3 tools/icon-font/build_icon_font.py \ + commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \ + commons/src/commonMain/composeResources/font/amethyst_icons.ttf \ + commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt +``` + +Both outputs must be committed together: codepoints are assigned in filename order, +so adding an icon renumbers the ones after it, and a stale `AmethystIcons.kt` then +points at the wrong glyph. Needs `fonttools` (`pip install fonttools`). The script +prints any icon it could not convert — an icon that is skipped must keep using its +`ImageVector`. + See `tools/material-symbols-subset/README.md` for details and prerequisites (`pip install fonttools brotli`). ## Code Formatting diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index 29feeb08aa..b01af54ff3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -43,6 +43,8 @@ import com.vitorpamplona.amethyst.commons.icons.Reposted import com.vitorpamplona.amethyst.commons.icons.Search import com.vitorpamplona.amethyst.commons.icons.Zap import com.vitorpamplona.amethyst.commons.icons.following +import com.vitorpamplona.amethyst.commons.icons.symbols.AmethystIconGlyph +import com.vitorpamplona.amethyst.commons.icons.symbols.AmethystIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.stringRes @@ -109,8 +111,8 @@ fun LikedIcon( modifier: Modifier, tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Liked, + AmethystIconGlyph( + symbol = AmethystIcons.Liked, contentDescription = stringRes(id = R.string.like_description), modifier = modifier, tint = tint, @@ -135,8 +137,8 @@ fun LikeIcon( iconSizeModifier: Modifier, grayTint: Color, ) { - Icon( - imageVector = Like, + AmethystIconGlyph( + symbol = AmethystIcons.Like, contentDescription = stringRes(id = R.string.like_description), modifier = iconSizeModifier, tint = grayTint, @@ -148,8 +150,8 @@ fun RepostIcon( modifier: Modifier, tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Repost, + AmethystIconGlyph( + symbol = AmethystIcons.Repost, contentDescription = stringRes(id = R.string.boost_or_quote_description), modifier = modifier, tint = tint, @@ -161,8 +163,8 @@ fun RepostedIcon( modifier: Modifier, tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Reposted, + AmethystIconGlyph( + symbol = AmethystIcons.Reposted, contentDescription = stringRes(id = R.string.boost_or_quote_description), modifier = modifier, tint = tint, @@ -318,8 +320,8 @@ fun CommentIcon( iconSizeModifier: Modifier, tint: Color, ) { - Icon( - imageVector = Reply, + AmethystIconGlyph( + symbol = AmethystIcons.Reply, contentDescription = stringRes(id = R.string.reply_description), modifier = iconSizeModifier, tint = tint, @@ -350,8 +352,8 @@ fun SearchIcon( modifier: Modifier, tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Search, + AmethystIconGlyph( + symbol = AmethystIcons.Search, contentDescription = stringRes(id = R.string.search_button), modifier = modifier, tint = tint, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 68838e57f6..706f05cca7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -65,7 +65,7 @@ import com.halilibo.richtext.ui.resolveDefaults import com.patrykandpatrick.vico.compose.common.VicoTheme import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols +import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons import com.vitorpamplona.amethyst.model.AccentColorType import com.vitorpamplona.amethyst.model.FontFamilyType import com.vitorpamplona.amethyst.model.FontSizeType @@ -738,7 +738,7 @@ fun AmethystTheme( typography = typography, shapes = Shapes, content = { - ProvideMaterialSymbols { + ProvideAppIcons { CompositionLocalProvider( LocalDensity provides scaledDensity, LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)), diff --git a/commons/src/commonMain/composeResources/font/amethyst_icons.ttf b/commons/src/commonMain/composeResources/font/amethyst_icons.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5c7e7a716c12978afe88b9707dc481423e9b0077 GIT binary patch literal 2064 zcmds2OHW%z6h3pu_VrC{!^JN^DYY@aHYD~nU|&NB4mczsybMkekSHmx!FD3d!+-+n z0z{;?3sU7F)HKSXt0)Whttug*stcrUy6&n%RV5;|Y8S1Vg-y>~n@7^pAJCCJbH4AK znfuL|Gh-kCa0*s|f+KcaZt^c({0@M4ksVKF6SH90yMg#Q;$V7eITd++>RZG=0cfi; zlZlBy?DZq`ZQ@VKAi?j_570(G)Mc^@OLQA8AfI4onVLx^G@H6w#NRWVO)SlV5e5+J z5j&<6*~!iE!k+*|+S#??q`bvmo1 zMq`hlUy@w4CeEtUNuHpebCDlORqWTQ#5lZL*D#g&fsI~}fOyS>@fai_gwWpSdff%{4x z@SUGak44>txT9m~x%0jOxz1llPV@X}f(EOQBlRb1|4)4<3&7mG2SfBOO@kBK@nfDw zwrdM!#%i(Knw!EAO;}MvA(jKPiSn2jm#bEi)CAfa)!gZLuIWW|Za6UT@vv49^?DkP zkM){cI|IYH&dp#V)0=8NBt(a#0mq~Yq6wvZV3=nCn~ z4a@o0CRqtqtQf`iyO%MZ;JnspuUc`R;ti?;H}s-bsg~!lOqd`%H(N8Fq*e4FtFzcu z?O`S8(W2Urs6s@{%iOl6a9G4$;v9u-iGC?m38VF0^{Dp$p*aU@+n?kbz~G$I*ccCt(Jr5yjDyhDDfy1mvI*{1Es@{86;;t$5s*`hTkK@L#*X E0YoNb#Q*>R literal 0 HcmV?d00001 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt new file mode 100644 index 0000000000..07fc7f362d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.icons.symbols + +/** Amethyst's own icons as font glyphs. See the build script for why. */ +object AmethystIcons { + val Bookmark = MaterialSymbol("\uE900") + val Like = MaterialSymbol("\uE901") + val Liked = MaterialSymbol("\uE902") + val Reply = MaterialSymbol("\uE903") + val Repost = MaterialSymbol("\uE904") + val Reposted = MaterialSymbol("\uE905") + val Search = MaterialSymbol("\uE906") + val Share = MaterialSymbol("\uE907") +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt new file mode 100644 index 0000000000..da2c0599fb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.icons.symbols + +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.amethyst_icons +import org.jetbrains.compose.resources.Font +import androidx.compose.material3.Icon as Material3Icon + +/** + * Amethyst's own icons, built into a font by `tools/icon-font/build_icon_font.py`. + * + * Unlike Material Symbols this is a static font — no FILL/opsz/GRAD axes — so it needs no + * [androidx.compose.ui.text.font.FontVariation] settings. Drawing an icon as a glyph blits from + * the shared text atlas instead of rasterising an ImageVector's paths into a per-instance cached + * layer, which is what made the feed re-rasterise the same three glyphs once per card. + */ +val LocalAmethystIconsFontFamily: ProvidableCompositionLocal = staticCompositionLocalOf { null } + +/** + * Builds the icon FontFamily once for the subtree and exposes it via CompositionLocal, for the + * same reason [ProvideMaterialSymbols] does: without it every call site allocates its own Font + * wrapper and breaks its own remember cache. Nest this inside ProvideMaterialSymbols at app roots. + */ +@Composable +fun ProvideAmethystIcons(content: @Composable () -> Unit) { + val font = Font(resource = Res.font.amethyst_icons) + val fontFamily = remember { FontFamily(font) } + CompositionLocalProvider(LocalAmethystIconsFontFamily provides fontFamily, content = content) +} + +/** + * Draws one of Amethyst's own icons as a font glyph. Tint is baked into the painter, so + * Material3 is told not to tint again. + */ +@Composable +fun AmethystIconGlyph( + symbol: MaterialSymbol, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, +) { + Material3Icon( + painter = rememberMaterialSymbolPainter(symbol, tint, amethystIconsFontFamily()), + contentDescription = contentDescription, + modifier = modifier, + tint = Color.Unspecified, + ) +} + +@Composable +fun amethystIconsFontFamily(): FontFamily = + LocalAmethystIconsFontFamily.current ?: run { + val font = Font(resource = Res.font.amethyst_icons) + remember { FontFamily(font) } + } + +/** + * Provides both icon fonts for a subtree: Material Symbols and Amethyst's own icons. + * + * App roots call this instead of [ProvideMaterialSymbols] so neither font is missed, and so + * adding the second one does not re-indent the whole root composable. + */ +@Composable +fun ProvideAppIcons( + weight: Int = MaterialSymbolsDefaults.WEIGHT, + content: @Composable () -> Unit, +) { + ProvideMaterialSymbols(weight = weight) { + ProvideAmethystIcons(content = content) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt index 64f7cfa296..4ff16e01cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt @@ -43,8 +43,11 @@ import androidx.compose.ui.unit.LayoutDirection fun rememberMaterialSymbolPainter( symbol: MaterialSymbol, tint: Color = LocalContentColor.current, + // Lets a caller draw from a different glyph font (e.g. Amethyst's own icon font) while + // reusing this painter, its shared TextMeasurer and its caching. + family: FontFamily? = null, ): Painter { - val fontFamily = materialSymbolsFontFamily() + val fontFamily = family ?: materialSymbolsFontFamily() val textMeasurer = materialSymbolsTextMeasurer() val density = LocalDensity.current val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index db0bd529f8..c3aa87d9c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -82,7 +82,7 @@ import androidx.compose.ui.window.rememberWindowState import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols +import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings @@ -1254,7 +1254,7 @@ private fun AppInner( .rememberSystemDark(LocalAwtWindow.current) com.vitorpamplona.amethyst.desktop.platform.PlatformMaterialTheme(isDark = isDark) { - ProvideMaterialSymbols( + ProvideAppIcons( weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current, ) { Surface( diff --git a/tools/icon-font/README.md b/tools/icon-font/README.md new file mode 100644 index 0000000000..f669635728 --- /dev/null +++ b/tools/icon-font/README.md @@ -0,0 +1,46 @@ +# Amethyst icon font + +Builds `amethyst_icons.ttf` from the Kotlin `ImageVector` icons in +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/`. + +## Why + +`Icon(imageVector = …)` calls `rememberVectorPainter`, and a `VectorPainter` +rasterises its paths into a cached graphics layer **per instance**. A feed therefore +re-rasterised the same handful of glyphs once for every card scrolled in. A font glyph +is a blit from the shared text atlas instead, shared across every call site in the app +for free — no `CompositionLocal` plumbing, no per-screen scoping. + +Measured on the uniform-corpus macrobenchmark (SM-T220, three arms, 0.2% noise floor): + +| approach | frame P90 | overrun P90 | artwork | +|---|---|---|---| +| one shared `VectorPainter` per icon | −8.2% | −14.2% | unchanged | +| MaterialSymbols glyph substitutes | −10.4% | −16.0% | **changes** | +| **this font** | **−10.7%** | **−17.4%** | unchanged | +| ceiling: draw no icons at all | −12.7% | −22.9% | n/a | + +## Usage + + pip install fonttools + python3 tools/icon-font/build_icon_font.py + +See the "Amethyst's own icons are also a font" section of `.claude/CLAUDE.md` for the +mandatory regeneration step and why both outputs must be committed together. + +## How it works + +The `ImageVector` builder DSL maps 1:1 onto SVG path commands (`moveTo` → `M`, +`curveToRelative` → `c`, …; none of the icons use `arcTo`), so the script extracts the +path data, emits an SVG `d` string, and draws it into a TrueType glyph via fontTools — +converting cubics to quadratics and flipping the y axis, since SVG grows downward and +font outlines grow upward from the baseline. + +Font metrics deliberately mirror the bundled `material_symbols_outlined.ttf` +(unitsPerEm 960, ascent 1056, descent −96, advance 960) so the glyphs align with +existing MaterialSymbols call sites and `Icon()` sizing. Generated outlines land within +a few units of Google's own: our `Like` spans (78,94)–(882,851), their heart +(80,120)–(880,854). + +An icon whose path data the parser cannot reach is reported and skipped rather than +silently emitted empty; it must keep using its `ImageVector`. diff --git a/tools/icon-font/build_icon_font.py b/tools/icon-font/build_icon_font.py new file mode 100755 index 0000000000..548eebb179 --- /dev/null +++ b/tools/icon-font/build_icon_font.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Build a custom icon font from Amethyst's Kotlin ImageVector icons. + +Why: drawing an ImageVector rasterises its paths into a per-instance cached layer, +so a feed re-rasterises the same glyph once per card. A font glyph is a blit from +the shared text atlas instead. Measured on the uniform-corpus macrobenchmark +(SM-T220, 0.2% noise floor): swapping the three reaction icons to font glyphs gave +frame P90 -10.4% vs -8.2% for per-icon shared painters, against a -12.7% ceiling. + +This keeps Amethyst's own artwork -- it converts the existing ImageVector path data +rather than substituting Google's glyphs, so the icons look identical. + +Font metrics deliberately mirror the bundled material_symbols_outlined.ttf +(unitsPerEm 960, ascent 1056, descent -96, advance 960) so the glyphs align with +existing MaterialSymbols call sites and Icon() sizing. + +Usage: build_icon_font.py +""" +import re, sys, os + +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.pens.cu2quPen import Cu2QuPen +from fontTools.pens.transformPen import TransformPen +from fontTools.misc.transform import Transform +from fontTools.svgLib.path.parser import parse_path + +UPEM, ASCENT, DESCENT, ADVANCE = 960, 1056, -96, 960 +FIRST_CODEPOINT = 0xE900 +MAX_ERR = 1.0 # cubic->quadratic tolerance, in font units + +# ImageVector DSL -> SVG path command. No arcTo: none of the icons use one. +CMDS = { + "moveTo": "M", "moveToRelative": "m", + "lineTo": "L", "lineToRelative": "l", + "horizontalLineTo": "H", "horizontalLineToRelative": "h", + "verticalLineTo": "V", "verticalLineToRelative": "v", + "curveTo": "C", "curveToRelative": "c", + "reflectiveCurveTo": "S", "reflectiveCurveToRelative": "s", + "quadTo": "Q", "quadToRelative": "q", + "reflectiveQuadTo": "T", "reflectiveQuadToRelative": "t", + "close": "Z", +} +CALL_RE = re.compile(r"\b(" + "|".join(CMDS) + r")\(([^()]*)\)") +NUM_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?") + + +STROKE_W_RE = re.compile(r"strokeLineWidth\s*=\s*([\d.]+)f?") + + +def is_stroked(src: str) -> bool: + """True if the icon paints a stroke, not just a fill. + + A glyph outline is filled: there is no pen width in a TrueType glyph. Converting a + stroked icon would emit only its fill and silently change the artwork -- Zap is drawn + as a thin outline (strokeLineWidth 1.2) and came out as a solid bolt. Such icons must + keep their ImageVector. + """ + if any(float(w) > 0 for w in STROKE_W_RE.findall(src)): + return True + return "stroke = SolidColor" in src + + +def kotlin_to_svg_path(src: str): + """Extract viewport and an SVG 'd' string from one ImageVector .kt file.""" + vw = re.search(r"viewportWidth\s*=\s*([\d.]+)f?", src) + vh = re.search(r"viewportHeight\s*=\s*([\d.]+)f?", src) + if vw and vh: + viewport = (float(vw.group(1)), float(vh.group(1))) + elif "materialIcon(" in src: + # materialIcon() sets the viewport itself; Material's convention is 24x24. + viewport = (24.0, 24.0) + else: + return None, None + + # Only look inside the vector builder, never the @Preview composable above it. + # Only look inside the vector builder, never the @Preview composable above it, and never + # a helper like materialOutlinedPath() declared after it. + start = src.find(".apply {") + if start == -1: + start = src.find("materialIcon(") + body = src[start:] if start != -1 else src + end = body.find("\ninline fun ") + if end != -1: + body = body[:end] + + parts = [] + for m in CALL_RE.finditer(body): + cmd, args = m.group(1), m.group(2) + letter = CMDS[cmd] + if cmd == "close": + parts.append("Z") + continue + nums = NUM_RE.findall(args) + if not nums: + continue + parts.append(letter + " " + " ".join(nums)) + return viewport, " ".join(parts) + + +def build_glyph(d: str, viewport): + vw, vh = viewport + # Uniform scale on the larger axis keeps non-square viewports undistorted. + s = UPEM / max(vw, vh) + pen = TTGlyphPen(None) + # y flips: SVG grows downward, font outlines grow upward from the baseline. + tp = TransformPen(Cu2QuPen(pen, MAX_ERR), Transform(s, 0, 0, -s, 0, vh * s)) + parse_path(d, tp) + return pen.glyph() + + +def main(icons_dir, out_ttf, out_kt): + files = sorted(f for f in os.listdir(icons_dir) if f.endswith(".kt")) + glyphs, cmap, names, skipped = {".notdef": TTGlyphPen(None).glyph()}, {}, [".notdef"], [] + cp = FIRST_CODEPOINT + for fn in files: + name = fn[:-3] + src = open(os.path.join(icons_dir, fn), encoding="utf-8").read() + if is_stroked(src): + skipped.append((name, "draws a stroke; a glyph can only be filled")) + continue + viewport, d = kotlin_to_svg_path(src) + if not d: + skipped.append((name, "no path data")) + continue + try: + glyphs[name] = build_glyph(d, viewport) + except Exception as e: # noqa: BLE001 - report and continue, don't kill the build + skipped.append((name, f"{type(e).__name__}: {e}")) + continue + cmap[cp] = name + names.append(name) + print(f" {name:<12} U+{cp:04X} viewport {viewport[0]:g}x{viewport[1]:g} {len(d)} chars") + cp += 1 + + fb = FontBuilder(UPEM, isTTF=True) + fb.setupGlyphOrder(names) + fb.setupCharacterMap(cmap) + fb.setupGlyf(glyphs) + fb.setupHorizontalMetrics({n: (ADVANCE, 0) for n in names}) + fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT) + fb.setupNameTable({ + "familyName": "Amethyst Icons", "styleName": "Regular", + "psName": "AmethystIcons-Regular", "version": "1.0", + }) + fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=DESCENT, + usWinAscent=ASCENT, usWinDescent=abs(DESCENT)) + fb.setupPost(keepGlyphNames=False) + fb.save(out_ttf) + + with open(out_kt, "w", encoding="utf-8") as fh: + fh.write("// GENERATED by tools/icon-font/build_icon_font.py -- do not edit by hand.\n") + fh.write("package com.vitorpamplona.amethyst.commons.icons.symbols\n\n") + fh.write("/** Amethyst's own icons as font glyphs. See the build script for why. */\n") + fh.write("object AmethystIcons {\n") + for code, name in sorted(cmap.items()): + fh.write(f' val {name} = MaterialSymbol("\\u{code:04X}")\n') + fh.write("}\n") + + print(f"\nwrote {out_ttf} ({os.path.getsize(out_ttf)} bytes), {len(cmap)} glyphs") + for name, why in skipped: + print(f" SKIPPED {name}: {why}") + + +if __name__ == "__main__": + if len(sys.argv) != 4: + sys.exit(__doc__) + main(*sys.argv[1:])