From 6a9e0a79a6afb3b7b73ffe8859234dc3d3976d74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 02:00:11 +0000 Subject: [PATCH 1/2] fix(icons): render MaterialSymbol Icons that rely on defaultMinSize The Canvas-based MaterialSymbol Icon collapsed to 0x0 whenever callers didn't pass an explicit size modifier (e.g. ArrowBackIcon, ClearTextIcon). Canvas is a Spacer + drawBehind, and Spacer's measure policy only uses maxWidth/maxHeight when the incoming constraints are fixed. Inside an IconButton (state layer 40dp, content constraints 0..40), defaultMinSize only lifts minWidth/minHeight, leaving the constraints unfixed - so Spacer picked 0x0 and the glyph never drew. Icons that passed Modifier.size(N.dp) worked only because size(...) produces fixed constraints. Swap Canvas for an empty Box with drawBehind. Box's EmptyBoxMeasurePolicy uses minWidth/minHeight, so defaultMinSize(24, 24) now takes effect when no size modifier is supplied - matching Material3's own Icon behavior. https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN --- .../amethyst/commons/icons/symbols/Icon.kt | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) 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 3a490b5bc9..61ef0b96f0 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,11 +20,12 @@ */ package com.vitorpamplona.amethyst.commons.icons.symbols -import androidx.compose.foundation.Canvas +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 @@ -66,32 +67,32 @@ fun Icon( Modifier } - Canvas( + Box( 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) } - } - } + .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), + ) } From 72bef059f21fec91b5cb702e7f75a114c7786c59 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 03:01:23 +0000 Subject: [PATCH 2/2] 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 --- .../vitorpamplona/amethyst/ui/theme/Theme.kt | 3 +- .../amethyst/commons/icons/symbols/Icon.kt | 70 +---- .../icons/symbols/MaterialSymbolPainter.kt | 34 ++- .../icons/symbols/MaterialSymbolsFont.kt | 60 +++- .../vitorpamplona/amethyst/desktop/Main.kt | 267 +++++++++--------- 5 files changed, 221 insertions(+), 213 deletions(-) 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 51e20cf873..b794b28a35 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 61ef0b96f0..38a0a8285a 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 e8c590b0ba..8854c6d267 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 d0e8eefbf3..27e58f724d 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 4bb527fab9..6b54720795 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() }, + ) + } } } }