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 51e20cf87..b794b28a3 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 @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/Icon.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/Icon.kt index 61ef0b96f..38a0a8285 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/Icon.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/Icon.kt @@ -20,29 +20,11 @@ */ package com.vitorpamplona.amethyst.commons.icons.symbols -import androidx.compose.foundation.layout.Box -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.draw.drawBehind -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( @@ -50,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 - } - - Box( - modifier = - modifier - .defaultMinSize(IconDefaultSize, IconDefaultSize) - .drawBehind { - 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) } - } - }.then(semanticsModifier), + 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, ) } 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 e8c590b0b..8854c6d26 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 @@ -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) } + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt index d0e8eefbf..27e58f724 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt @@ -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 = staticCompositionLocalOf { null } + +// Shared TextMeasurer so its internal LRU cache is hit by every icon draw in the tree. +val LocalMaterialSymbolsTextMeasurer: ProvidableCompositionLocal = 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) } } 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 4bb527fab..6b5472079 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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() }, + ) + } } } }