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..0dcdcb3fe --- /dev/null +++ b/src/main/java/com/cleanroommc/modularui/hud/HudElement.java @@ -0,0 +1,161 @@ +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 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. + * + * @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..941917312 --- /dev/null +++ b/src/main/java/com/cleanroommc/modularui/hud/HudManager.java @@ -0,0 +1,175 @@ +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..967edf277 --- /dev/null +++ b/src/main/java/com/cleanroommc/modularui/hud/HudScreen.java @@ -0,0 +1,25 @@ +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}. + */ +@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..0f53769d4 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); @@ -122,12 +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 = 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(); @@ -138,7 +141,7 @@ private ModularScreen(@NotNull String owner, @Nullable Function element.accept(this.context)); GlStateManager.enableRescaleNormal(); - GlStateManager.enableLighting(); - RenderHelper.enableStandardItemLighting(); + if (!isOverlay()) { + GlStateManager.enableLighting(); + RenderHelper.enableStandardItemLighting(); + } + GlStateManager.enableDepth(); GlStateManager.enableAlpha(); } @@ -329,8 +340,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 +359,11 @@ public void drawForeground() { this.context.popViewport(null); GlStateManager.enableRescaleNormal(); - GlStateManager.enableLighting(); - RenderHelper.enableStandardItemLighting(); + if (!isOverlay()) { + GlStateManager.enableLighting(); + RenderHelper.enableStandardItemLighting(); + } + GlStateManager.enableDepth(); GlStateManager.enableAlpha(); } @@ -526,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()) { @@ -539,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/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..6c98eef83 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,7 @@ public class TextFieldHandler { private Pattern pattern; private int maxCharacters = -1; private GuiContext guiContext; + private int scrollEdgeMargin = 4; public TextFieldHandler(BaseTextFieldWidget textFieldWidget) { this.textFieldWidget = textFieldWidget; @@ -46,6 +48,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 +103,27 @@ 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)); + int lineEndX = (int) this.renderer.getPosOf(measuredLine, new Point(line.length(), 0)).x; + scrollX.setScrollSize(lineEndX + this.scrollEdgeMargin); + if (scrollX.isScrollBarActive(this.scrollArea)) { + int cursorX = (int) this.renderer.getPosOf(measuredLine, new Point(main.x, 0)).x; + 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 +326,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);