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 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-03 17:38:32 +02:00
parent e7239980c6
commit afe126f393
9 changed files with 543 additions and 146 deletions
@@ -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)
}
}
@@ -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<com.vitorpamplona.quartz.nip01Core.core.Event?>(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
@@ -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(
@@ -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(
@@ -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) {
@@ -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,
@@ -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,
)
}
}
}
@@ -34,6 +34,12 @@ class DeckState {
private val _focusedColumnIndex = MutableStateFlow(0)
val focusedColumnIndex: StateFlow<Int> = _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(
@@ -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>(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,
)
}
}