Merge pull request #2445 from nrobi144/feat/desktop-app-drawer

feat(desktop): App Drawer with workspaces, customizable nav bar
This commit is contained in:
Vitor Pamplona
2026-04-19 10:57:23 -04:00
committed by GitHub
10 changed files with 1862 additions and 232 deletions
@@ -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"
@@ -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<com.vitorpamplona.quartz.nip01Core.core.Event?>(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<Int>,
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)
@@ -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")
}
},
)
}
@@ -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<Workspace.WorkspaceColumn>) {
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<String, Any?>): 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) }
@@ -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<List<DeckColumnType>> = _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<DeckColumnType>) {
_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<DeckColumnType> =
LAUNCHABLE_SCREENS.filter { !it.requiresInput() && it !is DeckColumnType.Editor }
val DEFAULT_PINNED: List<DeckColumnType> =
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
}
}
@@ -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>(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),
@@ -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
}
}
@@ -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<WorkspaceColumn>,
val singlePaneScreens: List<String> = emptyList(),
) {
data class WorkspaceColumn(
val typeKey: String,
val param: String? = null,
val width: Float = 400f,
)
}
object WorkspaceIcons {
private val icons: Map<String, ImageVector> =
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<String> = icons.keys.sorted()
fun resolve(name: String): ImageVector = icons[name] ?: Icons.Default.Home
}
@@ -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<List<Workspace>> = _workspaces.asStateFlow()
private val _activeIndex = MutableStateFlow(0)
val activeIndex: StateFlow<Int> = _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<DeckColumn>) {
_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<String, Any?> = mapper.readValue(json)
val activeIdx = (data["activeIndex"] as? Number)?.toInt() ?: 0
@Suppress("UNCHECKED_CAST")
val wsList = data["workspaces"] as? List<Map<String, Any?>> ?: 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<String>) ?: 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<String, Any?>>)?.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"),
),
)
}
}