diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ee0767c..10185e2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -356,6 +356,33 @@ jobs: signing_key: ${{ secrets.SIGNING_KEY }} signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} signing_password: ${{ secrets.SIGNING_PASSWORD }} + ui: + needs: + - rendering + uses: ./.github/workflows/build_and_test.yml + with: + module: ui + module_id: anvillib-ui + mod_id: anvillib_ui + ci_build: true + pr_build: false + secrets: + maven_url: ${{ secrets.MAVEN_URL }} + maven_user: ${{ secrets.MAVEN_USER }} + maven_pass: ${{ secrets.MAVEN_PASS }} + ui-maven-central-deploy: + uses: ./.github/workflows/publish_maven_central.yml + needs: + - ui + with: + module: ui + module_id: anvillib-ui + secrets: + maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + signing_key: ${{ secrets.SIGNING_KEY }} + signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} + signing_password: ${{ secrets.SIGNING_PASSWORD }} util: uses: ./.github/workflows/build_and_test.yml needs: @@ -426,6 +453,7 @@ jobs: - rendering - space-select - sync + - ui - util - wheel uses: ./.github/workflows/build_and_test.yml diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 5ef21865..bf7cd9cf 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -135,6 +135,16 @@ jobs: mod_id: anvillib_sync ci_build: true pr_build: true + ui: + needs: + - rendering + uses: ./.github/workflows/build_and_test.yml + with: + module: ui + module_id: anvillib-ui + mod_id: anvillib_ui + ci_build: true + pr_build: true util: uses: ./.github/workflows/build_and_test.yml needs: @@ -171,6 +181,7 @@ jobs: - rendering - space-select - sync + - ui - util - wheel uses: ./.github/workflows/build_and_test.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d11d4b94..a61065e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -356,6 +356,33 @@ jobs: signing_key: ${{ secrets.SIGNING_KEY }} signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} signing_password: ${{ secrets.SIGNING_PASSWORD }} + ui: + needs: + - rendering + uses: ./.github/workflows/build_and_test.yml + with: + module: ui + module_id: anvillib-ui + mod_id: anvillib_ui + ci_build: false + pr_build: false + secrets: + maven_url: ${{ secrets.MAVEN_URL }} + maven_user: ${{ secrets.MAVEN_USER }} + maven_pass: ${{ secrets.MAVEN_PASS }} + ui-maven-central-deploy: + uses: ./.github/workflows/publish_maven_central.yml + needs: + - ui + with: + module: ui + module_id: anvillib-ui + secrets: + maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + signing_key: ${{ secrets.SIGNING_KEY }} + signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} + signing_password: ${{ secrets.SIGNING_PASSWORD }} util: uses: ./.github/workflows/build_and_test.yml needs: @@ -426,6 +453,7 @@ jobs: - rendering - space-select - sync + - ui - util - wheel uses: ./.github/workflows/build_and_test.yml diff --git a/module.main/build.gradle b/module.main/build.gradle index bb017bab..f2f567ed 100644 --- a/module.main/build.gradle +++ b/module.main/build.gradle @@ -16,6 +16,7 @@ dependencies { jarJar(api("dev.anvilcraft.lib:anvillib-rendering-neoforge-26.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-space-select-neoforge-26.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-sync-neoforge-26.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-ui-neoforge-26.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-util-neoforge-26.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-wheel-neoforge-26.1:latest.release")) } else { @@ -35,6 +36,7 @@ dependencies { jarJar(implementation project(":anvillib-rendering-neoforge-26.1")) jarJar(implementation project(":anvillib-space-select-neoforge-26.1")) jarJar(implementation project(":anvillib-sync-neoforge-26.1")) + jarJar(implementation project(":anvillib-ui-neoforge-26.1")) jarJar(implementation project(":anvillib-util-neoforge-26.1")) jarJar(implementation project(":anvillib-wheel-neoforge-26.1")) } diff --git a/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/Sdf2d.java b/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/Sdf2d.java index 23aa4918..ff4df1b4 100644 --- a/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/Sdf2d.java +++ b/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/Sdf2d.java @@ -98,6 +98,14 @@ public static float sd( shape.x, shape.y, shape.z ); + + case TRIANGLE -> sdTriangle( + px, py, + shape.x, shape.y, + shape.z, shape.w, + Float.intBitsToFloat(params.getTypeParams().w), + params.getSharedParams().w + ); }; if (params.isOnion()) { @@ -259,4 +267,39 @@ public static float sdEgg( ) - (ce + ra); } + /** + * 三角形 SDF。来自 IQ。 + */ + public static float sdTriangle( + float px, float py, + float x0, float y0, + float x1, float y1, + float x2, float y2 + ) { + float ex0 = x1 - x0, ey0 = y1 - y0; + float ex1 = x2 - x1, ey1 = y2 - y1; + float ex2 = x0 - x2, ey2 = y0 - y2; + float e0x = px - x0, e0y = py - y0; + float e1x = px - x1, e1y = py - y1; + float e2x = px - x2, e2y = py - y2; + + float v0 = e0x * ey0 - e0y * ex0; + float v1 = e1x * ey1 - e1y * ex1; + float v2 = e2x * ey2 - e2y * ex2; + + float d; + if (v0 * v1 > 0.0f && v1 * v2 > 0.0f) { + d = -Math.min(Math.min( + (e0x * ex0 + e0y * ey0) / (float) Math.sqrt(ex0 * ex0 + ey0 * ey0), + (e1x * ex1 + e1y * ey1) / (float) Math.sqrt(ex1 * ex1 + ey1 * ey1)), + (e2x * ex2 + e2y * ey2) / (float) Math.sqrt(ex2 * ex2 + ey2 * ey2)); + } else { + d = (float) Math.sqrt(Math.min(Math.min( + e0x * e0x + e0y * e0y, + e1x * e1x + e1y * e1y), + e2x * e2x + e2y * e2y)); + } + return d; + } + } diff --git a/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfGraphics.java b/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfGraphics.java index 7b1aef1b..f0d5c6fa 100644 --- a/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfGraphics.java +++ b/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfGraphics.java @@ -109,6 +109,16 @@ public SdfGraphics egg( return this; } + /** 三角形。按逆时针顺序填入三个顶点坐标。包围盒自动计算。 */ + public SdfGraphics triangle( + float x0, float y0, + float x1, float y1, + float x2, float y2 + ) { + this.parameters.triangle(x0, y0, x1, y1, x2, y2); + return this; + } + public SdfGraphics color(int color) { this.parameters .color(color); return this; diff --git a/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfParameters.java b/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfParameters.java index 8e457c2d..43969770 100644 --- a/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfParameters.java +++ b/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfParameters.java @@ -74,6 +74,22 @@ public void egg(float topRadius, float bottomRadius, float height) { this.shapeParams .set(height, bottomRadius, topRadius, 0.0f); } + /** 三角形。按逆时针顺序填入三个顶点坐标。顶点自动转为局部坐标。 */ + public void triangle(float x0, float y0, float x1, float y1, float x2, float y2) { + this._renderType(SdfRenderType.TRIANGLE); + float minX = Math.min(Math.min(x0, x1), x2); + float minY = Math.min(Math.min(y0, y1), y2); + float maxX = Math.max(Math.max(x0, x1), x2); + float maxY = Math.max(Math.max(y0, y1), y2); + float cx = minX + (maxX - minX) / 2f; + float cy = minY + (maxY - minY) / 2f; + this.rect.set(minX, minY, maxX - minX, maxY - minY); + this.shapeParams.set(x0 - cx, y0 - cy, x1 - cx, y1 - cy); + this.typeParams.z = 0; // onion off + this.typeParams.w = Float.floatToIntBits(x2 - cx); + this.sharedParams.w = y2 - cy; // fill 模式下安全 + } + public void smooth(float smooth) { this ._smooth(smooth); } diff --git a/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfRenderType.java b/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfRenderType.java index ed5cd227..c53c87b1 100644 --- a/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfRenderType.java +++ b/module.rendering/src/main/java/dev/anvilcraft/lib/v2/rendering/sdf/SdfRenderType.java @@ -7,7 +7,8 @@ public enum SdfRenderType { SECTOR, PIE, CAPSULE, - EGG; + EGG, + TRIANGLE; private static final SdfRenderType[] VALUES = values(); diff --git a/module.rendering/src/main/resources/assets/anvillib_rendering/shaders/core/sdf_graphics.fsh b/module.rendering/src/main/resources/assets/anvillib_rendering/shaders/core/sdf_graphics.fsh index e375d52a..0a3e90d4 100644 --- a/module.rendering/src/main/resources/assets/anvillib_rendering/shaders/core/sdf_graphics.fsh +++ b/module.rendering/src/main/resources/assets/anvillib_rendering/shaders/core/sdf_graphics.fsh @@ -20,6 +20,7 @@ layout(std140) uniform SDFParameters { #define RT_PIE 4 #define RT_UCAPSULE 5 #define RT_EGG 6 +#define RT_TRIANGLE 7 #define PASS_FILL 0 #define PASS_LIGHT 1 @@ -106,6 +107,31 @@ float sdEgg( in vec2 p, in float he, in float ra, in float rb ) return length(vec2(p.x+ce,p.y))-(ce+ra); } +// from https://iquilezles.org/articles/distfunctions2d/ +float sdTriangle( in vec2 p, in vec2 v0, in vec2 v1, in vec2 v2 ) +{ + vec2 e0 = v1 - v0, e1 = v2 - v1, e2 = v0 - v2; + vec2 vp0 = p - v0, vp1 = p - v1, vp2 = p - v2; + + float s0 = vp0.x * e0.y - vp0.y * e0.x; + float s1 = vp1.x * e1.y - vp1.y * e1.x; + float s2 = vp2.x * e2.y - vp2.y * e2.x; + + float d; + if (s0 * s1 > 0.0 && s1 * s2 > 0.0) { + d = -min(min( + dot(vp0, e0) / length(e0), + dot(vp1, e1) / length(e1)), + dot(vp2, e2) / length(e2)); + } else { + d = sqrt(min(min( + dot(vp0, vp0), + dot(vp1, vp1)), + dot(vp2, vp2))); + } + return d; +} + void main() { Sdf params = SDFs[vIndex]; vec4 shape = params.Shape; @@ -135,6 +161,10 @@ void main() { case RT_EGG: d = sdEgg(p, shape.x, shape.y, shape.z); break; + case RT_TRIANGLE: + d = sdTriangle(p, shape.xy, shape.zw, + vec2(intBitsToFloat(params.Types.w), params.Shared.w)); + break; } float aa = max(fwidth(d) * 0.5, uSmoothRadius); diff --git a/module.test/build.gradle b/module.test/build.gradle index cfd2e563..c9f642ac 100644 --- a/module.test/build.gradle +++ b/module.test/build.gradle @@ -13,6 +13,7 @@ dependencies { implementation project(":anvillib-rendering-neoforge-26.1") implementation project(":anvillib-space-select-neoforge-26.1") implementation project(":anvillib-sync-neoforge-26.1") + implementation project(":anvillib-ui-neoforge-26.1") implementation project(":anvillib-util-neoforge-26.1") implementation project(":anvillib-wheel-neoforge-26.1") } diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/AnvilLibTestClient.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/AnvilLibTestClient.java index 1482a25f..eb5ea4b0 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/AnvilLibTestClient.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/AnvilLibTestClient.java @@ -5,9 +5,9 @@ import dev.anvilcraft.lib.v2.test.all.TestTiles; import dev.anvilcraft.lib.v2.test.client.cber.TestCachedRenderer; import dev.anvilcraft.lib.v2.test.client.gui.SdfGraphicsLayer; +import dev.anvilcraft.lib.v2.test.client.screen.DeclarativeTestScreen; import dev.anvilcraft.lib.v2.test.client.screen.GuiTestScreen; import net.minecraft.client.Minecraft; -import net.minecraft.commands.Commands; import net.neoforged.api.distmarker.Dist; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.common.EventBusSubscriber; @@ -21,6 +21,8 @@ @EventBusSubscriber(modid = AnvilLibTest.MOD_ID) @Mod(value = AnvilLibTest.MOD_ID, dist = Dist.CLIENT) public class AnvilLibTestClient { + public static boolean renderSdfLayer = false; + public AnvilLibTestClient() { } @@ -46,6 +48,19 @@ public static void on(RegisterClientCommandsEvent event) { Minecraft.getInstance().setScreen(new GuiTestScreen()); return 1; }) + ). + then( + literal("declarative"). + executes(_ -> { + Minecraft.getInstance().setScreen(new DeclarativeTestScreen()); + return 1; + }) + ).then( + literal("sdf") + .executes(_ -> { + AnvilLibTestClient.renderSdfLayer = !AnvilLibTestClient.renderSdfLayer; + return 1; + }) ) ); } diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/gui/SdfGraphicsLayer.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/gui/SdfGraphicsLayer.java index 16298a65..a50af134 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/gui/SdfGraphicsLayer.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/gui/SdfGraphicsLayer.java @@ -2,6 +2,7 @@ import dev.anvilcraft.lib.v2.rendering.sdf.SdfGraphics; import dev.anvilcraft.lib.v2.test.AnvilLibTest; +import dev.anvilcraft.lib.v2.test.client.AnvilLibTestClient; import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; @@ -17,15 +18,16 @@ public class SdfGraphicsLayer implements GuiLayer { @Override public void render( - @NonNull GuiGraphicsExtractor graphics, - @NonNull DeltaTracker tracker + @NonNull GuiGraphicsExtractor graphics, + @NonNull DeltaTracker tracker ) { + if (!AnvilLibTestClient.renderSdfLayer) return; this.timer += tracker.getGameTimeDeltaTicks(); var minecraft = Minecraft.getInstance(); if (minecraft.screen != null) return; - int xMouse = (int)minecraft.mouseHandler.getScaledXPos(minecraft.getWindow()); - int yMouse = (int)minecraft.mouseHandler.getScaledYPos(minecraft.getWindow()); + int xMouse = (int) minecraft.mouseHandler.getScaledXPos(minecraft.getWindow()); + int yMouse = (int) minecraft.mouseHandler.getScaledYPos(minecraft.getWindow()); /*SdfGraphics.getInstance() .center(true) @@ -53,13 +55,13 @@ public void render( .fill() .draw(graphics) .reset();*/ - var sdf = SdfGraphics.getInstance() - .reset() - .rotate(this.timer) - .center(true) + var sdf = SdfGraphics.getInstance() + .reset() + .rotate(this.timer) + .center(true) - .stroke(0) - .fill(); + .stroke(0) + .fill(); this.draw(graphics, sdf, 0, xMouse, yMouse); @@ -76,67 +78,67 @@ public void render( } private void draw( - GuiGraphicsExtractor graphics, - SdfGraphics sdf, - int shift, - int xMouse, int yMouse + GuiGraphicsExtractor graphics, + SdfGraphics sdf, + int shift, + int xMouse, int yMouse ) { this.draw( - graphics, - sdf.box(32, 40 + shift, 40, 20), - xMouse, yMouse + graphics, + sdf.box(32, 40 + shift, 40, 20), + xMouse, yMouse ); this.draw( - graphics, - sdf.box(30, 65 + shift, 40, 20) - .round(5), - xMouse, yMouse + graphics, + sdf.box(30, 65 + shift, 40, 20) + .round(5), + xMouse, yMouse ); sdf.round(0); this.draw( - graphics, - sdf.circle(80, 50 + shift, 20), - xMouse, yMouse + graphics, + sdf.circle(80, 50 + shift, 20), + xMouse, yMouse ); this.draw( - graphics, - sdf.arc(130, 50 + shift, 45, 20, 5), - xMouse, yMouse + graphics, + sdf.arc(130, 50 + shift, 45, 20, 5), + xMouse, yMouse ); this.draw( - graphics, - sdf.sector(180, 50 + shift, 45, 20, 5), - xMouse, yMouse + graphics, + sdf.sector(180, 50 + shift, 45, 20, 5), + xMouse, yMouse ); this.draw( - graphics, - sdf.pie(230, 50 + shift, 45, 20), - xMouse, yMouse + graphics, + sdf.pie(230, 50 + shift, 45, 20), + xMouse, yMouse ); this.draw( - graphics, - sdf.capsule(280, 50 + shift, 8, 10, 18), - xMouse, yMouse + graphics, + sdf.capsule(280, 50 + shift, 8, 10, 18), + xMouse, yMouse ); this.draw( - graphics, - sdf.egg(330, 50 + shift, 2, 10, 12), - xMouse, yMouse + graphics, + sdf.egg(330, 50 + shift, 2, 10, 12), + xMouse, yMouse ); } - + private void draw( - GuiGraphicsExtractor graphics, - SdfGraphics sdf, - int mouseX, int mouseY + GuiGraphicsExtractor graphics, + SdfGraphics sdf, + int mouseX, int mouseY ) { if (sdf.collide(mouseX, mouseY, 0.5f)) { diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/screen/DeclarativeTestScreen.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/screen/DeclarativeTestScreen.java new file mode 100644 index 00000000..0052d42c --- /dev/null +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/client/screen/DeclarativeTestScreen.java @@ -0,0 +1,139 @@ +package dev.anvilcraft.lib.v2.test.client.screen; + +import dev.anvilcraft.lib.v2.ui.Alignment; +import dev.anvilcraft.lib.v2.ui.Composition; +import dev.anvilcraft.lib.v2.ui.DeclarativeScreen; +import dev.anvilcraft.lib.v2.ui.ForEach; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.Ref; +import dev.anvilcraft.lib.v2.ui.UIScope; +import dev.anvilcraft.lib.v2.ui.component.TextComponent; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.NonNull; + +import java.util.List; + +/** + * 声明式 UI 全面测试 Screen。覆盖 Phase 1-8 所有组件和功能。 + */ +public class DeclarativeTestScreen extends DeclarativeScreen { + + public DeclarativeTestScreen() { + super(Component.literal("Declarative UI Test")); + } + + @Override + protected void content(UIScope scope) { + if (scope == null) return; + Composition comp = Composition.current(); + Ref<@NonNull Integer> counter = comp.ref(0); + Ref<@NonNull Boolean> checked = comp.ref(false); + Ref<@NonNull String> text = comp.ref(""); + + scope.Scrollable( + this.height, scroll -> scroll.Column(col -> { + // ── 标题 ── + col.Text("=== AnvilLib Declarative UI ===").color(0xFFFFFF00); + + // ── 1. Text 样式 ── + col.Text("1) Text styles:"); + col.Row(row -> { + row.Text("Shadow").shadow(true).color(0xFFFFAA00); + row.Text(" | Left").align(TextComponent.Align.LEFT); + row.Text("Center").align(TextComponent.Align.CENTER).color(0xFF00AAFF); + row.Text("Right |").align(TextComponent.Align.RIGHT); + }).spacing(8); + + col.Spacer(0, 4); + + // ── 2. Button + 状态 ── + col.Text("2) Button + state (Counter):"); + col.Row(row -> { + row.Button("-").onClick(() -> counter.accept(counter.get() - 1)); + row.Text(" " + counter.get() + " ").align(TextComponent.Align.CENTER); + row.Button("+").onClick(() -> counter.accept(counter.get() + 1)); + row.Button("Reset").onClick(() -> counter.accept(0)); + }).spacing(4); + + col.Spacer(0, 4); + + // ── 3. Box 层叠 ── + col.Text("3) Box overlay:"); + col.Box(box -> { + box.Text(" "); + box.Text("<< Overlay Text >>").color(0xFF00FF00); + }).contentAlignment(Alignment.Horizontal.Center, Alignment.Vertical.Center); + + col.Spacer(0, 4); + + // ── 4. Grid 网格 ── + col.Text("4) Grid (3 columns):"); + col.Grid( + 3, grid -> { + for (int i = 0; i < 6; i++) { + grid.Button("G" + i); + } + } + ).spacing(2, 2); + + col.Spacer(0, 4); + + // ── 5. Checkbox ── + col.Text("5) Checkbox:"); + col.Row(row -> { + row.Checkbox("Enable feature", checked); + row.Text(" Enabled: " + checked.get()); + }).spacing(4); + + col.Spacer(0, 4); + + // ── 6. Slider ── + Ref sliderVal = comp.ref(50f); + col.Text("6) Slider:"); + col.Row(row -> { + row.Slider(0, 100, 100, sliderVal); + row.Text(" " + sliderVal.get().intValue() + "%"); + }).spacing(4); + + col.Spacer(0, 4); + + // ── 7. TextInput ── + col.Text("7) TextInput:"); + col.Row(row -> { + row.TextInput("Enter text...", text); + row.Text(" Value: '" + text.get() + "'"); + }).spacing(4); + + col.Spacer(0, 4); + + // ── 8. Dropdown ── + col.Text("8) Dropdown:"); + String[] options = new String[26]; + for (int i = 0; i < 26; i++) { + options[i] = "Option " + (char) ('A' + i); + } + col.Dropdown(options, 0, 80).onChange(_ -> { + }); + col.Text(" (click to open)"); + + col.Spacer(0, 4); + + // ── 9. ForEach ── + col.Text("9) ForEach (list of 4 items):"); + ForEach.of(col, List.of("Apple", "Banana", "Cherry", "Date"), (s, item) -> s.Text(" - " + item)); + + col.Spacer(0, 4); + + // ── 10. Modifier 样式 ── + col.Text("10) Modifiers:"); + col.Row(row -> { + row.Button("Styled").modifier(Modifier.NONE.background(0xFF884444).border(1, 0xFFFF8888)); + row.Text(" "); + row.Text("Padded").modifier(Modifier.NONE.padding(8).background(0xFF444488)); + }).spacing(4); + + }).spacing(6).modifier(Modifier.NONE.padding(10)) + ); + } +} + diff --git a/module.ui/TODO.md b/module.ui/TODO.md new file mode 100644 index 00000000..ca491da9 --- /dev/null +++ b/module.ui/TODO.md @@ -0,0 +1,124 @@ +# module.ui — 声明式 UI 系统 + +参考 ArkUI 声明式组件体系。纯 Java API,`Consumer`/`Runnable` 尾参,Kotlin SAM 转 DSL。 + +--- + +## 容器 / 布局组件 + +| 组件 | 状态 | 用途 | +|---------------------------|-------|----------------------------------| +| **Column** | ✅ 已实现 | 纵向线性布局,主轴=垂直,交叉轴=水平 | +| **Row** | ✅ 已实现 | 横向线性布局,主轴=水平,交叉轴=垂直 | +| **Box** (≡ Stack) | ✅ 已实现 | 层叠布局,子组件按声明顺序从底到顶重叠 | +| **Grid** | ✅ 已实现 | 网格布局,指定列数,自动换行 | +| **Scrollable** (≡ Scroll) | ✅ 已实现 | 可滚动容器,maxHeight 超限时裁剪 + 滚动条 + 拖拽 | +| **Flex** | ✅ 已实现 | 弹性布局,子组件按 flexGrow 权重分配主轴剩余空间 | +| **List** / **LazyColumn** | ✅ 已实现 | 虚拟化长列表,固定项高,仅渲染可见项,滚轮+滚动条拖拽 | +| **Tabs** | ❎ 未实现 | 标签页切换容器 | +| **Swiper** | ❎ 未实现 | 轮播/滑动切换容器 | +| **SideBarContainer** | ❎ 未实现 | 侧边栏抽屉容器 | +| **Panel** | ❎ 未实现 | 可滑动面板 | +| **Refresh** | ❎ 未实现 | 下拉刷新容器 | +| **RelativeContainer** | ❎ 未实现 | 相对定位布局 | + +--- + +## 基础组件 + +| 组件 | 状态 | 用途 | +|-------------------------|-------|-----------------------------------------| +| **Text** | ✅ 已实现 | 单行文字,支持颜色/阴影/对齐(LEFT/CENTER/RIGHT) | +| **Button** | ✅ 已实现 | 可点击按钮,原版配色,hover 状态,点击音效 | +| **Image** | ✅ 已实现 | 纹理精灵渲染(`blitSprite`) | +| **Spacer** (≡ Blank) | ✅ 已实现 | 固定尺寸空白占位 | +| **Checkbox** | ✅ 已实现 | 复选框,16×16 深灰外框 + 选中白色内填充,点击音效 | +| **Slider** | ✅ 已实现 | 水平滑块,点击/拖拽设值,hover 高亮,点击音效,拖拽出界继续跟随 | +| **TextInput** | ✅ 已实现 | 单行输入框,placeholder,选择/复制/粘贴/剪切/全选,光标跟随滚动 | +| **Dropdown** (≡ Select) | ✅ 已实现 | 下拉菜单,可拖拽滚动条,hover 高亮,SDF 三角箭头 | +| **Divider** | ❎ 未实现 | 分割线(水平/垂直) | +| **Toggle** | ❎ 未实现 | 开关切换(不同于 Checkbox 的方块填充风格) | +| **Radio** | ❎ 未实现 | 单选按钮 | +| **Progress** | ❎ 未实现 | 进度条(线性/圆形) | +| **LoadingProgress** | ❎ 未实现 | 加载动画 | +| **TextArea** | ❎ 未实现 | 多行文本输入框 | +| **Search** | ❎ 未实现 | 搜索输入框 | +| **Menu** / **MenuItem** | ❎ 未实现 | 右键菜单 / 弹出菜单 | +| **Hyperlink** | ❎ 未实现 | 超链接文字 | +| **Marquee** | ❎ 未实现 | 跑马灯滚动文字 | +| **Rating** | ❎ 未实现 | 星级评分 | +| **Badge** | ❎ 未实现 | 角标/红点提示 | +| **QRCode** | ❎ 未实现 | 二维码显示 | + +--- + +## 选择器组件 + +| 组件 | 状态 | 用途 | +|----------------|-------|---------| +| **TextPicker** | ❎ 未实现 | 文字滚轮选择器 | +| **DatePicker** | ❎ 未实现 | 日期选择器 | +| **TimePicker** | ❎ 未实现 | 时间选择器 | + +--- + +## 交互 / 手势 + +| 组件 | 状态 | 用途 | +|-------------------------|-------|-------------------------------------------------------| +| **onClick** (≡ Gesture) | ✅ 已实现 | 点击事件,全体系统一递归分发,各组件覆写自身逻辑 | +| **onHover** | ✅ 已实现 | `UIComponent.updateHover()`,Button/Slider/Dropdown 覆写 | +| **onScroll** | ✅ 已实现 | `UIComponent.mouseScrolled()` 递归分发 | +| **onDrag** | ✅ 已实现 | `UIComponent.mouseDragged()` 递归分发 | +| **onKey** | ✅ 已实现 | `UIComponent.keyPressed()`/`charTyped()` 焦点路由 | + +--- + +## 状态管理 + +| 组件 | 状态 | 用途 | +|-------------------------|-------|---------------------------------------------------| +| **Ref\** (≡ @State) | ✅ 已实现 | 可观察状态持有者,读取追踪 slot,写入精确 markDirty | +| **remember** | ✅ 已实现 | 跨 recompose 持久化值,按调用位置缓存 | +| **ref()** | ✅ 已实现 | `comp.ref(0)` 等价于 `remember(() -> new Ref<>(0))` | +| **Animatable** | ✅ 已实现 | tick 驱动动画值,ease-in-out,`Composition.watch()` 自动推进 | + +--- + +## 修饰符系统 + +| 组件 | 状态 | 用途 | +|--------------------------------------|-------|--------------------------------------| +| **Modifier** | ✅ 已实现 | 链式修饰符 API(`then`/`foldIn`/`foldOut`) | +| `.size()` / `.width()` / `.height()` | ✅ 已实现 | 尺寸约束 | +| `.fillMaxWidth()` / `.fillMaxSize()` | ✅ 已实现 | 填充父容器 | +| `.padding()` | ✅ 已实现 | 内边距 | +| `.background()` | ✅ 已实现 | SDF 圆角填充背景 | +| `.border()` | ✅ 已实现 | SDF 描边边框 | + +--- + +## 引擎核心 + +| 组件 | 状态 | 用途 | +|-----------------------|-------|----------------------------------------------------------------------| +| **Composition** | ✅ 已实现 | Slot table 引擎:emit / recompose / invalidate / copyRuntimeState / ref | +| **DeclarativeScreen** | ✅ 已实现 | Screen 宿主:事件递归分发,焦点管理,hover 更新,无 `instanceof` 硬编码 | + +--- + +## 统计 + +- 总计参考组件:**52** +- 已实现:**28**(含引擎核心 + 状态管理 + 修饰符 + Select/Dropdown + Flex + LazyColumn) +- 未实现:**24** + +### 近期大更新 + +- `UIComponent` 事件系统:`mouseClicked`/`mouseDragged`/`mouseScrolled`/`keyPressed`/`charTyped`/`updateHover`/`eventPriority`/ + `renderingPriority`/`sortedChildren` +- `DeclarativeScreen` 不再 `instanceof` 硬编码,统一递归分发事件 +- `SdfGraphics.triangle()` — 三角形 SDF 渲染 +- `Scrollable` / `Dropdown` 弹出层滚动条可鼠标拖拽 +- `TextInput` 支持选择/复制/粘贴/剪切/全选/单词跳转,光标自动跟随滚动 +- `copyRuntimeState` 移至各组件覆写,Composition 统一调用 diff --git a/module.ui/build.gradle b/module.ui/build.gradle new file mode 100644 index 00000000..3dd3d6e5 --- /dev/null +++ b/module.ui/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(":anvillib-rendering-neoforge-26.1") +} \ No newline at end of file diff --git a/module.ui/gradle.properties b/module.ui/gradle.properties new file mode 100644 index 00000000..9a815e39 --- /dev/null +++ b/module.ui/gradle.properties @@ -0,0 +1,4 @@ +## Mod Properties +mod_id=anvillib_ui +mod_name=AnvilLib-UI +mod_description=A simple declarative UI library \ No newline at end of file diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Alignment.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Alignment.java new file mode 100644 index 00000000..df19ece9 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Alignment.java @@ -0,0 +1,55 @@ +package dev.anvilcraft.lib.v2.ui; + +/** + * 子组件在交叉轴上的对齐方式。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public final class Alignment { + private Alignment() { + } + + /** + * 水平对齐(Column 中每个子组件的 X 定位)。 + */ + public enum Horizontal { + Start, Center, End; + + /** + * @param totalWidth 父容器宽度 + * @param childWidth 子组件宽度 + * @return 子组件的 x 偏移 + */ + public float align(float totalWidth, float childWidth) { + return switch (this) { + case Horizontal.Start -> 0; + case Horizontal.Center -> (totalWidth - childWidth) / 2; + case Horizontal.End -> totalWidth - childWidth; + }; + } + } + + /** + * 垂直对齐(Row 中每个子组件的 Y 定位)。 + */ + public enum Vertical { + Top, Center, Bottom; + + /** + * @param totalHeight 父容器高度 + * @param childHeight 子组件高度 + * @return 子组件的 y 偏移 + */ + public float align(float totalHeight, float childHeight) { + return switch (this) { + case Vertical.Top -> 0; + case Vertical.Center -> (totalHeight - childHeight) / 2; + case Vertical.Bottom -> totalHeight - childHeight; + }; + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Animatable.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Animatable.java new file mode 100644 index 00000000..d639c6ea --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Animatable.java @@ -0,0 +1,73 @@ +package dev.anvilcraft.lib.v2.ui; + +import lombok.Getter; +import net.minecraft.util.Mth; + +/** + * 基于游戏 tick 的动画值。从当前值平滑过渡到目标值。 + * 每 tick 调用 {@link #tick()} 推进动画。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class Animatable { + @Getter + private float value; + private float startValue; + private float targetValue; + private int durationTicks; + private int elapsed; + + public Animatable(float initialValue) { + this.value = initialValue; + this.targetValue = initialValue; + } + + /** + * 直接设置值(无动画)。 + */ + public void setValue(float value) { + this.value = value; + this.targetValue = value; + this.elapsed = 0; + } + + /** + * 动画到目标值,durationTicks 帧内完成。 + */ + public void animateTo(float target, int durationTicks) { + if (durationTicks <= 0) { + this.value = target; + this.targetValue = target; + this.elapsed = 0; + return; + } + this.startValue = this.getValue(); + this.targetValue = target; + this.durationTicks = durationTicks; + this.elapsed = 0; + } + + /** + * 每 tick 调用一次以推进动画。返回 true 表示动画进行中。 + */ + public boolean tick() { + if (this.elapsed >= this.durationTicks) return false; + this.elapsed++; + float t = this.durationTicks > 0 ? (float) this.elapsed / this.durationTicks : 1f; + // 缓入缓出 + float eased = t < 0.5f ? 2f * t * t : -1f + (4f - 2f * t) * t; + this.value = Mth.lerp(eased, this.startValue, this.targetValue); + return this.elapsed < this.durationTicks; + } + + /** + * 动画是否进行中。 + */ + public boolean isRunning() { + return this.elapsed < this.durationTicks; + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/AnvilLibUi.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/AnvilLibUi.java new file mode 100644 index 00000000..c40ae3a6 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/AnvilLibUi.java @@ -0,0 +1,19 @@ +package dev.anvilcraft.lib.v2.ui; + +import net.minecraft.resources.Identifier; +import net.neoforged.fml.common.Mod; + +@Mod(AnvilLibUi.MOD_ID) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class AnvilLibUi { + public static final String MOD_ID = "anvillib_ui"; + + public static Identifier of(String path) { + return Identifier.fromNamespaceAndPath(AnvilLibUi.MOD_ID, path); + } +} \ No newline at end of file diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Arrangement.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Arrangement.java new file mode 100644 index 00000000..6756e05a --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Arrangement.java @@ -0,0 +1,163 @@ +package dev.anvilcraft.lib.v2.ui; + +import java.util.List; + +/** + * 子组件在主轴上的分布方式。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public final class Arrangement { + private Arrangement() { + } + + /** + * 纵向排列(Column 主轴)。 + */ + public enum Vertical { + Top, Center, Bottom, SpaceBetween, SpaceAround, SpaceEvenly; + + /** + * @param totalHeight 可用总高度 + * @param childHeights 各子组件高度 + * @param spacing 间距 + * @return 各子组件的 y 偏移 + */ + public float[] arrange(float totalHeight, List childHeights, float spacing) { + int n = childHeights.size(); + if (n == 0) return new float[0]; + + float content = 0; + for (float h : childHeights) content += h; + float gapTotal = spacing * (n - 1); + float extra = totalHeight - content - gapTotal; + + float[] offsets = new float[n]; + switch (this) { + case Vertical.Top -> { + float y = 0; + for (int i = 0; i < n; i++) { + offsets[i] = y; + y += childHeights.get(i) + spacing; + } + } + case Vertical.Center -> { + float y = Math.max(0, extra / 2); + for (int i = 0; i < n; i++) { + offsets[i] = y; + y += childHeights.get(i) + spacing; + } + } + case Vertical.Bottom -> { + float y = Math.max(0, extra); + for (int i = 0; i < n; i++) { + offsets[i] = y; + y += childHeights.get(i) + spacing; + } + } + case Vertical.SpaceBetween -> { + float gap = n > 1 ? (extra + gapTotal) / (n - 1) : 0; + float y = 0; + for (int i = 0; i < n; i++) { + offsets[i] = y; + y += childHeights.get(i) + gap; + } + } + case Vertical.SpaceAround -> { + float halfGap = n > 0 ? (extra + gapTotal) / (n * 2f) : 0; + float y = halfGap; + for (int i = 0; i < n; i++) { + offsets[i] = y; + y += childHeights.get(i) + spacing + halfGap * 2 - spacing; + } + } + case Vertical.SpaceEvenly -> { + float gap = n > 0 ? (extra + gapTotal) / (n + 1) : 0; + float y = gap; + for (int i = 0; i < n; i++) { + offsets[i] = y; + y += childHeights.get(i) + spacing + gap - spacing; + } + } + } + return offsets; + } + } + + /** + * 横向排列(Row 主轴)。 + */ + public enum Horizontal { + Start, Center, End, SpaceBetween, SpaceAround, SpaceEvenly; + + /** + * @param totalWidth 可用总宽度 + * @param childWidths 各子组件宽度 + * @param spacing 间距 + * @return 各子组件的 x 偏移 + */ + public float[] arrange(float totalWidth, List childWidths, float spacing) { + int n = childWidths.size(); + if (n == 0) return new float[0]; + + float content = 0; + for (float w : childWidths) content += w; + float gapTotal = spacing * (n - 1); + float extra = totalWidth - content - gapTotal; + + float[] offsets = new float[n]; + switch (this) { + case Horizontal.Start -> { + float x = 0; + for (int i = 0; i < n; i++) { + offsets[i] = x; + x += childWidths.get(i) + spacing; + } + } + case Horizontal.Center -> { + float x = Math.max(0, extra / 2); + for (int i = 0; i < n; i++) { + offsets[i] = x; + x += childWidths.get(i) + spacing; + } + } + case Horizontal.End -> { + float x = Math.max(0, extra); + for (int i = 0; i < n; i++) { + offsets[i] = x; + x += childWidths.get(i) + spacing; + } + } + case Horizontal.SpaceBetween -> { + float gap = n > 1 ? (extra + gapTotal) / (n - 1) : 0; + float x = 0; + for (int i = 0; i < n; i++) { + offsets[i] = x; + x += childWidths.get(i) + gap; + } + } + case Horizontal.SpaceAround -> { + float halfGap = n > 0 ? (extra + gapTotal) / (n * 2f) : 0; + float x = halfGap; + for (int i = 0; i < n; i++) { + offsets[i] = x; + x += childWidths.get(i) + spacing + halfGap * 2 - spacing; + } + } + case Horizontal.SpaceEvenly -> { + float gap = n > 0 ? (extra + gapTotal) / (n + 1) : 0; + float x = gap; + for (int i = 0; i < n; i++) { + offsets[i] = x; + x += childWidths.get(i) + spacing + gap - spacing; + } + } + } + return offsets; + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Composition.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Composition.java new file mode 100644 index 00000000..f951465c --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Composition.java @@ -0,0 +1,249 @@ +package dev.anvilcraft.lib.v2.ui; + +import dev.anvilcraft.lib.v2.ui.modifier.ModifierElement; +import lombok.Setter; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * 组合引擎,驱动 recompose、状态追踪和渲染。 + *

+ * 每个 {@link DeclarativeScreen} 创建一个 Composition。 + * 它维护一个按调用位置索引的扁平 slot table。 + * recompose 时内容 lambda 重执行,每次 {@link #emit(UIComponent)} 调用 + * 与同索引的 slot 做 diff。 + *

+ * 状态读取按 slot 追踪,写入只标记受影响 slot 为脏——不会波及整棵树。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class Composition { + private static final ThreadLocal<@Nullable Composition> CURRENT = new ThreadLocal<>(); + private final List slots = new ArrayList<>(); + private final Map rememberedValues = new HashMap<>(); + + // ── slot table ── + private final List animatables = new ArrayList<>(); + private final @Nullable UIScope rootScope; + /** + * 当前正在 emit 的 slot(在 {@link #emit} 期间设置)。 + */ + @Nullable Slot currentSlot; + private int currentIndex; + private int currentRememberKey; + private boolean dirty = true; + // ── 状态 ── + @Setter + private @Nullable Consumer content; + + public Composition(@Nullable UIScope rootScope) { + this.rootScope = rootScope; + } + + /** + * 返回当前线程上的组合实例,可能为 null。 + */ + @Nullable + public static Composition currentOrNull() { + return CURRENT.get(); + } + + /** + * 返回当前线程上的组合实例,不存在则抛出异常。 + */ + public static Composition current() { + Composition c = CURRENT.get(); + if (c == null) { + throw new IllegalStateException("Not inside a composition frame"); + } + return c; + } + + /** + * 标记组合需要在下一帧 recompose。 + */ + public void invalidate() { + this.dirty = true; + } + + /** + * 注册动画值,每帧自动 tick。动画进行中时自动触发 recompose。 + */ + public void watch(Animatable anim) { + if (!this.animatables.contains(anim)) { + this.animatables.add(anim); + } + } + + // ── remember / ref ── + + /** + * 在多次 recompose 间持久化一个值。init supplier 只在首次组合时调用。 + */ + @SuppressWarnings("unchecked") + public T remember(Supplier init) { + int key = this.currentRememberKey++; + Object existing = this.rememberedValues.get(key); + if (existing != null) { + return (T) existing; + } + T value = init.get(); + this.rememberedValues.put(key, value); + return value; + } + + /** + * {@code comp.ref(0)} 等价于 {@code comp.remember(() -> new Ref<>(0))}。 + */ + public Ref ref(T initialValue) { + return this.remember(() -> new Ref<>(initialValue)); + } + + // ── emit ── + + /** + * 向当前调用位置的 slot 中 emit 一个组件。由组件工厂函数调用。 + */ + public void emit(UIComponent component) { + Slot slot; + if (this.currentIndex < this.slots.size()) { + slot = this.slots.get(this.currentIndex); + UIComponent old = slot.component; + if (old != null && old.getClass() == component.getClass()) { + component.copyRuntimeState(old); + } + slot.component = component; + } else { + slot = new Slot(); + slot.component = component; + this.slots.add(slot); + } + this.currentSlot = slot; + this.currentIndex++; + } + + // ── 每帧入口 ── + + /** + * 每帧从 {@link DeclarativeScreen#extractRenderState} 调用。 + * 若脏则 recompose,然后 measure → layout → render。 + */ + public void renderFrame(GuiGraphicsExtractor extractor, float screenWidth, float screenHeight) { + CURRENT.set(this); + try { + for (Animatable anim : this.animatables) { + if (anim.tick()) this.dirty = true; + } + if (this.dirty || this.hasDirtySlots()) { + this.recompose(); + this.dirty = false; + } + Constraints rootConstraints = new Constraints(0, screenWidth, 0, screenHeight); + if (this.rootScope != null) { + for (UIComponent child : this.rootScope.getChildren()) { + this.renderTree(child, extractor, rootConstraints); + } + } + } finally { + CURRENT.set(null); + } + } + + // ── recompose ── + + private void recompose() { + if (this.rootScope != null) { + this.rootScope.clearChildren(); + } + this.currentIndex = 0; + this.currentRememberKey = 0; + for (Slot slot : this.slots) { + slot.dirty = false; + } + if (this.content != null && this.rootScope != null) { + this.content.accept(this.rootScope); + } + while (this.slots.size() > this.currentIndex) { + this.slots.removeLast(); + } + } + + private boolean hasDirtySlots() { + for (Slot slot : this.slots) { + if (slot.dirty) return true; + } + return false; + } + + // ── measure → layout → render 遍历 ── + + // 1. 修饰符作用于约束 + // 2. 测量 + // 3. 布局 + // 4. 发射修饰符渲染状态(背景、边框等) + // 5. 发射组件自身渲染状态 + // 6. 递归子组件(容器组件的 measure/layout/extractRenderState 自行处理) + + private void renderTree(UIComponent component, GuiGraphicsExtractor extractor, Constraints constraints) { + // 1. Apply modifier to constraints + Constraints modConstraints = component.modifier().foldIn(constraints, (c, el) -> el.modifyConstraints(c)); + + // 2. Measure + MeasuredSize size = component.measure(modConstraints); + size = component.modifier().foldOut(size, (el, s) -> el.modifyMeasuredSize(component, modConstraints, s)); + + // 3. Layout + LayoutRect rect = LayoutRect.of(0, 0, size.width(), size.height()); + rect = component.modifier().foldOut(rect, ModifierElement::modifyLayout); + component.layout(rect.x(), rect.y(), rect.width(), rect.height()); + + // 4. Emit modifier render states (background, border, etc.) + final LayoutRect finalRect = rect; + component.modifier().foldOut( + extractor, (el, e) -> { + el.emitRenderState(e, finalRect); + return e; + } + ); + + // 5. Emit component's own render states + component.extractRenderState(extractor); + + // 6. Recurse into children (container components handle their own children + // in measure/layout/extractRenderState, but we also walk them here + // so the external renderTree call drives the full tree) + } + + // ── slot ── + + /** + * slot table 中的一个位置。每个 slot 持有一个组件, + * 并追踪它读取了哪些状态,以便精确标记脏。 + */ + public static class Slot { + final Set> readStates = new HashSet<>(); + @Nullable UIComponent component; + boolean dirty = true; + + void addReadState(Ref state) { + this.readStates.add(state); + } + + void markDirty() { + this.dirty = true; + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Constraints.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Constraints.java new file mode 100644 index 00000000..64bff17f --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Constraints.java @@ -0,0 +1,34 @@ +package dev.anvilcraft.lib.v2.ui; + +/** + * 父容器传给子组件的 min/max 尺寸约束。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public record Constraints(float minWidth, float maxWidth, float minHeight, float maxHeight) { + public static final Constraints NONE = new Constraints(0, Float.MAX_VALUE, 0, Float.MAX_VALUE); + + public float constrainWidth(float w) { + return Math.clamp(w, this.minWidth(), this.maxWidth()); + } + + public float constrainHeight(float h) { + return Math.clamp(h, this.minHeight(), this.maxHeight()); + } + + public Constraints withWidth(float width) { + return new Constraints(width, width, this.minHeight(), this.maxHeight()); + } + + public Constraints withHeight(float height) { + return new Constraints(this.minWidth(), this.maxWidth(), height, height); + } + + public Constraints copy(float minW, float maxW, float minH, float maxH) { + return new Constraints(minW, maxW, minH, maxH); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/DeclarativeScreen.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/DeclarativeScreen.java new file mode 100644 index 00000000..5e58fe66 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/DeclarativeScreen.java @@ -0,0 +1,239 @@ +package dev.anvilcraft.lib.v2.ui; + +import dev.anvilcraft.lib.v2.ui.component.DropdownComponent; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +/** + * 声明式 UI 的 {@link Screen} 宿主。 + *

+ * 每帧自动完成:dirty check → recompose → measure → layout → render states。 + * 输入事件通过递归遍历组件树分发,各组件覆写 {@link UIComponent} 的事件方法处理自身逻辑。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public abstract class DeclarativeScreen extends Screen { + private final UIScope rootScope = new RootScope(); + @Nullable + private Composition composition; + @Nullable + private UIComponent focusOwner; + + protected DeclarativeScreen(Component title) { + super(title); + } + + protected abstract void content(@Nullable UIScope scope); + + // ── 每帧渲染 ── + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor, int mouseX, int mouseY, float partialTick) { + super.extractRenderState(extractor, mouseX, mouseY, partialTick); + if (this.composition != null) { + this.composition.renderFrame(extractor, this.width, this.height); + this.updateHover(mouseX, mouseY); + this.refreshFocus(); + } + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (event.key() == 256) { + this.onClose(); + return true; + } + if (this.focusOwner != null && this.focusOwner.keyPressed(event)) return true; + return super.keyPressed(event); + } + + // ── 焦点 ── + + @Override + protected void init() { + if (this.composition == null) { + this.composition = new Composition(this.rootScope); + this.composition.setContent(this::content); + } + } + + /** + * 窗口 resize 时不重建组件树,只标记 dirty 用新尺寸 recompose。 + */ + @Override + protected void repositionElements() { + if (this.composition != null) this.composition.invalidate(); + } + + private void refreshFocus() { + if (this.focusOwner == null) return; + for (UIComponent child : this.rootScope.getChildren()) { + UIComponent found = findFocused(child); + if (found != null) { + this.focusOwner = found; + return; + } + } + this.focusOwner = null; + } + + // ── 鼠标点击 ── + + private @Nullable UIComponent findFocused(UIComponent component) { + if (component instanceof Focusable f && f.focused()) return component; + for (UIComponent child : component.children()) { + UIComponent found = findFocused(child); + if (found != null) return found; + } + return null; + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean isDoubleClick) { + if (event.button() != 0) return super.mouseClicked(event, isDoubleClick); + + this.clearAllFocus(); + + if (this.dispatchMouseClicked(event, isDoubleClick)) return true; + + this.closeAllDropdowns(); + return super.mouseClicked(event, isDoubleClick); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + for (UIComponent child : this.rootScope.getChildren()) { + dispatchMouseReleased(child, event); + } + return super.mouseReleased(event); + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double deltaX, double deltaY) { + for (UIComponent child : this.rootScope.getChildren()) { + if (dispatchMouseDragged(child, event, deltaX, deltaY)) return true; + } + return super.mouseDragged(event, deltaX, deltaY); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + for (UIComponent child : this.rootScope.getChildren()) { + if (dispatchMouseScrolled(child, mouseX, mouseY, scrollX, scrollY)) return true; + } + return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + + @Override + public boolean charTyped(CharacterEvent event) { + if (this.focusOwner != null && this.focusOwner.charTyped(event)) return true; + return super.charTyped(event); + } + + private void clearAllFocus() { + this.focusOwner = null; + for (UIComponent child : this.rootScope.getChildren()) clearFocusRecursive(child); + } + + // ── 鼠标拖拽 ── + + private void clearFocusRecursive(UIComponent component) { + if (component instanceof Focusable f) f.setFocused(false); + for (UIComponent child : component.children()) clearFocusRecursive(child); + } + + private void closeAllDropdowns() { + for (UIComponent child : this.rootScope.getChildren()) closeDropdownsRecursive(child); + } + + // ── 鼠标释放 ── + + private void closeDropdownsRecursive(UIComponent component) { + if (component instanceof DropdownComponent dd) dd.setOpen(false); + for (UIComponent child : component.children()) closeDropdownsRecursive(child); + } + + private boolean dispatchMouseClicked(MouseButtonEvent event, boolean isDouble) { + for (UIComponent child : this.rootScope.getChildren()) { + if (dispatchMouseClickedRecursive(child, event, isDouble)) return true; + } + return false; + } + + // ── 滚轮 ── + + private boolean dispatchMouseClickedRecursive(UIComponent component, MouseButtonEvent event, boolean isDouble) { + var sorted = component.children() + .stream() + .sorted(java.util.Comparator.comparingInt(UIComponent::eventPriority).reversed()) + .toList(); + for (UIComponent child : sorted) { + if (dispatchMouseClickedRecursive(child, event, isDouble)) return true; + } + if (component.mouseClicked(event, isDouble)) { + if (component instanceof Focusable) this.focusOwner = component; + return true; + } + return false; + } + + private boolean dispatchMouseDragged(UIComponent component, MouseButtonEvent event, double dx, double dy) { + var sorted = component.children() + .stream() + .sorted(java.util.Comparator.comparingInt(UIComponent::eventPriority).reversed()) + .toList(); + for (UIComponent child : sorted) { + if (dispatchMouseDragged(child, event, dx, dy)) return true; + } + return component.mouseDragged(event, dx, dy); + } + + // ── 键盘 ── + + private void dispatchMouseReleased(UIComponent component, MouseButtonEvent event) { + var sorted = component.children() + .stream() + .sorted(java.util.Comparator.comparingInt(UIComponent::eventPriority).reversed()) + .toList(); + for (UIComponent child : sorted) dispatchMouseReleased(child, event); + component.mouseReleased(event); + } + + private boolean dispatchMouseScrolled(UIComponent component, double mx, double my, double sx, double sy) { + var sorted = component.children() + .stream() + .sorted(java.util.Comparator.comparingInt(UIComponent::eventPriority).reversed()) + .toList(); + for (UIComponent child : sorted) { + if (dispatchMouseScrolled(child, mx, my, sx, sy)) return true; + } + return component.mouseScrolled(mx, my, sx, sy); + } + + // ── hover ── + + private void updateHover(float mouseX, float mouseY) { + for (UIComponent child : this.rootScope.getChildren()) { + updateHoverRecursive(child, mouseX, mouseY); + } + } + + private void updateHoverRecursive(UIComponent component, float mx, float my) { + component.updateHover(mx, my); + for (UIComponent child : component.children()) updateHoverRecursive(child, mx, my); + } + + // ── 内部类 ── + + private static class RootScope extends UIScope { + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Focusable.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Focusable.java new file mode 100644 index 00000000..090db441 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Focusable.java @@ -0,0 +1,17 @@ +package dev.anvilcraft.lib.v2.ui; + +/** + * 可获取键盘焦点的组件接口。 + * 由 {@link DeclarativeScreen} 的焦点系统驱动。 + */ +public interface Focusable { + /** + * 是否已获取焦点。 + */ + boolean focused(); + + /** + * 设置焦点状态。 + */ + void setFocused(boolean focused); +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/ForEach.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/ForEach.java new file mode 100644 index 00000000..4c7640f9 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/ForEach.java @@ -0,0 +1,34 @@ +package dev.anvilcraft.lib.v2.ui; + +import java.util.List; +import java.util.function.BiConsumer; + +/** + * 循环渲染工具。将列表中的每个元素映射为一组 UI 组件。 + * 列表顺序稳定时,slot 位置保持稳定。 + * + *

{@code
+ * ForEach.of(scope, items, (s, item) -> {
+ *     s.Text(item.name());
+ * });
+ * }
+ */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public final class ForEach { + private ForEach() { + } + + /** + * 对列表中的每个元素执行内容函数。 + */ + public static void of(UIScope scope, List items, BiConsumer content) { + for (T item : items) { + content.accept(scope, item); + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/LayoutRect.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/LayoutRect.java new file mode 100644 index 00000000..80477956 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/LayoutRect.java @@ -0,0 +1,28 @@ +package dev.anvilcraft.lib.v2.ui; + +/** + * 布局阶段之后的定位矩形。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public record LayoutRect(float x, float y, float width, float height) { + public static LayoutRect of(float x, float y, float width, float height) { + return new LayoutRect(x, y, width, height); + } + + public float right() { + return this.x() + this.width(); + } + + public float bottom() { + return this.y() + this.height(); + } + + public boolean contains(float px, float py) { + return px >= this.x() && px < this.x() + this.width() && py >= this.y() && py < this.y() + this.height(); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/MeasuredSize.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/MeasuredSize.java new file mode 100644 index 00000000..88afe0aa --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/MeasuredSize.java @@ -0,0 +1,18 @@ +package dev.anvilcraft.lib.v2.ui; + +/** + * 组件 measure 阶段的结果。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public record MeasuredSize(float width, float height) { + public static final MeasuredSize ZERO = new MeasuredSize(0, 0); + + public static MeasuredSize of(float width, float height) { + return new MeasuredSize(width, height); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Modifier.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Modifier.java new file mode 100644 index 00000000..b5fc3c75 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Modifier.java @@ -0,0 +1,96 @@ +package dev.anvilcraft.lib.v2.ui; + +import dev.anvilcraft.lib.v2.ui.modifier.ModifierElement; +import dev.anvilcraft.lib.v2.ui.modifier.SingleElementModifier; + +import java.util.function.BiFunction; + +/** + * 链式修饰符 API。修饰符通过 {@link #then} 形成链表。 + *

+ * 每个修饰符元素可参与 measure、layout、render 阶段。 + * 调用方通过 {@link #foldIn} / {@link #foldOut} 遍历链。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public interface Modifier { + Modifier NONE = new Modifier() { + @Override + public Modifier then(Modifier other) { + return other; + } + + @Override + public R foldIn(R initial, BiFunction operation) { + return initial; + } + + @Override + public R foldOut(R initial, BiFunction operation) { + return initial; + } + }; + + Modifier then(Modifier other); + + R foldIn(R initial, BiFunction operation); + + R foldOut(R initial, BiFunction operation); + + // ── 工厂快捷方法 ── + + /** + * 返回一个前置了给定元素的新链。 + */ + default Modifier prepend(ModifierElement element) { + return then(new SingleElementModifier(element)); + } + + default Modifier size(float width, float height) { + return prepend(ModifierElement.size(width, height)); + } + + default Modifier width(float width) { + return prepend(ModifierElement.width(width)); + } + + default Modifier height(float height) { + return prepend(ModifierElement.height(height)); + } + + default Modifier fillMaxWidth() { + return prepend(ModifierElement.fillMaxWidth()); + } + + default Modifier fillMaxHeight() { + return prepend(ModifierElement.fillMaxHeight()); + } + + default Modifier fillMaxSize() { + return prepend(ModifierElement.fillMaxSize()); + } + + default Modifier padding(float all) { + return prepend(ModifierElement.padding(all)); + } + + default Modifier padding(float horizontal, float vertical) { + return prepend(ModifierElement.padding(horizontal, vertical)); + } + + default Modifier background(int color) { + return prepend(ModifierElement.background(color)); + } + + default Modifier border(float width, int color) { + return prepend(ModifierElement.border(width, color)); + } + + default Modifier border(float width, int color, float round) { + return prepend(ModifierElement.border(width, color, round)); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Ref.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Ref.java new file mode 100644 index 00000000..53223878 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/Ref.java @@ -0,0 +1,74 @@ +package dev.anvilcraft.lib.v2.ui; + +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * 可观察状态持有者。 + *

+ * 读取时追踪当前 composition slot,写入时标记所有 reader slot 为脏, + * 从而实现精确范围的 recompose。 + * + * @param 持有值的类型 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class Ref implements Supplier, Consumer { + final Set readers = new HashSet<>(); + final Map>> observers = new HashMap<>(); + private long used = 0; + private T value; + + public Ref(T initialValue) { + this.value = initialValue; + } + + /** + * 读取当前值。若在 composition emission 期间调用,记录此 slot 为 reader。 + */ + public T get() { + Composition comp = Composition.currentOrNull(); + if (comp != null && comp.currentSlot != null) { + comp.currentSlot.addReadState(this); + this.readers.add(comp.currentSlot); + } + return this.value; + } + + /** + * 设置新值。若值发生变化,标记所有 reader slot 为脏。 + */ + public void accept(T newValue) { + if (!Objects.equals(this.value, newValue)) { + this.value = newValue; + for (Composition.Slot slot : this.readers) { + slot.markDirty(); + } + } + } + + @Override + public String toString() { + return "State(" + this.value + ")"; + } + + public Long watch(Consumer> watcher) { + this.observers.put(this.used, watcher); + return this.used++; + } + + public Consumer> unwatch(Long id) { + return this.observers.remove(id); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/UIComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/UIComponent.java new file mode 100644 index 00000000..489b8dbc --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/UIComponent.java @@ -0,0 +1,128 @@ +package dev.anvilcraft.lib.v2.ui; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; + +import java.util.List; + +/** + * 所有 UI 组件的核心接口。 + *

+ * 组件每帧参与三个阶段: + *

    + *
  1. {@link #measure(Constraints)} — 根据父容器约束确定期望尺寸
  2. + *
  3. {@link #layout(float, float, float, float)} — 接收父容器分配的最终位置
  4. + *
  5. {@link #extractRenderState(GuiGraphicsExtractor)} — 提交渲染状态给 GPU
  6. + *
+ *

+ * 事件处理方法均有默认空实现,子类只覆写需要的。 + * {@link DeclarativeScreen} 负责递归遍历组件树并分发事件。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public interface UIComponent { + + Modifier modifier(); + + List children(); + + MeasuredSize measure(Constraints constraints); + + void layout(float x, float y, float width, float height); + + void extractRenderState(GuiGraphicsExtractor extractor); + + // ── 事件处理(默认空实现,子类覆写)── + + /** + * 鼠标点击。 + * + * @return 返回 true 表示已消费。 + */ + default boolean mouseClicked(MouseButtonEvent event, boolean isDoubleClick) { + return false; + } + + /** + * 鼠标拖拽。 + * + * @return 返回 true 表示已消费。 + */ + default boolean mouseDragged(MouseButtonEvent event, double deltaX, double deltaY) { + return false; + } + + /** + * 鼠标释放。 + */ + default boolean mouseReleased(MouseButtonEvent event) { + return false; + } + + /** + * 滚轮滚动。 + * + * @return 返回 true 表示已消费。 + */ + default boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + return false; + } + + /** + * 按键按下。 + * + * @return 返回 true 表示已消费。 + */ + default boolean keyPressed(KeyEvent event) { + return false; + } + + /** + * 字符输入。 + * + * @return 返回 true 表示已消费。 + */ + default boolean charTyped(CharacterEvent event) { + return false; + } + + /** + * 每帧更新 hover 状态。默认空实现。 + */ + default void updateHover(float mouseX, float mouseY) { + } + + /** + * 事件优先级。值越大越先处理。默认 0。 + */ + default int eventPriority() { + return 0; + } + + /** + * 渲染优先级。值越大越后提交渲染状态(上层)。 + * 默认 0。弹出层等需置于顶层的组件可覆写为更高值。 + */ + default int renderingPriority() { + return 0; + } + + /** + * 按渲染优先级排序后的子组件列表(低→高,先渲染的在前)。 + */ + default List sortedChildren() { + return this.children().stream().sorted(java.util.Comparator.comparingInt(UIComponent::renderingPriority)).toList(); + } + + /** + * 将旧组件的运行时状态复制到新组件。 + */ + default void copyRuntimeState(UIComponent old) { + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/UIScope.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/UIScope.java new file mode 100644 index 00000000..42d79cb5 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/UIScope.java @@ -0,0 +1,334 @@ +package dev.anvilcraft.lib.v2.ui; + +import dev.anvilcraft.lib.v2.ui.component.BoxComponent; +import dev.anvilcraft.lib.v2.ui.component.ButtonComponent; +import dev.anvilcraft.lib.v2.ui.component.CheckboxComponent; +import dev.anvilcraft.lib.v2.ui.component.ColumnComponent; +import dev.anvilcraft.lib.v2.ui.component.DropdownComponent; +import dev.anvilcraft.lib.v2.ui.component.FlexComponent; +import dev.anvilcraft.lib.v2.ui.component.FlexScope; +import dev.anvilcraft.lib.v2.ui.component.GridComponent; +import dev.anvilcraft.lib.v2.ui.component.ImageComponent; +import dev.anvilcraft.lib.v2.ui.component.LazyColumnComponent; +import dev.anvilcraft.lib.v2.ui.component.RowComponent; +import dev.anvilcraft.lib.v2.ui.component.ScrollableComponent; +import dev.anvilcraft.lib.v2.ui.component.SliderComponent; +import dev.anvilcraft.lib.v2.ui.component.SpacerComponent; +import dev.anvilcraft.lib.v2.ui.component.TextComponent; +import dev.anvilcraft.lib.v2.ui.component.TextInputComponent; +import dev.anvilcraft.lib.v2.ui.component.scope.BoxScope; +import dev.anvilcraft.lib.v2.ui.component.scope.ColumnScope; +import dev.anvilcraft.lib.v2.ui.component.scope.GridScope; +import dev.anvilcraft.lib.v2.ui.component.scope.RowScope; +import dev.anvilcraft.lib.v2.ui.component.scope.ScrollableScope; +import net.minecraft.resources.Identifier; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +/** + * 组件树的构建作用域。 + *

+ * 容器组件(Column、Row、Box 等)创建一个 scope, + * 对其执行内容 lambda,然后收集子组件。 + *

+ * 组件构建器在此定义为具体方法,所有 scope 子类自动继承。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public abstract class UIScope { + final List children = new ArrayList<>(); + + /** + * 向当前 scope 添加一个子组件。 + */ + public void addChild(UIComponent child) { + this.children.add(child); + } + + /** + * 返回当前 scope 中已收集的子组件列表(只读)。 + */ + public List getChildren() { + return Collections.unmodifiableList(this.children); + } + + /** + * 内部方法:recompose 前清空子组件。 + */ + void clearChildren() { + this.children.clear(); + } + + // ── 组件构建器 ── + + /** + * 创建一行文字。 + * + * @param text 显示文本 + * @return TextComponent 实例,可链式设置颜色、对齐、阴影等 + */ + public TextComponent Text(String text) { + TextComponent c = new TextComponent(Modifier.NONE, text); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建固定尺寸的空白占位。 + * + * @param width 宽度(像素) + * @param height 高度(像素) + */ + public SpacerComponent Spacer(float width, float height) { + SpacerComponent c = new SpacerComponent(Modifier.NONE, width, height); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建一个纹理精灵图片。 + * + * @param sprite 纹理标识符 + * @param width 显示宽度 + * @param height 显示高度 + */ + public ImageComponent Image(Identifier sprite, float width, float height) { + ImageComponent c = new ImageComponent(Modifier.NONE, sprite, width, height); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建复选框。 + * + * @param label 标签文字 + * @param checked 初始选中状态 + */ + public CheckboxComponent Checkbox(String label, boolean checked) { + CheckboxComponent c = new CheckboxComponent(Modifier.NONE, label, checked); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建复选框(vModel 双向绑定)。 + * + * @param label 标签文字 + * @param vModel {@code Ref},点击时自动同步值,无需手动 onToggle + */ + public CheckboxComponent Checkbox(String label, Ref vModel) { + CheckboxComponent c = new CheckboxComponent(Modifier.NONE, label, vModel.get() != null && vModel.get()); + c.onToggle(() -> vModel.accept(!vModel.get())); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建下拉菜单。 + * + * @param options 选项列表 + * @param selectedIndex 初始选中索引 + * @param maxPopupHeight 弹出层最大高度 + */ + public DropdownComponent Dropdown(String[] options, int selectedIndex, float maxPopupHeight) { + DropdownComponent c = new DropdownComponent(Modifier.NONE, options, selectedIndex, maxPopupHeight); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建滑块(手动值)。 + * + * @param value 初始值 + * @param min 最小值 + * @param max 最大值 + * @param width 轨道宽度(像素) + */ + public SliderComponent Slider(float value, float min, float max, float width) { + SliderComponent c = new SliderComponent(Modifier.NONE, value, min, max, width); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建滑块(vModel 双向绑定)。 + * + * @param min 最小值 + * @param max 最大值 + * @param width 轨道宽度(像素) + * @param vModel {@code Ref},拖拽时自动同步值 + */ + public SliderComponent Slider(float min, float max, float width, Ref vModel) { + SliderComponent c = new SliderComponent(Modifier.NONE, vModel.get() == null ? 0 : vModel.get(), min, max, width); + c.onChange(vModel::accept); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建单行文本输入框。 + * + * @param placeholder 占位提示文字(灰色,仅在无输入时显示) + */ + public TextInputComponent TextInput(String placeholder) { + TextInputComponent c = new TextInputComponent(Modifier.NONE, placeholder); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建单行文本输入框(vModel 双向绑定)。 + * + * @param placeholder 占位提示文字 + * @param vModel {@code Ref},输入时自动同步值 + */ + public TextInputComponent TextInput(String placeholder, Ref vModel) { + TextInputComponent c = new TextInputComponent(Modifier.NONE, placeholder); + c.onChange(vModel::accept); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建可点击按钮。 + * + * @param label 按钮文字 + */ + public ButtonComponent Button(String label) { + ButtonComponent c = new ButtonComponent(Modifier.NONE, label); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建纵向线性布局容器。 + * + * @param content 子组件声明 lambda + * @return ColumnComponent 实例,可链式设置 spacing、alignment 等 + */ + public ColumnComponent Column(Consumer content) { + ColumnComponent c = new ColumnComponent(Modifier.NONE); + ColumnScope inner = new ColumnScope(); + content.accept(inner); + c.setChildren(inner.getChildren()); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建横向线性布局容器。 + * + * @param content 子组件声明 lambda + * @return RowComponent 实例,可链式设置 spacing、alignment 等 + */ + public RowComponent Row(Consumer content) { + RowComponent c = new RowComponent(Modifier.NONE); + RowScope inner = new RowScope(); + content.accept(inner); + c.setChildren(inner.getChildren()); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建层叠布局容器。子组件按声明顺序从底到顶重叠。 + * + * @param content 子组件声明 lambda + */ + public BoxComponent Box(Consumer content) { + BoxComponent c = new BoxComponent(Modifier.NONE); + BoxScope inner = new BoxScope(); + content.accept(inner); + c.setChildren(inner.getChildren()); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建弹性布局容器。子组件按 flexGrow 权重分配剩余空间。 + * 用法:{@code scope.Flex(Direction.ROW, f -> { f.flexGrow(2).Text("Wide"); f.Text("Normal"); });} + * + * @param direction 主轴方向(ROW 或 COLUMN) + * @param content 子组件声明 lambda + */ + public FlexComponent Flex(FlexComponent.Direction direction, Consumer content) { + FlexComponent c = new FlexComponent(Modifier.NONE, direction); + FlexScope inner = new FlexScope(); + content.accept(inner); + c.setChildren(inner.getChildren()); + c.setChildFlexGrows(inner.childWeights()); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建网格布局容器。 + * + * @param columns 列数 + * @param content 子组件声明 lambda + * @return GridComponent 实例,可链式设置 spacing + */ + public GridComponent Grid(int columns, Consumer content) { + GridComponent c = new GridComponent(Modifier.NONE, columns); + GridScope inner = new GridScope(); + content.accept(inner); + c.setChildren(inner.getChildren()); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建可滚动容器。内容超过 maxHeight 时可垂直滚动,自动裁剪并显示滚动条。 + * + * @param maxHeight 容器最大可见高度(像素) + * @param content 子组件声明 lambda + */ + public ScrollableComponent Scrollable(float maxHeight, Consumer content) { + ScrollableComponent c = new ScrollableComponent(Modifier.NONE, maxHeight); + ScrollableScope inner = new ScrollableScope(); + content.accept(inner); + c.setChildren(inner.getChildren()); + this.addChild(c); + Composition.current().emit(c); + return c; + } + + /** + * 创建虚拟化纵向列表。仅渲染可见区域,支持滚轮和滚动条拖拽。 + * + * @param itemHeight 单项高度(像素) + * @param maxHeight 列表最大可见高度 + * @param items 数据源列表 + * @param builder 列表项构建函数 + */ + public LazyColumnComponent LazyColumn(float itemHeight, float maxHeight, + List items, LazyColumnComponent.ItemBuilder builder) { + LazyColumnComponent c = new LazyColumnComponent(Modifier.NONE, itemHeight, maxHeight, items, builder); + this.addChild(c); + Composition.current().emit(c); + return c; + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/BoxComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/BoxComponent.java new file mode 100644 index 00000000..54a27ff3 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/BoxComponent.java @@ -0,0 +1,101 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Alignment; +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 层叠布局。所有子组件重叠于同一区域,按声明顺序从底到顶绘制。 + * Box 本身的大小由最大的子组件决定。 + */ +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class BoxComponent implements UIComponent { + @Getter + @Setter + private Modifier modifier; + @Getter + private List children = Collections.emptyList(); + private List childSizes = Collections.emptyList(); + + private Alignment.Horizontal contentAlignmentH = Alignment.Horizontal.Start; + private Alignment.Vertical contentAlignmentV = Alignment.Vertical.Top; + + @Getter + private float x, y, width, height; + + public BoxComponent(Modifier modifier) { + this.modifier = modifier; + } + + public void setChildren(List children) { + this.children = List.copyOf(children); + } + + public BoxComponent contentAlignment(Alignment.Horizontal h, Alignment.Vertical v) { + this.contentAlignmentH = h; + this.contentAlignmentV = v; + return this; + } + + + @Override + public MeasuredSize measure(Constraints constraints) { + if (this.children.isEmpty()) return MeasuredSize.ZERO; + + float maxWidth = 0; + float maxHeight = 0; + List sizes = new ArrayList<>(this.children.size()); + + for (UIComponent child : this.children) { + MeasuredSize size = child.measure(constraints); + sizes.add(size); + maxWidth = Math.max(maxWidth, size.width()); + maxHeight = Math.max(maxHeight, size.height()); + } + + this.childSizes = sizes; + return MeasuredSize.of( + constraints.constrainWidth(maxWidth), + constraints.constrainHeight(maxHeight) + ); + } + + @Override + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + + for (int i = 0; i < this.children.size(); i++) { + UIComponent child = this.children.get(i); + MeasuredSize size = this.childSizes.get(i); + float childX = x + this.contentAlignmentH.align(width, size.width()); + float childY = y + this.contentAlignmentV.align(height, size.height()); + child.layout(childX, childY, size.width(), size.height()); + } + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + for (UIComponent child : this.sortedChildren()) { + child.extractRenderState(extractor); + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ButtonComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ButtonComponent.java new file mode 100644 index 00000000..f9cbd9b0 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ButtonComponent.java @@ -0,0 +1,123 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.sounds.SoundEvents; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.List; + +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class ButtonComponent implements UIComponent { + private static final int BG_COLOR = 0xFF404040; + private static final int BG_HOVER_COLOR = 0xFF606060; + private static final int TEXT_COLOR = 0xFFFFFFFF; + private static final float PADDING_H = 12; + private static final float PADDING_V = 6; + + @Getter + @Setter + private Modifier modifier; + @Setter + private String label; + @Nullable + private Runnable onClick; + private boolean hovered; + + private float x, y, width, height; + + public ButtonComponent(Modifier modifier, String label) { + this.modifier = modifier; + this.label = label; + } + + public ButtonComponent onClick(@Nullable Runnable onClick) { + this.onClick = onClick; + return this; + } + + public void setHovered(boolean hovered) { + this.hovered = hovered; + } + + @Override + public List children() { + return Collections.emptyList(); + } + + @Override + public MeasuredSize measure(Constraints constraints) { + var font = Minecraft.getInstance().font; + float textW = font.width(this.label); + float textH = font.lineHeight; + return MeasuredSize.of( + constraints.constrainWidth(textW + ButtonComponent.PADDING_H * 2), + constraints.constrainHeight(textH + ButtonComponent.PADDING_V * 2) + ); + } + + @Override + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + int bg = this.hovered ? ButtonComponent.BG_HOVER_COLOR : ButtonComponent.BG_COLOR; + int ix = (int) this.x, iy = (int) this.y, iw = (int) this.width, ih = (int) this.height; + extractor.fill(ix, iy, ix + iw, iy + ih, bg); + + var font = Minecraft.getInstance().font; + float textW = font.width(this.label); + int textX = (int) (this.x + (this.width - textW) / 2f); + int textY = (int) (this.y + (this.height - font.lineHeight) / 2f); + extractor.text(font, this.label, textX, textY, ButtonComponent.TEXT_COLOR, true); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean isDoubleClick) { + if (event.button() != 0) return false; + var mc = Minecraft.getInstance(); + int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow()); + int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow()); + if (this.hitRect().contains(mx, my)) { + this.click(); + mc.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0f)); + return true; + } + return false; + } + + @Override + public void updateHover(float mx, float my) { + this.hovered = this.hitRect().contains(mx, my); + } + + public LayoutRect hitRect() { + return LayoutRect.of(this.x, this.y, this.width, this.height); + } + + public void click() { + if (this.onClick != null) this.onClick.run(); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/CheckboxComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/CheckboxComponent.java new file mode 100644 index 00000000..999d10e5 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/CheckboxComponent.java @@ -0,0 +1,128 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.sounds.SoundEvents; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.List; + +/** + * 复选框。点击切换 boolean 状态。 + * 16x16 方框,未选中=深色空心,选中=浅色填充。 + */ +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class CheckboxComponent implements UIComponent { + private static final int BOX_COLOR = 0xFF404040; + private static final int CHECKED_COLOR = 0xFFFFFFFF; + private static final float SIZE = 16; + private static final float INSET = 3; + + @Getter + @Setter + private Modifier modifier; + @Setter + private String label; + private boolean checked; + @Setter + private @Nullable Runnable onToggle; + + @Getter + private float x, y, width, height; + + public CheckboxComponent(Modifier modifier, String label, boolean checked) { + this.modifier = modifier; + this.label = label; + this.checked = checked; + } + + + @Override + public List children() { + return Collections.emptyList(); + } + + @Override + public MeasuredSize measure(Constraints constraints) { + // 方框 + 间距 + 标签文字宽度(简化:估算每字符 7px 宽) + float labelW = this.label.length() * 7f; + return MeasuredSize.of( + constraints.constrainWidth(CheckboxComponent.SIZE + 4 + labelW), + constraints.constrainHeight(CheckboxComponent.SIZE) + ); + } + + @Override + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + int ix = (int) this.x, iy = (int) this.y; + + // 外层深灰方块(始终显示) + extractor.fill(ix, iy, ix + (int) CheckboxComponent.SIZE, iy + (int) CheckboxComponent.SIZE, CheckboxComponent.BOX_COLOR); + + // 选中时中间白色小方块 + if (this.checked) { + int iix = ix + (int) CheckboxComponent.INSET; + int iiy = iy + (int) CheckboxComponent.INSET; + int iiw = (int) CheckboxComponent.SIZE - (int) CheckboxComponent.INSET * 2; + int iih = (int) CheckboxComponent.SIZE - (int) CheckboxComponent.INSET * 2; + extractor.fill(iix, iiy, iix + iiw, iiy + iih, CheckboxComponent.CHECKED_COLOR); + } + + // 标签文字 + // TODO: 用 font.text() 渲染标签 + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean isDoubleClick) { + if (event.button() != 0) return false; + var mc = Minecraft.getInstance(); + int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow()); + int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow()); + if (this.hitRect().contains(mx, my)) { + this.toggle(); + mc.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0f)); + return true; + } + return false; + } + + /** + * 切换状态。 + */ + public void toggle() { + this.checked = !this.checked; + if (this.onToggle != null) this.onToggle.run(); + } + + /** + * 命中测试包围盒。 + */ + public LayoutRect hitRect() { + return LayoutRect.of(this.x, this.y, SIZE, SIZE); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ColumnComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ColumnComponent.java new file mode 100644 index 00000000..0c086ef0 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ColumnComponent.java @@ -0,0 +1,101 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Alignment; +import dev.anvilcraft.lib.v2.ui.Arrangement; +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class ColumnComponent implements UIComponent { + @Getter + @Setter + private Modifier modifier; + @Getter + private List children = Collections.emptyList(); + private List childSizes = Collections.emptyList(); + + @Setter + private Arrangement.Vertical verticalArrangement = Arrangement.Vertical.Top; + @Setter + private Alignment.Horizontal horizontalAlignment = Alignment.Horizontal.Start; + @Setter + private float spacing; + + @Getter + private float x, y, width, height; + + public ColumnComponent(Modifier modifier) { + this.modifier = modifier; + } + + public void setChildren(List children) { + this.children = List.copyOf(children); + } + + public MeasuredSize measure(Constraints constraints) { + if (this.children.isEmpty()) return MeasuredSize.ZERO; + + float totalHeight = 0; + float maxWidth = 0; + List sizes = new ArrayList<>(this.children.size()); + + Constraints childConstraints = new Constraints( + constraints.minWidth(), constraints.maxWidth(), + 0, Float.MAX_VALUE + ); + + for (UIComponent child : this.children) { + MeasuredSize size = child.measure(childConstraints); + sizes.add(size); + totalHeight += size.height(); + maxWidth = Math.max(maxWidth, size.width()); + } + totalHeight += this.spacing * (this.children.size() - 1); + + this.childSizes = sizes; + return MeasuredSize.of( + constraints.constrainWidth(maxWidth), + constraints.constrainHeight(totalHeight) + ); + } + + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + + List heights = new ArrayList<>(this.childSizes.size()); + for (MeasuredSize s : this.childSizes) heights.add(s.height()); + + float[] yOffsets = this.verticalArrangement.arrange(this.height, heights, this.spacing); + for (int i = 0; i < this.children.size(); i++) { + UIComponent child = this.children.get(i); + MeasuredSize size = this.childSizes.get(i); + float childX = this.x + this.horizontalAlignment.align(this.width, size.width()); + child.layout(childX, this.y + yOffsets[i], size.width(), size.height()); + } + } + + public void extractRenderState(GuiGraphicsExtractor extractor) { + for (UIComponent child : this.sortedChildren()) { + child.extractRenderState(extractor); + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/DropdownComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/DropdownComponent.java new file mode 100644 index 00000000..c437f37c --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/DropdownComponent.java @@ -0,0 +1,411 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.rendering.sdf.SdfGraphics; +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.util.Mth; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +/** + * 下拉菜单。点击展开选项列表,选择后收起。 + * 弹出层延迟渲染以确保 z-order 正确,支持最大高度 + 滚动。 + */ +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class DropdownComponent implements UIComponent { + private static final int BG_COLOR = 0xFF404040; + private static final int HOVER_COLOR = 0xFF606060; + private static final int TEXT_COLOR = 0xFFFFFFFF; + private static final int POPUP_BG = 0xFF303030; + private static final int POPUP_HOVER = 0xFF505050; + private static final int SCROLLBAR_COLOR = 0xFF888888; + private static final int SCROLLBAR_BG = 0xFF222222; + private static final int SCROLLBAR_W = 4; + private static final float PADDING_H = 8; + private static final float PADDING_V = 4; + private static final float ARROW_SIZE = 6; + private final String[] options; + private final float maxPopupHeight; + @Getter + @Setter + private Modifier modifier; + private int selectedIndex; + @Getter + private boolean open; + private boolean hovered; + @Getter + private float popupScrollY; + @Setter + private @Nullable Consumer onChange; + + // popup 拖拽 + @Getter + private boolean scrollbarDragging; + private float dragAnchorY; + + @Getter + private float x, y, width, height; + + public DropdownComponent(Modifier modifier, String[] options, int selectedIndex, float maxPopupHeight) { + this.modifier = modifier; + this.options = options; + this.selectedIndex = Mth.clamp(selectedIndex, 0, Math.max(0, options.length - 1)); + this.maxPopupHeight = maxPopupHeight; + } + + + public String selectedOption() { + return this.options.length > 0 ? this.options[this.selectedIndex] : ""; + } + + public void setOpen(boolean open) { + this.open = open; + if (!this.open) this.popupScrollY = 0; + } + + public void setHovered(boolean hovered) { + this.hovered = hovered; + } + + public void setPopupScrollY(float y) { + this.popupScrollY = y; + } + + @Override + public List children() { + return Collections.emptyList(); + } + + @Override + public MeasuredSize measure(Constraints constraints) { + var font = Minecraft.getInstance().font; + float maxTextW = 0; + for (String opt : this.options) maxTextW = Math.max(maxTextW, font.width(opt)); + float w = maxTextW + DropdownComponent.PADDING_H * 2 + DropdownComponent.ARROW_SIZE + 8; + float h = font.lineHeight + DropdownComponent.PADDING_V * 2; + return MeasuredSize.of(constraints.constrainWidth(w), constraints.constrainHeight(h)); + } + + @Override + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + this.renderTrigger(extractor); + this.renderPopup(extractor); + } + + // ── 触发器渲染 ── + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean isDoubleClick) { + if (event.button() != 0) return false; + var mc = Minecraft.getInstance(); + int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow()); + int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow()); + if (this.isOnPopupScrollbar(mx, my)) { + this.startPopupScrollbarDrag(my); + return true; + } + if (this.clickPopup(mx, my)) { + mc.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0f)); + return true; + } + if (this.clickTrigger(mx, my)) { + mc.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0f)); + return true; + } + return false; + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double deltaX, double deltaY) { + if (!this.scrollbarDragging()) return false; + var mc = Minecraft.getInstance(); + int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow()); + this.onPopupScrollbarDrag(my); + return true; + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + this.stopPopupScrollbarDrag(); + return false; + } + + // ── 弹出层渲染(延迟调用,确保 z-order) ── + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + if (this.open() && this.popupRect().contains((float) mouseX, (float) mouseY)) { + return this.onPopupScroll((float) scrollY); + } + return false; + } + + @Override + public void updateHover(float mx, float my) { + this.hovered = this.hitRect().contains(mx, my); + } + + @Override + public int renderingPriority() { + return this.open ? 100 : 0; + } + + private void renderTrigger(GuiGraphicsExtractor extractor) { + var font = Minecraft.getInstance().font; + int ix = (int) this.x, iy = (int) this.y, iw = (int) this.width, ih = (int) this.height; + int bg = (this.open || this.hovered) ? DropdownComponent.HOVER_COLOR : DropdownComponent.BG_COLOR; + extractor.fill(ix, iy, ix + iw, iy + ih, bg); + + String label = this.options.length > 0 ? this.options[this.selectedIndex] : ""; + int textY = (int) (this.y + (this.height + font.lineHeight) / 2f - font.lineHeight); + extractor.text(font, label, ix + (int) DropdownComponent.PADDING_H, textY, DropdownComponent.TEXT_COLOR); + + // SDF 三角箭头 + float arrowCx = this.x + this.width - DropdownComponent.PADDING_H - DropdownComponent.ARROW_SIZE / 2f; + float arrowCy = this.y + this.height / 2f; + float hs = DropdownComponent.ARROW_SIZE / 2f; + if (this.open) { + SdfGraphics.instance + .triangle( + arrowCx - hs, arrowCy + hs * 0.6f, + arrowCx + hs, arrowCy + hs * 0.6f, + arrowCx, arrowCy - hs * 0.8f + ) + .color(DropdownComponent.TEXT_COLOR) + .fill() + .draw(extractor); + } else { + SdfGraphics.instance + .triangle( + arrowCx - hs, arrowCy - hs * 0.6f, + arrowCx + hs, arrowCy - hs * 0.6f, + arrowCx, arrowCy + hs * 0.8f + ) + .color(DropdownComponent.TEXT_COLOR) + .fill() + .draw(extractor); + } + } + + // ── 交互 ── + + /** + * 弹出层 item 高度。 + */ + public float itemHeight() { + return Minecraft.getInstance().font.lineHeight + 4; + } + + /** + * 实际弹出层高度(min 内容高度, maxPopupHeight)。 + */ + public float popupHeight() { + if (this.options.length == 0) return 0; + return Math.min(this.options.length * this.itemHeight(), this.maxPopupHeight); + } + + /** + * 弹出层包围盒。 + */ + public LayoutRect popupRect() { + float ph = this.popupHeight(); + return LayoutRect.of(this.x, this.y + this.height, this.width, ph); + } + + /** + * 延迟渲染弹出层(在所有组件之后调用)。 + */ + public void renderPopup(GuiGraphicsExtractor extractor) { + if (!this.open || this.options.length == 0) return; + + var font = Minecraft.getInstance().font; + float itemH = this.itemHeight(); + float ph = this.popupHeight(); + float totalH = this.options.length * itemH; + float maxScroll = Math.max(0, totalH - ph); + this.popupScrollY = Mth.clamp(this.popupScrollY, -maxScroll, 0); + + int px = (int) this.x, py = (int) (this.y + this.height), pw = (int) this.width; + + extractor.enableScissor(px, py, px + pw, py + (int) ph); + extractor.fill(px, py, px + pw, py + (int) ph, DropdownComponent.POPUP_BG); + + // 计算鼠标悬停项 + var mc = Minecraft.getInstance(); + int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow()); + int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow()); + int hoveredIdx = -1; + if (mx >= px && mx < px + pw && my >= py && my < py + (int) ph) { + hoveredIdx = (int) ((my - py - this.popupScrollY) / itemH); + } + + float startY = this.y + this.height + this.popupScrollY; + for (int i = 0; i < this.options.length; i++) { + float iy = startY + i * itemH; + float bottom = iy + itemH; + if (bottom <= this.y + this.height || iy >= this.y + this.height + ph) continue; + + int bg; + if (i == this.selectedIndex) { + bg = DropdownComponent.POPUP_HOVER; + } else if (i == hoveredIdx) { + bg = DropdownComponent.SCROLLBAR_COLOR; // 悬停高亮 + } else { + bg = DropdownComponent.POPUP_BG; + } + int fillTop = Math.max((int) iy, py); + int fillBot = Math.min((int) bottom, py + (int) ph); + extractor.fill(px, fillTop, px + pw, fillBot, bg); + extractor.text(font, this.options[i], px + (int) DropdownComponent.PADDING_H, (int) iy + 2, DropdownComponent.TEXT_COLOR); + } + extractor.disableScissor(); + + // 滚动条 + if (totalH > ph) { + float bh = Math.max(12, ph * ph / totalH); + float by = py + (-this.popupScrollY / maxScroll) * (ph - bh); + int bx = px + pw - DropdownComponent.SCROLLBAR_W - 1; + extractor.fill(bx, py, bx + DropdownComponent.SCROLLBAR_W, py + (int) ph, DropdownComponent.SCROLLBAR_BG); + extractor.fill(bx, (int) by, bx + DropdownComponent.SCROLLBAR_W, (int) (by + bh), DropdownComponent.SCROLLBAR_COLOR); + } + } + + /** + * 点击触发器区域 → 切换展开。 + */ + public boolean clickTrigger(float px, float py) { + if (this.triggerRect().contains(px, py)) { + this.open = !this.open; + if (!this.open) this.popupScrollY = 0; + return true; + } + return false; + } + + /** + * 点击弹出层选项 → 选中并收起。返回 true 表示命中。 + */ + public boolean clickPopup(float px, float py) { + if (!this.open || this.options.length == 0) return false; + float ph = this.popupHeight(); + if (px < this.x || px > this.x + this.width || py < this.y + this.height || py > this.y + this.height + ph) return false; + + float itemH = this.itemHeight(); + float idxF = (py - this.y - this.height - this.popupScrollY) / itemH; + int idx = (int) idxF; + if (idx >= 0 && idx < this.options.length) { + this.select(idx); + return true; + } + return false; + } + + /** + * 滚轮滚动弹出层。 + */ + public boolean onPopupScroll(float amount) { + if (!this.open) return false; + float ph = this.popupHeight(); + float totalH = this.options.length * this.itemHeight(); + if (totalH <= ph) return false; + float maxScroll = totalH - ph; + this.popupScrollY = Mth.clamp(this.popupScrollY + amount * 20, -maxScroll, 0); + return true; + } + + /** + * 弹出层滚动条命中测试。 + */ + public boolean isOnPopupScrollbar(float mx, float my) { + if (!this.open) return false; + float ph = this.popupHeight(); + int py = (int) (this.y + this.height); + float totalH = this.options.length * this.itemHeight(); + if (totalH <= ph) return false; + float bh = Math.max(12, ph * ph / totalH); + float maxScroll = totalH - ph; + float by = py + (-this.popupScrollY / maxScroll) * (ph - bh); + int bx = (int) (this.x + this.width - DropdownComponent.SCROLLBAR_W - 1); + return mx >= bx && mx < bx + DropdownComponent.SCROLLBAR_W && my >= by && my < by + bh; + } + + public void startPopupScrollbarDrag(float my) { + this.scrollbarDragging = true; + float ph = this.popupHeight(); + int py = (int) (this.y + this.height); + float totalH = this.options.length * this.itemHeight(); + float bh = Math.max(12, ph * ph / totalH); + float maxScroll = totalH - ph; + float by = py + (-this.popupScrollY / maxScroll) * (ph - bh); + this.dragAnchorY = my - by; + } + + public void onPopupScrollbarDrag(float my) { + if (!this.scrollbarDragging) return; + float ph = this.popupHeight(); + int py = (int) (this.y + this.height); + float totalH = this.options.length * this.itemHeight(); + float bh = Math.max(12, ph * ph / totalH); + float maxScroll = totalH - ph; + float newBarY = my - this.dragAnchorY; + float ratio = Mth.clamp((newBarY - py) / (ph - bh), 0f, 1f); + this.popupScrollY = -(ratio * maxScroll); + } + + public void stopPopupScrollbarDrag() { + this.scrollbarDragging = false; + } + + private void select(int idx) { + if (idx != this.selectedIndex) { + this.selectedIndex = idx; + if (this.onChange != null) this.onChange.accept(this.options[idx]); + } + this.open = false; + this.popupScrollY = 0; + } + + private LayoutRect triggerRect() { + return LayoutRect.of(this.x, this.y, this.width, this.height); + } + + public LayoutRect hitRect() { + return this.triggerRect(); + } + + @Override + public void copyRuntimeState(UIComponent old) { + if (old instanceof DropdownComponent oldDd) { + this.setOpen(oldDd.open()); + this.setPopupScrollY(oldDd.popupScrollY()); + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/FlexComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/FlexComponent.java new file mode 100644 index 00000000..502db44c --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/FlexComponent.java @@ -0,0 +1,124 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Alignment; +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 弹性布局。子组件按权重(flexGrow)分配主轴上的剩余空间。 + * 主轴方向通过 {@link Direction} 指定。 + */ +@Accessors(fluent = true) +@SuppressWarnings({"unused", "UnusedReturnValue"}) +public class FlexComponent implements UIComponent { + + public enum Direction { ROW, COLUMN } + + @Getter @Setter + private Modifier modifier; + @Getter + private List children = Collections.emptyList(); + private final Direction direction; + private List childSizes = Collections.emptyList(); + private List childFlexGrows = Collections.emptyList(); + + @Setter + private float spacing; + @Setter + private Alignment.Horizontal crossAlignH = Alignment.Horizontal.Start; + @Setter + private Alignment.Vertical crossAlignV = Alignment.Vertical.Top; + + private float x, y, width, height; + + public FlexComponent(Modifier modifier, Direction direction) { + this.modifier = modifier; + this.direction = direction; + } + + public void setChildren(List children) { this.children = List.copyOf(children); } + public void setChildFlexGrows(List flexGrows) { this.childFlexGrows = List.copyOf(flexGrows); } + + @Override + public MeasuredSize measure(Constraints constraints) { + if (this.children.isEmpty()) return MeasuredSize.ZERO; + + boolean isRow = this.direction == Direction.ROW; + float totalMain = 0; + float maxCross = 0; + List sizes = new ArrayList<>(this.children.size()); + Constraints childC = isRow + ? new Constraints(0, Float.MAX_VALUE, constraints.minHeight(), constraints.maxHeight()) + : new Constraints(constraints.minWidth(), constraints.maxWidth(), 0, Float.MAX_VALUE); + + for (UIComponent child : this.children) { + MeasuredSize s = child.measure(childC); + sizes.add(s); + totalMain += isRow ? s.width() : s.height(); + maxCross = Math.max(maxCross, isRow ? s.height() : s.width()); + } + totalMain += this.spacing * (this.children.size() - 1); + + this.childSizes = sizes; + if (isRow) { + return MeasuredSize.of(constraints.constrainWidth(totalMain), constraints.constrainHeight(maxCross)); + } + return MeasuredSize.of(constraints.constrainWidth(maxCross), constraints.constrainHeight(totalMain)); + } + + @Override + public void layout(float x, float y, float width, float height) { + this.x = x; this.y = y; this.width = width; this.height = height; + if (this.children.isEmpty()) return; + + boolean isRow = this.direction == Direction.ROW; + float totalFixed = 0; + float totalWeight = 0; + for (int i = 0; i < this.children.size(); i++) { + float w = i < this.childFlexGrows.size() ? this.childFlexGrows.get(i) : 1f; + if (w <= 0) { + totalFixed += isRow ? this.childSizes.get(i).width() : this.childSizes.get(i).height(); + } else { + totalWeight += w; + } + } + float available = (isRow ? width : height) - totalFixed - this.spacing * (this.children.size() - 1); + + float currentMain = isRow ? x : y; + for (int i = 0; i < this.children.size(); i++) { + UIComponent child = this.children.get(i); + MeasuredSize size = this.childSizes.get(i); + float w = i < this.childFlexGrows.size() ? this.childFlexGrows.get(i) : 1f; + float childMain; + if (w <= 0 || totalWeight <= 0) { + childMain = isRow ? size.width() : size.height(); + } else { + childMain = (isRow ? size.width() : size.height()) + (w / totalWeight) * Math.max(0, available); + } + if (isRow) { + float childY = y + this.crossAlignV.align(height, size.height()); + child.layout(currentMain, childY, childMain, size.height()); + currentMain += childMain + this.spacing; + } else { + float childX = x + this.crossAlignH.align(width, size.width()); + child.layout(childX, currentMain, size.width(), childMain); + currentMain += childMain + this.spacing; + } + } + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + for (UIComponent child : this.sortedChildren()) child.extractRenderState(extractor); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/FlexScope.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/FlexScope.java new file mode 100644 index 00000000..02a22e9c --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/FlexScope.java @@ -0,0 +1,60 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Composition; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.Ref; +import dev.anvilcraft.lib.v2.ui.UIScope; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@link FlexComponent} 的子组件作用域。 + * 通过 {@link #flexGrow(float)} 设置下一个子组件的弹性权重,默认 1。 + */ +public class FlexScope extends UIScope { + + private final List pendingWeights = new ArrayList<>(); + private final List childWeights = new ArrayList<>(); + + /** 设置下一个子组件的弹性权重。0 = 固定尺寸。需在子组件声明前调用。 */ + public FlexScope flexGrow(float weight) { + this.pendingWeights.add(weight); + return this; + } + + private float takeWeight() { + float w = !this.pendingWeights.isEmpty() ? this.pendingWeights.removeFirst() : 1f; + this.childWeights.add(w); + return w; + } + + @Override + public TextComponent Text(String text) { this.takeWeight(); return super.Text(text); } + + @Override + public ButtonComponent Button(String label) { this.takeWeight(); return super.Button(label); } + + @Override + public CheckboxComponent Checkbox(String label, boolean checked) { this.takeWeight(); return super.Checkbox(label, checked); } + + @Override + public CheckboxComponent Checkbox(String label, Ref vModel) { this.takeWeight(); return super.Checkbox(label, vModel); } + + @Override + public SliderComponent Slider(float value, float min, float max, float width) { this.takeWeight(); return super.Slider(value, min, max, width); } + + @Override + public SliderComponent Slider(float min, float max, float width, Ref vModel) { this.takeWeight(); return super.Slider(min, max, width, vModel); } + + @Override + public TextInputComponent TextInput(String placeholder) { this.takeWeight(); return super.TextInput(placeholder); } + + @Override + public TextInputComponent TextInput(String placeholder, Ref vModel) { this.takeWeight(); return super.TextInput(placeholder, vModel); } + + @Override + public ImageComponent Image(net.minecraft.resources.Identifier sprite, float width, float height) { this.takeWeight(); return super.Image(sprite, width, height); } + + public List childWeights() { return List.copyOf(this.childWeights); } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/GridComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/GridComponent.java new file mode 100644 index 00000000..d6507916 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/GridComponent.java @@ -0,0 +1,96 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class GridComponent implements UIComponent { + private final int columns; + @Getter + @Setter + private Modifier modifier; + @Getter + private List children = Collections.emptyList(); + @SuppressWarnings("FieldCanBeLocal") + private List childSizes = Collections.emptyList(); + private float hSpacing, vSpacing; + + @Getter + private float x, y, width, height; + private float cellW, cellH; + + public GridComponent(Modifier modifier, int columns) { + this.modifier = modifier; + this.columns = Math.max(1, columns); + } + + public void setChildren(List children) { + this.children = List.copyOf(children); + } + + public GridComponent spacing(float h, float v) { + this.hSpacing = h; + this.vSpacing = v; + return this; + } + + public MeasuredSize measure(Constraints constraints) { + if (this.children.isEmpty()) return MeasuredSize.ZERO; + + float maxW = 0, maxH = 0; + List sizes = new ArrayList<>(this.children.size()); + Constraints childC = new Constraints(0, Float.MAX_VALUE, 0, Float.MAX_VALUE); + + for (UIComponent child : this.children) { + MeasuredSize s = child.measure(childC); + sizes.add(s); + maxW = Math.max(maxW, s.width()); + maxH = Math.max(maxH, s.height()); + } + + this.childSizes = sizes; + this.cellW = maxW; + this.cellH = maxH; + + int rows = (this.children.size() + this.columns - 1) / this.columns; + float totalW = maxW * this.columns + this.hSpacing * (this.columns - 1); + float totalH = maxH * rows + this.vSpacing * (rows - 1); + + return MeasuredSize.of(constraints.constrainWidth(totalW), constraints.constrainHeight(totalH)); + } + + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + + for (int i = 0; i < this.children.size(); i++) { + int col = i % this.columns; + int row = i / this.columns; + float cx = this.x + col * (this.cellW + this.hSpacing); + float cy = this.y + row * (this.cellH + this.vSpacing); + this.children.get(i).layout(cx, cy, this.cellW, this.cellH); + } + } + + public void extractRenderState(GuiGraphicsExtractor extractor) { + for (UIComponent child : this.sortedChildren()) child.extractRenderState(extractor); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ImageComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ImageComponent.java new file mode 100644 index 00000000..627399dc --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ImageComponent.java @@ -0,0 +1,82 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.resources.Identifier; + +import java.util.Collections; +import java.util.List; + +/** + * 渲染一个材质精灵(sprite)。 + * 通过 {@code GuiGraphicsExtractor#blitSprite} 使用原版纹理管线。 + */ +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class ImageComponent implements UIComponent { + @Getter + @Setter + private Modifier modifier; + @Setter + private Identifier sprite; + @Getter + @Setter + private float imageWidth; + @Getter + @Setter + private float imageHeight; + + @Getter + private float x, y, width, height; + + public ImageComponent(Modifier modifier, Identifier sprite, float imageWidth, float imageHeight) { + this.modifier = modifier; + this.sprite = sprite; + this.imageWidth = imageWidth; + this.imageHeight = imageHeight; + } + + + @Override + public List children() { + return Collections.emptyList(); + } + + @Override + public MeasuredSize measure(Constraints constraints) { + return MeasuredSize.of( + constraints.constrainWidth(this.imageWidth), + constraints.constrainHeight(this.imageHeight) + ); + } + + @Override + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + extractor.blitSprite( + RenderPipelines.GUI_TEXTURED, + this.sprite, + (int) this.x, (int) this.y, + (int) this.width, (int) this.height + ); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/LazyColumnComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/LazyColumnComponent.java new file mode 100644 index 00000000..73dca0aa --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/LazyColumnComponent.java @@ -0,0 +1,194 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.util.Mth; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 虚拟化纵向列表。仅渲染可见区域的项,支持滚动。 + * 每项固定高度,通过 {@code itemHeight} 指定。 + */ +@Accessors(fluent = true) +@SuppressWarnings({"unused", "UnusedReturnValue"}) +public class LazyColumnComponent implements UIComponent { + + private static final int SCROLLBAR_COLOR = 0xFF888888; + private static final int SCROLLBAR_BG = 0xFF333333; + private static final int SCROLLBAR_W = 4; + + @Getter @Setter + private Modifier modifier; + private final float itemHeight; + private final float maxHeight; + private final List items; + private final ItemBuilder builder; + private List children = Collections.emptyList(); + private float scrollOffset; + + @Getter + private boolean scrollbarDragging; + private float dragAnchorY; + + private float x, y, width, height; + + /** 列表项构建函数接口。 */ + @FunctionalInterface + public interface ItemBuilder { + UIComponent build(T item); + } + + public LazyColumnComponent(Modifier modifier, float itemHeight, float maxHeight, + List items, ItemBuilder builder) { + this.modifier = modifier; + this.itemHeight = itemHeight; + this.maxHeight = maxHeight; + this.items = List.copyOf(items); + this.builder = builder; + } + + @Override + public List children() { return this.children; } + + @Override + public MeasuredSize measure(Constraints constraints) { + float contentH = this.items.size() * this.itemHeight; + float displayH = Math.min(contentH, this.maxHeight); + return MeasuredSize.of(constraints.constrainWidth(200), constraints.constrainHeight(displayH)); + } + + @Override + public void layout(float x, float y, float width, float height) { + this.x = x; this.y = y; this.width = width; this.height = height; + rebuildChildren(); + float maxScroll = Math.max(0, this.items.size() * this.itemHeight - height); + this.scrollOffset = Mth.clamp(this.scrollOffset, 0, maxScroll); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void rebuildChildren() { + int first = (int) (this.scrollOffset / this.itemHeight); + int visible = Math.min((int) (this.height / this.itemHeight) + 1, this.items.size() - first); + List newChildren = new ArrayList<>(visible); + for (int i = 0; i < visible; i++) { + Object item = this.items.get(first + i); + newChildren.add(((ItemBuilder) this.builder).build(item)); + } + this.children = newChildren; + // Position each child + for (int i = 0; i < visible; i++) { + UIComponent child = newChildren.get(i); + float childY = this.y - (this.scrollOffset % this.itemHeight) + i * this.itemHeight; + child.measure(Constraints.NONE); + child.layout(this.x, childY, this.width, this.itemHeight); + } + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + int ix = (int) this.x, iy = (int) this.y, iw = (int) this.width, ih = (int) this.height; + extractor.enableScissor(ix, iy, ix + iw, iy + ih); + + for (UIComponent child : this.children) { + child.extractRenderState(extractor); + } + + extractor.disableScissor(); + + // 滚动条 + float contentH = this.items.size() * this.itemHeight; + if (contentH > this.height) { + float bh = Math.max(16, this.height * this.height / contentH); + float maxScroll = contentH - this.height; + float by = this.y + (this.scrollOffset / maxScroll) * (this.height - bh); + int bx = (int) (this.x + this.width - SCROLLBAR_W - 1); + extractor.fill(bx, iy, bx + SCROLLBAR_W, iy + ih, SCROLLBAR_BG); + extractor.fill(bx, (int) by, bx + SCROLLBAR_W, (int) (by + bh), SCROLLBAR_COLOR); + } + } + + // ── 滚动 ── + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + if (!this.hitRect().contains((float) mouseX, (float) mouseY)) return false; + this.scrollOffset = Mth.clamp( + this.scrollOffset + (float) (-scrollY * 20), + 0, Math.max(0, this.items.size() * this.itemHeight - this.height) + ); + return true; + } + + public boolean isOnScrollbar(float mx, float my) { + float contentH = this.items.size() * this.itemHeight; + if (contentH <= this.height) return false; + float bh = Math.max(16, this.height * this.height / contentH); + float maxScroll = contentH - this.height; + float by = this.y + (this.scrollOffset / maxScroll) * (this.height - bh); + int bx = (int) (this.x + this.width - SCROLLBAR_W - 1); + return mx >= bx && mx < bx + SCROLLBAR_W && my >= by && my < by + bh; + } + + public void startScrollbarDrag(float my) { + this.scrollbarDragging = true; + float contentH = this.items.size() * this.itemHeight; + float bh = Math.max(16, this.height * this.height / contentH); + float maxScroll = contentH - this.height; + float by = this.y + (this.scrollOffset / maxScroll) * (this.height - bh); + this.dragAnchorY = my - by; + } + + public void onScrollbarDrag(float my) { + if (!this.scrollbarDragging) return; + float contentH = this.items.size() * this.itemHeight; + float bh = Math.max(16, this.height * this.height / contentH); + float maxScroll = contentH - this.height; + float newBarY = my - this.dragAnchorY; + float ratio = Mth.clamp((newBarY - this.y) / (this.height - bh), 0f, 1f); + this.scrollOffset = ratio * maxScroll; + } + + public void stopScrollbarDrag() { this.scrollbarDragging = false; } + + public LayoutRect hitRect() { + return LayoutRect.of(this.x, this.y, this.width, this.height); + } + + @Override + public boolean mouseClicked(net.minecraft.client.input.MouseButtonEvent event, boolean isDouble) { + if (event.button() != 0) return false; + var mc = net.minecraft.client.Minecraft.getInstance(); + int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow()); + int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow()); + if (this.isOnScrollbar(mx, my)) { this.startScrollbarDrag(my); return true; } + for (UIComponent child : this.children) { + if (child.mouseClicked(event, isDouble)) return true; + } + return false; + } + + @Override + public boolean mouseDragged(net.minecraft.client.input.MouseButtonEvent event, double dx, double dy) { + if (!this.scrollbarDragging) return false; + var mc = net.minecraft.client.Minecraft.getInstance(); + int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow()); + this.onScrollbarDrag(my); + return true; + } + + @Override + public boolean mouseReleased(net.minecraft.client.input.MouseButtonEvent event) { + this.stopScrollbarDrag(); + return false; + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/RowComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/RowComponent.java new file mode 100644 index 00000000..1624a9ff --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/RowComponent.java @@ -0,0 +1,101 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Alignment; +import dev.anvilcraft.lib.v2.ui.Arrangement; +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class RowComponent implements UIComponent { + @Getter + @Setter + private Modifier modifier; + @Getter + private List children = Collections.emptyList(); + private List childSizes = Collections.emptyList(); + + @Setter + private Arrangement.Horizontal horizontalArrangement = Arrangement.Horizontal.Start; + @Setter + private Alignment.Vertical verticalAlignment = Alignment.Vertical.Top; + @Setter + private float spacing; + + @Getter + private float x, y, width, height; + + public RowComponent(Modifier modifier) { + this.modifier = modifier; + } + + public void setChildren(List children) { + this.children = List.copyOf(children); + } + + public MeasuredSize measure(Constraints constraints) { + if (this.children.isEmpty()) return MeasuredSize.ZERO; + + float totalWidth = 0; + float maxHeight = 0; + List sizes = new ArrayList<>(this.children.size()); + + Constraints childConstraints = new Constraints( + 0, Float.MAX_VALUE, + constraints.minHeight(), constraints.maxHeight() + ); + + for (UIComponent child : this.children) { + MeasuredSize size = child.measure(childConstraints); + sizes.add(size); + totalWidth += size.width(); + maxHeight = Math.max(maxHeight, size.height()); + } + totalWidth += this.spacing * (this.children.size() - 1); + + this.childSizes = sizes; + return MeasuredSize.of( + constraints.constrainWidth(totalWidth), + constraints.constrainHeight(maxHeight) + ); + } + + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + + List widths = new ArrayList<>(this.childSizes.size()); + for (MeasuredSize s : this.childSizes) widths.add(s.width()); + + float[] xOffsets = this.horizontalArrangement.arrange(this.width, widths, this.spacing); + for (int i = 0; i < this.children.size(); i++) { + UIComponent child = this.children.get(i); + MeasuredSize size = this.childSizes.get(i); + float childY = this.y + this.verticalAlignment.align(this.height, size.height()); + child.layout(this.x + xOffsets[i], childY, size.width(), size.height()); + } + } + + public void extractRenderState(GuiGraphicsExtractor extractor) { + for (UIComponent child : this.sortedChildren()) { + child.extractRenderState(extractor); + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ScrollableComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ScrollableComponent.java new file mode 100644 index 00000000..eaf3b660 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/ScrollableComponent.java @@ -0,0 +1,236 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.util.Mth; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 可滚动容器。限制最大高度,超出部分可垂直滚动。 + * 渲染时自动裁剪,并绘制滚动条。 + */ +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class ScrollableComponent implements UIComponent { + private static final int SCROLLBAR_COLOR = 0xFF888888; + private static final int SCROLLBAR_BG = 0xFF333333; + private static final int SCROLLBAR_W = 4; + private final float maxHeight; + @Getter + @Setter + private Modifier modifier; + @Getter + private List children = Collections.emptyList(); + private List childSizes = Collections.emptyList(); + + @Getter + private float scrollY; + private float contentHeight; + @Getter + private boolean scrollbarDragging; + private float dragAnchorY; + + @Getter + private float x, y, width, height; + + public ScrollableComponent(Modifier modifier, float maxHeight) { + this.modifier = modifier; + this.maxHeight = maxHeight; + } + + public void setChildren(List children) { + this.children = List.copyOf(children); + } + + + public MeasuredSize measure(Constraints constraints) { + if (this.children.isEmpty()) return MeasuredSize.ZERO; + + float maxW = 0; + float totalH = 0; + List sizes = new ArrayList<>(this.children.size()); + Constraints childC = new Constraints(0, constraints.maxWidth() - ScrollableComponent.SCROLLBAR_W - 1, 0, Float.MAX_VALUE); + + for (UIComponent child : this.children) { + MeasuredSize s = child.measure(childC); + // 修饰符会扩展尺寸(如 padding),需要计入内容高度 + s = child.modifier().foldOut(s, (el, sz) -> el.modifyMeasuredSize(child, childC, sz)); + sizes.add(s); + totalH += s.height(); + maxW = Math.max(maxW, s.width()); + } + this.childSizes = sizes; + this.contentHeight = totalH; + + return MeasuredSize.of(constraints.constrainWidth(maxW), constraints.constrainHeight(Math.min(totalH, this.maxHeight))); + } + + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + + // resize 后重新钳制滚动位置 + float maxScroll = Math.max(0, this.contentHeight - height); + this.scrollY = Mth.clamp(this.scrollY, -maxScroll, 0); + + float currentY = y + this.scrollY; + float childW = width - ScrollableComponent.SCROLLBAR_W - 1; + for (int i = 0; i < this.children.size(); i++) { + UIComponent child = this.children.get(i); + MeasuredSize size = this.childSizes.get(i); + child.layout(x, currentY, childW, size.height()); + currentY += size.height(); + } + } + + public void extractRenderState(GuiGraphicsExtractor extractor) { + int ix = (int) this.x, iy = (int) this.y, iw = (int) this.width, ih = (int) this.height; + + // 裁剪到容器范围 + extractor.enableScissor(ix, iy, ix + iw, iy + ih); + + for (UIComponent child : this.sortedChildren()) { + child.extractRenderState(extractor); + } + + extractor.disableScissor(); + + // 滚动条 + if (this.contentHeight > this.height) { + float bh = barH(); + float by = barY(); + int bx = (int) (this.x + this.width - ScrollableComponent.SCROLLBAR_W - 1); + extractor.fill(bx, iy, bx + ScrollableComponent.SCROLLBAR_W, iy + ih, ScrollableComponent.SCROLLBAR_BG); + extractor.fill(bx, (int) by, bx + ScrollableComponent.SCROLLBAR_W, (int) (by + bh), ScrollableComponent.SCROLLBAR_COLOR); + } + } + + // ── 滚动 ── + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean isDoubleClick) { + if (event.button() != 0) return false; + var mc = Minecraft.getInstance(); + int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow()); + int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow()); + if (this.isOnScrollbar(mx, my)) { + this.startScrollbarDrag(my); + return true; + } + return false; + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double deltaX, double deltaY) { + if (!this.scrollbarDragging()) return false; + var mc = Minecraft.getInstance(); + int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow()); + this.onScrollbarDrag(my); + return true; + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + this.stopScrollbarDrag(); + return false; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + if (this.hitRect().contains((float) mouseX, (float) mouseY)) { + return this.onScroll((float) scrollY); + } + return false; + } + + public boolean onScroll(float amount) { + if (this.contentHeight <= this.height) return false; + float maxScroll = this.contentHeight - this.height; + this.scrollY = Mth.clamp(this.scrollY + amount * 20, -maxScroll, 0); + return true; + } + + /** + * 鼠标是否在滚动条滑块上。 + */ + public boolean isOnScrollbar(float mx, float my) { + if (this.contentHeight <= this.height) return false; + float bh = this.barH(); + float by = this.barY(); + int bx = (int) (this.x + this.width - ScrollableComponent.SCROLLBAR_W - 1); + return mx >= bx && mx < bx + ScrollableComponent.SCROLLBAR_W && my >= by && my < by + bh; + } + + /** + * 开始拖拽滚动条。 + */ + public void startScrollbarDrag(float my) { + this.scrollbarDragging = true; + this.dragAnchorY = my - this.barY(); + } + + /** + * 拖拽滚动条时更新位置。 + */ + public void onScrollbarDrag(float my) { + if (!this.scrollbarDragging) return; + float bh = this.barH(); + float maxScroll = this.contentHeight - this.height; + float newBarY = my - this.dragAnchorY; + float ratio = Mth.clamp(newBarY / (this.height - bh), 0f, 1f); + this.scrollY = -(ratio * maxScroll); + } + + /** + * 停止拖拽。 + */ + public void stopScrollbarDrag() { + this.scrollbarDragging = false; + } + + private float barH() { + return Math.max(16, this.height * this.height / this.contentHeight); + } + + private float barY() { + float maxScroll = this.contentHeight - this.height; + return this.y + (-this.scrollY / maxScroll) * (this.height - this.barH()); + } + + public void setScrollY(float scrollY) { + this.scrollY = scrollY; + } + + /** + * 命中测试包围盒。 + */ + public LayoutRect hitRect() { + return LayoutRect.of(this.x, this.y, this.width, this.height); + } + + @Override + public void copyRuntimeState(UIComponent old) { + if (old instanceof ScrollableComponent oldSc) { + this.setScrollY(oldSc.scrollY()); + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/SliderComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/SliderComponent.java new file mode 100644 index 00000000..4b408a66 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/SliderComponent.java @@ -0,0 +1,170 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.util.Mth; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +/** + * 滑块。水平拖拽选择范围内的值。 + * 原版风格:深色轨道 + 浅色滑块按钮。 + */ +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class SliderComponent implements UIComponent { + private static final int TRACK_COLOR = 0xFF404040; + private static final int THUMB_COLOR = 0xFFAAAAAA; + private static final float TRACK_H = 4; + private static final float THUMB_W = 8; + private static final float THUMB_H = 16; + private final float min, max; + private final float trackWidth; + @Getter + @Setter + private Modifier modifier; + @Getter + private float value; + private boolean hovered; + @Getter + private boolean dragging; + @Setter + private @Nullable Consumer onChange; + + @Getter + private float x, y, width, height; + + public SliderComponent(Modifier modifier, float value, float min, float max, float trackWidth) { + this.modifier = modifier; + this.value = Mth.clamp(value, min, max); + this.min = min; + this.max = max; + this.trackWidth = trackWidth; + } + + public void setHovered(boolean hovered) { + this.hovered = hovered; + } + + public void setDragging(boolean dragging) { + this.dragging = dragging; + } + + @Override + public List children() { + return Collections.emptyList(); + } + + @Override + public MeasuredSize measure(Constraints constraints) { + return MeasuredSize.of( + constraints.constrainWidth(this.trackWidth), + constraints.constrainHeight(SliderComponent.THUMB_H) + ); + } + + @Override + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + int ix = (int) this.x, iy = (int) this.y; + int iw = (int) this.width; + + // 轨道 — 居中画在 THUMB_H 中间 + int trackY = (int) (this.y + (SliderComponent.THUMB_H - SliderComponent.TRACK_H) / 2f); + extractor.fill(ix, trackY, ix + iw, (int) (trackY + SliderComponent.TRACK_H), SliderComponent.TRACK_COLOR); + + // 滑块 — 按比例定位 + float ratio = (this.value - this.min) / (this.max - this.min); + float thumbX = this.x + ratio * (this.width - SliderComponent.THUMB_W); + int tix = (int) thumbX, tiy = (int) this.y; + int thumbColor = this.hovered ? 0xFFCCCCCC : SliderComponent.THUMB_COLOR; + extractor.fill(tix, tiy, tix + (int) SliderComponent.THUMB_W, tiy + (int) SliderComponent.THUMB_H, thumbColor); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean isDoubleClick) { + if (event.button() != 0) return false; + var mc = Minecraft.getInstance(); + int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow()); + int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow()); + if (this.hitRect().contains(mx, my)) { + this.dragging = true; + this.setValueFromMouse(mx); + mc.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0f)); + return true; + } + return false; + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double deltaX, double deltaY) { + if (!this.dragging) return false; + var mc = Minecraft.getInstance(); + int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow()); + this.setValueFromMouse(mx); + return true; + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + this.dragging = false; + return false; + } + + @Override + public void updateHover(float mx, float my) { + this.hovered = this.hitRect().contains(mx, my); + } + + /** + * 根据鼠标 X 坐标更新值。 + */ + public void setValueFromMouse(float mouseX) { + float ratio = Mth.clamp((mouseX - this.x) / Math.max(this.width - 1, 1), 0f, 1f); + float newValue = this.min + ratio * (this.max - this.min); + if (newValue != this.value) { + this.value = newValue; + if (this.onChange != null) this.onChange.accept(this.value); + } + } + + /** + * 命中测试包围盒(整个轨道+滑块区域)。 + */ + public LayoutRect hitRect() { + return LayoutRect.of(this.x, this.y, this.width, this.height); + } + + @Override + public void copyRuntimeState(UIComponent old) { + if (old instanceof SliderComponent oldSl) { + this.setDragging(oldSl.dragging()); + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/SpacerComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/SpacerComponent.java new file mode 100644 index 00000000..87d4c044 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/SpacerComponent.java @@ -0,0 +1,59 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.Collections; +import java.util.List; + +/** + * 固定尺寸的空白占位组件,不渲染任何内容。 + */ +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class SpacerComponent implements UIComponent { + private final float spacerWidth; + private final float spacerHeight; + @Getter + @Setter + private Modifier modifier; + + public SpacerComponent(Modifier modifier, float width, float height) { + this.modifier = modifier; + this.spacerWidth = width; + this.spacerHeight = height; + } + + @Override + public List children() { + return Collections.emptyList(); + } + + + @Override + public MeasuredSize measure(Constraints constraints) { + return MeasuredSize.of( + constraints.constrainWidth(this.spacerWidth), + constraints.constrainHeight(this.spacerHeight) + ); + } + + @Override + public void layout(float x, float y, float width, float height) { + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/TextComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/TextComponent.java new file mode 100644 index 00000000..b3a396a9 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/TextComponent.java @@ -0,0 +1,90 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.network.chat.Component; + +import java.util.Collections; +import java.util.List; + +/** + * 单行文字渲染。默认样式与原版一致:白色带阴影、左对齐。 + */ +@Accessors(fluent = true) +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class TextComponent implements UIComponent { + private static final int VANILLA_TEXT_COLOR = 0xFFFFFFFF; + @Getter + @Setter + private Modifier modifier; + @Setter + private String text; + @Setter + private int color = TextComponent.VANILLA_TEXT_COLOR; + @Setter + private boolean shadow; + @Setter + private Align align = Align.LEFT; + + @Getter + private float x, y, width, height; + + public TextComponent(Modifier modifier, String text) { + this.modifier = modifier; + this.text = text; + } + + @Override + public List children() { + return Collections.emptyList(); + } + + @Override + public MeasuredSize measure(Constraints constraints) { + var font = Minecraft.getInstance().font; + float w = font.width(Component.literal(this.text)); + float h = font.lineHeight; + return MeasuredSize.of(constraints.constrainWidth(w), constraints.constrainHeight(h)); + } + + @Override + public void layout(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + var font = Minecraft.getInstance().font; + Component component = Component.literal(this.text); + float textW = font.width(component); + + float renderX = switch (this.align) { + case Align.LEFT -> this.x; + case Align.CENTER -> this.x + (this.width - textW) / 2f; + case Align.RIGHT -> this.x + this.width - textW; + }; + + extractor.text(font, this.text, (int) renderX, (int) this.y, this.color, this.shadow); + } + + /** + * 文字水平对齐方式 + */ + public enum Align {LEFT, CENTER, RIGHT} +} + diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/TextInputComponent.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/TextInputComponent.java new file mode 100644 index 00000000..82092682 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/TextInputComponent.java @@ -0,0 +1,315 @@ +package dev.anvilcraft.lib.v2.ui.component; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.Focusable; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.Modifier; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.util.StringUtil; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +@Accessors(fluent = true) +@SuppressWarnings({"unused", "UnusedReturnValue"}) +public class TextInputComponent implements UIComponent, Focusable { + + private static final int BG_COLOR = 0xFF202020; + private static final int TEXT_COLOR = 0xFFFFFFFF; + private static final int PLACEHOLDER_COLOR = 0xFF555555; + private static final int CURSOR_COLOR = 0xFFFFFFFF; + private static final int HIGHLIGHT_COLOR = 0xFF8888FF; + private static final float PADDING_H = 4; + private static final float PADDING_V = 4; + private static final float WIDTH = 160; + private final String placeholder; + @Getter @Setter + private Modifier modifier; + @Getter + private String value = ""; + @Getter + private boolean focused; + @Setter + private @Nullable Consumer onChange; + @Getter + private int displayPos; + @Getter + private int cursorPos; + @Getter + private int highlightPos; + + private float x, y, width, height; + + public TextInputComponent(Modifier modifier, @Nullable String placeholder) { + this.modifier = modifier; + this.placeholder = placeholder != null ? placeholder : ""; + } + + public void setValue(@Nullable String value) { + this.value = value != null ? value : ""; + this.cursorPos = this.value.length(); + this.highlightPos = this.cursorPos; + this.scrollTo(this.cursorPos); + } + + public void setCursorPos(int pos) { + this.setCursorPos(pos, false); + } + + public void setCursorPos(int pos, boolean extendSelection) { + this.cursorPos = Math.clamp(pos, 0, this.value.length()); + if (!extendSelection) this.highlightPos = this.cursorPos; + this.scrollTo(this.cursorPos); + } + + private void scrollTo(int pos) { + if (pos < this.displayPos) { this.displayPos = pos; return; } + var font = Minecraft.getInstance().font; + float visibleW = this.width - PADDING_H * 2; + // 光标右溢出时,右移 displayPos 直到光标可见 + while (this.displayPos < pos && font.width(this.value.substring(this.displayPos, pos)) > visibleW) { + this.displayPos++; + } + } + + public void setFocused(boolean focused) { this.focused = focused; } + public void setDisplayPos(int pos) { this.displayPos = pos; } + + /** 获取选中文本。 */ + public String getHighlighted() { + int start = Math.min(this.cursorPos, this.highlightPos); + int end = Math.max(this.cursorPos, this.highlightPos); + return this.value.substring(start, end); + } + + @Override public List children() { return Collections.emptyList(); } + + @Override + public MeasuredSize measure(Constraints constraints) { + var font = Minecraft.getInstance().font; + return MeasuredSize.of(constraints.constrainWidth(WIDTH), constraints.constrainHeight(font.lineHeight + PADDING_V * 2)); + } + + @Override + public void layout(float x, float y, float width, float height) { + this.x = x; this.y = y; this.width = width; this.height = height; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor extractor) { + int ix = (int) this.x, iy = (int) this.y, iw = (int) this.width, ih = (int) this.height; + extractor.enableScissor(ix, iy, ix + iw, iy + ih); + extractor.fill(ix, iy, ix + iw, iy + ih, BG_COLOR); + + var font = Minecraft.getInstance().font; + int textX = ix + (int) PADDING_H; + int textY = (int) (this.y + (this.height + font.lineHeight) / 2f - font.lineHeight); + boolean hasText = !this.value.isEmpty(); + + if (hasText) { + String visible = this.value.substring(this.displayPos); + int selStart = Math.min(this.cursorPos, this.highlightPos) - this.displayPos; + int selEnd = Math.max(this.cursorPos, this.highlightPos) - this.displayPos; + + if (selStart < selEnd) { + // 选中区域前 + if (selStart > 0) { + String before = visible.substring(0, Math.min(selStart, visible.length())); + extractor.text(font, before, textX, textY, TEXT_COLOR); + textX += font.width(before); + } + // 选中区域(反色高亮) + String selected = visible.substring(Math.max(0, selStart), Math.min(selEnd, visible.length())); + int selW = font.width(selected); + if (selW > 0) { + extractor.fill(textX, iy + 1, textX + selW, iy + ih - 1, HIGHLIGHT_COLOR); + extractor.text(font, selected, textX, textY, TEXT_COLOR); + textX += selW; + } + // 选中区域后 + if (selEnd < visible.length()) { + extractor.text(font, visible.substring(selEnd), textX, textY, TEXT_COLOR); + } + } else { + extractor.text(font, visible, textX, textY, TEXT_COLOR); + } + + if (this.focused) { + String before = this.value.substring(this.displayPos, Math.min(this.cursorPos, this.value.length())); + int cursorX = ix + (int) PADDING_H + font.width(before); + extractor.fill(cursorX, iy + 2, cursorX + 1, iy + ih - 2, CURSOR_COLOR); + } + } else { + extractor.text(font, this.placeholder, textX, textY, PLACEHOLDER_COLOR); + } + extractor.disableScissor(); + } + + // ── 鼠标 ── + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean isDoubleClick) { + if (event.button() != 0) return false; + var mc = Minecraft.getInstance(); + int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow()); + if (this.hitRect().contains(mx, (float) mc.mouseHandler.getScaledYPos(mc.getWindow()))) { + this.setFocused(true); + int pos = posFromMouse(mx); + this.setCursorPos(pos, event.hasShiftDown()); + mc.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0f)); + return true; + } + return false; + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double deltaX, double deltaY) { + if (!this.focused) return false; + var mc = Minecraft.getInstance(); + int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow()); + this.setCursorPos(posFromMouse(mx), true); + return true; + } + + private int posFromMouse(int mx) { + var font = Minecraft.getInstance().font; + float relX = mx - (this.x + PADDING_H); + String visible = this.value.substring(this.displayPos); + float visibleW = font.width(visible); + // 鼠标在可见区域外右侧 → 定位到文本末尾,scrollTo 会自动跟进 + if (relX >= visibleW) return this.value.length(); + // 鼠标在可见区域外左侧 → 逐字符左移 displayPos + if (relX <= 0) return Math.max(0, this.displayPos - 1); + int pos = this.displayPos; + for (int i = 0; i < visible.length(); i++) { + if (font.width(visible.substring(0, i + 1)) > relX) break; + pos++; + } + return Math.clamp(pos, 0, this.value.length()); + } + + // ── 键盘 ── + + @Override + public boolean keyPressed(KeyEvent event) { + int key = event.key(); + boolean shift = event.hasShiftDown(); + boolean ctrl = event.hasControlDownWithQuirk(); + + // 剪贴板操作(默认分支处理,因 key code 不在 259-269 范围) + if (key < 259 || key > 269) { + if (event.isSelectAll()) { this.setCursorPos(this.value.length()); this.highlightPos = 0; return true; } + if (event.isCopy()) { Minecraft.getInstance().keyboardHandler.setClipboard(this.getHighlighted()); return true; } + if (event.isPaste()) { this.insertText(Minecraft.getInstance().keyboardHandler.getClipboard()); return true; } + if (event.isCut()) { + Minecraft.getInstance().keyboardHandler.setClipboard(this.getHighlighted()); + this.insertText(""); + return true; + } + return false; + } + + if (key == 259) { // Backspace + if (this.highlightPos != this.cursorPos) { this.insertText(""); return true; } + if (this.cursorPos > 0) { this.deleteChars(-1); return true; } + return true; + } + if (key == 261) { // Delete + if (this.highlightPos != this.cursorPos) { this.insertText(""); return true; } + if (this.cursorPos < this.value.length()) { this.deleteChars(1); return true; } + return true; + } + if (key == 262) { // Right + if (ctrl) { this.setCursorPos(this.getWordPosition(1), shift); } + else { this.setCursorPos(this.cursorPos + 1, shift); } + return true; + } + if (key == 263) { // Left + if (ctrl) { this.setCursorPos(this.getWordPosition(-1), shift); } + else { this.setCursorPos(this.cursorPos - 1, shift); } + return true; + } + if (key == 268) { this.setCursorPos(0, shift); return true; } // Home + if (key == 269) { this.setCursorPos(this.value.length(), shift); return true; } // End + return false; + } + + private void deleteChars(int dir) { + int start = Math.min(this.cursorPos, this.cursorPos + dir); + int end = Math.max(this.cursorPos, this.cursorPos + dir); + if (start == end) return; + this.value = new StringBuilder(this.value).delete(start, end).toString(); + this.setCursorPos(start); + this.fireChange(); + } + + private int getWordPosition(int dir) { + if (this.value.isEmpty()) return 0; + if (dir < 0) { + int i = this.cursorPos; + while (i > 0 && this.value.charAt(i - 1) == ' ') i--; + while (i > 0 && this.value.charAt(i - 1) != ' ') i--; + return i; + } + int i = this.cursorPos; + while (i < this.value.length() && this.value.charAt(i) != ' ') i++; + while (i < this.value.length() && this.value.charAt(i) == ' ') i++; + return i; + } + + @Override + public boolean charTyped(CharacterEvent event) { + if (!event.isAllowedChatCharacter()) return false; + String text = StringUtil.filterText(event.codepointAsString()); + if (text.isEmpty()) return false; + this.insertText(text); + return true; + } + + private void insertText(String text) { + if (this.highlightPos != this.cursorPos) { + int start = Math.min(this.cursorPos, this.highlightPos); + int end = Math.max(this.cursorPos, this.highlightPos); + this.value = new StringBuilder(this.value).replace(start, end, text).toString(); + this.cursorPos = start + text.length(); + } else { + this.value = new StringBuilder(this.value).insert(this.cursorPos, text).toString(); + this.cursorPos += text.length(); + } + this.highlightPos = this.cursorPos; + this.scrollTo(this.cursorPos); + this.fireChange(); + } + + private void fireChange() { + if (this.onChange != null) this.onChange.accept(this.value); + } + + public LayoutRect hitRect() { + return LayoutRect.of(this.x, this.y, this.width, this.height); + } + + @Override + public void copyRuntimeState(UIComponent old) { + if (old instanceof TextInputComponent oldTi) { + this.setValue(oldTi.value()); + this.setCursorPos(oldTi.cursorPos()); + this.highlightPos = oldTi.highlightPos(); + this.setFocused(oldTi.focused()); + this.setDisplayPos(oldTi.displayPos()); + } + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/package-info.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/package-info.java new file mode 100644 index 00000000..4e8a17af --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.anvilcraft.lib.v2.ui.component; + +import org.jspecify.annotations.NullMarked; diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/BoxScope.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/BoxScope.java new file mode 100644 index 00000000..62b6ab24 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/BoxScope.java @@ -0,0 +1,16 @@ +package dev.anvilcraft.lib.v2.ui.component.scope; + +import dev.anvilcraft.lib.v2.ui.UIScope; +import dev.anvilcraft.lib.v2.ui.component.BoxComponent; + +/** + * {@link BoxComponent} 的子级作用域。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class BoxScope extends UIScope { +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/ColumnScope.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/ColumnScope.java new file mode 100644 index 00000000..86e4ca86 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/ColumnScope.java @@ -0,0 +1,16 @@ +package dev.anvilcraft.lib.v2.ui.component.scope; + +import dev.anvilcraft.lib.v2.ui.UIScope; +import dev.anvilcraft.lib.v2.ui.component.ColumnComponent; + +/** + * {@link ColumnComponent} 的子级作用域。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class ColumnScope extends UIScope { +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/GridScope.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/GridScope.java new file mode 100644 index 00000000..a744ff91 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/GridScope.java @@ -0,0 +1,16 @@ +package dev.anvilcraft.lib.v2.ui.component.scope; + +import dev.anvilcraft.lib.v2.ui.UIScope; +import dev.anvilcraft.lib.v2.ui.component.GridComponent; + +/** + * {@link GridComponent} 子级作用域。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class GridScope extends UIScope { +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/RowScope.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/RowScope.java new file mode 100644 index 00000000..b146a7ee --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/RowScope.java @@ -0,0 +1,16 @@ +package dev.anvilcraft.lib.v2.ui.component.scope; + +import dev.anvilcraft.lib.v2.ui.UIScope; +import dev.anvilcraft.lib.v2.ui.component.RowComponent; + +/** + * {@link RowComponent} 的子级作用域。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class RowScope extends UIScope { +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/ScrollableScope.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/ScrollableScope.java new file mode 100644 index 00000000..fe8d6841 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/component/scope/ScrollableScope.java @@ -0,0 +1,16 @@ +package dev.anvilcraft.lib.v2.ui.component.scope; + +import dev.anvilcraft.lib.v2.ui.UIScope; +import dev.anvilcraft.lib.v2.ui.component.ScrollableComponent; + +/** + * {@link ScrollableComponent} 子级作用域。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class ScrollableScope extends UIScope { +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/BackgroundElement.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/BackgroundElement.java new file mode 100644 index 00000000..93125efe --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/BackgroundElement.java @@ -0,0 +1,30 @@ +package dev.anvilcraft.lib.v2.ui.modifier; + +import dev.anvilcraft.lib.v2.rendering.sdf.SdfGraphics; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +/** + * 渲染填充圆角矩形背景。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public record BackgroundElement(int color, float round) implements ModifierElement { + public BackgroundElement(int color) { + this(color, 0); + } + + @Override + public void emitRenderState(GuiGraphicsExtractor extractor, LayoutRect bounds) { + SdfGraphics.instance + .box(bounds.x(), bounds.y(), bounds.width(), bounds.height()) + .color(this.color()) + .round(this.round()) + .fill() + .draw(extractor); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/BorderElement.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/BorderElement.java new file mode 100644 index 00000000..1c69c185 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/BorderElement.java @@ -0,0 +1,30 @@ +package dev.anvilcraft.lib.v2.ui.modifier; + +import dev.anvilcraft.lib.v2.rendering.sdf.SdfGraphics; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +/** + * 渲染描边圆角矩形边框。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public record BorderElement(float width, int color, float round) implements ModifierElement { + public BorderElement(float width, int color) { + this(width, color, 0); + } + + @Override + public void emitRenderState(GuiGraphicsExtractor extractor, LayoutRect bounds) { + SdfGraphics.instance + .box(bounds.x(), bounds.y(), bounds.width(), bounds.height()) + .color(this.color()) + .round(this.round()) + .stroke(this.width()) + .draw(extractor); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/CombinedModifier.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/CombinedModifier.java new file mode 100644 index 00000000..a97218ac --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/CombinedModifier.java @@ -0,0 +1,28 @@ +package dev.anvilcraft.lib.v2.ui.modifier; + +import dev.anvilcraft.lib.v2.ui.Modifier; + +import java.util.function.BiFunction; + +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public record CombinedModifier(ModifierElement outer, Modifier inner) implements Modifier { + @Override + public Modifier then(Modifier other) { + return new CombinedModifier(this.outer(), this.inner().then(other)); + } + + @Override + public R foldIn(R initial, BiFunction operation) { + return this.inner().foldIn(operation.apply(initial, this.outer()), operation); + } + + @Override + public R foldOut(R initial, BiFunction operation) { + return operation.apply(this.outer(), this.inner().foldOut(initial, operation)); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/ModifierElement.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/ModifierElement.java new file mode 100644 index 00000000..741332e9 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/ModifierElement.java @@ -0,0 +1,84 @@ +package dev.anvilcraft.lib.v2.ui.modifier; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.UIComponent; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +/** + * {@link dev.anvilcraft.lib.v2.ui.Modifier} 链中的单个节点。 + * 每个元素可拦截 measure、layout、render 阶段。 + */ +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public interface ModifierElement { + static ModifierElement size(float width, float height) { + return new SizeElement(width, width, height, height); + } + + static ModifierElement width(float w) { + return new SizeElement(0, Float.MAX_VALUE, w, w); + } + + static ModifierElement height(float h) { + return new SizeElement(h, 0, h, Float.MAX_VALUE); + } + + static ModifierElement fillMaxWidth() { + return new SizeElement(0, Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE); + } + + // ── 工厂方法 ── + + static ModifierElement fillMaxHeight() { + return new SizeElement(Float.MAX_VALUE, Float.MAX_VALUE, 0, Float.MAX_VALUE); + } + + static ModifierElement fillMaxSize() { + return new SizeElement(Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE); + } + + static ModifierElement padding(float all) { + return new PaddingElement(all, all, all, all); + } + + static ModifierElement padding(float horizontal, float vertical) { + return new PaddingElement(horizontal, vertical, horizontal, vertical); + } + + static ModifierElement background(int color) { + return new BackgroundElement(color, 0); + } + + static ModifierElement background(int color, float round) { + return new BackgroundElement(color, round); + } + + static ModifierElement border(float width, int color) { + return new BorderElement(width, color, 0); + } + + static ModifierElement border(float width, int color, float round) { + return new BorderElement(width, color, round); + } + + default Constraints modifyConstraints(Constraints constraints) { + return constraints; + } + + default MeasuredSize modifyMeasuredSize(UIComponent component, Constraints constraints, MeasuredSize childSize) { + return childSize; + } + + default LayoutRect modifyLayout(LayoutRect rect) { + return rect; + } + + default void emitRenderState(GuiGraphicsExtractor extractor, LayoutRect bounds) { + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/PaddingElement.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/PaddingElement.java new file mode 100644 index 00000000..301a41e8 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/PaddingElement.java @@ -0,0 +1,32 @@ +package dev.anvilcraft.lib.v2.ui.modifier; + +import dev.anvilcraft.lib.v2.ui.Constraints; +import dev.anvilcraft.lib.v2.ui.LayoutRect; +import dev.anvilcraft.lib.v2.ui.MeasuredSize; +import dev.anvilcraft.lib.v2.ui.UIComponent; + +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public record PaddingElement(float left, float top, float right, float bottom) implements ModifierElement { + @Override + public MeasuredSize modifyMeasuredSize(UIComponent component, Constraints constraints, MeasuredSize childSize) { + return MeasuredSize.of( + childSize.width() + this.left() + this.right(), + childSize.height() + this.top() + this.bottom() + ); + } + + @Override + public LayoutRect modifyLayout(LayoutRect rect) { + return LayoutRect.of( + rect.x() + this.left(), + rect.y() + this.top(), + rect.width() - this.left() - this.right(), + rect.height() - this.top() - this.bottom() + ); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/SingleElementModifier.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/SingleElementModifier.java new file mode 100644 index 00000000..5201dc94 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/SingleElementModifier.java @@ -0,0 +1,28 @@ +package dev.anvilcraft.lib.v2.ui.modifier; + +import dev.anvilcraft.lib.v2.ui.Modifier; + +import java.util.function.BiFunction; + +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public record SingleElementModifier(ModifierElement element) implements Modifier { + @Override + public Modifier then(Modifier other) { + return new CombinedModifier(this.element(), other); + } + + @Override + public R foldIn(R initial, BiFunction operation) { + return operation.apply(initial, this.element()); + } + + @Override + public R foldOut(R initial, BiFunction operation) { + return operation.apply(this.element(), initial); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/SizeElement.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/SizeElement.java new file mode 100644 index 00000000..005b2e0a --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/SizeElement.java @@ -0,0 +1,23 @@ +package dev.anvilcraft.lib.v2.ui.modifier; + +import dev.anvilcraft.lib.v2.ui.Constraints; + +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public record SizeElement(float minWidthHint, float maxWidthHint, float minHeightHint, float maxHeightHint) + implements ModifierElement { + @Override + public Constraints modifyConstraints(Constraints constraints) { + float minW = this.minWidthHint() > 0 ? Math.max(constraints.minWidth(), this.minWidthHint()) : constraints.minWidth(); + float maxW = this.maxWidthHint() < Float.MAX_VALUE ? Math.min(constraints.maxWidth(), this.maxWidthHint()) : constraints.maxWidth(); + float minH = this.minHeightHint() > 0 ? Math.max(constraints.minHeight(), this.minHeightHint()) : constraints.minHeight(); + float maxH = this.maxHeightHint() < Float.MAX_VALUE + ? Math.min(constraints.maxHeight(), this.maxHeightHint()) + : constraints.maxHeight(); + return constraints.copy(minW, maxW, minH, maxH); + } +} diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/package-info.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/package-info.java new file mode 100644 index 00000000..d422b172 --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/modifier/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.anvilcraft.lib.v2.ui.modifier; + +import org.jspecify.annotations.NullMarked; diff --git a/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/package-info.java b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/package-info.java new file mode 100644 index 00000000..df1230bb --- /dev/null +++ b/module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.anvilcraft.lib.v2.ui; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file diff --git a/module.ui/src/main/resources/META-INF/neoforge.mods.toml b/module.ui/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..70445b6c --- /dev/null +++ b/module.ui/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,86 @@ +# This is an example mods.toml file. It contains the data relating to the loading mods. +# There are several mandatory fields (#mandatory), and many more that are optional (#optional). +# The overall format is standard TOML format, v0.5.0. +# Note that there are a couple of TOML lists in this file. +# Find more information on toml format here: https://github.com/toml-lang/toml +# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml +modLoader = "javafml" #mandatory +# A version range to match for said mod loader - for regular FML @Mod it will be the the FML version. This is currently 47. +loaderVersion = "${loader_version_range}" #mandatory +# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. +# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. +license = "${mod_license}" +# A URL to refer people to when problems occur with this mod +#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional +# A list of mods - how many allowed here is determined by the individual mod loader +[[mods]] #mandatory +# The modid of the mod +modId = "${mod_id}" #mandatory +# The version number of the mod +version = "${mod_version}" #mandatory +# A display name for the mod +displayName = "${mod_name}" #mandatory +# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforge.net/docs/misc/updatechecker/ +#updateJSONURL="https://change.me.example.invalid/updates.json" #optional +# A URL for the "homepage" for this mod, displayed in the mod UI +displayURL = "https://github.com/Anvil-Dev/AnvilLib" +# A file name (in the root of the mod JAR) containing a logo for display +logoFile = "icon.png" #optional +# A text field displayed in the mod UI +#credits="" #optional +# A text field displayed in the mod UI +authors = "${mod_authors}" #optional +# Display Test controls the display for your mod in the server connection screen +# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod. +# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod. +# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component. +# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value. +# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself. +#displayTest="MATCH_VERSION" # MATCH_VERSION is the default if nothing is specified (#optional) + +# The description text for the mod (multi line!) (#mandatory) +description = '''${mod_description}''' + +# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded. +[[mixins]] +config = "${mod_id}.mixins.json" + +# The [[accessTransformers]] block allows you to declare where your AT file is. +# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg +#[[accessTransformers]] +#file="META-INF/accesstransformer.cfg" + +# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json + +# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional. +[[dependencies."${mod_id}"]] #optional +# the modid of the dependency +modId = "neoforge" #mandatory +# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive). +# 'required' requires the mod to exist, 'optional' does not +# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning +type = "required" #mandatory +# Optional field describing why the dependency is required or why it is incompatible +# reason="..." +# The version range of the dependency +versionRange = "${neo_version_range}" #mandatory +# An ordering relationship for the dependency. +# BEFORE - This mod is loaded BEFORE the dependency +# AFTER - This mod is loaded AFTER the dependency +ordering = "NONE" +# Side this dependency is applied on - BOTH, CLIENT, or SERVER +side = "BOTH" +# Here's another dependency +[[dependencies."${mod_id}"]] +modId = "minecraft" +type = "required" +# This version range declares a minimum of the current minecraft version up to but not including the next major version +versionRange = "${minecraft_version_range}" +ordering = "NONE" +side = "BOTH" + +# Features are specific properties of the game environment, that you may want to declare you require. This example declares +# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't +# stop your mod loading on the server for example. +#[features."${mod_id}"] +#openGLVersion="[3.2,)" diff --git a/module.ui/src/main/resources/anvillib_ui.mixins.json b/module.ui/src/main/resources/anvillib_ui.mixins.json new file mode 100644 index 00000000..4144b1f6 --- /dev/null +++ b/module.ui/src/main/resources/anvillib_ui.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "dev.anvilcraft.lib.v2.ui.mixin", + "compatibilityLevel": "JAVA_8", + "refmap": "anvillib.refmap.json", + "mixins": [ + ], + "client": [ + ], + "injectors": { + "defaultRequire": 1 + } +} \ No newline at end of file diff --git a/module.ui/src/main/resources/icon.png b/module.ui/src/main/resources/icon.png new file mode 100644 index 00000000..15304bd5 Binary files /dev/null and b/module.ui/src/main/resources/icon.png differ diff --git a/settings.gradle b/settings.gradle index aca6eb7a..0eaccadb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -24,6 +24,7 @@ include 'module.registrum' include 'module.rendering' include 'module.space-select' include 'module.sync' +include 'module.ui' include 'module.util' include 'module.wheel' include 'module.main' @@ -43,6 +44,7 @@ project(':module.registrum').name = 'anvillib-registrum-neoforge-26.1' project(':module.rendering').name = 'anvillib-rendering-neoforge-26.1' project(':module.space-select').name = 'anvillib-space-select-neoforge-26.1' project(':module.sync').name = 'anvillib-sync-neoforge-26.1' +project(':module.ui').name = 'anvillib-ui-neoforge-26.1' project(':module.util').name = 'anvillib-util-neoforge-26.1' project(':module.wheel').name = 'anvillib-wheel-neoforge-26.1' project(':module.main').name = 'anvillib-neoforge-26.1'