From 955b224eb0ea412dbc06afa816bafcab8ea4cd2f Mon Sep 17 00:00:00 2001 From: zyr1xx Date: Wed, 9 Sep 2026 13:36:30 +0300 Subject: [PATCH] add: tooltipdisplay support --- README.md | 44 ++++ .../core/CoreItemPropsBundle.java | 2 + .../data/properties/PropTooltipDisplay.java | 176 ++++++++++++++++ plugin/src/main/resources/menu.conf | 10 + .../abstractmenus/core/CoreExtensionTest.java | 5 +- .../properties/TestPropTooltipDisplay.java | 194 ++++++++++++++++++ 6 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipDisplay.java create mode 100644 plugin/src/test/java/ru/abstractmenus/data/properties/TestPropTooltipDisplay.java diff --git a/README.md b/README.md index f9c80c8..9efef4b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,50 @@ current Paper API surface. [`PR_DESCRIPTION.md`](PR_DESCRIPTION.md) for the 1.18.0 profile-backed numbers). +## 🧰 Item properties + +Item properties are configured per menu item. The `tooltipDisplay` property +wraps the Paper `tooltip_display` data component (Minecraft 1.21.5+). + +Hide an item's tooltip entirely: + +```hocon +material: DIAMOND_SWORD +tooltipDisplay { + hideTooltip: true +} +``` + +Hide the tooltip of specific components, e.g. only the Bundle contents: + +```hocon +material: BUNDLE +tooltipDisplay { + hiddenComponents: [ + BUNDLE_CONTENTS + ] +} +``` + +Several components at once: + +```hocon +tooltipDisplay { + hiddenComponents: [ + BUNDLE_CONTENTS, + ATTRIBUTE_MODIFIERS, + ENCHANTMENTS + ] +} +``` + +`hiddenComponents` accepts component keys case-insensitively and in +namespaced form (`BUNDLE_CONTENTS`, `bundle_contents`, +`"minecraft:bundle_contents"`). Unknown component names fail loudly at menu load. +Namespaced keys contain `:`, which HOCON only allows inside quotes, so write +them as `"minecraft:bundle_contents"`. Namespaced keys without an explicit +namespace are assumed to be `minecraft:`. + ## 🔗 Links - [SpigotMC](https://www.spigotmc.org/resources/abstract-menus-an-advanced-gui-plugin.75107/) - [Discord](https://discord.gg/kt4P9Cgw) diff --git a/plugin/src/main/java/ru/abstractmenus/core/CoreItemPropsBundle.java b/plugin/src/main/java/ru/abstractmenus/core/CoreItemPropsBundle.java index 8c4a65a..ce4d247 100644 --- a/plugin/src/main/java/ru/abstractmenus/core/CoreItemPropsBundle.java +++ b/plugin/src/main/java/ru/abstractmenus/core/CoreItemPropsBundle.java @@ -34,6 +34,7 @@ import ru.abstractmenus.data.properties.PropBindings; import ru.abstractmenus.data.properties.PropNameLight; import ru.abstractmenus.data.properties.PropLoreLight; +import ru.abstractmenus.data.properties.PropTooltipDisplay; /** * Core item property registrations. Mirrors {@code ItemProps.init()} before @@ -60,6 +61,7 @@ void register(AbstractMenusApi api, MenuExtension owner) { api.itemProperties().register("enchantments", PropEnchantments.class, new PropEnchantments.Serializer(), owner); api.itemProperties().register("color", PropColor.class, new PropColor.Serializer(), owner); api.itemProperties().register("flags", PropFlags.class, new PropFlags.Serializer(), owner); + api.itemProperties().register("tooltipDisplay", PropTooltipDisplay.class, new PropTooltipDisplay.Serializer(), owner); api.itemProperties().register("unbreakable", PropUnbreakable.class, new PropUnbreakable.Serializer(), owner); api.itemProperties().register("potionData", PropPotionData.class, new PropPotionData.Serializer(), owner); api.itemProperties().register("fireworkData", PropFireworkData.class, new PropFireworkData.Serializer(), owner); diff --git a/plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipDisplay.java b/plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipDisplay.java new file mode 100644 index 0000000..cb3159b --- /dev/null +++ b/plugin/src/main/java/ru/abstractmenus/data/properties/PropTooltipDisplay.java @@ -0,0 +1,176 @@ +package ru.abstractmenus.data.properties; + +import io.papermc.paper.datacomponent.DataComponentType; +import io.papermc.paper.datacomponent.DataComponentTypes; +import io.papermc.paper.datacomponent.item.TooltipDisplay; +import org.bukkit.Registry; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import ru.abstractmenus.api.inventory.ItemProperty; +import ru.abstractmenus.api.inventory.Menu; +import ru.abstractmenus.datatype.TypeBool; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import ru.abstractmenus.hocon.api.serialize.NodeSerializer; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Item property controlling the {@code tooltip_display} data component + * ({@link TooltipDisplay}). Hides a tooltip entirely via {@code hideTooltip} + * or hides the tooltip of specific components via {@code hiddenComponents}. + * + *
{@code
+ * tooltipDisplay {
+ *     hideTooltip: false
+ *     hiddenComponents: [BUNDLE_CONTENTS, ENCHANTMENTS]
+ * }
+ * }
+ */ +public class PropTooltipDisplay implements ItemProperty { + + private final TypeBool hideTooltip; + private final Set hiddenComponents; + + private PropTooltipDisplay(TypeBool hideTooltip, Set hiddenComponents) { + this.hideTooltip = hideTooltip; + this.hiddenComponents = hiddenComponents; + } + + @Override + public boolean canReplaceMaterial() { + return false; + } + + @Override + public boolean isApplyMeta() { + return true; + } + + @Override + public void apply(ItemStack itemStack, ItemMeta meta, Player player, Menu menu) { + if (hideTooltip == null && hiddenComponents == null) { + return; + } + + tooltipDisplay(itemStack, player, menu); + } + + private void tooltipDisplay(ItemStack itemStack, Player player, Menu menu) { + TooltipDisplay existing = itemStack.getData(DataComponentTypes.TOOLTIP_DISPLAY); + + boolean hide = (hideTooltip != null) + ? hideTooltip.getBool(player, menu) + : (existing != null && existing.hideTooltip()); + + Set hidden = (hiddenComponents != null) + ? hiddenComponents + : (existing != null ? existing.hiddenComponents() : Collections.emptySet()); + + itemStack.setData(DataComponentTypes.TOOLTIP_DISPLAY, TooltipDisplay.tooltipDisplay() + .hideTooltip(hide) + .hiddenComponents(hidden) + .build()); + } + + public static class Serializer implements NodeSerializer { + + @Override + public PropTooltipDisplay deserialize(Class type, ConfigNode node) throws NodeSerializeException { + TypeBool hideTooltip = null; + Set hidden = null; + + ConfigNode hideNode = node.node("hideTooltip"); + if (hideNode.rawValue() != null) { + hideTooltip = new TypeBool.Serializer().deserialize(TypeBool.class, hideNode); + } + + ConfigNode hiddenNode = node.node("hiddenComponents"); + if (hiddenNode.rawValue() != null) { + if (!hiddenNode.isList()) { + throw new NodeSerializeException(node, "Field 'hiddenComponents' must be a list"); + } + hidden = new LinkedHashSet<>(); + for (ConfigNode componentNode : hiddenNode.childrenList()) { + String raw = componentNode.getString(); + DataComponentType component = resolveComponent(raw); + if (component == null) { + throw new NodeSerializeException(componentNode, "Unknown data component '" + raw + "'"); + } + hidden.add(component); + } + } + + return new PropTooltipDisplay(hideTooltip, hidden); + } + + } + + /** + * Test hook: replaces the live {@link Registry#DATA_COMPONENT_TYPE} source. + * Accepts any {@link Iterable} so tests do not need to mock the Registry + * class itself (its static initializer requires a live Paper server). + */ + static void configureRegistry(Iterable registry) { + registryOverride = registry; + cache = null; + } + + /** + * Test hook: restores the live registry source and clears the cache. + */ + static void resetRegistry() { + registryOverride = null; + cache = null; + } + + static DataComponentType resolveComponent(String raw) { + if (raw == null) { + return null; + } + String key = raw.trim().toLowerCase(Locale.ROOT); + int colon = key.indexOf(':'); + if (colon >= 0) { + key = key.substring(colon + 1); + } + if (key.isEmpty()) { + return null; + } + return registryMap().get(key); + } + + private static volatile Iterable registryOverride; + private static volatile Map cache; + + private static Map registryMap() { + Map local = cache; + if (local != null) { + return local; + } + Iterable source = registryOverride; + if (source == null) { + try { + source = Registry.DATA_COMPONENT_TYPE; + } catch (Throwable t) { + source = null; + } + } + Map built = new HashMap<>(); + if (source != null) { + for (DataComponentType type : source) { + if (type.getKey() == null) { + continue; + } + built.put(type.getKey().getKey().toLowerCase(Locale.ROOT), type); + } + } + cache = built; + return built; + } +} diff --git a/plugin/src/main/resources/menu.conf b/plugin/src/main/resources/menu.conf index 940076a..271f239 100644 --- a/plugin/src/main/resources/menu.conf +++ b/plugin/src/main/resources/menu.conf @@ -18,6 +18,16 @@ items: [ closeMenu: true } }, + { + slot: 5 + material: BUNDLE + name: "&6&lBundle" + tooltipDisplay { + hiddenComponents: [ + BUNDLE_CONTENTS + ] + } + }, { slot: 8 material: BARRIER diff --git a/plugin/src/test/java/ru/abstractmenus/core/CoreExtensionTest.java b/plugin/src/test/java/ru/abstractmenus/core/CoreExtensionTest.java index d94edc7..e6f0243 100644 --- a/plugin/src/test/java/ru/abstractmenus/core/CoreExtensionTest.java +++ b/plugin/src/test/java/ru/abstractmenus/core/CoreExtensionTest.java @@ -90,8 +90,8 @@ void coreExtension_registersExpectedRules() { @Test void coreExtension_registersExpectedItemProperties() { new CoreExtension().onEnable(api); - assertEquals(32, api.itemProperties().keys().size(), - "expected 32 core item-property types (matches Task 10 migration count)"); + assertEquals(33, api.itemProperties().keys().size(), + "expected 33 core item-property types (matches Task 10 migration count)"); } @Test @@ -128,6 +128,7 @@ void coreExtension_canonicalRules_resolve() { void coreExtension_canonicalItemProperty_resolve() { new CoreExtension().onEnable(api); assertNotNull(api.itemProperties().get("material"), "material item-property must be registered"); + assertNotNull(api.itemProperties().get("tooltipDisplay"), "tooltipDisplay item-property must be registered"); } // ------------------------------------------------------------------------- diff --git a/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropTooltipDisplay.java b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropTooltipDisplay.java new file mode 100644 index 0000000..f820dbf --- /dev/null +++ b/plugin/src/test/java/ru/abstractmenus/data/properties/TestPropTooltipDisplay.java @@ -0,0 +1,194 @@ +package ru.abstractmenus.data.properties; + +import io.papermc.paper.datacomponent.DataComponentType; +import org.bukkit.NamespacedKey; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.abstractmenus.datatype.TypeBool; +import ru.abstractmenus.hocon.api.ConfigNode; +import ru.abstractmenus.hocon.api.ConfigurationLoader; +import ru.abstractmenus.hocon.api.serialize.NodeSerializeException; +import ru.abstractmenus.hocon.api.source.ConfigSources; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Tests the {@code tooltipDisplay} item property's config parsing and its + * {@code hiddenComponents} component resolver. + * + *

The resolver is backed by the injectable {@link Registry#DATA_COMPONENT_TYPE} + * source, so case-insensitive / namespaced resolution and unknown-component + * handling are covered without a live server (see + * {@code PropTooltipDisplay.configureRegistry}). + * + *

Full {@code apply()} verification needs the Paper {@code TooltipDisplay} + * builder bridge, which requires a booted server (MockBukkit cannot bootstrap + * Paper 1.21.11) — see README's Tests section. + */ +class TestPropTooltipDisplay { + + private static final Map COMPONENTS = new LinkedHashMap<>(); + + @BeforeEach + void setUp() { + COMPONENTS.clear(); + COMPONENTS.put("bundle_contents", component("bundle_contents")); + COMPONENTS.put("enchantments", component("enchantments")); + COMPONENTS.put("attribute_modifiers", component("attribute_modifiers")); + COMPONENTS.put("stored_enchantments", component("stored_enchantments")); + COMPONENTS.put("custom_model_data", component("custom_model_data")); + + PropTooltipDisplay.configureRegistry(COMPONENTS.values()); + } + + @AfterEach + void tearDown() { + PropTooltipDisplay.resetRegistry(); + } + + // ----- resolver ----- + + @Test + void resolvesUppercaseName() { + assertSame(COMPONENTS.get("bundle_contents"), + PropTooltipDisplay.resolveComponent("BUNDLE_CONTENTS")); + } + + @Test + void resolvesLowercaseName() { + assertSame(COMPONENTS.get("bundle_contents"), + PropTooltipDisplay.resolveComponent("bundle_contents")); + } + + @Test + void resolvesNamespacedKey() { + assertSame(COMPONENTS.get("bundle_contents"), + PropTooltipDisplay.resolveComponent("minecraft:bundle_contents")); + } + + @Test + void resolveIsCaseInsensitiveForNamespacedInput() { + assertSame(COMPONENTS.get("bundle_contents"), + PropTooltipDisplay.resolveComponent("MINECRAFT:BUNDLE_CONTENTS")); + } + + @Test + void unknownComponentResolvesToNull() { + assertNull(PropTooltipDisplay.resolveComponent("NOPE_NOT_A_COMPONENT")); + } + + @Test + void nullComponentResolvesToNull() { + assertNull(PropTooltipDisplay.resolveComponent(null)); + assertNull(PropTooltipDisplay.resolveComponent(" ")); + } + + // ----- serialization ----- + + @Test + void parsesHideTooltipTrue() throws Exception { + PropTooltipDisplay prop = build("tooltipDisplay { hideTooltip: true }"); + TypeBool hide = (TypeBool) field(prop, "hideTooltip"); + assertNotNull(hide); + assertTrue(hide.getBool(null, null)); + } + + @Test + void parsesHideTooltipFalse() throws Exception { + PropTooltipDisplay prop = build("tooltipDisplay { hideTooltip: false }"); + TypeBool hide = (TypeBool) field(prop, "hideTooltip"); + assertNotNull(hide); + assertFalse(hide.getBool(null, null)); + } + + @Test + void parsesHiddenComponentsCaseInsensitive() throws Exception { + PropTooltipDisplay prop = build("tooltipDisplay { hiddenComponents: [BUNDLE_CONTENTS] }"); + Set hidden = hidden(prop); + assertEquals(Set.of(COMPONENTS.get("bundle_contents")), hidden); + } + + @Test + void parsesHiddenComponentsNamespaced() throws Exception { + PropTooltipDisplay prop = build("tooltipDisplay { hiddenComponents: [\"minecraft:bundle_contents\"] }"); + assertEquals(Set.of(COMPONENTS.get("bundle_contents")), hidden(prop)); + } + + @Test + void parsesMultipleHiddenComponents() throws Exception { + PropTooltipDisplay prop = build( + "tooltipDisplay { hiddenComponents: [BUNDLE_CONTENTS, ENCHANTMENTS, attribute_modifiers] }"); + assertEquals(Set.of( + COMPONENTS.get("bundle_contents"), + COMPONENTS.get("enchantments"), + COMPONENTS.get("attribute_modifiers")), hidden(prop)); + } + + @Test + void unknownComponentThrows() throws Exception { + ConfigNode root = loadNode("tooltipDisplay { hiddenComponents: [FUCKING_UNKNOWN_COMPONENT] }"); + PropTooltipDisplay.Serializer serializer = new PropTooltipDisplay.Serializer(); + assertThrows(NodeSerializeException.class, () -> + serializer.deserialize(PropTooltipDisplay.class, root.node("tooltipDisplay"))); + } + + @Test + void emptyHiddenComponentsIsAccepted() throws Exception { + PropTooltipDisplay prop = build("tooltipDisplay { hiddenComponents: [] }"); + assertEquals(Set.of(), hidden(prop)); + } + + @Test + void emptyTooltipDisplayLeavesFieldsNull() throws Exception { + PropTooltipDisplay prop = build("tooltipDisplay { }"); + assertNull(field(prop, "hideTooltip")); + assertNull(field(prop, "hiddenComponents")); + } + + @Test + void hideTooltipOnlyLeavesHiddenNull() throws Exception { + PropTooltipDisplay prop = build("tooltipDisplay { hideTooltip: false }"); + assertNull(field(prop, "hiddenComponents")); + } + + // ----- helpers ----- + + private static PropTooltipDisplay build(String hocon) throws Exception { + ConfigNode root = loadNode(hocon); + return new PropTooltipDisplay.Serializer().deserialize(PropTooltipDisplay.class, root.node("tooltipDisplay")); + } + + @SuppressWarnings("unchecked") + private static Set hidden(PropTooltipDisplay prop) throws Exception { + return (Set) field(prop, "hiddenComponents"); + } + + private static Object field(Object target, String name) throws Exception { + Field f = PropTooltipDisplay.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } + + private static ConfigNode loadNode(String hocon) throws Exception { + ByteArrayInputStream in = new ByteArrayInputStream(hocon.getBytes(StandardCharsets.UTF_8)); + return ConfigurationLoader.builder() + .source(ConfigSources.inputStream("test", in)) + .build() + .load(); + } + + private static DataComponentType component(String key) { + DataComponentType type = mock(DataComponentType.class); + when(type.getKey()).thenReturn(NamespacedKey.minecraft(key)); + return type; + } +}