diff --git a/src/main/java/top/fpsmaster/features/GlobalListener.java b/src/main/java/top/fpsmaster/features/GlobalListener.java index 9efe172a..9f0b60b7 100644 --- a/src/main/java/top/fpsmaster/features/GlobalListener.java +++ b/src/main/java/top/fpsmaster/features/GlobalListener.java @@ -81,11 +81,14 @@ public void onRender(EventRender2D e) { if (ClientSettings.blur.getValue()) { StencilUtil.initStencilToWrite(); - EventDispatcher.dispatchEvent(new EventShader()); - FPSMaster.componentsManager.drawBackgroundMasks(); - StencilUtil.readStencilBuffer(1); - KawaseBlur.renderBlur(3, 3); - StencilUtil.uninitStencilBuffer(); + try { + EventDispatcher.dispatchEvent(new EventShader()); + FPSMaster.componentsManager.drawBackgroundMasks(); + StencilUtil.readStencilBuffer(1); + KawaseBlur.renderBlur(3, 3); + } finally { + StencilUtil.uninitStencilBuffer(); + } } FPSMaster.componentsManager.draw((int) mouseX, (int) mouseY); diff --git a/src/main/java/top/fpsmaster/ui/click/MainPanel.java b/src/main/java/top/fpsmaster/ui/click/MainPanel.java index 14ca429d..b2f38792 100644 --- a/src/main/java/top/fpsmaster/ui/click/MainPanel.java +++ b/src/main/java/top/fpsmaster/ui/click/MainPanel.java @@ -406,6 +406,13 @@ public void keyTyped(char typedChar, int keyCode) throws IOException { super.keyTyped(typedChar, keyCode); } + @Override + protected float[] getOccludingBounds() { + // Only the panel itself, not the whole screen: dragging a HUD element that sits beside the + // ClickGUI is exactly what the user opened it for. + return new float[]{x, y, width, height}; + } + private void handlePointerPress() { ScaledGuiScreen.PointerEvent press = peekAnyPress(); if (press == null) { @@ -420,10 +427,11 @@ private void handlePointerPress() { return; } - if (hasPointerCapture()) { - return; - } - + // A hasPointerCapture() guard used to sit here. It compensated for peekAnyPress() returning + // presses that another widget had already consumed — beginDrag consumes the press that starts + // a slider drag, yet the category strip still saw it and switched category underneath. Now that + // peekAnyPress() skips consumed presses the guard is redundant: the press is gone on the frame + // a drag begins, and later frames of the same drag produce no press at all. float my = getCategoryStartY(); for (Category c : Category.values()) { if (Hover.is(x, my - 8, leftWidth, 24f, mouseX, mouseY)) { diff --git a/src/main/java/top/fpsmaster/ui/click/modules/ModuleRenderer.java b/src/main/java/top/fpsmaster/ui/click/modules/ModuleRenderer.java index bf221b37..25e64598 100644 --- a/src/main/java/top/fpsmaster/ui/click/modules/ModuleRenderer.java +++ b/src/main/java/top/fpsmaster/ui/click/modules/ModuleRenderer.java @@ -166,7 +166,9 @@ public void render(ScaledGuiScreen screen, float x, float y, float width, float settingHeight = (float) AnimMath.base(settingHeight, settingsHeight, 0.2); this.height = settingHeight; - ScaledGuiScreen.PointerEvent click = screen.consumePressInBounds(x + 5, y, width - 10, 40f); + // Gated on z-order: the module header sits under the settings of the module expanded above it, + // and under any panel drawn later. `mod` is a stable per-frame identity. + ScaledGuiScreen.PointerEvent click = screen.consumePressAsHovered(mod, x + 5, y, width - 10, 40f); if (click != null) { if (click.button == 0) { mod.toggle(); diff --git a/src/main/java/top/fpsmaster/ui/custom/Component.java b/src/main/java/top/fpsmaster/ui/custom/Component.java index e4d05a09..cc133c86 100644 --- a/src/main/java/top/fpsmaster/ui/custom/Component.java +++ b/src/main/java/top/fpsmaster/ui/custom/Component.java @@ -17,6 +17,7 @@ import top.fpsmaster.modules.logger.ClientLogger; import top.fpsmaster.ui.click.MainPanel; import top.fpsmaster.utils.core.Utility; +import top.fpsmaster.utils.render.gui.GuiOcclusion; import top.fpsmaster.utils.math.anim.AnimMath; import java.awt.*; @@ -227,8 +228,14 @@ public void display(ScaledResolution sr, int mouseX, int mouseY) { } if (hovered || overHandle || drag) { - if (Utility.mc.currentScreen instanceof MainPanel && ((MainPanel) Utility.mc.currentScreen).hasPointerCapture()) + // The HUD editor runs from EventRender2D, which 1.8.9 fires while drawing the in-game + // overlay — before currentScreen.drawScreen(). Without this it happily drags components + // that are sitting *underneath* an open panel. The previous guard only covered the case + // where a ClickGUI slider already held a drag capture, which is a small fraction of the + // panel's area. + if (GuiOcclusion.covers(Mouse.getX(), Utility.mc.displayHeight - Mouse.getY())) { return; + } if (allowScale && ClientSettings.isZoomBindDown()) { int dWheel = Mouse.getDWheel(); if (dWheel > 0) scaleUp(); diff --git a/src/main/java/top/fpsmaster/ui/screens/oobe/OobeDropdown.java b/src/main/java/top/fpsmaster/ui/screens/oobe/OobeDropdown.java index cbeb51f5..ec543318 100644 --- a/src/main/java/top/fpsmaster/ui/screens/oobe/OobeDropdown.java +++ b/src/main/java/top/fpsmaster/ui/screens/oobe/OobeDropdown.java @@ -121,8 +121,9 @@ public void renderInScreen(ScaledGuiScreen screen, float x, float y, float width } } - ScaledGuiScreen.PointerEvent outside = screen.peekAnyPress(); - if (outside != null && openProgress > 0.95f && !Hover.is(x, y, width, height + panelHeight + 4f, outside.x, outside.y)) { + // Consume the dismissing click, otherwise it also activates whatever sits behind the popup. + if (openProgress > 0.95f + && screen.consumePressOutside(x, y, width, height + panelHeight + 4f) != null) { open = false; } } diff --git a/src/main/java/top/fpsmaster/utils/render/StencilUtil.java b/src/main/java/top/fpsmaster/utils/render/StencilUtil.java index b3d8dbe5..3ba57174 100644 --- a/src/main/java/top/fpsmaster/utils/render/StencilUtil.java +++ b/src/main/java/top/fpsmaster/utils/render/StencilUtil.java @@ -78,8 +78,21 @@ public static void readStencilBuffer(int ref) { GL11.glStencilOp(GL11.GL_KEEP, GL11.GL_KEEP, GL11.GL_KEEP); } + /** + * Ends a stencil pass and restores everything {@link #initStencilToWrite()} changed. + * + *
The colour mask used to be reopened only by {@link #readStencilBuffer(int)}, which is a + * "switch to reading" call, not a teardown call. Any caller that threw — or returned early — + * between writing and reading left the mask fully closed for the rest of the process. The symptom + * is remote from the cause: the next {@code glClear(GL_COLOR_BUFFER_BIT)} anywhere (KawaseBlur's + * framebuffer clear, for instance) asks the driver to clear a colour buffer it is forbidden to + * write, which on Apple's Metal-backed GL aborts with "missing clear mask bits". + * + *
Reopening it here as well is idempotent and makes teardown total. + */ public static void uninitStencilBuffer() { GL11.glDisable(GL11.GL_STENCIL_TEST); + GL11.glColorMask(true, true, true, true); } } diff --git a/src/main/java/top/fpsmaster/utils/render/effects/Blur.java b/src/main/java/top/fpsmaster/utils/render/effects/Blur.java index f3111f21..454776d5 100644 --- a/src/main/java/top/fpsmaster/utils/render/effects/Blur.java +++ b/src/main/java/top/fpsmaster/utils/render/effects/Blur.java @@ -13,9 +13,12 @@ public static void area(int x, int y, int width, int height, int radius, Color c public static void area(float x, float y, float width, float height, int radius, Color color, int iterations, int offset) { StencilUtil.initStencilToWrite(); - RoundedUtil.drawRound(x, y, width, height, radius, true, color); - StencilUtil.readStencilBuffer(1); - KawaseBlur.renderBlur(iterations, offset); - StencilUtil.uninitStencilBuffer(); + try { + RoundedUtil.drawRound(x, y, width, height, radius, true, color); + StencilUtil.readStencilBuffer(1); + KawaseBlur.renderBlur(iterations, offset); + } finally { + StencilUtil.uninitStencilBuffer(); + } } } diff --git a/src/main/java/top/fpsmaster/utils/render/gui/GuiInputState.java b/src/main/java/top/fpsmaster/utils/render/gui/GuiInputState.java index 07e944b2..eed85ed0 100644 --- a/src/main/java/top/fpsmaster/utils/render/gui/GuiInputState.java +++ b/src/main/java/top/fpsmaster/utils/render/gui/GuiInputState.java @@ -45,6 +45,9 @@ private void consume() { private int mouseY; private int wheelDelta; + private Object hoveredId; + private Object lastHoveredId; + public void updateMousePosition(int mouseX, int mouseY) { this.mouseX = mouseX; this.mouseY = mouseY; @@ -87,8 +90,41 @@ public boolean hasPressEvent() { return !pressEvents.isEmpty(); } + /** + * The most recent press that nobody has claimed yet. + * + *
This used to return the press regardless of consumption, so a widget peeking at it could act + * on a click another widget had already handled — which is why {@code MainPanel} needed a + * {@code hasPointerCapture()} guard to undo the double handling. + */ public MouseButtonEvent getLatestPress() { - return latestPress; + return latestPress != null && latestPress.isConsumed() ? null : latestPress; + } + + /** + * Records that the cursor is inside this widget, overwriting whatever was recorded earlier in the + * frame. + * + *
This is how z-order falls out of an immediate-mode GUI: widgets are visited in paint order, + * so the last one to claim the cursor is the one drawn on top. No comparison is needed — + * later simply wins. The result is only usable next frame, once the whole frame has been walked, + * which is why {@link #wasHovered} reads the previous frame's value. GUI layout barely moves + * between frames, so the one-frame lag is not observable. + */ + public void markHovered(Object id, float x, float y, float width, float height) { + if (mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height) { + hoveredId = id; + } + } + + /** Whether {@code id} was the topmost widget under the cursor at the end of the previous frame. */ + public boolean wasHovered(Object id) { + return id != null && id.equals(lastHoveredId); + } + + /** True until any widget has claimed the cursor, i.e. nothing is known to be on top yet. */ + public boolean hasHoveredId() { + return lastHoveredId != null; } public int getMouseX() { @@ -120,7 +156,34 @@ public MouseButtonEvent consumePressInBounds(float x, float y, float width, floa return null; } + /** + * Claims a press that landed outside the given rectangle — the "click away to dismiss" + * gesture for popups. + * + *
Consuming it is the whole point: a popup that merely notices the outside click and closes + * itself lets that same click through to whatever it was covering, so dismissing a dropdown also + * activates the button behind it. + */ + public MouseButtonEvent consumePressOutside(float x, float y, float width, float height) { + for (MouseButtonEvent event : pressEvents) { + if (event.isConsumed()) { + continue; + } + boolean inside = event.getX() >= x && event.getX() <= x + width + && event.getY() >= y && event.getY() <= y + height; + if (inside) { + continue; + } + event.consume(); + return event; + } + return null; + } + public void finishFrame() { + // Hand this frame's topmost-under-cursor result to the next frame, where it arbitrates clicks. + lastHoveredId = hoveredId; + hoveredId = null; pressEvents.clear(); latestPress = null; wheelDelta = 0; @@ -132,6 +195,7 @@ public void reset() { } mouseX = 0; mouseY = 0; + lastHoveredId = null; finishFrame(); } } diff --git a/src/main/java/top/fpsmaster/utils/render/gui/GuiOcclusion.java b/src/main/java/top/fpsmaster/utils/render/gui/GuiOcclusion.java new file mode 100644 index 00000000..be3d12f6 --- /dev/null +++ b/src/main/java/top/fpsmaster/utils/render/gui/GuiOcclusion.java @@ -0,0 +1,49 @@ +package top.fpsmaster.utils.render.gui; + +/** + * Where the topmost custom screen is painting, in device pixels. + * + *
Hit-test ordering within one screen is handled by {@code GuiInputState}'s hovered-id. This solves + * the other half: the HUD component editor runs from {@code EventRender2D}, which 1.8.9 fires while + * drawing the in-game overlay — that is, before {@code currentScreen.drawScreen()}. The editor + * therefore reacts to presses underneath a screen that has not even been painted yet, using a + * completely separate input path, with no way for the two to arbitrate. + * + *
The fix is not to disable HUD editing while a panel is open — opening the ClickGUI is precisely + * when a user wants to move HUD elements. It is to make the panel occlude the cursor: components under + * the panel rectangle stop responding, components beside it keep working. + * + *
Device pixels are used because that is the only space the HUD editor and the ClickGUI agree on; + * they scale their own coordinates by different factors. + */ +public final class GuiOcclusion { + + private static float x; + private static float y; + private static float width; + private static float height; + private static boolean active; + + private GuiOcclusion() { + } + + /** Reported once per frame by the screen that is painting, before the next frame's HUD pass. */ + public static void set(float deviceX, float deviceY, float deviceWidth, float deviceHeight) { + x = deviceX; + y = deviceY; + width = deviceWidth; + height = deviceHeight; + active = deviceWidth > 0f && deviceHeight > 0f; + } + + public static void clear() { + active = false; + } + + /** @param deviceX cursor position in device pixels, y measured from the top */ + public static boolean covers(float deviceX, float deviceY) { + return active + && deviceX >= x && deviceX <= x + width + && deviceY >= y && deviceY <= y + height; + } +} diff --git a/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java b/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java index feeaaa19..b7ca4561 100644 --- a/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java +++ b/src/main/java/top/fpsmaster/utils/render/gui/ScaledGuiScreen.java @@ -66,6 +66,10 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { activeScreen = this; GL11.glScalef(renderScale, renderScale, 1f); render(inputState.getMouseX(), inputState.getMouseY(), partialTicks); + // Reported after render() so subclasses that compute their layout there (MainPanel centres + // itself every frame) have already done so. The HUD editor reads it on the next frame, + // which is fine — it runs ahead of this one within a frame anyway. + publishOcclusion(); } finally { inputState.finishFrame(); activeScreen = null; @@ -74,6 +78,27 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { } } + /** + * The rectangle this screen paints over, in logical coordinates, or {@code null} for "the whole + * screen". Override in screens that only cover part of the display so the HUD stays interactive + * around them. + * + * @return {@code {x, y, width, height}} + */ + protected float[] getOccludingBounds() { + return null; + } + + private void publishOcclusion() { + float[] bounds = getOccludingBounds(); + if (bounds == null) { + GuiOcclusion.set(0f, 0f, mc.displayWidth, mc.displayHeight); + return; + } + GuiOcclusion.set(bounds[0] * scaleFactor, bounds[1] * scaleFactor, + bounds[2] * scaleFactor, bounds[3] * scaleFactor); + } + @Override public void onResize(Minecraft mcIn, int w, int h) { super.onResize(mcIn, w, h); @@ -88,6 +113,14 @@ public void initGui() { super.initGui(); } + @Override + public void onGuiClosed() { + // Nothing is covering the HUD any more; leaving this set would freeze the editor under a + // rectangle that is no longer painted. + GuiOcclusion.clear(); + super.onGuiClosed(); + } + @Override public void handleMouseInput() throws IOException { refreshScaleAndMetrics(); @@ -261,6 +294,48 @@ public PointerEvent consumeClickInBounds(float x, float y, float width, float he return consumePressInBounds(x, y, width, height, button); } + /** + * Claims a click only if this widget was the topmost one under the cursor. + * + *
{@link #consumePressInBounds} alone is first-come-first-served, and widgets call it right + * after painting themselves — so the first painted, i.e. bottom-most, widget wins a + * contested click. That is the inverse of what z-order requires. This variant adds the missing + * ordering: {@code markHovered} records the topmost widget as the frame is walked, and the + * previous frame's answer gates the click here. + * + *
Widgets that still call {@code consumePressInBounds} keep the old behaviour, so this can be + * adopted one widget at a time. + * + * @param id stable identity for this widget across frames — the setting/module object itself works + * well; avoid values that are rebuilt every frame + */ + public PointerEvent consumePressAsHovered(Object id, float x, float y, float width, float height, int button) { + inputState.markHovered(id, x, y, width, height); + if (!inputState.wasHovered(id)) { + return null; + } + return consumePressInBounds(x, y, width, height, button); + } + + public PointerEvent consumePressAsHovered(Object id, float x, float y, float width, float height) { + return consumePressAsHovered(id, x, y, width, height, -1); + } + + /** + * Claims a press that landed outside the given rectangle — "click away to dismiss" for popups. + * The press is consumed so it does not also reach whatever the popup was covering. + */ + public PointerEvent consumePressOutside(float x, float y, float width, float height) { + GuiInputState.MouseButtonEvent event = inputState.consumePressOutside(x, y, width, height); + return event == null ? null : new PointerEvent(event.getX(), event.getY(), event.getButton()); + } + + /** Hover feedback should follow the same z-order rule as clicks, or highlights double up. */ + public boolean isHovered(Object id, float x, float y, float width, float height) { + inputState.markHovered(id, x, y, width, height); + return inputState.wasHovered(id); + } + private int getLogicalMouseX() { return clampLogicalMouseX((int) (Mouse.getX() / scaleFactor)); }