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 组件的核心接口。
+ * ]
+ * 组件每帧参与三个阶段:
+ *
+ * - {@link #measure(Constraints)} — 根据父容器约束确定期望尺寸
+ * - {@link #layout(float, float, float, float)} — 接收父容器分配的最终位置
+ * - {@link #extractRenderState(GuiGraphicsExtractor)} — 提交渲染状态给 GPU
+ *
+ *
+ * 事件处理方法均有默认空实现,子类只覆写需要的。
+ * {@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'