Add dropdowns and a popup color picker

Popup draws one element above the rest of the screen and gets mouse
events first; clicking or scrolling outside closes it. ElementContext now
carries each element's position on screen so an input inside a scroll can
open a popup beside itself.

Select is a dropdown: the current value opens an OptionList with the
current option ticked, scrolling for long lists and opening upward when
there's no room below. The color picker moved out of ColorInput into a
ColorPicker popup, so scroll areas no longer clip it and later rows no
longer draw over it, and all three of its parts can be dragged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-27 18:02:41 +02:00
co-authored by claude
parent 351507d60d
commit 6bfdfc40c9
16 changed files with 509 additions and 236 deletions
@@ -0,0 +1,140 @@
package org.saturnclient.ui;
import java.util.List;
/**
* A single element drawn above the rest of the screen, such as a dropdown's
* options or a color picker. It gets mouse events before anything else, and a
* click or scroll outside it closes it.
*
* Only one screen is open at a time, so the popup is kept here rather than on
* the screen; {@link SaturnScreen} forwards to it and clears it when it's
* rebuilt.
*/
public final class Popup {
private static final int SCREEN_MARGIN = 8;
private static final int OWNER_GAP = 4;
private static Element popup;
private static Element owner;
private static int screenWidth, screenHeight;
private Popup() {
}
/**
* Opens {@code popup} with its top-left corner at ({@code x}, {@code y}) on
* the screen, moved as needed to stay on it.
*
* @param owner the element that opened it, for {@link #isOpenFor}
*/
public static void open(Element owner, Element popup, int x, int y) {
Popup.owner = owner;
Popup.popup = popup;
popup.x = clamp(x, popup.width, screenWidth);
popup.y = clamp(y, popup.height, screenHeight);
}
/**
* Opens {@code popup} just below its owner, or above it when there isn't
* room below.
*
* @param originX the owner's top-left corner on the screen
* @param originY
*/
public static void openBelow(Element owner, Element popup, int originX, int originY) {
int y = originY + owner.height + OWNER_GAP;
if (y + popup.height > screenHeight - SCREEN_MARGIN) {
y = originY - OWNER_GAP - popup.height;
}
open(owner, popup, originX, y);
}
public static void close() {
popup = null;
owner = null;
}
public static boolean isOpenFor(Element element) {
return popup != null && owner == element;
}
// ---------------------------------------------------------------
// Called by SaturnScreen
// ---------------------------------------------------------------
static void reset(int width, int height) {
close();
screenWidth = width;
screenHeight = height;
}
static void render(RenderScope renderScope, long elapsed, int mouseX, int mouseY) {
if (popup != null) {
ElementRenderer.render(List.of(popup), elapsed, renderScope, mouseX, mouseY);
}
}
/**
* @return whether the popup took the click; a click outside it closes it
* and is taken too, so it doesn't reach what's underneath
*/
static boolean mouseClicked(double mouseX, double mouseY, int button) {
if (popup == null) {
return false;
}
if (isInside(mouseX, mouseY)) {
ElementRenderer.mouseClicked(List.of(popup), mouseX, mouseY, button);
} else {
close();
}
return true;
}
static boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
if (popup == null) {
return false;
}
ElementRenderer.mouseDragged(List.of(popup), mouseX, mouseY, button, deltaX, deltaY);
return true;
}
static void mouseReleased(double mouseX, double mouseY, int button) {
if (popup != null) {
ElementRenderer.mouseReleased(List.of(popup), mouseX, mouseY, button);
}
}
/**
* @return whether the popup took the scroll; scrolling outside it closes it,
* since it wouldn't move with what's underneath
*/
static boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
if (popup == null) {
return false;
}
if (isInside(mouseX, mouseY)) {
ElementRenderer.mouseScrolled(List.of(popup), mouseX, mouseY, horizontalAmount, verticalAmount);
} else {
close();
}
return true;
}
private static boolean isInside(double mouseX, double mouseY) {
return mouseX >= popup.x && mouseX <= popup.x + popup.width
&& mouseY >= popup.y && mouseY <= popup.y + popup.height;
}
private static int clamp(int position, int size, int screenSize) {
return Math.max(SCREEN_MARGIN, Math.min(position, screenSize - SCREEN_MARGIN - size));
}
}