From fd6e37b84a5a17405152a4e1ef804d33bffd0bd1 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 1 Jun 2026 10:23:24 +0300 Subject: [PATCH] feat(desktop): slide-animated inline navigation with 2-level back stack cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor ColumnNavigationState to use mutableStateListOf with direction tracking - Add pushWithCap(maxDepth=2) — replaces top entry when cap reached - Replace instant Surface overlay with AnimatedContent slide transitions (200ms) - Add Esc key handler (onPreviewKeyEvent) for back navigation - Add FocusRequester for keyboard nav to work after slide - Apply to both DeckColumnContainer and SinglePaneLayout Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/ui/deck/DeckColumnContainer.kt | 166 ++++++++++++++---- .../desktop/ui/deck/SinglePaneLayout.kt | 69 +++++--- 2 files changed, 174 insertions(+), 61 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 86d23cf80f..4a203c74b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -20,6 +20,14 @@ */ package com.vitorpamplona.amethyst.desktop.ui.deck +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight @@ -29,11 +37,22 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen @@ -65,25 +84,38 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen import com.vitorpamplona.amethyst.desktop.ui.relay.RelayDashboardScreen 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>(emptyList()) - val stack: kotlinx.coroutines.flow.StateFlow> = _stack.asStateFlow() + private val _stack = mutableStateListOf() + val stack: List get() = _stack + val current: DesktopScreen? get() = _stack.lastOrNull() + val hasBackStack: Boolean get() = _stack.isNotEmpty() + + var navigatingForward by mutableStateOf(true) + private set + + fun pushWithCap( + screen: DesktopScreen, + maxDepth: Int = 2, + ) { + navigatingForward = true + if (_stack.size >= maxDepth) _stack.removeFirst() + _stack.add(screen) + } fun push(screen: DesktopScreen) { - _stack.value = _stack.value + screen + pushWithCap(screen) } fun pop(): Boolean { - if (_stack.value.isEmpty()) return false - _stack.value = _stack.value.dropLast(1) + if (_stack.isEmpty()) return false + navigatingForward = false + _stack.removeLast() return true } fun clear() { - _stack.value = emptyList() + _stack.clear() } } @@ -111,19 +143,34 @@ fun DeckColumnContainer( modifier: Modifier = Modifier, ) { val navState = remember(column.id) { ColumnNavigationState() } - val navStack by navState.stack.collectAsState() - val currentOverlay = navStack.lastOrNull() + val currentOverlay = navState.current + val focusRequester = remember { FocusRequester() } + + // Request focus on nav change so Esc key works + LaunchedEffect(currentOverlay) { + focusRequester.requestFocus() + } Column( modifier = modifier .width(column.width.dp) - .fillMaxHeight(), + .fillMaxHeight() + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.key == Key.Escape && event.type == KeyEventType.KeyUp && navState.hasBackStack) { + navState.pop() + true + } else { + false + } + }, ) { ColumnHeader( column = column, canClose = canClose, - hasBackStack = navStack.isNotEmpty(), + hasBackStack = navState.hasBackStack, onBack = { navState.pop() }, onClose = onClose, onDoubleClick = onDoubleClickHeader, @@ -147,10 +194,8 @@ fun DeckColumnContainer( ) // Content runs edge-to-edge; each screen adds its own header padding - // to match the Messages pattern (padding(horizontal = 12, vertical = 8) - // on the title row, no outer wrapper). Box(modifier = Modifier.fillMaxSize()) { - // Always keep RootContent composed so state (e.g. search results) survives navigation + // Always keep RootContent composed so state survives navigation RootContent( columnType = column.type, relayManager = relayManager, @@ -174,28 +219,75 @@ fun DeckColumnContainer( onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, onNavigateToRelays = onNavigateToRelays, ) - if (currentOverlay != null) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.fillMaxSize(), - ) { - OverlayContent( - screen = currentOverlay, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, - draftStore = draftStore, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, - onBack = { navState.pop() }, - ) + + // Overlay with slide animation + AnimatedContent( + targetState = currentOverlay, + transitionSpec = { + val duration = 200 + if (navState.navigatingForward) { + ( + slideInHorizontally( + tween(duration), + ) { it } + + fadeIn( + androidx.compose.animation.core + .tween(duration), + ) + ).togetherWith( + slideOutHorizontally( + tween(duration), + ) { -it } + + fadeOut( + androidx.compose.animation.core + .tween(duration), + ), + ) + } else { + ( + slideInHorizontally( + tween(duration), + ) { -it } + + fadeIn( + androidx.compose.animation.core + .tween(duration), + ) + ).togetherWith( + slideOutHorizontally( + tween(duration), + ) { it } + + fadeOut( + androidx.compose.animation.core + .tween(duration), + ), + ) + } + }, + label = "ColumnNavAnimation", + ) { overlayScreen -> + if (overlayScreen != null) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + ) { + OverlayContent( + screen = overlayScreen, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onBack = { navState.pop() }, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 922f69f934..29e7779a3b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -20,6 +20,13 @@ */ package com.vitorpamplona.amethyst.desktop.ui.deck +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -74,8 +81,7 @@ fun SinglePaneLayout( ) { val currentColumnType by singlePaneState.currentScreen.collectAsState() val navState = remember { ColumnNavigationState() } - val navStack by navState.stack.collectAsState() - val currentOverlay = navStack.lastOrNull() + val currentOverlay = navState.current // Sidebar is now provided by Main.kt (shared MainSidebar for both layout modes). // SinglePaneLayout only renders the content pane. @@ -124,28 +130,43 @@ fun SinglePaneLayout( onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) }, onOpenFeedsDrawer = onOpenFeedsDrawer, ) - if (currentOverlay != null) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.fillMaxSize(), - ) { - OverlayContent( - screen = currentOverlay, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, - draftStore = draftStore, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, - onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, - onBack = { navState.pop() }, - ) + AnimatedContent( + targetState = currentOverlay, + transitionSpec = { + val duration = 200 + if (navState.navigatingForward) { + (slideInHorizontally(tween(duration)) { it } + fadeIn(tween(duration))) + .togetherWith(slideOutHorizontally(tween(duration)) { -it } + fadeOut(tween(duration))) + } else { + (slideInHorizontally(tween(duration)) { -it } + fadeIn(tween(duration))) + .togetherWith(slideOutHorizontally(tween(duration)) { it } + fadeOut(tween(duration))) + } + }, + label = "SinglePaneNavAnimation", + ) { overlayScreen -> + if (overlayScreen != null) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + ) { + OverlayContent( + screen = overlayScreen, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onBack = { navState.pop() }, + ) + } } } }