fix(desktop): make the single-pane navigation rail scrollable

The left navigation rail in single-pane mode renders a fixed list of
pinned screens (Home, Reads, Notifications, ...) plus a 'More' launcher
and a stack of bottom controls (relay health, bunker heartbeat, tor
status, account switcher).

Material3 `NavigationRail` lays its children out in a non-scrollable
`Column`. When the window is short — either because the OS window is
small or the user pinned several screens — the bottom items in the
list (and the 'More' button) get clipped and become unreachable.

Replace the `NavigationRail` with a `Column` that mirrors the rail's
container styling and splits content into two regions:

- A scrollable region (weight(1f) + verticalScroll) holding the pinned
  screens and the 'More' launcher. Overflow now scrolls instead of
  clipping.
- A fixed bottom region holding the relay health indicator, bunker
  heartbeat, tor status indicator, and the account switcher. These
  remain anchored at the bottom of the rail.

Item visuals are preserved by keeping `NavigationRailItem` for the
items themselves, with `NavigationRailItemDefaults.colors()`.

No behavior change when the rail content already fits the window.
This commit is contained in:
m
2026-05-08 16:22:47 +10:00
parent 2097089d3d
commit 01f6c1c24b
@@ -20,19 +20,24 @@
*/
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
@@ -105,120 +110,154 @@ fun SinglePaneLayout(
Row(modifier = modifier.fillMaxSize()) {
if (!isImmersive) {
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
// macOS: push rail items below the traffic lights.
header = { Spacer(Modifier.height(titleBarInsetTop)) },
// Custom navigation rail with a scrollable items area so all pinned
// screens (Home, Reads, Notifications, ...) remain reachable when
// the window is short. Bottom status indicators stay anchored.
//
// 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()
pinnedScreens.forEach { screenType ->
// Rename "Home" to "Feeds" in the nav rail
val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title()
// macOS: push rail items below the traffic lights.
Spacer(Modifier.height(titleBarInsetTop))
// 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(
selected = currentColumnType == screenType && navStack.isEmpty(),
onClick = {
singlePaneState.navigate(screenType)
navState.clear()
},
selected = false,
onClick = onOpenAppDrawer,
icon = {
Icon(
screenType.icon(),
contentDescription = label,
MaterialSymbols.Apps,
contentDescription = "App Drawer",
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
label,
"More",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
colors = NavigationRailItemDefaults.colors(),
)
}
NavigationRailItem(
selected = false,
onClick = onOpenAppDrawer,
icon = {
Icon(
MaterialSymbols.Apps,
contentDescription = "App Drawer",
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
"More",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
},
)
// Fixed bottom controls — always visible regardless of how
// many items are in the scrollable region above.
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
) {
// Relay health — shows elapsed time since last event (hidden when <30s)
RelayHealthIndicator(
lastEventReceivedAt = lastRelayEventAt,
modifier = Modifier.padding(bottom = 4.dp),
)
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)
RelayHealthIndicator(
lastEventReceivedAt = lastRelayEventAt,
modifier = Modifier.padding(bottom = 4.dp),
)
TorStatusIndicator(
status = torState.status,
onClick = {
singlePaneState.navigate(DeckColumnType.Settings)
navState.clear()
},
modifier = Modifier.padding(bottom = 4.dp),
)
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.padding(bottom = 4.dp),
)
// 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
AccountSwitcherDropdown(
activeNpub = accountManager.currentAccount()?.npub,
allAccounts = allAccountsState,
localCache = localCache,
onSwitchAccount = { npub ->
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) {