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_FEED_MODE = "feed_mode"
private const val KEY_LAST_SCREEN = "last_screen" private const val KEY_LAST_SCREEN = "last_screen"
private const val KEY_DECK_COLUMNS = "deck_columns" private const val KEY_DECK_COLUMNS = "deck_columns"
private const val KEY_LAYOUT_MODE = "layout_mode"
var feedMode: FeedMode var feedMode: FeedMode
get() { get() {
@@ -61,4 +62,10 @@ object DesktopPreferences {
set(value) { set(value) {
prefs.put(KEY_DECK_COLUMNS, 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.DeckLayout
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState 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.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
@@ -92,6 +93,11 @@ import kotlinx.coroutines.launch
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") 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). * 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) } var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
val deckState = remember { DeckState().also { it.load() } } val deckState = remember { DeckState().also { it.load() } }
var showAddColumnDialog by remember { mutableStateOf(false) } var showAddColumnDialog by remember { mutableStateOf(false) }
var layoutMode by remember {
mutableStateOf(
try {
LayoutMode.valueOf(DesktopPreferences.layoutMode)
} catch (e: Exception) {
LayoutMode.SINGLE_PANE
},
)
}
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
@@ -198,100 +213,117 @@ fun main() =
} }
Menu("View") { Menu("View") {
Item( Item(
"Add Column", if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout",
shortcut = shortcut =
if (isMacOS) { if (isMacOS) {
KeyShortcut(Key.T, meta = true) KeyShortcut(Key.D, meta = true, shift = true)
} else { } else {
KeyShortcut(Key.T, ctrl = true) KeyShortcut(Key.D, ctrl = true, shift = true)
},
onClick = { showAddColumnDialog = true },
)
Item(
"Close Column",
shortcut =
if (isMacOS) {
KeyShortcut(Key.W, meta = true)
} else {
KeyShortcut(Key.W, ctrl = true)
}, },
onClick = { onClick = {
val cols = deckState.columns.value layoutMode =
val idx = deckState.focusedColumnIndex.value if (layoutMode == LayoutMode.DECK) LayoutMode.SINGLE_PANE else LayoutMode.DECK
if (cols.size > 1 && idx in cols.indices) { DesktopPreferences.layoutMode = layoutMode.name
deckState.removeColumn(cols[idx].id)
}
}, },
) )
Item( if (layoutMode == LayoutMode.DECK) {
"Move Column Left", Separator()
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( Item(
"Column ${i + 1}", "Add Column",
shortcut = shortcut =
if (isMacOS) { if (isMacOS) {
KeyShortcut(key, meta = true) KeyShortcut(Key.T, meta = true)
} else { } else {
KeyShortcut(key, ctrl = true) KeyShortcut(Key.T, ctrl = true)
}, },
onClick = { deckState.focusColumn(i) }, onClick = { showAddColumnDialog = true },
) )
} Item(
Separator() "Close Column",
Menu("Add Column...") { shortcut =
Item("Home Feed", onClick = { deckState.addColumn(DeckColumnType.HomeFeed) }) if (isMacOS) {
Item("Notifications", onClick = { deckState.addColumn(DeckColumnType.Notifications) }) KeyShortcut(Key.W, meta = true)
Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) } else {
Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) KeyShortcut(Key.W, ctrl = true)
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) },
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) onClick = {
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) val cols = deckState.columns.value
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) 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") { Menu("Help") {
@@ -301,6 +333,7 @@ fun main() =
} }
App( App(
layoutMode = layoutMode,
deckState = deckState, deckState = deckState,
showComposeDialog = showComposeDialog, showComposeDialog = showComposeDialog,
showAddColumnDialog = showAddColumnDialog, showAddColumnDialog = showAddColumnDialog,
@@ -322,6 +355,7 @@ fun main() =
@Composable @Composable
fun App( fun App(
layoutMode: LayoutMode,
deckState: DeckState, deckState: DeckState,
showComposeDialog: Boolean, showComposeDialog: Boolean,
showAddColumnDialog: Boolean, showAddColumnDialog: Boolean,
@@ -425,6 +459,7 @@ fun App(
} }
MainContent( MainContent(
layoutMode = layoutMode,
deckState = deckState, deckState = deckState,
relayManager = relayManager, relayManager = relayManager,
localCache = localCache, localCache = localCache,
@@ -466,6 +501,7 @@ fun App(
@Composable @Composable
fun MainContent( fun MainContent(
layoutMode: LayoutMode,
deckState: DeckState, deckState: DeckState,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache, localCache: DesktopLocalCache,
@@ -497,27 +533,47 @@ fun MainContent(
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
DeckSidebar( when (layoutMode) {
onAddColumn = onShowAddColumnDialog, LayoutMode.SINGLE_PANE -> {
onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) }, 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( VerticalDivider()
deckState = deckState,
relayManager = relayManager, DeckLayout(
localCache = localCache, deckState = deckState,
accountManager = accountManager, relayManager = relayManager,
account = account, localCache = localCache,
nwcConnection = nwcConnection, accountManager = accountManager,
subscriptionsCoordinator = subscriptionsCoordinator, account = account,
appScope = appScope, nwcConnection = nwcConnection,
onShowComposeDialog = onShowComposeDialog, subscriptionsCoordinator = subscriptionsCoordinator,
onShowReplyDialog = onShowReplyDialog, appScope = appScope,
onZapFeedback = onZapFeedback, onShowComposeDialog = onShowComposeDialog,
modifier = Modifier.weight(1f), onShowReplyDialog = onShowReplyDialog,
) onZapFeedback = onZapFeedback,
modifier = Modifier.weight(1f),
)
}
}
} }
// Snackbar for zap feedback // Snackbar for zap feedback
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -475,16 +477,17 @@ fun FeedScreen(
) )
} }
@OptIn(ExperimentalLayoutApi::class)
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header with compose button // Header with compose button — wraps on narrow columns
Row( FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Column { Column {
Row( FlowRow(
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -259,16 +261,17 @@ fun ReadsScreen(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header // Header — wraps on narrow columns
Row( FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Column { Column {
Row( FlowRow(
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.desktop.ui.deck package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -48,6 +49,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -58,6 +60,7 @@ fun ColumnHeader(
hasBackStack: Boolean, hasBackStack: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
onDoubleClick: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@@ -66,7 +69,11 @@ fun ColumnHeader(
.fillMaxWidth() .fillMaxWidth()
.height(40.dp) .height(40.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 8.dp), .pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { onDoubleClick() },
)
}.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (hasBackStack) { if (hasBackStack) {
@@ -80,6 +80,7 @@ fun DeckColumnContainer(
column: DeckColumn, column: DeckColumn,
canClose: Boolean, canClose: Boolean,
onClose: () -> Unit, onClose: () -> Unit,
onDoubleClickHeader: () -> Unit = {},
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache, localCache: DesktopLocalCache,
accountManager: AccountManager, accountManager: AccountManager,
@@ -108,6 +109,7 @@ fun DeckColumnContainer(
hasBackStack = navStack.isNotEmpty(), hasBackStack = navStack.isNotEmpty(),
onBack = { navState.pop() }, onBack = { navState.pop() },
onClose = onClose, onClose = onClose,
onDoubleClick = onDoubleClickHeader,
) )
HorizontalDivider() HorizontalDivider()
@@ -152,7 +154,7 @@ fun DeckColumnContainer(
} }
@Composable @Composable
private fun RootContent( internal fun RootContent(
columnType: DeckColumnType, columnType: DeckColumnType,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache, localCache: DesktopLocalCache,
@@ -314,7 +316,7 @@ private fun RootContent(
} }
@Composable @Composable
private fun OverlayContent( internal fun OverlayContent(
screen: DesktopScreen, screen: DesktopScreen,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache, localCache: DesktopLocalCache,
@@ -21,13 +21,12 @@
package com.vitorpamplona.amethyst.desktop.ui.deck package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable 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.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
@@ -64,38 +64,41 @@ fun DeckLayout(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val columns by deckState.columns.collectAsState() val columns by deckState.columns.collectAsState()
val scrollState = rememberScrollState() val density = LocalDensity.current
Row( BoxWithConstraints(modifier = modifier.fillMaxSize()) {
modifier = modifier.fillMaxSize().horizontalScroll(scrollState), val availableWidthDp = with(density) { constraints.maxWidth.toDp().value }
) { deckState.setAvailableWidth(availableWidthDp)
columns.forEachIndexed { index, column ->
if (index > 0) { Row(modifier = Modifier.fillMaxSize()) {
DraggableDivider( columns.forEachIndexed { index, column ->
onDrag = { delta -> if (index > 0) {
val leftCol = columns[index - 1] DraggableDivider(
val rightCol = columns[index] onDrag = { delta ->
deckState.updateColumnWidth(leftCol.id, leftCol.width + delta) val leftCol = columns[index - 1]
deckState.updateColumnWidth(rightCol.id, rightCol.width - delta) 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) private val _focusedColumnIndex = MutableStateFlow(0)
val focusedColumnIndex: StateFlow<Int> = _focusedColumnIndex.asStateFlow() val focusedColumnIndex: StateFlow<Int> = _focusedColumnIndex.asStateFlow()
private var lastKnownWidth: Float = 0f
fun setAvailableWidth(width: Float) {
lastKnownWidth = width
}
fun addColumn( fun addColumn(
type: DeckColumnType, type: DeckColumnType,
afterIndex: Int? = null, afterIndex: Int? = null,
@@ -45,12 +51,24 @@ class DeckState {
} else { } else {
_columns.value + col _columns.value + col
} }
save() // Auto-fit all columns to available width when known
if (lastKnownWidth > 0f) {
fitColumnsToWidth(lastKnownWidth)
} else {
save()
}
} }
fun removeColumn(id: String) { fun removeColumn(id: String) {
if (_columns.value.size <= 1) return 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) { if (_focusedColumnIndex.value >= _columns.value.size) {
_focusedColumnIndex.value = _columns.value.size - 1 _focusedColumnIndex.value = _columns.value.size - 1
} }
@@ -80,6 +98,72 @@ class DeckState {
save() 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) { fun focusColumn(index: Int) {
if (index in _columns.value.indices) { if (index in _columns.value.indices) {
_focusedColumnIndex.value = index _focusedColumnIndex.value = index
@@ -130,6 +214,7 @@ class DeckState {
companion object { companion object {
const val MIN_COLUMN_WIDTH = 300f const val MIN_COLUMN_WIDTH = 300f
const val MAX_COLUMN_WIDTH = 800f const val MAX_COLUMN_WIDTH = 800f
const val DIVIDER_WIDTH = 4f
val DEFAULT_COLUMNS = val DEFAULT_COLUMNS =
listOf( 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,
)
}
}