feat(desktop): workspace system with save/switch/restore (v1c)
Add workspace presets for different usage modes. Each workspace stores layout mode + column configuration. Switching destroys current layout and rebuilds from saved config (no background state). - Add Workspace data model with WorkspaceColumn - Add WorkspaceManager with save/load/switch/add/delete - Add WorkspaceIcons registry for Material icon resolution - Add WorkspaceBar to AppDrawer footer with workspace chips - Add DeckState.loadFromWorkspace() for column rebuilding - Add workspaces persistence to DesktopPreferences - Default workspace: "Social" (Home + Notifications + DMs) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,14 @@ 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
|
||||
|
||||
@@ -96,6 +96,7 @@ 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.WorkspaceManager
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||
@@ -192,6 +193,7 @@ 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 showAppDrawer by remember { mutableStateOf(false) }
|
||||
@@ -446,6 +448,7 @@ fun main() {
|
||||
App(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
workspaceManager = workspaceManager,
|
||||
accountManager = accountManager,
|
||||
showComposeDialog = showComposeDialog,
|
||||
showAppDrawer = showAppDrawer,
|
||||
@@ -477,6 +480,7 @@ fun main() {
|
||||
fun App(
|
||||
layoutMode: LayoutMode,
|
||||
deckState: DeckState,
|
||||
workspaceManager: WorkspaceManager,
|
||||
accountManager: AccountManager,
|
||||
showComposeDialog: Boolean,
|
||||
showAppDrawer: Boolean,
|
||||
@@ -699,6 +703,7 @@ fun App(
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
workspaceManager = workspaceManager,
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
relayManager = relayManager,
|
||||
@@ -736,6 +741,10 @@ fun App(
|
||||
emptySet()
|
||||
},
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
workspaceManager = workspaceManager,
|
||||
onSwitchWorkspace = { ws ->
|
||||
deckState.loadFromWorkspace(ws.columns)
|
||||
},
|
||||
onSelectScreen = { type ->
|
||||
when (layoutMode) {
|
||||
LayoutMode.DECK -> {
|
||||
@@ -773,6 +782,7 @@ fun App(
|
||||
fun MainContent(
|
||||
layoutMode: LayoutMode,
|
||||
deckState: DeckState,
|
||||
workspaceManager: WorkspaceManager,
|
||||
singlePaneState: SinglePaneState,
|
||||
pinnedNavBarState: PinnedNavBarState,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
|
||||
+58
-1
@@ -59,6 +59,7 @@ import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -216,6 +217,8 @@ private class AppDrawerState {
|
||||
fun AppDrawer(
|
||||
openColumnTypes: Set<String>,
|
||||
pinnedNavBarState: PinnedNavBarState,
|
||||
workspaceManager: WorkspaceManager,
|
||||
onSwitchWorkspace: (Workspace) -> Unit,
|
||||
onSelectScreen: (DeckColumnType) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -306,7 +309,17 @@ fun AppDrawer(
|
||||
if (state.awaitingHashtag) {
|
||||
HashtagInputSection(state, onSelectScreen, onDismiss)
|
||||
} else {
|
||||
DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss)
|
||||
Box(Modifier.weight(1f)) {
|
||||
DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss)
|
||||
}
|
||||
// Workspace bar at the bottom
|
||||
WorkspaceBar(
|
||||
workspaceManager = workspaceManager,
|
||||
onSwitchWorkspace = { ws ->
|
||||
onSwitchWorkspace(ws)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -528,3 +541,47 @@ private fun HashtagInputSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkspaceBar(
|
||||
workspaceManager: WorkspaceManager,
|
||||
onSwitchWorkspace: (Workspace) -> Unit,
|
||||
) {
|
||||
val workspaces by workspaceManager.workspaces.collectAsState()
|
||||
val activeIndex by workspaceManager.activeIndex.collectAsState()
|
||||
|
||||
androidx.compose.material3.HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Workspaces",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
workspaces.forEachIndexed { index, ws ->
|
||||
val isActive = index == activeIndex
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = isActive,
|
||||
onClick = {
|
||||
if (!isActive) {
|
||||
val switched = workspaceManager.switchTo(index)
|
||||
if (switched != null) onSwitchWorkspace(switched)
|
||||
}
|
||||
},
|
||||
label = { Text(ws.name, style = MaterialTheme.typography.labelSmall) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
WorkspaceIcons.resolve(ws.iconName),
|
||||
contentDescription = ws.name,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,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
|
||||
|
||||
@@ -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 singlePaneScreen: String? = null,
|
||||
) {
|
||||
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
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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 =
|
||||
when (col.type) {
|
||||
is DeckColumnType.Profile -> col.type.pubKeyHex
|
||||
is DeckColumnType.Thread -> col.type.noteId
|
||||
is DeckColumnType.Hashtag -> col.type.tag
|
||||
else -> null
|
||||
},
|
||||
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 idx = _workspaces.value.indexOfFirst { it.id == id }
|
||||
_workspaces.update { it.filter { ws -> ws.id != id } }
|
||||
if (_activeIndex.value >= _workspaces.value.size) {
|
||||
_activeIndex.value = _workspaces.value.size - 1
|
||||
}
|
||||
if (idx == _activeIndex.value || _activeIndex.value >= _workspaces.value.size) {
|
||||
_activeIndex.value = 0
|
||||
}
|
||||
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,
|
||||
"singlePaneScreen" to ws.singlePaneScreen,
|
||||
"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
|
||||
}
|
||||
val singlePaneScreen = entry["singlePaneScreen"] as? String
|
||||
|
||||
@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,
|
||||
singlePaneScreen = singlePaneScreen,
|
||||
)
|
||||
} 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