diff options
-rw-r--r-- | src/main/java/at/hannibal2/skyhanni/utils/ComponentMatcherUtils.kt | 244 | ||||
-rw-r--r-- | src/test/java/at/hannibal2/skyhanni/test/ComponentSpanTest.kt | 53 |
2 files changed, 297 insertions, 0 deletions
diff --git a/src/main/java/at/hannibal2/skyhanni/utils/ComponentMatcherUtils.kt b/src/main/java/at/hannibal2/skyhanni/utils/ComponentMatcherUtils.kt new file mode 100644 index 000000000..4e2b6eb06 --- /dev/null +++ b/src/main/java/at/hannibal2/skyhanni/utils/ComponentMatcherUtils.kt @@ -0,0 +1,244 @@ +/** + * @author Linnea Gräf + */ +package at.hannibal2.skyhanni.utils + +import at.hannibal2.skyhanni.utils.ComponentMatcherUtils.findStyledMatcher +import at.hannibal2.skyhanni.utils.ComponentMatcherUtils.intoSpan +import at.hannibal2.skyhanni.utils.ComponentMatcherUtils.matchStyledMatcher +import at.hannibal2.skyhanni.utils.StringUtils.findMatcher +import at.hannibal2.skyhanni.utils.StringUtils.matchMatcher +import net.minecraft.util.ChatComponentText +import net.minecraft.util.ChatStyle +import net.minecraft.util.IChatComponent +import java.util.Stack +import java.util.regex.Matcher +import java.util.regex.Pattern + +object ComponentMatcherUtils { + + /** + * Convert an [IChatComponent] into a [ComponentSpan], which allows taking substrings of the given component, + * while preserving chat style. + */ + fun IChatComponent.intoSpan(): ComponentSpan { + val text = this.unformattedText + return ComponentSpan( + this, + text, + 0, + text.length + ) + } + + /** + * Create a styled matcher, analogous to [Pattern.matcher], but while preserving [ChatStyle]. + */ + fun Pattern.styledMatcher(span: ComponentSpan): ComponentMatcher { + val matcher = matcher(span.getText()) + return ComponentMatcher(matcher, span) + } + + /** + * Equivalent to [matchMatcher], but while preserving [ChatStyle] + */ + inline fun <T> Pattern.matchStyledMatcher(chat: IChatComponent, consumer: ComponentMatcher.() -> T) = + matchStyledMatcher(chat.intoSpan(), consumer) + + /** + * Equivalent to [matchMatcher], but while preserving [ChatStyle] + */ + inline fun <T> Pattern.matchStyledMatcher(span: ComponentSpan, consumer: ComponentMatcher.() -> T) = + styledMatcher(span).let { if (it.matches()) consumer(it) else null } + + /** + * Equivalent to [findMatcher], but while preserving [ChatStyle] + */ + inline fun <T> Pattern.findStyledMatcher(chat: IChatComponent, consumer: ComponentMatcher.() -> T) = + findStyledMatcher(chat.intoSpan(), consumer) + + /** + * Equivalent to [findMatcher], but while preserving [ChatStyle] + */ + inline fun <T> Pattern.findStyledMatcher(span: ComponentSpan, consumer: ComponentMatcher.() -> T) = + styledMatcher(span).let { if (it.find()) consumer(it) else null } +} + +/** + * This is an analogue for [Matcher], but for [ComponentSpan], therefore it is stateful, with [matches] and [find] + * mutating this state. Direct usage of this class is recommended against in favor of using + * [ComponentMatcherUtils.matchStyledMatcher] and [ComponentMatcherUtils.findStyledMatcher] + */ +class ComponentMatcher internal constructor( + val matcher: Matcher, + val span: ComponentSpan, +) { + /** + * Try to match the entire input component against the stored regex. + */ + fun matches(): Boolean { + return matcher.matches() + } + + /** + * Try to find the next match in the input component of the stored regex. + */ + fun find(): Boolean { + return matcher.find() + } + + /** + * Return a span equivalent to the entire match found by [matches] or [find] + */ + fun group(): ComponentSpan { + return this.span.slice(matcher.start(), matcher.end()) + } + + /** + * Return a span equivalent to the group with the given index found by [matches] or [find] + */ + fun group(index: Int): ComponentSpan? { + val start = matcher.start(index) + if (start < 0) return null + return this.span.slice(start, matcher.end(index)) + } + + /** + * Return a span equivalent to the group with the given name found by [matches] or [find] + */ + fun group(name: String): ComponentSpan? { + val start = matcher.start(name) + if (start < 0) return null + return this.span.slice(start, matcher.end(name)) + } +} + +/** + * Represents a substring of a [IChatComponent], preserving the [chat style][IChatComponent.getChatStyle]. + * This class always deals in what the chat component APIs call [unformatted text][IChatComponent.getUnformattedText]. + * This text may contain formatting codes, but will not have additional formatting codes inserted based on the chat + * style. Specifically, it will look at the [IChatComponent.getUnformattedTextForChat], which *excludes* the text from + * the siblings/children of a chat component. To make sure that internal cached states are upheld, use + * [ComponentMatcherUtils.intoSpan] instead of the constructor. + */ +class ComponentSpan internal constructor( + val textComponent: IChatComponent, + private val cachedText: String, + val start: Int, val end: Int +) { + init { + require(start <= end) + require(0 <= start) + require(end <= cachedText.length) + } + + /** + * Returns the text length of this span. + */ + val length = end - start + + /** + * Slice this component span. This is equivalent to the [String.substring] operation on the [text][getText]. + */ + fun slice(start: Int, end: Int): ComponentSpan { + require(0 <= start) + require(start <= end) + require(end <= length) + return ComponentSpan(textComponent, cachedText, this.start + start, this.start + end) + } + + /** + * Returns the text (without any chat style formatting applied). May still contain formatting codes. + */ + fun getText() = cachedText.substring(start, end) + + /** + * Sample the chat style at the start of the span. + */ + fun sampleStyleAtStart(): ChatStyle? = sampleAtStart().chatStyle + + /** + * Sample all the components that intersect with this span. Note that some of the returned components may contain + * children/siblings that are not intersecting this span, and that some of the returned components may only + * partially intersect with this span. + */ + fun sampleComponents(): List<IChatComponent> { + return sampleSlicedComponents().map { it.first } + } + + /** + * Sample all the components that intersect with this span, with their respective indices. This behaves like + * [sampleComponents], but it will also return indices indicating which part of the + * [IChatComponent.getUnformattedTextForChat] is actually intersecting with this span. + * + * ``` + * val firstComponent = this.sampleSlicedComponents().first() + * firstComponent.first.getUnformattedTextForChat().substring(firstComponent.second, firstComponent.third) + * ``` + * + * @see intoComponent + */ + fun sampleSlicedComponents(): List<Triple<IChatComponent, Int, Int>> { + var index = start + val workStack = Stack<IChatComponent>() + workStack.push(textComponent) + var lastComponent = textComponent + val listBuilder = mutableListOf<Triple<IChatComponent, Int, Int>>() + while (workStack.isNotEmpty()) { + val currentComponent = workStack.pop() + if (index + length <= 0) { + break + } + for (sibling in currentComponent.siblings.reversed()) { + workStack.push(sibling) + } + val rawText = currentComponent.unformattedTextForChat + index -= rawText.length + if (index < 0) { + listBuilder.add( + Triple( + currentComponent, + (rawText.length + index).coerceAtLeast(0), + (rawText.length + index + length).coerceAtMost(rawText.length) + ) + ) + } + lastComponent = currentComponent + } + if (listBuilder.isEmpty()) + listBuilder.add(Triple(lastComponent, 0, 0)) + return listBuilder + } + + /** + * Returns the first [chat component][IChatComponent] that intersects with this span. + */ + fun sampleAtStart(): IChatComponent { + return sampleComponents().first() + } + + /** + * Create an [IChatComponent] that looks identical to this slice, including hover events and such. + * This new chat component will be structurally different (flat) and not therefore not have the same property + * inheritances as the old [textComponent]. Be therefore careful when modifying styles. This new component will also + * only use [ChatComponentText], converting any other [IChatComponent] in the process. + */ + fun intoComponent(): IChatComponent { + val parent = ChatComponentText("") + parent.chatStyle = ChatStyle() + sampleSlicedComponents().forEach { + val copy = ChatComponentText(it.first.unformattedTextForChat.substring(it.second, it.third)) + copy.chatStyle = it.first.chatStyle.createDeepCopy() + parent.appendSibling(copy) + } + return parent + } + + /** + * Returns a list of all the styles that intersect with this span. + */ + fun sampleStyles(): List<ChatStyle> { + return sampleComponents().map { it.chatStyle } + } + +} diff --git a/src/test/java/at/hannibal2/skyhanni/test/ComponentSpanTest.kt b/src/test/java/at/hannibal2/skyhanni/test/ComponentSpanTest.kt new file mode 100644 index 000000000..9b5f40829 --- /dev/null +++ b/src/test/java/at/hannibal2/skyhanni/test/ComponentSpanTest.kt @@ -0,0 +1,53 @@ +package at.hannibal2.skyhanni.test + +import at.hannibal2.skyhanni.utils.ComponentMatcherUtils.findStyledMatcher +import at.hannibal2.skyhanni.utils.ComponentMatcherUtils.intoSpan +import at.hannibal2.skyhanni.utils.ComponentMatcherUtils.matchStyledMatcher +import net.minecraft.util.ChatComponentText +import net.minecraft.util.ChatStyle +import net.minecraft.util.EnumChatFormatting +import org.junit.jupiter.api.Test +import java.util.regex.Pattern + +class ComponentSpanTest { + private fun text(string: String, init: ChatComponentText.() -> Unit = {}) = ChatComponentText(string).also(init) + + @Test + fun testComponent() { + val component = text("12345") { + appendSibling(text("12345") { + chatStyle = ChatStyle().setColor(EnumChatFormatting.RED) + }) + appendSibling(text("12345")) + } + val span = component.intoSpan() + require(span.sampleStyleAtStart()?.isEmpty == true) + require(span.slice(5, 8).sampleStyleAtStart()?.color == EnumChatFormatting.RED) + require(span.slice(8, 12).sampleStyleAtStart()?.color == EnumChatFormatting.RED) + require(span.slice(10, 12).sampleStyleAtStart()?.isEmpty == true) + require(span.slice(4, 11).intoComponent().formattedText == "§r5§r§c12345§r1§r") + } + + @Test + fun testRegex() { + val component = text("12345") { + appendSibling(text("abcdef") { + chatStyle = ChatStyle().setColor(EnumChatFormatting.RED) + }) + appendSibling(text("12345")) + } + Pattern.compile("[0-9]*(?<middle>[a-z]+)[0-9]*").matchStyledMatcher(component) { + require(group("middle")?.sampleStyleAtStart()?.color == EnumChatFormatting.RED) + } + val middlePartExtracted = + Pattern.compile("[0-9]*(?<middle>[0-9][a-z]+[0-9])[0-9]*").matchStyledMatcher(component) { + require(group("middle")?.sampleComponents()?.size == 3) + require(group("middle")?.sampleStyles()?.find { it.color != null }?.color == EnumChatFormatting.RED) + group("middle") + }!! + Pattern.compile("(?<whole>c)").findStyledMatcher(middlePartExtracted) { + require(group("whole")?.sampleStyleAtStart()?.color == EnumChatFormatting.RED) + } + } + +} |