From e7239980c6c4d5abc7a0ccd257ca1c176f06a6c7 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Feb 2026 11:53:31 +0200 Subject: [PATCH 1/4] feat(desktop): add multi-column deck layout replacing NavigationRail Replace the 80dp NavigationRail + single content area with a TweetDeck-style multi-column deck. Default layout: Home Feed + Notifications + Messages. New deck system: - DeckColumnType sealed class with 12 column types - DeckState with StateFlow-based column management - Per-column navigation stack for in-column drill-down - Draggable dividers for column resize (300-800dp) - Column config persisted via Jackson JSON in DesktopPreferences - 48dp thin sidebar with add-column and settings buttons - AddColumnDialog for picking column types Keyboard shortcuts: - Cmd+T: Add column - Cmd+W: Close focused column - Cmd+1-9: Focus column by index - Cmd+Shift+Left/Right: Move column left/right Co-Authored-By: Claude Opus 4.6 --- .../amethyst/desktop/DesktopPreferences.kt | 7 + .../vitorpamplona/amethyst/desktop/Main.kt | 403 +++++++----------- .../desktop/ui/deck/AddColumnDialog.kt | 162 +++++++ .../amethyst/desktop/ui/deck/ColumnHeader.kt | 129 ++++++ .../desktop/ui/deck/DeckColumnContainer.kt | 365 ++++++++++++++++ .../desktop/ui/deck/DeckColumnType.kt | 93 ++++ .../amethyst/desktop/ui/deck/DeckLayout.kt | 122 ++++++ .../amethyst/desktop/ui/deck/DeckSidebar.kt | 89 ++++ .../amethyst/desktop/ui/deck/DeckState.kt | 163 +++++++ 9 files changed, 1279 insertions(+), 254 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt 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 a78c3b68e..2dbcbbdd3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -35,6 +35,7 @@ object DesktopPreferences { private const val KEY_FEED_MODE = "feed_mode" private const val KEY_LAST_SCREEN = "last_screen" + private const val KEY_DECK_COLUMNS = "deck_columns" var feedMode: FeedMode get() { @@ -54,4 +55,10 @@ object DesktopPreferences { set(value) { prefs.put(KEY_LAST_SCREEN, value) } + + var deckColumns: String + get() = prefs.get(KEY_DECK_COLUMNS, "") + set(value) { + prefs.put(KEY_DECK_COLUMNS, value) + } } 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 e97b2f18a..86099ffdc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -25,31 +25,20 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Article -import androidx.compose.material.icons.filled.Email -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.Refresh -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHost @@ -82,21 +71,16 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState -import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator -import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog -import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen -import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen -import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen -import com.vitorpamplona.amethyst.desktop.ui.SearchScreen -import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen -import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback -import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen -import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog +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.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect @@ -109,7 +93,7 @@ import kotlinx.coroutines.launch private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") /** - * Desktop navigation state - extends AppScreen with dynamic destinations. + * Desktop navigation state — used for in-column navigation (drill-down). */ sealed class DesktopScreen { object Feed : DesktopScreen() @@ -147,7 +131,8 @@ fun main() = ) var showComposeDialog by remember { mutableStateOf(false) } var replyToNote by remember { mutableStateOf(null) } - var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } + val deckState = remember { DeckState().also { it.load() } } + var showAddColumnDialog by remember { mutableStateOf(false) } Window( onCloseRequest = ::exitApplication, @@ -175,7 +160,7 @@ fun main() = } else { KeyShortcut(Key.Comma, ctrl = true) }, - onClick = { currentScreen = DesktopScreen.Settings }, + onClick = { deckState.addColumn(DeckColumnType.Settings) }, ) Separator() Item( @@ -212,9 +197,102 @@ fun main() = ) } Menu("View") { - Item("Feed", onClick = { }) - Item("Messages", onClick = { }) - Item("Notifications", onClick = { }) + Item( + "Add Column", + shortcut = + if (isMacOS) { + KeyShortcut(Key.T, meta = true) + } else { + KeyShortcut(Key.T, ctrl = true) + }, + onClick = { showAddColumnDialog = true }, + ) + Item( + "Close Column", + shortcut = + if (isMacOS) { + KeyShortcut(Key.W, meta = true) + } else { + KeyShortcut(Key.W, ctrl = true) + }, + onClick = { + val cols = deckState.columns.value + val idx = deckState.focusedColumnIndex.value + if (cols.size > 1 && idx in cols.indices) { + deckState.removeColumn(cols[idx].id) + } + }, + ) + Item( + "Move Column Left", + shortcut = + if (isMacOS) { + KeyShortcut(Key.DirectionLeft, meta = true, shift = true) + } else { + KeyShortcut(Key.DirectionLeft, ctrl = true, shift = true) + }, + onClick = { + val idx = deckState.focusedColumnIndex.value + if (idx > 0) { + deckState.moveColumn(idx, idx - 1) + deckState.focusColumn(idx - 1) + } + }, + ) + Item( + "Move Column Right", + shortcut = + if (isMacOS) { + KeyShortcut(Key.DirectionRight, meta = true, shift = true) + } else { + KeyShortcut(Key.DirectionRight, ctrl = true, shift = true) + }, + onClick = { + val idx = deckState.focusedColumnIndex.value + val size = deckState.columns.value.size + if (idx < size - 1) { + deckState.moveColumn(idx, idx + 1) + deckState.focusColumn(idx + 1) + } + }, + ) + Separator() + // Focus column by index (Cmd/Ctrl+1..9) + val columnKeys = + listOf( + Key.One, + Key.Two, + Key.Three, + Key.Four, + Key.Five, + Key.Six, + Key.Seven, + Key.Eight, + Key.Nine, + ) + columnKeys.forEachIndexed { i, key -> + Item( + "Column ${i + 1}", + shortcut = + if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + }, + onClick = { deckState.focusColumn(i) }, + ) + } + Separator() + Menu("Add Column...") { + Item("Home Feed", onClick = { deckState.addColumn(DeckColumnType.HomeFeed) }) + Item("Notifications", onClick = { deckState.addColumn(DeckColumnType.Notifications) }) + Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) + Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) + Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) + Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) + Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) + Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) + } } Menu("Help") { Item("About Amethyst", onClick = { }) @@ -223,9 +301,9 @@ fun main() = } App( - currentScreen = currentScreen, - onScreenChange = { currentScreen = it }, + deckState = deckState, showComposeDialog = showComposeDialog, + showAddColumnDialog = showAddColumnDialog, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -235,6 +313,8 @@ fun main() = showComposeDialog = false replyToNote = null }, + onDismissAddColumnDialog = { showAddColumnDialog = false }, + onShowAddColumnDialog = { showAddColumnDialog = true }, replyToNote = replyToNote, ) } @@ -242,12 +322,14 @@ fun main() = @Composable fun App( - currentScreen: DesktopScreen, - onScreenChange: (DesktopScreen) -> Unit, + deckState: DeckState, showComposeDialog: Boolean, + showAddColumnDialog: Boolean, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, + onDismissAddColumnDialog: () -> Unit, + onShowAddColumnDialog: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, ) { val relayManager = remember { DesktopRelayConnectionManager() } @@ -329,7 +411,7 @@ fun App( is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { onScreenChange(DesktopScreen.Feed) }, + onLoginSuccess = { }, ) } @@ -343,16 +425,17 @@ fun App( } MainContent( - currentScreen = currentScreen, - onScreenChange = onScreenChange, + deckState = deckState, relayManager = relayManager, localCache = localCache, accountManager = accountManager, account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + appScope = scope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onShowAddColumnDialog = onShowAddColumnDialog, ) // Compose dialog @@ -364,6 +447,17 @@ fun App( replyTo = replyToNote, ) } + + // Add column dialog + if (showAddColumnDialog) { + AddColumnDialog( + onDismiss = onDismissAddColumnDialog, + onAdd = { type -> + deckState.addColumn(type) + onDismissAddColumnDialog() + }, + ) + } } } } @@ -372,16 +466,17 @@ fun App( @Composable fun MainContent( - currentScreen: DesktopScreen, - onScreenChange: (DesktopScreen) -> Unit, + deckState: DeckState, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, nwcConnection: Nip47WalletConnect.Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onShowAddColumnDialog: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -402,227 +497,27 @@ fun MainContent( Box(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) { - // Sidebar Navigation - NavigationRail( - modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ) { - Spacer(Modifier.height(16.dp)) - - NavigationRailItem( - icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, - label = { Text("Feed") }, - selected = currentScreen == DesktopScreen.Feed, - onClick = { onScreenChange(DesktopScreen.Feed) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") }, - label = { Text("Reads") }, - selected = currentScreen == DesktopScreen.Reads, - onClick = { onScreenChange(DesktopScreen.Reads) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, - label = { Text("Search") }, - selected = currentScreen == DesktopScreen.Search, - onClick = { onScreenChange(DesktopScreen.Search) }, - ) - - NavigationRailItem( - icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") }, - label = { Text("Bookmarks") }, - selected = currentScreen == DesktopScreen.Bookmarks, - onClick = { onScreenChange(DesktopScreen.Bookmarks) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, - label = { Text("DMs") }, - selected = currentScreen == DesktopScreen.Messages, - onClick = { onScreenChange(DesktopScreen.Messages) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, - label = { Text("Alerts") }, - selected = currentScreen == DesktopScreen.Notifications, - onClick = { onScreenChange(DesktopScreen.Notifications) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, - label = { Text("Profile") }, - selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile, - onClick = { onScreenChange(DesktopScreen.MyProfile) }, - ) - - Spacer(Modifier.weight(1f)) - - HorizontalDivider(Modifier.padding(horizontal = 16.dp)) - - NavigationRailItem( - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - label = { Text("Settings") }, - selected = currentScreen == DesktopScreen.Settings, - onClick = { onScreenChange(DesktopScreen.Settings) }, - ) - - Spacer(Modifier.height(16.dp)) - } + DeckSidebar( + onAddColumn = onShowAddColumnDialog, + onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) }, + ) VerticalDivider() - // Main Content - Box( - modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), - ) { - when (currentScreen) { - DesktopScreen.Feed -> { - FeedScreen( - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - onZapFeedback = onZapFeedback, - ) - } - - DesktopScreen.Reads -> { - ReadsScreen( - relayManager = relayManager, - localCache = localCache, - account = account, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToArticle = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - ) - } - - DesktopScreen.Search -> { - SearchScreen( - localCache = localCache, - relayManager = relayManager, - subscriptionsCoordinator = subscriptionsCoordinator, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - ) - } - - DesktopScreen.Bookmarks -> { - BookmarksScreen( - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - onZapFeedback = onZapFeedback, - ) - } - - DesktopScreen.Messages -> { - val dmSendTracker = - remember(relayManager) { - DmSendTracker(relayManager.client) - } - val iAccount = - remember(account, localCache, relayManager, dmSendTracker) { - DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope) - } - DesktopMessagesScreen( - account = iAccount, - cacheProvider = localCache, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - ) - } - - DesktopScreen.Notifications -> { - NotificationsScreen(relayManager, account, subscriptionsCoordinator) - } - - DesktopScreen.MyProfile -> { - UserProfileScreen( - pubKeyHex = account.pubKeyHex, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onZapFeedback = onZapFeedback, - ) - } - - is DesktopScreen.UserProfile -> { - UserProfileScreen( - pubKeyHex = currentScreen.pubKeyHex, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onZapFeedback = onZapFeedback, - ) - } - - is DesktopScreen.Thread -> { - ThreadScreen( - noteId = currentScreen.noteId, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - onZapFeedback = onZapFeedback, - onReply = onShowReplyDialog, - ) - } - - DesktopScreen.Settings -> { - RelaySettingsScreen(relayManager, account, accountManager) - } - } - } + DeckLayout( + deckState = deckState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) } // Snackbar for zap feedback diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt new file mode 100644 index 000000000..3c4f6628a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt @@ -0,0 +1,162 @@ +/* + * 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.Bookmarks, + DeckColumnType.GlobalFeed, + DeckColumnType.MyProfile, + ) + +@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") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt new file mode 100644 index 000000000..91ac19edb --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -0,0 +1,129 @@ +/* + * 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.background +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Email +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.Public +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +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 + +@Composable +fun ColumnHeader( + column: DeckColumn, + canClose: Boolean, + hasBackStack: Boolean, + onBack: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(40.dp) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasBackStack) { + IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(4.dp)) + } + + Icon( + imageVector = column.type.icon(), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = column.type.title(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + if (canClose) { + IconButton(onClick = onClose, modifier = Modifier.size(28.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Close column", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +fun DeckColumnType.icon(): ImageVector = + when (this) { + DeckColumnType.HomeFeed -> Icons.Default.Home + DeckColumnType.Notifications -> Icons.Default.Notifications + DeckColumnType.Messages -> Icons.Default.Email + DeckColumnType.Search -> Icons.Default.Search + DeckColumnType.Reads -> Icons.AutoMirrored.Filled.Article + DeckColumnType.Bookmarks -> com.vitorpamplona.amethyst.commons.icons.Bookmark + DeckColumnType.GlobalFeed -> Icons.Default.Public + DeckColumnType.MyProfile -> Icons.Default.Person + DeckColumnType.Settings -> Icons.Default.Settings + is DeckColumnType.Profile -> Icons.Default.Person + is DeckColumnType.Thread -> Icons.Default.Home + is DeckColumnType.Hashtag -> Icons.Default.Tag + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt new file mode 100644 index 000000000..bdcdee177 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -0,0 +1,365 @@ +/* + * 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.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.DesktopScreen +import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen +import com.vitorpamplona.amethyst.desktop.ui.FeedScreen +import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen +import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen +import com.vitorpamplona.amethyst.desktop.ui.SearchScreen +import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen +import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen +import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow + +class ColumnNavigationState { + private val _stack = MutableStateFlow>(emptyList()) + val stack = _stack + + fun push(screen: DesktopScreen) { + _stack.value = _stack.value + screen + } + + fun pop(): Boolean { + if (_stack.value.isEmpty()) return false + _stack.value = _stack.value.dropLast(1) + return true + } + + fun clear() { + _stack.value = emptyList() + } +} + +@Composable +fun DeckColumnContainer( + column: DeckColumn, + canClose: Boolean, + onClose: () -> Unit, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + modifier: Modifier = Modifier, +) { + val navState = remember(column.id) { ColumnNavigationState() } + val navStack by navState.stack.collectAsState() + val currentOverlay = navStack.lastOrNull() + + Column( + modifier = + modifier + .width(column.width.dp) + .fillMaxHeight(), + ) { + ColumnHeader( + column = column, + canClose = canClose, + hasBackStack = navStack.isNotEmpty(), + onBack = { navState.pop() }, + onClose = onClose, + ) + + HorizontalDivider() + + Box( + modifier = Modifier.fillMaxSize().padding(12.dp), + ) { + if (currentOverlay != null) { + OverlayContent( + screen = currentOverlay, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onBack = { navState.pop() }, + ) + } else { + RootContent( + columnType = column.type, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + ) + } + } + } +} + +@Composable +private fun RootContent( + columnType: DeckColumnType, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + + when (columnType) { + DeckColumnType.HomeFeed -> { + FeedScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.Notifications -> { + NotificationsScreen(relayManager, account, subscriptionsCoordinator) + } + + DeckColumnType.Messages -> { + val dmSendTracker = + remember(relayManager) { + DmSendTracker(relayManager.client) + } + val iAccount = + remember(account, localCache, relayManager, dmSendTracker) { + DesktopIAccount(account, localCache, relayManager, dmSendTracker, appScope) + } + DesktopMessagesScreen( + account = iAccount, + cacheProvider = localCache, + onNavigateToProfile = onNavigateToProfile, + ) + } + + DeckColumnType.Search -> { + SearchScreen( + localCache = localCache, + relayManager = relayManager, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + ) + } + + DeckColumnType.Reads -> { + ReadsScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToThread, + ) + } + + DeckColumnType.Bookmarks -> { + BookmarksScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.GlobalFeed -> { + FeedScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.MyProfile -> { + UserProfileScreen( + pubKeyHex = account.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = {}, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.Settings -> { + RelaySettingsScreen(relayManager, account, accountManager) + } + + is DeckColumnType.Profile -> { + UserProfileScreen( + pubKeyHex = columnType.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = {}, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onZapFeedback = onZapFeedback, + ) + } + + is DeckColumnType.Thread -> { + ThreadScreen( + noteId = columnType.noteId, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = {}, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + onReply = onShowReplyDialog, + ) + } + + is DeckColumnType.Hashtag -> { + SearchScreen( + localCache = localCache, + relayManager = relayManager, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + ) + } + } +} + +@Composable +private fun OverlayContent( + screen: DesktopScreen, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + onBack: () -> Unit, +) { + when (screen) { + is DesktopScreen.UserProfile -> { + UserProfileScreen( + pubKeyHex = screen.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = onBack, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onZapFeedback = onZapFeedback, + ) + } + + is DesktopScreen.Thread -> { + ThreadScreen( + noteId = screen.noteId, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = onBack, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + onReply = onShowReplyDialog, + ) + } + + else -> {} + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt new file mode 100644 index 000000000..9817782f7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -0,0 +1,93 @@ +/* + * 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 java.util.UUID + +sealed class DeckColumnType { + object HomeFeed : DeckColumnType() + + object Notifications : DeckColumnType() + + object Messages : DeckColumnType() + + object Search : DeckColumnType() + + object Reads : DeckColumnType() + + object Bookmarks : DeckColumnType() + + object GlobalFeed : DeckColumnType() + + object MyProfile : DeckColumnType() + + object Settings : DeckColumnType() + + data class Profile( + val pubKeyHex: String, + ) : DeckColumnType() + + data class Thread( + val noteId: String, + ) : DeckColumnType() + + data class Hashtag( + val tag: String, + ) : DeckColumnType() + + fun title(): String = + when (this) { + HomeFeed -> "Home" + Notifications -> "Notifications" + Messages -> "Messages" + Search -> "Search" + Reads -> "Reads" + Bookmarks -> "Bookmarks" + GlobalFeed -> "Global" + MyProfile -> "Profile" + Settings -> "Settings" + is Profile -> "Profile" + is Thread -> "Thread" + is Hashtag -> "#$tag" + } + + fun typeKey(): String = + when (this) { + HomeFeed -> "home" + Notifications -> "notifications" + Messages -> "messages" + Search -> "search" + Reads -> "reads" + Bookmarks -> "bookmarks" + GlobalFeed -> "global" + MyProfile -> "my_profile" + Settings -> "settings" + is Profile -> "profile" + is Thread -> "thread" + is Hashtag -> "hashtag" + } +} + +data class DeckColumn( + val id: String = UUID.randomUUID().toString(), + val type: DeckColumnType, + val width: Float = 400f, +) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt new file mode 100644 index 000000000..7ed4473d6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -0,0 +1,122 @@ +/* + * 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.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm +import kotlinx.coroutines.CoroutineScope +import java.awt.Cursor + +@Composable +fun DeckLayout( + deckState: DeckState, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + modifier: Modifier = Modifier, +) { + val columns by deckState.columns.collectAsState() + val scrollState = rememberScrollState() + + Row( + modifier = modifier.fillMaxSize().horizontalScroll(scrollState), + ) { + columns.forEachIndexed { index, column -> + if (index > 0) { + DraggableDivider( + onDrag = { delta -> + val leftCol = columns[index - 1] + val rightCol = columns[index] + deckState.updateColumnWidth(leftCol.id, leftCol.width + delta) + deckState.updateColumnWidth(rightCol.id, rightCol.width - delta) + }, + ) + } + + DeckColumnContainer( + column = column, + canClose = columns.size > 1, + onClose = { deckState.removeColumn(column.id) }, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + ) + } + } +} + +@Composable +private fun DraggableDivider(onDrag: (Float) -> Unit) { + Box( + modifier = + Modifier + .width(4.dp) + .fillMaxHeight() + .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + onDrag(dragAmount.x / density) + } + }, + ) { + VerticalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt new file mode 100644 index 000000000..912042a1b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -0,0 +1,89 @@ +/* + * 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.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.filled.Add +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun DeckSidebar( + onAddColumn: () -> Unit, + onOpenSettings: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .width(48.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Text( + "A", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + fontSize = 20.sp, + ) + + Spacer(Modifier.size(16.dp)) + + IconButton(onClick = onAddColumn) { + Icon( + Icons.Default.Add, + contentDescription = "Add Column", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.weight(1f)) + + IconButton(onClick = onOpenSettings) { + Icon( + Icons.Default.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} 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 new file mode 100644 index 000000000..aa4370177 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -0,0 +1,163 @@ +/* + * 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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class DeckState { + private val _columns = MutableStateFlow(DEFAULT_COLUMNS) + val columns: StateFlow> = _columns.asStateFlow() + + private val _focusedColumnIndex = MutableStateFlow(0) + val focusedColumnIndex: StateFlow = _focusedColumnIndex.asStateFlow() + + fun addColumn( + type: DeckColumnType, + afterIndex: Int? = null, + ) { + val col = DeckColumn(type = type) + _columns.value = + if (afterIndex != null && afterIndex < _columns.value.size) { + _columns.value.toMutableList().apply { add(afterIndex + 1, col) } + } else { + _columns.value + col + } + save() + } + + fun removeColumn(id: String) { + if (_columns.value.size <= 1) return + _columns.value = _columns.value.filter { it.id != id } + if (_focusedColumnIndex.value >= _columns.value.size) { + _focusedColumnIndex.value = _columns.value.size - 1 + } + save() + } + + fun moveColumn( + fromIndex: Int, + toIndex: Int, + ) { + val list = _columns.value.toMutableList() + if (fromIndex !in list.indices || toIndex !in list.indices) return + val item = list.removeAt(fromIndex) + list.add(toIndex, item) + _columns.value = list + save() + } + + fun updateColumnWidth( + id: String, + width: Float, + ) { + _columns.value = + _columns.value.map { + if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it + } + save() + } + + fun focusColumn(index: Int) { + if (index in _columns.value.indices) { + _focusedColumnIndex.value = index + } + } + + fun save() { + try { + val data = + _columns.value.map { col -> + mapOf( + "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 + }, + ) + } + DesktopPreferences.deckColumns = mapper.writeValueAsString(data) + } catch (_: Exception) { + } + } + + fun load() { + try { + val json = DesktopPreferences.deckColumns + if (json.isBlank()) return + val data: List> = mapper.readValue(json) + val loaded = + data.mapNotNull { entry -> + val type = parseColumnType(entry) ?: return@mapNotNull null + val id = entry["id"] as? String ?: return@mapNotNull null + val width = (entry["width"] as? Number)?.toFloat() ?: 400f + DeckColumn(id = id, type = type, width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } + if (loaded.isNotEmpty()) { + _columns.value = loaded + } + } catch (_: Exception) { + } + } + + companion object { + const val MIN_COLUMN_WIDTH = 300f + const val MAX_COLUMN_WIDTH = 800f + + val DEFAULT_COLUMNS = + listOf( + DeckColumn(type = DeckColumnType.HomeFeed), + DeckColumn(type = DeckColumnType.Notifications), + DeckColumn(type = DeckColumnType.Messages), + ) + + private val mapper = jacksonObjectMapper() + + private fun parseColumnType(entry: Map): DeckColumnType? { + val typeKey = entry["type"] as? String ?: return null + val param = entry["param"] as? String + return when (typeKey) { + "home" -> DeckColumnType.HomeFeed + "notifications" -> DeckColumnType.Notifications + "messages" -> DeckColumnType.Messages + "search" -> DeckColumnType.Search + "reads" -> DeckColumnType.Reads + "bookmarks" -> DeckColumnType.Bookmarks + "global" -> DeckColumnType.GlobalFeed + "my_profile" -> DeckColumnType.MyProfile + "settings" -> DeckColumnType.Settings + "profile" -> param?.let { DeckColumnType.Profile(it) } + "thread" -> param?.let { DeckColumnType.Thread(it) } + "hashtag" -> param?.let { DeckColumnType.Hashtag(it) } + else -> null + } + } + } +} From afe126f393eb4a0d1c0fb1039ea08dc49febde57 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 3 Mar 2026 17:38:32 +0200 Subject: [PATCH 2/4] feat(desktop): add single-pane/deck toggle with smart column resizing - Add LayoutMode enum (SINGLE_PANE/DECK) persisted in DesktopPreferences - SinglePaneLayout: NavigationRail + single content area reusing deck routing - View menu "Deck Layout" toggle with Cmd+Shift+D shortcut - Column resize: redistribute width on add/remove, double-click header to expand - Constrain drag resize to window bounds via BoxWithConstraints - FlowRow headers in FeedScreen/ReadsScreen to wrap on narrow columns Co-Authored-By: Claude Opus 4.6 --- .../amethyst/desktop/DesktopPreferences.kt | 7 + .../vitorpamplona/amethyst/desktop/Main.kt | 256 +++++++++++------- .../amethyst/desktop/ui/FeedScreen.kt | 13 +- .../amethyst/desktop/ui/ReadsScreen.kt | 13 +- .../amethyst/desktop/ui/deck/ColumnHeader.kt | 9 +- .../desktop/ui/deck/DeckColumnContainer.kt | 6 +- .../amethyst/desktop/ui/deck/DeckLayout.kt | 65 ++--- .../amethyst/desktop/ui/deck/DeckState.kt | 89 +++++- .../desktop/ui/deck/SinglePaneLayout.kt | 231 ++++++++++++++++ 9 files changed, 543 insertions(+), 146 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt 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 2dbcbbdd3..86b7e9d13 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -36,6 +36,7 @@ object DesktopPreferences { private const val KEY_FEED_MODE = "feed_mode" private const val KEY_LAST_SCREEN = "last_screen" private const val KEY_DECK_COLUMNS = "deck_columns" + private const val KEY_LAYOUT_MODE = "layout_mode" var feedMode: FeedMode get() { @@ -61,4 +62,10 @@ object DesktopPreferences { set(value) { prefs.put(KEY_DECK_COLUMNS, value) } + + var layoutMode: String + get() = prefs.get(KEY_LAYOUT_MODE, "SINGLE_PANE") + set(value) { + prefs.put(KEY_LAYOUT_MODE, value) + } } 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 86099ffdc..c69c65ae9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -81,6 +81,7 @@ 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.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect @@ -92,6 +93,11 @@ import kotlinx.coroutines.launch private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") +enum class LayoutMode { + SINGLE_PANE, + DECK, +} + /** * Desktop navigation state — used for in-column navigation (drill-down). */ @@ -133,6 +139,15 @@ fun main() = var replyToNote by remember { mutableStateOf(null) } val deckState = remember { DeckState().also { it.load() } } var showAddColumnDialog by remember { mutableStateOf(false) } + var layoutMode by remember { + mutableStateOf( + try { + LayoutMode.valueOf(DesktopPreferences.layoutMode) + } catch (e: Exception) { + LayoutMode.SINGLE_PANE + }, + ) + } Window( onCloseRequest = ::exitApplication, @@ -198,100 +213,117 @@ fun main() = } Menu("View") { Item( - "Add Column", + if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout", shortcut = if (isMacOS) { - KeyShortcut(Key.T, meta = true) + KeyShortcut(Key.D, meta = true, shift = true) } else { - KeyShortcut(Key.T, ctrl = true) - }, - onClick = { showAddColumnDialog = true }, - ) - Item( - "Close Column", - shortcut = - if (isMacOS) { - KeyShortcut(Key.W, meta = true) - } else { - KeyShortcut(Key.W, ctrl = true) + KeyShortcut(Key.D, ctrl = true, shift = true) }, onClick = { - val cols = deckState.columns.value - val idx = deckState.focusedColumnIndex.value - if (cols.size > 1 && idx in cols.indices) { - deckState.removeColumn(cols[idx].id) - } + layoutMode = + if (layoutMode == LayoutMode.DECK) LayoutMode.SINGLE_PANE else LayoutMode.DECK + DesktopPreferences.layoutMode = layoutMode.name }, ) - Item( - "Move Column Left", - shortcut = - if (isMacOS) { - KeyShortcut(Key.DirectionLeft, meta = true, shift = true) - } else { - KeyShortcut(Key.DirectionLeft, ctrl = true, shift = true) - }, - onClick = { - val idx = deckState.focusedColumnIndex.value - if (idx > 0) { - deckState.moveColumn(idx, idx - 1) - deckState.focusColumn(idx - 1) - } - }, - ) - Item( - "Move Column Right", - shortcut = - if (isMacOS) { - KeyShortcut(Key.DirectionRight, meta = true, shift = true) - } else { - KeyShortcut(Key.DirectionRight, ctrl = true, shift = true) - }, - onClick = { - val idx = deckState.focusedColumnIndex.value - val size = deckState.columns.value.size - if (idx < size - 1) { - deckState.moveColumn(idx, idx + 1) - deckState.focusColumn(idx + 1) - } - }, - ) - Separator() - // Focus column by index (Cmd/Ctrl+1..9) - val columnKeys = - listOf( - Key.One, - Key.Two, - Key.Three, - Key.Four, - Key.Five, - Key.Six, - Key.Seven, - Key.Eight, - Key.Nine, - ) - columnKeys.forEachIndexed { i, key -> + if (layoutMode == LayoutMode.DECK) { + Separator() Item( - "Column ${i + 1}", + "Add Column", shortcut = if (isMacOS) { - KeyShortcut(key, meta = true) + KeyShortcut(Key.T, meta = true) } else { - KeyShortcut(key, ctrl = true) + KeyShortcut(Key.T, ctrl = true) }, - onClick = { deckState.focusColumn(i) }, + onClick = { showAddColumnDialog = true }, ) - } - Separator() - Menu("Add Column...") { - Item("Home Feed", onClick = { deckState.addColumn(DeckColumnType.HomeFeed) }) - Item("Notifications", onClick = { deckState.addColumn(DeckColumnType.Notifications) }) - Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) - Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) - Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) - Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) - Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) - Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) + Item( + "Close Column", + shortcut = + if (isMacOS) { + KeyShortcut(Key.W, meta = true) + } else { + KeyShortcut(Key.W, ctrl = true) + }, + onClick = { + val cols = deckState.columns.value + val idx = deckState.focusedColumnIndex.value + if (cols.size > 1 && idx in cols.indices) { + deckState.removeColumn(cols[idx].id) + } + }, + ) + Item( + "Move Column Left", + shortcut = + if (isMacOS) { + KeyShortcut(Key.DirectionLeft, meta = true, shift = true) + } else { + KeyShortcut(Key.DirectionLeft, ctrl = true, shift = true) + }, + onClick = { + val idx = deckState.focusedColumnIndex.value + if (idx > 0) { + deckState.moveColumn(idx, idx - 1) + deckState.focusColumn(idx - 1) + } + }, + ) + Item( + "Move Column Right", + shortcut = + if (isMacOS) { + KeyShortcut(Key.DirectionRight, meta = true, shift = true) + } else { + KeyShortcut(Key.DirectionRight, ctrl = true, shift = true) + }, + onClick = { + val idx = deckState.focusedColumnIndex.value + val size = deckState.columns.value.size + if (idx < size - 1) { + deckState.moveColumn(idx, idx + 1) + deckState.focusColumn(idx + 1) + } + }, + ) + Separator() + // Focus column by index (Cmd/Ctrl+1..9) + val columnKeys = + listOf( + Key.One, + Key.Two, + Key.Three, + Key.Four, + Key.Five, + Key.Six, + Key.Seven, + Key.Eight, + Key.Nine, + ) + columnKeys.forEachIndexed { i, key -> + Item( + "Column ${i + 1}", + shortcut = + if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + }, + onClick = { deckState.focusColumn(i) }, + ) + } + Separator() + Menu("Add Column...") { + Item("Home Feed", onClick = { deckState.addColumn(DeckColumnType.HomeFeed) }) + Item("Notifications", onClick = { deckState.addColumn(DeckColumnType.Notifications) }) + Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) + Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) + Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) + Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) + Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) + Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) + } } } Menu("Help") { @@ -301,6 +333,7 @@ fun main() = } App( + layoutMode = layoutMode, deckState = deckState, showComposeDialog = showComposeDialog, showAddColumnDialog = showAddColumnDialog, @@ -322,6 +355,7 @@ fun main() = @Composable fun App( + layoutMode: LayoutMode, deckState: DeckState, showComposeDialog: Boolean, showAddColumnDialog: Boolean, @@ -425,6 +459,7 @@ fun App( } MainContent( + layoutMode = layoutMode, deckState = deckState, relayManager = relayManager, localCache = localCache, @@ -466,6 +501,7 @@ fun App( @Composable fun MainContent( + layoutMode: LayoutMode, deckState: DeckState, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, @@ -497,27 +533,47 @@ fun MainContent( Box(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) { - DeckSidebar( - onAddColumn = onShowAddColumnDialog, - onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) }, - ) + when (layoutMode) { + LayoutMode.SINGLE_PANE -> { + SinglePaneLayout( + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) + } - VerticalDivider() + LayoutMode.DECK -> { + DeckSidebar( + onAddColumn = onShowAddColumnDialog, + onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) }, + ) - DeckLayout( - deckState = deckState, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - modifier = Modifier.weight(1f), - ) + VerticalDivider() + + DeckLayout( + deckState = deckState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) + } + } } // Snackbar for zap feedback diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 224971776..32d262282 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -475,16 +477,17 @@ fun FeedScreen( ) } + @OptIn(ExperimentalLayoutApi::class) Column(modifier = Modifier.fillMaxSize()) { - // Header with compose button - Row( + // Header with compose button — wraps on narrow columns + FlowRow( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Column { - Row( - verticalAlignment = Alignment.CenterVertically, + FlowRow( + verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index c1096316e..11121055b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -259,16 +261,17 @@ fun ReadsScreen( } } + @OptIn(ExperimentalLayoutApi::class) Column(modifier = Modifier.fillMaxSize()) { - // Header - Row( + // Header — wraps on narrow columns + FlowRow( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Column { - Row( - verticalAlignment = Alignment.CenterVertically, + FlowRow( + verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index 91ac19edb..d289cbb7f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.ui.deck import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -48,6 +49,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,6 +60,7 @@ fun ColumnHeader( hasBackStack: Boolean, onBack: () -> Unit, onClose: () -> Unit, + onDoubleClick: () -> Unit = {}, modifier: Modifier = Modifier, ) { Row( @@ -66,7 +69,11 @@ fun ColumnHeader( .fillMaxWidth() .height(40.dp) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(horizontal = 8.dp), + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { onDoubleClick() }, + ) + }.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (hasBackStack) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index bdcdee177..601e75d8a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -80,6 +80,7 @@ fun DeckColumnContainer( column: DeckColumn, canClose: Boolean, onClose: () -> Unit, + onDoubleClickHeader: () -> Unit = {}, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, accountManager: AccountManager, @@ -108,6 +109,7 @@ fun DeckColumnContainer( hasBackStack = navStack.isNotEmpty(), onBack = { navState.pop() }, onClose = onClose, + onDoubleClick = onDoubleClickHeader, ) HorizontalDivider() @@ -152,7 +154,7 @@ fun DeckColumnContainer( } @Composable -private fun RootContent( +internal fun RootContent( columnType: DeckColumnType, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, @@ -314,7 +316,7 @@ private fun RootContent( } @Composable -private fun OverlayContent( +internal fun OverlayContent( screen: DesktopScreen, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 7ed4473d6..902b8871d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -21,13 +21,12 @@ package com.vitorpamplona.amethyst.desktop.ui.deck import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -37,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -64,38 +64,41 @@ fun DeckLayout( modifier: Modifier = Modifier, ) { val columns by deckState.columns.collectAsState() - val scrollState = rememberScrollState() + val density = LocalDensity.current - Row( - modifier = modifier.fillMaxSize().horizontalScroll(scrollState), - ) { - columns.forEachIndexed { index, column -> - if (index > 0) { - DraggableDivider( - onDrag = { delta -> - val leftCol = columns[index - 1] - val rightCol = columns[index] - deckState.updateColumnWidth(leftCol.id, leftCol.width + delta) - deckState.updateColumnWidth(rightCol.id, rightCol.width - delta) - }, + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val availableWidthDp = with(density) { constraints.maxWidth.toDp().value } + deckState.setAvailableWidth(availableWidthDp) + + Row(modifier = Modifier.fillMaxSize()) { + columns.forEachIndexed { index, column -> + if (index > 0) { + DraggableDivider( + onDrag = { delta -> + val leftCol = columns[index - 1] + val rightCol = columns[index] + deckState.resizePair(leftCol.id, rightCol.id, delta, availableWidthDp) + }, + ) + } + + DeckColumnContainer( + column = column, + canClose = columns.size > 1, + onClose = { deckState.removeColumn(column.id) }, + onDoubleClickHeader = { deckState.expandColumn(column.id, availableWidthDp) }, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, ) } - - DeckColumnContainer( - column = column, - canClose = columns.size > 1, - onClose = { deckState.removeColumn(column.id) }, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - ) } } } 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 aa4370177..47e136467 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 @@ -34,6 +34,12 @@ class DeckState { private val _focusedColumnIndex = MutableStateFlow(0) val focusedColumnIndex: StateFlow = _focusedColumnIndex.asStateFlow() + private var lastKnownWidth: Float = 0f + + fun setAvailableWidth(width: Float) { + lastKnownWidth = width + } + fun addColumn( type: DeckColumnType, afterIndex: Int? = null, @@ -45,12 +51,24 @@ class DeckState { } else { _columns.value + col } - save() + // Auto-fit all columns to available width when known + if (lastKnownWidth > 0f) { + fitColumnsToWidth(lastKnownWidth) + } else { + save() + } } fun removeColumn(id: String) { if (_columns.value.size <= 1) return - _columns.value = _columns.value.filter { it.id != id } + val removed = _columns.value.find { it.id == id } ?: return + val remaining = _columns.value.filter { it.id != id } + // Redistribute removed column's width evenly across remaining columns + val extra = removed.width / remaining.size + _columns.value = + remaining.map { + it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } if (_focusedColumnIndex.value >= _columns.value.size) { _focusedColumnIndex.value = _columns.value.size - 1 } @@ -80,6 +98,72 @@ class DeckState { save() } + fun expandColumn( + id: String, + availableWidth: Float, + ) { + val cols = _columns.value + val target = cols.find { it.id == id } ?: return + val others = cols.filter { it.id != id } + // Shrink others to minimum, give the rest to the target + val othersMin = others.size * MIN_COLUMN_WIDTH + val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH + val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + _columns.value = + cols.map { + if (it.id == id) { + it.copy(width = maxForTarget) + } else { + it.copy(width = MIN_COLUMN_WIDTH) + } + } + save() + } + + fun resizePair( + leftId: String, + rightId: String, + delta: Float, + availableWidth: Float, + ) { + val cols = _columns.value + val left = cols.find { it.id == leftId } ?: return + val right = cols.find { it.id == rightId } ?: return + // Compute max total allowed (available minus other columns and dividers) + val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat() + val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH + val maxPairWidth = availableWidth - otherWidth - dividerWidth + var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + // Ensure pair doesn't exceed available space + if (newLeft + newRight > maxPairWidth) { + if (delta > 0) { + newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + } else { + newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + } + } + _columns.value = + cols.map { + when (it.id) { + leftId -> it.copy(width = newLeft) + rightId -> it.copy(width = newRight) + else -> it + } + } + save() + } + + fun fitColumnsToWidth(availableWidth: Float) { + val cols = _columns.value + if (cols.isEmpty()) return + val dividers = (cols.size - 1) * DIVIDER_WIDTH + val usable = availableWidth - dividers + val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + _columns.value = cols.map { it.copy(width = perColumn) } + save() + } + fun focusColumn(index: Int) { if (index in _columns.value.indices) { _focusedColumnIndex.value = index @@ -130,6 +214,7 @@ class DeckState { companion object { const val MIN_COLUMN_WIDTH = 300f const val MAX_COLUMN_WIDTH = 800f + const val DIVIDER_WIDTH = 4f val DEFAULT_COLUMNS = listOf( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt new file mode 100644 index 000000000..35cfbda07 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -0,0 +1,231 @@ +/* + * 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.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +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.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.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Text +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.Alignment +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.desktop.DesktopScreen +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +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.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.Settings, Icons.Default.Settings, "Settings"), + ) + +@Composable +fun SinglePaneLayout( + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + modifier: Modifier = Modifier, +) { + var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } + val navState = remember { ColumnNavigationState() } + val navStack by navState.stack.collectAsState() + val currentOverlay = navStack.lastOrNull() + + Row(modifier = modifier.fillMaxSize()) { + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + navItems.forEach { item -> + NavigationRailItem( + selected = currentColumnType == item.type && navStack.isEmpty(), + onClick = { + currentColumnType = item.type + navState.clear() + }, + icon = { + Icon( + item.icon, + contentDescription = item.label, + modifier = Modifier.size(22.dp), + ) + }, + label = { + Text( + item.label, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } + + VerticalDivider() + + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + // Show header with back button when navigated into overlay + if (navStack.isNotEmpty()) { + SinglePaneHeader( + title = + when (currentOverlay) { + is DesktopScreen.UserProfile -> "Profile" + is DesktopScreen.Thread -> "Thread" + else -> currentColumnType.title() + }, + onBack = { navState.pop() }, + ) + HorizontalDivider() + } + + Box( + modifier = Modifier.fillMaxSize().padding(12.dp), + ) { + if (currentOverlay != null) { + OverlayContent( + screen = currentOverlay, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onBack = { navState.pop() }, + ) + } else { + RootContent( + columnType = currentColumnType, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + ) + } + } + } + } +} + +@Composable +private fun SinglePaneHeader( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(40.dp) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} From 73b8059b5559a8b64f1c0ed856e14fe6b6cb0dc6 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 4 Mar 2026 09:45:01 +0200 Subject: [PATCH 3/4] feat(desktop): collapsible info panel in chess deck column Hide GameInfo/MoveHistory/Actions panel by default in deck view with a chevron toggle to expand. Add Chess to AddColumnDialog. Co-Authored-By: Claude Opus 4.6 --- .../amethyst/desktop/chess/ChessScreen.kt | 368 ++++++++++-------- .../desktop/ui/deck/AddColumnDialog.kt | 1 + .../desktop/ui/deck/DeckColumnContainer.kt | 1 + 3 files changed, 198 insertions(+), 172 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 48f0d794f..432fd7c47 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -39,6 +39,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh @@ -90,6 +92,7 @@ fun ChessScreen( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, onBack: () -> Unit = {}, + compactMode: Boolean = false, ) { val scope = rememberCoroutineScope() val viewModel = @@ -274,6 +277,7 @@ fun ChessScreen( }, onResign = { viewModel.resign(gameState.startEventId) }, isSpectatorOverride = isSpectating, + compactMode = compactMode, ) } } else { @@ -593,6 +597,7 @@ private fun DesktopChessGameLayout( onMoveMade: (from: String, to: String, san: String) -> Unit, onResign: () -> Unit, isSpectatorOverride: Boolean? = null, + compactMode: Boolean = false, ) { // Collect state flows to trigger recomposition on changes val currentPosition by gameState.currentPosition.collectAsState() @@ -604,12 +609,13 @@ private fun DesktopChessGameLayout( val opponentPubkey = gameState.opponentPubkey val isSpectator = isSpectatorOverride ?: gameState.isSpectator + var showInfoPanel by remember { mutableStateOf(!compactMode) } + BoxWithConstraints( modifier = Modifier.fillMaxSize().padding(16.dp), ) { // Calculate board size based on available space - // Leave room for the info panel (300dp + 24dp spacing) - val infoPanelWidth = 300.dp + 24.dp + val infoPanelWidth = if (showInfoPanel) 300.dp + 24.dp else 0.dp val availableWidth = maxWidth - infoPanelWidth val availableHeight = maxHeight // Board should fit within available space, maintaining square aspect ratio @@ -617,11 +623,11 @@ private fun DesktopChessGameLayout( Row( modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(24.dp), + horizontalArrangement = Arrangement.spacedBy(if (showInfoPanel) 24.dp else 0.dp), ) { - // Left side: Chess board + // Left side: Chess board + toggle Box( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center, ) { InteractiveChessBoard( @@ -633,188 +639,206 @@ private fun DesktopChessGameLayout( positionVersion = moveHistory.size, onMoveMade = onMoveMade, ) + + // Toggle button anchored to top-end of board area + IconButton( + onClick = { showInfoPanel = !showInfoPanel }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + Icon( + if (showInfoPanel) { + Icons.AutoMirrored.Filled.KeyboardArrowRight + } else { + Icons.AutoMirrored.Filled.KeyboardArrowLeft + }, + contentDescription = if (showInfoPanel) "Hide info panel" else "Show info panel", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - // Right side: Game info, moves, controls - Column( - modifier = - Modifier - .width(300.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Extract human-readable game name - val gameName = - remember(startEventId) { - ChessGameNameGenerator.extractDisplayName(startEventId) - } - - // Game info card - Card( - modifier = Modifier.fillMaxWidth(), + // Right side: Game info, moves, controls (collapsible) + if (showInfoPanel) { + Column( + modifier = + Modifier + .width(300.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Show readable game name if available - if (gameName != null) { - Text( - gameName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Text( - "Game Info", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) + // Extract human-readable game name + val gameName = + remember(startEventId) { + ChessGameNameGenerator.extractDisplayName(startEventId) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + // Game info card + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - UserAvatar( - userHex = opponentPubkey, - pictureUrl = opponentPicture, - size = 48.dp, - ) - Column { - if (isSpectator) { - Text( - "Spectating", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.tertiary, - ) - } else { - Text( - "vs $opponentName", - style = MaterialTheme.typography.bodyLarge, - ) - Text( - "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Show readable game name if available + if (gameName != null) { + Text( + gameName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + "Game Info", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = opponentPicture, + size = 48.dp, + ) + Column { + if (isSpectator) { + Text( + "Spectating", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + ) + } else { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Use currentPosition to derive turn (triggers recomposition on move) + val currentTurn = currentPosition.activeColor + + if (isSpectator) { + Text( + "${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + val isYourTurn = currentTurn == playerColor + Text( + if (isYourTurn) "Your turn" else "Opponent's turn", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + color = + if (isYourTurn) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + + // Move history card + if (moveHistory.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Move History", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + // Format moves as numbered pairs + val moveText = + moveHistory + .chunked(2) + .mapIndexed { index, pair -> + "${index + 1}. ${pair.joinToString(" ")}" + }.joinToString("\n") + + Text( + moveText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Game controls card (only for participants, not spectators) + if (!isSpectator) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + OutlinedButton( + onClick = onResign, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Resign") } } } - - // Use currentPosition to derive turn (triggers recomposition on move) - val currentTurn = currentPosition.activeColor - - if (isSpectator) { - Text( - "${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - val isYourTurn = currentTurn == playerColor - Text( - if (isYourTurn) "Your turn" else "Opponent's turn", - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, - color = - if (isYourTurn) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - } - - // Move history card - if (moveHistory.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + } else { + // Spectator info card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), + ), ) { - Text( - "Move History", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - - // Format moves as numbered pairs - val moveText = - moveHistory - .chunked(2) - .mapIndexed { index, pair -> - "${index + 1}. ${pair.joinToString(" ")}" - }.joinToString("\n") - - Text( - moveText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - // Game controls card (only for participants, not spectators) - if (!isSpectator) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "Actions", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - - OutlinedButton( - onClick = onResign, - modifier = Modifier.fillMaxWidth(), + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text("Resign") + Icon(Icons.Default.Visibility, contentDescription = null) + Text( + "Watching game - spectator mode", + style = MaterialTheme.typography.bodyMedium, + ) } } } - } else { - // Spectator info card - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), - ), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon(Icons.Default.Visibility, contentDescription = null) - Text( - "Watching game - spectator mode", - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - // Game ID (small footer) - Text( - "Game: ${startEventId.take(16)}...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Game ID (small footer) + Text( + "Game: ${startEventId.take(16)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt index 3c4f6628a..f7ef606dd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt @@ -56,6 +56,7 @@ private val COLUMN_OPTIONS = DeckColumnType.Bookmarks, DeckColumnType.GlobalFeed, DeckColumnType.MyProfile, + DeckColumnType.Chess, ) @Composable diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 9751cbf82..34d324564 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -276,6 +276,7 @@ internal fun RootContent( relayManager = relayManager, account = account, onBack = {}, + compactMode = true, ) } From db530a6820728a95954a6639dcdaece060feb370 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 4 Mar 2026 10:02:22 +0200 Subject: [PATCH 4/4] fix(desktop): audit fixes for deck layout Critical: - GlobalFeed now renders global feed (was identical to HomeFeed) - DeckState uses atomic _columns.update{} instead of non-atomic .value= - lastKnownWidth marked @Volatile for thread safety Medium: - save() debounced 500ms via coroutine (was sync disk I/O per drag frame) - Width redistribution fills gaps after clamping on column remove - Hashtag column pre-fills search query - ColumnNavigationState.stack exposed as StateFlow not MutableStateFlow - Settings column deduped (focuses existing instead of adding duplicate) - Exceptions logged in save/load instead of silently swallowed Low: - Thread column icon fixed (was Home, now Article) - Drag divider hit target widened to 12dp (visual stays 4dp) - Horizontal scroll added for column overflow - Auto-fit columns on first composition / window resize - Column shortcuts limited to actual column count - Deterministic default column IDs - Overlay fallback shows message instead of blank Co-Authored-By: Claude Opus 4.6 --- .../vitorpamplona/amethyst/desktop/Main.kt | 22 ++- .../amethyst/desktop/ui/FeedScreen.kt | 3 +- .../amethyst/desktop/ui/SearchScreen.kt | 8 + .../amethyst/desktop/ui/deck/ColumnHeader.kt | 2 +- .../desktop/ui/deck/DeckColumnContainer.kt | 15 +- .../amethyst/desktop/ui/deck/DeckLayout.kt | 23 ++- .../amethyst/desktop/ui/deck/DeckState.kt | 182 +++++++++++------- 7 files changed, 177 insertions(+), 78 deletions(-) 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 ef02ca6f3..fa3abb3e2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -141,7 +141,8 @@ fun main() = ) var showComposeDialog by remember { mutableStateOf(false) } var replyToNote by remember { mutableStateOf(null) } - val deckState = remember { DeckState().also { it.load() } } + val deckScope = rememberCoroutineScope() + val deckState = remember { DeckState(deckScope).also { it.load() } } var showAddColumnDialog by remember { mutableStateOf(false) } var layoutMode by remember { mutableStateOf( @@ -179,7 +180,13 @@ fun main() = } else { KeyShortcut(Key.Comma, ctrl = true) }, - onClick = { deckState.addColumn(DeckColumnType.Settings) }, + onClick = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, ) Separator() Item( @@ -305,7 +312,8 @@ fun main() = Key.Eight, Key.Nine, ) - columnKeys.forEachIndexed { i, key -> + val columnCount = deckState.columns.value.size + columnKeys.take(columnCount).forEachIndexed { i, key -> Item( "Column ${i + 1}", shortcut = @@ -617,7 +625,13 @@ fun MainContent( LayoutMode.DECK -> { DeckSidebar( onAddColumn = onShowAddColumnDialog, - onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) }, + onOpenSettings = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, ) VerticalDivider() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 0e04f34dd..e79aa3cfa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -151,6 +151,7 @@ fun FeedScreen( account: AccountState.LoggedIn? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + initialFeedMode: FeedMode? = null, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, @@ -170,7 +171,7 @@ fun FeedScreen( } val events by eventState.items.collectAsState() var replyToEvent by remember { mutableStateOf(null) } - var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) } + var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } // Track reaction event IDs per target event to deduplicate diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index c787c3471..cc4cee93b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -78,6 +78,7 @@ fun SearchScreen( localCache: DesktopLocalCache, relayManager: DesktopRelayConnectionManager, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + initialQuery: String = "", onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, onNavigateToHashtag: (String) -> Unit = {}, @@ -86,6 +87,13 @@ fun SearchScreen( val scope = rememberCoroutineScope() val searchState = remember { SearchBarState(localCache, scope) } val focusRequester = remember { FocusRequester() } + + // Pre-fill initial query (e.g., hashtag column) + LaunchedEffect(initialQuery) { + if (initialQuery.isNotBlank()) { + searchState.updateSearchText(initialQuery) + } + } val relayStatuses by relayManager.relayStatuses.collectAsState() // Collect state from SearchBarState diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index da4933ad8..3deb62b25 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -133,6 +133,6 @@ fun DeckColumnType.icon(): ImageVector = DeckColumnType.Chess -> Icons.Default.Extension DeckColumnType.Settings -> Icons.Default.Settings is DeckColumnType.Profile -> Icons.Default.Person - is DeckColumnType.Thread -> Icons.Default.Home + is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article is DeckColumnType.Hashtag -> Icons.Default.Tag } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 34d324564..ce90b7ef6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen @@ -56,10 +57,11 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow class ColumnNavigationState { private val _stack = MutableStateFlow>(emptyList()) - val stack = _stack + val stack: kotlinx.coroutines.flow.StateFlow> = _stack.asStateFlow() fun push(screen: DesktopScreen) { _stack.value = _stack.value + screen @@ -180,6 +182,7 @@ internal fun RootContent( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + initialFeedMode = FeedMode.FOLLOWING, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, @@ -249,6 +252,7 @@ internal fun RootContent( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + initialFeedMode = FeedMode.GLOBAL, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, @@ -320,6 +324,7 @@ internal fun RootContent( localCache = localCache, relayManager = relayManager, subscriptionsCoordinator = subscriptionsCoordinator, + initialQuery = "#${columnType.tag}", onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, ) @@ -374,6 +379,12 @@ internal fun OverlayContent( ) } - else -> {} + else -> { + androidx.compose.material3.Text( + "Unsupported screen type", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 902b8871d..1df6bf135 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -21,15 +21,18 @@ package com.vitorpamplona.amethyst.desktop.ui.deck import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -70,7 +73,22 @@ fun DeckLayout( val availableWidthDp = with(density) { constraints.maxWidth.toDp().value } deckState.setAvailableWidth(availableWidthDp) - Row(modifier = Modifier.fillMaxSize()) { + // Auto-fit columns on first composition or when available width changes significantly + LaunchedEffect(availableWidthDp, columns.size) { + val dividers = (columns.size - 1) * DeckState.DIVIDER_WIDTH + val totalColumnWidth = columns.sumOf { it.width.toDouble() }.toFloat() + val diff = kotlin.math.abs(totalColumnWidth + dividers - availableWidthDp) + if (diff > 20f && columns.isNotEmpty()) { + deckState.fitColumnsToWidth(availableWidthDp) + } + } + + Row( + modifier = + Modifier + .fillMaxSize() + .horizontalScroll(rememberScrollState()), + ) { columns.forEachIndexed { index, column -> if (index > 0) { DraggableDivider( @@ -108,7 +126,7 @@ private fun DraggableDivider(onDrag: (Float) -> Unit) { Box( modifier = Modifier - .width(4.dp) + .width(12.dp) .fillMaxHeight() .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) .pointerInput(Unit) { @@ -117,6 +135,7 @@ private fun DraggableDivider(onDrag: (Float) -> Unit) { onDrag(dragAmount.x / density) } }, + contentAlignment = androidx.compose.ui.Alignment.Center, ) { VerticalDivider( color = MaterialTheme.colorScheme.outlineVariant, 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 54ec6c1ec..bdfb40298 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 @@ -23,19 +23,29 @@ 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 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 DeckState { +class DeckState( + private val saveScope: CoroutineScope, +) { private val _columns = MutableStateFlow(DEFAULT_COLUMNS) val columns: StateFlow> = _columns.asStateFlow() private val _focusedColumnIndex = MutableStateFlow(0) val focusedColumnIndex: StateFlow = _focusedColumnIndex.asStateFlow() + @Volatile private var lastKnownWidth: Float = 0f + private var saveJob: Job? = null + fun setAvailableWidth(width: Float) { lastKnownWidth = width } @@ -45,71 +55,95 @@ class DeckState { afterIndex: Int? = null, ) { val col = DeckColumn(type = type) - _columns.value = - if (afterIndex != null && afterIndex < _columns.value.size) { - _columns.value.toMutableList().apply { add(afterIndex + 1, col) } + _columns.update { current -> + if (afterIndex != null && afterIndex < current.size) { + current.toMutableList().apply { add(afterIndex + 1, col) } } else { - _columns.value + col + current + col } + } // Auto-fit all columns to available width when known - if (lastKnownWidth > 0f) { - fitColumnsToWidth(lastKnownWidth) + val width = lastKnownWidth + if (width > 0f) { + fitColumnsToWidth(width) } else { - save() + scheduleSave() } } + fun hasColumnOfType(type: DeckColumnType): Boolean = _columns.value.any { it.type == type } + + fun focusExistingColumn(type: DeckColumnType) { + val idx = _columns.value.indexOfFirst { it.type == type } + if (idx >= 0) focusColumn(idx) + } + fun removeColumn(id: String) { - if (_columns.value.size <= 1) return - val removed = _columns.value.find { it.id == id } ?: return - val remaining = _columns.value.filter { it.id != id } - // Redistribute removed column's width evenly across remaining columns - val extra = removed.width / remaining.size - _columns.value = - remaining.map { - it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + _columns.update { current -> + if (current.size <= 1) return + val removed = current.find { it.id == id } ?: return + val remaining = current.filter { it.id != id } + val extra = removed.width / remaining.size + val result = + remaining.map { + it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } + // Fix gaps: if total is less than available, redistribute remainder + val width = lastKnownWidth + if (width > 0f) { + val dividers = (result.size - 1) * DIVIDER_WIDTH + val totalUsed = result.sumOf { it.width.toDouble() }.toFloat() + val deficit = width - dividers - totalUsed + if (deficit > 1f) { + val perColumn = deficit / result.size + return@update result.map { + it.copy(width = (it.width + perColumn).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } + } } - if (_focusedColumnIndex.value >= _columns.value.size) { - _focusedColumnIndex.value = _columns.value.size - 1 + result } - save() + _focusedColumnIndex.update { idx -> + idx.coerceAtMost(_columns.value.size - 1) + } + scheduleSave() } fun moveColumn( fromIndex: Int, toIndex: Int, ) { - val list = _columns.value.toMutableList() - if (fromIndex !in list.indices || toIndex !in list.indices) return - val item = list.removeAt(fromIndex) - list.add(toIndex, item) - _columns.value = list - save() + _columns.update { current -> + if (fromIndex !in current.indices || toIndex !in current.indices) return + current.toMutableList().apply { + val item = removeAt(fromIndex) + add(toIndex, item) + } + } + scheduleSave() } fun updateColumnWidth( id: String, width: Float, ) { - _columns.value = - _columns.value.map { + _columns.update { current -> + current.map { if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it } - save() + } + scheduleSave() } fun expandColumn( id: String, availableWidth: Float, ) { - val cols = _columns.value - val target = cols.find { it.id == id } ?: return - val others = cols.filter { it.id != id } - // Shrink others to minimum, give the rest to the target - val othersMin = others.size * MIN_COLUMN_WIDTH - val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH - val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) - _columns.value = + _columns.update { cols -> + if (cols.find { it.id == id } == null) return + val othersMin = (cols.size - 1) * MIN_COLUMN_WIDTH + val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH + val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) cols.map { if (it.id == id) { it.copy(width = maxForTarget) @@ -117,7 +151,8 @@ class DeckState { it.copy(width = MIN_COLUMN_WIDTH) } } - save() + } + scheduleSave() } fun resizePair( @@ -126,24 +161,21 @@ class DeckState { delta: Float, availableWidth: Float, ) { - val cols = _columns.value - val left = cols.find { it.id == leftId } ?: return - val right = cols.find { it.id == rightId } ?: return - // Compute max total allowed (available minus other columns and dividers) - val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat() - val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH - val maxPairWidth = availableWidth - otherWidth - dividerWidth - var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) - var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) - // Ensure pair doesn't exceed available space - if (newLeft + newRight > maxPairWidth) { - if (delta > 0) { - newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) - } else { - newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + _columns.update { cols -> + val left = cols.find { it.id == leftId } ?: return + val right = cols.find { it.id == rightId } ?: return + val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat() + val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH + val maxPairWidth = availableWidth - otherWidth - dividerWidth + var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + if (newLeft + newRight > maxPairWidth) { + if (delta > 0) { + newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + } else { + newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + } } - } - _columns.value = cols.map { when (it.id) { leftId -> it.copy(width = newLeft) @@ -151,17 +183,19 @@ class DeckState { else -> it } } - save() + } + scheduleSave() } fun fitColumnsToWidth(availableWidth: Float) { - val cols = _columns.value - if (cols.isEmpty()) return - val dividers = (cols.size - 1) * DIVIDER_WIDTH - val usable = availableWidth - dividers - val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) - _columns.value = cols.map { it.copy(width = perColumn) } - save() + _columns.update { cols -> + if (cols.isEmpty()) return + val dividers = (cols.size - 1) * DIVIDER_WIDTH + val usable = availableWidth - dividers + val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + cols.map { it.copy(width = perColumn) } + } + scheduleSave() } fun focusColumn(index: Int) { @@ -170,6 +204,15 @@ class DeckState { } } + private fun scheduleSave() { + saveJob?.cancel() + saveJob = + saveScope.launch { + delay(SAVE_DEBOUNCE_MS) + save() + } + } + fun save() { try { val data = @@ -188,7 +231,8 @@ class DeckState { ) } DesktopPreferences.deckColumns = mapper.writeValueAsString(data) - } catch (_: Exception) { + } catch (e: Exception) { + println("DeckState: failed to save columns: ${e.message}") } } @@ -207,20 +251,22 @@ class DeckState { if (loaded.isNotEmpty()) { _columns.value = loaded } - } catch (_: Exception) { + } catch (e: Exception) { + println("DeckState: failed to load columns: ${e.message}") } } companion object { const val MIN_COLUMN_WIDTH = 300f const val MAX_COLUMN_WIDTH = 800f - const val DIVIDER_WIDTH = 4f + const val DIVIDER_WIDTH = 12f + private const val SAVE_DEBOUNCE_MS = 500L val DEFAULT_COLUMNS = listOf( - DeckColumn(type = DeckColumnType.HomeFeed), - DeckColumn(type = DeckColumnType.Notifications), - DeckColumn(type = DeckColumnType.Messages), + DeckColumn(id = "default-home", type = DeckColumnType.HomeFeed), + DeckColumn(id = "default-notifications", type = DeckColumnType.Notifications), + DeckColumn(id = "default-messages", type = DeckColumnType.Messages), ) private val mapper = jacksonObjectMapper()