From e79096f16b028eee011434efb227f1433b60bb0e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 17 Apr 2026 13:19:39 +0300 Subject: [PATCH] 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 + } +}