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
@@ -1,206 +1,53 @@
package org.saturnclient.ui.components.inputs;
import java.awt.Color;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.ColorProperty;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.Popup;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.elements.Field;
/**
* Shows a color as a swatch and its hex code, and opens a {@link ColorPicker}
* beside it when clicked.
*/
public class ColorInput extends Element {
private static final int SWATCH = 20;
private static final int PICKER_GAP = 8;
public ColorProperty prop;
// Picker state
private boolean open = false;
private float hue = 0f;
private float opacity = 1f;
private int selectedX = 0;
private int selectedY = 0;
private boolean draggingOpacity = false;
// Dimensions
private static final int PICKER_WIDTH = 100;
private static final int PICKER_HEIGHT = 100;
private static final int SLIDER_WIDTH = 100;
private static final int SLIDER_HEIGHT = 10;
private static final int HUE_SLIDER_HEIGHT = 10;
// The picker opens to the left, since the input sits at the right of its row.
private static final int PICKER_X = -PICKER_WIDTH - 12;
private static final int SWATCH = 20;
// Bounds of the picker's backdrop, relative to the color square
private static final int BACKDROP_PADDING = 8;
private static final int BACKDROP_TOP = -SLIDER_HEIGHT - 10 - BACKDROP_PADDING;
private static final int BACKDROP_BOTTOM = PICKER_HEIGHT + 10 + HUE_SLIDER_HEIGHT + BACKDROP_PADDING;
/** Where this was last drawn on the screen, to open the picker beside it */
private int originX, originY;
public ColorInput(ColorProperty prop) {
this.prop = prop;
this.width = 116;
this.height = Field.HEIGHT;
syncFromColor();
}
@Override
public void click(int mouseX, int mouseY) {
open = !open;
}
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
if (!open) {
return;
}
boolean onInput = mouseX >= 0 && mouseX <= width && mouseY >= 0 && mouseY <= height;
if (!onInput && !isOverPicker(mouseX - PICKER_X, mouseY)) {
open = false;
return;
}
handlePickerClick(mouseX - PICKER_X, mouseY);
}
@Override
public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
if (open) {
handlePickerDrag(mouseX - PICKER_X, mouseY);
}
}
@Override
public void mouseReleased(double mouseX, double mouseY, int button) {
draggingOpacity = false;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
Field.drawBox(renderScope, width, height, ctx.isHovering(), open);
originX = ctx.originX;
originY = ctx.originY;
int swatchY = (height - SWATCH) / 2;
renderScope.drawRoundedRectangle(swatchY, swatchY, SWATCH, SWATCH, 5, 1, prop.value,
Field.drawBox(renderScope, width, height, ctx.isHovering(), Popup.isOpenFor(this));
int swatchInset = (height - SWATCH) / 2;
renderScope.drawRoundedRectangle(swatchInset, swatchInset, SWATCH, SWATCH, 5, 1, prop.value,
Theme.withAlpha(0.3f, Theme.FOREGROUND.value));
renderScope.drawText(Field.TEXT_SCALE, toHex(prop.value), swatchY + SWATCH + 8,
renderScope.drawText(Field.TEXT_SCALE, toHex(prop.value), swatchInset + SWATCH + 8,
Field.textY(height, Field.TEXT_SCALE), Theme.FONT.value, Theme.FOREGROUND.value);
if (!open)
return;
int pickerX = PICKER_X;
int pickerY = 0;
// Backdrop behind the picker and its two sliders
renderScope.drawRoundedRectangle(pickerX - BACKDROP_PADDING, BACKDROP_TOP,
PICKER_WIDTH + BACKDROP_PADDING * 2, BACKDROP_BOTTOM - BACKDROP_TOP,
Theme.WIDGET_RADIUS.value, 1, Theme.withAlpha(0.95f, Theme.BACKGROUND.value), Theme.getBorder());
renderColorSquare(renderScope, pickerX, pickerY);
renderOpacitySlider(renderScope, pickerX, pickerY - SLIDER_HEIGHT - 10);
renderHueSlider(renderScope, pickerX, pickerY + PICKER_HEIGHT + 10);
}
private void renderColorSquare(RenderScope renderScope, int xStart, int yStart) {
for (int x = 0; x < PICKER_WIDTH; x++) {
for (int y = 0; y < PICKER_HEIGHT; y++) {
float saturation = x / (float) PICKER_WIDTH;
float value = 1f - y / (float) PICKER_HEIGHT;
int color = Color.HSBtoRGB(hue, saturation, value);
int alpha = (int) (opacity * 255) & 0xFF;
color = (alpha << 24) | (color & 0xFFFFFF);
renderScope.fill(xStart + x, yStart + y, xStart + x + 1, yStart + y + 1, color);
}
}
// Draw selection square
int selX = xStart + selectedX - 4;
int selY = yStart + selectedY - 4;
renderScope.fill(selX, selY, selX + 8, selY + 8, 0xFF000000 | prop.value);
renderScope.drawBorder(selX, selY, 8, 8, 0xFFFFFFFF);
}
private void renderOpacitySlider(RenderScope renderScope, int xStart, int yStart) {
for (int x = 0; x < SLIDER_WIDTH; x++) {
float localOpacity = x / (float) SLIDER_WIDTH;
int alpha = (int) (localOpacity * 255) & 0xFF;
int color = (alpha << 24) | (prop.value & 0xFFFFFF);
renderScope.fill(xStart + x, yStart, xStart + x + 1, yStart + SLIDER_HEIGHT, color);
}
int knobX = xStart + (int) (opacity * SLIDER_WIDTH);
renderScope.fill(knobX - 1, yStart - 2, knobX + 1, yStart + SLIDER_HEIGHT + 2, 0xFF000000);
}
private void renderHueSlider(RenderScope renderScope, int xStart, int yStart) {
for (int x = 0; x < SLIDER_WIDTH; x++) {
float localHue = x / (float) SLIDER_WIDTH;
int color = Color.HSBtoRGB(localHue, 1f, 1f);
renderScope.fill(xStart + x, yStart, xStart + x + 1, yStart + HUE_SLIDER_HEIGHT, 0xFF000000 | color);
}
int knobX = xStart + (int) (hue * SLIDER_WIDTH);
renderScope.fill(knobX - 1, yStart - 2, knobX + 1, yStart + HUE_SLIDER_HEIGHT + 2, 0xFF000000);
}
private void handlePickerClick(double mouseX, double mouseY) {
// Color square
if (mouseX >= 0 && mouseX < PICKER_WIDTH && mouseY >= 0 && mouseY < PICKER_HEIGHT) {
selectedX = (int) mouseX;
selectedY = (int) mouseY;
updateColorFromSelection();
}
// Opacity slider
int opY = -SLIDER_HEIGHT - 10;
if (mouseX >= 0 && mouseX < SLIDER_WIDTH && mouseY >= opY && mouseY <= opY + SLIDER_HEIGHT) {
updateOpacity(mouseX);
draggingOpacity = true;
}
// Hue slider
int hueY = PICKER_HEIGHT + 10;
if (mouseX >= 0 && mouseX < SLIDER_WIDTH && mouseY >= hueY && mouseY <= hueY + HUE_SLIDER_HEIGHT) {
updateHue(mouseX);
}
}
private static boolean isOverPicker(double x, double y) {
return x >= -BACKDROP_PADDING && x <= PICKER_WIDTH + BACKDROP_PADDING
&& y >= BACKDROP_TOP && y <= BACKDROP_BOTTOM;
}
private void handlePickerDrag(double mouseX, double mouseY) {
// Opacity
if (draggingOpacity)
updateOpacity(mouseX);
// Color square
if (mouseX >= 0 && mouseX < PICKER_WIDTH && mouseY >= 0 && mouseY < PICKER_HEIGHT) {
selectedX = (int) mouseX;
selectedY = (int) mouseY;
updateColorFromSelection();
}
}
private void updateColorFromSelection() {
float saturation = selectedX / (float) PICKER_WIDTH;
float value = 1f - selectedY / (float) PICKER_HEIGHT;
int color = Color.HSBtoRGB(hue, saturation, value);
int alpha = (int) (opacity * 255) & 0xFF;
prop.value = (alpha << 24) | (color & 0xFFFFFF);
}
private void updateHue(double mouseX) {
hue = (float) Math.max(0, Math.min(1, mouseX / SLIDER_WIDTH));
updateColorFromSelection();
}
private void updateOpacity(double mouseX) {
opacity = (float) Math.max(0, Math.min(1, mouseX / SLIDER_WIDTH));
updateColorFromSelection();
@Override
public void click(int mouseX, int mouseY) {
// The input sits at the right of its row, so the picker opens to its left,
// centered on it. While it's open, a click here closes it instead.
ColorPicker picker = new ColorPicker(prop);
Popup.open(this, picker, originX - PICKER_GAP - picker.width, originY + (height - picker.height) / 2);
}
/**
@@ -212,16 +59,4 @@ public class ColorInput extends Element {
? String.format("#%06X", color & 0xFFFFFF)
: String.format("#%08X", color);
}
private void syncFromColor() {
int rgb = prop.value & 0xFFFFFF;
float[] hsb = Color.RGBtoHSB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, null);
hue = hsb[0];
selectedX = (int) (hsb[1] * PICKER_WIDTH);
selectedY = (int) ((1f - hsb[2]) * PICKER_HEIGHT);
int alpha = (prop.value >> 24) & 0xFF;
if (alpha == 0)
alpha = 255;
opacity = alpha / 255f;
}
}
}
@@ -0,0 +1,164 @@
package org.saturnclient.ui.components.inputs;
import java.awt.Color;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.ColorProperty;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
/**
* A color picker shown as a popup: opacity across the top, a
* saturation/brightness square, and hue across the bottom. Each part can be
* clicked or dragged.
*/
public class ColorPicker extends Element {
private enum Part {
OPACITY, SQUARE, HUE
}
private static final int PADDING = 10;
private static final int SIZE = 100;
private static final int SLIDER_HEIGHT = 10;
private static final int GAP = 10;
private static final int OPACITY_Y = PADDING;
private static final int SQUARE_Y = OPACITY_Y + SLIDER_HEIGHT + GAP;
private static final int HUE_Y = SQUARE_Y + SIZE + GAP;
private final ColorProperty prop;
private float hue;
private float saturation;
private float brightness;
private float opacity;
/** The part being dragged, or null */
private Part dragging = null;
public ColorPicker(ColorProperty prop) {
this.prop = prop;
this.width = SIZE + PADDING * 2;
this.height = HUE_Y + SLIDER_HEIGHT + PADDING;
syncFromColor();
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.97f, Theme.BACKGROUND.value), Theme.withAlpha(0.14f, Theme.FOREGROUND.value));
renderOpacity(renderScope);
renderSquare(renderScope);
renderHue(renderScope);
}
private void renderOpacity(RenderScope renderScope) {
int rgb = prop.value & 0xFFFFFF;
for (int x = 0; x < SIZE; x++) {
int alpha = (int) (x / (float) SIZE * 255) & 0xFF;
renderScope.fill(PADDING + x, OPACITY_Y, PADDING + x + 1, OPACITY_Y + SLIDER_HEIGHT, (alpha << 24) | rgb);
}
drawMarker(renderScope, PADDING + (int) (opacity * SIZE), OPACITY_Y);
}
private void renderSquare(RenderScope renderScope) {
for (int x = 0; x < SIZE; x++) {
for (int y = 0; y < SIZE; y++) {
int color = Color.HSBtoRGB(hue, x / (float) SIZE, 1f - y / (float) SIZE);
renderScope.fill(PADDING + x, SQUARE_Y + y, PADDING + x + 1, SQUARE_Y + y + 1, 0xFF000000 | color);
}
}
int selX = PADDING + (int) (saturation * SIZE) - 4;
int selY = SQUARE_Y + (int) ((1f - brightness) * SIZE) - 4;
renderScope.fill(selX, selY, selX + 8, selY + 8, 0xFF000000 | prop.value);
renderScope.drawBorder(selX, selY, 8, 8, 0xFFFFFFFF);
}
private void renderHue(RenderScope renderScope) {
for (int x = 0; x < SIZE; x++) {
int color = Color.HSBtoRGB(x / (float) SIZE, 1f, 1f);
renderScope.fill(PADDING + x, HUE_Y, PADDING + x + 1, HUE_Y + SLIDER_HEIGHT, 0xFF000000 | color);
}
drawMarker(renderScope, PADDING + (int) (hue * SIZE), HUE_Y);
}
private static void drawMarker(RenderScope renderScope, int x, int sliderY) {
renderScope.fill(x - 2, sliderY - 2, x + 2, sliderY + SLIDER_HEIGHT + 2, 0xFFFFFFFF);
renderScope.fill(x - 1, sliderY - 1, x + 1, sliderY + SLIDER_HEIGHT + 1, 0xFF000000);
}
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
dragging = getPartAt(mouseX, mouseY);
update(mouseX, mouseY);
}
@Override
public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
update(mouseX, mouseY);
}
@Override
public void mouseReleased(double mouseX, double mouseY, int button) {
dragging = null;
}
private Part getPartAt(double mouseX, double mouseY) {
if (mouseX < PADDING - 4 || mouseX > PADDING + SIZE + 4) {
return null;
}
if (mouseY >= OPACITY_Y - 4 && mouseY <= OPACITY_Y + SLIDER_HEIGHT + 4) {
return Part.OPACITY;
} else if (mouseY >= SQUARE_Y && mouseY <= SQUARE_Y + SIZE) {
return Part.SQUARE;
} else if (mouseY >= HUE_Y - 4 && mouseY <= HUE_Y + SLIDER_HEIGHT + 4) {
return Part.HUE;
}
return null;
}
/**
* Moves the dragged part to the mouse, even when the mouse has left it.
*/
private void update(double mouseX, double mouseY) {
if (dragging == null) {
return;
}
float x = clamp01((float) (mouseX - PADDING) / SIZE);
switch (dragging) {
case OPACITY -> opacity = x;
case HUE -> hue = x;
case SQUARE -> {
saturation = x;
brightness = 1f - clamp01((float) (mouseY - SQUARE_Y) / SIZE);
}
}
int rgb = Color.HSBtoRGB(hue, saturation, brightness) & 0xFFFFFF;
prop.value = ((int) (opacity * 255) << 24) | rgb;
}
private void syncFromColor() {
int rgb = prop.value & 0xFFFFFF;
float[] hsb = Color.RGBtoHSB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, null);
hue = hsb[0];
saturation = hsb[1];
brightness = hsb[2];
int alpha = (prop.value >>> 24) & 0xFF;
opacity = (alpha == 0 ? 255 : alpha) / 255f;
}
private static float clamp01(float value) {
return Math.max(0f, Math.min(1f, value));
}
}
@@ -0,0 +1,113 @@
package org.saturnclient.ui.components.inputs;
import java.util.function.IntConsumer;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.elements.Field;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
/**
* A dropdown's list of options, shown as a popup. Long lists scroll, and the
* current option is ticked.
*/
public class OptionList extends Element {
private static final int ITEM_HEIGHT = 30;
private static final int PADDING = 4;
private static final int MAX_VISIBLE = 8;
private static final int CHECK = 14;
private final String[] options;
private final int selected;
private final IntConsumer onPick;
/** Index of the first visible option */
private int first;
/** Wheel movement not yet turned into whole options, so trackpads scroll smoothly */
private double pendingScroll = 0;
public OptionList(String[] options, int selected, int width, IntConsumer onPick) {
this.options = options;
this.selected = selected;
this.onPick = onPick;
this.width = width;
this.height = Math.min(options.length, MAX_VISIBLE) * ITEM_HEIGHT + PADDING * 2;
// Start with the current option in view, a few rows down if possible
this.first = clampFirst(selected - MAX_VISIBLE / 2 + 1);
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.97f, Theme.BACKGROUND.value), Theme.withAlpha(0.14f, Theme.FOREGROUND.value));
int visible = Math.min(options.length, MAX_VISIBLE);
int hovered = getIndexAt(ctx.mouseX, ctx.mouseY);
for (int row = 0; row < visible; row++) {
int index = first + row;
int y = PADDING + row * ITEM_HEIGHT;
boolean isSelected = index == selected;
if (index == hovered) {
renderScope.drawRoundedRectangle(PADDING, y, width - PADDING * 2, ITEM_HEIGHT,
Theme.WIDGET_RADIUS.value - 3, Theme.withAlpha(0.07f, Theme.FOREGROUND.value));
}
int textWidth = width - PADDING * 2 - 12 - CHECK - 10;
String text = Fonts.ellipsize(options[index], Theme.FONT.value, Field.TEXT_SCALE, textWidth);
renderScope.drawText(Field.TEXT_SCALE, text, PADDING + 10, y + Field.textY(ITEM_HEIGHT, Field.TEXT_SCALE),
Theme.FONT.value, isSelected ? Theme.ACCENT.value : Theme.FOREGROUND.value);
if (isSelected) {
renderScope.drawTexture(Textures.ICON_CHECK, width - PADDING - 10 - CHECK,
y + (ITEM_HEIGHT - CHECK) / 2, 0, 0, CHECK, CHECK, Theme.ACCENT.value);
}
}
// A thin bar showing where in a long list this is
if (options.length > MAX_VISIBLE) {
int track = height - PADDING * 2;
int thumb = Math.max(16, track * MAX_VISIBLE / options.length);
int thumbY = PADDING + (track - thumb) * first / (options.length - MAX_VISIBLE);
renderScope.drawRoundedRectangle(width - 4, thumbY, 2, thumb, 1,
Theme.withAlpha(0.3f, Theme.FOREGROUND.value));
}
}
@Override
public void click(int mouseX, int mouseY) {
int index = getIndexAt(mouseX, mouseY);
if (index >= 0) {
onPick.accept(index);
}
}
@Override
public void scroll(int mouseX, int mouseY, double horizontalAmount, double verticalAmount) {
pendingScroll -= verticalAmount;
int whole = (int) pendingScroll;
pendingScroll -= whole;
first = clampFirst(first + whole);
}
/**
* The option under the mouse, or -1.
*/
private int getIndexAt(int mouseX, int mouseY) {
if (mouseX < PADDING || mouseX > width - PADDING || mouseY < PADDING || mouseY >= height - PADDING) {
return -1;
}
int index = first + (mouseY - PADDING) / ITEM_HEIGHT;
return index < options.length ? index : -1;
}
private int clampFirst(int value) {
return Math.max(0, Math.min(value, options.length - Math.min(options.length, MAX_VISIBLE)));
}
}
@@ -4,21 +4,26 @@ import org.saturnclient.config.Theme;
import org.saturnclient.config.property.SelectProperty;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.Popup;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.elements.Field;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
/**
* Steps through a {@link SelectProperty}'s values with arrows on either side.
* Clicking the left third goes back; anywhere else goes forward.
* A dropdown for a {@link SelectProperty}: the current value, opening a list
* of every value when clicked.
*/
public class Select extends Element {
private static final int TEXT_X = 12;
private static final int ARROW = 16;
private static final int ARROW_INSET = 8;
private static final int ARROW_INSET = 10;
private final SelectProperty prop;
/** Where this was last drawn on the screen, to open the list under it */
private int originX, originY;
public Select(SelectProperty prop) {
this.prop = prop;
this.width = 200;
@@ -27,28 +32,29 @@ public class Select extends Element {
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
Field.drawBox(renderScope, width, height, ctx.isHovering(), false);
originX = ctx.originX;
originY = ctx.originY;
boolean open = Popup.isOpenFor(this);
boolean prevHovered = ctx.isHovering(0, 0, width / 3, height);
boolean nextHovered = ctx.isHovering() && !prevHovered;
int arrowY = (height - ARROW) / 2;
Field.drawBox(renderScope, width, height, ctx.isHovering(), open);
renderScope.drawTexture(Textures.ICON_CHEVRON_LEFT, ARROW_INSET, arrowY, 0, 0, ARROW, ARROW,
prevHovered ? Theme.ACCENT.value : Theme.getMutedFg());
renderScope.drawTexture(Textures.ICON_CHEVRON_RIGHT, width - ARROW_INSET - ARROW, arrowY, 0, 0, ARROW, ARROW,
nextHovered ? Theme.ACCENT.value : Theme.getMutedFg());
int textWidth = width - TEXT_X - ARROW_INSET - ARROW - 8;
String text = Fonts.ellipsize(prop.getSelection(), Theme.FONT.value, Field.TEXT_SCALE, textWidth);
renderScope.drawText(Field.TEXT_SCALE, text, TEXT_X, Field.textY(height, Field.TEXT_SCALE), Theme.FONT.value,
Theme.FOREGROUND.value);
int textSpace = width - (ARROW_INSET + ARROW + 4) * 2;
String text = Fonts.ellipsize(prop.getSelection(), Theme.FONT.value, Field.TEXT_SCALE, textSpace);
Field.drawCenteredText(renderScope, text, width, height, Theme.FONT.value, Theme.FOREGROUND.value);
renderScope.drawTexture(Textures.ICON_CHEVRON_DOWN, width - ARROW_INSET - ARROW, (height - ARROW) / 2, 0, 0,
ARROW, ARROW, open ? Theme.ACCENT.value : Theme.getMutedFg());
}
@Override
public void click(int mouseX, int mouseY) {
if (mouseX < width / 3) {
prop.prev();
} else {
prop.next();
}
// While the list is open, a click here goes to the popup, which closes it.
OptionList list = new OptionList(prop.availableValues, prop.value, width, index -> {
prop.setSelection(index);
Popup.close();
});
Popup.openBelow(this, list, originX, originY);
}
}