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)
|
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 KEY_BLOSSOM_SERVERS = "blossom_servers"
|
||||||
private const val DEFAULT_BLOSSOM_SERVER = "https://blossom.primal.net"
|
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.ZapFeedback
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog
|
import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
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.DeckColumnType
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout
|
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar
|
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
|
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.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.LocalAwtWindow
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
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) }
|
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
|
||||||
val deckScope = rememberCoroutineScope()
|
val deckScope = rememberCoroutineScope()
|
||||||
val deckState = remember { DeckState(deckScope).also { it.load() } }
|
val deckState = remember { DeckState(deckScope).also { it.load() } }
|
||||||
|
val workspaceManager = remember { WorkspaceManager(deckScope).also { it.load() } }
|
||||||
val accountManager = remember { AccountManager.create() }
|
val accountManager = remember { AccountManager.create() }
|
||||||
val accountState by accountManager.accountState.collectAsState()
|
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
|
// Tor state at Window level — survives key() app rebuild
|
||||||
var torSettings by remember {
|
var torSettings by remember {
|
||||||
@@ -237,6 +243,42 @@ fun main() {
|
|||||||
},
|
},
|
||||||
onClick = { showComposeDialog = true },
|
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()
|
Separator()
|
||||||
Item(
|
Item(
|
||||||
"Settings",
|
"Settings",
|
||||||
@@ -299,6 +341,17 @@ fun main() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Menu("View") {
|
Menu("View") {
|
||||||
|
Item(
|
||||||
|
"App Drawer",
|
||||||
|
shortcut =
|
||||||
|
if (isMacOS) {
|
||||||
|
KeyShortcut(Key.K, meta = true)
|
||||||
|
} else {
|
||||||
|
KeyShortcut(Key.K, ctrl = true)
|
||||||
|
},
|
||||||
|
onClick = { showAppDrawer = !showAppDrawer },
|
||||||
|
)
|
||||||
|
Separator()
|
||||||
Item(
|
Item(
|
||||||
if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout",
|
if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout",
|
||||||
shortcut =
|
shortcut =
|
||||||
@@ -323,7 +376,7 @@ fun main() {
|
|||||||
} else {
|
} else {
|
||||||
KeyShortcut(Key.T, ctrl = true)
|
KeyShortcut(Key.T, ctrl = true)
|
||||||
},
|
},
|
||||||
onClick = { showAddColumnDialog = true },
|
onClick = { showAppDrawer = true },
|
||||||
)
|
)
|
||||||
Item(
|
Item(
|
||||||
"Close Column",
|
"Close Column",
|
||||||
@@ -432,10 +485,15 @@ fun main() {
|
|||||||
key(appRestartKey) {
|
key(appRestartKey) {
|
||||||
App(
|
App(
|
||||||
layoutMode = layoutMode,
|
layoutMode = layoutMode,
|
||||||
|
onLayoutModeChange = { newMode ->
|
||||||
|
layoutMode = newMode
|
||||||
|
DesktopPreferences.layoutMode = newMode.name
|
||||||
|
},
|
||||||
deckState = deckState,
|
deckState = deckState,
|
||||||
|
workspaceManager = workspaceManager,
|
||||||
accountManager = accountManager,
|
accountManager = accountManager,
|
||||||
showComposeDialog = showComposeDialog,
|
showComposeDialog = showComposeDialog,
|
||||||
showAddColumnDialog = showAddColumnDialog,
|
showAppDrawer = showAppDrawer,
|
||||||
onShowComposeDialog = { showComposeDialog = true },
|
onShowComposeDialog = { showComposeDialog = true },
|
||||||
onShowReplyDialog = { event ->
|
onShowReplyDialog = { event ->
|
||||||
replyToNote = event
|
replyToNote = event
|
||||||
@@ -445,8 +503,8 @@ fun main() {
|
|||||||
showComposeDialog = false
|
showComposeDialog = false
|
||||||
replyToNote = null
|
replyToNote = null
|
||||||
},
|
},
|
||||||
onDismissAddColumnDialog = { showAddColumnDialog = false },
|
onDismissAppDrawer = { showAppDrawer = false },
|
||||||
onShowAddColumnDialog = { showAddColumnDialog = true },
|
onShowAppDrawer = { showAppDrawer = true },
|
||||||
replyToNote = replyToNote,
|
replyToNote = replyToNote,
|
||||||
onRestartApp = { appRestartKey++ },
|
onRestartApp = { appRestartKey++ },
|
||||||
torManager = torManager,
|
torManager = torManager,
|
||||||
@@ -463,15 +521,17 @@ fun main() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
layoutMode: LayoutMode,
|
layoutMode: LayoutMode,
|
||||||
|
onLayoutModeChange: (LayoutMode) -> Unit,
|
||||||
deckState: DeckState,
|
deckState: DeckState,
|
||||||
|
workspaceManager: WorkspaceManager,
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
showComposeDialog: Boolean,
|
showComposeDialog: Boolean,
|
||||||
showAddColumnDialog: Boolean,
|
showAppDrawer: Boolean,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onDismissComposeDialog: () -> Unit,
|
onDismissComposeDialog: () -> Unit,
|
||||||
onDismissAddColumnDialog: () -> Unit,
|
onDismissAppDrawer: () -> Unit,
|
||||||
onShowAddColumnDialog: () -> Unit,
|
onShowAppDrawer: () -> Unit,
|
||||||
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
||||||
onRestartApp: () -> Unit = {},
|
onRestartApp: () -> Unit = {},
|
||||||
torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager,
|
torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager,
|
||||||
@@ -479,6 +539,9 @@ fun App(
|
|||||||
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
|
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
|
||||||
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
|
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
|
// Always reload from prefs — after key() rebuild, prefs have the latest saved settings
|
||||||
var torSettings by remember {
|
var torSettings by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -683,6 +746,9 @@ fun App(
|
|||||||
MainContent(
|
MainContent(
|
||||||
layoutMode = layoutMode,
|
layoutMode = layoutMode,
|
||||||
deckState = deckState,
|
deckState = deckState,
|
||||||
|
workspaceManager = workspaceManager,
|
||||||
|
singlePaneState = singlePaneState,
|
||||||
|
pinnedNavBarState = pinnedNavBarState,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
accountManager = accountManager,
|
accountManager = accountManager,
|
||||||
@@ -693,7 +759,7 @@ fun App(
|
|||||||
torStatus = currentTorStatus,
|
torStatus = currentTorStatus,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onShowAddColumnDialog = onShowAddColumnDialog,
|
onShowAppDrawer = onShowAppDrawer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,14 +773,53 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add column dialog
|
// App Drawer overlay
|
||||||
if (showAddColumnDialog) {
|
if (showAppDrawer) {
|
||||||
AddColumnDialog(
|
val openColumns by deckState.columns.collectAsState()
|
||||||
onDismiss = onDismissAddColumnDialog,
|
AppDrawer(
|
||||||
onAdd = { type ->
|
openColumnTypes =
|
||||||
deckState.addColumn(type)
|
if (layoutMode == LayoutMode.DECK) {
|
||||||
onDismissAddColumnDialog()
|
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(
|
fun MainContent(
|
||||||
layoutMode: LayoutMode,
|
layoutMode: LayoutMode,
|
||||||
deckState: DeckState,
|
deckState: DeckState,
|
||||||
|
workspaceManager: WorkspaceManager,
|
||||||
|
singlePaneState: SinglePaneState,
|
||||||
|
pinnedNavBarState: PinnedNavBarState,
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
localCache: DesktopLocalCache,
|
localCache: DesktopLocalCache,
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
@@ -746,7 +854,7 @@ fun MainContent(
|
|||||||
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
|
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onShowAddColumnDialog: () -> Unit,
|
onShowAppDrawer: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -893,6 +1001,9 @@ fun MainContent(
|
|||||||
highlightStore = highlightStore,
|
highlightStore = highlightStore,
|
||||||
draftStore = draftStore,
|
draftStore = draftStore,
|
||||||
appScope = appScope,
|
appScope = appScope,
|
||||||
|
singlePaneState = singlePaneState,
|
||||||
|
pinnedNavBarState = pinnedNavBarState,
|
||||||
|
onOpenAppDrawer = onShowAppDrawer,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
@@ -906,7 +1017,7 @@ fun MainContent(
|
|||||||
LayoutMode.DECK -> {
|
LayoutMode.DECK -> {
|
||||||
if (!isImmersive) {
|
if (!isImmersive) {
|
||||||
DeckSidebar(
|
DeckSidebar(
|
||||||
onAddColumn = onShowAddColumnDialog,
|
onAddColumn = onShowAppDrawer,
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||||
deckState.focusExistingColumn(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,
|
"id" to col.id,
|
||||||
"type" to col.type.typeKey(),
|
"type" to col.type.typeKey(),
|
||||||
"width" to col.width,
|
"width" to col.width,
|
||||||
"param" to
|
"param" to col.type.param(),
|
||||||
when (col.type) {
|
|
||||||
is DeckColumnType.Profile -> col.type.pubKeyHex
|
|
||||||
is DeckColumnType.Thread -> col.type.noteId
|
|
||||||
is DeckColumnType.Hashtag -> col.type.tag
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DesktopPreferences.deckColumns = mapper.writeValueAsString(data)
|
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 {
|
companion object {
|
||||||
const val MIN_COLUMN_WIDTH = 300f
|
const val MIN_COLUMN_WIDTH = 300f
|
||||||
const val MAX_COLUMN_WIDTH = 800f
|
const val MAX_COLUMN_WIDTH = 800f
|
||||||
@@ -271,6 +279,11 @@ class DeckState(
|
|||||||
|
|
||||||
private val mapper = jacksonObjectMapper()
|
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? {
|
private fun parseColumnType(entry: Map<String, Any?>): DeckColumnType? {
|
||||||
val typeKey = entry["type"] as? String ?: return null
|
val typeKey = entry["type"] as? String ?: return null
|
||||||
val param = entry["param"] as? String
|
val param = entry["param"] as? String
|
||||||
@@ -285,6 +298,10 @@ class DeckState(
|
|||||||
"my_profile" -> DeckColumnType.MyProfile
|
"my_profile" -> DeckColumnType.MyProfile
|
||||||
"chess" -> DeckColumnType.Chess
|
"chess" -> DeckColumnType.Chess
|
||||||
"settings" -> DeckColumnType.Settings
|
"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) }
|
"profile" -> param?.let { DeckColumnType.Profile(it) }
|
||||||
"thread" -> param?.let { DeckColumnType.Thread(it) }
|
"thread" -> param?.let { DeckColumnType.Thread(it) }
|
||||||
"hashtag" -> param?.let { DeckColumnType.Hashtag(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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
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
|
|
||||||
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.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationRail
|
import androidx.compose.material3.NavigationRail
|
||||||
@@ -49,11 +41,8 @@ import androidx.compose.material3.VerticalDivider
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
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 com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
|
||||||
import kotlinx.coroutines.CoroutineScope
|
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
|
@Composable
|
||||||
fun SinglePaneLayout(
|
fun SinglePaneLayout(
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
@@ -106,6 +74,9 @@ fun SinglePaneLayout(
|
|||||||
highlightStore: DesktopHighlightStore,
|
highlightStore: DesktopHighlightStore,
|
||||||
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
|
draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore,
|
||||||
appScope: CoroutineScope,
|
appScope: CoroutineScope,
|
||||||
|
singlePaneState: SinglePaneState,
|
||||||
|
pinnedNavBarState: PinnedNavBarState,
|
||||||
|
onOpenAppDrawer: () -> Unit,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onZapFeedback: (ZapFeedback) -> Unit,
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
@@ -114,7 +85,7 @@ fun SinglePaneLayout(
|
|||||||
lastRelayEventAt: Long? = null,
|
lastRelayEventAt: Long? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var currentColumnType by remember { mutableStateOf<DeckColumnType>(DeckColumnType.HomeFeed) }
|
val currentColumnType by singlePaneState.currentScreen.collectAsState()
|
||||||
val navState = remember { ColumnNavigationState() }
|
val navState = remember { ColumnNavigationState() }
|
||||||
val navStack by navState.stack.collectAsState()
|
val navStack by navState.stack.collectAsState()
|
||||||
val currentOverlay = navStack.lastOrNull()
|
val currentOverlay = navStack.lastOrNull()
|
||||||
@@ -127,23 +98,24 @@ fun SinglePaneLayout(
|
|||||||
modifier = Modifier.width(80.dp).fillMaxHeight(),
|
modifier = Modifier.width(80.dp).fillMaxHeight(),
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
) {
|
) {
|
||||||
navItems.forEach { item ->
|
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
|
||||||
|
pinnedScreens.forEach { screenType ->
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
selected = currentColumnType == item.type && navStack.isEmpty(),
|
selected = currentColumnType == screenType && navStack.isEmpty(),
|
||||||
onClick = {
|
onClick = {
|
||||||
currentColumnType = item.type
|
singlePaneState.navigate(screenType)
|
||||||
navState.clear()
|
navState.clear()
|
||||||
},
|
},
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
item.icon,
|
screenType.icon(),
|
||||||
contentDescription = item.label,
|
contentDescription = screenType.title(),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
item.label,
|
screenType.title(),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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))
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
// Relay health — shows elapsed time since last event (hidden when <30s)
|
// Relay health — shows elapsed time since last event (hidden when <30s)
|
||||||
@@ -171,7 +162,7 @@ fun SinglePaneLayout(
|
|||||||
TorStatusIndicator(
|
TorStatusIndicator(
|
||||||
status = torState.status,
|
status = torState.status,
|
||||||
onClick = {
|
onClick = {
|
||||||
currentColumnType = DeckColumnType.Settings
|
singlePaneState.navigate(DeckColumnType.Settings)
|
||||||
navState.clear()
|
navState.clear()
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
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