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, + ) + } +}