diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index f06acf2e7..069c06de4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -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" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 306105f95..a6d9cad7d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index c483938e1..56a360e74 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -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, + 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, + 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), + ) + }, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt new file mode 100644 index 000000000..fe9252dd4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt @@ -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> = _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 = + LAUNCHABLE_SCREENS.filter { !it.requiresInput() && it !is DeckColumnType.Editor } + + val DEFAULT_PINNED: List = + 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 + } +} 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 92640a5cf..873f38eab 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 @@ -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,