Merge pull request #2780 from mstrofnone/fix/desktop-sidebar-scrollable

fix(desktop): make the single-pane navigation rail scrollable
This commit is contained in:
Vitor Pamplona
2026-05-08 08:30:01 -04:00
committed by GitHub
@@ -20,19 +20,24 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui.deck package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
@@ -105,120 +110,154 @@ fun SinglePaneLayout(
Row(modifier = modifier.fillMaxSize()) { Row(modifier = modifier.fillMaxSize()) {
if (!isImmersive) { if (!isImmersive) {
NavigationRail( // Custom navigation rail with a scrollable items area so all pinned
modifier = Modifier.width(80.dp).fillMaxHeight(), // screens (Home, Reads, Notifications, ...) remain reachable when
containerColor = MaterialTheme.colorScheme.surfaceContainer, // the window is short. Bottom status indicators stay anchored.
// macOS: push rail items below the traffic lights. //
header = { Spacer(Modifier.height(titleBarInsetTop)) }, // We don't use Material3 `NavigationRail` directly here because its
// internal `Column` is not scrollable — when there are more pinned
// items than fit vertically, items at the bottom of the list (and
// the "More" button) get clipped on small windows. We replicate the
// rail's container styling via NavigationRailItemDefaults so item
// visuals are unchanged.
val railScrollState = rememberScrollState()
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
val torState = LocalTorState.current
val allAccountsState by accountManager.allAccounts.collectAsState()
val singlePaneScope = rememberCoroutineScope()
var showAddAccountDialog by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
.width(80.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceContainer),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
) { ) {
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState() // macOS: push rail items below the traffic lights.
pinnedScreens.forEach { screenType -> Spacer(Modifier.height(titleBarInsetTop))
// Rename "Home" to "Feeds" in the nav rail
val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title() // Scrollable region for pinned screens + "More" launcher.
// Takes all remaining space above the fixed bottom controls
// (weight(1f)) and scrolls vertically when items overflow.
Column(
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(railScrollState),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
pinnedScreens.forEach { screenType ->
// Rename "Home" to "Feeds" in the nav rail
val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title()
NavigationRailItem(
selected = currentColumnType == screenType && navStack.isEmpty(),
onClick = {
singlePaneState.navigate(screenType)
navState.clear()
},
icon = {
Icon(
screenType.icon(),
contentDescription = label,
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
colors = NavigationRailItemDefaults.colors(),
)
}
NavigationRailItem( NavigationRailItem(
selected = currentColumnType == screenType && navStack.isEmpty(), selected = false,
onClick = { onClick = onOpenAppDrawer,
singlePaneState.navigate(screenType)
navState.clear()
},
icon = { icon = {
Icon( Icon(
screenType.icon(), MaterialSymbols.Apps,
contentDescription = label, contentDescription = "App Drawer",
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
) )
}, },
label = { label = {
Text( Text(
label, "More",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
}, },
colors = NavigationRailItemDefaults.colors(),
) )
} }
NavigationRailItem( // Fixed bottom controls — always visible regardless of how
selected = false, // many items are in the scrollable region above.
onClick = onOpenAppDrawer, Column(
icon = { modifier = Modifier.fillMaxWidth(),
Icon( horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
MaterialSymbols.Apps, ) {
contentDescription = "App Drawer", // Relay health — shows elapsed time since last event (hidden when <30s)
modifier = Modifier.size(22.dp), RelayHealthIndicator(
) lastEventReceivedAt = lastRelayEventAt,
}, modifier = Modifier.padding(bottom = 4.dp),
label = { )
Text(
"More",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
},
)
Spacer(Modifier.weight(1f)) BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.padding(bottom = 4.dp),
)
// Relay health — shows elapsed time since last event (hidden when <30s) TorStatusIndicator(
RelayHealthIndicator( status = torState.status,
lastEventReceivedAt = lastRelayEventAt, onClick = {
modifier = Modifier.padding(bottom = 4.dp), singlePaneState.navigate(DeckColumnType.Settings)
) navState.clear()
},
modifier = Modifier.padding(bottom = 4.dp),
)
BunkerHeartbeatIndicator( AccountSwitcherDropdown(
signerConnectionState = signerConnectionState, activeNpub = accountManager.currentAccount()?.npub,
lastPingTimeSec = lastPingTimeSec, allAccounts = allAccountsState,
modifier = Modifier.padding(bottom = 4.dp), localCache = localCache,
) onSwitchAccount = { npub ->
// Tor status
val torState = LocalTorState.current
TorStatusIndicator(
status = torState.status,
onClick = {
singlePaneState.navigate(DeckColumnType.Settings)
navState.clear()
},
modifier = Modifier.padding(bottom = 4.dp),
)
// Account switcher — at very bottom of rail
val allAccountsState by accountManager.allAccounts.collectAsState()
val singlePaneScope = rememberCoroutineScope()
var showAddAccountDialog by remember { mutableStateOf(false) }
AccountSwitcherDropdown(
activeNpub = accountManager.currentAccount()?.npub,
allAccounts = allAccountsState,
localCache = localCache,
onSwitchAccount = { npub ->
singlePaneScope.launch(Dispatchers.IO) {
accountManager.switchAccount(npub)
}
},
onAddAccount = { showAddAccountDialog = true },
onRemoveAccount = { npub ->
singlePaneScope.launch(Dispatchers.IO) {
accountManager.removeAccountFromStorage(npub)
}
},
modifier = Modifier.padding(bottom = 8.dp),
)
if (showAddAccountDialog) {
AddAccountDialog(
accountManager = accountManager,
onDismiss = { showAddAccountDialog = false },
onAccountAdded = {
showAddAccountDialog = false
singlePaneScope.launch(Dispatchers.IO) { singlePaneScope.launch(Dispatchers.IO) {
accountManager.refreshAccountList() accountManager.switchAccount(npub)
} }
}, },
onAddAccount = { showAddAccountDialog = true },
onRemoveAccount = { npub ->
singlePaneScope.launch(Dispatchers.IO) {
accountManager.removeAccountFromStorage(npub)
}
},
modifier = Modifier.padding(bottom = 8.dp),
) )
} }
} }
if (showAddAccountDialog) {
AddAccountDialog(
accountManager = accountManager,
onDismiss = { showAddAccountDialog = false },
onAccountAdded = {
showAddAccountDialog = false
singlePaneScope.launch(Dispatchers.IO) {
accountManager.refreshAccountList()
}
},
)
}
} }
if (!isImmersive) { if (!isImmersive) {