fix(desktop): audit fixes for deck layout

Critical:
- GlobalFeed now renders global feed (was identical to HomeFeed)
- DeckState uses atomic _columns.update{} instead of non-atomic .value=
- lastKnownWidth marked @Volatile for thread safety

Medium:
- save() debounced 500ms via coroutine (was sync disk I/O per drag frame)
- Width redistribution fills gaps after clamping on column remove
- Hashtag column pre-fills search query
- ColumnNavigationState.stack exposed as StateFlow not MutableStateFlow
- Settings column deduped (focuses existing instead of adding duplicate)
- Exceptions logged in save/load instead of silently swallowed

Low:
- Thread column icon fixed (was Home, now Article)
- Drag divider hit target widened to 12dp (visual stays 4dp)
- Horizontal scroll added for column overflow
- Auto-fit columns on first composition / window resize
- Column shortcuts limited to actual column count
- Deterministic default column IDs
- Overlay fallback shows message instead of blank

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-04 10:02:22 +02:00
parent 73b8059b55
commit db530a6820
7 changed files with 177 additions and 78 deletions
@@ -141,7 +141,8 @@ fun main() =
)
var showComposeDialog by remember { mutableStateOf(false) }
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
val deckState = remember { DeckState().also { it.load() } }
val deckScope = rememberCoroutineScope()
val deckState = remember { DeckState(deckScope).also { it.load() } }
var showAddColumnDialog by remember { mutableStateOf(false) }
var layoutMode by remember {
mutableStateOf(
@@ -179,7 +180,13 @@ fun main() =
} else {
KeyShortcut(Key.Comma, ctrl = true)
},
onClick = { deckState.addColumn(DeckColumnType.Settings) },
onClick = {
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
},
)
Separator()
Item(
@@ -305,7 +312,8 @@ fun main() =
Key.Eight,
Key.Nine,
)
columnKeys.forEachIndexed { i, key ->
val columnCount = deckState.columns.value.size
columnKeys.take(columnCount).forEachIndexed { i, key ->
Item(
"Column ${i + 1}",
shortcut =
@@ -617,7 +625,13 @@ fun MainContent(
LayoutMode.DECK -> {
DeckSidebar(
onAddColumn = onShowAddColumnDialog,
onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) },
onOpenSettings = {
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
},
)
VerticalDivider()
@@ -151,6 +151,7 @@ fun FeedScreen(
account: AccountState.LoggedIn? = null,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
initialFeedMode: FeedMode? = null,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
@@ -170,7 +171,7 @@ fun FeedScreen(
}
val events by eventState.items.collectAsState()
var replyToEvent by remember { mutableStateOf<Event?>(null) }
var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) }
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
// Track reaction event IDs per target event to deduplicate
@@ -78,6 +78,7 @@ fun SearchScreen(
localCache: DesktopLocalCache,
relayManager: DesktopRelayConnectionManager,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
initialQuery: String = "",
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToHashtag: (String) -> Unit = {},
@@ -86,6 +87,13 @@ fun SearchScreen(
val scope = rememberCoroutineScope()
val searchState = remember { SearchBarState(localCache, scope) }
val focusRequester = remember { FocusRequester() }
// Pre-fill initial query (e.g., hashtag column)
LaunchedEffect(initialQuery) {
if (initialQuery.isNotBlank()) {
searchState.updateSearchText(initialQuery)
}
}
val relayStatuses by relayManager.relayStatuses.collectAsState()
// Collect state from SearchBarState
@@ -133,6 +133,6 @@ fun DeckColumnType.icon(): ImageVector =
DeckColumnType.Chess -> Icons.Default.Extension
DeckColumnType.Settings -> Icons.Default.Settings
is DeckColumnType.Profile -> Icons.Default.Person
is DeckColumnType.Thread -> Icons.Default.Home
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Hashtag -> Icons.Default.Tag
}
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
@@ -56,10 +57,11 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class ColumnNavigationState {
private val _stack = MutableStateFlow<List<DesktopScreen>>(emptyList())
val stack = _stack
val stack: kotlinx.coroutines.flow.StateFlow<List<DesktopScreen>> = _stack.asStateFlow()
fun push(screen: DesktopScreen) {
_stack.value = _stack.value + screen
@@ -180,6 +182,7 @@ internal fun RootContent(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
initialFeedMode = FeedMode.FOLLOWING,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
@@ -249,6 +252,7 @@ internal fun RootContent(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
initialFeedMode = FeedMode.GLOBAL,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
@@ -320,6 +324,7 @@ internal fun RootContent(
localCache = localCache,
relayManager = relayManager,
subscriptionsCoordinator = subscriptionsCoordinator,
initialQuery = "#${columnType.tag}",
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
)
@@ -374,6 +379,12 @@ internal fun OverlayContent(
)
}
else -> {}
else -> {
androidx.compose.material3.Text(
"Unsupported screen type",
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -21,15 +21,18 @@
package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -70,7 +73,22 @@ fun DeckLayout(
val availableWidthDp = with(density) { constraints.maxWidth.toDp().value }
deckState.setAvailableWidth(availableWidthDp)
Row(modifier = Modifier.fillMaxSize()) {
// Auto-fit columns on first composition or when available width changes significantly
LaunchedEffect(availableWidthDp, columns.size) {
val dividers = (columns.size - 1) * DeckState.DIVIDER_WIDTH
val totalColumnWidth = columns.sumOf { it.width.toDouble() }.toFloat()
val diff = kotlin.math.abs(totalColumnWidth + dividers - availableWidthDp)
if (diff > 20f && columns.isNotEmpty()) {
deckState.fitColumnsToWidth(availableWidthDp)
}
}
Row(
modifier =
Modifier
.fillMaxSize()
.horizontalScroll(rememberScrollState()),
) {
columns.forEachIndexed { index, column ->
if (index > 0) {
DraggableDivider(
@@ -108,7 +126,7 @@ private fun DraggableDivider(onDrag: (Float) -> Unit) {
Box(
modifier =
Modifier
.width(4.dp)
.width(12.dp)
.fillMaxHeight()
.pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
.pointerInput(Unit) {
@@ -117,6 +135,7 @@ private fun DraggableDivider(onDrag: (Float) -> Unit) {
onDrag(dragAmount.x / density)
}
},
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
VerticalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
@@ -23,19 +23,29 @@ package com.vitorpamplona.amethyst.desktop.ui.deck
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class DeckState {
class DeckState(
private val saveScope: CoroutineScope,
) {
private val _columns = MutableStateFlow(DEFAULT_COLUMNS)
val columns: StateFlow<List<DeckColumn>> = _columns.asStateFlow()
private val _focusedColumnIndex = MutableStateFlow(0)
val focusedColumnIndex: StateFlow<Int> = _focusedColumnIndex.asStateFlow()
@Volatile
private var lastKnownWidth: Float = 0f
private var saveJob: Job? = null
fun setAvailableWidth(width: Float) {
lastKnownWidth = width
}
@@ -45,71 +55,95 @@ class DeckState {
afterIndex: Int? = null,
) {
val col = DeckColumn(type = type)
_columns.value =
if (afterIndex != null && afterIndex < _columns.value.size) {
_columns.value.toMutableList().apply { add(afterIndex + 1, col) }
_columns.update { current ->
if (afterIndex != null && afterIndex < current.size) {
current.toMutableList().apply { add(afterIndex + 1, col) }
} else {
_columns.value + col
current + col
}
}
// Auto-fit all columns to available width when known
if (lastKnownWidth > 0f) {
fitColumnsToWidth(lastKnownWidth)
val width = lastKnownWidth
if (width > 0f) {
fitColumnsToWidth(width)
} else {
save()
scheduleSave()
}
}
fun hasColumnOfType(type: DeckColumnType): Boolean = _columns.value.any { it.type == type }
fun focusExistingColumn(type: DeckColumnType) {
val idx = _columns.value.indexOfFirst { it.type == type }
if (idx >= 0) focusColumn(idx)
}
fun removeColumn(id: String) {
if (_columns.value.size <= 1) return
val removed = _columns.value.find { it.id == id } ?: return
val remaining = _columns.value.filter { it.id != id }
// Redistribute removed column's width evenly across remaining columns
val extra = removed.width / remaining.size
_columns.value =
remaining.map {
it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH))
_columns.update { current ->
if (current.size <= 1) return
val removed = current.find { it.id == id } ?: return
val remaining = current.filter { it.id != id }
val extra = removed.width / remaining.size
val result =
remaining.map {
it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH))
}
// Fix gaps: if total is less than available, redistribute remainder
val width = lastKnownWidth
if (width > 0f) {
val dividers = (result.size - 1) * DIVIDER_WIDTH
val totalUsed = result.sumOf { it.width.toDouble() }.toFloat()
val deficit = width - dividers - totalUsed
if (deficit > 1f) {
val perColumn = deficit / result.size
return@update result.map {
it.copy(width = (it.width + perColumn).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH))
}
}
}
if (_focusedColumnIndex.value >= _columns.value.size) {
_focusedColumnIndex.value = _columns.value.size - 1
result
}
save()
_focusedColumnIndex.update { idx ->
idx.coerceAtMost(_columns.value.size - 1)
}
scheduleSave()
}
fun moveColumn(
fromIndex: Int,
toIndex: Int,
) {
val list = _columns.value.toMutableList()
if (fromIndex !in list.indices || toIndex !in list.indices) return
val item = list.removeAt(fromIndex)
list.add(toIndex, item)
_columns.value = list
save()
_columns.update { current ->
if (fromIndex !in current.indices || toIndex !in current.indices) return
current.toMutableList().apply {
val item = removeAt(fromIndex)
add(toIndex, item)
}
}
scheduleSave()
}
fun updateColumnWidth(
id: String,
width: Float,
) {
_columns.value =
_columns.value.map {
_columns.update { current ->
current.map {
if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it
}
save()
}
scheduleSave()
}
fun expandColumn(
id: String,
availableWidth: Float,
) {
val cols = _columns.value
val target = cols.find { it.id == id } ?: return
val others = cols.filter { it.id != id }
// Shrink others to minimum, give the rest to the target
val othersMin = others.size * MIN_COLUMN_WIDTH
val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH
val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
_columns.value =
_columns.update { cols ->
if (cols.find { it.id == id } == null) return
val othersMin = (cols.size - 1) * MIN_COLUMN_WIDTH
val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH
val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
cols.map {
if (it.id == id) {
it.copy(width = maxForTarget)
@@ -117,7 +151,8 @@ class DeckState {
it.copy(width = MIN_COLUMN_WIDTH)
}
}
save()
}
scheduleSave()
}
fun resizePair(
@@ -126,24 +161,21 @@ class DeckState {
delta: Float,
availableWidth: Float,
) {
val cols = _columns.value
val left = cols.find { it.id == leftId } ?: return
val right = cols.find { it.id == rightId } ?: return
// Compute max total allowed (available minus other columns and dividers)
val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat()
val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH
val maxPairWidth = availableWidth - otherWidth - dividerWidth
var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
// Ensure pair doesn't exceed available space
if (newLeft + newRight > maxPairWidth) {
if (delta > 0) {
newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
} else {
newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
_columns.update { cols ->
val left = cols.find { it.id == leftId } ?: return
val right = cols.find { it.id == rightId } ?: return
val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat()
val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH
val maxPairWidth = availableWidth - otherWidth - dividerWidth
var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
if (newLeft + newRight > maxPairWidth) {
if (delta > 0) {
newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
} else {
newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
}
}
}
_columns.value =
cols.map {
when (it.id) {
leftId -> it.copy(width = newLeft)
@@ -151,17 +183,19 @@ class DeckState {
else -> it
}
}
save()
}
scheduleSave()
}
fun fitColumnsToWidth(availableWidth: Float) {
val cols = _columns.value
if (cols.isEmpty()) return
val dividers = (cols.size - 1) * DIVIDER_WIDTH
val usable = availableWidth - dividers
val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
_columns.value = cols.map { it.copy(width = perColumn) }
save()
_columns.update { cols ->
if (cols.isEmpty()) return
val dividers = (cols.size - 1) * DIVIDER_WIDTH
val usable = availableWidth - dividers
val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
cols.map { it.copy(width = perColumn) }
}
scheduleSave()
}
fun focusColumn(index: Int) {
@@ -170,6 +204,15 @@ class DeckState {
}
}
private fun scheduleSave() {
saveJob?.cancel()
saveJob =
saveScope.launch {
delay(SAVE_DEBOUNCE_MS)
save()
}
}
fun save() {
try {
val data =
@@ -188,7 +231,8 @@ class DeckState {
)
}
DesktopPreferences.deckColumns = mapper.writeValueAsString(data)
} catch (_: Exception) {
} catch (e: Exception) {
println("DeckState: failed to save columns: ${e.message}")
}
}
@@ -207,20 +251,22 @@ class DeckState {
if (loaded.isNotEmpty()) {
_columns.value = loaded
}
} catch (_: Exception) {
} catch (e: Exception) {
println("DeckState: failed to load columns: ${e.message}")
}
}
companion object {
const val MIN_COLUMN_WIDTH = 300f
const val MAX_COLUMN_WIDTH = 800f
const val DIVIDER_WIDTH = 4f
const val DIVIDER_WIDTH = 12f
private const val SAVE_DEBOUNCE_MS = 500L
val DEFAULT_COLUMNS =
listOf(
DeckColumn(type = DeckColumnType.HomeFeed),
DeckColumn(type = DeckColumnType.Notifications),
DeckColumn(type = DeckColumnType.Messages),
DeckColumn(id = "default-home", type = DeckColumnType.HomeFeed),
DeckColumn(id = "default-notifications", type = DeckColumnType.Notifications),
DeckColumn(id = "default-messages", type = DeckColumnType.Messages),
)
private val mapper = jacksonObjectMapper()