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;
+ }
+}