diff options
author | Linnea Gräf <nea@nea.moe> | 2024-04-23 08:41:33 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-23 08:41:33 +0200 |
commit | 68175690b3109f8e94c2a0f93a7a38b9ad993510 (patch) | |
tree | 68ad1ebd5338d064a9836f4591242664e6a513f1 /src/main/java/at/hannibal2/skyhanni | |
parent | b40ca6837bd362be9a14b1f027ac176220040f9c (diff) | |
download | skyhanni-68175690b3109f8e94c2a0f93a7a38b9ad993510.tar.gz skyhanni-68175690b3109f8e94c2a0f93a7a38b9ad993510.tar.bz2 skyhanni-68175690b3109f8e94c2a0f93a7a38b9ad993510.zip |
Add component regex matcher API (#1512)
Diffstat (limited to 'src/main/java/at/hannibal2/skyhanni')
-rw-r--r-- | src/main/java/at/hannibal2/skyhanni/utils/ComponentMatcherUtils.kt | 244 |
1 files changed, 244 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 } + } + +} |