diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 069c06de4..11e9521bb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index a6d9cad7d..b16540f6c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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(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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index 56a360e74..eabbf78d4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -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, 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), + ) + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index bdfb40298..6c033deaf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -256,6 +256,20 @@ class DeckState( } } + fun loadFromWorkspace(workspaceColumns: List) { + 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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt new file mode 100644 index 000000000..0c3e4f801 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/Workspace.kt @@ -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, + val singlePaneScreen: String? = null, +) { + data class WorkspaceColumn( + val typeKey: String, + val param: String? = null, + val width: Float = 400f, + ) +} + +object WorkspaceIcons { + private val icons: Map = + 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 = icons.keys.sorted() + + fun resolve(name: String): ImageVector = icons[name] ?: Icons.Default.Home +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt new file mode 100644 index 000000000..4317f10ca --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/WorkspaceManager.kt @@ -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> = _workspaces.asStateFlow() + + private val _activeIndex = MutableStateFlow(0) + val activeIndex: StateFlow = _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) { + _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 = mapper.readValue(json) + val activeIdx = (data["activeIndex"] as? Number)?.toInt() ?: 0 + + @Suppress("UNCHECKED_CAST") + val wsList = data["workspaces"] as? List> ?: 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 { 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"), + ), + ) + } +}