feat(desktop): customizable nav bar with pin/unpin (v1b)

Add PinnedNavBarState to manage user-customizable sidebar items.
Replace hardcoded navItems in SinglePaneLayout with pinnedScreens
from state, persisted as CSV to DesktopPreferences. Right-click
context menu on App Drawer items to pin/unpin from sidebar.

- Add PinnedNavBarState with pin/unpin/move/save/load
- Add pinnedNavItems to DesktopPreferences (CSV persistence)
- Update SinglePaneLayout to use pinnedScreens from state
- Update AppDrawer with isPinned indicator and context menu
- Remove NavItem data class and hardcoded navItems list
- HomeFeed and Settings are always pinned (non-removable)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-17 13:37:06 +03:00
parent e79096f16b
commit 28e9b8202f
5 changed files with 228 additions and 74 deletions
@@ -69,6 +69,14 @@ object DesktopPreferences {
prefs.put(KEY_LAYOUT_MODE, value)
}
private const val KEY_PINNED_NAV_ITEMS = "pinned_nav_items"
var pinnedNavItems: String
get() = prefs.get(KEY_PINNED_NAV_ITEMS, "")
set(value) {
prefs.put(KEY_PINNED_NAV_ITEMS, value)
}
private const val KEY_BLOSSOM_SERVERS = "blossom_servers"
private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net"
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
import com.vitorpamplona.amethyst.desktop.ui.deck.PinnedNavBarState
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
@@ -492,6 +493,7 @@ fun App(
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
) {
val singlePaneState = remember { SinglePaneState() }
val pinnedNavBarState = remember { PinnedNavBarState().also { it.load() } }
// Always reload from prefs — after key() rebuild, prefs have the latest saved settings
var torSettings by remember {
@@ -698,6 +700,7 @@ fun App(
layoutMode = layoutMode,
deckState = deckState,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
@@ -732,6 +735,7 @@ fun App(
} else {
emptySet()
},
pinnedNavBarState = pinnedNavBarState,
onSelectScreen = { type ->
when (layoutMode) {
LayoutMode.DECK -> {
@@ -770,6 +774,7 @@ fun MainContent(
layoutMode: LayoutMode,
deckState: DeckState,
singlePaneState: SinglePaneState,
pinnedNavBarState: PinnedNavBarState,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
accountManager: AccountManager,
@@ -928,6 +933,7 @@ fun MainContent(
draftStore = draftStore,
appScope = appScope,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
onOpenAppDrawer = onShowAppDrawer,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
@@ -45,6 +45,7 @@ import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.Button
@@ -214,6 +215,7 @@ private class AppDrawerState {
@Composable
fun AppDrawer(
openColumnTypes: Set<String>,
pinnedNavBarState: PinnedNavBarState,
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
) {
@@ -304,7 +306,7 @@ fun AppDrawer(
if (state.awaitingHashtag) {
HashtagInputSection(state, onSelectScreen, onDismiss)
} else {
DrawerGrid(state, openColumnTypes, onSelectScreen, onDismiss)
DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss)
}
}
}
@@ -316,6 +318,7 @@ fun AppDrawer(
private fun DrawerGrid(
state: AppDrawerState,
openColumnTypes: Set<String>,
pinnedNavBarState: PinnedNavBarState,
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
) {
@@ -349,7 +352,15 @@ private fun DrawerGrid(
type = screen,
isSelected = (startIndex + localIdx) == state.selectedIndex,
isOpen = openColumnTypes.contains(screen.typeKey()),
isPinned = pinnedNavBarState.isPinned(screen),
onClick = { state.select(screen, onSelectScreen, onDismiss) },
onTogglePin = {
if (pinnedNavBarState.isPinned(screen)) {
pinnedNavBarState.unpin(screen)
} else {
pinnedNavBarState.pin(screen)
}
},
onHover = { state.selectedIndex = startIndex + localIdx },
)
}
@@ -366,46 +377,93 @@ private fun DrawerScreenCard(
type: DeckColumnType,
isSelected: Boolean,
isOpen: Boolean,
isPinned: Boolean,
onClick: () -> Unit,
onTogglePin: () -> Unit,
onHover: () -> Unit,
) {
Surface(
modifier =
Modifier
.size(80.dp)
.clickable(onClick = onClick)
.onPointerEvent(PointerEventType.Enter) { onHover() },
shape = RoundedCornerShape(12.dp),
tonalElevation = if (isSelected) 8.dp else 2.dp,
color =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
},
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
type.icon(),
contentDescription = type.title(),
modifier = Modifier.size(28.dp),
)
Spacer(Modifier.height(4.dp))
Text(
type.title(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
var showMenu by remember { mutableStateOf(false) }
Box {
Surface(
modifier =
Modifier
.size(80.dp)
.clickable(onClick = onClick)
.onPointerEvent(PointerEventType.Enter) { onHover() }
.onPointerEvent(PointerEventType.Press) { event ->
// Right-click opens context menu
if (event.changes.any { it.pressed && event.button?.index == 2 }) {
showMenu = true
}
},
shape = RoundedCornerShape(12.dp),
tonalElevation = if (isSelected) 8.dp else 2.dp,
color =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
},
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
type.icon(),
contentDescription = type.title(),
modifier = Modifier.size(28.dp),
)
Spacer(Modifier.height(4.dp))
Text(
type.title(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
}
// Open indicator (top-end dot)
if (isOpen) {
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(6.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape),
)
}
// Pinned indicator (top-start dot)
if (isPinned) {
Box(
modifier =
Modifier
.align(Alignment.TopStart)
.padding(4.dp)
.size(6.dp)
.background(MaterialTheme.colorScheme.tertiary, CircleShape),
)
}
}
if (isOpen) {
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(6.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape),
}
// Context menu for pin/unpin
androidx.compose.material3.DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
if (PinnedNavBarState.isPinnable(type)) {
androidx.compose.material3.DropdownMenuItem(
text = { Text(if (isPinned) "Unpin from sidebar" else "Pin to sidebar") },
onClick = {
onTogglePin()
showMenu = false
},
leadingIcon = {
Icon(
Icons.Default.PushPin,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
},
)
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Manages which screens are pinned to the navigation sidebar.
* Persists to DesktopPreferences as CSV of typeKey strings.
*/
class PinnedNavBarState {
private val _pinnedScreens = MutableStateFlow(DEFAULT_PINNED)
val pinnedScreens: StateFlow<List<DeckColumnType>> = _pinnedScreens.asStateFlow()
fun isPinned(type: DeckColumnType): Boolean = _pinnedScreens.value.any { it.typeKey() == type.typeKey() }
fun pin(type: DeckColumnType) {
if (isPinned(type)) return
if (!isPinnable(type)) return
_pinnedScreens.update { it + type }
save()
}
fun unpin(type: DeckColumnType) {
if (!isUnpinnable(type)) return
_pinnedScreens.update { current -> current.filter { it.typeKey() != type.typeKey() } }
save()
}
fun move(
fromIndex: Int,
toIndex: Int,
) {
_pinnedScreens.update { current ->
if (fromIndex !in current.indices || toIndex !in current.indices) return@update current
val mutable = current.toMutableList()
val item = mutable.removeAt(fromIndex)
mutable.add(toIndex, item)
mutable.toList()
}
save()
}
fun save() {
DesktopPreferences.pinnedNavItems = _pinnedScreens.value.joinToString(",") { it.typeKey() }
}
fun load() {
val raw = DesktopPreferences.pinnedNavItems
if (raw.isBlank()) {
_pinnedScreens.value = DEFAULT_PINNED
return
}
val keys = raw.split(",").filter { it.isNotBlank() }
val screens =
keys.mapNotNull { key ->
PINNABLE_SCREENS.find { it.typeKey() == key }
}
_pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED }
}
companion object {
// Only object types are pinnable (no parameterized types like Hashtag, Editor)
val PINNABLE_SCREENS: List<DeckColumnType> =
LAUNCHABLE_SCREENS.filter { !it.requiresInput() && it !is DeckColumnType.Editor }
val DEFAULT_PINNED: List<DeckColumnType> =
listOf(
DeckColumnType.HomeFeed,
DeckColumnType.Reads,
DeckColumnType.Drafts,
DeckColumnType.MyHighlights,
DeckColumnType.Search,
DeckColumnType.Bookmarks,
DeckColumnType.Messages,
DeckColumnType.Notifications,
DeckColumnType.MyProfile,
DeckColumnType.Chess,
DeckColumnType.Settings,
)
// These screens cannot be unpinned
private val ALWAYS_PINNED = setOf("home", "settings")
fun isPinnable(type: DeckColumnType): Boolean = PINNABLE_SCREENS.any { it.typeKey() == type.typeKey() }
fun isUnpinnable(type: DeckColumnType): Boolean = type.typeKey() !in ALWAYS_PINNED
}
}
@@ -30,16 +30,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
@@ -52,7 +43,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
@@ -72,27 +62,6 @@ import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
private data class NavItem(
val type: DeckColumnType,
val icon: ImageVector,
val label: String,
)
private val navItems =
listOf(
NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"),
NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"),
NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"),
NavItem(DeckColumnType.MyHighlights, Icons.AutoMirrored.Filled.Article, "Highlights"),
NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"),
NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"),
NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"),
NavItem(DeckColumnType.Notifications, Icons.Default.Notifications, "Notifications"),
NavItem(DeckColumnType.MyProfile, Icons.Default.Person, "Profile"),
NavItem(DeckColumnType.Chess, Icons.Default.Extension, "Chess"),
NavItem(DeckColumnType.Settings, Icons.Default.Settings, "Settings"),
)
@Composable
fun SinglePaneLayout(
relayManager: DesktopRelayConnectionManager,
@@ -106,6 +75,7 @@ fun SinglePaneLayout(
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
appScope: CoroutineScope,
singlePaneState: SinglePaneState,
pinnedNavBarState: PinnedNavBarState,
onOpenAppDrawer: () -> Unit,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -128,23 +98,24 @@ fun SinglePaneLayout(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
) {
navItems.forEach { item ->
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
pinnedScreens.forEach { screenType ->
NavigationRailItem(
selected = currentColumnType == item.type && navStack.isEmpty(),
selected = currentColumnType == screenType && navStack.isEmpty(),
onClick = {
singlePaneState.navigate(item.type)
singlePaneState.navigate(screenType)
navState.clear()
},
icon = {
Icon(
item.icon,
contentDescription = item.label,
screenType.icon(),
contentDescription = screenType.title(),
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
item.label,
screenType.title(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,