diff --git a/api/src/main/java/net/kyori/adventure/audience/Audience.java b/api/src/main/java/net/kyori/adventure/audience/Audience.java index 16a56ee56e..587371fb07 100644 --- a/api/src/main/java/net/kyori/adventure/audience/Audience.java +++ b/api/src/main/java/net/kyori/adventure/audience/Audience.java @@ -37,6 +37,7 @@ import net.kyori.adventure.bossbar.BossBarViewer; import net.kyori.adventure.chat.ChatType; import net.kyori.adventure.chat.SignedMessage; +import net.kyori.adventure.dialog.DialogLike; import net.kyori.adventure.identity.Identified; import net.kyori.adventure.identity.Identity; import net.kyori.adventure.inventory.Book; @@ -659,8 +660,6 @@ default void playSound(final @NotNull Sound sound, final double x, final double * *

To play a sound that follows the recipient, use {@link Sound.Emitter#self()}.

* - *

Note: Due to MC-138832, the volume and pitch may be ignored when using this method.

- * * @param sound a sound * @param emitter an emitter * @since 4.8.0 @@ -862,4 +861,33 @@ default void removeResourcePacks(final @NotNull UUID id, final @NotNull UUID@Not */ default void clearResourcePacks() { } + + // ----------------- + // ---- Dialogs ---- + // ----------------- + + /** + * Shows a dialog to this audience. + * + *

This method exists to allow initial native support for dialogs until Adventure + * has full API to support building and sending dialogs.

+ * + * @param dialog the dialog + * @since 4.22.0 + * @sinceMinecraft 1.21.6 + */ + default void showDialog(final @NotNull DialogLike dialog) { + } + + /** + * Closes the dialog that is currently being shown to this audience, if any. + * + *

This will return the user to the previous dialog if one was opened from the + * current dialog.

+ * + * @since 4.24.0 + * @sinceMinecraft 1.21.6 + */ + default void closeDialog() { + } } diff --git a/api/src/main/java/net/kyori/adventure/audience/ForwardingAudience.java b/api/src/main/java/net/kyori/adventure/audience/ForwardingAudience.java index 197b5d4a9d..1771910768 100644 --- a/api/src/main/java/net/kyori/adventure/audience/ForwardingAudience.java +++ b/api/src/main/java/net/kyori/adventure/audience/ForwardingAudience.java @@ -34,6 +34,7 @@ import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.chat.ChatType; import net.kyori.adventure.chat.SignedMessage; +import net.kyori.adventure.dialog.DialogLike; import net.kyori.adventure.identity.Identified; import net.kyori.adventure.identity.Identity; import net.kyori.adventure.inventory.Book; @@ -221,6 +222,16 @@ default void clearResourcePacks() { for (final Audience audience : this.audiences()) audience.clearResourcePacks(); } + @Override + default void showDialog(final @NotNull DialogLike dialog) { + for (final Audience audience : this.audiences()) audience.showDialog(dialog); + } + + @Override + default void closeDialog() { + for (final Audience audience : this.audiences()) audience.closeDialog(); + } + /** * An audience that forwards everything to a single other audience. * @@ -403,5 +414,15 @@ default void removeResourcePacks(final @NotNull UUID id, final @NotNull UUID @No default void clearResourcePacks() { this.audience().clearResourcePacks(); } + + @Override + default void showDialog(final @NotNull DialogLike dialog) { + this.audience().showDialog(dialog); + } + + @Override + default void closeDialog() { + this.audience().closeDialog(); + } } } diff --git a/api/src/main/java/net/kyori/adventure/bossbar/BossBarImpl.java b/api/src/main/java/net/kyori/adventure/bossbar/BossBarImpl.java index b88c3df004..410b5f376f 100644 --- a/api/src/main/java/net/kyori/adventure/bossbar/BossBarImpl.java +++ b/api/src/main/java/net/kyori/adventure/bossbar/BossBarImpl.java @@ -26,7 +26,6 @@ import java.util.Collections; import java.util.EnumSet; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; @@ -92,12 +91,12 @@ private ImplementationAccessor() { @Override public @NotNull BossBar name(final @NotNull Component newName) { + // We do not check if the new name equals the old name here as the GlobalTranslator + // may produce a different resulting component for the end user. requireNonNull(newName, "name"); final Component oldName = this.name; - if (!Objects.equals(newName, oldName)) { - this.name = newName; - this.forEachListener(listener -> listener.bossBarNameChanged(this, oldName, newName)); - } + this.name = newName; + this.forEachListener(listener -> listener.bossBarNameChanged(this, oldName, newName)); return this; } diff --git a/api/src/main/java/net/kyori/adventure/dialog/DialogLike.java b/api/src/main/java/net/kyori/adventure/dialog/DialogLike.java new file mode 100644 index 0000000000..a36e7b653b --- /dev/null +++ b/api/src/main/java/net/kyori/adventure/dialog/DialogLike.java @@ -0,0 +1,39 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.dialog; + +import net.kyori.adventure.audience.Audience; + +/** + * Something that can be represented as a Dialog. + * + *

This interface exists to allow initial native support for dialogs until Adventure + * has full API to support building and sending dialogs.

+ * + * @see Audience#showDialog(DialogLike) + * @since 4.22.0 + * @sinceMinecraft 1.21.6 + */ +public interface DialogLike { +} diff --git a/api/src/main/java/net/kyori/adventure/dialog/package-info.java b/api/src/main/java/net/kyori/adventure/dialog/package-info.java new file mode 100644 index 0000000000..8887ed2226 --- /dev/null +++ b/api/src/main/java/net/kyori/adventure/dialog/package-info.java @@ -0,0 +1,30 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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. + */ +/** + * Dialogs. + * + * @sinceMinecraft 1.21.6 + * @since 4.22 + */ +package net.kyori.adventure.dialog; diff --git a/api/src/main/java/net/kyori/adventure/pointer/PointersSupplierImpl.java b/api/src/main/java/net/kyori/adventure/pointer/PointersSupplierImpl.java index 3d1181b622..e1f01d24d7 100644 --- a/api/src/main/java/net/kyori/adventure/pointer/PointersSupplierImpl.java +++ b/api/src/main/java/net/kyori/adventure/pointer/PointersSupplierImpl.java @@ -33,7 +33,7 @@ import org.jetbrains.annotations.Nullable; final class PointersSupplierImpl implements PointersSupplier { - private final PointersSupplier parent; + private final @Nullable PointersSupplier parent; private final Map, Function> resolvers; PointersSupplierImpl(final @NotNull BuilderImpl builder) { @@ -87,7 +87,10 @@ static final class ForwardingPointers implements Pointers { // Fallback to the parent. if (resolver == null) { - resolver = this.supplier.parent.resolver(pointer); + final PointersSupplier parent = this.supplier.parent; + if (parent != null) { + resolver = parent.resolver(pointer); + } } // Finally, wrap in an optional. @@ -117,7 +120,7 @@ public boolean supports(final @NotNull Pointer pointer) { } static final class BuilderImpl implements Builder { - private PointersSupplier parent = null; + private @Nullable PointersSupplier parent = null; private final Map, Function> resolvers; BuilderImpl() { diff --git a/api/src/main/java/net/kyori/adventure/sound/Sound.java b/api/src/main/java/net/kyori/adventure/sound/Sound.java index 29387a3667..c49075d7ee 100644 --- a/api/src/main/java/net/kyori/adventure/sound/Sound.java +++ b/api/src/main/java/net/kyori/adventure/sound/Sound.java @@ -54,6 +54,12 @@ *
a number in the range [0,2] representing which pitch the sound should be played at
* * + *

There are some bugs that are of note when using sounds:

+ *
    + *
  • As documented in MC-146721, stereo sounds are always played globally in 1.14+.
  • + *
  • Due to MC-138832, the volume and pitch are ignored when playing a sound with an emitter in 1.14 to 1.16.5.
  • + *
+ * * @see SoundStop * @since 4.0.0 */ @@ -229,19 +235,110 @@ public interface Sound extends Examinable { /** * The sound source. * + *

The documentation for each source details the vanilla use.

+ * * @since 4.0.0 */ enum Source { + /** + * The main sound source. + * + *

This source controls the overall sound of the game.

+ * + * @since 4.0.0 + */ MASTER("master"), + + /** + * The music sound source. + * + *

This source handles the in-game soundtrack.

+ * + * @since 4.0.0 + */ MUSIC("music"), + + /** + * The record sound source. + * + *

This source handles jukeboxes and note blocks.

+ * + * @since 4.0.0 + */ RECORD("record"), + + /** + * The weather sound source. + * + *

This source handles weather sounds.

+ * + * @since 4.0.0 + */ WEATHER("weather"), + + /** + * The block sound source. + * + *

This source handles player interaction with blocks as well as passive block sounds.

+ * + * @since 4.0.0 + */ BLOCK("block"), + + /** + * The hostile sound source. + * + *

This source handles hostile entities.

+ * + * @since 4.0.0 + */ HOSTILE("hostile"), + + /** + * The neutral sound source. + * + *

This source handles neutral entities.

+ * + * @since 4.0.0 + */ NEUTRAL("neutral"), + + /** + * The player sound source. + * + *

This source handles player entities.

+ * + * @since 4.0.0 + */ PLAYER("player"), + + /** + * The ambient sound source. + * + *

This source handles ambience.

+ * + * @since 4.0.0 + */ AMBIENT("ambient"), - VOICE("voice"); + + /** + * The voice sound source. + * + *

This source handles the narrator.

+ * + * @since 4.0.0 + */ + VOICE("voice"), + + /** + * The UI sound source. + * + *

This source handles UI actions.

+ * + * @since 4.22.0 + * @sinceMinecraft 1.21.6 + */ + UI("ui"); /** * The name map. diff --git a/api/src/main/java/net/kyori/adventure/sound/SoundImpl.java b/api/src/main/java/net/kyori/adventure/sound/SoundImpl.java index 64ac45823f..ba05709e00 100644 --- a/api/src/main/java/net/kyori/adventure/sound/SoundImpl.java +++ b/api/src/main/java/net/kyori/adventure/sound/SoundImpl.java @@ -73,7 +73,7 @@ public float pitch() { } @Override - public OptionalLong seed() { + public @NotNull OptionalLong seed() { return this.seed; } diff --git a/api/src/main/java/net/kyori/adventure/text/AbstractComponentBuilder.java b/api/src/main/java/net/kyori/adventure/text/AbstractComponentBuilder.java index b4d7964c66..300ab50e65 100644 --- a/api/src/main/java/net/kyori/adventure/text/AbstractComponentBuilder.java +++ b/api/src/main/java/net/kyori/adventure/text/AbstractComponentBuilder.java @@ -289,7 +289,9 @@ private void prepareChildren() { @Override @SuppressWarnings("unchecked") public @NotNull B mergeStyle(final @NotNull Component that, final @NotNull Set merges) { - this.styleBuilder().merge(requireNonNull(that, "component").style(), merges); + final Style thatStyle = requireNonNull(that, "that").style(); + if (thatStyle.isEmpty() && merges.isEmpty()) return (B) this; + this.styleBuilder().merge(thatStyle, merges); return (B) this; } @@ -313,6 +315,7 @@ private void prepareChildren() { return this.styleBuilder; } + @SuppressWarnings("BooleanMethodIsAlwaysInverted") protected final boolean hasStyle() { return this.styleBuilder != null || this.style != null; } diff --git a/api/src/main/java/net/kyori/adventure/text/VirtualComponentImpl.java b/api/src/main/java/net/kyori/adventure/text/VirtualComponentImpl.java index 90a142eadf..7ca84c3e4e 100644 --- a/api/src/main/java/net/kyori/adventure/text/VirtualComponentImpl.java +++ b/api/src/main/java/net/kyori/adventure/text/VirtualComponentImpl.java @@ -25,8 +25,10 @@ import java.util.Collections; import java.util.List; +import java.util.Objects; import net.kyori.adventure.text.format.Style; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; final class VirtualComponentImpl extends TextComponentImpl implements VirtualComponent { static VirtualComponent createVirtual(final @NotNull Class contextType, final @NotNull VirtualComponentRenderer renderer) { @@ -73,6 +75,23 @@ VirtualComponent create0(final @NotNull List children, return new BuilderImpl<>(this); } + @Override + public boolean equals(final @Nullable Object other) { + if (this == other) return true; + if (!(other instanceof VirtualComponentImpl)) return false; + if (!super.equals(other)) return false; + final VirtualComponentImpl that = (VirtualComponentImpl) other; + return Objects.equals(this.contextType, that.contextType) && Objects.equals(this.renderer, that.renderer); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = (31 * result) + this.contextType.hashCode(); + result = (31 * result) + this.renderer.hashCode(); + return result; + } + static final class BuilderImpl extends TextComponentImpl.BuilderImpl { private final Class contextType; private final VirtualComponentRenderer renderer; diff --git a/api/src/main/java/net/kyori/adventure/text/event/ClickEvent.java b/api/src/main/java/net/kyori/adventure/text/event/ClickEvent.java index 7cc4ad228a..656e39c499 100644 --- a/api/src/main/java/net/kyori/adventure/text/event/ClickEvent.java +++ b/api/src/main/java/net/kyori/adventure/text/event/ClickEvent.java @@ -29,7 +29,11 @@ import java.util.stream.Stream; import net.kyori.adventure.audience.Audience; import net.kyori.adventure.builder.AbstractBuilder; +import net.kyori.adventure.dialog.DialogLike; import net.kyori.adventure.internal.Internals; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.key.Keyed; +import net.kyori.adventure.nbt.api.BinaryTagHolder; import net.kyori.adventure.text.format.Style; import net.kyori.adventure.text.format.StyleBuilderApplicable; import net.kyori.adventure.util.Index; @@ -56,7 +60,7 @@ public final class ClickEvent implements Examinable, StyleBuilderApplicable { * @since 4.0.0 */ public static @NotNull ClickEvent openUrl(final @NotNull String url) { - return new ClickEvent(Action.OPEN_URL, url); + return new ClickEvent(Action.OPEN_URL, Payload.string(url)); } /** @@ -80,7 +84,7 @@ public final class ClickEvent implements Examinable, StyleBuilderApplicable { * @since 4.0.0 */ public static @NotNull ClickEvent openFile(final @NotNull String file) { - return new ClickEvent(Action.OPEN_FILE, file); + return new ClickEvent(Action.OPEN_FILE, Payload.string(file)); } /** @@ -91,7 +95,7 @@ public final class ClickEvent implements Examinable, StyleBuilderApplicable { * @since 4.0.0 */ public static @NotNull ClickEvent runCommand(final @NotNull String command) { - return new ClickEvent(Action.RUN_COMMAND, command); + return new ClickEvent(Action.RUN_COMMAND, Payload.string(command)); } /** @@ -102,7 +106,7 @@ public final class ClickEvent implements Examinable, StyleBuilderApplicable { * @since 4.0.0 */ public static @NotNull ClickEvent suggestCommand(final @NotNull String command) { - return new ClickEvent(Action.SUGGEST_COMMAND, command); + return new ClickEvent(Action.SUGGEST_COMMAND, Payload.string(command)); } /** @@ -110,10 +114,14 @@ public final class ClickEvent implements Examinable, StyleBuilderApplicable { * * @param page the page to change to * @return a click event + * @throws IllegalArgumentException if the page cannot be represented as an integer using * @since 4.0.0 + * @deprecated For removal since 4.22.0, pages are integers, use {@link #changePage(int)} */ + @Deprecated public static @NotNull ClickEvent changePage(final @NotNull String page) { - return new ClickEvent(Action.CHANGE_PAGE, page); + requireNonNull(page, "page"); + return new ClickEvent(Action.CHANGE_PAGE, Payload.integer(Integer.parseInt(page))); } /** @@ -124,7 +132,7 @@ public final class ClickEvent implements Examinable, StyleBuilderApplicable { * @since 4.0.0 */ public static @NotNull ClickEvent changePage(final int page) { - return changePage(String.valueOf(page)); + return new ClickEvent(Action.CHANGE_PAGE, Payload.integer(page)); } /** @@ -136,7 +144,7 @@ public final class ClickEvent implements Examinable, StyleBuilderApplicable { * @sinceMinecraft 1.15 */ public static @NotNull ClickEvent copyToClipboard(final @NotNull String text) { - return new ClickEvent(Action.COPY_TO_CLIPBOARD, text); + return new ClickEvent(Action.COPY_TO_CLIPBOARD, Payload.string(text)); } /** @@ -180,23 +188,74 @@ public final class ClickEvent implements Examinable, StyleBuilderApplicable { } /** - * Creates a click event. + * Creates a click event that shows a dialog. + * + * @param dialog the dialog + * @return the click event + * @since 4.22.0 + */ + public static @NotNull ClickEvent showDialog(final @NotNull DialogLike dialog) { + requireNonNull(dialog, "dialog"); + return new ClickEvent(Action.SHOW_DIALOG, Payload.dialog(dialog)); + } + + /** + * Creates a click event sends a custom event to the server. + * + * @param key the key + * @param data the data + * @return the click event + * @since 4.22.0 + * @deprecated For removal since 4.23.0, payloads hold NBT data, use {@link #custom(Key, BinaryTagHolder)} instead. + * This method will create NBT using {@link BinaryTagHolder#binaryTagHolder(String)}. + */ + @Deprecated + public static @NotNull ClickEvent custom(final @NotNull Key key, final @NotNull String data) { + return custom(key, BinaryTagHolder.binaryTagHolder(data)); + } + + /** + * Creates a click event that sends a custom event to the server. + * + *

See {@link BinaryTagHolder#binaryTagHolder(String)} for a simple way to create NBT from SNBT. + * For simple use cases, you can use plain strings directly as SNBT.

+ * + * @param key the key identifying the payload + * @param nbt the nbt data + * @return the click event + * @since 4.23.0 + */ + public static @NotNull ClickEvent custom(final @NotNull Key key, final @NotNull BinaryTagHolder nbt) { + requireNonNull(key, "key"); + requireNonNull(nbt, "nbt"); + return new ClickEvent(Action.CUSTOM, Payload.custom(key, nbt)); + } + + /** + * Creates a click event with a {@link Payload.Text string payload}. * * @param action the action * @param value the value * @return a click event + * @throws IllegalArgumentException if the action does not support a string payload * @since 4.0.0 + * @deprecated For removal since 4.22.0, not all actions support string payloads */ + @Deprecated public static @NotNull ClickEvent clickEvent(final @NotNull Action action, final @NotNull String value) { - return new ClickEvent(action, value); + // A special case here to ensure that page can still accept a string. + if (action == Action.CHANGE_PAGE) return changePage(value); + if (!action.payloadType().equals(Payload.Text.class)) throw new IllegalArgumentException("Action " + action + " does not support string payloads"); + return new ClickEvent(action, Payload.string(value)); } private final Action action; - private final String value; + private final Payload payload; - private ClickEvent(final @NotNull Action action, final @NotNull String value) { + private ClickEvent(final @NotNull Action action, final @NotNull Payload payload) { + if (!action.supports(payload)) throw new IllegalArgumentException("Action " + action + " does not support payload " + payload); this.action = requireNonNull(action, "action"); - this.value = requireNonNull(value, "value"); + this.payload = requireNonNull(payload, "payload"); } /** @@ -210,13 +269,32 @@ private ClickEvent(final @NotNull Action action, final @NotNull String value) { } /** - * Gets the click event value. + * Gets the click event value if the payload is a {@link Payload.Text string payload}. * * @return the click event value + * @throws IllegalStateException if the payload is not a string payload * @since 4.0.0 + * @deprecated For removal since 4.22.0, click events can hold more than just strings, see {@link #payload()} */ + @Deprecated public @NotNull String value() { - return this.value; + if (this.payload instanceof Payload.Text) { + return ((Payload.Text) this.payload).value(); + } else if (this.action == Action.CHANGE_PAGE) { // Special case for page. + return String.valueOf(((Payload.Int) this.payload).integer()); + } else { + throw new IllegalStateException("Payload is not a string payload, is " + this.payload); + } + } + + /** + * Gets the payload associated with this click event. + * + * @return the payload + * @since 4.22.0 + */ + public @NotNull Payload payload() { + return this.payload; } @Override @@ -229,13 +307,13 @@ public boolean equals(final @Nullable Object other) { if (this == other) return true; if (other == null || this.getClass() != other.getClass()) return false; final ClickEvent that = (ClickEvent) other; - return this.action == that.action && Objects.equals(this.value, that.value); + return this.action == that.action && Objects.equals(this.payload, that.payload); } @Override public int hashCode() { int result = this.action.hashCode(); - result = (31 * result) + this.value.hashCode(); + result = (31 * result) + this.payload.hashCode(); return result; } @@ -243,7 +321,7 @@ public int hashCode() { public @NotNull Stream examinableProperties() { return Stream.of( ExaminableProperty.of("action", this.action), - ExaminableProperty.of("value", this.value) + ExaminableProperty.of("payload", this.payload) ); } @@ -252,6 +330,155 @@ public String toString() { return Internals.toString(this); } + /** + * A payload for a click event. + * + * @since 4.22.0 + */ + public /* sealed */ interface Payload /* permits String, Dialog, Custom */ extends Examinable { + /** + * Creates a text payload. + * + * @param value the payload value + * @return the payload + * @since 4.22.0 + */ + static ClickEvent.Payload.@NotNull Text string(final @NotNull String value) { + requireNonNull(value, "value"); + return new PayloadImpl.TextImpl(value); + } + + /** + * Creates an integer payload. + * + * @param integer the integer + * @return the payload + * @since 4.22.0 + */ + static ClickEvent.Payload.@NotNull Int integer(final int integer) { + return new PayloadImpl.IntImpl(integer); + } + + /** + * Creates a dialog payload. + * + * @param dialog the payload value + * @return the payload + * @since 4.22.0 + */ + static Payload.@NotNull Dialog dialog(final @NotNull DialogLike dialog) { + requireNonNull(dialog, "dialog"); + return new PayloadImpl.DialogImpl(dialog); + } + + /** + * Creates a custom payload. + * + * @param key the key identifying the payload + * @param data the payload data + * @return the payload + * @since 4.22.0 + * @deprecated For removal since 4.23.0, payloads hold NBT data, use {@link #custom(Key, BinaryTagHolder)} instead. + * This method will create NBT using {@link BinaryTagHolder#binaryTagHolder(String)}. + */ + @Deprecated + static Payload.@NotNull Custom custom(final @NotNull Key key, final @NotNull String data) { + return Payload.custom(key, BinaryTagHolder.binaryTagHolder(data)); + } + + /** + * Creates a custom payload. + * + *

See {@link BinaryTagHolder#binaryTagHolder(String)} for a simple way to create NBT from SNBT. + * For simple use cases, you can use plain strings directly as SNBT.

+ * + * @param key the key identifying the payload + * @param nbt the payload nbt data + * @return the payload + * @since 4.23.0 + */ + static Payload.@NotNull Custom custom(final @NotNull Key key, final @NotNull BinaryTagHolder nbt) { + requireNonNull(key, "key"); + requireNonNull(nbt, "nbt"); + return new PayloadImpl.CustomImpl(key, nbt); + } + + /** + * A payload that holds a string. + * + * @since 4.22.0 + */ + interface Text extends Payload { + /** + * The string value for this payload. + * + * @return the string + * @since 4.22.0 + */ + @NotNull String value(); + } + + /** + * A payload that holds an integer. + * + * @since 4.22.0 + */ + interface Int extends Payload { + /** + * The integer value for this payload. + * + * @return the integer + * @since 4.22.0 + */ + int integer(); + } + + /** + * A payload that holds a dialog. + * + * @see Action#SHOW_DIALOG + * @since 4.22.0 + */ + interface Dialog extends Payload { + /** + * The dialog. + * + * @return the dialog + * @since 4.22.0 + */ + @NotNull DialogLike dialog(); + } + + /** + * A payload that holds custom data. + * + * @see Action#CUSTOM + * @since 4.22.0 + */ + interface Custom extends Payload, Keyed { + /** + * The custom data. + * + * @return the data + * @since 4.22.0 + * @deprecated For removal since 4.23.0, custom payloads contain NBT data, use {@link #nbt()} instead. + * This method will return {@link BinaryTagHolder#string()} on the held NBT. + */ + @Deprecated + @NotNull String data(); + + /** + * The custom data. + * + *

See {@link BinaryTagHolder#string()} for a simple way to return SNBT from NBT data.

+ * + * @return the data + * @since 4.23.0 + */ + @NotNull BinaryTagHolder nbt(); + } + } + /** * An enumeration of click event actions. * @@ -263,7 +490,7 @@ public enum Action { * * @since 4.0.0 */ - OPEN_URL("open_url", true), + OPEN_URL("open_url", true, Payload.Text.class), /** * Opens a file when clicked. * @@ -271,32 +498,48 @@ public enum Action { * * @since 4.0.0 */ - OPEN_FILE("open_file", false), + OPEN_FILE("open_file", false, Payload.Text.class), /** * Runs a command when clicked. * * @since 4.0.0 */ - RUN_COMMAND("run_command", true), + RUN_COMMAND("run_command", true, Payload.Text.class), /** * Suggests a command into the chat box. * * @since 4.0.0 */ - SUGGEST_COMMAND("suggest_command", true), + SUGGEST_COMMAND("suggest_command", true, Payload.Text.class), /** * Changes the page of a book. * * @since 4.0.0 */ - CHANGE_PAGE("change_page", true), + CHANGE_PAGE("change_page", true, Payload.Int.class), /** * Copies text to the clipboard. * * @since 4.0.0 * @sinceMinecraft 1.15 */ - COPY_TO_CLIPBOARD("copy_to_clipboard", true); + COPY_TO_CLIPBOARD("copy_to_clipboard", true, Payload.Text.class), + /** + * Shows a dialog. + * + *

This action is not readable at this time until Adventure has a full Dialog API.

+ * + * @since 4.22.0 + * @sinceMinecraft 1.21.6 + */ + SHOW_DIALOG("show_dialog", false, Payload.Dialog.class), + /** + * Sends a custom event to the server. + * + * @since 4.22.0 + * @sinceMinecraft 1.21.6 + */ + CUSTOM("custom", true, Payload.Custom.class); /** * The name map. @@ -311,10 +554,12 @@ public enum Action { *

When an action is not readable it will not be deserialized.

*/ private final boolean readable; + private final Class payloadType; - Action(final @NotNull String name, final boolean readable) { + Action(final @NotNull String name, final boolean readable, final @NotNull Class payloadType) { this.name = name; this.readable = readable; + this.payloadType = payloadType; } /** @@ -328,6 +573,28 @@ public boolean readable() { return this.readable; } + /** + * Returns if this action supports the provided payload. + * + * @param payload the payload + * @return {@code true} if this action supports the payload + * @since 4.22.0 + */ + public boolean supports(final @NotNull Payload payload) { + requireNonNull(payload, "payload"); + return this.payloadType.isAssignableFrom(payload.getClass()); + } + + /** + * The type of the payload this click event supports. + * + * @return the payload type + * @since 4.22.0 + */ + public @NotNull Class payloadType() { + return this.payloadType; + } + @Override public @NotNull String toString() { return this.name; diff --git a/api/src/main/java/net/kyori/adventure/text/event/PayloadImpl.java b/api/src/main/java/net/kyori/adventure/text/event/PayloadImpl.java new file mode 100644 index 0000000000..4d6b679704 --- /dev/null +++ b/api/src/main/java/net/kyori/adventure/text/event/PayloadImpl.java @@ -0,0 +1,187 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2025 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.event; + +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.dialog.DialogLike; +import net.kyori.adventure.internal.Internals; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.nbt.api.BinaryTagHolder; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; + +abstract class PayloadImpl implements ClickEvent.Payload { + @Override + public String toString() { + return Internals.toString(this); + } + + static final class TextImpl extends PayloadImpl implements ClickEvent.Payload.Text { + private final String value; + + TextImpl(final @NotNull String value) { + this.value = value; + } + + @Override + public @NotNull String value() { + return this.value; + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("value", this.value) + ); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || getClass() != other.getClass()) return false; + final TextImpl that = (TextImpl) other; + return Objects.equals(this.value, that.value); + } + + @Override + public int hashCode() { + return this.value.hashCode(); + } + } + + static final class IntImpl extends PayloadImpl implements ClickEvent.Payload.Int { + private final int integer; + + IntImpl(final int integer) { + this.integer = integer; + } + + @Override + public int integer() { + return this.integer; + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("integer", this.integer) + ); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || getClass() != other.getClass()) return false; + final IntImpl that = (IntImpl) other; + return Objects.equals(this.integer, that.integer); + } + + @Override + public int hashCode() { + return this.integer; + } + } + + static final class DialogImpl extends PayloadImpl implements ClickEvent.Payload.Dialog { + private final DialogLike dialogLike; + + DialogImpl(final @NotNull DialogLike dialogLike) { + this.dialogLike = dialogLike; + } + + @Override + public @NotNull DialogLike dialog() { + return this.dialogLike; + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("dialog", this.dialogLike) + ); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || getClass() != other.getClass()) return false; + final DialogImpl that = (DialogImpl) other; + return Objects.equals(this.dialogLike, that.dialogLike); + } + + @Override + public int hashCode() { + return this.dialogLike.hashCode(); + } + } + + static final class CustomImpl extends PayloadImpl implements ClickEvent.Payload.Custom { + private final Key key; + private final BinaryTagHolder nbt; + + CustomImpl(final @NotNull Key key, final @NotNull BinaryTagHolder nbt) { + this.key = key; + this.nbt = nbt; + } + + @Override + public @NotNull Key key() { + return this.key; + } + + @Override + public @NotNull String data() { + return this.nbt.string(); + } + + @Override + public @NotNull BinaryTagHolder nbt() { + return this.nbt; + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("key", this.key), + ExaminableProperty.of("nbt", this.nbt) + ); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || getClass() != other.getClass()) return false; + final CustomImpl that = (CustomImpl) other; + return Objects.equals(this.key, that.key) && Objects.equals(this.nbt, that.nbt); + } + + @Override + public int hashCode() { + int result = this.key.hashCode(); + result = (31 * result) + this.nbt.hashCode(); + return result; + } + } +} diff --git a/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattener.java b/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattener.java index 0186d28993..f863a567e7 100644 --- a/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattener.java +++ b/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattener.java @@ -31,6 +31,7 @@ import net.kyori.adventure.util.Buildable; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Range; /** * A 'flattener' to convert a component tree to a linear string for display. @@ -38,6 +39,13 @@ * @since 4.7.0 */ public interface ComponentFlattener extends Buildable { + /** + * A constant representing a flattener with no limit on nested flatten calls. + * + * @since 4.22.0 + */ + int NO_NESTING_LIMIT = -1; + /** * Create a new builder for a flattener. * @@ -123,5 +131,16 @@ interface Builder extends AbstractBuilder, Buildable.Builder * @since 4.7.0 */ @NotNull Builder unknownMapper(final @Nullable Function converter); + + /** + * Sets the limit of nested flatten calls. + * + *

The default value is {@link #NO_NESTING_LIMIT}, which means there is no limit on nesting.

+ * + * @param limit the new limit (must be a positive integer, or {@link #NO_NESTING_LIMIT}) + * @return this builder + * @since 4.22.0 + */ + @NotNull Builder nestingLimit(final @Range(from = 1, to = Integer.MAX_VALUE) int limit); } } diff --git a/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattenerImpl.java b/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattenerImpl.java index 7f00c20819..bcbf07252d 100644 --- a/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattenerImpl.java +++ b/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattenerImpl.java @@ -23,6 +23,9 @@ */ package net.kyori.adventure.text.flattener; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; @@ -36,6 +39,7 @@ import net.kyori.adventure.util.InheritanceAwareMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Range; import static java.util.Objects.requireNonNull; @@ -64,41 +68,90 @@ final class ComponentFlattenerImpl implements ComponentFlattener { private final InheritanceAwareMap flatteners; private final Function unknownHandler; + private final int maxNestedDepth; - ComponentFlattenerImpl(final InheritanceAwareMap flatteners, final @Nullable Function unknownHandler) { + ComponentFlattenerImpl(final InheritanceAwareMap flatteners, final @Nullable Function unknownHandler, final int maxNestedDepth) { this.flatteners = flatteners; this.unknownHandler = unknownHandler; + this.maxNestedDepth = maxNestedDepth; + } + + private static final class StackEntry { + final Component component; + final int depth; + final int stylesToPop; + + StackEntry(final Component component, final int depth, final int stylesToPop) { + this.component = component; + this.depth = depth; + this.stylesToPop = stylesToPop; + } } @Override public void flatten(final @NotNull Component input, final @NotNull FlattenerListener listener) { - this.flatten0(input, listener, 0); + this.flatten0(input, listener, 0, 0); } - private void flatten0(final @NotNull Component input, final @NotNull FlattenerListener listener, final int depth) { + private void flatten0(final @NotNull Component input, final @NotNull FlattenerListener listener, final int depth, final int nestedDepth) { requireNonNull(input, "input"); requireNonNull(listener, "listener"); if (input == Component.empty()) return; - if (depth > MAX_DEPTH) { - throw new IllegalStateException("Exceeded maximum depth of " + MAX_DEPTH + " while attempting to flatten components!"); + + if (this.maxNestedDepth != ComponentFlattener.NO_NESTING_LIMIT && nestedDepth > this.maxNestedDepth) { + throw new IllegalStateException("Exceeded maximum nesting depth of " + this.maxNestedDepth + " while attempting to flatten components!"); } - final @Nullable Handler flattener = this.flattener(input); - final Style inputStyle = input.style(); + final Deque componentStack = new ArrayDeque<>(); + final Deque