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:
@@ -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,
|
||||
|
||||
+95
-37
@@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+111
@@ -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
|
||||
}
|
||||
}
|
||||
+8
-37
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user