diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt index 9528f51c1..3ac45b0d1 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt @@ -1,11 +1,14 @@ package org.polyfrost.oneconfig.internal.ui.components import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.border import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme enum class IconButtonType { @@ -19,6 +22,9 @@ fun IconButton( modifier: Modifier = Modifier, foreground: Color = LocalTheme.current.textColor.copy(0.7f), hoveredForeground: Color = LocalTheme.current.textColor, + enabled: Boolean = true, + /** Outlines the button on hover; off by default so small inline icons (field clear buttons) stay bare */ + hoverBorder: Boolean = false, onClick: () -> Unit, ) { val interactionSource = rememberInteractionSource() @@ -26,12 +32,26 @@ fun IconButton( val isHovered by interactionSource.collectIsHoveredAsState() val iconColor by animateColorAsState( - if (isHovered) hoveredForeground else foreground + if (!enabled) { + LocalTheme.current.textColorSecondary.copy(alpha = 0.35f) + } else { + if (isHovered) hoveredForeground else foreground + } + ) + val borderColor by animateColorAsState( + if (hoverBorder && enabled && isHovered) LocalTheme.current.borderColor + else LocalTheme.current.borderColor.copy(0f) ) Icon( iconName, - color = iconColor, modifier = Modifier.onClick(interactionSource, onClick).then(modifier) + color = iconColor, + modifier = Modifier.onClick(interactionSource, enabled, onClick).then(modifier) + // inset the glyph so the outline doesn't hug it; size bordered buttons 8dp larger to keep the icon size + .then( + if (hoverBorder) Modifier.border(1.dp, borderColor, LocalTheme.current.buttonShape).padding(4.dp) + else Modifier + ) ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Chips.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Chips.kt index e277511d7..a5179ae86 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Chips.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Chips.kt @@ -3,6 +3,7 @@ package org.polyfrost.oneconfig.internal.ui.components import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize @@ -11,6 +12,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp @@ -26,8 +30,11 @@ fun Chip( onClick: () -> Unit, ) { val interactionSource = rememberInteractionSource() + val isHovered by interactionSource.collectIsHoveredAsState() + val theme = LocalTheme.current + val hoverBackground = Accent.copy(alpha = 0.5f).compositeOver(theme.chipBackground) val backgroundColor by animateColorAsState( - if (selected) Accent else LocalTheme.current.chipBackground + if (selected) Accent else if (isHovered) hoverBackground else theme.chipBackground ) val textColor by animateColorAsState( if (selected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor @@ -52,4 +59,4 @@ fun Chip( Text(label, fontSize = 14.sp, color = textColor) } } -} +} \ No newline at end of file diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt index 6f5b0babe..bf10525a2 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt @@ -1,6 +1,7 @@ package org.polyfrost.oneconfig.internal.ui.components import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -49,11 +51,25 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.toRoute import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus import org.polyfrost.oneconfig.internal.ui.LocalCloseRequest +import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.Tooltip import org.polyfrost.oneconfig.internal.ui.components.dropdown.DropdownPositionProvider +import org.polyfrost.oneconfig.internal.ui.navigation.graph.ChangeLogEntryRoute +import org.polyfrost.oneconfig.internal.ui.navigation.graph.ChangeLogRoute +import org.polyfrost.oneconfig.internal.ui.navigation.graph.CreditsRoute +import org.polyfrost.oneconfig.internal.ui.navigation.graph.KeybindsRoute +import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute +import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModsRoute +import org.polyfrost.oneconfig.internal.ui.navigation.graph.PreferencesRoute +import org.polyfrost.oneconfig.internal.ui.navigation.graph.ProfilesRoute +import org.polyfrost.oneconfig.internal.ui.navigation.graph.ThemesRoute import org.polyfrost.oneconfig.internal.ui.navigation.searchPlaceholder import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController import org.polyfrost.oneconfig.internal.ui.shell.SearchFocus @@ -65,6 +81,14 @@ import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme fun Header(modifier: Modifier = Modifier) { val controller = LocalNavController.wrapper val closeRequest = LocalCloseRequest.current + val backStackEntry by LocalNavController.current.currentBackStackEntryAsState() + val title = remember(backStackEntry, ConfigRegistry.revision) { backStackEntry.headerTitle() } + val openOriginalScreen = title?.openOriginalScreen + // historyRevision reads snapshot state so this recomposes and re-queries the stacks whenever the + // (plain ArrayDeque) history changes; the stacks stay the single source of truth. + controller.historyRevision + val canGoBack = controller.canGoBack() + val canGoForward = controller.canGoForward() Row( modifier = modifier.fillMaxWidth().height(36.dp), @@ -73,29 +97,65 @@ fun Header(modifier: Modifier = Modifier) { ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(24.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - IconButton("left-arrow") { controller.back() } - IconButton("right-arrow") { controller.forward() } + IconButton("left-arrow", Modifier.size(32.dp), enabled = canGoBack, hoverBorder = true) { controller.back() } + IconButton("right-arrow", Modifier.size(32.dp), enabled = canGoForward, hoverBorder = true) { controller.forward() } } - AnimatedContent( - ShellState.title, - transitionSpec = { - fadeIn() togetherWith fadeOut() + Spacer(Modifier.size(24.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + AnimatedContent( + title, + transitionSpec = { + fadeIn() togetherWith fadeOut() + } + ) { headerTitle -> + headerTitle?.let { info -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + info.text, + color = LocalTheme.current.textColor, + fontSize = 24.sp, + fontWeight = FontWeight.Medium + ) + TitleAuthorByline(info.authors) + TitleInfoTooltip(info) + } + } } - ) { text -> - text?.let { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(it, color = LocalTheme.current.textColor, fontSize = 24.sp, fontWeight = FontWeight.Medium) - TitleAuthorByline(it) - TitleInfoTooltip(it) + // Separate animation keeps the button composed exactly once per frame while still giving + // it the same fade as the title. Sharing the title's AnimatedContent duplicated the + // button per transition target, and the outgoing copy had no byline or tooltip (both + // gated on the current title) so it briefly showed against the bare title. + // + // AnimatedVisibility rather than AnimatedContent: the header Row vertically centers its + // children, so animating AnimatedContent's measured size re-centers it and drags the + // button around. AnimatedVisibility only fades and keeps the content's natural size for + // the whole transition, so the Row never has to re-center it. + var lastAction by remember { mutableStateOf(null) } + LaunchedEffect(openOriginalScreen) { + if (openOriginalScreen != null) lastAction = openOriginalScreen + } + AnimatedVisibility( + visible = openOriginalScreen != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + // one Row so the spacer and the button share a single measured node and keep their + // horizontal order + // prefer the live action so switching between two mods never shows the previous + // mod's runnable for the frame before the effect updates lastAction + Row(verticalAlignment = Alignment.CenterVertically) { + (openOriginalScreen ?: lastAction)?.let { + Spacer(Modifier.size(8.dp)) + OpenOriginalScreenButton(it) + } } } } @@ -105,13 +165,61 @@ fun Header(modifier: Modifier = Modifier) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(26.dp) ) { - ShellState.openOriginalScreen?.let { OpenOriginalScreenButton(it) } GlobalSearchBar() - IconButton("close") { closeRequest() } + IconButton("close", Modifier.size(32.dp), hoverBorder = true) { closeRequest() } } } } +/** + * The header title and its extra info, resolved purely from the entry currently on top of the back + * stack. + * + * Deliberately derived from the live back-stack entry instead of screen-owned `ShellState` fields: a + * screen-scoped `DisposableEffect` only clears on dispose, which NavHost delays until the outgoing + * screen's exit transition finishes, so the outgoing screen's `onDispose` wiped the incoming screen's + * title info mid-transition (the byline vanished and the "Open original screen" button outlived its + * page). Deriving it here makes the title a pure function of the current route, so each transition + * target renders its own content. + */ +private data class HeaderTitle( + val text: String, + val authors: String? = null, + val credits: String? = null, + val version: String? = null, + val openOriginalScreen: Runnable? = null, +) + +private fun NavBackStackEntry?.headerTitle(): HeaderTitle? { + val entry = this ?: return null + val destination = entry.destination + + if (destination.hasRoute(ModConfigRoute::class)) { + val id = runCatching { entry.toRoute().id }.getOrNull() ?: return null + val config = ConfigRegistry.findById(id) ?: return null + val tree = ConfigRegistry.findTree(id) + return HeaderTitle( + text = config.title.asRenderText(), + authors = config.authors, + credits = config.credits, + version = config.version, + openOriginalScreen = tree?.getMetadata("open_original_screen"), + ) + } + + return when { + destination.hierarchy.any { it.hasRoute(ModsRoute::class) } -> HeaderTitle("Mods") + destination.hierarchy.any { it.hasRoute(ProfilesRoute::class) } -> HeaderTitle("Profiles") + destination.hierarchy.any { it.hasRoute(KeybindsRoute::class) } -> HeaderTitle("Keybinds") + destination.hierarchy.any { it.hasRoute(ThemesRoute::class) } -> HeaderTitle("Themes") + destination.hierarchy.any { it.hasRoute(PreferencesRoute::class) } -> HeaderTitle("Preferences") + destination.hierarchy.any { it.hasRoute(ChangeLogRoute::class) || it.hasRoute(ChangeLogEntryRoute::class) } -> + HeaderTitle("Changelog") + destination.hierarchy.any { it.hasRoute(CreditsRoute::class) } -> HeaderTitle("Credits") + else -> null + } +} + @Composable private fun OpenOriginalScreenButton(runnable: Runnable) { val theme = LocalTheme.current @@ -135,18 +243,24 @@ private fun OpenOriginalScreenButton(runnable: Runnable) { .pointerHoverIcon(PointerIcon.Hand) .background(bgColor, theme.buttonShape) .onClick(interactionSource) { runnable.run() } + .border(1.dp, LocalTheme.current.borderColor, LocalTheme.current.buttonShape) .padding(horizontal = 16.dp, vertical = 6.dp), contentAlignment = Alignment.Center, ) { - Text("Open original screen", color = theme.accentTextColor, fontSize = 13.sp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon("external-link", color = theme.accentTextColor, modifier = Modifier.size(14.dp)) + Text("Original menu", color = theme.accentTextColor, fontSize = 13.sp) + } } } } @Composable -private fun TitleAuthorByline(title: String) { - if (ShellState.titleInfoForTitle != title) return - val byline = ShellState.titleAuthors?.authorByline() ?: return +private fun TitleAuthorByline(authors: String?) { + val byline = authors?.authorByline() ?: return Text( byline, color = LocalTheme.current.textColorSecondary, @@ -168,11 +282,10 @@ private fun String.authorByline(): String? { } @Composable -private fun TitleInfoTooltip(title: String) { - if (ShellState.titleInfoForTitle != title) return - val version = ShellState.titleVersion - val authors = ShellState.titleAuthors - val credits = ShellState.titleCredits +private fun TitleInfoTooltip(title: HeaderTitle) { + val version = title.version + val authors = title.authors + val credits = title.credits if (version == null && authors == null && credits == null) return val interactionSource = rememberInteractionSource() val isHovered by interactionSource.collectIsHoveredAsState() diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Sidebar.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Sidebar.kt index 6777655f2..8a0708723 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Sidebar.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Sidebar.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -256,7 +257,7 @@ private fun Account() { horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - PlayerHead(Modifier.size(32.dp)) + PlayerHead(Modifier.size(32.dp).offset(x = 2.dp)) Column( verticalArrangement = Arrangement.spacedBy(2.dp), ) { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt index d21cf34df..823965d72 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt @@ -16,12 +16,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -71,6 +69,7 @@ import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController import org.polyfrost.oneconfig.internal.ui.shell.PageGutter import org.polyfrost.oneconfig.internal.ui.shell.ShellState +import org.polyfrost.oneconfig.internal.ui.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -230,13 +229,10 @@ private fun MdBlocks(node: Node, styles: MdStyles, modifier: Modifier = Modifier @Composable fun Changelog() { - DisposableEffect(Unit) { - ShellState.title = "Changelog" - onDispose { } - } - - val lazyListState = rememberLazyListState() val localSearchQuery = ShellState.searchQuery.trim() + // back/forward re-navigate rather than pop so nav-host saved state is gone on return; keep the + // position in the shell's scroll memory like the other list pages + val lazyListState = rememberRestorableLazyListState("changelog", localSearchQuery) val visibleSections = remember(localSearchQuery) { changelogs.sections.mapIndexed { index, section -> index to section } .filter { (_, section) -> @@ -272,11 +268,6 @@ private fun ChangelogSection.matchesSearch(query: String): Boolean { @Composable fun Changelog(index: Int) { - DisposableEffect(Unit) { - ShellState.title = "Changelog" - onDispose { } - } - val data = changelogs.sections.getOrNull(index) ?: return val theme = LocalTheme.current val styles = MdStyles( diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Credits.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Credits.kt index 169416c4e..977e382da 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Credits.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Credits.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -22,7 +21,6 @@ import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.rememberBrandTint import org.polyfrost.oneconfig.internal.ui.components.rememberSvgResourcePainter import org.polyfrost.oneconfig.internal.ui.shell.PageGutter -import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -73,11 +71,6 @@ fun Credits() { val theme = LocalTheme.current val scrollState = rememberScrollState() - DisposableEffect(Unit) { - ShellState.title = "Credits" - onDispose { } - } - ScrollbarBox(scrollState, PageGutter, Modifier.fillMaxSize()) { Column( modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(horizontal = PageGutter) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt index 78eabf28a..f9dd81d54 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -86,11 +85,6 @@ private val CONFLICT_COLOR = Color(0xFFE0524F) fun Keybinds() { val revision = ConfigRegistry.revision - DisposableEffect(Unit) { - ShellState.title = "Keybinds" - onDispose { } - } - val configs = ConfigRegistry.configs.toList() val providerRevision = KeybindProviderRegistry.revision.intValue val groups = remember(revision, providerRevision, configs) { collectAllKeybindGroups() } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModConfig.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModConfig.kt index eea367c4d..33c9ddf58 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModConfig.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModConfig.kt @@ -16,8 +16,6 @@ import org.polyfrost.oneconfig.api.ui.v1.ModToggles import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.PropertyModToggle import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.asRenderText -import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @Composable @@ -26,23 +24,6 @@ fun ModConfig(id: String, initialCategory: String? = null) { val config = ConfigRegistry.findById(id) val tree = ConfigRegistry.findTree(id) - DisposableEffect(id, config?.title, config?.authors, config?.credits, config?.version, tree) { - val title = config?.title?.asRenderText() - ShellState.title = title - ShellState.titleInfoForTitle = title - ShellState.titleAuthors = config?.authors - ShellState.titleCredits = config?.credits - ShellState.titleVersion = config?.version - ShellState.openOriginalScreen = tree?.getMetadata("open_original_screen") - onDispose { - ShellState.titleInfoForTitle = null - ShellState.titleAuthors = null - ShellState.titleCredits = null - ShellState.titleVersion = null - ShellState.openOriginalScreen = null - } - } - val externalOpen = config?.onOpen when { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt index 067c7d432..433a5f4c5 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt @@ -127,11 +127,6 @@ enum class ModCategory( fun Mods() { var activeCategory by remember { mutableStateOf(ModCategory.All) } - DisposableEffect(Unit) { - ShellState.title = "Mods" - onDispose { } - } - Column(verticalArrangement = Arrangement.spacedBy(19.dp)) { Row( modifier = Modifier.padding(horizontal = PageGutter), @@ -230,6 +225,15 @@ fun ColumnScope.ModsGrid(category: ModCategory) { onDispose { reorderState.cancel() } } + // The overlay card is drawn as a sibling of the grid, but the page pane that wraps the nav host + // clips its content, so it would be cut off at the pane edge. Drop that clip for as long as an + // overlay card exists (dragging and the settle-back animation) so it can be seen in full. + val overlayActive = reorderState.overlayKey != null + DisposableEffect(overlayActive) { + ShellState.modDragging = overlayActive + onDispose { if (overlayActive) ShellState.modDragging = false } + } + // override scroll to stay in place instead of following the first visible item DisposableEffect(favoriteRevision) { gridState.requestScrollToItem(gridState.firstVisibleItemIndex, gridState.firstVisibleItemScrollOffset) @@ -242,7 +246,8 @@ fun ColumnScope.ModsGrid(category: ModCategory) { PageGutter, modifier = Modifier .weight(1f) - .clipToBounds() + // see overlayActive above: the overlay card has to be able to leave the grid while it exists + .then(if (overlayActive) Modifier else Modifier.clipToBounds()) // cards can't take focus, so clear it on press to unfocus the search field .pointerInput(focusManager) { awaitEachGesture { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Preferences.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Preferences.kt index 7638f6d4d..79bf0a813 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Preferences.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Preferences.kt @@ -3,13 +3,11 @@ package org.polyfrost.oneconfig.internal.ui.screens import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme const val PREFERENCES_ID = "oneconfig.json" @@ -19,11 +17,6 @@ fun Preferences() { val revision = ConfigRegistry.revision val tree = ConfigRegistry.findTree(PREFERENCES_ID) - DisposableEffect(Unit) { - ShellState.title = "Preferences" - onDispose { } - } - if (tree == null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("No preferences available.", color = LocalTheme.current.textColorSecondary) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt index ead9773eb..670c1d041 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -233,11 +232,6 @@ fun Profiles() { } } - DisposableEffect(Unit) { - ShellState.title = "Profiles" - onDispose { } - } - Box(modifier = Modifier.fillMaxWidth().padding(horizontal = PageGutter)) { Column( verticalArrangement = Arrangement.spacedBy(19.dp) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt index 0b142942e..8c3d23815 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.ui.Alignment @@ -29,7 +28,6 @@ import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.shell.PageGutter -import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.themes.ThemeRegistry @@ -41,10 +39,6 @@ const val THEMES_ID = "themes.json" fun Themes() { val revision = ConfigRegistry.revision val tree = ConfigRegistry.findTree(THEMES_ID) - DisposableEffect(Unit) { - ShellState.title = "Themes" - onDispose { } - } Column( verticalArrangement = Arrangement.spacedBy(20.dp) ) { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt index 6e95fb9fe..448d30ce3 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt @@ -18,6 +18,7 @@ object HudEditorRoute enum class SearchFocus { SelectAll, + /** Focuses the search field and leaves its text and cursor alone */ Focus, Unfocus, @@ -26,18 +27,12 @@ enum class SearchFocus { object Lifecycle : LifecycleOwner { override val lifecycle = LifecycleRegistry(this) } -object OCViewModelStoreOwner: ViewModelStoreOwner { + +object OCViewModelStoreOwner : ViewModelStoreOwner { override val viewModelStore: ViewModelStore = ViewModelStore() } -object ShellState { - var title by mutableStateOf(null) - var titleInfoForTitle by mutableStateOf(null) - var titleAuthors by mutableStateOf(null) - var titleCredits by mutableStateOf(null) - var titleVersion by mutableStateOf(null) - - var openOriginalScreen by mutableStateOf(null) +object ShellState { var playerName by mutableStateOf("Player") /** PNG bytes for the local player's head avatar or null until loaded */ @@ -55,6 +50,12 @@ object ShellState { var hudDragging by mutableStateOf(false) + /** + * True while a mod card is being dragged (or settling back) so the content pane can drop its + * clip and let the overlay card draw outside the grid + */ + var modDragging by mutableStateOf(false) + var shellBounds by mutableStateOf(null) var flipTopOptionOrder by mutableStateOf(false) @@ -97,7 +98,15 @@ object ShellState { var awaitingInitialRoute by mutableStateOf(false) var openingTransitionTarget: String? = null + + /** + * True while the most recent navigation was arrow-back so the shared enter/exit transition + * can mirror the old pop direction. Plain field on purpose: it is only read by NavHost + * transition lambdas at navigation time, and must not itself trigger recomposition. + */ + var lastNavWasBack: Boolean = false } + object LocalNavController { private var _current: NavHostController? = null @@ -135,6 +144,21 @@ object LocalNavController { private val backStack = ArrayDeque() private val forwardStack = ArrayDeque() + // Bumped on every history mutation so composition can observe the (non-snapshot) deques + // below; the deques stay ArrayDeque and remain the single source of truth. + private var historyRevisionState by mutableStateOf(0) + + /** Read during composition to subscribe to history changes, then query [canGoBack]/[canGoForward] */ + val historyRevision: Int get() = historyRevisionState + + private fun updateArrows() { + historyRevisionState++ + } + + fun canGoBack(): Boolean = backStack.isNotEmpty() + + fun canGoForward(): Boolean = forwardStack.isNotEmpty() + private var currentEntry = Entry(ModsGraph) val currentRoute: Any get() = currentEntry.route @@ -143,6 +167,7 @@ object LocalNavController { backStack.clear() forwardStack.clear() currentEntry = Entry(ModsGraph) + updateArrows() } private fun host(): NavHostController? = if (isReady) current else null @@ -156,7 +181,9 @@ object LocalNavController { currentEntry = Entry(route) if (clearSearch) ShellState.searchQuery = "" ShellState.lastRoute = route + ShellState.lastNavWasBack = false seedRouteCategory(route) + updateArrows() host.navigate(route) } @@ -172,6 +199,7 @@ object LocalNavController { backStack.addLast(currentEntry.copy(categoryKey = pageKey, category = previous)) currentEntry = currentEntry.copy(categoryKey = pageKey, category = category) ShellState.selectedCategories[pageKey] = category + updateArrows() } fun back() { @@ -182,16 +210,29 @@ object LocalNavController { forwardStack.addLast(currentEntry.withCurrentSearch()) currentEntry = previous applyCategory(previous) + updateArrows() return } val host = host() ?: return - if (host.popBackStack()) { - forwardStack.addLast(currentEntry.withCurrentSearch()) - currentEntry = backStack.removeLast() - applyCategory(currentEntry) - ShellState.searchQuery = currentEntry.searchQuery - ShellState.lastRoute = currentEntry.route + // Route back through navigate() instead of popBackStack(): this NavHost version never + // plays its pop transitions from a resting state, while the forward enter/exit + // transition reliably plays (matching the existing forward() path below). The shared + // transition in Shell mirrors the old pop direction while lastNavWasBack is set. + // This leaves the host's own back stack growing by an entry per back press. Don't trim it with + // popUpTo here: popping the outgoing entry drops its content mid-transition, so the page snaps + // instead of animating. The host is rebuilt every time the menu opens, which bounds the growth + // to one session; this wrapper's stacks are the real history. + ShellState.lastNavWasBack = true + runCatching { host.navigate(previous.route) }.onFailure { + ShellState.lastNavWasBack = false + return } + forwardStack.addLast(currentEntry.withCurrentSearch()) + currentEntry = backStack.removeLast() + applyCategory(currentEntry) + ShellState.searchQuery = currentEntry.searchQuery + ShellState.lastRoute = currentEntry.route + updateArrows() } fun forward() { @@ -203,9 +244,14 @@ object LocalNavController { currentEntry = next if (host != null) seedRouteCategory(next.route) applyCategory(next) - if (host == null) return + if (host == null) { + updateArrows() + return + } ShellState.searchQuery = next.searchQuery ShellState.lastRoute = next.route + ShellState.lastNavWasBack = false + updateArrows() host.navigate(next.route) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Shell.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Shell.kt index 4e40c2e6d..8f95e6e2e 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Shell.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Shell.kt @@ -201,7 +201,10 @@ fun Shell( ) { Header(Modifier.padding(horizontal = PageGutter)) Box( - modifier = Modifier.weight(1f).fillMaxSize().clipToBounds() + modifier = Modifier.weight(1f).fillMaxSize() + // a dragged mod card is drawn in an overlay that must be able to leave the page + // area; keep the clip otherwise so page content cannot overflow the pane + .then(if (ShellState.modDragging) Modifier else Modifier.clipToBounds()) .alpha(if (ShellState.awaitingInitialRoute) 0f else 1f) ) { if (isSearching) { @@ -226,16 +229,24 @@ fun Shell( fun pageMs() = (OneConfigConfig.pageAnimationDuration * 1000f).toInt().coerceAtLeast(1) + // Arrow-back routes through navigate() on purpose (this NavHost version never + // plays its pop transitions from a resting state), so the shared enter/exit + // transition mirrors the old pop direction while lastNavWasBack is set. + fun enterSlide() = + if (ShellState.lastNavWasBack) slideInHorizontally(tween(pageMs())) { -it / 5 } + else slideInHorizontally(tween(pageMs())) { it / 5 } + + fun exitSlide() = + if (ShellState.lastNavWasBack) slideOutHorizontally(tween(pageMs())) { it / 5 } + else slideOutHorizontally(tween(pageMs())) { -it / 5 } + NavHost( modifier = Modifier.fillMaxSize(), navController = LocalNavController.current, startDestination = ModsGraph, - enterTransition = { if (resolvePageAnimate(targetState.id)) slideInHorizontally(tween(pageMs())) { it / 5 } + fadeIn(tween(pageMs())) else EnterTransition.None }, - exitTransition = { if (resolvePageAnimate(targetState.id)) slideOutHorizontally(tween(pageMs())) { -it / 5 } + fadeOut(tween(pageMs())) else ExitTransition.None }, - - popEnterTransition = { if (OneConfigConfig.showPageAnimations) slideInHorizontally(tween(pageMs())) { -it / 5 } + fadeIn(tween(pageMs())) else EnterTransition.None }, - popExitTransition = { if (OneConfigConfig.showPageAnimations) slideOutHorizontally(tween(pageMs())) { it / 5 } + fadeOut(tween(pageMs())) else ExitTransition.None } + enterTransition = { if (resolvePageAnimate(targetState.id)) enterSlide() + fadeIn(tween(pageMs())) else EnterTransition.None }, + exitTransition = { if (resolvePageAnimate(targetState.id)) exitSlide() + fadeOut(tween(pageMs())) else ExitTransition.None }, ) { navigation() } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Impls.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Impls.kt index 93e6c94cc..23f5b275d 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Impls.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Impls.kt @@ -29,6 +29,7 @@ private val MinecraftIconOverrides = mapOf( "dots-grid" to "/assets/oneconfig/ico/minecraft/dots-grid.svg", "dots-vertical" to "/assets/oneconfig/ico/minecraft/dots-vertical.svg", "down" to "/assets/oneconfig/ico/minecraft/down.svg", + "external-link" to "/assets/oneconfig/ico/minecraft/external-link.svg", "eye" to "/assets/oneconfig/ico/minecraft/eye.svg", "eye-off" to "/assets/oneconfig/ico/minecraft/eye-off.svg", "hud" to "/assets/oneconfig/ico/minecraft/hud.svg", diff --git a/modules/ui/src/main/resources/assets/oneconfig/ico/external-link.svg b/modules/ui/src/main/resources/assets/oneconfig/ico/external-link.svg new file mode 100644 index 000000000..58bacb6ec --- /dev/null +++ b/modules/ui/src/main/resources/assets/oneconfig/ico/external-link.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/external-link.svg b/modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/external-link.svg new file mode 100644 index 000000000..6d3592c14 --- /dev/null +++ b/modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/external-link.svg @@ -0,0 +1,3 @@ + + +