From dccba4304442a2ba9bc9435a6106880c0a0bf761 Mon Sep 17 00:00:00 2001 From: EternalQ Date: Thu, 16 Jul 2026 10:33:07 +0300 Subject: [PATCH 1/4] feat: HUD system --- .../cleanroommc/modularui/ClientProxy.java | 3 + .../cleanroommc/modularui/hud/HudContext.java | 27 +++ .../cleanroommc/modularui/hud/HudElement.java | 151 +++++++++++++++ .../cleanroommc/modularui/hud/HudManager.java | 172 ++++++++++++++++++ .../cleanroommc/modularui/hud/HudScreen.java | 29 +++ .../cleanroommc/modularui/hud/HudWrapper.java | 37 ++++ .../modularui/screen/ModularScreen.java | 55 +++++- 7 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/cleanroommc/modularui/hud/HudContext.java create mode 100644 src/main/java/com/cleanroommc/modularui/hud/HudElement.java create mode 100644 src/main/java/com/cleanroommc/modularui/hud/HudManager.java create mode 100644 src/main/java/com/cleanroommc/modularui/hud/HudScreen.java create mode 100644 src/main/java/com/cleanroommc/modularui/hud/HudWrapper.java diff --git a/src/main/java/com/cleanroommc/modularui/ClientProxy.java b/src/main/java/com/cleanroommc/modularui/ClientProxy.java index e211f19d4..fa175f380 100644 --- a/src/main/java/com/cleanroommc/modularui/ClientProxy.java +++ b/src/main/java/com/cleanroommc/modularui/ClientProxy.java @@ -8,6 +8,7 @@ import com.cleanroommc.modularui.factory.inventory.InventoryTypes; import com.cleanroommc.modularui.holoui.HoloScreenEntity; import com.cleanroommc.modularui.holoui.ScreenEntityRender; +import com.cleanroommc.modularui.hud.HudManager; import com.cleanroommc.modularui.network.ModularNetwork; import com.cleanroommc.modularui.screen.ClientScreenHandler; import com.cleanroommc.modularui.test.TestItem; @@ -66,6 +67,8 @@ void preInit(FMLPreInitializationEvent event) { // registered to both buses since handled events are not bound to a single bus FMLCommonHandler.instance().bus().register(clientScreenHandler); MinecraftForge.EVENT_BUS.register(clientScreenHandler); + // register the HUD manager on the Forge event bus + HudManager.init(); AnimatorManager.init(); if (ModularUIConfig.enableTestGuis) { diff --git a/src/main/java/com/cleanroommc/modularui/hud/HudContext.java b/src/main/java/com/cleanroommc/modularui/hud/HudContext.java new file mode 100644 index 000000000..412312b4a --- /dev/null +++ b/src/main/java/com/cleanroommc/modularui/hud/HudContext.java @@ -0,0 +1,27 @@ +package com.cleanroommc.modularui.hud; + +import com.cleanroommc.modularui.screen.ModularScreen; +import com.cleanroommc.modularui.screen.viewport.ModularGuiContext; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; + +import org.jetbrains.annotations.ApiStatus; + +/** + * A read-only context for HUD elements: disables the hover/focus/drag subsystem, since HUD + * elements are display-only. + */ +@ApiStatus.Internal +@SideOnly(Side.CLIENT) +public class HudContext extends ModularGuiContext { + + public HudContext(ModularScreen screen) { + super(screen); + } + + @Override + public void onFrameUpdate() { + // no-op: display-only, no hover/focus/drag + } +} diff --git a/src/main/java/com/cleanroommc/modularui/hud/HudElement.java b/src/main/java/com/cleanroommc/modularui/hud/HudElement.java new file mode 100644 index 000000000..f49089f22 --- /dev/null +++ b/src/main/java/com/cleanroommc/modularui/hud/HudElement.java @@ -0,0 +1,151 @@ +package com.cleanroommc.modularui.hud; + +import com.cleanroommc.modularui.screen.ModularPanel; +import com.cleanroommc.modularui.screen.ModularScreen; +import com.cleanroommc.modularui.screen.viewport.ModularGuiContext; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; + +import lombok.Getter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; +import java.util.function.Function; + +/** + * A single HUD element rendered on top of the game world. + *

+ * A HUD element is display-only: it cannot capture mouse or keyboard input, has no + * hover events, no focus, and no drag support. The context's mouse/keyboard state is still + * updated each frame so widgets can read absolute mouse position in their {@code draw()} methods. + * + *

Example

+ *
{@code
+ * HudElement hud = new HudElement("mymod", ctx -> {
+ *     ModularPanel panel = new ModularPanel("status");
+ *     panel.size(100, 20).pos(5, 5);
+ *     panel.child(new TextWidget()
+ *         .text(IKey.dynamic(() -> "Current time: " + System.currentTimeMillis())));
+ *     return panel;
+ * });
+ * HudManager.register(hud);
+ * }
+ */ +@SideOnly(Side.CLIENT) +public class HudElement { + + @Getter private final ModularScreen screen; + private final HudWrapper wrapper; + + @Getter private boolean enabled = true; + private boolean visibleInWorld = true; + private boolean visibleInGui = true; + private boolean overGui = false; + @Getter private int renderPriority = 0; + + /** + * Creates a new HUD element with the given owner and panel creator. + * + * @param owner owner of this element (usually a mod id). Used for theme lookup. + * @param panelCreator function which creates the main panel. Receives the (read-only) + * {@link ModularGuiContext} of this element. + */ + public HudElement(@NotNull String owner, @NotNull Function panelCreator) { + Objects.requireNonNull(owner, "The owner must not be null!"); + Objects.requireNonNull(panelCreator, "The panel creator must not be null!"); + this.screen = new HudScreen(owner, panelCreator); + this.wrapper = new HudWrapper(this.screen); + } + + /** + * Sets whether this element is rendered when no screen is open (in-game). + * + * @param visibleInWorld true to render in-world + * @return this + */ + public HudElement visibleInWorld(boolean visibleInWorld) { + this.visibleInWorld = visibleInWorld; + return this; + } + + /** + * Sets whether and where this element is drawn while a {@code GuiScreen} (inventory, etc.) is + * open. Visible underneath open screens by default, not over them: most HUD elements (persistent + * overlays, ambient status) should not cover an open screen's own content. + * + * @param visibleInGui true to render at all while a screen is open + * @param isOverGui if visible, true to draw on top of the screen, false to draw underneath it + * @return this + */ + public HudElement visibleInGui(boolean visibleInGui, boolean isOverGui) { + this.visibleInGui = visibleInGui; + this.overGui = isOverGui; + return this; + } + + /** + * Sets the render priority. Higher values are drawn on top of lower values. + * + * @param renderPriority priority value + * @return this + */ + public HudElement renderPriority(int renderPriority) { + this.renderPriority = renderPriority; + return this; + } + + /** + * Enables or disables this element. Disabled elements are not rendered or ticked. + * + * @param enabled true to enable + * @return this + */ + public HudElement enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + public ModularPanel getPanel() { + return screen.getMainPanel(); + } + + /** + * @return true if this element should be rendered in-world (no screen open) + */ + public boolean isVisibleInWorld() { + return enabled && visibleInWorld; + } + + /** + * @return true if this element should be rendered on top of an open {@code GuiScreen} + */ + public boolean isVisibleOverGui() { + return enabled && visibleInGui && overGui; + } + + /** + * @return true if this element should be rendered underneath an open {@code GuiScreen} + */ + public boolean isVisibleUnderGui() { + return enabled && visibleInGui && !overGui; + } + + /** + * @return true if this element can be ticked (enabled) + */ + boolean canDraw() { + return enabled; + } + + @Nullable + public String getOwner() { + return screen.getOwner(); + } + + @Override + public String toString() { + return "HudElement[" + screen.getOwner() + ":" + screen.getName() + "]"; + } +} diff --git a/src/main/java/com/cleanroommc/modularui/hud/HudManager.java b/src/main/java/com/cleanroommc/modularui/hud/HudManager.java new file mode 100644 index 000000000..1db8301e1 --- /dev/null +++ b/src/main/java/com/cleanroommc/modularui/hud/HudManager.java @@ -0,0 +1,172 @@ +package com.cleanroommc.modularui.hud; + +import com.cleanroommc.modularui.utils.GlStateManager; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiScreen; +import net.minecraftforge.client.event.GuiScreenEvent; +import net.minecraftforge.client.event.RenderGameOverlayEvent; +import net.minecraftforge.client.event.RenderGameOverlayEvent.ElementType; +import cpw.mods.fml.common.eventhandler.EventPriority; +import cpw.mods.fml.common.eventhandler.SubscribeEvent; +import cpw.mods.fml.common.gameevent.TickEvent; +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; + +import org.jetbrains.annotations.ApiStatus; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.function.Predicate; + +/** + * Manages all registered {@link HudElement HUD elements}. + *

+ * Subscribes to Forge render/tick events and draws each visible element. HUD elements are + * display-only (no input capture). See {@link HudElement} for details. + */ +@ApiStatus.Internal +@SideOnly(Side.CLIENT) +public class HudManager { + + private static final List elements = new ArrayList<>(); + private static int lastWidth = -1; + private static int lastHeight = -1; + private static boolean initialized = false; + private static final HudManager INSTANCE = new HudManager(); + + private HudManager() {} + + /** + * Registers the HUD manager on the Forge event bus. Must be called once during client init, + * typically from {@code ClientProxy.preInit()}. + */ + public static void init() { + if (initialized) return; + initialized = true; + // must be an instance method - Forge's ASMEventHandler crashes on a static @SubscribeEvent + net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(INSTANCE); + } + + /** + * Registers a HUD element so it is rendered and ticked. + * + * @param element the element to register + */ + public static void register(HudElement element) { + if (element == null) throw new NullPointerException("Element must not be null!"); + if (!elements.contains(element)) { + elements.add(element); + } + } + + /** + * Unregisters a HUD element. + * + * @param element the element to unregister + * @return true if the element was registered and is now removed + */ + public static boolean unregister(HudElement element) { + return elements.remove(element); + } + + /** + * Removes all registered HUD elements. + */ + public static void clear() { + elements.clear(); + } + + /** + * Renders HUD elements over the game world (no screen open), after the vanilla HUD. + */ + @SubscribeEvent(priority = EventPriority.LOW) + public void onRenderGameOverlay(RenderGameOverlayEvent.Post event) { + if (event.type != ElementType.ALL) return; + Minecraft mc = Minecraft.getMinecraft(); + if (mc.currentScreen != null) return; + drawVisible(event.mouseX, event.mouseY, event.partialTicks, HudElement::isVisibleInWorld); + } + + /** + * Renders HUD elements underneath an open {@link GuiScreen} (before the screen draws). + */ + @SubscribeEvent(priority = EventPriority.LOW) + public void onGuiDrawPre(GuiScreenEvent.DrawScreenEvent.Pre event) { + drawVisible(event.mouseX, event.mouseY, event.renderPartialTicks, HudElement::isVisibleUnderGui); + } + + /** + * Renders HUD elements over an open {@link GuiScreen} (after the screen draws). + */ + @SubscribeEvent(priority = EventPriority.HIGH) + public void onGuiDrawPost(GuiScreenEvent.DrawScreenEvent.Post event) { + drawVisible(event.mouseX, event.mouseY, event.renderPartialTicks, HudElement::isVisibleOverGui); + } + + private static void drawVisible(int mouseX, int mouseY, float partialTicks, Predicate visibility) { + if (elements.isEmpty()) return; + checkResize(); + boolean anyVisible = false; + for (HudElement e : elements) { + if (visibility.test(e)) { + anyVisible = true; + e.getScreen() + .getContext() + .updateState(mouseX, mouseY, partialTicks); + } + } + if (!anyVisible) return; + + // Render from lowest to highest priority so higher priority draws on top. + elements.stream() + .filter(visibility) + .sorted(Comparator.comparingInt(HudElement::getRenderPriority)) + .forEach(e -> { + GlStateManager.enableBlend(); + GlStateManager.color(1f, 1f, 1f, 1f); + e.getScreen() + .drawScreen(); + e.getScreen() + .drawForeground(); + }); + } + + /** + * Ticks all enabled, drawable HUD elements at 20 Hz (client tick). + */ + @SubscribeEvent + public void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) return; + if (elements.isEmpty()) return; + for (HudElement e : elements) { + if (e.canDraw()) { + e.getScreen() + .onUpdate(); + } + } + } + + private static void checkResize() { + Minecraft mc = Minecraft.getMinecraft(); + int scaledWidth; + int scaledHeight; + if (mc.currentScreen != null) { + scaledWidth = mc.currentScreen.width; + scaledHeight = mc.currentScreen.height; + } else { + net.minecraft.client.gui.ScaledResolution res = new net.minecraft.client.gui.ScaledResolution(mc, mc.displayWidth, mc.displayHeight); + scaledWidth = res.getScaledWidth(); + scaledHeight = res.getScaledHeight(); + } + + boolean resized = scaledWidth != lastWidth || scaledHeight != lastHeight; + if (resized) { lastWidth = scaledWidth; lastHeight = scaledHeight; } + for (HudElement e : elements) { + if (resized || !e.getScreen().getPanelManager().isOpen()) { + e.getScreen().onResize(scaledWidth, scaledHeight); + } + } + } +} diff --git a/src/main/java/com/cleanroommc/modularui/hud/HudScreen.java b/src/main/java/com/cleanroommc/modularui/hud/HudScreen.java new file mode 100644 index 000000000..00165aa83 --- /dev/null +++ b/src/main/java/com/cleanroommc/modularui/hud/HudScreen.java @@ -0,0 +1,29 @@ +package com.cleanroommc.modularui.hud; + +import com.cleanroommc.modularui.screen.ModularPanel; +import com.cleanroommc.modularui.screen.ModularScreen; +import com.cleanroommc.modularui.screen.viewport.ModularGuiContext; + +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.function.Function; + +/** + * Internal subclass of {@link ModularScreen} used by {@link HudElement}. + *

+ * Constructs the screen with a read-only {@link HudContext} instead of the default + * {@link ModularGuiContext}, then builds the main panel via the supplied creator function + * using that context. + */ +@ApiStatus.Internal +@SideOnly(Side.CLIENT) +class HudScreen extends ModularScreen { + + HudScreen(@NotNull String owner, @NotNull Function panelCreator) { + super(owner, panelCreator, HudContext::new); + } +} diff --git a/src/main/java/com/cleanroommc/modularui/hud/HudWrapper.java b/src/main/java/com/cleanroommc/modularui/hud/HudWrapper.java new file mode 100644 index 000000000..eee59f4bd --- /dev/null +++ b/src/main/java/com/cleanroommc/modularui/hud/HudWrapper.java @@ -0,0 +1,37 @@ +package com.cleanroommc.modularui.hud; + +import com.cleanroommc.modularui.screen.ModularScreen; + +import net.minecraft.client.gui.GuiScreen; +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; + +import org.jetbrains.annotations.ApiStatus; + +/** + * A phantom {@link GuiScreen}, never set as {@code Minecraft.currentScreen} - it only exists to + * satisfy {@link ModularScreen#constructOverlay(GuiScreen)}, reusing the normal render pipeline + * for HUD elements. Input methods are inherited as no-ops, which is correct since HUD elements + * are display-only. + */ +@ApiStatus.Internal +@SideOnly(Side.CLIENT) +class HudWrapper extends GuiScreen { + + private final ModularScreen screen; + + HudWrapper(ModularScreen screen) { + this.screen = screen; + this.screen.constructOverlay(this); + } + + @Override + public boolean doesGuiPauseGame() { + return false; + } + + @Override + public String toString() { + return "HudWrapper(" + this.screen + ")"; + } +} diff --git a/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java b/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java index b8dc16b38..af203b9bb 100644 --- a/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java +++ b/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java @@ -80,7 +80,7 @@ public static ModularScreen getCurrent() { private final String owner; private final String name; private final PanelManager panelManager; - private final ModularGuiContext context = new ModularGuiContext(this); + private final ModularGuiContext context; private final Map, List> guiActionListeners = new Object2ObjectOpenHashMap<>(); private final Object2ObjectArrayMap frameUpdates = new Object2ObjectArrayMap<>(); private final ScreenResizeNode resizeNode = new ScreenResizeNode(this); @@ -128,6 +128,7 @@ public ModularScreen(@NotNull String owner, @NotNull Function mainPanelCreator, boolean ignored) { Objects.requireNonNull(owner, "The owner must not be null!"); this.owner = owner; + this.context = new ModularGuiContext(this); ModularPanel mainPanel = mainPanelCreator != null ? mainPanelCreator.apply(this.context) : buildUI(this.context); Objects.requireNonNull(mainPanel, "The main panel must not be null!"); this.name = mainPanel.getName(); @@ -141,6 +142,31 @@ private ModularScreen(@NotNull String owner, @Nullable Function + * This is intended for internal use by the HUD system where a read-only context + * ({@link com.cleanroommc.modularui.hud.HudContext}) is needed instead of the default + * {@link ModularGuiContext}. The context factory receives {@code this} screen and is + * invoked before the panel is created, so the panel creator receives the custom context. + * + * @param owner owner of this screen (usually a mod id) + * @param panelCreator function which creates the main panel of this screen + * @param contextFactory factory that creates the context for this screen + */ + protected ModularScreen(@NotNull String owner, @NotNull Function panelCreator, + @NotNull Function contextFactory) { + Objects.requireNonNull(owner, "The owner must not be null!"); + Objects.requireNonNull(panelCreator, "The main panel function must not be null!"); + Objects.requireNonNull(contextFactory, "The context factory must not be null!"); + this.owner = owner; + this.context = contextFactory.apply(this); + ModularPanel mainPanel = panelCreator.apply(this.context); + Objects.requireNonNull(mainPanel, "The main panel must not be null!"); + this.name = mainPanel.getName(); + this.panelManager = new PanelManager(this, mainPanel); + } + /** * Intended for use in {@link CustomModularScreen} */ @@ -296,8 +322,13 @@ public void onFrameUpdate() { */ public void drawScreen() { GlStateManager.disableRescaleNormal(); - RenderHelper.disableStandardItemLighting(); - GlStateManager.disableLighting(); + // enableStandardItemLighting() bakes GL_LIGHT0/1 positions relative to the current matrix - + // fine for a normal screen (drawn from the matrix state vanilla expects), but corrupts + // lighting for whatever draws next when called from a HUD overlay's own transform stack. + if (!isOverlay()) { + RenderHelper.disableStandardItemLighting(); + GlStateManager.disableLighting(); + } GlStateManager.disableDepth(); GlStateManager.disableAlpha(); @@ -317,8 +348,10 @@ public void drawScreen() { this.context.postRenderCallbacks.forEach(element -> element.accept(this.context)); GlStateManager.enableRescaleNormal(); - GlStateManager.enableLighting(); - RenderHelper.enableStandardItemLighting(); + if (!isOverlay()) { + GlStateManager.enableLighting(); + RenderHelper.enableStandardItemLighting(); + } GlStateManager.enableAlpha(); } @@ -329,8 +362,10 @@ public void drawScreen() { */ public void drawForeground() { GlStateManager.disableRescaleNormal(); - RenderHelper.disableStandardItemLighting(); - GlStateManager.disableLighting(); + if (!isOverlay()) { + RenderHelper.disableStandardItemLighting(); + GlStateManager.disableLighting(); + } GlStateManager.disableDepth(); GlStateManager.disableAlpha(); @@ -346,8 +381,10 @@ public void drawForeground() { this.context.popViewport(null); GlStateManager.enableRescaleNormal(); - GlStateManager.enableLighting(); - RenderHelper.enableStandardItemLighting(); + if (!isOverlay()) { + GlStateManager.enableLighting(); + RenderHelper.enableStandardItemLighting(); + } GlStateManager.enableAlpha(); } From a6716bc31b120edf8f100f361a569273979cb027 Mon Sep 17 00:00:00 2001 From: EternalQ Date: Thu, 16 Jul 2026 10:33:27 +0300 Subject: [PATCH 2/4] fix: `...TextWidget` scroll, `isEnabled()`, ... --- .../cleanroommc/modularui/utils/Platform.java | 7 +- .../modularui/widget/InternalWidgetTree.java | 5 ++ .../textfield/BaseTextFieldWidget.java | 20 +++++- .../widgets/textfield/TextFieldHandler.java | 71 +++++++++++++++---- .../widgets/textfield/TextFieldRenderer.java | 5 +- 5 files changed, 91 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/cleanroommc/modularui/utils/Platform.java b/src/main/java/com/cleanroommc/modularui/utils/Platform.java index 7a47cde2a..53adb21fc 100644 --- a/src/main/java/com/cleanroommc/modularui/utils/Platform.java +++ b/src/main/java/com/cleanroommc/modularui/utils/Platform.java @@ -135,7 +135,12 @@ public static void endDrawItem() { } public static void setupDrawFont() { - setupDrawTex(); + // blend must be on (with alpha blend func) or a text color's alpha channel - e.g. a fade-out + // effect - has no visual effect: FontRenderer still writes fully opaque pixels with GL_BLEND off. + setupDrawTex(true); + GlStateManager.tryBlendFuncSeparate( + GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA, + GlStateManager.SourceFactor.ONE, GlStateManager.DestFactor.ZERO); } /** diff --git a/src/main/java/com/cleanroommc/modularui/widget/InternalWidgetTree.java b/src/main/java/com/cleanroommc/modularui/widget/InternalWidgetTree.java index 01d8c39e3..f099c05be 100644 --- a/src/main/java/com/cleanroommc/modularui/widget/InternalWidgetTree.java +++ b/src/main/java/com/cleanroommc/modularui/widget/InternalWidgetTree.java @@ -166,6 +166,11 @@ static void drawBackground(IWidget parent, ModularGuiContext context, boolean ig } static void drawTreeForeground(IWidget parent, ModularGuiContext context) { + // Mirror drawTree: a disabled widget (and its whole subtree) must not draw its foreground + // either. This matches the documented contract of IWidget#drawForeground ("If a parent of + // this widget is disabled, this widget will not be drawn.") which the recursion below + // otherwise violated, leaving tooltips/foreground of disabled widgets rendering. + if (!parent.isEnabled()) return; IViewport viewport = parent instanceof IViewport viewport1 ? viewport1 : null; context.pushMatrix(); parent.transform(context); diff --git a/src/main/java/com/cleanroommc/modularui/widgets/textfield/BaseTextFieldWidget.java b/src/main/java/com/cleanroommc/modularui/widgets/textfield/BaseTextFieldWidget.java index 6a3e5856b..369d8cd9c 100644 --- a/src/main/java/com/cleanroommc/modularui/widgets/textfield/BaseTextFieldWidget.java +++ b/src/main/java/com/cleanroommc/modularui/widgets/textfield/BaseTextFieldWidget.java @@ -137,7 +137,14 @@ protected void drawText(ModularGuiContext context, TextFieldTheme widgetTheme) { } else { this.renderer.draw(this.handler.getText()); } - getScrollArea().getScrollX().setScrollSize(Math.max(0, (int) (this.renderer.getLastActualWidth() + 0.5f))); + // + renderer.getX(): scrollSize has to be measured in the same coordinate space as + // TextFieldHandler's cursor positions (getPosOf, which include the field's left-padding offset + // via getStartX) - otherwise ScrollData.clamp's "scrollSize - visibleSize" cap silently pulls the + // scroll back short of the cursor on every single frame, undoing whatever the handler just set. + // + getScrollEdgeMargin(): matches the same margin setMainCursor bakes into its own scrollSize, + // so this per-frame recompute doesn't clamp that margin back down to zero on the right/end edge. + getScrollArea().getScrollX().setScrollSize(this.renderer.getX() + + Math.max(0, (int) (this.renderer.getLastActualWidth() + 0.5f)) + this.handler.getScrollEdgeMargin()); } @Override @@ -391,6 +398,17 @@ public W setFocusOnGuiOpen(boolean focusOnGuiOpen) { return getThis(); } + /** + * Safety margin, in pixels, kept between the cursor/revealed text and the field's real clip edge + * when scrolling horizontally (see {@link TextFieldHandler#setScrollEdgeMargin}). Defaults to + * this field's own default horizontal padding, since that's already the breathing room the field + * visually reserves; override this if a different padding is configured. + */ + public W scrollEdgeMargin(int margin) { + this.handler.setScrollEdgeMargin(margin); + return getThis(); + } + /** * Sets a constant hint text. The hint is displayed in a less noticeable color when the field is empty. * The color is by default obtained from the current them, but can be overriden with {@link #hintColor(int)}. diff --git a/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldHandler.java b/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldHandler.java index 3f28b4f28..463c340b2 100644 --- a/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldHandler.java +++ b/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldHandler.java @@ -3,6 +3,7 @@ import com.cleanroommc.modularui.screen.viewport.GuiContext; import com.cleanroommc.modularui.utils.MathUtils; import com.cleanroommc.modularui.widget.scroll.ScrollArea; +import com.cleanroommc.modularui.widget.scroll.ScrollData; import com.google.common.base.Joiner; import org.jetbrains.annotations.Nullable; @@ -33,6 +34,16 @@ public class TextFieldHandler { private Pattern pattern; private int maxCharacters = -1; private GuiContext guiContext; + /** + * Safety margin, in pixels, kept between the cursor/revealed text and the field's real clip edge + * (see BaseTextFieldWidget#preDraw's Stencil.apply(1, 1, w - 2, h - 2, ..), which clips a couple + * pixels tighter than the raw visible size). Also added on top of scrollSize itself (see + * BaseTextFieldWidget#drawText and setMainCursor below), since otherwise ScrollData.clamp's own + * "scrollSize - visibleSize" cap silently overrides the right-edge margin back down to zero. + * Defaults to matching the field's own default horizontal padding (see BaseTextFieldWidget's + * constructor), since that's already the amount of breathing room the field visually reserves. + */ + private int scrollEdgeMargin = 4; public TextFieldHandler(BaseTextFieldWidget textFieldWidget) { this.textFieldWidget = textFieldWidget; @@ -46,6 +57,14 @@ public void setMaxCharacters(int maxCharacters) { this.maxCharacters = maxCharacters; } + public int getScrollEdgeMargin() { + return this.scrollEdgeMargin; + } + + public void setScrollEdgeMargin(int scrollEdgeMargin) { + this.scrollEdgeMargin = scrollEdgeMargin; + } + public void setScrollArea(@Nullable ScrollArea scrollArea) { this.scrollArea = scrollArea; } @@ -93,19 +112,41 @@ public void setMainCursor(int linePos, int charPos, boolean animate) { if (main.x != charPos || main.y != linePos) { main.setLocation(charPos, linePos); if (!this.text.isEmpty() && this.renderer != null && this.scrollArea != null) { - // update actual width - this.renderer.setSimulate(true); - this.renderer.draw(this.text); - this.renderer.setSimulate(false); - this.scrollArea.getScrollX().setScrollSize((int) this.renderer.getLastActualWidth()); - if (this.scrollArea.getScrollX().isScrollBarActive(this.scrollArea)) { - String line = this.text.get(main.y); - int scrollTo = (int) this.renderer.getPosOf(this.renderer.measureLines(Collections.singletonList(line)), new Point(main.x, 0)).x; - scrollTo -= this.scrollArea.getScrollX().getFullVisibleSize(this.scrollArea) / 2; - if (animate) { - this.scrollArea.getScrollX().animateTo(this.scrollArea, scrollTo); - } else { - this.scrollArea.getScrollX().scrollTo(this.scrollArea, scrollTo); + ScrollData scrollX = this.scrollArea.getScrollX(); + String line = this.text.get(main.y); + var measuredLine = this.renderer.measureLines(Collections.singletonList(line)); + // scrollSize has to be measured in the same coordinate space as getPosOf's cursor + // positions below (which include the field's own left padding baked in via getStartX), + // otherwise ScrollData.clamp's own "scrollSize - visibleSize" cap silently overrides our + // scrollTo target by that same offset, leaving the cursor a few pixels short of actually + // being scrolled into view - i.e. clipped/invisible - no matter what we compute below. + int lineEndX = (int) this.renderer.getPosOf(measuredLine, new Point(line.length(), 0)).x; + // + scrollEdgeMargin: without this, ScrollData.clamp's own "scrollSize - visibleSize" + // cap sits exactly scrollEdgeMargin below the scrollTo computed further down, and + // silently pulls it back down to the tight boundary - canceling the margin on this + // (the right/end) edge specifically. The left edge has no equivalent upper-bound clamp, + // which is why only it appeared to be fixed before this. + scrollX.setScrollSize(lineEndX + this.scrollEdgeMargin); + if (scrollX.isScrollBarActive(this.scrollArea)) { + int cursorX = (int) this.renderer.getPosOf(measuredLine, new Point(main.x, 0)).x; + // scroll just enough to keep the cursor in view, rather than re-centering it on every + // move: centering combined with an animated scroll can't catch up to a target that + // itself keeps moving forward while typing continuously, so the cursor ends up past the + // visible (clipped) edge - i.e. invisible - until the animation eventually catches up. + int visible = scrollX.getFullVisibleSize(this.scrollArea) - this.scrollEdgeMargin; + int current = scrollX.getScroll(); + int scrollTo = current; + if (cursorX < current + this.scrollEdgeMargin) { + scrollTo = Math.max(0, cursorX - this.scrollEdgeMargin); + } else if (cursorX > current + visible) { + scrollTo = cursorX - visible; + } + if (scrollTo != current) { + if (animate) { + scrollX.animateTo(this.scrollArea, scrollTo); + } else { + scrollX.scrollTo(this.scrollArea, scrollTo); + } } } } @@ -308,7 +349,9 @@ public void insert(List text, boolean hasHorizontalScrolling) { if (point == null || copy.size() > this.maxLines || !this.renderer.wouldFit(copy, !hasHorizontalScrolling)) return; this.text.clear(); this.text.addAll(copy); - setCursor(point, true); + // no animation here: typing fires this on every keystroke, and an animated scroll can't catch up + // to a target that keeps moving forward each character - see setMainCursor's comment. + setCursor(point, false); onChanged(); } diff --git a/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldRenderer.java b/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldRenderer.java index 8f6ebc96c..7a4186f4e 100644 --- a/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldRenderer.java +++ b/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldRenderer.java @@ -235,7 +235,10 @@ public void drawMarked(float y0, float x0, float x1) { private void drawCursor(float x0, float y0) { x0 = (x0 - 0.8f) / this.scale; y0 = (y0 - 1) / this.scale; - float x1 = x0 + 0.6f; + // 1.0 rather than the previous 0.6: a sub-pixel-wide quad can fail to rasterize as a visible + // pixel at all once it lands right at a scrolled/clipped edge (see TextFieldHandler's scroll + // margin), which is exactly where the cursor sits while typing past a field's visible width. + float x1 = x0 + 1.0f; float y1 = y0 + 9; float red = Color.getRedF(this.cursorColor); float green = Color.getGreenF(this.cursorColor); From 0a3606a81e637c5f311bef734bdccd75d5024fe0 Mon Sep 17 00:00:00 2001 From: EternalQ Date: Fri, 17 Jul 2026 11:53:28 +0300 Subject: [PATCH 3/4] fix: 3D render in Inventory --- .../java/com/cleanroommc/modularui/screen/ModularScreen.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java b/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java index af203b9bb..e644a0ec5 100644 --- a/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java +++ b/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java @@ -352,6 +352,7 @@ public void drawScreen() { GlStateManager.enableLighting(); RenderHelper.enableStandardItemLighting(); } + GlStateManager.enableDepth(); GlStateManager.enableAlpha(); } @@ -385,6 +386,7 @@ public void drawForeground() { GlStateManager.enableLighting(); RenderHelper.enableStandardItemLighting(); } + GlStateManager.enableDepth(); GlStateManager.enableAlpha(); } From 1b397ba0f5a50ecf8af6201c41c49c753fe97a8f Mon Sep 17 00:00:00 2001 From: EternalQ Date: Fri, 17 Jul 2026 23:22:17 +0300 Subject: [PATCH 4/4] chore: PR requested changes --- .../cleanroommc/modularui/hud/HudElement.java | 10 +++++ .../cleanroommc/modularui/hud/HudManager.java | 31 +++++++------ .../cleanroommc/modularui/hud/HudScreen.java | 4 -- .../modularui/screen/ModularScreen.java | 43 ++++++------------- .../widgets/textfield/TextFieldHandler.java | 23 ---------- 5 files changed, 39 insertions(+), 72 deletions(-) diff --git a/src/main/java/com/cleanroommc/modularui/hud/HudElement.java b/src/main/java/com/cleanroommc/modularui/hud/HudElement.java index f49089f22..0dcdcb3fe 100644 --- a/src/main/java/com/cleanroommc/modularui/hud/HudElement.java +++ b/src/main/java/com/cleanroommc/modularui/hud/HudElement.java @@ -45,6 +45,16 @@ public class HudElement { private boolean overGui = false; @Getter private int renderPriority = 0; + /** + * Creates a new HUD element with the given owner and main panel. + * + * @param owner owner of this element (usually a mod id). Used for theme lookup. + * @param mainPanel main panel of this element. + */ + public HudElement(@NotNull String owner, @NotNull ModularPanel mainPanel) { + this(owner, ctx -> mainPanel); + } + /** * Creates a new HUD element with the given owner and panel creator. * diff --git a/src/main/java/com/cleanroommc/modularui/hud/HudManager.java b/src/main/java/com/cleanroommc/modularui/hud/HudManager.java index 1db8301e1..941917312 100644 --- a/src/main/java/com/cleanroommc/modularui/hud/HudManager.java +++ b/src/main/java/com/cleanroommc/modularui/hud/HudManager.java @@ -113,24 +113,24 @@ private static void drawVisible(int mouseX, int mouseY, float partialTicks, Pred if (visibility.test(e)) { anyVisible = true; e.getScreen() - .getContext() - .updateState(mouseX, mouseY, partialTicks); + .getContext() + .updateState(mouseX, mouseY, partialTicks); } } if (!anyVisible) return; // Render from lowest to highest priority so higher priority draws on top. elements.stream() - .filter(visibility) - .sorted(Comparator.comparingInt(HudElement::getRenderPriority)) - .forEach(e -> { - GlStateManager.enableBlend(); - GlStateManager.color(1f, 1f, 1f, 1f); - e.getScreen() - .drawScreen(); - e.getScreen() - .drawForeground(); - }); + .filter(visibility) + .sorted(Comparator.comparingInt(HudElement::getRenderPriority)) + .forEach(e -> { + GlStateManager.enableBlend(); + GlStateManager.color(1f, 1f, 1f, 1f); + e.getScreen() + .drawScreen(); + e.getScreen() + .drawForeground(); + }); } /** @@ -143,7 +143,7 @@ public void onClientTick(TickEvent.ClientTickEvent event) { for (HudElement e : elements) { if (e.canDraw()) { e.getScreen() - .onUpdate(); + .onUpdate(); } } } @@ -162,7 +162,10 @@ private static void checkResize() { } boolean resized = scaledWidth != lastWidth || scaledHeight != lastHeight; - if (resized) { lastWidth = scaledWidth; lastHeight = scaledHeight; } + if (resized) { + lastWidth = scaledWidth; + lastHeight = scaledHeight; + } for (HudElement e : elements) { if (resized || !e.getScreen().getPanelManager().isOpen()) { e.getScreen().onResize(scaledWidth, scaledHeight); diff --git a/src/main/java/com/cleanroommc/modularui/hud/HudScreen.java b/src/main/java/com/cleanroommc/modularui/hud/HudScreen.java index 00165aa83..967edf277 100644 --- a/src/main/java/com/cleanroommc/modularui/hud/HudScreen.java +++ b/src/main/java/com/cleanroommc/modularui/hud/HudScreen.java @@ -14,10 +14,6 @@ /** * Internal subclass of {@link ModularScreen} used by {@link HudElement}. - *

- * Constructs the screen with a read-only {@link HudContext} instead of the default - * {@link ModularGuiContext}, then builds the main panel via the supplied creator function - * using that context. */ @ApiStatus.Internal @SideOnly(Side.CLIENT) diff --git a/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java b/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java index e644a0ec5..0f53769d4 100644 --- a/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java +++ b/src/main/java/com/cleanroommc/modularui/screen/ModularScreen.java @@ -122,13 +122,15 @@ public ModularScreen(@NotNull String owner, @NotNull ModularPanel mainPanel) { * @param mainPanelCreator function which creates the main panel of this screen */ public ModularScreen(@NotNull String owner, @NotNull Function mainPanelCreator) { - this(owner, Objects.requireNonNull(mainPanelCreator, "The main panel function must not be null!"), false); + this(owner, Objects.requireNonNull(mainPanelCreator, "The main panel function must not be null!"), ModularGuiContext::new); } - private ModularScreen(@NotNull String owner, @Nullable Function mainPanelCreator, boolean ignored) { + protected ModularScreen(@NotNull String owner, @Nullable Function mainPanelCreator, + @NotNull Function contextFactory) { Objects.requireNonNull(owner, "The owner must not be null!"); + Objects.requireNonNull(contextFactory, "The context factory must not be null!"); this.owner = owner; - this.context = new ModularGuiContext(this); + this.context = contextFactory.apply(this); ModularPanel mainPanel = mainPanelCreator != null ? mainPanelCreator.apply(this.context) : buildUI(this.context); Objects.requireNonNull(mainPanel, "The main panel must not be null!"); this.name = mainPanel.getName(); @@ -139,32 +141,7 @@ private ModularScreen(@NotNull String owner, @Nullable Function - * This is intended for internal use by the HUD system where a read-only context - * ({@link com.cleanroommc.modularui.hud.HudContext}) is needed instead of the default - * {@link ModularGuiContext}. The context factory receives {@code this} screen and is - * invoked before the panel is created, so the panel creator receives the custom context. - * - * @param owner owner of this screen (usually a mod id) - * @param panelCreator function which creates the main panel of this screen - * @param contextFactory factory that creates the context for this screen - */ - protected ModularScreen(@NotNull String owner, @NotNull Function panelCreator, - @NotNull Function contextFactory) { - Objects.requireNonNull(owner, "The owner must not be null!"); - Objects.requireNonNull(panelCreator, "The main panel function must not be null!"); - Objects.requireNonNull(contextFactory, "The context factory must not be null!"); - this.owner = owner; - this.context = contextFactory.apply(this); - ModularPanel mainPanel = panelCreator.apply(this.context); - Objects.requireNonNull(mainPanel, "The main panel must not be null!"); - this.name = mainPanel.getName(); - this.panelManager = new PanelManager(this, mainPanel); + this(owner, null, ModularGuiContext::new); } /** @@ -565,7 +542,9 @@ public boolean onMouseDrag(int mouseButton, long timeSinceClick) { return false; } - /** Lwjgl3 key press event */ + /** + * Lwjgl3 key press event + */ @Optional.Method(modid = ModularUI.ModIds.LWJGL3IFY) public void onKeyEvent(InputEvents.KeyEvent event) { for (ModularPanel panel : this.panelManager.getOpenPanels()) { @@ -578,7 +557,9 @@ public void onKeyEvent(InputEvents.KeyEvent event) { } } - /** Lwjgl3 text input event */ + /** + * Lwjgl3 text input event + */ @Optional.Method(modid = ModularUI.ModIds.LWJGL3IFY) public void onTextEvent(InputEvents.TextEvent event) { for (ModularPanel panel : this.panelManager.getOpenPanels()) { diff --git a/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldHandler.java b/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldHandler.java index 463c340b2..6c98eef83 100644 --- a/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldHandler.java +++ b/src/main/java/com/cleanroommc/modularui/widgets/textfield/TextFieldHandler.java @@ -34,15 +34,6 @@ public class TextFieldHandler { private Pattern pattern; private int maxCharacters = -1; private GuiContext guiContext; - /** - * Safety margin, in pixels, kept between the cursor/revealed text and the field's real clip edge - * (see BaseTextFieldWidget#preDraw's Stencil.apply(1, 1, w - 2, h - 2, ..), which clips a couple - * pixels tighter than the raw visible size). Also added on top of scrollSize itself (see - * BaseTextFieldWidget#drawText and setMainCursor below), since otherwise ScrollData.clamp's own - * "scrollSize - visibleSize" cap silently overrides the right-edge margin back down to zero. - * Defaults to matching the field's own default horizontal padding (see BaseTextFieldWidget's - * constructor), since that's already the amount of breathing room the field visually reserves. - */ private int scrollEdgeMargin = 4; public TextFieldHandler(BaseTextFieldWidget textFieldWidget) { @@ -115,24 +106,10 @@ public void setMainCursor(int linePos, int charPos, boolean animate) { ScrollData scrollX = this.scrollArea.getScrollX(); String line = this.text.get(main.y); var measuredLine = this.renderer.measureLines(Collections.singletonList(line)); - // scrollSize has to be measured in the same coordinate space as getPosOf's cursor - // positions below (which include the field's own left padding baked in via getStartX), - // otherwise ScrollData.clamp's own "scrollSize - visibleSize" cap silently overrides our - // scrollTo target by that same offset, leaving the cursor a few pixels short of actually - // being scrolled into view - i.e. clipped/invisible - no matter what we compute below. int lineEndX = (int) this.renderer.getPosOf(measuredLine, new Point(line.length(), 0)).x; - // + scrollEdgeMargin: without this, ScrollData.clamp's own "scrollSize - visibleSize" - // cap sits exactly scrollEdgeMargin below the scrollTo computed further down, and - // silently pulls it back down to the tight boundary - canceling the margin on this - // (the right/end) edge specifically. The left edge has no equivalent upper-bound clamp, - // which is why only it appeared to be fixed before this. scrollX.setScrollSize(lineEndX + this.scrollEdgeMargin); if (scrollX.isScrollBarActive(this.scrollArea)) { int cursorX = (int) this.renderer.getPosOf(measuredLine, new Point(main.x, 0)).x; - // scroll just enough to keep the cursor in view, rather than re-centering it on every - // move: centering combined with an animated scroll can't catch up to a target that - // itself keeps moving forward while typing continuously, so the cursor ends up past the - // visible (clipped) edge - i.e. invisible - until the animation eventually catches up. int visible = scrollX.getFullVisibleSize(this.scrollArea) - this.scrollEdgeMargin; int current = scrollX.getScroll(); int scrollTo = current;