From 01f6c1c24b0d137cd77e891c3a9e8c1cdab62b8f Mon Sep 17 00:00:00 2001 From: m Date: Fri, 8 May 2026 16:22:47 +1000 Subject: [PATCH] fix(desktop): make the single-pane navigation rail scrollable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../desktop/ui/deck/SinglePaneLayout.kt | 219 +++++++++++------- 1 file changed, 129 insertions(+), 90 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 72a21bc76..3e23bc70b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -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) {