Merge pull request #2445 from nrobi144/feat/desktop-app-drawer
feat(desktop): App Drawer with workspaces, customizable nav bar
This commit is contained in:
+16
@@ -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)
|
||||
|
||||
-165
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+1201
File diff suppressed because it is too large
Load Diff
+24
-7
@@ -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) }
|
||||
|
||||
+114
@@ -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
|
||||
}
|
||||
}
|
||||
+32
-41
@@ -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),
|
||||
|
||||
+37
@@ -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
|
||||
}
|
||||
+227
@@ -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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user