From 586bc4151c4e8b494e2a78d1bb293a08f72bd13a Mon Sep 17 00:00:00 2001
From: m1Riss <3081974632@qq.com>
Date: Wed, 10 Jun 2026 21:27:42 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E4=BA=86emi?=
=?UTF-8?q?=E6=8A=98=E5=8F=A0=E5=8A=9F=E8=83=BD=EF=BC=88=E5=8F=AA=E5=90=AB?=
=?UTF-8?q?=E5=B0=91=E9=83=A8=E5=88=86=E5=AE=9E=E4=BE=8B=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Conflicts:
# src/generated/resources/data/ctnhenergy/tags/items/p2p_attunements/eu_p2p_tunnel.json
---
.gitignore | 4 +
.../ses_154b374d0ffenb6pOEITn2kJq4.json | 10 +
.../ses_15520a243ffe1boZ9xCFJR4DYc.json | 10 +
.../ses_1561201f3ffe5FecL1zDYz3OlR.json | 10 +
.../ses_1582a1b3fffeVytcZ1qXuOdHd0.json | 10 +
.../ses_1582debb4ffe5n4umaN66NYAZ9.json | 10 +
.../ses_15835033effetw28iiOqzf40dW.json | 10 +
dependencies.gradle | 3 +-
.../resources/assets/ctnhcore/lang/en_ud.json | 17 +-
.../resources/assets/ctnhcore/lang/en_us.json | 17 +-
.../resources/assets/ctnhcore/lang/zh_cn.json | 17 +-
.../data/lang/old/ChineseLangHandler.java | 10 +
.../data/lang/old/EnglishLangHandler.java | 10 +
.../mixin/emi/EmiScreenManagerInputMixin.java | 245 ++++
.../emi/EmiScreenManagerScreenSpaceMixin.java | 297 +++++
.../collapsible/CTNHCollapsibleGroups.java | 1021 +++++++++++++++++
src/main/resources/ctnhcore.mixins.json | 2 +
17 files changed, 1698 insertions(+), 5 deletions(-)
create mode 100644 .omo/run-continuation/ses_154b374d0ffenb6pOEITn2kJq4.json
create mode 100644 .omo/run-continuation/ses_15520a243ffe1boZ9xCFJR4DYc.json
create mode 100644 .omo/run-continuation/ses_1561201f3ffe5FecL1zDYz3OlR.json
create mode 100644 .omo/run-continuation/ses_1582a1b3fffeVytcZ1qXuOdHd0.json
create mode 100644 .omo/run-continuation/ses_1582debb4ffe5n4umaN66NYAZ9.json
create mode 100644 .omo/run-continuation/ses_15835033effetw28iiOqzf40dW.json
create mode 100644 src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java
create mode 100644 src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java
create mode 100644 src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java
diff --git a/.gitignore b/.gitignore
index 5b75105f..cf2b61db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,7 @@ src/test/*
# macOS
.DS_Store
+
+# opencode
+.omo
+.opencode
diff --git a/.omo/run-continuation/ses_154b374d0ffenb6pOEITn2kJq4.json b/.omo/run-continuation/ses_154b374d0ffenb6pOEITn2kJq4.json
new file mode 100644
index 00000000..9a4b25dc
--- /dev/null
+++ b/.omo/run-continuation/ses_154b374d0ffenb6pOEITn2kJq4.json
@@ -0,0 +1,10 @@
+{
+ "sessionID": "ses_154b374d0ffenb6pOEITn2kJq4",
+ "updatedAt": "2026-06-09T07:34:36.095Z",
+ "sources": {
+ "background-task": {
+ "state": "idle",
+ "updatedAt": "2026-06-09T07:34:36.095Z"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.omo/run-continuation/ses_15520a243ffe1boZ9xCFJR4DYc.json b/.omo/run-continuation/ses_15520a243ffe1boZ9xCFJR4DYc.json
new file mode 100644
index 00000000..44b2a2f8
--- /dev/null
+++ b/.omo/run-continuation/ses_15520a243ffe1boZ9xCFJR4DYc.json
@@ -0,0 +1,10 @@
+{
+ "sessionID": "ses_15520a243ffe1boZ9xCFJR4DYc",
+ "updatedAt": "2026-06-09T05:37:52.672Z",
+ "sources": {
+ "background-task": {
+ "state": "idle",
+ "updatedAt": "2026-06-09T05:37:52.672Z"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.omo/run-continuation/ses_1561201f3ffe5FecL1zDYz3OlR.json b/.omo/run-continuation/ses_1561201f3ffe5FecL1zDYz3OlR.json
new file mode 100644
index 00000000..154a3d12
--- /dev/null
+++ b/.omo/run-continuation/ses_1561201f3ffe5FecL1zDYz3OlR.json
@@ -0,0 +1,10 @@
+{
+ "sessionID": "ses_1561201f3ffe5FecL1zDYz3OlR",
+ "updatedAt": "2026-06-09T01:14:05.782Z",
+ "sources": {
+ "background-task": {
+ "state": "idle",
+ "updatedAt": "2026-06-09T01:14:05.782Z"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.omo/run-continuation/ses_1582a1b3fffeVytcZ1qXuOdHd0.json b/.omo/run-continuation/ses_1582a1b3fffeVytcZ1qXuOdHd0.json
new file mode 100644
index 00000000..55eb9d92
--- /dev/null
+++ b/.omo/run-continuation/ses_1582a1b3fffeVytcZ1qXuOdHd0.json
@@ -0,0 +1,10 @@
+{
+ "sessionID": "ses_1582a1b3fffeVytcZ1qXuOdHd0",
+ "updatedAt": "2026-06-08T15:36:47.526Z",
+ "sources": {
+ "background-task": {
+ "state": "idle",
+ "updatedAt": "2026-06-08T15:36:47.526Z"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.omo/run-continuation/ses_1582debb4ffe5n4umaN66NYAZ9.json b/.omo/run-continuation/ses_1582debb4ffe5n4umaN66NYAZ9.json
new file mode 100644
index 00000000..36563288
--- /dev/null
+++ b/.omo/run-continuation/ses_1582debb4ffe5n4umaN66NYAZ9.json
@@ -0,0 +1,10 @@
+{
+ "sessionID": "ses_1582debb4ffe5n4umaN66NYAZ9",
+ "updatedAt": "2026-06-08T15:25:47.646Z",
+ "sources": {
+ "background-task": {
+ "state": "idle",
+ "updatedAt": "2026-06-08T15:25:47.646Z"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.omo/run-continuation/ses_15835033effetw28iiOqzf40dW.json b/.omo/run-continuation/ses_15835033effetw28iiOqzf40dW.json
new file mode 100644
index 00000000..8a648eb3
--- /dev/null
+++ b/.omo/run-continuation/ses_15835033effetw28iiOqzf40dW.json
@@ -0,0 +1,10 @@
+{
+ "sessionID": "ses_15835033effetw28iiOqzf40dW",
+ "updatedAt": "2026-06-09T07:34:08.125Z",
+ "sources": {
+ "background-task": {
+ "state": "idle",
+ "updatedAt": "2026-06-09T07:34:08.125Z"
+ }
+ }
+}
\ No newline at end of file
diff --git a/dependencies.gradle b/dependencies.gradle
index 6c6ded8a..03b55aed 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -138,6 +138,5 @@ dependencies {
// Immersive Aircraft
modImplementation(ctnh.immersiveaircraft)
- //emiaccelerator
- modImplementation(ctnh.emiaccelerator)
+ // emiaccelerator is not defined in the version catalog.
}
diff --git a/src/generated/resources/assets/ctnhcore/lang/en_ud.json b/src/generated/resources/assets/ctnhcore/lang/en_ud.json
index 6d975f10..c63f80b3 100644
--- a/src/generated/resources/assets/ctnhcore/lang/en_ud.json
+++ b/src/generated/resources/assets/ctnhcore/lang/en_ud.json
@@ -48,6 +48,21 @@
"block.ctnhcore.creative_item_input_bus": "snᗺ ʇnduI ɯǝʇI ǝʌıʇɐǝɹƆ",
"block.ctnhcore.creative_laser_hatch": "ɥɔʇɐH ʇnduI ɹǝsɐꞀ ǝʌıʇɐǝɹƆ",
"block.ctnhcore.cryotheum_freezer": "ɹǝzǝǝɹℲ ɯnǝɥʇoʎɹƆ",
+ "ctnhcore.emi.collapsible.button.collapse_all": ")s% 个分组(部全叠折:键左",
+ "ctnhcore.emi.collapsible.button.collapse_all.right_click": "部全叠折:键右",
+ "ctnhcore.emi.collapsible.button.expand_all": ")s% 个已折叠(部全开展:键左",
+ "ctnhcore.emi.collapsible.group.axes": "sǝxⱯ",
+ "ctnhcore.emi.collapsible.group.hoes": "sǝoH",
+ "ctnhcore.emi.collapsible.group.lingering_potions": "suoıʇoԀ ƃuıɹǝƃuıꞀ",
+ "ctnhcore.emi.collapsible.group.music_discs": "sɔsıᗡ ɔısnW",
+ "ctnhcore.emi.collapsible.group.pickaxes": "sǝxɐʞɔıԀ",
+ "ctnhcore.emi.collapsible.group.shovels": "sןǝʌoɥS",
+ "ctnhcore.emi.collapsible.group.spawn_eggs": "sƃƃƎ uʍɐdS",
+ "ctnhcore.emi.collapsible.group.spawners": "sɹǝuʍɐdS",
+ "ctnhcore.emi.collapsible.group.splash_potions": "suoıʇoԀ ɥsɐןdS",
+ "ctnhcore.emi.collapsible.group.swords": "spɹoʍS",
+ "ctnhcore.emi.collapsible.tooltip.group": "s%:组叠折",
+ "ctnhcore.emi.collapsible.tooltip.toggle_hint": "组此叠折/开展:键左 + tlA",
"block.ctnhcore.crystallizer": "ɹǝzıןןɐʇsʎɹƆ",
"block.ctnhcore.cultivationroom": "ɯooɹuoıʇɐʌıʇןnƆ",
"block.ctnhcore.dark_blue_elevator_casing": "buısɐƆ ɹoʇɐʌǝןƎ ǝnןᗺ ʞɹɐᗡ",
@@ -955,4 +970,4 @@
"tuff_uraninite_vein_ad": "pⱯ uıǝΛ ǝʇıuıuɐɹ∩ ɟɟn⟘",
"uranium238_vein_ad": "pⱯ uıǝΛ 8Ɛᄅɯnıuɐɹ∩",
"wollastonite_vein": "uıǝΛ ǝʇıuoʇsɐןןoM"
-}
\ No newline at end of file
+}
diff --git a/src/generated/resources/assets/ctnhcore/lang/en_us.json b/src/generated/resources/assets/ctnhcore/lang/en_us.json
index a6fdbef7..49a55542 100644
--- a/src/generated/resources/assets/ctnhcore/lang/en_us.json
+++ b/src/generated/resources/assets/ctnhcore/lang/en_us.json
@@ -48,6 +48,21 @@
"block.ctnhcore.creative_item_input_bus": "Creative Item Input Bus",
"block.ctnhcore.creative_laser_hatch": "Creative Laser Input Hatch",
"block.ctnhcore.cryotheum_freezer": "Cryotheum Freezer",
+ "ctnhcore.emi.collapsible.button.collapse_all": "Left click: collapse all (%s groups)",
+ "ctnhcore.emi.collapsible.button.collapse_all.right_click": "Right click: collapse all",
+ "ctnhcore.emi.collapsible.button.expand_all": "Left click: expand all (%s collapsed)",
+ "ctnhcore.emi.collapsible.group.axes": "Axes",
+ "ctnhcore.emi.collapsible.group.hoes": "Hoes",
+ "ctnhcore.emi.collapsible.group.lingering_potions": "Lingering Potions",
+ "ctnhcore.emi.collapsible.group.music_discs": "Music Discs",
+ "ctnhcore.emi.collapsible.group.pickaxes": "Pickaxes",
+ "ctnhcore.emi.collapsible.group.shovels": "Shovels",
+ "ctnhcore.emi.collapsible.group.spawn_eggs": "Spawn Eggs",
+ "ctnhcore.emi.collapsible.group.spawners": "Spawners",
+ "ctnhcore.emi.collapsible.group.splash_potions": "Splash Potions",
+ "ctnhcore.emi.collapsible.group.swords": "Swords",
+ "ctnhcore.emi.collapsible.tooltip.group": "Collapsed group: %s",
+ "ctnhcore.emi.collapsible.tooltip.toggle_hint": "Alt + left click: expand/collapse this group",
"block.ctnhcore.crystallizer": "Crystallizer",
"block.ctnhcore.cultivationroom": "Cultivationroom",
"block.ctnhcore.dark_blue_elevator_casing": "Dark Blue Elevator Casing",
@@ -955,4 +970,4 @@
"tuff_uraninite_vein_ad": "Tuff Uraninite Vein Ad",
"uranium238_vein_ad": "Uranium238 Vein Ad",
"wollastonite_vein": "Wollastonite Vein"
-}
\ No newline at end of file
+}
diff --git a/src/generated/resources/assets/ctnhcore/lang/zh_cn.json b/src/generated/resources/assets/ctnhcore/lang/zh_cn.json
index e68c7223..aa370919 100644
--- a/src/generated/resources/assets/ctnhcore/lang/zh_cn.json
+++ b/src/generated/resources/assets/ctnhcore/lang/zh_cn.json
@@ -48,6 +48,21 @@
"block.ctnhcore.creative_item_input_bus": "创造模式输入总线",
"block.ctnhcore.creative_laser_hatch": "创造模式激光靶仓",
"block.ctnhcore.cryotheum_freezer": "凛冰冷冻机",
+ "ctnhcore.emi.collapsible.button.collapse_all": "左键:折叠全部(%s 个分组)",
+ "ctnhcore.emi.collapsible.button.collapse_all.right_click": "右键:折叠全部",
+ "ctnhcore.emi.collapsible.button.expand_all": "左键:展开全部(%s 个已折叠)",
+ "ctnhcore.emi.collapsible.group.axes": "斧",
+ "ctnhcore.emi.collapsible.group.hoes": "锄",
+ "ctnhcore.emi.collapsible.group.lingering_potions": "滞留型药水",
+ "ctnhcore.emi.collapsible.group.music_discs": "音乐唱片",
+ "ctnhcore.emi.collapsible.group.pickaxes": "镐",
+ "ctnhcore.emi.collapsible.group.shovels": "锹",
+ "ctnhcore.emi.collapsible.group.spawn_eggs": "刷怪蛋",
+ "ctnhcore.emi.collapsible.group.spawners": "刷怪笼",
+ "ctnhcore.emi.collapsible.group.splash_potions": "喷溅型药水",
+ "ctnhcore.emi.collapsible.group.swords": "剑",
+ "ctnhcore.emi.collapsible.tooltip.group": "折叠组:%s",
+ "ctnhcore.emi.collapsible.tooltip.toggle_hint": "Alt + 左键:展开/折叠此组",
"block.ctnhcore.crystallizer": "结晶器",
"block.ctnhcore.cultivationroom": "培养室",
"block.ctnhcore.dark_blue_elevator_casing": "深蓝色电梯机械方块",
@@ -2058,4 +2073,4 @@
"uranium238_vein_ad": "铀238矿脉",
"wollastonite_vein": "白云石矿脉",
"zenith_machine_sp": "§5灵能灯塔屹立不倒!"
-}
\ No newline at end of file
+}
diff --git a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java
index dec79c9d..c27bacd1 100644
--- a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java
+++ b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java
@@ -46,6 +46,16 @@ public static void init(RegistrateCNLangProvider provider) {
"缺少足够能量以启动核聚变反应");
provider.add("gtceu.recipe_modifier.coil_temperature_too_low", "线圈温度过低!");
+ provider.add("ctnhcore.emi.collapsible.group.logs", "原木");
+ provider.add("ctnhcore.emi.collapsible.group.stairs", "楼梯");
+ provider.add("ctnhcore.emi.collapsible.group.slabs", "台阶");
+ provider.add("ctnhcore.emi.collapsible.group.fences", "栅栏");
+ provider.add("ctnhcore.emi.collapsible.group.fence_gates", "栅栏门");
+ provider.add("ctnhcore.emi.collapsible.group.doors", "门");
+ provider.add("ctnhcore.emi.collapsible.group.trapdoors", "活板门");
+ provider.add("ctnhcore.emi.collapsible.group.pressure_plates", "压力板");
+ provider.add("ctnhcore.emi.collapsible.group.buttons", "按钮");
+
// Config
provider.add("config.ctnhcore.option.ftbPlugin", "FTB相关");
provider.add("config.ctnhcore.option.kinetic", "应力相关");
diff --git a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java
index ce7e6750..b05eba97 100644
--- a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java
+++ b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java
@@ -33,6 +33,16 @@ public static void init(RegistrateLangProvider provider) {
provider.add("config.jade.plugin_ctnhcore.recipe_logic_provider", "Recipe Logic Info");
provider.add("config.jade.plugin_ctnhcore.recipe_output_provider", "Recipe Output Info");
+ provider.add("ctnhcore.emi.collapsible.group.logs", "Logs");
+ provider.add("ctnhcore.emi.collapsible.group.stairs", "Stairs");
+ provider.add("ctnhcore.emi.collapsible.group.slabs", "Slabs");
+ provider.add("ctnhcore.emi.collapsible.group.fences", "Fences");
+ provider.add("ctnhcore.emi.collapsible.group.fence_gates", "Fence Gates");
+ provider.add("ctnhcore.emi.collapsible.group.doors", "Doors");
+ provider.add("ctnhcore.emi.collapsible.group.trapdoors", "Trapdoors");
+ provider.add("ctnhcore.emi.collapsible.group.pressure_plates", "Pressure Plates");
+ provider.add("ctnhcore.emi.collapsible.group.buttons", "Buttons");
+
// Recipe Types
provider.add("gtceu.underfloor_heating_system", "Underfloor Heating");
provider.add("gtceu.astronomical_observatory", "Astronomical Observatory");
diff --git a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java
new file mode 100644
index 00000000..398d1b9e
--- /dev/null
+++ b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java
@@ -0,0 +1,245 @@
+package io.github.cpearl0.ctnhcore.mixin.emi;
+
+import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups;
+import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups.CollapsibleGroup;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent;
+import net.minecraft.network.chat.Component;
+
+import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
+import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
+import dev.emi.emi.api.stack.EmiIngredient;
+import dev.emi.emi.api.stack.EmiStackInteraction;
+import dev.emi.emi.config.SidebarType;
+import dev.emi.emi.runtime.EmiDrawContext;
+import dev.emi.emi.runtime.EmiSidebars;
+import dev.emi.emi.screen.EmiScreenBase;
+import dev.emi.emi.screen.EmiScreenManager;
+import dev.emi.emi.screen.widget.EmiSearchWidget;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 注入 EMI 的 {@link EmiScreenManager},负责折叠组的输入与提示层逻辑。
+ *
+ *
+ * 本 mixin 不决定哪些物品属于同一组;分组数据由 {@link CTNHCollapsibleGroups} 管理。
+ * 这里负责把玩家输入和 EMI 生命周期事件转发给分组管理器,包括:
+ *
+ *
+ * - Alt + 左键点击单个分组代表项时展开或折叠该组。
+ * - 在搜索框旁绘制 G 按钮,用于批量展开或折叠所有分组。
+ * - 在 EMI 搜索来源刷新时触发分组重建。
+ * - 给折叠代表项 tooltip 追加分组名和操作提示。
+ *
+ */
+@Mixin(value = EmiScreenManager.class, remap = false)
+public class EmiScreenManagerInputMixin {
+
+ /** EMI 原生搜索框实例,用于把 G 按钮定位到搜索框右侧。 */
+ @Shadow
+ public static EmiSearchWidget search;
+
+ /** G 按钮边长,和 EMI 侧栏单格尺寸保持接近。 */
+ @Unique
+ private static final int TOGGLE_BUTTON_SIZE = 16;
+
+ /** G 按钮与搜索框之间的水平间距。 */
+ @Unique
+ private static final int TOGGLE_BUTTON_GAP = 4;
+
+ /** G 按钮左上角 X 坐标;-1 表示当前没有可点击按钮。 */
+ @Unique
+ private static int ctnhcore$toggleBtnX = -1;
+
+ /** G 按钮左上角 Y 坐标;-1 表示当前没有可点击按钮。 */
+ @Unique
+ private static int ctnhcore$toggleBtnY = -1;
+
+ /** 鼠标当前是否悬停在 G 按钮上,用于按钮高亮和 tooltip。 */
+ @Unique
+ private static boolean ctnhcore$hoveredToggleBtn = false;
+
+ /**
+ * 处理折叠组相关鼠标点击。
+ *
+ *
+ * 注入在 {@code mouseClicked} 开头,先于 EMI 原生点击逻辑执行。这样当玩家点击 G 按钮
+ * 或 Alt + 左键点击分组项时,可以消费本次事件,避免 EMI 同时打开配方或执行其他默认动作。
+ *
+ */
+ @Inject(method = "mouseClicked", at = @At("HEAD"), cancellable = true)
+ private static void ctnhcore$handleMouseClicked(double mouseX, double mouseY, int button,
+ CallbackInfoReturnable cir) {
+ if (!CTNHCollapsibleGroups.isEnabled()) return;
+ if (CTNHCollapsibleGroups.needsRebuild()) return;
+ if (!CTNHCollapsibleGroups.hasGroups()) return;
+
+ int mx = (int) mouseX;
+ int my = (int) mouseY;
+
+ // 先检查 G 按钮,避免按钮区域被误判为侧栏物品点击。
+ if (ctnhcore$toggleBtnX >= 0 && ctnhcore$toggleBtnY >= 0 && mx >= ctnhcore$toggleBtnX &&
+ mx < ctnhcore$toggleBtnX + TOGGLE_BUTTON_SIZE && my >= ctnhcore$toggleBtnY &&
+ my < ctnhcore$toggleBtnY + TOGGLE_BUTTON_SIZE) {
+ if (button == 0) {
+ CTNHCollapsibleGroups.toggleAll(false);
+ } else if (button == 1) {
+ CTNHCollapsibleGroups.toggleAll(true);
+ }
+ EmiScreenManager.repopulatePanels(SidebarType.INDEX);
+ cir.setReturnValue(true);
+ return;
+ }
+
+ // Alt + 左键点击 INDEX 侧栏中的分组项时,切换该项所属分组。
+ if (button == 0 && Screen.hasAltDown()) {
+ EmiStackInteraction interaction = EmiScreenManager.getHoveredStack(mx, my, false);
+ EmiIngredient hovered = interaction.getStack();
+ if (!hovered.isEmpty()) {
+ if (CTNHCollapsibleGroups.toggleGroup(hovered)) {
+ EmiScreenManager.repopulatePanels(SidebarType.INDEX);
+ cir.setReturnValue(true);
+ }
+ }
+ }
+ }
+
+ /**
+ * 在 EMI 小部件渲染结束后绘制 G 按钮。
+ *
+ *
+ * 按钮位于实时搜索框右侧。左键为智能展开/折叠全部:存在折叠组时展开全部,
+ * 否则折叠全部;右键始终折叠全部。没有有效分组时会隐藏按钮并清空 hover 状态。
+ *
+ */
+ @Inject(method = "renderWidgets", at = @At("TAIL"))
+ private static void ctnhcore$renderToggleButton(EmiDrawContext context, int mouseX, int mouseY,
+ float delta, EmiScreenBase base,
+ CallbackInfo ci) {
+ if (!CTNHCollapsibleGroups.isEnabled()) return;
+ if (CTNHCollapsibleGroups.needsRebuild()) return;
+ if (!CTNHCollapsibleGroups.hasGroups()) {
+ ctnhcore$toggleBtnX = -1;
+ ctnhcore$toggleBtnY = -1;
+ ctnhcore$hoveredToggleBtn = false;
+ return;
+ }
+
+ if (base == null || search == null) return;
+ ctnhcore$toggleBtnX = search.getX() + search.getWidth() + TOGGLE_BUTTON_GAP;
+ ctnhcore$toggleBtnY = search.getY();
+
+ int x = ctnhcore$toggleBtnX;
+ int y = ctnhcore$toggleBtnY;
+
+ GuiGraphics graphics = context.raw();
+
+ ctnhcore$hoveredToggleBtn = mouseX >= x && mouseX < x + TOGGLE_BUTTON_SIZE && mouseY >= y &&
+ mouseY < y + TOGGLE_BUTTON_SIZE;
+
+ int bgColor = ctnhcore$hoveredToggleBtn ? 0xFF444444 : 0xFF333333;
+ graphics.fill(x, y, x + TOGGLE_BUTTON_SIZE, y + TOGGLE_BUTTON_SIZE, bgColor);
+
+ int borderColor = ctnhcore$hoveredToggleBtn ? 0xFF888888 : 0xFF555555;
+ graphics.fill(x, y, x + TOGGLE_BUTTON_SIZE, y + 1, borderColor);
+ graphics.fill(x, y + TOGGLE_BUTTON_SIZE - 1, x + TOGGLE_BUTTON_SIZE, y + TOGGLE_BUTTON_SIZE, borderColor);
+ graphics.fill(x, y, x + 1, y + TOGGLE_BUTTON_SIZE, borderColor);
+ graphics.fill(x + TOGGLE_BUTTON_SIZE - 1, y, x + TOGGLE_BUTTON_SIZE, y + TOGGLE_BUTTON_SIZE, borderColor);
+
+ int collapsedCount = CTNHCollapsibleGroups.collapsedGroupCount();
+ int textColor = collapsedCount > 0 ? 0xFF88FF88 : 0xFF888888;
+ graphics.drawString(Minecraft.getInstance().font, "G", x + 4, y + 4, textColor, false);
+
+ if (ctnhcore$hoveredToggleBtn) {
+ int totalCount = CTNHCollapsibleGroups.totalGroupCount();
+ if (collapsedCount > 0) {
+ graphics.renderComponentTooltip(Minecraft.getInstance().font,
+ List.of(
+ Component.translatable("ctnhcore.emi.collapsible.button.expand_all", collapsedCount),
+ Component.translatable("ctnhcore.emi.collapsible.button.collapse_all.right_click")),
+ x, y + TOGGLE_BUTTON_SIZE + 4);
+ } else {
+ graphics.renderComponentTooltip(Minecraft.getInstance().font,
+ List.of(
+ Component.translatable("ctnhcore.emi.collapsible.button.collapse_all", totalCount),
+ Component.translatable("ctnhcore.emi.collapsible.button.collapse_all.right_click")),
+ x, y + TOGGLE_BUTTON_SIZE + 4);
+ }
+ }
+ }
+
+ /**
+ * 在 EMI 获取搜索来源后触发折叠组重建。
+ *
+ *
+ * 这里故意使用 {@link EmiSidebars#getStacks(SidebarType)} 的 INDEX 完整来源,而不是搜索过滤后的
+ * 返回值。这样分组成员来自完整 EMI 列表,搜索时再由投影逻辑只显示当前搜索结果中存在的成员。
+ *
+ */
+ @Inject(method = "getSearchSource", at = @At("RETURN"))
+ private static void ctnhcore$rebuildOnSearch(CallbackInfoReturnable> cir) {
+ if (!CTNHCollapsibleGroups.isEnabled()) return;
+ if (CTNHCollapsibleGroups.needsRebuild()) {
+ List extends EmiIngredient> source = EmiSidebars.getStacks(SidebarType.INDEX);
+ if (source != null && !source.isEmpty()) {
+ CTNHCollapsibleGroups.rebuild(source);
+ }
+ }
+ }
+
+ /**
+ * 当 EMI 可见性切换时标记分组为脏。
+ *
+ *
+ * 可见性切换常伴随 EMI 重载、关闭或重新打开。标记 dirty 后,下次获取搜索来源时会重新扫描列表,
+ * 避免继续使用旧的 ingredient 对象身份映射。
+ *
+ */
+ @Inject(method = "toggleVisibility", at = @At("HEAD"))
+ private static void ctnhcore$markDirtyOnToggle(boolean notify, CallbackInfo ci) {
+ CTNHCollapsibleGroups.markDirty();
+ }
+
+ /**
+ * 包装 EMI 原生 tooltip,给折叠代表项追加分组信息。
+ *
+ *
+ * 只有当前 ingredient 是折叠代表项时才追加文本。展开后的普通成员不追加,避免 tooltip 噪音。
+ * 追加内容包括分组显示名和 Alt + 左键展开/折叠提示。
+ *
+ */
+ @WrapOperation(method = "renderCurrentTooltip",
+ at = @At(value = "INVOKE",
+ target = "Ldev/emi/emi/api/stack/EmiIngredient;getTooltip()Ljava/util/List;"))
+ private static List ctnhcore$wrapGroupTooltip(EmiIngredient instance,
+ Operation> original) {
+ List list = original.call(instance);
+ if (CTNHCollapsibleGroups.needsRebuild() || !CTNHCollapsibleGroups.isEnabled()) return list;
+ if (CTNHCollapsibleGroups.collapsedGroupCount() == 0) return list;
+
+ CollapsibleGroup group = CTNHCollapsibleGroups.getGroup(instance);
+ if (group != null && CTNHCollapsibleGroups.isCollapsedRepresentative(instance)) {
+ List modified = new ArrayList<>(list.size() + 2);
+ modified.addAll(list);
+ modified.add(ClientTooltipComponent.create(
+ Component.translatable("ctnhcore.emi.collapsible.tooltip.group", group.displayName)
+ .getVisualOrderText()));
+ modified.add(ClientTooltipComponent.create(
+ Component.translatable("ctnhcore.emi.collapsible.tooltip.toggle_hint").getVisualOrderText()));
+ return modified;
+ }
+ return list;
+ }
+}
diff --git a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java
new file mode 100644
index 00000000..79d00446
--- /dev/null
+++ b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java
@@ -0,0 +1,297 @@
+package io.github.cpearl0.ctnhcore.mixin.emi;
+
+import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups;
+import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups.CollapsibleGroup;
+
+import net.minecraft.client.gui.GuiGraphics;
+
+import dev.emi.emi.api.stack.EmiIngredient;
+import dev.emi.emi.config.SidebarType;
+import dev.emi.emi.runtime.EmiDrawContext;
+import dev.emi.emi.screen.EmiScreenManager;
+import dev.emi.emi.screen.StackBatcher;
+import org.spongepowered.asm.mixin.Final;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.Redirect;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+import com.mojang.blaze3d.systems.RenderSystem;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 注入 EMI 侧栏空间 {@link EmiScreenManager.ScreenSpace},负责折叠组的显示层改写。
+ *
+ *
+ * 本 mixin 只作用于 INDEX 搜索侧栏。它会在 EMI 读取待显示列表时调用
+ * {@link CTNHCollapsibleGroups#project(List)},把已折叠分组压缩成一个代表项;随后在渲染阶段
+ * 给折叠代表项和展开成员绘制边框,帮助玩家看出这些物品属于同一个折叠组。
+ *
+ */
+@Mixin(value = EmiScreenManager.ScreenSpace.class, remap = false)
+public abstract class EmiScreenManagerScreenSpaceMixin {
+
+ /** EMI 原字段:当前 ScreenSpace 是否处于搜索/索引侧栏模式。 */
+ @Shadow
+ @Final
+ public boolean search;
+
+ /** EMI 原方法:返回该侧栏空间的类型,例如 INDEX 或 FAVORITES。 */
+ @Shadow
+ public abstract SidebarType getType();
+
+ /** EMI 原字段:侧栏渲染起点 X。当前类保留 shadow 以匹配原布局上下文。 */
+ @Shadow
+ @Final
+ public int tx;
+
+ /** EMI 原字段:侧栏渲染起点 Y。当前类保留 shadow 以匹配原布局上下文。 */
+ @Shadow
+ @Final
+ public int ty;
+
+ /** EMI 原字段:侧栏网格高度,按 18x18 单元格行数计算。 */
+ @Shadow
+ @Final
+ public int th;
+
+ /** EMI 原字段:每行可显示的单元格宽度数组。 */
+ @Shadow
+ @Final
+ public int[] widths;
+
+ /** EMI 原方法:取得指定行可显示的单元格数量。 */
+ @Shadow
+ public abstract int getWidth(int y);
+
+ /** EMI 原方法:把网格列/行坐标转换成屏幕 X 坐标。 */
+ @Shadow
+ public abstract int getX(int x, int y);
+
+ /** EMI 原方法:把网格列/行坐标转换成屏幕 Y 坐标。 */
+ @Shadow
+ public abstract int getY(int x, int y);
+
+ /** EMI 原字段:当前页最多渲染多少个 ingredient。 */
+ @Shadow
+ @Final
+ public int pageSize;
+
+ /** EMI 原方法:返回当前侧栏空间要显示的 ingredient 列表。 */
+ @Shadow
+ public abstract List extends EmiIngredient> getStacks();
+
+ /** EMI 原字段:侧栏使用的批量物品渲染器。 */
+ @Shadow
+ @Final
+ public StackBatcher batcher;
+
+ /** EMI 侧栏单格尺寸。 */
+ @Unique
+ private static final int ENTRY_SIZE = 18;
+
+ /** 展开分组成员的外边框颜色。GuiGraphics.fill 使用 ABGR 格式。 */
+ @Unique
+ private static final int GROUP_BORDER_COLOR = 0xCC3344AA;
+
+ /** 展开分组成员的半透明背景颜色。 */
+ @Unique
+ private static final int GROUP_BG_COLOR = 0x44113377;
+
+ /** 折叠代表项边框颜色。这里只画边框,不覆盖 EMI 原本的物品图标。 */
+ @Unique
+ private static final int COLLAPSED_BORDER_COLOR = 0xCC4466CC;
+
+ /** 后方叠层图标透明度。 */
+ @Unique
+ private static final float STACKED_BACK_ICON_ALPHA = 0.45F;
+
+ /** 后方叠层图标遮罩,让不吃 shader alpha 的 item 渲染路径也能显得更靠后。 */
+ @Unique
+ private static final int STACKED_BACK_ICON_DIM_COLOR = 0x77000000;
+
+ /**
+ * 在 EMI 返回侧栏列表后替换为折叠投影列表。
+ *
+ *
+ * 只处理 INDEX 搜索侧栏,避免影响收藏夹等其他侧栏。分组尚未重建或没有有效分组时,
+ * 保持 EMI 原始列表不变。
+ *
+ */
+ @Inject(method = "getStacks", at = @At("RETURN"), cancellable = true)
+ private void ctnhcore$projectGetStacks(CallbackInfoReturnable> cir) {
+ if (!CTNHCollapsibleGroups.isEnabled()) return;
+ if (search && getType() == SidebarType.INDEX) {
+ List extends EmiIngredient> original = cir.getReturnValue();
+ if (original == null || original.isEmpty()) return;
+ if (!CTNHCollapsibleGroups.needsRebuild() && CTNHCollapsibleGroups.hasGroups()) {
+ cir.setReturnValue(CTNHCollapsibleGroups.project(original));
+ }
+ }
+ }
+
+ /**
+ * 折叠代表项由本 mixin 在批量绘制完成后手动画双层图标。
+ *
+ *
+ * 跳过 EMI 原始的单图标绘制,避免“原图标 + 双层图标”三层重叠。
+ *
+ */
+ @Redirect(method = "render",
+ at = @At(value = "INVOKE",
+ target = "Ldev/emi/emi/screen/StackBatcher;render(Ldev/emi/emi/api/stack/EmiIngredient;Lnet/minecraft/client/gui/GuiGraphics;IIF)V"))
+ private void ctnhcore$skipCollapsedRepresentativeOriginalIcon(StackBatcher instance, EmiIngredient stack,
+ GuiGraphics draw, int x, int y, float delta) {
+ if (CTNHCollapsibleGroups.isEnabled() && search && getType() == SidebarType.INDEX &&
+ !CTNHCollapsibleGroups.needsRebuild() && CTNHCollapsibleGroups.isCollapsedRepresentative(stack)) {
+ return;
+ }
+ instance.render(stack, draw, x, y, delta);
+ }
+
+ /**
+ * 在 EMI 批量绘制物品图标后绘制折叠组叠加层。
+ *
+ *
+ * 折叠代表项只画蓝色边框,保留 EMI 原本渲染出的代表物品图标。展开成员会绘制淡色背景,
+ * 并根据相邻格子是否属于同一组来决定哪些边需要画,从而形成连贯的分组区域。
+ *
+ */
+ @Inject(method = "render",
+ at = @At(value = "INVOKE",
+ target = "Ldev/emi/emi/screen/StackBatcher;draw()V",
+ shift = At.Shift.AFTER))
+ private void ctnhcore$renderGroupOverlays(EmiDrawContext context, int mouseX, int mouseY,
+ float delta, int startIndex, CallbackInfo ci) {
+ if (!CTNHCollapsibleGroups.isEnabled()) return;
+ if (!search || getType() != SidebarType.INDEX) return;
+ if (CTNHCollapsibleGroups.needsRebuild()) return;
+
+ List extends EmiIngredient> stacks = getStacks();
+ if (stacks == null || stacks.isEmpty()) return;
+
+ GuiGraphics graphics = context.raw();
+ int endIndex = Math.min(startIndex + pageSize, stacks.size());
+
+ List expandedXos = new ArrayList<>();
+ List expandedYos = new ArrayList<>();
+ List expandedCxs = new ArrayList<>();
+ List expandedCys = new ArrayList<>();
+ List expandedGroupGuids = new ArrayList<>();
+
+ int ri = startIndex;
+ outer:
+ for (int yo = 0; yo < th; yo++) {
+ for (int xo = 0; xo < getWidth(yo); xo++) {
+ if (ri >= endIndex) break outer;
+ EmiIngredient stack = stacks.get(ri);
+ ri++;
+
+ CollapsibleGroup group = CTNHCollapsibleGroups.getGroup(stack);
+ if (group == null) continue;
+
+ int cx = getX(xo, yo);
+ int cy = getY(xo, yo);
+
+ if (CTNHCollapsibleGroups.isCollapsedRepresentative(stack)) {
+ drawCollapsedGroupStack(context, stack, cx, cy);
+ drawCollapsedGroupOverlay(graphics, cx, cy);
+ } else {
+ expandedXos.add(xo);
+ expandedYos.add(yo);
+ expandedCxs.add(cx);
+ expandedCys.add(cy);
+ expandedGroupGuids.add(group.guid);
+ }
+ }
+ }
+
+ for (int index = 0; index < expandedCxs.size(); index++) {
+ drawExpandedMemberCell(graphics, index, expandedXos, expandedYos, expandedCxs, expandedCys,
+ expandedGroupGuids);
+ }
+ }
+
+ /** 为折叠代表项绘制单格边框。 */
+ @Unique
+ private void drawCollapsedGroupOverlay(GuiGraphics graphics, int cx, int cy) {
+ graphics.fill(cx, cy, cx + ENTRY_SIZE, cy + 1, COLLAPSED_BORDER_COLOR);
+ graphics.fill(cx, cy + ENTRY_SIZE - 1, cx + ENTRY_SIZE, cy + ENTRY_SIZE, COLLAPSED_BORDER_COLOR);
+ graphics.fill(cx, cy, cx + 1, cy + ENTRY_SIZE, COLLAPSED_BORDER_COLOR);
+ graphics.fill(cx + ENTRY_SIZE - 1, cy, cx + ENTRY_SIZE, cy + ENTRY_SIZE, COLLAPSED_BORDER_COLOR);
+ }
+
+ /** 为折叠代表项补画第二层图标,接近 GTNH NEI 的堆叠视觉。 */
+ @Unique
+ private void drawCollapsedGroupStack(EmiDrawContext context, EmiIngredient representative, int cx, int cy) {
+ EmiIngredient secondary = CTNHCollapsibleGroups.getSecondaryRepresentative(representative);
+ if (secondary == null || secondary.isEmpty()) return;
+
+ context.raw().pose().pushPose();
+ context.raw().pose().translate(0, 0, -50);
+ RenderSystem.enableBlend();
+ RenderSystem.defaultBlendFunc();
+ RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, STACKED_BACK_ICON_ALPHA);
+ context.drawStack(secondary, cx + 2, cy - 2, EmiIngredient.RENDER_ICON);
+ RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
+ context.raw().fill(cx + 2, cy - 2, cx + 18, cy + 14, STACKED_BACK_ICON_DIM_COLOR);
+ context.raw().pose().popPose();
+ context.drawStack(representative, cx - 1, cy + 1, EmiIngredient.RENDER_ICON);
+ }
+
+ /**
+ * 为展开状态下的单个成员绘制背景和外边框。
+ *
+ *
+ * 如果上下左右存在同组相邻成员,则省略共享边,视觉上形成连续的分组块。
+ *
+ */
+ @Unique
+ private void drawExpandedMemberCell(GuiGraphics graphics, int index, List xos, List yos,
+ List cxs, List cys, List groupGuids) {
+ int cx = cxs.get(index);
+ int cy = cys.get(index);
+ graphics.fill(cx + 1, cy + 1, cx + ENTRY_SIZE - 1, cy + ENTRY_SIZE - 1, GROUP_BG_COLOR);
+
+ if (!hasNeighbor(index, xos, yos, groupGuids, 0, -1)) {
+ graphics.fill(cx, cy, cx + ENTRY_SIZE, cy + 1, GROUP_BORDER_COLOR);
+ }
+ if (!hasNeighbor(index, xos, yos, groupGuids, 0, 1)) {
+ graphics.fill(cx, cy + ENTRY_SIZE - 1, cx + ENTRY_SIZE, cy + ENTRY_SIZE, GROUP_BORDER_COLOR);
+ }
+ if (!hasNeighbor(index, xos, yos, groupGuids, -1, 0)) {
+ graphics.fill(cx, cy, cx + 1, cy + ENTRY_SIZE, GROUP_BORDER_COLOR);
+ }
+ if (!hasNeighbor(index, xos, yos, groupGuids, 1, 0)) {
+ graphics.fill(cx + ENTRY_SIZE - 1, cy, cx + ENTRY_SIZE, cy + ENTRY_SIZE, GROUP_BORDER_COLOR);
+ }
+ }
+
+ /**
+ * 判断指定方向上是否存在同组相邻格子。
+ *
+ * @param index 当前成员在临时坐标列表中的索引
+ * @param dx 横向偏移,-1 表示左邻居,1 表示右邻居
+ * @param dy 纵向偏移,-1 表示上邻居,1 表示下邻居
+ * @return true 表示相邻格子存在且属于同一分组
+ */
+ @Unique
+ private boolean hasNeighbor(int index, List xos, List yos, List groupGuids, int dx,
+ int dy) {
+ int nx = xos.get(index) + dx;
+ int ny = yos.get(index) + dy;
+ String groupGuid = groupGuids.get(index);
+ for (int other = 0; other < xos.size(); other++) {
+ if (xos.get(other) == nx && yos.get(other) == ny && groupGuids.get(other).equals(groupGuid)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java b/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java
new file mode 100644
index 00000000..f7f02ecb
--- /dev/null
+++ b/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java
@@ -0,0 +1,1021 @@
+package io.github.cpearl0.ctnhcore.utils.emi.collapsible;
+
+import io.github.cpearl0.ctnhcore.CTNHCore;
+
+import net.minecraft.core.registries.Registries;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.tags.TagKey;
+import net.minecraft.world.item.BlockItem;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.level.block.Block;
+import net.minecraftforge.fml.loading.FMLPaths;
+import net.minecraftforge.registries.ForgeRegistries;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import dev.emi.emi.api.stack.EmiIngredient;
+import dev.emi.emi.api.stack.EmiStack;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Writer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/**
+ * EMI 侧栏折叠组的核心管理类。
+ *
+ *
+ * 本类负责三件事:
+ *
+ *
+ * - 根据 EMI 侧栏提供的 {@link EmiIngredient} 列表注册折叠组。
+ * - 在侧栏渲染前把原始列表投影成“折叠代表项”或“展开成员列表”。
+ * - 把每个分组的展开/折叠状态保存到配置目录,保证重启后状态仍然保留。
+ *
+ *
+ *
+ * 分组规则从 config/ctnhcore/emi_collapsible_groups.json 读取,支持 item/block tag、物品 id 正则和
+ * 一组接近 GTNH NEI collapsibleitems.cfg 的简化 item filter 语法。
+ *
+ */
+public class CTNHCollapsibleGroups {
+
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
+ /** 折叠组展开状态的持久化文件,位于 config/ctnhcore/collapsible_emi_groups.json。 */
+ private static final Path STATE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/collapsible_emi_groups.json");
+
+ /** 折叠组规则定义文件,位于 config/ctnhcore/emi_collapsible_groups.json。 */
+ private static final Path RULE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/emi_collapsible_groups.json");
+
+ /** 兼容 GTNH/NEI collapsibleitems.cfg 风格的一行一组配置。 */
+ private static final Path LEGACY_RULE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/collapsibleitems.cfg");
+
+ /** 总开关。关闭后不会重建、投影或响应折叠组交互。 */
+ private static boolean enabled = true;
+
+ /** 标记 EMI 来源列表是否需要重新扫描并重建分组。 */
+ private static boolean dirty = true;
+
+ /** 防止每次重建都重复读取状态文件。 */
+ private static boolean statesLoaded = false;
+
+ /** guid -> 分组对象。使用 LinkedHashMap 保持注册顺序,从而稳定投影顺序。 */
+ private static final Map GROUPS = Collections.synchronizedMap(new LinkedHashMap<>());
+
+ /** EMI ingredient 对象身份 -> 分组 guid。这里依赖 EMI 当前侧栏列表中的对象身份。 */
+ private static final Map STACK_TO_GROUP = new IdentityHashMap<>();
+
+ /** 分组 guid -> 是否展开。没有记录时默认视为折叠。 */
+ private static final Map EXPANDED_STATE = new HashMap<>();
+
+ /** 当前投影列表中的折叠代表项 -> 分组 guid,用于 tooltip、边框和点击切换。 */
+ private static final IdentityHashMap REPRESENTATIVE_TO_GROUP = new IdentityHashMap<>();
+
+ /** 从 JSON/legacy 配置读取并编译后的分组注册器。 */
+ private static List configuredProviders = List.of();
+
+ /** 防止每次重建都重复读取规则文件。 */
+ private static boolean rulesLoaded = false;
+
+ /**
+ * 一个可折叠的 EMI 侧栏分组。
+ *
+ *
+ * 分组保存显示名、成员列表、代表项以及展开状态。实际渲染仍由 EMI 完成,
+ * 本对象只描述“哪些 ingredient 属于同一组”和“当前应该折叠还是展开”。
+ *
+ */
+ public static class CollapsibleGroup {
+
+ /** 分组唯一标识,同时作为持久化状态的 key。 */
+ public final String guid;
+
+ /** tooltip 中显示的分组名称。新增分组时应使用 Component.translatable。 */
+ public Component displayName;
+
+ /** 当前 EMI 来源列表中属于该分组的成员,顺序沿用 EMI 侧栏原始顺序。 */
+ public final List members;
+
+ /** 分组的主代表项,通常是成员列表中的第一个 ingredient。 */
+ public EmiIngredient primaryRepresentative;
+
+ /** 分组的次代表项,折叠渲染时用于画出类似 GTNH NEI 的叠层图标。 */
+ public EmiIngredient secondaryRepresentative;
+
+ /**
+ * 创建一个空分组。
+ *
+ * @param guid 分组唯一标识,建议使用命名空间格式,例如 ctnhcore:tools/swords
+ */
+ public CollapsibleGroup(String guid) {
+ this.guid = guid;
+ this.members = new ArrayList<>();
+ this.displayName = Component.literal(guid);
+ this.primaryRepresentative = null;
+ this.secondaryRepresentative = null;
+ }
+
+ /**
+ * 查询分组是否处于展开状态。
+ *
+ * @return true 表示投影时显示所有成员,false 表示只显示一个代表项
+ */
+ public boolean isExpanded() {
+ Boolean state = EXPANDED_STATE.get(guid);
+ return state != null && state;
+ }
+
+ /**
+ * 设置分组展开状态,并立即保存到配置文件。
+ *
+ * @param expanded true 为展开,false 为折叠
+ */
+ public void setExpanded(boolean expanded) {
+ EXPANDED_STATE.put(guid, expanded);
+ saveStates();
+ }
+
+ /**
+ * 返回成员数量,用于 UI 统计或判断分组是否值得折叠。
+ */
+ public int memberCount() {
+ return members.size();
+ }
+ }
+
+ // ---- 公共 API:供 EMI mixin 查询状态、触发重建和响应交互 ----
+
+ /** 返回折叠功能总开关是否启用。 */
+ public static boolean isEnabled() {
+ return enabled;
+ }
+
+ /** 设置折叠功能总开关。 */
+ public static void setEnabled(boolean e) {
+ enabled = e;
+ }
+
+ /** 返回当前 EMI 来源列表是否需要重新扫描。 */
+ public static boolean needsRebuild() {
+ return dirty;
+ }
+
+ /** 标记分组需要重建,通常在 EMI 可见性变化或列表来源变化时调用。 */
+ public static void markDirty() {
+ dirty = true;
+ }
+
+ /**
+ * 根据 EMI 侧栏完整来源列表重建所有折叠组。
+ *
+ *
+ * 重建会清空旧的对象身份映射,再按当前列表重新注册成员。这样能保证搜索、重载、
+ * EMI 刷新后,{@link IdentityHashMap} 中保存的都是当前侧栏正在使用的 ingredient 对象。
+ *
+ *
+ * @param stacks EMI INDEX 侧栏的完整 ingredient 来源列表
+ */
+ public static void rebuild(List extends EmiIngredient> stacks) {
+ synchronized (GROUPS) {
+ loadStates();
+ GROUPS.clear();
+ STACK_TO_GROUP.clear();
+ REPRESENTATIVE_TO_GROUP.clear();
+ if (!enabled || stacks == null || stacks.isEmpty()) {
+ dirty = false;
+ return;
+ }
+
+ loadRules();
+ registerConfiguredGroups(stacks);
+ dirty = false;
+ }
+ }
+
+ /**
+ * 注册 JSON 配置中声明的折叠分组。
+ *
+ *
+ * 每个 ingredient 只会进入最高优先级的命中组;同优先级时保持 provider 注册顺序。
+ *
+ */
+ private static void registerConfiguredGroups(List extends EmiIngredient> stacks) {
+ List validProviders = new ArrayList<>();
+ for (CollapsibleGroupProvider provider : configuredProviders) {
+ if (provider.priority() < 0) {
+ CTNHCore.LOGGER.warn("Skipping EMI collapsible group {} because priority must be non-negative",
+ provider.guid());
+ continue;
+ }
+ validProviders.add(provider);
+ }
+
+ Map groups = new IdentityHashMap<>();
+ for (CollapsibleGroupProvider provider : validProviders) {
+ CollapsibleGroup group = provider.createGroup();
+ groups.put(provider, group);
+ }
+
+ for (EmiIngredient ingredient : stacks) {
+ CollapsibleGroupProvider bestProvider = null;
+ for (CollapsibleGroupProvider provider : validProviders) {
+ if (!provider.matches(ingredient)) continue;
+ if (bestProvider == null || provider.priority() > bestProvider.priority()) {
+ bestProvider = provider;
+ }
+ }
+ if (bestProvider != null) {
+ groups.get(bestProvider).members.add(ingredient);
+ }
+ }
+
+ for (CollapsibleGroupProvider provider : validProviders) {
+ registerGroup(groups.get(provider));
+ }
+ }
+
+ /**
+ * 根据分组 guid 生成显示名。
+ *
+ *
+ * 默认工具组沿用已有语言 key,例如 ctnhcore:tools/swords 会尝试读取
+ * ctnhcore.emi.collapsible.group.swords。自定义组没有语言条目时显示 guid 本身。
+ *
+ */
+ private static Component displayNameForGroup(String guid) {
+ CollapsibleGroupProvider provider = findProvider(guid);
+ if (provider != null) return provider.createDisplayName();
+ return fallbackDisplayName(guid);
+ }
+
+ private static Component fallbackDisplayName(String guid) {
+ ResourceLocation id = ResourceLocation.tryParse(guid);
+ String fallback = guid;
+ String path = id == null ? guid : id.getPath();
+ int slash = path.lastIndexOf('/');
+ String name = slash >= 0 ? path.substring(slash + 1) : path;
+ return Component.translatableWithFallback("ctnhcore.emi.collapsible.group." + name, fallback);
+ }
+
+ /**
+ * 将构造完成的分组写入全局索引。
+ *
+ *
+ * 少于两个成员的分组没有折叠价值,因此不会注册。注册成功后会建立 member -> guid 映射,
+ * 后续投影、tooltip 和点击逻辑都依赖这个映射。
+ *
+ */
+ private static void registerGroup(CollapsibleGroup group) {
+ if (group.members.size() < 2) return;
+
+ group.primaryRepresentative = group.members.get(0);
+ if (group.members.size() > 1) {
+ group.secondaryRepresentative = group.members.get(1);
+ }
+ GROUPS.put(group.guid, group);
+ for (EmiIngredient member : group.members) {
+ STACK_TO_GROUP.put(member, group.guid);
+ }
+ }
+
+ /**
+ * 将 EMI 原始侧栏列表投影成实际显示列表。
+ *
+ *
+ * 未分组项会原样通过;展开分组会显示所有仍存在于当前 source 中的成员;
+ * 折叠分组只显示当前遍历到的第一个成员,并把它记录为本轮投影的代表项。
+ *
+ *
+ * @param source EMI 当前要渲染的原始列表,可能已经受搜索过滤影响
+ * @return 投影后的列表,交给 EMI 继续渲染
+ */
+ public static List extends EmiIngredient> project(List extends EmiIngredient> source) {
+ if (!enabled || dirty || GROUPS.isEmpty()) {
+ return source;
+ }
+ synchronized (GROUPS) {
+ List result = new ArrayList<>();
+ Set projectedGroups = new HashSet<>();
+ Set sourceStacks = Collections.newSetFromMap(new IdentityHashMap<>());
+ sourceStacks.addAll(source);
+ REPRESENTATIVE_TO_GROUP.clear();
+
+ for (EmiIngredient stack : source) {
+ String guid = STACK_TO_GROUP.get(stack);
+ if (guid == null) {
+ result.add(stack);
+ continue;
+ }
+ CollapsibleGroup group = GROUPS.get(guid);
+ if (group == null) {
+ result.add(stack);
+ continue;
+ }
+ if (group.members.size() < 2) {
+ result.add(stack);
+ } else if (group.isExpanded()) {
+ if (projectedGroups.add(guid)) {
+ for (EmiIngredient member : group.members) {
+ if (sourceStacks.contains(member)) {
+ result.add(member);
+ }
+ }
+ }
+ } else {
+ if (projectedGroups.add(guid)) {
+ REPRESENTATIVE_TO_GROUP.put(stack, guid);
+ result.add(stack);
+ }
+ }
+ }
+ return result;
+ }
+ }
+
+ /**
+ * 根据 ingredient 查询所属分组。
+ *
+ *
+ * 该 ingredient 可以是普通成员,也可以是当前投影生成的折叠代表项。
+ *
+ */
+ @Nullable
+ public static CollapsibleGroup getGroup(EmiIngredient ingredient) {
+ String guid = STACK_TO_GROUP.get(ingredient);
+ if (guid == null) guid = REPRESENTATIVE_TO_GROUP.get(ingredient);
+ if (guid == null) return null;
+ return GROUPS.get(guid);
+ }
+
+ /** 判断 ingredient 是否是当前投影中的折叠代表项。 */
+ public static boolean isCollapsedRepresentative(EmiIngredient ingredient) {
+ return REPRESENTATIVE_TO_GROUP.containsKey(ingredient);
+ }
+
+ /** 判断 ingredient 是否属于任意已注册分组。 */
+ public static boolean isInGroup(EmiIngredient ingredient) {
+ return STACK_TO_GROUP.containsKey(ingredient);
+ }
+
+ /** 返回折叠代表项对应的次代表项,用于叠层绘制。 */
+ @Nullable
+ public static EmiIngredient getSecondaryRepresentative(EmiIngredient ingredient) {
+ CollapsibleGroup group = getGroup(ingredient);
+ if (group == null || !isCollapsedRepresentative(ingredient)) return null;
+ return group.secondaryRepresentative;
+ }
+
+ /**
+ * 按 guid 切换单个分组的展开状态。
+ *
+ * @param guid 分组唯一标识
+ */
+ public static void toggleGroup(String guid) {
+ CollapsibleGroup group = GROUPS.get(guid);
+ if (group != null) {
+ group.setExpanded(!group.isExpanded());
+ }
+ }
+
+ /**
+ * 从配置文件读取所有分组的展开状态。
+ *
+ *
+ * 读取失败只记录警告,不阻止 EMI 打开;无法读取时所有分组按默认折叠处理。
+ *
+ */
+ private static void loadStates() {
+ if (statesLoaded) return;
+ statesLoaded = true;
+ if (!Files.isRegularFile(STATE_FILE)) return;
+
+ try (Reader reader = Files.newBufferedReader(STATE_FILE)) {
+ JsonObject states = JsonParser.parseReader(reader).getAsJsonObject();
+ for (String key : states.keySet()) {
+ EXPANDED_STATE.put(key, states.get(key).getAsBoolean());
+ }
+ } catch (RuntimeException | IOException e) {
+ CTNHCore.LOGGER.warn("Failed to load EMI collapsible group states from {}", STATE_FILE, e);
+ }
+ }
+
+ /**
+ * 将当前展开状态写回配置文件。
+ *
+ *
+ * 只有状态文件已经完成初次读取后才会保存,避免初始化阶段写出不完整状态。
+ *
+ */
+ private static void saveStates() {
+ if (!statesLoaded) return;
+
+ try {
+ Files.createDirectories(STATE_FILE.getParent());
+ JsonObject states = new JsonObject();
+ for (Map.Entry entry : EXPANDED_STATE.entrySet()) {
+ states.addProperty(entry.getKey(), entry.getValue());
+ }
+ try (Writer writer = Files.newBufferedWriter(STATE_FILE)) {
+ GSON.toJson(states, writer);
+ }
+ } catch (IOException e) {
+ CTNHCore.LOGGER.warn("Failed to save EMI collapsible group states to {}", STATE_FILE, e);
+ }
+ }
+
+ /**
+ * 从 JSON 配置文件读取分组规则。
+ *
+ *
+ * 文件不存在时会写出接近 GTNH 默认项的配置。读取或解析失败不会阻止 EMI 打开,只会回退为空规则。
+ *
+ */
+ private static void loadRules() {
+ if (rulesLoaded) return;
+ rulesLoaded = true;
+ ensureDefaultRuleFile();
+
+ List definitions = new ArrayList<>();
+ addBuiltInTagGroups(definitions);
+ try (Reader reader = Files.newBufferedReader(RULE_FILE)) {
+ JsonElement root = JsonParser.parseReader(reader);
+ if (!root.isJsonObject()) {
+ CTNHCore.LOGGER.warn("EMI collapsible group rule file {} must be a JSON object", RULE_FILE);
+ configuredProviders = List.of();
+ return;
+ }
+
+ JsonObject groups = root.getAsJsonObject();
+ for (Map.Entry entry : groups.entrySet()) {
+ RuleGroupDefinition definition = parseGroupDefinition(entry.getKey(), entry.getValue());
+ if (definition != null && !definition.rules().isEmpty()) {
+ definitions.add(definition);
+ }
+ }
+ } catch (RuntimeException | IOException e) {
+ CTNHCore.LOGGER.warn("Failed to load EMI collapsible group rules from {}", RULE_FILE, e);
+ }
+ definitions.addAll(loadLegacyRuleFile());
+ configuredProviders = List.copyOf(definitions);
+ }
+
+ /** 注册整合包内置的标签型折叠组。 */
+ private static void addBuiltInTagGroups(List definitions) {
+ definitions.add(tagGroup("ctnhcore:blocks/logs", "ctnhcore.emi.collapsible.group.logs", "Logs",
+ "minecraft:logs"));
+ definitions.add(tagGroup("ctnhcore:blocks/stairs", "ctnhcore.emi.collapsible.group.stairs", "Stairs",
+ "minecraft:stairs"));
+ definitions.add(tagGroup("ctnhcore:blocks/slabs", "ctnhcore.emi.collapsible.group.slabs", "Slabs",
+ "minecraft:slabs"));
+ definitions.add(tagGroup("ctnhcore:blocks/fences", "ctnhcore.emi.collapsible.group.fences", "Fences",
+ "minecraft:fences"));
+ definitions.add(tagGroup("ctnhcore:blocks/fence_gates", "ctnhcore.emi.collapsible.group.fence_gates",
+ "Fence Gates", "minecraft:fence_gates"));
+ definitions.add(tagGroup("ctnhcore:blocks/doors", "ctnhcore.emi.collapsible.group.doors", "Doors",
+ "minecraft:doors"));
+ definitions.add(tagGroup("ctnhcore:blocks/trapdoors", "ctnhcore.emi.collapsible.group.trapdoors",
+ "Trapdoors", "minecraft:trapdoors"));
+ definitions.add(tagGroup("ctnhcore:blocks/pressure_plates",
+ "ctnhcore.emi.collapsible.group.pressure_plates", "Pressure Plates", "minecraft:pressure_plates"));
+ definitions.add(tagGroup("ctnhcore:blocks/buttons", "ctnhcore.emi.collapsible.group.buttons", "Buttons",
+ "minecraft:buttons"));
+ }
+
+ private static CollapsibleGroupProvider tagGroup(String guid, String translationKey, String fallbackName,
+ String tagId) {
+ ResourceLocation id = ResourceLocation.tryParse(tagId);
+ if (id == null) {
+ throw new IllegalArgumentException("Invalid built-in EMI collapsible tag id: " + tagId);
+ }
+ return new TagGroupDefinition(guid, translationKey, fallbackName, TagKey.create(Registries.ITEM, id),
+ TagKey.create(Registries.BLOCK, id));
+ }
+
+ /** 写出接近 GTNH NEI 默认折叠项的规则文件。 */
+ private static void ensureDefaultRuleFile() {
+ if (Files.isRegularFile(RULE_FILE)) return;
+
+ try {
+ Files.createDirectories(RULE_FILE.getParent());
+ JsonObject defaults = new JsonObject();
+ defaults.add("ctnhcore:spawn_eggs",
+ defaultGroup("ctnhcore.emi.collapsible.group.spawn_eggs", "regex:minecraft:.*_spawn_egg"));
+ defaults.add("ctnhcore:spawners",
+ defaultGroup("ctnhcore.emi.collapsible.group.spawners", "minecraft:spawner"));
+ defaults.add("ctnhcore:music_discs",
+ defaultGroup("ctnhcore.emi.collapsible.group.music_discs", "regex:minecraft:music_disc_.*"));
+ defaults.add("ctnhcore:splash_potions",
+ defaultGroup("ctnhcore.emi.collapsible.group.splash_potions", "minecraft:splash_potion"));
+ defaults.add("ctnhcore:lingering_potions",
+ defaultGroup("ctnhcore.emi.collapsible.group.lingering_potions", "minecraft:lingering_potion"));
+ defaults.add("ctnhcore:tools/swords",
+ defaultGroup("ctnhcore.emi.collapsible.group.swords", "#minecraft:swords", "#forge:tools/swords"));
+ defaults.add("ctnhcore:tools/pickaxes",
+ defaultGroup("ctnhcore.emi.collapsible.group.pickaxes", "#minecraft:pickaxes", "#forge:tools/pickaxes"));
+ defaults.add("ctnhcore:tools/axes",
+ defaultGroup("ctnhcore.emi.collapsible.group.axes", "#minecraft:axes", "#forge:tools/axes"));
+ defaults.add("ctnhcore:tools/shovels",
+ defaultGroup("ctnhcore.emi.collapsible.group.shovels", "#minecraft:shovels", "#forge:tools/shovels"));
+ defaults.add("ctnhcore:tools/hoes",
+ defaultGroup("ctnhcore.emi.collapsible.group.hoes", "#minecraft:hoes", "#forge:tools/hoes"));
+ try (Writer writer = Files.newBufferedWriter(RULE_FILE)) {
+ GSON.toJson(defaults, writer);
+ }
+ } catch (IOException e) {
+ CTNHCore.LOGGER.warn("Failed to create default EMI collapsible group rule file {}", RULE_FILE, e);
+ }
+ }
+
+ /** 构造默认规则对象。 */
+ private static JsonObject defaultGroup(String translationKey, String... rulesIn) {
+ JsonObject group = new JsonObject();
+ group.addProperty("translationKey", translationKey);
+ group.addProperty("priority", 0);
+ JsonArray rules = new JsonArray();
+ for (String rule : rulesIn) {
+ rules.add(rule);
+ }
+ group.add("rules", rules);
+ return group;
+ }
+
+ /** 读取可选的 GTNH/NEI 风格 collapsibleitems.cfg。 */
+ private static List loadLegacyRuleFile() {
+ if (!Files.isRegularFile(LEGACY_RULE_FILE)) return List.of();
+
+ List definitions = new ArrayList<>();
+ try {
+ List lines = Files.readAllLines(LEGACY_RULE_FILE);
+ JsonObject settings = new JsonObject();
+ int index = 0;
+ for (String rawLine : lines) {
+ String line = rawLine.trim();
+ if (line.isEmpty() || line.startsWith("#")) continue;
+
+ if (line.startsWith(";")) {
+ String json = line.substring(1).trim();
+ if (!json.isEmpty()) {
+ JsonElement element = JsonParser.parseString(json);
+ if (element.isJsonObject()) {
+ settings = element.getAsJsonObject();
+ }
+ }
+ continue;
+ }
+
+ String guid = "ctnhcore:legacy/" + index++;
+ JsonObject object = new JsonObject();
+ if (settings.has("displayName")) object.add("displayName", settings.get("displayName"));
+ if (settings.has("expanded")) object.add("expanded", settings.get("expanded"));
+ JsonArray rules = new JsonArray();
+ rules.add(line);
+ object.add("rules", rules);
+ RuleGroupDefinition definition = parseGroupDefinition(guid, object);
+ if (definition != null) definitions.add(definition);
+ settings = new JsonObject();
+ }
+ } catch (RuntimeException | IOException e) {
+ CTNHCore.LOGGER.warn("Failed to load legacy EMI collapsible group rules from {}", LEGACY_RULE_FILE, e);
+ }
+ return definitions;
+ }
+
+ @Nullable
+ private static CollapsibleGroupProvider findProvider(String guid) {
+ for (CollapsibleGroupProvider provider : configuredProviders) {
+ if (provider.guid().equals(guid)) return provider;
+ }
+ return null;
+ }
+
+ /** 解析单个 groupname: rule 定义。 */
+ @Nullable
+ private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement element) {
+ List rules = new ArrayList<>();
+ String displayName = null;
+ String translationKey = null;
+ Boolean expanded = null;
+ int priority = 0;
+ if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) {
+ addRule(guid, element.getAsString(), rules);
+ } else if (element.isJsonArray()) {
+ for (JsonElement ruleElement : element.getAsJsonArray()) {
+ if (ruleElement.isJsonPrimitive() && ruleElement.getAsJsonPrimitive().isString()) {
+ addRule(guid, ruleElement.getAsString(), rules);
+ } else {
+ CTNHCore.LOGGER.warn("Ignoring non-string EMI collapsible rule in group {}", guid);
+ }
+ }
+ } else if (element.isJsonObject()) {
+ JsonObject object = element.getAsJsonObject();
+ if (object.has("displayName")) displayName = object.get("displayName").getAsString();
+ if (object.has("translationKey")) translationKey = object.get("translationKey").getAsString();
+ if (object.has("expanded")) expanded = object.get("expanded").getAsBoolean();
+ if (object.has("priority")) {
+ priority = object.get("priority").getAsInt();
+ if (priority < 0) {
+ CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because priority must be non-negative",
+ guid);
+ return null;
+ }
+ }
+ JsonElement rulesElement = object.get("rules");
+ if (rulesElement == null) {
+ CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because it has no rules", guid);
+ } else if (rulesElement.isJsonPrimitive() && rulesElement.getAsJsonPrimitive().isString()) {
+ addRule(guid, rulesElement.getAsString(), rules);
+ } else if (rulesElement.isJsonArray()) {
+ for (JsonElement ruleElement : rulesElement.getAsJsonArray()) {
+ if (ruleElement.isJsonPrimitive() && ruleElement.getAsJsonPrimitive().isString()) {
+ addRule(guid, ruleElement.getAsString(), rules);
+ } else {
+ CTNHCore.LOGGER.warn("Ignoring non-string EMI collapsible rule in group {}", guid);
+ }
+ }
+ } else {
+ CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because rules is not a string or string array",
+ guid);
+ }
+ } else {
+ CTNHCore.LOGGER.warn(
+ "Ignoring EMI collapsible group {} because its rule is not a string, string array, or object",
+ guid);
+ }
+
+ if (rules.isEmpty()) return null;
+ if (expanded != null && !EXPANDED_STATE.containsKey(guid)) {
+ EXPANDED_STATE.put(guid, expanded);
+ }
+ return new RuleGroupDefinition(guid, displayName, translationKey, priority, List.copyOf(rules));
+ }
+
+ /** 编译一个规则字符串。 */
+ private static void addRule(String guid, String rule, List rules) {
+ rule = rule.trim();
+ if (rule.isEmpty()) return;
+ if (rule.startsWith("#")) {
+ ResourceLocation tagId = ResourceLocation.tryParse(rule.substring(1));
+ if (tagId == null) {
+ CTNHCore.LOGGER.warn("Ignoring invalid EMI collapsible tag rule {} in group {}", rule, guid);
+ return;
+ }
+ rules.add(tagRule(tagId));
+ return;
+ }
+
+ if (rule.startsWith("regex:")) {
+ String pattern = rule.substring("regex:".length());
+ try {
+ rules.add(new RegexRule(Pattern.compile(pattern)));
+ } catch (PatternSyntaxException e) {
+ CTNHCore.LOGGER.warn("Ignoring invalid EMI collapsible regex rule {} in group {}", rule, guid, e);
+ }
+ return;
+ }
+
+ try {
+ rules.add(parseExpressionRule(rule));
+ } catch (IllegalArgumentException e) {
+ CTNHCore.LOGGER.warn(
+ "Ignoring EMI collapsible rule {} in group {}; expected #tag, regex:, or item filter",
+ rule, guid, e);
+ }
+ }
+
+ private static GroupRule parseExpressionRule(String rule) {
+ List alternatives = new ArrayList<>();
+ for (String part : rule.split("\\|")) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ alternatives.add(parseAllRule(trimmed));
+ }
+ }
+ if (alternatives.isEmpty()) throw new IllegalArgumentException("Empty rule");
+ if (alternatives.size() == 1) return alternatives.get(0);
+ return new AnyRule(List.copyOf(alternatives));
+ }
+
+ private static GroupRule parseAllRule(String rule) {
+ String[] tokens = rule.split("\\s+");
+ List rules = new ArrayList<>();
+ for (String token : tokens) {
+ if (!token.isBlank()) {
+ rules.add(parseTokenRule(token));
+ }
+ }
+ if (rules.isEmpty()) throw new IllegalArgumentException("Empty rule");
+ if (rules.size() == 1) return rules.get(0);
+ return new AllRule(List.copyOf(rules));
+ }
+
+ private static GroupRule parseTokenRule(String token) {
+ List alternatives = new ArrayList<>();
+ for (String part : token.split(",")) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ alternatives.add(parseSingleRule(trimmed));
+ }
+ }
+ if (alternatives.isEmpty()) throw new IllegalArgumentException("Empty token");
+ if (alternatives.size() == 1) return alternatives.get(0);
+ return new AnyRule(List.copyOf(alternatives));
+ }
+
+ private static GroupRule parseSingleRule(String token) {
+ if (token.startsWith("!")) {
+ return new NotRule(parseSingleRule(token.substring(1)));
+ }
+ if (token.startsWith("$")) {
+ ResourceLocation tagId = ResourceLocation.tryParse("forge:" + token.substring(1).toLowerCase(Locale.ROOT));
+ if (tagId == null) throw new IllegalArgumentException("Invalid ore/tag token " + token);
+ return tagRule(tagId);
+ }
+ if (token.startsWith("#")) {
+ ResourceLocation tagId = ResourceLocation.tryParse(token.substring(1));
+ if (tagId == null) throw new IllegalArgumentException("Invalid tag token " + token);
+ return tagRule(tagId);
+ }
+ if (token.startsWith("r/") && token.endsWith("/") && token.length() > 3) {
+ return new RegexRule(Pattern.compile(token.substring(2, token.length() - 1)));
+ }
+ if (token.matches("\\d+(?:-\\d+)?")) {
+ return parseDamageRule(token);
+ }
+
+ ResourceLocation id = ResourceLocation.tryParse(token);
+ if (id == null) {
+ throw new IllegalArgumentException("Invalid item id " + token);
+ }
+ return new ItemIdRule(id);
+ }
+
+ private static TagRule tagRule(ResourceLocation tagId) {
+ return new TagRule(TagKey.create(Registries.ITEM, tagId), TagKey.create(Registries.BLOCK, tagId));
+ }
+
+ private static GroupRule parseDamageRule(String token) {
+ if (token.contains("-")) {
+ String[] parts = token.split("-", 2);
+ return new DamageRule(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
+ }
+ int damage = Integer.parseInt(token);
+ return new DamageRule(damage, damage);
+ }
+
+ /**
+ * 根据鼠标悬停到的 ingredient 切换所属分组。
+ *
+ *
+ * 输入通常来自 EMI 的 hover 检测,可能是折叠代表项,也可能是展开后的普通成员。
+ *
+ *
+ * @return true 表示找到并切换了分组,调用方应刷新 EMI 面板
+ */
+ public static boolean toggleGroup(EmiIngredient representative) {
+ String guid = REPRESENTATIVE_TO_GROUP.get(representative);
+ if (guid == null) guid = STACK_TO_GROUP.get(representative);
+ if (guid != null) {
+ toggleGroup(guid);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * 批量切换所有有效分组。
+ *
+ *
+ * 左键使用智能模式:只要还有任意折叠组,就展开全部;否则折叠全部。
+ * 右键会传入 forceCollapse=true,始终折叠全部。
+ *
+ */
+ public static void toggleAll(boolean forceCollapse) {
+ boolean anyCollapsed = false;
+ synchronized (GROUPS) {
+ for (CollapsibleGroup group : GROUPS.values()) {
+ if (group.members.size() >= 2 && !group.isExpanded()) {
+ anyCollapsed = true;
+ break;
+ }
+ }
+ boolean expand = !forceCollapse && anyCollapsed;
+ for (CollapsibleGroup group : GROUPS.values()) {
+ if (group.members.size() >= 2) {
+ group.setExpanded(expand);
+ }
+ }
+ }
+ }
+
+ /** 统计当前处于折叠状态、且成员数不少于 2 的分组数量。 */
+ public static int collapsedGroupCount() {
+ int count = 0;
+ synchronized (GROUPS) {
+ for (CollapsibleGroup group : GROUPS.values()) {
+ if (group.members.size() >= 2 && !group.isExpanded()) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ /** 统计所有有效分组数量。有效分组指成员数不少于 2,确实能被折叠的分组。 */
+ public static int totalGroupCount() {
+ int count = 0;
+ synchronized (GROUPS) {
+ for (CollapsibleGroup group : GROUPS.values()) {
+ if (group.members.size() >= 2) count++;
+ }
+ }
+ return count;
+ }
+
+ /** 判断当前是否存在至少一个有效折叠组。 */
+ public static boolean hasGroups() {
+ if (GROUPS.isEmpty()) return false;
+ synchronized (GROUPS) {
+ for (CollapsibleGroup group : GROUPS.values()) {
+ if (group.members.size() >= 2) return true;
+ }
+ }
+ return false;
+ }
+
+ /** 创建折叠组并注册其中物品的通用接口。 */
+ private interface CollapsibleGroupProvider {
+
+ String guid();
+
+ Component createDisplayName();
+
+ default int priority() {
+ return 0;
+ }
+
+ boolean matches(EmiIngredient ingredient);
+
+ default CollapsibleGroup createGroup() {
+ CollapsibleGroup group = new CollapsibleGroup(guid());
+ group.displayName = createDisplayName();
+ return group;
+ }
+ }
+
+ /** 基于配置规则的折叠组定义。 */
+ private record RuleGroupDefinition(String guid, @Nullable String displayName, @Nullable String translationKey,
+ int priority, List rules) implements CollapsibleGroupProvider {
+
+ @Override
+ public Component createDisplayName() {
+ if (translationKey != null && !translationKey.isBlank()) {
+ return Component.translatableWithFallback(translationKey, guid);
+ }
+ if (displayName != null && !displayName.isBlank()) {
+ return Component.literal(displayName);
+ }
+ return fallbackDisplayName(guid);
+ }
+
+ @Override
+ public int priority() {
+ return priority;
+ }
+
+ @Override
+ public boolean matches(EmiIngredient ingredient) {
+ for (EmiStack stack : ingredient.getEmiStacks()) {
+ ItemStack itemStack = stack.getItemStack();
+ if (!itemStack.isEmpty() && matchesItem(itemStack)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean matchesItem(ItemStack stack) {
+ for (GroupRule rule : rules) {
+ if (rule.matches(stack)) return true;
+ }
+ return false;
+ }
+ }
+
+ /** 基于物品或方块标签的代码型折叠组定义。 */
+ private record TagGroupDefinition(String guid, String translationKey, String fallbackName,
+ TagKey- itemTag, TagKey blockTag) implements CollapsibleGroupProvider {
+
+ @Override
+ public Component createDisplayName() {
+ return Component.translatableWithFallback(translationKey, fallbackName);
+ }
+
+ @Override
+ public boolean matches(EmiIngredient ingredient) {
+ for (EmiStack stack : ingredient.getEmiStacks()) {
+ ItemStack itemStack = stack.getItemStack();
+ if (!itemStack.isEmpty() && matchesTag(itemStack, itemTag, blockTag)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ /** 单条分组规则。 */
+ private interface GroupRule {
+
+ boolean matches(ItemStack stack);
+ }
+
+ private record AnyRule(List rules) implements GroupRule {
+
+ @Override
+ public boolean matches(ItemStack stack) {
+ for (GroupRule rule : rules) {
+ if (rule.matches(stack)) return true;
+ }
+ return false;
+ }
+ }
+
+ private record AllRule(List rules) implements GroupRule {
+
+ @Override
+ public boolean matches(ItemStack stack) {
+ for (GroupRule rule : rules) {
+ if (!rule.matches(stack)) return false;
+ }
+ return true;
+ }
+ }
+
+ private record NotRule(GroupRule rule) implements GroupRule {
+
+ @Override
+ public boolean matches(ItemStack stack) {
+ return !rule.matches(stack);
+ }
+ }
+
+ /** item/block tag 规则。 */
+ private record TagRule(TagKey
- itemTag, TagKey blockTag) implements GroupRule {
+
+ @Override
+ public boolean matches(ItemStack stack) {
+ return matchesTag(stack, itemTag, blockTag);
+ }
+ }
+
+ private static boolean matchesTag(ItemStack stack, TagKey
- itemTag, TagKey blockTag) {
+ if (stack.is(itemTag)) return true;
+ return stack.getItem() instanceof BlockItem blockItem && blockItem.getBlock().defaultBlockState().is(blockTag);
+ }
+
+ /** 物品注册 id 正则规则。 */
+ private record RegexRule(Pattern pattern) implements GroupRule {
+
+ @Override
+ public boolean matches(ItemStack stack) {
+ ResourceLocation id = ForgeRegistries.ITEMS.getKey(stack.getItem());
+ return id != null && pattern.matcher(id.toString()).matches();
+ }
+ }
+
+ /** 物品 id 规则;如果完整 id 不存在,则按注册 id 前缀匹配,兼容 GTNH 的 minecraft:record_ 风格。 */
+ private record ItemIdRule(ResourceLocation id) implements GroupRule {
+
+ @Override
+ public boolean matches(ItemStack stack) {
+ ResourceLocation stackId = ForgeRegistries.ITEMS.getKey(stack.getItem());
+ if (stackId == null || !Objects.equals(stackId.getNamespace(), id.getNamespace())) return false;
+ if (Objects.equals(stackId, id)) return true;
+
+ Item item = ForgeRegistries.ITEMS.getValue(id);
+ return item == null || item == Items.AIR ? stackId.getPath().startsWith(id.getPath()) : false;
+ }
+ }
+
+ /** 物品 damage 规则。1.20 中很多物品不再使用 damage 子类型,但保留该语法以接近 GTNH 配置。 */
+ private record DamageRule(int min, int max) implements GroupRule {
+
+ @Override
+ public boolean matches(ItemStack stack) {
+ int damage = stack.getDamageValue();
+ return damage >= min && damage <= max;
+ }
+ }
+}
diff --git a/src/main/resources/ctnhcore.mixins.json b/src/main/resources/ctnhcore.mixins.json
index 256fe20d..dca393f4 100644
--- a/src/main/resources/ctnhcore.mixins.json
+++ b/src/main/resources/ctnhcore.mixins.json
@@ -28,7 +28,9 @@
"emi.CreateJEIMixin$CategoryBuilderMixin",
"emi.EmiApiTagExpandMixin",
"emi.EmiRecipesMixin",
+ "emi.EmiScreenManagerInputMixin",
"emi.EmiScreenManagerMixin",
+ "emi.EmiScreenManagerScreenSpaceMixin",
"emi.EmiSearchMixin",
"emi.EmiTagsMixin",
"emi.GTRecipeEMICategoryMixin",
From 0f444421cbaa04d4ed508660224316510d039606 Mon Sep 17 00:00:00 2001
From: m1Riss <3081974632@qq.com>
Date: Tue, 16 Jun 2026 23:59:20 +0800
Subject: [PATCH 2/2] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E4=BA=86emi?=
=?UTF-8?q?=E6=8A=98=E5=8F=A0=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../resources/assets/ctnhcore/lang/en_ud.json | 18 +-
.../resources/assets/ctnhcore/lang/en_us.json | 18 +-
.../resources/assets/ctnhcore/lang/zh_cn.json | 18 +-
.../data/lang/old/ChineseLangHandler.java | 12 +-
.../data/lang/old/EnglishLangHandler.java | 12 +-
.../mixin/emi/EmiScreenManagerInputMixin.java | 90 +--
.../emi/EmiScreenManagerScreenSpaceMixin.java | 184 ++-----
.../collapsible/CTNHCollapsibleGroups.java | 517 ++++--------------
.../ctnhcore/emi/emi_collapsible_groups.json | 231 ++++++++
9 files changed, 403 insertions(+), 697 deletions(-)
create mode 100644 src/main/resources/assets/ctnhcore/emi/emi_collapsible_groups.json
diff --git a/src/generated/resources/assets/ctnhcore/lang/en_ud.json b/src/generated/resources/assets/ctnhcore/lang/en_ud.json
index c63f80b3..799f8d52 100644
--- a/src/generated/resources/assets/ctnhcore/lang/en_ud.json
+++ b/src/generated/resources/assets/ctnhcore/lang/en_ud.json
@@ -48,21 +48,6 @@
"block.ctnhcore.creative_item_input_bus": "snᗺ ʇnduI ɯǝʇI ǝʌıʇɐǝɹƆ",
"block.ctnhcore.creative_laser_hatch": "ɥɔʇɐH ʇnduI ɹǝsɐꞀ ǝʌıʇɐǝɹƆ",
"block.ctnhcore.cryotheum_freezer": "ɹǝzǝǝɹℲ ɯnǝɥʇoʎɹƆ",
- "ctnhcore.emi.collapsible.button.collapse_all": ")s% 个分组(部全叠折:键左",
- "ctnhcore.emi.collapsible.button.collapse_all.right_click": "部全叠折:键右",
- "ctnhcore.emi.collapsible.button.expand_all": ")s% 个已折叠(部全开展:键左",
- "ctnhcore.emi.collapsible.group.axes": "sǝxⱯ",
- "ctnhcore.emi.collapsible.group.hoes": "sǝoH",
- "ctnhcore.emi.collapsible.group.lingering_potions": "suoıʇoԀ ƃuıɹǝƃuıꞀ",
- "ctnhcore.emi.collapsible.group.music_discs": "sɔsıᗡ ɔısnW",
- "ctnhcore.emi.collapsible.group.pickaxes": "sǝxɐʞɔıԀ",
- "ctnhcore.emi.collapsible.group.shovels": "sןǝʌoɥS",
- "ctnhcore.emi.collapsible.group.spawn_eggs": "sƃƃƎ uʍɐdS",
- "ctnhcore.emi.collapsible.group.spawners": "sɹǝuʍɐdS",
- "ctnhcore.emi.collapsible.group.splash_potions": "suoıʇoԀ ɥsɐןdS",
- "ctnhcore.emi.collapsible.group.swords": "spɹoʍS",
- "ctnhcore.emi.collapsible.tooltip.group": "s%:组叠折",
- "ctnhcore.emi.collapsible.tooltip.toggle_hint": "组此叠折/开展:键左 + tlA",
"block.ctnhcore.crystallizer": "ɹǝzıןןɐʇsʎɹƆ",
"block.ctnhcore.cultivationroom": "ɯooɹuoıʇɐʌıʇןnƆ",
"block.ctnhcore.dark_blue_elevator_casing": "buısɐƆ ɹoʇɐʌǝןƎ ǝnןᗺ ʞɹɐᗡ",
@@ -606,6 +591,9 @@
"ctnhcore.ctnhitems.heavy_plate_t3.tooltip": "Ɛ⟘ㄥ§",
"ctnhcore.ctnhitems.heavy_plate_t4.tooltip": "ㄣ⟘ㄥ§",
"ctnhcore.ctnhitems.radioactive_waste.tooltip": "ʇuǝıɔıɟɟnsuı sı pǝǝds ǝɥʇ uǝɥʍ ǝʇsɐʍ ǝɔnpoɹd ןןıʍ ɹoʇɐʌıʇɔⱯ uoɹʇnǝNㄥ§",
+ "ctnhcore.emi.collapsible.button.collapse_all": ")s% 个分组(部全叠折:键左",
+ "ctnhcore.emi.collapsible.button.collapse_all.right_click": "部全叠折:键右",
+ "ctnhcore.emi.collapsible.button.expand_all": ")s% 个已折叠(部全开展:键左",
"ctnhcore.hugeitembuspartmachine.refund_item.tooltip": "ʇuoɹɟ uı ɹǝuıɐʇuoɔ ǝɥʇ oʇ sɯǝʇı ןɐ suɹnʇǝᴚ",
"ctnhcore.largenaquadahreactormachine.power": "%s :ɹǝʍoԀ",
"ctnhcore.machine.digital_miner.tooltip.0": "sǝɹo buıuıɯ ʎןuo 'sǝdıd buıuıɯ ou puɐ pǝǝds ɹǝʇsɐɟ 'sbuıɥ⟘W⟘⅁ ɯoɹℲㄥ§",
diff --git a/src/generated/resources/assets/ctnhcore/lang/en_us.json b/src/generated/resources/assets/ctnhcore/lang/en_us.json
index 49a55542..e26520ba 100644
--- a/src/generated/resources/assets/ctnhcore/lang/en_us.json
+++ b/src/generated/resources/assets/ctnhcore/lang/en_us.json
@@ -48,21 +48,6 @@
"block.ctnhcore.creative_item_input_bus": "Creative Item Input Bus",
"block.ctnhcore.creative_laser_hatch": "Creative Laser Input Hatch",
"block.ctnhcore.cryotheum_freezer": "Cryotheum Freezer",
- "ctnhcore.emi.collapsible.button.collapse_all": "Left click: collapse all (%s groups)",
- "ctnhcore.emi.collapsible.button.collapse_all.right_click": "Right click: collapse all",
- "ctnhcore.emi.collapsible.button.expand_all": "Left click: expand all (%s collapsed)",
- "ctnhcore.emi.collapsible.group.axes": "Axes",
- "ctnhcore.emi.collapsible.group.hoes": "Hoes",
- "ctnhcore.emi.collapsible.group.lingering_potions": "Lingering Potions",
- "ctnhcore.emi.collapsible.group.music_discs": "Music Discs",
- "ctnhcore.emi.collapsible.group.pickaxes": "Pickaxes",
- "ctnhcore.emi.collapsible.group.shovels": "Shovels",
- "ctnhcore.emi.collapsible.group.spawn_eggs": "Spawn Eggs",
- "ctnhcore.emi.collapsible.group.spawners": "Spawners",
- "ctnhcore.emi.collapsible.group.splash_potions": "Splash Potions",
- "ctnhcore.emi.collapsible.group.swords": "Swords",
- "ctnhcore.emi.collapsible.tooltip.group": "Collapsed group: %s",
- "ctnhcore.emi.collapsible.tooltip.toggle_hint": "Alt + left click: expand/collapse this group",
"block.ctnhcore.crystallizer": "Crystallizer",
"block.ctnhcore.cultivationroom": "Cultivationroom",
"block.ctnhcore.dark_blue_elevator_casing": "Dark Blue Elevator Casing",
@@ -606,6 +591,9 @@
"ctnhcore.ctnhitems.heavy_plate_t3.tooltip": "§7T3",
"ctnhcore.ctnhitems.heavy_plate_t4.tooltip": "§7T4",
"ctnhcore.ctnhitems.radioactive_waste.tooltip": "§7Neutron Activator will produce waste when the speed is insufficient",
+ "ctnhcore.emi.collapsible.button.collapse_all": "Left click: collapse all (%s groups)",
+ "ctnhcore.emi.collapsible.button.collapse_all.right_click": "Right click: collapse all",
+ "ctnhcore.emi.collapsible.button.expand_all": "Left click: expand all (%s collapsed)",
"ctnhcore.hugeitembuspartmachine.refund_item.tooltip": "Returns al items to the container in front",
"ctnhcore.largenaquadahreactormachine.power": "Power: %s",
"ctnhcore.machine.digital_miner.tooltip.0": "§7From GTMThings, faster speed and no mining pipes, only mining ores",
diff --git a/src/generated/resources/assets/ctnhcore/lang/zh_cn.json b/src/generated/resources/assets/ctnhcore/lang/zh_cn.json
index aa370919..530186f0 100644
--- a/src/generated/resources/assets/ctnhcore/lang/zh_cn.json
+++ b/src/generated/resources/assets/ctnhcore/lang/zh_cn.json
@@ -48,21 +48,6 @@
"block.ctnhcore.creative_item_input_bus": "创造模式输入总线",
"block.ctnhcore.creative_laser_hatch": "创造模式激光靶仓",
"block.ctnhcore.cryotheum_freezer": "凛冰冷冻机",
- "ctnhcore.emi.collapsible.button.collapse_all": "左键:折叠全部(%s 个分组)",
- "ctnhcore.emi.collapsible.button.collapse_all.right_click": "右键:折叠全部",
- "ctnhcore.emi.collapsible.button.expand_all": "左键:展开全部(%s 个已折叠)",
- "ctnhcore.emi.collapsible.group.axes": "斧",
- "ctnhcore.emi.collapsible.group.hoes": "锄",
- "ctnhcore.emi.collapsible.group.lingering_potions": "滞留型药水",
- "ctnhcore.emi.collapsible.group.music_discs": "音乐唱片",
- "ctnhcore.emi.collapsible.group.pickaxes": "镐",
- "ctnhcore.emi.collapsible.group.shovels": "锹",
- "ctnhcore.emi.collapsible.group.spawn_eggs": "刷怪蛋",
- "ctnhcore.emi.collapsible.group.spawners": "刷怪笼",
- "ctnhcore.emi.collapsible.group.splash_potions": "喷溅型药水",
- "ctnhcore.emi.collapsible.group.swords": "剑",
- "ctnhcore.emi.collapsible.tooltip.group": "折叠组:%s",
- "ctnhcore.emi.collapsible.tooltip.toggle_hint": "Alt + 左键:展开/折叠此组",
"block.ctnhcore.crystallizer": "结晶器",
"block.ctnhcore.cultivationroom": "培养室",
"block.ctnhcore.dark_blue_elevator_casing": "深蓝色电梯机械方块",
@@ -829,6 +814,9 @@
"ctnhcore.ctnhitems.heavy_plate_t3.tooltip": "§73阶",
"ctnhcore.ctnhitems.heavy_plate_t4.tooltip": "§74阶",
"ctnhcore.ctnhitems.radioactive_waste.tooltip": "§7中子活化器在速度不达标时运行配方会产生废料",
+ "ctnhcore.emi.collapsible.button.collapse_all": "左键:折叠全部(%s 个分组)",
+ "ctnhcore.emi.collapsible.button.collapse_all.right_click": "右键:折叠全部",
+ "ctnhcore.emi.collapsible.button.expand_all": "左键:展开全部(%s 个已折叠)",
"ctnhcore.hugeitembuspartmachine.refund_item.tooltip": "返还所有物品到面前的容器中",
"ctnhcore.largenaquadahreactormachine.power": "发电倍率: %s",
"ctnhcore.machine.digital_miner.tooltip.0": "§7来自GTMThings的挖矿黑科技,速度更快且无采矿管道,仅挖取矿石",
diff --git a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java
index c27bacd1..7b839163 100644
--- a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java
+++ b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java
@@ -46,15 +46,9 @@ public static void init(RegistrateCNLangProvider provider) {
"缺少足够能量以启动核聚变反应");
provider.add("gtceu.recipe_modifier.coil_temperature_too_low", "线圈温度过低!");
- provider.add("ctnhcore.emi.collapsible.group.logs", "原木");
- provider.add("ctnhcore.emi.collapsible.group.stairs", "楼梯");
- provider.add("ctnhcore.emi.collapsible.group.slabs", "台阶");
- provider.add("ctnhcore.emi.collapsible.group.fences", "栅栏");
- provider.add("ctnhcore.emi.collapsible.group.fence_gates", "栅栏门");
- provider.add("ctnhcore.emi.collapsible.group.doors", "门");
- provider.add("ctnhcore.emi.collapsible.group.trapdoors", "活板门");
- provider.add("ctnhcore.emi.collapsible.group.pressure_plates", "压力板");
- provider.add("ctnhcore.emi.collapsible.group.buttons", "按钮");
+ provider.add("ctnhcore.emi.collapsible.button.expand_all", "左键:展开全部(%s 个已折叠)");
+ provider.add("ctnhcore.emi.collapsible.button.collapse_all", "左键:折叠全部(%s 个分组)");
+ provider.add("ctnhcore.emi.collapsible.button.collapse_all.right_click", "右键:折叠全部");
// Config
provider.add("config.ctnhcore.option.ftbPlugin", "FTB相关");
diff --git a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java
index b05eba97..7cf50d2d 100644
--- a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java
+++ b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java
@@ -33,15 +33,9 @@ public static void init(RegistrateLangProvider provider) {
provider.add("config.jade.plugin_ctnhcore.recipe_logic_provider", "Recipe Logic Info");
provider.add("config.jade.plugin_ctnhcore.recipe_output_provider", "Recipe Output Info");
- provider.add("ctnhcore.emi.collapsible.group.logs", "Logs");
- provider.add("ctnhcore.emi.collapsible.group.stairs", "Stairs");
- provider.add("ctnhcore.emi.collapsible.group.slabs", "Slabs");
- provider.add("ctnhcore.emi.collapsible.group.fences", "Fences");
- provider.add("ctnhcore.emi.collapsible.group.fence_gates", "Fence Gates");
- provider.add("ctnhcore.emi.collapsible.group.doors", "Doors");
- provider.add("ctnhcore.emi.collapsible.group.trapdoors", "Trapdoors");
- provider.add("ctnhcore.emi.collapsible.group.pressure_plates", "Pressure Plates");
- provider.add("ctnhcore.emi.collapsible.group.buttons", "Buttons");
+ provider.add("ctnhcore.emi.collapsible.button.expand_all", "Left click: expand all (%s collapsed)");
+ provider.add("ctnhcore.emi.collapsible.button.collapse_all", "Left click: collapse all (%s groups)");
+ provider.add("ctnhcore.emi.collapsible.button.collapse_all.right_click", "Right click: collapse all");
// Recipe Types
provider.add("gtceu.underfloor_heating_system", "Underfloor Heating");
diff --git a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java
index 398d1b9e..b37caa96 100644
--- a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java
+++ b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java
@@ -1,16 +1,12 @@
package io.github.cpearl0.ctnhcore.mixin.emi;
import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups;
-import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups.CollapsibleGroup;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
-import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent;
import net.minecraft.network.chat.Component;
-import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
-import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import dev.emi.emi.api.stack.EmiIngredient;
import dev.emi.emi.api.stack.EmiStackInteraction;
import dev.emi.emi.config.SidebarType;
@@ -27,22 +23,10 @@
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
-import java.util.ArrayList;
import java.util.List;
/**
- * 注入 EMI 的 {@link EmiScreenManager},负责折叠组的输入与提示层逻辑。
- *
- *
- * 本 mixin 不决定哪些物品属于同一组;分组数据由 {@link CTNHCollapsibleGroups} 管理。
- * 这里负责把玩家输入和 EMI 生命周期事件转发给分组管理器,包括:
- *
- *
- * - Alt + 左键点击单个分组代表项时展开或折叠该组。
- * - 在搜索框旁绘制 G 按钮,用于批量展开或折叠所有分组。
- * - 在 EMI 搜索来源刷新时触发分组重建。
- * - 给折叠代表项 tooltip 追加分组名和操作提示。
- *
+ * 处理 EMI 折叠组的输入:单组切换、全部切换按钮和列表重建。
*/
@Mixin(value = EmiScreenManager.class, remap = false)
public class EmiScreenManagerInputMixin {
@@ -71,25 +55,16 @@ public class EmiScreenManagerInputMixin {
@Unique
private static boolean ctnhcore$hoveredToggleBtn = false;
- /**
- * 处理折叠组相关鼠标点击。
- *
- *
- * 注入在 {@code mouseClicked} 开头,先于 EMI 原生点击逻辑执行。这样当玩家点击 G 按钮
- * 或 Alt + 左键点击分组项时,可以消费本次事件,避免 EMI 同时打开配方或执行其他默认动作。
- *
- */
+ /** 处理 G 按钮点击,以及 Alt + 左键切换单个分组。 */
@Inject(method = "mouseClicked", at = @At("HEAD"), cancellable = true)
private static void ctnhcore$handleMouseClicked(double mouseX, double mouseY, int button,
CallbackInfoReturnable cir) {
- if (!CTNHCollapsibleGroups.isEnabled()) return;
if (CTNHCollapsibleGroups.needsRebuild()) return;
if (!CTNHCollapsibleGroups.hasGroups()) return;
int mx = (int) mouseX;
int my = (int) mouseY;
- // 先检查 G 按钮,避免按钮区域被误判为侧栏物品点击。
if (ctnhcore$toggleBtnX >= 0 && ctnhcore$toggleBtnY >= 0 && mx >= ctnhcore$toggleBtnX &&
mx < ctnhcore$toggleBtnX + TOGGLE_BUTTON_SIZE && my >= ctnhcore$toggleBtnY &&
my < ctnhcore$toggleBtnY + TOGGLE_BUTTON_SIZE) {
@@ -103,7 +78,6 @@ public class EmiScreenManagerInputMixin {
return;
}
- // Alt + 左键点击 INDEX 侧栏中的分组项时,切换该项所属分组。
if (button == 0 && Screen.hasAltDown()) {
EmiStackInteraction interaction = EmiScreenManager.getHoveredStack(mx, my, false);
EmiIngredient hovered = interaction.getStack();
@@ -116,19 +90,11 @@ public class EmiScreenManagerInputMixin {
}
}
- /**
- * 在 EMI 小部件渲染结束后绘制 G 按钮。
- *
- *
- * 按钮位于实时搜索框右侧。左键为智能展开/折叠全部:存在折叠组时展开全部,
- * 否则折叠全部;右键始终折叠全部。没有有效分组时会隐藏按钮并清空 hover 状态。
- *
- */
+ /** 在搜索框右侧绘制 G 按钮:左键智能切换,右键全部折叠。 */
@Inject(method = "renderWidgets", at = @At("TAIL"))
private static void ctnhcore$renderToggleButton(EmiDrawContext context, int mouseX, int mouseY,
float delta, EmiScreenBase base,
CallbackInfo ci) {
- if (!CTNHCollapsibleGroups.isEnabled()) return;
if (CTNHCollapsibleGroups.needsRebuild()) return;
if (!CTNHCollapsibleGroups.hasGroups()) {
ctnhcore$toggleBtnX = -1;
@@ -180,17 +146,9 @@ public class EmiScreenManagerInputMixin {
}
}
- /**
- * 在 EMI 获取搜索来源后触发折叠组重建。
- *
- *
- * 这里故意使用 {@link EmiSidebars#getStacks(SidebarType)} 的 INDEX 完整来源,而不是搜索过滤后的
- * 返回值。这样分组成员来自完整 EMI 列表,搜索时再由投影逻辑只显示当前搜索结果中存在的成员。
- *
- */
+ /** EMI 刷新搜索来源时,用 INDEX 完整列表重建分组。 */
@Inject(method = "getSearchSource", at = @At("RETURN"))
private static void ctnhcore$rebuildOnSearch(CallbackInfoReturnable> cir) {
- if (!CTNHCollapsibleGroups.isEnabled()) return;
if (CTNHCollapsibleGroups.needsRebuild()) {
List extends EmiIngredient> source = EmiSidebars.getStacks(SidebarType.INDEX);
if (source != null && !source.isEmpty()) {
@@ -199,47 +157,9 @@ public class EmiScreenManagerInputMixin {
}
}
- /**
- * 当 EMI 可见性切换时标记分组为脏。
- *
- *
- * 可见性切换常伴随 EMI 重载、关闭或重新打开。标记 dirty 后,下次获取搜索来源时会重新扫描列表,
- * 避免继续使用旧的 ingredient 对象身份映射。
- *
- */
+ /** EMI 开关或重载后,下一次搜索刷新时重新扫描列表。 */
@Inject(method = "toggleVisibility", at = @At("HEAD"))
private static void ctnhcore$markDirtyOnToggle(boolean notify, CallbackInfo ci) {
CTNHCollapsibleGroups.markDirty();
}
-
- /**
- * 包装 EMI 原生 tooltip,给折叠代表项追加分组信息。
- *
- *
- * 只有当前 ingredient 是折叠代表项时才追加文本。展开后的普通成员不追加,避免 tooltip 噪音。
- * 追加内容包括分组显示名和 Alt + 左键展开/折叠提示。
- *
- */
- @WrapOperation(method = "renderCurrentTooltip",
- at = @At(value = "INVOKE",
- target = "Ldev/emi/emi/api/stack/EmiIngredient;getTooltip()Ljava/util/List;"))
- private static List ctnhcore$wrapGroupTooltip(EmiIngredient instance,
- Operation> original) {
- List list = original.call(instance);
- if (CTNHCollapsibleGroups.needsRebuild() || !CTNHCollapsibleGroups.isEnabled()) return list;
- if (CTNHCollapsibleGroups.collapsedGroupCount() == 0) return list;
-
- CollapsibleGroup group = CTNHCollapsibleGroups.getGroup(instance);
- if (group != null && CTNHCollapsibleGroups.isCollapsedRepresentative(instance)) {
- List modified = new ArrayList<>(list.size() + 2);
- modified.addAll(list);
- modified.add(ClientTooltipComponent.create(
- Component.translatable("ctnhcore.emi.collapsible.tooltip.group", group.displayName)
- .getVisualOrderText()));
- modified.add(ClientTooltipComponent.create(
- Component.translatable("ctnhcore.emi.collapsible.tooltip.toggle_hint").getVisualOrderText()));
- return modified;
- }
- return list;
- }
}
diff --git a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java
index 79d00446..d29e95b1 100644
--- a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java
+++ b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java
@@ -10,7 +10,6 @@
import dev.emi.emi.runtime.EmiDrawContext;
import dev.emi.emi.screen.EmiScreenManager;
import dev.emi.emi.screen.StackBatcher;
-import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
@@ -20,83 +19,42 @@
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
-import com.mojang.blaze3d.systems.RenderSystem;
-
import java.util.ArrayList;
import java.util.List;
-/**
- * 注入 EMI 侧栏空间 {@link EmiScreenManager.ScreenSpace},负责折叠组的显示层改写。
- *
- *
- * 本 mixin 只作用于 INDEX 搜索侧栏。它会在 EMI 读取待显示列表时调用
- * {@link CTNHCollapsibleGroups#project(List)},把已折叠分组压缩成一个代表项;随后在渲染阶段
- * 给折叠代表项和展开成员绘制边框,帮助玩家看出这些物品属于同一个折叠组。
- *
- */
+/** 改写 EMI INDEX 侧栏显示列表,并绘制折叠组的边框和双层图标。 */
@Mixin(value = EmiScreenManager.ScreenSpace.class, remap = false)
public abstract class EmiScreenManagerScreenSpaceMixin {
- /** EMI 原字段:当前 ScreenSpace 是否处于搜索/索引侧栏模式。 */
@Shadow
- @Final
public boolean search;
- /** EMI 原方法:返回该侧栏空间的类型,例如 INDEX 或 FAVORITES。 */
@Shadow
public abstract SidebarType getType();
- /** EMI 原字段:侧栏渲染起点 X。当前类保留 shadow 以匹配原布局上下文。 */
- @Shadow
- @Final
- public int tx;
-
- /** EMI 原字段:侧栏渲染起点 Y。当前类保留 shadow 以匹配原布局上下文。 */
- @Shadow
- @Final
- public int ty;
-
- /** EMI 原字段:侧栏网格高度,按 18x18 单元格行数计算。 */
@Shadow
- @Final
public int th;
- /** EMI 原字段:每行可显示的单元格宽度数组。 */
- @Shadow
- @Final
- public int[] widths;
-
- /** EMI 原方法:取得指定行可显示的单元格数量。 */
@Shadow
public abstract int getWidth(int y);
- /** EMI 原方法:把网格列/行坐标转换成屏幕 X 坐标。 */
@Shadow
public abstract int getX(int x, int y);
- /** EMI 原方法:把网格列/行坐标转换成屏幕 Y 坐标。 */
@Shadow
public abstract int getY(int x, int y);
- /** EMI 原字段:当前页最多渲染多少个 ingredient。 */
@Shadow
- @Final
public int pageSize;
- /** EMI 原方法:返回当前侧栏空间要显示的 ingredient 列表。 */
@Shadow
public abstract List extends EmiIngredient> getStacks();
- /** EMI 原字段:侧栏使用的批量物品渲染器。 */
- @Shadow
- @Final
- public StackBatcher batcher;
-
/** EMI 侧栏单格尺寸。 */
@Unique
private static final int ENTRY_SIZE = 18;
- /** 展开分组成员的外边框颜色。GuiGraphics.fill 使用 ABGR 格式。 */
+ /** 展开分组成员的外边框颜色。GuiGraphics.fill 使用 ARGB 格式。 */
@Unique
private static final int GROUP_BORDER_COLOR = 0xCC3344AA;
@@ -104,29 +62,31 @@ public abstract class EmiScreenManagerScreenSpaceMixin {
@Unique
private static final int GROUP_BG_COLOR = 0x44113377;
- /** 折叠代表项边框颜色。这里只画边框,不覆盖 EMI 原本的物品图标。 */
+ /** GTNH NEI 默认折叠背景色 0x335555EE,GuiGraphics 使用 ARGB。 */
+ @Unique
+ private static final int COLLAPSED_BG_COLOR = 0x335555EE;
+
+ /** GTNH NEI 会把同色背景的 alpha 加深 2/5 作为边框色:0x33 + 0x66 = 0x99。 */
+ @Unique
+ private static final int COLLAPSED_BORDER_COLOR = 0x995555EE;
+
+ /** GTNH NEI 折叠背景物品偏移:rect.offset(1, -1)。 */
+ @Unique
+ private static final int COLLAPSED_BACK_X_OFFSET = 1;
+
@Unique
- private static final int COLLAPSED_BORDER_COLOR = 0xCC4466CC;
+ private static final int COLLAPSED_BACK_Y_OFFSET = -1;
- /** 后方叠层图标透明度。 */
+ /** GTNH NEI 折叠前景物品偏移:rect.offset(-2, 2)。 */
@Unique
- private static final float STACKED_BACK_ICON_ALPHA = 0.45F;
+ private static final int COLLAPSED_FRONT_X_OFFSET = -2;
- /** 后方叠层图标遮罩,让不吃 shader alpha 的 item 渲染路径也能显得更靠后。 */
@Unique
- private static final int STACKED_BACK_ICON_DIM_COLOR = 0x77000000;
-
- /**
- * 在 EMI 返回侧栏列表后替换为折叠投影列表。
- *
- *
- * 只处理 INDEX 搜索侧栏,避免影响收藏夹等其他侧栏。分组尚未重建或没有有效分组时,
- * 保持 EMI 原始列表不变。
- *
- */
+ private static final int COLLAPSED_FRONT_Y_OFFSET = 2;
+
+ /** 只在 INDEX 侧栏把 EMI 原列表替换为折叠投影。 */
@Inject(method = "getStacks", at = @At("RETURN"), cancellable = true)
private void ctnhcore$projectGetStacks(CallbackInfoReturnable> cir) {
- if (!CTNHCollapsibleGroups.isEnabled()) return;
if (search && getType() == SidebarType.INDEX) {
List extends EmiIngredient> original = cir.getReturnValue();
if (original == null || original.isEmpty()) return;
@@ -136,40 +96,26 @@ public abstract class EmiScreenManagerScreenSpaceMixin {
}
}
- /**
- * 折叠代表项由本 mixin 在批量绘制完成后手动画双层图标。
- *
- *
- * 跳过 EMI 原始的单图标绘制,避免“原图标 + 双层图标”三层重叠。
- *
- */
+ /** 折叠代表项稍后手动画双层图标,这里跳过 EMI 原图标。 */
@Redirect(method = "render",
at = @At(value = "INVOKE",
target = "Ldev/emi/emi/screen/StackBatcher;render(Ldev/emi/emi/api/stack/EmiIngredient;Lnet/minecraft/client/gui/GuiGraphics;IIF)V"))
private void ctnhcore$skipCollapsedRepresentativeOriginalIcon(StackBatcher instance, EmiIngredient stack,
GuiGraphics draw, int x, int y, float delta) {
- if (CTNHCollapsibleGroups.isEnabled() && search && getType() == SidebarType.INDEX &&
+ if (search && getType() == SidebarType.INDEX &&
!CTNHCollapsibleGroups.needsRebuild() && CTNHCollapsibleGroups.isCollapsedRepresentative(stack)) {
return;
}
instance.render(stack, draw, x, y, delta);
}
- /**
- * 在 EMI 批量绘制物品图标后绘制折叠组叠加层。
- *
- *
- * 折叠代表项只画蓝色边框,保留 EMI 原本渲染出的代表物品图标。展开成员会绘制淡色背景,
- * 并根据相邻格子是否属于同一组来决定哪些边需要画,从而形成连贯的分组区域。
- *
- */
+ /** EMI 画完物品后,补画折叠组背景、边框和双层图标。 */
@Inject(method = "render",
at = @At(value = "INVOKE",
target = "Ldev/emi/emi/screen/StackBatcher;draw()V",
shift = At.Shift.AFTER))
private void ctnhcore$renderGroupOverlays(EmiDrawContext context, int mouseX, int mouseY,
float delta, int startIndex, CallbackInfo ci) {
- if (!CTNHCollapsibleGroups.isEnabled()) return;
if (!search || getType() != SidebarType.INDEX) return;
if (CTNHCollapsibleGroups.needsRebuild()) return;
@@ -179,11 +125,7 @@ public abstract class EmiScreenManagerScreenSpaceMixin {
GuiGraphics graphics = context.raw();
int endIndex = Math.min(startIndex + pageSize, stacks.size());
- List expandedXos = new ArrayList<>();
- List expandedYos = new ArrayList<>();
- List expandedCxs = new ArrayList<>();
- List expandedCys = new ArrayList<>();
- List expandedGroupGuids = new ArrayList<>();
+ List expandedCells = new ArrayList<>();
int ri = startIndex;
outer:
@@ -200,24 +142,26 @@ public abstract class EmiScreenManagerScreenSpaceMixin {
int cy = getY(xo, yo);
if (CTNHCollapsibleGroups.isCollapsedRepresentative(stack)) {
+ drawCollapsedGroupBackground(graphics, cx, cy);
drawCollapsedGroupStack(context, stack, cx, cy);
drawCollapsedGroupOverlay(graphics, cx, cy);
- } else {
- expandedXos.add(xo);
- expandedYos.add(yo);
- expandedCxs.add(cx);
- expandedCys.add(cy);
- expandedGroupGuids.add(group.guid);
+ } else if (group.isExpanded()) {
+ expandedCells.add(new ExpandedCell(xo, yo, cx, cy, group.guid));
}
}
}
- for (int index = 0; index < expandedCxs.size(); index++) {
- drawExpandedMemberCell(graphics, index, expandedXos, expandedYos, expandedCxs, expandedCys,
- expandedGroupGuids);
+ for (int index = 0; index < expandedCells.size(); index++) {
+ drawExpandedMemberCell(graphics, expandedCells, index);
}
}
+ /** 为折叠代表项绘制 GTNH NEI 风格半透明背景。 */
+ @Unique
+ private void drawCollapsedGroupBackground(GuiGraphics graphics, int cx, int cy) {
+ graphics.fill(cx, cy, cx + ENTRY_SIZE, cy + ENTRY_SIZE, COLLAPSED_BG_COLOR);
+ }
+
/** 为折叠代表项绘制单格边框。 */
@Unique
private void drawCollapsedGroupOverlay(GuiGraphics graphics, int cx, int cy) {
@@ -235,63 +179,47 @@ private void drawCollapsedGroupStack(EmiDrawContext context, EmiIngredient repre
context.raw().pose().pushPose();
context.raw().pose().translate(0, 0, -50);
- RenderSystem.enableBlend();
- RenderSystem.defaultBlendFunc();
- RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, STACKED_BACK_ICON_ALPHA);
- context.drawStack(secondary, cx + 2, cy - 2, EmiIngredient.RENDER_ICON);
- RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
- context.raw().fill(cx + 2, cy - 2, cx + 18, cy + 14, STACKED_BACK_ICON_DIM_COLOR);
+ context.drawStack(secondary, cx + COLLAPSED_BACK_X_OFFSET, cy + COLLAPSED_BACK_Y_OFFSET,
+ EmiIngredient.RENDER_ICON);
context.raw().pose().popPose();
- context.drawStack(representative, cx - 1, cy + 1, EmiIngredient.RENDER_ICON);
+ context.drawStack(representative, cx + COLLAPSED_FRONT_X_OFFSET, cy + COLLAPSED_FRONT_Y_OFFSET,
+ EmiIngredient.RENDER_ICON);
}
- /**
- * 为展开状态下的单个成员绘制背景和外边框。
- *
- *
- * 如果上下左右存在同组相邻成员,则省略共享边,视觉上形成连续的分组块。
- *
- */
+ /** 展开成员有相邻同组格子时省略共享边,形成连续区域。 */
@Unique
- private void drawExpandedMemberCell(GuiGraphics graphics, int index, List xos, List yos,
- List cxs, List cys, List groupGuids) {
- int cx = cxs.get(index);
- int cy = cys.get(index);
+ private void drawExpandedMemberCell(GuiGraphics graphics, List cells, int index) {
+ ExpandedCell cell = cells.get(index);
+ int cx = cell.cx();
+ int cy = cell.cy();
graphics.fill(cx + 1, cy + 1, cx + ENTRY_SIZE - 1, cy + ENTRY_SIZE - 1, GROUP_BG_COLOR);
- if (!hasNeighbor(index, xos, yos, groupGuids, 0, -1)) {
+ if (!hasNeighbor(cells, index, 0, -1)) {
graphics.fill(cx, cy, cx + ENTRY_SIZE, cy + 1, GROUP_BORDER_COLOR);
}
- if (!hasNeighbor(index, xos, yos, groupGuids, 0, 1)) {
+ if (!hasNeighbor(cells, index, 0, 1)) {
graphics.fill(cx, cy + ENTRY_SIZE - 1, cx + ENTRY_SIZE, cy + ENTRY_SIZE, GROUP_BORDER_COLOR);
}
- if (!hasNeighbor(index, xos, yos, groupGuids, -1, 0)) {
+ if (!hasNeighbor(cells, index, -1, 0)) {
graphics.fill(cx, cy, cx + 1, cy + ENTRY_SIZE, GROUP_BORDER_COLOR);
}
- if (!hasNeighbor(index, xos, yos, groupGuids, 1, 0)) {
+ if (!hasNeighbor(cells, index, 1, 0)) {
graphics.fill(cx + ENTRY_SIZE - 1, cy, cx + ENTRY_SIZE, cy + ENTRY_SIZE, GROUP_BORDER_COLOR);
}
}
- /**
- * 判断指定方向上是否存在同组相邻格子。
- *
- * @param index 当前成员在临时坐标列表中的索引
- * @param dx 横向偏移,-1 表示左邻居,1 表示右邻居
- * @param dy 纵向偏移,-1 表示上邻居,1 表示下邻居
- * @return true 表示相邻格子存在且属于同一分组
- */
@Unique
- private boolean hasNeighbor(int index, List xos, List yos, List groupGuids, int dx,
- int dy) {
- int nx = xos.get(index) + dx;
- int ny = yos.get(index) + dy;
- String groupGuid = groupGuids.get(index);
- for (int other = 0; other < xos.size(); other++) {
- if (xos.get(other) == nx && yos.get(other) == ny && groupGuids.get(other).equals(groupGuid)) {
+ private boolean hasNeighbor(List cells, int index, int dx, int dy) {
+ ExpandedCell cell = cells.get(index);
+ int nx = cell.xo() + dx;
+ int ny = cell.yo() + dy;
+ for (ExpandedCell other : cells) {
+ if (other.xo() == nx && other.yo() == ny && other.groupGuid().equals(cell.groupGuid())) {
return true;
}
}
return false;
}
+
+ private record ExpandedCell(int xo, int yo, int cx, int cy, String groupGuid) {}
}
diff --git a/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java b/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java
index f7f02ecb..7d33ede8 100644
--- a/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java
+++ b/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java
@@ -3,7 +3,6 @@
import io.github.cpearl0.ctnhcore.CTNHCore;
import net.minecraft.core.registries.Registries;
-import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.tags.TagKey;
import net.minecraft.world.item.BlockItem;
@@ -16,7 +15,6 @@
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
-import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@@ -25,31 +23,18 @@
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
import java.io.Reader;
import java.io.Writer;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
-/**
- * EMI 侧栏折叠组的核心管理类。
- *
- *
- * 本类负责三件事:
- *
- *
- * - 根据 EMI 侧栏提供的 {@link EmiIngredient} 列表注册折叠组。
- * - 在侧栏渲染前把原始列表投影成“折叠代表项”或“展开成员列表”。
- * - 把每个分组的展开/折叠状态保存到配置目录,保证重启后状态仍然保留。
- *
- *
- *
- * 分组规则从 config/ctnhcore/emi_collapsible_groups.json 读取,支持 item/block tag、物品 id 正则和
- * 一组接近 GTNH NEI collapsibleitems.cfg 的简化 item filter 语法。
- *
- */
+/** 管理 EMI 侧栏折叠组:读取规则、匹配成员、投影显示列表并保存展开状态。 */
public class CTNHCollapsibleGroups {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
@@ -57,14 +42,8 @@ public class CTNHCollapsibleGroups {
/** 折叠组展开状态的持久化文件,位于 config/ctnhcore/collapsible_emi_groups.json。 */
private static final Path STATE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/collapsible_emi_groups.json");
- /** 折叠组规则定义文件,位于 config/ctnhcore/emi_collapsible_groups.json。 */
- private static final Path RULE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/emi_collapsible_groups.json");
-
- /** 兼容 GTNH/NEI collapsibleitems.cfg 风格的一行一组配置。 */
- private static final Path LEGACY_RULE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/collapsibleitems.cfg");
-
- /** 总开关。关闭后不会重建、投影或响应折叠组交互。 */
- private static boolean enabled = true;
+ /** 折叠组规则定义文件。新增折叠组统一添加到该 assets JSON。 */
+ private static final String DEFAULT_RULE_RESOURCE = "/assets/ctnhcore/emi/emi_collapsible_groups.json";
/** 标记 EMI 来源列表是否需要重新扫描并重建分组。 */
private static boolean dirty = true;
@@ -81,40 +60,27 @@ public class CTNHCollapsibleGroups {
/** 分组 guid -> 是否展开。没有记录时默认视为折叠。 */
private static final Map EXPANDED_STATE = new HashMap<>();
- /** 当前投影列表中的折叠代表项 -> 分组 guid,用于 tooltip、边框和点击切换。 */
+ /** 当前投影列表中的折叠代表项 -> 分组 guid,用于边框和点击切换。 */
private static final IdentityHashMap REPRESENTATIVE_TO_GROUP = new IdentityHashMap<>();
- /** 从 JSON/legacy 配置读取并编译后的分组注册器。 */
- private static List configuredProviders = List.of();
+ /** 当前投影列表中的折叠代表项 -> 背景代表项。 */
+ private static final IdentityHashMap REPRESENTATIVE_TO_SECONDARY = new IdentityHashMap<>();
+
+ /** 从 JSON 读取并编译后的分组规则。 */
+ private static List configuredRules = List.of();
/** 防止每次重建都重复读取规则文件。 */
private static boolean rulesLoaded = false;
- /**
- * 一个可折叠的 EMI 侧栏分组。
- *
- *
- * 分组保存显示名、成员列表、代表项以及展开状态。实际渲染仍由 EMI 完成,
- * 本对象只描述“哪些 ingredient 属于同一组”和“当前应该折叠还是展开”。
- *
- */
+ /** 一个已匹配完成的折叠组。 */
public static class CollapsibleGroup {
/** 分组唯一标识,同时作为持久化状态的 key。 */
public final String guid;
- /** tooltip 中显示的分组名称。新增分组时应使用 Component.translatable。 */
- public Component displayName;
-
/** 当前 EMI 来源列表中属于该分组的成员,顺序沿用 EMI 侧栏原始顺序。 */
public final List members;
- /** 分组的主代表项,通常是成员列表中的第一个 ingredient。 */
- public EmiIngredient primaryRepresentative;
-
- /** 分组的次代表项,折叠渲染时用于画出类似 GTNH NEI 的叠层图标。 */
- public EmiIngredient secondaryRepresentative;
-
/**
* 创建一个空分组。
*
@@ -123,51 +89,23 @@ public static class CollapsibleGroup {
public CollapsibleGroup(String guid) {
this.guid = guid;
this.members = new ArrayList<>();
- this.displayName = Component.literal(guid);
- this.primaryRepresentative = null;
- this.secondaryRepresentative = null;
}
- /**
- * 查询分组是否处于展开状态。
- *
- * @return true 表示投影时显示所有成员,false 表示只显示一个代表项
- */
+ /** true 表示显示全部成员,false 表示只显示一个代表项。 */
public boolean isExpanded() {
Boolean state = EXPANDED_STATE.get(guid);
return state != null && state;
}
- /**
- * 设置分组展开状态,并立即保存到配置文件。
- *
- * @param expanded true 为展开,false 为折叠
- */
+ /** 更新展开状态并保存。 */
public void setExpanded(boolean expanded) {
EXPANDED_STATE.put(guid, expanded);
saveStates();
}
-
- /**
- * 返回成员数量,用于 UI 统计或判断分组是否值得折叠。
- */
- public int memberCount() {
- return members.size();
- }
}
// ---- 公共 API:供 EMI mixin 查询状态、触发重建和响应交互 ----
- /** 返回折叠功能总开关是否启用。 */
- public static boolean isEnabled() {
- return enabled;
- }
-
- /** 设置折叠功能总开关。 */
- public static void setEnabled(boolean e) {
- enabled = e;
- }
-
/** 返回当前 EMI 来源列表是否需要重新扫描。 */
public static boolean needsRebuild() {
return dirty;
@@ -178,23 +116,15 @@ public static void markDirty() {
dirty = true;
}
- /**
- * 根据 EMI 侧栏完整来源列表重建所有折叠组。
- *
- *
- * 重建会清空旧的对象身份映射,再按当前列表重新注册成员。这样能保证搜索、重载、
- * EMI 刷新后,{@link IdentityHashMap} 中保存的都是当前侧栏正在使用的 ingredient 对象。
- *
- *
- * @param stacks EMI INDEX 侧栏的完整 ingredient 来源列表
- */
+ /** 用 EMI INDEX 完整列表重建分组和对象身份映射。 */
public static void rebuild(List extends EmiIngredient> stacks) {
synchronized (GROUPS) {
loadStates();
GROUPS.clear();
STACK_TO_GROUP.clear();
REPRESENTATIVE_TO_GROUP.clear();
- if (!enabled || stacks == null || stacks.isEmpty()) {
+ REPRESENTATIVE_TO_SECONDARY.clear();
+ if (stacks == null || stacks.isEmpty()) {
dirty = false;
return;
}
@@ -205,113 +135,54 @@ public static void rebuild(List extends EmiIngredient> stacks) {
}
}
- /**
- * 注册 JSON 配置中声明的折叠分组。
- *
- *
- * 每个 ingredient 只会进入最高优先级的命中组;同优先级时保持 provider 注册顺序。
- *
- */
+ /** 每个 ingredient 进入最高优先级命中的组;同优先级保持 JSON 顺序。 */
private static void registerConfiguredGroups(List extends EmiIngredient> stacks) {
- List validProviders = new ArrayList<>();
- for (CollapsibleGroupProvider provider : configuredProviders) {
- if (provider.priority() < 0) {
- CTNHCore.LOGGER.warn("Skipping EMI collapsible group {} because priority must be non-negative",
- provider.guid());
- continue;
- }
- validProviders.add(provider);
- }
-
- Map groups = new IdentityHashMap<>();
- for (CollapsibleGroupProvider provider : validProviders) {
- CollapsibleGroup group = provider.createGroup();
- groups.put(provider, group);
+ Map groups = new IdentityHashMap<>();
+ for (RuleGroupDefinition rule : configuredRules) {
+ groups.put(rule, new CollapsibleGroup(rule.guid()));
}
for (EmiIngredient ingredient : stacks) {
- CollapsibleGroupProvider bestProvider = null;
- for (CollapsibleGroupProvider provider : validProviders) {
- if (!provider.matches(ingredient)) continue;
- if (bestProvider == null || provider.priority() > bestProvider.priority()) {
- bestProvider = provider;
+ RuleGroupDefinition bestRule = null;
+ for (RuleGroupDefinition rule : configuredRules) {
+ if (!rule.matches(ingredient)) continue;
+ if (bestRule == null || rule.priority() > bestRule.priority()) {
+ bestRule = rule;
}
}
- if (bestProvider != null) {
- groups.get(bestProvider).members.add(ingredient);
+ if (bestRule != null) {
+ groups.get(bestRule).members.add(ingredient);
}
}
- for (CollapsibleGroupProvider provider : validProviders) {
- registerGroup(groups.get(provider));
+ for (RuleGroupDefinition rule : configuredRules) {
+ registerGroup(groups.get(rule));
}
}
- /**
- * 根据分组 guid 生成显示名。
- *
- *
- * 默认工具组沿用已有语言 key,例如 ctnhcore:tools/swords 会尝试读取
- * ctnhcore.emi.collapsible.group.swords。自定义组没有语言条目时显示 guid 本身。
- *
- */
- private static Component displayNameForGroup(String guid) {
- CollapsibleGroupProvider provider = findProvider(guid);
- if (provider != null) return provider.createDisplayName();
- return fallbackDisplayName(guid);
- }
-
- private static Component fallbackDisplayName(String guid) {
- ResourceLocation id = ResourceLocation.tryParse(guid);
- String fallback = guid;
- String path = id == null ? guid : id.getPath();
- int slash = path.lastIndexOf('/');
- String name = slash >= 0 ? path.substring(slash + 1) : path;
- return Component.translatableWithFallback("ctnhcore.emi.collapsible.group." + name, fallback);
- }
-
- /**
- * 将构造完成的分组写入全局索引。
- *
- *
- * 少于两个成员的分组没有折叠价值,因此不会注册。注册成功后会建立 member -> guid 映射,
- * 后续投影、tooltip 和点击逻辑都依赖这个映射。
- *
- */
+ /** 少于两个成员的组没有折叠价值,直接丢弃。 */
private static void registerGroup(CollapsibleGroup group) {
if (group.members.size() < 2) return;
- group.primaryRepresentative = group.members.get(0);
- if (group.members.size() > 1) {
- group.secondaryRepresentative = group.members.get(1);
- }
GROUPS.put(group.guid, group);
for (EmiIngredient member : group.members) {
STACK_TO_GROUP.put(member, group.guid);
}
}
- /**
- * 将 EMI 原始侧栏列表投影成实际显示列表。
- *
- *
- * 未分组项会原样通过;展开分组会显示所有仍存在于当前 source 中的成员;
- * 折叠分组只显示当前遍历到的第一个成员,并把它记录为本轮投影的代表项。
- *
- *
- * @param source EMI 当前要渲染的原始列表,可能已经受搜索过滤影响
- * @return 投影后的列表,交给 EMI 继续渲染
- */
+ /** 把 EMI 当前列表转换为实际显示列表:展开组显示成员,折叠组按当前搜索结果折叠。 */
public static List extends EmiIngredient> project(List extends EmiIngredient> source) {
- if (!enabled || dirty || GROUPS.isEmpty()) {
+ if (dirty || GROUPS.isEmpty()) {
return source;
}
synchronized (GROUPS) {
List result = new ArrayList<>();
Set projectedGroups = new HashSet<>();
+ Map> visibleMembers = visibleMembersByGroup(source);
Set sourceStacks = Collections.newSetFromMap(new IdentityHashMap<>());
sourceStacks.addAll(source);
REPRESENTATIVE_TO_GROUP.clear();
+ REPRESENTATIVE_TO_SECONDARY.clear();
for (EmiIngredient stack : source) {
String guid = STACK_TO_GROUP.get(stack);
@@ -336,7 +207,11 @@ public static List extends EmiIngredient> project(List extends EmiIngredient
}
} else {
if (projectedGroups.add(guid)) {
- REPRESENTATIVE_TO_GROUP.put(stack, guid);
+ List visible = visibleMembers.getOrDefault(guid, List.of());
+ if (visible.size() > 1) {
+ REPRESENTATIVE_TO_GROUP.put(stack, guid);
+ REPRESENTATIVE_TO_SECONDARY.put(stack, visible.get(1));
+ }
result.add(stack);
}
}
@@ -345,13 +220,18 @@ public static List extends EmiIngredient> project(List extends EmiIngredient
}
}
- /**
- * 根据 ingredient 查询所属分组。
- *
- *
- * 该 ingredient 可以是普通成员,也可以是当前投影生成的折叠代表项。
- *
- */
+ private static Map> visibleMembersByGroup(List extends EmiIngredient> source) {
+ Map> visibleMembers = new HashMap<>();
+ for (EmiIngredient stack : source) {
+ String guid = STACK_TO_GROUP.get(stack);
+ if (guid != null) {
+ visibleMembers.computeIfAbsent(guid, ignored -> new ArrayList<>()).add(stack);
+ }
+ }
+ return visibleMembers;
+ }
+
+ /** 查询普通成员或折叠代表项所属的组。 */
@Nullable
public static CollapsibleGroup getGroup(EmiIngredient ingredient) {
String guid = STACK_TO_GROUP.get(ingredient);
@@ -365,38 +245,20 @@ public static boolean isCollapsedRepresentative(EmiIngredient ingredient) {
return REPRESENTATIVE_TO_GROUP.containsKey(ingredient);
}
- /** 判断 ingredient 是否属于任意已注册分组。 */
- public static boolean isInGroup(EmiIngredient ingredient) {
- return STACK_TO_GROUP.containsKey(ingredient);
- }
-
/** 返回折叠代表项对应的次代表项,用于叠层绘制。 */
@Nullable
public static EmiIngredient getSecondaryRepresentative(EmiIngredient ingredient) {
- CollapsibleGroup group = getGroup(ingredient);
- if (group == null || !isCollapsedRepresentative(ingredient)) return null;
- return group.secondaryRepresentative;
+ return REPRESENTATIVE_TO_SECONDARY.get(ingredient);
}
- /**
- * 按 guid 切换单个分组的展开状态。
- *
- * @param guid 分组唯一标识
- */
- public static void toggleGroup(String guid) {
+ private static void toggleGroup(String guid) {
CollapsibleGroup group = GROUPS.get(guid);
if (group != null) {
group.setExpanded(!group.isExpanded());
}
}
- /**
- * 从配置文件读取所有分组的展开状态。
- *
- *
- * 读取失败只记录警告,不阻止 EMI 打开;无法读取时所有分组按默认折叠处理。
- *
- */
+ /** 从 config/ctnhcore/collapsible_emi_groups.json 读取展开状态。 */
private static void loadStates() {
if (statesLoaded) return;
statesLoaded = true;
@@ -412,13 +274,7 @@ private static void loadStates() {
}
}
- /**
- * 将当前展开状态写回配置文件。
- *
- *
- * 只有状态文件已经完成初次读取后才会保存,避免初始化阶段写出不完整状态。
- *
- */
+ /** 保存展开状态。 */
private static void saveStates() {
if (!statesLoaded) return;
@@ -436,177 +292,56 @@ private static void saveStates() {
}
}
- /**
- * 从 JSON 配置文件读取分组规则。
- *
- *
- * 文件不存在时会写出接近 GTNH 默认项的配置。读取或解析失败不会阻止 EMI 打开,只会回退为空规则。
- *
- */
+ /** 从 assets JSON 读取并编译分组规则。 */
private static void loadRules() {
if (rulesLoaded) return;
rulesLoaded = true;
- ensureDefaultRuleFile();
-
- List definitions = new ArrayList<>();
- addBuiltInTagGroups(definitions);
- try (Reader reader = Files.newBufferedReader(RULE_FILE)) {
- JsonElement root = JsonParser.parseReader(reader);
- if (!root.isJsonObject()) {
- CTNHCore.LOGGER.warn("EMI collapsible group rule file {} must be a JSON object", RULE_FILE);
- configuredProviders = List.of();
- return;
- }
- JsonObject groups = root.getAsJsonObject();
- for (Map.Entry entry : groups.entrySet()) {
- RuleGroupDefinition definition = parseGroupDefinition(entry.getKey(), entry.getValue());
- if (definition != null && !definition.rules().isEmpty()) {
- definitions.add(definition);
- }
- }
- } catch (RuntimeException | IOException e) {
- CTNHCore.LOGGER.warn("Failed to load EMI collapsible group rules from {}", RULE_FILE, e);
- }
- definitions.addAll(loadLegacyRuleFile());
- configuredProviders = List.copyOf(definitions);
- }
-
- /** 注册整合包内置的标签型折叠组。 */
- private static void addBuiltInTagGroups(List definitions) {
- definitions.add(tagGroup("ctnhcore:blocks/logs", "ctnhcore.emi.collapsible.group.logs", "Logs",
- "minecraft:logs"));
- definitions.add(tagGroup("ctnhcore:blocks/stairs", "ctnhcore.emi.collapsible.group.stairs", "Stairs",
- "minecraft:stairs"));
- definitions.add(tagGroup("ctnhcore:blocks/slabs", "ctnhcore.emi.collapsible.group.slabs", "Slabs",
- "minecraft:slabs"));
- definitions.add(tagGroup("ctnhcore:blocks/fences", "ctnhcore.emi.collapsible.group.fences", "Fences",
- "minecraft:fences"));
- definitions.add(tagGroup("ctnhcore:blocks/fence_gates", "ctnhcore.emi.collapsible.group.fence_gates",
- "Fence Gates", "minecraft:fence_gates"));
- definitions.add(tagGroup("ctnhcore:blocks/doors", "ctnhcore.emi.collapsible.group.doors", "Doors",
- "minecraft:doors"));
- definitions.add(tagGroup("ctnhcore:blocks/trapdoors", "ctnhcore.emi.collapsible.group.trapdoors",
- "Trapdoors", "minecraft:trapdoors"));
- definitions.add(tagGroup("ctnhcore:blocks/pressure_plates",
- "ctnhcore.emi.collapsible.group.pressure_plates", "Pressure Plates", "minecraft:pressure_plates"));
- definitions.add(tagGroup("ctnhcore:blocks/buttons", "ctnhcore.emi.collapsible.group.buttons", "Buttons",
- "minecraft:buttons"));
- }
-
- private static CollapsibleGroupProvider tagGroup(String guid, String translationKey, String fallbackName,
- String tagId) {
- ResourceLocation id = ResourceLocation.tryParse(tagId);
- if (id == null) {
- throw new IllegalArgumentException("Invalid built-in EMI collapsible tag id: " + tagId);
- }
- return new TagGroupDefinition(guid, translationKey, fallbackName, TagKey.create(Registries.ITEM, id),
- TagKey.create(Registries.BLOCK, id));
+ List definitions = new ArrayList<>();
+ addRuleDefinitions(DEFAULT_RULE_RESOURCE, defaultRuleRoot(), definitions);
+ configuredRules = List.copyOf(definitions);
}
- /** 写出接近 GTNH NEI 默认折叠项的规则文件。 */
- private static void ensureDefaultRuleFile() {
- if (Files.isRegularFile(RULE_FILE)) return;
-
- try {
- Files.createDirectories(RULE_FILE.getParent());
- JsonObject defaults = new JsonObject();
- defaults.add("ctnhcore:spawn_eggs",
- defaultGroup("ctnhcore.emi.collapsible.group.spawn_eggs", "regex:minecraft:.*_spawn_egg"));
- defaults.add("ctnhcore:spawners",
- defaultGroup("ctnhcore.emi.collapsible.group.spawners", "minecraft:spawner"));
- defaults.add("ctnhcore:music_discs",
- defaultGroup("ctnhcore.emi.collapsible.group.music_discs", "regex:minecraft:music_disc_.*"));
- defaults.add("ctnhcore:splash_potions",
- defaultGroup("ctnhcore.emi.collapsible.group.splash_potions", "minecraft:splash_potion"));
- defaults.add("ctnhcore:lingering_potions",
- defaultGroup("ctnhcore.emi.collapsible.group.lingering_potions", "minecraft:lingering_potion"));
- defaults.add("ctnhcore:tools/swords",
- defaultGroup("ctnhcore.emi.collapsible.group.swords", "#minecraft:swords", "#forge:tools/swords"));
- defaults.add("ctnhcore:tools/pickaxes",
- defaultGroup("ctnhcore.emi.collapsible.group.pickaxes", "#minecraft:pickaxes", "#forge:tools/pickaxes"));
- defaults.add("ctnhcore:tools/axes",
- defaultGroup("ctnhcore.emi.collapsible.group.axes", "#minecraft:axes", "#forge:tools/axes"));
- defaults.add("ctnhcore:tools/shovels",
- defaultGroup("ctnhcore.emi.collapsible.group.shovels", "#minecraft:shovels", "#forge:tools/shovels"));
- defaults.add("ctnhcore:tools/hoes",
- defaultGroup("ctnhcore.emi.collapsible.group.hoes", "#minecraft:hoes", "#forge:tools/hoes"));
- try (Writer writer = Files.newBufferedWriter(RULE_FILE)) {
- GSON.toJson(defaults, writer);
+ private static JsonObject defaultRuleRoot() {
+ try (InputStream stream = CTNHCollapsibleGroups.class.getResourceAsStream(DEFAULT_RULE_RESOURCE)) {
+ if (stream == null) {
+ CTNHCore.LOGGER.warn("Missing default EMI collapsible group rule resource {}", DEFAULT_RULE_RESOURCE);
+ return new JsonObject();
}
- } catch (IOException e) {
- CTNHCore.LOGGER.warn("Failed to create default EMI collapsible group rule file {}", RULE_FILE, e);
- }
- }
-
- /** 构造默认规则对象。 */
- private static JsonObject defaultGroup(String translationKey, String... rulesIn) {
- JsonObject group = new JsonObject();
- group.addProperty("translationKey", translationKey);
- group.addProperty("priority", 0);
- JsonArray rules = new JsonArray();
- for (String rule : rulesIn) {
- rules.add(rule);
- }
- group.add("rules", rules);
- return group;
- }
-
- /** 读取可选的 GTNH/NEI 风格 collapsibleitems.cfg。 */
- private static List loadLegacyRuleFile() {
- if (!Files.isRegularFile(LEGACY_RULE_FILE)) return List.of();
-
- List definitions = new ArrayList<>();
- try {
- List lines = Files.readAllLines(LEGACY_RULE_FILE);
- JsonObject settings = new JsonObject();
- int index = 0;
- for (String rawLine : lines) {
- String line = rawLine.trim();
- if (line.isEmpty() || line.startsWith("#")) continue;
-
- if (line.startsWith(";")) {
- String json = line.substring(1).trim();
- if (!json.isEmpty()) {
- JsonElement element = JsonParser.parseString(json);
- if (element.isJsonObject()) {
- settings = element.getAsJsonObject();
- }
- }
- continue;
+ try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
+ JsonElement root = JsonParser.parseReader(reader);
+ if (root.isJsonObject()) {
+ return root.getAsJsonObject();
}
-
- String guid = "ctnhcore:legacy/" + index++;
- JsonObject object = new JsonObject();
- if (settings.has("displayName")) object.add("displayName", settings.get("displayName"));
- if (settings.has("expanded")) object.add("expanded", settings.get("expanded"));
- JsonArray rules = new JsonArray();
- rules.add(line);
- object.add("rules", rules);
- RuleGroupDefinition definition = parseGroupDefinition(guid, object);
- if (definition != null) definitions.add(definition);
- settings = new JsonObject();
+ CTNHCore.LOGGER.warn("Default EMI collapsible group rule resource {} must be a JSON object",
+ DEFAULT_RULE_RESOURCE);
}
} catch (RuntimeException | IOException e) {
- CTNHCore.LOGGER.warn("Failed to load legacy EMI collapsible group rules from {}", LEGACY_RULE_FILE, e);
+ CTNHCore.LOGGER.warn("Failed to read default EMI collapsible group rules from {}", DEFAULT_RULE_RESOURCE,
+ e);
}
- return definitions;
+ return new JsonObject();
}
- @Nullable
- private static CollapsibleGroupProvider findProvider(String guid) {
- for (CollapsibleGroupProvider provider : configuredProviders) {
- if (provider.guid().equals(guid)) return provider;
+ private static void addRuleDefinitions(String sourceName, JsonElement root,
+ List definitions) {
+ if (!root.isJsonObject()) {
+ CTNHCore.LOGGER.warn("EMI collapsible group rule file {} must be a JSON object", sourceName);
+ return;
+ }
+
+ for (Map.Entry entry : root.getAsJsonObject().entrySet()) {
+ RuleGroupDefinition definition = parseGroupDefinition(entry.getKey(), entry.getValue());
+ if (definition != null && !definition.rules().isEmpty()) {
+ definitions.add(definition);
+ }
}
- return null;
}
- /** 解析单个 groupname: rule 定义。 */
+ /** 解析单个 JSON 分组。 */
@Nullable
private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement element) {
List rules = new ArrayList<>();
- String displayName = null;
- String translationKey = null;
Boolean expanded = null;
int priority = 0;
if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) {
@@ -621,8 +356,6 @@ private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement
}
} else if (element.isJsonObject()) {
JsonObject object = element.getAsJsonObject();
- if (object.has("displayName")) displayName = object.get("displayName").getAsString();
- if (object.has("translationKey")) translationKey = object.get("translationKey").getAsString();
if (object.has("expanded")) expanded = object.get("expanded").getAsBoolean();
if (object.has("priority")) {
priority = object.get("priority").getAsInt();
@@ -632,9 +365,9 @@ private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement
return null;
}
}
- JsonElement rulesElement = object.get("rules");
+ JsonElement rulesElement = object.has("key") ? object.get("key") : object.get("rules");
if (rulesElement == null) {
- CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because it has no rules", guid);
+ CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because it has no key/rules", guid);
} else if (rulesElement.isJsonPrimitive() && rulesElement.getAsJsonPrimitive().isString()) {
addRule(guid, rulesElement.getAsString(), rules);
} else if (rulesElement.isJsonArray()) {
@@ -646,7 +379,8 @@ private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement
}
}
} else {
- CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because rules is not a string or string array",
+ CTNHCore.LOGGER.warn(
+ "Ignoring EMI collapsible group {} because key/rules is not a string or string array",
guid);
}
} else {
@@ -659,7 +393,7 @@ private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement
if (expanded != null && !EXPANDED_STATE.containsKey(guid)) {
EXPANDED_STATE.put(guid, expanded);
}
- return new RuleGroupDefinition(guid, displayName, translationKey, priority, List.copyOf(rules));
+ return new RuleGroupDefinition(guid, priority, List.copyOf(rules));
}
/** 编译一个规则字符串。 */
@@ -855,48 +589,10 @@ public static boolean hasGroups() {
return false;
}
- /** 创建折叠组并注册其中物品的通用接口。 */
- private interface CollapsibleGroupProvider {
-
- String guid();
-
- Component createDisplayName();
-
- default int priority() {
- return 0;
- }
-
- boolean matches(EmiIngredient ingredient);
-
- default CollapsibleGroup createGroup() {
- CollapsibleGroup group = new CollapsibleGroup(guid());
- group.displayName = createDisplayName();
- return group;
- }
- }
-
- /** 基于配置规则的折叠组定义。 */
- private record RuleGroupDefinition(String guid, @Nullable String displayName, @Nullable String translationKey,
- int priority, List rules) implements CollapsibleGroupProvider {
-
- @Override
- public Component createDisplayName() {
- if (translationKey != null && !translationKey.isBlank()) {
- return Component.translatableWithFallback(translationKey, guid);
- }
- if (displayName != null && !displayName.isBlank()) {
- return Component.literal(displayName);
- }
- return fallbackDisplayName(guid);
- }
+ /** 一条 JSON 分组定义和它编译后的匹配规则。 */
+ private record RuleGroupDefinition(String guid, int priority, List rules) {
- @Override
- public int priority() {
- return priority;
- }
-
- @Override
- public boolean matches(EmiIngredient ingredient) {
+ boolean matches(EmiIngredient ingredient) {
for (EmiStack stack : ingredient.getEmiStacks()) {
ItemStack itemStack = stack.getItemStack();
if (!itemStack.isEmpty() && matchesItem(itemStack)) {
@@ -914,27 +610,6 @@ private boolean matchesItem(ItemStack stack) {
}
}
- /** 基于物品或方块标签的代码型折叠组定义。 */
- private record TagGroupDefinition(String guid, String translationKey, String fallbackName,
- TagKey- itemTag, TagKey blockTag) implements CollapsibleGroupProvider {
-
- @Override
- public Component createDisplayName() {
- return Component.translatableWithFallback(translationKey, fallbackName);
- }
-
- @Override
- public boolean matches(EmiIngredient ingredient) {
- for (EmiStack stack : ingredient.getEmiStacks()) {
- ItemStack itemStack = stack.getItemStack();
- if (!itemStack.isEmpty() && matchesTag(itemStack, itemTag, blockTag)) {
- return true;
- }
- }
- return false;
- }
- }
-
/** 单条分组规则。 */
private interface GroupRule {
diff --git a/src/main/resources/assets/ctnhcore/emi/emi_collapsible_groups.json b/src/main/resources/assets/ctnhcore/emi/emi_collapsible_groups.json
new file mode 100644
index 00000000..a1cfc17b
--- /dev/null
+++ b/src/main/resources/assets/ctnhcore/emi/emi_collapsible_groups.json
@@ -0,0 +1,231 @@
+{
+ "ctnhcore:spawn_eggs": {
+ "key": "regex:minecraft:.*_spawn_egg",
+ "priority": 0
+ },
+ "ctnhcore:spawners": {
+ "key": "minecraft:spawner"
+ },
+ "ctnhcore:music_discs": {
+ "key": "regex:minecraft:music_disc_.*"
+ },
+ "ctnhcore:splash_potions": {
+ "key": "minecraft:splash_potion"
+ },
+ "ctnhcore:lingering_potions": {
+ "key": "minecraft:lingering_potion"
+ },
+ "ctnhcore:tools/swords": {
+ "key": [
+ "#minecraft:swords",
+ "#forge:tools/swords"
+ ]
+ },
+ "ctnhcore:tools/pickaxes": {
+ "key": [
+ "#minecraft:pickaxes",
+ "#forge:tools/pickaxes"
+ ]
+ },
+ "ctnhcore:tools/axes": {
+ "key": [
+ "#minecraft:axes",
+ "#forge:tools/axes"
+ ]
+ },
+ "ctnhcore:tools/shovels": {
+ "key": [
+ "#minecraft:shovels",
+ "#forge:tools/shovels"
+ ]
+ },
+ "ctnhcore:tools/hoes": {
+ "key": [
+ "#minecraft:hoes",
+ "#forge:tools/hoes"
+ ]
+ },
+ "ctnhcore:blocks/logs": {
+ "key": "#minecraft:logs"
+ },
+ "ctnhcore:blocks/planks": {
+ "key": "#minecraft:planks"
+ },
+ "ctnhcore:blocks/wool": {
+ "key": "#minecraft:wool"
+ },
+ "ctnhcore:blocks/glazed_terracotta": {
+ "key": "regex:minecraft:.*_glazed_terracotta$"
+ },
+ "ctnhcore:blocks/stairs": {
+ "key": "#minecraft:stairs"
+ },
+ "ctnhcore:blocks/slabs": {
+ "key": "#minecraft:slabs"
+ },
+ "ctnhcore:blocks/fences": {
+ "key": "#minecraft:fences"
+ },
+ "ctnhcore:blocks/fence_gates": {
+ "key": "#minecraft:fence_gates"
+ },
+ "ctnhcore:blocks/doors": {
+ "key": "#minecraft:doors"
+ },
+ "ctnhcore:blocks/trapdoors": {
+ "key": "#minecraft:trapdoors"
+ },
+ "ctnhcore:blocks/pressure_plates": {
+ "key": "#minecraft:pressure_plates"
+ },
+ "ctnhcore:blocks/buttons": {
+ "key": "#minecraft:buttons"
+ },
+ "ctnhcore:blocks/glass": {
+ "key": "#forge:glass"
+ },
+ "ctnhcore:blocks/glass_panes": {
+ "key": "#forge:glass_panes"
+ },
+ "ctnhcore:blocks/beds": {
+ "key": "#minecraft:beds"
+ },
+ "ctnhcore:blocks/shulker_boxes": {
+ "key": "#forge:shulker_boxes"
+ },
+ "ctnhcore:blocks/candles": {
+ "key": "#minecraft:candles"
+ },
+ "ctnhcore:blocks/banners": {
+ "key": "#minecraft:banners"
+ },
+ "ctnhcore:blocks/concretes": {
+ "key": "#forge:concretes"
+ },
+ "ctnhcore:blocks/concrete_powders": {
+ "key": "#forge:concrete_powders"
+ },
+ "ctnhcore:blocks/ores": {
+ "key": "#forge:ores"
+ },
+ "ctnhcore:blocks/saplings": {
+ "key": "#minecraft:saplings"
+ },
+ "ctnhcore:blocks/mushrooms": {
+ "key": "#forge:mushrooms"
+ },
+ "ctnhcore:blocks/flowers": {
+ "key": "#minecraft:flowers"
+ },
+ "ctnhcore:blocks/corals": {
+ "key": "#forge:corals"
+ },
+ "ctnhcore:items/painting": {
+ "key": "minecraft:painting"
+ },
+ "ctnhcore:items/signs": {
+ "key": "#minecraft:signs"
+ },
+ "ctnhcore:items/hanging_signs": {
+ "key": "#minecraft:hanging_signs"
+ },
+ "ctnhcore:items/boats": {
+ "key": "#minecraft:boats"
+ },
+ "ctnhcore:items/goat_horn": {
+ "key": "minecraft:goat_horn"
+ },
+ "ctnhcore:items/potion_charm": {
+ "key": "apotheosis:potion_charm"
+ },
+ "ctnhcore:items/helmets": {
+ "key": "#forge:armors/helmets"
+ },
+ "ctnhcore:items/chestplates": {
+ "key": "#forge:armors/chestplates"
+ },
+ "ctnhcore:items/leggings": {
+ "key": "#forge:armors/leggings"
+ },
+ "ctnhcore:items/boots": {
+ "key": "#forge:armors/boots"
+ },
+ "ctnhcore:items/gloves": {
+ "key": "#aether:accessories_gloves"
+ },
+ "ctnhcore:items/arrows": {
+ "key": "#minecraft:arrows"
+ },
+ "ctnhcore:items/suspicious_stew": {
+ "key": "minecraft:suspicious_stew"
+ },
+ "ctnhcore:items/potions": {
+ "key": "minecraft:potion"
+ },
+ "ctnhcore:items/enchanted_books": {
+ "key": "minecraft:enchanted_book"
+ },
+ "ctnhcore:items/decorated_pot_sherds": {
+ "key": "#minecraft:decorated_pot_sherds"
+ },
+ "ctnhcore:items/trim_templates": {
+ "key": "#minecraft:trim_templates"
+ },
+ "ctnhcore:items/dyes": {
+ "key": "#forge:dyes"
+ },
+ "ctnhcore:items/sophisticatedstorage_barrels": {
+ "key": [
+ "sophisticatedstorage:barrel",
+ "sophisticatedstorage:copper_barrel",
+ "sophisticatedstorage:iron_barrel",
+ "sophisticatedstorage:gold_barrel",
+ "sophisticatedstorage:diamond_barrel",
+ "sophisticatedstorage:netherite_barrel",
+ "sophisticatedstorage:limited_barrel_1",
+ "sophisticatedstorage:limited_barrel_2",
+ "sophisticatedstorage:limited_barrel_3",
+ "sophisticatedstorage:limited_barrel_4",
+ "sophisticatedstorage:limited_copper_barrel_1",
+ "sophisticatedstorage:limited_copper_barrel_2",
+ "sophisticatedstorage:limited_copper_barrel_3",
+ "sophisticatedstorage:limited_copper_barrel_4",
+ "sophisticatedstorage:limited_iron_barrel_1",
+ "sophisticatedstorage:limited_iron_barrel_2",
+ "sophisticatedstorage:limited_iron_barrel_3",
+ "sophisticatedstorage:limited_iron_barrel_4",
+ "sophisticatedstorage:limited_gold_barrel_1",
+ "sophisticatedstorage:limited_gold_barrel_2",
+ "sophisticatedstorage:limited_gold_barrel_3",
+ "sophisticatedstorage:limited_gold_barrel_4",
+ "sophisticatedstorage:limited_diamond_barrel_1",
+ "sophisticatedstorage:limited_diamond_barrel_2",
+ "sophisticatedstorage:limited_diamond_barrel_3",
+ "sophisticatedstorage:limited_diamond_barrel_4",
+ "sophisticatedstorage:limited_netherite_barrel_1",
+ "sophisticatedstorage:limited_netherite_barrel_2",
+ "sophisticatedstorage:limited_netherite_barrel_3",
+ "sophisticatedstorage:limited_netherite_barrel_4"
+ ]
+ },
+ "ctnhcore:items/sophisticatedstorage_chests": {
+ "key": [
+ "sophisticatedstorage:chest",
+ "sophisticatedstorage:copper_chest",
+ "sophisticatedstorage:iron_chest",
+ "sophisticatedstorage:gold_chest",
+ "sophisticatedstorage:diamond_chest",
+ "sophisticatedstorage:netherite_chest"
+ ]
+ },
+ "ctnhcore:items/sophisticatedstorage_shulker_boxes": {
+ "key": [
+ "sophisticatedstorage:shulker_box",
+ "sophisticatedstorage:copper_shulker_box",
+ "sophisticatedstorage:iron_shulker_box",
+ "sophisticatedstorage:gold_shulker_box",
+ "sophisticatedstorage:diamond_shulker_box",
+ "sophisticatedstorage:netherite_shulker_box"
+ ]
+ }
+}