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 e997c3d77..2a2ed7c60 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 @@ -50,7 +50,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/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java index 440c54d50..fb4718841 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; /** @@ -252,6 +254,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/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..d512574d0 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatIds.kt @@ -0,0 +1,41 @@ +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(notAllowedIdRegex, "_")?.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 9613c32d1..c65afd518 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() @@ -217,7 +228,8 @@ object MidnightLibCompat { val translationKey = translationKeyOf(entry, entryAnnotation, "$modid.midnightconfig.$fieldName") val name = labelOf(translationKey) ?: prettify(fieldName) val description = translateOrNull("$translationKey.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..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 @@ -19,10 +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.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 -import java.util.* import kotlin.reflect.KClass // do not remove the im import org.polyfrost.oneconfig.internal.compat.MoulPropertyBuilder @@ -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 = UUID.randomUUID().toString() + id = uniqueId(usedIds, idPart(category.identifier, "category")) this.category = categoryName this.title = displayName this.subcategory = displayName @@ -112,12 +115,14 @@ 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 = UUID.randomUUID().toString() - accordionTree.title = MoulPropertyBuilder(children).name?.takeIf { it.isNotBlank() } ?: "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 @@ -131,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 @@ -146,6 +151,7 @@ data object MoulConfigCompat { children: ProcessedOption, categoryName: String, subcategoryName: String, + usedIds: MutableSet, ): Property<*>? { val property = MoulPropertyBuilder(children) @@ -275,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 @@ -290,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 { @@ -313,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 ebcc1a0b5..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 @@ -4,12 +4,14 @@ 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.internal.compat.CompatIds.uniqueId 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") @@ -24,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 = UUID.randomUUID().toString(), + fun build(usedIds: MutableSet) = Properties.functional( + id = uniqueId(usedIds, 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 7a69fc9b7..2afa1fddf 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, ) 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 e658ccc2f..95f9da215 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 } @@ -358,6 +367,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/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index 4879b46db..35e1bdb8f 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/config-impl/api/config-impl.api b/modules/config-impl/api/config-impl.api index cdea7f710..406fdba26 100644 --- a/modules/config-impl/api/config-impl.api +++ b/modules/config-impl/api/config-impl.api @@ -71,6 +71,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; @@ -109,6 +110,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 5578c7baa..58855f296 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,6 +69,7 @@ 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 static final String PROFILE_LOCAL_METADATA = "profileLocal"; @@ -76,10 +77,19 @@ 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); @@ -705,7 +715,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/hud/api/hud.api b/modules/hud/api/hud.api index 78f0be02a..da1a5cf93 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; @@ -75,6 +76,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 @@ -255,6 +257,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 drawMergedBackgrounds (Lorg/polyfrost/compose/render/RenderContext;)V 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 57c542b4d..f7cdef963 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/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 8e74c267a..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 @@ -42,12 +42,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() var revision by mutableIntStateOf(0) private set @@ -201,11 +203,22 @@ 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 revision++ if (hud.updateFrequency() == 0L) LOGGER.warn("update of HUD ${hud.title} is 0, this is not recommended!") + notifyRegistrationChanged() } @JvmStatic @@ -238,7 +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++ + if (hudProviders.remove(hud::class.java) != null) { + revision++ + 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/api/ConfigData.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigData.kt index d34ea4341..7cd5ea5c1 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 @@ -17,6 +17,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 7da07328d..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 @@ -10,12 +10,14 @@ import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.internal.ui.components.asRenderText import org.polyfrost.oneconfig.internal.ui.hud.hudModCardConfigs 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 { private val hiddenModCardIds = setOf( "oneconfig.json", - "oneconfig.builtin", "themes.json", + "oneconfig.builtin", // built-in huds "minecraft", "resourcefulconfig", "modmenu", @@ -57,6 +59,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.asRenderText().lowercase() !in hiddenModCardTitles @@ -74,21 +81,31 @@ 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 } + // 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(ConfigDocumentSource) 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) { @@ -97,6 +114,7 @@ object ConfigRegistry { fun unregister(id: String) { if (configs.removeAll { it.id == id }) { + SearchCorpus.invalidate(ConfigDocumentSource) revision++ } } @@ -105,15 +123,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(ConfigDocumentSource) 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 72378e632..00f422018 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 @@ -9,7 +9,7 @@ import org.polyfrost.oneconfig.internal.ui.components.localizedValue 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 @@ -58,6 +58,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/components/Header.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt index 13c1ecd90..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 @@ -46,18 +46,11 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import org.polyfrost.oneconfig.internal.ui.components.dropdown.DropdownPositionProvider import androidx.navigation.compose.currentBackStackEntryAsState -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.ConfigRegistry -import org.polyfrost.oneconfig.internal.ui.api.TreeConfigData import org.polyfrost.oneconfig.internal.ui.LocalCloseRequest import org.polyfrost.oneconfig.internal.ui.navigation.searchPlaceholder import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import net.kyori.adventure.text.ComponentLike import org.polyfrost.oneconfig.internal.ui.api.Tooltip import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.Accent @@ -217,120 +210,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.localizedDescription()?.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<*> -> { - if (node.title == null) return@forEach - val title = node.localizedTitle() - if (searchMatches(title.asRenderText(), q) || descriptionMatches || searchTags) { - val cat = localizedLabel(node.getMetadata("category")) - matchingOptions += OptionResult(configData.id, configData.title, title, cat, configData.icon, node) - } - } - is Tree -> { - val subTitle = node.title?.let { node.localizedTitle() } - if (subTitle != null && searchMatches(subTitle.asRenderText(), q) ) { - val cat = localizedLabel(node.getMetadata("category")) - matchingOptions += OptionResult(configData.id, configData.title, subTitle, cat, configData.icon, null) - } - node.map.values.filterIsInstance>().forEach { prop -> - if (prop.title == null) return@forEach - val pt = prop.localizedTitle() - if (searchMatches(pt.asRenderText(), q) || descriptionMatches || searchTags) { - val cat = localizedLabel(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 @@ -345,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/hud/BuiltinHudConfig.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/BuiltinHudConfig.kt index 07d915045..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 @@ -8,6 +8,9 @@ import org.polyfrost.oneconfig.api.ui.v1.ModCardTypes 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" internal const val BUILTIN_HUD_ICON = "/assets/oneconfig/brand/oneconfig-icon.svg" @@ -32,6 +35,7 @@ object BuiltinHudRegistrar { HudManager.providers().forEach { hud -> if (hud.configId == null) hud.configId = BUILTIN_HUD_CONFIG_ID } + 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..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 @@ -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:" @@ -40,7 +41,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 +58,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/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 ee40ef459..624c3d342 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 @@ -55,7 +57,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 @@ -65,11 +66,14 @@ import org.polyfrost.oneconfig.internal.ui.components.layout.FlexibleLayout import org.polyfrost.oneconfig.internal.ui.hud.HudCanvasPasteMenu import org.polyfrost.oneconfig.internal.ui.hud.HudCanvasResetMenu import org.polyfrost.oneconfig.internal.ui.hud.LegacyHudOverlayBridge +import org.polyfrost.oneconfig.internal.ui.hud.modNameFor import org.polyfrost.oneconfig.internal.ui.hud.components.HudPreviewCanvas import org.polyfrost.oneconfig.internal.ui.hud.components.rememberHudPreview 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 @@ -1083,13 +1087,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 -> @@ -2552,15 +2556,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?, @@ -2678,6 +2673,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/keybind/KeybindCatalog.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/KeybindCatalog.kt index 7d9414f01..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,8 @@ 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( val modId: String, @@ -36,6 +38,7 @@ object KeybindProviderRegistry { fun register(provider: KeybindGroupProvider) { if (provider in providers) return providers += provider + SearchCorpus.invalidate(KeybindDocumentSource) revision.intValue++ } 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 1cf23de3b..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 @@ -58,11 +58,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,43 +73,35 @@ import org.polyfrost.oneconfig.internal.ui.components.asRenderText import org.polyfrost.oneconfig.internal.ui.components.blockInteraction 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 -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.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.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.util.LayoutRef -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) } @@ -140,40 +133,39 @@ 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 search = rememberSearchResults(index, localSearchQuery, pageKey) + val results = search.results + val entries = remember(index, selectedCategory, localSearchQuery, revision, results) { + when { + localSearchQuery.isBlank() -> + selectedCategory?.let(::filterHiddenNodes)?.let(::flattenEntries).orEmpty() + results == null -> emptyList() + else -> flattenSearchEntries(searchCategories(results)) } } + val lazyListState = rememberRestorableLazyListState(pageKey, localSearchQuery, search.query) + 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 } - val lazyListState = rememberRestorableLazyListState(pageKey) Box(modifier = Modifier.fillMaxSize()) { LazyColumn( state = lazyListState, 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), @@ -183,52 +175,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 -> @@ -258,6 +204,45 @@ 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, +): 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 LocalSearchResults(results, searchedQuery) +} + +/** + * 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 -> @@ -303,60 +288,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 @@ -365,8 +296,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 @@ -838,7 +787,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( @@ -884,16 +833,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) } } } } @@ -903,30 +843,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 e198be206..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 @@ -16,11 +16,11 @@ 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 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 +44,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 @@ -56,7 +59,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 +68,11 @@ 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.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 @@ -85,14 +92,20 @@ 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 search = rememberKeybindSearchResults(groups, localSearchQuery) + val searchResults = search.groups + val visibleGroups = if (localSearchQuery.isBlank()) groups else searchResults.orEmpty() + + val listState = rememberRestorableLazyListState("keybinds", localSearchQuery, search.query) 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 @@ -104,7 +117,6 @@ fun Keybinds() { val searching = localSearchQuery.isNotBlank() - val listState = rememberLazyListState() Box(Modifier.fillMaxSize()) { LazyColumn( state = listState, @@ -140,19 +152,40 @@ 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) + +private class KeybindSearchResults(val groups: List?, val query: String?) + +@Composable +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 KeybindSearchResults(results, searchedQuery) } -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/Profiles.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt index f61da93d8..b4d43c861 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 20531c5c9..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 @@ -12,23 +12,25 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +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.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.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.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import org.polyfrost.oneconfig.api.config.v1.Property private const val MOD_GRID_COLUMNS = 4 private val MOD_GRID_GAP = 19.dp @@ -36,31 +38,46 @@ private val MOD_GRID_GAP = 19.dp @Composable fun SearchResultsScreen(query: String) { val theme = LocalTheme.current - val results by remember(query) { derivedStateOf { performSearch(query) } } + + // Run search asynchronously + var searchedQuery by remember { mutableStateOf(null) } + var results by remember { mutableStateOf>>>(emptyMap()) } + LaunchedEffect(query) { + val found = withContext(Dispatchers.Default) { + 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 byGroup = LinkedHashMap>() + results.forEach { (row, documents) -> + if (row == null || documents.isEmpty()) return@forEach + val node = searchNode(row.node, documents) ?: return@forEach + val mod = row.modTitle ?: "Other" + byGroup.getOrPut(row.groupLabel?.let { "$mod / $it" } ?: mod) { ArrayList() } += node } - map + byGroup } + val listState = rememberRestorableLazyListState("global-search", query, searchedQuery) + 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 } - val listState = rememberRestorableLazyListState("global-search") Box(Modifier.fillMaxSize()) { LazyColumn( state = listState, @@ -101,7 +118,7 @@ fun SearchResultsScreen(query: String) { } } - groupedOptions.forEach { (group, props) -> + groupedOptions.forEach { (group, nodes) -> item(key = "header:opts:$group") { Text( group.uppercase(), @@ -111,9 +128,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..f2ec40abb --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt @@ -0,0 +1,212 @@ +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.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.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 + +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(), + modDescription = config.description?.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.takeIf { it.isNotBlank() }, + path = entry.path.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 = localizedLabel(config.category.name)?.takeIf { it.isNotBlank() }, + subcategory = tree?.subcategory?.asRenderText()?.takeIf { it.isNotBlank() }, + ), + payload = config, + ) +} + +private fun treeDocuments( + tree: Tree, + ownerId: String, + modTitle: String?, + modDescription: 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() }, + modDescription = modDescription?.takeIf { it.isNotBlank() }, + tags = searchTags, + path = path + ), + 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 +} + +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(HudDocumentSource) } + } + + 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() }, + 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() }, + ), + 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 new file mode 100644 index 000000000..2baf778c2 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/DefaultSearchProvider.kt @@ -0,0 +1,97 @@ +package org.polyfrost.oneconfig.internal.ui.search + +import org.polyfrost.oneconfig.internal.OneConfigConfig + +internal object DefaultSearchProvider : SearchProvider { + override val priority: Int = 0 // Low priority + + override fun isAvailable(): Boolean = true + + override fun search( + query: String, + scopes: Set + ): List> { + 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 + } + + val meta = it.metadata + 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 + // 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) || + meta.id.matches(q) || meta.modTitle.matches(q) + ) + ) return@filter true + // Match old search for keybinds + if (searchingKeybinds && SearchScope.Keybinds in it.scopes && ( + meta.category.matches(q) || meta.subcategory.matches(q) || + meta.id.matches(q) || meta.path.matches(q) + ) + ) return@filter true + false + } + } + + override fun searchGrouped( + query: String, + scopes: Set, + grouper: (SearchDocument<*>) -> T + ): Map>> { + return search(query, scopes).groupBy(grouper) + } + + private fun String?.matches(query: String): Boolean = this != null && searchMatches(this, query) +} + +/** + * 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 [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, query: String): Boolean { + val q = query.lowercase() + 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/SearchCorpus.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt new file mode 100644 index 000000000..a2c9e5e43 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchCorpus.kt @@ -0,0 +1,251 @@ +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 +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. 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") + + private var initialized = AtomicBoolean(false) + 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, only touched under `synchronized(sources)`. */ + private val dirtySources = HashSet() + + /** What every source built, only touched under [rebuildMutex]. */ + private val produced = HashMap>>() + private var rebuildJob: Job? = null + + @Volatile + var corpus: Map> = emptyMap() + private set + + init { + registerSource(ConfigDocumentSource) + registerSource(KeybindDocumentSource) + registerSource(HudDocumentSource) + registerSource(HudModCardDocumentSource) + } + + fun registerSource(source: SearchDocumentSource) { + synchronized(sources) { + if (source in sources) return + sources += source + } + invalidate(source) + } + + fun unregisterSource(source: SearchDocumentSource) { + synchronized(sources) { + if (!sources.remove(source)) return + dirtySources -= source + } + schedule() + } + + /** + * Called when resources are done loading, prevents a lot of corpus builds during initial loading + */ + fun init() { + if (initialized.getAndSet(true)) return + invalidate() + } + + /** + * Schedule a background rebuild + */ + 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() + 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 { + // Collect rebuild parameters in a thread safe manner + val start = System.currentTimeMillis() + val snapshot: List + val dirty: Set + synchronized(sources) { + snapshot = sources.toList() + dirty = expandDirty(dirtySources, snapshot) + } + + val previous = corpus + val documents = LinkedHashMap>(previous.size.coerceAtLeast(16)) + val upserted = ArrayList>() + var asked = 0 + for (source in snapshot) { + 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 -> + previous[document.id]?.takeIf { it.equivalentTo(document) } ?: document + } + } 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 + } + } + 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 + } + + // 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}" + ) + + 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 new file mode 100644 index 000000000..bf3484005 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchDocument.kt @@ -0,0 +1,82 @@ +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 + + /** 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 +} + +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 modDescription: 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 + + /** + * 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 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 new file mode 100644 index 000000000..cb8923c87 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProvider.kt @@ -0,0 +1,50 @@ +package org.polyfrost.oneconfig.internal.ui.search + + +/** + * 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 all options/mods within a scope + * + * @param query The search query + * @param scopes The scopes to search in + * @return A list of search results + */ + fun search( + query: String, + 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) {} +} + 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..fda17cfbd --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SearchProviderRegistry.kt @@ -0,0 +1,34 @@ +package org.polyfrost.oneconfig.internal.ui.search + +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Object storing all search providers + */ +object SearchProviderRegistry { + private val providers: CopyOnWriteArrayList = CopyOnWriteArrayList() + + /** + * 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 + */ + internal fun get(): SearchProvider = + providers.firstOrNull { runCatching { it.isAvailable() }.getOrNull() ?: false } ?: DefaultSearchProvider + + internal fun all(): List = providers.toList() + + init { + registerSearchProvider(DefaultSearchProvider) + } +} \ No newline at end of file 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..497c6302f --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt @@ -0,0 +1,215 @@ +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, + /** + * 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, +) + +/** + * 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() + 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, groupLabel, 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 + } +} 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) 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..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 @@ -6,11 +6,18 @@ 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 -/** 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,18 +25,42 @@ 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, + 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, contentToken, initial = 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 } + .collect { ShellState.scrollAnchors[key] = it.copy(token = currentToken) } } return state } +/** + * Jumps [state] back to the top whenever [key] changes. + */ +@Composable +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) + } +} + +private class LastKey(var value: Any?) + /** [rememberRestorableLazyListState] for a grid. */ @Composable fun rememberRestorableLazyGridState(key: String): LazyGridState { diff --git a/modules/utils/api/utils.api b/modules/utils/api/utils.api index 654f2be7e..98df3b8a7 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