From 9cebf8a13f3f331bdad353c567dc8a331a49f2bd Mon Sep 17 00:00:00 2001 From: Tektonikal Date: Tue, 6 Oct 2026 16:41:09 -0400 Subject: [PATCH 1/3] refactor(ui): rework header titles and navigation controls --- .../internal/ui/components/Button.kt | 17 +- .../oneconfig/internal/ui/components/Chips.kt | 4 +- .../internal/ui/components/Header.kt | 159 +++++++++++++++--- .../internal/ui/components/Sidebar.kt | 3 +- .../internal/ui/screens/Changelog.kt | 11 -- .../oneconfig/internal/ui/screens/Credits.kt | 7 - .../oneconfig/internal/ui/screens/Keybinds.kt | 6 - .../internal/ui/screens/ModConfig.kt | 20 --- .../oneconfig/internal/ui/screens/Mods.kt | 16 +- .../internal/ui/screens/Preferences.kt | 7 - .../oneconfig/internal/ui/screens/Profiles.kt | 6 - .../oneconfig/internal/ui/screens/Themes.kt | 6 - .../oneconfig/internal/ui/shell/Data.kt | 74 ++++++-- .../oneconfig/internal/ui/shell/Shell.kt | 20 ++- 14 files changed, 237 insertions(+), 119 deletions(-) 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..5acb1bc82 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,13 @@ 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.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 +21,7 @@ fun IconButton( modifier: Modifier = Modifier, foreground: Color = LocalTheme.current.textColor.copy(0.7f), hoveredForeground: Color = LocalTheme.current.textColor, + enabled: Boolean = true, onClick: () -> Unit, ) { val interactionSource = rememberInteractionSource() @@ -26,12 +29,22 @@ 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 (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) + .border(1.dp, borderColor, LocalTheme.current.buttonShape) ) } 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..7cd3b89b3 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 @@ -26,8 +27,9 @@ fun Chip( onClick: () -> Unit, ) { val interactionSource = rememberInteractionSource() + val isHovered by interactionSource.collectIsHoveredAsState() val backgroundColor by animateColorAsState( - if (selected) Accent else LocalTheme.current.chipBackground + if (selected) Accent else if (isHovered) Accent.copy(alpha = 0.5f) else LocalTheme.current.chipBackground ) val textColor by animateColorAsState( if (selected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor 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 d79763c20..95f6b50b5 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() { 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,63 @@ fun Header() { ) { 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(24.dp), enabled = canGoBack) { controller.back() } + IconButton("right-arrow", Modifier.size(24.dp), enabled = canGoForward) { 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) + } + } + } + // 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 } - ) { 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) + 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 + Row(verticalAlignment = Alignment.CenterVertically) { + lastAction?.let { + Spacer(Modifier.size(8.dp)) + OpenOriginalScreenButton(it) + } } } } @@ -105,13 +163,61 @@ fun Header() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(26.dp) ) { - ShellState.openOriginalScreen?.let { OpenOriginalScreenButton(it) } GlobalSearchBar() - IconButton("close") { closeRequest() } + IconButton("close", Modifier.size(24.dp)) { 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,6 +241,7 @@ 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, ) { @@ -144,9 +251,8 @@ private fun OpenOriginalScreenButton(runnable: Runnable) { } @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 +274,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 8fbe706b0..eae16ebf1 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 @@ -23,7 +23,6 @@ import androidx.compose.foundation.rememberScrollbarAdapter 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 @@ -230,11 +229,6 @@ 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() val visibleSections = remember(localSearchQuery) { @@ -275,11 +269,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 36ac6c643..634d50c80 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 @@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter 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 @@ -24,7 +23,6 @@ import org.polyfrost.oneconfig.api.platform.v1.ModInfo 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.ShellState import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -75,11 +73,6 @@ fun Credits() { val theme = LocalTheme.current val scrollState = rememberScrollState() - DisposableEffect(Unit) { - ShellState.title = "Credits" - onDispose { } - } - Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(end = 16.dp) 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 0497bc51b..4941148fb 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 @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollbarAdapter 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 d61ad8963..03b107636 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 @@ -3,14 +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.components.asRenderText -import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @Composable @@ -19,23 +16,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 e021a46e5..804db3974 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 @@ -110,11 +110,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(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ModCategory.entries.forEach { @@ -186,13 +181,22 @@ fun ColumnScope.ModsGrid(category: ModCategory) { canSwap = { from, to -> sameModGroup(entries, from, to) }, ) + // 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 } + } + Box(modifier = Modifier.weight(1f)) { LazyVerticalGrid( state = gridState, columns = GridCells.Fixed(4), verticalArrangement = Arrangement.spacedBy(19.dp), horizontalArrangement = Arrangement.spacedBy(19.dp), - modifier = Modifier.fillMaxSize().clipToBounds().onGloballyPositioned { + modifier = Modifier.fillMaxSize().onGloballyPositioned { animateItems = true }, ) { 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 78d7eb3f1..e9c97bd57 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()) { 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 f9ca512ab..13f220301 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 @@ -15,7 +15,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 @@ -27,7 +26,6 @@ import androidx.compose.ui.unit.dp 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.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 @@ -39,10 +37,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..8e4528376 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,25 @@ 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. + 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 +240,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 d52d8325e..0a5ec596c 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 @@ -199,7 +199,10 @@ fun Shell( ) { Header() 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) { @@ -224,13 +227,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 }, + enterTransition = { if (resolvePageAnimate(targetState.id)) enterSlide() + fadeIn(tween(pageMs())) else EnterTransition.None }, + exitTransition = { if (resolvePageAnimate(targetState.id)) exitSlide() + 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 } From ab0cf02280fc2bd59499bfdce22773f6029d63d7 Mon Sep 17 00:00:00 2001 From: Tektonikal Date: Sun, 18 Oct 2026 13:39:03 -0400 Subject: [PATCH 2/3] fix(ui): address header-rework review findings - opt header icon buttons into the hover border and pad it off the glyph - show the live open-original action instead of a frame-stale one - keep the changelog scroll position across back/forward - drop unreachable pop transitions now that back re-navigates - pick a readable text color for hovered chips - document why back() doesn't trim the host back stack --- .../internal/ui/components/Button.kt | 11 ++++++++-- .../oneconfig/internal/ui/components/Chips.kt | 21 +++++++++++++++++-- .../internal/ui/components/Header.kt | 10 +++++---- .../internal/ui/screens/Changelog.kt | 6 ++++-- .../oneconfig/internal/ui/shell/Data.kt | 4 ++++ .../oneconfig/internal/ui/shell/Shell.kt | 3 --- 6 files changed, 42 insertions(+), 13 deletions(-) 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 5acb1bc82..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 @@ -3,6 +3,7 @@ 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 @@ -22,6 +23,8 @@ fun IconButton( 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() @@ -36,7 +39,7 @@ fun IconButton( } ) val borderColor by animateColorAsState( - if (enabled && isHovered) LocalTheme.current.borderColor + if (hoverBorder && enabled && isHovered) LocalTheme.current.borderColor else LocalTheme.current.borderColor.copy(0f) ) @@ -44,7 +47,11 @@ fun IconButton( iconName, color = iconColor, modifier = Modifier.onClick(interactionSource, enabled, onClick).then(modifier) - .border(1.dp, borderColor, LocalTheme.current.buttonShape) + // 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 7cd3b89b3..646b9fc87 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 @@ -12,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 @@ -28,11 +31,18 @@ fun Chip( ) { 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 if (isHovered) Accent.copy(alpha = 0.5f) 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 + when { + selected -> theme.accentTextColor + // the half-accent tint can sit closer to either text color depending on theme and accent + isHovered -> readableOn(hoverBackground, theme.accentTextColor, theme.textColor) + else -> theme.textColor + } ) Row( @@ -55,3 +65,10 @@ fun Chip( } } } + +private fun readableOn(background: Color, a: Color, b: Color): Color { + val bg = background.luminance() + return if (contrast(a.luminance(), bg) >= contrast(b.luminance(), bg)) a else b +} + +private fun contrast(l1: Float, l2: Float) = (maxOf(l1, l2) + 0.05f) / (minOf(l1, l2) + 0.05f) 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 95f6b50b5..c66170b95 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 @@ -102,8 +102,8 @@ fun Header() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - IconButton("left-arrow", Modifier.size(24.dp), enabled = canGoBack) { controller.back() } - IconButton("right-arrow", Modifier.size(24.dp), enabled = canGoForward) { 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() } } Spacer(Modifier.size(24.dp)) Row(verticalAlignment = Alignment.CenterVertically) { @@ -149,8 +149,10 @@ fun Header() { ) { // 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) { - lastAction?.let { + (openOriginalScreen ?: lastAction)?.let { Spacer(Modifier.size(8.dp)) OpenOriginalScreenButton(it) } @@ -164,7 +166,7 @@ fun Header() { horizontalArrangement = Arrangement.spacedBy(26.dp) ) { GlobalSearchBar() - IconButton("close", Modifier.size(24.dp)) { closeRequest() } + IconButton("close", Modifier.size(32.dp), hoverBorder = true) { closeRequest() } } } } 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 eae16ebf1..beca06c16 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 @@ -17,7 +17,6 @@ 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.rememberScrollbarAdapter import androidx.compose.foundation.text.BasicText @@ -70,6 +69,7 @@ import org.polyfrost.oneconfig.internal.ui.navigation.graph.ChangeLogEntryRoute import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController 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 @@ -229,8 +229,10 @@ private fun MdBlocks(node: Node, styles: MdStyles, modifier: Modifier = Modifier @Composable fun Changelog() { - 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) -> 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 8e4528376..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 @@ -218,6 +218,10 @@ object LocalNavController { // 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 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 0a5ec596c..b20708fc4 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 @@ -245,9 +245,6 @@ fun Shell( enterTransition = { if (resolvePageAnimate(targetState.id)) enterSlide() + fadeIn(tween(pageMs())) else EnterTransition.None }, exitTransition = { if (resolvePageAnimate(targetState.id)) exitSlide() + 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 } ) { navigation() } From 77120863023c94fc8ae7cc66e1f089f1003aeafc Mon Sep 17 00:00:00 2001 From: Tektonikal Date: Sun, 18 Oct 2026 14:20:55 -0400 Subject: [PATCH 3/3] improve original mod screen button, clean up --- .../oneconfig/internal/ui/components/Chips.kt | 16 ++-------------- .../oneconfig/internal/ui/components/Header.kt | 8 +++++++- .../oneconfig/internal/ui/themes/Impls.kt | 1 + .../assets/oneconfig/ico/external-link.svg | 5 +++++ .../oneconfig/ico/minecraft/external-link.svg | 3 +++ 5 files changed, 18 insertions(+), 15 deletions(-) create mode 100644 modules/ui/src/main/resources/assets/oneconfig/ico/external-link.svg create mode 100644 modules/ui/src/main/resources/assets/oneconfig/ico/minecraft/external-link.svg 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 646b9fc87..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 @@ -37,12 +37,7 @@ fun Chip( if (selected) Accent else if (isHovered) hoverBackground else theme.chipBackground ) val textColor by animateColorAsState( - when { - selected -> theme.accentTextColor - // the half-accent tint can sit closer to either text color depending on theme and accent - isHovered -> readableOn(hoverBackground, theme.accentTextColor, theme.textColor) - else -> theme.textColor - } + if (selected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor ) Row( @@ -64,11 +59,4 @@ fun Chip( Text(label, fontSize = 14.sp, color = textColor) } } -} - -private fun readableOn(background: Color, a: Color, b: Color): Color { - val bg = background.luminance() - return if (contrast(a.luminance(), bg) >= contrast(b.luminance(), bg)) a else b -} - -private fun contrast(l1: Float, l2: Float) = (maxOf(l1, l2) + 0.05f) / (minOf(l1, l2) + 0.05f) +} \ 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 3c14c1505..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 @@ -247,7 +247,13 @@ private fun OpenOriginalScreenButton(runnable: Runnable) { .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) + } } } } 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 @@ + + +