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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-17 13:19:39 +03:00
parent a525ebabd5
commit e79096f16b
5 changed files with 589 additions and 189 deletions
@@ -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<Int>,
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)
@@ -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")
}
},
)
}
@@ -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<DeckColumnType> =
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<DeckColumnType> 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<ScreenCategory, List<DeckColumnType>> 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<String>,
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<String>,
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")
}
}
}
}
@@ -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>(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),
@@ -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>(DeckColumnType.HomeFeed)
val currentScreen: StateFlow<DeckColumnType> = _currentScreen.asStateFlow()
fun navigate(type: DeckColumnType) {
_currentScreen.value = type
}
}