Open dropdowns within the visible part of their list

ElementContext carries the part of the screen where an element can be
seen, passed down in a Frame and narrowed by each Scroll to its own box.
A dropdown opens below only if its list fits in that part, otherwise above
if it fits there, or else on the side with more room, so one at the
bottom of a scrolled list no longer opens past the list's edge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-27 18:05:26 +02:00
co-authored by claude
parent 6bfdfc40c9
commit 8b58fe919d
6 changed files with 58 additions and 27 deletions
@@ -1,24 +1,54 @@
package org.saturnclient.ui;
public class ElementContext {
/**
* Where a container's children are drawn: the screen position of their
* (0, 0), and the part of the screen, top to bottom, where they can be seen.
*/
public record Frame(int x, int y, int visibleTop, int visibleBottom) {
/** The whole screen, for top-level elements */
public static final Frame SCREEN = new Frame(0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE);
}
public int mouseX, mouseY;
/** The element's top-left corner on the screen, for placing popups next to it */
public int originX, originY;
/** The part of the screen, top to bottom, where the element can be seen */
public int visibleTop, visibleBottom;
public int elementWidth, elementHeight;
public float elementScale;
public long elapsed;
public ElementContext(long elapsed, int mouseX, int mouseY, int parentX, int parentY, Element e) {
public ElementContext(long elapsed, int mouseX, int mouseY, Frame frame, Element e) {
this.mouseX = mouseX - e.x;
this.mouseY = mouseY - e.y;
this.originX = parentX + e.x;
this.originY = parentY + e.y;
this.originX = frame.x() + e.x;
this.originY = frame.y() + e.y;
this.visibleTop = frame.visibleTop();
this.visibleBottom = frame.visibleBottom();
this.elementWidth = e.width;
this.elementHeight = e.height;
this.elementScale = e.scale;
this.elapsed = elapsed;
}
/**
* The frame for this element's children, drawn {@code dx}, {@code dy} from
* its corner and seen only between {@code clipTop} and {@code clipBottom}
* (relative to its corner).
*/
public Frame childFrame(int dx, int dy, int clipTop, int clipBottom) {
return new Frame(originX + dx, originY + dy,
Math.max(visibleTop, originY + clipTop), Math.min(visibleBottom, originY + clipBottom));
}
/**
* The frame for children drawn at this element's corner and not clipped.
*/
public Frame childFrame() {
return new Frame(originX, originY, visibleTop, visibleBottom);
}
public boolean isHovering() {
return Utils.isHovering(mouseX, mouseY, elementWidth, elementHeight, elementScale);
}
@@ -15,15 +15,14 @@ public class ElementRenderer {
}
public static void render(List<Element> elements, long elapsed, RenderScope renderScope, int mouseX, int mouseY) {
render(elements, elapsed, renderScope, mouseX, mouseY, 0, 0);
render(elements, elapsed, renderScope, mouseX, mouseY, ElementContext.Frame.SCREEN);
}
/**
* Renders elements whose parent's top-left corner is at
* ({@code parentX}, {@code parentY}) on the screen.
* Renders a container's children within its {@code frame}.
*/
public static void render(List<Element> elements, long elapsed, RenderScope renderScope, int mouseX, int mouseY,
int parentX, int parentY) {
ElementContext.Frame frame) {
for (Element element : elements) {
element.playAnimationFrame(elapsed);
@@ -31,7 +30,7 @@ public class ElementRenderer {
renderScope.setOpacity(element.opacity);
renderScope.getMatrixStack().translate(element.x, element.y);
renderScope.getMatrixStack().scale(element.scale, element.scale);
element.render(renderScope, new ElementContext(elapsed, mouseX, mouseY, parentX, parentY, element));
element.render(renderScope, new ElementContext(elapsed, mouseX, mouseY, frame, element));
renderScope.getMatrixStack().pop();
}
}
+15 -10
View File
@@ -37,20 +37,25 @@ public final class Popup {
}
/**
* Opens {@code popup} just below its owner, or above it when there isn't
* room below.
* Opens {@code popup} just below its owner if it fits in the part of the
* screen where the owner can be seen, such as a scroll's box; otherwise
* above it if it fits there, or else on the side with more room.
*
* @param originX the owner's top-left corner on the screen
* @param originY
* @param ownerContext the owner's context from its last render
*/
public static void openBelow(Element owner, Element popup, int originX, int originY) {
int y = originY + owner.height + OWNER_GAP;
public static void openBelow(Element owner, Element popup, ElementContext ownerContext) {
int top = Math.max(ownerContext.visibleTop, SCREEN_MARGIN);
int bottom = Math.min(ownerContext.visibleBottom, screenHeight - SCREEN_MARGIN);
if (y + popup.height > screenHeight - SCREEN_MARGIN) {
y = originY - OWNER_GAP - popup.height;
}
int below = ownerContext.originY + owner.height + OWNER_GAP;
int above = ownerContext.originY - OWNER_GAP - popup.height;
open(owner, popup, originX, y);
boolean fitsBelow = below + popup.height <= bottom;
boolean fitsAbove = above >= top;
boolean moreRoomBelow = bottom - below >= ownerContext.originY - OWNER_GAP - top;
int y = fitsBelow || (!fitsAbove && moreRoomBelow) ? below : above;
open(owner, popup, ownerContext.originX, y);
}
public static void close() {
@@ -21,8 +21,8 @@ public class Select extends Element {
private final SelectProperty prop;
/** Where this was last drawn on the screen, to open the list under it */
private int originX, originY;
/** The context this was last drawn with, to open the list next to it */
private ElementContext lastContext;
public Select(SelectProperty prop) {
this.prop = prop;
@@ -32,8 +32,7 @@ public class Select extends Element {
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
originX = ctx.originX;
originY = ctx.originY;
lastContext = ctx;
boolean open = Popup.isOpenFor(this);
Field.drawBox(renderScope, width, height, ctx.isHovering(), open);
@@ -55,6 +54,6 @@ public class Select extends Element {
Popup.close();
});
Popup.openBelow(this, list, originX, originY);
Popup.openBelow(this, list, lastContext);
}
}
@@ -42,8 +42,7 @@ public class AnimationStagger extends Element {
ElementRenderer.render(children, ctx.elapsed, renderScope,
ctx.mouseX,
ctx.mouseY,
ctx.originX,
ctx.originY);
ctx.childFrame());
}
@Override
@@ -81,8 +81,7 @@ public class Scroll extends Element {
ElementRenderer.render(children, ctx.elapsed, renderScope,
ctx.mouseX - padding,
ctx.mouseY - padding + offset,
ctx.originX + padding,
ctx.originY + padding - offset);
ctx.childFrame(padding, padding - offset, padding, height - padding));
renderScope.getMatrixStack().pop();
renderScope.disableScissor();