Merge origin/main (Amethyst icon font)

Icons.kt conflicts: kept main's AmethystIconGlyph calls with this
branch's migrated Res.string content descriptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
This commit is contained in:
Claude
2026-09-02 15:18:38 +00:00
10 changed files with 395 additions and 18 deletions
+27 -1
View File
@@ -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
@@ -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.commons.resources.Res
@@ -128,8 +130,8 @@ fun LikedIcon(
modifier: Modifier,
tint: Color = Color.Unspecified,
) {
Icon(
imageVector = Liked,
AmethystIconGlyph(
symbol = AmethystIcons.Liked,
contentDescription = stringRes(id = Res.string.like_description),
modifier = modifier,
tint = tint,
@@ -154,8 +156,8 @@ fun LikeIcon(
iconSizeModifier: Modifier,
grayTint: Color,
) {
Icon(
imageVector = Like,
AmethystIconGlyph(
symbol = AmethystIcons.Like,
contentDescription = stringRes(id = Res.string.like_description),
modifier = iconSizeModifier,
tint = grayTint,
@@ -167,8 +169,8 @@ fun RepostIcon(
modifier: Modifier,
tint: Color = Color.Unspecified,
) {
Icon(
imageVector = Repost,
AmethystIconGlyph(
symbol = AmethystIcons.Repost,
contentDescription = stringRes(id = Res.string.boost_or_quote_description),
modifier = modifier,
tint = tint,
@@ -180,8 +182,8 @@ fun RepostedIcon(
modifier: Modifier,
tint: Color = Color.Unspecified,
) {
Icon(
imageVector = Reposted,
AmethystIconGlyph(
symbol = AmethystIcons.Reposted,
contentDescription = stringRes(id = Res.string.boost_or_quote_description),
modifier = modifier,
tint = tint,
@@ -337,8 +339,8 @@ fun CommentIcon(
iconSizeModifier: Modifier,
tint: Color,
) {
Icon(
imageVector = Reply,
AmethystIconGlyph(
symbol = AmethystIcons.Reply,
contentDescription = stringRes(id = Res.string.reply_description),
modifier = iconSizeModifier,
tint = tint,
@@ -369,8 +371,8 @@ fun SearchIcon(
modifier: Modifier,
tint: Color = Color.Unspecified,
) {
Icon(
imageVector = Search,
AmethystIconGlyph(
symbol = AmethystIcons.Search,
contentDescription = stringRes(id = Res.string.search_button),
modifier = modifier,
tint = tint,
@@ -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)),
@@ -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")
}
@@ -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<FontFamily?> = 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)
}
}
@@ -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
@@ -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(
+46
View File
@@ -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 <icons-dir> <out.ttf> <out.kt>
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`.
+168
View File
@@ -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 <icons-dir> <out.ttf> <out.kt>
"""
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:])