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()