From e79096f16b028eee011434efb227f1433b60bb0e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 13:19:39 +0300 Subject: [PATCH 01/10] feat(desktop): add App Drawer with categorized screen launcher Replace AddColumnDialog with full-screen App Drawer overlay (Cmd+K). Screens organized by category (Social, Long-Form, Discovery, Identity, Play) with instant search, keyboard navigation (arrows + Enter), and open-column indicators in Deck mode. Works in both Single Pane and Deck layout modes. - Add AppDrawer.kt with ScreenCategory, LAUNCHABLE_SCREENS registry - Add SinglePaneState for hoisted single-pane navigation - Wire Cmd+K shortcut, redirect Cmd+T to drawer - Add "More" button to SinglePaneLayout nav rail - Delete AddColumnDialog.kt Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 74 ++- .../desktop/ui/deck/AddColumnDialog.kt | 165 ------ .../amethyst/desktop/ui/deck/AppDrawer.kt | 472 ++++++++++++++++++ .../desktop/ui/deck/SinglePaneLayout.kt | 30 +- .../desktop/ui/deck/SinglePaneState.kt | 37 ++ 5 files changed, 589 insertions(+), 189 deletions(-) delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt 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 7d72e82e3..306105f95 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -88,12 +88,13 @@ import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker -import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog +import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer 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.SinglePaneLayout +import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState @@ -192,7 +193,7 @@ fun main() { val deckState = remember { DeckState(deckScope).also { it.load() } } val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() - var showAddColumnDialog by remember { mutableStateOf(false) } + var showAppDrawer by remember { mutableStateOf(false) } // Tor state at Window level — survives key() app rebuild var torSettings by remember { @@ -299,6 +300,17 @@ fun main() { ) } Menu("View") { + Item( + "App Drawer", + shortcut = + if (isMacOS) { + KeyShortcut(Key.K, meta = true) + } else { + KeyShortcut(Key.K, ctrl = true) + }, + onClick = { showAppDrawer = !showAppDrawer }, + ) + Separator() Item( if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout", shortcut = @@ -323,7 +335,7 @@ fun main() { } else { KeyShortcut(Key.T, ctrl = true) }, - onClick = { showAddColumnDialog = true }, + onClick = { showAppDrawer = true }, ) Item( "Close Column", @@ -435,7 +447,7 @@ fun main() { deckState = deckState, accountManager = accountManager, showComposeDialog = showComposeDialog, - showAddColumnDialog = showAddColumnDialog, + showAppDrawer = showAppDrawer, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -445,8 +457,8 @@ fun main() { showComposeDialog = false replyToNote = null }, - onDismissAddColumnDialog = { showAddColumnDialog = false }, - onShowAddColumnDialog = { showAddColumnDialog = true }, + onDismissAppDrawer = { showAppDrawer = false }, + onShowAppDrawer = { showAppDrawer = true }, replyToNote = replyToNote, onRestartApp = { appRestartKey++ }, torManager = torManager, @@ -466,12 +478,12 @@ fun App( deckState: DeckState, accountManager: AccountManager, showComposeDialog: Boolean, - showAddColumnDialog: Boolean, + showAppDrawer: Boolean, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, - onDismissAddColumnDialog: () -> Unit, - onShowAddColumnDialog: () -> Unit, + onDismissAppDrawer: () -> Unit, + onShowAppDrawer: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, onRestartApp: () -> Unit = {}, torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager, @@ -479,6 +491,8 @@ fun App( externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, ) { + val singlePaneState = remember { SinglePaneState() } + // Always reload from prefs — after key() rebuild, prefs have the latest saved settings var torSettings by remember { mutableStateOf( @@ -683,6 +697,7 @@ fun App( MainContent( layoutMode = layoutMode, deckState = deckState, + singlePaneState = singlePaneState, relayManager = relayManager, localCache = localCache, accountManager = accountManager, @@ -693,7 +708,7 @@ fun App( torStatus = currentTorStatus, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, - onShowAddColumnDialog = onShowAddColumnDialog, + onShowAppDrawer = onShowAppDrawer, ) } @@ -707,14 +722,32 @@ fun App( ) } - // Add column dialog - if (showAddColumnDialog) { - AddColumnDialog( - onDismiss = onDismissAddColumnDialog, - onAdd = { type -> - deckState.addColumn(type) - onDismissAddColumnDialog() + // App Drawer overlay + if (showAppDrawer) { + val openColumns by deckState.columns.collectAsState() + AppDrawer( + openColumnTypes = + if (layoutMode == LayoutMode.DECK) { + openColumns.map { it.type.typeKey() }.toSet() + } else { + emptySet() + }, + onSelectScreen = { type -> + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + } + + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(type) + } + } }, + onDismiss = onDismissAppDrawer, ) } } @@ -736,6 +769,7 @@ fun App( fun MainContent( layoutMode: LayoutMode, deckState: DeckState, + singlePaneState: SinglePaneState, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, accountManager: AccountManager, @@ -746,7 +780,7 @@ fun MainContent( torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, - onShowAddColumnDialog: () -> Unit, + onShowAppDrawer: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -893,6 +927,8 @@ fun MainContent( highlightStore = highlightStore, draftStore = draftStore, appScope = appScope, + singlePaneState = singlePaneState, + onOpenAppDrawer = onShowAppDrawer, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, @@ -906,7 +942,7 @@ fun MainContent( LayoutMode.DECK -> { if (!isImmersive) { DeckSidebar( - onAddColumn = onShowAddColumnDialog, + onAddColumn = onShowAppDrawer, onOpenSettings = { if (deckState.hasColumnOfType(DeckColumnType.Settings)) { deckState.focusExistingColumn(DeckColumnType.Settings) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt deleted file mode 100644 index f85a4707c..000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt +++ /dev/null @@ -1,165 +0,0 @@ -/* - * 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 androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -private val COLUMN_OPTIONS = - listOf( - DeckColumnType.HomeFeed, - DeckColumnType.Notifications, - DeckColumnType.Messages, - DeckColumnType.Search, - DeckColumnType.Reads, - DeckColumnType.Drafts, - DeckColumnType.MyHighlights, - DeckColumnType.Bookmarks, - DeckColumnType.GlobalFeed, - DeckColumnType.MyProfile, - DeckColumnType.Chess, - ) - -@Composable -fun AddColumnDialog( - onDismiss: () -> Unit, - onAdd: (DeckColumnType) -> Unit, -) { - var hashtagInput by remember { mutableStateOf("") } - var showHashtagInput by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Add Column") }, - text = { - Column { - if (showHashtagInput) { - Text( - "Enter hashtag:", - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = hashtagInput, - onValueChange = { hashtagInput = it.removePrefix("#") }, - label = { Text("Hashtag") }, - placeholder = { Text("bitcoin") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } else { - COLUMN_OPTIONS.forEach { type -> - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { onAdd(type) } - .padding(vertical = 10.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - ) { - Icon( - imageVector = type.icon(), - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(12.dp)) - Text( - type.title(), - style = MaterialTheme.typography.bodyLarge, - ) - } - } - - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { showHashtagInput = true } - .padding(vertical = 10.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = DeckColumnType.Hashtag("").icon(), - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(12.dp)) - Text( - "Hashtag...", - style = MaterialTheme.typography.bodyLarge, - ) - } - } - } - }, - confirmButton = { - if (showHashtagInput) { - Button( - onClick = { - if (hashtagInput.isNotBlank()) { - onAdd(DeckColumnType.Hashtag(hashtagInput.trim())) - } - }, - enabled = hashtagInput.isNotBlank(), - ) { - Text("Add") - } - } - }, - dismissButton = { - TextButton(onClick = { - if (showHashtagInput) { - showHashtagInput = false - } else { - onDismiss() - } - }) { - Text(if (showHashtagInput) "Back" else "Cancel") - } - }, - ) -} 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 new file mode 100644 index 000000000..c483938e1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -0,0 +1,472 @@ +/* + * 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 androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +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.Search +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +// -- Screen categories -- + +enum class ScreenCategory( + val title: String, + val icon: ImageVector, +) { + SOCIAL("Social", Icons.Default.Groups), + LONG_FORM("Long-Form", Icons.AutoMirrored.Filled.Article), + DISCOVERY("Discovery", Icons.Default.Explore), + IDENTITY("Identity", Icons.Default.Person), + PLAY("Play", Icons.Default.SportsEsports), +} + +// -- Extensions on DeckColumnType -- + +fun DeckColumnType.category(): ScreenCategory = + when (this) { + DeckColumnType.HomeFeed, + DeckColumnType.Notifications, + DeckColumnType.Messages, + DeckColumnType.GlobalFeed, + -> ScreenCategory.SOCIAL + + DeckColumnType.Reads, + DeckColumnType.Drafts, + is DeckColumnType.Editor, + DeckColumnType.MyHighlights, + -> ScreenCategory.LONG_FORM + + DeckColumnType.Search, + is DeckColumnType.Hashtag, + DeckColumnType.Bookmarks, + -> ScreenCategory.DISCOVERY + + DeckColumnType.MyProfile, + DeckColumnType.Settings, + -> ScreenCategory.IDENTITY + + DeckColumnType.Chess -> ScreenCategory.PLAY + + // Deep-link types — not in LAUNCHABLE_SCREENS but need a category for exhaustiveness + is DeckColumnType.Profile, + is DeckColumnType.Thread, + is DeckColumnType.Article, + -> ScreenCategory.SOCIAL + } + +fun DeckColumnType.requiresInput(): Boolean = + when (this) { + is DeckColumnType.Hashtag -> true + else -> false + } + +val LAUNCHABLE_SCREENS: List = + listOf( + DeckColumnType.HomeFeed, + DeckColumnType.Notifications, + DeckColumnType.Messages, + DeckColumnType.GlobalFeed, + DeckColumnType.Reads, + DeckColumnType.Drafts, + DeckColumnType.Editor(), + DeckColumnType.MyHighlights, + DeckColumnType.Search, + DeckColumnType.Hashtag(""), + DeckColumnType.Bookmarks, + DeckColumnType.MyProfile, + DeckColumnType.Settings, + DeckColumnType.Chess, + ) + +// -- State -- + +@Stable +private class AppDrawerState { + var searchQuery by mutableStateOf("") + var selectedIndex by mutableStateOf(0) + var hashtagInput by mutableStateOf("") + var awaitingHashtag by mutableStateOf(false) + private var consumed by mutableStateOf(false) + + val filteredScreens: List by derivedStateOf { + if (searchQuery.isBlank()) { + LAUNCHABLE_SCREENS + } else { + LAUNCHABLE_SCREENS.filter { + it.title().contains(searchQuery, ignoreCase = true) || + it.category().title.contains(searchQuery, ignoreCase = true) + } + } + } + + val groupedScreens: Map> by derivedStateOf { + filteredScreens.groupBy { it.category() }.filterValues { it.isNotEmpty() } + } + + fun moveSelection(delta: Int) { + val size = filteredScreens.size + if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1) + } + + fun select( + screen: DeckColumnType, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, + ) { + if (consumed) return + if (screen.requiresInput()) { + awaitingHashtag = true + hashtagInput = "" + } else { + consumed = true + onSelectScreen(screen) + onDismiss() + } + } + + fun confirmHashtag( + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, + ) { + if (consumed || hashtagInput.isBlank()) return + consumed = true + onSelectScreen(DeckColumnType.Hashtag(hashtagInput.removePrefix("#").trim())) + onDismiss() + } +} + +// -- Composables -- + +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) +@Composable +fun AppDrawer( + openColumnTypes: Set, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, +) { + val state = remember { AppDrawerState() } + val searchFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(50) + searchFocusRequester.requestFocus() + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Escape -> { + if (state.awaitingHashtag) { + state.awaitingHashtag = false + } else { + onDismiss() + } + true + } + + Key.DirectionDown -> { + state.moveSelection(1) + true + } + + Key.DirectionUp -> { + state.moveSelection(-1) + true + } + + Key.Enter -> { + if (state.awaitingHashtag) { + state.confirmHashtag(onSelectScreen, onDismiss) + } else { + state.filteredScreens.getOrNull(state.selectedIndex)?.let { + state.select(it, onSelectScreen, onDismiss) + } + } + true + } + + else -> { + false + } + } + }.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = + Modifier + .fillMaxWidth(0.55f) + .fillMaxHeight(0.7f) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { /* consume click — prevent propagation to scrim */ }, + shape = RoundedCornerShape(16.dp), + tonalElevation = 8.dp, + ) { + Column { + TextField( + value = state.searchQuery, + onValueChange = { + state.searchQuery = it + state.selectedIndex = 0 + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(searchFocusRequester), + placeholder = { Text("Search screens...") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, "Search") }, + ) + + if (state.awaitingHashtag) { + HashtagInputSection(state, onSelectScreen, onDismiss) + } else { + DrawerGrid(state, openColumnTypes, onSelectScreen, onDismiss) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) +@Composable +private fun DrawerGrid( + state: AppDrawerState, + openColumnTypes: Set, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, +) { + val listState = rememberLazyListState() + + LaunchedEffect(state.selectedIndex) { + val approxItem = (state.selectedIndex / 4).coerceAtLeast(0) + if (approxItem < listState.layoutInfo.totalItemsCount) { + listState.animateScrollToItem(approxItem) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.padding(top = 8.dp), + ) { + var globalIndex = 0 + state.groupedScreens.forEach { (category, screens) -> + stickyHeader(key = "header-${category.name}") { + CategoryHeader(category) + } + val startIndex = globalIndex + item(key = "grid-${category.name}") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + screens.forEachIndexed { localIdx, screen -> + DrawerScreenCard( + type = screen, + isSelected = (startIndex + localIdx) == state.selectedIndex, + isOpen = openColumnTypes.contains(screen.typeKey()), + onClick = { state.select(screen, onSelectScreen, onDismiss) }, + onHover = { state.selectedIndex = startIndex + localIdx }, + ) + } + } + } + globalIndex += screens.size + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun DrawerScreenCard( + type: DeckColumnType, + isSelected: Boolean, + isOpen: Boolean, + onClick: () -> 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, + ) + } + if (isOpen) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(6.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + ) + } + } + } +} + +@Composable +private fun CategoryHeader(category: ScreenCategory) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(category.icon, category.title, Modifier.size(16.dp)) + Text( + category.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun HashtagInputSection( + state: AppDrawerState, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, +) { + val hashtagFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(50) + hashtagFocusRequester.requestFocus() + } + + Column(Modifier.padding(16.dp)) { + Text("Enter hashtag:", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.hashtagInput, + onValueChange = { state.hashtagInput = it.removePrefix("#") }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(hashtagFocusRequester), + placeholder = { Text("bitcoin, nostr...") }, + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { state.awaitingHashtag = false }) { Text("Back") } + Button( + onClick = { state.confirmHashtag(onSelectScreen, onDismiss) }, + enabled = state.hashtagInput.isNotBlank(), + ) { + Text("Open") + } + } + } +} 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 5e2599627..92640a5cf 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 @@ -31,6 +31,7 @@ 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 @@ -49,9 +50,7 @@ import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow @@ -106,6 +105,8 @@ fun SinglePaneLayout( highlightStore: DesktopHighlightStore, draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore, appScope: CoroutineScope, + singlePaneState: SinglePaneState, + onOpenAppDrawer: () -> Unit, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, @@ -114,7 +115,7 @@ fun SinglePaneLayout( lastRelayEventAt: Long? = null, modifier: Modifier = Modifier, ) { - var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } + val currentColumnType by singlePaneState.currentScreen.collectAsState() val navState = remember { ColumnNavigationState() } val navStack by navState.stack.collectAsState() val currentOverlay = navStack.lastOrNull() @@ -131,7 +132,7 @@ fun SinglePaneLayout( NavigationRailItem( selected = currentColumnType == item.type && navStack.isEmpty(), onClick = { - currentColumnType = item.type + singlePaneState.navigate(item.type) navState.clear() }, icon = { @@ -152,6 +153,25 @@ fun SinglePaneLayout( ) } + NavigationRailItem( + selected = false, + onClick = onOpenAppDrawer, + icon = { + Icon( + Icons.Default.Apps, + contentDescription = "App Drawer", + modifier = Modifier.size(22.dp), + ) + }, + label = { + Text( + "More", + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + }, + ) + Spacer(Modifier.weight(1f)) // Relay health — shows elapsed time since last event (hidden when <30s) @@ -171,7 +191,7 @@ fun SinglePaneLayout( TorStatusIndicator( status = torState.status, onClick = { - currentColumnType = DeckColumnType.Settings + singlePaneState.navigate(DeckColumnType.Settings) navState.clear() }, modifier = Modifier.padding(bottom = 12.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt new file mode 100644 index 000000000..9b4a69f3b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneState.kt @@ -0,0 +1,37 @@ +/* + * 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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Holds current screen for single-pane mode. Mirrors DeckState pattern. + */ +class SinglePaneState { + private val _currentScreen = MutableStateFlow(DeckColumnType.HomeFeed) + val currentScreen: StateFlow = _currentScreen.asStateFlow() + + fun navigate(type: DeckColumnType) { + _currentScreen.value = type + } +} From 28e9b8202f38e741b2b6470eb4b5be1a2a90c2e5 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 13:37:06 +0300 Subject: [PATCH 02/10] 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) --- .../amethyst/desktop/DesktopPreferences.kt | 8 ++ .../vitorpamplona/amethyst/desktop/Main.kt | 6 + .../amethyst/desktop/ui/deck/AppDrawer.kt | 132 +++++++++++++----- .../desktop/ui/deck/PinnedNavBarState.kt | 111 +++++++++++++++ .../desktop/ui/deck/SinglePaneLayout.kt | 45 ++---- 5 files changed, 228 insertions(+), 74 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt 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, From e528656ebdea1524023dab59d77f871d2abbaf22 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 13:42:30 +0300 Subject: [PATCH 03/10] feat(desktop): workspace system with save/switch/restore (v1c) Add workspace presets for different usage modes. Each workspace stores layout mode + column configuration. Switching destroys current layout and rebuilds from saved config (no background state). - Add Workspace data model with WorkspaceColumn - Add WorkspaceManager with save/load/switch/add/delete - Add WorkspaceIcons registry for Material icon resolution - Add WorkspaceBar to AppDrawer footer with workspace chips - Add DeckState.loadFromWorkspace() for column rebuilding - Add workspaces persistence to DesktopPreferences - Default workspace: "Social" (Home + Notifications + DMs) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/DesktopPreferences.kt | 8 + .../vitorpamplona/amethyst/desktop/Main.kt | 10 + .../amethyst/desktop/ui/deck/AppDrawer.kt | 59 ++++- .../amethyst/desktop/ui/deck/DeckState.kt | 14 ++ .../amethyst/desktop/ui/deck/Workspace.kt | 81 +++++++ .../desktop/ui/deck/WorkspaceManager.kt | 225 ++++++++++++++++++ 6 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt 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 069c06de4..11e9521bb 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_WORKSPACES = "workspaces" + + var workspaces: String + get() = prefs.get(KEY_WORKSPACES, "") + set(value) { + prefs.put(KEY_WORKSPACES, value) + } + private const val KEY_PINNED_NAV_ITEMS = "pinned_nav_items" var pinnedNavItems: String 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 a6d9cad7d..b16540f6c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -96,6 +96,7 @@ 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.deck.WorkspaceManager import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState @@ -192,6 +193,7 @@ fun main() { var replyToNote by remember { mutableStateOf(null) } val deckScope = rememberCoroutineScope() val deckState = remember { DeckState(deckScope).also { it.load() } } + val workspaceManager = remember { WorkspaceManager(deckScope).also { it.load() } } val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() var showAppDrawer by remember { mutableStateOf(false) } @@ -446,6 +448,7 @@ fun main() { App( layoutMode = layoutMode, deckState = deckState, + workspaceManager = workspaceManager, accountManager = accountManager, showComposeDialog = showComposeDialog, showAppDrawer = showAppDrawer, @@ -477,6 +480,7 @@ fun main() { fun App( layoutMode: LayoutMode, deckState: DeckState, + workspaceManager: WorkspaceManager, accountManager: AccountManager, showComposeDialog: Boolean, showAppDrawer: Boolean, @@ -699,6 +703,7 @@ fun App( MainContent( layoutMode = layoutMode, deckState = deckState, + workspaceManager = workspaceManager, singlePaneState = singlePaneState, pinnedNavBarState = pinnedNavBarState, relayManager = relayManager, @@ -736,6 +741,10 @@ fun App( emptySet() }, pinnedNavBarState = pinnedNavBarState, + workspaceManager = workspaceManager, + onSwitchWorkspace = { ws -> + deckState.loadFromWorkspace(ws.columns) + }, onSelectScreen = { type -> when (layoutMode) { LayoutMode.DECK -> { @@ -773,6 +782,7 @@ fun App( fun MainContent( layoutMode: LayoutMode, deckState: DeckState, + workspaceManager: WorkspaceManager, singlePaneState: SinglePaneState, pinnedNavBarState: PinnedNavBarState, relayManager: DesktopRelayConnectionManager, 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 56a360e74..eabbf78d4 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 @@ -59,6 +59,7 @@ import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -216,6 +217,8 @@ private class AppDrawerState { fun AppDrawer( openColumnTypes: Set, pinnedNavBarState: PinnedNavBarState, + workspaceManager: WorkspaceManager, + onSwitchWorkspace: (Workspace) -> Unit, onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, ) { @@ -306,7 +309,17 @@ fun AppDrawer( if (state.awaitingHashtag) { HashtagInputSection(state, onSelectScreen, onDismiss) } else { - DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss) + Box(Modifier.weight(1f)) { + DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss) + } + // Workspace bar at the bottom + WorkspaceBar( + workspaceManager = workspaceManager, + onSwitchWorkspace = { ws -> + onSwitchWorkspace(ws) + onDismiss() + }, + ) } } } @@ -528,3 +541,47 @@ private fun HashtagInputSection( } } } + +@Composable +private fun WorkspaceBar( + workspaceManager: WorkspaceManager, + onSwitchWorkspace: (Workspace) -> Unit, +) { + val workspaces by workspaceManager.workspaces.collectAsState() + val activeIndex by workspaceManager.activeIndex.collectAsState() + + androidx.compose.material3.HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Workspaces", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + workspaces.forEachIndexed { index, ws -> + val isActive = index == activeIndex + androidx.compose.material3.FilterChip( + selected = isActive, + onClick = { + if (!isActive) { + val switched = workspaceManager.switchTo(index) + if (switched != null) onSwitchWorkspace(switched) + } + }, + label = { Text(ws.name, style = MaterialTheme.typography.labelSmall) }, + leadingIcon = { + Icon( + WorkspaceIcons.resolve(ws.iconName), + contentDescription = ws.name, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index bdfb40298..6c033deaf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -256,6 +256,20 @@ class DeckState( } } + fun loadFromWorkspace(workspaceColumns: List) { + val loaded = + workspaceColumns.mapNotNull { col -> + val entry = mapOf("type" to col.typeKey, "param" to col.param) + val type = parseColumnType(entry) ?: return@mapNotNull null + DeckColumn( + type = type, + width = col.width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH), + ) + } + _columns.value = loaded.ifEmpty { DEFAULT_COLUMNS } + _focusedColumnIndex.value = 0 + } + companion object { const val MIN_COLUMN_WIDTH = 300f const val MAX_COLUMN_WIDTH = 800f diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt new file mode 100644 index 000000000..0c3e4f801 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt @@ -0,0 +1,81 @@ +/* + * 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 androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.MenuBook +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Work +import androidx.compose.ui.graphics.vector.ImageVector +import com.vitorpamplona.amethyst.desktop.LayoutMode + +data class Workspace( + val id: String = + java.util.UUID + .randomUUID() + .toString(), + val name: String, + val iconName: String, + val layoutMode: LayoutMode, + val columns: List, + val singlePaneScreen: String? = null, +) { + data class WorkspaceColumn( + val typeKey: String, + val param: String? = null, + val width: Float = 400f, + ) +} + +object WorkspaceIcons { + private val icons: Map = + mapOf( + "Groups" to Icons.Default.Groups, + "Edit" to Icons.Default.Edit, + "MenuBook" to Icons.Default.MenuBook, + "Home" to Icons.Default.Home, + "Chat" to Icons.Default.Chat, + "Search" to Icons.Default.Search, + "SportsEsports" to Icons.Default.SportsEsports, + "Bookmark" to Icons.Default.Bookmark, + "Explore" to Icons.Default.Explore, + "Person" to Icons.Default.Person, + "Star" to Icons.Default.Star, + "Favorite" to Icons.Default.Favorite, + "Work" to Icons.Default.Work, + "Code" to Icons.Default.Code, + ) + + val availableNames: List = icons.keys.sorted() + + fun resolve(name: String): ImageVector = icons[name] ?: Icons.Default.Home +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt new file mode 100644 index 000000000..4317f10ca --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -0,0 +1,225 @@ +/* + * 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.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.LayoutMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class WorkspaceManager( + private val saveScope: CoroutineScope, +) { + private val _workspaces = MutableStateFlow(listOf(DEFAULT_WORKSPACE)) + val workspaces: StateFlow> = _workspaces.asStateFlow() + + private val _activeIndex = MutableStateFlow(0) + val activeIndex: StateFlow = _activeIndex.asStateFlow() + + val activeWorkspace: Workspace + get() = _workspaces.value.getOrElse(_activeIndex.value) { _workspaces.value.first() } + + private var saveJob: Job? = null + + fun switchTo(index: Int): Workspace? { + if (index !in _workspaces.value.indices) return null + _activeIndex.value = index + scheduleSave() + return activeWorkspace + } + + fun saveCurrentColumns(columns: List) { + _workspaces.update { wsList -> + wsList.mapIndexed { idx, ws -> + if (idx == _activeIndex.value) { + ws.copy( + columns = + columns.map { col -> + Workspace.WorkspaceColumn( + typeKey = col.type.typeKey(), + param = + when (col.type) { + is DeckColumnType.Profile -> col.type.pubKeyHex + is DeckColumnType.Thread -> col.type.noteId + is DeckColumnType.Hashtag -> col.type.tag + else -> null + }, + width = col.width, + ) + }, + ) + } else { + ws + } + } + } + scheduleSave() + } + + fun addWorkspace(workspace: Workspace) { + if (_workspaces.value.size >= MAX_WORKSPACES) return + _workspaces.update { it + workspace } + scheduleSave() + } + + fun updateWorkspace(workspace: Workspace) { + _workspaces.update { wsList -> + wsList.map { if (it.id == workspace.id) workspace else it } + } + scheduleSave() + } + + fun deleteWorkspace(id: String) { + if (_workspaces.value.size <= 1) return + val idx = _workspaces.value.indexOfFirst { it.id == id } + _workspaces.update { it.filter { ws -> ws.id != id } } + if (_activeIndex.value >= _workspaces.value.size) { + _activeIndex.value = _workspaces.value.size - 1 + } + if (idx == _activeIndex.value || _activeIndex.value >= _workspaces.value.size) { + _activeIndex.value = 0 + } + scheduleSave() + } + + private fun scheduleSave() { + saveJob?.cancel() + saveJob = + saveScope.launch { + delay(SAVE_DEBOUNCE_MS) + save() + } + } + + fun save() { + try { + val data = + mapOf( + "activeIndex" to _activeIndex.value, + "workspaces" to + _workspaces.value.map { ws -> + mapOf( + "id" to ws.id, + "name" to ws.name, + "iconName" to ws.iconName, + "layoutMode" to ws.layoutMode.name, + "singlePaneScreen" to ws.singlePaneScreen, + "columns" to + ws.columns.map { col -> + mapOf( + "typeKey" to col.typeKey, + "param" to col.param, + "width" to col.width, + ) + }, + ) + }, + ) + DesktopPreferences.workspaces = mapper.writeValueAsString(data) + } catch (e: Exception) { + println("WorkspaceManager: failed to save: ${e.message}") + } + } + + fun load() { + try { + val json = DesktopPreferences.workspaces + if (json.isBlank()) return + val data: Map = mapper.readValue(json) + val activeIdx = (data["activeIndex"] as? Number)?.toInt() ?: 0 + + @Suppress("UNCHECKED_CAST") + val wsList = data["workspaces"] as? List> ?: return + val loaded = + wsList.mapNotNull { entry -> + try { + val name = entry["name"] as? String ?: return@mapNotNull null + val iconName = entry["iconName"] as? String ?: "Home" + val layoutMode = + try { + LayoutMode.valueOf(entry["layoutMode"] as? String ?: "DECK") + } catch (e: Exception) { + LayoutMode.DECK + } + val singlePaneScreen = entry["singlePaneScreen"] as? String + + @Suppress("UNCHECKED_CAST") + val columns = + (entry["columns"] as? List>)?.map { col -> + Workspace.WorkspaceColumn( + typeKey = col["typeKey"] as? String ?: "home", + param = col["param"] as? String, + width = (col["width"] as? Number)?.toFloat() ?: 400f, + ) + } ?: emptyList() + + Workspace( + id = + entry["id"] as? String ?: java.util.UUID + .randomUUID() + .toString(), + name = name, + iconName = iconName, + layoutMode = layoutMode, + columns = columns, + singlePaneScreen = singlePaneScreen, + ) + } catch (e: Exception) { + null + } + } + if (loaded.isNotEmpty()) { + _workspaces.value = loaded + _activeIndex.value = activeIdx.coerceIn(loaded.indices) + } + } catch (e: Exception) { + println("WorkspaceManager: failed to load: ${e.message}") + } + } + + companion object { + const val MAX_WORKSPACES = 9 + private const val SAVE_DEBOUNCE_MS = 500L + private val mapper = jacksonObjectMapper() + + val DEFAULT_WORKSPACE = + Workspace( + id = "default-social", + name = "Social", + iconName = "Groups", + layoutMode = LayoutMode.DECK, + columns = + listOf( + Workspace.WorkspaceColumn("home"), + Workspace.WorkspaceColumn("notifications"), + Workspace.WorkspaceColumn("messages"), + ), + ) + } +} From 5b8ddf0a20065b960751d6936b4841a7e7b81201 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 13:47:17 +0300 Subject: [PATCH 04/10] fix(desktop): review fixes for navigation overhaul - Fix right-click detection: use isSecondaryPressed (not button.index==2) - Fix parseColumnType missing drafts/highlights/editor/article cases - Fix param extraction for Editor.draftSlug and Article.addressTag - Fix deleteWorkspace index correction logic Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/deck/AppDrawer.kt | 6 ++++-- .../amethyst/desktop/ui/deck/DeckState.kt | 6 ++++++ .../desktop/ui/deck/WorkspaceManager.kt | 17 ++++++++++------- 3 files changed, 20 insertions(+), 9 deletions(-) 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 eabbf78d4..7f74a8e60 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 @@ -78,6 +78,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay @@ -405,8 +406,9 @@ private fun DrawerScreenCard( .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 }) { + if (event.buttons.isSecondaryPressed && + event.changes.any { it.pressed && !it.previousPressed } + ) { showMenu = true } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index 6c033deaf..cd66cd024 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -226,6 +226,8 @@ class DeckState( is DeckColumnType.Profile -> col.type.pubKeyHex is DeckColumnType.Thread -> col.type.noteId is DeckColumnType.Hashtag -> col.type.tag + is DeckColumnType.Editor -> col.type.draftSlug + is DeckColumnType.Article -> col.type.addressTag else -> null }, ) @@ -299,6 +301,10 @@ class DeckState( "my_profile" -> DeckColumnType.MyProfile "chess" -> DeckColumnType.Chess "settings" -> DeckColumnType.Settings + "drafts" -> DeckColumnType.Drafts + "highlights" -> DeckColumnType.MyHighlights + "editor" -> DeckColumnType.Editor(param) + "article" -> param?.let { DeckColumnType.Article(it) } "profile" -> param?.let { DeckColumnType.Profile(it) } "thread" -> param?.let { DeckColumnType.Thread(it) } "hashtag" -> param?.let { DeckColumnType.Hashtag(it) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt index 4317f10ca..f8ebd1e8e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -68,6 +68,8 @@ class WorkspaceManager( is DeckColumnType.Profile -> col.type.pubKeyHex is DeckColumnType.Thread -> col.type.noteId is DeckColumnType.Hashtag -> col.type.tag + is DeckColumnType.Editor -> col.type.draftSlug + is DeckColumnType.Article -> col.type.addressTag else -> null }, width = col.width, @@ -97,14 +99,15 @@ class WorkspaceManager( fun deleteWorkspace(id: String) { if (_workspaces.value.size <= 1) return - val idx = _workspaces.value.indexOfFirst { it.id == id } + val deletedIdx = _workspaces.value.indexOfFirst { it.id == id } + if (deletedIdx < 0) return _workspaces.update { it.filter { ws -> ws.id != id } } - if (_activeIndex.value >= _workspaces.value.size) { - _activeIndex.value = _workspaces.value.size - 1 - } - if (idx == _activeIndex.value || _activeIndex.value >= _workspaces.value.size) { - _activeIndex.value = 0 - } + _activeIndex.value = + when { + deletedIdx < _activeIndex.value -> _activeIndex.value - 1 + deletedIdx == _activeIndex.value -> 0 + else -> _activeIndex.value + }.coerceIn(_workspaces.value.indices) scheduleSave() } From 09f76f036a7fab8179a0d63a86386600275791bb Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 14:47:24 +0300 Subject: [PATCH 05/10] feat(desktop): workspace management UX with tabs, editor, unified search Add two-tab App Drawer (Screens/Workspaces), workspace cards with CRUD, editor dialog with icon picker and column configuration, unified search across screens and workspaces, layout mode auto-switching, and Cmd+Shift+S to save current layout as workspace. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 62 +- .../amethyst/desktop/ui/deck/AppDrawer.kt | 641 ++++++++++++++++-- .../amethyst/desktop/ui/deck/DeckState.kt | 5 + 3 files changed, 659 insertions(+), 49 deletions(-) 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 b16540f6c..321e2b118 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -96,6 +96,7 @@ 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.deck.Workspace import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen @@ -241,6 +242,44 @@ fun main() { }, onClick = { showComposeDialog = true }, ) + Item( + "Save as Workspace", + shortcut = + if (isMacOS) { + KeyShortcut(Key.S, meta = true, shift = true) + } else { + KeyShortcut(Key.S, ctrl = true, shift = true) + }, + onClick = { + if (workspaceManager.workspaces.value.size < WorkspaceManager.MAX_WORKSPACES) { + val columns = + deckState.columns.value.map { col -> + Workspace.WorkspaceColumn( + typeKey = col.type.typeKey(), + param = + when (col.type) { + is DeckColumnType.Hashtag -> col.type.tag + is DeckColumnType.Editor -> col.type.draftSlug + is DeckColumnType.Article -> col.type.addressTag + is DeckColumnType.Profile -> col.type.pubKeyHex + is DeckColumnType.Thread -> col.type.noteId + else -> null + }, + width = col.width, + ) + } + val ws = + Workspace( + name = "Workspace ${workspaceManager.workspaces.value.size + 1}", + iconName = "Star", + layoutMode = layoutMode, + columns = columns, + ) + workspaceManager.addWorkspace(ws) + } + }, + enabled = workspaceManager.workspaces.value.size < WorkspaceManager.MAX_WORKSPACES, + ) Separator() Item( "Settings", @@ -447,6 +486,10 @@ fun main() { key(appRestartKey) { App( layoutMode = layoutMode, + onLayoutModeChange = { newMode -> + layoutMode = newMode + DesktopPreferences.layoutMode = newMode.name + }, deckState = deckState, workspaceManager = workspaceManager, accountManager = accountManager, @@ -479,6 +522,7 @@ fun main() { @Composable fun App( layoutMode: LayoutMode, + onLayoutModeChange: (LayoutMode) -> Unit, deckState: DeckState, workspaceManager: WorkspaceManager, accountManager: AccountManager, @@ -743,7 +787,23 @@ fun App( pinnedNavBarState = pinnedNavBarState, workspaceManager = workspaceManager, onSwitchWorkspace = { ws -> - deckState.loadFromWorkspace(ws.columns) + // Switch layout mode to match workspace + onLayoutModeChange(ws.layoutMode) + // Load columns or single pane screen + when (ws.layoutMode) { + LayoutMode.DECK -> { + deckState.loadFromWorkspace(ws.columns) + } + + LayoutMode.SINGLE_PANE -> { + val screenKey = + ws.singlePaneScreen + ?: ws.columns.firstOrNull()?.typeKey + ?: "home" + val type = DeckState.parseColumnTypeFromKey(screenKey) + if (type != null) singlePaneState.navigate(type) + } + } }, onSelectScreen = { type -> when (layoutMode) { 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 7f74a8e60..be80058d0 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 @@ -36,23 +36,40 @@ 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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit 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.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField @@ -80,7 +97,9 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.LayoutMode import kotlinx.coroutines.delay // -- Screen categories -- @@ -154,12 +173,20 @@ val LAUNCHABLE_SCREENS: List = DeckColumnType.Chess, ) +// -- Tabs -- + +enum class AppDrawerTab { + SCREENS, + WORKSPACES, +} + // -- State -- @Stable private class AppDrawerState { var searchQuery by mutableStateOf("") var selectedIndex by mutableStateOf(0) + var activeTab by mutableStateOf(AppDrawerTab.SCREENS) var hashtagInput by mutableStateOf("") var awaitingHashtag by mutableStateOf(false) private var consumed by mutableStateOf(false) @@ -179,11 +206,36 @@ private class AppDrawerState { filteredScreens.groupBy { it.category() }.filterValues { it.isNotEmpty() } } + val isSearching: Boolean by derivedStateOf { searchQuery.isNotBlank() } + + fun filteredWorkspaces(allWorkspaces: List): List = + if (searchQuery.isBlank()) { + allWorkspaces + } else { + allWorkspaces.filter { ws -> + ws.name.contains(searchQuery, ignoreCase = true) || + ws.columns.any { col -> + val displayName = + DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title() + ?: col.typeKey + displayName.contains(searchQuery, ignoreCase = true) + } + } + } + fun moveSelection(delta: Int) { val size = filteredScreens.size if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1) } + fun moveSelectionUnified( + delta: Int, + wsCount: Int, + ) { + val total = wsCount + filteredScreens.size + if (total > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, total - 1) + } + fun select( screen: DeckColumnType, onSelectScreen: (DeckColumnType) -> Unit, @@ -200,6 +252,30 @@ private class AppDrawerState { } } + fun selectCurrent( + filteredWs: List, + onSelectScreen: (DeckColumnType) -> Unit, + onSwitchWorkspace: (Workspace) -> Unit, + onDismiss: () -> Unit, + ) { + if (consumed) return + if (!isSearching) { + // Tab-based: only screens use Enter + filteredScreens.getOrNull(selectedIndex)?.let { select(it, onSelectScreen, onDismiss) } + return + } + // Unified: workspaces first, then screens + val wsCount = filteredWs.size + if (selectedIndex < wsCount) { + consumed = true + onSwitchWorkspace(filteredWs[selectedIndex]) + onDismiss() + } else { + val screenIdx = selectedIndex - wsCount + filteredScreens.getOrNull(screenIdx)?.let { select(it, onSelectScreen, onDismiss) } + } + } + fun confirmHashtag( onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, @@ -213,7 +289,7 @@ private class AppDrawerState { // -- Composables -- -@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable fun AppDrawer( openColumnTypes: Set, @@ -225,6 +301,10 @@ fun AppDrawer( ) { val state = remember { AppDrawerState() } val searchFocusRequester = remember { FocusRequester() } + val allWorkspaces by workspaceManager.workspaces.collectAsState() + val filteredWs by remember(allWorkspaces, state.searchQuery) { + derivedStateOf { state.filteredWorkspaces(allWorkspaces) } + } LaunchedEffect(Unit) { delay(50) @@ -249,12 +329,20 @@ fun AppDrawer( } Key.DirectionDown -> { - state.moveSelection(1) + if (state.isSearching) { + state.moveSelectionUnified(1, filteredWs.size) + } else { + state.moveSelection(1) + } true } Key.DirectionUp -> { - state.moveSelection(-1) + if (state.isSearching) { + state.moveSelectionUnified(-1, filteredWs.size) + } else { + state.moveSelection(-1) + } true } @@ -262,9 +350,12 @@ fun AppDrawer( if (state.awaitingHashtag) { state.confirmHashtag(onSelectScreen, onDismiss) } else { - state.filteredScreens.getOrNull(state.selectedIndex)?.let { - state.select(it, onSelectScreen, onDismiss) - } + state.selectCurrent( + filteredWs, + onSelectScreen, + onSwitchWorkspace, + onDismiss, + ) } true } @@ -302,25 +393,66 @@ fun AppDrawer( Modifier .fillMaxWidth() .focusRequester(searchFocusRequester), - placeholder = { Text("Search screens...") }, + placeholder = { Text("Search screens and workspaces...") }, singleLine = true, leadingIcon = { Icon(Icons.Default.Search, "Search") }, ) if (state.awaitingHashtag) { HashtagInputSection(state, onSelectScreen, onDismiss) - } else { + } else if (state.isSearching) { Box(Modifier.weight(1f)) { - DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss) + UnifiedSearchResults( + state = state, + filteredWs = filteredWs, + workspaceManager = workspaceManager, + onSwitchWorkspace = onSwitchWorkspace, + onSelectScreen = onSelectScreen, + openColumnTypes = openColumnTypes, + pinnedNavBarState = pinnedNavBarState, + onDismiss = onDismiss, + ) + } + } else { + // Tab header + PrimaryTabRow(selectedTabIndex = state.activeTab.ordinal) { + AppDrawerTab.entries.forEach { tab -> + Tab( + selected = state.activeTab == tab, + onClick = { state.activeTab = tab }, + text = { + Text( + tab.name.lowercase().replaceFirstChar { it.uppercase() }, + ) + }, + ) + } + } + + Box(Modifier.weight(1f)) { + when (state.activeTab) { + AppDrawerTab.SCREENS -> { + DrawerGrid( + state, + openColumnTypes, + pinnedNavBarState, + onSelectScreen, + onDismiss, + ) + } + + AppDrawerTab.WORKSPACES -> { + WorkspacesGrid( + workspaceManager = workspaceManager, + onSwitchWorkspace = { + onSwitchWorkspace(it) + onDismiss() + }, + onDismiss = onDismiss, + ) + } + } } - // Workspace bar at the bottom - WorkspaceBar( - workspaceManager = workspaceManager, - onSwitchWorkspace = { ws -> - onSwitchWorkspace(ws) - onDismiss() - }, - ) } } } @@ -461,12 +593,12 @@ private fun DrawerScreenCard( } // Context menu for pin/unpin - androidx.compose.material3.DropdownMenu( + DropdownMenu( expanded = showMenu, onDismissRequest = { showMenu = false }, ) { if (PinnedNavBarState.isPinnable(type)) { - androidx.compose.material3.DropdownMenuItem( + DropdownMenuItem( text = { Text(if (isPinned) "Unpin from sidebar" else "Pin to sidebar") }, onClick = { onTogglePin() @@ -544,46 +676,459 @@ private fun HashtagInputSection( } } +// -- Workspaces Tab -- + @Composable -private fun WorkspaceBar( +private fun WorkspacesGrid( workspaceManager: WorkspaceManager, onSwitchWorkspace: (Workspace) -> Unit, + onDismiss: () -> Unit, ) { val workspaces by workspaceManager.workspaces.collectAsState() val activeIndex by workspaceManager.activeIndex.collectAsState() + var showEditor by remember { mutableStateOf(false) } + var editTarget by remember { mutableStateOf(null) } - androidx.compose.material3.HorizontalDivider() - - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "Workspaces", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - workspaces.forEachIndexed { index, ws -> - val isActive = index == activeIndex - androidx.compose.material3.FilterChip( - selected = isActive, - onClick = { - if (!isActive) { - val switched = workspaceManager.switchTo(index) - if (switched != null) onSwitchWorkspace(switched) + LazyColumn(Modifier.padding(8.dp)) { + itemsIndexed(workspaces) { index, ws -> + WorkspaceCard( + workspace = ws, + isActive = index == activeIndex, + onSwitch = { + val switched = workspaceManager.switchTo(index) + if (switched != null) { + onSwitchWorkspace(switched) } }, - label = { Text(ws.name, style = MaterialTheme.typography.labelSmall) }, - leadingIcon = { - Icon( - WorkspaceIcons.resolve(ws.iconName), - contentDescription = ws.name, - modifier = Modifier.size(16.dp), - ) + onEdit = { + editTarget = ws + showEditor = true }, + onDelete = { workspaceManager.deleteWorkspace(ws.id) }, + canDelete = workspaces.size > 1, + ) + } + // "+" card + item { + AddWorkspaceCard( + onClick = { + editTarget = null + showEditor = true + }, + enabled = workspaces.size < WorkspaceManager.MAX_WORKSPACES, + ) + } + } + + if (showEditor) { + WorkspaceEditorDialog( + initial = editTarget, + onSave = { ws -> + if (editTarget != null) { + workspaceManager.updateWorkspace(ws) + } else { + workspaceManager.addWorkspace(ws) + } + showEditor = false + }, + onDismiss = { showEditor = false }, + ) + } +} + +@Composable +private fun WorkspaceCard( + workspace: Workspace, + isActive: Boolean, + onSwitch: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + canDelete: Boolean, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(onClick = onSwitch), + shape = RoundedCornerShape(12.dp), + tonalElevation = if (isActive) 8.dp else 2.dp, + color = + if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Row( + Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + WorkspaceIcons.resolve(workspace.iconName), + workspace.name, + Modifier.size(28.dp), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(workspace.name, style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + // Layout mode badge — non-interactive Surface+Text + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + if (workspace.layoutMode == LayoutMode.DECK) "Deck" else "Single", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + // Column preview with display names + Text( + workspace.columns.joinToString(", ") { col -> + DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title() + ?: col.typeKey + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // Active indicator + if (isActive) { + Icon( + Icons.Default.Check, + "Active", + Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, "Edit", Modifier.size(18.dp)) + } + IconButton(onClick = onDelete, enabled = canDelete) { + Icon(Icons.Default.Delete, "Delete", Modifier.size(18.dp)) + } + } + } +} + +@Composable +private fun AddWorkspaceCard( + onClick: () -> Unit, + enabled: Boolean, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(enabled = enabled, onClick = onClick), + shape = RoundedCornerShape(12.dp), + tonalElevation = 1.dp, + ) { + Row( + Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon(Icons.Default.Add, "Add workspace", Modifier.size(24.dp)) + Spacer(Modifier.width(8.dp)) + Text( + "New Workspace", + style = MaterialTheme.typography.titleSmall, + color = + if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, ) } } } + +// -- Workspace Editor Dialog -- + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun WorkspaceEditorDialog( + initial: Workspace?, + onSave: (Workspace) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf(initial?.name ?: "") } + var iconName by remember { mutableStateOf(initial?.iconName ?: "Home") } + var layoutMode by remember { mutableStateOf(initial?.layoutMode ?: LayoutMode.DECK) } + var columns by remember { + mutableStateOf(initial?.columns ?: listOf(Workspace.WorkspaceColumn("home"))) + } + var singlePaneScreen by remember { mutableStateOf(initial?.singlePaneScreen) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (initial != null) "Edit Workspace" else "New Workspace") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Name + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + // Icon picker + Text("Icon", style = MaterialTheme.typography.labelMedium) + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + WorkspaceIcons.availableNames.forEach { iName -> + IconButton(onClick = { iconName = iName }) { + Icon( + WorkspaceIcons.resolve(iName), + iName, + tint = + if (iName == iconName) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + // Layout mode toggle + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Layout:", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.width(8.dp)) + SingleChoiceSegmentedButtonRow { + SegmentedButton( + selected = layoutMode == LayoutMode.SINGLE_PANE, + onClick = { layoutMode = LayoutMode.SINGLE_PANE }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { + Text("Single Pane") + } + SegmentedButton( + selected = layoutMode == LayoutMode.DECK, + onClick = { layoutMode = LayoutMode.DECK }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { + Text("Deck") + } + } + } + // Deck: column list + if (layoutMode == LayoutMode.DECK) { + DeckColumnEditor(columns = columns, onColumnsChange = { columns = it }) + } + // Single Pane: screen picker + if (layoutMode == LayoutMode.SINGLE_PANE) { + SinglePaneScreenPicker( + selected = singlePaneScreen, + onSelect = { singlePaneScreen = it }, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + onSave( + Workspace( + id = + initial?.id ?: java.util.UUID + .randomUUID() + .toString(), + name = name.ifBlank { "Untitled" }, + iconName = iconName, + layoutMode = layoutMode, + columns = columns, + singlePaneScreen = singlePaneScreen, + ), + ) + }, + enabled = name.isNotBlank(), + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun DeckColumnEditor( + columns: List, + onColumnsChange: (List) -> Unit, +) { + Column { + Text("Columns", style = MaterialTheme.typography.labelMedium) + columns.forEachIndexed { idx, col -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title() ?: col.typeKey, + Modifier.weight(1f), + ) + IconButton( + onClick = { + if (columns.size > 1) { + onColumnsChange(columns.toMutableList().apply { removeAt(idx) }) + } + }, + enabled = columns.size > 1, + ) { + Icon(Icons.Default.Close, "Remove") + } + } + } + // Add column dropdown + var expanded by remember { mutableStateOf(false) } + TextButton(onClick = { expanded = true }) { Text("+ Add Column") } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + LAUNCHABLE_SCREENS.filter { !it.requiresInput() }.forEach { screen -> + DropdownMenuItem( + text = { Text(screen.title()) }, + onClick = { + onColumnsChange(columns + Workspace.WorkspaceColumn(screen.typeKey())) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun SinglePaneScreenPicker( + selected: String?, + onSelect: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selectedTitle = + selected?.let { DeckState.parseColumnTypeFromKey(it)?.title() } ?: "Select screen" + + Column { + Text("Screen", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(4.dp)) + TextButton(onClick = { expanded = true }) { Text(selectedTitle) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + LAUNCHABLE_SCREENS.filter { !it.requiresInput() }.forEach { screen -> + DropdownMenuItem( + text = { Text(screen.title()) }, + onClick = { + onSelect(screen.typeKey()) + expanded = false + }, + ) + } + } + } +} + +// -- Unified Search Results -- + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun UnifiedSearchResults( + state: AppDrawerState, + filteredWs: List, + workspaceManager: WorkspaceManager, + onSwitchWorkspace: (Workspace) -> Unit, + onSelectScreen: (DeckColumnType) -> Unit, + openColumnTypes: Set, + pinnedNavBarState: PinnedNavBarState, + onDismiss: () -> Unit, +) { + val activeIndex by workspaceManager.activeIndex.collectAsState() + val allWorkspaces by workspaceManager.workspaces.collectAsState() + + LazyColumn(Modifier.padding(8.dp)) { + // Workspace results first + if (filteredWs.isNotEmpty()) { + stickyHeader { + Text( + "Workspaces", + style = MaterialTheme.typography.titleSmall, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + itemsIndexed(filteredWs) { idx, ws -> + val wsIdx = allWorkspaces.indexOf(ws) + val isHighlighted = idx == state.selectedIndex + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + tonalElevation = if (isHighlighted) 8.dp else 0.dp, + ) { + WorkspaceCard( + workspace = ws, + isActive = wsIdx == activeIndex, + onSwitch = { + val switched = workspaceManager.switchTo(wsIdx) + if (switched != null) { + onSwitchWorkspace(switched) + onDismiss() + } + }, + onEdit = { /* not supported in search results */ }, + onDelete = { workspaceManager.deleteWorkspace(ws.id) }, + canDelete = allWorkspaces.size > 1, + ) + } + } + } + // Screen results below + if (state.filteredScreens.isNotEmpty()) { + stickyHeader { + Text( + "Screens", + style = MaterialTheme.typography.titleSmall, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp), + ) { + val wsOffset = filteredWs.size + state.filteredScreens.forEachIndexed { localIdx, screen -> + DrawerScreenCard( + type = screen, + isSelected = (wsOffset + 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 = wsOffset + localIdx }, + ) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index cd66cd024..6d932c685 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -287,6 +287,11 @@ class DeckState( private val mapper = jacksonObjectMapper() + fun parseColumnTypeFromKey( + typeKey: String, + param: String? = null, + ): DeckColumnType? = parseColumnType(mapOf("type" to typeKey, "param" to param)) + private fun parseColumnType(entry: Map): DeckColumnType? { val typeKey = entry["type"] as? String ?: return null val param = entry["param"] as? String From ac349665eb5ef02106de08ee4638b2c750c6b1d7 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 14:55:25 +0300 Subject: [PATCH 06/10] fix(desktop): review fixes for workspace management UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove consumed flag (stale on re-open, double-fire is harmless) - Fix keyboard nav for WORKSPACES tab (was broken, only worked for screens) - Reset selectedIndex on tab switch - Fix delete active workspace → reload columns for new active workspace - Extract DeckColumnType.param() extension to eliminate 3x duplication - Simplify moveSelection to handle all modes (tabs + unified search) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 11 +- .../amethyst/desktop/ui/deck/AppDrawer.kt | 103 +++++++++++------- .../amethyst/desktop/ui/deck/DeckState.kt | 10 +- .../desktop/ui/deck/WorkspaceManager.kt | 10 +- 4 files changed, 67 insertions(+), 67 deletions(-) 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 321e2b118..3a76d9e14 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -98,6 +98,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState import com.vitorpamplona.amethyst.desktop.ui.deck.Workspace import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager +import com.vitorpamplona.amethyst.desktop.ui.deck.param import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState @@ -256,15 +257,7 @@ fun main() { deckState.columns.value.map { col -> Workspace.WorkspaceColumn( typeKey = col.type.typeKey(), - param = - when (col.type) { - is DeckColumnType.Hashtag -> col.type.tag - is DeckColumnType.Editor -> col.type.draftSlug - is DeckColumnType.Article -> col.type.addressTag - is DeckColumnType.Profile -> col.type.pubKeyHex - is DeckColumnType.Thread -> col.type.noteId - else -> null - }, + param = col.type.param(), width = col.width, ) } 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 be80058d0..5a004bba4 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 @@ -155,6 +155,16 @@ fun DeckColumnType.requiresInput(): Boolean = else -> false } +fun DeckColumnType.param(): String? = + when (this) { + is DeckColumnType.Profile -> pubKeyHex + is DeckColumnType.Thread -> noteId + is DeckColumnType.Hashtag -> tag + is DeckColumnType.Editor -> draftSlug + is DeckColumnType.Article -> addressTag + else -> null + } + val LAUNCHABLE_SCREENS: List = listOf( DeckColumnType.HomeFeed, @@ -189,7 +199,6 @@ private class AppDrawerState { var activeTab by mutableStateOf(AppDrawerTab.SCREENS) var hashtagInput by mutableStateOf("") var awaitingHashtag by mutableStateOf(false) - private var consumed by mutableStateOf(false) val filteredScreens: List by derivedStateOf { if (searchQuery.isBlank()) { @@ -223,17 +232,22 @@ private class AppDrawerState { } } - fun moveSelection(delta: Int) { - val size = filteredScreens.size - if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1) + fun switchTab(tab: AppDrawerTab) { + activeTab = tab + selectedIndex = 0 } - fun moveSelectionUnified( + fun moveSelection( delta: Int, - wsCount: Int, + wsCount: Int = 0, ) { - val total = wsCount + filteredScreens.size - if (total > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, total - 1) + val size = + when { + isSearching -> wsCount + filteredScreens.size + activeTab == AppDrawerTab.WORKSPACES -> wsCount + else -> filteredScreens.size + } + if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1) } fun select( @@ -241,12 +255,10 @@ private class AppDrawerState { onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, ) { - if (consumed) return if (screen.requiresInput()) { awaitingHashtag = true hashtagInput = "" } else { - consumed = true onSelectScreen(screen) onDismiss() } @@ -258,21 +270,29 @@ private class AppDrawerState { onSwitchWorkspace: (Workspace) -> Unit, onDismiss: () -> Unit, ) { - if (consumed) return - if (!isSearching) { - // Tab-based: only screens use Enter - filteredScreens.getOrNull(selectedIndex)?.let { select(it, onSelectScreen, onDismiss) } - return - } - // Unified: workspaces first, then screens - val wsCount = filteredWs.size - if (selectedIndex < wsCount) { - consumed = true - onSwitchWorkspace(filteredWs[selectedIndex]) - onDismiss() + if (isSearching) { + // Unified: workspaces first, then screens + val wsCount = filteredWs.size + if (selectedIndex < wsCount) { + onSwitchWorkspace(filteredWs[selectedIndex]) + onDismiss() + } else { + val screenIdx = selectedIndex - wsCount + filteredScreens.getOrNull(screenIdx)?.let { select(it, onSelectScreen, onDismiss) } + } } else { - val screenIdx = selectedIndex - wsCount - filteredScreens.getOrNull(screenIdx)?.let { select(it, onSelectScreen, onDismiss) } + when (activeTab) { + AppDrawerTab.SCREENS -> { + filteredScreens.getOrNull(selectedIndex)?.let { select(it, onSelectScreen, onDismiss) } + } + + AppDrawerTab.WORKSPACES -> { + filteredWs.getOrNull(selectedIndex)?.let { + onSwitchWorkspace(it) + onDismiss() + } + } + } } } @@ -280,8 +300,7 @@ private class AppDrawerState { onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, ) { - if (consumed || hashtagInput.isBlank()) return - consumed = true + if (hashtagInput.isBlank()) return onSelectScreen(DeckColumnType.Hashtag(hashtagInput.removePrefix("#").trim())) onDismiss() } @@ -329,20 +348,12 @@ fun AppDrawer( } Key.DirectionDown -> { - if (state.isSearching) { - state.moveSelectionUnified(1, filteredWs.size) - } else { - state.moveSelection(1) - } + state.moveSelection(1, filteredWs.size) true } Key.DirectionUp -> { - if (state.isSearching) { - state.moveSelectionUnified(-1, filteredWs.size) - } else { - state.moveSelection(-1) - } + state.moveSelection(-1, filteredWs.size) true } @@ -419,7 +430,7 @@ fun AppDrawer( AppDrawerTab.entries.forEach { tab -> Tab( selected = state.activeTab == tab, - onClick = { state.activeTab = tab }, + onClick = { state.switchTab(tab) }, text = { Text( tab.name.lowercase().replaceFirstChar { it.uppercase() }, @@ -704,7 +715,13 @@ private fun WorkspacesGrid( editTarget = ws showEditor = true }, - onDelete = { workspaceManager.deleteWorkspace(ws.id) }, + onDelete = { + val wasActive = index == activeIndex + workspaceManager.deleteWorkspace(ws.id) + if (wasActive) { + onSwitchWorkspace(workspaceManager.activeWorkspace) + } + }, canDelete = workspaces.size > 1, ) } @@ -1082,8 +1099,14 @@ private fun UnifiedSearchResults( onDismiss() } }, - onEdit = { /* not supported in search results */ }, - onDelete = { workspaceManager.deleteWorkspace(ws.id) }, + onEdit = { /* edit via Workspaces tab */ }, + onDelete = { + val wasActive = wsIdx == activeIndex + workspaceManager.deleteWorkspace(ws.id) + if (wasActive) { + onSwitchWorkspace(workspaceManager.activeWorkspace) + } + }, canDelete = allWorkspaces.size > 1, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index 6d932c685..653042d2a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -221,15 +221,7 @@ class DeckState( "id" to col.id, "type" to col.type.typeKey(), "width" to col.width, - "param" to - when (col.type) { - is DeckColumnType.Profile -> col.type.pubKeyHex - is DeckColumnType.Thread -> col.type.noteId - is DeckColumnType.Hashtag -> col.type.tag - is DeckColumnType.Editor -> col.type.draftSlug - is DeckColumnType.Article -> col.type.addressTag - else -> null - }, + "param" to col.type.param(), ) } DesktopPreferences.deckColumns = mapper.writeValueAsString(data) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt index f8ebd1e8e..d4edbd1e9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -63,15 +63,7 @@ class WorkspaceManager( columns.map { col -> Workspace.WorkspaceColumn( typeKey = col.type.typeKey(), - param = - when (col.type) { - is DeckColumnType.Profile -> col.type.pubKeyHex - is DeckColumnType.Thread -> col.type.noteId - is DeckColumnType.Hashtag -> col.type.tag - is DeckColumnType.Editor -> col.type.draftSlug - is DeckColumnType.Article -> col.type.addressTag - else -> null - }, + param = col.type.param(), width = col.width, ) }, From 0455d1f8c6b438deebbbc0cd632aaf575e0f9725 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 19 Apr 2026 05:21:12 +0300 Subject: [PATCH 07/10] feat(desktop): single-pane workspaces store nav bar screen list Change Workspace.singlePaneScreen (single string) to singlePaneScreens (list of typeKey strings). First screen is the default. On workspace switch, loads the screen list into PinnedNavBarState and navigates to the first screen. - Add SinglePaneScreensEditor to workspace editor dialog - Add PinnedNavBarState.loadFromList() for workspace-driven nav - Backward compat: load old "singlePaneScreen" format as single-item list - Cmd+Shift+S captures current deck columns as singlePaneScreens Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 23 ++++-- .../amethyst/desktop/ui/deck/AppDrawer.kt | 74 +++++++++++++------ .../desktop/ui/deck/PinnedNavBarState.kt | 5 ++ .../amethyst/desktop/ui/deck/Workspace.kt | 2 +- .../desktop/ui/deck/WorkspaceManager.kt | 13 +++- 5 files changed, 83 insertions(+), 34 deletions(-) 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 3a76d9e14..bbac3c026 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -267,6 +267,12 @@ fun main() { iconName = "Star", layoutMode = layoutMode, columns = columns, + singlePaneScreens = + if (layoutMode == LayoutMode.SINGLE_PANE) { + columns.map { it.typeKey } + } else { + emptyList() + }, ) workspaceManager.addWorkspace(ws) } @@ -789,12 +795,17 @@ fun App( } LayoutMode.SINGLE_PANE -> { - val screenKey = - ws.singlePaneScreen - ?: ws.columns.firstOrNull()?.typeKey - ?: "home" - val type = DeckState.parseColumnTypeFromKey(screenKey) - if (type != null) singlePaneState.navigate(type) + // Set nav bar screens from workspace + if (ws.singlePaneScreens.isNotEmpty()) { + val screens = + ws.singlePaneScreens.mapNotNull { + DeckState.parseColumnTypeFromKey(it) + } + if (screens.isNotEmpty()) { + pinnedNavBarState.loadFromList(screens) + singlePaneState.navigate(screens.first()) + } + } } } }, 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 5a004bba4..0adca7e11 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 @@ -55,6 +55,7 @@ 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.material.icons.filled.Star import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu @@ -883,7 +884,9 @@ private fun WorkspaceEditorDialog( var columns by remember { mutableStateOf(initial?.columns ?: listOf(Workspace.WorkspaceColumn("home"))) } - var singlePaneScreen by remember { mutableStateOf(initial?.singlePaneScreen) } + var singlePaneScreens by remember { + mutableStateOf(initial?.singlePaneScreens ?: listOf("home")) + } AlertDialog( onDismissRequest = onDismiss, @@ -941,11 +944,11 @@ private fun WorkspaceEditorDialog( if (layoutMode == LayoutMode.DECK) { DeckColumnEditor(columns = columns, onColumnsChange = { columns = it }) } - // Single Pane: screen picker + // Single Pane: nav bar screen selector (first = default) if (layoutMode == LayoutMode.SINGLE_PANE) { - SinglePaneScreenPicker( - selected = singlePaneScreen, - onSelect = { singlePaneScreen = it }, + SinglePaneScreensEditor( + screens = singlePaneScreens, + onScreensChange = { singlePaneScreens = it }, ) } } @@ -963,7 +966,7 @@ private fun WorkspaceEditorDialog( iconName = iconName, layoutMode = layoutMode, columns = columns, - singlePaneScreen = singlePaneScreen, + singlePaneScreens = singlePaneScreens, ), ) }, @@ -1021,29 +1024,52 @@ private fun DeckColumnEditor( } @Composable -private fun SinglePaneScreenPicker( - selected: String?, - onSelect: (String) -> Unit, +private fun SinglePaneScreensEditor( + screens: List, + onScreensChange: (List) -> Unit, ) { - var expanded by remember { mutableStateOf(false) } - val selectedTitle = - selected?.let { DeckState.parseColumnTypeFromKey(it)?.title() } ?: "Select screen" - Column { - Text("Screen", style = MaterialTheme.typography.labelMedium) + Text("Nav Bar Screens", style = MaterialTheme.typography.labelMedium) + Text( + "First screen is the default", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) Spacer(Modifier.height(4.dp)) - TextButton(onClick = { expanded = true }) { Text(selectedTitle) } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - LAUNCHABLE_SCREENS.filter { !it.requiresInput() }.forEach { screen -> - DropdownMenuItem( - text = { Text(screen.title()) }, - onClick = { - onSelect(screen.typeKey()) - expanded = false - }, - ) + screens.forEachIndexed { idx, key -> + val displayName = DeckState.parseColumnTypeFromKey(key)?.title() ?: key + Row(verticalAlignment = Alignment.CenterVertically) { + if (idx == 0) { + Icon( + Icons.Default.Star, + "Default", + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(4.dp)) + } + Text(displayName, Modifier.weight(1f)) + IconButton(onClick = { + onScreensChange(screens.toMutableList().apply { removeAt(idx) }) + }) { Icon(Icons.Default.Close, "Remove", Modifier.size(16.dp)) } } } + // Add screen dropdown + var expanded by remember { mutableStateOf(false) } + TextButton(onClick = { expanded = true }) { Text("+ Add Screen") } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + PinnedNavBarState.PINNABLE_SCREENS + .filter { screen -> screens.none { it == screen.typeKey() } } + .forEach { screen -> + DropdownMenuItem( + text = { Text(screen.title()) }, + onClick = { + onScreensChange(screens + screen.typeKey()) + expanded = false + }, + ) + } + } } } 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 index fe9252dd4..00d5f5eb9 100644 --- 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 @@ -67,6 +67,11 @@ class PinnedNavBarState { DesktopPreferences.pinnedNavItems = _pinnedScreens.value.joinToString(",") { it.typeKey() } } + fun loadFromList(screens: List) { + _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } + save() + } + fun load() { val raw = DesktopPreferences.pinnedNavItems if (raw.isBlank()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt index 0c3e4f801..0e2d2c62a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt @@ -47,7 +47,7 @@ data class Workspace( val iconName: String, val layoutMode: LayoutMode, val columns: List, - val singlePaneScreen: String? = null, + val singlePaneScreens: List = emptyList(), ) { data class WorkspaceColumn( val typeKey: String, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt index d4edbd1e9..2de3e9cf4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -124,7 +124,7 @@ class WorkspaceManager( "name" to ws.name, "iconName" to ws.iconName, "layoutMode" to ws.layoutMode.name, - "singlePaneScreen" to ws.singlePaneScreen, + "singlePaneScreens" to ws.singlePaneScreens, "columns" to ws.columns.map { col -> mapOf( @@ -162,7 +162,14 @@ class WorkspaceManager( } catch (e: Exception) { LayoutMode.DECK } - val singlePaneScreen = entry["singlePaneScreen"] as? String + + @Suppress("UNCHECKED_CAST") + val singlePaneScreens = + (entry["singlePaneScreens"] as? List) ?: run { + // Backward compat: old format had single "singlePaneScreen" string + val legacy = entry["singlePaneScreen"] as? String + if (legacy != null) listOf(legacy) else emptyList() + } @Suppress("UNCHECKED_CAST") val columns = @@ -183,7 +190,7 @@ class WorkspaceManager( iconName = iconName, layoutMode = layoutMode, columns = columns, - singlePaneScreen = singlePaneScreen, + singlePaneScreens = singlePaneScreens, ) } catch (e: Exception) { null From 200c5227fcc2f48777291a04078670b0fc002269 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 19 Apr 2026 05:40:28 +0300 Subject: [PATCH 08/10] refactor(desktop): pin/unpin syncs to active workspace PinnedNavBarState now takes WorkspaceManager reference. Pin/unpin actions update the active workspace's singlePaneScreens list, making sidebar customization and workspace editing the same action. - PinnedNavBarState.syncToWorkspace() updates active workspace on pin/unpin - PinnedNavBarState.loadFromWorkspace() loads from active workspace - Remove separate DesktopPreferences.pinnedNavItems persistence - Workspace is the single source of truth for nav bar screens Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 19 +++----- .../desktop/ui/deck/PinnedNavBarState.kt | 46 +++++++++---------- 2 files changed, 29 insertions(+), 36 deletions(-) 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 bbac3c026..f8c28cd80 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -540,7 +540,7 @@ fun App( initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, ) { val singlePaneState = remember { SinglePaneState() } - val pinnedNavBarState = remember { PinnedNavBarState().also { it.load() } } + val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } } // Always reload from prefs — after key() rebuild, prefs have the latest saved settings var torSettings by remember { @@ -795,17 +795,12 @@ fun App( } LayoutMode.SINGLE_PANE -> { - // Set nav bar screens from workspace - if (ws.singlePaneScreens.isNotEmpty()) { - val screens = - ws.singlePaneScreens.mapNotNull { - DeckState.parseColumnTypeFromKey(it) - } - if (screens.isNotEmpty()) { - pinnedNavBarState.loadFromList(screens) - singlePaneState.navigate(screens.first()) - } - } + // Load nav bar from workspace + navigate to first screen + pinnedNavBarState.loadFromWorkspace() + val firstKey = + ws.singlePaneScreens.firstOrNull() ?: "home" + val type = DeckState.parseColumnTypeFromKey(firstKey) + if (type != null) singlePaneState.navigate(type) } } }, 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 index 00d5f5eb9..6deb120b7 100644 --- 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 @@ -20,7 +20,6 @@ */ 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 @@ -28,9 +27,11 @@ import kotlinx.coroutines.flow.update /** * Manages which screens are pinned to the navigation sidebar. - * Persists to DesktopPreferences as CSV of typeKey strings. + * Pin/unpin syncs to the active workspace's singlePaneScreens. */ -class PinnedNavBarState { +class PinnedNavBarState( + private val workspaceManager: WorkspaceManager? = null, +) { private val _pinnedScreens = MutableStateFlow(DEFAULT_PINNED) val pinnedScreens: StateFlow> = _pinnedScreens.asStateFlow() @@ -40,13 +41,13 @@ class PinnedNavBarState { if (isPinned(type)) return if (!isPinnable(type)) return _pinnedScreens.update { it + type } - save() + syncToWorkspace() } fun unpin(type: DeckColumnType) { if (!isUnpinnable(type)) return _pinnedScreens.update { current -> current.filter { it.typeKey() != type.typeKey() } } - save() + syncToWorkspace() } fun move( @@ -60,34 +61,32 @@ class PinnedNavBarState { mutable.add(toIndex, item) mutable.toList() } - save() - } - - fun save() { - DesktopPreferences.pinnedNavItems = _pinnedScreens.value.joinToString(",") { it.typeKey() } + syncToWorkspace() } fun loadFromList(screens: List) { _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } - save() } - fun load() { - val raw = DesktopPreferences.pinnedNavItems - if (raw.isBlank()) { - _pinnedScreens.value = DEFAULT_PINNED - return + fun loadFromWorkspace() { + val ws = workspaceManager?.activeWorkspace ?: return + if (ws.singlePaneScreens.isNotEmpty()) { + val screens = + ws.singlePaneScreens.mapNotNull { key -> + PINNABLE_SCREENS.find { it.typeKey() == key } + } + _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } } - val keys = raw.split(",").filter { it.isNotBlank() } - val screens = - keys.mapNotNull { key -> - PINNABLE_SCREENS.find { it.typeKey() == key } - } - _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } + } + + private fun syncToWorkspace() { + val wm = workspaceManager ?: return + val ws = wm.activeWorkspace + val updated = ws.copy(singlePaneScreens = _pinnedScreens.value.map { it.typeKey() }) + wm.updateWorkspace(updated) } 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 } @@ -106,7 +105,6 @@ class PinnedNavBarState { 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() } From 486ff2e8cf3da7594666d90a46164b00b5456858 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 19 Apr 2026 05:52:02 +0300 Subject: [PATCH 09/10] =?UTF-8?q?fix(desktop):=20UI=20polish=20=E2=80=94?= =?UTF-8?q?=20single-line=20layout=20toggle,=20checkmark=20margin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shorten "Single Pane" to "Single" in SegmentedButton to prevent wrapping - Add 8dp left margin before checkmark in workspace card Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 0adca7e11..44807eff5 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 @@ -817,6 +817,7 @@ private fun WorkspaceCard( } // Active indicator if (isActive) { + Spacer(Modifier.width(8.dp)) Icon( Icons.Default.Check, "Active", @@ -929,7 +930,7 @@ private fun WorkspaceEditorDialog( onClick = { layoutMode = LayoutMode.SINGLE_PANE }, shape = SegmentedButtonDefaults.itemShape(0, 2), ) { - Text("Single Pane") + Text("Single", maxLines = 1) } SegmentedButton( selected = layoutMode == LayoutMode.DECK, From cb306b52191be31481108466bdf4b383d8cb0958 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 19 Apr 2026 05:55:09 +0300 Subject: [PATCH 10/10] fix(desktop): icon picker shows selected state with background highlight Replace tint-only selection with Surface background + primaryContainer color so the selected icon is clearly visible in the workspace editor. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/deck/AppDrawer.kt | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) 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 44807eff5..f98466bd4 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 @@ -904,19 +904,36 @@ private fun WorkspaceEditorDialog( ) // Icon picker Text("Icon", style = MaterialTheme.typography.labelMedium) - FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val isSelected = { iName: String -> iName == iconName } WorkspaceIcons.availableNames.forEach { iName -> - IconButton(onClick = { iconName = iName }) { - Icon( - WorkspaceIcons.resolve(iName), - iName, - tint = - if (iName == iconName) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) + Surface( + modifier = Modifier.size(40.dp).clickable { iconName = iName }, + shape = RoundedCornerShape(8.dp), + color = + if (isSelected(iName)) { + MaterialTheme.colorScheme.primaryContainer + } else { + Color.Transparent + }, + tonalElevation = if (isSelected(iName)) 4.dp else 0.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + WorkspaceIcons.resolve(iName), + iName, + modifier = Modifier.size(24.dp), + tint = + if (isSelected(iName)) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } } } }