diff options
8 files changed, 335 insertions, 3 deletions
diff --git a/src/main/java/moe/nea/firmament/init/AutoDiscoveryPlugin.java b/src/main/java/moe/nea/firmament/init/AutoDiscoveryPlugin.java index a9db7f9..07e4549 100644 --- a/src/main/java/moe/nea/firmament/init/AutoDiscoveryPlugin.java +++ b/src/main/java/moe/nea/firmament/init/AutoDiscoveryPlugin.java @@ -27,6 +27,8 @@ public class AutoDiscoveryPlugin { return mixins.stream().map(it -> defaultName + "." + it).toList(); } + // TODO: remove println + private static final List<AutoDiscoveryPlugin> mixinPlugins = new ArrayList<>(); public static List<AutoDiscoveryPlugin> getMixinPlugins() { diff --git a/src/main/kotlin/Firmament.kt b/src/main/kotlin/Firmament.kt index b00546a..218d304 100644 --- a/src/main/kotlin/Firmament.kt +++ b/src/main/kotlin/Firmament.kt @@ -74,6 +74,7 @@ object Firmament { prettyPrint = DEBUG isLenient = true allowTrailingComma = true + allowComments = true ignoreUnknownKeys = true encodeDefaults = true prettyPrintIndent = if (prettyPrint) "\t" else DEFAULT_JSON_INDENT diff --git a/src/main/kotlin/util/textutil.kt b/src/main/kotlin/util/textutil.kt index cfda2e9..177b0af 100644 --- a/src/main/kotlin/util/textutil.kt +++ b/src/main/kotlin/util/textutil.kt @@ -179,10 +179,11 @@ fun Text.transformEachRecursively(function: (Text) -> Text): Text { val c = this.content if (c is TranslatableTextContent) { return Text.translatableWithFallback(c.key, c.fallback, *c.args.map { - (if (it is Text) it else Text.literal(it.toString())).transformEachRecursively(function) + (it as? Text ?: Text.literal(it.toString())).transformEachRecursively(function) }.toTypedArray()).also { new -> new.style = this.style new.siblings.clear() + val new = function(new) this.siblings.forEach { child -> new.siblings.add(child.transformEachRecursively(function)) } diff --git a/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomTextReplacements.kt b/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomTextReplacements.kt new file mode 100644 index 0000000..8f7fc06 --- /dev/null +++ b/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomTextReplacements.kt @@ -0,0 +1,56 @@ +package moe.nea.firmament.features.texturepack + +import net.minecraft.resource.ResourceManager +import net.minecraft.resource.SinglePreparationResourceReloader +import net.minecraft.text.Text +import net.minecraft.util.profiler.Profiler +import moe.nea.firmament.Firmament +import moe.nea.firmament.annotations.Subscribe +import moe.nea.firmament.events.FinalizeResourceManagerEvent +import moe.nea.firmament.util.ErrorUtil.intoCatch + +object CustomTextReplacements : SinglePreparationResourceReloader<List<TreeishTextReplacer>>() { + + override fun prepare( + manager: ResourceManager, + profiler: Profiler + ): List<TreeishTextReplacer> { + return manager.findResources("overrides/texts") { it.namespace == "firmskyblock" && it.path.endsWith(".json") } + .mapNotNull { + Firmament.tryDecodeJsonFromStream<TreeishTextReplacer>(it.value.inputStream) + .intoCatch("Failed to load text override from ${it.key}").orNull() + } + } + + var textReplacers: List<TreeishTextReplacer> = listOf() + + override fun apply( + prepared: List<TreeishTextReplacer>, + manager: ResourceManager, + profiler: Profiler + ) { + this.textReplacers = prepared + } + + @JvmStatic + fun replaceTexts(texts: List<Text>): List<Text> { + return texts.map { replaceText(it) } + } + + @JvmStatic + fun replaceText(text: Text): Text { + // TODO: add a config option for this + val rawText = text.string + var text = text + for (replacer in textReplacers) { + if (!replacer.match.matches(rawText)) continue + text = replacer.replaceText(text) + } + return text + } + + @Subscribe + fun onReloadStart(event: FinalizeResourceManagerEvent) { + event.resourceManager.registerReloader(this) + } +} diff --git a/src/texturePacks/java/moe/nea/firmament/features/texturepack/StringMatcher.kt b/src/texturePacks/java/moe/nea/firmament/features/texturepack/StringMatcher.kt index 7de84d2..863ca3b 100644 --- a/src/texturePacks/java/moe/nea/firmament/features/texturepack/StringMatcher.kt +++ b/src/texturePacks/java/moe/nea/firmament/features/texturepack/StringMatcher.kt @@ -23,6 +23,10 @@ interface StringMatcher { return matches(text.string) } + val asRegex: java.util.regex.Pattern + + fun matchWithGroups(string: String): MatchNamedGroupCollection? + fun matches(nbt: NbtString): Boolean { val string = nbt.value val jsonStart = string.indexOf('{') @@ -36,20 +40,59 @@ interface StringMatcher { } class Equals(input: String, val stripColorCodes: Boolean) : StringMatcher { + override val asRegex by lazy(LazyThreadSafetyMode.PUBLICATION) { input.toPattern(java.util.regex.Pattern.LITERAL) } private val expected = if (stripColorCodes) input.removeColorCodes() else input override fun matches(string: String): Boolean { return expected == (if (stripColorCodes) string.removeColorCodes() else string) } + override fun matchWithGroups(string: String): MatchNamedGroupCollection? { + if (matches(string)) + return object : MatchNamedGroupCollection { + override fun get(name: String): MatchGroup? { + return null + } + + override fun get(index: Int): MatchGroup? { + return null + } + + override val size: Int + get() = 0 + + override fun isEmpty(): Boolean { + return true + } + + override fun contains(element: MatchGroup?): Boolean { + return false + } + + override fun iterator(): Iterator<MatchGroup?> { + return emptyList<MatchGroup>().iterator() + } + + override fun containsAll(elements: Collection<MatchGroup?>): Boolean { + return elements.isEmpty() + } + } + return null + } + override fun toString(): String { return "Equals($expected, stripColorCodes = $stripColorCodes)" } } class Pattern(val patternWithColorCodes: String, val stripColorCodes: Boolean) : StringMatcher { - private val regex: Predicate<String> = patternWithColorCodes.toPattern().asMatchPredicate() + private val pattern = patternWithColorCodes.toRegex() + override val asRegex = pattern.toPattern() override fun matches(string: String): Boolean { - return regex.test(if (stripColorCodes) string.removeColorCodes() else string) + return pattern.matches(if (stripColorCodes) string.removeColorCodes() else string) + } + + override fun matchWithGroups(string: String): MatchNamedGroupCollection? { + return pattern.matchEntire(if (stripColorCodes) string.removeColorCodes() else string)?.groups as MatchNamedGroupCollection? } override fun toString(): String { diff --git a/src/texturePacks/java/moe/nea/firmament/features/texturepack/TreeishTextReplacer.kt b/src/texturePacks/java/moe/nea/firmament/features/texturepack/TreeishTextReplacer.kt new file mode 100644 index 0000000..0a59451 --- /dev/null +++ b/src/texturePacks/java/moe/nea/firmament/features/texturepack/TreeishTextReplacer.kt @@ -0,0 +1,87 @@ +package moe.nea.firmament.features.texturepack + +import java.lang.StringBuilder +import java.util.regex.Matcher +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonElement +import net.minecraft.text.Text +import net.minecraft.text.TextCodecs +import moe.nea.firmament.util.directLiteralStringContent +import moe.nea.firmament.util.json.KJsonOps +import moe.nea.firmament.util.transformEachRecursively + +@Serializable +data class TreeishTextReplacer( + val match: StringMatcher, + val replacements: List<SubPartReplacement> +) { + @Serializable + data class SubPartReplacement( + val match: StringMatcher, + val replace: @Serializable(TextSerializer::class) Text, + ) + + object TextSerializer : KSerializer<Text> { + override val descriptor: SerialDescriptor + get() = JsonElement.serializer().descriptor + + override fun serialize(encoder: Encoder, value: Text) { + encoder.encodeSerializableValue( + JsonElement.serializer(), + TextCodecs.CODEC.encodeStart(KJsonOps.INSTANCE, value).orThrow + ) + } + + override fun deserialize(decoder: Decoder): Text { + return TextCodecs.CODEC.decode(KJsonOps.INSTANCE, decoder.decodeSerializableValue(JsonElement.serializer())) + .orThrow.first + } + } + + companion object { + val pattern = "(?!<\\$([$]{2})*)[$]\\{(?<name>[^}])\\}".toPattern() + fun injectMatchResults(text: Text, matches: Matcher): Text { + return text.transformEachRecursively { it -> + val content = it.directLiteralStringContent ?: return@transformEachRecursively it + val matcher = pattern.matcher(content) + val builder = StringBuilder() + while (matcher.find()) { + matcher.appendReplacement(builder, matches.group(matcher.group("name")).toString()) + } + matcher.appendTail(builder) + Text.literal(builder.toString()).setStyle(it.style) + } + } + } + + fun match(text: Text): Boolean { + return match.matches(text) + } + + fun replaceText(text: Text): Text { + return text.transformEachRecursively { part -> + var part: Text = part + for (replacement in replacements) { + val rawPartText = part.string + val matcher = replacement.match.asRegex.matcher(rawPartText) + if (!matcher.find()) continue + val p = Text.literal("") + p.setStyle(part.style) + var lastAppendPosition = 0 + do { + p.append(rawPartText.substring(lastAppendPosition, matcher.start())) + lastAppendPosition = matcher.end() + p.append(injectMatchResults(replacement.replace, matcher)) + } while (matcher.find()) + p.append(rawPartText.substring(lastAppendPosition)) + part = p + } + part + } + } + +} diff --git a/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/ReplaceTextsInDrawContext.java b/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/ReplaceTextsInDrawContext.java new file mode 100644 index 0000000..faf15cc --- /dev/null +++ b/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/ReplaceTextsInDrawContext.java @@ -0,0 +1,55 @@ +package moe.nea.firmament.mixins.custommodels; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import moe.nea.firmament.features.texturepack.CustomTextReplacements; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.text.StringVisitable; +import net.minecraft.text.Text; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +import java.util.stream.Stream; + +@Mixin(DrawContext.class) +public class ReplaceTextsInDrawContext { + // I HATE THIS SO MUCH WHY CANT I JUST OPERATE ON ORDEREDTEXTS!!! + // JUNE I WILL RIP ALL OF THIS OUT AND MAKE YOU REWRITE EVERYTHING + // TODO: be in a mood to rewrite this + + @ModifyVariable(method = "drawText(Lnet/minecraft/client/font/TextRenderer;Lnet/minecraft/text/Text;IIIZ)V", at = @At("HEAD"), argsOnly = true) + private Text replaceTextInDrawText(Text text) { + return CustomTextReplacements.replaceText(text); + } + + @ModifyVariable(method = "drawCenteredTextWithShadow(Lnet/minecraft/client/font/TextRenderer;Lnet/minecraft/text/Text;III)V", at = @At("HEAD"), argsOnly = true) + private Text replaceTextInDrawCenteredTextWithShadow(Text text) { + return CustomTextReplacements.replaceText(text); + } + + @ModifyVariable(method = "drawWrappedText", at = @At("HEAD"), argsOnly = true) + private StringVisitable replaceTextInDrawWrappedText(StringVisitable stringVisitable) { + return stringVisitable instanceof Text text ? CustomTextReplacements.replaceText(text) : stringVisitable; + } + + @ModifyExpressionValue(method = "drawTooltip(Lnet/minecraft/client/font/TextRenderer;Ljava/util/List;IILnet/minecraft/util/Identifier;)V", at = @At(value = "INVOKE", target = "Ljava/util/List;stream()Ljava/util/stream/Stream;")) + private Stream<Text> replaceTextInDrawTooltipListText(Stream<Text> original) { + return original.map(CustomTextReplacements::replaceText); + } + + @ModifyExpressionValue(method = "drawTooltip(Lnet/minecraft/client/font/TextRenderer;Ljava/util/List;Ljava/util/Optional;IILnet/minecraft/util/Identifier;)V", at = @At(value = "INVOKE", target = "Ljava/util/List;stream()Ljava/util/stream/Stream;")) + private Stream<Text> replaceTextInDrawTooltipListTextWithOptional(Stream<Text> original) { + return original.map(CustomTextReplacements::replaceText); + } + + @ModifyVariable(method = "drawTooltip(Lnet/minecraft/text/Text;II)V", at = @At("HEAD"), argsOnly = true) + private Text replaceTextInDrawTooltipSingle(Text text) { + return CustomTextReplacements.replaceText(text); + } + + @ModifyExpressionValue(method = "drawHoverEvent", at = @At(value = "INVOKE", target = "Lnet/minecraft/text/HoverEvent$ShowText;value()Lnet/minecraft/text/Text;")) + private Text replaceShowTextInHover(Text text) { + return CustomTextReplacements.replaceText(text); + } + +} diff --git a/web/src/pages/docs/_texture-pack-format.md b/web/src/pages/docs/_texture-pack-format.md index 3575cfc..129ec14 100644 --- a/web/src/pages/docs/_texture-pack-format.md +++ b/web/src/pages/docs/_texture-pack-format.md @@ -719,6 +719,93 @@ Available options | `<extraComponent>.width` | false | The new width of the component | | `<extraComponent>.height` | false | The new height of the component | +## Text Replacements + +> [!WARNING] +> This syntax is _experimental_ and may be reworked with no backwards compatibility guarantees. If you have a use case for this syntax, please contact me so that I can figure out what kind of features are needed for the final version of this API. + +Firmament allows you to replace arbitrary texts with other texts during rendering. This only affects rendering, not what other mods see. + +To do this, place a text override in `firmskyblock:overrides/texts/<my-override>.json`: + +```json +{ + "match": { + "regex": ".*Strength.*" + }, + "replacements": [ + { + "match": "❁", + "replace": { + "text": "<newIcon>", + "color": "#ff0000" + } + } + ] +} +``` + +There are notably two separate "match" sections. This is important. The first (top-level) match checks against the entire text element, while the replacement match operates on each individual subcomponent. Let's look at an example: + +```json +{ + "italic": false, + "text": "", + "extra": [ + " ", + { + "color": "red", + "text": "❁ Strength " + }, + { + "color": "white", + "text": "510.45" + } + ] +} +``` + +In this the entire text rendered out looks like `" ❁ Strength 510.45"` and the top-level match (`".*Strength.*"`) needs to match that line. + +Then each replacement (in the `replacements` array) is matched against each subcomponent. +First, it tries to find `"❁"` in the empty root element `""`. Then it tries the first child (`" "`) and fails again. Then it tries the `"❁ Strength "` component and finds one match. It then splits the `"❁ Strength "` component into multiple subsubcomponents and replaces just the `❁` part with the one specified in the replacements array. Afterwards, it fails to match the `"510.45"` component and returns. + +Our finalized text looks like this: + +```json +{ + "italic": false, + "text": "", + "extra": [ + " ", + { + "color": "red", + "text": "", + "extra": [ + { + "text": "<newIcon>", + "color": "#ff0000" + }, + " Strength " + ] + }, + { + "color": "white", + "text": "510.45" + } + ] +} +``` + +Which rendered out looks like ` <newIcon> Strength 510.45`, with all colours original, except the `<newIcon>` which not only has new text but also a new colour. + +| Field | Required | Description | +|--------------------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `match` | yes | A top level [string matcher](#string-matcher). Allows for testing parts of the text unrelated to the replacement and improves performance. | +| `replacements` | yes | A list of replacements to apply to each part of the text | +| `replacements.*.match` | yes | A [string matcher](#string-matcher) substring to replace in each component of the text. Notabene: Unlike most string matchers, this one is not anchored to the beginning and end of the element, so if the entire component needs to be matched a regex with `^$` needs to be used. | +| `replacements.*.replace` | yes | A vanilla [text](https://minecraft.wiki/w/Text_component_format#Java_Edition) that is inserted to replace the substring matched in the match. If literal texts (not translated texts) are used, then `${name}` can be used to access named groups in the match regex (if a regex matcher was used). | + ## Global Item Texture Replacement |
