diff --git a/.gitignore b/.gitignore
index 87240257..1d6848fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,4 +27,4 @@ changelog.html
\.classpath
\.project
\.settings/
-*.launch
+*.launch
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index f8fbabf3..2ef4b8cc 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -16,7 +16,7 @@ generate_javadocs_jar = false
# Mod Information
# HIGHLY RECOMMEND complying with SemVer for mod_version: https://semver.org/
-mod_version = 4.29.15
+mod_version = 4.30.0
root_package = mezz
mod_id = jei
mod_name = Had Enough Items
diff --git a/src/api/java/mezz/jei/api/ICollapsibleGroupRegistry.java b/src/api/java/mezz/jei/api/ICollapsibleGroupRegistry.java
new file mode 100644
index 00000000..6674768d
--- /dev/null
+++ b/src/api/java/mezz/jei/api/ICollapsibleGroupRegistry.java
@@ -0,0 +1,110 @@
+package mezz.jei.api;
+
+import mezz.jei.api.ingredients.IIngredientRegistry;
+import mezz.jei.api.recipe.IIngredientType;
+
+import java.util.function.Predicate;
+
+/**
+ * Registry for mods to define collapsible groups in the HEI ingredient list.
+ * They can be toggled on/off by the user but cannot be edited or deleted.
+ *
+ * Obtain an instance via {@link IModPlugin#registerCollapsibleGroups(ICollapsibleGroupRegistry)}.
+ *
+ * EXAMPLE 1 — Group with filtered items:
+ *
+ * @Override
+ * public void registerCollapsibleGroups(ICollapsibleGroupRegistry registry) {
+ * registry.newGroup("matteroverdrive:colored_floor_tile", "tile.decorative.floor_tile.name")
+ * .addAny(VanillaTypes.ITEM,
+ * stack -> Block.getBlockFromItem(stack.getItem()) == MatterOverdrive.BLOCKS.decorative_floor_tile);
+ * }
+ *
+ *
+ * EXAMPLE 2 — Group mixing exact items, fluids, and all of a type:
+ *
+ * @Override
+ * public void registerCollapsibleGroups(ICollapsibleGroupRegistry registry) {
+ * registry.newGroup("mymod:my_group", "group.mymod.my_group")
+ * .add(new ItemStack(MyMod.Items.PICKAXE)) // add one exact item
+ * .add(new ItemStack(MyMod.Blocks.MY_BLOCK, 1, 1)) // add a specific block variant (meta=1)
+ * .add(FluidRegistry.getFluidStack("water", 1000)) // add an exact fluid
+ * .addAllOf(MyMod.CUSTOM_INGREDIENT_TYPE); // add every ingredient of a custom type
+ * }
+ *
+ *
+ * EXAMPLE 3 — Multiple groups from one plugin:
+ *
+ * @Override
+ * public void registerCollapsibleGroups(ICollapsibleGroupRegistry registry) {
+ * registry.newGroup("mymod:ores", "group.mymod.ores")
+ * .addAny(VanillaTypes.ITEM, stack -> isOre(stack));
+ *
+ * registry.newGroup("mymod:gems", "group.mymod.gems")
+ * .addAny(VanillaTypes.ITEM, stack -> isGem(stack));
+ * }
+ *
+ *
+ * @since HEI 4.30.0
+ */
+public interface ICollapsibleGroupRegistry {
+
+ /**
+ * Creates (or retrieves) a mod collapsible group builder.
+ *
+ * Multiple calls with the same {@code id} return a builder for the same logical group.
+ *
+ * @param id Unique group ID, should be namespaced with your mod ID.
+ * @param langKey Unlocalized translation key for the group name (e.g. {@code "tile.mymod.name"}).
+ */
+ CollapsibleGroupBuilder newGroup(String id, String langKey);
+
+ interface CollapsibleGroupBuilder {
+ /**
+ * Add one exact ingredient to the group.
+ *
+ * The backend resolves this ingredient to its unique id and matches by id.
+ * Works with ItemStacks, FluidStacks, or any registered ingredient type.
+ *
+ * @param ingredient the ingredient to add (e.g. new ItemStack(Items.APPLE))
+ * @return this builder for chaining
+ */
+ CollapsibleGroupBuilder add(Object ingredient);
+
+ /**
+ * Add multiple exact ingredients to the group. Equivalent to calling {@link #add(Object)}
+ * for each element in order.
+ *
+ * @param ingredients varargs list of ingredients to add
+ * @return this builder for chaining
+ */
+ CollapsibleGroupBuilder add(Object... ingredients);
+
+ /**
+ * Add every ingredient of the given type(s) to the group — no filtering applied.
+ *
+ * Use this for custom ingredient types where you want to collapse all registered
+ * instances into one group. Third-party ingredient types registered via
+ * {@link IIngredientRegistry} are supported.
+ *
+ *
Note: passing {@code VanillaTypes.ITEM} or {@code VanillaTypes.FLUID} will match
+ * every item or fluid in the game. Prefer {@link #addAny} with a predicate
+ * when you only want a subset.
+ *
+ * @param types the ingredient type(s) whose every instance should be included
+ * @return this builder for chaining
+ */
+ CollapsibleGroupBuilder addAllOf(IIngredientType>... types);
+
+ /**
+ * Add any ingredient of a given type that matches the provided predicate filter.
+ *
+ * Use this when you need to match items by condition (e.g. "all tools", "all ores", etc).
+ *
+ * @param type The ingredient type (e.g. {@code VanillaTypes.ITEM}).
+ * @param filter Predicate receiving a fully-typed {@code V} — no casting needed.
+ * @return this builder for chaining
+ */
+ CollapsibleGroupBuilder addAny(IIngredientType type, Predicate filter);
+ }
+}
diff --git a/src/api/java/mezz/jei/api/IModPlugin.java b/src/api/java/mezz/jei/api/IModPlugin.java
index 8e93e240..b9b193e0 100644
--- a/src/api/java/mezz/jei/api/IModPlugin.java
+++ b/src/api/java/mezz/jei/api/IModPlugin.java
@@ -48,6 +48,22 @@ default void registerCategories(IRecipeCategoryRegistration registry) {
}
+ /**
+ * Register collapsible ingredient groups provided by this mod.
+ * These appear in the "Manage Groups" screen tagged as "Mod" and can be toggled
+ * by the user but are not editable or deletable.
+ *
+ * Use {@link ICollapsibleGroupRegistry#newGroup(String, String)} to create a builder
+ * and call its methods to define the group's members. See {@link ICollapsibleGroupRegistry}
+ * for full usage examples.
+ *
+ * @param registry the registry used to create collapsible group builders
+ * @since HEI 4.30.0
+ */
+ default void registerCollapsibleGroups(ICollapsibleGroupRegistry registry) {
+
+ }
+
/**
* Register this mod plugin with the mod registry.
*/
diff --git a/src/main/java/mezz/jei/Internal.java b/src/main/java/mezz/jei/Internal.java
index ee87b787..d63f0449 100644
--- a/src/main/java/mezz/jei/Internal.java
+++ b/src/main/java/mezz/jei/Internal.java
@@ -5,6 +5,7 @@
import mezz.jei.bookmarks.BookmarkList;
import mezz.jei.color.ColorNamer;
import mezz.jei.gui.GuiEventHandler;
+import mezz.jei.ingredients.CollapsedStackRegistry;
import mezz.jei.ingredients.IngredientFilter;
import mezz.jei.ingredients.IngredientRegistry;
import mezz.jei.input.InputHandler;
@@ -40,6 +41,8 @@ public final class Internal {
private static InputHandler inputHandler;
@Nullable
private static BookmarkList bookmarkList;
+ @Nullable
+ private static CollapsedStackRegistry collapsedStackRegistry;
private Internal() {
@@ -151,4 +154,16 @@ public static BookmarkList getBookmarkList() {
Preconditions.checkState(bookmarkList != null, "Bookmark List has not been created yet.");
return bookmarkList;
}
+
+ public static CollapsedStackRegistry getCollapsedStackRegistry() {
+ if (collapsedStackRegistry == null) {
+ collapsedStackRegistry = CollapsedStackRegistry.getInstance();
+ }
+ return collapsedStackRegistry;
+ }
+
+ public static void setCollapsedStackRegistry(CollapsedStackRegistry registry) {
+ Internal.collapsedStackRegistry = registry;
+ CollapsedStackRegistry.setInstance(registry);
+ }
}
diff --git a/src/main/java/mezz/jei/config/Config.java b/src/main/java/mezz/jei/config/Config.java
index de12b7b0..31f1b9d7 100644
--- a/src/main/java/mezz/jei/config/Config.java
+++ b/src/main/java/mezz/jei/config/Config.java
@@ -16,6 +16,7 @@
import mezz.jei.startup.ForgeModIdHelper;
import mezz.jei.startup.IModIdHelper;
import mezz.jei.util.GiveMode;
+import mezz.jei.util.CollapsedClickAction;
import mezz.jei.util.Log;
import mezz.jei.util.Translator;
import net.minecraft.init.Items;
@@ -39,6 +40,7 @@
import java.io.IOException;
import java.util.*;
import java.util.List;
+import java.util.ArrayList;
public final class Config {
private static final String configKeyPrefix = "config.jei";
@@ -49,6 +51,7 @@ public final class Config {
public static final String CATEGORY_RENDERING = "rendering";
public static final String CATEGORY_MISC = "misc";
public static final String CATEGORY_CATEGORY = "category";
+ public static final String CATEGORY_COLLAPSIBLE = "collapsible";
public static final String defaultModNameFormatFriendly = "blue italic";
public static final int smallestNumColumns = 4;
@@ -65,6 +68,8 @@ public final class Config {
@Nullable
private static LocalizedConfiguration searchColorsConfig;
@Nullable
+ private static CustomGroupsConfig customGroupsConfig;
+ @Nullable
private static File bookmarkFile;
@Nullable
private static File favoriteFile;
@@ -89,6 +94,39 @@ public static boolean isOverlayEnabled() {
KeyBindings.toggleOverlay.getKeyCode() == 0; // if there is no key binding to enable it, don't allow the overlay to be disabled
}
+ public static boolean isCollapsibleGroupsEnabled() {
+ return values.collapsibleGroupsEnabled;
+ }
+
+ public static boolean isCollapseOnClose() {
+ return values.collapseOnClose;
+ }
+
+ public static CollapsedClickAction getCollapsedClickAction() {
+ return values.collapsedClickAction;
+ }
+
+ @Nullable
+ public static CustomGroupsConfig getCustomGroupsConfig() {
+ return customGroupsConfig;
+ }
+
+ public static Set getDisabledGroups() {
+ return values.disabledGroups;
+ }
+
+ public static void saveDisabledGroups(Set disabledGroups) {
+ values.disabledGroups.clear();
+ values.disabledGroups.addAll(disabledGroups);
+ if (config != null) {
+ Property property = config.get(CATEGORY_COLLAPSIBLE, "disabledGroups", new String[]{});
+ property.set(disabledGroups.toArray(new String[0]));
+ if (config.hasChanged()) {
+ config.save();
+ }
+ }
+ }
+
public static void toggleOverlayEnabled() {
values.overlayEnabled = !values.overlayEnabled;
@@ -412,6 +450,9 @@ public static void preInit(FMLPreInitializationEvent event) {
itemBlacklistConfig = new LocalizedConfiguration(configKeyPrefix, itemBlacklistConfigFile, "0.1.0");
searchColorsConfig = new LocalizedConfiguration(configKeyPrefix, searchColorsConfigFile, "0.1.0");
+ customGroupsConfig = new CustomGroupsConfig(jeiConfigurationDir);
+ customGroupsConfig.load();
+
syncConfig();
syncItemBlacklistConfig();
syncSearchColorsConfig();
@@ -448,6 +489,10 @@ private static boolean syncConfig() {
config.addCategory(CATEGORY_SEARCH);
config.addCategory(CATEGORY_ADVANCED);
config.addCategory(CATEGORY_MISC);
+ config.addCategory(CATEGORY_COLLAPSIBLE);
+ // Override collapsible category lang keys from config.jei.* to config.hei.*
+ config.setCategoryLanguageKey(CATEGORY_COLLAPSIBLE, "config.hei.collapsible");
+ config.setCategoryComment(CATEGORY_COLLAPSIBLE, Translator.translateToLocal("config.hei.collapsible.comment"));
ConfigCategory modeCategory = config.getCategory("mode");
if (modeCategory != null) {
@@ -525,7 +570,13 @@ private static boolean syncConfig() {
values.tooltipShowRecipeBy = config.getBoolean(CATEGORY_MISC, "tooltipShowRecipeBy", defaultValues.tooltipShowRecipeBy);
- values.showHiddenIngredientsInCreative = config.getBoolean(CATEGORY_MISC, "showHiddenIngredientsInCreative", defaultValues.showHiddenIngredientsInCreative);
+ {
+ boolean prev = values.showHiddenIngredientsInCreative;
+ values.showHiddenIngredientsInCreative = config.getBoolean(CATEGORY_MISC, "showHiddenIngredientsInCreative", defaultValues.showHiddenIngredientsInCreative);
+ if (prev != values.showHiddenIngredientsInCreative) {
+ needsReload = true;
+ }
+ }
values.skipShowingProgressBar = config.getBoolean(CATEGORY_MISC, "skipShowingProgressBar", defaultValues.skipShowingProgressBar);
@@ -533,6 +584,54 @@ private static boolean syncConfig() {
values.hideBottomLeftCornerBookmarkButton = config.getBoolean(CATEGORY_MISC, "hideBottomLeftCornerBookmarkButton", defaultValues.hideBottomLeftCornerBookmarkButton);
+ {
+ boolean prev = values.collapsibleGroupsEnabled;
+ values.collapsibleGroupsEnabled = config.getBoolean(CATEGORY_COLLAPSIBLE, "collapsibleGroupsEnabled", defaultValues.collapsibleGroupsEnabled);
+ if (prev != values.collapsibleGroupsEnabled) {
+ needsReload = true;
+ }
+ }
+
+ values.collapseOnClose = config.getBoolean(CATEGORY_COLLAPSIBLE, "collapseOnClose", defaultValues.collapseOnClose);
+
+ values.collapsedClickAction = config.getEnum("collapsedClickAction", CATEGORY_COLLAPSIBLE, defaultValues.collapsedClickAction, CollapsedClickAction.values());
+
+ // Override property lang keys and comments from config.jei.collapsible.* to config.hei.collapsible.*
+ {
+ String heiPrefix = "config.hei.collapsible.";
+
+ Property collapsibleGroupsEnabledProp = config.get(CATEGORY_COLLAPSIBLE, "collapsibleGroupsEnabled", defaultValues.collapsibleGroupsEnabled);
+ collapsibleGroupsEnabledProp.setLanguageKey(heiPrefix + "collapsibleGroupsEnabled");
+ collapsibleGroupsEnabledProp.setComment(Translator.translateToLocal(heiPrefix + "collapsibleGroupsEnabled.comment"));
+
+ Property collapseOnCloseProp = config.get(CATEGORY_COLLAPSIBLE, "collapseOnClose", defaultValues.collapseOnClose);
+ collapseOnCloseProp.setLanguageKey(heiPrefix + "collapseOnClose");
+ collapseOnCloseProp.setComment(Translator.translateToLocal(heiPrefix + "collapseOnClose.comment"));
+
+ Property collapsedClickActionProp = config.get(CATEGORY_COLLAPSIBLE, "collapsedClickAction", defaultValues.collapsedClickAction.name());
+ collapsedClickActionProp.setLanguageKey(heiPrefix + "collapsedClickAction");
+ String defaultLocalized = Translator.translateToLocal("config.jei.default");
+ String validLocalized = Translator.translateToLocal("config.jei.valid");
+ collapsedClickActionProp.setComment(Translator.translateToLocal(heiPrefix + "collapsedClickAction.comment")
+ + "\n[" + defaultLocalized + ": " + defaultValues.collapsedClickAction.name().toLowerCase(Locale.ENGLISH) + "]"
+ + "\n[" + validLocalized + ": " + Arrays.toString(collapsedClickActionProp.getValidValues()) + ']');
+ }
+
+ // Explicit property order so the GUI shows collapsibleGroupsEnabled first, then collapseOnClose.
+ List collapsibleOrder = new ArrayList<>();
+ collapsibleOrder.add("collapsibleGroupsEnabled");
+ collapsibleOrder.add("collapseOnClose");
+ collapsibleOrder.add("collapsedClickAction");
+ config.setCategoryPropertyOrder(CATEGORY_COLLAPSIBLE, collapsibleOrder);
+
+ {
+ String[] disabledGroupsArray = config.getStringList("disabledGroups", CATEGORY_COLLAPSIBLE, new String[]{});
+ Property disabledProp = config.get(CATEGORY_COLLAPSIBLE, "disabledGroups", new String[]{});
+ disabledProp.setShowInGui(false);
+ values.disabledGroups.clear();
+ Collections.addAll(values.disabledGroups, disabledGroupsArray);
+ }
+
{
Property property = config.get(CATEGORY_ADVANCED, "debugModeEnabled", defaultValues.debugModeEnabled);
property.setShowInGui(false);
diff --git a/src/main/java/mezz/jei/config/ConfigValues.java b/src/main/java/mezz/jei/config/ConfigValues.java
index ab88d7a4..50fced31 100644
--- a/src/main/java/mezz/jei/config/ConfigValues.java
+++ b/src/main/java/mezz/jei/config/ConfigValues.java
@@ -1,11 +1,14 @@
package mezz.jei.config;
+import mezz.jei.util.CollapsedClickAction;
import mezz.jei.util.GiveMode;
import net.minecraft.init.Items;
import net.minecraft.item.ItemStack;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
public class ConfigValues {
// advanced
@@ -53,4 +56,10 @@ public class ConfigValues {
// category
public List categoryUidOrder = new ArrayList<>();
+
+ // collapsible groups
+ public boolean collapsibleGroupsEnabled = true;
+ public boolean collapseOnClose = false;
+ public CollapsedClickAction collapsedClickAction = CollapsedClickAction.OPEN_GROUP;
+ public Set disabledGroups = new HashSet<>();
}
diff --git a/src/main/java/mezz/jei/config/CustomGroupsConfig.java b/src/main/java/mezz/jei/config/CustomGroupsConfig.java
new file mode 100644
index 00000000..5b81f930
--- /dev/null
+++ b/src/main/java/mezz/jei/config/CustomGroupsConfig.java
@@ -0,0 +1,109 @@
+package mezz.jei.config;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.reflect.TypeToken;
+import mezz.jei.util.Log;
+
+import javax.annotation.Nullable;
+import java.io.*;
+import java.lang.reflect.Type;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Manages custom collapsible groups persistence as JSON.
+ * File: config/jei/customCollapsibleGroups.json
+ */
+public class CustomGroupsConfig {
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+ private static final Type GROUP_LIST_TYPE = new TypeToken>() {}.getType();
+
+ private final File configFile;
+ private Map customGroups = new LinkedHashMap<>();
+
+ public CustomGroupsConfig(File configDir) {
+ this.configFile = new File(configDir, "customCollapsibleGroups.json");
+ }
+
+ public void load() {
+ customGroups = new LinkedHashMap<>();
+ if (!configFile.exists()) {
+ return;
+ }
+ try (Reader reader = new InputStreamReader(new FileInputStream(configFile), StandardCharsets.UTF_8)) {
+ List loaded = GSON.fromJson(reader, GROUP_LIST_TYPE);
+ if (loaded != null) {
+ for (CustomGroup group : loaded) {
+ customGroups.put(group.id, group);
+ }
+ }
+ } catch (Exception e) {
+ Log.get().error("Failed to load custom collapsible groups from {}", configFile, e);
+ customGroups = new LinkedHashMap<>();
+ }
+ }
+
+ public void save() {
+ try (Writer writer = new OutputStreamWriter(new FileOutputStream(configFile), StandardCharsets.UTF_8)) {
+ GSON.toJson(new ArrayList<>(customGroups.values()), GROUP_LIST_TYPE, writer);
+ } catch (Exception e) {
+ Log.get().error("Failed to save custom collapsible groups to {}", configFile, e);
+ }
+ }
+
+ public Collection getCustomGroups() {
+ return Collections.unmodifiableCollection(customGroups.values());
+ }
+
+ @Nullable
+ public CustomGroup getGroup(String id) {
+ return customGroups.get(id);
+ }
+
+ public void addGroup(CustomGroup group) {
+ customGroups.put(group.id, group);
+ save();
+ }
+
+ public void removeGroup(String id) {
+ customGroups.remove(id);
+ save();
+ }
+
+ public void updateGroup(CustomGroup updated) {
+ customGroups.put(updated.id, updated);
+ save();
+ }
+
+ /**
+ * A user-defined collapsible group stored as JSON.
+ * Items are identified by their unique identifier string from StackHelper.
+ */
+ public static class CustomGroup {
+ public String id;
+ public String displayName;
+ public List itemUids;
+
+ public CustomGroup() {
+ this.id = "";
+ this.displayName = "";
+ this.itemUids = new ArrayList<>();
+ }
+
+ public CustomGroup(String id, String displayName, List itemUids) {
+ this.id = id;
+ this.displayName = displayName;
+ this.itemUids = new ArrayList<>(itemUids);
+ }
+
+ public CustomGroup copy() {
+ return new CustomGroup(id, displayName, new ArrayList<>(itemUids));
+ }
+ }
+}
diff --git a/src/main/java/mezz/jei/config/JEIModConfigGui.java b/src/main/java/mezz/jei/config/JEIModConfigGui.java
index f65dd89f..d87e3c47 100644
--- a/src/main/java/mezz/jei/config/JEIModConfigGui.java
+++ b/src/main/java/mezz/jei/config/JEIModConfigGui.java
@@ -1,11 +1,15 @@
package mezz.jei.config;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
+import java.util.regex.Pattern;
import net.minecraftforge.fml.client.FMLClientHandler;
import net.minecraftforge.fml.client.GuiModList;
+import net.minecraftforge.fml.client.config.ConfigGuiType;
import net.minecraftforge.fml.client.config.GuiConfig;
+import net.minecraftforge.fml.client.config.GuiConfigEntries;
import net.minecraftforge.fml.client.config.IConfigElement;
import net.minecraftforge.common.config.ConfigCategory;
import net.minecraftforge.common.config.ConfigElement;
@@ -15,9 +19,12 @@
import net.minecraft.client.gui.GuiButton;
import net.minecraft.client.gui.GuiScreen;
import net.minecraft.client.gui.inventory.GuiInventory;
+import net.minecraft.client.resources.I18n;
import net.minecraft.network.NetworkManager;
+import net.minecraftforge.fml.client.config.GuiButtonExt;
import mezz.jei.JustEnoughItems;
+import mezz.jei.gui.overlay.collapsible.GuiCollapsibleGroups;
import mezz.jei.gui.recipes.RecipesGui;
import mezz.jei.network.packets.PacketRequestCheatPermission;
import mezz.jei.util.Translator;
@@ -49,19 +56,46 @@ private static GuiScreen getParent(GuiScreen parent) {
return parent;
}
+ private static void addCollapsibleElements(List configElements, LocalizedConfiguration config) {
+ // Show collapsibleGroupsEnabled inline (aligned like other boolean properties)
+ ConfigCategory catCollapsible = config.getCategory(Config.CATEGORY_COLLAPSIBLE);
+ for (IConfigElement element : new ConfigElement(catCollapsible).getChildElements()) {
+ if (element.showInGui()) {
+ configElements.add(element);
+ }
+ }
+ // "Manage Groups" navigation entry — opens GuiCollapsibleGroups in the same visual row style
+ configElements.add(new ManageGroupsConfigElement());
+ }
+
private static List getConfigElements() {
List configElements = new ArrayList<>();
+ LocalizedConfiguration config = Config.getConfig();
+
if (Minecraft.getMinecraft().world != null) {
Configuration worldConfig = Config.getWorldConfig();
if (worldConfig != null) {
NetworkManager networkManager = FMLClientHandler.instance().getClientToServerNetworkManager();
ConfigCategory categoryWorldConfig = worldConfig.getCategory(ServerInfo.getWorldUid(networkManager));
- configElements.addAll(new ConfigElement(categoryWorldConfig).getChildElements());
+ List worldElements = new ConfigElement(categoryWorldConfig).getChildElements();
+
+ // Find the "Hide Ingredients Mode" entry and insert Collapsible Groups submenu immediately after it
+ int insertAt = worldElements.size();
+ for (int i = 0; i < worldElements.size(); i++) {
+ if ("config.jei.mode.editEnabled".equals(worldElements.get(i).getLanguageKey())) {
+ insertAt = i + 1;
+ break;
+ }
+ }
+ configElements.addAll(worldElements.subList(0, insertAt));
+ if (config != null) {
+ addCollapsibleElements(configElements, config);
+ }
+ configElements.addAll(worldElements.subList(insertAt, worldElements.size()));
}
}
- LocalizedConfiguration config = Config.getConfig();
if (config != null) {
ConfigCategory categoryAdvanced = config.getCategory(Config.CATEGORY_ADVANCED);
configElements.addAll(new ConfigElement(categoryAdvanced).getChildElements());
@@ -72,6 +106,11 @@ private static List getConfigElements() {
ConfigCategory categoryMisc = config.getCategory(Config.CATEGORY_MISC);
configElements.addAll(new ConfigElement(categoryMisc).getChildElements());
+ // If we never had a world config section (world == null), show Collapsible Groups here instead
+ if (Minecraft.getMinecraft().world == null) {
+ addCollapsibleElements(configElements, config);
+ }
+
ConfigCategory categorySearch = config.getCategory(Config.CATEGORY_SEARCH);
configElements.add(new ConfigElement(categorySearch));
}
@@ -97,4 +136,66 @@ protected void actionPerformed(GuiButton button) {
JustEnoughItems.getProxy().sendPacketToServer(new PacketRequestCheatPermission());
}
}
+
+ // -------------------------------------------------------------------------
+ // "Manage Groups" config list entry
+ // -------------------------------------------------------------------------
+
+ /**
+ * A CategoryEntry that opens GuiCollapsibleGroups instead of a standard GuiConfig subcategory.
+ * Constructor signature must match (GuiConfig, GuiConfigEntries, IConfigElement).
+ */
+ public static class ManageGroupsEntry extends GuiConfigEntries.ButtonEntry {
+ public ManageGroupsEntry(GuiConfig owningScreen, GuiConfigEntries owningEntryList, IConfigElement configElement) {
+ super(owningScreen, owningEntryList, configElement,
+ new GuiButtonExt(0, owningEntryList.controlX, 0, owningEntryList.controlWidth, 18,
+ I18n.format("hei.gui.collapsible.title")));
+ }
+
+ @Override public void updateValueButtonText() {}
+ @Override public void valueButtonPressed(int slotIndex) {
+ this.mc.displayGuiScreen(new GuiCollapsibleGroups(this.owningScreen));
+ }
+ @Override public boolean isDefault() { return true; }
+ @Override public void setToDefault() {}
+ @Override public boolean isChanged() { return false; }
+ @Override public void undoChanges() {}
+ @Override public boolean saveConfigElement() { return false; }
+ @Override public Object getCurrentValue() { return ""; }
+ @Override public Object[] getCurrentValues() { return new Object[]{ "" }; }
+ }
+
+ /**
+ * A minimal IConfigElement that represents a category-type navigation entry
+ * pointing to GuiCollapsibleGroups via ManageGroupsEntry.
+ */
+ public static class ManageGroupsConfigElement implements IConfigElement {
+ @Override public boolean isProperty() { return false; }
+ @Override public Class extends GuiConfigEntries.IConfigEntry> getConfigEntryClass() { return ManageGroupsEntry.class; }
+ @Override public Class extends net.minecraftforge.fml.client.config.GuiEditArrayEntries.IArrayEntry> getArrayEntryClass() { return null; }
+ @Override public String getName() { return "manageGroups"; }
+ @Override public String getQualifiedName() { return "manageGroups"; }
+ @Override public String getLanguageKey() { return "hei.gui.collapsible.title"; }
+ @Override public String getComment() { return ""; }
+ @Override public List getChildElements() { return Collections.emptyList(); }
+ @Override public ConfigGuiType getType() { return ConfigGuiType.CONFIG_CATEGORY; }
+ @Override public boolean isList() { return false; }
+ @Override public boolean isListLengthFixed() { return false; }
+ @Override public int getMaxListLength() { return -1; }
+ @Override public boolean isDefault() { return true; }
+ @Override public Object getDefault() { return null; }
+ @Override public Object[] getDefaults() { return null; }
+ @Override public void setToDefault() {}
+ @Override public boolean requiresWorldRestart() { return false; }
+ @Override public boolean showInGui() { return true; }
+ @Override public boolean requiresMcRestart() { return false; }
+ @Override public Object get() { return null; }
+ @Override public Object[] getList() { return null; }
+ @Override public void set(Object value) {}
+ @Override public void set(Object[] aVal) {}
+ @Override public String[] getValidValues() { return null; }
+ @Override public Object getMinValue() { return null; }
+ @Override public Object getMaxValue() { return null; }
+ @Override public Pattern getValidationPattern() { return null; }
+ }
}
diff --git a/src/main/java/mezz/jei/gui/GuiEventHandler.java b/src/main/java/mezz/jei/gui/GuiEventHandler.java
index c4ecafd4..81208cae 100644
--- a/src/main/java/mezz/jei/gui/GuiEventHandler.java
+++ b/src/main/java/mezz/jei/gui/GuiEventHandler.java
@@ -1,7 +1,9 @@
package mezz.jei.gui;
+import mezz.jei.Internal;
import mezz.jei.config.Config;
import mezz.jei.config.OverlayToggleEvent;
+import mezz.jei.ingredients.CollapsedStackRegistry;
import mezz.jei.gui.ghost.GhostIngredientDragManager;
import mezz.jei.gui.overlay.IngredientListOverlay;
import mezz.jei.gui.overlay.bookmarks.LeftAreaDispatcher;
@@ -56,10 +58,18 @@ public void onGuiInit(GuiScreenEvent.InitGuiEvent.Post event) {
@SubscribeEvent
public void onGuiOpen(GuiOpenEvent event) {
+ boolean wasDisplayed = ingredientListOverlay.isListDisplayed();
GuiScreen gui = event.getGui();
ingredientListOverlay.updateScreen(gui, false);
leftAreaDispatcher.updateScreen(gui, false);
ghostIngredientDragManager.updateScreen(gui, false);
+ if (wasDisplayed && !ingredientListOverlay.isListDisplayed() && Config.isCollapseOnClose()
+ && Internal.hasIngredientFilter()) {
+ CollapsedStackRegistry registry = Internal.getCollapsedStackRegistry();
+ registry.getEntries().forEach(e -> e.setExpanded(false));
+ registry.getCustomEntries().forEach(e -> e.setExpanded(false));
+ Internal.getIngredientFilter().notifyCollapsedStateChanged();
+ }
}
@SubscribeEvent
diff --git a/src/main/java/mezz/jei/gui/overlay/ConfigButton.java b/src/main/java/mezz/jei/gui/overlay/ConfigButton.java
index 70aa2f24..d397df2d 100644
--- a/src/main/java/mezz/jei/gui/overlay/ConfigButton.java
+++ b/src/main/java/mezz/jei/gui/overlay/ConfigButton.java
@@ -1,5 +1,6 @@
package mezz.jei.gui.overlay;
+import java.util.Collection;
import java.util.List;
import net.minecraft.client.Minecraft;
@@ -14,6 +15,8 @@
import mezz.jei.config.KeyBindings;
import mezz.jei.gui.GuiHelper;
import mezz.jei.gui.elements.GuiIconToggleButton;
+import mezz.jei.ingredients.CollapsedStack;
+import mezz.jei.ingredients.CollapsedStackRegistry;
import mezz.jei.util.Translator;
import org.lwjgl.input.Keyboard;
@@ -33,6 +36,9 @@ private ConfigButton(IDrawable disabledIcon, IDrawable enabledIcon, IngredientLi
@Override
protected void getTooltips(List tooltip) {
tooltip.add(Translator.translateToLocal("jei.tooltip.config"));
+ if (Config.isOverlayEnabled() && Config.isCollapsibleGroupsEnabled()) {
+ tooltip.add(TextFormatting.GOLD + Translator.translateToLocal("hei.tooltip.config.expandCollapseAll"));
+ }
if (!Config.isOverlayEnabled()) {
tooltip.add(TextFormatting.GOLD + Translator.translateToLocal("jei.tooltip.ingredient.list.disabled"));
tooltip.add(TextFormatting.GOLD + Translator.translateToLocalFormatted("jei.tooltip.ingredient.list.disabled.how.to.fix", KeyBindings.toggleOverlay.getDisplayName()));
@@ -59,7 +65,18 @@ protected boolean isIconToggledOn() {
@Override
protected boolean onMouseClicked(int mouseX, int mouseY) {
if (Config.isOverlayEnabled()) {
- if (Keyboard.getEventKeyState() && (Keyboard.getEventKey() == Keyboard.KEY_LCONTROL || Keyboard.getEventKey() == Keyboard.KEY_RCONTROL)) {
+ if (GuiScreen.isAltKeyDown() && Config.isCollapsibleGroupsEnabled()
+ && Internal.hasIngredientFilter()) {
+ CollapsedStackRegistry registry = Internal.getCollapsedStackRegistry();
+ Collection entries = registry.getEntries();
+ List customEntries = registry.getCustomEntries();
+ boolean allExpanded = entries.stream().allMatch(CollapsedStack::isExpanded)
+ && customEntries.stream().allMatch(CollapsedStack::isExpanded);
+ boolean targetExpanded = !allExpanded;
+ entries.forEach(e -> e.setExpanded(targetExpanded));
+ customEntries.forEach(e -> e.setExpanded(targetExpanded));
+ Internal.getIngredientFilter().notifyCollapsedStateChanged();
+ } else if (Keyboard.getEventKeyState() && (Keyboard.getEventKey() == Keyboard.KEY_LCONTROL || Keyboard.getEventKey() == Keyboard.KEY_RCONTROL)) {
Config.toggleCheatItemsEnabled();
} else {
Minecraft minecraft = Minecraft.getMinecraft();
diff --git a/src/main/java/mezz/jei/gui/overlay/IIngredientGridSource.java b/src/main/java/mezz/jei/gui/overlay/IIngredientGridSource.java
index f99a4b54..a613c045 100644
--- a/src/main/java/mezz/jei/gui/overlay/IIngredientGridSource.java
+++ b/src/main/java/mezz/jei/gui/overlay/IIngredientGridSource.java
@@ -7,6 +7,22 @@
public interface IIngredientGridSource {
List getIngredientList();
+ /**
+ * Returns a list of display items for the grid, which may include
+ * CollapsedStack objects alongside IIngredientListElement objects
+ * when collapsible groups are enabled.
+ */
+ default List getCollapsedIngredientList() {
+ return getIngredientList();
+ }
+
+ /**
+ * Returns the total number of displayed ingredients (counting collapsed groups as 1 each).
+ */
+ default int collapsedSize() {
+ return size();
+ }
+
int size();
void addListener(Listener listener);
diff --git a/src/main/java/mezz/jei/gui/overlay/IngredientGrid.java b/src/main/java/mezz/jei/gui/overlay/IngredientGrid.java
index a8a300e8..537f191b 100644
--- a/src/main/java/mezz/jei/gui/overlay/IngredientGrid.java
+++ b/src/main/java/mezz/jei/gui/overlay/IngredientGrid.java
@@ -6,21 +6,26 @@
import mezz.jei.gui.TooltipRenderer;
import mezz.jei.gui.ingredients.GuiItemStackGroup;
import mezz.jei.gui.ingredients.IIngredientListElement;
+import mezz.jei.ingredients.CollapsedStack;
+import mezz.jei.ingredients.IngredientFilter;
import mezz.jei.input.ClickedIngredient;
import mezz.jei.input.IClickedIngredient;
import mezz.jei.input.IShowsRecipeFocuses;
import mezz.jei.input.MouseHelper;
import mezz.jei.network.packets.PacketDeletePlayerItem;
import mezz.jei.network.packets.PacketJei;
+import mezz.jei.render.CollapsedStackRenderer;
import mezz.jei.render.IngredientListBatchRenderer;
import mezz.jei.render.IngredientListSlot;
import mezz.jei.render.IngredientRenderer;
import mezz.jei.runtime.JeiRuntime;
import mezz.jei.util.GiveMode;
+import mezz.jei.util.CollapsedClickAction;
import mezz.jei.util.MathUtil;
import mezz.jei.util.Translator;
import net.minecraft.client.Minecraft;
import net.minecraft.client.entity.EntityPlayerSP;
+import net.minecraft.client.gui.GuiScreen;
import net.minecraft.client.renderer.GlStateManager;
import net.minecraft.entity.player.EntityPlayer;
import net.minecraft.item.ItemStack;
@@ -108,11 +113,17 @@ public void draw(Minecraft minecraft, int mouseX, int mouseY) {
GlStateManager.disableBlend();
guiIngredientSlots.render(minecraft);
+ guiIngredientSlots.renderExpandedGroupOutlines();
if (!shouldDeleteItemOnClick(minecraft, mouseX, mouseY) && isMouseOver(mouseX, mouseY)) {
- IngredientRenderer> hovered = guiIngredientSlots.getHovered(mouseX, mouseY);
- if (hovered != null) {
- hovered.drawHighlight();
+ CollapsedStackRenderer collapsedHovered = guiIngredientSlots.getHoveredCollapsed(mouseX, mouseY);
+ if (collapsedHovered != null) {
+ collapsedHovered.drawHighlight();
+ } else {
+ IngredientRenderer> hovered = guiIngredientSlots.getHovered(mouseX, mouseY);
+ if (hovered != null) {
+ hovered.drawHighlight();
+ }
}
}
@@ -125,9 +136,21 @@ public void drawTooltips(Minecraft minecraft, int mouseX, int mouseY) {
String deleteItem = Translator.translateToLocal("jei.tooltip.delete.item");
TooltipRenderer.drawHoveringText(minecraft, deleteItem, mouseX, mouseY);
} else {
- IngredientRenderer> hovered = guiIngredientSlots.getHovered(mouseX, mouseY);
- if (hovered != null) {
- hovered.drawTooltip(minecraft, mouseX, mouseY);
+ CollapsedStackRenderer collapsedHovered = guiIngredientSlots.getHoveredCollapsed(mouseX, mouseY);
+ if (collapsedHovered != null) {
+ collapsedHovered.drawTooltip(minecraft, mouseX, mouseY);
+ } else {
+ IngredientRenderer> hovered = guiIngredientSlots.getHovered(mouseX, mouseY);
+ if (hovered != null) {
+ CollapsedStack expandedGroup = guiIngredientSlots.getExpandedCollapsedGroupAt(mouseX, mouseY);
+ if (expandedGroup != null) {
+ String hint = net.minecraft.util.text.TextFormatting.YELLOW
+ + mezz.jei.util.Translator.translateToLocal("hei.tooltip.collapsed.collapse");
+ hovered.drawTooltip(minecraft, mouseX, mouseY, java.util.Collections.singletonList(hint));
+ } else {
+ hovered.drawTooltip(minecraft, mouseX, mouseY);
+ }
+ }
}
}
}
@@ -168,6 +191,30 @@ public boolean isMouseOver(int mouseX, int mouseY) {
public boolean handleMouseClicked(int mouseX, int mouseY) {
if (isMouseOver(mouseX, mouseY)) {
+ boolean firstItemMode = Config.getCollapsedClickAction() == CollapsedClickAction.FIRST_ITEM;
+ boolean altDown = GuiScreen.isAltKeyDown();
+ // OPEN_GROUP: plain click expands a collapsed icon; alt+click falls through (first item).
+ // FIRST_ITEM: alt+click expands a collapsed icon; plain click falls through (first item).
+ boolean expandKeyDown = firstItemMode ? altDown : !altDown;
+ if (expandKeyDown) {
+ CollapsedStackRenderer collapsedHovered = guiIngredientSlots.getHoveredCollapsed(mouseX, mouseY);
+ // A group with only 1 visible item should act as a plain ingredient click,
+ // not expand/collapse — the single item is already trivially "shown".
+ if (collapsedHovered != null && collapsedHovered.getCollapsedStack().size() > 1) {
+ collapsedHovered.getCollapsedStack().toggleExpanded();
+ Internal.getIngredientFilter().notifyCollapsedStateChanged();
+ return true;
+ }
+ }
+ // Alt+Click on any item inside an expanded group always collapses it.
+ if (altDown) {
+ CollapsedStack expandedHovered = guiIngredientSlots.getExpandedCollapsedGroupAt(mouseX, mouseY);
+ if (expandedHovered != null) {
+ expandedHovered.toggleExpanded();
+ Internal.getIngredientFilter().notifyCollapsedStateChanged();
+ return true;
+ }
+ }
Minecraft minecraft = Minecraft.getMinecraft();
if (shouldDeleteItemOnClick(minecraft, mouseX, mouseY)) {
EntityPlayerSP player = minecraft.player;
diff --git a/src/main/java/mezz/jei/gui/overlay/IngredientGridWithNavigation.java b/src/main/java/mezz/jei/gui/overlay/IngredientGridWithNavigation.java
index 87ff9b26..5c8830b7 100644
--- a/src/main/java/mezz/jei/gui/overlay/IngredientGridWithNavigation.java
+++ b/src/main/java/mezz/jei/gui/overlay/IngredientGridWithNavigation.java
@@ -51,11 +51,11 @@ public void updateLayout(boolean resetToFirstPage) {
if (resetToFirstPage) {
firstItemIndex = 0;
}
- List ingredientList = ingredientSource.getIngredientList();
- if (firstItemIndex >= ingredientList.size()) {
+ List collapsedList = ingredientSource.getCollapsedIngredientList();
+ if (firstItemIndex >= ingredientSource.collapsedSize()) {
firstItemIndex = 0;
}
- this.ingredientGrid.guiIngredientSlots.set(firstItemIndex, ingredientList);
+ this.ingredientGrid.guiIngredientSlots.setCollapsed(firstItemIndex, collapsedList);
this.navigation.updatePageState();
}
@@ -200,7 +200,7 @@ public List getVisibleElements() {
private class IngredientGridPaged implements IPaged {
@Override
public boolean nextPage() {
- final int itemsCount = ingredientSource.size();
+ final int itemsCount = ingredientSource.collapsedSize();
if (itemsCount > 0) {
firstItemIndex += ingredientGrid.size();
if (firstItemIndex >= itemsCount) {
@@ -223,7 +223,7 @@ public boolean previousPage() {
updateLayout(false);
return false;
}
- final int itemsCount = ingredientSource.size();
+ final int itemsCount = ingredientSource.collapsedSize();
int pageNum = firstItemIndex / itemsPerPage;
if (pageNum == 0) {
@@ -245,19 +245,19 @@ public boolean previousPage() {
public boolean hasNext() {
// true if there is more than one page because this wraps around
int itemsPerPage = ingredientGrid.size();
- return itemsPerPage > 0 && ingredientSource.size() > itemsPerPage;
+ return itemsPerPage > 0 && ingredientSource.collapsedSize() > itemsPerPage;
}
@Override
public boolean hasPrevious() {
// true if there is more than one page because this wraps around
int itemsPerPage = ingredientGrid.size();
- return itemsPerPage > 0 && ingredientSource.size() > itemsPerPage;
+ return itemsPerPage > 0 && ingredientSource.collapsedSize() > itemsPerPage;
}
@Override
public int getPageCount() {
- final int itemCount = ingredientSource.size();
+ final int itemCount = ingredientSource.collapsedSize();
final int stacksPerPage = ingredientGrid.size();
if (stacksPerPage == 0) {
return 1;
diff --git a/src/main/java/mezz/jei/gui/overlay/IngredientListOverlay.java b/src/main/java/mezz/jei/gui/overlay/IngredientListOverlay.java
index 93137d75..2621764a 100644
--- a/src/main/java/mezz/jei/gui/overlay/IngredientListOverlay.java
+++ b/src/main/java/mezz/jei/gui/overlay/IngredientListOverlay.java
@@ -59,6 +59,7 @@ public IngredientListOverlay(IngredientFilter ingredientFilter, IngredientRegist
this.contents = new IngredientGridWithNavigation(ingredientFilter, guiScreenHelper, GridAlignment.LEFT);
ingredientFilter.addListener(() -> onSetFilterText(Config.getFilterText()));
+ ingredientFilter.addCollapsedStateListener(() -> this.contents.updateLayout(false));
this.searchField = new GuiTextFieldFilter(0, ingredientFilter);
this.configButton = ConfigButton.create(this);
this.ghostIngredientDragManager = dragManager;
diff --git a/src/main/java/mezz/jei/gui/overlay/collapsible/GuiCollapsibleGroups.java b/src/main/java/mezz/jei/gui/overlay/collapsible/GuiCollapsibleGroups.java
new file mode 100644
index 00000000..63391206
--- /dev/null
+++ b/src/main/java/mezz/jei/gui/overlay/collapsible/GuiCollapsibleGroups.java
@@ -0,0 +1,667 @@
+package mezz.jei.gui.overlay.collapsible;
+
+import mezz.jei.Internal;
+import mezz.jei.api.ingredients.IIngredientRenderer;
+import mezz.jei.config.Config;
+import mezz.jei.config.CustomGroupsConfig;
+import mezz.jei.gui.ingredients.IIngredientListElement;
+import mezz.jei.ingredients.CollapsedStack;
+import mezz.jei.ingredients.CollapsedStack.GroupSource;
+import mezz.jei.ingredients.CollapsedStackRegistry;
+import mezz.jei.ingredients.IngredientFilter;
+import mezz.jei.util.Translator;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiButton;
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.renderer.GlStateManager;
+import net.minecraft.client.renderer.RenderHelper;
+import net.minecraft.client.util.ITooltipFlag;
+import net.minecraft.item.ItemStack;
+import net.minecraftforge.fml.client.config.GuiUtils;
+import org.lwjgl.input.Mouse;
+
+import javax.annotation.Nullable;
+import java.io.IOException;
+import java.util.*;
+
+/**
+ * Management screen for collapsible groups.
+ * Shows all default and custom groups with toggle/configure/delete controls.
+ */
+public class GuiCollapsibleGroups extends GuiScreen {
+
+ private static final int CARD_HEIGHT = 84;
+ private static final int CARD_PADDING = 4;
+ private static final int PREVIEW_SIZE = 16;
+ private static final int PREVIEW_COLS = 8;
+ private static final int PREVIEW_ROWS = 3;
+ // Maximum items fetched per card for the scrollable preview (20 scrollable rows)
+ private static final int PREVIEW_FETCH_MAX = PREVIEW_COLS * 20;
+
+ // Dynamic layout — recomputed on each initGui() call so the screen adapts to GUI scale
+ private int cardsPerCol;
+ private int cardsPerPage;
+ private int layoutContentTop;
+ private int layoutContentWidth;
+ private int layoutContentLeft;
+ private int layoutColGap;
+ private int layoutColWidth;
+
+ private static final int BTN_BACK = 0;
+ private static final int BTN_NEW = 1;
+ private static final int BTN_PREV_PAGE = 2;
+ private static final int BTN_NEXT_PAGE = 3;
+ private static final int BTN_TOGGLE_BASE = 100;
+ private static final int BTN_CONFIGURE_BASE = 200;
+ private static final int BTN_DELETE_BASE = 300;
+ private static final int BTN_DELETE_CONFIRM_BASE = 400;
+ private static final int BTN_DELETE_CANCEL_BASE = 500;
+
+ private final GuiScreen parentScreen;
+ private final List cardEntries = new ArrayList<>();
+ private int currentPage = 0;
+ private int totalPages = 1;
+ /** Index into {@code cardEntries} of the custom group awaiting delete confirmation, or -1. */
+ private int pendingDeleteIdx = -1;
+ @Nullable private IIngredientListElement> tooltipElement = null;
+
+ // Drag-to-scroll state for card preview boxes
+ private int dragCardAbsIdx = -1;
+ private int dragStartMouseY;
+ private int dragStartRow;
+
+ public GuiCollapsibleGroups(GuiScreen parentScreen) {
+ this.parentScreen = parentScreen;
+ }
+
+ private void computeLayout() {
+ layoutContentTop = 30;
+ layoutColGap = 4;
+ // Use up to 90% of screen width, capped at 700 so wide monitors still look reasonable
+ layoutContentWidth = Math.min(this.width - 20, Math.max(300, (int) (this.width * 0.9)));
+ layoutContentLeft = (this.width - layoutContentWidth) / 2;
+ layoutColWidth = (layoutContentWidth - layoutColGap) / 2;
+ // Reserve: header (30) + header buttons (24) + nav row (28) + bottom margin (6)
+ int availableForCards = this.height - layoutContentTop - 28 - 6;
+ cardsPerCol = Math.max(1, availableForCards / (CARD_HEIGHT + CARD_PADDING));
+ cardsPerPage = 2 * cardsPerCol;
+ }
+
+ @Override
+ public void initGui() {
+ super.initGui();
+ this.buttonList.clear();
+ computeLayout();
+
+ // Back button
+ this.buttonList.add(new GuiButton(BTN_BACK, layoutContentLeft, 4, 60, 20,
+ Translator.translateToLocal("hei.gui.collapsible.back")));
+
+ // New Group button
+ this.buttonList.add(new GuiButton(BTN_NEW, layoutContentLeft + layoutContentWidth - 62, 4, 60, 20,
+ Translator.translateToLocal("hei.gui.collapsible.newGroup")));
+
+ rebuildCards();
+ rebuildPageButtons();
+ }
+
+ private void rebuildCards() {
+ cardEntries.clear();
+
+ CollapsedStackRegistry registry = Internal.getCollapsedStackRegistry();
+
+ // Custom groups come first (like REI)
+ addCardsMergedById(registry.getCustomEntries(), GroupSource.CUSTOM, registry.getDisabledGroups());
+
+ // Mod-registered groups (same ID can be registered multiple times for different ingredient types)
+ addCardsMergedById(registry.getModEntries(), GroupSource.MOD, registry.getDisabledGroups());
+
+ // Default groups
+ addCardsMergedById(registry.getEntries(), GroupSource.DEFAULT, registry.getDisabledGroups());
+
+ totalPages = Math.max(1, (cardEntries.size() + cardsPerPage - 1) / cardsPerPage);
+ if (currentPage >= totalPages) {
+ currentPage = totalPages - 1;
+ }
+ }
+
+ private void addCardsMergedById(Collection entries, GroupSource source, Set disabledGroups) {
+ Map> groupedById = new LinkedHashMap<>();
+ Map displayNamesById = new HashMap<>();
+
+ for (CollapsedStack entry : entries) {
+ groupedById.computeIfAbsent(entry.getId(), k -> new ArrayList<>()).add(entry);
+ displayNamesById.putIfAbsent(entry.getId(), entry.getDisplayName());
+ }
+
+ for (Map.Entry> groupedEntry : groupedById.entrySet()) {
+ String id = groupedEntry.getKey();
+ List groupedStacks = groupedEntry.getValue();
+ List> previewItems = getPreviewItems(groupedStacks);
+ int itemCount = getMatchedItemCount(groupedStacks);
+ cardEntries.add(new GroupCardEntry(id, displayNamesById.get(id), source,
+ !disabledGroups.contains(id), previewItems, itemCount));
+ }
+ }
+
+ private void rebuildPageButtons() {
+ // Remove old card-specific and page buttons
+ buttonList.removeIf(b -> b.id >= BTN_PREV_PAGE);
+
+ int startIdx = currentPage * cardsPerPage;
+ int endIdx = Math.min(startIdx + cardsPerPage, cardEntries.size());
+
+ for (int i = startIdx; i < endIdx; i++) {
+ int localIdx = i - startIdx;
+ int col = localIdx / cardsPerCol; // 0 = left, 1 = right
+ int row = localIdx % cardsPerCol;
+ int cardX = layoutContentLeft + col * (layoutColWidth + layoutColGap);
+ int cardY = layoutContentTop + row * (CARD_HEIGHT + CARD_PADDING);
+ GroupCardEntry card = cardEntries.get(i);
+
+ int btnX = cardX + layoutColWidth - 56;
+ int btnY = cardY + 4;
+
+ // Toggle button
+ String toggleLabel = card.enabled
+ ? Translator.translateToLocal("hei.gui.collapsible.enabled")
+ : Translator.translateToLocal("hei.gui.collapsible.disabled");
+ this.buttonList.add(new GuiButton(BTN_TOGGLE_BASE + i, btnX, btnY, 52, 20, toggleLabel));
+
+ if (card.source == GroupSource.CUSTOM) {
+ if (i == pendingDeleteIdx) {
+ // Confirm row: ✔ Yes / ✗ No
+ this.buttonList.add(new GuiButton(BTN_DELETE_CONFIRM_BASE + i, btnX, btnY + 22, 24, 20,
+ "\u2714")); // ✔ checkmark
+ this.buttonList.add(new GuiButton(BTN_DELETE_CANCEL_BASE + i, btnX + 26, btnY + 22, 26, 20,
+ "\u2716")); // ✗ cancel
+ } else {
+ // Configure button
+ this.buttonList.add(new GuiButton(BTN_CONFIGURE_BASE + i, btnX, btnY + 22, 24, 20,
+ "\u270E")); // pencil unicode
+ // Delete button
+ this.buttonList.add(new GuiButton(BTN_DELETE_BASE + i, btnX + 26, btnY + 22, 26, 20,
+ "\u2716")); // cross unicode
+ }
+ }
+ }
+
+ // Page navigation
+ if (totalPages > 1) {
+ int navY = layoutContentTop + cardsPerCol * (CARD_HEIGHT + CARD_PADDING) + 4;
+ this.buttonList.add(new GuiButton(BTN_PREV_PAGE, layoutContentLeft, navY, 40, 20, "<"));
+ this.buttonList.add(new GuiButton(BTN_NEXT_PAGE, layoutContentLeft + layoutContentWidth - 40, navY, 40, 20, ">"));
+ }
+ }
+
+ @Override
+ protected void actionPerformed(GuiButton button) throws IOException {
+ if (button.id == BTN_BACK) {
+ this.mc.displayGuiScreen(parentScreen);
+ return;
+ }
+ if (button.id == BTN_NEW) {
+ String newId = "custom:" + UUID.randomUUID().toString().substring(0, 8);
+ CustomGroupsConfig customGroupsConfig = Config.getCustomGroupsConfig();
+ if (customGroupsConfig != null) {
+ CustomGroupsConfig.CustomGroup newGroup = new CustomGroupsConfig.CustomGroup(newId, "New Group", new ArrayList<>());
+ this.mc.displayGuiScreen(new GuiCustomGroupEditor(this, newGroup));
+ }
+ return;
+ }
+ if (button.id == BTN_PREV_PAGE) {
+ currentPage = Math.max(0, currentPage - 1);
+ pendingDeleteIdx = -1;
+ rebuildPageButtons();
+ return;
+ }
+ if (button.id == BTN_NEXT_PAGE) {
+ currentPage = Math.min(totalPages - 1, currentPage + 1);
+ pendingDeleteIdx = -1;
+ rebuildPageButtons();
+ return;
+ }
+
+ // Toggle
+ if (button.id >= BTN_TOGGLE_BASE && button.id < BTN_CONFIGURE_BASE) {
+ int idx = button.id - BTN_TOGGLE_BASE;
+ if (idx >= 0 && idx < cardEntries.size()) {
+ GroupCardEntry card = cardEntries.get(idx);
+ card.enabled = !card.enabled;
+
+ CollapsedStackRegistry registry = Internal.getCollapsedStackRegistry();
+ Set disabled = new HashSet<>(registry.getDisabledGroups());
+ if (card.enabled) {
+ disabled.remove(card.id);
+ } else {
+ disabled.add(card.id);
+ }
+ registry.setDisabledGroups(disabled);
+ Config.saveDisabledGroups(disabled);
+
+ if (Internal.hasIngredientFilter()) {
+ IngredientFilter filter = Internal.getIngredientFilter();
+ filter.invalidateCache();
+ filter.getIngredientList(Config.getFilterText());
+ filter.notifyListenersOfChange();
+ }
+
+ rebuildPageButtons();
+ }
+ return;
+ }
+
+ // Configure
+ if (button.id >= BTN_CONFIGURE_BASE && button.id < BTN_DELETE_BASE) {
+ int idx = button.id - BTN_CONFIGURE_BASE;
+ if (idx >= 0 && idx < cardEntries.size()) {
+ GroupCardEntry card = cardEntries.get(idx);
+ if (card.source == GroupSource.CUSTOM) {
+ CustomGroupsConfig customGroupsConfig = Config.getCustomGroupsConfig();
+ if (customGroupsConfig != null) {
+ CustomGroupsConfig.CustomGroup group = customGroupsConfig.getGroup(card.id);
+ if (group != null) {
+ this.mc.displayGuiScreen(new GuiCustomGroupEditor(this, group));
+ return;
+ }
+ }
+ }
+ }
+ return;
+ }
+
+ // Delete — first click: arm confirmation
+ if (button.id >= BTN_DELETE_BASE && button.id < BTN_DELETE_CONFIRM_BASE) {
+ int idx = button.id - BTN_DELETE_BASE;
+ if (idx >= 0 && idx < cardEntries.size() && cardEntries.get(idx).source == GroupSource.CUSTOM) {
+ pendingDeleteIdx = idx;
+ rebuildPageButtons();
+ }
+ return;
+ }
+
+ // Delete confirmed — execute the actual removal
+ if (button.id >= BTN_DELETE_CONFIRM_BASE && button.id < BTN_DELETE_CANCEL_BASE) {
+ int idx = button.id - BTN_DELETE_CONFIRM_BASE;
+ pendingDeleteIdx = -1;
+ if (idx >= 0 && idx < cardEntries.size()) {
+ GroupCardEntry card = cardEntries.get(idx);
+ if (card.source == GroupSource.CUSTOM) {
+ CustomGroupsConfig customGroupsConfig = Config.getCustomGroupsConfig();
+ if (customGroupsConfig != null) {
+ customGroupsConfig.removeGroup(card.id);
+ Internal.getCollapsedStackRegistry().recollectCustomEntries();
+
+ if (Internal.hasIngredientFilter()) {
+ IngredientFilter filter = Internal.getIngredientFilter();
+ filter.invalidateCache();
+ filter.getIngredientList(Config.getFilterText());
+ filter.notifyListenersOfChange();
+ }
+
+ rebuildCards();
+ rebuildPageButtons();
+ }
+ }
+ }
+ return;
+ }
+
+ // Delete cancelled
+ if (button.id >= BTN_DELETE_CANCEL_BASE) {
+ pendingDeleteIdx = -1;
+ rebuildPageButtons();
+ }
+ }
+
+ @Override
+ public void drawScreen(int mouseX, int mouseY, float partialTicks) {
+ this.drawDefaultBackground();
+
+ // Title
+ String title = Translator.translateToLocal("hei.gui.collapsible.title");
+ this.drawCenteredString(this.fontRenderer, title, this.width / 2, 10, 0xFFFFFF);
+
+ int startIdx = currentPage * cardsPerPage;
+ int endIdx = Math.min(startIdx + cardsPerPage, cardEntries.size());
+
+ tooltipElement = null;
+ for (int i = startIdx; i < endIdx; i++) {
+ int localIdx = i - startIdx;
+ int col = localIdx / cardsPerCol;
+ int row = localIdx % cardsPerCol;
+ int cardX = layoutContentLeft + col * (layoutColWidth + layoutColGap);
+ int cardY = layoutContentTop + row * (CARD_HEIGHT + CARD_PADDING);
+ GroupCardEntry card = cardEntries.get(i);
+ drawCard(card, cardX, cardY, layoutColWidth, mouseX, mouseY);
+
+ // Draw "Delete?" label to the left of the ✔/✗ confirm buttons
+ if (i == pendingDeleteIdx && card.source == GroupSource.CUSTOM) {
+ int btnX = cardX + layoutColWidth - 56;
+ int btnY = cardY + 4;
+ String confirmLabel = Translator.translateToLocal("hei.gui.collapsible.confirmDelete");
+ int labelX = btnX - this.fontRenderer.getStringWidth(confirmLabel) - 3;
+ this.fontRenderer.drawStringWithShadow(confirmLabel, labelX, btnY + 27, 0xFFFF4444);
+ }
+ }
+
+ // Page counter
+ if (totalPages > 1) {
+ int navY = layoutContentTop + cardsPerCol * (CARD_HEIGHT + CARD_PADDING) + 4;
+ String pageText = (currentPage + 1) + "/" + totalPages;
+ this.drawCenteredString(this.fontRenderer, pageText, this.width / 2, navY + 6, 0xAAAAAA);
+ }
+
+ // Empty state
+ if (cardEntries.isEmpty()) {
+ this.drawCenteredString(this.fontRenderer, "No collapsible groups defined", this.width / 2, layoutContentTop + 20, 0x888888);
+ }
+
+ super.drawScreen(mouseX, mouseY, partialTicks);
+
+ if (tooltipElement != null) {
+ renderIngredientTooltip(tooltipElement, mouseX, mouseY);
+ }
+ }
+
+ private void drawCard(GroupCardEntry card, int x, int y, int width, int mouseX, int mouseY) {
+ // Card background
+ int bgColor;
+ switch (card.source) {
+ case CUSTOM: bgColor = 0x40336699; break;
+ case MOD: bgColor = 0x40553366; break;
+ default: bgColor = 0x40444444; break;
+ }
+ drawRect(x, y, x + width, y + CARD_HEIGHT, bgColor);
+
+ // Border
+ int borderColor = card.enabled ? 0xFF558855 : 0xFF885555;
+ drawHorizontalLine(x, x + width - 1, y, borderColor);
+ drawHorizontalLine(x, x + width - 1, y + CARD_HEIGHT - 1, borderColor);
+ drawVerticalLine(x, y, y + CARD_HEIGHT - 1, borderColor);
+ drawVerticalLine(x + width - 1, y, y + CARD_HEIGHT - 1, borderColor);
+
+ // Group name
+ String sourceLabel;
+ String sourceColor;
+ switch (card.source) {
+ case CUSTOM:
+ sourceColor = "\u00A7e";
+ sourceLabel = Translator.translateToLocal("hei.gui.collapsible.customGroup");
+ break;
+ case MOD:
+ sourceColor = "\u00A7d";
+ sourceLabel = Translator.translateToLocal("hei.gui.collapsible.modGroup");
+ break;
+ default:
+ sourceColor = "\u00A77";
+ sourceLabel = Translator.translateToLocal("hei.gui.collapsible.defaultGroup");
+ break;
+ }
+ String namePrefix = sourceColor + "[" + sourceLabel + "] \u00A7r";
+ this.fontRenderer.drawStringWithShadow(namePrefix + card.displayName, x + 4, y + 4, 0xFFFFFF);
+
+ // Item count
+ String countText = String.format(Translator.translateToLocal("hei.gui.collapsible.itemCount"), card.itemCount);
+ this.fontRenderer.drawStringWithShadow(countText, x + 4, y + 16, 0xAAAAAA);
+
+ // Scrollable preview grid
+ int slotSize = PREVIEW_SIZE + 2;
+ int previewX = x + 4;
+ int previewY = y + 28;
+ int totalRows = (card.previewItems.size() + PREVIEW_COLS - 1) / PREVIEW_COLS;
+ int maxScrollRow = Math.max(0, totalRows - PREVIEW_ROWS);
+ card.previewScrollRow = Math.max(0, Math.min(card.previewScrollRow, maxScrollRow));
+ int scrollRow = card.previewScrollRow;
+
+ int firstItem = scrollRow * PREVIEW_COLS;
+ int lastItem = Math.min(card.previewItems.size(), (scrollRow + PREVIEW_ROWS) * PREVIEW_COLS);
+
+ RenderHelper.enableGUIStandardItemLighting();
+ GlStateManager.enableDepth();
+ for (int i = firstItem; i < lastItem; i++) {
+ IIngredientListElement> element = card.previewItems.get(i);
+ int visibleRow = (i / PREVIEW_COLS) - scrollRow;
+ int col = i % PREVIEW_COLS;
+ int itemX = previewX + col * slotSize;
+ int itemY = previewY + visibleRow * slotSize;
+ renderIngredient(element, itemX + 1, itemY + 1);
+ if (mouseX >= itemX && mouseX < itemX + PREVIEW_SIZE
+ && mouseY >= itemY && mouseY < itemY + PREVIEW_SIZE) {
+ tooltipElement = element;
+ }
+ }
+ RenderHelper.disableStandardItemLighting();
+ GlStateManager.disableDepth();
+
+ // Scrollbar indicator
+ if (maxScrollRow > 0) {
+ int sbX = previewX + PREVIEW_COLS * slotSize + 2;
+ int sbY = previewY;
+ int sbH = PREVIEW_ROWS * slotSize;
+ drawRect(sbX, sbY, sbX + 3, sbY + sbH, 0x55FFFFFF);
+ int thumbH = Math.max(4, sbH * PREVIEW_ROWS / totalRows);
+ int thumbY = sbY + (sbH - thumbH) * scrollRow / maxScrollRow;
+ drawRect(sbX, thumbY, sbX + 3, thumbY + thumbH, 0xCCFFFFFF);
+ }
+ }
+
+ @Override
+ public void handleMouseInput() throws IOException {
+ super.handleMouseInput();
+ int scrollDelta = Mouse.getEventDWheel();
+ if (scrollDelta != 0) {
+ int mouseX = Mouse.getEventX() * this.width / this.mc.displayWidth;
+ int mouseY = this.height - Mouse.getEventY() * this.height / this.mc.displayHeight - 1;
+
+ // Check if the cursor is inside any visible card's preview box
+ boolean handledByCard = false;
+ int slotSize = PREVIEW_SIZE + 2;
+ int startIdx = currentPage * cardsPerPage;
+ int endIdx = Math.min(startIdx + cardsPerPage, cardEntries.size());
+ for (int i = startIdx; i < endIdx; i++) {
+ int localIdx = i - startIdx;
+ int col = localIdx / cardsPerCol;
+ int row = localIdx % cardsPerCol;
+ int cardX = layoutContentLeft + col * (layoutColWidth + layoutColGap);
+ int cardY = layoutContentTop + row * (CARD_HEIGHT + CARD_PADDING);
+ int previewX = cardX + 4;
+ int previewY = cardY + 28;
+ int previewW = PREVIEW_COLS * slotSize;
+ int previewH = PREVIEW_ROWS * slotSize;
+ if (mouseX >= previewX && mouseX < previewX + previewW
+ && mouseY >= previewY && mouseY < previewY + previewH) {
+ GroupCardEntry card = cardEntries.get(i);
+ int totalRows = (card.previewItems.size() + PREVIEW_COLS - 1) / PREVIEW_COLS;
+ int maxScrollRow = Math.max(0, totalRows - PREVIEW_ROWS);
+ if (maxScrollRow > 0) {
+ if (scrollDelta < 0) {
+ card.previewScrollRow = Math.min(card.previewScrollRow + 1, maxScrollRow);
+ } else {
+ card.previewScrollRow = Math.max(card.previewScrollRow - 1, 0);
+ }
+ handledByCard = true;
+ }
+ break;
+ }
+ }
+
+ if (!handledByCard) {
+ if (scrollDelta < 0 && currentPage < totalPages - 1) {
+ currentPage++;
+ rebuildPageButtons();
+ } else if (scrollDelta > 0 && currentPage > 0) {
+ currentPage--;
+ rebuildPageButtons();
+ }
+ }
+ }
+ }
+
+ @Override
+ protected void mouseClicked(int mouseX, int mouseY, int mouseButton) throws IOException {
+ super.mouseClicked(mouseX, mouseY, mouseButton);
+ if (mouseButton == 0) {
+ dragCardAbsIdx = -1;
+ int slotSize = PREVIEW_SIZE + 2;
+ int startIdx = currentPage * cardsPerPage;
+ int endIdx = Math.min(startIdx + cardsPerPage, cardEntries.size());
+ for (int i = startIdx; i < endIdx; i++) {
+ int localIdx = i - startIdx;
+ int col = localIdx / cardsPerCol;
+ int row = localIdx % cardsPerCol;
+ int cardX = layoutContentLeft + col * (layoutColWidth + layoutColGap);
+ int cardY = layoutContentTop + row * (CARD_HEIGHT + CARD_PADDING);
+ int previewX = cardX + 4;
+ int previewY = cardY + 28;
+ int previewW = PREVIEW_COLS * slotSize;
+ int previewH = PREVIEW_ROWS * slotSize;
+ if (mouseX >= previewX && mouseX < previewX + previewW
+ && mouseY >= previewY && mouseY < previewY + previewH) {
+ dragCardAbsIdx = i;
+ dragStartMouseY = mouseY;
+ dragStartRow = cardEntries.get(i).previewScrollRow;
+ break;
+ }
+ }
+ }
+ }
+
+ @Override
+ protected void mouseClickMove(int mouseX, int mouseY, int clickedMouseButton, long timeSinceLastClick) {
+ super.mouseClickMove(mouseX, mouseY, clickedMouseButton, timeSinceLastClick);
+ if (clickedMouseButton == 0 && dragCardAbsIdx >= 0 && dragCardAbsIdx < cardEntries.size()) {
+ int slotSize = PREVIEW_SIZE + 2;
+ GroupCardEntry card = cardEntries.get(dragCardAbsIdx);
+ int totalRows = (card.previewItems.size() + PREVIEW_COLS - 1) / PREVIEW_COLS;
+ int maxScrollRow = Math.max(0, totalRows - PREVIEW_ROWS);
+ // Dragging up (negative deltaY) scrolls down through items
+ int rowDelta = (dragStartMouseY - mouseY) / slotSize;
+ card.previewScrollRow = Math.max(0, Math.min(dragStartRow + rowDelta, maxScrollRow));
+ }
+ }
+
+ @Override
+ protected void mouseReleased(int mouseX, int mouseY, int state) {
+ super.mouseReleased(mouseX, mouseY, state);
+ if (state == 0) {
+ dragCardAbsIdx = -1;
+ }
+ }
+
+ @Override
+ protected void keyTyped(char typedChar, int keyCode) throws IOException {
+ if (keyCode == 1) { // ESC
+ this.mc.displayGuiScreen(parentScreen);
+ return;
+ }
+ super.keyTyped(typedChar, keyCode);
+ }
+
+ /**
+ * Get up to PREVIEW_FETCH_MAX preview elements for a collapsible entry,
+ * returning the raw IIngredientListElement so each type renders via its own renderer.
+ */
+ private List> getPreviewItems(List entries) {
+ List> items = new ArrayList<>();
+ if (!Internal.hasIngredientFilter()) {
+ return items;
+ }
+ IngredientFilter filter = Internal.getIngredientFilter();
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ List> ingredientList = (List>) (List) filter.getIngredientList("");
+ for (IIngredientListElement> element : ingredientList) {
+ if (matchesAny(entries, element)) {
+ items.add(element);
+ if (items.size() >= PREVIEW_FETCH_MAX) {
+ break;
+ }
+ }
+ }
+ return items;
+ }
+
+ @SuppressWarnings("unchecked")
+ private void renderIngredient(IIngredientListElement element, int x, int y) {
+ try {
+ IIngredientRenderer renderer = element.getIngredientRenderer();
+ renderer.render(this.mc, x, y, element.getIngredient());
+ } catch (Exception ignored) {
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void renderIngredientTooltip(IIngredientListElement element, int mouseX, int mouseY) {
+ try {
+ T ingredient = element.getIngredient();
+ if (ingredient instanceof ItemStack) {
+ renderToolTip((ItemStack) ingredient, mouseX, mouseY);
+ return;
+ }
+ IIngredientRenderer renderer = element.getIngredientRenderer();
+ List tooltip = renderer.getTooltip(this.mc, ingredient,
+ this.mc.gameSettings.advancedItemTooltips
+ ? ITooltipFlag.TooltipFlags.ADVANCED
+ : ITooltipFlag.TooltipFlags.NORMAL);
+ if (!tooltip.isEmpty()) {
+ drawHoveringText(tooltip, mouseX, mouseY, renderer.getFontRenderer(this.mc, ingredient));
+ }
+ } catch (Exception ignored) {
+ }
+ }
+
+ /**
+ * Count matched items for display.
+ */
+ private int getMatchedItemCount(List entries) {
+ if (!Internal.hasIngredientFilter()) {
+ return 0;
+ }
+ IngredientFilter filter = Internal.getIngredientFilter();
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ List> ingredientList = (List>) (List) filter.getIngredientList("");
+ int count = 0;
+ for (IIngredientListElement> element : ingredientList) {
+ if (matchesAny(entries, element)) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private static boolean matchesAny(List entries, IIngredientListElement> element) {
+ for (CollapsedStack entry : entries) {
+ if (entry.matches(element)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Called when returning from the editor screen to refresh the card list.
+ */
+ public void onEditorClosed() {
+ rebuildCards();
+ rebuildPageButtons();
+ }
+
+ private static class GroupCardEntry {
+ final String id;
+ final String displayName;
+ final GroupSource source;
+ boolean enabled;
+ final List> previewItems;
+ final int itemCount;
+ int previewScrollRow = 0;
+
+ GroupCardEntry(String id, String displayName, GroupSource source, boolean enabled, List> previewItems, int itemCount) {
+ this.id = id;
+ this.displayName = displayName;
+ this.source = source;
+ this.enabled = enabled;
+ this.previewItems = previewItems;
+ this.itemCount = itemCount;
+ }
+ }
+}
diff --git a/src/main/java/mezz/jei/gui/overlay/collapsible/GuiCustomGroupEditor.java b/src/main/java/mezz/jei/gui/overlay/collapsible/GuiCustomGroupEditor.java
new file mode 100644
index 00000000..4e5d4dc4
--- /dev/null
+++ b/src/main/java/mezz/jei/gui/overlay/collapsible/GuiCustomGroupEditor.java
@@ -0,0 +1,947 @@
+package mezz.jei.gui.overlay.collapsible;
+
+import mezz.jei.Internal;
+import mezz.jei.api.ingredients.IIngredientRenderer;
+import mezz.jei.config.Config;
+import mezz.jei.config.CustomGroupsConfig;
+import mezz.jei.gui.ingredients.IIngredientListElement;
+import mezz.jei.ingredients.IngredientFilter;
+import mezz.jei.startup.StackHelper;
+import mezz.jei.util.Translator;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiButton;
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.gui.GuiTextField;
+import net.minecraft.client.renderer.GlStateManager;
+import net.minecraft.client.renderer.RenderHelper;
+import net.minecraft.client.util.ITooltipFlag;
+import net.minecraft.item.ItemStack;
+import net.minecraft.util.text.TextFormatting;
+import net.minecraftforge.fml.client.config.GuiUtils;
+import org.lwjgl.input.Keyboard;
+import org.lwjgl.input.Mouse;
+
+import javax.annotation.Nullable;
+import java.io.IOException;
+import java.util.*;
+
+/**
+ * Editor screen for a custom collapsible group.
+ * Left panel: searchable item grid for selection.
+ * Right panel: preview of selected items.
+ */
+public class GuiCustomGroupEditor extends GuiScreen {
+
+ private static final int ITEM_SIZE = 18;
+ private static final int GRID_PADDING = 2;
+
+ private static final int BTN_SAVE = 0;
+ private static final int BTN_CANCEL = 1;
+ private static final int BTN_PREV_PAGE = 2;
+ private static final int BTN_NEXT_PAGE = 3;
+ private static final int BTN_PREV_SEL_PAGE = 4;
+ private static final int BTN_NEXT_SEL_PAGE = 5;
+
+ private final GuiCollapsibleGroups parentScreen;
+ private final CustomGroupsConfig.CustomGroup group;
+ private final Set selectedUids = new LinkedHashSet<>();
+
+ // Persisted across editor instances, reopening restores last search and page.
+ private static String savedSearchText = "";
+ private static int savedFirstItemIndex = 0;
+
+ @Nullable
+ private GuiTextField nameField;
+ @Nullable
+ private GuiTextField searchField;
+
+ // Left grid (all items)
+ private List filteredItems = Collections.emptyList();
+ private int leftCols;
+ private int leftRows;
+ private int leftGridX;
+ private int leftGridY;
+ private int leftPage = 0;
+ private int leftTotalPages = 1;
+ private int leftItemsPerPage;
+
+ // Right grid (selected items)
+ private int rightCols;
+ private int rightRows;
+ private int rightGridX;
+ private int rightGridY;
+ private int rightPage = 0;
+ private int rightTotalPages = 1;
+ private int rightItemsPerPage;
+
+ // Cached selected elements for the right panel (any ingredient type)
+ private List> selectedStacks = new ArrayList<>();
+ // Maps each right-panel element to its stored UID (exact or wildcard ending in ":*")
+ private final Map, String> selectedStackToStoredUid = new HashMap<>();
+
+ // Index of OTHER custom groups — used to tint items that already belong to a different group.
+ // Built once in initGui(); key = exact UID, value = list of display names.
+ private Map> otherGroupExactUids = new HashMap<>();
+ // [prefix, displayName] pairs for wildcard entries in other groups
+ private List otherGroupWildcardPrefixes = new ArrayList<>();
+
+ // Drag-select state
+ private boolean isDragging = false;
+ private boolean dragAdding = false;
+ /** True when the current drag was started with Ctrl held — uses wildcard UIDs. */
+ private boolean dragWildcard = false;
+ @Nullable private String lastDraggedUid = null;
+
+ public GuiCustomGroupEditor(GuiCollapsibleGroups parentScreen, CustomGroupsConfig.CustomGroup group) {
+ this.parentScreen = parentScreen;
+ this.group = group;
+ this.selectedUids.addAll(group.itemUids);
+ }
+
+ @Override
+ public void initGui() {
+ super.initGui();
+ Keyboard.enableRepeatEvents(true);
+ this.buttonList.clear();
+
+ int topBarHeight = 48;
+ int panelDivider = (int) (this.width * 0.65);
+
+ // Name field
+ nameField = new GuiTextField(10, this.fontRenderer, 62, 6, panelDivider - 70, 16);
+ nameField.setMaxStringLength(40);
+ nameField.setText(group.displayName != null ? group.displayName : "");
+
+ // Search field — restore saved text so the user's last search carries over
+ searchField = new GuiTextField(11, this.fontRenderer, 4, topBarHeight - 18, panelDivider - 12, 14);
+ searchField.setMaxStringLength(128);
+ searchField.setText(savedSearchText);
+
+ // Save & Cancel buttons
+ this.buttonList.add(new GuiButton(BTN_SAVE, panelDivider + 4, 4, 50, 20,
+ Translator.translateToLocal("hei.gui.collapsible.editor.save")));
+ this.buttonList.add(new GuiButton(BTN_CANCEL, panelDivider + 58, 4, 50, 20,
+ Translator.translateToLocal("hei.gui.collapsible.back")));
+
+ // Calculate left grid layout
+ int leftWidth = panelDivider - 8;
+ int leftHeight = this.height - topBarHeight - 26; // room for page nav
+ leftCols = Math.max(1, leftWidth / ITEM_SIZE);
+ leftRows = Math.max(1, leftHeight / ITEM_SIZE);
+ leftGridX = (panelDivider - 4 - leftCols * ITEM_SIZE) / 2;
+ leftGridY = topBarHeight;
+ leftItemsPerPage = leftCols * leftRows;
+
+ // Calculate right grid layout
+ int rightWidth = this.width - panelDivider - 8;
+ int rightHeight = this.height - topBarHeight - 26;
+ rightCols = Math.max(1, rightWidth / ITEM_SIZE);
+ rightRows = Math.max(1, rightHeight / ITEM_SIZE);
+ rightGridX = panelDivider + 4;
+ rightGridY = topBarHeight;
+ rightItemsPerPage = rightCols * rightRows;
+
+ // Page nav buttons for left grid
+ int leftNavY = this.height - 22;
+ this.buttonList.add(new GuiButton(BTN_PREV_PAGE, 4, leftNavY, 30, 20, "<"));
+ this.buttonList.add(new GuiButton(BTN_NEXT_PAGE, panelDivider - 34, leftNavY, 30, 20, ">"));
+
+ // Page nav buttons for right grid
+ this.buttonList.add(new GuiButton(BTN_PREV_SEL_PAGE, panelDivider + 4, leftNavY, 30, 20, "<"));
+ this.buttonList.add(new GuiButton(BTN_NEXT_SEL_PAGE, this.width - 34, leftNavY, 30, 20, ">"));
+
+ updateFilteredItems();
+ leftPage = Math.max(0, Math.min(savedFirstItemIndex / leftItemsPerPage, leftTotalPages - 1));
+ updateSelectedStacks();
+ buildOtherGroupIndex();
+ }
+
+ @Override
+ public void onGuiClosed() {
+ super.onGuiClosed();
+ Keyboard.enableRepeatEvents(false);
+ // Persist the current search text and the absolute index of the first visible item.
+ savedSearchText = (searchField != null) ? searchField.getText() : "";
+ savedFirstItemIndex = leftPage * leftItemsPerPage;
+ }
+
+ /**
+ * Returns a unique string identifier for any ingredient type, or null if unavailable.
+ * Uses StackHelper for ItemStacks, and the generic IngredientRegistry helper for everything else
+ * (e.g. FluidStack via FluidStackHelper.getUniqueId).
+ */
+ @Nullable
+ private static String getIngredientUid(Object ingredient) {
+ if (ingredient instanceof ItemStack) {
+ ItemStack stack = (ItemStack) ingredient;
+ if (stack.isEmpty()) return null;
+ try {
+ return Internal.getStackHelper().getUniqueIdentifierForStack(stack);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+ try {
+ @SuppressWarnings("unchecked")
+ mezz.jei.api.ingredients.IIngredientHelper