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 showComposeDialog by remember { mutableStateOf(false) }
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 deckScope = rememberCoroutineScope()
val deckState = remember { DeckState(deckScope).also { it.load() } }
var showAddColumnDialog by remember { mutableStateOf(false) } var showAddColumnDialog by remember { mutableStateOf(false) }
var layoutMode by remember { var layoutMode by remember {
mutableStateOf( mutableStateOf(
@@ -179,7 +180,13 @@ fun main() =
} else { } else {
KeyShortcut(Key.Comma, ctrl = true) 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() Separator()
Item( Item(
@@ -305,7 +312,8 @@ fun main() =
Key.Eight, Key.Eight,
Key.Nine, Key.Nine,
) )
columnKeys.forEachIndexed { i, key -> val columnCount = deckState.columns.value.size
columnKeys.take(columnCount).forEachIndexed { i, key ->
Item( Item(
"Column ${i + 1}", "Column ${i + 1}",
shortcut = shortcut =
@@ -617,7 +625,13 @@ fun MainContent(
LayoutMode.DECK -> { LayoutMode.DECK -> {
DeckSidebar( DeckSidebar(
onAddColumn = onShowAddColumnDialog, onAddColumn = onShowAddColumnDialog,
onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) }, onOpenSettings = {
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
},
) )
VerticalDivider() VerticalDivider()
@@ -151,6 +151,7 @@ fun FeedScreen(
account: AccountState.LoggedIn? = null, account: AccountState.LoggedIn? = null,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
initialFeedMode: FeedMode? = null,
onCompose: () -> Unit = {}, onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {},
@@ -170,7 +171,7 @@ fun FeedScreen(
} }
val events by eventState.items.collectAsState() val events by eventState.items.collectAsState()
var replyToEvent by remember { mutableStateOf<Event?>(null) } 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 followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) } var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
// Track reaction event IDs per target event to deduplicate // Track reaction event IDs per target event to deduplicate
@@ -78,6 +78,7 @@ fun SearchScreen(
localCache: DesktopLocalCache, localCache: DesktopLocalCache,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
initialQuery: String = "",
onNavigateToProfile: (String) -> Unit, onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit, onNavigateToThread: (String) -> Unit,
onNavigateToHashtag: (String) -> Unit = {}, onNavigateToHashtag: (String) -> Unit = {},
@@ -86,6 +87,13 @@ fun SearchScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val searchState = remember { SearchBarState(localCache, scope) } val searchState = remember { SearchBarState(localCache, scope) }
val focusRequester = remember { FocusRequester() } 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() val relayStatuses by relayManager.relayStatuses.collectAsState()
// Collect state from SearchBarState // Collect state from SearchBarState
@@ -133,6 +133,6 @@ fun DeckColumnType.icon(): ImageVector =
DeckColumnType.Chess -> Icons.Default.Extension DeckColumnType.Chess -> Icons.Default.Extension
DeckColumnType.Settings -> Icons.Default.Settings DeckColumnType.Settings -> Icons.Default.Settings
is DeckColumnType.Profile -> Icons.Default.Person 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 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.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator 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.BookmarksScreen
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen 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 com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class ColumnNavigationState { class ColumnNavigationState {
private val _stack = MutableStateFlow<List<DesktopScreen>>(emptyList()) private val _stack = MutableStateFlow<List<DesktopScreen>>(emptyList())
val stack = _stack val stack: kotlinx.coroutines.flow.StateFlow<List<DesktopScreen>> = _stack.asStateFlow()
fun push(screen: DesktopScreen) { fun push(screen: DesktopScreen) {
_stack.value = _stack.value + screen _stack.value = _stack.value + screen
@@ -180,6 +182,7 @@ internal fun RootContent(
account = account, account = account,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
initialFeedMode = FeedMode.FOLLOWING,
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread, onNavigateToThread = onNavigateToThread,
@@ -249,6 +252,7 @@ internal fun RootContent(
account = account, account = account,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
initialFeedMode = FeedMode.GLOBAL,
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread, onNavigateToThread = onNavigateToThread,
@@ -320,6 +324,7 @@ internal fun RootContent(
localCache = localCache, localCache = localCache,
relayManager = relayManager, relayManager = relayManager,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
initialQuery = "#${columnType.tag}",
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread, 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 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.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
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -70,7 +73,22 @@ fun DeckLayout(
val availableWidthDp = with(density) { constraints.maxWidth.toDp().value } val availableWidthDp = with(density) { constraints.maxWidth.toDp().value }
deckState.setAvailableWidth(availableWidthDp) 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 -> columns.forEachIndexed { index, column ->
if (index > 0) { if (index > 0) {
DraggableDivider( DraggableDivider(
@@ -108,7 +126,7 @@ private fun DraggableDivider(onDrag: (Float) -> Unit) {
Box( Box(
modifier = modifier =
Modifier Modifier
.width(4.dp) .width(12.dp)
.fillMaxHeight() .fillMaxHeight()
.pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
.pointerInput(Unit) { .pointerInput(Unit) {
@@ -117,6 +135,7 @@ private fun DraggableDivider(onDrag: (Float) -> Unit) {
onDrag(dragAmount.x / density) onDrag(dragAmount.x / density)
} }
}, },
contentAlignment = androidx.compose.ui.Alignment.Center,
) { ) {
VerticalDivider( VerticalDivider(
color = MaterialTheme.colorScheme.outlineVariant, 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.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.desktop.DesktopPreferences 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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) private val _columns = MutableStateFlow(DEFAULT_COLUMNS)
val columns: StateFlow<List<DeckColumn>> = _columns.asStateFlow() val columns: StateFlow<List<DeckColumn>> = _columns.asStateFlow()
private val _focusedColumnIndex = MutableStateFlow(0) private val _focusedColumnIndex = MutableStateFlow(0)
val focusedColumnIndex: StateFlow<Int> = _focusedColumnIndex.asStateFlow() val focusedColumnIndex: StateFlow<Int> = _focusedColumnIndex.asStateFlow()
@Volatile
private var lastKnownWidth: Float = 0f private var lastKnownWidth: Float = 0f
private var saveJob: Job? = null
fun setAvailableWidth(width: Float) { fun setAvailableWidth(width: Float) {
lastKnownWidth = width lastKnownWidth = width
} }
@@ -45,71 +55,95 @@ class DeckState {
afterIndex: Int? = null, afterIndex: Int? = null,
) { ) {
val col = DeckColumn(type = type) val col = DeckColumn(type = type)
_columns.value = _columns.update { current ->
if (afterIndex != null && afterIndex < _columns.value.size) { if (afterIndex != null && afterIndex < current.size) {
_columns.value.toMutableList().apply { add(afterIndex + 1, col) } current.toMutableList().apply { add(afterIndex + 1, col) }
} else { } else {
_columns.value + col current + col
}
} }
// Auto-fit all columns to available width when known // Auto-fit all columns to available width when known
if (lastKnownWidth > 0f) { val width = lastKnownWidth
fitColumnsToWidth(lastKnownWidth) if (width > 0f) {
fitColumnsToWidth(width)
} else { } 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) { fun removeColumn(id: String) {
if (_columns.value.size <= 1) return _columns.update { current ->
val removed = _columns.value.find { it.id == id } ?: return if (current.size <= 1) return
val remaining = _columns.value.filter { it.id != id } val removed = current.find { it.id == id } ?: return
// Redistribute removed column's width evenly across remaining columns val remaining = current.filter { it.id != id }
val extra = removed.width / remaining.size val extra = removed.width / remaining.size
_columns.value = val result =
remaining.map { remaining.map {
it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH))
} }
if (_focusedColumnIndex.value >= _columns.value.size) { // Fix gaps: if total is less than available, redistribute remainder
_focusedColumnIndex.value = _columns.value.size - 1 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))
} }
save() }
}
result
}
_focusedColumnIndex.update { idx ->
idx.coerceAtMost(_columns.value.size - 1)
}
scheduleSave()
} }
fun moveColumn( fun moveColumn(
fromIndex: Int, fromIndex: Int,
toIndex: Int, toIndex: Int,
) { ) {
val list = _columns.value.toMutableList() _columns.update { current ->
if (fromIndex !in list.indices || toIndex !in list.indices) return if (fromIndex !in current.indices || toIndex !in current.indices) return
val item = list.removeAt(fromIndex) current.toMutableList().apply {
list.add(toIndex, item) val item = removeAt(fromIndex)
_columns.value = list add(toIndex, item)
save() }
}
scheduleSave()
} }
fun updateColumnWidth( fun updateColumnWidth(
id: String, id: String,
width: Float, width: Float,
) { ) {
_columns.value = _columns.update { current ->
_columns.value.map { current.map {
if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it
} }
save() }
scheduleSave()
} }
fun expandColumn( fun expandColumn(
id: String, id: String,
availableWidth: Float, availableWidth: Float,
) { ) {
val cols = _columns.value _columns.update { cols ->
val target = cols.find { it.id == id } ?: return if (cols.find { it.id == id } == null) return
val others = cols.filter { it.id != id } val othersMin = (cols.size - 1) * MIN_COLUMN_WIDTH
// 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 dividerWidth = (cols.size - 1) * DIVIDER_WIDTH
val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
_columns.value =
cols.map { cols.map {
if (it.id == id) { if (it.id == id) {
it.copy(width = maxForTarget) it.copy(width = maxForTarget)
@@ -117,7 +151,8 @@ class DeckState {
it.copy(width = MIN_COLUMN_WIDTH) it.copy(width = MIN_COLUMN_WIDTH)
} }
} }
save() }
scheduleSave()
} }
fun resizePair( fun resizePair(
@@ -126,16 +161,14 @@ class DeckState {
delta: Float, delta: Float,
availableWidth: Float, availableWidth: Float,
) { ) {
val cols = _columns.value _columns.update { cols ->
val left = cols.find { it.id == leftId } ?: return val left = cols.find { it.id == leftId } ?: return
val right = cols.find { it.id == rightId } ?: 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 otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat()
val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH
val maxPairWidth = availableWidth - otherWidth - dividerWidth val maxPairWidth = availableWidth - otherWidth - dividerWidth
var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
var newRight = (right.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 (newLeft + newRight > maxPairWidth) {
if (delta > 0) { if (delta > 0) {
newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
@@ -143,7 +176,6 @@ class DeckState {
newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
} }
} }
_columns.value =
cols.map { cols.map {
when (it.id) { when (it.id) {
leftId -> it.copy(width = newLeft) leftId -> it.copy(width = newLeft)
@@ -151,17 +183,19 @@ class DeckState {
else -> it else -> it
} }
} }
save() }
scheduleSave()
} }
fun fitColumnsToWidth(availableWidth: Float) { fun fitColumnsToWidth(availableWidth: Float) {
val cols = _columns.value _columns.update { cols ->
if (cols.isEmpty()) return if (cols.isEmpty()) return
val dividers = (cols.size - 1) * DIVIDER_WIDTH val dividers = (cols.size - 1) * DIVIDER_WIDTH
val usable = availableWidth - dividers val usable = availableWidth - dividers
val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
_columns.value = cols.map { it.copy(width = perColumn) } cols.map { it.copy(width = perColumn) }
save() }
scheduleSave()
} }
fun focusColumn(index: Int) { 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() { fun save() {
try { try {
val data = val data =
@@ -188,7 +231,8 @@ class DeckState {
) )
} }
DesktopPreferences.deckColumns = mapper.writeValueAsString(data) 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()) { if (loaded.isNotEmpty()) {
_columns.value = loaded _columns.value = loaded
} }
} catch (_: Exception) { } catch (e: Exception) {
println("DeckState: failed to load columns: ${e.message}")
} }
} }
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 const val DIVIDER_WIDTH = 12f
private const val SAVE_DEBOUNCE_MS = 500L
val DEFAULT_COLUMNS = val DEFAULT_COLUMNS =
listOf( listOf(
DeckColumn(type = DeckColumnType.HomeFeed), DeckColumn(id = "default-home", type = DeckColumnType.HomeFeed),
DeckColumn(type = DeckColumnType.Notifications), DeckColumn(id = "default-notifications", type = DeckColumnType.Notifications),
DeckColumn(type = DeckColumnType.Messages), DeckColumn(id = "default-messages", type = DeckColumnType.Messages),
) )
private val mapper = jacksonObjectMapper() private val mapper = jacksonObjectMapper()