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..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,22 @@ 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 + 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 7d72e82e3..f8c28cd80 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,17 @@ 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.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.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 @@ -190,9 +195,10 @@ 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 showAddColumnDialog by remember { mutableStateOf(false) } + var showAppDrawer by remember { mutableStateOf(false) } // Tor state at Window level — survives key() app rebuild var torSettings by remember { @@ -237,6 +243,42 @@ 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 = col.type.param(), + width = col.width, + ) + } + val ws = + Workspace( + name = "Workspace ${workspaceManager.workspaces.value.size + 1}", + iconName = "Star", + layoutMode = layoutMode, + columns = columns, + singlePaneScreens = + if (layoutMode == LayoutMode.SINGLE_PANE) { + columns.map { it.typeKey } + } else { + emptyList() + }, + ) + workspaceManager.addWorkspace(ws) + } + }, + enabled = workspaceManager.workspaces.value.size < WorkspaceManager.MAX_WORKSPACES, + ) Separator() Item( "Settings", @@ -299,6 +341,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 +376,7 @@ fun main() { } else { KeyShortcut(Key.T, ctrl = true) }, - onClick = { showAddColumnDialog = true }, + onClick = { showAppDrawer = true }, ) Item( "Close Column", @@ -432,10 +485,15 @@ fun main() { key(appRestartKey) { App( layoutMode = layoutMode, + onLayoutModeChange = { newMode -> + layoutMode = newMode + DesktopPreferences.layoutMode = newMode.name + }, deckState = deckState, + workspaceManager = workspaceManager, accountManager = accountManager, showComposeDialog = showComposeDialog, - showAddColumnDialog = showAddColumnDialog, + showAppDrawer = showAppDrawer, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -445,8 +503,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, @@ -463,15 +521,17 @@ fun main() { @Composable fun App( layoutMode: LayoutMode, + onLayoutModeChange: (LayoutMode) -> Unit, deckState: DeckState, + workspaceManager: WorkspaceManager, 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 +539,9 @@ fun App( externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, ) { + val singlePaneState = remember { SinglePaneState() } + 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 { mutableStateOf( @@ -683,6 +746,9 @@ fun App( MainContent( layoutMode = layoutMode, deckState = deckState, + workspaceManager = workspaceManager, + singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, relayManager = relayManager, localCache = localCache, accountManager = accountManager, @@ -693,7 +759,7 @@ fun App( torStatus = currentTorStatus, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, - onShowAddColumnDialog = onShowAddColumnDialog, + onShowAppDrawer = onShowAppDrawer, ) } @@ -707,14 +773,53 @@ 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() + }, + pinnedNavBarState = pinnedNavBarState, + workspaceManager = workspaceManager, + onSwitchWorkspace = { ws -> + // 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 -> { + // 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) + } + } }, + 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 +841,9 @@ fun App( fun MainContent( layoutMode: LayoutMode, deckState: DeckState, + workspaceManager: WorkspaceManager, + singlePaneState: SinglePaneState, + pinnedNavBarState: PinnedNavBarState, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, accountManager: AccountManager, @@ -746,7 +854,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 +1001,9 @@ fun MainContent( highlightStore = highlightStore, draftStore = draftStore, appScope = appScope, + singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, + onOpenAppDrawer = onShowAppDrawer, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, @@ -906,7 +1017,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..f98466bd4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -0,0 +1,1201 @@ +/* + * 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.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.material.icons.filled.Star +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 +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 +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.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 -- + +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 + } + +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, + 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, + ) + +// -- 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) + + 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() } + } + + 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 switchTab(tab: AppDrawerTab) { + activeTab = tab + selectedIndex = 0 + } + + fun moveSelection( + delta: Int, + wsCount: Int = 0, + ) { + 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( + screen: DeckColumnType, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, + ) { + if (screen.requiresInput()) { + awaitingHashtag = true + hashtagInput = "" + } else { + onSelectScreen(screen) + onDismiss() + } + } + + fun selectCurrent( + filteredWs: List, + onSelectScreen: (DeckColumnType) -> Unit, + onSwitchWorkspace: (Workspace) -> Unit, + onDismiss: () -> Unit, + ) { + 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 { + when (activeTab) { + AppDrawerTab.SCREENS -> { + filteredScreens.getOrNull(selectedIndex)?.let { select(it, onSelectScreen, onDismiss) } + } + + AppDrawerTab.WORKSPACES -> { + filteredWs.getOrNull(selectedIndex)?.let { + onSwitchWorkspace(it) + onDismiss() + } + } + } + } + } + + fun confirmHashtag( + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, + ) { + if (hashtagInput.isBlank()) return + onSelectScreen(DeckColumnType.Hashtag(hashtagInput.removePrefix("#").trim())) + onDismiss() + } +} + +// -- Composables -- + +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AppDrawer( + openColumnTypes: Set, + pinnedNavBarState: PinnedNavBarState, + workspaceManager: WorkspaceManager, + onSwitchWorkspace: (Workspace) -> Unit, + onSelectScreen: (DeckColumnType) -> Unit, + onDismiss: () -> Unit, +) { + 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) + 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, filteredWs.size) + true + } + + Key.DirectionUp -> { + state.moveSelection(-1, filteredWs.size) + true + } + + Key.Enter -> { + if (state.awaitingHashtag) { + state.confirmHashtag(onSelectScreen, onDismiss) + } else { + state.selectCurrent( + filteredWs, + onSelectScreen, + onSwitchWorkspace, + 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 and workspaces...") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, "Search") }, + ) + + if (state.awaitingHashtag) { + HashtagInputSection(state, onSelectScreen, onDismiss) + } else if (state.isSearching) { + Box(Modifier.weight(1f)) { + 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.switchTab(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, + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) +@Composable +private fun DrawerGrid( + state: AppDrawerState, + openColumnTypes: Set, + pinnedNavBarState: PinnedNavBarState, + 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()), + 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 }, + ) + } + } + } + globalIndex += screens.size + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun DrawerScreenCard( + type: DeckColumnType, + isSelected: Boolean, + isOpen: Boolean, + isPinned: Boolean, + onClick: () -> Unit, + onTogglePin: () -> Unit, + onHover: () -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + + Box { + Surface( + modifier = + Modifier + .size(80.dp) + .clickable(onClick = onClick) + .onPointerEvent(PointerEventType.Enter) { onHover() } + .onPointerEvent(PointerEventType.Press) { event -> + if (event.buttons.isSecondaryPressed && + event.changes.any { it.pressed && !it.previousPressed } + ) { + 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), + ) + } + } + } + + // Context menu for pin/unpin + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + if (PinnedNavBarState.isPinnable(type)) { + 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), + ) + }, + ) + } + } + } +} + +@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") + } + } + } +} + +// -- Workspaces Tab -- + +@Composable +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) } + + 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) + } + }, + onEdit = { + editTarget = ws + showEditor = true + }, + onDelete = { + val wasActive = index == activeIndex + workspaceManager.deleteWorkspace(ws.id) + if (wasActive) { + onSwitchWorkspace(workspaceManager.activeWorkspace) + } + }, + 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) { + Spacer(Modifier.width(8.dp)) + 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 singlePaneScreens by remember { + mutableStateOf(initial?.singlePaneScreens ?: listOf("home")) + } + + 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), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val isSelected = { iName: String -> iName == iconName } + WorkspaceIcons.availableNames.forEach { iName -> + 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 + }, + ) + } + } + } + } + // 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", maxLines = 1) + } + 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: nav bar screen selector (first = default) + if (layoutMode == LayoutMode.SINGLE_PANE) { + SinglePaneScreensEditor( + screens = singlePaneScreens, + onScreensChange = { singlePaneScreens = it }, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + onSave( + Workspace( + id = + initial?.id ?: java.util.UUID + .randomUUID() + .toString(), + name = name.ifBlank { "Untitled" }, + iconName = iconName, + layoutMode = layoutMode, + columns = columns, + singlePaneScreens = singlePaneScreens, + ), + ) + }, + 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 SinglePaneScreensEditor( + screens: List, + onScreensChange: (List) -> Unit, +) { + Column { + 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)) + 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 + }, + ) + } + } + } +} + +// -- 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 = { /* edit via Workspaces tab */ }, + onDelete = { + val wasActive = wsIdx == activeIndex + workspaceManager.deleteWorkspace(ws.id) + if (wasActive) { + onSwitchWorkspace(workspaceManager.activeWorkspace) + } + }, + 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 bdfb40298..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,13 +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 - else -> null - }, + "param" to col.type.param(), ) } DesktopPreferences.deckColumns = mapper.writeValueAsString(data) @@ -256,6 +250,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 @@ -271,6 +279,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 @@ -285,6 +298,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/PinnedNavBarState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt new file mode 100644 index 000000000..6deb120b7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/PinnedNavBarState.kt @@ -0,0 +1,114 @@ +/* + * 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 +import kotlinx.coroutines.flow.update + +/** + * Manages which screens are pinned to the navigation sidebar. + * Pin/unpin syncs to the active workspace's singlePaneScreens. + */ +class PinnedNavBarState( + private val workspaceManager: WorkspaceManager? = null, +) { + 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 } + syncToWorkspace() + } + + fun unpin(type: DeckColumnType) { + if (!isUnpinnable(type)) return + _pinnedScreens.update { current -> current.filter { it.typeKey() != type.typeKey() } } + syncToWorkspace() + } + + 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() + } + syncToWorkspace() + } + + fun loadFromList(screens: List) { + _pinnedScreens.value = screens.ifEmpty { DEFAULT_PINNED } + } + + 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 } + } + } + + 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 { + 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, + ) + + 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 5e2599627..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,15 +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.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.material.icons.filled.Apps import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail @@ -49,11 +41,8 @@ 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 import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState @@ -73,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 +74,9 @@ fun SinglePaneLayout( highlightStore: DesktopHighlightStore, 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, onZapFeedback: (ZapFeedback) -> Unit, @@ -114,7 +85,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() @@ -127,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 = { - currentColumnType = 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, @@ -152,6 +124,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 +162,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 + } +} 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..0e2d2c62a --- /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 singlePaneScreens: List = emptyList(), +) { + 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..2de3e9cf4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -0,0 +1,227 @@ +/* + * 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 = col.type.param(), + 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 deletedIdx = _workspaces.value.indexOfFirst { it.id == id } + if (deletedIdx < 0) return + _workspaces.update { it.filter { ws -> ws.id != id } } + _activeIndex.value = + when { + deletedIdx < _activeIndex.value -> _activeIndex.value - 1 + deletedIdx == _activeIndex.value -> 0 + else -> _activeIndex.value + }.coerceIn(_workspaces.value.indices) + 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, + "singlePaneScreens" to ws.singlePaneScreens, + "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 + } + + @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 = + (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, + singlePaneScreens = singlePaneScreens, + ) + } 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"), + ), + ) + } +}