feat(desktop): slide-animated inline navigation with 2-level back stack cap

- 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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-06-01 10:23:24 +03:00
co-authored by Claude Opus 4.6
parent 144b911867
commit fd6e37b84a
2 changed files with 174 additions and 61 deletions
@@ -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<List<DesktopScreen>>(emptyList())
val stack: kotlinx.coroutines.flow.StateFlow<List<DesktopScreen>> = _stack.asStateFlow()
private val _stack = mutableStateListOf<DesktopScreen>()
val stack: List<DesktopScreen> 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() },
)
}
}
}
}
@@ -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() },
)
}
}
}
}