From 063602a3790b1a4b09d59934291954ed104d3df3 Mon Sep 17 00:00:00 2001 From: Matthew Olsson Date: Tue, 26 Dec 2023 16:47:51 -0800 Subject: [PATCH] TextComponent: Completely rewrite + delete Message The separation between TextComponent and Message was vague at best, and confusing at worst (as observed from questions in the Discord). To solve this, they are merged together into a single TextComponent class, and the API has been altered a bit. Specifically: - It is now very easy to iterate over the different parts (i.e. text + style) of a TextComponent. It implements List, and the objects that are iterated are in the form "{ text: '...', bold: true, italic: true, ... }". - It is now immutable. To this end, there are helper methods that return modified version of the TextComponent, like withTextAt() that inserts a part at a given index. - The 'isFormatted' flag has been removed, and TextComponents are now always formatted. This flag had limited utility, as formatting codes can be stripped with ChatLib.removeFormatting, and they can also be escaped anywhere where formatted strings are accepted (i.e. "\&afoo"). --- .../internal/mixins/PlayerEntityMixin.java | 2 +- .../ctjs/api/commands/DynamicCommands.kt | 8 +- .../chattriggers/ctjs/api/entity/PlayerMP.kt | 2 +- .../chattriggers/ctjs/api/inventory/Item.kt | 2 +- .../chattriggers/ctjs/api/message/ChatLib.kt | 86 ++- .../chattriggers/ctjs/api/message/Message.kt | 177 ----- .../ctjs/api/message/TextComponent.kt | 726 ++++++++++-------- .../com/chattriggers/ctjs/api/world/Server.kt | 2 +- .../chattriggers/ctjs/api/world/TabList.kt | 4 +- .../ctjs/internal/commands/CTCommand.kt | 20 +- .../chattriggers/js/moduleProvidedLibs.js | 1 - 11 files changed, 492 insertions(+), 538 deletions(-) delete mode 100644 src/main/kotlin/com/chattriggers/ctjs/api/message/Message.kt diff --git a/src/main/java/com/chattriggers/ctjs/internal/mixins/PlayerEntityMixin.java b/src/main/java/com/chattriggers/ctjs/internal/mixins/PlayerEntityMixin.java index 4c8d1d70..f2a91b7b 100644 --- a/src/main/java/com/chattriggers/ctjs/internal/mixins/PlayerEntityMixin.java +++ b/src/main/java/com/chattriggers/ctjs/internal/mixins/PlayerEntityMixin.java @@ -18,7 +18,7 @@ public class PlayerEntityMixin implements NameTagOverridable { @ModifyVariable(method = "getDisplayName", at = @At(value = "STORE", ordinal = 0)) private MutableText injectGetName(MutableText original) { if (overriddenNametagName != null) - return overriddenNametagName.getComponent(); + return overriddenNametagName.toMutableText$ctjs(); return original; } diff --git a/src/main/kotlin/com/chattriggers/ctjs/api/commands/DynamicCommands.kt b/src/main/kotlin/com/chattriggers/ctjs/api/commands/DynamicCommands.kt index 3e88ec61..1e83b934 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/api/commands/DynamicCommands.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/api/commands/DynamicCommands.kt @@ -853,23 +853,23 @@ object DynamicCommands : CommandCollection() { if (impl.selectors.isEmpty()) return TextComponent(text) - val component = TextComponent(text.substring(0, impl.selectors[0].start)) + var component = TextComponent(text.substring(0, impl.selectors[0].start)) var i = impl.selectors[0].start for (selector in impl.selectors) { val entities = EntitySelectorWrapper(selector.selector).getEntities() val nameComponent = EntitySelector.getNames(entities.map(Entity::toMC)) if (i < selector.start) - component.append(TextComponent(text.substring(i, selector.start))) + component = component.withText(text.substring(i, selector.start)) if (nameComponent != null) - component.append(nameComponent) + component = component.withText(nameComponent) i = selector.end } if (i < text.length) - component.append(TextComponent(text.drop(i))) + component = component.withText(text.drop(i)) return component } diff --git a/src/main/kotlin/com/chattriggers/ctjs/api/entity/PlayerMP.kt b/src/main/kotlin/com/chattriggers/ctjs/api/entity/PlayerMP.kt index 66021fc1..b395ac8b 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/api/entity/PlayerMP.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/api/entity/PlayerMP.kt @@ -55,7 +55,7 @@ class PlayerMP(override val mcValue: PlayerEntity) : LivingEntity(mcValue) { } private fun getPlayerName(playerListEntry: PlayerListEntry?): TextComponent { - return playerListEntry?.displayName?.let(::TextComponent) + return playerListEntry?.displayName?.let { TextComponent(it) } ?: TextComponent( MCTeam.decorateName( playerListEntry?.scoreboardTeam, diff --git a/src/main/kotlin/com/chattriggers/ctjs/api/inventory/Item.kt b/src/main/kotlin/com/chattriggers/ctjs/api/inventory/Item.kt index a796f2e7..a467a6f7 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/api/inventory/Item.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/api/inventory/Item.kt @@ -84,7 +84,7 @@ class Item(override val mcValue: ItemStack) : CTWrapper { fun getLore(advanced: Boolean = false): List { mcValue.asMixin().ctjs_setShouldSkip(true) val tooltip = mcValue.getTooltip(Player.toMC(), if (advanced) TooltipContext.ADVANCED else TooltipContext.BASIC) - .map(::TextComponent) + .map { TextComponent(it) } mcValue.asMixin().ctjs_setShouldSkip(false) return tooltip diff --git a/src/main/kotlin/com/chattriggers/ctjs/api/message/ChatLib.kt b/src/main/kotlin/com/chattriggers/ctjs/api/message/ChatLib.kt index 88b627c8..862b64ac 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/api/message/ChatLib.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/api/message/ChatLib.kt @@ -1,9 +1,7 @@ package com.chattriggers.ctjs.api.message import com.chattriggers.ctjs.api.client.Client -import com.chattriggers.ctjs.api.client.Player import com.chattriggers.ctjs.api.render.Renderer -import com.chattriggers.ctjs.engine.printToConsole import com.chattriggers.ctjs.internal.listeners.ClientListener import com.chattriggers.ctjs.internal.mixins.ChatHudAccessor import com.chattriggers.ctjs.internal.utils.asMixin @@ -22,50 +20,47 @@ object ChatLib { /** * Prints text in the chat. - * The text can be a String, a [Message] or a [TextComponent] + * The text can be a String or a [TextComponent] * * @param text the text to be printed */ @JvmStatic fun chat(text: Any?) { when (text) { - is String -> Message(text).chat() - is Message -> text.chat() - is TextComponent -> text.chat() - else -> Message(text.toString()).chat() - } + is TextComponent -> text + is String -> TextComponent(text) + else -> TextComponent(text.toString()) + }.chat() } /** * Shows text in the action bar. - * The text can be a String, a [Message] or a [TextComponent] + * The text can be a String or a [TextComponent] * * @param text the text to show */ @JvmStatic fun actionBar(text: Any?) { when (text) { - is String -> Message(text).actionBar() - is Message -> text.actionBar() - is TextComponent -> text.actionBar() - else -> Message(text.toString()).actionBar() - } + is TextComponent -> text + is String -> TextComponent(text) + else -> TextComponent(text.toString()) + }.actionBar() } /** * Simulates a chat message to be caught by other triggers for testing. - * The text can be a String, a [Message] or a [TextComponent] + * The text can be a String or a [TextComponent] * * @param text The message to simulate */ @JvmStatic fun simulateChat(text: Any?) { when (text) { - is String -> Message(text).setRecursive(true).chat() - is Message -> text.setRecursive(true).chat() - is TextComponent -> Message(text).setRecursive(true).chat() - else -> Message(text.toString()).setRecursive(true).chat() - } + is TextComponent -> text + is String -> TextComponent(text) + else -> TextComponent(text.toString()) + }.withRecursive().chat() } @@ -229,15 +224,15 @@ object ChatLib { } /** - * Edits an already sent chat message by the [Message] + * Edits an already sent chat message by the [TextComponent] * * @param toReplace the message to be replaced * @param replacements the new message(s) to be put in place of the old one */ @JvmStatic - fun editChat(toReplace: Message, vararg replacements: Any) { + fun editChat(toReplace: TextComponent, vararg replacements: Any) { editLines(replacements) { - toReplace.chatMessage.formattedText == TextComponent(it.content).formattedText + toReplace.formattedText == TextComponent(it.content).formattedText } } @@ -254,6 +249,18 @@ object ChatLib { } } + /** + * Edits an already sent chat message by given a callback that receives + * [TextComponent] instances + * + * @param matcher a function that accepts a [TextComponent] and returns a boolean + * @param replacements the new message(s) to be put in place of the old one + */ + @JvmStatic + fun editChat(matcher: (TextComponent) -> Boolean, vararg replacements: Any) { + editLines(replacements) { matcher(TextComponent(it.content)) } + } + private fun editLines(replacements: Array, matcher: (ChatHudLine) -> Boolean) { val mc = Client.getMinecraft() val indicator = if (mc.isConnectedToLocalServer) MessageIndicator.singlePlayer() else MessageIndicator.system() @@ -267,11 +274,8 @@ object ChatLib { it.remove() chatLineIds.remove(next) for (replacement in replacements) { - val message = if (replacement !is Message) { - TextComponent.from(replacement)?.let(::Message) ?: continue - } else replacement - - val line = ChatHudLine(next.creationTick, message.chatMessage, null, indicator) + val message = replacement as? TextComponent ?: TextComponent(replacement) + val line = ChatHudLine(next.creationTick, message, null, indicator) if (message.getChatLineId() != -1) chatLineIds[line] = message.getChatLineId() @@ -317,14 +321,14 @@ object ChatLib { } /** - * Deletes an already sent chat message by the [Message] + * Deletes an already sent chat message by the [TextComponent] * * @param toDelete the message to be deleted */ @JvmStatic - fun deleteChat(toDelete: Message) { + fun deleteChat(toDelete: TextComponent) { removeLines { - toDelete.chatMessage.formattedText == TextComponent(it.content).formattedText + toDelete.formattedText == TextComponent(it.content).formattedText } } @@ -338,6 +342,17 @@ object ChatLib { removeLines { chatLineIds[it] == chatLineId } } + /** + * Deletes an already sent chat message given a callback that receives + * [TextComponent] instances + * + * @param chatLineId the chat line id of the message to be deleted + */ + @JvmStatic + fun deleteChat(matcher: (TextComponent) -> Boolean) { + removeLines { matcher(TextComponent(it.content)) } + } + private fun removeLines(matcher: (ChatHudLine) -> Boolean) { var removed = false val it = chatHudAccessor?.messages?.listIterator() ?: return @@ -383,15 +398,14 @@ object ChatLib { } } - internal fun sendMessageWithId(message: Message) { + internal fun sendMessageWithId(message: TextComponent) { require(message.getChatLineId() != -1) val chatGui = Client.getChatGui() ?: return - val chatMessage = message.chatMessage - chatGui.addMessage(chatMessage) - val newChatLine: ChatHudLine = chatHudAccessor!!.messages[0] + chatGui.addMessage(message) + val newChatLine = chatHudAccessor!!.messages[0] - check(chatMessage == newChatLine.content()) { + check(message == newChatLine.content()) { "Expected new chat message to be at index 0" } diff --git a/src/main/kotlin/com/chattriggers/ctjs/api/message/Message.kt b/src/main/kotlin/com/chattriggers/ctjs/api/message/Message.kt deleted file mode 100644 index f8334cfe..00000000 --- a/src/main/kotlin/com/chattriggers/ctjs/api/message/Message.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.chattriggers.ctjs.api.message - -import com.chattriggers.ctjs.api.client.Client -import com.chattriggers.ctjs.api.client.Player -import net.minecraft.network.packet.s2c.play.GameMessageS2CPacket -import net.minecraft.text.MutableText -import java.util.concurrent.ThreadLocalRandom - -class Message { - private var chatLineId: Int = -1 - private var isRecursive: Boolean = false - private var isFormatted: Boolean = true - - /** - * The individual components in the message - */ - val messageParts: MutableList = mutableListOf() - - /** - * The underlying [TextComponent] - */ - lateinit var chatMessage: TextComponent - private set - - val formattedText: String - get() = chatMessage.formattedText - - val unformattedText: String - get() = chatMessage.unformattedText - - constructor(component: TextComponent) { - if (component.siblings.isEmpty()) { - messageParts.add(component) - } else { - component.siblings - .filterIsInstance() - .map { TextComponent(it) } - .forEach { messageParts.add(it) } - } - parseMessage() - } - - constructor(vararg parts: Any) { - parts.forEach(::addPart) - parseMessage() - } - - /** - * @return the chat line ID of the message - */ - fun getChatLineId(): Int = chatLineId - - /** - * Sets the chat line ID of the message. Useful for updating an already sent chat message. - */ - fun setChatLineId(id: Int) = apply { chatLineId = id } - - /** - * @return true if the message can trip other triggers. - */ - fun isRecursive(): Boolean = isRecursive - - /** - * Sets whether the message can trip other triggers. - * @param recursive true if message can trip other triggers. - */ - fun setRecursive(recursive: Boolean) = apply { this.isRecursive = recursive } - - /** - * @return true if the message is formatted - */ - fun isFormatted(): Boolean = isFormatted - - /** - * Sets if the message is to be formatted - * @param formatted true if formatted - */ - fun setFormatted(formatted: Boolean) = apply { this.isFormatted = formatted } - - /** - * Sets the TextComponent or String in the Message at index. - * - * @param index the index of the TextComponent or String to change - * @param component the new TextComponent or String to replace with - * @return the Message for method chaining - */ - fun setTextComponent(index: Int, component: Any) = apply { - if (component is String) { - messageParts[index] = TextComponent(component) - } else { - TextComponent.from(component)?.also { messageParts[index] = it } - } - parseMessage() - } - - /** - * Adds a TextComponent or String at index of the Message. - * - * @param index the index to insert the new TextComponent or String - * @param component the new TextComponent or String to insert - * @return the Message for method chaining - */ - fun addTextComponent(index: Int, component: Any) = apply { - if (component is String) { - messageParts.add(index, TextComponent(component)) - } else { - TextComponent.from(component)?.also { messageParts.add(index, it) } - } - parseMessage() - } - - /** - * Adds a TextComponent or String to the end of the Message. - * - * @param component the new TextComponent or String to add - * @return the Message for method chaining - */ - fun addTextComponent(component: Any): Message = addTextComponent(messageParts.size, component) - - /** - * Must be called to be able to edit later - */ - fun mutable() = apply { - chatLineId = ThreadLocalRandom.current().nextInt() - } - - fun edit(vararg replacements: Any) = apply { - require(chatLineId != -1) { "This Message is not mutable" } - messageParts.clear() - replacements.forEach(::addPart) - parseMessage() - ChatLib.editChat(chatLineId, *replacements) - } - - fun chat() = apply { - if (Player.toMC() == null) - return@apply - - if (chatLineId != -1) { - ChatLib.sendMessageWithId(this) - return@apply - } - - if (isRecursive) { - Client.scheduleTask { - Client.getMinecraft().networkHandler?.onGameMessage(GameMessageS2CPacket(chatMessage, false)) - } - } else { - Player.toMC()?.sendMessage(chatMessage) - } - } - - fun actionBar() = apply { - if (Player.toMC() == null) - return@apply - - if (isRecursive) { - Client.scheduleTask { - Client.getMinecraft().networkHandler?.onGameMessage(GameMessageS2CPacket(chatMessage, true)) - } - } else { - Player.toMC()?.sendMessage(chatMessage, true) - } - } - - private fun addPart(part: Any) { - if (part is TextComponent) { - messageParts.add(part) - } else TextComponent.from(part)?.also(messageParts::add) - parseMessage() - } - - private fun parseMessage() { - chatMessage = TextComponent("") - messageParts.forEach { chatMessage.appendSibling(it) } - } -} diff --git a/src/main/kotlin/com/chattriggers/ctjs/api/message/TextComponent.kt b/src/main/kotlin/com/chattriggers/ctjs/api/message/TextComponent.kt index bf28dee2..95f5871d 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/api/message/TextComponent.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/api/message/TextComponent.kt @@ -1,420 +1,540 @@ package com.chattriggers.ctjs.api.message +import com.chattriggers.ctjs.CTJS import com.chattriggers.ctjs.MCEntity +import com.chattriggers.ctjs.api.client.Client +import com.chattriggers.ctjs.api.client.Player import com.chattriggers.ctjs.api.entity.Entity import com.chattriggers.ctjs.api.inventory.Item import com.chattriggers.ctjs.api.inventory.ItemType -import com.chattriggers.ctjs.api.render.Renderer import com.chattriggers.ctjs.internal.utils.hoverEventActionByName +import com.chattriggers.ctjs.internal.utils.toIdentifier +import com.mojang.serialization.Codec +import com.mojang.serialization.MapCodec +import com.mojang.serialization.codecs.RecordCodecBuilder import gg.essential.universal.UChat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject import net.minecraft.item.ItemStack +import net.minecraft.network.packet.s2c.play.GameMessageS2CPacket import net.minecraft.text.* import net.minecraft.util.Formatting +import net.minecraft.util.Identifier +import org.mozilla.javascript.Context +import org.mozilla.javascript.NativeObject +import org.mozilla.javascript.ScriptRuntime import java.util.* - -@Suppress("MemberVisibilityCanBePrivate") -class TextComponent : Text { - lateinit var component: MutableText - private set - - private var text: String - private var formatted = true - private var clickAction: ClickEvent.Action? = null - private var clickValue: String? = null - private var hoverAction: HoverEvent.Action<*>? = null - private var hoverValue: Any? = null - +import java.util.concurrent.ThreadLocalRandom +import kotlin.streams.toList + +/** + * A wrapper around the Minecraft Text class and it's various inheritors. + * + * This class acts as a container for "pairs". A pair is a string of text + * that is associated with a particular [Style]. Unlike Minecraft's Text + * class, this container is a list, not a tree, which makes them much easier + * to work with. It implements [List]<[NativeObject]>, so it can be iterated + * over. + * + * Importantly, instances of [TextComponent] are immutable. Methods for + * "mutation" exist, but they return new instances of [TextComponent]. See + * [withText] for an example. + * + * @see Text + */ +// Note: For the sake of the Text implementations, parts[0] is the "self" part, +// and parts[1..] are the siblings, but the exposed CT API treats this as +// a container of individual parts +class TextComponent private constructor( + private val parts: MutableList, + private val chatLineId: Int = -1, + private val isRecursive: Boolean = false, +) : Text, List { /** - * Creates a [TextComponent] from a string. - * - * @param text the text string in the component. + * Creates an empty [TextComponent] with a single, unstyled, empty part. */ - constructor(text: String) { - this.text = text - reInstance() - } + constructor() : this(listOf(Part("", Style.EMPTY))) /** - * Creates a [TextComponent] from an existing [Text] instance. + * Creates a [TextComponent] from a variable number of objects. These + * objects can be: + * - A plain string possibly containing formatting codes. If the string has + * formatting codes, it will be split into different parts accordingly + * - A [TextComponent], whose parts will be appended in sequence to this + * [TextComponent]'s list of parts + * - A [Text] object, which acts as a single part + * - A JS object, which must contain a "text" key, and can optionally contain + * any of the [Style] keys * - * @param component the [Text] to convert + * @see Style */ - constructor(component: Text) : this(component.copy()) + constructor(vararg parts: Any) : this(parts.flatMap(Part::of).toMutableList()) /** - * Creates a [TextComponent] from an existing [Text] instance. - * - * @param component the [Text] to convert + * Returns the text of all parts concatenated without formatting codes. */ - constructor(component: MutableText) { - this.component = component - text = formattedText - - val clickEvent = component.style.clickEvent - if (clickEvent != null) { - clickAction = clickEvent.action - clickValue = clickEvent.value - } - - val hoverEvent = component.style.hoverEvent - if (hoverEvent != null) { - hoverAction = hoverEvent.action - hoverValue = hoverEvent.getValue(hoverAction) - } + val unformattedText by lazy { + parts.fold("") { prev, curr -> prev + curr.text } } /** - * Gets the component text - */ - fun getText() = text - - /** - * Sets the component text + * Returns the text of all parts concatenated with formatting codes. */ - fun setText(value: String) = apply { - text = value - reInstance() + val formattedText by lazy { + parts.fold("") { prev, curr -> prev + curr.style_.formatCodes() + curr.text } } /** - * Whether this component is formatted. A formatted component interprets - * color codes (using both & and §) and applies them as style. - */ - fun isFormatted() = formatted - - /** - * Sets whether this component is formatted. A formatted component interprets - * color codes (using both & and §) and applies them as style. - */ - fun setFormatted(value: Boolean) = apply { - formatted = value - reInstance() - } - - /** - * Gets the action to be performed when the component is clicked on. See [setClickAction] - * for possible values. - */ - fun getClickAction() = clickAction - - /** - * Sets the action to be performed when the component is clicked on. Possible actions include: - * - open_url - * - open_file - * - run_command - * - suggest_command - * - change_page + * Get the chat line ID of this message, if it exists. The chat line can be used + * to easily edit or delete a message later via [ChatLib.editChat] and + * [ChatLib.deleteChat]. * - * @param value The new click action, can be a [ClickEvent.Action], [String], or null + * @return the chat line ID of the message, or -1 if this [TextComponent] does + * not have an associated chat line ID. */ - fun setClickAction(value: Any?) = apply { - clickAction = when (value) { - is ClickEvent.Action -> value - is String -> ClickEvent.Action.valueOf(value.uppercase()) - null -> null - else -> error( - "TextComponent.setClickAction() expects a String, ClickEvent.Action, or null, but got " + - value::class - ) - } - - reInstanceClick() - } + fun getChatLineId() = chatLineId /** - * The value to be used by the click action. The value is interpreted according to [clickAction] + * @return a new [TextComponent] with the given chat line id */ - fun getClickValue() = clickValue + @JvmOverloads + fun withChatLineId(id: Int = ThreadLocalRandom.current().nextInt()) = copy(chatLineId = id) /** - * Sets the value to be used by the click action. The value is interpreted according to [clickAction]. - */ - fun setClickValue(value: String?) = apply { - clickValue = value - reInstanceClick() - } - - /** - * Sets the click action and value of the component.See [clickAction] for - * possible click actions. + * If this [TextComponent] is recursive, sending this instance (via [chat] or + * [actionBar]) may trigger other `chat` triggers as if it had been received by + * the server. [TextComponent]s are non-recursive by default. * - * @param action the click action - * @param value the click value + * @return true if the message can trigger other triggers. */ - fun setClick(action: ClickEvent.Action?, value: String?) = apply { - clickAction = action - clickValue = value - reInstanceClick() - } + fun isRecursive(): Boolean = isRecursive /** - * Sets the click action and value of the component.See [clickAction] for - * possible click actions. + * Sets whether the message can trigger other triggers. * - * @param action the click action as a [String] - * @param value the click value + * @param recursive true if message can trigger other triggers. */ - fun setClick(action: String, value: String?) = apply { - setClick(ClickEvent.Action.valueOf(action.uppercase()), value) - } + @JvmOverloads + fun withRecursive(recursive: Boolean = true) = copy(isRecursive = recursive) /** - * Gets the action to be performed when the component is hovered. See [setHoverAction] - * for possible values + * @return a new [TextComponent] with the specified [value] appended to the end. + * This accepts all types of objects that the vararg constructor does. */ - fun getHoverAction() = hoverAction + fun withText(value: Any) = copy(parts = (parts + Part.of(value)).toMutableList()) /** - * Sets the action to be performed when the component is hovered. Possible actions include: - * - show_text - * - show_item - * - show_entity - * - * @param value The new hover action, can be a [HoverEvent.Action], [String], or null + * @return a new [TextComponent] with the specified [value] inserted at [index]. + * This accepts all types of objects that the vararg constructor does. */ - fun setHoverAction(value: Any?) = apply { - hoverAction = when (value) { - is HoverEvent.Action<*> -> value - is String -> hoverEventActionByName(value.lowercase()) - null -> null - else -> error( - "TextComponent.setHoverAction() expects a String, HoverEvent.Action, or null, but got " + - value::class - ) - } - - // Trigger re-wrapping if necessary - setHoverValue(hoverValue) - } + fun withTextAt(index: Int, value: Any) = + copy(parts = (parts.take(index) + Part.of(value) + parts.drop(index)).toMutableList()) /** - * Gets the value to be used by the hover action. The value is interpreted according to [hoverAction] + * @return a new [TextComponent] without the part at [index] */ - fun getHoverValue() = hoverValue + fun withoutTextAt(index: Int) = + copy(parts = (parts.take(index) + parts.drop(index + 1)).toMutableList()) /** - * Sets the value to be used by the hover action. The value is interpreted according to [hoverAction] + * Edits this text component, replacing it with the given [newText]. Note that + * this compares [TextComponent]s based on [formattedText]; if an exact match + * is needed, use [ChatLib.editChat] in conjunction with a chat line ID. */ - fun setHoverValue(value: Any?) = apply { - hoverValue = value?.let { - when (hoverAction) { - HoverEvent.Action.SHOW_TEXT -> from(it) - HoverEvent.Action.SHOW_ITEM -> parseItemContent(it) - HoverEvent.Action.SHOW_ENTITY -> parseEntityContent(it) - else -> value - } - } - - reInstanceHover() + fun edit(newText: TextComponent) = apply { + ChatLib.editChat(this, newText) } /** - * Sets the hover action and value of the component. See [hoverAction] for possible hover actions. - * - * @param action the hover action - * @param value the hover value + * Edits this text component, replacing it with a new [TextComponent] from the + * given [parts]. Note that this compares [TextComponent]s based on + * [formattedText]; if an exact match is needed, use [ChatLib.editChat] in + * conjunction with a chat line ID. */ - fun setHover(action: HoverEvent.Action<*>?, value: Any?) = apply { - hoverAction = action - setHoverValue(value) - } + fun edit(vararg parts: Any) = edit(TextComponent(*parts)) /** - * Sets the hover action and value of the component. See [hoverAction] for possible hover actions. - * - * @param action the hover action as a [String] - * @param value the hover value + * Deletes this text component. Note that this compares [TextComponent]s based on + * [formattedText]; if an exact match is needed, use [ChatLib.editChat] in conjunction + * with a chat line ID. */ - fun setHover(action: String, value: Any?) = apply { - setHover(hoverEventActionByName(action.lowercase()), value) + fun delete() = apply { + ChatLib.deleteChat(this) } /** - * Sets the color of this [TextComponent] - * This won't override your color codes unless &r is explicitly used. + * Sends this [TextComponent] to the players chat. * - * @param color RGB value acquired using [Renderer.getColor]. Alpha values will be ignored - */ - fun setColor(color: Long) = apply { - component.setStyle(component.style.withColor(color.toInt())); - } - - /** - * Sets the color of this [TextComponent] - * This won't override your color codes unless &r is explicitly used. + * Note that this is purely client-side, and will not be sent to the server. If [isRecursive], + * will trigger any matching `chat` triggers * - * @param red value between 0 and 255 - * @param green value between 0 and 255 - * @param blue value between 0 and 255 - */ - fun setColor(red: Int, green: Int, blue: Int) = setColor(Renderer.getColor(red, green, blue)) - - /** - * Shows the component in chat as a new [Message] + * @see ChatLib.chat + * @see ChatLib.say */ fun chat() = apply { - Message(this).chat() + if (Player.toMC() == null) + return@apply + + if (chatLineId != -1) { + ChatLib.sendMessageWithId(this) + return@apply + } + + if (isRecursive) { + Client.scheduleTask { + Client.getMinecraft().networkHandler?.onGameMessage(GameMessageS2CPacket(this, false)) + } + } else { + Player.toMC()?.sendMessage(this) + } } /** - * Shows the component on the actionbar as a new [Message] + * Sends this [TextComponent] to the players action bar. + * + * If [isRecursive], will trigger any matching `actionBar` triggers + * + * @see ChatLib.actionBar */ fun actionBar() = apply { - Message(this).actionBar() - } + if (Player.toMC() == null) + return@apply - override fun toString() = "TextComponent(${if (formatted) formattedText else unformattedText})" - - private fun parseItemContent(obj: Any): HoverEvent.ItemStackContent { - return when (obj) { - is ItemStack -> obj - is Item -> obj.toMC() - is String -> ItemType(obj).asItem().toMC() - is HoverEvent.ItemStackContent -> return obj - else -> error("${obj::class} cannot be parsed as an item HoverEvent") - }.let(HoverEvent::ItemStackContent) - } - - private fun parseEntityContent(obj: Any): HoverEvent.EntityContent? { - return when (obj) { - is MCEntity -> obj - is Entity -> obj.toMC() - //#if MC>=12004 - is String -> return HoverEvent.EntityContent.legacySerializer(from(obj)).getOrThrow(false) {} - //#else - //$$ is String -> return HoverEvent.EntityContent.parse(from(obj)) - //#endif - is HoverEvent.EntityContent -> return obj - else -> error("${obj::class} cannot be parsed as an entity HoverEvent") - }.let { HoverEvent.EntityContent(it.type, it.uuid, it.name) } + if (isRecursive) { + Client.scheduleTask { + Client.getMinecraft().networkHandler?.onGameMessage(GameMessageS2CPacket(this, true)) + } + } else { + Player.toMC()?.sendMessage(this, true) + } } - private fun reInstance() { - component = Text.literal(text.formatIf(formatted)) + override fun toString() = formattedText - reInstanceClick() - reInstanceHover() + internal fun toMutableText() = Text.empty().apply { + parts.forEach(::append) } - private fun reInstanceClick() { - if (clickAction == null || clickValue == null) - return + // Make this method manually to avoid exposing it as a public API + private fun copy( + parts: MutableList = this.parts, + chatLineId: Int = this.chatLineId, + isRecursive: Boolean = this.isRecursive + ) = TextComponent(parts, chatLineId, isRecursive) - val event = ClickEvent(clickAction, clickValue!!.formatIf(formatted)) - component.style = component.style.withClickEvent(event) - } + ////////// + // Text // + ////////// - private fun reInstanceHover() { - if (hoverAction == null || hoverValue == null) - return + override fun getContent(): TextContent = parts[0] - @Suppress("UNCHECKED_CAST") - val event = HoverEvent(hoverAction as HoverEvent.Action, hoverValue!!) - component.style = component.style.withHoverEvent(event) - } + override fun getString(): String = parts[0].text - private fun String.formatIf(predicate: Boolean) = if (predicate) UChat.addColor(this) else this + override fun getStyle(): Style = parts[0].style_ - private class TextBuilder(private val isFormatted: Boolean) : CharacterVisitor { - private val builder = StringBuilder() - private var cachedStyle: Style? = null + override fun getSiblings(): MutableList = parts.drop(1).toMutableList() - override fun accept(index: Int, style: Style, codePoint: Int): Boolean { - if (isFormatted && style != cachedStyle) { - cachedStyle = style - builder.append(formatString(style)) + override fun asOrderedText(): OrderedText = OrderedText { visitor -> + var i = 0 + parts.all { + it.text.codePoints().toList().all { cp -> + visitor.accept(i++, style, cp) } - - builder.append(codePoint.toChar()) - return true } + } - fun getString() = builder.toString() - - private fun formatString(style: Style): String { - val builder = StringBuilder("§r") - - when { - style.isBold -> builder.append("§l") - style.isItalic -> builder.append("§o") - style.isUnderlined -> builder.append("§n") - style.isStrikethrough -> builder.append("§m") - style.isObfuscated -> builder.append("§k") + ///////////// + // List // + ///////////// + + override val size by parts::size + + override fun contains(element: NativeObject) = parts.any { ScriptRuntime.eq(element, it.nativeObject) } + + override fun containsAll(elements: Collection) = elements.all(::contains) + + override fun get(index: Int) = parts[index].nativeObject + + override fun indexOf(element: NativeObject) = parts.indexOfFirst { it.nativeObject == element } + + override fun isEmpty() = parts.isEmpty() + + override fun iterator() = parts.map(Part::nativeObject).iterator() + + override fun listIterator() = parts.map(Part::nativeObject).listIterator() + + override fun listIterator(index: Int) = parts.map(Part::nativeObject).listIterator(index) + + override fun lastIndexOf(element: NativeObject) = parts.indexOfLast { it.nativeObject == element } + + override fun spliterator() = parts.map(Part::nativeObject).spliterator() + + override fun subList(fromIndex: Int, toIndex: Int): List = + parts.subList(fromIndex, toIndex).map(Part::nativeObject) + + private class Part(val text: String, val style_: Style) : Text, TextContent { + val nativeObject: NativeObject by lazy { + val cx = Context.getContext() + cx.newObject(cx.topCallScope).also { + it.put("text", it, text) + if (style_.color != null) + it.put("color", it, style_.color) + if (style_.isBold) + it.put("bold", it, true) + if (style_.isItalic) + it.put("italic", it, true) + if (style_.isUnderlined) + it.put("underline", it, true) + if (style_.isStrikethrough) + it.put("strikethrough", it, true) + if (style_.isObfuscated) + it.put("obfuscated", it, true) + style_.clickEvent?.let { event -> + if (event.action != null) { + //#if MC>=12004 + it.put("clickAction", it, event.action.asString()) + //#else + //$$ it.put("clickAction", it, event.action.name) + //#endif + if (event.value != null) + it.put("clickValue", it, event.value) + } + } + style_.hoverEvent?.let { event -> + if (event.action != null) { + //#if MC>=12004 + it.put("hoverAction", it, event.action.asString()) + //#else + //$$ it.put("hoverAction", it, event.action.name) + //#endif + event.getValue(event.action!!)?.let { value -> + it.put("hoverValue", it, value) + } + } + } + if (style_.insertion != null) + it.put("insertion", it, style_.insertion) + if (style_.font != null && style_.font.toString() != "minecraft:default") + it.put("font", it, style_.font) } + } - style.color?.let(colorToFormatChar::get)?.run(builder::append) + override fun getContent(): TextContent = this - return builder.toString() - } + override fun getString(): String = text - companion object { - private val colorToFormatChar = Formatting.values().mapNotNull { format -> - TextColor.fromFormatting(format)?.let { it to format } - }.toMap() - } - } + override fun getStyle(): Style = style_ - // ********************** - // * METHOD DELEGATIONS * - // ********************** + override fun getSiblings(): MutableList = mutableListOf() - override fun getContent(): TextContent = component.content + override fun visit(visitor: StringVisitable.Visitor): Optional = visitor.accept(text) - val unformattedText: String - get() { - val builder = TextBuilder(false) - component.asOrderedText().accept(builder) - return builder.getString() + override fun visit(visitor: StringVisitable.StyledVisitor, style: Style): Optional { + return visitor.accept(this.style_, text) } - val formattedText: String - get() { - val builder = TextBuilder(true) - component.asOrderedText().accept(builder) - return builder.getString() - } + override fun asTruncatedString(length: Int): String = text.take(length) - fun appendSibling(text: Text): MutableText = component.append(text) + override fun asOrderedText(): OrderedText = OrderedText { visitor -> + text.codePoints().toList().withIndex().all { (index, cp) -> + visitor.accept(index, style_, cp) + } + } - fun append(text: Text) = appendSibling(text) + //#if MC>=12004 + override fun getType(): TextContent.Type<*> = TextContent.Type(CODEC, "ctjs_part") + //#endif - override fun getString(): String = component.string + companion object { + //#if MC>=12004 + private val CODEC: MapCodec = RecordCodecBuilder.mapCodec { builder -> + builder.group( + Codec.STRING.fieldOf("text").forGetter(Part::text), + net.minecraft.text.Style.Codecs.CODEC.fieldOf("style").forGetter { it.style_ }, + ).apply(builder) { text, style -> Part(text, style) } + } + //#endif - override fun getStyle(): Style = component.style + fun of(obj: Any) = when (obj) { + is NativeObject -> { + val text = obj["text"] + ?: throw IllegalArgumentException("Expected TextComponent part to have a \"text\" key") + require(text is String) { "TextComponent part's \"text\" key must be a string" } + listOf(Part(UChat.addColor(text), jsObjectToStyle(obj))) + } + is Part -> listOf(obj) + is TextComponent -> obj.parts + is Text -> listOf(Part(obj.string, obj.style)) + is String -> { + val parts = mutableListOf() + val builder = StringBuilder() + var lastStyle = Style.EMPTY + + TextVisitFactory.visitFormatted(UChat.addColor(obj), 0, Style.EMPTY) { _, style, cp -> + if (style != lastStyle) { + parts.add(Part(builder.toString(), lastStyle)) + lastStyle = style + builder.clear() + } + builder.appendCodePoint(cp) + true + } + + if (builder.isNotEmpty()) + parts.add(Part(builder.toString(), lastStyle)) + + parts + } + else -> throw IllegalArgumentException("Cannot convert ${obj::class.simpleName} to TextComponent part") + } + } + } - override fun getSiblings(): MutableList = component.siblings + companion object { + private val colorToFormatChar = Formatting.values().mapNotNull { format -> + TextColor.fromFormatting(format)?.let { it to format } + }.toMap() + + fun jsObjectToStyle(obj: NativeObject): Style { + return Style.EMPTY + .withColor(obj["color"]?.let { color -> + when (color) { + is TextColor -> color + is Formatting -> TextColor.fromFormatting(color) + is Int -> TextColor.fromRgb(color) + //#if MC>=12004 + is String -> TextColor.parse(color).result().orElseThrow { + IllegalArgumentException("Could not parse \"$color\" as a text color") + } + //#else + //$$ is String -> TextColor.parse(color) ?: throw IllegalArgumentException("Could not parse \"$color\" as a text color") + //#endif + else -> throw IllegalArgumentException("Could not convert type ${color::class.simpleName} to a text color") + } + }) + .withBold( + obj.getOrDefault("bold", false) as? Boolean + ?: error("Expected \"bold\" key to be a boolean") + ) + .withItalic( + obj.getOrDefault("italic", false) as? Boolean + ?: error("Expected \"italic\" key to be a boolean") + ) + .withUnderline( + obj.getOrDefault("underline", false) as? Boolean + ?: error("Expected \"underline\" key to be a boolean") + ) + .withStrikethrough( + obj.getOrDefault("strikethrough", false) as? Boolean + ?: error("Expected \"strikethrough\" key to be a boolean") + ) + .withObfuscated( + obj.getOrDefault("obfuscated", false) as? Boolean + ?: error("Expected \"obfuscated\" key to be a boolean") + ) + .withClickEvent( + makeClickEvent( + obj["clickAction"], + when (val clickValue = obj["clickValue"]) { + null -> null + is String -> clickValue + else -> error("Expected \"clickValue\" key to be a string") + } + ) + ) + .withHoverEvent(makeHoverEvent(obj["hoverAction"], obj["hoverValue"])) + .withInsertion( + when (val insertion = obj["insertion"]) { + null -> null + is String -> insertion + else -> error("Expected \"insertion\" key to be a String") + } + ) + .withFont( + when (val font = obj["font"]) { + null -> null + is String -> font.toIdentifier() + else -> error("Expected \"font\" key to be a String") + } + ) + } - override fun visit(styledVisitor: StringVisitable.StyledVisitor?, style: Style?): Optional = - component.visit(styledVisitor, style) + private fun Style.formatCodes() = buildString { + append("§r") - override fun visit(visitor: StringVisitable.Visitor?): Optional = component.visit(visitor) + when { + isBold -> append("§l") + isItalic -> append("§o") + isUnderlined -> append("§n") + isStrikethrough -> append("§m") + isObfuscated -> append("§k") + } - override fun asTruncatedString(length: Int): String = component.asTruncatedString(length) + color?.let(colorToFormatChar::get)?.run(::append) + } - override fun copyContentOnly(): MutableText = component.copyContentOnly() + private fun makeClickEvent(action: Any?, value: String?): ClickEvent? { + val clickAction = when (action) { + is ClickEvent.Action -> action + is String -> ClickEvent.Action.valueOf(action.uppercase()) + null -> if (value != null) { + error("Cannot set Style's click value without a click action") + } else return null + else -> error("Style.withClickAction() expects a String, ClickEvent.Action, or null, but got ${action::class.simpleName}") + } - override fun copy(): MutableText = component.copy() + return ClickEvent(clickAction, value.orEmpty()) + } - override fun asOrderedText(): OrderedText = component.asOrderedText() + private fun makeHoverEvent(action: Any?, value: Any?): HoverEvent? { + val hoverAction = when (action) { + is HoverEvent.Action<*> -> action + is String -> hoverEventActionByName(action) + null -> if (value != null) { + error("Cannot set Style's hover value without a hover action") + } else return null + else -> error("Style.withHoverAction() expects a String, HoverEvent.Action, or null, but got ${action::class.simpleName}") + } - override fun withoutStyle(): MutableList = component.withoutStyle() + if (value == null) + return HoverEvent(hoverAction, null) - override fun getWithStyle(style: Style?): MutableList = component.getWithStyle(style) + val hoverValue: Any? = when (hoverAction) { + HoverEvent.Action.SHOW_TEXT -> TextComponent(value) + HoverEvent.Action.SHOW_ITEM -> parseItemContent(value) + HoverEvent.Action.SHOW_ENTITY -> parseEntityContent(value) + else -> error("unreachable") + } - override fun contains(text: Text?): Boolean = component.contains(text) + @Suppress("UNCHECKED_CAST") + return HoverEvent(hoverAction as HoverEvent.Action, hoverValue) + } - companion object { - fun from(obj: Any): TextComponent? { + private fun parseItemContent(obj: Any): HoverEvent.ItemStackContent { return when (obj) { - is TextComponent -> obj - is String -> TextComponent(obj) - is Text -> TextComponent(obj) - else -> null - } + is ItemStack -> obj + is Item -> obj.toMC() + is String -> ItemType(obj).asItem().toMC() + is HoverEvent.ItemStackContent -> return obj + else -> error("${obj::class} cannot be parsed as an item HoverEvent") + }.let(HoverEvent::ItemStackContent) } - fun stripFormatting(string: String): String { - return Formatting.strip(string)!! + private fun parseEntityContent(obj: Any): HoverEvent.EntityContent? { + return when (obj) { + is MCEntity -> obj + is Entity -> obj.toMC() + //#if MC>=12004 + is String -> return HoverEvent.EntityContent.legacySerializer(TextComponent(obj)) + .getOrThrow(false) {} + //#else + //$$ is String -> return HoverEvent.EntityContent.parse(TextComponent(obj)) + //#endif + is HoverEvent.EntityContent -> return obj + else -> error("${obj::class} cannot be parsed as an entity HoverEvent") + }.let { HoverEvent.EntityContent(it.type, it.uuid, it.name) } } } } diff --git a/src/main/kotlin/com/chattriggers/ctjs/api/world/Server.kt b/src/main/kotlin/com/chattriggers/ctjs/api/world/Server.kt index 7f0d6b22..cc80ae9a 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/api/world/Server.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/api/world/Server.kt @@ -50,7 +50,7 @@ object Server { if (isSingleplayer()) return "SinglePlayer" - return toMC()?.label?.let(::TextComponent)?.formattedText ?: "" + return toMC()?.label?.let { TextComponent(it) }?.formattedText ?: "" } // TODO(breaking): Return -1 if not in a world diff --git a/src/main/kotlin/com/chattriggers/ctjs/api/world/TabList.kt b/src/main/kotlin/com/chattriggers/ctjs/api/world/TabList.kt index 8719f0c7..b37e41f8 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/api/world/TabList.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/api/world/TabList.kt @@ -75,7 +75,7 @@ object TabList { } @JvmStatic - fun getHeaderComponent() = toMC()?.asMixin()?.header?.let(::TextComponent) + fun getHeaderComponent() = toMC()?.asMixin()?.header?.let { TextComponent(it) } @JvmStatic fun getHeader() = getHeaderComponent()?.formattedText @@ -100,7 +100,7 @@ object TabList { fun clearHeader() = setHeader(null) @JvmStatic - fun getFooterComponent() = toMC()?.asMixin()?.footer?.let(::TextComponent) + fun getFooterComponent() = toMC()?.asMixin()?.footer?.let { TextComponent(it) } @JvmStatic fun getFooter() = getFooterComponent()?.formattedText diff --git a/src/main/kotlin/com/chattriggers/ctjs/internal/commands/CTCommand.kt b/src/main/kotlin/com/chattriggers/ctjs/internal/commands/CTCommand.kt index 25f9de17..c3d660b1 100644 --- a/src/main/kotlin/com/chattriggers/ctjs/internal/commands/CTCommand.kt +++ b/src/main/kotlin/com/chattriggers/ctjs/internal/commands/CTCommand.kt @@ -4,12 +4,10 @@ import com.chattriggers.ctjs.CTJS import com.chattriggers.ctjs.api.Config import com.chattriggers.ctjs.api.client.Client import com.chattriggers.ctjs.api.message.ChatLib -import com.chattriggers.ctjs.api.message.Message import com.chattriggers.ctjs.api.message.TextComponent import com.chattriggers.ctjs.internal.commands.StaticCommand.Companion.onExecute import com.chattriggers.ctjs.engine.Console import com.chattriggers.ctjs.engine.printTraceToConsole -import com.chattriggers.ctjs.internal.engine.JSLoader import com.chattriggers.ctjs.internal.engine.module.ModuleManager import com.chattriggers.ctjs.internal.engine.module.ModulesGui import com.chattriggers.ctjs.internal.listeners.ClientListener @@ -29,9 +27,11 @@ import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallba import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource import net.minecraft.text.ClickEvent import net.minecraft.text.HoverEvent +import net.minecraft.text.Text import net.minecraft.util.Util import java.io.File import java.io.IOException +import java.lang.IllegalArgumentException import kotlin.concurrent.thread internal object CTCommand : Initializer { @@ -177,21 +177,19 @@ internal object CTCommand : Initializer { val messages = type.messageList() val toDump = lines.coerceAtMost(messages.size) - Message("&6&m${ChatLib.getChatBreak()}").setChatLineId(idFixed).chat() + TextComponent("&6&m${ChatLib.getChatBreak()}").withChatLineId(idFixed).chat() for (i in 0 until toDump) { val msg = ChatLib.replaceFormatting(messages[messages.size - toDump + i].formattedText) - Message( - TextComponent(msg) - .setClick(ClickEvent.Action.COPY_TO_CLIPBOARD, msg) - .setHover(HoverEvent.Action.SHOW_TEXT, TextComponent("&eClick here to copy this message.")) - .setFormatted(true) - ).setFormatted(false) - .setChatLineId(idFixed + i + 1) + TextComponent(Text.literal(msg).styled { + it.withClickEvent(ClickEvent(ClickEvent.Action.COPY_TO_CLIPBOARD, msg)) + .withHoverEvent(HoverEvent(HoverEvent.Action.SHOW_TEXT, TextComponent("&eClick here to copy this message."))) + }) + .withChatLineId(idFixed + i + 1) .chat() } - Message("&6&m${ChatLib.getChatBreak()}").setChatLineId(idFixed + lines + 1).chat() + TextComponent("&6&m${ChatLib.getChatBreak()}").withChatLineId(idFixed + lines + 1).chat() idFixedOffset = idFixed + lines + 1 } diff --git a/src/main/resources/assets/chattriggers/js/moduleProvidedLibs.js b/src/main/resources/assets/chattriggers/js/moduleProvidedLibs.js index 2ecbbaf6..ff453ab8 100644 --- a/src/main/resources/assets/chattriggers/js/moduleProvidedLibs.js +++ b/src/main/resources/assets/chattriggers/js/moduleProvidedLibs.js @@ -77,7 +77,6 @@ loadClass("com.chattriggers.ctjs.api.inventory.Slot"); loadClass("com.chattriggers.ctjs.api.message.ChatLib"); - loadClass("com.chattriggers.ctjs.api.message.Message"); loadClass("com.chattriggers.ctjs.api.message.TextComponent"); loadClass("com.chattriggers.ctjs.api.render.Book");