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:
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -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
-2
@@ -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,
|
||||||
|
|||||||
+92
-46
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user