From fe3b6c0b174b4a4b348d2757d0dc8f6018ad0703 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:46:55 +0200 Subject: [PATCH 01/20] Fix: load modmenu menus once render thread becomes available --- .../internal/compat/ModMenuCompat.kt | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ModMenuCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ModMenuCompat.kt index 5f2c0760f..2041a2391 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ModMenuCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ModMenuCompat.kt @@ -8,6 +8,8 @@ import org.polyfrost.oneconfig.api.config.v1.CompatSnapshots import org.polyfrost.oneconfig.api.config.v1.ConfigManager import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.api.config.v1.backend.Backend +import org.polyfrost.oneconfig.api.event.v1.EventManager +import org.polyfrost.oneconfig.api.event.v1.events.FramebufferRenderEvent import org.polyfrost.oneconfig.api.platform.v1.ModInfo import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry @@ -15,8 +17,11 @@ import org.polyfrost.oneconfig.internal.ui.api.ConfigSource import org.polyfrost.oneconfig.internal.ui.compose.impls.OneConfigUIScreen import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.atomic.AtomicBoolean object ModMenuCompat { + private val LOGGER = org.apache.logging.log4j.LogManager.getLogger("OneConfig/ModMenu-Compat") val mods: MutableList = mutableListOf() @@ -54,11 +59,13 @@ object ModMenuCompat { mod.id }.toSet() - mods.filterNot { + val foundMods = mods.filterNot { CompatLoader.nativeLoadedConfigs.contains(it.id) || it.id in nativeCoveredMods || hasNativeOcConfig(it.id) } + + foundMods .forEach { mod -> val modMenuTree = Tree.tree() @@ -107,6 +114,31 @@ object ModMenuCompat { ConfigManager.active().register(modMenuTree) CompatLoader.markFirstModAsSkip() } + + scheduleWarmup(foundMods) + } + + // Some compat layers can only load when a config UI is opened, + // this has to be done when the render thread is available. + // Do this one per frame to prevent a huge lag spike + private val warmupQueue = ConcurrentLinkedDeque() + private val warmupScheduled = AtomicBoolean(false) + + private fun scheduleWarmup(mods: List) { + if (mods.isEmpty()) return + warmupQueue.addAll(mods) + if (!warmupScheduled.compareAndSet(false, true)) return + EventManager.register(FramebufferRenderEvent.End::class.java) { _ -> warmupNext() } + } + + private fun warmupNext() { + val mod = warmupQueue.poll() ?: return + runCatching { + CompatLoader.withForcedModId(mod.id) { + // The screen is thrown away; building it is what makes the compat mixins fire. + ModMenu.getConfigScreen(mod.id, Platform.screen().current()) + } + }.onFailure { LOGGER.warn("Failed to warm up config screen for '{}'", mod.id, it) } } // A mod can ship BOTH a native OneConfig config and a Mod Menu entrypoint. The native config From a9cfb9221ab5b2fa9b4fca7090a65d9d77bf5cf8 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:59:53 +0200 Subject: [PATCH 02/20] Search providers for global search --- gradle.properties | 2 +- .../internal/ui/components/Header.kt | 112 --------------- .../internal/ui/screens/Changelog.kt | 2 +- .../internal/ui/screens/ConfigScreen.kt | 2 +- .../oneconfig/internal/ui/screens/Keybinds.kt | 2 +- .../oneconfig/internal/ui/screens/Profiles.kt | 2 +- .../ui/screens/SearchResultsScreen.kt | 17 ++- .../ui/search/DefaultSearchProvider.kt | 131 ++++++++++++++++++ .../internal/ui/search/SearchProvider.kt | 34 +++++ .../ui/search/SearchProviderRegistry.kt | 27 ++++ .../internal/ui/search/SearchResults.kt | 26 ++++ 11 files changed, 236 insertions(+), 121 deletions(-) create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchResults.kt diff --git a/gradle.properties b/gradle.properties index a9cbefd52..771593488 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx4096m group=org.polyfrost.oneconfig -version=1.0.13 +version=1.0.13+SEARCH1 ksp.incremental=false \ No newline at end of file diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt index bccadd753..21ce8bc95 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt @@ -217,118 +217,6 @@ private fun TitleInfoTooltip(title: String) { } } -internal sealed interface SearchResult { - val displayName: Any - val icon: String? -} - -internal data class ModResult(val config: ConfigData) : SearchResult { - override val displayName get() = config.title - override val icon get() = config.icon -} - -internal data class OptionResult( - val modId: String, - val modTitle: Any, - val optionTitle: Any, - val category: String?, - override val icon: String?, - val prop: Property<*>?, -) : SearchResult { - override val displayName get() = optionTitle -} - -/** - * Computes the Levenshtein (edit) distance between two strings, capped early once it exceeds [max]. - */ -private fun levenshtein(a: String, b: String, max: Int): Int { - if (a == b) return 0 - if (a.isEmpty()) return b.length - if (b.isEmpty()) return a.length - if (kotlin.math.abs(a.length - b.length) > max) return max + 1 - var prev = IntArray(b.length + 1) { it } - var curr = IntArray(b.length + 1) - for (i in 1..a.length) { - curr[0] = i - var rowMin = curr[0] - for (j in 1..b.length) { - val cost = if (a[i - 1] == b[j - 1]) 0 else 1 - curr[j] = minOf(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost) - if (curr[j] < rowMin) rowMin = curr[j] - } - if (rowMin > max) return max + 1 - val tmp = prev; prev = curr; curr = tmp - } - return prev[b.length] -} - -/** - * Returns true if [text] matches [q] either as a substring or, when "Search Distance" > 0, by a fuzzy - * (Levenshtein) match against the whole string or any of its words. [q] is expected to be lowercase. - */ -internal fun searchMatches(text: String, q: String): Boolean { - val t = text.lowercase() - if (t.contains(q)) return true - val dist = OneConfigConfig.searchDistance - if (dist <= 0) return false - if (levenshtein(t, q, dist) <= dist) return true - return t.split(' ', '.', '_', '-', '/').any { it.isNotEmpty() && levenshtein(it, q, dist) <= dist } -} - -internal fun performSearch(query: String): Map> { - if (query.isBlank()) return emptyMap() - val q = query.trim().lowercase() - val results = LinkedHashMap>() - - val matchingMods = ConfigRegistry.modCardConfigs.filter { searchMatches(it.title.asRenderText(), q) } - if (matchingMods.isNotEmpty()) { - results["Mods"] = matchingMods.map { ModResult(it) }.toMutableList() - } - - for (configData in ConfigRegistry.configs) { - if (!ConfigRegistry.shouldShowInSearch(configData)) continue - val tree = (configData as? TreeConfigData)?.tree ?: continue - val matchingOptions = mutableListOf() - tree.map.values.forEach { node -> - val descriptionMatches = node.description?.asRenderText()?.let { searchMatches(it, q) } == true - val searchTags = node.metadata?.get("searchTags")?.let { - if (it is Iterable<*>) it.mapNotNull { - if (it !is String && it !is ComponentLike) return@mapNotNull null - it.asRenderText() - } else if (it is String) listOf(it) else listOf() - }?.any { searchMatches(it, q) } == true - when (node) { - is Property<*> -> { - val title = node.title ?: return@forEach - if (searchMatches(title.asRenderText(), q) || descriptionMatches || searchTags) { - val cat = node.getMetadata("category") - matchingOptions += OptionResult(configData.id, configData.title, title, cat, configData.icon, node) - } - } - is Tree -> { - val subTitle = node.title - if (subTitle != null && searchMatches(subTitle.asRenderText(), q) ) { - val cat = node.getMetadata("category") - matchingOptions += OptionResult(configData.id, configData.title, subTitle, cat, configData.icon, null) - } - node.map.values.filterIsInstance>().forEach { prop -> - val pt = prop.title ?: return@forEach - if (searchMatches(pt.asRenderText(), q) || descriptionMatches || searchTags) { - val cat = prop.getMetadata("category") - matchingOptions += OptionResult(configData.id, configData.title, pt, cat, configData.icon, prop) - } - } - } - } - } - if (matchingOptions.isNotEmpty()) { - results[configData.title.asRenderText()] = matchingOptions - } - } - - return results -} - @Composable fun GlobalSearchBar() { val navController = LocalNavController.current diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt index 4a9d3a4e5..f477b7689 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt @@ -66,8 +66,8 @@ import org.commonmark.parser.Parser import org.polyfrost.oneconfig.internal.ui.api.ChangelogData import org.polyfrost.oneconfig.internal.ui.api.ChangelogSection import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.searchMatches import org.polyfrost.oneconfig.internal.ui.navigation.graph.ChangeLogEntryRoute +import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.Accent 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 b756f0790..2eafcea33 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 @@ -76,12 +76,12 @@ import org.polyfrost.oneconfig.internal.ui.components.localizedGroup import org.polyfrost.oneconfig.internal.ui.components.localizedTitle import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource -import org.polyfrost.oneconfig.internal.ui.components.searchMatches import org.polyfrost.oneconfig.internal.ui.components.settings.LocalOptionWidth import org.polyfrost.oneconfig.internal.ui.components.settings.Option import org.polyfrost.oneconfig.internal.ui.components.settings.OptionActionButton import org.polyfrost.oneconfig.internal.ui.components.settings.OptionContextMenu import org.polyfrost.oneconfig.internal.ui.components.settings.SwitchControl +import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt index 30a14334a..69fdf19c8 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt @@ -56,7 +56,6 @@ import org.polyfrost.oneconfig.internal.ui.components.localizedDescription import org.polyfrost.oneconfig.internal.ui.components.localizedTitle import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource -import org.polyfrost.oneconfig.internal.ui.components.searchMatches import org.polyfrost.oneconfig.internal.ui.components.settings.KeybindConflicts import org.polyfrost.oneconfig.internal.ui.components.settings.Option import org.polyfrost.oneconfig.internal.ui.components.settings.OptionActionButton @@ -66,6 +65,7 @@ import org.polyfrost.oneconfig.internal.ui.keybind.KeybindGroup import org.polyfrost.oneconfig.internal.ui.keybind.KeybindGroupCollapseStore import org.polyfrost.oneconfig.internal.ui.keybind.KeybindProviderRegistry import org.polyfrost.oneconfig.internal.ui.keybind.collectAllKeybindGroups +import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt index 4eeb777dc..36f1a20f1 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt @@ -57,7 +57,7 @@ import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource -import org.polyfrost.oneconfig.internal.ui.components.searchMatches +import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt index f006787a6..d9a61d919 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt @@ -21,17 +21,26 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.internal.ui.api.ConfigData -import org.polyfrost.oneconfig.internal.ui.components.ModResult -import org.polyfrost.oneconfig.internal.ui.components.OptionResult import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.performSearch import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry +import org.polyfrost.oneconfig.internal.ui.search.ModResult +import org.polyfrost.oneconfig.internal.ui.search.OptionResult +import org.polyfrost.oneconfig.internal.ui.search.SearchProviderRegistry @Composable fun SearchResultsScreen(query: String) { val theme = LocalTheme.current - val results by remember(query) { derivedStateOf { performSearch(query) } } + val results by remember(query) { + derivedStateOf { + SearchProviderRegistry.get().performSearch( + query = query, + configs = ConfigRegistry.modCardConfigs.filter { ConfigRegistry.shouldShowInSearch(it) }, + searchMods = true + ) + } + } val matchingMods: List = remember(results) { results.values.flatten().filterIsInstance().map { it.config } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt new file mode 100644 index 000000000..e02369585 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt @@ -0,0 +1,131 @@ +package org.polyfrost.oneconfig.internal.ui.search + +import net.kyori.adventure.text.ComponentLike +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.config.v1.Tree +import org.polyfrost.oneconfig.internal.OneConfigConfig +import org.polyfrost.oneconfig.internal.ui.api.ConfigData +import org.polyfrost.oneconfig.internal.ui.api.TreeConfigData +import org.polyfrost.oneconfig.internal.ui.components.asRenderText + +internal object DefaultSearchProvider : SearchProvider { + override val priority: Int = 0 // Low priority + + override fun isAvailable(): Boolean = true + + override fun performSearch( + query: String, + configs: List, + searchMods: Boolean + ): Map> { + if (query.isBlank()) return emptyMap() + val q = query.trim().lowercase() + val results = LinkedHashMap>() + + val matchingMods = configs.filter { searchMatches(it.title.asRenderText(), q) } + if (matchingMods.isNotEmpty()) { + results["Mods"] = matchingMods.map { ModResult(it) }.toMutableList() + } + + for (configData in configs) { + val tree = (configData as? TreeConfigData)?.tree ?: continue + val matchingOptions = mutableListOf() + tree.map.values.forEach { node -> + val descriptionMatches = node.description?.asRenderText()?.let { searchMatches(it, q) } == true + val searchTags = node.metadata?.get("searchTags")?.let { + if (it is Iterable<*>) it.mapNotNull { + if (it !is String && it !is ComponentLike) return@mapNotNull null + it.asRenderText() + } else if (it is String) listOf(it) else listOf() + }?.any { searchMatches(it, q) } == true + when (node) { + is Property<*> -> { + val title = node.title ?: return@forEach + if (searchMatches(title.asRenderText(), q) || descriptionMatches || searchTags) { + val cat = node.getMetadata("category") + matchingOptions += OptionResult( + configData.id, + configData.title, + title, + cat, + configData.icon, + node + ) + } + } + + is Tree -> { + val subTitle = node.title + if (subTitle != null && searchMatches(subTitle.asRenderText(), q)) { + val cat = node.getMetadata("category") + matchingOptions += OptionResult( + configData.id, + configData.title, + subTitle, + cat, + configData.icon, + null + ) + } + node.map.values.filterIsInstance>().forEach { prop -> + val pt = prop.title ?: return@forEach + if (searchMatches(pt.asRenderText(), q) || descriptionMatches || searchTags) { + val cat = prop.getMetadata("category") + matchingOptions += OptionResult( + configData.id, + configData.title, + pt, + cat, + configData.icon, + prop + ) + } + } + } + } + } + if (matchingOptions.isNotEmpty()) { + results[configData.title.asRenderText()] = matchingOptions + } + } + + return results + } +} + +/** + * Computes the Levenshtein (edit) distance between two strings, capped early once it exceeds [max]. + */ +private fun levenshtein(a: String, b: String, max: Int): Int { + if (a == b) return 0 + if (a.isEmpty()) return b.length + if (b.isEmpty()) return a.length + if (kotlin.math.abs(a.length - b.length) > max) return max + 1 + var prev = IntArray(b.length + 1) { it } + var curr = IntArray(b.length + 1) + for (i in 1..a.length) { + curr[0] = i + var rowMin = curr[0] + for (j in 1..b.length) { + val cost = if (a[i - 1] == b[j - 1]) 0 else 1 + curr[j] = minOf(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost) + if (curr[j] < rowMin) rowMin = curr[j] + } + if (rowMin > max) return max + 1 + val tmp = prev; prev = curr; curr = tmp + } + return prev[b.length] +} + +/** + * Returns true if [text] matches [q] either as a substring or, when "Search Distance" > 0, by a fuzzy + * (Levenshtein) match against the whole string or any of its words. [q] is expected to be lowercase. + */ +internal fun searchMatches(text: String, q: String): Boolean { + val t = text.lowercase() + if (t.contains(q)) return true + val dist = OneConfigConfig.searchDistance + if (dist <= 0) return false + if (levenshtein(t, q, dist) <= dist) return true + return t.split(' ', '.', '_', '-', '/').any { it.isNotEmpty() && levenshtein(it, q, dist) <= dist } +} \ No newline at end of file diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt new file mode 100644 index 000000000..409f72db1 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt @@ -0,0 +1,34 @@ +package org.polyfrost.oneconfig.internal.ui.search + +import org.polyfrost.oneconfig.internal.ui.api.ConfigData + + +/** + * A class responsible for searching configs + */ +interface SearchProvider { + /** + * The priority this provider has, a higher priority will be used when available + */ + val priority: Int + + /** + * Check if this search provider is currently available and ready to be used + */ + fun isAvailable(): Boolean + + /** + * Perform the search on the configs + * + * @param query The search query + * @param configs The configs to search in + * @param searchMods Whether to search include full mods as result, used by global search + * @return A map of mod name (or if searching mods, "Mods") to search results + */ + fun performSearch( + query: String, + configs: List, + searchMods: Boolean = false + ): Map> +} + diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt new file mode 100644 index 000000000..5a6dbc112 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt @@ -0,0 +1,27 @@ +package org.polyfrost.oneconfig.internal.ui.search + +/** + * Object storing all search providers + */ +object SearchProviderRegistry { + private val providers: MutableList = mutableListOf() + + /** + * Register a new search provider + */ + fun registerSearchProvider(provider: SearchProvider) { + providers.add(provider) + providers.sortByDescending { it.priority } + } + + /** + * Get the search provider with the highest priority that is currently available + */ + fun get(): SearchProvider { + return providers.first { it.isAvailable() } + } + + init { + registerSearchProvider(DefaultSearchProvider) + } +} \ No newline at end of file diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchResults.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchResults.kt new file mode 100644 index 000000000..14b9e2aa3 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchResults.kt @@ -0,0 +1,26 @@ +package org.polyfrost.oneconfig.internal.ui.search + +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.internal.ui.api.ConfigData + + +sealed interface SearchResult { + val displayName: Any + val icon: String? +} + +data class ModResult(val config: ConfigData) : SearchResult { + override val displayName get() = config.title + override val icon get() = config.icon +} + +data class OptionResult( + val modId: String, + val modTitle: Any, + val optionTitle: Any, + val category: String?, + override val icon: String?, + val prop: Property<*>?, +) : SearchResult { + override val displayName get() = optionTitle +} From 9780f0e1b6cdadd2b8ab327f8cc7983351bf5380 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:00:10 +0200 Subject: [PATCH 03/20] Add description to modinfo --- .../platform/v1/internal/CompatibilityPlatformImpl.java | 3 ++- modules/utils/api/utils.api | 9 ++++++--- .../org/polyfrost/oneconfig/api/platform/v1/ModInfo.kt | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java b/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java index 8f4167222..c14fc5b5f 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java @@ -46,7 +46,8 @@ public Set getMods() { metadata.getContributors().stream() .map(person -> person.getName()) .filter(name -> !name.isBlank()) - .collect(Collectors.joining(", ")) + .collect(Collectors.joining(", ")), + metadata.getDescription() ); }).collect(Collectors.toSet()); //? } diff --git a/modules/utils/api/utils.api b/modules/utils/api/utils.api index f2dc82069..c1b641da8 100644 --- a/modules/utils/api/utils.api +++ b/modules/utils/api/utils.api @@ -107,7 +107,8 @@ public final class org/polyfrost/oneconfig/api/platform/v1/ModInfo { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; @@ -115,12 +116,14 @@ public final class org/polyfrost/oneconfig/api/platform/v1/ModInfo { public final fun component5 ()Ljava/lang/String; public final fun component6 ()Ljava/lang/String; public final fun component7 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; - public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; + public final fun component8 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; public fun equals (Ljava/lang/Object;)Z public final fun extractIconFile ()Ljava/lang/String; public final fun getAuthors ()Ljava/lang/String; public final fun getCredits ()Ljava/lang/String; + public final fun getDescription ()Ljava/lang/String; public final fun getFile ()Ljava/nio/file/Path; public final fun getId ()Ljava/lang/String; public static final fun getLoadedMods ()Ljava/util/Set; diff --git a/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/api/platform/v1/ModInfo.kt b/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/api/platform/v1/ModInfo.kt index 9cb8b01ac..50a92eb68 100644 --- a/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/api/platform/v1/ModInfo.kt +++ b/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/api/platform/v1/ModInfo.kt @@ -13,6 +13,7 @@ data class ModInfo @JvmOverloads constructor( val modIconPath: String?, val authors: String? = null, val credits: String? = null, + val description: String? = null, ){ /** * Resolves [modIconPath] against this mod's own jar root ([file]) and copies the icon out to a From fb7e2bd65ffad80dcdfffa601ef01dc003dba0a9 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:37:32 +0200 Subject: [PATCH 04/20] Run search asynchronously --- .../ui/screens/SearchResultsScreen.kt | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt index d9a61d919..b2f0404d1 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt @@ -12,9 +12,13 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -28,18 +32,23 @@ import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.search.ModResult import org.polyfrost.oneconfig.internal.ui.search.OptionResult import org.polyfrost.oneconfig.internal.ui.search.SearchProviderRegistry +import org.polyfrost.oneconfig.internal.ui.search.SearchResult @Composable fun SearchResultsScreen(query: String) { val theme = LocalTheme.current - val results by remember(query) { - derivedStateOf { - SearchProviderRegistry.get().performSearch( - query = query, - configs = ConfigRegistry.modCardConfigs.filter { ConfigRegistry.shouldShowInSearch(it) }, - searchMods = true - ) + + // Run search asynchronously + var searchedQuery by remember { mutableStateOf(null) } + var results by remember { mutableStateOf>>(emptyMap()) } + LaunchedEffect(query) { + val configs = ConfigRegistry.modCardConfigs.filter { ConfigRegistry.shouldShowInSearch(it) } + val provider = SearchProviderRegistry.get() + val found = withContext(Dispatchers.Default) { + provider.performSearch(query = query, configs = configs, searchMods = true) } + results = found + searchedQuery = query } val matchingMods: List = remember(results) { @@ -59,7 +68,10 @@ fun SearchResultsScreen(query: String) { if (matchingMods.isEmpty() && groupedOptions.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No results for \"$query\"", color = theme.textColorSecondary, fontSize = 15.sp) + // Nothing to say until the first search comes back. + searchedQuery?.also { + Text("No results for \"$it\"", color = theme.textColorSecondary, fontSize = 15.sp) + } ?: Text("Searching...", color = theme.textColorSecondary, fontSize = 15.sp) } return } From 616ee4639af01af07cdcde6937f1848a5b3fc36f Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:32:47 +0200 Subject: [PATCH 05/20] Centralized search system with search corpus --- gradle.properties | 2 +- .../oneconfig/internal/OneConfig.java | 4 + modules/config-impl/api/config-impl.api | 5 + .../api/config/v1/ConfigManager.java | 25 +- .../oneconfig/internal/ui/api/ConfigData.kt | 1 + .../internal/ui/api/ConfigRegistry.kt | 43 ++- .../internal/ui/api/TreeConfigData.kt | 6 +- .../internal/ui/keybind/KeybindCatalog.kt | 2 + .../internal/ui/screens/ConfigScreen.kt | 267 ++++++------------ .../oneconfig/internal/ui/screens/Keybinds.kt | 57 +++- .../ui/screens/SearchResultsScreen.kt | 66 ++--- .../internal/ui/search/ConfigDocuments.kt | 145 ++++++++++ .../ui/search/DefaultSearchProvider.kt | 111 +++----- .../internal/ui/search/SearchCorpus.kt | 169 +++++++++++ .../internal/ui/search/SearchDocument.kt | 67 +++++ .../internal/ui/search/SearchProvider.kt | 20 +- .../ui/search/SearchProviderRegistry.kt | 10 +- .../internal/ui/search/SettingIndex.kt | 204 +++++++++++++ 18 files changed, 873 insertions(+), 331 deletions(-) create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt diff --git a/gradle.properties b/gradle.properties index 771593488..4428374e9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx4096m group=org.polyfrost.oneconfig -version=1.0.13+SEARCH1 +version=1.0.13+SEARCH2 ksp.incremental=false \ No newline at end of file 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 3fe58a3ed..8fd7c2d2b 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java @@ -44,6 +44,7 @@ import org.polyfrost.oneconfig.api.config.v1.ConfigManager; import org.polyfrost.oneconfig.api.event.v1.EventManager; import org.polyfrost.oneconfig.api.event.v1.events.InitializationEvent; +import org.polyfrost.oneconfig.api.event.v1.events.ResourceFinishedLoading; import org.polyfrost.oneconfig.api.event.v1.events.ScreenOpenEvent; import org.polyfrost.oneconfig.api.event.v1.events.WorldEvent; import org.polyfrost.oneconfig.api.hud.v1.HudManager; @@ -67,6 +68,7 @@ import org.polyfrost.oneconfig.internal.ui.keybind.KeybindProviderRegistry; import org.polyfrost.oneconfig.internal.ui.keybind.MinecraftKeybindProvider; import org.polyfrost.oneconfig.internal.ui.keybind.RightShiftConflicts; +import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus; import org.polyfrost.oneconfig.test.TestMod_Test; /** @@ -251,6 +253,8 @@ private static void registerEventHandlers() { org.polyfrost.oneconfig.internal.ui.themes.ThemeRegistry.INSTANCE.loadFromConfig(); }); EventManager.register(WorldEvent.Load.class, e -> showFirstLaunchNotification()); + // Initialize search corpus after loading is finished (and translation keys are available) + EventManager.register(ResourceFinishedLoading.class, e -> SearchCorpus.INSTANCE.init()); // //#if MC < 1.13 // // this is cringe but is better than the alternative of checking every frame in a mixin (that's how vanilla does it lol) // AtomicBoolean active = new AtomicBoolean(false); diff --git a/modules/config-impl/api/config-impl.api b/modules/config-impl/api/config-impl.api index cd0bc830d..c72e3c3ce 100644 --- a/modules/config-impl/api/config-impl.api +++ b/modules/config-impl/api/config-impl.api @@ -70,6 +70,7 @@ public final class org/polyfrost/oneconfig/api/config/v1/ConfigManager { public static fun active ()Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager; public static fun activeProfile ()Ljava/lang/String; public static fun addProfileChangeListener (Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager$ProfileChangeListener;)V + public static fun addTreeRegistrationListener (Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager$TreeRegistrationListener;)V public static fun backup ()Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager; public static fun collect (Ljava/lang/Object;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; public static fun core ()Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager; @@ -108,6 +109,10 @@ public abstract interface class org/polyfrost/oneconfig/api/config/v1/ConfigMana public abstract fun onProfileChanged (Ljava/lang/String;)V } +public abstract interface class org/polyfrost/oneconfig/api/config/v1/ConfigManager$TreeRegistrationListener { + public abstract fun onTreeRegistered (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)V +} + public class org/polyfrost/oneconfig/api/config/v1/KtConfig : org/polyfrost/oneconfig/api/config/v1/Config { public static final field $stable I public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/config/v1/Config$Category;Ljava/lang/String;)V diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java index 4d2e1ff3a..91eae3393 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java @@ -69,15 +69,25 @@ public final class ConfigManager { private static final Map initializedConfigs = new LinkedHashMap<>(); private static boolean rebindingProfiles = false; private static final java.util.concurrent.CopyOnWriteArrayList profileListeners = new java.util.concurrent.CopyOnWriteArrayList<>(); + private static final java.util.concurrent.CopyOnWriteArrayList treeListeners = new java.util.concurrent.CopyOnWriteArrayList<>(); public interface ProfileChangeListener { void onProfileChanged(String newProfile); } + public interface TreeRegistrationListener { + void onTreeRegistered(@NotNull Tree tree); + } + public static void addProfileChangeListener(ProfileChangeListener listener) { profileListeners.add(listener); } + @ApiStatus.Internal + public static void addTreeRegistrationListener(TreeRegistrationListener listener) { + treeListeners.add(listener); + } + public static Path profileDir(String profile) { profile = normalizeProfileName(profile, true); return profile.isEmpty() ? Paths.get("config") : PROFILES_DIR.resolve(profile); @@ -701,7 +711,20 @@ public Path getFolder() { } public Backend.RegistrationResult register(Tree t) { - return backend.register(t); + Backend.RegistrationResult result = backend.register(t); + if (this == active) notifyTreeRegistered(result.get()); + return result; + } + + private static void notifyTreeRegistered(Tree tree) { + if (tree == null || tree.getID() == null || treeListeners.isEmpty()) return; + for (TreeRegistrationListener listener : treeListeners) { + try { + listener.onTreeRegistered(tree); + } catch (Throwable t) { + LOGGER.error("Tree registration listener failed for {}", tree.getID(), t); + } + } } public boolean delete(String id) { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigData.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigData.kt index 153d827e9..c977b2009 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigData.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigData.kt @@ -15,6 +15,7 @@ interface ConfigData { val authors: String? get() = null val credits: String? get() = null val version: String? get() = null + val description: String? get() = null val source: ConfigSource val category: Config.Category val onOpen: (() -> Unit)? get() = null diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt index d10427249..1f7eb2721 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt @@ -8,11 +8,13 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import org.polyfrost.oneconfig.api.config.v1.ConfigManager import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.internal.ui.keybind.MinecraftKeybindRegistrar +import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus object ConfigRegistry { private val hiddenModCardIds = setOf( "oneconfig.json", "themes.json", + "oneconfig.builtin", // built-in huds "minecraft", "resourcefulconfig", "modmenu", @@ -54,6 +56,11 @@ object ConfigRegistry { var revision by mutableIntStateOf(0) private set + init { + // Index configs as they come in (compat layers etc...) + ConfigManager.addTreeRegistrationListener { tree -> registerTree(tree, ConfigSource.OC) } + } + fun shouldShowModCard(config: ConfigData): Boolean = config.id.lowercase() !in hiddenModCardIds && config.title.toString().lowercase() !in hiddenModCardTitles @@ -66,21 +73,30 @@ object ConfigRegistry { */ fun loadFrom(manager: ConfigManager, source: ConfigSource) { val seenIds = HashSet() + var changed = false manager.trees().forEach { tree -> tree.id?.let(seenIds::add) MinecraftKeybindRegistrar.scan(tree) - registerTree(tree, source, bumpRevision = false) + if (registerTree(tree, source, bumpRevision = false)) changed = true } - configs.removeAll { it.source == source && it.id !in seenIds } + if (configs.removeAll { it.source == source && it.id !in seenIds }) changed = true + if (!changed) return + SearchCorpus.invalidate() revision++ } + /** Returns whether the registry actually changed. */ @JvmOverloads - fun registerTree(tree: Tree, source: ConfigSource, onOpen: (() -> Unit)? = null, bumpRevision: Boolean = true) { + fun registerTree( + tree: Tree, + source: ConfigSource, + onOpen: (() -> Unit)? = null, + bumpRevision: Boolean = true + ): Boolean { MinecraftKeybindRegistrar.scan(tree) - if (tree.id == null || tree.title == null) return - if (tree.getMetadata("hidden") != null) return - upsert(TreeConfigData(tree, source, onOpen), bumpRevision) + if (tree.id == null || tree.title == null) return false + if (tree.getMetadata("hidden") != null) return false + return upsert(TreeConfigData(tree, source, onOpen), bumpRevision) } fun register(data: ConfigData) { @@ -89,6 +105,7 @@ object ConfigRegistry { fun unregister(id: String) { if (configs.removeAll { it.id == id }) { + SearchCorpus.invalidate() revision++ } } @@ -97,15 +114,27 @@ object ConfigRegistry { fun findTree(id: String): Tree? = (findById(id) as? TreeConfigData)?.tree - private fun upsert(data: ConfigData, bumpRevision: Boolean) { + private fun upsert(data: ConfigData, bumpRevision: Boolean): Boolean { val index = configs.indexOfFirst { it.id == data.id } if (index >= 0) { + if (configs[index].wraps(data)) return false configs[index] = data } else { configs.add(data) } + SearchCorpus.invalidate() if (bumpRevision) { revision++ } + return true + } + + /** + * Quick check to see if 2 config data instances provide the same (tree) information + */ + private fun ConfigData.wraps(other: ConfigData): Boolean { + if (this === other) return true + if (this !is TreeConfigData || other !is TreeConfigData) return false + return tree === other.tree && source == other.source && explicitOnOpen === other.explicitOnOpen } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TreeConfigData.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TreeConfigData.kt index 4aeb47ed2..1f48493a1 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TreeConfigData.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TreeConfigData.kt @@ -8,7 +8,7 @@ import org.polyfrost.oneconfig.internal.ui.components.asRenderText class TreeConfigData( val tree: Tree, override val source: ConfigSource, - private val explicitOnOpen: (() -> Unit)? = null, + internal val explicitOnOpen: (() -> Unit)? = null, ) : ConfigData { override val id: String get() = tree.id ?: "" override val title: Any get() = tree.getMetadata("mod_card_title") ?: tree.title ?: id @@ -56,6 +56,10 @@ class TreeConfigData( return ThirdPartyModCategories.categoryFor(id, modInfo) ?: explicit ?: Config.Category.OTHER } + override val description: String? + get() = tree.getMetadata("mod_card_description")?.nonBlankOrNull() + ?: modInfo?.description?.nonBlankOrNull() + private val modInfo: ModInfo? get() { val mods = ModInfo.loadedMods diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/KeybindCatalog.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/KeybindCatalog.kt index 639132542..c7faedd13 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/KeybindCatalog.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/KeybindCatalog.kt @@ -9,6 +9,7 @@ import org.polyfrost.oneconfig.api.config.v1.internal.ConfigVisualizer import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.TreeConfigData import org.polyfrost.oneconfig.internal.ui.components.localizedGroup +import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus data class KeybindGroup( val modId: String, @@ -36,6 +37,7 @@ object KeybindProviderRegistry { fun register(provider: KeybindGroupProvider) { if (provider in providers) return providers += provider + SearchCorpus.invalidate() revision.intValue++ } 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 2eafcea33..a35a13267 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 @@ -59,11 +59,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import java.util.function.Consumer import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.polyfrost.oneconfig.api.config.v1.Node import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.api.config.v1.Visualizer -import org.polyfrost.oneconfig.api.config.v1.internal.ConfigVisualizer import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ui.api.Tooltip import org.polyfrost.oneconfig.internal.ui.components.Chip @@ -72,7 +73,6 @@ import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.asRenderText import org.polyfrost.oneconfig.internal.ui.components.isEmptyText import org.polyfrost.oneconfig.internal.ui.components.localizedDescription -import org.polyfrost.oneconfig.internal.ui.components.localizedGroup import org.polyfrost.oneconfig.internal.ui.components.localizedTitle import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource @@ -81,32 +81,25 @@ import org.polyfrost.oneconfig.internal.ui.components.settings.Option import org.polyfrost.oneconfig.internal.ui.components.settings.OptionActionButton import org.polyfrost.oneconfig.internal.ui.components.settings.OptionContextMenu import org.polyfrost.oneconfig.internal.ui.components.settings.SwitchControl +import org.polyfrost.oneconfig.internal.ui.search.CategoryGroup +import org.polyfrost.oneconfig.internal.ui.search.ConfigListEntry +import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus +import org.polyfrost.oneconfig.internal.ui.search.SearchDocument +import org.polyfrost.oneconfig.internal.ui.search.SearchRow +import org.polyfrost.oneconfig.internal.ui.search.SearchScope +import org.polyfrost.oneconfig.internal.ui.search.SettingNode +import org.polyfrost.oneconfig.internal.ui.search.SubcategoryGroup +import org.polyfrost.oneconfig.internal.ui.search.buildCategories +import org.polyfrost.oneconfig.internal.ui.search.buildSearchIndex +import org.polyfrost.oneconfig.internal.ui.search.filterHiddenNodes +import org.polyfrost.oneconfig.internal.ui.search.flattenEntries +import org.polyfrost.oneconfig.internal.ui.search.flattenSearchEntries import org.polyfrost.oneconfig.internal.ui.search.searchMatches +import org.polyfrost.oneconfig.internal.ui.search.searchNode import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -private sealed interface SettingNode { - data class Leaf(val prop: Property<*>) : SettingNode - data class Accordion(val tree: Tree, val head: Property?, val body: List>) : SettingNode -} - -private data class CategoryGroup( - val name: String, - val subcategories: List -) - -private data class SubcategoryGroup( - val name: String, - val nodes: List -) - -private sealed interface ConfigListEntry { - data class CategoryHeader(val title: String) : ConfigListEntry - data class SubcategoryHeader(val title: String) : ConfigListEntry - data class Item(val node: SettingNode) : ConfigListEntry -} - @Composable fun ConfigScreen(tree: Tree, initialCategory: String? = null, pageKey: String) { val categories = remember(tree) { buildCategories(tree) } @@ -138,18 +131,24 @@ fun ConfigScreen(tree: Tree, initialCategory: String? = null, pageKey: String) { } val revision = rememberDisplayRevision(categories) - val entries = remember(categories, selectedCategory, localSearchQuery, revision) { - if (localSearchQuery.isBlank()) { - selectedCategory?.let(::filterHiddenNodes)?.let(::flattenEntries).orEmpty() - } else { - flattenSearchEntries(filterCategories(categories, localSearchQuery).mapNotNull(::filterHiddenNodes)) + val index = remember(categories) { buildSearchIndex(categories) } + val results = rememberSearchResults(index, localSearchQuery, pageKey) + val entries = remember(index, selectedCategory, localSearchQuery, revision, results) { + when { + localSearchQuery.isBlank() -> + selectedCategory?.let(::filterHiddenNodes)?.let(::flattenEntries).orEmpty() + results == null -> emptyList() + else -> flattenSearchEntries(searchCategories(results)) } } if (entries.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - val message = if (localSearchQuery.isBlank()) "No settings available." - else "No settings match \"$localSearchQuery\"" + val message = when { + localSearchQuery.isBlank() -> "No settings available." + results == null -> "Searching..." + else -> "No settings match \"$localSearchQuery\"" + } Text(message, color = LocalTheme.current.textColorSecondary) } return@Column @@ -162,16 +161,7 @@ fun ConfigScreen(tree: Tree, initialCategory: String? = null, pageKey: String) { verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(end = 16.dp) ) { - items(entries) { entry -> - when (entry) { - is ConfigListEntry.CategoryHeader -> CategoryHeader(entry.title) - is ConfigListEntry.SubcategoryHeader -> SubcategoryHeader(entry.title) - is ConfigListEntry.Item -> when (val node = entry.node) { - is SettingNode.Leaf -> SettingRow(node.prop) - is SettingNode.Accordion -> AccordionRow(node) - } - } - } + items(entries) { entry -> ConfigListRow(entry) } } VerticalScrollbar( adapter = rememberScrollbarAdapter(lazyListState), @@ -181,52 +171,6 @@ fun ConfigScreen(tree: Tree, initialCategory: String? = null, pageKey: String) { } } -private fun flattenEntries(category: CategoryGroup): List { - val showHeaders = category.subcategories.size > 1 || category.subcategories.any { - it.name != ConfigVisualizer.DEFAULT_SUBCATEGORY - } - return buildList { - category.subcategories.forEach { subcategory -> - if (showHeaders) { - add(ConfigListEntry.SubcategoryHeader(subcategory.name)) - } - subcategory.nodes.forEach { add(ConfigListEntry.Item(it)) } - } - } -} - -private fun flattenSearchEntries(categories: List): List { - return buildList { - val showCategoryHeaders = categories.size > 1 - categories.forEach { category -> - if (showCategoryHeaders) { - add(ConfigListEntry.CategoryHeader(category.name)) - } - addAll(flattenEntries(category)) - } - } -} - -/** - * Drop nodes which are currently hidden by an unmet dependency, so that they never occupy an entry in the - * (lazy) setting list. Emitting a zero-height row for them instead leaves stray gaps and empty headers, and the - * row cannot observe its own display state while it is scrolled out of composition. - */ -private fun filterHiddenNodes(category: CategoryGroup): CategoryGroup? { - val subcategories = category.subcategories.mapNotNull { subcategory -> - val nodes = subcategory.nodes.filter { node -> - when (node) { - is SettingNode.Leaf -> !node.prop.isHidden() - is SettingNode.Accordion -> node.head?.isHidden() == false || node.body.any { !it.isHidden() } - } - } - if (nodes.isEmpty()) null else subcategory.copy(nodes = nodes) - } - return if (subcategories.isEmpty()) null else category.copy(subcategories = subcategories) -} - -private fun Property<*>.isHidden(): Boolean = display == Property.Display.HIDDEN - private fun categoryProperties(categories: List): List> { return categories.flatMap { category -> category.subcategories.flatMap { subcategory -> @@ -256,6 +200,38 @@ private fun rememberDisplayRevision(categories: List): Int { return revision } +@Composable +private fun rememberSearchResults( + index: Map, + query: String, + pageKey: String, +): Map>>? { + var results by remember(pageKey) { mutableStateOf>>?>(null) } + LaunchedEffect(index, query, pageKey) { + results = if (query.isBlank()) emptyMap() else withContext(Dispatchers.Default) { + SearchCorpus.searchGrouped(query, setOf(SearchScope.Config(pageKey))) { document -> + (document.payload as? Node)?.let(index::get) + } + } + } + return results +} + +/** + * Rebuild categories from matched search results + */ +private fun searchCategories(grouped: Map>>): List { + val byCategory = LinkedHashMap>>() + grouped.forEach { (row, documents) -> + if (row == null || documents.isEmpty()) return@forEach + val node = searchNode(row.node, documents) ?: return@forEach + byCategory.getOrPut(row.category) { LinkedHashMap() }.getOrPut(row.subcategory) { ArrayList() } += node + } + return byCategory.map { (category, subcategories) -> + CategoryGroup(category, subcategories.map { (name, nodes) -> SubcategoryGroup(name, nodes) }) + } +} + private fun filterCategories(categories: List, query: String): List { val q = query.lowercase() return categories.mapNotNull { category -> @@ -301,60 +277,6 @@ private fun Tree.matchesLocalSearch(category: String, subcategory: String, query .any { searchMatches(it.asRenderText(), query) } } -private fun buildCategories(tree: Tree): List { - val grouped = LinkedHashMap>>() - tree.map.values.forEach { node -> - val category = nodeGroup(node, "category", ConfigVisualizer.DEFAULT_CATEGORY) - val subcategory = nodeGroup(node, "subcategory", ConfigVisualizer.DEFAULT_SUBCATEGORY) - val bucket = grouped.getOrPut(category) { LinkedHashMap() }.getOrPut(subcategory) { ArrayList() } - - when (node) { - is Property<*> -> { - if (isRenderableProperty(node)) { - bucket += SettingNode.Leaf(node) - } - } - is Tree -> buildAccordionNode(node)?.let(bucket::add) - } - } - - return grouped.mapNotNull { (category, subcategories) -> - val groups = subcategories.mapNotNull { (subcategory, nodes) -> - if (nodes.isEmpty()) null else SubcategoryGroup(subcategory, nodes.toList()) - } - if (groups.isEmpty()) null else CategoryGroup(category, groups) - } -} - -private fun buildAccordionNode(tree: Tree): SettingNode.Accordion? { - val properties = tree.map.values.filterIsInstance>() - if (properties.isEmpty()) { - return null - } - - @Suppress("UNCHECKED_CAST") - val head = properties.firstOrNull(::isAccordionToggle) as? Property - val body = properties - .filter { it !== head } - .filter(::isRenderableProperty) - - if (body.isEmpty()) { - return null - } - - return SettingNode.Accordion(tree, head, body) -} - -private fun isAccordionToggle(prop: Property<*>): Boolean { - val isBoolean = prop.type == Boolean::class.java || prop.type == Boolean::class.javaPrimitiveType - return isBoolean && prop.getMetadata("visualizer") == null -} - -private fun isRenderableProperty(prop: Property<*>): Boolean { - if (prop.getMetadata("hidden") != null) return false - return (prop.getMetadata("visualizer") != null) || prop.canDisplay() -} - private fun isWideControl(prop: Property<*>): Boolean { return when (prop.getMetadata("visualizer")) { Visualizer.SliderVisualizer::class.java -> true @@ -363,8 +285,26 @@ private fun isWideControl(prop: Property<*>): Boolean { } } -private fun nodeGroup(node: Node, key: String, default: String): String { - return node.localizedGroup(key, "${key}Key", default) +/** + * Renders one entry of a flattened settings list. [compact] decides per node whether its row stacks the label above the + * control, which the HUD editor needs for its narrower column. + */ +@Composable +internal fun ConfigListRow(entry: ConfigListEntry, compact: (SettingNode) -> Boolean = { false }) { + when (entry) { + is ConfigListEntry.CategoryHeader -> CategoryHeader(entry.title) + is ConfigListEntry.SubcategoryHeader -> SubcategoryHeader(entry.title) + is ConfigListEntry.Item -> SettingEntryRow(entry.node, compact(entry.node)) + } +} + +/** Renders one settings row */ +@Composable +internal fun SettingEntryRow(node: SettingNode, compact: Boolean = false) { + when (node) { + is SettingNode.Leaf -> SettingRow(node.prop, compact = compact) + is SettingNode.Accordion -> AccordionRow(node, compact = compact) + } } @Composable @@ -832,7 +772,7 @@ fun HudConfigScreen(tree: Tree, initialCategory: String? = null) { val filteredTree = remember(tree) { tree } - val categories = remember(filteredTree) { buildHudCategories(filteredTree) } + val categories = remember(filteredTree) { buildCategories(filteredTree) { !isHudInternal(it) } } val localSearchQuery = if (ShellState.globalSearchActive) "" else ShellState.searchQuery.trim() var selectedCategory by remember(filteredTree, initialCategory) { mutableStateOf( @@ -878,16 +818,7 @@ fun HudConfigScreen(tree: Tree, initialCategory: String? = null) { } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - entries.forEach { entry -> - when (entry) { - is ConfigListEntry.CategoryHeader -> CategoryHeader(entry.title) - is ConfigListEntry.SubcategoryHeader -> SubcategoryHeader(entry.title) - is ConfigListEntry.Item -> when (val node = entry.node) { - is SettingNode.Leaf -> SettingRow(node.prop, compact = isWideControl(node.prop)) - is SettingNode.Accordion -> AccordionRow(node, compact = node.body.any(::isWideControl)) - } - } - } + entries.forEach { entry -> ConfigListRow(entry, ::hasWideControl) } } } } @@ -897,30 +828,8 @@ private fun isHudInternal(node: Node): Boolean { return node.getMetadata("hudInternal") != null } -private fun buildHudCategories(tree: Tree): List { - val grouped = LinkedHashMap>>() - tree.map.values.forEach { node -> - // skip hudInternal nodes - if (isHudInternal(node)) return@forEach - - val category = nodeGroup(node, "category", ConfigVisualizer.DEFAULT_CATEGORY) - val subcategory = nodeGroup(node, "subcategory", ConfigVisualizer.DEFAULT_SUBCATEGORY) - val bucket = grouped.getOrPut(category) { LinkedHashMap() }.getOrPut(subcategory) { ArrayList() } - - when (node) { - is Property<*> -> { - if (isRenderableProperty(node)) { - bucket += SettingNode.Leaf(node) - } - } - is Tree -> buildAccordionNode(node)?.let(bucket::add) - } - } - - return grouped.mapNotNull { (category, subcategories) -> - val groups = subcategories.mapNotNull { (subcategory, nodes) -> - if (nodes.isEmpty()) null else SubcategoryGroup(subcategory, nodes.toList()) - } - if (groups.isEmpty()) null else CategoryGroup(category, groups) - } +/** The HUD editor's column is narrow, so any row holding a wide control stacks its label above it. */ +private fun hasWideControl(node: SettingNode): Boolean = when (node) { + is SettingNode.Leaf -> isWideControl(node.prop) + is SettingNode.Accordion -> node.body.any(::isWideControl) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt index 69fdf19c8..356957b24 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.animation.core.animateFloatAsState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -44,7 +45,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import java.util.IdentityHashMap import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry @@ -65,6 +69,9 @@ import org.polyfrost.oneconfig.internal.ui.keybind.KeybindGroup import org.polyfrost.oneconfig.internal.ui.keybind.KeybindGroupCollapseStore import org.polyfrost.oneconfig.internal.ui.keybind.KeybindProviderRegistry import org.polyfrost.oneconfig.internal.ui.keybind.collectAllKeybindGroups +import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus +import org.polyfrost.oneconfig.internal.ui.search.SearchDocument +import org.polyfrost.oneconfig.internal.ui.search.SearchScope import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -84,14 +91,17 @@ fun Keybinds() { val providerRevision = KeybindProviderRegistry.revision.intValue val groups = remember(revision, providerRevision, configs) { collectAllKeybindGroups() } val localSearchQuery = if (ShellState.globalSearchActive) "" else ShellState.searchQuery.trim() - val visibleGroups = remember(groups, localSearchQuery) { - if (localSearchQuery.isBlank()) groups else filterKeybindGroups(groups, localSearchQuery) - } + val searchResults = rememberKeybindSearchResults(groups, localSearchQuery) + val visibleGroups = if (localSearchQuery.isBlank()) groups else searchResults.orEmpty() if (visibleGroups.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - val message = if (localSearchQuery.isBlank()) "No keybinds available." - else "No keybinds match \"$localSearchQuery\"" + val message = when { + localSearchQuery.isBlank() -> "No keybinds available." + // Nothing to say until the first search comes back. + searchResults == null -> "Searching..." + else -> "No keybinds match \"$localSearchQuery\"" + } Text(message, color = LocalTheme.current.textColorSecondary, fontSize = 15.sp) } return @@ -139,19 +149,36 @@ fun Keybinds() { } } -private fun filterKeybindGroups(groups: List, query: String): List { - val q = query.lowercase() - return groups.mapNotNull { group -> - val groupMatches = searchMatches(group.modTitle.asRenderText(), q) || searchMatches(group.modId, q) - val entries = if (groupMatches) group.entries else group.entries.filter { it.matchesSearch(q) } - if (entries.isEmpty()) null else group.copy(entries = entries) +/** The group and entry one keybind property renders as, for resolving corpus hits back to rows. */ +private class KeybindOwner(val group: KeybindGroup, val entry: KeybindEntry) + +@Composable +private fun rememberKeybindSearchResults(groups: List, query: String): List? { + var results by remember { mutableStateOf?>(null) } + LaunchedEffect(groups, query) { + results = if (query.isBlank()) null + else withContext(Dispatchers.Default) { searchKeybindGroups(groups, query) } } + return results } -private fun KeybindEntry.matchesSearch(query: String): Boolean { - val prop = this.prop - return listOfNotNull(path, category, subcategory, prop.title, prop.id, prop.description) - .any { searchMatches(it.asRenderText(), query) } +private fun searchKeybindGroups(groups: List, query: String): List { + val owners = IdentityHashMap, KeybindOwner>() + groups.forEach { group -> group.entries.forEach { owners[it.prop] = KeybindOwner(group, it) } } + fun ownerOf(document: SearchDocument<*>) = (document.payload as? Property<*>)?.let(owners::get) + + val hits = SearchCorpus.searchGrouped(query, setOf(SearchScope.Keybinds)) { ownerOf(it)?.group?.modId } + .mapNotNull { (modId, documents) -> + if (modId == null) return@mapNotNull null + modId to documents.mapNotNull { ownerOf(it)?.entry } + }.toMap() + + // Group headers are not corpus documents, so surface them here explicitly + val q = query.lowercase() + return groups.mapNotNull { group -> + if (searchMatches(group.modTitle.asRenderText(), q) || searchMatches(group.modId, q)) return@mapNotNull group + hits[group.modId]?.takeIf { it.isNotEmpty() }?.let { group.copy(entries = it) } + } } @Composable diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt index b2f0404d1..6971b6422 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt @@ -1,38 +1,28 @@ package org.polyfrost.oneconfig.internal.ui.screens import androidx.compose.foundation.VerticalScrollbar -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter -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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.polyfrost.oneconfig.internal.ui.api.ConfigData import org.polyfrost.oneconfig.internal.ui.components.Text +import org.polyfrost.oneconfig.internal.ui.search.GlobalSettingIndex +import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus +import org.polyfrost.oneconfig.internal.ui.search.SearchDocument +import org.polyfrost.oneconfig.internal.ui.search.SearchRow +import org.polyfrost.oneconfig.internal.ui.search.SearchScope +import org.polyfrost.oneconfig.internal.ui.search.SettingNode +import org.polyfrost.oneconfig.internal.ui.search.searchNode import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import org.polyfrost.oneconfig.api.config.v1.Property -import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry -import org.polyfrost.oneconfig.internal.ui.search.ModResult -import org.polyfrost.oneconfig.internal.ui.search.OptionResult -import org.polyfrost.oneconfig.internal.ui.search.SearchProviderRegistry -import org.polyfrost.oneconfig.internal.ui.search.SearchResult @Composable fun SearchResultsScreen(query: String) { @@ -40,30 +30,28 @@ fun SearchResultsScreen(query: String) { // Run search asynchronously var searchedQuery by remember { mutableStateOf(null) } - var results by remember { mutableStateOf>>(emptyMap()) } + var results by remember { mutableStateOf>>>(emptyMap()) } LaunchedEffect(query) { - val configs = ConfigRegistry.modCardConfigs.filter { ConfigRegistry.shouldShowInSearch(it) } - val provider = SearchProviderRegistry.get() val found = withContext(Dispatchers.Default) { - provider.performSearch(query = query, configs = configs, searchMods = true) + SearchCorpus.searchGrouped(query, setOf(SearchScope.Mods, SearchScope.Options), GlobalSettingIndex::rowOf) } results = found searchedQuery = query } val matchingMods: List = remember(results) { - results.values.flatten().filterIsInstance().map { it.config } + results[null].orEmpty().map { it.payload }.filterIsInstance() } - val groupedOptions: Map>> = remember(results) { - val map = LinkedHashMap>>() - results.forEach { (group, items) -> - items.filterIsInstance().forEach { opt -> - if (opt.prop != null) { - map.getOrPut(group) { mutableListOf() }.add(opt.prop) - } - } + // Grouped by owning mod, in order of first appearance, so a mod is only headed once while keeping its best hit's + // rank. Accordions collapse into one row per accordion rather than one per matching option inside it. + val groupedOptions: Map> = remember(results) { + val byMod = LinkedHashMap>() + results.forEach { (row, documents) -> + if (row == null || documents.isEmpty()) return@forEach + val node = searchNode(row.node, documents) ?: return@forEach + byMod.getOrPut(row.modTitle ?: "Other") { ArrayList() } += node } - map + byMod } if (matchingMods.isEmpty() && groupedOptions.isEmpty()) { @@ -113,7 +101,7 @@ fun SearchResultsScreen(query: String) { } } - groupedOptions.forEach { (group, props) -> + groupedOptions.forEach { (group, nodes) -> item(key = "header:opts:$group") { Text( group.uppercase(), @@ -123,9 +111,9 @@ fun SearchResultsScreen(query: String) { modifier = Modifier.padding(top = 8.dp, bottom = 2.dp) ) } - props.forEachIndexed { idx, prop -> - item(key = "opt:$group:${prop.id}:$idx") { - SettingRow(prop) + nodes.forEachIndexed { idx, node -> + item(key = "opt:$group:$idx") { + SettingEntryRow(node) } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt new file mode 100644 index 000000000..4c00a1603 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt @@ -0,0 +1,145 @@ +package org.polyfrost.oneconfig.internal.ui.search + +import net.kyori.adventure.text.ComponentLike +import org.polyfrost.oneconfig.api.config.v1.Node +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.config.v1.Tree +import org.polyfrost.oneconfig.api.config.v1.dsl.subcategory +import org.polyfrost.oneconfig.api.config.v1.internal.ConfigVisualizer +import org.polyfrost.oneconfig.internal.ui.api.ConfigData +import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry +import org.polyfrost.oneconfig.internal.ui.api.TreeConfigData +import org.polyfrost.oneconfig.internal.ui.components.asRenderText +import org.polyfrost.oneconfig.internal.ui.components.localizedDescription +import org.polyfrost.oneconfig.internal.ui.components.localizedGroup +import org.polyfrost.oneconfig.internal.ui.components.localizedTitle +import org.polyfrost.oneconfig.internal.ui.keybind.KeybindProviderRegistry +import org.polyfrost.oneconfig.internal.ui.keybind.isKeybindProperty + +private const val ID_SEPARATOR = "::" + +/** + * Add all properties of a mod, as well as the mod itself. + */ +object ConfigDocumentSource : SearchDocumentSource { + override fun documents(): List> { + val documents = ArrayList>() + ConfigRegistry.configs.toList().forEach { config -> + val searchable = ConfigRegistry.shouldShowInSearch(config) + if (searchable && ConfigRegistry.shouldShowModCard(config)) { + documents += modDocument(config) + } + + val tree = (config as? TreeConfigData)?.tree ?: return@forEach + val scopes = mutableSetOf(SearchScope.Config(config.id)) + if (searchable) scopes += SearchScope.Options // Global search + + documents += treeDocuments( + tree = tree, + ownerId = config.id, + modTitle = config.title.asRenderText(), + scopes = scopes, + ) + } + return documents + } +} + +/** + * Keybinds from outside OneConfig, like MC keybinds + */ +object KeybindDocumentSource : SearchDocumentSource { + override fun documents(): List>> { + + return KeybindProviderRegistry.groups().flatMap { group -> + val modTitle = group.modTitle.asRenderText() + group.entries.map { entry -> + SearchDocument( + id = "${group.modId}$ID_SEPARATOR${entry.path}", + scopes = setOf(SearchScope.Keybinds), + metadata = SearchMetadata( + title = entry.prop.localizedTitle().asRenderText().takeIf { it.isNotBlank() }, + id = entry.prop.id?.takeIf { it.isNotBlank() }, + description = entry.prop.localizedDescription()?.asRenderText()?.takeIf { it.isNotBlank() }, + section = null, + category = entry.category.asRenderText().takeIf { it.isNotBlank() }, + subcategory = entry.subcategory.asRenderText().takeIf { it.isNotBlank() }, + modTitle = modTitle.asRenderText().takeIf { it.isNotBlank() }, + path = entry.path.asRenderText().takeIf { it.isNotBlank() }, + ), + payload = entry.prop, + ) + } + } + } +} + +private fun modDocument(config: ConfigData): SearchDocument { + val tree = (config as? TreeConfigData)?.tree + return SearchDocument( + id = "mod$ID_SEPARATOR${config.id}", + scopes = setOf(SearchScope.Mods), + metadata = SearchMetadata( + title = config.title.asRenderText().takeIf { it.isNotBlank() }, + id = config.id, + description = config.description?.asRenderText()?.takeIf { it.isNotBlank() }, + category = config.category.asRenderText().takeIf { it.isNotBlank() }, + subcategory = tree?.subcategory?.asRenderText()?.takeIf { it.isNotBlank() }, + ), + payload = config, + ) +} + +private fun treeDocuments( + tree: Tree, + ownerId: String, + modTitle: String?, + scopes: Set, + include: (Node) -> Boolean = { true }, +): List> { + val documents = ArrayList>() + + fun walk(node: Node, path: String, category: String, subcategory: String, section: String?) { + if (!include(node)) return + if (node.getMetadata("hidden") != null) return + + val documentScopes = if (node is Property<*> && node.isKeybindProperty()) scopes + SearchScope.Keybinds + else scopes + + val title = node.localizedTitle().asRenderText().takeIf { it.isNotBlank() } + val nodeCategory = node.localizedGroup("category", "categoryKey", category) + val nodeSubcategory = node.localizedGroup("subcategory", "subcategoryKey", subcategory) + val searchTags = node.metadata?.get("searchTags")?.let { + if (it is Iterable<*>) it.mapNotNull { + if (it !is String && it !is ComponentLike) return@mapNotNull null + it.asRenderText() + } else if (it is String) listOf(it) else listOf() + } ?: emptyList() + documents += SearchDocument( + id = "$ownerId$ID_SEPARATOR$path", + scopes = documentScopes, + metadata = SearchMetadata( + title = title, + id = node.id?.takeIf { it.isNotBlank() }, + description = node.localizedDescription()?.asRenderText()?.takeIf { it.isNotBlank() }, + section = section, + category = nodeCategory.takeIf { it.isNotBlank() }, + subcategory = nodeSubcategory.takeIf { it.isNotBlank() }, + modTitle = modTitle?.takeIf { it.isNotBlank() }, + tags = searchTags, + ), + payload = node, + ) + + if (node is Tree) { + node.map.forEach { (id, child) -> + walk(child, "$path.$id", nodeCategory, nodeSubcategory, title) + } + } + } + + tree.map.forEach { (id, node) -> + walk(node, id, ConfigVisualizer.DEFAULT_CATEGORY, ConfigVisualizer.DEFAULT_SUBCATEGORY, null) + } + return documents +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt index e02369585..a5bf06f91 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt @@ -1,95 +1,50 @@ package org.polyfrost.oneconfig.internal.ui.search -import net.kyori.adventure.text.ComponentLike -import org.polyfrost.oneconfig.api.config.v1.Property -import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.internal.OneConfigConfig -import org.polyfrost.oneconfig.internal.ui.api.ConfigData -import org.polyfrost.oneconfig.internal.ui.api.TreeConfigData -import org.polyfrost.oneconfig.internal.ui.components.asRenderText internal object DefaultSearchProvider : SearchProvider { override val priority: Int = 0 // Low priority override fun isAvailable(): Boolean = true - override fun performSearch( + override fun search( query: String, - configs: List, - searchMods: Boolean - ): Map> { - if (query.isBlank()) return emptyMap() + scopes: Set + ): List> { + val corpus = SearchCorpus.corpus val q = query.trim().lowercase() - val results = LinkedHashMap>() - - val matchingMods = configs.filter { searchMatches(it.title.asRenderText(), q) } - if (matchingMods.isNotEmpty()) { - results["Mods"] = matchingMods.map { ModResult(it) }.toMutableList() - } - - for (configData in configs) { - val tree = (configData as? TreeConfigData)?.tree ?: continue - val matchingOptions = mutableListOf() - tree.map.values.forEach { node -> - val descriptionMatches = node.description?.asRenderText()?.let { searchMatches(it, q) } == true - val searchTags = node.metadata?.get("searchTags")?.let { - if (it is Iterable<*>) it.mapNotNull { - if (it !is String && it !is ComponentLike) return@mapNotNull null - it.asRenderText() - } else if (it is String) listOf(it) else listOf() - }?.any { searchMatches(it, q) } == true - when (node) { - is Property<*> -> { - val title = node.title ?: return@forEach - if (searchMatches(title.asRenderText(), q) || descriptionMatches || searchTags) { - val cat = node.getMetadata("category") - matchingOptions += OptionResult( - configData.id, - configData.title, - title, - cat, - configData.icon, - node - ) - } - } - - is Tree -> { - val subTitle = node.title - if (subTitle != null && searchMatches(subTitle.asRenderText(), q)) { - val cat = node.getMetadata("category") - matchingOptions += OptionResult( - configData.id, - configData.title, - subTitle, - cat, - configData.icon, - null - ) - } - node.map.values.filterIsInstance>().forEach { prop -> - val pt = prop.title ?: return@forEach - if (searchMatches(pt.asRenderText(), q) || descriptionMatches || searchTags) { - val cat = prop.getMetadata("category") - matchingOptions += OptionResult( - configData.id, - configData.title, - pt, - cat, - configData.icon, - prop - ) - } - } - } - } - } - if (matchingOptions.isNotEmpty()) { - results[configData.title.asRenderText()] = matchingOptions + return corpus.values.filter { + if (it.scopes.intersect(scopes).isEmpty()) return@filter false + if (it.scopes.contains(SearchScope.Mods)) { + return@filter it.metadata.title != null && searchMatches(it.metadata.title, q) } + if (listOfNotNull(it.metadata.title, it.metadata.description).any { p -> + searchMatches(p, q) + } || it.metadata.tags.any { t -> searchMatches(t, q) }) return@filter true + // Match old search for keybinds + if (scopes.contains(SearchScope.Keybinds) && listOfNotNull( + it.metadata.category, + it.metadata.distinctSubcategory, + it.metadata.id, it.metadata.path + ).any { k -> searchMatches(k, q) } + ) return@filter true + false } + } + + override fun searchGrouped( + query: String, + scopes: Set, + grouper: (SearchDocument<*>) -> T + ): Map>> { + return search(query, scopes).groupBy(grouper) + } - return results + override suspend fun onCorpusUpdate( + added: List>, + removed: Set + ) { + // No-op, we just use the corpus directly } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt new file mode 100644 index 000000000..85db8b18a --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt @@ -0,0 +1,169 @@ +package org.polyfrost.oneconfig.internal.ui.search + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.apache.logging.log4j.LogManager +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration.Companion.milliseconds + +/** How long to wait for further config registrations before rebuilding. */ +private const val REBUILD_DEBOUNCE_MS = 250L + +/** + * Owns the searchable corpus and keeps every registered [SearchProvider] indexed in the background. + * + * Indexing is push-based and incremental. Configs register over the course of startup and mods may register + * later still, so rebuilds are coalesced and only the documents whose text actually changed are forwarded - + * otherwise a single late registration would re-index everything. + */ +object SearchCorpus { + private val LOGGER = LogManager.getLogger("OneConfig/Search") + + private var initialized = AtomicBoolean(false) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val rebuildMutex = Mutex() + private val sources = ArrayList() + private var rebuildJob: Job? = null + + @Volatile + var corpus: Map> = emptyMap() + private set + + init { + registerSource(ConfigDocumentSource) + registerSource(KeybindDocumentSource) + } + + fun registerSource(source: SearchDocumentSource) { + synchronized(sources) { + if (source in sources) return + sources += source + } + invalidate() + } + + fun unregisterSource(source: SearchDocumentSource) { + synchronized(sources) { + if (!sources.remove(source)) return + } + invalidate() + } + + /** + * Called when resources are done loading, prevents a lot of corpus builds during initial loading + */ + fun init() { + if (initialized.getAndSet(true)) return + invalidate() + } + + /** + * Schedules a coalesced background rebuild. Cheap enough to call from every config mutation. + */ + fun invalidate() { + if (!initialized.get()) return + synchronized(this) { + rebuildJob?.cancel() + rebuildJob = scope.launch { + delay(REBUILD_DEBOUNCE_MS.milliseconds) + rebuild() + } + } + } + + /** + * Runs [query] against [scope] on the highest-priority available provider. + */ + fun search(query: String, scope: Set): List> { + if (query.isBlank()) return emptyList() + val provider = SearchProviderRegistry.get() + val hits = try { + provider.search(query, scope) + } catch (e: Throwable) { + LOGGER.error("Search provider ${provider.javaClass.name} failed, falling back to the default", e) + if (provider === DefaultSearchProvider) return emptyList() + runCatching { DefaultSearchProvider.search(query, scope) }.onFailure { + LOGGER.error("Default search provider failed", it) + }.getOrDefault(emptyList()) + } + + // Re-resolve against current corpus in case of stale search results + val current = corpus + return hits.mapNotNull { hit -> current[hit.id] } + } + + fun searchGrouped( + query: String, + scope: Set, + grouper: (SearchDocument<*>) -> T + ): Map>> { + if (query.isBlank()) return emptyMap() + val provider = SearchProviderRegistry.get() + val hits = try { + provider.searchGrouped(query, scope, grouper) + } catch (e: Throwable) { + LOGGER.error("Search provider ${provider.javaClass.name} failed, falling back to the default", e) + if (provider === DefaultSearchProvider) return emptyMap() + runCatching { DefaultSearchProvider.searchGrouped(query, scope, grouper) }.onFailure { + LOGGER.error("Default search provider failed", it) + }.getOrDefault(emptyMap()) + } + + // Re-resolve against current corpus in case of stale search results + val current = corpus + return hits.mapValues { (_, group) -> group.mapNotNull { hit -> current[hit.id] } } + } + + /** Hands a freshly registered provider the corpus that already exists. */ + internal fun seed(provider: SearchProvider) { + if (!initialized.get()) return + scope.launch { + val documents = rebuildMutex.withLock { corpus.values.toList() } + if (documents.isEmpty()) return@launch + runCatching { provider.onCorpusUpdate(documents, emptySet()) } + .onFailure { LOGGER.error("Failed to seed search provider ${provider.javaClass.name}", it) } + } + } + + private suspend fun rebuild() = rebuildMutex.withLock { + LOGGER.info("Rebuilding corpus") + val start = System.currentTimeMillis() + val snapshot = synchronized(sources) { sources.toList() } + val documents = LinkedHashMap>() + for (source in snapshot) { + val produced = try { + source.documents() + } catch (e: Throwable) { + LOGGER.error("Search document source ${source.javaClass.name} failed", e) + continue + } + produced.forEach { + if (documents.putIfAbsent(it.id, it) != null) { + LOGGER.warn("Duplicate document: $it") + } + } + } + + // TODO: re-use previous if content & payload stayed the same + val previous = corpus + corpus = documents + GlobalSettingIndex.rebuild() + + val upserted = documents.values.filter { previous[it.id]?.contentEquals(it) != true } + val removed = previous.keys - documents.keys + LOGGER.info("Rebuilt corpus, took ${System.currentTimeMillis() - start}ms, added ${upserted.size}, removed ${removed.size}") + + if (upserted.isNotEmpty() || removed.isNotEmpty()) { + SearchProviderRegistry.all().forEach { provider -> + runCatching { provider.onCorpusUpdate(upserted, removed) } + .onFailure { LOGGER.error("Failed to index into ${provider.javaClass.name}", it) } + } + } + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt new file mode 100644 index 000000000..e465c7706 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt @@ -0,0 +1,67 @@ +package org.polyfrost.oneconfig.internal.ui.search + +/** + * Scope of a search/where a search document should appear + */ +sealed interface SearchScope { + /** Mod cards */ + data object Mods : SearchScope + + /** Every option visible in global search */ + data object Options : SearchScope + + /** Keybinds in keybind screen */ + data object Keybinds : SearchScope + + /** Every option in a specific mod's config */ + data class Config(val id: String) : SearchScope +} + +data class SearchMetadata( + val title: String? = null, + val id: String? = null, + val description: String? = null, + /** Accordion a config option is in */ + val section: String? = null, + val category: String? = null, + val subcategory: String? = null, + val tags: List = emptyList(), + /** Data about the mod/config owning this option */ + val modTitle: String? = null, + val path: String? = null, +) { + /** + * Returns subcategory only if it is not the same as category + */ + val distinctSubcategory: String? + get() = subcategory?.takeUnless { it == category } +} + +/** + * A searchable document + * + * [payload] is used by screens to map back to what they need to render. + */ +class SearchDocument( + val id: String, + val scopes: Set, + val metadata: SearchMetadata, + val payload: T, +) { + /** + * Whether this document indexes the same text as [other]. + */ + fun contentEquals(other: SearchDocument<*>): Boolean = + id == other.id && scopes == other.scopes && metadata == other.metadata + + override fun toString(): String { + return "SearchDocument(id=$id, scopes=$scopes, metadata=$metadata, payload=$payload)" + } +} + +/** + * Produces part of the searchable corpus. Called async when the + */ +fun interface SearchDocumentSource { + fun documents(): List> +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt index 409f72db1..2af4bcf70 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt @@ -21,14 +21,20 @@ interface SearchProvider { * Perform the search on the configs * * @param query The search query - * @param configs The configs to search in - * @param searchMods Whether to search include full mods as result, used by global search - * @return A map of mod name (or if searching mods, "Mods") to search results + * @param scopes The scopes to search in + * @return A list of search results */ - fun performSearch( + fun search( query: String, - configs: List, - searchMods: Boolean = false - ): Map> + scopes: Set + ): List> + + fun searchGrouped( + query: String, + scopes: Set, + grouper: (SearchDocument<*>) -> T + ): Map>> + + suspend fun onCorpusUpdate(added: List>, removed: Set) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt index 5a6dbc112..cd09ed0a4 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt @@ -10,16 +10,20 @@ object SearchProviderRegistry { * Register a new search provider */ fun registerSearchProvider(provider: SearchProvider) { + if (provider in providers) { + return + } providers.add(provider) providers.sortByDescending { it.priority } + SearchCorpus.seed(provider) } /** * Get the search provider with the highest priority that is currently available */ - fun get(): SearchProvider { - return providers.first { it.isAvailable() } - } + internal fun get(): SearchProvider = providers.first { it.isAvailable() } + + internal fun all(): List = providers.toList() init { registerSearchProvider(DefaultSearchProvider) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt new file mode 100644 index 000000000..0043be5af --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt @@ -0,0 +1,204 @@ +package org.polyfrost.oneconfig.internal.ui.search + +import java.util.IdentityHashMap +import org.polyfrost.oneconfig.api.config.v1.Node +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.config.v1.Tree +import org.polyfrost.oneconfig.api.config.v1.internal.ConfigVisualizer +import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry +import org.polyfrost.oneconfig.internal.ui.api.TreeConfigData +import org.polyfrost.oneconfig.internal.ui.components.asRenderText +import org.polyfrost.oneconfig.internal.ui.components.localizedGroup + +/** One row of a settings list: a single property, or an accordion together with the body it expands to. */ +internal sealed interface SettingNode { + data class Leaf(val prop: Property<*>) : SettingNode + data class Accordion(val tree: Tree, val head: Property?, val body: List>) : SettingNode +} + +internal data class CategoryGroup( + val name: String, + val subcategories: List +) + +internal data class SubcategoryGroup( + val name: String, + val nodes: List +) + +internal sealed interface ConfigListEntry { + data class CategoryHeader(val title: String) : ConfigListEntry + data class SubcategoryHeader(val title: String) : ConfigListEntry + data class Item(val node: SettingNode) : ConfigListEntry +} + +/** + * Splits [tree] into the rows a settings screen renders, grouped by category and subcategory. + * [include] can filter the elements. + */ +internal fun buildCategories(tree: Tree, include: (Node) -> Boolean = { true }): List { + val grouped = LinkedHashMap>>() + tree.map.values.forEach { node -> + if (!include(node)) return@forEach + + val category = nodeGroup(node, "category", ConfigVisualizer.DEFAULT_CATEGORY) + val subcategory = nodeGroup(node, "subcategory", ConfigVisualizer.DEFAULT_SUBCATEGORY) + val bucket = grouped.getOrPut(category) { LinkedHashMap() }.getOrPut(subcategory) { ArrayList() } + + when (node) { + is Property<*> -> { + if (isRenderableProperty(node)) { + bucket += SettingNode.Leaf(node) + } + } + is Tree -> buildAccordionNode(node)?.let(bucket::add) + } + } + + return grouped.mapNotNull { (category, subcategories) -> + val groups = subcategories.mapNotNull { (subcategory, nodes) -> + if (nodes.isEmpty()) null else SubcategoryGroup(subcategory, nodes.toList()) + } + if (groups.isEmpty()) null else CategoryGroup(category, groups) + } +} + +internal fun buildAccordionNode(tree: Tree): SettingNode.Accordion? { + val properties = tree.map.values.filterIsInstance>() + if (properties.isEmpty()) { + return null + } + + @Suppress("UNCHECKED_CAST") + val head = properties.firstOrNull(::isAccordionToggle) as? Property + val body = properties + .filter { it !== head } + .filter(::isRenderableProperty) + + if (body.isEmpty()) { + return null + } + + return SettingNode.Accordion(tree, head, body) +} + +private fun isAccordionToggle(prop: Property<*>): Boolean { + val isBoolean = prop.type == Boolean::class.java || prop.type == Boolean::class.javaPrimitiveType + return isBoolean && prop.getMetadata("visualizer") == null +} + +internal fun isRenderableProperty(prop: Property<*>): Boolean { + if (prop.getMetadata("hidden") != null) return false + return (prop.getMetadata("visualizer") != null) || prop.canDisplay() +} + +internal fun nodeGroup(node: Node, key: String, default: String): String { + return node.localizedGroup(key, "${key}Key", default) +} + +internal fun Property<*>.isHidden(): Boolean = display == Property.Display.HIDDEN + +internal fun filterHiddenNodes(category: CategoryGroup): CategoryGroup? { + val subcategories = category.subcategories.mapNotNull { subcategory -> + val nodes = subcategory.nodes.filter { node -> + when (node) { + is SettingNode.Leaf -> !node.prop.isHidden() + is SettingNode.Accordion -> node.head?.isHidden() == false || node.body.any { !it.isHidden() } + } + } + if (nodes.isEmpty()) null else subcategory.copy(nodes = nodes) + } + return if (subcategories.isEmpty()) null else category.copy(subcategories = subcategories) +} + +internal fun flattenEntries(category: CategoryGroup): List { + val showHeaders = category.subcategories.size > 1 || category.subcategories.any { + it.name != ConfigVisualizer.DEFAULT_SUBCATEGORY + } + return buildList { + category.subcategories.forEach { subcategory -> + if (showHeaders) { + add(ConfigListEntry.SubcategoryHeader(subcategory.name)) + } + subcategory.nodes.forEach { add(ConfigListEntry.Item(it)) } + } + } +} + +internal fun flattenSearchEntries(categories: List): List { + return buildList { + val showCategoryHeaders = categories.size > 1 + categories.forEach { category -> + if (showCategoryHeaders) { + add(ConfigListEntry.CategoryHeader(category.name)) + } + addAll(flattenEntries(category)) + } + } +} + +internal class SearchRow( + val modTitle: String?, + val category: String, + val subcategory: String, + val node: SettingNode, +) + +/** + * Maps every node the corpus can return back to the row which renders it. + */ +internal fun buildSearchIndex(categories: List, modTitle: String? = null): Map { + val owners = IdentityHashMap() + categories.forEach { category -> + category.subcategories.forEach { subcategory -> + subcategory.nodes.forEach { node -> + val row = SearchRow(modTitle, category.name, subcategory.name, node) + when (node) { + is SettingNode.Leaf -> owners[node.prop] = row + is SettingNode.Accordion -> { + owners[node.tree] = row + node.head?.let { owners[it] = row } + node.body.forEach { owners[it] = row } + } + } + } + } + } + return owners +} + +/** + * Narrows one row to what its hits matched, handled accordions and hidden options + */ +internal fun searchNode(node: SettingNode, documents: List>): SettingNode? { + return when (node) { + is SettingNode.Leaf -> node.takeUnless { it.prop.isHidden() } + is SettingNode.Accordion -> { + val whole = documents.any { it.payload === node.tree || it.payload === node.head } + val body = if (whole) node.body else documents.mapNotNull { it.payload as? Property<*> } + val visible = body.any { !it.isHidden() } || (whole && node.head?.isHidden() == false) + if (visible) node.copy(body = body) else null + } + } +} + +/** + * The node -> row index across every searchable config, for screens which search outside a single config. + */ +internal object GlobalSettingIndex { + @Volatile + private var rows: Map = emptyMap() + + /** The row which renders [document]'s payload, or null. */ + fun rowOf(document: SearchDocument<*>): SearchRow? = (document.payload as? Node)?.let(rows::get) + + fun rebuild() { + val built = IdentityHashMap() + ConfigRegistry.configs.toList().forEach { config -> + if (!ConfigRegistry.shouldShowInSearch(config)) return@forEach + val tree = (config as? TreeConfigData)?.tree ?: return@forEach + built += buildSearchIndex(buildCategories(tree), config.title.asRenderText()) + } + rows = built + } +} From 61139bfce7da4ca8660d54d3c14af7aa1e6b5f38 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:26:17 +0200 Subject: [PATCH 06/20] Make compat ids stable --- .../internal/compat/ClothConfigCompat.kt | 57 +++++--- .../oneconfig/internal/compat/CompatIds.kt | 40 ++++++ .../internal/compat/DandelionCompat.kt | 75 +++++++--- .../internal/compat/MidnightLibCompat.kt | 30 ++-- .../internal/compat/MoulConfigCompat.kt | 9 +- .../internal/compat/MoulPropertyBuilder.kt | 5 +- .../oneconfig/internal/compat/OdinCompat.kt | 13 +- .../internal/compat/RConfigCompat.kt | 130 ++++++++++++------ .../internal/compat/Tr7zwConfigCompat.kt | 30 ++-- .../internal/compat/WalksyLibCompat.kt | 42 ++++-- .../oneconfig/internal/compat/YACLCompat.kt | 75 +++++++--- 11 files changed, 369 insertions(+), 137 deletions(-) create mode 100644 minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatIds.kt diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ClothConfigCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ClothConfigCompat.kt index 0d0b87e83..27a11df3b 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ClothConfigCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ClothConfigCompat.kt @@ -11,6 +11,9 @@ import org.polyfrost.oneconfig.api.config.v1.dsl.noCache import org.polyfrost.oneconfig.api.config.v1.dsl.saveFunction import org.polyfrost.oneconfig.api.config.v1.dsl.subcategory import org.polyfrost.oneconfig.api.platform.v1.ModInfo +import org.polyfrost.oneconfig.internal.compat.CompatIds.componentKey +import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart +import org.polyfrost.oneconfig.internal.compat.CompatIds.uniqueId import java.lang.reflect.Field import java.util.* import java.util.function.Consumer @@ -67,12 +70,15 @@ object ClothConfigCompat { } var added = false + val usedIds = HashSet() for (category in categoryMap.values) { if (category == null) continue - val rawCategoryName = runCatching { - resolveComponent(category.javaClass.getMethod("getCategoryKey").invoke(category)) - }.getOrNull()?.takeIf { it.isNotBlank() } + val categoryKey = runCatching { + category.javaClass.getMethod("getCategoryKey").invoke(category) + }.getOrNull() + val rawCategoryName = resolveComponent(categoryKey)?.takeIf { it.isNotBlank() } val categoryName = cleanName(rawCategoryName, "General") + val categoryId = idPart(componentKey(categoryKey) ?: rawCategoryName, "general") @Suppress("UNCHECKED_CAST") val entries = runCatching { @@ -82,7 +88,7 @@ object ClothConfigCompat { for (entry in entries) { if (entry == null) continue runCatching { - if (parseEntry(entry, categoryName, categoryName, tree, tree)) added = true + if (parseEntry(entry, categoryName, categoryName, categoryId, tree, tree, usedIds)) added = true }.onFailure { LOGGER.warn("Failed to parse Cloth entry", it) } } } @@ -90,22 +96,32 @@ object ClothConfigCompat { return if (added) tree else null } - private fun parseEntry(entry: Any, categoryName: String, subcategoryName: String, dest: Tree, root: Tree): Boolean { - val name = runCatching { - resolveComponent(entry.javaClass.getMethod("getFieldName").invoke(entry)) - }.getOrNull() ?: return false + private fun parseEntry( + entry: Any, + categoryName: String, + subcategoryName: String, + idPath: String, + dest: Tree, + root: Tree, + usedIds: MutableSet, + ): Boolean { + val nameComponent = runCatching { entry.javaClass.getMethod("getFieldName").invoke(entry) }.getOrNull() + val name = resolveComponent(nameComponent) ?: return false + val entryPath = "$idPath/${idPart(componentKey(nameComponent) ?: name, "entry")}" val value = runCatching { invokeGetValue(entry) }.getOrNull() if (isSubCategory(entry, value)) { val children = value as? Collection<*> ?: return false - val rawSubName = runCatching { - resolveComponent(entry.javaClass.getMethod("getCategoryName").invoke(entry)) - }.getOrNull()?.takeIf { it.isNotBlank() } + val subNameComponent = runCatching { + entry.javaClass.getMethod("getCategoryName").invoke(entry) + }.getOrNull() + val rawSubName = resolveComponent(subNameComponent)?.takeIf { it.isNotBlank() } val subName = cleanName(rawSubName, name) + val subPath = componentKey(subNameComponent)?.let { "$idPath/${idPart(it, "entry")}" } ?: entryPath val accordion = if (dest === root) { - Tree.tree(UUID.randomUUID().toString()).also { + Tree.tree(uniqueId(usedIds, subPath)).also { it.title = subName it.addMetadata("category", categoryName) if (!isExpanded(entry)) it.addMetadata("collapsed", true) @@ -118,7 +134,7 @@ object ClothConfigCompat { for (child in children) { if (child == null) continue runCatching { - if (parseEntry(child, categoryName, subName, accordion, root)) added = true + if (parseEntry(child, categoryName, subName, subPath, accordion, root, usedIds)) added = true }.onFailure { LOGGER.warn("Failed to parse Cloth sub-entry", it) } } @@ -129,7 +145,15 @@ object ClothConfigCompat { val currentValue = value ?: return false if (currentValue is List<*>) { - return parseListEntry(entry, currentValue, name, categoryName, subcategoryName, dest) + return parseListEntry( + entry, + currentValue, + name, + uniqueId(usedIds, entryPath), + categoryName, + subcategoryName, + dest, + ) } val isColor = isColorEntry(entry) @@ -156,7 +180,7 @@ object ClothConfigCompat { @Suppress("UNCHECKED_CAST") (saveCallbackField?.get(entry) as? Consumer)?.accept(v) }, - id = UUID.randomUUID().toString(), + id = uniqueId(usedIds, entryPath), name = name, description = resolveTooltip(entry), ) @@ -193,6 +217,7 @@ object ClothConfigCompat { entry: Any, currentValue: List<*>, name: String, + id: String, categoryName: String, subcategoryName: String, dest: Tree, @@ -212,7 +237,7 @@ object ClothConfigCompat { val values = v.mapTo(ArrayList()) { if (numeric) coerceNumber(it, element) else it?.toString() ?: "" } (saveCallbackField?.get(entry) as? Consumer)?.accept(values) }, - id = UUID.randomUUID().toString(), + id = id, name = name, description = resolveTooltip(entry), type = java.util.List::class.java as Class>, diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatIds.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatIds.kt new file mode 100644 index 000000000..7c2c50f55 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatIds.kt @@ -0,0 +1,40 @@ +package org.polyfrost.oneconfig.internal.compat + +/** + * Create stable ids for a mod + */ +internal object CompatIds { + + /** + * Take a string and turn it into something allowed in a node id + */ + fun idPart(raw: String?, fallback: String): String { + val cleaned = raw?.trim()?.lowercase()?.replace(Regex("[^a-z0-9._-]+"), "_")?.trim('_') + return cleaned?.takeIf { it.isNotEmpty() } ?: fallback + } + + /** + * Increment the id until it is unique, should be the same every launch because they are added in declaration order + */ + fun uniqueId(used: MutableSet, base: String): String { + if (used.add(base)) return base + var i = 2 + while (!used.add("${base}_$i")) i++ + return "${base}_$i" + } + + /** + * Try to get the translation key of a component, preferred over the displayed text so ids do not + * change with the active language + */ + fun componentKey(value: Any?): String? { + if (value == null || value is String) return null + val contents = runCatching { value.javaClass.getMethod("getContents").invoke(value) }.getOrNull() + if (contents != null) { + runCatching { contents.javaClass.getMethod("getKey").invoke(contents) as? String } + .getOrNull()?.takeIf { it.isNotBlank() }?.let { return it } + } + return runCatching { value.javaClass.getMethod("getKey").invoke(value) as? String } + .getOrNull()?.takeIf { it.isNotBlank() } + } +} \ No newline at end of file diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/DandelionCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/DandelionCompat.kt index aa859f7a5..e9097dfc2 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/DandelionCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/DandelionCompat.kt @@ -34,7 +34,9 @@ import org.polyfrost.oneconfig.api.config.v1.dsl.visualizer import org.polyfrost.oneconfig.api.config.v1.internal.ConfigVisualizer import org.polyfrost.oneconfig.api.platform.v1.ModInfo import org.polyfrost.oneconfig.api.platform.v1.Platform -import java.util.UUID +import org.polyfrost.oneconfig.internal.compat.CompatIds.componentKey +import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart +import org.polyfrost.oneconfig.internal.compat.CompatIds.uniqueId import java.util.function.Function import java.util.function.Supplier @@ -68,40 +70,74 @@ object DandelionCompat { tree.addMetadata("icon_path", it) } - categories.forEach { parseCategory(it, tree) } + val usedIds = HashSet() + categories.forEach { parseCategory(it, tree, usedIds) } return tree } - fun parseCategory(category: ConfigCategory, root: Tree) { - category.rootGroup()?.let { parseGroup(it, category, true, root) } - category.groups().forEach { parseGroup(it, category, false, root) } + fun parseCategory(category: ConfigCategory, root: Tree, usedIds: MutableSet) { + val categoryPath = idPart(componentKey(category.name()) ?: category.name().string, "general") + category.rootGroup()?.let { parseGroup(it, category, true, root, categoryPath, usedIds) } + category.groups().forEach { parseGroup(it, category, false, root, categoryPath, usedIds) } } - fun parseGroup(group: OptionGroup, category: ConfigCategory, isRootGroup: Boolean, root: Tree) { + fun parseGroup( + group: OptionGroup, + category: ConfigCategory, + isRootGroup: Boolean, + root: Tree, + categoryPath: String, + usedIds: MutableSet, + ) { if (group is ListOption<*>) { @Suppress("UNCHECKED_CAST") - parseOption(group as ListOption, root, category.name().string, ConfigVisualizer.DEFAULT_SUBCATEGORY) + parseOption( + group as ListOption, + root, + category.name().string, + ConfigVisualizer.DEFAULT_SUBCATEGORY, + categoryPath, + usedIds, + ) return } + val groupPath = if (isRootGroup) { + categoryPath + } else { + "$categoryPath/${idPart(componentKey(group.name()) ?: group.name().string, "group")}" + } + group.options().forEach { parseOption( it, root, category.name().string, - if (isRootGroup) ConfigVisualizer.DEFAULT_SUBCATEGORY else group.name().string + if (isRootGroup) ConfigVisualizer.DEFAULT_SUBCATEGORY else group.name().string, + groupPath, + usedIds, ) } } - fun parseOption(option: Option, root: Tree, category: String, subcategory: String) = runCatching { + fun parseOption( + option: Option, + root: Tree, + category: String, + subcategory: String, + groupPath: String, + usedIds: MutableSet, + ) = runCatching { val controller = runCatching { option.controller() }.getOrNull() + // Dandelion options usually carry their own id; fall back to the option's place in the config. + val optionId = option.id()?.toString() + ?: uniqueId(usedIds, "$groupPath/${idPart(componentKey(option.name()) ?: option.name().string, "option")}") when (option) { is ButtonOption -> { - val property = Properties.dummy(id = option.id()?.toString() ?: UUID.randomUUID().toString()) + val property = Properties.dummy(id = optionId) property.title = option.name() property.description = option.description() property.visualizer = Visualizer.ButtonVisualizer::class.java @@ -115,7 +151,7 @@ object DandelionCompat { } is LabelOption -> { - val property = Properties.dummy(id = option.id()?.toString() ?: UUID.randomUUID().toString()) + val property = Properties.dummy(id = optionId) property.title = option.name() property.description = option.description() property.category = category @@ -125,17 +161,17 @@ object DandelionCompat { } is ListOption<*> -> { - val property = listProperty(option, category, subcategory) + val property = listProperty(option, optionId, category, subcategory) if (property == null) { LOGGER.warn("Unsupported list: ${option.name()} - ${option.entryType().simpleName}") - root.put(unsupportedOptionProperty(option, option.entryController(), category, subcategory)) + root.put(unsupportedOptionProperty(option, optionId, option.entryController(), category, subcategory)) } else { root.put(property) } } else if controller == null -> { - val property = Properties.dummy(id = option.id()?.toString() ?: UUID.randomUUID().toString()) + val property = Properties.dummy(id = optionId) property.title = option.name() property.description = Component.literal("Failed to create compat entry for option! ").append(option.name()) @@ -163,7 +199,7 @@ object DandelionCompat { val property = Properties.functional( getter = { getter() }, setter = { value -> setter(value) }, - id = UUID.randomUUID().toString(), + id = optionId, name = option.name(), description = option.description(), ) @@ -196,7 +232,7 @@ object DandelionCompat { is StringController -> property.visualizer = Visualizer.TextVisualizer::class.java else -> { LOGGER.warn("Unsupported: ${option.name()} - ${controller.javaClass.simpleName}") - root.put(unsupportedOptionProperty(option, controller, category, subcategory)) + root.put(unsupportedOptionProperty(option, optionId, controller, category, subcategory)) return@runCatching } } @@ -206,7 +242,7 @@ object DandelionCompat { } @Suppress("UNCHECKED_CAST") - private fun listProperty(listOption: ListOption<*>, category: String, subcategory: String): Property<*>? { + private fun listProperty(listOption: ListOption<*>, id: String, category: String, subcategory: String): Property<*>? { val option = listOption as ListOption val entryType = option.entryType() val entryController = runCatching { option.entryController() }.getOrNull() @@ -243,7 +279,7 @@ object DandelionCompat { option.listeners().forEach { it.onUpdate(option, OptionListener.UpdateType.VALUE_CHANGE) } option.flags().forEach { it.accept(Minecraft.getInstance()) } }, - id = option.id()?.toString() ?: UUID.randomUUID().toString(), + id = id, name = option.name(), description = option.description(), type = java.util.List::class.java as Class>, @@ -277,11 +313,12 @@ object DandelionCompat { private fun unsupportedOptionProperty( option: Option, + id: String, controller: Any, category: String, subcategory: String ): Property { - val property = Properties.dummy(id = option.id()?.toString() ?: UUID.randomUUID().toString()) + val property = Properties.dummy(id = id) property.title = option.name() property.description = Component.literal("Option currently not supported by OneConfig") diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MidnightLibCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MidnightLibCompat.kt index 00f9b136e..97833b661 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MidnightLibCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MidnightLibCompat.kt @@ -19,9 +19,11 @@ import org.polyfrost.oneconfig.api.config.v1.dsl.saveFunction import org.polyfrost.oneconfig.api.config.v1.dsl.subcategory import org.polyfrost.oneconfig.api.platform.v1.ModInfo import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.internal.compat.CompatIds.componentKey +import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart +import org.polyfrost.oneconfig.internal.compat.CompatIds.uniqueId import org.polyfrost.oneconfig.internal.mixin.compat.midnightlib.SliderButtonAccessor import java.lang.reflect.Field -import java.util.UUID import java.util.function.Supplier object MidnightLibCompat { @@ -75,6 +77,7 @@ object MidnightLibCompat { val pending = ArrayList() // The raw (untranslated) category ids, which are also the tab ids passed to onTabInit. val rawCategories = LinkedHashSet() + val usedIds = HashSet() for (entry in entries) { if (entry == null) continue @@ -96,7 +99,7 @@ object MidnightLibCompat { ?: return@runCatching rawCategories += rawCategoryOf(readAnnotationString(entryAnnotation, "category")) - val property = parseEntry(entry, modid, field, entryAnnotation, subcategoryByCategory) + val property = parseEntry(entry, modid, field, entryAnnotation, subcategoryByCategory, usedIds) ?: return@runCatching tree.put(property) propertyByField[field.name] = property @@ -106,12 +109,18 @@ object MidnightLibCompat { wireConditions(pending, propertyByField) - val custom = parseCustomEntries(modid, config, tree, rawCategories) + val custom = parseCustomEntries(modid, config, tree, rawCategories, usedIds) return if (pending.isNotEmpty() || custom > 0) tree else null } - private fun parseCustomEntries(modid: String, config: Class<*>, tree: Tree, rawCategories: Set): Int { + private fun parseCustomEntries( + modid: String, + config: Class<*>, + tree: Tree, + rawCategories: Set, + usedIds: MutableSet, + ): Int { val instance = configInstance(config, modid) ?: return 0 val onTabInit = instance.javaClass.methods.firstOrNull { it.name == "onTabInit" && it.parameterCount == 3 } ?: return 0 @@ -137,7 +146,8 @@ object MidnightLibCompat { for (row in (list as AbstractSelectionList<*>).children()) { if (row == null) continue - val title = (readField(row, "text")?.get(row) as? Component)?.string?.takeIf { it.isNotBlank() } + val titleComponent = readField(row, "text")?.get(row) as? Component + val title = titleComponent?.string?.takeIf { it.isNotBlank() } val widgets = (readField(row, "buttons")?.get(row) as? List<*>).orEmpty() if (widgets.isEmpty()) { @@ -146,8 +156,9 @@ object MidnightLibCompat { } if (title == null || !seen.add(title)) continue + val path = "${idPart(rawCategory, "general")}/${idPart(componentKey(titleComponent) ?: title, "widget")}" val property = widgets.filterIsInstance() - .firstNotNullOfOrNull { widgetProperty(it, title) } ?: continue + .firstNotNullOfOrNull { widgetProperty(it, title, uniqueId(usedIds, path)) } ?: continue property.category = category property.subcategory = subcategory ?: category tree.put(property) @@ -159,8 +170,7 @@ object MidnightLibCompat { return added } - private fun widgetProperty(widget: AbstractWidget, title: String): Property<*>? { - val id = UUID.randomUUID().toString() + private fun widgetProperty(widget: AbstractWidget, title: String, id: String): Property<*>? { return when (widget) { is AbstractSliderButton -> { val accessor = widget as SliderButtonAccessor @@ -204,6 +214,7 @@ object MidnightLibCompat { field: Field, entryAnnotation: Any, subcategoryByCategory: Map, + usedIds: MutableSet, ): Property<*>? { val currentValue = runCatching { field.get(null) }.getOrNull() @@ -214,7 +225,8 @@ object MidnightLibCompat { val fieldName = field.name val name = translateOrNull("$modid.midnightconfig.$fieldName") ?: prettify(fieldName) val description = translateOrNull("$modid.midnightconfig.$fieldName.tooltip") - val id = UUID.randomUUID().toString() + // Field names are unique within the config class, so no category prefix + val id = uniqueId(usedIds, idPart(fieldName, "entry")) val property: Property<*> val visualizer: Class diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt index b0829d3df..2c17a5825 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt @@ -19,10 +19,10 @@ import org.polyfrost.oneconfig.api.config.v1.dsl.saveFunction import org.polyfrost.oneconfig.api.config.v1.dsl.subcategory import org.polyfrost.oneconfig.api.ui.v1.keybind.KeyModifiers import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind +import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart import org.polyfrost.oneconfig.internal.utils.MoulConfigGuiOptionEditorDropdownAccessor import java.awt.Color import java.lang.reflect.Type -import java.util.* import kotlin.reflect.KClass // do not remove the im import org.polyfrost.oneconfig.internal.compat.MoulPropertyBuilder @@ -98,7 +98,7 @@ data object MoulConfigCompat { } return Tree.tree().apply { - id = UUID.randomUUID().toString() + id = idPart(category.identifier, "category") this.category = categoryName this.title = displayName this.subcategory = displayName @@ -115,9 +115,10 @@ data object MoulConfigCompat { ) { val editor = children.editor if (editor is GuiOptionEditorAccordion) { + val builder = MoulPropertyBuilder(children) val accordionTree = Tree.tree() - accordionTree.id = UUID.randomUUID().toString() - accordionTree.title = MoulPropertyBuilder(children).name?.takeIf { it.isNotBlank() } ?: "Section" + accordionTree.id = idPart(builder.path ?: builder.name, "section") + accordionTree.title = builder.name?.takeIf { it.isNotBlank() } ?: "Section" accordionTree.category = categoryName accordionTree.subcategory = subcategoryName diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt index ebcc1a0b5..5cb79a216 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt @@ -4,12 +4,13 @@ package org.polyfrost.oneconfig.internal.compat import io.github.notenoughupdates.moulconfig.processor.ProcessedOption import org.polyfrost.oneconfig.api.config.v1.CompatSnapshots import org.polyfrost.oneconfig.api.config.v1.Properties +import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart import org.polyfrost.oneconfig.relocator.annotations.MoulConfig import java.lang.reflect.Field -import java.util.* @MoulConfig class MoulPropertyBuilder internal constructor(option: ProcessedOption) { + val path: String? = runCatching { option.path }.getOrNull()?.takeIf { it.isNotBlank() } val name: String? = resolveTextGetter(option, "getName") val description: String? = resolveTextGetter(option, "getDescription") @@ -25,7 +26,7 @@ class MoulPropertyBuilder internal constructor(option: ProcessedOption) { private val snapshotKey: String? = backingField?.let { "${it.declaringClass.name}#${it.name}" } fun build() = Properties.functional( - id = UUID.randomUUID().toString(), + id = idPart(path ?: snapshotKey ?: name, "option"), getter = getter, setter = setter, name = name, diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/OdinCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/OdinCompat.kt index eebd8ed5a..f59201963 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/OdinCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/OdinCompat.kt @@ -164,13 +164,13 @@ private object OdinSettingsAdapter { val module = hud.module val huds = module.settings.values.filterIsInstance() val out = ArrayList>() - for (setting in module.settings.values) { + for ((index, setting) in module.settings.values.withIndex()) { if (setting !is RenderableSetting<*>) continue if (setting is HUDSetting) continue if (setting is DropdownSetting) continue if (setting is KeybindSetting) continue if (!ownedByHud(setting, hud, huds)) continue - runCatching { buildProperty(setting) }.getOrNull()?.let(out::add) + runCatching { buildProperty(setting, index) }.getOrNull()?.let(out::add) } return out } @@ -198,8 +198,8 @@ private object OdinSettingsAdapter { private fun baseToken(hudName: String): String = hudName.trim().removeSuffix("HUD").removeSuffix("Hud").removeSuffix("hud").trim().lowercase() - private fun buildProperty(setting: RenderableSetting<*>): Property<*>? { - val id = settingId(setting) + private fun buildProperty(setting: RenderableSetting<*>, index: Int): Property<*>? { + val id = settingId(setting, index) val prop: Property<*> = when (setting) { is BooleanSetting -> Properties.functional( { setting.value }, { setting.value = it }, @@ -260,9 +260,10 @@ private object OdinSettingsAdapter { return prop } - private fun settingId(setting: RenderableSetting<*>): String { + // Fallback to position in config if name is empty + private fun settingId(setting: RenderableSetting<*>, index: Int): String { val normalized = setting.name.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_') - return "odin_setting_" + normalized.ifEmpty { System.identityHashCode(setting).toString() } + return "odin_setting_" + normalized.ifEmpty { index.toString() } } private fun colorAllowsAlpha(setting: ColorSetting): Boolean = runCatching { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/RConfigCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/RConfigCompat.kt index ecbaa937c..61223f6b6 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/RConfigCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/RConfigCompat.kt @@ -32,7 +32,8 @@ import org.polyfrost.oneconfig.api.config.v1.dsl.index import org.polyfrost.oneconfig.api.config.v1.dsl.subcategory import org.polyfrost.oneconfig.api.config.v1.dsl.visualizer import org.polyfrost.oneconfig.api.platform.v1.ModInfo -import java.util.* +import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart +import org.polyfrost.oneconfig.internal.compat.CompatIds.uniqueId internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/RconfigCompat") { @@ -73,15 +74,16 @@ internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/Rconfi tree.addMetadata("icon_path", it) } + val usedIds = HashSet() config.categories().values.forEach { - parseCategory(it, config.id(), null, tree) + parseCategory(it, config.id(), null, tree, usedIds) } //? >= 1.21.8 { - parseAny(config.elements(), tree) + parseAny(config.elements(), tree, config.id(), usedIds) //? } else { - /*parseAny(config.entries(), tree) - parseButtons(config.buttons(), tree) + /*parseAny(config.entries(), tree, config.id(), usedIds) + parseButtons(config.buttons(), tree, config.id(), usedIds) *///? } tree.addMetadata("custom_save", Runnable { config.save() }) @@ -92,7 +94,13 @@ internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/Rconfi // 1st layer gets converted to categories, 2nd+ layer to subcategories - private fun parseCategory(config: ResourcefulConfig, id: String, category: String?, root: Tree) { + private fun parseCategory( + config: ResourcefulConfig, + id: String, + category: String?, + root: Tree, + usedIds: MutableSet, + ) { val tree = Tree.tree() val nestedId = "$id/${config.id()}" @@ -108,13 +116,13 @@ internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/Rconfi } for ((_, entry) in config.categories()) { - parseCategory(entry, nestedId, category ?: title, root) + parseCategory(entry, nestedId, category ?: title, root, usedIds) } //? >= 1.21.8 { - parseAny(config.elements(), tree) + parseAny(config.elements(), tree, nestedId, usedIds) //? } else { - /*parseAny(config.entries(), tree) - parseButtons(config.buttons(), tree) + /*parseAny(config.entries(), tree, nestedId, usedIds) + parseButtons(config.buttons(), tree, nestedId, usedIds) *///? } tree.map.forEach { (_, node) -> @@ -124,8 +132,8 @@ internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/Rconfi } } - private fun parseButton(button: ResourcefulConfigButton, tree: Tree) { - val property = Properties.dummy(id = UUID.randomUUID().toString()) + private fun parseButton(button: ResourcefulConfigButton, tree: Tree, path: String, usedIds: MutableSet) { + val property = Properties.dummy(id = uniqueId(usedIds, "$path/${idPart(button.title(), "button")}")) property.title = button.title()?.takeUnless { it.isEmpty() } ?: "button" //todo find a better way of doing this, rconfig allows empty names property.description = button.description() @@ -135,65 +143,98 @@ internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/Rconfi } //? >= 1.21.8 { - private fun parseAny(list: Iterable, tree: Tree) = list.forEach { + private fun parseAny( + list: Iterable, + tree: Tree, + path: String, + usedIds: MutableSet, + ) = list.forEach { when (it) { - is ResourcefulConfigCategory -> parseCategory(it, tree) - is ResourcefulConfigEntryElement -> parseAny(it, tree) - is ResourcefulConfigButton -> parseButton(it, tree) + is ResourcefulConfigCategory -> parseCategory(it, tree, path, usedIds) + is ResourcefulConfigEntryElement -> parseAny(it, tree, path, usedIds) + is ResourcefulConfigButton -> parseButton(it, tree, path, usedIds) } } - private fun parseAny(element: ResourcefulConfigEntryElement, tree: Tree) { + private fun parseAny(element: ResourcefulConfigEntryElement, tree: Tree, path: String, usedIds: MutableSet) { + val elementPath = "$path/${idPart(element.id(), "entry")}" when (val entry = element.entry()) { - is ResourcefulConfigObjectEntry -> parseObject(entry, tree) - is ResourcefulConfigValueEntry -> buildAndAdd(entry, element.id(), tree) + is ResourcefulConfigObjectEntry -> parseObject(entry, tree, elementPath, usedIds) + is ResourcefulConfigValueEntry -> buildAndAdd(entry, element.id(), tree, elementPath, usedIds) } } - private fun parseCategory(entry: ResourcefulConfigCategory, tree: Tree) { + private fun parseCategory( + entry: ResourcefulConfigCategory, + tree: Tree, + path: String, + usedIds: MutableSet, + ) { + val categoryPath = "$path/${idPart(entry.id(), "category")}" val category = Tree.tree() category.title = entry.info().title().toComponent() category.description = entry.info().description().toComponent() - category.id = UUID.randomUUID().toString() + category.id = uniqueId(usedIds, categoryPath) category.category = tree.category category.subcategory = entry.info().title().toComponent().string category.index = -1 - parseAny(entry.elements(), category) + parseAny(entry.elements(), category, categoryPath, usedIds) tree.put(category) } - private fun parseObject(entry: ResourcefulConfigObjectEntry, tree: Tree) { + private fun parseObject( + entry: ResourcefulConfigObjectEntry, + tree: Tree, + path: String, + usedIds: MutableSet, + ) { val objectEntry = Tree.tree() objectEntry.title = entry.options().title.toComponent() objectEntry.description = entry.options().comment.toComponent() - objectEntry.id = UUID.randomUUID().toString() + objectEntry.id = uniqueId(usedIds, path) objectEntry.category = tree.category objectEntry.subcategory = entry.options().title.toComponent().string objectEntry.index = -1 - parseAny(entry.elements(), objectEntry) + parseAny(entry.elements(), objectEntry, path, usedIds) tree.put(objectEntry) } //? } else { - /*private fun parseAny(entries: Map, tree: Tree) = entries.forEach { (id, entry) -> + /*private fun parseAny( + entries: Map, + tree: Tree, + path: String, + usedIds: MutableSet, + ) = entries.forEach { (id, entry) -> + val entryPath = "$path/${idPart(id, "entry")}" when (entry) { - is ResourcefulConfigObjectEntry -> parseObject(entry, tree) - is ResourcefulConfigValueEntry -> buildAndAdd(entry, id, tree) + is ResourcefulConfigObjectEntry -> parseObject(entry, tree, entryPath, usedIds) + is ResourcefulConfigValueEntry -> buildAndAdd(entry, id, tree, entryPath, usedIds) } } - private fun parseButtons(buttons: List, tree: Tree) { - buttons.forEach { parseButton(it, tree) } + private fun parseButtons( + buttons: List, + tree: Tree, + path: String, + usedIds: MutableSet, + ) { + buttons.forEach { parseButton(it, tree, path, usedIds) } } - private fun parseObject(entry: ResourcefulConfigObjectEntry, tree: Tree) { + private fun parseObject( + entry: ResourcefulConfigObjectEntry, + tree: Tree, + path: String, + usedIds: MutableSet, + ) { val objectEntry = Tree.tree() objectEntry.title = entry.options().title.toLocalizedString() objectEntry.description = entry.options().comment.toLocalizedString() - objectEntry.id = UUID.randomUUID().toString() + objectEntry.id = uniqueId(usedIds, path) objectEntry.category = tree.category objectEntry.subcategory = entry.options().title.toLocalizedString() objectEntry.index = -1 - parseAny(entry.entries(), objectEntry) + parseAny(entry.entries(), objectEntry, path, usedIds) tree.put(objectEntry) } *///? } @@ -201,10 +242,11 @@ internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/Rconfi @JvmStatic fun buildProperties(entry: ResourcefulConfigObjectEntry): List> { val tmp = Tree.tree() + val usedIds = HashSet() //? >= 1.21.8 { - parseAny(entry.elements(), tmp) + parseAny(entry.elements(), tmp, "object", usedIds) //? } else { - /*parseAny(entry.entries(), tmp) + /*parseAny(entry.entries(), tmp, "object", usedIds) *///? } val out = ArrayList>() collectProperties(tmp, out) @@ -220,8 +262,14 @@ internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/Rconfi } } - private fun buildAndAdd(entry: ResourcefulConfigValueEntry, id: String, tree: Tree) { - val builder = RConfigPropertyBuilder(entry, id) + private fun buildAndAdd( + entry: ResourcefulConfigValueEntry, + id: String, + tree: Tree, + path: String, + usedIds: MutableSet, + ) { + val builder = RConfigPropertyBuilder(entry, id, uniqueId(usedIds, path)) val options = entry.options() if (entry.isArray) { @@ -391,7 +439,11 @@ internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/Rconfi *///? } } - private class RConfigPropertyBuilder constructor(option: ResourcefulConfigValueEntry, val sourceId: String) { + private class RConfigPropertyBuilder constructor( + option: ResourcefulConfigValueEntry, + val sourceId: String, + val nodeId: String, + ) { //? >= 1.21.8 { val name = option.options().title.toComponent() val description = option.options().comment.toComponent() @@ -430,7 +482,7 @@ internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/Rconfi setter, name = name, description = description, - id = UUID.randomUUID().toString() + id = nodeId ).apply { addMetadata(RCONFIG_ID, sourceId) defaultValue?.let { addMetadata("default", it) } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/Tr7zwConfigCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/Tr7zwConfigCompat.kt index 53c332980..602e7cfdc 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/Tr7zwConfigCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/Tr7zwConfigCompat.kt @@ -11,8 +11,9 @@ import org.polyfrost.oneconfig.api.config.v1.dsl.noCache import org.polyfrost.oneconfig.api.config.v1.dsl.saveFunction import org.polyfrost.oneconfig.api.config.v1.dsl.subcategory import org.polyfrost.oneconfig.api.platform.v1.ModInfo +import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart +import org.polyfrost.oneconfig.internal.compat.CompatIds.uniqueId import java.lang.reflect.Field -import java.util.UUID import java.util.function.Consumer import java.util.function.DoubleConsumer import java.util.function.DoubleSupplier @@ -59,23 +60,32 @@ object Tr7zwConfigCompat { } var category = DEFAULT_CATEGORY + var categoryPath = idPart(DEFAULT_CATEGORY, "general") var added = false + val usedIds = HashSet() for (option in options) { if (option == null) continue - val splitLine = readSplitLineName(option) - if (splitLine != null) { - category = splitLine + if (option.javaClass.simpleName == "SplitLine") { + val splitKey = readTranslationKey(option) + category = splitKey?.let { resolveTranslation(it) ?: cleanKey(it) } ?: DEFAULT_CATEGORY + categoryPath = idPart(splitKey, "general") continue } runCatching { - if (parseOption(option, category, tree)) added = true + if (parseOption(option, category, categoryPath, tree, usedIds)) added = true }.onFailure { LOGGER.warn("Failed to parse tr7zw option", it) } } return if (added) tree else null } - private fun parseOption(option: Any, category: String, root: Tree): Boolean { + private fun parseOption( + option: Any, + category: String, + categoryPath: String, + root: Tree, + usedIds: MutableSet, + ): Boolean { val key = readTranslationKey(option) ?: return false val name = resolveTranslation(key) ?: cleanKey(key) val description = resolveTranslation("$key.tooltip") @@ -91,7 +101,7 @@ object Tr7zwConfigCompat { val property = Properties.functional( getter = builder.getter, setter = builder.setter, - id = UUID.randomUUID().toString(), + id = uniqueId(usedIds, "$categoryPath/${idPart(key, "option")}"), name = name, description = description, ) @@ -173,12 +183,6 @@ object Tr7zwConfigCompat { private fun readTranslationKey(option: Any): String? = runCatching { recordComponent(option, "translationKey") as? String }.getOrNull() - private fun readSplitLineName(option: Any): String? { - if (option.javaClass.simpleName != "SplitLine") return null - val key = readTranslationKey(option) ?: return DEFAULT_CATEGORY - return resolveTranslation(key) ?: cleanKey(key) - } - private fun readScreenTitle(screen: Any): String? { val field = findField(screen.javaClass, "title")?.apply { isAccessible = true } ?: return null return (field.get(screen) as? Component)?.string diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WalksyLibCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WalksyLibCompat.kt index 3aeec79fe..ee9889b1d 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WalksyLibCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WalksyLibCompat.kt @@ -11,8 +11,9 @@ import org.polyfrost.oneconfig.api.config.v1.dsl.noCache import org.polyfrost.oneconfig.api.config.v1.dsl.saveFunction import org.polyfrost.oneconfig.api.config.v1.dsl.subcategory import org.polyfrost.oneconfig.api.platform.v1.ModInfo +import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart +import org.polyfrost.oneconfig.internal.compat.CompatIds.uniqueId import net.minecraft.client.Minecraft -import java.util.UUID object WalksyLibCompat { @@ -59,19 +60,22 @@ object WalksyLibCompat { CompatLoader.originalScreenOpener(modid)?.let { tree.addMetadata("open_original_screen", it) } var added = false + val usedIds = HashSet() for (category in categories) { if (category == null) continue val categoryName = invoke(category, "name") as? String ?: continue + val categoryPath = idPart(categoryName, "general") (invoke(category, "options") as? Collection<*>)?.forEach { option -> - if (option != null && parseOption(option, categoryName, null, tree)) added = true + if (option != null && parseOption(option, categoryName, null, categoryPath, tree, usedIds)) added = true } (invoke(category, "optionGroups") as? Collection<*>)?.forEach { group -> if (group == null) return@forEach val groupName = invoke(group, "getName") as? String + val groupPath = "$categoryPath/${idPart(groupName, "group")}" (invoke(group, "getOptions") as? Collection<*>)?.forEach { option -> - if (option != null && parseOption(option, categoryName, groupName, tree)) added = true + if (option != null && parseOption(option, categoryName, groupName, groupPath, tree, usedIds)) added = true } } } @@ -79,26 +83,34 @@ object WalksyLibCompat { return if (added) tree else null } - private fun parseOption(option: Any, categoryName: String, subcategoryName: String?, tree: Tree): Boolean { + private fun parseOption( + option: Any, + categoryName: String, + subcategoryName: String?, + groupPath: String, + tree: Tree, + usedIds: MutableSet, + ): Boolean { val optionClass = option::class.java val type = invoke(option, "getType") as? Class<*> ?: return false val name = (invoke(option, "getName") as? String)?.takeIf { it.isNotBlank() } ?: return false val description = resolveDescription(option) + val optionPath = "$groupPath/${idPart(name, "option")}" if (type == Runnable::class.java) { - return parseButton(option, name, description, categoryName, subcategoryName, tree) + return parseButton(option, name, description, uniqueId(usedIds, optionPath), categoryName, subcategoryName, tree) } if (type.name == COLOR_CLASS) { - return parseColor(option, type, name, description, categoryName, subcategoryName, tree) + return parseColor(option, type, name, description, uniqueId(usedIds, optionPath), categoryName, subcategoryName, tree) } if (type.name == SPRITE_CLASS) { - return parseSprite(option, type, name, description, categoryName, subcategoryName, tree) + return parseSprite(option, type, name, description, uniqueId(usedIds, optionPath), categoryName, subcategoryName, tree) } if (java.util.List::class.java.isAssignableFrom(type)) { - return parseStringList(option, name, description, categoryName, subcategoryName, tree) + return parseStringList(option, name, description, uniqueId(usedIds, optionPath), categoryName, subcategoryName, tree) } val visualizer: Class = when { @@ -119,7 +131,7 @@ object WalksyLibCompat { val property = Properties.functional( { runCatching { getValueM.invoke(option) }.getOrNull() }, { value -> runCatching { setValueM.invoke(option, coerce(value, type)) } }, - UUID.randomUUID().toString(), + uniqueId(usedIds, optionPath), name, description, type as Class, @@ -151,6 +163,7 @@ object WalksyLibCompat { colorClass: Class<*>, name: String, description: String?, + id: String, categoryName: String, subcategoryName: String?, tree: Tree, @@ -199,7 +212,7 @@ object WalksyLibCompat { setValueM.invoke(option, wc) } }, - UUID.randomUUID().toString(), + id, name, description, java.awt.Color::class.java, @@ -225,6 +238,7 @@ object WalksyLibCompat { wrapperClass: Class<*>, name: String, description: String?, + id: String, categoryName: String, subcategoryName: String?, tree: Tree, @@ -287,7 +301,7 @@ object WalksyLibCompat { } }.onFailure { LOGGER.warn("Failed to set WalksyLib sprite value", it) } }, - UUID.randomUUID().toString(), + id, name, description, String::class.java, @@ -308,6 +322,7 @@ object WalksyLibCompat { option: Any, name: String, description: String?, + id: String, categoryName: String, subcategoryName: String?, tree: Tree, @@ -328,7 +343,7 @@ object WalksyLibCompat { runCatching { setValueM.invoke(option, stringsOf(value)) } .onFailure { LOGGER.warn("Failed to set WalksyLib string list value", it) } }, - UUID.randomUUID().toString(), + id, name, description, java.util.List::class.java as Class>, @@ -347,12 +362,13 @@ object WalksyLibCompat { option: Any, name: String, description: String?, + id: String, categoryName: String, subcategoryName: String?, tree: Tree, ): Boolean { val action = invoke(option, "getValue") as? Runnable ?: return false - val property = Properties.dummy(UUID.randomUUID().toString(), name, description) + val property = Properties.dummy(id, name, description) property.addMetadata("visualizer", Visualizer.ButtonVisualizer::class.java) property.addMetadata("textKey", name) property.addMetadata("runnable", Runnable { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/YACLCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/YACLCompat.kt index 5cb599b88..2d02bec21 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/YACLCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/YACLCompat.kt @@ -11,7 +11,9 @@ import org.polyfrost.oneconfig.api.config.v1.dsl.saveFunction import org.polyfrost.oneconfig.api.config.v1.dsl.subcategory import org.polyfrost.oneconfig.api.platform.v1.ModInfo import org.polyfrost.oneconfig.api.platform.v1.Platform -import java.util.* +import org.polyfrost.oneconfig.internal.compat.CompatIds.componentKey +import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart +import org.polyfrost.oneconfig.internal.compat.CompatIds.uniqueId object YACLCompat { @@ -68,19 +70,22 @@ object YACLCompat { CompatLoader.originalScreenOpener(id)?.let { tree.addMetadata("open_original_screen", it) } } + val usedIds = HashSet() for (category in categories) { if (category == null) continue - parseCategory(category, tree) + parseCategory(category, tree, usedIds) } return tree } - private fun parseCategory(category: Any, root: Tree) { + private fun parseCategory(category: Any, root: Tree, usedIds: MutableSet) { val categoryClass = category::class.java val nameMethod = categoryClass.methods.firstOrNull { it.name == "name" && it.parameterCount == 0 } - val categoryName = nameMethod?.let { resolveComponent(it.invoke(category)) }?.nonBlankOrNull() ?: "General" + val nameComponent = nameMethod?.let { runCatching { it.invoke(category) }.getOrNull() } + val categoryName = resolveComponent(nameComponent)?.nonBlankOrNull() ?: "General" + val categoryPath = idPart(componentKey(nameComponent) ?: categoryName, "general") val groupsMethod = categoryClass.methods.firstOrNull { it.name == "groups" && it.parameterCount == 0 @@ -91,15 +96,22 @@ object YACLCompat { for (group in groups) { if (group == null) continue - parseGroup(group, categoryName, root) + parseGroup(group, categoryName, categoryPath, root, usedIds) } } - private fun parseGroup(group: Any, categoryName: String, root: Tree) { + private fun parseGroup( + group: Any, + categoryName: String, + categoryPath: String, + root: Tree, + usedIds: MutableSet, + ) { val groupClass = group::class.java val nameMethod = groupClass.methods.firstOrNull { it.name == "name" && it.parameterCount == 0 } - val groupName = nameMethod?.let { resolveComponent(it.invoke(group)) }?.nonBlankOrNull() + val nameComponent = nameMethod?.let { runCatching { it.invoke(group) }.getOrNull() } + val groupName = resolveComponent(nameComponent)?.nonBlankOrNull() val isRoot = groupClass.methods.firstOrNull { it.name == "isRoot" && it.parameterCount == 0 && @@ -108,11 +120,18 @@ object YACLCompat { val subcategoryName = if (isRoot) null else groupName if (isListOption(groupClass)) { - runCatching { parseOption(group, categoryName, null, root) } + // The group is the option itself, so it keeps the category's path rather than nesting under itself. + runCatching { parseOption(group, categoryName, null, categoryPath, root, usedIds) } .onFailure { LOGGER.warn("Failed to parse YACL list option", it) } return } + val groupPath = if (isRoot) { + categoryPath + } else { + "$categoryPath/${idPart(componentKey(nameComponent) ?: groupName, "group")}" + } + val optionsMethod = groupClass.methods.firstOrNull { it.name == "options" && it.parameterCount == 0 } @@ -123,17 +142,27 @@ object YACLCompat { for (option in options) { if (option == null) continue - runCatching { parseOption(option, categoryName, subcategoryName, root) } + runCatching { parseOption(option, categoryName, subcategoryName, groupPath, root, usedIds) } .onFailure { LOGGER.warn("Failed to parse YACL option", it) } } } - private fun parseOption(option: Any, categoryName: String, subcategoryName: String?, root: Tree) { + private fun parseOption( + option: Any, + categoryName: String, + subcategoryName: String?, + groupPath: String, + root: Tree, + usedIds: MutableSet, + ) { val optionClass = option::class.java val nameMethod = optionClass.methods.firstOrNull { it.name == "name" && it.parameterCount == 0 } val descMethod = optionClass.methods.firstOrNull { it.name == "description" && it.parameterCount == 0 } - val name = nameMethod?.let { resolveComponent(it.invoke(option)) }?.nonBlankOrNull() ?: return + val nameComponent = nameMethod?.let { runCatching { it.invoke(option) }.getOrNull() } + val name = resolveComponent(nameComponent)?.nonBlankOrNull() ?: return + // Claimed only once a property is actually built, so skipped options do not shift the suffixes. + val optionPath = "$groupPath/${idPart(componentKey(nameComponent) ?: name, "option")}" val desc = descMethod?.let { runCatching { val descResult = it.invoke(option) @@ -146,7 +175,7 @@ object YACLCompat { // ButtonOption exposes no readable value (its binding throws), so handle it before the // binding logic. Detect it by its interface name and render it as a clickable button. if (isButtonOption(optionClass)) { - parseButtonOption(option, name, desc, categoryName, subcategoryName, root) + parseButtonOption(option, name, desc, uniqueId(usedIds, optionPath), categoryName, subcategoryName, root) return } @@ -184,7 +213,19 @@ object YACLCompat { val currentValue = runCatching { getter() }.getOrNull() ?: return if (currentValue is List<*>) { - parseListOption(option, currentValue, name, desc, getter, setter, defaultValue, categoryName, subcategoryName, root) + parseListOption( + option, + currentValue, + name, + desc, + uniqueId(usedIds, optionPath), + getter, + setter, + defaultValue, + categoryName, + subcategoryName, + root, + ) return } @@ -200,7 +241,7 @@ object YACLCompat { val property = Properties.functional( getter = { getter() }, setter = { value -> setter(value) }, - id = UUID.randomUUID().toString(), + id = uniqueId(usedIds, optionPath), name = name, description = desc, ) @@ -233,6 +274,7 @@ object YACLCompat { currentValue: List<*>, name: String, desc: String?, + id: String, getter: () -> Any?, setter: (Any?) -> Unit, defaultValue: Any?, @@ -269,7 +311,7 @@ object YACLCompat { ArrayList(list.map(::read)) }, setter = { value: List -> setter(value.mapTo(ArrayList(), ::write)) }, - id = UUID.randomUUID().toString(), + id = id, name = name, description = desc, type = List::class.java, @@ -342,6 +384,7 @@ object YACLCompat { option: Any, name: String, desc: String?, + id: String, categoryName: String, subcategoryName: String?, root: Tree, @@ -356,7 +399,7 @@ object YACLCompat { val action = actionMethod?.let { runCatching { it.invoke(option) }.getOrNull() } val property = Properties.dummy( - id = UUID.randomUUID().toString(), + id = id, name = name, description = desc, ) From 44fb0473d2b8429e2eb1b9578edefb9e533d85da Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:19:12 +0200 Subject: [PATCH 07/20] Scroll to top on search change --- .../internal/ui/screens/ConfigScreen.kt | 5 ++++- .../oneconfig/internal/ui/screens/Keybinds.kt | 5 ++++- .../internal/ui/screens/SearchResultsScreen.kt | 5 ++++- .../oneconfig/internal/ui/shell/ScrollMemory.kt | 16 ++++++++++++++++ 4 files changed, 28 insertions(+), 3 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 833599bcb..44d64102c 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 @@ -97,6 +97,7 @@ import org.polyfrost.oneconfig.internal.ui.search.flattenSearchEntries import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.search.searchNode import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController +import org.polyfrost.oneconfig.internal.ui.shell.ScrollToTopOnChange import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -144,6 +145,9 @@ fun ConfigScreen(tree: Tree, initialCategory: String? = null, pageKey: String) { } } + val lazyListState = rememberRestorableLazyListState(pageKey) + ScrollToTopOnChange(lazyListState, localSearchQuery) + if (entries.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { val message = when { @@ -156,7 +160,6 @@ fun ConfigScreen(tree: Tree, initialCategory: String? = null, pageKey: String) { return@Column } - val lazyListState = rememberRestorableLazyListState(pageKey) Box(modifier = Modifier.fillMaxSize()) { LazyColumn( state = lazyListState, diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt index c53417c0f..420aa0eb5 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt @@ -73,6 +73,7 @@ import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus import org.polyfrost.oneconfig.internal.ui.search.SearchDocument import org.polyfrost.oneconfig.internal.ui.search.SearchScope import org.polyfrost.oneconfig.internal.ui.search.searchMatches +import org.polyfrost.oneconfig.internal.ui.shell.ScrollToTopOnChange import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.util.LayoutRef @@ -95,6 +96,9 @@ fun Keybinds() { val searchResults = rememberKeybindSearchResults(groups, localSearchQuery) val visibleGroups = if (localSearchQuery.isBlank()) groups else searchResults.orEmpty() + val listState = rememberLazyListState() + ScrollToTopOnChange(listState, localSearchQuery) + if (visibleGroups.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { val message = when { @@ -114,7 +118,6 @@ fun Keybinds() { val searching = localSearchQuery.isNotBlank() - val listState = rememberLazyListState() Box(Modifier.fillMaxSize()) { LazyColumn( state = listState, diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt index 72f155b05..8bbcb42aa 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt @@ -21,6 +21,7 @@ import org.polyfrost.oneconfig.internal.ui.search.SearchRow import org.polyfrost.oneconfig.internal.ui.search.SearchScope import org.polyfrost.oneconfig.internal.ui.search.SettingNode import org.polyfrost.oneconfig.internal.ui.search.searchNode +import org.polyfrost.oneconfig.internal.ui.shell.ScrollToTopOnChange import org.polyfrost.oneconfig.internal.ui.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -54,6 +55,9 @@ fun SearchResultsScreen(query: String) { byMod } + val listState = rememberRestorableLazyListState("global-search") + ScrollToTopOnChange(listState, query) + if (matchingMods.isEmpty() && groupedOptions.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { // Nothing to say until the first search comes back. @@ -64,7 +68,6 @@ fun SearchResultsScreen(query: String) { return } - val listState = rememberRestorableLazyListState("global-search") Box(Modifier.fillMaxSize()) { LazyColumn( state = listState, diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt index 9a45eb4b6..56e92790f 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt @@ -30,6 +30,22 @@ fun rememberRestorableLazyListState(key: String): LazyListState { return state } +/** + * Jumps [state] back to the top whenever [key] changes. + */ +@Composable +fun ScrollToTopOnChange(state: LazyListState, key: Any?) { + val previous = remember(state) { LastKey(key) } + LaunchedEffect(state, key) { + if (previous.value != key) { + previous.value = key + state.scrollToItem(0) + } + } +} + +private class LastKey(var value: Any?) + /** [rememberRestorableLazyListState] for a grid. */ @Composable fun rememberRestorableLazyGridState(key: String): LazyGridState { From 507d46d4c1f7b336b47036af4624d70de567a919 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:30:42 +0200 Subject: [PATCH 08/20] Add mod description to search corpus metadata --- gradle.properties | 2 +- .../internal/ui/search/ConfigDocuments.kt | 3 +++ .../oneconfig/internal/ui/search/SearchDocument.kt | 1 + .../oneconfig/internal/ui/search/SearchProvider.kt | 14 +++++++++++++- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 6aaf644cf..f0b2eed35 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx4096m group=org.polyfrost.oneconfig -version=1.1.2 +version=1.1.2+SEARCH ksp.incremental=false \ No newline at end of file diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt index 4c00a1603..9611163b4 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt @@ -38,6 +38,7 @@ object ConfigDocumentSource : SearchDocumentSource { tree = tree, ownerId = config.id, modTitle = config.title.asRenderText(), + modDescription = config.description?.asRenderText(), scopes = scopes, ) } @@ -94,6 +95,7 @@ private fun treeDocuments( tree: Tree, ownerId: String, modTitle: String?, + modDescription: String?, scopes: Set, include: (Node) -> Boolean = { true }, ): List> { @@ -126,6 +128,7 @@ private fun treeDocuments( category = nodeCategory.takeIf { it.isNotBlank() }, subcategory = nodeSubcategory.takeIf { it.isNotBlank() }, modTitle = modTitle?.takeIf { it.isNotBlank() }, + modDescription = modDescription?.takeIf { it.isNotBlank() }, tags = searchTags, ), payload = node, diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt index e465c7706..17ce31d71 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt @@ -28,6 +28,7 @@ data class SearchMetadata( val tags: List = emptyList(), /** Data about the mod/config owning this option */ val modTitle: String? = null, + val modDescription: String? = null, val path: String? = null, ) { /** diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt index 2af4bcf70..d961c2571 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt @@ -18,7 +18,7 @@ interface SearchProvider { fun isAvailable(): Boolean /** - * Perform the search on the configs + * Perform the search on all options/mods within a scope * * @param query The search query * @param scopes The scopes to search in @@ -29,12 +29,24 @@ interface SearchProvider { scopes: Set ): List> + /** + * Perform the search on all options/mods within a scope, and then group by the grouper. + * + * @param query The search query + * @param scopes The scopes to search in + * @param grouper The grouper + * @return A list of search results + */ fun searchGrouped( query: String, scopes: Set, grouper: (SearchDocument<*>) -> T ): Map>> + /** + * Function that is called every time the search corpus updates, + * with the new/updated documents, and the removed document ids + */ suspend fun onCorpusUpdate(added: List>, removed: Set) } From b7be5627a70c86080dd3ca97aab34f6cab8aa5b4 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:34:10 +0200 Subject: [PATCH 09/20] Add HUDs to search corpus --- gradle.properties | 2 +- .../assets/oneconfig/lang/en_us.json | 12 +++++ modules/hud/api/hud.api | 1 + .../oneconfig/api/hud/v1/HudManager.kt | 14 ++++++ .../internal/ui/hud/BuiltinHudConfig.kt | 2 + .../oneconfig/internal/ui/hud/HudModInfo.kt | 23 ++++++++++ .../ui/hud/screens/HudDesignStudio.kt | 46 ++++++++++++------- .../internal/ui/search/ConfigDocuments.kt | 29 +++++++++++- .../ui/search/DefaultSearchProvider.kt | 6 +++ .../internal/ui/search/SearchCorpus.kt | 1 + .../internal/ui/search/SearchDocument.kt | 3 ++ 11 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModInfo.kt diff --git a/gradle.properties b/gradle.properties index f0b2eed35..fbb3fdb43 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx4096m group=org.polyfrost.oneconfig -version=1.1.2+SEARCH +version=1.1.2+SEARCH2 ksp.incremental=false \ No newline at end of file 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 814a5f068..48795410e 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -154,6 +154,18 @@ "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", + "oneconfig.other": "Other", + "oneconfig.performance": "Performance", + "oneconfig.visuals": "Visuals", + "oneconfig.hud": "HUD", + "oneconfig.utility": "Utility", + "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...", diff --git a/modules/hud/api/hud.api b/modules/hud/api/hud.api index 3f9a7c350..47045ab81 100644 --- a/modules/hud/api/hud.api +++ b/modules/hud/api/hud.api @@ -243,6 +243,7 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudManager { public static field pendingSelection Lorg/polyfrost/oneconfig/api/hud/v1/Hud; 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 closeEditor ()V public final fun getActiveInstances ()Ljava/util/ArrayList; 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 0b3176bd9..bd4674496 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 @@ -39,12 +39,14 @@ import org.polyfrost.oneconfig.api.event.v1.EventManager import org.polyfrost.oneconfig.api.hud.v1.events.HudEditorToggleEvent import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.utils.v1.MHUtils +import java.util.concurrent.CopyOnWriteArrayList object HudManager { internal val LOGGER = LogManager.getLogger("OneConfig/HUD") private val hudProviders = HashMap, Hud>() private val hudIcons = HashMap() + private val registrationListeners = CopyOnWriteArrayList() private var init = false private val hiddenHudPaint by lazy { org.jetbrains.skia.Paint().apply { setAlphaf(0.35f) } } @@ -181,10 +183,21 @@ object HudManager { } } + /** + * Adds a listener that is run when a new Hud is registered + */ + @ApiStatus.Internal + fun addRegistrationListener(listener: Runnable) { + registrationListeners.add(listener) + } + + private fun notifyRegistrationChanged() = registrationListeners.forEach(Runnable::run) + @JvmStatic fun register(hud: Hud) { hudProviders[hud::class.java] = hud if (hud.updateFrequency() == 0L) LOGGER.warn("update of HUD ${hud.title} is 0, this is not recommended!") + notifyRegistrationChanged() } @JvmStatic @@ -218,6 +231,7 @@ object HudManager { fun unregister(hud: T, removeActiveInstances: Boolean = false, delete: Boolean = false): ArrayList? { hudProviders.remove(hud::class.java) + notifyRegistrationChanged() if (!removeActiveInstances) return null val out = ArrayList(10.coerceAtMost(activeInstances.size)) val iter = activeInstances.iterator() diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt index 415042ac8..4903cd4e9 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt @@ -5,6 +5,7 @@ import org.polyfrost.oneconfig.api.hud.v1.HudManager import org.polyfrost.oneconfig.internal.ui.api.ConfigData import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.ConfigSource +import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus private object BuiltinHudConfigData : ConfigData { override val id = "oneconfig.builtin" @@ -21,5 +22,6 @@ object BuiltinHudRegistrar { HudManager.providers().forEach { hud -> if (hud.configId == null) hud.configId = "oneconfig.builtin" } + SearchCorpus.invalidate() } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModInfo.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModInfo.kt new file mode 100644 index 000000000..237c98816 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModInfo.kt @@ -0,0 +1,23 @@ +package org.polyfrost.oneconfig.internal.ui.hud + +import org.polyfrost.oneconfig.api.platform.v1.ModInfo +import org.polyfrost.oneconfig.internal.ui.api.ConfigData +import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry +import org.polyfrost.oneconfig.internal.ui.components.asRenderText + +/** + * Get the config of a HUD's ùpd + */ +internal fun configForHud(configId: String): ConfigData? { + val modId = configId.removeSuffix(".json").substringBefore('/') + return ConfigRegistry.findById(configId) + ?: ConfigRegistry.findById("$configId.json") + ?: ConfigRegistry.configs.firstOrNull { it.id.removeSuffix(".json") == modId } +} + +/** Get the name of the mod owning a hud */ +internal fun modNameFor(configId: String): String? { + val modId = configId.removeSuffix(".json").substringBefore('/') + ModInfo.loadedMods.firstOrNull { it.id == modId }?.name?.let { return it } + return configForHud(configId)?.title?.asRenderText() +} 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 42e13369b..55dcd6311 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 @@ -42,8 +42,10 @@ 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.flow.first +import kotlinx.coroutines.withContext import org.apache.logging.log4j.LogManager import org.jetbrains.skia.Paint import org.polyfrost.compose.render.FontManager @@ -56,7 +58,6 @@ import org.polyfrost.oneconfig.api.hud.v1.HudResize import org.polyfrost.oneconfig.api.notifications.v1.Notification import org.polyfrost.oneconfig.api.notifications.v1.Notifications import org.polyfrost.oneconfig.api.notifications.v1.NotificationsManager -import org.polyfrost.oneconfig.api.platform.v1.ModInfo import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindUtils import org.polyfrost.oneconfig.internal.OneConfigConfig @@ -66,9 +67,12 @@ 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.repairHudStaticSize import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.Designer import org.polyfrost.oneconfig.internal.ui.hud.screens.sections.Settings +import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus +import org.polyfrost.oneconfig.internal.ui.search.SearchScope import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent import org.polyfrost.oneconfig.internal.ui.sound.UiSounds @@ -954,13 +958,13 @@ fun HudDesignStudio(onReturnToOneConfig: (() -> Unit)? = null) { val providers = remember { HudManager.providers().toList() } val modIds = remember(providers) { providers.mapNotNull { it.configId }.distinct() } val modNames = remember(modIds) { modIds.associateWith { modNameFor(it) ?: it } } - val librarySections = providers - .filter { hud -> - (searchText.isEmpty() || localizedLabel(hud.title)?.contains(searchText, ignoreCase = true) == true) && - (hud.multipleInstancesAllowed() || HudManager.getHudsOfType(hud::class.java).isEmpty()) - } - .groupBy { it.configId } - .map { (modId, huds) -> HudLibrarySection(modId, modId?.let { modNames[it] } ?: "Other", huds) } + val searchHits = rememberHudSearchResults(providers, searchText) + val groupedHuds = searchHits ?: providers.groupBy { it.configId }.map { (modId, huds) -> modId to huds } + val librarySections = groupedHuds.mapNotNull { (modId, huds) -> + huds.filter { it.multipleInstancesAllowed() || HudManager.getHudsOfType(it::class.java).isEmpty() } + .takeIf { it.isNotEmpty() } + ?.let { HudLibrarySection(modId, modId?.let { id -> modNames[id] } ?: "Other", it) } + } val librarySectionIds = librarySections.map { it.modId } val scrolledLibraryMod = librarySections .lastOrNull { section -> @@ -2300,15 +2304,6 @@ fun HudDragLayer(modifier: Modifier = Modifier) { } -private fun modNameFor(configId: String): String? { - val modId = configId.removeSuffix(".json").substringBefore('/') - ModInfo.loadedMods.firstOrNull { it.id == modId }?.name?.let { return it } - val config = ConfigRegistry.findById(configId) - ?: ConfigRegistry.findById("$configId.json") - ?: ConfigRegistry.configs.firstOrNull { it.id.removeSuffix(".json") == modId } - return config?.title?.asRenderText() -} - @Composable private fun DesignStudioPanel( selectedHud: Hud?, @@ -2426,6 +2421,23 @@ private fun DesignStudioPanel( } } +@Composable +private fun rememberHudSearchResults(providers: List, query: String): List>>? { + var results by remember { mutableStateOf>>?>(null) } + LaunchedEffect(providers, query) { + results = if (query.isBlank()) null + else withContext(Dispatchers.Default) { + val known = providers.toHashSet() + SearchCorpus.searchGrouped(query, setOf(SearchScope.Huds)) { (it.payload as? Hud)?.configId } + .map { (modId, documents) -> + modId to documents.mapNotNull { document -> (document.payload as? Hud)?.takeIf { it in known } } + } + .filter { (_, huds) -> huds.isNotEmpty() } + } + } + return results +} + /** One mod's worth of addable HUDs, as shown in the continuous library list. */ private class HudLibrarySection(val modId: String?, val title: String, val huds: List) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt index 9611163b4..2805d2110 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt @@ -6,13 +6,18 @@ import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.api.config.v1.dsl.subcategory import org.polyfrost.oneconfig.api.config.v1.internal.ConfigVisualizer +import org.polyfrost.oneconfig.api.hud.v1.Hud +import org.polyfrost.oneconfig.api.hud.v1.HudManager import org.polyfrost.oneconfig.internal.ui.api.ConfigData import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.TreeConfigData import org.polyfrost.oneconfig.internal.ui.components.asRenderText import org.polyfrost.oneconfig.internal.ui.components.localizedDescription import org.polyfrost.oneconfig.internal.ui.components.localizedGroup +import org.polyfrost.oneconfig.internal.ui.components.localizedLabel import org.polyfrost.oneconfig.internal.ui.components.localizedTitle +import org.polyfrost.oneconfig.internal.ui.hud.configForHud +import org.polyfrost.oneconfig.internal.ui.hud.modNameFor import org.polyfrost.oneconfig.internal.ui.keybind.KeybindProviderRegistry import org.polyfrost.oneconfig.internal.ui.keybind.isKeybindProperty @@ -84,7 +89,7 @@ private fun modDocument(config: ConfigData): SearchDocument { title = config.title.asRenderText().takeIf { it.isNotBlank() }, id = config.id, description = config.description?.asRenderText()?.takeIf { it.isNotBlank() }, - category = config.category.asRenderText().takeIf { it.isNotBlank() }, + category = localizedLabel(config.category.name)?.takeIf { it.isNotBlank() }, subcategory = tree?.subcategory?.asRenderText()?.takeIf { it.isNotBlank() }, ), payload = config, @@ -146,3 +151,25 @@ private fun treeDocuments( } return documents } + +object HudDocumentSource : SearchDocumentSource { + init { + HudManager.addRegistrationListener { SearchCorpus.invalidate() } + } + + override fun documents(): List> = HudManager.providers().toList().map { hud -> + val config = hud.configId?.let(::configForHud) + return@map SearchDocument( + id = "hud$ID_SEPARATOR${hud::class.java.name}$ID_SEPARATOR${hud.id}", + scopes = setOf(SearchScope.Huds), + metadata = SearchMetadata( + title = localizedLabel(hud.title)?.takeIf { it.isNotBlank() }, + id = hud.id.takeIf { it.isNotBlank() }, + category = localizedLabel(hud.category.name)?.takeIf { it.isNotBlank() }, + modTitle = hud.configId?.let { modNameFor(it) ?: it }?.takeIf { it.isNotBlank() }, + modDescription = config?.description?.asRenderText()?.takeIf { it.isNotBlank() }, + ), + payload = hud, + ) + } +} \ No newline at end of file diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt index a5bf06f91..80d660f0d 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt @@ -21,6 +21,12 @@ internal object DefaultSearchProvider : SearchProvider { if (listOfNotNull(it.metadata.title, it.metadata.description).any { p -> searchMatches(p, q) } || it.metadata.tags.any { t -> searchMatches(t, q) }) return@filter true + // Hud specific + if (it.scopes.contains(SearchScope.Huds) && listOfNotNull( + it.metadata.category, it.metadata.distinctSubcategory, + it.metadata.id, it.metadata.modTitle + ).any { k -> searchMatches(k, q) } + ) return@filter true // Match old search for keybinds if (scopes.contains(SearchScope.Keybinds) && listOfNotNull( it.metadata.category, diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt index 85db8b18a..725188959 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt @@ -38,6 +38,7 @@ object SearchCorpus { init { registerSource(ConfigDocumentSource) registerSource(KeybindDocumentSource) + registerSource(HudDocumentSource) } fun registerSource(source: SearchDocumentSource) { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt index 17ce31d71..0562b0339 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt @@ -13,6 +13,9 @@ sealed interface SearchScope { /** Keybinds in keybind screen */ data object Keybinds : SearchScope + /** HUD providers in the HUD editor's library */ + data object Huds : SearchScope + /** Every option in a specific mod's config */ data class Config(val id: String) : SearchScope } From 3a459fe26ae4cef11780db5019b4b49f12c297cb Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:57:07 +0200 Subject: [PATCH 10/20] Optimize search corpus --- .../internal/ui/api/ConfigRegistry.kt | 10 +- .../internal/ui/hud/BuiltinHudConfig.kt | 3 +- .../internal/ui/keybind/KeybindCatalog.kt | 3 +- .../internal/ui/search/ConfigDocuments.kt | 9 +- .../ui/search/DefaultSearchProvider.kt | 51 ++++--- .../internal/ui/search/SearchCorpus.kt | 133 ++++++++++++++---- .../internal/ui/search/SearchDocument.kt | 13 +- .../internal/ui/search/SearchProvider.kt | 2 +- 8 files changed, 159 insertions(+), 65 deletions(-) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt index ebd7b2cba..2eabe144a 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt @@ -9,6 +9,7 @@ import org.polyfrost.oneconfig.api.config.v1.ConfigManager import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.internal.ui.components.asRenderText import org.polyfrost.oneconfig.internal.ui.keybind.MinecraftKeybindRegistrar +import org.polyfrost.oneconfig.internal.ui.search.ConfigDocumentSource import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus object ConfigRegistry { @@ -80,9 +81,10 @@ object ConfigRegistry { MinecraftKeybindRegistrar.scan(tree) if (registerTree(tree, source, bumpRevision = false)) changed = true } - if (configs.removeAll { it.source == source && it.id !in seenIds }) changed = true + // Only prune what this manager owns + if (configs.removeAll { it.source == source && it is TreeConfigData && it.id !in seenIds }) changed = true if (!changed) return - SearchCorpus.invalidate() + SearchCorpus.invalidate(ConfigDocumentSource) revision++ } @@ -106,7 +108,7 @@ object ConfigRegistry { fun unregister(id: String) { if (configs.removeAll { it.id == id }) { - SearchCorpus.invalidate() + SearchCorpus.invalidate(ConfigDocumentSource) revision++ } } @@ -123,7 +125,7 @@ object ConfigRegistry { } else { configs.add(data) } - SearchCorpus.invalidate() + SearchCorpus.invalidate(ConfigDocumentSource) if (bumpRevision) { revision++ } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt index 4903cd4e9..e62f6092d 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt @@ -5,6 +5,7 @@ import org.polyfrost.oneconfig.api.hud.v1.HudManager import org.polyfrost.oneconfig.internal.ui.api.ConfigData import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.ConfigSource +import org.polyfrost.oneconfig.internal.ui.search.HudDocumentSource import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus private object BuiltinHudConfigData : ConfigData { @@ -22,6 +23,6 @@ object BuiltinHudRegistrar { HudManager.providers().forEach { hud -> if (hud.configId == null) hud.configId = "oneconfig.builtin" } - SearchCorpus.invalidate() + SearchCorpus.invalidate(HudDocumentSource) } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/KeybindCatalog.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/KeybindCatalog.kt index 5f91fca07..063c97bdf 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/KeybindCatalog.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/KeybindCatalog.kt @@ -9,6 +9,7 @@ import org.polyfrost.oneconfig.api.config.v1.internal.ConfigVisualizer import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.TreeConfigData import org.polyfrost.oneconfig.internal.ui.components.localizedGroup +import org.polyfrost.oneconfig.internal.ui.search.KeybindDocumentSource import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus data class KeybindGroup( @@ -37,7 +38,7 @@ object KeybindProviderRegistry { fun register(provider: KeybindGroupProvider) { if (provider in providers) return providers += provider - SearchCorpus.invalidate() + SearchCorpus.invalidate(KeybindDocumentSource) revision.intValue++ } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt index 2805d2110..2afe3947d 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt @@ -70,8 +70,8 @@ object KeybindDocumentSource : SearchDocumentSource { section = null, category = entry.category.asRenderText().takeIf { it.isNotBlank() }, subcategory = entry.subcategory.asRenderText().takeIf { it.isNotBlank() }, - modTitle = modTitle.asRenderText().takeIf { it.isNotBlank() }, - path = entry.path.asRenderText().takeIf { it.isNotBlank() }, + modTitle = modTitle.takeIf { it.isNotBlank() }, + path = entry.path.takeIf { it.isNotBlank() }, ), payload = entry.prop, ) @@ -153,8 +153,11 @@ private fun treeDocuments( } object HudDocumentSource : SearchDocumentSource { + /** Hud documents use the title and description of the config owning the hud */ + override val dependencies = setOf(ConfigDocumentSource) + init { - HudManager.addRegistrationListener { SearchCorpus.invalidate() } + HudManager.addRegistrationListener { SearchCorpus.invalidate(HudDocumentSource) } } override fun documents(): List> = HudManager.providers().toList().map { hud -> diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt index 80d660f0d..9a42cfc13 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt @@ -11,28 +11,29 @@ internal object DefaultSearchProvider : SearchProvider { query: String, scopes: Set ): List> { - val corpus = SearchCorpus.corpus - val q = query.trim().lowercase() - return corpus.values.filter { - if (it.scopes.intersect(scopes).isEmpty()) return@filter false - if (it.scopes.contains(SearchScope.Mods)) { - return@filter it.metadata.title != null && searchMatches(it.metadata.title, q) + val q = query.trim() + if (q.isEmpty()) return emptyList() + val searchingKeybinds = SearchScope.Keybinds in scopes + return SearchCorpus.corpus.values.filter { + if (it.scopes.intersect(scopes).isEmpty()) { + return@filter false } - if (listOfNotNull(it.metadata.title, it.metadata.description).any { p -> - searchMatches(p, q) - } || it.metadata.tags.any { t -> searchMatches(t, q) }) return@filter true + + val meta = it.metadata + if (it.scopes.contains(SearchScope.Mods)) return@filter meta.title.matches(q) + if (meta.title.matches(q) || meta.description.matches(q)) return@filter true + if (meta.tags.any { t -> t.matches(q) }) return@filter true // Hud specific - if (it.scopes.contains(SearchScope.Huds) && listOfNotNull( - it.metadata.category, it.metadata.distinctSubcategory, - it.metadata.id, it.metadata.modTitle - ).any { k -> searchMatches(k, q) } + if (SearchScope.Huds in it.scopes && ( + meta.category.matches(q) || meta.subcategory.matches(q) || + meta.id.matches(q) || meta.modTitle.matches(q) + ) ) return@filter true // Match old search for keybinds - if (scopes.contains(SearchScope.Keybinds) && listOfNotNull( - it.metadata.category, - it.metadata.distinctSubcategory, - it.metadata.id, it.metadata.path - ).any { k -> searchMatches(k, q) } + if (searchingKeybinds && ( + meta.category.matches(q) || meta.subcategory.matches(q) || + meta.id.matches(q) || meta.path.matches(q) + ) ) return@filter true false } @@ -46,12 +47,7 @@ internal object DefaultSearchProvider : SearchProvider { return search(query, scopes).groupBy(grouper) } - override suspend fun onCorpusUpdate( - added: List>, - removed: Set - ) { - // No-op, we just use the corpus directly - } + private fun String?.matches(query: String): Boolean = this != null && searchMatches(this, query) } /** @@ -79,10 +75,11 @@ private fun levenshtein(a: String, b: String, max: Int): Int { } /** - * Returns true if [text] matches [q] either as a substring or, when "Search Distance" > 0, by a fuzzy - * (Levenshtein) match against the whole string or any of its words. [q] is expected to be lowercase. + * Returns true if [text] matches [query] either as a substring or, when "Search Distance" > 0, by a fuzzy + * (Levenshtein) match against the whole string or any of its words. */ -internal fun searchMatches(text: String, q: String): Boolean { +internal fun searchMatches(text: String, query: String): Boolean { + val q = query.lowercase() val t = text.lowercase() if (t.contains(q)) return true val dist = OneConfigConfig.searchDistance diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt index 725188959..7561babea 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt @@ -3,9 +3,11 @@ package org.polyfrost.oneconfig.internal.ui.search import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.apache.logging.log4j.LogManager @@ -18,9 +20,9 @@ private const val REBUILD_DEBOUNCE_MS = 250L /** * Owns the searchable corpus and keeps every registered [SearchProvider] indexed in the background. * - * Indexing is push-based and incremental. Configs register over the course of startup and mods may register - * later still, so rebuilds are coalesced and only the documents whose text actually changed are forwarded - - * otherwise a single late registration would re-index everything. + * Indexing is push-based and incremental. When a config is ready it will be registered, and then the + * search documents for this config can be built. Only what actually changed is forwarded to the search + * providers. */ object SearchCorpus { private val LOGGER = LogManager.getLogger("OneConfig/Search") @@ -29,6 +31,12 @@ object SearchCorpus { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val rebuildMutex = Mutex() private val sources = ArrayList() + + /** Sources which need to be run again on the next rebuild. */ + private val dirtySources = HashSet() + + /** What every source built, only touched under [rebuildMutex]. */ + private val produced = HashMap>>() private var rebuildJob: Job? = null @Volatile @@ -46,14 +54,14 @@ object SearchCorpus { if (source in sources) return sources += source } - invalidate() + invalidate(source) } fun unregisterSource(source: SearchDocumentSource) { synchronized(sources) { if (!sources.remove(source)) return } - invalidate() + schedule() } /** @@ -65,9 +73,20 @@ object SearchCorpus { } /** - * Schedules a coalesced background rebuild. Cheap enough to call from every config mutation. + * Schedule a background rebuild */ - fun invalidate() { + fun invalidate(vararg invalidated: SearchDocumentSource) { + synchronized(sources) { + if (invalidated.isEmpty()) { + dirtySources += sources // All sources + } else { + dirtySources += invalidated + } + } + schedule() + } + + private fun schedule() { if (!initialized.get()) return synchronized(this) { rebuildJob?.cancel() @@ -133,38 +152,98 @@ object SearchCorpus { } private suspend fun rebuild() = rebuildMutex.withLock { - LOGGER.info("Rebuilding corpus") + // Collect rebuild parameters in a thread safe manner val start = System.currentTimeMillis() - val snapshot = synchronized(sources) { sources.toList() } - val documents = LinkedHashMap>() + val snapshot: List + val dirty: Set + synchronized(sources) { + snapshot = sources.toList() + synchronized(dirtySources) { + dirty = expandDirty(dirtySources, snapshot) + dirtySources.clear() + } + } + + val previous = corpus + val documents = LinkedHashMap>(previous.size.coerceAtLeast(16)) + val upserted = ArrayList>() + var asked = 0 for (source in snapshot) { - val produced = try { - source.documents() - } catch (e: Throwable) { - LOGGER.error("Search document source ${source.javaClass.name} failed", e) - continue + val cached = produced[source] + val sourceDocuments = if (cached != null && source !in dirty) cached else { + asked++ + try { + // Keep the previous element of the corpus if nothing changed + source.documents().map { document -> + previous[document.id]?.takeIf { it.equivalentTo(document) } ?: document + } + } catch (e: Throwable) { + LOGGER.error("Search document source ${source.javaClass.name} failed", e) + cached ?: continue + } } - produced.forEach { - if (documents.putIfAbsent(it.id, it) != null) { - LOGGER.warn("Duplicate document: $it") + produced[source] = sourceDocuments + + for (document in sourceDocuments) { + if (documents.putIfAbsent(document.id, document) != null) { + LOGGER.warn("Duplicate document: $document") + continue } + if (previous[document.id] !== document) upserted += document } } + // Remove other sources from the produced if they haven't been re-run/got removed + produced.keys.retainAll(snapshot.toHashSet()) + + // Get removed keys, if no upserted and same size -> no removed + val removed = if (upserted.isEmpty() && documents.size == previous.size) emptySet() + else previous.keys - documents.keys + if (upserted.isEmpty() && removed.isEmpty()) { + LOGGER.debug( + "Corpus unchanged, asked $asked/${snapshot.size} sources," + + " took ${System.currentTimeMillis() - start}ms" + ) + return@withLock + } - // TODO: re-use previous if content & payload stayed the same - val previous = corpus - corpus = documents - GlobalSettingIndex.rebuild() - - val upserted = documents.values.filter { previous[it.id]?.contentEquals(it) != true } - val removed = previous.keys - documents.keys - LOGGER.info("Rebuilt corpus, took ${System.currentTimeMillis() - start}ms, added ${upserted.size}, removed ${removed.size}") + // Swap to new corpus, non-cancellable to prevent desyncs with search providers + withContext(NonCancellable) { + corpus = documents + if (ConfigDocumentSource in dirty) GlobalSettingIndex.rebuild() + LOGGER.info( + "Rebuilt corpus from $asked/${snapshot.size} sources, " + + "took ${System.currentTimeMillis() - start}ms, " + + "added ${upserted.size}, removed ${removed.size}" + ) - if (upserted.isNotEmpty() || removed.isNotEmpty()) { SearchProviderRegistry.all().forEach { provider -> runCatching { provider.onCorpusUpdate(upserted, removed) } .onFailure { LOGGER.error("Failed to index into ${provider.javaClass.name}", it) } } } } + + private fun expandDirty( + dirty: Set, + snapshot: List + ): Set { + if (dirty.isEmpty()) return emptySet() + val expanded = HashSet(dirty) + var added = true + while (added) { + if (expanded.size == snapshot.size) { + return expanded + } + + added = false + for (source in snapshot) { + if (source in expanded) continue + if (source.dependencies.any { it in expanded }) { + expanded += source + added = true + } + } + } + return expanded + } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt index 0562b0339..bf3484005 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt @@ -58,14 +58,25 @@ class SearchDocument( fun contentEquals(other: SearchDocument<*>): Boolean = id == other.id && scopes == other.scopes && metadata == other.metadata + /** + * Whether this entry is equivalent to another entry, and can safely be replaced by it without breaking anything + */ + fun equivalentTo(other: SearchDocument<*>): Boolean = contentEquals(other) && payload === other.payload + override fun toString(): String { return "SearchDocument(id=$id, scopes=$scopes, metadata=$metadata, payload=$payload)" } } /** - * Produces part of the searchable corpus. Called async when the + * Produces part of the searchable corpus. Called async when the corpus rebuilds.. */ fun interface SearchDocumentSource { fun documents(): List> + + /** + * Sources whose data this one reads. Invalidating any of them should invalidate this source as well. + */ + val dependencies: Set + get() = emptySet() } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt index d961c2571..81d8b6315 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt @@ -47,6 +47,6 @@ interface SearchProvider { * Function that is called every time the search corpus updates, * with the new/updated documents, and the removed document ids */ - suspend fun onCorpusUpdate(added: List>, removed: Set) + suspend fun onCorpusUpdate(added: List>, removed: Set) {} } From 5b435a46bcced7a9c1ff22739e3efe6bd26cb652 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:13:28 +0200 Subject: [PATCH 11/20] Add category to global search grouping --- .../internal/ui/screens/SearchResultsScreen.kt | 7 ++++--- .../oneconfig/internal/ui/search/SettingIndex.kt | 13 ++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt index 8bbcb42aa..316cd5967 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt @@ -46,13 +46,14 @@ fun SearchResultsScreen(query: String) { // Grouped by owning mod, in order of first appearance, so a mod is only headed once while keeping its best hit's // rank. Accordions collapse into one row per accordion rather than one per matching option inside it. val groupedOptions: Map> = remember(results) { - val byMod = LinkedHashMap>() + val byGroup = LinkedHashMap>() results.forEach { (row, documents) -> if (row == null || documents.isEmpty()) return@forEach val node = searchNode(row.node, documents) ?: return@forEach - byMod.getOrPut(row.modTitle ?: "Other") { ArrayList() } += node + val mod = row.modTitle ?: "Other" + byGroup.getOrPut(row.groupLabel?.let { "$mod / $it" } ?: mod) { ArrayList() } += node } - byMod + byGroup } val listState = rememberRestorableLazyListState("global-search") diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt index 0043be5af..497c6302f 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt @@ -141,6 +141,11 @@ internal class SearchRow( val modTitle: String?, val category: String, val subcategory: String, + /** + * How this row should be headed inside its mod: the category, or the subcategory when the config only has one + * category, or null when the config only has 1 category and subcategory. + */ + val groupLabel: String?, val node: SettingNode, ) @@ -149,10 +154,16 @@ internal class SearchRow( */ internal fun buildSearchIndex(categories: List, modTitle: String? = null): Map { val owners = IdentityHashMap() + val singleCategory = categories.size == 1 categories.forEach { category -> category.subcategories.forEach { subcategory -> + val groupLabel = when { + !singleCategory -> category.name + category.subcategories.size > 1 -> subcategory.name + else -> null + } subcategory.nodes.forEach { node -> - val row = SearchRow(modTitle, category.name, subcategory.name, node) + val row = SearchRow(modTitle, category.name, subcategory.name, groupLabel, node) when (node) { is SettingNode.Leaf -> owners[node.prop] = row is SettingNode.Accordion -> { From f9e2f61cee325e21d75a444c055282d44389237b Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:26:20 +0200 Subject: [PATCH 12/20] Fix not always scrolling to top on search --- .../internal/ui/screens/ConfigScreen.kt | 4 +-- .../oneconfig/internal/ui/screens/Keybinds.kt | 6 ++--- .../ui/screens/SearchResultsScreen.kt | 4 +-- .../internal/ui/shell/ScrollMemory.kt | 27 ++++++++++++------- 4 files changed, 21 insertions(+), 20 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 44d64102c..7882cf33a 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 @@ -97,7 +97,6 @@ import org.polyfrost.oneconfig.internal.ui.search.flattenSearchEntries import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.search.searchNode import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController -import org.polyfrost.oneconfig.internal.ui.shell.ScrollToTopOnChange import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -145,8 +144,7 @@ fun ConfigScreen(tree: Tree, initialCategory: String? = null, pageKey: String) { } } - val lazyListState = rememberRestorableLazyListState(pageKey) - ScrollToTopOnChange(lazyListState, localSearchQuery) + val lazyListState = rememberRestorableLazyListState(pageKey, localSearchQuery) if (entries.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt index 420aa0eb5..81d4857e4 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.animation.core.animateFloatAsState import androidx.compose.runtime.Composable @@ -73,7 +72,7 @@ import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus import org.polyfrost.oneconfig.internal.ui.search.SearchDocument import org.polyfrost.oneconfig.internal.ui.search.SearchScope import org.polyfrost.oneconfig.internal.ui.search.searchMatches -import org.polyfrost.oneconfig.internal.ui.shell.ScrollToTopOnChange +import org.polyfrost.oneconfig.internal.ui.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.util.LayoutRef @@ -96,8 +95,7 @@ fun Keybinds() { val searchResults = rememberKeybindSearchResults(groups, localSearchQuery) val visibleGroups = if (localSearchQuery.isBlank()) groups else searchResults.orEmpty() - val listState = rememberLazyListState() - ScrollToTopOnChange(listState, localSearchQuery) + val listState = rememberRestorableLazyListState("keybinds", localSearchQuery) if (visibleGroups.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt index 316cd5967..8ab2e52d1 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt @@ -21,7 +21,6 @@ import org.polyfrost.oneconfig.internal.ui.search.SearchRow import org.polyfrost.oneconfig.internal.ui.search.SearchScope import org.polyfrost.oneconfig.internal.ui.search.SettingNode import org.polyfrost.oneconfig.internal.ui.search.searchNode -import org.polyfrost.oneconfig.internal.ui.shell.ScrollToTopOnChange import org.polyfrost.oneconfig.internal.ui.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -56,8 +55,7 @@ fun SearchResultsScreen(query: String) { byGroup } - val listState = rememberRestorableLazyListState("global-search") - ScrollToTopOnChange(listState, query) + val listState = rememberRestorableLazyListState("global-search", query) if (matchingMods.isEmpty() && groupedOptions.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt index 56e92790f..58478d499 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt @@ -9,8 +9,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow -/** The first visible item of a scrollable page, and how far it is scrolled past. */ -data class ScrollAnchor(val index: Int, val offset: Int) +/** + * The first visible item of a scrollable page, and how far it is scrolled past. + * + * [token] records what the page was showing when the anchor was taken (like a search query). + * Only remount if the token is the same. + */ +data class ScrollAnchor(val index: Int, val offset: Int, val token: Any? = null) /** * A [LazyListState] that outlives the Compose scene. @@ -18,14 +23,18 @@ data class ScrollAnchor(val index: Int, val offset: Int) * Displaying another screen over the OneConfig UI disposes the scene and rebuilds it on the way back, which * loses everything held in composition. Pages that want to come back where they were keep their position in * [ShellState.scrollAnchors] under a stable [key] — a mod id, a page name — instead. + * + * [resetToken] identifies the token being stored, for things like search queries, if this changes it will scroll + * to the top of the page. */ @Composable -fun rememberRestorableLazyListState(key: String): LazyListState { - val anchor = remember(key) { ShellState.scrollAnchors[key] } +fun rememberRestorableLazyListState(key: String, resetToken: Any? = null): LazyListState { + val anchor = remember(key) { ShellState.scrollAnchors[key]?.takeIf { it.token == resetToken } } val state = rememberLazyListState(anchor?.index ?: 0, anchor?.offset ?: 0) + ScrollToTopOnChange(state, resetToken) LaunchedEffect(state, key) { snapshotFlow { ScrollAnchor(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset) } - .collect { ShellState.scrollAnchors[key] = it } + .collect { ShellState.scrollAnchors[key] = it.copy(token = resetToken) } } return state } @@ -36,11 +45,9 @@ fun rememberRestorableLazyListState(key: String): LazyListState { @Composable fun ScrollToTopOnChange(state: LazyListState, key: Any?) { val previous = remember(state) { LastKey(key) } - LaunchedEffect(state, key) { - if (previous.value != key) { - previous.value = key - state.scrollToItem(0) - } + if (previous.value != key) { + previous.value = key + state.requestScrollToItem(0) } } From cd3338012f7227b566fedd7c842d5d73111f0286 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:04:28 +0200 Subject: [PATCH 13/20] Fix: recover UI on composition failure --- .../oneconfig/internal/ui/compose/ComposeScreen.kt | 10 ++++++++++ .../internal/ui/compose/impls/OneConfigUIScreen.kt | 12 ++++++++++++ .../oneconfig/internal/ui/components/Header.kt | 1 + .../polyfrost/oneconfig/internal/ui/shell/Data.kt | 6 ++++++ 4 files changed, 29 insertions(+) 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 647e45a1b..170982a25 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 @@ -151,6 +151,15 @@ abstract class ComposeScreen( ) } + /** + * Called when a failed scene has been discarded and the screen is about to be rebuilt from scratch. + * + * The session is still open, but everything the old composition held is gone, so whatever the screen + * wants back has to be put somewhere the fresh composition will read it. Runs before the new scene is + * given its content. + */ + protected open fun onSceneRebuilding() {} + private fun ensureScene(): ComposeScene? { if (scenePoisoned) closeSceneQuietly() sceneOrNull?.let { return it } @@ -356,6 +365,7 @@ abstract class ComposeScreen( return } sceneRebuilds++ + onSceneRebuilding() val rebuilt = ensureScene() if (rebuilt == null || !bindContent(rebuilt)) { closeSceneQuietly() 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 d607fa7ec..320e8496d 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 @@ -140,6 +140,7 @@ class OneConfigUIScreen @JvmOverloads constructor( ShellState.playerName = "Player" } ShellState.focusSearchField = OneConfigConfig.instantSearch + ShellState.searchFieldFocused = false val client = net.minecraft.client.Minecraft.getInstance() val cachedHead = PlayerHeadLoader.cachedLocalPlayerHeadPng(client) if (cachedHead != null) { @@ -178,6 +179,17 @@ class OneConfigUIScreen @JvmOverloads constructor( super.init() } + /** + * A scene that failed mid-frame is thrown away and rebuilt, but the menu never closed. + * Treat the rebuild as coming back from another screen, fixes hanging of the GUI. + */ + override fun onSceneRebuilding() { + ShellState.lastRoute?.takeIf { it !== HudEditorRoute }?.let { route = it } + resuming = true + restoring = true + if (ShellState.searchFieldFocused) ShellState.focusSearchField = true + } + override fun removed() { // A screen opened over this one removes it and hands it back when it closes, and the scene is rebuilt // from scratch in between, so the page has to be carried across by hand. diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt index de0ec22d1..9406a0b94 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt @@ -224,6 +224,7 @@ fun GlobalSearchBar() { LaunchedEffect(searchText) { ShellState.searchQuery = searchText } LaunchedEffect(ShellState.searchQuery) { if (ShellState.searchQuery != searchText) searchText = ShellState.searchQuery } + LaunchedEffect(isFocused) { ShellState.searchFieldFocused = isFocused } LaunchedEffect(ShellState.focusSearchField) { if (ShellState.focusSearchField) { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt index a7327d228..ebc3fabd6 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/Data.kt @@ -43,6 +43,12 @@ object ShellState { var focusSearchField by mutableStateOf(false) + /** + * Whether the search field currently holds focus. Mirrored here so a discarded composition + * can recover the state. + */ + var searchFieldFocused: Boolean = false + var showSearchField by mutableStateOf(false) var hudDragging by mutableStateOf(false) From ae69d6afb1d8588a73f338e14ed5c85a0e5b7193 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:41:42 +0200 Subject: [PATCH 14/20] Small fixes --- .../oneconfig/internal/compat/CompatIds.kt | 3 ++- .../ui/search/DefaultSearchProvider.kt | 2 +- .../internal/ui/search/SearchCorpus.kt | 4 ++- .../internal/ui/search/SearchProvider.kt | 2 -- .../ui/search/SearchProviderRegistry.kt | 4 ++- .../internal/ui/search/SearchResults.kt | 26 ------------------- 6 files changed, 9 insertions(+), 32 deletions(-) delete mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchResults.kt diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatIds.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatIds.kt index 7c2c50f55..d512574d0 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatIds.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatIds.kt @@ -4,12 +4,13 @@ package org.polyfrost.oneconfig.internal.compat * Create stable ids for a mod */ internal object CompatIds { + private val notAllowedIdRegex = Regex("[^a-z0-9._-]+") /** * Take a string and turn it into something allowed in a node id */ fun idPart(raw: String?, fallback: String): String { - val cleaned = raw?.trim()?.lowercase()?.replace(Regex("[^a-z0-9._-]+"), "_")?.trim('_') + val cleaned = raw?.trim()?.lowercase()?.replace(notAllowedIdRegex, "_")?.trim('_') return cleaned?.takeIf { it.isNotEmpty() } ?: fallback } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt index 9a42cfc13..81c7465e9 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt @@ -15,7 +15,7 @@ internal object DefaultSearchProvider : SearchProvider { if (q.isEmpty()) return emptyList() val searchingKeybinds = SearchScope.Keybinds in scopes return SearchCorpus.corpus.values.filter { - if (it.scopes.intersect(scopes).isEmpty()) { + if (scopes.none { s -> s in it.scopes }) { return@filter false } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt index 7561babea..ed43ccef6 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt @@ -160,7 +160,6 @@ object SearchCorpus { snapshot = sources.toList() synchronized(dirtySources) { dirty = expandDirty(dirtySources, snapshot) - dirtySources.clear() } } @@ -209,6 +208,9 @@ object SearchCorpus { // Swap to new corpus, non-cancellable to prevent desyncs with search providers withContext(NonCancellable) { corpus = documents + // Update sources that are no longer dirty + dirtySources -= dirty + if (ConfigDocumentSource in dirty) GlobalSettingIndex.rebuild() LOGGER.info( "Rebuilt corpus from $asked/${snapshot.size} sources, " + diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt index 81d8b6315..cb8923c87 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt @@ -1,7 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.search -import org.polyfrost.oneconfig.internal.ui.api.ConfigData - /** * A class responsible for searching configs diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt index cd09ed0a4..779df97e9 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt @@ -1,10 +1,12 @@ package org.polyfrost.oneconfig.internal.ui.search +import java.util.concurrent.CopyOnWriteArrayList + /** * Object storing all search providers */ object SearchProviderRegistry { - private val providers: MutableList = mutableListOf() + private val providers: CopyOnWriteArrayList = CopyOnWriteArrayList() /** * Register a new search provider diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchResults.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchResults.kt deleted file mode 100644 index 14b9e2aa3..000000000 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchResults.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.polyfrost.oneconfig.internal.ui.search - -import org.polyfrost.oneconfig.api.config.v1.Property -import org.polyfrost.oneconfig.internal.ui.api.ConfigData - - -sealed interface SearchResult { - val displayName: Any - val icon: String? -} - -data class ModResult(val config: ConfigData) : SearchResult { - override val displayName get() = config.title - override val icon get() = config.icon -} - -data class OptionResult( - val modId: String, - val modTitle: Any, - val optionTitle: Any, - val category: String?, - override val icon: String?, - val prop: Property<*>?, -) : SearchResult { - override val displayName get() = optionTitle -} From 03411b6ab5e0ba65f4c8cccc3ed78e1ce9e0f04b Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:15:00 +0200 Subject: [PATCH 15/20] Add description and search tags to HUD --- .../kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt | 10 ++++++++++ .../oneconfig/internal/ui/search/ConfigDocuments.kt | 2 ++ 2 files changed, 12 insertions(+) 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 a6b240e2e..9594e1910 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 @@ -108,6 +108,16 @@ private const val MAX_ANCHOR_DEPTH = 16 @Suppress("EqualsOrHashCode", "UnstableApiUsage") abstract class Hud(id: String, title: String, val category: Category) : Cloneable, Config(id, null, title, null) { + /** + * Description of the HUD + */ + open val description: String? = null + + /** + * Search tags, like synonyms, what your users might search for + */ + open val searchTags: List = emptyList() + private var _staticWidth: MutableState = mutableStateOf(false) var staticWidth: Boolean get() = _staticWidth.value diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt index 2afe3947d..bb50fe8d7 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt @@ -167,8 +167,10 @@ object HudDocumentSource : SearchDocumentSource { scopes = setOf(SearchScope.Huds), metadata = SearchMetadata( title = localizedLabel(hud.title)?.takeIf { it.isNotBlank() }, + description = localizedLabel(hud.description)?.takeIf { it.isNotBlank() }, id = hud.id.takeIf { it.isNotBlank() }, category = localizedLabel(hud.category.name)?.takeIf { it.isNotBlank() }, + tags = hud.searchTags.mapNotNull { localizedLabel(it)?.takeIf { l -> l.isNotEmpty() } }, modTitle = hud.configId?.let { modNameFor(it) ?: it }?.takeIf { it.isNotBlank() }, modDescription = config?.description?.asRenderText()?.takeIf { it.isNotBlank() }, ), From 533af55233b1c1b3137dfa9eb70de6745ad88342 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:36:45 +0200 Subject: [PATCH 16/20] Add HUD mod card to search corpus --- .../internal/ui/hud/BuiltinHudConfig.kt | 3 +- .../internal/ui/hud/HudModCardData.kt | 20 +++++++++--- .../ui/screens/SearchResultsScreen.kt | 31 +++++++++++-------- .../internal/ui/search/ConfigDocuments.kt | 31 +++++++++++++++++++ .../ui/search/DefaultSearchProvider.kt | 4 ++- .../internal/ui/search/SearchCorpus.kt | 1 + 6 files changed, 70 insertions(+), 20 deletions(-) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt index b98fade1d..ca717be24 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt @@ -9,6 +9,7 @@ import org.polyfrost.oneconfig.internal.ui.api.ConfigData import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.ConfigSource import org.polyfrost.oneconfig.internal.ui.search.HudDocumentSource +import org.polyfrost.oneconfig.internal.ui.search.HudModCardDocumentSource import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus internal const val BUILTIN_HUD_CONFIG_ID = "oneconfig.builtin" @@ -34,7 +35,7 @@ object BuiltinHudRegistrar { HudManager.providers().forEach { hud -> if (hud.configId == null) hud.configId = BUILTIN_HUD_CONFIG_ID } - SearchCorpus.invalidate(HudDocumentSource) + SearchCorpus.invalidate(HudDocumentSource, HudModCardDocumentSource) ModCardTypes.register(ModCardType(HUD_MOD_CARD_TYPE_ID, "HUDs", "hud", priority = 100)) ModCardTypes.registerResolver(hudTypeResolver) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt index fa78e7931..eb6cedf28 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt @@ -40,7 +40,12 @@ private fun ownerVisible(ownerId: String, owner: ConfigData?): Boolean { (owner == null || ConfigRegistry.shouldShowModCardId(owner.id)) } -internal fun hudModCardConfigs(): List { +/** + * Cache so the search corpus can keep using the same mod cards + */ +private val cardCache = HashMap() + +internal fun hudModCardConfigs(): List = synchronized(cardCache) { @Suppress("UNUSED_VARIABLE") val revision = HudManager.revision @@ -52,20 +57,25 @@ internal fun hudModCardConfigs(): List { val owner = findOwner(ownerId) if (!ownerVisible(ownerId, owner)) continue val id = hudCardId(hud, ownerId) - if (seen.add(id)) out.add(HudModCardData(hud, ownerId, owner, id)) + if (!seen.add(id)) continue + val cached = cardCache[id]?.takeIf { it.hud === hud && it.owner === owner } + out.add(cached ?: HudModCardData(hud, ownerId, owner, id).also { cardCache[id] = it }) } + cardCache.keys.retainAll(seen) return out } -private class HudModCardData( - private val hud: Hud, +internal class HudModCardData( + val hud: Hud, ownerId: String, - owner: ConfigData?, + val owner: ConfigData?, override val id: String, ) : ConfigData { override val title: Any get() = withHudSuffix(localizedValue(hud.title) ?: hud.title) + override val description: String? = hud.description + override val icon: String? = if (hud is LegacyHudMarker) null else { HudManager.iconFor(ownerId) ?: owner?.icon diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt index 8ab2e52d1..96744a3ad 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt @@ -24,6 +24,9 @@ import org.polyfrost.oneconfig.internal.ui.search.searchNode import org.polyfrost.oneconfig.internal.ui.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +/** Cards per row, matching the mods screen grid. */ +private const val MOD_COLUMNS = 4 + @Composable fun SearchResultsScreen(query: String) { val theme = LocalTheme.current @@ -84,21 +87,23 @@ fun SearchResultsScreen(query: String) { ) } item(key = "mods-grid") { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(19.dp), - verticalArrangement = Arrangement.spacedBy(19.dp), - modifier = Modifier.fillMaxWidth(), - maxItemsInEachRow = 4, - ) { - matchingMods.forEach { mod -> - Box(Modifier.weight(1f)) { - ModCard(mod) + // Use fixed width or HUD mod card dies when rendering + Column(verticalArrangement = Arrangement.spacedBy(19.dp)) { + matchingMods.chunked(MOD_COLUMNS).forEach { row -> + Row( + horizontalArrangement = Arrangement.spacedBy(19.dp), + modifier = Modifier.fillMaxWidth(), + ) { + row.forEach { mod -> + Box(Modifier.weight(1f)) { + ModCard(mod) + } + } + repeat(MOD_COLUMNS - row.size) { + Box(Modifier.weight(1f)) + } } } - val remainder = (4 - matchingMods.size % 4) % 4 - repeat(remainder) { - Box(Modifier.weight(1f)) - } } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt index bb50fe8d7..62a13856d 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt @@ -16,7 +16,9 @@ import org.polyfrost.oneconfig.internal.ui.components.localizedDescription import org.polyfrost.oneconfig.internal.ui.components.localizedGroup import org.polyfrost.oneconfig.internal.ui.components.localizedLabel import org.polyfrost.oneconfig.internal.ui.components.localizedTitle +import org.polyfrost.oneconfig.internal.ui.hud.HudModCardData import org.polyfrost.oneconfig.internal.ui.hud.configForHud +import org.polyfrost.oneconfig.internal.ui.hud.hudModCardConfigs import org.polyfrost.oneconfig.internal.ui.hud.modNameFor import org.polyfrost.oneconfig.internal.ui.keybind.KeybindProviderRegistry import org.polyfrost.oneconfig.internal.ui.keybind.isKeybindProperty @@ -177,4 +179,33 @@ object HudDocumentSource : SearchDocumentSource { payload = hud, ) } +} + +/** + * The HUDs shown as mod cards on the config screen + */ +object HudModCardDocumentSource : SearchDocumentSource { + override val dependencies = setOf(ConfigDocumentSource) + + init { + HudManager.addRegistrationListener { SearchCorpus.invalidate(HudModCardDocumentSource) } + } + + override fun documents(): List> = hudModCardConfigs().mapNotNull { card -> + val hud = (card as? HudModCardData)?.hud ?: return@mapNotNull null + SearchDocument( + id = "mod$ID_SEPARATOR${card.id}", + scopes = setOf(SearchScope.Mods), + metadata = SearchMetadata( + title = card.title.asRenderText().takeIf { it.isNotBlank() }, + id = hud.id.takeIf { it.isNotBlank() }, + description = localizedLabel(hud.description)?.takeIf { it.isNotBlank() }, + category = localizedLabel(hud.category.name)?.takeIf { it.isNotBlank() }, + tags = hud.searchTags.mapNotNull { localizedLabel(it)?.takeIf { l -> l.isNotBlank() } }, + modTitle = hud.configId?.let { modNameFor(it) ?: it }?.takeIf { it.isNotBlank() }, + modDescription = card.owner?.description?.asRenderText()?.takeIf { it.isNotBlank() }, + ), + payload = card, + ) + } } \ No newline at end of file diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt index 81c7465e9..a4b20ab59 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt @@ -20,7 +20,9 @@ internal object DefaultSearchProvider : SearchProvider { } val meta = it.metadata - if (it.scopes.contains(SearchScope.Mods)) return@filter meta.title.matches(q) + if (it.scopes.contains(SearchScope.Mods)) { + return@filter meta.title.matches(q) || meta.tags.any { t -> t.matches(q) } + } if (meta.title.matches(q) || meta.description.matches(q)) return@filter true if (meta.tags.any { t -> t.matches(q) }) return@filter true // Hud specific diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt index ed43ccef6..e301474c2 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt @@ -47,6 +47,7 @@ object SearchCorpus { registerSource(ConfigDocumentSource) registerSource(KeybindDocumentSource) registerSource(HudDocumentSource) + registerSource(HudModCardDocumentSource) } fun registerSource(source: SearchDocumentSource) { From 3ecb021082122a46e562452fa3b52e125129893a Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:10:36 +0200 Subject: [PATCH 17/20] Fix small issues --- modules/hud/api/hud.api | 2 ++ .../polyfrost/oneconfig/api/hud/v1/HudManager.kt | 1 - .../oneconfig/internal/ui/api/ConfigRegistry.kt | 1 - .../oneconfig/internal/ui/hud/HudModCardData.kt | 12 ++++++++---- .../oneconfig/internal/ui/search/ConfigDocuments.kt | 1 + .../internal/ui/search/DefaultSearchProvider.kt | 8 +++++++- .../oneconfig/internal/ui/search/SearchCorpus.kt | 13 ++++++------- .../internal/ui/search/SearchProviderRegistry.kt | 3 ++- .../oneconfig/internal/ui/shell/ScrollMemory.kt | 6 +++++- modules/ui/api/ui.api | 4 +++- 10 files changed, 34 insertions(+), 17 deletions(-) diff --git a/modules/hud/api/hud.api b/modules/hud/api/hud.api index c663bd7d8..e88e5f38e 100644 --- a/modules/hud/api/hud.api +++ b/modules/hud/api/hud.api @@ -49,6 +49,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun getCategory ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; public final fun getConfigId ()Ljava/lang/String; public fun getCustomScale ()F + public fun getDescription ()Ljava/lang/String; public final fun getEffectiveAnchorParent ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud; public final fun getEffectiveAnchorPoint ()Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor; public final fun getEffectiveGrowthAnchor ()Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor; @@ -74,6 +75,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun getRuntimeOrNull ()Lorg/polyfrost/compose/runtime/PolyComposeRuntime; public fun getScaledHeight ()F public fun getScaledWidth ()F + public fun getSearchTags ()Ljava/util/List; public final fun getSection ()Lorg/polyfrost/oneconfig/api/hud/v1/Section; public final fun getSelfAnchorPoint ()Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor; public final fun getShadowChroma ()Z 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 7250407ef..8de21eec4 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 @@ -247,7 +247,6 @@ object HudManager { fun providers(): Collection = hudProviders.values fun unregister(hud: T, removeActiveInstances: Boolean = false, delete: Boolean = false): ArrayList? { - hudProviders.remove(hud::class.java) if (hudProviders.remove(hud::class.java) != null) revision++ notifyRegistrationChanged() if (!removeActiveInstances) return null diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt index b0d91408e..49ee15769 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt @@ -16,7 +16,6 @@ import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus object ConfigRegistry { private val hiddenModCardIds = setOf( "oneconfig.json", - "oneconfig.builtin", "themes.json", "oneconfig.builtin", // built-in huds "minecraft", diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt index eb6cedf28..1fd9a6851 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt @@ -12,6 +12,7 @@ import org.polyfrost.oneconfig.internal.ui.api.ConfigSource import org.polyfrost.oneconfig.internal.ui.components.asRenderText import org.polyfrost.oneconfig.internal.ui.components.localizedValue import org.polyfrost.oneconfig.internal.ui.hud.components.HudPreview +import java.util.concurrent.ConcurrentHashMap private const val HUD_CARD_ID_PREFIX = "oneconfig.hud:" @@ -43,9 +44,9 @@ private fun ownerVisible(ownerId: String, owner: ConfigData?): Boolean { /** * Cache so the search corpus can keep using the same mod cards */ -private val cardCache = HashMap() +private val cardCache = ConcurrentHashMap() -internal fun hudModCardConfigs(): List = synchronized(cardCache) { +internal fun hudModCardConfigs(): List { @Suppress("UNUSED_VARIABLE") val revision = HudManager.revision @@ -58,8 +59,11 @@ internal fun hudModCardConfigs(): List = synchronized(cardCache) { if (!ownerVisible(ownerId, owner)) continue val id = hudCardId(hud, ownerId) if (!seen.add(id)) continue - val cached = cardCache[id]?.takeIf { it.hud === hud && it.owner === owner } - out.add(cached ?: HudModCardData(hud, ownerId, owner, id).also { cardCache[id] = it }) + out.add( + cardCache.compute(id) { _, cached -> + cached?.takeIf { it.hud === hud && it.owner === owner } ?: HudModCardData(hud, ownerId, owner, id) + }!!, + ) } cardCache.keys.retainAll(seen) return out diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt index 62a13856d..f2ec40abb 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt @@ -137,6 +137,7 @@ private fun treeDocuments( modTitle = modTitle?.takeIf { it.isNotBlank() }, modDescription = modDescription?.takeIf { it.isNotBlank() }, tags = searchTags, + path = path ), payload = node, ) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt index a4b20ab59..2baf778c2 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt @@ -14,6 +14,7 @@ internal object DefaultSearchProvider : SearchProvider { val q = query.trim() if (q.isEmpty()) return emptyList() val searchingKeybinds = SearchScope.Keybinds in scopes + val searchingConfig = scopes.any { it is SearchScope.Config } return SearchCorpus.corpus.values.filter { if (scopes.none { s -> s in it.scopes }) { return@filter false @@ -25,6 +26,11 @@ internal object DefaultSearchProvider : SearchProvider { } if (meta.title.matches(q) || meta.description.matches(q)) return@filter true if (meta.tags.any { t -> t.matches(q) }) return@filter true + // Config specific + if (searchingConfig && it.scopes.any { s -> s is SearchScope.Config } && + (meta.category.matches(q) || meta.subcategory.matches(q) || meta.id.matches(q))) { + return@filter true + } // Hud specific if (SearchScope.Huds in it.scopes && ( meta.category.matches(q) || meta.subcategory.matches(q) || @@ -32,7 +38,7 @@ internal object DefaultSearchProvider : SearchProvider { ) ) return@filter true // Match old search for keybinds - if (searchingKeybinds && ( + if (searchingKeybinds && SearchScope.Keybinds in it.scopes && ( meta.category.matches(q) || meta.subcategory.matches(q) || meta.id.matches(q) || meta.path.matches(q) ) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt index e301474c2..a2c9e5e43 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt @@ -32,7 +32,7 @@ object SearchCorpus { private val rebuildMutex = Mutex() private val sources = ArrayList() - /** Sources which need to be run again on the next rebuild. */ + /** Sources which need to be run again on the next rebuild, only touched under `synchronized(sources)`. */ private val dirtySources = HashSet() /** What every source built, only touched under [rebuildMutex]. */ @@ -61,6 +61,7 @@ object SearchCorpus { fun unregisterSource(source: SearchDocumentSource) { synchronized(sources) { if (!sources.remove(source)) return + dirtySources -= source } schedule() } @@ -159,9 +160,7 @@ object SearchCorpus { val dirty: Set synchronized(sources) { snapshot = sources.toList() - synchronized(dirtySources) { - dirty = expandDirty(dirtySources, snapshot) - } + dirty = expandDirty(dirtySources, snapshot) } val previous = corpus @@ -172,6 +171,7 @@ object SearchCorpus { val cached = produced[source] val sourceDocuments = if (cached != null && source !in dirty) cached else { asked++ + synchronized(sources) { dirtySources -= source } try { // Keep the previous element of the corpus if nothing changed source.documents().map { document -> @@ -179,6 +179,8 @@ object SearchCorpus { } } catch (e: Throwable) { LOGGER.error("Search document source ${source.javaClass.name} failed", e) + // Add this source as dirty, since it failed + synchronized(sources) { if (source in sources) dirtySources += source } cached ?: continue } } @@ -209,9 +211,6 @@ object SearchCorpus { // Swap to new corpus, non-cancellable to prevent desyncs with search providers withContext(NonCancellable) { corpus = documents - // Update sources that are no longer dirty - dirtySources -= dirty - if (ConfigDocumentSource in dirty) GlobalSettingIndex.rebuild() LOGGER.info( "Rebuilt corpus from $asked/${snapshot.size} sources, " + diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt index 779df97e9..fda17cfbd 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt @@ -23,7 +23,8 @@ object SearchProviderRegistry { /** * Get the search provider with the highest priority that is currently available */ - internal fun get(): SearchProvider = providers.first { it.isAvailable() } + internal fun get(): SearchProvider = + providers.firstOrNull { runCatching { it.isAvailable() }.getOrNull() ?: false } ?: DefaultSearchProvider internal fun all(): List = providers.toList() diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt index 58478d499..68c421063 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt @@ -6,7 +6,9 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow /** @@ -32,9 +34,11 @@ fun rememberRestorableLazyListState(key: String, resetToken: Any? = null): LazyL val anchor = remember(key) { ShellState.scrollAnchors[key]?.takeIf { it.token == resetToken } } val state = rememberLazyListState(anchor?.index ?: 0, anchor?.offset ?: 0) ScrollToTopOnChange(state, resetToken) + // the effect outlives token changes, so read the latest one + val currentToken by rememberUpdatedState(resetToken) LaunchedEffect(state, key) { snapshotFlow { ScrollAnchor(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset) } - .collect { ShellState.scrollAnchors[key] = it.copy(token = resetToken) } + .collect { ShellState.scrollAnchors[key] = it.copy(token = currentToken) } } return state } diff --git a/modules/ui/api/ui.api b/modules/ui/api/ui.api index 422b00b92..2ee0ad793 100644 --- a/modules/ui/api/ui.api +++ b/modules/ui/api/ui.api @@ -3,8 +3,10 @@ public final class org/polyfrost/oneconfig/api/ui/v1/ModCardType { public fun (Ljava/lang/String;Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZ)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun equals (Ljava/lang/Object;)Z + public final fun getCollapsedByDefault ()Z public final fun getIcon ()Ljava/lang/String; public final fun getId ()Ljava/lang/String; public final fun getPriority ()I From 9303dfc0fda26bf2135e02c047bd745d76e4479d Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:19:35 +0200 Subject: [PATCH 18/20] Revert modcard concurrency change --- .../oneconfig/internal/ui/hud/HudModCardData.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt index 1fd9a6851..9dafb3695 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModCardData.kt @@ -44,9 +44,9 @@ private fun ownerVisible(ownerId: String, owner: ConfigData?): Boolean { /** * Cache so the search corpus can keep using the same mod cards */ -private val cardCache = ConcurrentHashMap() +private val cardCache = HashMap() -internal fun hudModCardConfigs(): List { +internal fun hudModCardConfigs(): List = synchronized(cardCache) { @Suppress("UNUSED_VARIABLE") val revision = HudManager.revision @@ -59,11 +59,8 @@ internal fun hudModCardConfigs(): List { if (!ownerVisible(ownerId, owner)) continue val id = hudCardId(hud, ownerId) if (!seen.add(id)) continue - out.add( - cardCache.compute(id) { _, cached -> - cached?.takeIf { it.hud === hud && it.owner === owner } ?: HudModCardData(hud, ownerId, owner, id) - }!!, - ) + val cached = cardCache[id]?.takeIf { it.hud === hud && it.owner === owner } + out.add(cached ?: HudModCardData(hud, ownerId, owner, id).also { cardCache[id] = it }) } cardCache.keys.retainAll(seen) return out From c217ffa5033ad0f8cedb1c1188a3357523bd23c2 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:01:37 +0200 Subject: [PATCH 19/20] Fix moulconfig compat ids not going trough unique id --- .../internal/compat/MoulConfigCompat.kt | 23 ++++++++++++------- .../internal/compat/MoulPropertyBuilder.kt | 5 ++-- .../oneconfig/api/hud/v1/HudManager.kt | 6 +++-- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt index 2c17a5825..7b72d841c 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt @@ -20,6 +20,7 @@ import org.polyfrost.oneconfig.api.config.v1.dsl.subcategory import org.polyfrost.oneconfig.api.ui.v1.keybind.KeyModifiers import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart +import org.polyfrost.oneconfig.internal.compat.CompatIds.uniqueId import org.polyfrost.oneconfig.internal.utils.MoulConfigGuiOptionEditorDropdownAccessor import java.awt.Color import java.lang.reflect.Type @@ -63,6 +64,7 @@ data object MoulConfigCompat { fun parseConfigTree(config: MoulConfig, children: Iterable): Tree = Tree.tree().apply { val map = mutableMapOf() + val usedIds = HashSet() val mod = CompatLoader.findFirstMod() LOGGER.info("Loading for ${mod?.id ?: "unknown"}") this.id = mod?.id ?: config.toString() @@ -75,7 +77,7 @@ data object MoulConfigCompat { } children.forEach { - val tree = parseCategory(config, it, this) { parent -> map[parent] ?: this } + val tree = parseCategory(config, it, this, usedIds) { parent -> map[parent] ?: this } map[it.identifier] = tree this.put(tree) } @@ -85,6 +87,7 @@ data object MoulConfigCompat { config: MoulConfig, category: ProcessedCategory, root: Tree, + usedIds: MutableSet, parentResolver: (String?) -> Tree, ): Tree { val displayName = resolveDisplayName(category) @@ -94,11 +97,11 @@ data object MoulConfigCompat { val accordionMap = mutableMapOf() category.options.forEach { option -> - parseOption(config, option, categoryName, displayName, root, accordionMap) + parseOption(config, option, categoryName, displayName, root, accordionMap, usedIds) } return Tree.tree().apply { - id = idPart(category.identifier, "category") + id = uniqueId(usedIds, idPart(category.identifier, "category")) this.category = categoryName this.title = displayName this.subcategory = displayName @@ -112,12 +115,13 @@ data object MoulConfigCompat { subcategoryName: String, root: Tree, accordionMap: MutableMap, + usedIds: MutableSet, ) { val editor = children.editor if (editor is GuiOptionEditorAccordion) { val builder = MoulPropertyBuilder(children) val accordionTree = Tree.tree() - accordionTree.id = idPart(builder.path ?: builder.name, "section") + accordionTree.id = uniqueId(usedIds, idPart(builder.path ?: builder.name, "section")) accordionTree.title = builder.name?.takeIf { it.isNotBlank() } ?: "Section" accordionTree.category = categoryName accordionTree.subcategory = subcategoryName @@ -132,7 +136,7 @@ data object MoulConfigCompat { return } - val built = buildOptionProperty(config, children, categoryName, subcategoryName) ?: return + val built = buildOptionProperty(config, children, categoryName, subcategoryName, usedIds) ?: return val parentTarget = if (children.accordionId >= 0) { accordionMap[children.accordionId] ?: root @@ -147,6 +151,7 @@ data object MoulConfigCompat { children: ProcessedOption, categoryName: String, subcategoryName: String, + usedIds: MutableSet, ): Property<*>? { val property = MoulPropertyBuilder(children) @@ -276,7 +281,7 @@ data object MoulConfigCompat { } property.metadata["visualizer"] = visualizer - val built = property.build() + val built = property.build(usedIds) built.category = categoryName built.subcategory = subcategoryName return built @@ -291,13 +296,14 @@ data object MoulConfigCompat { subcategory: String? = null, ): List> { val out = ArrayList>() + val usedIds = HashSet() runCatching { processor.allCategories.values.forEach { processed -> val catName = category ?: resolveDisplayName(processed) val subName = subcategory ?: catName processed.options.forEach { option -> if (MoulPropertyBuilder(option).declaringClass != declaringClass) return@forEach - buildOptionProperty(config, option, catName, subName)?.let(out::add) + buildOptionProperty(config, option, catName, subName, usedIds)?.let(out::add) } } }.onFailure { @@ -314,8 +320,9 @@ data object MoulConfigCompat { subcategory: String, ): List> { val out = ArrayList>() + val usedIds = HashSet() options.forEach { option -> - runCatching { buildOptionProperty(config, option, category, subcategory) } + runCatching { buildOptionProperty(config, option, category, subcategory, usedIds) } .onFailure { LOGGER.error("Failed to build property for ${option.path}: $it") } .getOrNull()?.let(out::add) } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt index 5cb79a216..b3a291284 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt @@ -5,6 +5,7 @@ import io.github.notenoughupdates.moulconfig.processor.ProcessedOption import org.polyfrost.oneconfig.api.config.v1.CompatSnapshots import org.polyfrost.oneconfig.api.config.v1.Properties import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart +import org.polyfrost.oneconfig.internal.compat.CompatIds.uniqueId import org.polyfrost.oneconfig.relocator.annotations.MoulConfig import java.lang.reflect.Field @@ -25,8 +26,8 @@ class MoulPropertyBuilder internal constructor(option: ProcessedOption) { private val snapshotKey: String? = backingField?.let { "${it.declaringClass.name}#${it.name}" } - fun build() = Properties.functional( - id = idPart(path ?: snapshotKey ?: name, "option"), + fun build(usedIds: MutableSet) = Properties.functional( + id = uniqueId(usedIds, idPart(path ?: snapshotKey ?: name, "option")), getter = getter, setter = setter, name = name, 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 04931cf01..f8dcbdd8c 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 @@ -251,8 +251,10 @@ object HudManager { fun providers(): Collection = hudProviders.values fun unregister(hud: T, removeActiveInstances: Boolean = false, delete: Boolean = false): ArrayList? { - if (hudProviders.remove(hud::class.java) != null) revision++ - notifyRegistrationChanged() + if (hudProviders.remove(hud::class.java) != null) { + revision++ + notifyRegistrationChanged() + } if (!removeActiveInstances) return null val out = ArrayList(10.coerceAtMost(activeInstances.size)) val iter = activeInstances.iterator() From aac9a24d13d58103e9ab3256f08078638371b280 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 8 Aug 2026 01:46:13 +0700 Subject: [PATCH 20/20] fix scroll issues --- .../internal/ui/screens/ConfigScreen.kt | 16 ++++++++++++---- .../oneconfig/internal/ui/screens/Keybinds.kt | 13 +++++++++---- .../internal/ui/screens/SearchResultsScreen.kt | 2 +- .../oneconfig/internal/ui/shell/ScrollMemory.kt | 14 +++++++++----- 4 files changed, 31 insertions(+), 14 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 7882cf33a..36613ff45 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 @@ -134,7 +134,8 @@ fun ConfigScreen(tree: Tree, initialCategory: String? = null, pageKey: String) { val revision = rememberDisplayRevision(categories) val index = remember(categories) { buildSearchIndex(categories) } - val results = rememberSearchResults(index, localSearchQuery, pageKey) + val search = rememberSearchResults(index, localSearchQuery, pageKey) + val results = search.results val entries = remember(index, selectedCategory, localSearchQuery, revision, results) { when { localSearchQuery.isBlank() -> @@ -144,7 +145,7 @@ fun ConfigScreen(tree: Tree, initialCategory: String? = null, pageKey: String) { } } - val lazyListState = rememberRestorableLazyListState(pageKey, localSearchQuery) + val lazyListState = rememberRestorableLazyListState(pageKey, localSearchQuery, search.query) if (entries.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -203,21 +204,28 @@ private fun rememberDisplayRevision(categories: List): Int { return revision } +private class LocalSearchResults( + val results: Map>>?, + val query: String?, +) + @Composable private fun rememberSearchResults( index: Map, query: String, pageKey: String, -): Map>>? { +): LocalSearchResults { var results by remember(pageKey) { mutableStateOf>>?>(null) } + var searchedQuery by remember(pageKey) { mutableStateOf(null) } LaunchedEffect(index, query, pageKey) { results = if (query.isBlank()) emptyMap() else withContext(Dispatchers.Default) { SearchCorpus.searchGrouped(query, setOf(SearchScope.Config(pageKey))) { document -> (document.payload as? Node)?.let(index::get) } } + searchedQuery = query } - return results + return LocalSearchResults(results, searchedQuery) } /** diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt index 81d4857e4..b18dfa009 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt @@ -92,10 +92,11 @@ fun Keybinds() { val providerRevision = KeybindProviderRegistry.revision.intValue val groups = remember(revision, providerRevision, configs) { collectAllKeybindGroups() } val localSearchQuery = if (ShellState.globalSearchActive) "" else ShellState.searchQuery.trim() - val searchResults = rememberKeybindSearchResults(groups, localSearchQuery) + val search = rememberKeybindSearchResults(groups, localSearchQuery) + val searchResults = search.groups val visibleGroups = if (localSearchQuery.isBlank()) groups else searchResults.orEmpty() - val listState = rememberRestorableLazyListState("keybinds", localSearchQuery) + val listState = rememberRestorableLazyListState("keybinds", localSearchQuery, search.query) if (visibleGroups.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -154,14 +155,18 @@ fun Keybinds() { /** The group and entry one keybind property renders as, for resolving corpus hits back to rows. */ private class KeybindOwner(val group: KeybindGroup, val entry: KeybindEntry) +private class KeybindSearchResults(val groups: List?, val query: String?) + @Composable -private fun rememberKeybindSearchResults(groups: List, query: String): List? { +private fun rememberKeybindSearchResults(groups: List, query: String): KeybindSearchResults { var results by remember { mutableStateOf?>(null) } + var searchedQuery by remember { mutableStateOf(null) } LaunchedEffect(groups, query) { results = if (query.isBlank()) null else withContext(Dispatchers.Default) { searchKeybindGroups(groups, query) } + searchedQuery = query } - return results + return KeybindSearchResults(results, searchedQuery) } private fun searchKeybindGroups(groups: List, query: String): List { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt index 0e329c14c..65a3d32bd 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt @@ -66,7 +66,7 @@ fun SearchResultsScreen(query: String) { byGroup } - val listState = rememberRestorableLazyListState("global-search", query) + val listState = rememberRestorableLazyListState("global-search", query, searchedQuery) if (matchingMods.isEmpty() && groupedOptions.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt index 68c421063..15dfaa1db 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ScrollMemory.kt @@ -30,10 +30,14 @@ data class ScrollAnchor(val index: Int, val offset: Int, val token: Any? = null) * to the top of the page. */ @Composable -fun rememberRestorableLazyListState(key: String, resetToken: Any? = null): LazyListState { +fun rememberRestorableLazyListState( + key: String, + resetToken: Any? = null, + contentToken: Any? = resetToken, +): LazyListState { val anchor = remember(key) { ShellState.scrollAnchors[key]?.takeIf { it.token == resetToken } } val state = rememberLazyListState(anchor?.index ?: 0, anchor?.offset ?: 0) - ScrollToTopOnChange(state, resetToken) + ScrollToTopOnChange(state, contentToken, initial = resetToken) // the effect outlives token changes, so read the latest one val currentToken by rememberUpdatedState(resetToken) LaunchedEffect(state, key) { @@ -47,9 +51,9 @@ fun rememberRestorableLazyListState(key: String, resetToken: Any? = null): LazyL * Jumps [state] back to the top whenever [key] changes. */ @Composable -fun ScrollToTopOnChange(state: LazyListState, key: Any?) { - val previous = remember(state) { LastKey(key) } - if (previous.value != key) { +fun ScrollToTopOnChange(state: LazyListState, key: Any?, initial: Any? = key) { + val previous = remember(state) { LastKey(initial) } + if (key != null && previous.value != key) { previous.value = key state.requestScrollToItem(0) }