diff --git a/src/main/java/top/fpsmaster/ui/kit/EdgeCanvas.java b/src/main/java/top/fpsmaster/ui/kit/EdgeCanvas.java index 150c306c..b974a688 100644 --- a/src/main/java/top/fpsmaster/ui/kit/EdgeCanvas.java +++ b/src/main/java/top/fpsmaster/ui/kit/EdgeCanvas.java @@ -118,6 +118,18 @@ public void popClip() { applyClip(clips.peek()); } + /** + * Current composed clip, or {@code null} when scissor is off. Used by + * {@link EdgeInput} so hit-tests match what {@link #pushClip} actually draws. + */ + float[] currentClip() { + float[] clip = clips.peek(); + if (clip == null) { + return null; + } + return new float[] {clip[0], clip[1], clip[2], clip[3]}; + } + private static void applyClip(float[] clip) { GL11.glEnable(GL11.GL_SCISSOR_TEST); if (clip[2] <= 0f || clip[3] <= 0f) { diff --git a/src/main/java/top/fpsmaster/ui/kit/EdgeHost.java b/src/main/java/top/fpsmaster/ui/kit/EdgeHost.java index 481f1edd..cf1ca011 100644 --- a/src/main/java/top/fpsmaster/ui/kit/EdgeHost.java +++ b/src/main/java/top/fpsmaster/ui/kit/EdgeHost.java @@ -24,7 +24,7 @@ final class EdgeHost implements UiHost { EdgeHost(ScaledGuiScreen screen, FrameInput fallback, float width, float height) { this.screen = screen; - this.input = new EdgeInput(screen, fallback); + this.input = new EdgeInput(screen, fallback, canvas); this.width = width; this.height = height; } diff --git a/src/main/java/top/fpsmaster/ui/kit/EdgeInput.java b/src/main/java/top/fpsmaster/ui/kit/EdgeInput.java index 52e320a8..103f5685 100644 --- a/src/main/java/top/fpsmaster/ui/kit/EdgeInput.java +++ b/src/main/java/top/fpsmaster/ui/kit/EdgeInput.java @@ -1,6 +1,7 @@ package top.fpsmaster.ui.kit; import top.fpsmaster.utils.render.gui.ScaledGuiScreen; +import top.fpsmaster.utils.render.gui.Scissor; import top.fpsmaster.prism.input.FrameInput; import top.fpsmaster.prism.input.Input; import top.fpsmaster.prism.input.PointerEvent; @@ -8,10 +9,12 @@ final class EdgeInput implements Input { private final ScaledGuiScreen screen; private final FrameInput fallback; + private final EdgeCanvas canvas; - EdgeInput(ScaledGuiScreen screen, FrameInput fallback) { + EdgeInput(ScaledGuiScreen screen, FrameInput fallback, EdgeCanvas canvas) { this.screen = screen; this.fallback = fallback; + this.canvas = canvas; } public int mouseX() { @@ -27,10 +30,14 @@ public boolean isButtonDown(int button) { } public PointerEvent consumePressInBounds(float x, float y, float w, float h, int button) { + float[] hit = clippedHit(x, y, w, h); + if (!Scissor.hasArea(hit)) { + return null; + } if (screen == null) { - return fallback.consumePressInBounds(x, y, w, h, button); + return fallback.consumePressInBounds(hit[0], hit[1], hit[2], hit[3], button); } - ScaledGuiScreen.PointerEvent event = screen.consumePressInBounds(x, y, w, h, button); + ScaledGuiScreen.PointerEvent event = screen.consumePressInBounds(hit[0], hit[1], hit[2], hit[3], button); return event == null ? null : new PointerEvent(event.x, event.y, event.button); } @@ -54,14 +61,24 @@ public boolean hasPressOutside(float x, float y, float w, float h) { } public int consumeWheelDelta(float x, float y, float w, float h) { - return screen != null ? screen.consumeWheelDelta(x, y, w, h) : fallback.consumeWheelDelta(x, y, w, h); + float[] hit = clippedHit(x, y, w, h); + if (!Scissor.hasArea(hit)) { + return 0; + } + return screen != null + ? screen.consumeWheelDelta(hit[0], hit[1], hit[2], hit[3]) + : fallback.consumeWheelDelta(hit[0], hit[1], hit[2], hit[3]); } public void markHovered(Object id, float x, float y, float w, float h) { + float[] hit = clippedHit(x, y, w, h); + if (!Scissor.hasArea(hit)) { + return; + } if (screen != null) { - screen.isHovered(id, x, y, w, h); + screen.isHovered(id, hit[0], hit[1], hit[2], hit[3]); } else { - fallback.markHovered(id, x, y, w, h); + fallback.markHovered(id, hit[0], hit[1], hit[2], hit[3]); } } @@ -70,8 +87,13 @@ public boolean wasHovered(Object id) { } public boolean beginDrag(Object owner, int button, float x, float y, float w, float h) { - return screen != null ? screen.beginDrag(owner, button, x, y, w, h) - : fallback.beginDrag(owner, button, x, y, w, h); + float[] hit = clippedHit(x, y, w, h); + if (!Scissor.hasArea(hit)) { + return screen != null ? screen.isDragging(owner) : fallback.isDragging(owner); + } + return screen != null + ? screen.beginDrag(owner, button, hit[0], hit[1], hit[2], hit[3]) + : fallback.beginDrag(owner, button, hit[0], hit[1], hit[2], hit[3]); } public boolean isDragging(Object owner) { @@ -109,4 +131,9 @@ public String clipboard() { public void setClipboard(String text) { fallback.setClipboard(text); } + + /** Intersect the widget with the active canvas clip so off-screen rows cannot steal clicks. */ + private float[] clippedHit(float x, float y, float w, float h) { + return Scissor.constrainHit(canvas.currentClip(), x, y, w, h); + } } diff --git a/src/main/java/top/fpsmaster/utils/render/gui/Scissor.java b/src/main/java/top/fpsmaster/utils/render/gui/Scissor.java index 0bf632a5..e93e75d2 100644 --- a/src/main/java/top/fpsmaster/utils/render/gui/Scissor.java +++ b/src/main/java/top/fpsmaster/utils/render/gui/Scissor.java @@ -28,6 +28,28 @@ public static float[] intersect( }; } + /** + * Shrink a widget hit box to the current clip. {@code clip == null} keeps the + * original rectangle (no scissor is active). Empty width/height means the + * widget is fully outside the clip and must not consume the pointer. + */ + public static float[] constrainHit(float[] clip, float x, float y, float w, float h) { + if (clip == null) { + return new float[] {x, y, w, h}; + } + return intersect(clip[0], clip[1], clip[2], clip[3], x, y, w, h); + } + + public static boolean hasArea(float[] rect) { + return rect != null && rect[2] > 0f && rect[3] > 0f; + } + + public static boolean contains(float[] rect, float px, float py) { + return hasArea(rect) + && px >= rect[0] && px <= rect[0] + rect[2] + && py >= rect[1] && py <= rect[1] + rect[3]; + } + public static void apply(float x, float y, float width, float height) { float scale = UiScale.isActive() ? UiScale.getLayoutScale() : 1.0f; applyScaled(x, y, width, height, scale); diff --git a/src/test/java/top/fpsmaster/utils/render/gui/ScissorIntersectTest.java b/src/test/java/top/fpsmaster/utils/render/gui/ScissorIntersectTest.java index 94204970..de967059 100644 --- a/src/test/java/top/fpsmaster/utils/render/gui/ScissorIntersectTest.java +++ b/src/test/java/top/fpsmaster/utils/render/gui/ScissorIntersectTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class ScissorIntersectTest { @@ -40,4 +42,34 @@ void negativeExtentsDoNotExpandTheOtherRect() { float[] clipped = Scissor.intersect(0f, 0f, 20f, 20f, 4f, 4f, -8f, 10f); assertEquals(0f, clipped[2], 0.01f); } + + @Test + void constrainHitDropsRowsBelowTheComposedClip() { + float[] list = {140f, 40f, 330f, 270f}; + float[] settings = {146f, 70f, 318f, 950f}; + float[] clip = Scissor.intersect( + list[0], list[1], list[2], list[3], + settings[0], settings[1], settings[2], settings[3]); + float[] hiddenRow = Scissor.constrainHit(clip, 146f, 400f, 318f, 18f); + assertFalse(Scissor.hasArea(hiddenRow)); + assertFalse(Scissor.contains(clip, 200f, 400f)); + } + + @Test + void constrainHitKeepsTheVisibleOverlap() { + float[] clip = {140f, 40f, 330f, 270f}; + float[] visible = Scissor.constrainHit(clip, 146f, 280f, 318f, 40f); + assertEquals(146f, visible[0], 0.01f); + assertEquals(280f, visible[1], 0.01f); + assertEquals(318f, visible[2], 0.01f); + assertEquals(30f, visible[3], 0.01f); + assertTrue(Scissor.contains(visible, 200f, 300f)); + assertFalse(Scissor.contains(visible, 200f, 320f)); + } + + @Test + void constrainHitWithoutClipLeavesTheWidgetAlone() { + assertArrayEquals(new float[] {8f, 12f, 40f, 16f}, + Scissor.constrainHit(null, 8f, 12f, 40f, 16f), 0.01f); + } }