diff --git a/api/src/main/java/net/kyori/adventure/text/AbstractComponent.java b/api/src/main/java/net/kyori/adventure/text/AbstractComponent.java index bf1d77239..186c5c9dc 100644 --- a/api/src/main/java/net/kyori/adventure/text/AbstractComponent.java +++ b/api/src/main/java/net/kyori/adventure/text/AbstractComponent.java @@ -86,6 +86,11 @@ protected AbstractComponent(final @NonNull List childre return TextReplacementRenderer.INSTANCE.render(this, ((TextReplacementConfigImpl) config).createState()); } + @Override + public @NonNull Component compact() { + return ComponentCompacting.optimize(this, null); + } + @Override public boolean equals(final @Nullable Object other) { if(this == other) return true; diff --git a/api/src/main/java/net/kyori/adventure/text/Component.java b/api/src/main/java/net/kyori/adventure/text/Component.java index eb0ca8087..b89673453 100644 --- a/api/src/main/java/net/kyori/adventure/text/Component.java +++ b/api/src/main/java/net/kyori/adventure/text/Component.java @@ -1693,6 +1693,14 @@ default boolean hasStyling() { @Contract(pure = true) @NonNull Component replaceText(final @NonNull TextReplacementConfig config); + /** + * Create a new component with any redundant style elements or children removed. + * + * @return the optimized component + * @since 4.8.0 + */ + @NonNull Component compact(); + /** * Finds and replaces text within any {@link Component}s using a string literal. * diff --git a/api/src/main/java/net/kyori/adventure/text/ComponentCompacting.java b/api/src/main/java/net/kyori/adventure/text/ComponentCompacting.java new file mode 100644 index 000000000..93f83443c --- /dev/null +++ b/api/src/main/java/net/kyori/adventure/text/ComponentCompacting.java @@ -0,0 +1,152 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.text; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.ListIterator; +import java.util.Objects; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextDecoration; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +final class ComponentCompacting { + private static final TextDecoration[] DECORATIONS = TextDecoration.values(); + + private ComponentCompacting() { + } + + static @NonNull Component optimize(final AbstractComponent component, final @Nullable Style parentStyle) { + Component optimized = component.children(Collections.emptyList()); + + if(parentStyle != null) { + optimized = optimized.style(simplifyStyle(component.style(), parentStyle)); + } + + // propagate the parent style context to children + // by merging this component's style into the parent style + Style childParentStyle = optimized.style(); + if(parentStyle != null) { + childParentStyle = parentStyle.merge(childParentStyle, Style.Merge.Strategy.IF_ABSENT_ON_TARGET); + } + + // optimize all children + final List childrenToAppend = new ArrayList<>(component.children.size()); + for(int i = 0; i < component.children.size(); ++i) { + childrenToAppend.add(optimize((AbstractComponent) component.children.get(i), childParentStyle)); + } + + // try to merge children into this parent component + for(final ListIterator it = childrenToAppend.listIterator(); it.hasNext();) { + final Component child = it.next(); + final Style childStyle = child.style().merge(childParentStyle, Style.Merge.Strategy.IF_ABSENT_ON_TARGET); + + if(optimized instanceof TextComponent && child instanceof TextComponent && Objects.equals(childStyle, childParentStyle)) { + // merge child components into the parent if they are a text component with the same effective style + // in context of their parent style + optimized = concatContentWithFirstStyle((TextComponent) optimized, (TextComponent) child); + it.remove(); + + // if the merged child had any children, retain them + child.children().forEach(it::add); + } else { + // this child can't be merged into the parent, so all children from now on must remain children + break; + } + } + + // try to concatenate any further children with their neighbor + // until no further joining is possible + for(int i = 0; i + 1 < childrenToAppend.size();) { + final Component child = childrenToAppend.get(i); + final Component neighbor = childrenToAppend.get(i + 1); + + // calculate the children's styles in context of their parent style + final Style childStyle = child.style().merge(childParentStyle, Style.Merge.Strategy.IF_ABSENT_ON_TARGET); + final Style neighborStyle = neighbor.style().merge(childParentStyle, Style.Merge.Strategy.IF_ABSENT_ON_TARGET); + + if(child instanceof TextComponent && neighbor instanceof TextComponent && childStyle.equals(neighborStyle)) { + final Component combined = concatContentWithFirstStyle((TextComponent) child, (TextComponent) neighbor); + + // replace the child and its neighbor with the single, combined component + childrenToAppend.set(i, combined); + childrenToAppend.remove(i + 1); + + // don't increment the index - + // we want to try and optimize this combined component even further + } else { + i++; + } + } + + return optimized.children(childrenToAppend); + } + + // todo(kashike): extract + /** + * Simplify the provided style to remove any information that is redundant. + * + * @param style style to simplify + * @param parentStyle parent to compare against + * @return a new, simplified style + */ + private static @NonNull Style simplifyStyle(final @NonNull Style style, final @NonNull Style parentStyle) { + final Style.Builder builder = style.toBuilder(); + + if(Objects.equals(style.font(), parentStyle.font())) { + builder.font(null); + } + + if(Objects.equals(style.color(), parentStyle.color())) { + builder.color(null); + } + + for(int i = 0, length = DECORATIONS.length; i < length; i++) { + final TextDecoration decoration = DECORATIONS[i]; + if(style.decoration(decoration) == parentStyle.decoration(decoration)) { + builder.decoration(decoration, TextDecoration.State.NOT_SET); + } + } + + if(Objects.equals(style.clickEvent(), parentStyle.clickEvent())) { + builder.clickEvent(null); + } + + if(Objects.equals(style.hoverEvent(), parentStyle.hoverEvent())) { + builder.hoverEvent(null); + } + + if(Objects.equals(style.insertion(), parentStyle.insertion())) { + builder.insertion(null); + } + + return builder.build(); + } + + private static TextComponent concatContentWithFirstStyle(final TextComponent one, final TextComponent two) { + return Component.text(one.content() + two.content(), one.style()); + } +} diff --git a/api/src/test/java/net/kyori/adventure/text/ComponentCompactingTest.java b/api/src/test/java/net/kyori/adventure/text/ComponentCompactingTest.java new file mode 100644 index 000000000..0643d6dfe --- /dev/null +++ b/api/src/test/java/net/kyori/adventure/text/ComponentCompactingTest.java @@ -0,0 +1,233 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2021 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.text; + +import java.util.stream.Stream; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextDecoration; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +import static net.kyori.adventure.key.Key.key; +import static net.kyori.adventure.text.Component.text; +import static net.kyori.adventure.text.Component.translatable; +import static net.kyori.adventure.text.format.Style.style; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +class ComponentCompactingTest { + @TestFactory + Stream testNestedComponentsWithSameStyleAreCombined() { + final Style style = style() + .color(NamedTextColor.AQUA) + .font(key("uniform")) + .decorate(TextDecoration.BOLD, TextDecoration.ITALIC) + .build(); + + return Stream.of( + dynamicTest("child's style fully inherited", () -> { + final Component input = text() + .content("Hello ") + .style(style) + .append(text("World!")) + .build(); + + assertEquals(text("Hello World!", style), input.compact()); + }), + dynamicTest("child's style partially redeclared, but effectively redundant", () -> { + final Component input = text() + .content("Hello ") + .style(style) + .append(text("World!", style(TextDecoration.BOLD).font(key("uniform")))) + .build(); + + assertEquals(text("Hello World!", style), input.compact()); + }), + dynamicTest("multiple layers of nesting, with some children redeclaring styles", () -> { + final Component input = text() + .content("Hello ") + .style(style) + .append(text(c -> c.content("World! ") + .decorate(TextDecoration.BOLD) + .append(text("What a ") + .append(text("beautiful day!", style(s -> s.font(key("uniform")))))) + )) + .build(); + + assertEquals(text("Hello World! What a beautiful day!", style), input.compact()); + }), + dynamicTest("multiple sibling children", () -> { + final Component input = text() + .content("Hello ") + .style(style) + .append(text("World! ")) + .append(text("What a ", style().font(key("uniform")).build())) + .append(text("beautiful day!")) + .build(); + + assertEquals(text("Hello World! What a beautiful day!", style), input.compact()); + }), + dynamicTest("multiple siblings across multiple layers", () -> { + final Component input = text() + .content("Hello ") + .style(style) + .append(text("World! ")) + .append(text("What a ", style().font(key("uniform")).build())) + .append(text(c -> c.content("beautiful day ") + .decorate(TextDecoration.BOLD) + .append(text("to stay inside ")) + .append(text("and hone your ")) + .append(text("development skills!", style(TextDecoration.ITALIC))) + )) + .build(); + + assertEquals(text("Hello World! What a beautiful day to stay inside and hone your development skills!", style), input.compact()); + }) + ); + } + + @TestFactory + Stream testCompactingNestedComponentsInterruptedByDifferentlyStyledComponents() { + final Style baseStyle = style(NamedTextColor.RED, TextDecoration.BOLD, TextDecoration.OBFUSCATED); + return Stream.of( + dynamicTest("simple component with most joinable, and an unjoinable in the middle", () -> { + final Component component = + text().content("Hello ") + .style(baseStyle) + .append(text("World! ")) + .append(text("What a ", NamedTextColor.RED)) + .append(text("beautiful day ", NamedTextColor.BLUE)) + .append(text("to create ")) + .append(text("a PR on Adventure!", style(TextDecoration.BOLD))) + .build(); + + assertEquals( + text() + .content("Hello World! What a ") + .style(baseStyle) + + .append(text("beautiful day ", NamedTextColor.BLUE)) + .append(text("to create a PR on Adventure!")) + .build(), + component.compact()); + }), + dynamicTest("simple component with joinable children, and an unjoinable child with joinable children", () -> { + final Component component = + text().content("Hello ") + .style(baseStyle) + + .append(text("World! ")) + .append(text("What a ", NamedTextColor.RED)) + .append(text(c -> c.content("beautiful day ") + .color(NamedTextColor.BLUE) + .append(text("to create ", style(TextDecoration.ITALIC))) + .append(text("a PR ", style(TextDecoration.BOLD))) + .append(text("on Adventure!")) + )) + .build(); + + assertEquals( + text() + .content("Hello World! What a ") + .style(baseStyle) + .append(text(c -> c.content("beautiful day ") + .color(NamedTextColor.BLUE) + .append(text("to create ", style(TextDecoration.ITALIC))) + .append(text("a PR on Adventure!")))) + .build(), + component.compact()); + }) + ); + } + + @Test + void testCompactingNestedComponentsInterruptedByOthers() { + final Style baseStyle = style(NamedTextColor.RED, TextDecoration.BOLD, TextDecoration.OBFUSCATED); + final Component input = translatable() + .key("some.language.key") + .style(baseStyle) + .append( + text(c -> c.content("Hello World! ") + .append(text("What a ", style(TextDecoration.BOLD))) + .append(text("beautiful ", style(TextDecoration.OBFUSCATED))) + .append(translatable(t -> t.key("unit.day") + .append(text(" to create ") + .append(text("a PR on Adventure!"))))) + )) + .build(); + + assertEquals(translatable("some.language.key", baseStyle) + .append(text("Hello World! What a beautiful ") + .append(translatable("unit.day") + .append(text(" to create a PR on Adventure!")))), input.compact()); + } + + @TestFactory + Stream testCompactWithEmptyComponentsInHierarchy() { + return Stream.of( + dynamicTest("1", () -> { + final Component component = text().content("Hello ").append(text().append(text("World!"))).build(); + assertEquals(text("Hello World!"), component.compact()); + }), + dynamicTest("2", () -> { + final Component component = text() + .append(text().content("Hello ").append(text().append(text("World!")))) + .build(); + assertEquals(text("Hello World!"), component.compact()); + }), + dynamicTest("3", () -> { + final Component component = text() + .append(text() + .append(text("Hello ") + .append(text() + .append(text("World!"))))) + .build(); + assertEquals(text("Hello World!"), component.compact()); + }), + dynamicTest("4", () -> { + final Component component = text() + .append(text("Hello ")) + .append(text("World!")) + .build(); + assertEquals(text("Hello World!"), component.compact()); + }), + dynamicTest("5", () -> { + final Component component = text() + .append(text() + .append(text("Hello ", style().font(key("alt")).build())) + .append(text("World!", style().font(key("uniform")).build())) + ) + .build(); + + assertEquals(text() + .append(text("Hello ", style().font(key("alt")).build())) + .append(text("World!", style().font(key("uniform")).build())) + .build(), + component.compact()); + }) + ); + } +}