Merge pull request #2541 from vitorpamplona/claude/fix-arrowback-icon-0v6aT

Optimize Material Symbols rendering with shared font and text measurer
This commit is contained in:
Vitor Pamplona
2026-04-23 23:11:10 -04:00
committed by GitHub
5 changed files with 222 additions and 213 deletions
@@ -61,6 +61,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.model.ThemeType
private val DarkColorPalette =
@@ -591,7 +592,7 @@ fun AmethystTheme(
colorScheme = colors,
typography = Typography,
shapes = Shapes,
content = content,
content = { ProvideMaterialSymbols(content) },
)
val view = LocalView.current
@@ -20,28 +20,11 @@
*/
package com.vitorpamplona.amethyst.commons.icons.symbols
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
private val IconDefaultSize = 24.dp
import androidx.compose.material3.Icon as Material3Icon
@Composable
fun Icon(
@@ -49,49 +32,13 @@ fun Icon(
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current,
weight: Int = MaterialSymbolsDefaults.WEIGHT,
) {
val fontFamily = rememberMaterialSymbolsFontFamily(weight)
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
val mirror = symbol.autoMirror && LocalLayoutDirection.current == LayoutDirection.Rtl
val semanticsModifier =
if (contentDescription != null) {
Modifier.semantics {
this.contentDescription = contentDescription
this.role = Role.Image
}
} else {
Modifier
}
Canvas(
modifier =
modifier
.defaultMinSize(IconDefaultSize, IconDefaultSize)
.then(semanticsModifier),
) {
val sizePx = size.minDimension
val fontSize = with(density) { sizePx.toSp() }
val layout =
textMeasurer.measure(
text = symbol.glyph,
style =
TextStyle(
fontFamily = fontFamily,
fontSize = fontSize,
color = tint,
),
)
val tx = (size.width - layout.size.width) / 2f
val ty = (size.height - layout.size.height) / 2f
if (mirror) {
scale(scaleX = -1f, scaleY = 1f, pivot = Offset(size.width / 2f, size.height / 2f)) {
translate(tx, ty) { drawText(layout) }
}
} else {
translate(tx, ty) { drawText(layout) }
}
}
Material3Icon(
painter = rememberMaterialSymbolPainter(symbol, tint),
contentDescription = contentDescription,
modifier = modifier,
// Tint is already baked into the painter draw; pass Unspecified so Material3 doesn't
// double-tint via a ColorFilter.
tint = Color.Unspecified,
)
}
@@ -23,57 +23,63 @@ package com.vitorpamplona.amethyst.commons.icons.symbols
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
@Composable
fun rememberMaterialSymbolPainter(
symbol: MaterialSymbol,
tint: Color = LocalContentColor.current,
weight: Int = MaterialSymbolsDefaults.WEIGHT,
): Painter {
val fontFamily = rememberMaterialSymbolsFontFamily(weight)
val textMeasurer = rememberTextMeasurer()
val fontFamily = materialSymbolsFontFamily()
val textMeasurer = materialSymbolsTextMeasurer()
val density = LocalDensity.current
return remember(symbol, fontFamily, tint, density) {
MaterialSymbolPainter(symbol, fontFamily, tint, textMeasurer, density)
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
return remember(symbol, fontFamily, textMeasurer, density, tint, rtl) {
MaterialSymbolPainter(symbol, fontFamily, textMeasurer, density, tint, rtl)
}
}
private class MaterialSymbolPainter(
private val symbol: MaterialSymbol,
private val fontFamily: FontFamily,
private val tint: Color,
private val textMeasurer: TextMeasurer,
private val density: Density,
private val tint: Color,
private val rtl: Boolean,
) : Painter() {
override val intrinsicSize: Size = Size.Unspecified
override fun DrawScope.onDraw() {
val sizePx = size.minDimension
val fontSize = with(density) { sizePx.toSp() }
// TextStyle omits color; we override at draw time so TextMeasurer's cache hits across tints.
val layout =
textMeasurer.measure(
text = symbol.glyph,
style =
TextStyle(
fontFamily = fontFamily,
fontSize = fontSize,
color = tint,
),
style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
)
val tx = (size.width - layout.size.width) / 2f
val ty = (size.height - layout.size.height) / 2f
translate(tx, ty) { drawText(layout) }
if (symbol.autoMirror && rtl) {
scale(scaleX = -1f, scaleY = 1f, pivot = Offset(size.width / 2f, size.height / 2f)) {
translate(tx, ty) { drawText(layout, color = tint) }
}
} else {
translate(tx, ty) { drawText(layout, color = tint) }
}
}
}
@@ -21,27 +21,79 @@
package com.vitorpamplona.amethyst.commons.icons.symbols
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.text.TextMeasurer
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.material_symbols_outlined
import org.jetbrains.compose.resources.Font
// Held on CompositionLocal so every Material Symbol call site in the tree reuses one FontFamily
// instance. Without this, every Icon() composition allocates a new Font wrapper, breaks its own
// remember cache, and forces a fresh TextMeasurer per call site.
val LocalMaterialSymbolsFontFamily: ProvidableCompositionLocal<FontFamily?> = staticCompositionLocalOf { null }
// Shared TextMeasurer so its internal LRU cache is hit by every icon draw in the tree.
val LocalMaterialSymbolsTextMeasurer: ProvidableCompositionLocal<TextMeasurer?> = staticCompositionLocalOf { null }
private const val TEXT_MEASURER_CACHE_SIZE = 64
/**
* Builds the Material Symbols FontFamily and a shared TextMeasurer once for the subtree and
* exposes them via CompositionLocal. Wrap app roots (AmethystTheme, desktop MaterialTheme) in this.
*/
@Composable
fun rememberMaterialSymbolsFontFamily(weight: Int = MaterialSymbolsDefaults.WEIGHT): FontFamily {
fun ProvideMaterialSymbols(content: @Composable () -> Unit) {
val font =
Font(
resource = Res.font.material_symbols_outlined,
weight = FontWeight(weight),
weight = FontWeight(MaterialSymbolsDefaults.WEIGHT),
variationSettings =
FontVariation.Settings(
FontVariation.weight(weight),
FontVariation.weight(MaterialSymbolsDefaults.WEIGHT),
FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL),
FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE),
FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE),
),
)
return remember(font) { FontFamily(font) }
// Keyless remember: the Font wrapper identity changes every composition but the underlying
// resource is a compile-time constant, so one FontFamily for the lifetime of the subtree.
val fontFamily = remember { FontFamily(font) }
val textMeasurer = rememberTextMeasurer(cacheSize = TEXT_MEASURER_CACHE_SIZE)
CompositionLocalProvider(
LocalMaterialSymbolsFontFamily provides fontFamily,
LocalMaterialSymbolsTextMeasurer provides textMeasurer,
content = content,
)
}
@Composable
internal fun materialSymbolsFontFamily(): FontFamily = LocalMaterialSymbolsFontFamily.current ?: localMaterialSymbolsFontFamilyFallback()
@Composable
internal fun materialSymbolsTextMeasurer(): TextMeasurer =
LocalMaterialSymbolsTextMeasurer.current
?: rememberTextMeasurer(cacheSize = TEXT_MEASURER_CACHE_SIZE)
@Composable
private fun localMaterialSymbolsFontFamilyFallback(): FontFamily {
val font =
Font(
resource = Res.font.material_symbols_outlined,
weight = FontWeight(MaterialSymbolsDefaults.WEIGHT),
variationSettings =
FontVariation.Settings(
FontVariation.weight(MaterialSymbolsDefaults.WEIGHT),
FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL),
FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE),
FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE),
),
)
return remember { FontFamily(font) }
}
@@ -71,6 +71,7 @@ import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
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.relayClient.nip17Dm.unwrapAndUnsealOrNull
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
@@ -740,148 +741,150 @@ fun App(
MaterialTheme(
colorScheme = darkColorScheme(),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
when (accountState) {
is AccountState.LoggedOut -> {
LoginScreen(
accountManager = accountManager,
onLoginSuccess = {
// Start heartbeat if bunker account
val current = accountManager.currentAccount()
if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) {
accountManager.startHeartbeat(scope)
}
},
)
}
is AccountState.ConnectingRelays -> {
val relays by relayManager.relayStatuses.collectAsState()
ConnectingRelaysScreen(
subtitle = "Restoring remote signer session",
relayStatuses = relays,
)
}
is AccountState.LoggedIn -> {
val account = accountState as AccountState.LoggedIn
val nwcConnection by accountManager.nwcConnection.collectAsState()
// Load NWC connection on first composition
LaunchedEffect(Unit) {
accountManager.loadNwcConnection()
}
val currentTorStatus = torManager.status.collectAsState().value
androidx.compose.runtime.CompositionLocalProvider(
com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides
com.vitorpamplona.amethyst.desktop.ui.tor.TorState(
status = currentTorStatus,
settings = torSettings,
onSettingsChanged = { newSettings ->
torSettings = newSettings
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.save(newSettings)
torTypeFlow.value = newSettings.torType
externalPortFlow.value = newSettings.externalSocksPort
// Rebuild app to apply Tor changes
onRestartApp()
},
),
) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
ProvideMaterialSymbols {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
when (accountState) {
is AccountState.LoggedOut -> {
LoginScreen(
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
nip11Fetcher = nip11Fetcher,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAppDrawer = onShowAppDrawer,
)
}
// Compose dialog
if (showComposeDialog) {
ComposeNoteDialog(
onDismiss = onDismissComposeDialog,
relayManager = relayManager,
account = account,
replyTo = replyToNote,
)
}
// App Drawer overlay
if (showAppDrawer) {
val openColumns by deckState.columns.collectAsState()
AppDrawer(
openColumnTypes =
if (layoutMode == LayoutMode.DECK) {
openColumns.map { it.type.typeKey() }.toSet()
} else {
emptySet()
},
pinnedNavBarState = pinnedNavBarState,
workspaceManager = workspaceManager,
onSwitchWorkspace = { ws ->
// Switch layout mode to match workspace
onLayoutModeChange(ws.layoutMode)
// Load columns or single pane screen
when (ws.layoutMode) {
LayoutMode.DECK -> {
deckState.loadFromWorkspace(ws.columns)
}
LayoutMode.SINGLE_PANE -> {
// Load nav bar from workspace + navigate to first screen
pinnedNavBarState.loadFromWorkspace()
val firstKey =
ws.singlePaneScreens.firstOrNull() ?: "home"
val type = DeckState.parseColumnTypeFromKey(firstKey)
if (type != null) singlePaneState.navigate(type)
}
onLoginSuccess = {
// Start heartbeat if bunker account
val current = accountManager.currentAccount()
if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) {
accountManager.startHeartbeat(scope)
}
},
onSelectScreen = { type ->
when (layoutMode) {
LayoutMode.DECK -> {
if (deckState.hasColumnOfType(type)) {
deckState.focusExistingColumn(type)
} else {
deckState.addColumn(type)
)
}
is AccountState.ConnectingRelays -> {
val relays by relayManager.relayStatuses.collectAsState()
ConnectingRelaysScreen(
subtitle = "Restoring remote signer session",
relayStatuses = relays,
)
}
is AccountState.LoggedIn -> {
val account = accountState as AccountState.LoggedIn
val nwcConnection by accountManager.nwcConnection.collectAsState()
// Load NWC connection on first composition
LaunchedEffect(Unit) {
accountManager.loadNwcConnection()
}
val currentTorStatus = torManager.status.collectAsState().value
androidx.compose.runtime.CompositionLocalProvider(
com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides
com.vitorpamplona.amethyst.desktop.ui.tor.TorState(
status = currentTorStatus,
settings = torSettings,
onSettingsChanged = { newSettings ->
torSettings = newSettings
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.save(newSettings)
torTypeFlow.value = newSettings.torType
externalPortFlow.value = newSettings.externalSocksPort
// Rebuild app to apply Tor changes
onRestartApp()
},
),
) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
nip11Fetcher = nip11Fetcher,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAppDrawer = onShowAppDrawer,
)
}
// Compose dialog
if (showComposeDialog) {
ComposeNoteDialog(
onDismiss = onDismissComposeDialog,
relayManager = relayManager,
account = account,
replyTo = replyToNote,
)
}
// App Drawer overlay
if (showAppDrawer) {
val openColumns by deckState.columns.collectAsState()
AppDrawer(
openColumnTypes =
if (layoutMode == LayoutMode.DECK) {
openColumns.map { it.type.typeKey() }.toSet()
} else {
emptySet()
},
pinnedNavBarState = pinnedNavBarState,
workspaceManager = workspaceManager,
onSwitchWorkspace = { ws ->
// Switch layout mode to match workspace
onLayoutModeChange(ws.layoutMode)
// Load columns or single pane screen
when (ws.layoutMode) {
LayoutMode.DECK -> {
deckState.loadFromWorkspace(ws.columns)
}
LayoutMode.SINGLE_PANE -> {
// Load nav bar from workspace + navigate to first screen
pinnedNavBarState.loadFromWorkspace()
val firstKey =
ws.singlePaneScreens.firstOrNull() ?: "home"
val type = DeckState.parseColumnTypeFromKey(firstKey)
if (type != null) singlePaneState.navigate(type)
}
}
},
onSelectScreen = { type ->
when (layoutMode) {
LayoutMode.DECK -> {
if (deckState.hasColumnOfType(type)) {
deckState.focusExistingColumn(type)
} else {
deckState.addColumn(type)
}
}
LayoutMode.SINGLE_PANE -> {
singlePaneState.navigate(type)
LayoutMode.SINGLE_PANE -> {
singlePaneState.navigate(type)
}
}
}
},
onDismiss = onDismissAppDrawer,
)
},
onDismiss = onDismissAppDrawer,
)
}
}
}
}
// Force logout dialog overlay
val forceLogoutReason by accountManager.forceLogoutReason.collectAsState()
forceLogoutReason?.let { reason ->
ForceLogoutDialog(
reason = reason,
onDismiss = { accountManager.clearForceLogoutReason() },
)
// Force logout dialog overlay
val forceLogoutReason by accountManager.forceLogoutReason.collectAsState()
forceLogoutReason?.let { reason ->
ForceLogoutDialog(
reason = reason,
onDismiss = { accountManager.clearForceLogoutReason() },
)
}
}
}
}