Merge pull request #2780 from mstrofnone/fix/desktop-sidebar-scrollable
fix(desktop): make the single-pane navigation rail scrollable
This commit is contained in:
+129
-90
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user