perf(icons): share FontFamily + TextMeasurer across Material Symbol icons

Material Symbol icons were allocating a fresh Font wrapper on every
composition (Font(resource = ...) returns a new instance each call),
which invalidated the remember(font) cache in rememberMaterialSymbolsFontFamily
and forced a fresh TextMeasurer per call site. Tint was also baked into
the TextStyle passed to measure(), so any tint change (selected /
unselected reaction icons, hover states) produced a cache miss.

Hoist the FontFamily + TextMeasurer to CompositionLocals provided once
at the root (AmethystTheme on Android, MaterialTheme wrapper on desktop)
via ProvideMaterialSymbols. Every icon in the subtree now reuses:

- One FontFamily (Skia typeface cache hit for every subsequent glyph)
- One TextMeasurer with a 64-entry LRU, shared across all call sites
  so its measurement cache is hit across 300+ MaterialSymbols usages
- Tint applied via drawText(layout, color = tint) instead of TextStyle,
  so the measured TextLayoutResult is reused across tint variations

Icon(symbol = ...) now delegates to Material3's Icon(painter = ...),
restoring proper sizing/semantics without the custom Box+drawBehind
workaround. autoMirror handled inside the painter's onDraw. The
previously-unused MaterialSymbolPainter is now the single render path.

The weight parameter is dropped from the Icon/Painter APIs since the
app uses a single weight globally (MaterialSymbolsDefaults.WEIGHT).

A local fallback path keeps @Preview composables that don't wrap in
the theme renderable (per-composition allocation, same cost as before).

https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
This commit is contained in:
Claude
2026-04-24 03:01:23 +00:00
parent a436df7a21
commit 72bef059f2
5 changed files with 221 additions and 213 deletions
@@ -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() },
)
}
}
}
}