From c4afae86a1a30219730b71df4946d60f59d2c1e4 Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 16 Sep 2026 02:48:13 +0000 Subject: [PATCH 01/19] build: replace purged advanture-platform snapshot --- buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts b/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts index d7d4c7982..98a0a3195 100644 --- a/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts @@ -180,7 +180,7 @@ val firmamentRelocatedConfiguration: Configuration by configurations.creating { val adventurePlatform = when { loader != "fabric" -> null - stonecutter.eval(stonecutter.current.version, ">= 26.2") -> "7.0.0-SNAPSHOT" + stonecutter.eval(stonecutter.current.version, ">= 26.2") -> "7.0.0" stonecutter.eval(stonecutter.current.version, ">= 26.1") -> "6.9.0" stonecutter.eval(stonecutter.current.version, ">= 1.21.11") -> "6.8.0" stonecutter.eval(stonecutter.current.version, ">= 1.21.10") -> "6.7.0" From 9ad85c34b352da2760db4f2aa9a7a0c6f7e53826 Mon Sep 17 00:00:00 2001 From: Saad Nadeem <88615188+saadndm@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:35:58 -0400 Subject: [PATCH 02/19] fix(ui): remove UI scaling config (#1084) --- .../assets/oneconfig/lang/en_us.json | 6 ----- .../oneconfig/internal/OneConfigConfig.java | 24 ------------------- .../oneconfig/internal/ui/themes/Provider.kt | 16 ++++--------- .../assets/oneconfig/en_default.lang | 4 ---- .../ui/themes/PixelGridScaleTest.java | 15 ------------ 5 files changed, 4 insertions(+), 61 deletions(-) diff --git a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index 7ac3e849f..417e3d7e4 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -52,12 +52,6 @@ "oneconfig.preferences.pause_game.title": "Pause Game", "oneconfig.preferences.pause_game.description": "Pause singleplayer worlds while the OneConfig menu is open.", - "oneconfig.preferences.use_custom_ui_size.title": "Set UI size manually", - "oneconfig.preferences.use_custom_ui_size.description": "Size the OneConfig menu yourself instead of fitting it to the window.", - - "oneconfig.preferences.ui_pixel_size.title": "UI size", - "oneconfig.preferences.ui_pixel_size.description": "How many screen pixels wide one pixel of the menu is drawn. Whole sizes are the sharpest.", - "oneconfig.preferences.reduced_res_filter.title": "Reduced-resolution filter", "oneconfig.preferences.reduced_res_filter.description": "Counteracts blur when the game renders at a reduced resolution (e.g. Sodium Extra's 'Reduce Resolution on macOS'). Only takes effect when the display is upscaling a reduced framebuffer. 'Sharpen' softly crisps the whole menu; 'Harden edges' aliases text/edges so they survive the upscale like Minecraft's font (crisper text, but blockier corners).", "oneconfig.preferences.reduced_res_filter.off": "Off", diff --git a/modules/internal/src/main/java/org/polyfrost/oneconfig/internal/OneConfigConfig.java b/modules/internal/src/main/java/org/polyfrost/oneconfig/internal/OneConfigConfig.java index 87b9c46a0..0d88bc69c 100644 --- a/modules/internal/src/main/java/org/polyfrost/oneconfig/internal/OneConfigConfig.java +++ b/modules/internal/src/main/java/org/polyfrost/oneconfig/internal/OneConfigConfig.java @@ -258,29 +258,6 @@ public class OneConfigConfig extends Config { ) public static boolean pauseGame = false; - @Switch( - title = "oneconfig.preferences.use_custom_ui_size.title", - titleTranslation = true, - subcategory = "oneconfig.preferences.category.gui", - subcategoryTranslation = true, - description = "oneconfig.preferences.use_custom_ui_size.description", - descriptionTranslation = true - ) - public static boolean useCustomUiSize = false; - - @Slider( - title = "oneconfig.preferences.ui_pixel_size.title", - titleTranslation = true, - subcategory = "oneconfig.preferences.category.gui", - subcategoryTranslation = true, - min = 1f, - max = 4f, - step = 0.5f, - description = "oneconfig.preferences.ui_pixel_size.description", - descriptionTranslation = true - ) - public static float uiPixelSize = 2f; - @Dropdown( title = "oneconfig.preferences.reduced_res_filter.title", titleTranslation = true, @@ -618,7 +595,6 @@ protected void initialize(boolean byConfigManager) { if (tree == null) { return; } - addDependency("uiPixelSize", "useCustomUiSize"); addDependency( "uiSharpening", "Reduced-resolution filter", diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Provider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Provider.kt index 58f697eb0..aef2e01a6 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Provider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Provider.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.graphics.toArgb import org.polyfrost.compose.render.PolyColor import org.polyfrost.oneconfig.api.notifications.v1.NotificationTheme import org.polyfrost.oneconfig.api.platform.v1.Platform -import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ThemeConfig import org.polyfrost.oneconfig.internal.ui.DESIGN_HEIGHT_DP import org.polyfrost.oneconfig.internal.ui.DESIGN_WIDTH_DP @@ -50,8 +49,6 @@ private const val EM_STEP_PX = 5f private const val MIN_EM_PX = 10f -private const val GLYPH_PIXELS_PER_EM = 10f - private fun scrollbarStyle(theme: UITheme) = ScrollbarStyle( minimalHeight = 24.dp, thickness = 8.dp, @@ -70,13 +67,9 @@ fun pixelGridScale(scale: Float, max: Float, anchorSp: Float = GRID_ANCHOR_SP): if (scale <= 0f) return scale val density = LocalDensity.current val anchorPx = anchorSp * density.fontScale * density.density * scale * surfaceRatio() - return scale * snapScaleToPixelGrid(anchorPx, max / scale, chosenEmPx()) + return scale * snapScaleToPixelGrid(anchorPx, max / scale) } -private fun chosenEmPx(): Float? = - if (OneConfigConfig.useCustomUiSize) OneConfigConfig.uiPixelSize.coerceIn(1f, 4f) * GLYPH_PIXELS_PER_EM - else null - @Composable private fun pixelGridDensity(designWidth: Dp, designHeight: Dp): Density { val density = LocalDensity.current @@ -93,14 +86,13 @@ private fun pixelGridDensity(designWidth: Dp, designHeight: Dp): Density { } } -@JvmOverloads -internal fun snapScaleToPixelGrid(anchorPx: Float, max: Float, chosenEm: Float? = null): Float { +internal fun snapScaleToPixelGrid(anchorPx: Float, max: Float): Float { if (anchorPx <= 0f) return 1f val fits = floor(anchorPx * max / EM_STEP_PX) * EM_STEP_PX val nearest = round(anchorPx / EM_STEP_PX) * EM_STEP_PX - val em = chosenEm ?: maxOf(nearest, fits) + val em = maxOf(nearest, fits) val scale = em.coerceAtLeast(MIN_EM_PX) / anchorPx - return if (chosenEm != null || scale <= max) scale else max + return if (scale <= max) scale else max } @Composable diff --git a/modules/internal/src/main/resources/assets/oneconfig/en_default.lang b/modules/internal/src/main/resources/assets/oneconfig/en_default.lang index d31a46b15..78ca2235f 100644 --- a/modules/internal/src/main/resources/assets/oneconfig/en_default.lang +++ b/modules/internal/src/main/resources/assets/oneconfig/en_default.lang @@ -51,10 +51,6 @@ oneconfig.preferences.sidebar_opacity.title=Sidebar Opacity oneconfig.preferences.sidebar_opacity.description=How opaque the OneConfig sidebar background is. 0 = fully transparent, 100 = fully opaque. oneconfig.preferences.pause_game.title=Pause Game oneconfig.preferences.pause_game.description=Pause singleplayer worlds while the OneConfig menu is open. -oneconfig.preferences.use_custom_ui_size.title=Set UI size manually -oneconfig.preferences.use_custom_ui_size.description=Size the OneConfig menu yourself instead of fitting it to the window. -oneconfig.preferences.ui_pixel_size.title=UI size -oneconfig.preferences.ui_pixel_size.description=How many screen pixels wide one pixel of the menu is drawn. Whole sizes are the sharpest. oneconfig.preferences.opening_behavior.title=Opening Behavior oneconfig.preferences.opening_behavior.description=Which page to open when the OneConfig menu is launched. oneconfig.preferences.opening_behavior.previous_page=Previous page diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/themes/PixelGridScaleTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/themes/PixelGridScaleTest.java index 04ffebc55..cc2bfbf42 100644 --- a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/themes/PixelGridScaleTest.java +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/themes/PixelGridScaleTest.java @@ -93,21 +93,6 @@ void minecraftsSmallestWindowStillLeavesItsMargin() { assertEquals(1f, ProviderKt.snapScaleToPixelGrid(7.7355f, 1f), 1e-4f); } - @Test - void aChosenSizeReplacesWhateverTheWindowWouldHavePicked() { - assertEquals(20f / ANCHOR_PX, ProviderKt.snapScaleToPixelGrid(ANCHOR_PX, FIT_1080P, 20f), 1e-4f); - } - - @Test - void aChosenSizeIsHonouredEvenWhenItOverflowsTheWindow() { - assertEquals(40f / ANCHOR_PX, ProviderKt.snapScaleToPixelGrid(ANCHOR_PX, FIT_1080P, 40f), 1e-4f); - } - - @Test - void aChosenSizeStillCannotGoBelowOnePixelPerGlyphPixel() { - assertEquals(10f / ANCHOR_PX, ProviderKt.snapScaleToPixelGrid(ANCHOR_PX, FIT_1080P, 5f), 1e-4f); - } - @Test void aDegenerateAnchorIsLeftAlone() { assertEquals(1f, ProviderKt.snapScaleToPixelGrid(0f, 2f), 1e-4f); From 47f34db15b437d9443026e497eb5e0655bba6d11 Mon Sep 17 00:00:00 2001 From: awruff Date: Wed, 16 Sep 2026 09:47:53 -0400 Subject: [PATCH 03/19] chore: bump to 1.2.1 --- CHANGELOG.md | 6 ++---- gradle.properties | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d94bfbfc5..da4e6e026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,2 @@ -1.2.0 changelogs: -- Fixed conflicting keybind handling always using Right Shift instead of the actual OneConfig open keybind -- Fixed notifications not being clickable on macOS Retina and possibly other HiDPI setups -- Added support for Minecraft 26.3 \ No newline at end of file +1.2.1 changelogs: +- remove ui scaling config \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index e416fbd4c..34fee8f89 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx4096m group=org.polyfrost.oneconfig -version=1.2.0 +version=1.2.1 ksp.incremental=false \ No newline at end of file From 58f24b0e91dddf488b0640711ba2633361af5b57 Mon Sep 17 00:00:00 2001 From: awruff Date: Thu, 17 Sep 2026 22:09:54 -0400 Subject: [PATCH 04/19] build: compile modmenu shim with project toolchain --- buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts b/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts index 98a0a3195..aeea686e4 100644 --- a/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts @@ -87,6 +87,7 @@ if (loader == "fabric") { val modMenuShimClasses = layout.buildDirectory.dir("classes/modMenuShim") val compileModMenuApiShimJava = tasks.register("compileModMenuApiShimJava") { val mainSourceSet = sourceSets.named("main").get() + javaCompiler.set(tasks.named("compileJava").flatMap { it.javaCompiler }) source(rootProject.projectDir.resolve("minecraft/src/modMenuShim/java")) classpath = files(mainSourceSet.output.classesDirs, mainSourceSet.compileClasspath) destinationDirectory.set(modMenuShimClasses) From 092d202f1306201f299727bad04d328f06a4b325 Mon Sep 17 00:00:00 2001 From: Saad Nadeem <88615188+saadndm@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:45:56 -0400 Subject: [PATCH 05/19] fix(hud): pause game while HUD editor is open (#1088) --- .../oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt index a682c2f49..f8a66a326 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt @@ -115,6 +115,8 @@ class HudEditorUIScreen private constructor() : ComposeScreen() { super.removed() } + override fun isPauseScreen(): Boolean = OneConfigConfig.pauseGame + private fun handleOneConfigKeybind(): Boolean { if (closeRequested) return cancelClose() if (OneConfigConfig.keybindClosesGui) { From 158990aa55eef96dfb9f20941b2c6b50a025e501 Mon Sep 17 00:00:00 2001 From: Saad Nadeem <88615188+saadndm@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:47:51 -0400 Subject: [PATCH 06/19] fix(ui): remove duplicate accordion option padding (#1089) Co-authored-by: Julian Chang --- .../oneconfig/internal/ui/screens/ConfigScreen.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt index e24715030..e862c2e2e 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt @@ -434,7 +434,7 @@ private fun AccordionRow(node: SettingNode.Accordion, compact: Boolean = false) }, shape = shape ) - .padding(vertical = 12.dp) + .padding(bottom = 12.dp) ) { AccordionOptionsGrid(node.body, compact = compact) } @@ -612,9 +612,10 @@ private fun displayAlpha(display: Property.Display): Float { @Composable private fun SettingContent(prop: Property<*>, nested: Boolean = false, compact: Boolean = false, enabled: Boolean = true) { val theme = LocalTheme.current + val verticalPadding = if (nested) 0.dp else 12.dp if (prop.getMetadata("visualizer") == Visualizer.InfoVisualizer::class.java) { - Row(modifier = Modifier.fillMaxWidth().blockInteraction(!enabled).padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(modifier = Modifier.fillMaxWidth().blockInteraction(!enabled).padding(horizontal = 16.dp, vertical = verticalPadding)) { Option(prop) } return @@ -677,7 +678,7 @@ private fun SettingContent(prop: Property<*>, nested: Boolean = false, compact: modifier = Modifier .fillMaxWidth() .blockInteraction(!enabled) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(start = 16.dp, end = 16.dp, top = verticalPadding, bottom = if (nested) 8.dp else verticalPadding), verticalArrangement = Arrangement.spacedBy(10.dp), ) { SettingLabel(prop, nested = nested) @@ -704,7 +705,7 @@ private fun SettingContent(prop: Property<*>, nested: Boolean = false, compact: modifier = Modifier .fillMaxWidth() .blockInteraction(!enabled) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = verticalPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { From 50cd4a40acb38bcba87532f1c00b06b749b70fee Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 18 Sep 2026 22:51:16 +0000 Subject: [PATCH 07/19] chore: remove unused kotlinx-atomicfu dependency --- buildSrc/src/main/kotlin/oneconfig-bootstrap.gradle.kts | 1 - gradle/libs.versions.toml | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/buildSrc/src/main/kotlin/oneconfig-bootstrap.gradle.kts b/buildSrc/src/main/kotlin/oneconfig-bootstrap.gradle.kts index 7d30dc493..3fa16cd8c 100644 --- a/buildSrc/src/main/kotlin/oneconfig-bootstrap.gradle.kts +++ b/buildSrc/src/main/kotlin/oneconfig-bootstrap.gradle.kts @@ -74,7 +74,6 @@ afterEvaluate { "org.jetbrains.kotlinx:kotlinx-serialization-core-jvm", "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", "org.jetbrains.kotlinx:kotlinx-serialization-cbor-jvm", - "org.jetbrains.kotlinx:atomicfu-jvm", "org.jetbrains.kotlinx:kotlinx-datetime-jvm", "org.jetbrains.kotlinx:kotlinx-io-core-jvm", "org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b18053511..2107c760c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ # All kotlin = "2.4.10" kotlinx-coroutines = "1.10.2" -kotlinx-atomicfu = "0.29.0" fabric-language-kotlin = "1.13.13+kotlin.2.4.10" google-ksp = "2.3.11" annotations = "24.1.0" @@ -83,7 +82,6 @@ kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", versi kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-jvm = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-jdk8 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8", version.ref = "kotlinx-coroutines" } -kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu-jvm", version.ref = "kotlinx-atomicfu" } fabric-language-kotlin = { module = "net.fabricmc:fabric-language-kotlin", version.ref = "fabric-language-kotlin" } jetbrains-compose-foundation = { module = "org.jetbrains.compose.foundation:foundation-desktop", version.ref = "compose" } @@ -145,7 +143,7 @@ adventure = [ "kyori-option", ] kotlin = ["kotlin-reflect", "kotlin-stdlib", "kotlin-stdlib-jdk7", "kotlin-stdlib-jdk8"] -kotlinx = ["kotlinx-coroutines", "kotlinx-coroutines-jvm", "kotlinx-coroutines-jdk8", "kotlinx-atomicfu"] +kotlinx = ["kotlinx-coroutines", "kotlinx-coroutines-jvm", "kotlinx-coroutines-jdk8"] lwjgl = ["lwjgl", "lwjgl-nvg", "lwjgl-opengl", "lwjgl-stb", "lwjgl-tinyfd"] nightconfig = ["nightconfig", "nightconfig-json", "nightconfig-toml", "nightconfig-yaml"] test-core = ["junit", "logging-impl"] From c6a61309ff4c1fb8b2fefd971c426c1f0c8f0f6d Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 16 Sep 2026 19:06:07 +0000 Subject: [PATCH 08/19] fix(compat): gate SkyCubed HUD placement until overlay bounds are available --- .../org/polyfrost/oneconfig/internal/compat/SkyCubedCompat.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/SkyCubedCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/SkyCubedCompat.kt index 61760bda8..eb6e4ea1c 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/SkyCubedCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/SkyCubedCompat.kt @@ -184,6 +184,10 @@ class SkyCubedHudWrapper(private val overlay: Overlay) : OneConfigHudWrapper { get() = enabledProperty?.get() == false set(value) { enabledProperty?.set(!value) } + // setX/setY clamp against bounds + // some overlays build example ItemStacks for that, which requires bound item components + override val placementReady: Boolean get() = runCatching { overlay.bounds }.isSuccess + override var scaledWidth: Float get() { if (!overlay.enabled) return 0f From 53f14df428fc885bd8b6129f212f48a19c250540 Mon Sep 17 00:00:00 2001 From: Saad Nadeem <88615188+saadndm@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:30:53 -0400 Subject: [PATCH 09/19] HUD preview improvements (#1086) Co-authored-by: Julian Chang --- .../internal/ui/hud/components/HudPreview.kt | 32 +- .../ui/hud/screens/HudDesignStudio.kt | 576 ++++++++++++------ 2 files changed, 410 insertions(+), 198 deletions(-) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/HudPreview.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/HudPreview.kt index 44dbf96b6..e13b4baba 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/HudPreview.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/HudPreview.kt @@ -6,11 +6,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.drawscope.drawIntoCanvas @@ -50,8 +52,14 @@ internal class HudPreviewState(val runtime: PolyComposeRuntime) { internal set var naturalHeight by mutableStateOf(0f) internal set + var measured by mutableStateOf(false) + internal set val ready: Boolean get() = naturalWidth > 0f && naturalHeight > 0f + + fun dispose() { + runtime.dispose() + } } private object HudPreviewCache { @@ -70,7 +78,7 @@ private object HudPreviewCache { if (cache.isEmpty()) return for (state in cache.values) { try { - state.runtime.dispose() + state.dispose() } catch (failure: Throwable) { if (failure.isFatalPreviewFailure()) throw failure LOGGER.warn("Failed to dispose HUD preview", failure) @@ -105,6 +113,8 @@ internal fun rememberHudPreview(hud: Hud): HudPreviewState { state.naturalWidth = state.runtime.root.width state.naturalHeight = state.runtime.root.height } + // Zero-size previews still count as measured so they don't block shortcut navigation + state.measured = true } return state } @@ -115,9 +125,23 @@ internal fun hudPreviewScale(naturalW: Float, naturalH: Float, availableW: Float } @Composable -internal fun HudPreviewCanvas(state: HudPreviewState, scale: Float, modifier: Modifier = Modifier) { +internal fun HudPreviewCanvas( + state: HudPreviewState, + scale: Float, + alwaysRedraw: Boolean, + modifier: Modifier = Modifier +) { + var animationRevision by remember { mutableIntStateOf(0) } + LaunchedEffect(alwaysRedraw) { + if (!alwaysRedraw) return@LaunchedEffect + while (true) { + withFrameNanos { animationRevision++ } + } + } + Canvas(modifier) { HudManager.previewRevision.intValue + if (alwaysRedraw) animationRevision drawIntoCanvas { canvas -> val skia = canvas.skiaCanvas skia.save() @@ -166,7 +190,7 @@ internal fun HudPreview(hud: Hud, modifier: Modifier = Modifier) { val scale = hudPreviewScale(state.naturalWidth, state.naturalHeight, availableW, availableH) val w = (state.naturalWidth * scale / density).dp val h = (state.naturalHeight * scale / density).dp - HudPreviewCanvas(state, scale, Modifier.size(w, h)) + HudPreviewCanvas(state, scale, hud.alwaysRedraw, Modifier.size(w, h)) } }) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt index 35352cabc..14ec73e73 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt @@ -4,12 +4,17 @@ import androidx.compose.animation.* import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.v2.ScrollbarAdapter import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment @@ -32,19 +37,21 @@ import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.apache.logging.log4j.LogManager import org.jetbrains.skia.Paint @@ -63,12 +70,12 @@ import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.components.* -import org.polyfrost.oneconfig.internal.ui.components.layout.FlexibleLayout import org.polyfrost.oneconfig.internal.ui.hud.HudCanvasPasteMenu import org.polyfrost.oneconfig.internal.ui.hud.HudCanvasResetMenu import org.polyfrost.oneconfig.internal.ui.hud.LegacyHudOverlayBridge import org.polyfrost.oneconfig.internal.ui.hud.modNameFor import org.polyfrost.oneconfig.internal.ui.hud.components.HudPreviewCanvas +import org.polyfrost.oneconfig.internal.ui.hud.components.HudPreviewState import org.polyfrost.oneconfig.internal.ui.hud.components.rememberHudPreview import org.polyfrost.oneconfig.internal.ui.hud.repairHudStaticSize import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.Designer @@ -932,10 +939,8 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { // the mod last picked from the icon column kept separate from the scroll-derived section because the // list clamps at its end so the final sections can never become the first visible item var libraryModIntent by remember { mutableStateOf(null) } - val libraryScrollState = rememberScrollState() - // content-relative top of each section reported by the panel as it lays out and used to scroll to a - // mod and to tell which section the user is looking at - val librarySectionOffsets = remember { mutableStateMapOf() } + val libraryScrollState = rememberLazyListState() + var librarySectionIndexes by remember { mutableStateOf>(emptyMap()) } val chromeRects = remember { mutableStateMapOf() } var panelOffset by remember { mutableStateOf(Offset.Zero) } var panelBoxWidth by remember { mutableStateOf(0) } @@ -963,6 +968,12 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { // the active HUD of a selection driving the settings panel resize handles action bar and keybinds fun primaryHud(): Hud? = selectedHuds.lastOrNull() + fun closeLibrary() { + libraryVisible = false + pendingLibraryScroll = null + libraryModIntent = null + } + val panelHud: Hud? = if (panelOpen) primaryHud() else null val deleteHuds: (Collection) -> Unit = { huds -> @@ -993,7 +1004,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { Snapshot.withMutableSnapshot { selectedHuds = setOf(instance) panelOpen = true - libraryVisible = false + closeLibrary() } } } else if (pending != null) { @@ -1119,36 +1130,37 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { val searchHits = rememberHudSearchResults(providers, searchText) val groupedHuds = searchHits?.map { (modId, huds) -> modId to huds.filter { it.addableToLibrary } } ?: addable.groupBy { it.configId }.map { (modId, huds) -> modId to huds } - val librarySections = groupedHuds.mapNotNull { (modId, huds) -> - huds.takeIf { it.isNotEmpty() } - ?.let { HudLibrarySection(modId, modId?.let { id -> modNames[id] } ?: "Other", it) } - } - val librarySectionIds = librarySections.map { it.modId } - val scrolledLibraryMod = librarySections - .lastOrNull { section -> - val top = librarySectionOffsets[librarySectionKey(section.modId)] - top != null && top <= libraryScrollState.value + 1 + val librarySections = remember(groupedHuds, modNames) { + groupedHuds.mapNotNull { (modId, huds) -> + huds.takeIf { it.isNotEmpty() } + ?.let { HudLibrarySection(modId, modId?.let { id -> modNames[id] } ?: "Other", it) } + } + } + val scrolledLibraryMod by remember(librarySections, librarySectionIndexes, libraryScrollState) { + derivedStateOf { + val info = libraryScrollState.layoutInfo + if (!libraryScrollState.canScrollForward && !libraryScrollState.canScrollBackward) { + librarySections.firstOrNull()?.modId + } else { + val activationLine = + info.viewportStartOffset + (info.viewportEndOffset - info.viewportStartOffset) * 0.55f + val activeItem = info.visibleItemsInfo.lastOrNull { it.offset <= activationLine }?.index + ?: libraryScrollState.firstVisibleItemIndex + librarySections.lastOrNull { section -> + val index = librarySectionIndexes[librarySectionKey(section.modId)] + index != null && index <= activeItem + }?.modId ?: librarySections.firstOrNull()?.modId + } } - ?.modId ?: librarySections.firstOrNull()?.modId - val activeLibraryMod = libraryModIntent ?: scrolledLibraryMod + } + val activeLibraryMod = libraryModIntent?.takeIf { intent -> librarySections.any { it.modId == intent } } + ?: scrolledLibraryMod - // a manual scroll takes the highlight back off the clicked icon and hands it to the list LaunchedEffect(libraryScrollState) { snapshotFlow { libraryScrollState.isScrollInProgress } - .collect { scrolling -> if (scrolling && pendingLibraryScroll == null) libraryModIntent = null } - } - - LaunchedEffect(pendingLibraryScroll, librarySectionIds) { - val target = pendingLibraryScroll ?: return@LaunchedEffect - if (target !in librarySectionIds) { - if (searchText.isEmpty()) pendingLibraryScroll = null - return@LaunchedEffect - } - val top = snapshotFlow { librarySectionOffsets[librarySectionKey(target)] } - .filterNotNull() - .first() - libraryScrollState.animateScrollTo(top) - pendingLibraryScroll = null + .collect { scrolling -> + if (scrolling && pendingLibraryScroll == null) libraryModIntent = null + } } val densityObj = LocalDensity.current @@ -1264,7 +1276,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { } hudContextMenuTarget = hit hudContextMenuOffset = IntOffset(pos.x.roundToInt(), pos.y.roundToInt()) - libraryVisible = false + closeLibrary() pasteMenuOffset = null } else { hudContextMenuTarget = null @@ -1293,7 +1305,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { resizeStartStaticW = selected.staticW resizeStartStaticH = selected.staticH hoveredHud = selected - libraryVisible = false + closeLibrary() pasteMenuOffset = null } return@safePointerEvent @@ -1313,7 +1325,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { if (hit !in selectedHuds) UiSounds.play(UiSoundEvent.HUD_SELECT) Snapshot.withMutableSnapshot { selectedHuds = if (actionPressed) selectedHuds + hit else setOf(hit) - libraryVisible = false + closeLibrary() lockedPressHud = hit lockedPressOrigin = pos } @@ -1330,7 +1342,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { else -> if (wasSelected) selectedHuds else setOf(hit) } selectedHuds = newSelection - libraryVisible = false + closeLibrary() if (newSelection.isNotEmpty()) { // grabbing an unselected HUD drags the whole fused shape while grabbing an already // selected one pulls it back out of that shape @@ -1525,7 +1537,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { UiSounds.play(UiSoundEvent.HUD_RESIZE_END) Snapshot.withMutableSnapshot { if (wasResizedHud != null) selectedHuds = setOf(wasResizedHud) - libraryVisible = false + closeLibrary() } return@safePointerEvent } @@ -1557,7 +1569,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { if (hit !in selectedHuds) UiSounds.play(UiSoundEvent.HUD_SELECT) Snapshot.withMutableSnapshot { selectedHuds = setOf(hit) - libraryVisible = false + closeLibrary() } } } else { @@ -1567,7 +1579,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { // pulls that one out instead of moving the shape a second time if (wasCluster) selectedHuds = setOf(wasDraggedHud) else if (wasDraggedGroup.isNotEmpty()) selectedHuds = wasDraggedGroup - libraryVisible = false + closeLibrary() } } } @@ -2075,9 +2087,18 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { modifier = Modifier.chromeBlocker(CHROME_LIBRARY), searchText = searchText, onSearchChange = { searchText = it }, + onClose = { closeLibrary() }, sections = librarySections, + activeModId = activeLibraryMod, scrollState = libraryScrollState, - onSectionMeasured = { key, top -> librarySectionOffsets[key] = top }, + onSectionIndexesChanged = { librarySectionIndexes = it }, + pendingScroll = pendingLibraryScroll, + onScrollComplete = { target, interrupted -> + if (pendingLibraryScroll == target) { + pendingLibraryScroll = null + if (interrupted) libraryModIntent = null + } + }, onDragStart = { hud, sx, sy, hudLocalOffX, hudLocalOffY -> try { // a single-instance provider is its own instance so a real but inactive one @@ -2110,7 +2131,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { dragStarts = mapOf(instance to (initX to initY)) selectedHuds = setOf(instance) hoveredHud = instance - libraryVisible = false + closeLibrary() } } catch (_: Throwable) {} }, @@ -2121,7 +2142,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { Snapshot.withMutableSnapshot { selectedHuds = setOf(instance) panelOpen = true - libraryVisible = false + closeLibrary() } } }, @@ -2139,15 +2160,10 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { libraryVisible = libraryVisible, ) { modId -> Snapshot.withMutableSnapshot { - if (activeLibraryMod == modId && libraryVisible) { - libraryVisible = false - libraryModIntent = null - } else { - if (librarySectionIds.none { it == modId }) searchText = "" - libraryVisible = true - libraryModIntent = modId - pendingLibraryScroll = modId - } + if (librarySections.none { it.modId == modId }) searchText = "" + libraryVisible = true + libraryModIntent = modId + pendingLibraryScroll = modId } } } @@ -2211,7 +2227,7 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { anchorPickSelf = if (contextMenuTargets.size == 1) null else HudAnchor.Auto hoveredAnchor = null hoveredHud = null - libraryVisible = false + closeLibrary() } }, anchorEnabled = contextMenuTargets.isNotEmpty() && @@ -2714,7 +2730,7 @@ private fun rememberHudSearchResults(providers: List, query: String): List< .filter { (_, huds) -> huds.isNotEmpty() } } } - return results + return results.takeUnless { query.isBlank() } } /** One mod's worth of addable HUDs as shown in the continuous library list */ @@ -2729,14 +2745,178 @@ private fun librarySectionKey(modId: String?): String = modId ?: "" private fun libraryIconFor(modId: String?): String = modId?.let { HudManager.iconFor(it) ?: ConfigRegistry.findById(it)?.icon } ?: "qol" +private val LIBRARY_HEADER_HEIGHT = 16.dp + +private data class HudLibraryRowKey(val section: String, val firstHud: Hud) +private data class HudLibraryRow(val huds: List, val height: Int) + +private data class HudLibraryItem( + val section: HudLibrarySection, + val row: HudLibraryRow?, + val height: Int, + val bottomPadding: Dp = 0.dp, +) { + val key: Any = row?.let { HudLibraryRowKey(librarySectionKey(section.modId), it.huds.first()) } + ?: "header:${librarySectionKey(section.modId)}" +} + +private data class HudPreviewGeometry(val size: DpSize, val scale: Float) + +private data class HudLibraryLayout( + val items: List, + val sectionIndexes: Map, + val measured: Boolean, + val previews: Map, + val geometry: Map, +) + +private data class HudLibraryScrollMetrics( + val itemOffsets: List, + val contentHeight: Int, +) + +private fun hudLibraryScrollMetrics( + items: List, + spacing: Int, +): HudLibraryScrollMetrics { + val offsets = ArrayList() + var height = 0 + items.forEach { item -> + offsets.add(height) + height += item.height + spacing + } + if (offsets.isNotEmpty()) height -= spacing + return HudLibraryScrollMetrics(offsets, height) +} + +private class HudLibraryScrollbarAdapter( + private val state: LazyListState, + private val metrics: HudLibraryScrollMetrics, + viewportHeight: Int, +) : ScrollbarAdapter { + override val contentSize = metrics.contentHeight.toDouble() + override val viewportSize = viewportHeight.toDouble() + private val maxOffset get() = (contentSize - viewportSize).coerceAtLeast(0.0) + + override val scrollOffset + get() = ((metrics.itemOffsets.getOrNull(state.firstVisibleItemIndex) ?: 0).toDouble() + + state.firstVisibleItemScrollOffset).coerceIn(0.0, maxOffset) + + override suspend fun scrollTo(scrollOffset: Double) { + if (metrics.itemOffsets.isEmpty()) return + val target = scrollOffset.coerceIn(0.0, maxOffset).roundToInt() + val index = metrics.itemOffsets.binarySearch(target).let { if (it >= 0) it else (-it - 2) } + state.scrollToItem(index, target - metrics.itemOffsets[index]) + } +} + +@Composable +private fun HudLibraryHeader(section: HudLibrarySection, active: Boolean) { + val theme = LocalTheme.current + val textColor by animateColorAsState( + if (active) theme.textColor else theme.textColorSecondary, + animationSpec = tween(150), + ) + Row( + modifier = Modifier.fillMaxWidth().height(LIBRARY_HEADER_HEIGHT), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(libraryIconFor(section.modId), modifier = Modifier.size(16.dp), color = theme.textColorSecondary) + Text( + section.title.uppercase(), + color = textColor, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun packHudLibraryRows(huds: List, sizes: Map, availableWidth: Int, spacing: Int): List { + val rows = ArrayList() + var row = ArrayList() + var rowWidth = 0 + var rowHeight = 0 + for (hud in huds) { + val size = sizes[hud] ?: continue + if (row.isNotEmpty() && rowWidth + spacing + size.width > availableWidth) { + rows.add(HudLibraryRow(row, rowHeight)) + row = ArrayList() + rowWidth = 0 + rowHeight = 0 + } + if (row.isNotEmpty()) rowWidth += spacing + row.add(hud) + rowWidth += size.width + rowHeight = maxOf(rowHeight, size.height) + } + if (row.isNotEmpty()) rows.add(HudLibraryRow(row, rowHeight)) + return rows +} + +@Composable +private fun rememberHudLibraryLayout(sections: List, maxCardWidth: Dp): HudLibraryLayout { + val density = LocalDensity.current + // Create preview state up front for packing, only visible cards are composed + val previews = HashMap() + for (section in sections) { + for (hud in section.huds) { + if (hud !is LegacyHud) previews[hud] = key(hud) { rememberHudPreview(hud) } + } + } + // Preview dimensions can arrive after composition, repack only when sizes or layout inputs change + return remember(sections, previews, maxCardWidth, density) { + derivedStateOf { + val sizes = HashMap() + val geometry = HashMap() + for (section in sections) { + for (hud in section.huds) { + val (naturalW, naturalH) = if (hud is LegacyHud) { + val (minW, minH) = hud.minimumSize() + (if (minW > 0f) minW else LEGACY_PREVIEW_FALLBACK_SIZE) to + (if (minH > 0f) minH else LEGACY_PREVIEW_FALLBACK_SIZE) + } else previews.getValue(hud).let { it.naturalWidth to it.naturalHeight } + if (naturalW > 0f && naturalH > 0f) { + val scale = previewScaleFor(naturalW, naturalH, maxCardWidth, density.density) + val size = previewCardSize(naturalW, naturalH, scale, maxCardWidth, density.density, hud is LegacyHud) + geometry[hud] = HudPreviewGeometry(size, scale) + sizes[hud] = with(density) { IntSize(size.width.roundToPx(), size.height.roundToPx()) } + } + } + } + val items = with(density) { + buildList { + sections.forEachIndexed { sectionIndex, section -> + add(HudLibraryItem(section, null, LIBRARY_HEADER_HEIGHT.roundToPx())) + val rows = packHudLibraryRows(section.huds, sizes, maxCardWidth.roundToPx(), 10.dp.roundToPx()) + rows.forEachIndexed { rowIndex, row -> + val padding = if (rowIndex == rows.lastIndex && sectionIndex < sections.lastIndex) 6.dp else 0.dp + add(HudLibraryItem(section, row, row.height + padding.roundToPx(), padding)) + } + } + } + } + val indexes = items.mapIndexedNotNull { index, item -> + if (item.row == null) librarySectionKey(item.section.modId) to index else null + }.toMap() + HudLibraryLayout(items, indexes, previews.values.all { it.measured }, previews, geometry) + } + }.value +} + @Composable private fun HudLibraryPanel( modifier: Modifier = Modifier, searchText: String, onSearchChange: (String) -> Unit, + onClose: () -> Unit, sections: List, - scrollState: ScrollState, - onSectionMeasured: (String, Int) -> Unit, + activeModId: String?, + scrollState: LazyListState, + onSectionIndexesChanged: (Map) -> Unit, + pendingScroll: String?, + onScrollComplete: (target: String, interrupted: Boolean) -> Unit, onDragStart: (Hud, Float, Float, Float, Float) -> Unit = { _, _, _, _, _ -> }, onCardClick: (Hud) -> Unit = {}, ) { @@ -2758,78 +2938,93 @@ private fun HudLibraryPanel( ) { Text("HUDs", color = theme.textColor, fontSize = 18.sp) LibrarySearchBar(searchText, onSearchChange) + IconButton("close", modifier = Modifier.size(18.dp), onClick = onClose) } Text( "Click to add · Drag to place it yourself", color = theme.textColorSecondary, fontSize = 11.sp, ) - Box(modifier = Modifier.fillMaxWidth().weight(1f)) { - var viewportTop by remember { mutableStateOf(0f) } - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { viewportTop = it.positionInRoot().y } - ) { + Box(Modifier.fillMaxWidth().weight(1f)) { + BoxWithConstraints(Modifier.fillMaxSize()) { val maxCardWidth = maxWidth - 16.dp - Column( + val density = LocalDensity.current + val (items, sectionIndexes, previewsMeasured, previews, geometry) = rememberHudLibraryLayout(sections, maxCardWidth) + val completeScroll by rememberUpdatedState(onScrollComplete) + val metrics = remember(items, density) { + with(density) { + hudLibraryScrollMetrics(items, 10.dp.roundToPx()) + } + } + val scrollbarAdapter = remember(scrollState, metrics, constraints.maxHeight) { + HudLibraryScrollbarAdapter(scrollState, metrics, constraints.maxHeight) + } + SideEffect { onSectionIndexesChanged(sectionIndexes) } + LaunchedEffect(pendingScroll, sectionIndexes, previewsMeasured, searchText, metrics, constraints.maxHeight) { + val target = pendingScroll ?: return@LaunchedEffect + if (!previewsMeasured) return@LaunchedEffect + val index = sectionIndexes[librarySectionKey(target)] + if (index == null) { + if (searchText.isEmpty()) completeScroll(target, false) + return@LaunchedEffect + } + try { + snapshotFlow { scrollState.layoutInfo.totalItemsCount }.first { it == metrics.itemOffsets.size } + val maxOffset = (metrics.contentHeight - constraints.maxHeight).coerceAtLeast(0) + val targetOffset = metrics.itemOffsets[index].coerceAtMost(maxOffset) + val distance = targetOffset - scrollbarAdapter.scrollOffset + scrollState.animateScrollBy(distance.toFloat()) + } catch (cancelled: CancellationException) { + if (currentCoroutineContext().isActive) completeScroll(target, true) + throw cancelled + } + completeScroll(target, false) + } + LazyColumn( + state = scrollState, modifier = Modifier .fillMaxSize() - .verticalScroll(scrollState) .padding(end = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { if (sections.isEmpty()) { - Text( - "No HUDs found", - color = theme.textColorSecondary, - fontSize = 14.sp, - ) + item(key = "empty") { + Text( + "No HUDs found", + color = theme.textColorSecondary, + fontSize = 14.sp, + ) + } } - sections.forEach { section -> - key(librarySectionKey(section.modId)) { - Column( - verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.onGloballyPositioned { - val top = it.positionInRoot().y - viewportTop + scrollState.value - onSectionMeasured( - librarySectionKey(section.modId), - top.roundToInt().coerceAtLeast(0), - ) - }, - ) { + items.forEach { libraryItem -> + item(key = libraryItem.key, contentType = if (libraryItem.row == null) "header" else "cards") { + val row = libraryItem.row + if (row == null) { + HudLibraryHeader(libraryItem.section, active = libraryItem.section.modId == activeModId) + } else { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = libraryItem.bottomPadding), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Icon( - libraryIconFor(section.modId), - modifier = Modifier.size(16.dp), - color = theme.textColorSecondary, - ) - Text( - section.title.uppercase(), - color = theme.textColorSecondary, - fontSize = 12.sp, - ) - } - FlexibleLayout( - horizontalSpacing = 10.dp, - verticalSpacing = 10.dp - ) { - section.huds.forEach { hud -> - HudPreviewCard(hud, maxCardWidth, onDragStart, onCardClick) + row.huds.forEach { hud -> + key(hud) { + if (hud is LegacyHud) { + LegacyHudPreviewCard(hud, geometry.getValue(hud), onDragStart, onCardClick) + } else { + ComposeHudPreviewCard(hud, previews.getValue(hud), geometry.getValue(hud), onDragStart, onCardClick) + } + } } } } } } } + VerticalScrollbar( + adapter = scrollbarAdapter, + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight() + ) } - VerticalScrollbar( - adapter = rememberScrollbarAdapter(scrollState), - modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight() - ) } } } @@ -2892,34 +3087,23 @@ private fun previewScaleFor(naturalW: Float, naturalH: Float, maxCardWidth: Dp, .coerceAtLeast(0.05f) } -@Composable -private fun HudPreviewCard( - hud: Hud, - maxCardWidth: Dp, - onDragStart: (Hud, Float, Float, Float, Float) -> Unit, - onCardClick: (Hud) -> Unit, -) { - // legacy HUDs have no Compose content tree so render a sized titled placeholder instead of an empty - // zero-size preview - if (hud is LegacyHud) { - LegacyHudPreviewCard(hud, maxCardWidth, onDragStart, onCardClick) - } else { - ComposeHudPreviewCard(hud, maxCardWidth, onDragStart, onCardClick) - } +private fun previewCardSize(naturalW: Float, naturalH: Float, scale: Float, maxCardWidth: Dp, density: Float, legacy: Boolean): DpSize { + val width = (naturalW * scale / density).dp + PREVIEW_CARD_PADDING * 2 + val height = (naturalH * scale / density).dp + PREVIEW_CARD_PADDING * 2 + return DpSize( + (if (legacy) width.coerceAtLeast(72.dp) else width).coerceAtMost(maxCardWidth), + if (legacy) height.coerceAtLeast(72.dp) else height, + ) } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun LegacyHudPreviewCard( hud: Hud, - maxCardWidth: Dp, + geometry: HudPreviewGeometry, onDragStart: (Hud, Float, Float, Float, Float) -> Unit, onCardClick: (Hud) -> Unit, ) { - // legacy HUDs reporting no minimum size would be dropped from the library so fall back to a square - val (minW, minH) = hud.minimumSize() - val naturalW = if (minW > 0f) minW else LEGACY_PREVIEW_FALLBACK_SIZE - val naturalH = if (minH > 0f) minH else LEGACY_PREVIEW_FALLBACK_SIZE val density = LocalDensity.current.density val theme = LocalTheme.current @@ -2932,15 +3116,10 @@ private fun LegacyHudPreviewCard( var dragStarted by remember { mutableStateOf(false) } val cardPadding = PREVIEW_CARD_PADDING - val minTile = 72.dp - val previewScale = previewScaleFor(naturalW, naturalH, maxCardWidth, density) - val w = ((naturalW * previewScale / density).dp + cardPadding * 2) - .coerceAtLeast(minTile) - .coerceAtMost(maxCardWidth) - val h = ((naturalH * previewScale / density).dp + cardPadding * 2).coerceAtLeast(minTile) + val (size, previewScale) = geometry Box( modifier = Modifier - .size(w, h) + .size(size) .background(backgroundColor, theme.buttonShape) .border(1.dp, theme.borderColor, theme.buttonShape) .clip(theme.buttonShape) @@ -2997,13 +3176,11 @@ private fun LegacyHudPreviewCard( @Composable private fun ComposeHudPreviewCard( hud: Hud, - maxCardWidth: Dp, + preview: HudPreviewState, + geometry: HudPreviewGeometry, onDragStart: (Hud, Float, Float, Float, Float) -> Unit, onCardClick: (Hud) -> Unit, ) { - val preview = rememberHudPreview(hud) - val naturalW = preview.naturalWidth - val naturalH = preview.naturalHeight val density = LocalDensity.current.density val theme = LocalTheme.current @@ -3016,62 +3193,59 @@ private fun ComposeHudPreviewCard( var pressPos by remember { mutableStateOf(null) } var dragStarted by remember { mutableStateOf(false) } - if (naturalW > 0f && naturalH > 0f) { - val cardPadding = PREVIEW_CARD_PADDING - val previewScale = previewScaleFor(naturalW, naturalH, maxCardWidth, density) - val w = ((naturalW * previewScale / density).dp + cardPadding * 2).coerceAtMost(maxCardWidth) - val h = (naturalH * previewScale / density).dp + cardPadding * 2 - Box( - modifier = Modifier - .size(w, h) - .background(backgroundColor, theme.buttonShape) - .border(1.dp, theme.borderColor, theme.buttonShape) - .clip(theme.buttonShape) - .safePointerEvent(PointerEventType.Enter) { isHovered = true } - .safePointerEvent(PointerEventType.Exit) { - isHovered = false - if (!dragStarted) pressPos = null - } - .safePointerEvent(PointerEventType.Press) { event -> - val pos = event.changes.firstOrNull()?.position - if (pos != null) { - pressPos = pos - dragStarted = false - } + val cardPadding = PREVIEW_CARD_PADDING + val (size, previewScale) = geometry + Box( + modifier = Modifier + .size(size) + .background(backgroundColor, theme.buttonShape) + .border(1.dp, theme.borderColor, theme.buttonShape) + .clip(theme.buttonShape) + .safePointerEvent(PointerEventType.Enter) { isHovered = true } + .safePointerEvent(PointerEventType.Exit) { + isHovered = false + if (!dragStarted) pressPos = null + } + .safePointerEvent(PointerEventType.Press) { event -> + val pos = event.changes.firstOrNull()?.position + if (pos != null) { + pressPos = pos + dragStarted = false } - .safePointerEvent(PointerEventType.Move) { event -> - val pos = event.changes.firstOrNull()?.position ?: return@safePointerEvent - val start = pressPos ?: return@safePointerEvent - if (!dragStarted && event.changes.any { it.pressed }) { - val dx = pos.x - start.x - val dy = pos.y - start.y - val dist = kotlin.math.sqrt(dx * dx + dy * dy) - if (dist > 8f) { - dragStarted = true - val paddingPx = cardPadding.value * density - val hudLocalX = ((start.x - paddingPx) / previewScale).coerceAtLeast(0f) - val hudLocalY = ((start.y - paddingPx) / previewScale).coerceAtLeast(0f) - onDragStart(hud, pos.x, pos.y, hudLocalX, hudLocalY) - pressPos = null - isHovered = false - } + } + .safePointerEvent(PointerEventType.Move) { event -> + val pos = event.changes.firstOrNull()?.position ?: return@safePointerEvent + val start = pressPos ?: return@safePointerEvent + if (!dragStarted && event.changes.any { it.pressed }) { + val dx = pos.x - start.x + val dy = pos.y - start.y + val dist = kotlin.math.sqrt(dx * dx + dy * dy) + if (dist > 8f) { + dragStarted = true + val paddingPx = cardPadding.value * density + val hudLocalX = ((start.x - paddingPx) / previewScale).coerceAtLeast(0f) + val hudLocalY = ((start.y - paddingPx) / previewScale).coerceAtLeast(0f) + onDragStart(hud, pos.x, pos.y, hudLocalX, hudLocalY) + pressPos = null + isHovered = false } } - .safePointerEvent(PointerEventType.Release) { - if (!dragStarted && pressPos != null) { - onCardClick(hud) - } - pressPos = null - dragStarted = false + } + .safePointerEvent(PointerEventType.Release) { + if (!dragStarted && pressPos != null) { + onCardClick(hud) } - ) { - HudPreviewCanvas( - preview, - previewScale, - modifier = Modifier.fillMaxSize().padding(cardPadding), - ) - HudCardAddOverlay(isHovered) - } + pressPos = null + dragStarted = false + } + ) { + HudPreviewCanvas( + preview, + previewScale, + hud.alwaysRedraw, + modifier = Modifier.fillMaxSize().padding(cardPadding), + ) + HudCardAddOverlay(isHovered) } } @@ -3080,19 +3254,33 @@ private fun ModFilterIcon(iconName: String, selected: Boolean, onClick: () -> Un val interactionSource = rememberInteractionSource() val isHovered by interactionSource.collectIsHoveredAsState() val theme = LocalTheme.current - val iconColor by animateColorAsState( - if (selected) theme.textColor.copy(1f) - else if (isHovered) theme.textColor.copy(0.8f) - else theme.textColor.copy(0.7f) + val iconAlpha by animateFloatAsState( + if (selected) 1f else if (isHovered) 0.8f else 0.7f ) Box( modifier = Modifier .size(36.dp) + .drawWithContent { + drawContent() + if (selected) { + val barHeight = 24.dp.toPx() + drawRoundRect( + color = Accent, + topLeft = Offset(-8.dp.toPx(), (size.height - barHeight) / 2f), + size = Size(4.dp.toPx(), barHeight), + cornerRadius = CornerRadius(2.dp.toPx()), + ) + } + } .onClick(interactionSource) { onClick() }, contentAlignment = Alignment.Center ) { - Icon(iconName, modifier = Modifier.size(32.dp), color = iconColor) + Icon( + iconName, + modifier = Modifier.size(32.dp).graphicsLayer { alpha = iconAlpha }, + color = theme.textColor.copy(alpha = 1f), + ) } } From 5f3d81043438bd2035b8e2c42d0cba89bccdec1b Mon Sep 17 00:00:00 2001 From: Saad Nadeem <88615188+saadndm@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:52:04 -0400 Subject: [PATCH 10/19] fix(ui): move warm-up to startup (#1073) Co-authored-by: Julian Chang --- .../internal/OneConfigMixinInit.java | 1 + .../skia/Mixin_InitSkiaFontRenderer.java | 12 + .../skia/Mixin_StartupWarmupOverlay.java | 57 ++++ .../internal/ui/compose/ComposePreloader.kt | 248 ++++++++---------- .../internal/ui/compose/ComposeScreen.kt | 15 +- .../internal/ui/compose/SkiaFontRenderer.kt | 111 +++++--- .../ui/compose/impls/HudEditorUIScreen.kt | 5 +- .../ui/compose/impls/OneConfigUIScreen.kt | 37 +-- .../oneconfig/internal/ui/components/Icon.kt | 5 +- 9 files changed, 288 insertions(+), 203 deletions(-) create mode 100644 minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_StartupWarmupOverlay.java diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java index c3e97f4d2..2075a9c27 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java @@ -191,6 +191,7 @@ public List getMixins() { /*mixins.add("skia.Mixin_ScreenshotComposite"); *///? } mixins.add("skia.Mixin_InitSkiaFontRenderer"); + mixins.add("skia.Mixin_StartupWarmupOverlay"); //? if >= 1.21.10 { mixins.add("keybind.Mixin_KeybindCategoryLabel"); diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_InitSkiaFontRenderer.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_InitSkiaFontRenderer.java index c2805e50c..2f7106a0d 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_InitSkiaFontRenderer.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_InitSkiaFontRenderer.java @@ -1,14 +1,26 @@ package org.polyfrost.oneconfig.internal.mixin.skia; import net.minecraft.client.Minecraft; +import net.minecraft.server.packs.resources.ReloadableResourceManager; import org.polyfrost.oneconfig.internal.ui.compose.SkiaFontRenderer; +import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @Mixin(Minecraft.class) public class Mixin_InitSkiaFontRenderer { + @Shadow + @Final + private ReloadableResourceManager resourceManager; + + @Inject(method = "", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/packs/resources/ReloadableResourceManager;createReload(Ljava/util/concurrent/Executor;Ljava/util/concurrent/Executor;Ljava/util/concurrent/CompletableFuture;Ljava/util/List;)Lnet/minecraft/server/packs/resources/ReloadInstance;"), require = 1) + private void impl$registerFontReloadListener(CallbackInfo ci) { + this.resourceManager.registerReloadListener(SkiaFontRenderer.INSTANCE); + } + @Inject(method = "", at = @At("TAIL")) void impl$__init__(CallbackInfo ci) { SkiaFontRenderer.INSTANCE.init(); diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_StartupWarmupOverlay.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_StartupWarmupOverlay.java new file mode 100644 index 000000000..616d90d9d --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_StartupWarmupOverlay.java @@ -0,0 +1,57 @@ +package org.polyfrost.oneconfig.internal.mixin.skia; + +import net.minecraft.client.gui.screens.LoadingOverlay; +//? if >= 1.21.11 { +import net.minecraft.util.Util; +//?} else +//import net.minecraft.Util; +import org.polyfrost.oneconfig.internal.ui.compose.ComposePreloader; +import org.polyfrost.oneconfig.internal.ui.compose.SkiaCtx; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LoadingOverlay.class) +public class Mixin_StartupWarmupOverlay { + @Shadow + @Final + private boolean fadeIn; + + @Shadow + private long fadeOutStart; + + @Unique + private boolean oneconfig$holdingFade; + + @Unique + private long oneconfig$holdStartedNanos; + + @Unique + private static final long ONECONFIG_HOLD_TIMEOUT_NANOS = 15_000_000_000L; + + //~ if >= 26.1 'render' -> 'extractRenderState' + @Inject(method = "extractRenderState", at = @At("HEAD")) + private void oneconfig$holdStartupReveal(CallbackInfo ci) { + // Only hold the initial startup overlay once resource loading has finished + if (this.fadeIn || this.fadeOutStart < 0L) return; + if (!SkiaCtx.INSTANCE.isReady()) ComposePreloader.INSTANCE.fail("Skia context unavailable", null); + boolean hold = !ComposePreloader.INSTANCE.getStopped(); + if (hold) { + long now = System.nanoTime(); + if (!this.oneconfig$holdingFade) this.oneconfig$holdStartedNanos = now; + if (now - this.oneconfig$holdStartedNanos >= ONECONFIG_HOLD_TIMEOUT_NANOS) { + ComposePreloader.INSTANCE.fail("startup held for more than 15 seconds", null); + hold = false; + } + } + if (hold || this.oneconfig$holdingFade) { + // Restart normal fade when warm up finishes + this.fadeOutStart = Util.getMillis(); + } + this.oneconfig$holdingFade = hold; + } +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposePreloader.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposePreloader.kt index 8288a4e34..9f63650b1 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposePreloader.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposePreloader.kt @@ -1,179 +1,155 @@ package org.polyfrost.oneconfig.internal.ui.compose -import net.fabricmc.loader.api.FabricLoader import net.minecraft.client.Minecraft -import net.minecraft.client.gui.screens.ConnectScreen -import net.minecraft.client.gui.screens.LevelLoadingScreen -import net.minecraft.client.gui.screens.ProgressScreen +import net.minecraft.client.gui.screens.LoadingOverlay +import org.polyfrost.oneconfig.api.config.v1.ConfigManager +import org.polyfrost.oneconfig.api.event.v1.EventManager +import org.polyfrost.oneconfig.api.event.v1.events.TickEvent +import org.polyfrost.oneconfig.api.event.v1.invoke.EventHandler import org.polyfrost.oneconfig.api.notifications.v1.NotificationsManager import org.polyfrost.oneconfig.api.platform.v1.Platform -import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.compose.impls.HudEditorUIScreen import org.polyfrost.oneconfig.internal.ui.compose.impls.OneConfigUIScreen -import org.polyfrost.oneconfig.utils.v1.Multithreading import org.slf4j.LoggerFactory -import java.nio.file.Files -/** - * Builds the OneConfig UI while nobody is waiting for a frame, so that opening it costs nothing - */ object ComposePreloader { private val LOG = LoggerFactory.getLogger(ComposePreloader::class.java) @Volatile private var gpuWarmed = false + var stopped = false + private set + private var failed = false + + private var menuComplete = false + private var passStartedNanos = 0L + private var passes = 0 + + private data class Inputs(val width: Int, val height: Int, val configs: Int, val inWorld: Boolean) + + private var manager: ConfigManager? = null + private var warmed: Inputs? = null + private var seen: Inputs? = null + private var seenTicks = 0 + private var passQueued = false + private var watcher: EventHandler? = null + fun preloadGpuWarmup() { if (gpuWarmed) return gpuWarmed = true NotificationsManager.ensureInitialized() - warmClasses() - SkiaCtx.queueWarmup(::warmUp) + SkiaCtx.queueWarmup(::startupWarmUp) } - private fun warmClasses() { - val loader = ComposePreloader::class.java.classLoader ?: return - Multithreading.submit { - val startNanos = System.nanoTime() - val names = ourUiClasses() + LAZY_LIBRARY_CLASSES - val warmed = names.count { runCatching { Class.forName(it, false, loader) }.isSuccess } - LOG.info("Warmed {} of {} UI classes in {} ms", warmed, names.size, - (System.nanoTime() - startNanos) / 1_000_000) - } + fun fail(reason: String, cause: Throwable? = null) { + if (failed) return + failed = true + stopped = true + LOG.warn("OneConfig UI warm-up failed: $reason.", cause) + endPass() + stopWatching() } - private val WARM_PACKAGES = listOf( - "org/polyfrost/oneconfig/internal/ui/api/", - "org/polyfrost/oneconfig/internal/ui/components/", - "org/polyfrost/oneconfig/internal/ui/hud/", - "org/polyfrost/oneconfig/internal/ui/keybind/", - "org/polyfrost/oneconfig/internal/ui/screens/", - "org/polyfrost/oneconfig/internal/ui/search/", - "org/polyfrost/oneconfig/internal/ui/themes/", - ) - - private fun ourUiClasses(): List = FabricLoader.getInstance().allMods - .filter { it.metadata.id.startsWith("org_polyfrost_oneconfig") } - .flatMap { it.rootPaths } - .flatMap { root -> - runCatching { - Files.walk(root).use { paths -> - paths.map { path -> root.relativize(path).joinToString("/") } - .filter { name -> name.endsWith(".class") && WARM_PACKAGES.any(name::startsWith) } - .map { name -> name.removeSuffix(".class").replace('/', '.') } - .toList() - } - }.getOrDefault(emptyList()) - } + private fun endPass() { + menuComplete = false + passStartedNanos = 0L + runCatching { OneConfigUIScreen.endPrewarmShared() }.onFailure { LOG.warn("Failed to end menu warm-up", it) } + runCatching { HudEditorUIScreen.endPrewarmShared() }.onFailure { LOG.warn("Failed to end HUD editor warm-up", it) } + } - private val LAZY_LIBRARY_CLASSES = listOf( - "androidx.compose.ui.graphics.SkiaBackedPath_skikoKt", - "androidx.compose.foundation.lazy.LazyListItemProviderKt", - "androidx.compose.ui.text.SkiaParagraph", - "androidx.compose.ui.text.platform.DesktopFont_desktopKt", - "androidx.compose.ui.text.platform.FontCache", - "androidx.compose.foundation.lazy.LazyListKt", - "androidx.compose.foundation.lazy.LazyListMeasureKt", - "androidx.compose.foundation.lazy.LazyListState", - "androidx.compose.foundation.lazy.LazyListMeasuredItem", - "androidx.compose.foundation.lazy.LazyListMeasuredItemProvider", - "androidx.compose.foundation.lazy.LazyListMeasureResult", - "androidx.compose.foundation.lazy.LazyListIntervalContent", - "androidx.compose.foundation.lazy.LazyListItemProviderImpl", - "androidx.compose.foundation.lazy.LazyDslKt", - "androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScopeImpl", - "androidx.compose.foundation.lazy.layout.LazyLayoutItemContentFactory", - "androidx.compose.foundation.lazy.layout.LazySaveableStateHolder", - "androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItemKt", - "kotlinx.coroutines.flow.internal.ChannelFlowTransformLatest", - "kotlinx.coroutines.flow.internal.ChannelFlowOperator", - "kotlinx.coroutines.flow.internal.ChannelFlow", - "kotlinx.coroutines.flow.internal.MergeKt", - "org.commonmark.parser.Parser", - "org.commonmark.internal.DocumentParser", - "org.commonmark.internal.ParagraphParser", - "org.commonmark.internal.LinkReferenceDefinitionParser", - "androidx.compose.ui.text.TextMeasurer", - "androidx.compose.ui.text.TextLayoutCache", - "androidx.compose.ui.text.SpanStyle", - "androidx.compose.ui.text.TextStyle", - "androidx.compose.ui.text.ParagraphStyle", - "androidx.compose.ui.text.ParagraphKt", - "androidx.compose.ui.text.platform.ParagraphBuilder", - "androidx.compose.foundation.text.TextFieldDelegateKt", - "androidx.compose.foundation.text.TextFieldSize", - "androidx.compose.foundation.text.TextFieldScrollKt", - "androidx.compose.foundation.text.KeyMapping_skikoKt", - "androidx.compose.foundation.text.TextFieldKeyInput", - ) + private fun releaseHold() { + stopped = true + if (!failed && watcher == null) watcher = EventManager.register(TickEvent.End::class.java, Runnable(::onTick)) + } - private data class Inputs(val width: Int, val height: Int, val configs: Int, val inWorld: Boolean) + private fun stopWatching() { + watcher?.let { EventManager.INSTANCE.unregister(it) } + watcher = null + } - private var warmed: Inputs? = null - private var passes = 0 + private fun inputs(): Inputs { + val manager = manager ?: ConfigManager.active().also { manager = it } + return Inputs( + width = Platform.screen().windowWidth(), + height = Platform.screen().windowHeight(), + configs = manager.trees().size, + inWorld = Minecraft.getInstance().level != null, + ) + } + + private fun step(): Boolean { + if (passStartedNanos == 0L) passStartedNanos = System.nanoTime() + if (!menuComplete) { + menuComplete = OneConfigUIScreen.prewarmShared() + return false + } + if (!HudEditorUIScreen.prewarmShared()) return false + val tookMs = (System.nanoTime() - passStartedNanos) / 1_000_000 + warmed = inputs() + passes++ + endPass() + LOG.info("OneConfig UI warm-up pass {} completed in {} ms ({})", passes, tookMs, warmed) + return true + } - private var passNanos = 0L - private var passFrames = 0 + private fun startupWarmUp() { + if (stopped) return - private var worstFrameNanos = 0L - private var deadline = 0L + //? if >= 26.2 { + val overlay = Minecraft.getInstance().gui.overlay() + //?} else + //val overlay = Minecraft.getInstance().overlay - private fun warmUp() { - if (waitDeadline == 0L) waitDeadline = System.nanoTime() + WAIT_NANOS - if (!readyToWarm()) { - if (System.nanoTime() < waitDeadline) SkiaCtx.queueWarmup(::warmUp) + if (overlay !is LoadingOverlay) { + releaseHold() return } - if (deadline == 0L) deadline = System.nanoTime() + WATCH_NANOS - val inputs = Inputs( - width = Platform.screen().windowWidth(), - height = Platform.screen().windowHeight(), - configs = ConfigRegistry.configs.size, - inWorld = Minecraft.getInstance().level != null, - ) - if (inputs != warmed) { - val startNanos = System.nanoTime() - val done = OneConfigUIScreen.prewarmShared() - val frameNanos = System.nanoTime() - startNanos - passNanos += frameNanos - passFrames++ - if (frameNanos > worstFrameNanos) worstFrameNanos = frameNanos - if (done) { - passes++ - HudEditorUIScreen.prewarmShared() - warmed = inputs - LOG.info( - "OneConfig UI warm-up pass {} in {} ms over {} frame(s), worst {} ms ({} configs, {}x{}, {})", - passes, passNanos / 1_000_000, passFrames, worstFrameNanos / 1_000_000, - inputs.configs, inputs.width, inputs.height, - if (inputs.inWorld) "in world" else "no world", - ) - passNanos = 0L - passFrames = 0 - worstFrameNanos = 0L + try { + if (SkiaFontRenderer.isReadyForWarmup()) { + if (step()) releaseHold() + } else if (SkiaFontRenderer.reloadFailed) { + fail("font reload produced no usable font") } + } catch (t: Throwable) { + fail("${t.javaClass.simpleName}: ${t.message}", t) } - if (warmed?.inWorld != true && passes < MAX_PASSES && System.nanoTime() < deadline) { - SkiaCtx.queueWarmup(::warmUp) - } else { - OneConfigUIScreen.endPrewarmShared() - HudEditorUIScreen.endPrewarmShared() - } + if (!stopped) SkiaCtx.queueWarmup(::startupWarmUp) } - private fun readyToWarm(): Boolean { - if (Minecraft.getInstance().level != null) return true - val screen = Platform.screen().current() - return screen is LevelLoadingScreen || screen is ConnectScreen || screen is ProgressScreen + private fun onTick() { + if (passQueued || failed || Platform.screen().current() is ComposeScreen) return + val inputs = inputs() + if (inputs.width <= 0 || inputs.height <= 0) return + if (inputs != seen) { + seen = inputs + seenTicks = 0 + return + } + if (inputs == warmed || ++seenTicks < SETTLE_TICKS) return + passQueued = true + SkiaCtx.queueWarmup(::rewarm) } - private const val WAIT_NANOS = 600_000_000_000L + private fun rewarm() { + if (failed) return + val done = try { + SkiaFontRenderer.isReadyForWarmup() && step() + } catch (t: Throwable) { + fail("${t.javaClass.simpleName}: ${t.message}", t) + return + } + if (!done) { + SkiaCtx.queueWarmup(::rewarm) + return + } + passQueued = false + if (warmed?.inWorld == true || passes >= MAX_PASSES) stopWatching() + } - private var waitDeadline = 0L + private const val SETTLE_TICKS = 10 private const val MAX_PASSES = 8 - - private const val WATCH_NANOS = 300_000_000_000L } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt index 5d88a6d30..d5bdd29e6 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt @@ -375,10 +375,6 @@ abstract class ComposeScreen( } fun endPrewarm() { - if (prewarmCursor > 0) { - // Release hidden search focus so it doesn't suppress keybinds - withScene { it.focusManager.releaseFocus() } - } prewarmCursor = 0 releasePrewarmSurface() } @@ -386,24 +382,26 @@ abstract class ComposeScreen( protected fun prewarm(frames: Int, budget: Int = frames, step: (Int) -> Unit): Boolean { val name = this::class.java.simpleName if (ensureScene() == null) { - LOGGER.warn("{} warm-up: no scene ({})", name, ComposeSupport.unavailableReason() ?: "createScene failed") + ComposePreloader.fail("$name: no scene (${ComposeSupport.unavailableReason() ?: "createScene failed"})") return false } syncSceneMetrics() if (lastSceneW <= 0 || lastSceneH <= 0) { - LOGGER.warn("{} warm-up: window is {}x{}", name, lastSceneW, lastSceneH) closeSceneQuietly() + ComposePreloader.fail("$name: window is ${lastSceneW}x${lastSceneH}") return false } val hadContent = contentSet if (!bindContent()) { - LOGGER.warn("{} warm-up: setContent did not take (poisoned={})", name, scenePoisoned) + val reason = "$name: setContent did not take (poisoned=$scenePoisoned)" closeSceneQuietly() + ComposePreloader.fail(reason) return false } if (!hadContent) return false val surface = prewarmSurface() ?: run { closeSceneQuietly() + ComposePreloader.fail("$name: could not allocate warm-up surface") return false } try { @@ -413,6 +411,7 @@ abstract class ComposeScreen( val scope = renderScopeOrNull if (recomposer == null || scope == null) { closeSceneQuietly() + ComposePreloader.fail("$name: missing recomposer or render scope") return false } while (prewarmCursor < until) { @@ -426,7 +425,7 @@ abstract class ComposeScreen( prewarmCursor = 0 releasePrewarmSurface() closeSceneQuietly() - LOGGER.warn("Compose warm-up failed; the first open will build the UI instead", t) + ComposePreloader.fail("Compose warm-up failed; the first open will build the UI instead", t) return false } sceneDirty = true diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaFontRenderer.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaFontRenderer.kt index f338e4e01..d17edc6c3 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaFontRenderer.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaFontRenderer.kt @@ -5,7 +5,6 @@ import com.google.gson.JsonParser import net.minecraft.client.Minecraft import net.minecraft.resources.Identifier import net.minecraft.server.packs.resources.PreparableReloadListener -import net.minecraft.server.packs.resources.ReloadableResourceManager import net.minecraft.server.packs.resources.ResourceManager //? < 1.21.4 //import net.minecraft.util.profiling.ProfilerFiller @@ -15,6 +14,7 @@ import org.polyfrost.compose.mc.McFontQueue import org.polyfrost.compose.render.FontManager import org.slf4j.LoggerFactory import java.io.ByteArrayInputStream +import java.util.Optional import java.util.concurrent.CompletableFuture import java.util.concurrent.Executor import java.util.zip.ZipInputStream @@ -118,10 +118,6 @@ object SkiaFontRenderer : PreparableReloadListener { McFontQueue.measureWidth = { text, scale -> measureWidth(text) * scale } McFontQueue.measureHeight = { scale -> LINE_HEIGHT * scale } McFontQueue.renderer = ::draw - val resourceManager = Minecraft.getInstance().resourceManager - if (resourceManager is ReloadableResourceManager) { - resourceManager.registerReloadListener(this) - } ComposePreloader.preloadGpuWarmup() } @@ -189,9 +185,15 @@ object SkiaFontRenderer : PreparableReloadListener { val now = System.currentTimeMillis() if (now - lastLoadAttempt < RETRY_INTERVAL_MS) return lastLoadAttempt = now - if (rebuild(Minecraft.getInstance().resourceManager)) loaded = true + prepare(Minecraft.getInstance().resourceManager, fontOptionsMask())?.let(::applyPrepared) } + internal fun isReadyForWarmup(): Boolean = loaded && builtOptions == fontOptionsMask() + + @Volatile + internal var reloadFailed = false + private set + private fun drawGlyphs(canvas: Canvas, text: String, x: Float, y: Float, color: Int, scale: Float, isShadow: Boolean) { var curX = x var curColor = color @@ -476,11 +478,22 @@ object SkiaFontRenderer : PreparableReloadListener { } } - private fun rebuild(rm: ResourceManager): Boolean { - val options = fontOptionsMask() + private class PreparedFont( + val glyphs: Map, + val fast: Array, + val spaces: Map, + val atlases: List, + val unihex: UnihexGlyphs?, + val options: Int, + ) { + fun close() = atlases.forEach { it.close() } + } + + // Uses only local data and CPU-backed Skia images, never the shared GPU context. + private fun prepare(rm: ResourceManager, options: Int): PreparedFont? { val providers = ArrayList() runCatching { collectProviders(rm, ROOT_FONT, providers, HashSet(), options) } - if (providers.isEmpty()) return false + if (providers.isEmpty()) return null val newGlyphs = HashMap(4096) val newSpace = HashMap(4) @@ -488,47 +501,59 @@ object SkiaFontRenderer : PreparableReloadListener { val claimed = HashSet(4096) val hex = HexAccumulator() - for (provider in providers) { - when (provider.get("type")?.asString) { - "space" -> { - val advances = provider.getAsJsonObject("advances") ?: continue - for ((key, value) in advances.entrySet()) { - if (key.isEmpty()) continue - val cp = key.codePointAt(0) - if (claimed.add(cp)) newSpace[cp] = value.asFloat + var prepared = false + try { + for (provider in providers) { + when (provider.get("type")?.asString) { + "space" -> { + val advances = provider.getAsJsonObject("advances") ?: continue + for ((key, value) in advances.entrySet()) { + if (key.isEmpty()) continue + val cp = key.codePointAt(0) + if (claimed.add(cp)) newSpace[cp] = value.asFloat + } } + "bitmap" -> runCatching { loadBitmap(rm, provider, newGlyphs, newAtlases, claimed) } + .onFailure { LOGGER.warn("Failed to load bitmap font provider, skipping", it) } + "unihex" -> runCatching { loadUnihex(rm, provider, hex, claimed) } + .onFailure { LOGGER.warn("Failed to load unihex font provider, skipping", it) } + else -> {} } - "bitmap" -> runCatching { loadBitmap(rm, provider, newGlyphs, newAtlases, claimed) } - .onFailure { LOGGER.warn("Failed to load bitmap font provider, skipping", it) } - "unihex" -> runCatching { loadUnihex(rm, provider, hex, claimed) } - .onFailure { LOGGER.warn("Failed to load unihex font provider, skipping", it) } - else -> {} } - } - if (newGlyphs.isEmpty() && hex.size == 0) return false + if (newGlyphs.isEmpty() && hex.size == 0) return null + + val newFast = arrayOfNulls(FAST_GLYPH_LIMIT) + for ((cp, glyph) in newGlyphs) if (cp in 0 until FAST_GLYPH_LIMIT) newFast[cp] = glyph - val newFast = arrayOfNulls(FAST_GLYPH_LIMIT) - for ((cp, glyph) in newGlyphs) if (cp in 0 until FAST_GLYPH_LIMIT) newFast[cp] = glyph + return PreparedFont(newGlyphs, newFast, newSpace, newAtlases, hex.build(), options).also { + prepared = true + } + } finally { + if (!prepared) newAtlases.forEach { it.close() } + } + } + private fun applyPrepared(prepared: PreparedFont) { val oldAtlases = atlases val oldImages = unihexImages val oldShaders = shaders - glyphs = newGlyphs - glyphFast = newFast - spaceAdvances = if (newSpace.isEmpty()) mapOf(' '.code to 4f) else newSpace - atlases = newAtlases - unihex = hex.build() + glyphs = prepared.glyphs + glyphFast = prepared.fast + spaceAdvances = prepared.spaces.ifEmpty { mapOf(' '.code to 4f) } + atlases = prepared.atlases + unihex = prepared.unihex unihexImages = HashMap() shaders = HashMap() shaderImage = null - builtOptions = options + builtOptions = prepared.options + reloadFailed = false val retiring = ArrayList(oldAtlases.size + oldImages.size + oldShaders.size) retiring.addAll(oldAtlases) retiring.addAll(oldImages.values) retiring.addAll(oldShaders.values) retire(retiring) - return true + loaded = true } private class HexAccumulator { @@ -779,17 +804,23 @@ object SkiaFontRenderer : PreparableReloadListener { //? >= 1.21.10 val resourceManager = sharedState.resourceManager() - return CompletableFuture.supplyAsync({ }, executor) - .thenCompose { + val options = fontOptionsMask() + return CompletableFuture.supplyAsync({ Optional.ofNullable(prepare(resourceManager, options)) }, executor) + .thenCompose { prepared -> //? >= 1.21.10 { - preparationBarrier.wait(Unit) + preparationBarrier.wait(prepared) //? } else - //preparationBarrier!!.wait(Unit) - }.thenAcceptAsync({ - if (rebuild(resourceManager)) { - loaded = true + //preparationBarrier!!.wait(prepared) + .whenComplete { _, failure -> + if (failure != null) prepared.ifPresent { it.close() } + } + }.thenAcceptAsync({ result -> + val prepared = result.orElse(null) + if (prepared != null) { + applyPrepared(prepared) } else { loaded = false + reloadFailed = true lastLoadAttempt = 0L } }, executor2) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt index f8a66a326..2cb965de9 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt @@ -17,6 +17,7 @@ import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen +import org.polyfrost.oneconfig.internal.ui.compose.ComposePreloader import org.polyfrost.oneconfig.internal.ui.components.RetainedVisibility import org.polyfrost.oneconfig.internal.ui.guiCloseAnimationMillis import org.polyfrost.oneconfig.internal.ui.keybind.KeybindRecordingBus @@ -55,9 +56,9 @@ class HudEditorUIScreen private constructor() : ComposeScreen() { private fun runPrewarm(): Boolean { if (everOpened || Platform.screen().current() === this) return true return try { - prewarm(PREWARM_FRAMES) { } + prewarm(PREWARM_FRAMES, budget = 1) { } } catch (t: Throwable) { - LOGGER.warn("HUD editor warm-up failed; the first open will build it instead", t) + ComposePreloader.fail("HUD editor warm-up failed", t) false } } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt index f7b49fb1a..ab250c7fa 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt @@ -22,6 +22,7 @@ import org.polyfrost.oneconfig.internal.ui.OneConfigInterface import org.polyfrost.oneconfig.internal.ui.components.warmIconCache import org.polyfrost.oneconfig.internal.ui.guiCloseAnimationMillis import org.polyfrost.oneconfig.internal.ui.compose.BlurRenderer +import org.polyfrost.oneconfig.internal.ui.compose.ComposePreloader import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen import org.polyfrost.oneconfig.internal.ui.compose.SkiaCtx import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute @@ -119,15 +120,19 @@ class OneConfigUIScreen @JvmOverloads constructor( @JvmStatic fun endPrewarmShared() { - sharedScreen?.endPrewarm() + sharedScreen?.let { + if (!it.everOpened && Platform.screen().current() !== it) { + it.restorePrewarmScroll() + it.restorePrewarmNavigation() + } + it.endPrewarm() + } } private const val PREWARM_FRAME_BUDGET = 1 private const val PREWARM_OPEN_FRAME = 1 - private const val PREWARM_FOCUS_FRAME = 2 - private val PREWARM_SCROLL_FRAMES = 5..17 private const val PREWARM_RESTORE_FRAME = 18 @@ -195,34 +200,36 @@ class OneConfigUIScreen @JvmOverloads constructor( @Volatile private var everOpened = false + private fun restorePrewarmScroll() { + scrollModGrid(0) + } + + private fun restorePrewarmNavigation() { + warmRoute(ModsGraph) + LocalNavController.wrapper.reset() + ShellState.lastRoute = null + } + private fun runPrewarm(): Boolean { if (everOpened || Platform.screen().current() === this) return true prewarming = true return try { ConfigRegistry.loadFrom(ConfigManager.active(), ConfigSource.OC) warmIconCache(ConfigRegistry.modCardConfigs.mapNotNull { it.icon }) - var restoreTo = 0 prewarm(PREWARM_FRAMES, PREWARM_FRAME_BUDGET) { frame -> when (frame) { PREWARM_OPEN_FRAME -> requestOpenCallback?.invoke() - PREWARM_FOCUS_FRAME -> ShellState.focusSearchField = true - PREWARM_CLOSE_FRAME -> { - ShellState.focusSearchField = false - ShellState.searchFieldFocused = false - ShellState.searchQuery = "" - requestCloseCallback?.invoke() - } - PREWARM_RESTORE_FRAME -> scrollModGrid(restoreTo) + PREWARM_CLOSE_FRAME -> requestCloseCallback?.invoke() + PREWARM_RESTORE_FRAME -> restorePrewarmScroll() in PREWARM_PAGE_FRAMES -> { val step = frame - PREWARM_PAGE_FRAMES.first if (step % PREWARM_FRAMES_PER_PAGE == 0) { warmRoute(PREWARM_ROUTES[step / PREWARM_FRAMES_PER_PAGE]) } } - PREWARM_FORGET_FRAME -> LocalNavController.wrapper.reset() + PREWARM_FORGET_FRAME -> restorePrewarmNavigation() in PREWARM_SCROLL_FRAMES -> { val grid = ShellState.gridStates[MOD_GRID_KEY] ?: return@prewarm - if (frame == PREWARM_SCROLL_FRAMES.first) restoreTo = grid.firstVisibleItemIndex val last = (grid.layoutInfo.totalItemsCount - 1).coerceAtLeast(0) val step = frame - PREWARM_SCROLL_FRAMES.first val span = PREWARM_SCROLL_FRAMES.last - PREWARM_SCROLL_FRAMES.first @@ -232,7 +239,7 @@ class OneConfigUIScreen @JvmOverloads constructor( } } catch (t: Throwable) { endPrewarm() - LOGGER.warn("OneConfig UI warm-up failed; the first open will build the UI instead", t) + ComposePreloader.fail("menu warm-up failed", t) false } finally { prewarming = false diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Icon.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Icon.kt index 2a6d90122..6626b719c 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Icon.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Icon.kt @@ -89,7 +89,8 @@ private object IconResource { }.bytes fun warm(names: Collection) { - val pending = names.filterTo(HashSet()) { !cache.containsKey(it) && warming.add(it) } + val pending = names.asSequence().map { it.toIconResourcePath() } + .filter { !cache.containsKey(it) && warming.add(it) }.toSet() if (pending.isEmpty()) return Multithreading.submit { for (name in pending) { @@ -207,7 +208,7 @@ fun Icon( return } } else { - val icon = rememberAsyncRasterIcon(iconName, lastModified) { file.readBytes() } + val icon = rememberAsyncRasterIcon(iconName, lastModified) { IconResource.fileBytes(file, lastModified) } val imageModifier = modifier.then(iconSizeModifier(null)).clip(DefaultRasterIconShape) if (icon != null) { Image( From c198c243d02d140b90496541ec7224d9b648ca29 Mon Sep 17 00:00:00 2001 From: Saad Nadeem <88615188+saadndm@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:33:49 -0400 Subject: [PATCH 11/19] perf(items): render item icons from a GPU atlas (#1087) Co-authored-by: anolithme <260661304+anolithme@users.noreply.github.com> Co-authored-by: Julian Chang --- .../main/kotlin/oneconfig-fabric.gradle.kts | 6 + gradle/fabric/1.21.1.versions.toml | 2 +- .../oneconfig/internal/OneConfig.java | 8 +- .../internal/OneConfigMixinInit.java | 1 + .../mixin/Mixin_SimpleReloadInstance.java | 1 + .../skia/Mixin_GuiRendererLegacyTarget.java | 11 + .../mixin/skia/Mixin_ItemAtlasScissor.java | 50 + .../oneconfig/api/ui/v1/item/PolyItemIcon.kt | 191 +++ .../oneconfig/internal/ui/RenderTargetFbo.kt | 4 + .../internal/ui/SkiaOffscreenTarget.kt | 23 +- .../item/MinecraftItemCatalogService.kt | 1453 +++++++++++------ .../internal/ui/compose/ComposeScreen.kt | 11 +- .../oneconfig/internal/ui/compose/SkiaCtx.kt | 37 +- .../ui/compose/impls/HudEditorUIScreen.kt | 2 + .../ui/compose/impls/OneConfigUIScreen.kt | 2 + .../ui/compose/opengl/StoredGLState.kt | 4 + .../ui/compose/opengl/StoredGLStateProps.kt | 2 + .../internal/ui/hud/GuiTargetRedirect.kt | 12 + .../ui/services/VulkanModVulkanService.kt | 47 +- .../internal/ui/services/VulkanService.kt | 2 + .../oneconfig/test/TestItemHud_Test.kt | 16 +- minecraft/src/main/resources/fabric.mod.json | 3 +- .../main/resources/oneconfigv1.classtweaker | 28 + minecraft/stonecutter.gradle.kts | 10 + modules/hud/api/hud.api | 9 +- .../org/polyfrost/oneconfig/api/hud/v1/Hud.kt | 16 +- .../oneconfig/api/hud/v1/HudManager.kt | 74 +- .../ui/components/item/ItemCatalogService.kt | 88 +- .../ui/components/item/ItemVisuals.kt | 85 +- .../ui/components/item/PolyItemVisuals.kt | 100 -- .../ui/components/settings/ItemListOption.kt | 4 +- 31 files changed, 1607 insertions(+), 695 deletions(-) create mode 100644 minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_ItemAtlasScissor.java create mode 100644 minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/item/PolyItemIcon.kt create mode 100644 minecraft/src/main/resources/oneconfigv1.classtweaker delete mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/PolyItemVisuals.kt diff --git a/buildSrc/src/main/kotlin/oneconfig-fabric.gradle.kts b/buildSrc/src/main/kotlin/oneconfig-fabric.gradle.kts index d7cf6feaa..8dbf08a43 100644 --- a/buildSrc/src/main/kotlin/oneconfig-fabric.gradle.kts +++ b/buildSrc/src/main/kotlin/oneconfig-fabric.gradle.kts @@ -1,3 +1,4 @@ +import dev.kikugie.stonecutter.build.StonecutterBuildExtension import net.fabricmc.loom.api.LoomGradleExtensionAPI import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.tasks.testing.logging.TestExceptionFormat @@ -40,8 +41,13 @@ tasks.withType().configureEach { } } +val sc = extensions.getByName("stonecutter") val loom = extensions.getByName("loom") loom.apply { + accessWidenerPath = sc.process( + rootProject.file("minecraft/src/main/resources/oneconfigv1.classtweaker"), + "build/processed.classtweaker", + ) runConfigs["client"].apply { ideConfigGenerated(true) runDir = "../../run" diff --git a/gradle/fabric/1.21.1.versions.toml b/gradle/fabric/1.21.1.versions.toml index 0a8cd43ed..9d61ca8e8 100644 --- a/gradle/fabric/1.21.1.versions.toml +++ b/gradle/fabric/1.21.1.versions.toml @@ -3,7 +3,7 @@ modmenu = "11.0.3" yacl = "3.7.1+1.21.1-fabric" moulconfig = "3.11.0" -vulkanmod = "pY3DXoVe" +vulkanmod = "2eqroDKD" [libraries] modmenu = { module = "com.terraformersmc:modmenu", version.ref = "modmenu"} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java index d4bf3671d..2ea4f221b 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java @@ -60,6 +60,7 @@ import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry; import org.polyfrost.oneconfig.internal.ui.api.ConfigSource; import org.polyfrost.oneconfig.internal.ui.api.ThirdPartyModCategories; +import org.polyfrost.oneconfig.internal.ui.components.item.ItemCatalog; import org.polyfrost.oneconfig.internal.ui.compose.McFontService; import org.polyfrost.oneconfig.internal.ui.compose.SkiaCtx; import org.polyfrost.oneconfig.internal.ui.compose.impls.HudEditorUIScreen; @@ -190,7 +191,7 @@ public static void render(GuiGraphicsExtractor graphics, float partial) { // records the F3 overlay offscreen so Skia can put it above the Compose UI instead of below the // blur and it must run every frame regardless of the HUD dirty gate org.polyfrost.oneconfig.internal.ui.hud.DebugOverlayOffscreen.INSTANCE.render(); - if (HudManager.INSTANCE.beginFrame(sw, sh)) { + if (HudManager.INSTANCE.beginFrame(sw, sh, ItemCatalog.INSTANCE::renderHudIcons)) { SkiaCtx.INSTANCE.queueHudDraw(() -> { var ctx = new RenderContext(SkiaCtx.INSTANCE.getCanvas()); HudManager.INSTANCE.render(ctx, sw, sh); @@ -258,7 +259,10 @@ private static void registerEventHandlers() { org.polyfrost.oneconfig.internal.compat.WWaypointsCompat.register(); org.polyfrost.oneconfig.internal.ui.themes.ThemeRegistry.INSTANCE.loadFromConfig(); }); - EventManager.register(WorldEvent.Load.class, e -> showFirstLaunchNotification()); + EventManager.register(WorldEvent.Load.class, e -> { + showFirstLaunchNotification(); + ItemCatalog.INSTANCE.markIconsAvailable(); + }); // after loading finishes so translation keys are available EventManager.register(ResourceFinishedLoading.class, e -> SearchCorpus.INSTANCE.init()); // //#if MC < 1.13 diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java index 2075a9c27..dac2c6f1f 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java @@ -184,6 +184,7 @@ public List getMixins() { mixins.add("render.GameRendererAccessor"); mixins.add("render.GuiRendererAccessor"); //? } + mixins.add("skia.Mixin_ItemAtlasScissor"); //? if < 1.21.8 //mixins.add("skia.Mixin_MainTargetRedirect"); mixins.add("skia.Mixin_DebugOverlayAboveUi"); diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/Mixin_SimpleReloadInstance.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/Mixin_SimpleReloadInstance.java index b268e1e45..8243bb5dc 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/Mixin_SimpleReloadInstance.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/Mixin_SimpleReloadInstance.java @@ -15,6 +15,7 @@ public class Mixin_SimpleReloadInstance { @Inject(method = "create", at = @At("RETURN")) private static void oneconfig$onResourceFinishedLoading(CallbackInfoReturnable cir) { cir.getReturnValue().done().whenComplete((result, throwable) -> { + if (throwable != null) return; EventManager.INSTANCE.post(ResourceFinishedLoading.INSTANCE); }); } diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_GuiRendererLegacyTarget.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_GuiRendererLegacyTarget.java index 258bb28b1..03b144c6f 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_GuiRendererLegacyTarget.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_GuiRendererLegacyTarget.java @@ -18,6 +18,17 @@ @Mixin(GuiRenderer.class) public class Mixin_GuiRendererLegacyTarget { + @ModifyExpressionValue( + method = "draw", + //? if >= 26.1 { + at = @At(value = "FIELD", target = "Lnet/minecraft/client/renderer/state/WindowRenderState;guiScale:I", opcode = Opcodes.GETFIELD) + //?} else + //at = @At(value = "INVOKE", target = "Lcom/mojang/blaze3d/platform/Window;getGuiScale()I") + ) + private int oneconfig$itemAtlasProjectionScale(int original) { + return GuiTargetRedirect.itemRenderSizePx > 0 ? 1 : original; + } + //? if >= 26.1 { @ModifyExpressionValue( method = "draw", diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_ItemAtlasScissor.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_ItemAtlasScissor.java new file mode 100644 index 000000000..91312a5ef --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/skia/Mixin_ItemAtlasScissor.java @@ -0,0 +1,50 @@ +package org.polyfrost.oneconfig.internal.mixin.skia; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +//? if >= 1.21.8 { +import com.llamalad7.mixinextras.expression.Expression; +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.mojang.renderpearl.api.commands.RenderPass; +import net.minecraft.client.gui.render.GuiRenderer; +//?} else +//import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.navigation.ScreenRectangle; +import org.polyfrost.oneconfig.internal.ui.hud.GuiTargetRedirect; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +//~ if < 1.21.8 'GuiRenderer' -> 'GuiGraphics' +@Mixin(GuiRenderer.class) +public class Mixin_ItemAtlasScissor { + //? if >= 1.21.8 { + @Expression("16 * ?") + @ModifyExpressionValue(method = "prepareItemElements", at = @At("MIXINEXTRAS:EXPRESSION")) + private int oneconfig$itemAtlasSlotSize(int original) { + int requested = GuiTargetRedirect.itemRenderSizePx; + return requested > 0 ? requested : original; + } + //?} + + @WrapOperation( + //? if >= 1.21.8 { + method = "enableScissor", + at = @At(value = "INVOKE", target = "Lcom/mojang/renderpearl/api/commands/RenderPass;enableScissor(IIII)V") + //?} else { + /*method = "applyScissor", + at = @At(value = "INVOKE", target = "Lcom/mojang/blaze3d/systems/RenderSystem;enableScissor(IIII)V") + *///?} + ) + //~ if < 1.21.8 '(RenderPass pass, int x' -> '(int x' + private void oneconfig$targetScissor(RenderPass pass, int x, int y, int width, int height, Operation original, ScreenRectangle rectangle) { + GuiTargetRedirect.ScissorTransform transform = GuiTargetRedirect.scissorTransform; + if (transform == null) { + //~ if < 1.21.8 '(pass, x' -> '(x' + original.call(pass, x, y, width, height); + return; + } + int[] mapped = transform.map(rectangle.left(), rectangle.top(), rectangle.right(), rectangle.bottom()); + //~ if < 1.21.8 '(pass, mapped[0]' -> '(mapped[0]' + original.call(pass, mapped[0], mapped[1], mapped[2], mapped[3]); + } +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/item/PolyItemIcon.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/item/PolyItemIcon.kt new file mode 100644 index 000000000..0dffa73de --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/item/PolyItemIcon.kt @@ -0,0 +1,191 @@ +package org.polyfrost.oneconfig.api.ui.v1.item + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import net.minecraft.client.Minecraft +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.world.item.Item +import net.minecraft.world.item.ItemStack +import org.jetbrains.skia.Rect +import org.polyfrost.compose.composables.PolyCanvas +import org.polyfrost.compose.composables.PolyBox +import org.polyfrost.compose.composables.PolyMcText +import org.polyfrost.compose.composables.PolyModifier +import org.polyfrost.compose.composables.absoluteAt +import org.polyfrost.compose.composables.size +import org.polyfrost.compose.render.PolyColor +import org.polyfrost.oneconfig.api.event.v1.EventManager +import org.polyfrost.oneconfig.api.event.v1.events.TickEvent +import org.polyfrost.oneconfig.api.hud.v1.LocalHud +import org.polyfrost.oneconfig.api.hud.v1.HudManager +import org.polyfrost.oneconfig.internal.ui.components.item.ItemCatalog +import org.polyfrost.oneconfig.internal.ui.components.item.MinecraftItemCatalogService +import org.polyfrost.oneconfig.internal.ui.components.item.polyItemRenderSizePx +import org.polyfrost.oneconfig.internal.ui.components.item.rememberItemIconHandle +import kotlin.math.ceil + +private const val ITEM_SIZE = 16f +private const val BAR_X = 2f +private const val BAR_Y = 13f +private const val BAR_WIDTH = 13f + +private val BAR_BACKGROUND = PolyColor(0xFF000000.toInt()) +private val COOLDOWN_OVERLAY = PolyColor(0x7FFFFFFF) + +private val clientDispatcher by lazy { Minecraft.getInstance().asCoroutineDispatcher() } + +private val clientTicks = MutableStateFlow(0).also { ticks -> + EventManager.register(TickEvent.End::class.java, Runnable { ticks.value++ }) +} + +private suspend fun awaitClientTick() { + val seen = clientTicks.value + clientTicks.first { it != seen } +} + +/** Draws the item identified by its registry ID, or a placeholder while it is unavailable. */ +@Composable +fun PolyItemIcon( + id: String, + size: Float = 16f, + modifier: PolyModifier = PolyModifier, + placeholderColor: PolyColor = PolyColor(0xFFAAAAAA.toInt()), +) { + val itemIcon = rememberItemIconHandle(id) + val hud = LocalHud.current + SideEffect { + itemIcon?.setRenderSizePx(polyItemRenderSizePx(size, hud)) + } + PolyCanvas(modifier = modifier.size(size, size)) { x, y, w, h -> + if (itemIcon == null) { + rectStroke(x, y, w, h, placeholderColor, strokeWidth = 1f, radius = 1f) + line(x, y, x + w, y + h, placeholderColor, strokeWidth = 1f) + } else { + itemIcon.setRenderSizePx(polyItemRenderSizePx(w, hud)) + itemIcon.draw(canvas, Rect.makeLTRB(x, y, x + w, y + h)) + } + } +} + +/** Draws the default icon for [item]. */ +@Composable +fun PolyItemIcon(item: Item, size: Float = 16f, modifier: PolyModifier = PolyModifier) { + val id = remember(item) { BuiltInRegistries.ITEM.getKey(item).toString() } + PolyItemIcon(id, size, modifier) +} + +/** + * Draws a live stack including its optional count, durability, and cooldown decorations. + * + * @param countOverride text shown instead of the stack count, or `null` to show the normal vanilla count + */ +@Composable +fun PolyItemIcon( + stack: ItemStack, + size: Float = 16f, + modifier: PolyModifier = PolyModifier, + decorations: Boolean = true, + countOverride: String? = null, +) { + val hud = LocalHud.current + val visible = hud == null || hud.isVisible.value + val icon = rememberItemIconHandle(Unit) { forHud -> + (ItemCatalog.platformService() as? MinecraftItemCatalogService)?.openStack(stack, forHud) + } + SideEffect { + icon?.update(stack) + icon?.setRenderSizePx(polyItemRenderSizePx(size, hud)) + } + + PolyBox(modifier = modifier.size(size, size)) { + PolyCanvas(modifier = PolyModifier.size(size, size)) { x, y, w, h -> + if (icon == null) return@PolyCanvas + icon.setRenderSizePx(polyItemRenderSizePx(w, hud)) + icon.draw(canvas, Rect.makeLTRB(x, y, x + w, y + h)) + } + if (!visible || !decorations || stack.isEmpty) return@PolyBox + + val scale = size / ITEM_SIZE + ItemDurability(stack, scale) + ItemCooldown(stack, scale) + ItemCount(stack, countOverride, scale) + } +} + +@Composable +private fun ItemDurability(stack: ItemStack, scale: Float) { + if (!stack.isBarVisible) return + val filled = stack.barWidth.toFloat() + val color = PolyColor(0xFF000000.toInt() or (stack.barColor and 0xFFFFFF)) + + val size = ITEM_SIZE * scale + PolyCanvas(PolyModifier.size(size, size)) { x, y, _, _ -> + rect(x + BAR_X * scale, y + BAR_Y * scale, BAR_WIDTH * scale, 2f * scale, BAR_BACKGROUND) + rect(x + BAR_X * scale, y + BAR_Y * scale, filled * scale, scale, color) + } +} + +@Composable +private fun ItemCooldown(stack: ItemStack, scale: Float) { + val size = ITEM_SIZE * scale + val forHud = LocalHud.current != null + val height = remember { intArrayOf(cooldownHeight(stack)) } + + LaunchedEffect(forHud, stack) { + withContext(clientDispatcher) { + while (true) { + val updated = cooldownHeight(stack) + if (height[0] != updated) { + height[0] = updated + // Previews render without read observation, so they redraw on their shared revision. + if (forHud) HudManager.invalidate() else HudManager.previewRevision.intValue++ + } + if (updated > 0) withFrameNanos { } else awaitClientTick() + } + } + } + + PolyCanvas(PolyModifier.size(size, size)) { x, y, _, _ -> + val overlayHeight = height[0] + if (overlayHeight > 0) { + rect(x, y + (ITEM_SIZE - overlayHeight) * scale, size, overlayHeight * scale, COOLDOWN_OVERLAY) + } + } +} + +private fun cooldownHeight(stack: ItemStack): Int { + val mc = Minecraft.getInstance() + val player = mc.player ?: return 0 + //? if < 1.21.4 { + /*val cooldown = player.cooldowns.getCooldownPercent( + stack.item, + mc.timer.getGameTimeDeltaPartialTick(true), + ) + *///?} else { + val cooldown = player.cooldowns.getCooldownPercent( + stack, + mc.deltaTracker.getGameTimeDeltaPartialTick(true), + ) + //?} + return ceil(ITEM_SIZE * cooldown).toInt().coerceIn(0, ITEM_SIZE.toInt()) +} + +@Composable +private fun ItemCount(stack: ItemStack, countOverride: String?, scale: Float) { + val text = countOverride ?: stack.count.takeIf { it > 1 }?.toString() ?: return + val width = Minecraft.getInstance().font.width(text) + PolyMcText( + text = text, + color = PolyColor.WHITE, + shadow = true, + scale = scale, + modifier = PolyModifier.absoluteAt((17f - width) * scale, 9f * scale), + ) +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/RenderTargetFbo.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/RenderTargetFbo.kt index d82250b52..b1d418bcd 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/RenderTargetFbo.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/RenderTargetFbo.kt @@ -60,4 +60,8 @@ object RenderTargetFbo { //? } else { /*fun getColorTexId(frameBuffer: RenderTarget): Int = frameBuffer.colorTextureId *///? } + + // Creating, clearing, and destroying a render target leaves framebuffer 0 bound, and 1.21.1 GUI draws do not rebind. + //? if < 1.21.5 + //fun restoreMainTarget() = net.minecraft.client.Minecraft.getInstance().mainRenderTarget?.bindWrite(true) } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/SkiaOffscreenTarget.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/SkiaOffscreenTarget.kt index 41582c017..a998e6438 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/SkiaOffscreenTarget.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/SkiaOffscreenTarget.kt @@ -48,8 +48,10 @@ class SkiaOffscreenTarget { /*val rt = TextureTarget(w, h, true, Minecraft.ON_OSX) *///?} target = rt - //? if < 1.21.5 - //rt.setClearColor(0f, 0f, 0f, 0f) + //? if < 1.21.5 { + /*rt.setClearColor(0f, 0f, 0f, 0f) + RenderTargetFbo.restoreMainTarget() + *///?} if (!SkiaCtx.isVulkanMode && RenderTargetFbo.getFboId(rt) <= 0) { destroy() return false @@ -57,9 +59,11 @@ class SkiaOffscreenTarget { val (b, colorFmt) = svc.makeOffscreenBRT(rt, w, h) brt = b val origin = if (SkiaCtx.isDeferredComposeBackend) SurfaceOrigin.TOP_LEFT else SurfaceOrigin.BOTTOM_LEFT - surface = Surface.makeFromBackendRenderTarget( - SkiaCtx.directContext, b, origin, colorFmt, ColorSpace.sRGB, null, - ) + surface = SkiaCtx.withIsolatedGl { + Surface.makeFromBackendRenderTarget( + SkiaCtx.directContext, b, origin, colorFmt, ColorSpace.sRGB, null, + ) + } if (surface == null) { destroy() return false @@ -92,15 +96,24 @@ class SkiaOffscreenTarget { *///?} else { /*rt.clear(Minecraft.ON_OSX) *///?} + //? if < 1.21.5 + //RenderTargetFbo.restoreMainTarget() } fun destroy() { surface?.close(); surface = null brt?.close(); brt = null target?.destroyBuffers(); target = null + //? if < 1.21.5 + //RenderTargetFbo.restoreMainTarget() lastW = -1; lastH = -1 } + fun dispose() { + destroy() + live.remove(this) + } + companion object { private val LOG = LoggerFactory.getLogger("OneConfig/SkiaOffscreenTarget") diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/MinecraftItemCatalogService.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/MinecraftItemCatalogService.kt index aa80587d9..e6339e870 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/MinecraftItemCatalogService.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/MinecraftItemCatalogService.kt @@ -1,54 +1,258 @@ package org.polyfrost.oneconfig.internal.ui.components.item +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.snapshots.Snapshot +import com.mojang.blaze3d.pipeline.RenderTarget +import com.mojang.blaze3d.systems.RenderSystem +import net.minecraft.client.Minecraft //? if >= 26.2 import com.mojang.renderpearl.api.GpuFormat -import com.mojang.blaze3d.pipeline.TextureTarget -import com.mojang.blaze3d.systems.RenderSystem +//? if >= 1.21.8 && < 26.2 +//import org.polyfrost.oneconfig.internal.mixin.render.GameRendererAccessor +//? if < 1.21.8 { +/*import com.mojang.blaze3d.platform.Lighting +import net.minecraft.CrashReport +import net.minecraft.ReportedException +import net.minecraft.client.renderer.texture.OverlayTexture +import org.joml.Matrix4f +import org.lwjgl.opengl.GL11 +*///?} +//? if = 1.21.5 { +/*import com.mojang.renderpearl.api.textures.GpuTexture +import com.mojang.renderpearl.api.textures.TextureFormat +*///?} //? if >= 1.21.4 && < 1.21.8 //import com.mojang.blaze3d.ProjectionType -//? if >= 1.21.8 { -import com.mojang.renderpearl.api.buffers.GpuBuffer -//? } else if >= 1.21.5 { -/*import com.mojang.renderpearl.api.buffers.BufferType -import com.mojang.renderpearl.api.buffers.BufferUsage -*///? } -//? if < 1.21.5 -//import com.mojang.blaze3d.platform.NativeImage -import net.minecraft.client.Minecraft -import net.minecraft.client.gui.GuiGraphicsExtractor -//? if >= 1.21.8 -import net.minecraft.client.gui.render.GuiRenderer //? if >= 26.1 { import net.minecraft.client.renderer.state.gui.GuiRenderState -import org.polyfrost.oneconfig.internal.mixin.render.GameRendererAccessor -//? } else if >= 1.21.8 { +import net.minecraft.client.renderer.state.gui.GuiItemRenderState +import net.minecraft.util.Util +//?} elif >= 1.21.8 { /*import net.minecraft.client.gui.render.state.GuiRenderState -import org.polyfrost.oneconfig.internal.mixin.render.GameRendererAccessor -*///? } +import net.minecraft.client.gui.render.state.GuiItemRenderState +*///?} elif >= 1.21.4 { +/*import net.minecraft.client.renderer.item.ItemStackRenderState +*///?} else { +/*import net.minecraft.resources.ResourceLocation +import net.minecraft.client.resources.model.ModelResourceLocation +import net.minecraft.client.resources.model.BakedModel +import net.minecraft.client.renderer.ItemBlockRenderTypes +import net.minecraft.client.renderer.block.model.BakedQuad +*///?} +//? if < 1.21.5 +//import com.mojang.blaze3d.platform.GlStateManager +//? if >= 1.21.8 { +import net.minecraft.client.gui.render.GuiRenderer +import net.minecraft.client.gui.GuiGraphicsExtractor +import net.minecraft.client.renderer.item.TrackingItemStackRenderState +import net.minecraft.client.gui.navigation.ScreenRectangle +import org.joml.Matrix3x2f +//?} else { +/*import net.minecraft.client.gui.GuiGraphics +import net.minecraft.core.Direction +import net.minecraft.util.RandomSource +*///?} import net.minecraft.core.registries.BuiltInRegistries import net.minecraft.network.chat.Component import net.minecraft.world.item.Item +import net.minecraft.world.item.ItemDisplayContext import net.minecraft.world.item.ItemStack import net.minecraft.world.item.Items +import org.jetbrains.skia.Canvas +import org.jetbrains.skia.ContentChangeMode +import org.jetbrains.skia.Image +import org.jetbrains.skia.Paint +import org.jetbrains.skia.Rect +import org.jetbrains.skia.SamplingMode import org.polyfrost.oneconfig.api.event.v1.EventManager import org.polyfrost.oneconfig.api.event.v1.events.ResourceFinishedLoading -import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.event.v1.events.ResizeEvent +import org.polyfrost.oneconfig.api.event.v1.events.ServerJoinEvent +import org.polyfrost.oneconfig.api.event.v1.events.TickEvent +import org.polyfrost.oneconfig.api.hud.v1.HudManager +import org.polyfrost.oneconfig.internal.ui.SkiaOffscreenTarget +import org.polyfrost.oneconfig.internal.ui.compose.SkiaCtx import org.polyfrost.oneconfig.internal.ui.hud.GuiTargetRedirect import org.slf4j.LoggerFactory -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.Collections +import java.util.ArrayDeque import kotlin.math.ceil -import kotlin.math.floor -import kotlin.math.min import kotlin.math.roundToInt -//? if < 1.21.8 -//import org.joml.Matrix4f +import kotlin.math.sqrt class MinecraftItemCatalogService : ItemCatalogService { + internal interface ItemStackIconHandle : ItemIconHandle { + /** Updates the stack rendered by this handle while preserving its atlas slot. */ + fun update(stack: ItemStack) + } + + /** Reusable storage for comparing model identities without allocating a new buffer each frame. */ + private class IdentityBuffer { + //? if >= 1.21.8 { + val values = ArrayList() + fun clear() = values.clear() + fun sameAs(other: IdentityBuffer): Boolean = values == other.values + //?} else { + /*private var kinds = ByteArray(16) + private var refs = arrayOfNulls(16) + private var ints = IntArray(16) + private var size = 0 + + fun clear() { + for (index in 0 until size) refs[index] = null + size = 0 + } + + fun ref(value: Any?) { + ensureCapacity() + kinds[size] = 0 + refs[size] = value + size++ + } + + fun int(value: Int) { + ensureCapacity() + kinds[size] = 1 + ints[size] = value + size++ + } + + fun sameAs(other: IdentityBuffer): Boolean { + if (size != other.size) return false + for (index in 0 until size) { + if (kinds[index] != other.kinds[index]) return false + if (kinds[index].toInt() == 0) { + if (refs[index] !== other.refs[index]) return false + } else if (ints[index] != other.ints[index]) return false + } + return true + } + + private fun ensureCapacity() { + if (size < kinds.size) return + val capacity = size * 2 + kinds = kinds.copyOf(capacity) + refs = refs.copyOf(capacity) + ints = ints.copyOf(capacity) + } + *///?} + } + + //? if >= 1.21.8 { + private class ReusableTrackingState : TrackingItemStackRenderState() { + private var identity: Any = emptyList() + private var collector = ArrayList() + + fun useIdentity(buffer: ArrayList) { + collector = buffer + identity = buffer + } + + fun freezeIdentity(identity: List) { + this.identity = identity + } + + override fun clear() { + super.clear() + collector.clear() + } + + override fun appendModelIdentityElement(element: Any) { + collector.add(element) + } + + override fun getModelIdentity(): Any = identity + } + //?} + private data class RegistryEntry(val item: Item, val id: String) - private data class Placement(val id: String, val x: Int, val y: Int) - private data class RenderBatch(val ids: List, val generation: Long) + /** One handle owns its live stack, resolved state and atlas slot. */ + private inner class IconEntry(var stack: ItemStack, val forHud: Boolean) : ItemStackIconHandle { + val revision = mutableIntStateOf(0) + var requestedSizePx = 0 + private set + private var closed = false + var slot = 0 + var x = 0 + var y = 0 + var atlasSource: Rect = Rect.makeWH(0f, 0f) + var atlas: AtlasState? = null + var animated = false + var special = false + var specialRefreshAtNs = 0L + var failed = false + var identityInitialized = false + var committedIdentity = IdentityBuffer() + var scratchIdentity = IdentityBuffer() + //? if >= 1.21.8 { + var stableIdentity: List = emptyList() + val resolutionState = ReusableTrackingState() + //?} elif >= 1.21.4 { + /*val resolutionState = ItemStackRenderState() + *///?} else { + /*var resolvedGuiModel: BakedModel? = null + var resolvedUsesBlockLight = true + *///?} + //? if < 1.21.5 + //val resolutionRandom = RandomSource.create(42L) + + override fun setRenderSizePx(size: Int) { + synchronized(requestLock) { + val requested = size.coerceAtLeast(1) + if (closed || requestedSizePx == requested) return + requestedSizePx = requested + activeEntries.add(this) + topologyDirty = true + } + } + + override fun update(stack: ItemStack) { + synchronized(requestLock) { + if (!closed) this.stack = stack + } + } + + override fun draw(canvas: Canvas, bounds: Rect, alpha: Float): Boolean { + synchronized(requestLock) { + if (closed) return false + // Compose draw nodes re-record when this entry's pixels change. + revision.intValue + } + return this@MinecraftItemCatalogService.draw(this, canvas, bounds, alpha) + } + + override fun close() { + synchronized(requestLock) { + if (closed) return + closed = true + activeEntries.remove(this) + topologyDirty = true + } + } + } + + private data class AtlasLayout(val iconsPerSide: Int, val iconSizePx: Int) { + val atlasSizePx: Int get() = iconsPerSide * iconSizePx + val capacity: Int get() = iconsPerSide * iconsPerSide + } + /** + * Atlas page grouped by icon size and HUD/screen ownership. + * + * If every icon cannot fit at [renderSizePx] within the maximum texture size, smaller cells are used. + */ + private class AtlasState(val renderSizePx: Int, val forHud: Boolean) { + val offscreenTarget = SkiaOffscreenTarget() + val entries = ArrayList() + val changed = ArrayList() + var layout: AtlasLayout? = null + var atlasImage: Image? = null + var readyForSampling = false + var rebuild = true + var animatedTick = -1 + val freeSlots = ArrayDeque() + var nextUnusedSlot = 0 + //? if >= 1.21.8 + var itemGuiResources: ItemGuiResources? = null + } private val entries: List by lazy { BuiltInRegistries.ITEM.mapNotNull { item -> @@ -58,21 +262,67 @@ class MinecraftItemCatalogService : ItemCatalogService { } } private val entriesById: Map by lazy { entries.associateBy(RegistryEntry::id) } - private val iconCache: MutableMap = Collections.synchronizedMap( - object : LinkedHashMap(MAX_CACHED_ICONS, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = - size > MAX_CACHED_ICONS - } - ) + private val atlasPaint = Paint() private val requestLock = Any() - private val waiting = LinkedHashMap Unit>>() + private val atlases = HashMap, AtlasState>() + private val activeEntries = LinkedHashSet() + //? if = 1.21.5 + //private val clearTiles = HashMap() + private var topologyDirty = true + private var iconInitializationStarted = false + @Volatile private var catalogCache: List? = null - private var cacheGeneration = 0L - private var renderScheduled = false + @Volatile + private var renderingFailed = false + private val failedItems = HashSet() + + // Texture animations and glint only change visibly per tick, so animated icons rerender at that rate. + private var clientTick = 0 + + //? if >= 1.21.8 + private data class ItemGuiResources(val state: GuiRenderState, val renderer: GuiRenderer) init { EventManager.register(ResourceFinishedLoading::class.java, Runnable(::clearCaches)) + EventManager.register(ServerJoinEvent::class.java, Runnable { + clearCaches() + ItemCatalog.markIconsAvailable() + }) + EventManager.register(ResizeEvent::class.java, Runnable(::recover)) + EventManager.register(TickEvent.End::class.java, Runnable { clientTick++ }) + } + + private fun clearCaches() { + synchronized(requestLock) { + catalogCache = null + destroyAtlases(queueRendererDisposal = true) + } + recover() + } + + /** Clears rendering failures so the atlas and failed items can retry after a reload or resize. */ + private fun recover() { + renderingFailed = false + synchronized(requestLock) { + failedItems.clear() + activeEntries.forEach { entry -> + if (!entry.failed) return@forEach + entry.failed = false + entry.identityInitialized = false + } + } + HudManager.invalidate() + } + + private fun markFailed(entry: IconEntry, stage: String, throwable: Throwable) { + entry.failed = true + val item = entry.stack.item + val firstFailure = synchronized(requestLock) { failedItems.add(item) } + val id = BuiltInRegistries.ITEM.getKey(item) + if (firstFailure) { + LOG.warn("Item icon for {} failed while {}, it will stay blank until the next server join, resize, or resources reload", id, stage, throwable) + } } override fun items(): List { @@ -84,210 +334,517 @@ class MinecraftItemCatalogService : ItemCatalogService { } } - override fun icon(id: String): ItemIconData? = iconCache[id] + override fun requestIcons() { + if (iconInitializationStarted) return + iconInitializationStarted = true - override fun loadIcon(id: String, onLoaded: (ItemIconData?) -> Unit) { - iconCache[id]?.let { - onLoaded(it) - return + //? if >= 26.1 { + // Item components are not bound before the first world load, so initialize them for title screen rendering. + val componentsBound = { BuiltInRegistries.ITEM.all { BuiltInRegistries.ITEM.wrapAsHolder(it).areComponentsBound() } } + java.util.concurrent.CompletableFuture.supplyAsync( + { + if (componentsBound()) null else { + //~ if < 26.3 'createWorldLookup' -> 'createLookup' + val lookup = net.minecraft.data.registries.VanillaRegistries.createWorldLookup() + BuiltInRegistries.DATA_COMPONENT_INITIALIZERS.build(lookup) + } + }, + Util.backgroundExecutor(), + ).whenComplete { pending, failure -> + Minecraft.getInstance().schedule { + try { + if (failure != null) throw failure + if (!componentsBound()) pending?.forEach { it.apply() } + ItemCatalog.markIconsAvailable() + } catch (throwable: Throwable) { + LOG.warn("Could not enable item icons before joining a world", throwable) + } + } } - if (id !in entriesById) { - onLoaded(null) - return + //?} else + //ItemCatalog.markIconsAvailable() + } + + override fun openIcon(id: String, forHud: Boolean): ItemIconHandle? { + val entry = entriesById[id] ?: return null + return openStack(ItemStack(entry.item), forHud) + } + + internal fun openStack(stack: ItemStack, forHud: Boolean = false): ItemStackIconHandle = + IconEntry(stack, forHud) + + override fun renderIcons(): Boolean = renderIcons(forHud = false) + + override fun renderHudIcons() { + if (renderIcons(forHud = true)) HudManager.invalidate() + } + + /** Renders the pages owned by one consumer and returns whether any of its pixels changed. */ + private fun renderIcons(forHud: Boolean): Boolean { + if (!ItemCatalog.iconsAvailable || renderingFailed) return false + var changed = false + try { + reconcileTopology() + for (page in atlases.values) { + if (page.forHud == forHud && renderAtlas(page)) changed = true + } + } catch (throwable: Throwable) { + renderingFailed = true + destroyAtlases() + HudManager.invalidate() + LOG.warn("Failed to render item selector icons", throwable) + changed = true } + return changed + } - var cachedIcon: ItemIconData? = null - var shouldSchedule = false - synchronized(requestLock) { - cachedIcon = iconCache[id] - if (cachedIcon == null) { - waiting.getOrPut(id, ::mutableListOf).add(onLoaded) - if (!renderScheduled) { - renderScheduled = true - shouldSchedule = true + private fun draw(entry: IconEntry, canvas: Canvas, bounds: Rect, alpha: Float): Boolean { + val resolved = synchronized(requestLock) { + val state = entry.atlas + if (state == null || !atlasAvailable(state)) { + if (state?.entries?.isNotEmpty() == true) { + resetAtlas(state, state.layout) + invalidateEntries(state.entries) } + null + } else { + val image = state.atlasImage ?: return@synchronized null + entry to image } } - cachedIcon?.let(onLoaded) - if (shouldSchedule) scheduleRender() - } + if (resolved == null) { + return false + } + val (icon, image) = resolved + return runCatching { + val source = icon.atlasSource + if (source.width <= 0f || source.height <= 0f || bounds.width <= 0f || bounds.height <= 0f) { + return false + } - private fun scheduleRender() { - // always queue the batch so the current Compose frame finishes before native GUI rendering starts - Minecraft.getInstance().schedule(::renderPendingBatch) + atlasPaint.alpha = (alpha.coerceIn(0f, 1f) * 255f).roundToInt() + canvas.drawImageRect(image, source, bounds, SamplingMode.DEFAULT, atlasPaint, true) + true + }.getOrElse { + LOG.debug("Failed to draw an item icon from the atlas", it) + false + } } - private fun renderPendingBatch() { - if (Minecraft.getInstance().player == null) { - completeBatch(currentBatch(), emptyMap()) - return + private fun atlasAvailable(state: AtlasState): Boolean = state.offscreenTarget.target != null && state.offscreenTarget.surface != null + + private fun renderAtlas(atlasState: AtlasState): Boolean { + val layout = atlasState.layout ?: return false + if (atlasState.entries.isEmpty()) return false + + val offscreenTarget = atlasState.offscreenTarget + val previousTarget = offscreenTarget.target + if (previousTarget == null || offscreenTarget.surface == null) discardAtlasImage(atlasState) + + val resolved = offscreenTarget.resolveTarget(layout.atlasSizePx, layout.atlasSizePx) + val target = offscreenTarget.target + val surface = offscreenTarget.surface + check(resolved && target != null && surface != null) { + "Failed to create a ${layout.iconSizePx}px item icon atlas" } - val guiWidth = Platform.screen().guiWidth() - val guiHeight = Platform.screen().guiHeight() - val windowWidth = Platform.screen().windowWidth() - val windowHeight = Platform.screen().windowHeight() - val viewportWidth = Platform.screen().viewportWidth() - val viewportHeight = Platform.screen().viewportHeight() - if ( - guiWidth <= 0 || guiHeight <= 0 || - windowWidth <= 0 || windowHeight <= 0 || - viewportWidth <= 0 || viewportHeight <= 0 - ) { - completeBatch(currentBatch(), emptyMap()) - return + + val guiSize = layout.iconsPerSide * CELL_SIZE + val targetChanged = target !== previousTarget + if (targetChanged) { + discardAtlasImage(atlasState) + atlasState.readyForSampling = false } - val scaleX = viewportWidth.toDouble() / guiWidth.toDouble() - val scaleY = viewportHeight.toDouble() / guiHeight.toDouble() - val pixelRatio = maxOf( - viewportWidth.toDouble() / windowWidth.toDouble(), - viewportHeight.toDouble() / windowHeight.toDouble(), - ) - val iconPixelSize = (ICON_SIZE * pixelRatio).roundToInt().coerceIn(ICON_SIZE, MAX_ICON_PIXEL_SIZE) - val columns = min( - MAX_BATCH_COLUMNS, - floor(MAX_TARGET_SIZE / (CELL_SIZE * scaleX)).toInt().coerceAtLeast(1), + val changed = atlasState.changed + changed.clear() + val rebuild = targetChanged || atlasState.rebuild + val now = System.nanoTime() + for (entry in atlasState.entries) { + if (entry.failed) continue + val resolvedChanged = try { + resolveIconState(entry) + } catch (throwable: Throwable) { + markFailed(entry, "resolving its model", throwable) + changed.add(entry) + continue + } + val specialRefresh = entry.special && now - entry.specialRefreshAtNs >= 0 + if (resolvedChanged || (entry.animated && atlasState.animatedTick != clientTick) || rebuild || specialRefresh) { + changed.add(entry) + if (entry.special) entry.specialRefreshAtNs = now + SPECIAL_REFRESH_NS + } + } + atlasState.animatedTick = clientTick + + if (changed.isEmpty()) return false + + val backend = SkiaCtx.vulkanService + discardAtlasImage(atlasState) + surface.notifyContentWillChange(if (rebuild) ContentChangeMode.DISCARD else ContentChangeMode.RETAIN) + if (atlasState.readyForSampling) backend?.transitionOffscreenForRendering(target) + if (rebuild) clearTarget(target) else clearCells(target, changed, layout) + + renderItemsIsolated(atlasState, target, layout, changed, guiSize) + backend?.midFrameFlush() + backend?.transitionOffscreenForSampling(target) + backend?.midFrameFlush() + atlasState.atlasImage = surface.makeImageSnapshot() + atlasState.readyForSampling = true + atlasState.rebuild = false + // Rebuilds may move every atlas cell. + invalidateEntries(if (rebuild) atlasState.entries else changed) + return true + } + + private fun invalidateEntries(entries: Collection) { + Snapshot.withMutableSnapshot { + for (entry in entries) entry.revision.intValue++ + if (entries.any { !it.forHud }) HudManager.previewRevision.intValue++ + } + } + + private fun resolveIconState(entry: IconEntry): Boolean { + val stack = entry.stack + val client = Minecraft.getInstance() + val scratch = entry.scratchIdentity + scratch.clear() + //? if >= 1.21.8 { + val state = entry.resolutionState + state.useIdentity(scratch.values) + client.itemModelResolver.updateForTopItem( + state, + stack, + ItemDisplayContext.GUI, + client.level, + client.player, + 0, ) - val rows = min( - MAX_BATCH_ROWS, - floor(MAX_TARGET_SIZE / (CELL_SIZE * scaleY)).toInt().coerceAtLeast(1), + val animated = state.isAnimated + val special = false + //?} else if >= 1.21.4 { + /*val state = entry.resolutionState + client.itemModelResolver.updateForTopItem( + state, + stack, + ItemDisplayContext.GUI, + //? if = 1.21.4 + //false, + client.level, + client.player, + 0, ) - val batch = currentBatch(columns * rows) - if (batch.ids.isEmpty()) { - markRenderFinished() - return + var animated = false + var special = false + scratch.int(state.activeLayerCount) + for (index in 0 until state.activeLayerCount) { + val layer = state.layers[index] + //? if >= 1.21.5 { + val quads = layer.prepareQuadList() + scratch.int(quads.size) + var quadIndex = 0 + while (quadIndex < quads.size) { + val quad = quads[quadIndex++] + scratch.ref(quad) + if (quad.sprite.contents().animatedTexture != null) animated = true + } + //?} else { + /*val random = entry.resolutionRandom + random.setSeed(42L) + var quadCount = 0 + val directions = Direction.entries + var directionIndex = 0 + while (directionIndex < directions.size) { + val quads = layer.model?.getQuads(null, directions[directionIndex++], random).orEmpty() + var quadIndex = 0 + while (quadIndex < quads.size) { + val quad = quads[quadIndex++] + scratch.ref(quad) + quadCount++ + if (quad.sprite.contents().animatedTexture != null) animated = true + } + random.setSeed(42L) + } + val quads = layer.model?.getQuads(null, null, random).orEmpty() + var quadIndex = 0 + while (quadIndex < quads.size) { + val quad = quads[quadIndex++] + scratch.ref(quad) + quadCount++ + if (quad.sprite.contents().animatedTexture != null) animated = true + } + scratch.int(quadCount) + *///?} + if (layer.foilType != ItemStackRenderState.FoilType.NONE) animated = true + if (layer.specialRenderer != null) special = true + scratch.ref(layer.specialRenderer) + scratch.int(layer.tintLayers.size) + layer.tintLayers.forEach { tint -> scratch.int(tint) } + scratch.ref(layer.renderType) + //? if >= 1.21.5 { + scratch.ref(layer.transform) + scratch.int(if (layer.usesBlockLight) 1 else 0) + //?} else { + /*scratch.ref(layer.model?.transforms?.getTransform(ItemDisplayContext.GUI)) + scratch.int(if (layer.model?.usesBlockLight() == true) 1 else 0) + *///?} } - - val usedColumns = min(columns, batch.ids.size) - val usedRows = (batch.ids.size + usedColumns - 1) / usedColumns - val renderGuiWidth = usedColumns * CELL_SIZE - val renderGuiHeight = usedRows * CELL_SIZE - val targetWidth = ceil(renderGuiWidth * scaleX).toInt().coerceIn(1, MAX_TARGET_SIZE) - val targetHeight = ceil(renderGuiHeight * scaleY).toInt().coerceIn(1, MAX_TARGET_SIZE) - val placements = batch.ids.mapIndexed { index, id -> - Placement(id, index % usedColumns * CELL_SIZE, index / usedColumns * CELL_SIZE) + if (special) scratch.int(stack.components.hashCode()) + *///?} else { + /*val renderer = client.itemRenderer + val resolvedModel = renderer.getModel(stack, client.level, client.player, 0) + // Vanilla replaces the held trident and spyglass models in GUIs + val guiModel = when { + stack.`is`(Items.TRIDENT) -> renderer.itemModelShaper.modelManager.getModel( + ModelResourceLocation.inventory(ResourceLocation.withDefaultNamespace("trident")), + ) + stack.`is`(Items.SPYGLASS) -> renderer.itemModelShaper.modelManager.getModel( + ModelResourceLocation.inventory(ResourceLocation.withDefaultNamespace("spyglass")), + ) + else -> resolvedModel } - val target = try { - createTarget(targetWidth, targetHeight) - } catch (throwable: Throwable) { - LOG.warn("Failed to create the item icon render target", throwable) - completeBatch(batch, emptyMap()) - return + entry.resolvedGuiModel = guiModel + entry.resolvedUsesBlockLight = resolvedModel.usesBlockLight() + // These can change each frame without changing model identity. + var animated = stack.hasFoil() + val special = guiModel.isCustomRenderer + if (special) scratch.int(stack.components.hashCode()) + scratch.ref(guiModel) + val random = entry.resolutionRandom + random.setSeed(42L) + var quadCount = 0 + val directions = Direction.entries + var directionIndex = 0 + while (directionIndex < directions.size) { + val quads = guiModel.getQuads(null, directions[directionIndex++], random) + var quadIndex = 0 + while (quadIndex < quads.size) { + val quad = quads[quadIndex++] + if (quad.sprite.contents().animatedTexture != null) animated = true + scratch.ref(quad) + scratch.int(if (quad.isTinted) 1 else 0) + if (quad.isTinted) scratch.int(renderer.itemColors.getColor(stack, quad.tintIndex)) + quadCount++ + } + random.setSeed(42L) } - - var renderResource: AutoCloseable? = null - try { - renderResource = renderItems(target, placements, renderGuiWidth, renderGuiHeight) - readTarget(target, placements, renderGuiWidth, renderGuiHeight, iconPixelSize, batch, renderResource) - } catch (throwable: Throwable) { - LOG.warn("Failed to render item selector icons", throwable) - closeRenderResource(renderResource) - target.destroyBuffers() - completeBatch(batch, emptyMap()) + val quads = guiModel.getQuads(null, null, random) + var quadIndex = 0 + while (quadIndex < quads.size) { + val quad = quads[quadIndex++] + if (quad.sprite.contents().animatedTexture != null) animated = true + scratch.ref(quad) + scratch.int(if (quad.isTinted) 1 else 0) + if (quad.isTinted) scratch.int(renderer.itemColors.getColor(stack, quad.tintIndex)) + quadCount++ + } + scratch.int(quadCount) + scratch.ref(guiModel.transforms.getTransform(ItemDisplayContext.GUI)) + scratch.ref(ItemBlockRenderTypes.getRenderType(stack, true)) + scratch.int(if (resolvedModel.usesBlockLight()) 1 else 0) + *///?} + val changed = !entry.identityInitialized || !scratch.sameAs(entry.committedIdentity) + if (changed) { + val old = entry.committedIdentity + entry.committedIdentity = scratch + entry.scratchIdentity = old + //? if >= 1.21.8 + entry.stableIdentity = entry.committedIdentity.values.toList() } + // GuiRenderState uses the model identity as a hash key, so keep an immutable copy until the model changes. + //? if >= 1.21.8 + entry.resolutionState.freezeIdentity(entry.stableIdentity) + entry.identityInitialized = true + val animationChanged = entry.animated != animated + entry.animated = animated + entry.special = special + return changed || animationChanged } - private fun createTarget(width: Int, height: Int): TextureTarget { - //? if >= 26.3 { - return TextureTarget( - null, width, height, - GpuFormat.RGBA8_UNORM, - GpuFormat.D32_FLOAT, - ) - //? } else if >= 26.2 { - /*return TextureTarget(null, width, height, true, GpuFormat.RGBA8_UNORM) - *///? } else if >= 1.21.5 { - /*return TextureTarget(null, width, height, true) - *///? } else if >= 1.21.4 { - /*return TextureTarget(width, height, true).also { it.setClearColor(0f, 0f, 0f, 0f) } - *///? } else { - /*return TextureTarget(width, height, true, Minecraft.ON_OSX).also { it.setClearColor(0f, 0f, 0f, 0f) } - *///? } + private fun renderItemsIsolated( + atlasState: AtlasState, + target: RenderTarget, + layout: AtlasLayout, + changed: List, + guiSize: Int + ) { + val renderable = if (changed.any { it.failed }) changed.filter { !it.failed } else changed + if (renderable.isEmpty()) { + //? if < 1.21.5 + //Minecraft.getInstance().mainRenderTarget.bindWrite(true) + return + } + // Retry individually so one broken item does not prevent the rest of the batch from rendering. + if (runCatching { renderItems(atlasState, target, renderable, guiSize, guiSize) }.isSuccess) return + clearCells(target, renderable, layout) + for (entry in renderable) { + val single = listOf(entry) + try { + renderItems(atlasState, target, single, guiSize, guiSize) + } catch (throwable: Throwable) { + markFailed(entry, "rendering", throwable) + clearCells(target, single, layout) + } + } } private fun renderItems( - target: TextureTarget, - placements: List, + page: AtlasState, + target: RenderTarget, + entries: List, guiWidth: Int, guiHeight: Int, - ): AutoCloseable? { - clearTarget(target) - //? if >= 1.21.8 { - val state = GuiRenderState() - //? if >= 1.21.11 { - val graphics = GuiGraphicsExtractor(Minecraft.getInstance(), state, guiWidth, guiHeight) - //? } else - //val graphics = GuiGraphicsExtractor(Minecraft.getInstance(), state) - placements.forEach { placement -> - val item = entriesById[placement.id]?.item ?: return@forEach - //? if >= 26.1 { - graphics.fakeItem(ItemStack(item), placement.x + ITEM_PADDING, placement.y + ITEM_PADDING) - //? } else - //graphics.renderFakeItem(ItemStack(item), placement.x + ITEM_PADDING, placement.y + ITEM_PADDING) - } - - val client = Minecraft.getInstance() - val guiRenderer = createItemGuiRenderer(client, state) + ) { + val mc = Minecraft.getInstance() val previousTarget = GuiTargetRedirect.target - GuiTargetRedirect.target = target + val previousScissor = GuiTargetRedirect.scissorTransform + val renderSizePx = checkNotNull(page.layout).iconSizePx + + //? if >= 1.21.8 { try { - //? if >= 26.2 { - guiRenderer.render() - //? } else { - /*val fog = (client.gameRenderer as GameRendererAccessor).`oneconfig$getFogRenderer`() - .getBuffer(net.minecraft.client.renderer.fog.FogRenderer.FogMode.NONE) - guiRenderer.render(fog) - *///? } - } catch (throwable: Throwable) { - guiRenderer.close() - throw throwable - } finally { - GuiTargetRedirect.target = previousTarget - } - return guiRenderer - //? } else if >= 1.21.5 { - /*val client = Minecraft.getInstance() - val graphics = GuiGraphicsExtractor(client, client.renderBuffers().bufferSource()) - val previousTarget = GuiTargetRedirect.target - withGuiProjection(guiWidth, guiHeight) { - GuiTargetRedirect.target = target + val resources = page.itemGuiResources ?: GuiRenderState().let { state -> + ItemGuiResources(state, createItemGuiRenderer(mc, state)).also { page.itemGuiResources = it } + } + //? if >= 1.21.11 { + val graphics = GuiGraphicsExtractor(mc, resources.state, guiWidth, guiHeight) + //?} else + //val graphics = GuiGraphicsExtractor(mc, resources.state) + val pose = graphics.pose() + pose.pushMatrix() + // Scale vanilla's 16×16 GUI slots to the atlas cell size. + pose.scale( + target.width.toFloat() / guiWidth.toFloat(), + target.height.toFloat() / guiHeight.toFloat(), + ) try { - placements.forEach { placement -> - val item = entriesById[placement.id]?.item ?: return@forEach - graphics.renderFakeItem(ItemStack(item), placement.x + ITEM_PADDING, placement.y + ITEM_PADDING) + entries.forEach { entry -> + val itemPose = Matrix3x2f(pose) + val scissor = ScreenRectangle( + entry.x / CELL_SIZE * renderSizePx, entry.y / CELL_SIZE * renderSizePx, + renderSizePx, renderSizePx, + ) + // Submit the resolved state directly to avoid evaluating dynamic model properties twice. + //? if >= 26.1 { + resources.state.addItem(GuiItemRenderState(itemPose, entry.resolutionState, entry.x, entry.y, scissor)) + //?} else { + /*resources.state.submitItem( + GuiItemRenderState( + entry.stack.item.name.toString(), itemPose, entry.resolutionState, + entry.x, entry.y, scissor, + ), + ) + *///?} } - graphics.flush() } finally { + pose.popMatrix() + } + + val previousRenderSize = GuiTargetRedirect.itemRenderSizePx + GuiTargetRedirect.target = target + GuiTargetRedirect.scissorTransform = GuiTargetRedirect.ScissorTransform { left, top, right, bottom -> + intArrayOf(left, target.height - bottom, right - left, bottom - top) + } + GuiTargetRedirect.itemRenderSizePx = renderSizePx + try { + //? if >= 26.2 { + resources.renderer.render() + //?} else { + /*val fog = (mc.gameRenderer as GameRendererAccessor).`oneconfig$getFogRenderer`() + .getBuffer(net.minecraft.client.renderer.fog.FogRenderer.FogMode.NONE) + resources.renderer.render(fog) + *///?} + } finally { + GuiTargetRedirect.scissorTransform = previousScissor + GuiTargetRedirect.itemRenderSizePx = previousRenderSize GuiTargetRedirect.target = previousTarget } + //? if >= 26.1 { + resources.renderer.endFrame() + //?} else + //resources.renderer.incrementFrameNumber() + } catch (throwable: Throwable) { + discardItemGuiResources(page) + throw throwable } - return null - *///? } else { - /*val client = Minecraft.getInstance() - val graphics = GuiGraphicsExtractor(client, client.renderBuffers().bufferSource()) - val previousTarget = GuiTargetRedirect.target + //?} else { + /*val bufferSource = mc.renderBuffers().bufferSource() + val graphics = GuiGraphics(mc, bufferSource) withGuiProjection(guiWidth, guiHeight) { GuiTargetRedirect.target = target - target.bindWrite(true) + GuiTargetRedirect.scissorTransform = atlasScissorTransform(target, renderSizePx, CELL_SIZE.toFloat()) + + //? if < 1.21.5 { + /*target.bindWrite(true) + RenderSystem.disableScissor() + *///?} + try { - placements.forEach { placement -> - val item = entriesById[placement.id]?.item ?: return@forEach - graphics.renderFakeItem(ItemStack(item), placement.x + ITEM_PADDING, placement.y + ITEM_PADDING) + entries.forEach { entry -> + graphics.enableScissor(entry.x, entry.y, entry.x + CELL_SIZE, entry.y + CELL_SIZE) + try { + val pose = graphics.pose() + pose.pushPose() + pose.translate(entry.x + 8f, entry.y + 8f, 150f) + try { + pose.scale(16f, -16f, 16f) + //? if >= 1.21.4 { + val flat = !entry.resolutionState.usesBlockLight() + if (flat) { + graphics.flush() + Lighting.setupForFlatItems() + } + entry.resolutionState.render(pose, bufferSource, 15728880, OverlayTexture.NO_OVERLAY) + //?} else { + /*val model = checkNotNull(entry.resolvedGuiModel) + val flat = !entry.resolvedUsesBlockLight + if (flat) Lighting.setupForFlatItems() + mc.itemRenderer.render( + entry.stack, ItemDisplayContext.GUI, false, pose, bufferSource, + 15728880, OverlayTexture.NO_OVERLAY, model, + ) + *///?} + graphics.flush() + if (flat) Lighting.setupFor3DItems() + } catch (throwable: Throwable) { + val report = CrashReport.forThrowable(throwable, "Rendering item") + val category = report.addCategory("Item being rendered") + category.setDetail("Item Type") { entry.stack.item.toString() } + category.setDetail("Item Components") { entry.stack.components.toString() } + category.setDetail("Item Foil") { entry.stack.hasFoil().toString() } + throw ReportedException(report) + } finally { + pose.popPose() + } + } finally { graphics.disableScissor() } } graphics.flush() } finally { + //? if < 1.21.5 { + /*Lighting.setupFor3DItems() + RenderSystem.disableScissor() + *///?} + GuiTargetRedirect.scissorTransform = previousScissor GuiTargetRedirect.target = previousTarget - (previousTarget ?: client.mainRenderTarget).bindWrite(true) + //? if < 1.21.5 + //(previousTarget ?: mc.mainRenderTarget).bindWrite(true) } } - return null - *///? } + *///?} } + //? if < 1.21.8 { + /** Scales the scissor rectangle to the item texture and flips its Y axis. */ + private fun atlasScissorTransform(target: RenderTarget, renderSizePx: Int, scissorSlotSize: Float) = + GuiTargetRedirect.ScissorTransform { left, top, right, bottom -> + val leftPx = (left / scissorSlotSize).roundToInt() * renderSizePx + val topPx = (top / scissorSlotSize).roundToInt() * renderSizePx + val rightPx = (right / scissorSlotSize).roundToInt() * renderSizePx + val bottomPx = (bottom / scissorSlotSize).roundToInt() * renderSizePx + intArrayOf(leftPx, target.height - bottomPx, rightPx - leftPx, bottomPx - topPx) + } + //?} + //? if >= 1.21.8 { private fun createItemGuiRenderer(client: Minecraft, state: GuiRenderState): GuiRenderer { - // keep this icon batch separate from the active frame's render state and item atlas - // because reusing the game's renderer would mix two independently-lived frames //? if >= 26.2 { return GuiRenderer(state, client.gameRenderer.featureRenderDispatcher(), emptyList()) //? } else if >= 1.21.10 { @@ -302,6 +859,18 @@ class MinecraftItemCatalogService : ItemCatalogService { /*return GuiRenderer(state, client.renderBuffers().bufferSource(), emptyList()) *///? } } + + private fun discardItemGuiResources(page: AtlasState, queued: Boolean = false) { + val resources = page.itemGuiResources + page.itemGuiResources = null + if (resources != null) { + val close: () -> Unit = { + runCatching { resources.renderer.close() } + .onFailure { LOG.warn("Failed to release item icon render resources", it) } + } + if (queued) Minecraft.getInstance().schedule(Runnable(close)) else close() + } + } //? } //? if < 1.21.8 { @@ -336,313 +905,267 @@ class MinecraftItemCatalogService : ItemCatalogService { } *///? } - private fun clearTarget(target: TextureTarget) { - //? if >= 26.2 { + private fun clearTarget(target: RenderTarget) { + //? if >= 1.21.5 { val encoder = RenderSystem.getDevice().createCommandEncoder() - target.colorTexture?.let { encoder.clearColorTexture(it, org.joml.Vector4f(0f, 0f, 0f, 0f)) } - target.depthTexture?.let { encoder.clearDepthTexture(it, 1.0) } - //? } else if >= 1.21.5 { - /*val encoder = RenderSystem.getDevice().createCommandEncoder() - target.colorTexture?.let { encoder.clearColorTexture(it, 0) } - target.depthTexture?.let { encoder.clearDepthTexture(it, 1.0) } - *///? } else if >= 1.21.4 { + val colorTexture = target.colorTexture ?: return + val depthTexture = target.depthTexture ?: return + //? if >= 26.2 { + val clearColor = org.joml.Vector4f(0f, 0f, 0f, 0f) + val clearDepth = 0.0 + //?} else { + /*val clearColor = 0 + val clearDepth = 1.0 + *///?} + encoder.clearColorAndDepthTextures(colorTexture, clearColor, depthTexture, clearDepth) + //?} else if >= 1.21.4 { /*target.clear() - *///? } else { + *///?} else { /*target.clear(Minecraft.ON_OSX) - *///? } + *///?} } - private fun readTarget( - target: TextureTarget, - placements: List, - guiWidth: Int, - guiHeight: Int, - iconSize: Int, - batch: RenderBatch, - renderResource: AutoCloseable?, - ) { - //? if >= 1.21.5 { - val texture = target.colorTexture ?: error("Item icon render target has no color texture") - val width = target.width - val height = target.height - //? if >= 26.2 { - val pixelSize = texture.format.blockSize() - //? } else - //val pixelSize = texture.format.pixelSize() + private fun clearCells(target: RenderTarget, entries: List, layout: AtlasLayout) { + val guiSize = layout.iconsPerSide * CELL_SIZE + + //? if >= 1.21.8 { + val colorTexture = target.colorTexture ?: return + val depthTexture = target.depthTexture ?: return + val encoder = RenderSystem.getDevice().createCommandEncoder() + //~ if < 26.2 '= 0.0' -> '= 1.0' + val clearDepth = 0.0 + + entries.forEach { entry -> + val left = entry.x * target.width / guiSize + val top = entry.y * target.height / guiSize + val right = (entry.x + CELL_SIZE) * target.width / guiSize + val bottom = (entry.y + CELL_SIZE) * target.height / guiSize + + encoder.clearColorAndDepthTextures( + colorTexture, GuiRenderer.CLEAR_COLOR, depthTexture, clearDepth, + left, target.height - bottom, right - left, bottom - top, + //? if >= 26.3 + 0, + ) + } + //?} elif = 1.21.5 { + /*// 1.21.5 cannot clear a texture subregion directly, so copy a transparent tile into each changed cell. + val colorTexture = target.colorTexture ?: return val device = RenderSystem.getDevice() - //? if >= 1.21.11 { - val buffer = device.createBuffer( - { "OneConfig item icon readback" }, - GpuBuffer.USAGE_MAP_READ or GpuBuffer.USAGE_COPY_DST, - width.toLong() * height.toLong() * pixelSize, - ) - //? } else if >= 1.21.8 { - /*val buffer = device.createBuffer( - { "OneConfig item icon readback" }, - GpuBuffer.USAGE_MAP_READ or GpuBuffer.USAGE_COPY_DST, - width * height * pixelSize, - ) - *///? } else { - /*val buffer = device.createBuffer( - { "OneConfig item icon readback" }, - BufferType.PIXEL_PACK, - BufferUsage.STATIC_READ, - width * height * pixelSize, - ) - *///? } - //? if < 26.2 - //val readEncoder = device.createCommandEncoder() - val onCopied = Runnable { - try { - //? if >= 26.2 { - buffer.map(true, false).use { view -> - completeReadback( - placements, - guiWidth, - guiHeight, - width, - height, - pixelSize, - iconSize, - view.data(), - batch, - ) - } - //? } else if >= 1.21.8 { - /*readEncoder.mapBuffer(buffer, true, false).use { view -> - completeReadback( - placements, - guiWidth, - guiHeight, - width, - height, - pixelSize, - iconSize, - view.data(), - batch, - ) - } - *///? } else { - /*readEncoder.readBuffer(buffer).use { view -> - completeReadback( - placements, - guiWidth, - guiHeight, - width, - height, - pixelSize, - iconSize, - view.data(), - batch, - ) - } - *///? } - } catch (throwable: Throwable) { - LOG.warn("Failed to read item selector icons from the GPU", throwable) - completeBatch(batch, emptyMap()) - } finally { - buffer.close() - closeRenderResource(renderResource) - target.destroyBuffers() - } + val encoder = device.createCommandEncoder() + val clearTile = clearTiles.getOrPut(layout.iconSizePx) { + device.createTexture( + "OneConfig item icon clear tile", + TextureFormat.RGBA8, + layout.iconSizePx, + layout.iconSizePx, + 1, + ).also { encoder.clearColorTexture(it, 0) } } - try { - //? if >= 1.21.11 { - device.createCommandEncoder().copyTextureToBuffer(texture, buffer, 0L, onCopied, 0) - //? } else - //device.createCommandEncoder().copyTextureToBuffer(texture, buffer, 0, onCopied, 0) - } catch (throwable: Throwable) { - buffer.close() - throw throwable + + entries.forEach { entry -> + val left = entry.x * target.width / guiSize + val top = entry.y * target.height / guiSize + val right = (entry.x + CELL_SIZE) * target.width / guiSize + val bottom = (entry.y + CELL_SIZE) * target.height / guiSize + + encoder.copyTextureToTexture( + clearTile, colorTexture, 0, + left, target.height - bottom, 0, 0, + right - left, bottom - top, + ) } - //? } else { - /*val image = NativeImage(target.width, target.height, false) - val previousTexture = RenderSystem.getShaderTexture(0) + target.depthTexture?.let { encoder.clearDepthTexture(it, 1.0) } + *///?} else { + /*target.bindWrite(false) + GlStateManager._clearColor(0f, 0f, 0f, 0f) + GlStateManager._clearDepth(1.0) + GlStateManager._colorMask(true, true, true, true) + GlStateManager._depthMask(true) + GlStateManager._enableScissorTest() + try { - RenderSystem.bindTexture(target.colorTextureId) - image.downloadTexture(0, false) - image.flipY() - val icons = extractIcons( - placements, - guiWidth, - guiHeight, - image.width, - image.height, - iconSize, - ) { x, y -> - //? if >= 1.21.4 { - image.getPixel(x, y) - //? } else - //abgrToArgb(image.getPixelRGBA(x, y)) + entries.forEach { entry -> + val left = entry.x * target.width / guiSize + val top = entry.y * target.height / guiSize + val right = (entry.x + CELL_SIZE) * target.width / guiSize + val bottom = (entry.y + CELL_SIZE) * target.height / guiSize + + val width = right - left + val height = bottom - top + // VulkanMod's glClear ignores the scissor box. + if (SkiaCtx.vulkanService?.clearOffscreenRect(left, top, width, height) != true) { + GlStateManager._scissorBox(left, target.height - bottom, width, height) + //? if >= 1.21.4 { + GlStateManager._clear(GL11.GL_COLOR_BUFFER_BIT or GL11.GL_DEPTH_BUFFER_BIT) + //?} else + //GlStateManager._clear(GL11.GL_COLOR_BUFFER_BIT or GL11.GL_DEPTH_BUFFER_BIT, Minecraft.ON_OSX) + } } - completeBatch(batch, icons) } finally { - RenderSystem.bindTexture(previousTexture) - image.close() - closeRenderResource(renderResource) - target.destroyBuffers() + GlStateManager._disableScissorTest() + Minecraft.getInstance().mainRenderTarget.bindWrite(true) } - *///? } - } - - private fun closeRenderResource(resource: AutoCloseable?) { - runCatching { resource?.close() } - .onFailure { LOG.warn("Failed to release item selector render resources", it) } + *///?} } - private fun completeReadback( - placements: List, - guiWidth: Int, - guiHeight: Int, - width: Int, - height: Int, - pixelSize: Int, - iconSize: Int, - data: ByteBuffer, - batch: RenderBatch, - ) { - val nativeData = data.order(ByteOrder.nativeOrder()) - val icons = extractIcons(placements, guiWidth, guiHeight, width, height, iconSize) { x, y -> - val sourceY = height - y - 1 - val raw = nativeData.getInt((x + sourceY * width) * pixelSize) - abgrToArgb(raw) - } - completeBatch(batch, icons) - } + /** Assigns active icon handles to atlas pages and stable slots. */ + private fun reconcileTopology() { + synchronized(requestLock) { + if (!topologyDirty) return + if (activeEntries.isEmpty()) { + destroyAtlases() + topologyDirty = false + return + } - private fun extractIcons( - placements: List, - guiWidth: Int, - guiHeight: Int, - imageWidth: Int, - imageHeight: Int, - iconSize: Int, - pixel: (Int, Int) -> Int, - ): Map { - val scaleX = imageWidth.toDouble() / guiWidth.toDouble() - val scaleY = imageHeight.toDouble() / guiHeight.toDouble() - return placements.associate { placement -> - val pixels = IntArray(iconSize * iconSize) - for (y in 0 until iconSize) { - val logicalY = placement.y + ITEM_PADDING + (y + 0.5) * ITEM_RENDER_SIZE / iconSize - val sourceY = logicalY * scaleY - 0.5 - for (x in 0 until iconSize) { - val logicalX = placement.x + ITEM_PADDING + (x + 0.5) * ITEM_RENDER_SIZE / iconSize - val sourceX = logicalX * scaleX - 0.5 - pixels[y * iconSize + x] = unpremultiply( - sampleBilinear(sourceX, sourceY, imageWidth, imageHeight, pixel), - ) + val maxTextureSizePx = maxSupportedTextureSize() + for (page in atlases.values) { + for (index in page.entries.lastIndex downTo 0) { + val entry = page.entries[index] + if (entry !in activeEntries || entry.requestedSizePx != page.renderSizePx) { + releaseSlot(entry) + } } } - placement.id to ItemIconData(iconSize, iconSize, pixels) - } - } - - /** Smooths model edges while keeping transparent pixels free of dark color fringes */ - private fun sampleBilinear( - x: Double, - y: Double, - width: Int, - height: Int, - pixel: (Int, Int) -> Int, - ): Int { - val clampedX = x.coerceIn(0.0, (width - 1).toDouble()) - val clampedY = y.coerceIn(0.0, (height - 1).toDouble()) - val x0 = floor(clampedX).toInt() - val y0 = floor(clampedY).toInt() - val x1 = min(x0 + 1, width - 1) - val y1 = min(y0 + 1, height - 1) - val xWeight = clampedX - x0 - val yWeight = clampedY - y0 - val c00 = pixel(x0, y0) - val c10 = pixel(x1, y0) - val c01 = pixel(x0, y1) - val c11 = pixel(x1, y1) - - fun channel(shift: Int): Int { - val top = ((c00 ushr shift) and 0xFF) * (1.0 - xWeight) + - ((c10 ushr shift) and 0xFF) * xWeight - val bottom = ((c01 ushr shift) and 0xFF) * (1.0 - xWeight) + - ((c11 ushr shift) and 0xFF) * xWeight - return (top * (1.0 - yWeight) + bottom * yWeight).roundToInt().coerceIn(0, 255) - } - - return channel(24) shl 24 or - (channel(16) shl 16) or - (channel(8) shl 8) or - channel(0) - } - - private fun completeBatch(batch: RenderBatch, icons: Map) { - val callbacks = mutableListOf Unit, ItemIconData?>>() - var scheduleNext = false - synchronized(requestLock) { - if (batch.generation == cacheGeneration) { - icons.forEach { (id, icon) -> iconCache[id] = icon } - batch.ids.forEach { id -> - val icon = icons[id] - waiting.remove(id).orEmpty().forEach { callbacks += it to icon } + activeEntries.forEach { entry -> + if (entry.atlas != null) return@forEach + entry.failed = entry.stack.item in failedItems + val page = atlases.getOrPut(entry.requestedSizePx to entry.forHud) { AtlasState(entry.requestedSizePx, entry.forHud) } + allocateSlot(page, entry, maxTextureSizePx) + } + + val iterator = atlases.values.iterator() + while (iterator.hasNext()) { + val page = iterator.next() + if (page.entries.isEmpty()) { + disposeAtlas(page) + //? if >= 1.21.8 + discardItemGuiResources(page) + iterator.remove() } } - renderScheduled = false - if (waiting.isNotEmpty()) { - renderScheduled = true - scheduleNext = true + + //? if = 1.21.5 { + /*val tileIterator = clearTiles.iterator() + while (tileIterator.hasNext()) { + val (size, texture) = tileIterator.next() + if (atlases.values.none { it.layout?.iconSizePx == size }) { + texture.close() + tileIterator.remove() + } } + *///?} + topologyDirty = false } - if (scheduleNext) scheduleRender() - callbacks.forEach { (callback, icon) -> - runCatching { callback(icon) } - .onFailure { LOG.warn("Failed to deliver a rendered item selector icon", it) } - } + } - private fun markRenderFinished() { - var scheduleNext = false - synchronized(requestLock) { - renderScheduled = false - if (waiting.isNotEmpty()) { - renderScheduled = true - scheduleNext = true - } + private fun allocateSlot(page: AtlasState, entry: IconEntry, maxTextureSizePx: Int) { + takeFreeSlot(page)?.let { slot -> + occupy(page, entry, slot) + return } - if (scheduleNext) scheduleRender() + // Grow capacity by 50%, reducing cell resolution if necessary to keep the atlases within the texture limit. + val side = ceil(sqrt(((page.layout?.capacity ?: 0) + 1) * 1.5)).toInt() + val cellSizePx = page.renderSizePx.coerceAtMost(maxTextureSizePx / side).coerceAtLeast(1) + val grown = AtlasLayout(side, cellSizePx) + // Recreate GuiRenderer resources when cell resolution changes to avoid reusing storage sized for previous cells. + //? if >= 1.21.8 + if (page.layout?.iconSizePx != cellSizePx) discardItemGuiResources(page) + resetAtlas(page, grown) + page.entries.forEach { place(it, it.slot, grown) } + occupy(page, entry, checkNotNull(takeFreeSlot(page))) } - private fun currentBatch(limit: Int = Int.MAX_VALUE): RenderBatch = synchronized(requestLock) { - RenderBatch(waiting.keys.take(limit), cacheGeneration) + private fun takeFreeSlot(page: AtlasState): Int? { + page.freeSlots.pollFirst()?.let { return it } + if (page.nextUnusedSlot >= (page.layout?.capacity ?: 0)) return null + return page.nextUnusedSlot++ } - private fun clearCaches() { + private fun occupy(page: AtlasState, entry: IconEntry, slot: Int) { + check(entry.atlas == null) { "Item atlas entry is already placed" } + entry.atlas = page + place(entry, slot, checkNotNull(page.layout)) + page.entries.add(entry) + entry.identityInitialized = false + } + + private fun releaseSlot(entry: IconEntry) { + val page = entry.atlas ?: return + val slot = entry.slot + page.entries.remove(entry) + entry.atlas = null + page.freeSlots.addLast(slot) + } + + private fun maxSupportedTextureSize(): Int { + //? if >= 26.2 { + return RenderSystem.getDevice().deviceInfo.limits().maxTextureSizeForFormat(GpuFormat.RGBA8_UNORM) + //?} else if >= 1.21.5 { + /*return RenderSystem.getDevice().getMaxTextureSize() + *///?} else + //return RenderSystem.maxSupportedTextureSize() + } + + private fun resetAtlas(state: AtlasState, layout: AtlasLayout?) { synchronized(requestLock) { - cacheGeneration++ - catalogCache = null - iconCache.clear() + discardAtlasImage(state) + state.layout = layout + state.rebuild = true } } - private fun abgrToArgb(color: Int): Int = - (color and 0xFF00FF00.toInt()) or ((color and 0xFF) shl 16) or ((color ushr 16) and 0xFF) + private fun destroyAtlases(queueRendererDisposal: Boolean = false) { + synchronized(requestLock) { + invalidateEntries(activeEntries) + atlases.values.forEach { page -> + disposeAtlas(page) + //? if >= 1.21.8 + discardItemGuiResources(page, queueRendererDisposal) + } + //? if = 1.21.5 { + /*clearTiles.values.forEach(GpuTexture::close) + clearTiles.clear() + *///?} + atlases.clear() + topologyDirty = true + } + } + + private fun disposeAtlas(state: AtlasState) { + discardAtlasImage(state) + state.offscreenTarget.dispose() + state.entries.forEach { it.atlas = null } + state.entries.clear() + state.freeSlots.clear() + state.nextUnusedSlot = 0 + } + + private fun discardAtlasImage(state: AtlasState) { + state.atlasImage?.close() + state.atlasImage = null + } - private fun unpremultiply(color: Int): Int { - val alpha = color ushr 24 - if (alpha == 0 || alpha == 255) return color - val red = min(255, ((color ushr 16) and 0xFF) * 255 / alpha) - val green = min(255, ((color ushr 8) and 0xFF) * 255 / alpha) - val blue = min(255, (color and 0xFF) * 255 / alpha) - return alpha shl 24 or (red shl 16) or (green shl 8) or blue + private fun place(entry: IconEntry, slot: Int, layout: AtlasLayout) { + val x = slot % layout.iconsPerSide * CELL_SIZE + val y = slot / layout.iconsPerSide * CELL_SIZE + val scale = layout.iconSizePx.toFloat() / CELL_SIZE + entry.slot = slot + entry.x = x + entry.y = y + entry.atlasSource = Rect.makeLTRB( + x * scale, + y * scale, + (x + CELL_SIZE) * scale, + (y + CELL_SIZE) * scale, + ) } private companion object { val LOG = LoggerFactory.getLogger("OneConfig/ItemList") - const val CELL_SIZE = 20 - const val ITEM_PADDING = 2 - const val ITEM_RENDER_SIZE = CELL_SIZE - ITEM_PADDING * 2 - const val ICON_SIZE = 32 - const val MAX_ICON_PIXEL_SIZE = 64 - const val MAX_CACHED_ICONS = 512 - const val MAX_BATCH_COLUMNS = 8 - const val MAX_BATCH_ROWS = 8 - const val MAX_TARGET_SIZE = 512 + const val CELL_SIZE = 16 + const val SPECIAL_REFRESH_NS = 1_000_000_000L } } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt index d5bdd29e6..7a6f563f0 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt @@ -45,6 +45,7 @@ import org.polyfrost.oneconfig.api.platform.v1.DesktopHelper import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ui.components.LocalUiOversample +import org.polyfrost.oneconfig.internal.ui.components.item.ItemCatalog import org.polyfrost.oneconfig.internal.ui.keybind.KeybindRecordingBus import java.awt.Component import java.awt.event.InputEvent @@ -560,6 +561,14 @@ abstract class ComposeScreen( withScene { it.invalidatePositionInWindow() } } + // Recompose and lay out before rendering so icons revealed by scrolling or filtering draw on the same frame. + val nanos = frameNanos() + withScene { scene -> + recomposerOrNull?.performFrame(nanos) + scene.measureAndLayout() + } + if (ItemCatalog.renderIcons()) sceneDirty = true + val debugOverlayOnTop = org.polyfrost.oneconfig.internal.ui.hud.DebugOverlayOffscreen.shouldSuppressVanilla() if (renderMode == RenderMode.ON_DEMAND && !sceneDirty && !awaitingFirstFrame && SkiaCtx.isDeferredComposeBackend && !debugOverlayOnTop @@ -587,7 +596,7 @@ abstract class ComposeScreen( val scope = renderScopeOrNull val composeCanvas = canvas.asComposeCanvas() val rendered = if (recomposer == null || scope == null) null else withScene { - with(scope) { it.render(recomposer, composeCanvas, frameNanos()) } + with(scope) { it.render(recomposer, composeCanvas, nanos) } } if (rendered != null) { sceneRebuilds = 0 diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaCtx.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaCtx.kt index 06bd2dd23..05efb9177 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaCtx.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/SkiaCtx.kt @@ -328,18 +328,32 @@ object SkiaCtx { queuedWarmups.add(block) } + /** + * Use for any Skia GPU call outside a draw. Skia applies a pending [DirectContext.resetGLAll] on its next GL call, + * and GlStateManager's cache becomes stale. + */ + fun withIsolatedGl(block: () -> T): T { + if (isVulkanMode || !this::directContext.isInitialized) return block() + gl.capture() + directContext.resetGLAll() + try { + return block() + } finally { + directContext.flush() + gl.restore() + } + } + private fun runWarmups() { if (!this::directContext.isInitialized) return if (queuedWarmups.isEmpty()) return val warmups = queuedWarmups.toList() queuedWarmups.clear() - val savedFbo = IntArray(1) try { if (isVulkanMode) { directContext.resetAll() } else { gl.capture() - GL30.glGetIntegerv(GL30.GL_FRAMEBUFFER_BINDING, savedFbo) directContext.resetGLAll() } @@ -349,13 +363,11 @@ object SkiaCtx { directContext.flush() } else { directContext.flush() - GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, savedFbo[0]) gl.restore() } } catch (e: Throwable) { LOG.warn("SkiaCtx.runWarmups() error", e) if (!isVulkanMode) try { - GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, savedFbo[0]) gl.restore() } catch (_: Throwable) { } @@ -623,15 +635,12 @@ object SkiaCtx { if (mainSurface == null) return currentSurface = mainSurface - val savedFbo = IntArray(1) try { if (isVulkanMode) { vulkanService?.midFrameFlush() directContext.resetAll() } else { gl.capture() - // restoring this fbo later avoids screen flickering - GL30.glGetIntegerv(GL30.GL_FRAMEBUFFER_BINDING, savedFbo) directContext.resetGLAll() GL11.glViewport(0, 0, mainSurface.width, mainSurface.height) GL11.glDisable(GL11.GL_SCISSOR_TEST) @@ -665,13 +674,11 @@ object SkiaCtx { vulkanService?.restoreMainRTLayout() } else { directContext.flush() - GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, savedFbo[0]) gl.restore() } } catch (e: Throwable) { LOG.warn("SkiaCtx.draw() error", e) if (!isVulkanMode) try { - GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, savedFbo[0]) gl.restore() } catch (_: Throwable) { } @@ -698,13 +705,11 @@ object SkiaCtx { private fun flushToTarget(draws: List<() -> Unit>, surface: Surface, flipY: Boolean = false) { currentSurface = surface - val savedFbo = IntArray(1) try { if (isVulkanMode) { directContext.resetAll() } else { gl.capture() - GL30.glGetIntegerv(GL30.GL_FRAMEBUFFER_BINDING, savedFbo) directContext.resetGLAll() GL11.glViewport(0, 0, surface.width, surface.height) GL11.glDisable(GL11.GL_SCISSOR_TEST) @@ -727,13 +732,11 @@ object SkiaCtx { directContext.flushAndSubmit(surface, false) } else { directContext.flush() - GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, savedFbo[0]) gl.restore() } } catch (e: Throwable) { LOG.warn("SkiaCtx.flushToTarget() error", e) if (!isVulkanMode) try { - GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, savedFbo[0]) gl.restore() } catch (_: Throwable) { } @@ -811,6 +814,8 @@ object SkiaCtx { return null } hudTarget = rt + //? if < 1.21.5 + //org.polyfrost.oneconfig.internal.ui.RenderTargetFbo.restoreMainTarget() //? >= 1.21.5 { if (!isVulkanMode) { @@ -856,7 +861,11 @@ object SkiaCtx { private fun destroyHudTarget() { hudSurface?.close(); hudSurface = null hudBrt?.close(); hudBrt = null - hudTarget?.destroyBuffers() + hudTarget?.let { target -> + target.destroyBuffers() + //? if < 1.21.5 + //org.polyfrost.oneconfig.internal.ui.RenderTargetFbo.restoreMainTarget() + } hudTarget = null } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt index 2cb965de9..5b496c03c 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/HudEditorUIScreen.kt @@ -19,6 +19,7 @@ import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen import org.polyfrost.oneconfig.internal.ui.compose.ComposePreloader import org.polyfrost.oneconfig.internal.ui.components.RetainedVisibility +import org.polyfrost.oneconfig.internal.ui.components.item.ItemCatalog import org.polyfrost.oneconfig.internal.ui.guiCloseAnimationMillis import org.polyfrost.oneconfig.internal.ui.keybind.KeybindRecordingBus import org.polyfrost.oneconfig.internal.ui.hud.screens.HudDesignStudio @@ -175,6 +176,7 @@ class HudEditorUIScreen private constructor() : ComposeScreen() { HudManager.guiScreenWidth = sw HudManager.guiScreenHeight = sh HudManager.prepare(sw, sh) + ItemCatalog.renderHudIcons() } HudEditorViewport.update(Platform.screen().windowWidth(), Platform.screen().windowHeight()) //~ if >= 26.1 'render' -> 'extractRenderState' diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt index ab250c7fa..5f7ffacf2 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/impls/OneConfigUIScreen.kt @@ -20,6 +20,7 @@ import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.ConfigSource import org.polyfrost.oneconfig.internal.ui.OneConfigInterface import org.polyfrost.oneconfig.internal.ui.components.warmIconCache +import org.polyfrost.oneconfig.internal.ui.components.item.ItemCatalog import org.polyfrost.oneconfig.internal.ui.guiCloseAnimationMillis import org.polyfrost.oneconfig.internal.ui.compose.BlurRenderer import org.polyfrost.oneconfig.internal.ui.compose.ComposePreloader @@ -452,6 +453,7 @@ class OneConfigUIScreen @JvmOverloads constructor( HudManager.guiScreenWidth = sw HudManager.guiScreenHeight = sh HudManager.prepare(sw, sh) + ItemCatalog.renderHudIcons() } HudEditorViewport.update(Platform.screen().windowWidth(), Platform.screen().windowHeight()) if (OneConfigConfig.enableBackgroundBlur) { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt index 141f5c74a..d19543c5b 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt @@ -21,6 +21,8 @@ class StoredGLState(private val glVersion: Int) { fun capture(): StoredGLState { with(props) { + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, lastDrawFramebuffer) + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, lastReadFramebuffer) glGetIntegerv(GL_ACTIVE_TEXTURE, lastActiveTexture) glActiveTexture(GL_TEXTURE0) glGetIntegerv(GL_CURRENT_PROGRAM, lastProgram) @@ -100,6 +102,8 @@ class StoredGLState(private val glVersion: Int) { fun restore(): StoredGLState { with(props) { + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, lastDrawFramebuffer[0]) + glBindFramebuffer(GL_READ_FRAMEBUFFER, lastReadFramebuffer[0]) glUseProgram(lastProgram[0]) glBindTexture(GL_TEXTURE_2D, lastTexture[0]) if (glVersion >= 330 || GL.getCapabilities().GL_ARB_sampler_objects) { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLStateProps.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLStateProps.kt index f0d42d4a2..cbe4b8221 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLStateProps.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLStateProps.kt @@ -6,6 +6,8 @@ import java.util.BitSet class StoredGLStateProps { val lastActiveTexture = IntArray(1) + val lastDrawFramebuffer = IntArray(1) + val lastReadFramebuffer = IntArray(1) val lastProgram = IntArray(1) val lastTexture = IntArray(1) val lastSampler = IntArray(1) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/GuiTargetRedirect.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/GuiTargetRedirect.kt index f2ca2656a..760170e5d 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/GuiTargetRedirect.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/GuiTargetRedirect.kt @@ -3,7 +3,19 @@ package org.polyfrost.oneconfig.internal.ui.hud import com.mojang.blaze3d.pipeline.RenderTarget object GuiTargetRedirect { + fun interface ScissorTransform { + fun map(left: Int, top: Int, right: Int, bottom: Int): IntArray + } + @Volatile @JvmField var target: RenderTarget? = null + + @Volatile + @JvmField + var scissorTransform: ScissorTransform? = null + + @Volatile + @JvmField + var itemRenderSizePx: Int = 0 } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/VulkanModVulkanService.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/VulkanModVulkanService.kt index 8768fbdd1..5cf927387 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/VulkanModVulkanService.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/VulkanModVulkanService.kt @@ -4,15 +4,24 @@ package org.polyfrost.oneconfig.internal.ui.services /*import com.mojang.blaze3d.pipeline.RenderTarget import net.vulkanmod.gl.VkGlTexture import net.vulkanmod.vulkan.Renderer +import net.vulkanmod.vulkan.VRenderSystem import net.vulkanmod.vulkan.device.DeviceManager import net.vulkanmod.vulkan.queue.Queue import net.vulkanmod.vulkan.texture.VulkanImage import org.jetbrains.skia.BackendRenderTarget import org.jetbrains.skia.DirectContext import org.jetbrains.skia.SurfaceColorFormat +import org.lwjgl.opengl.GL11.GL_COLOR_BUFFER_BIT +import org.lwjgl.opengl.GL11.GL_DEPTH_BUFFER_BIT import org.lwjgl.system.MemoryStack import org.lwjgl.vulkan.VK import org.lwjgl.vulkan.VK12.* +//? if > 1.21.1 && < 1.21.10 { +/*import org.lwjgl.vulkan.VkClearAttachment +import org.lwjgl.vulkan.VkClearRect +import org.lwjgl.vulkan.VkClearValue +import org.lwjgl.vulkan.VkRect2D +*///?} import org.lwjgl.vulkan.VkCommandBuffer import org.lwjgl.vulkan.VkImageBlit import org.lwjgl.vulkan.VkOffset3D @@ -75,6 +84,42 @@ class VulkanModVulkanService private constructor( return makeBackendRenderTarget(width, height, image.id, vkFmt, queueFamilyIndex) to colorFormatFor(vkFmt) } + override fun clearOffscreenRect(x: Int, y: Int, width: Int, height: Int): Boolean { + VRenderSystem.setClearColor(0f, 0f, 0f, 0f) + VRenderSystem.clearDepth(1.0) + + //? if = 1.21.1 || >= 1.21.10 { + Renderer.clearAttachments(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT, x, y, width, height) + //?} else { + /*// Equivalent to VulkanMod's five argument Renderer.clearAttachments overload, missing in 1.21.4 and 1.21.5 + MemoryStack.stackPush().use { stack -> + val colorValue = VkClearValue.calloc(stack) + colorValue.color().float32(0, 0f).float32(1, 0f).float32(2, 0f).float32(3, 0f) + val depthValue = VkClearValue.calloc(stack) + depthValue.depthStencil().set(1f, 0) + + val attachments = VkClearAttachment.malloc(2, stack) + attachments[0] + .aspectMask(VK_IMAGE_ASPECT_COLOR_BIT) + .colorAttachment(0) + .clearValue(colorValue) + attachments[1] + .aspectMask(VK_IMAGE_ASPECT_DEPTH_BIT) + .colorAttachment(0) + .clearValue(depthValue) + + val renderArea = VkRect2D.malloc(stack) + renderArea.offset().set(x, y) + renderArea.extent().set(width, height) + val clearRect = VkClearRect.malloc(1, stack) + clearRect[0].rect(renderArea).baseArrayLayer(0).layerCount(1) + + vkCmdClearAttachments(Renderer.getCommandBuffer(), attachments, clearRect) + } + *///?} + return true + } + override fun getMainColorImageInfo(): Triple = try { val image = Renderer.getInstance()?.swapChain?.colorAttachment ?: return Triple(0L, 0, 0) @@ -222,7 +267,7 @@ class VulkanModVulkanService private constructor( val instAddr = instance.address() val physAddr = physicalDevice.address() val devAddr = vkDevice.address() - //? if >= 1.21.10 { + //? if = 1.21.1 || >= 1.21.10 { val queueAddr = graphicsQueue.vkQueue().address() //? } else { /*val queueAddr = graphicsQueue.queue().address() diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/VulkanService.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/VulkanService.kt index e4b62c39d..69bc5d1ad 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/VulkanService.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/VulkanService.kt @@ -24,6 +24,8 @@ interface VulkanService { fun transitionOffscreenForRendering(target: RenderTarget) {} + fun clearOffscreenRect(x: Int, y: Int, width: Int, height: Int): Boolean = false + fun restoreMainRTLayout() {} fun makeDirectContext(): DirectContext diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/test/TestItemHud_Test.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/test/TestItemHud_Test.kt index 477f14dc4..09b7b6791 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/test/TestItemHud_Test.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/test/TestItemHud_Test.kt @@ -13,7 +13,7 @@ import org.polyfrost.oneconfig.api.config.v1.annotations.Slider import org.polyfrost.oneconfig.api.config.v1.annotations.Switch import org.polyfrost.oneconfig.api.hud.v1.Hud import org.polyfrost.oneconfig.internal.ui.components.item.ItemCatalog -import org.polyfrost.oneconfig.internal.ui.components.item.PolyItemIcon +import org.polyfrost.oneconfig.api.ui.v1.item.PolyItemIcon class TestItemHud_Test : Hud("test-item-hud", "Item List Hud", Category.INFO) { @@ -38,16 +38,18 @@ class TestItemHud_Test : Hud("test-item-hud", "Item List Hud", Category.INFO) { @Composable override fun Content() { val ids = displayItems.value - PolyBox(modifier = hudBackground().padding(PolyInsets(padLeft, padTop, padRight, padBottom))) { - PolyRow(gap = 3f) { + val scale = textScale + val padding = PolyInsets(padLeft * scale, padTop * scale, padRight * scale, padBottom * scale) + PolyBox(modifier = hudBackground().padding(padding)) { + PolyRow(gap = 3f * scale) { if (ids.isEmpty()) { - PolyMcText(text = "§7No items", scale = 1f) + PolyMcText(text = "§7No items", scale = scale) } else { ids.forEach { id -> - PolyRow(gap = 2f) { - PolyItemIcon(id, iconSize.toFloat()) + PolyRow(gap = 2f * scale) { + PolyItemIcon(id, iconSize * scale) if (showNames) { - PolyMcText(text = displayName(id), scale = 1f) + PolyMcText(text = displayName(id), scale = scale) } } } diff --git a/minecraft/src/main/resources/fabric.mod.json b/minecraft/src/main/resources/fabric.mod.json index 1c31baf99..35f2994b9 100644 --- a/minecraft/src/main/resources/fabric.mod.json +++ b/minecraft/src/main/resources/fabric.mod.json @@ -14,6 +14,7 @@ }, "license": "LGPL-3.0", "environment": "client", + "accessWidener": "oneconfigv1.classtweaker", "mixins": [ "mixins.oneconfigv1.init.json", "mixins.oneconfigv1.json" @@ -33,7 +34,7 @@ ] }, "depends": { - "fabricloader": ">=0.12.0", + "fabricloader": ">=0.18.0", "minecraft": "${fabric_mc_version}", "fabric-api": "*" }, diff --git a/minecraft/src/main/resources/oneconfigv1.classtweaker b/minecraft/src/main/resources/oneconfigv1.classtweaker new file mode 100644 index 000000000..127a9f8cc --- /dev/null +++ b/minecraft/src/main/resources/oneconfigv1.classtweaker @@ -0,0 +1,28 @@ +classTweaker v2 official + +#? if < 1.21.8 { +#accessible class net/minecraft/client/renderer/texture/SpriteContents$AnimatedTexture +#accessible field net/minecraft/client/renderer/texture/SpriteContents animatedTexture Lnet/minecraft/client/renderer/texture/SpriteContents$AnimatedTexture; +#?} + +#? if >= 1.21.4 && < 1.21.8 { +#accessible field net/minecraft/client/renderer/item/ItemStackRenderState activeLayerCount I +#accessible field net/minecraft/client/renderer/item/ItemStackRenderState layers [Lnet/minecraft/client/renderer/item/ItemStackRenderState$LayerRenderState; +#accessible field net/minecraft/client/renderer/item/ItemStackRenderState$LayerRenderState renderType Lnet/minecraft/client/renderer/RenderType; +#accessible field net/minecraft/client/renderer/item/ItemStackRenderState$LayerRenderState foilType Lnet/minecraft/client/renderer/item/ItemStackRenderState$FoilType; +#accessible field net/minecraft/client/renderer/item/ItemStackRenderState$LayerRenderState tintLayers [I +#accessible field net/minecraft/client/renderer/item/ItemStackRenderState$LayerRenderState specialRenderer Lnet/minecraft/client/renderer/special/SpecialModelRenderer; +#?} + +#? if 1.21.5 { +#accessible field net/minecraft/client/renderer/item/ItemStackRenderState$LayerRenderState usesBlockLight Z +#accessible field net/minecraft/client/renderer/item/ItemStackRenderState$LayerRenderState transform Lnet/minecraft/client/renderer/block/model/ItemTransform; +#?} + +#? if 1.21.4 { +#accessible field net/minecraft/client/renderer/item/ItemStackRenderState$LayerRenderState model Lnet/minecraft/client/resources/model/BakedModel; +#?} + +#? if < 1.21.4 { +#accessible field net/minecraft/client/renderer/entity/ItemRenderer itemColors Lnet/minecraft/client/color/item/ItemColors; +#?} diff --git a/minecraft/stonecutter.gradle.kts b/minecraft/stonecutter.gradle.kts index 41967ded6..f67628cbc 100644 --- a/minecraft/stonecutter.gradle.kts +++ b/minecraft/stonecutter.gradle.kts @@ -3,6 +3,11 @@ plugins { } stonecutter active "26.3-fabric" + +stonecutter handlers { + inherit("aw", "classtweaker") +} + stonecutter { parameters { constants { @@ -46,6 +51,9 @@ stonecutter { } replacements { + string(eval(current.version, "< 26.1")) { + replace("classTweaker v2 official", "classTweaker v2 named") + } string(eval(current.version, ">= 26.1"), "gui_graphics") { replace("GuiGraphics", "GuiGraphicsExtractor") @@ -78,9 +86,11 @@ stonecutter { replace("com.mojang.blaze3d.textures", "com.mojang.renderpearl.api.textures") replace("com.mojang.blaze3d.buffers", "com.mojang.renderpearl.api.buffers") replace("com.mojang.blaze3d.GpuFormat", "com.mojang.renderpearl.api.GpuFormat") + replace("com.mojang.blaze3d.systems.RenderPass", "com.mojang.renderpearl.api.commands.RenderPass") // mixin descriptors replace("com/mojang/blaze3d/systems/GpuSurface", "com/mojang/renderpearl/api/device/GpuSurface") replace("com/mojang/blaze3d/systems/CommandEncoder", "com/mojang/renderpearl/api/commands/CommandEncoder") + replace("com/mojang/blaze3d/systems/RenderPass", "com/mojang/renderpearl/api/commands/RenderPass") replace("com/mojang/blaze3d/textures/GpuTextureView", "com/mojang/renderpearl/api/textures/GpuTextureView") } } diff --git a/modules/hud/api/hud.api b/modules/hud/api/hud.api index 79508b6fc..23db92aac 100644 --- a/modules/hud/api/hud.api +++ b/modules/hud/api/hud.api @@ -118,6 +118,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun isAnchored ()Z public final fun isMergeAnchored ()Z public final fun isReal ()Z + public final fun isVisible ()Landroidx/compose/runtime/MutableState; public final fun make (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)Lorg/polyfrost/oneconfig/api/hud/v1/Hud; public static synthetic fun make$default (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Lorg/polyfrost/oneconfig/api/config/v1/Tree;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/hud/v1/Hud; protected final fun measureNaturalContentSize ()Lkotlin/Pair; @@ -190,6 +191,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun setTextWeight (Lorg/polyfrost/oneconfig/api/hud/v1/Weight;)V public final fun setToggleKey (I)V public final fun setUseGuiScale (Z)V + public final fun setVisible (Landroidx/compose/runtime/MutableState;)V public fun setX (F)V public fun setY (F)V public fun setup ()V @@ -246,6 +248,10 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudAnchor$Companion { public final fun of (Lorg/polyfrost/compose/layout/PolyAlign;)Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor; } +public final class org/polyfrost/oneconfig/api/hud/v1/HudKt { + public static final fun getLocalHud ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + public final class org/polyfrost/oneconfig/api/hud/v1/HudManager { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/hud/v1/HudManager; @@ -266,7 +272,8 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudManager { public static field targetPixelHeight I public static field targetPixelWidth I public final fun addRegistrationListener (Ljava/lang/Runnable;)V - public final fun beginFrame (FF)Z + public final fun beginFrame (FFLjava/lang/Runnable;)Z + public static synthetic fun beginFrame$default (Lorg/polyfrost/oneconfig/api/hud/v1/HudManager;FFLjava/lang/Runnable;ILjava/lang/Object;)Z public final fun closeEditor ()V public final fun drawMergedBackgrounds (Lorg/polyfrost/compose/render/RenderContext;)V public final fun getActiveInstances ()Ljava/util/ArrayList; diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt index bb08a2b65..2f1f21287 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt @@ -27,7 +27,9 @@ package org.polyfrost.oneconfig.api.hud.v1 import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.MustBeInvokedByOverriders @@ -47,6 +49,9 @@ import org.polyfrost.oneconfig.api.config.v1.backend.Backend import org.polyfrost.oneconfig.api.hud.v1.HudManager.LOGGER import org.polyfrost.oneconfig.api.platform.v1.Platform +@ApiStatus.Internal +val LocalHud = compositionLocalOf { null } + enum class Font { Minecraft, Poppins; @@ -981,6 +986,10 @@ abstract class Hud(id: String, title: String, val category: Category) : Cloneabl @Transient internal var _runtime: PolyComposeRuntime? = null + @Transient + @ApiStatus.Internal + var isVisible = mutableStateOf(false) + @Transient private var capturedDefaults: Tree? = null @@ -990,7 +999,11 @@ abstract class Hud(id: String, title: String, val category: Category) : Cloneabl val runtime: PolyComposeRuntime get() = _runtime ?: PolyComposeRuntime().also { _runtime = it - it.setContent { Content() } + it.setContent { + CompositionLocalProvider(LocalHud provides this) { + Content() + } + } } @Composable @@ -1205,6 +1218,7 @@ abstract class Hud(id: String, title: String, val category: Category) : Cloneabl @Suppress("UNCHECKED_CAST") override fun clone(): Hud = (super.clone() as Hud).apply { _runtime = null + isVisible = mutableStateOf(false) showKey = -1 toggleKey = -1 _staticWidth = mutableStateOf(this@Hud.staticWidth) diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt index 5a03e9073..dfd77f784 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt @@ -568,7 +568,9 @@ object HudManager { layoutOrder.clear() var volatileContent = false for (hud in orderedForRender()) { - if (shouldDraw(hud)) { + val visible = shouldDraw(hud) + hud.isVisible.value = visible + if (visible) { frameOrder.add(hud) if (hud.alwaysRedraw) volatileContent = true } else if (keepsBackgroundOnly(hud)) { @@ -580,22 +582,37 @@ object HudManager { return volatileContent } - @ApiStatus.Internal - fun beginFrame(screenWidth: Float, screenHeight: Float): Boolean { + private inline fun prepareFrame( + screenWidth: Float, + screenHeight: Float, + selectHuds: () -> List, + afterPreparation: Runnable? = null, + ): Float { drainProfileReload() migratePositions(screenWidth, screenHeight) val scale = Platform.compatibility().options().guiScale frameId++ - Snapshot.sendApplyNotifications() - val volatileContent = collectFrameOrder() + val huds = selectHuds() + updateAndAdvance(huds) + layoutAll(huds, screenWidth, screenHeight, scale) + updateBackgroundGroups(huds, screenWidth, screenHeight, scale) - updateAndAdvance(layoutOrder) + // Atlas rendering can invalidate HUD content after composition has reconciled icon handles. + // Run it before consuming contentDirty so those changes are drawn in this frame. + afterPreparation?.run() + return scale + } - layoutAll(layoutOrder, screenWidth, screenHeight, scale) - updateBackgroundGroups(layoutOrder, screenWidth, screenHeight, scale) + @ApiStatus.Internal + fun beginFrame(screenWidth: Float, screenHeight: Float, beforeDirtyCheck: Runnable? = null): Boolean { + var volatileContent = false + val scale = prepareFrame(screenWidth, screenHeight, { + volatileContent = collectFrameOrder() + layoutOrder + }, beforeDirtyCheck) val key = frameKey() val keyChanged = key != lastFrameKey || @@ -800,39 +817,28 @@ object HudManager { @ApiStatus.Internal fun prepare(screenWidth: Float, screenHeight: Float) { - drainProfileReload() - migratePositions(screenWidth, screenHeight) - val scale = Platform.compatibility().options().guiScale - Snapshot.sendApplyNotifications() - frameId++ - val huds = prepareOrder - huds.clear() - for (hud in activeInstances) if (hud !is LegacyHudMarker) huds.add(hud) - updateAndAdvance(huds) - for (hud in huds) { - try { - layoutOnce(hud, screenWidth, screenHeight, scale) - } catch (e: Throwable) { - LOGGER.error("Failed to lay out HUD ${hud.title}", e) + prepareFrame(screenWidth, screenHeight, { + prepareOrder.clear() + for (hud in activeInstances) { + val visible = hud !is LegacyHudMarker + hud.isVisible.value = visible + if (visible) prepareOrder.add(hud) } - } - updateBackgroundGroups(huds, screenWidth, screenHeight, scale) + prepareOrder + }) } @ApiStatus.Internal fun render(ctx: RenderContext, screenWidth: Float, screenHeight: Float) { - val scale = Platform.compatibility().options().guiScale - val prepared = preparedFrameValid preparedFrameValid = false - if (!prepared) { - migratePositions(screenWidth, screenHeight) - Snapshot.sendApplyNotifications() - frameId++ - collectFrameOrder() - updateAndAdvance(layoutOrder) - layoutAll(layoutOrder, screenWidth, screenHeight, scale) - updateBackgroundGroups(layoutOrder, screenWidth, screenHeight, scale) + val scale = if (prepared) { + Platform.compatibility().options().guiScale + } else { + prepareFrame(screenWidth, screenHeight, { + collectFrameOrder() + layoutOrder + }) } ctx.save() diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/ItemCatalogService.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/ItemCatalogService.kt index f5eb74ed3..e652676e2 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/ItemCatalogService.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/ItemCatalogService.kt @@ -1,31 +1,49 @@ package org.polyfrost.oneconfig.internal.ui.components.item +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.Snapshot +import org.jetbrains.skia.Canvas +import org.jetbrains.skia.Rect +import org.polyfrost.oneconfig.api.hud.v1.LocalHud import java.util.Locale import java.util.ServiceLoader /** A stable item registry ID and the localized name shown to the user */ data class ItemDescriptor(val id: String, val displayName: String) -/** A small ARGB image supplied by the Minecraft integration for an item preview */ -data class ItemIconData(val width: Int, val height: Int, val argb: IntArray) +/** An item icon registered with the renderer until [close] is called. */ +interface ItemIconHandle : AutoCloseable { + /** Sets the pixel size of this icon's square atlas entry */ + fun setRenderSizePx(size: Int) + + /** Returns whether the icon was rendered. */ + fun draw(canvas: Canvas, bounds: Rect, alpha: Float = 1f): Boolean + + /** Unregisters this icon from the renderer. */ + override fun close() +} -/** Supplies item registry data without exposing Minecraft classes to the UI module */ interface ItemCatalogService { fun items(): List - fun icon(id: String): ItemIconData? + fun requestIcons() = Unit - /** Resolves an icon that may require rendering on the Minecraft render thread */ - fun loadIcon(id: String, onLoaded: (ItemIconData?) -> Unit) { - onLoaded(icon(id)) - } + /** HUD consumers request a HUD redraw when their icon's cached pixels change. */ + fun openIcon(id: String, forHud: Boolean = false): ItemIconHandle? + + /** Renders icons needed by screen consumers and returns whether their pixels changed this frame. */ + fun renderIcons(): Boolean = false + + /** Renders icons needed by HUD consumers before the HUD dirty gate. */ + fun renderHudIcons() = Unit } object ItemCatalog { - @Volatile - private var overrideService: ItemCatalogService? = null - - private val loadedService: ItemCatalogService? by lazy { + private val service: ItemCatalogService? by lazy { runCatching { ServiceLoader.load(ItemCatalogService::class.java, ItemCatalogService::class.java.classLoader) .iterator() @@ -33,20 +51,52 @@ object ItemCatalog { }.getOrNull() } - private val service: ItemCatalogService? get() = overrideService ?: loadedService + private val _iconsAvailable = mutableStateOf(false) + + val iconsAvailable: Boolean get() = _iconsAvailable.value + + fun platformService(): ItemCatalogService? = service fun items(): List = service?.items().orEmpty() - fun icon(id: String): ItemIconData? = service?.icon(id) + fun requestIcons() { + if (!iconsAvailable) service?.requestIcons() + } + + fun openIcon(id: String, forHud: Boolean = false): ItemIconHandle? = + if (iconsAvailable) service?.openIcon(id, forHud) else null + + fun renderIcons(): Boolean = iconsAvailable && service?.renderIcons() == true + + fun renderHudIcons() { + if (iconsAvailable) service?.renderHudIcons() + } - fun loadIcon(id: String, onLoaded: (ItemIconData?) -> Unit) { - service?.loadIcon(id, onLoaded) ?: onLoaded(null) + fun markIconsAvailable() { + Snapshot.withMutableSnapshot { _iconsAvailable.value = true } } +} - /** Installs an in-memory catalog for desktop previews and tests */ - fun installOverride(service: ItemCatalogService?) { - overrideService = service +@Composable +fun rememberItemIconHandle(id: String): ItemIconHandle? = + rememberItemIconHandle(id) { forHud -> ItemCatalog.openIcon(id, forHud) } + +/** Holds an icon handle open while icons are available and the enclosing HUD, if any, is visible. */ +@Composable +fun rememberItemIconHandle(key: Any?, open: (forHud: Boolean) -> T?): T? { + val hud = LocalHud.current + val visible = hud == null || hud.isVisible.value + val available = ItemCatalog.iconsAvailable + SideEffect { + if (visible && !available) ItemCatalog.requestIcons() + } + val icon = remember(key, visible, available, hud) { + if (visible && available) open(hud != null) else null + } + DisposableEffect(icon) { + onDispose { icon?.close() } } + return icon } fun normalizeItemIds(ids: Iterable): List = ids diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/ItemVisuals.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/ItemVisuals.kt index dbbdf5daf..c288fca2a 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/ItemVisuals.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/ItemVisuals.kt @@ -1,7 +1,7 @@ package org.polyfrost.oneconfig.internal.ui.components.item import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.Image +import androidx.compose.foundation.Canvas import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -40,13 +41,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.skiaCanvas import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -55,6 +56,9 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus +import org.jetbrains.skia.Rect +import org.polyfrost.oneconfig.api.hud.v1.Hud +import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.internal.ui.api.Tooltip import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.components.IconButton @@ -65,13 +69,16 @@ import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import java.awt.image.BufferedImage +import kotlin.math.ceil val ItemTileShape = RoundedCornerShape(8.dp) val ItemIconShape = RoundedCornerShape(5.dp) val ItemTileSize = 44.dp +fun polyItemRenderSizePx(size: Float, hud: Hud?): Int = + ceil(size * Platform.compatibility().options().guiScale * (hud?.effectiveScale ?: 1f)).toInt() + /** The catalog deduplicated and sorted by display name so grid positions never jump */ @Composable fun rememberItemCatalog(key: Any? = Unit): List = remember(key) { @@ -91,8 +98,6 @@ fun rememberItemCatalogById(catalog: List): Map + val surfaceScale = Platform.screen().surfaceRatio().coerceAtLeast(0.0001f) + itemIcon.setRenderSizePx(ceil(maxOf(coords.size.width, coords.size.height) * surfaceScale).toInt()) + }) { + drawIntoCanvas { canvas -> + itemIcon.draw( + canvas.skiaCanvas, + Rect.makeWH(size.width, size.height), + alpha, + ) + } + } } else { Icon( placeholder, @@ -197,7 +206,7 @@ fun ItemTile( .border(1.dp, border, ItemTileShape) .hoverable(interaction) .pointerHoverIcon(if (interactive) PointerIcon.Hand else PointerIcon.Default) - .then(if (interactive) Modifier.onClick(interaction, onClick!!) else Modifier) + .then(if (interactive) Modifier.onClick(interaction, onClick) else Modifier) .padding(5.dp), contentAlignment = Alignment.Center, ) { @@ -372,7 +381,6 @@ fun ItemPicker( titleKey: String? = ItemStrings.Title, searchPlaceholderKey: String = ItemStrings.Search, emptyKey: String = ItemStrings.Empty, - catalogUnavailableKey: String = ItemStrings.CatalogUnavailable, header: @Composable (ColumnScope.() -> Unit)? = null, ) { val theme = LocalTheme.current @@ -421,17 +429,20 @@ fun ItemPicker( placeholderKey = searchPlaceholderKey, ) - when { - items.isEmpty() -> ItemMessage(catalogUnavailableKey) - filtered.isEmpty() -> ItemMessage(emptyKey) - else -> ItemGrid( - items = filtered, - selected = { it.id in selected }, - enabled = { canSelect(selected, it.id, maxEntries) }, - cellSize = cellSize, - maxHeight = gridMaxHeight, - onClick = { onToggle(it.id) }, - ) + Box(Modifier.fillMaxWidth().height(gridMaxHeight)) { + when { + items.isEmpty() -> ItemMessage(emptyKey, height = gridMaxHeight) + filtered.isEmpty() -> ItemMessage(emptyKey, height = gridMaxHeight) + else -> ItemGrid( + items = filtered, + selected = { it.id in selected }, + enabled = { canSelect(selected, it.id, maxEntries) }, + modifier = Modifier.fillMaxSize(), + cellSize = cellSize, + maxHeight = gridMaxHeight, + onClick = { onToggle(it.id) }, + ) + } } } } @@ -454,16 +465,8 @@ object ItemStrings { const val Search = "oneconfig.itemlist.search" const val Empty = "oneconfig.itemlist.empty" const val Unavailable = "oneconfig.itemlist.unavailable" - const val CatalogUnavailable = "oneconfig.itemlist.catalog_unavailable" } /** Whether [id] can be picked where already-selected items stay clickable so they can be deselected */ fun canSelect(selected: List, id: String, maxEntries: Int): Boolean = id in selected || maxEntries <= 0 || maxEntries == 1 || selected.size < maxEntries - -internal fun ItemIconData.toBitmap() = runCatching { - require(width > 0 && height > 0 && argb.size >= width * height) - BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB).also { - it.setRGB(0, 0, width, height, argb, 0, width) - }.toComposeImageBitmap() -}.getOrNull() diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/PolyItemVisuals.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/PolyItemVisuals.kt deleted file mode 100644 index 3bb0e86d5..000000000 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/item/PolyItemVisuals.kt +++ /dev/null @@ -1,100 +0,0 @@ -package org.polyfrost.oneconfig.internal.ui.components.item - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import org.jetbrains.skia.ColorAlphaType -import org.jetbrains.skia.ColorType -import org.jetbrains.skia.Image -import org.jetbrains.skia.ImageInfo -import org.jetbrains.skia.Paint -import org.polyfrost.compose.composables.PolyBox -import org.polyfrost.compose.composables.PolyCanvas -import org.polyfrost.compose.composables.PolyModifier -import org.polyfrost.compose.composables.PolyRow -import org.polyfrost.compose.composables.size -import org.polyfrost.compose.render.ImageLoader -import org.polyfrost.compose.render.PolyColor - -private val PlaceholderColor = PolyColor(0x40FFFFFF) -private val itemPaint = Paint() - -fun itemImage(id: String): Image? { - ImageLoader.get(itemCacheKey(id))?.let { return it } - val image = ItemCatalog.icon(id)?.toSkiaImage() ?: return null - ImageLoader.put(itemCacheKey(id), image) - return image -} - -fun evictItemImage(id: String) = ImageLoader.evictKey(itemCacheKey(id)) - -private fun itemCacheKey(id: String) = "item:$id" - -@Composable -fun rememberItemImage(id: String): Image? { - var image by remember(id) { mutableStateOf(itemImage(id)) } - LaunchedEffect(id) { - if (image == null) { - ItemCatalog.loadIcon(id) { data -> - val loaded = data?.toSkiaImage() ?: return@loadIcon - ImageLoader.put(itemCacheKey(id), loaded) - image = loaded - } - } - } - return image -} - -@Composable -fun PolyItemIcon( - id: String, - size: Float = 16f, - modifier: PolyModifier = PolyModifier, - placeholderColor: PolyColor = PlaceholderColor, -) { - val image = rememberItemImage(id) - PolyCanvas(modifier = modifier.size(size, size)) { x, y, w, h -> - if (image != null) { - image(image, x, y, w, h, itemPaint) - } else { - rectStroke(x, y, w, h, placeholderColor, strokeWidth = 1f, radius = 1f) - } - } -} - -@Composable -fun PolyItemRow( - ids: List, - size: Float = 16f, - gap: Float = 2f, - modifier: PolyModifier = PolyModifier, -) { - PolyRow(gap = gap, modifier = modifier) { - ids.forEach { id -> - PolyBox(modifier = PolyModifier.size(size, size)) { - PolyItemIcon(id, size) - } - } - } -} - -private fun ItemIconData.toSkiaImage(): Image? = runCatching { - require(width > 0 && height > 0 && argb.size >= width * height) - val bytes = ByteArray(width * height * 4) - for (i in 0 until width * height) { - val pixel = argb[i] - val offset = i * 4 - bytes[offset] = (pixel and 0xFF).toByte() - bytes[offset + 1] = (pixel shr 8 and 0xFF).toByte() - bytes[offset + 2] = (pixel shr 16 and 0xFF).toByte() - bytes[offset + 3] = (pixel shr 24 and 0xFF).toByte() - } - Image.makeRaster( - ImageInfo(width, height, ColorType.BGRA_8888, ColorAlphaType.UNPREMUL), - bytes, - width * 4, - ) -}.getOrNull() diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ItemListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ItemListOption.kt index 00698205f..2823470b6 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ItemListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ItemListOption.kt @@ -29,7 +29,7 @@ import org.polyfrost.oneconfig.internal.ui.components.item.toggleItem import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme private val ItemRowHeight = 44.dp -private val SelectorWidth = 440.dp +private val SelectorWidth = 436.dp private val SelectorShape = RoundedCornerShape(12.dp) @Composable @@ -57,7 +57,7 @@ fun ItemListOption(data: ItemListOptionData) { SimpleDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, - offset = DpOffset(0.dp, 8.dp), + offset = DpOffset((-16).dp, 8.dp), modifier = Modifier.width(SelectorWidth), ) { ItemPicker( From 965279e5361f3b149131b562625ff0e4b2995057 Mon Sep 17 00:00:00 2001 From: awruff Date: Sun, 20 Sep 2026 08:56:32 -0400 Subject: [PATCH 12/19] chore: bump to 1.2.2 --- CHANGELOG.md | 9 +++++++-- gradle.properties | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da4e6e026..77b7858c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,7 @@ -1.2.1 changelogs: -- remove ui scaling config \ No newline at end of file +1.2.2 changelogs: +- optimize item icon rendering +- HUD preview improvements +- the game is now paused while the HUD editor is open +- moved UI warm-up to startup, removing the first-open stutter +- fixed duplicate padding on accordion options +- fixed SkyCubed HUD placement before overlay bounds are available diff --git a/gradle.properties b/gradle.properties index 34fee8f89..e05e2d9c8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx4096m group=org.polyfrost.oneconfig -version=1.2.1 +version=1.2.2 ksp.incremental=false \ No newline at end of file From 725f9c79c574461d52ce2ffa348d66b83ae3775a Mon Sep 17 00:00:00 2001 From: awruff Date: Sun, 20 Sep 2026 19:09:39 -0400 Subject: [PATCH 13/19] fix(render): create the Skia GL context on Wayland/EGL --- .../ui/services/GLInterfaceFactory.kt | 106 ++++++++++++------ .../internal/ui/services/GLVulkanService.kt | 24 +++- 2 files changed, 94 insertions(+), 36 deletions(-) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/GLInterfaceFactory.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/GLInterfaceFactory.kt index e14150924..caf8a1bca 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/GLInterfaceFactory.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/GLInterfaceFactory.kt @@ -4,42 +4,46 @@ import org.jetbrains.skia.DirectContext import org.jetbrains.skia.GLAssembledInterface import org.jetbrains.skia.makeGLWithInterface import org.lwjgl.opengl.GL -import org.lwjgl.system.APIUtil -//? if >=26.1 -import org.lwjgl.system.Callback -import org.lwjgl.system.CallbackI import org.lwjgl.system.FunctionProvider import org.lwjgl.system.MemoryUtil +import org.slf4j.LoggerFactory +//? if >= 26.3 +import org.lwjgl.sdl.SDLVideo.nSDL_GL_GetProcAddress +//? if >= 26.1 { +import java.lang.foreign.Arena +import java.lang.foreign.FunctionDescriptor +import java.lang.foreign.Linker +import java.lang.foreign.MemorySegment +import java.lang.foreign.ValueLayout +import java.lang.invoke.MethodHandles +import java.lang.invoke.MethodType +//? } else { +/*import org.lwjgl.system.APIUtil +import org.lwjgl.system.CallbackI import org.lwjgl.system.Pointer import org.lwjgl.system.libffi.FFICIF import org.lwjgl.system.libffi.LibFFI -import org.slf4j.LoggerFactory -//? if >=26.1 -import java.lang.invoke.MethodHandles +*///? } internal object GLInterfaceFactory { private val LOG = LoggerFactory.getLogger(GLInterfaceFactory::class.java) - private var getProcCallback: GetProcCallback? = null private var getProcAddress: Long = 0L + @Volatile + private var provider: FunctionProvider? = null + fun makeDirectContextViaLwjgl(): DirectContext? { - val provider = try { + provider = try { GL.getFunctionProvider() } catch (e: Throwable) { LOG.warn("GLInterfaceFactory: GL.getFunctionProvider() unavailable", e) null - } ?: run { - LOG.warn("GLInterfaceFactory: no active GL function provider, cannot assemble interface") - return null } + if (provider == null) LOG.warn("GLInterfaceFactory: no GL function provider, resolving through SDL only") val getProc = try { - if (getProcAddress == 0L) { - val cb = GetProcCallback(provider) - getProcCallback = cb - getProcAddress = cb.address() - } + if (getProcAddress == 0L) getProcAddress = createGetProcStub() getProcAddress } catch (e: Throwable) { LOG.warn("GLInterfaceFactory: failed to create getProc closure", e) @@ -58,12 +62,56 @@ internal object GLInterfaceFactory { } } + @JvmStatic + private fun lookupProc(name: Long): Long { + if (name == 0L) return 0L + //? if >= 26.3 { + try { + val sdl = nSDL_GL_GetProcAddress(name) + if (sdl != 0L) return sdl + } catch (_: Throwable) { + } + //? } + return try { + provider?.getFunctionAddress(MemoryUtil.memUTF8(name)) ?: 0L + } catch (_: Throwable) { + 0L + } + } + + //? if >= 26.1 { + @JvmStatic + private fun getProcUpcall(context: MemorySegment, name: MemorySegment): MemorySegment = + MemorySegment.ofAddress(lookupProc(name.address())) + + private fun createGetProcStub(): Long { + val handle = MethodHandles.lookup().findStatic( + GLInterfaceFactory::class.java, + "getProcUpcall", + MethodType.methodType( + MemorySegment::class.java, + MemorySegment::class.java, + MemorySegment::class.java, + ), + ) + return Linker.nativeLinker().upcallStub( + handle, + FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS), + Arena.global(), + ).address() + } + //? } else { + /*private var getProcCallback: GetProcCallback? = null + + private fun createGetProcStub(): Long { + val cb = GetProcCallback() + getProcCallback = cb + return cb.address() + } + @FunctionalInterface private fun interface GetProcCallbackI : CallbackI { - //? if >=26.1 - override fun getDescriptor(): Callback.Descriptor = DESCRIPTOR - //? if <26.1 - //override fun getCallInterface(): FFICIF = CALL_INTERFACE + override fun getCallInterface(): FFICIF = CALL_INTERFACE override fun callback(ret: Long, args: Long) { val contextPtr = MemoryUtil.memGetAddress(MemoryUtil.memGetAddress(args)) @@ -82,21 +130,11 @@ internal object GLInterfaceFactory { LibFFI.ffi_type_pointer, LibFFI.ffi_type_pointer, ) - //? if >= 26.3 { - private val DESCRIPTOR: Callback.Descriptor = Callback.Descriptor(GetProcCallbackI::class.java, MethodHandles.lookup(), CALL_INTERFACE) - //?} elif >= 26.1 - //private val DESCRIPTOR: Callback.Descriptor = Callback.Descriptor(MethodHandles.lookup(), CALL_INTERFACE) } } - private class GetProcCallback( - private val provider: FunctionProvider, - ) : GetProcCallbackI { - - override fun invoke(context: Long, name: Long): Long = try { - if (name != 0L) provider.getFunctionAddress(MemoryUtil.memUTF8(name)) else 0L - } catch (_: Throwable) { - 0L - } + private class GetProcCallback : GetProcCallbackI { + override fun invoke(context: Long, name: Long): Long = lookupProc(name) } + *///? } } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/GLVulkanService.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/GLVulkanService.kt index 4d8484d5c..3ee02fc59 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/GLVulkanService.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/services/GLVulkanService.kt @@ -7,6 +7,8 @@ import org.jetbrains.skia.BackendRenderTarget import org.jetbrains.skia.DirectContext import org.jetbrains.skia.FramebufferFormat import org.jetbrains.skia.SurfaceColorFormat +//? if >= 26.3 +import org.lwjgl.sdl.SDLVideo.SDL_GetCurrentVideoDriver import org.polyfrost.oneconfig.internal.ui.RenderTargetFbo import org.slf4j.LoggerFactory @@ -15,13 +17,31 @@ object GLVulkanService : VulkanService { private val client get() = Minecraft.getInstance() override val isVulkan = false - override fun makeDirectContext(): DirectContext = - try { + override fun makeDirectContext(): DirectContext { + if (!isGlxBackend) { + GLInterfaceFactory.makeDirectContextViaLwjgl()?.let { return it } + LOG.warn("Assembled GL interface unavailable on a non-GLX backend; falling back to DirectContext.makeGL()") + } + return try { DirectContext.makeGL() } catch (e: Exception) { LOG.warn("DirectContext.makeGL() failed; retrying via LWJGL proc loader (SDL/EGL backend?)", e) GLInterfaceFactory.makeDirectContextViaLwjgl() ?: throw e } + } + + private val isGlxBackend: Boolean + get() { + //? if >= 26.3 { + val driver = try { + SDL_GetCurrentVideoDriver() + } catch (_: Throwable) { + null + } + if (driver != null) return !driver.equals("wayland", ignoreCase = true) + //?} + return true + } override fun makeBackendRenderTarget( width: Int, height: Int, From 35d0809165a675da675f3aab2ddbc4be618a2710 Mon Sep 17 00:00:00 2001 From: awruff Date: Sun, 20 Sep 2026 20:33:50 -0400 Subject: [PATCH 14/19] chore: bump to 1.2.3 --- CHANGELOG.md | 9 ++------- gradle.properties | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b7858c7..fe36cf134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,2 @@ -1.2.2 changelogs: -- optimize item icon rendering -- HUD preview improvements -- the game is now paused while the HUD editor is open -- moved UI warm-up to startup, removing the first-open stutter -- fixed duplicate padding on accordion options -- fixed SkyCubed HUD placement before overlay bounds are available +1.2.3 changelogs: +- fixed gl context not being created on wayland/egl \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index e05e2d9c8..426ff486b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx4096m group=org.polyfrost.oneconfig -version=1.2.2 +version=1.2.3 ksp.incremental=false \ No newline at end of file From a18a1fc6fb047de167efd9c2ce0cd0f8919a4b8e Mon Sep 17 00:00:00 2001 From: Saad Nadeem Date: Sun, 20 Sep 2026 16:28:07 -0400 Subject: [PATCH 15/19] chore(legacy): fix nvim formatter diff --- .../assets/oneconfig/lang/en_us.json | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index ddce228cd..451a6b0eb 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -1,9 +1,11 @@ { "oneconfig.preferences.category.gui": "GUI", + "oneconfig.preferences.keybind.title": "OneConfig Keybind", "oneconfig.preferences.keybind.description": "The keybind used to open the OneConfig menu.", "oneconfig.preferences.keybind_closes_gui.title": "Keybind Closes Menu", "oneconfig.preferences.keybind_closes_gui.description": "Pressing the OneConfig keybind while the menu or HUD editor is open closes it, instead of switching between the two.", + "oneconfig.preferences.category.hud_editor": "HUD Editor", "oneconfig.preferences.hud_settings_keybind.title": "Open HUD Settings", "oneconfig.preferences.hud_settings_keybind.description": "Opens the settings panel for the currently selected HUD in the HUD editor.", @@ -31,82 +33,119 @@ "oneconfig.preferences.hud_marquee_anim.description": "Fades the selection rectangle in and out when drag-selecting HUDs in the editor.", "oneconfig.preferences.hud_marquee_anim_duration.title": "Selection Box Animation Duration (ms)", "oneconfig.preferences.hud_marquee_anim_duration.description": "The duration of the fade animation for the drag selection rectangle in milliseconds.", + "oneconfig.preferences.background_blur.title": "Background Blur", "oneconfig.preferences.background_blur.description": "Blurs the full game background when the OneConfig menu is open.", + "oneconfig.preferences.hud_drag_ui_opacity.title": "HUD drag opacity", "oneconfig.preferences.hud_drag_ui_opacity.description": "How visible the OneConfig menu stays while you drag a HUD. 0 = fully transparent, 1 = no fade.", + "oneconfig.preferences.page_opacity.title": "Page Opacity", "oneconfig.preferences.page_opacity.description": "How opaque the OneConfig content panel background is. 0 = fully transparent, 100 = fully opaque.", + "oneconfig.preferences.sidebar_opacity.title": "Sidebar Opacity", "oneconfig.preferences.sidebar_opacity.description": "How opaque the OneConfig sidebar background is. 0 = fully transparent, 100 = fully opaque.", + "oneconfig.preferences.glow_opacity.title": "Background Glow Opacity", "oneconfig.preferences.glow_opacity.description": "How strong the accent-coloured glow behind the OneConfig menu is. 0 = hidden, 100 = fully opaque.", + "oneconfig.preferences.pause_game.title": "Pause Game", "oneconfig.preferences.pause_game.description": "Pause singleplayer worlds while the OneConfig menu is open.", + "oneconfig.preferences.reduced_res_filter.title": "Reduced-resolution filter", "oneconfig.preferences.reduced_res_filter.description": "Counteracts blur when the game renders at a reduced resolution (e.g. Sodium Extra's 'Reduce Resolution on macOS'). Only takes effect when the display is upscaling a reduced framebuffer. 'Sharpen' softly crisps the whole menu; 'Harden edges' aliases text/edges so they survive the upscale like Minecraft's font (crisper text, but blockier corners).", "oneconfig.preferences.reduced_res_filter.off": "Off", "oneconfig.preferences.reduced_res_filter.sharpen": "Sharpen", "oneconfig.preferences.reduced_res_filter.harden": "Harden edges", + "oneconfig.preferences.ui_sharpening.title": "Filter strength", "oneconfig.preferences.ui_sharpening.description": "How strongly to apply the reduced-resolution filter. Higher is crisper but can add halos (Sharpen) or jagged edges (Harden).", + "oneconfig.preferences.opening_behavior.title": "Opening Behavior", "oneconfig.preferences.opening_behavior.description": "Which page to open when the OneConfig menu is launched.", + "oneconfig.mods": "Mods", "oneconfig.preferences": "Preferences", "oneconfig.preferences.opening_behavior.previous_page": "Previous page", "oneconfig.preferences.opening_behavior.smart_reset": "Smart reset", + "oneconfig.preferences.restore_hud_editor.title": "Reopen HUD editor", "oneconfig.preferences.restore_hud_editor.description": "Reopens the HUD editor instead of the OneConfig menu when the HUD editor was the last page you closed.", + "oneconfig.preferences.show_opening_page_animation.title": "Show opening page animation", "oneconfig.preferences.show_opening_page_animation.description": "Plays the page animation when the menu is first opened.", + "oneconfig.preferences.instant_search.title": "Instant Search", "oneconfig.preferences.instant_search.description": "Focuses the search bar when the OneConfig menu opens.", + "oneconfig.preferences.show_option_action_buttons.title": "Show Option Action Buttons", "oneconfig.preferences.show_option_action_buttons.description": "Shows a more actions button on active option rows. Right-click options still opens the same menu.", + "oneconfig.preferences.flip_top_option_order.title": "Flip Top Option Order", "oneconfig.preferences.flip_top_option_order.description": "Moves the Edit HUD and Global Search buttons to the bottom of the sidebar, above your account.", + "oneconfig.preferences.time_before_reset.title": "Time before reset", "oneconfig.preferences.time_before_reset.description": "Seconds of inactivity before the smart reset returns to the default page.", + "oneconfig.preferences.category.search": "Search", + "oneconfig.preferences.search_distance.title": "Search Distance", "oneconfig.preferences.search_distance.description": "The maximum Levenshtein distance used when fuzzy-matching search queries.", + "oneconfig.preferences.category.animations": "Animations", + "oneconfig.preferences.opening_animation.title": "Opening Animation", "oneconfig.preferences.opening_animation.description": "Plays an animation when opening the OneConfig menu.", + "oneconfig.preferences.closing_animation.title": "Closing Animation", "oneconfig.preferences.closing_animation.description": "Plays an animation when closing the OneConfig menu.", + "oneconfig.preferences.opening_time.title": "Opening Time", "oneconfig.preferences.opening_time.description": "Duration of the opening and closing animations, in seconds.", + "oneconfig.preferences.show_page_animations.title": "Show Page Animations", "oneconfig.preferences.show_page_animations.description": "Animate transitions between pages.", + "oneconfig.preferences.category.sounds": "Sounds", + "oneconfig.preferences.ui_sounds.title": "UI Sounds", "oneconfig.preferences.ui_sounds.description": "Play sound effects when interacting with the OneConfig menu and HUD designer.", + "oneconfig.preferences.menu_sounds.title": "Menu Sounds", "oneconfig.preferences.menu_sounds.description": "Play sounds when opening and closing OneConfig screens.", + "oneconfig.preferences.click_sounds.title": "Click Sounds", "oneconfig.preferences.click_sounds.description": "Play sounds when clicking OneConfig controls.", + "oneconfig.preferences.slider_sounds.title": "Slider Sounds", "oneconfig.preferences.slider_sounds.description": "Play tick sounds when dragging sliders and ordered lists.", + "oneconfig.preferences.hud_editor_sounds.title": "HUD Editor Sounds", "oneconfig.preferences.hud_editor_sounds.description": "Play sounds when selecting, dragging, and resizing HUD elements.", + "oneconfig.preferences.ui_ambience.title": "UI Ambience", "oneconfig.preferences.ui_ambience.description": "Play a soft ambient loop while the OneConfig menu or HUD designer is open.", + "oneconfig.preferences.duck_music.title": "Duck Music During Ambience", "oneconfig.preferences.duck_music.description": "Lower Minecraft music while OneConfig ambience is playing.", + "oneconfig.preferences.ui_sound_volume.title": "UI Sound Volume", "oneconfig.preferences.ui_sound_volume.description": "Volume of OneConfig's UI sound effects.", + "oneconfig.preferences.ui_ambience_volume.title": "UI Ambience Volume", "oneconfig.preferences.ui_ambience_volume.description": "Volume of OneConfig's UI ambience.", + "oneconfig.preferences.page_animation_duration.title": "Page Animation Duration", "oneconfig.preferences.page_animation_duration.description": "Duration of page transition animations, in seconds.", + "oneconfig.preferences.category.general": "General", + "oneconfig.preferences.show_first_launch_message.title": "Show First Launch Message", "oneconfig.preferences.show_first_launch_message.description": "Show the welcome message the first time OneConfig is launched.", "oneconfig.notification.first_launch.title": "Welcome to OneConfig!", "oneconfig.notification.first_launch.message": "Press '%s' to open OneConfig and access all your mod settings in one place.", + "oneconfig.combat": "Combat", "oneconfig.qol": "Quality of Life", "oneconfig.hypixel": "Hypixel", @@ -118,10 +157,12 @@ "oneconfig.info": "Info", "oneconfig.player": "Player", "oneconfig.compat": "Compatibility", + "oneconfig.textinput.placeholder": "Enter text...", "oneconfig.numberinput.placeholder": "Enter a number...", "oneconfig.filepicker.placeholder": "Select a file...", "oneconfig.filepicker.browse": "Browse", + "oneconfig.itemlist.add": "Choose items", "oneconfig.itemlist.title": "Select items", "oneconfig.itemlist.search": "Search by name or ID", From 2308752e10dc38a61272a3dcf66562b0c2a32024 Mon Sep 17 00:00:00 2001 From: Saad Nadeem Date: Sun, 20 Sep 2026 19:03:08 -0400 Subject: [PATCH 16/19] feat(legacy): OSL 0.21 --- gradle/ornithe.versions.toml | 2 +- minecraft/ornithe.gradle.kts | 4 ---- minecraft/src/main/resources/ornithe.mod.json | 8 +++++--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/gradle/ornithe.versions.toml b/gradle/ornithe.versions.toml index 84f228572..1b64e5f74 100644 --- a/gradle/ornithe.versions.toml +++ b/gradle/ornithe.versions.toml @@ -1,6 +1,6 @@ [versions] loader = "0.19.3" -osl = "0.20.3" +osl = "0.21.0" feather_build = "2" ploceus = "1.17.4" diff --git a/minecraft/ornithe.gradle.kts b/minecraft/ornithe.gradle.kts index 98853d2ba..784d9f7b2 100644 --- a/minecraft/ornithe.gradle.kts +++ b/minecraft/ornithe.gradle.kts @@ -14,10 +14,6 @@ ploceus { setIntermediaryGeneration(2) } -loom { - accessWidenerPath.set(rootProject.file("minecraft/src/main/resources/oneconfigv1.classtweaker")) -} - dependencies { modImplementation(versionedCatalog["fabric-language-kotlin"]) modImplementation(versionedCatalog["fabric-loader"]) diff --git a/minecraft/src/main/resources/ornithe.mod.json b/minecraft/src/main/resources/ornithe.mod.json index 8420d5924..f0a7bde72 100644 --- a/minecraft/src/main/resources/ornithe.mod.json +++ b/minecraft/src/main/resources/ornithe.mod.json @@ -37,10 +37,12 @@ "fabricloader": ">=0.18.0", "minecraft": "${fabric_mc_version}", "lenis": ">=0.1.4", - "osl-core": ">=0.9.0", + "osl-core": ">=0.10.0", "osl-config": ">=0.6.2", - "osl-resource-loader": ">=0.7.4", - "osl-text-components": ">=0.1.0-alpha.3" + "osl-localization": ">=0.1.1", + "osl-networking": ">=0.10.0", + "osl-resource-loader": ">=0.8.0", + "osl-text-components": ">=0.1.0-alpha.5" }, "custom": { "modmenu": { From 4b4ef8505895769925c98bbc8c1d4c3c67cfd6c4 Mon Sep 17 00:00:00 2001 From: Saad Nadeem Date: Sun, 20 Sep 2026 21:20:42 -0400 Subject: [PATCH 17/19] fix(legacy): use java 25 --- buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts b/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts index 9cf16e8a8..dcaf746a9 100644 --- a/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts @@ -452,7 +452,10 @@ tasks.withType() { exclude("ornithe.mod.json") } - val mixinCompat = if (stonecutter.eval(stonecutter.current.version, ">= 26.1")) "JAVA_25" else "JAVA_21" + val mixinCompat = if ( + stonecutter.eval(stonecutter.current.version, ">= 26.1") || + stonecutter.eval(stonecutter.current.version, "= 1.8.9") + ) "JAVA_25" else "JAVA_21" this.inputs.property("mixin_compat", mixinCompat) this.filesMatching("mixins.oneconfigv1*.json") { filter { line -> line.replace("\"JAVA_21\"", "\"$mixinCompat\"") } @@ -460,7 +463,10 @@ tasks.withType() { } val minJavaVersion = 21 -val javaVersion = if (stonecutter.eval(stonecutter.current.version, ">= 26.1")) { +val javaVersion = if ( + stonecutter.eval(stonecutter.current.version, ">= 26.1") || + stonecutter.eval(stonecutter.current.version, "= 1.8.9") +) { 25 } else { 21 From be9fcadad00a607665bc84dea8cd1247fdc1315e Mon Sep 17 00:00:00 2001 From: Saad Nadeem Date: Sun, 20 Sep 2026 23:57:15 -0400 Subject: [PATCH 18/19] fix(legacy): restore vanilla space width Fixes #1058 --- .../resources/assets/oneconfig/font/legacy_default.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/minecraft/src/main/resources/assets/oneconfig/font/legacy_default.json b/minecraft/src/main/resources/assets/oneconfig/font/legacy_default.json index 7fdf97d48..b73f0071e 100644 --- a/minecraft/src/main/resources/assets/oneconfig/font/legacy_default.json +++ b/minecraft/src/main/resources/assets/oneconfig/font/legacy_default.json @@ -1,5 +1,11 @@ { "providers": [ + { + "type": "space", + "advances": { + " ": 4 + } + }, { "type": "bitmap", "file": "minecraft:font/nonlatin_european.png", From 27e5607d6f43b5c46eeea1fc720a825a4c890112 Mon Sep 17 00:00:00 2001 From: Saad Nadeem Date: Mon, 21 Sep 2026 00:11:00 -0400 Subject: [PATCH 19/19] fix(legacy): resync depth test cache so chat hovers render Fixes #1059 --- .../oneconfig/internal/ui/compose/opengl/StoredGLState.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt index c0e58e0b6..170cd3cc1 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt @@ -240,9 +240,11 @@ class StoredGLState(private val glVersion: Int) { //?} else { /*private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") private var alphaTestEnabled = false + private var depthTestEnabled = false fun capture() { alphaTestEnabled = GL11.glIsEnabled(GL11.GL_ALPHA_TEST) + depthTestEnabled = GL11.glIsEnabled(GL11.GL_DEPTH_TEST) GL11.glPushClientAttrib(GL11.GL_CLIENT_ALL_ATTRIB_BITS) GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS) @@ -292,6 +294,7 @@ class StoredGLState(private val glVersion: Int) { GL11.glPopAttrib() GL11.glPopClientAttrib() if (alphaTestEnabled) GlStateManager.enableAlphaTest() else GlStateManager.disableAlphaTest() + if (depthTestEnabled) GlStateManager.enableDepthTest() else GlStateManager.disableDepthTest() with(props) { GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, lastDrawFramebuffer[0])