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
@@ -2,13 +2,17 @@ package org.saturnclient.ui;
public class ElementContext {
public int mouseX, mouseY;
/** The element's top-left corner on the screen, for placing popups next to it */
public int originX, originY;
public int elementWidth, elementHeight;
public float elementScale;
public long elapsed;
public ElementContext(long elapsed, int mouseX, int mouseY, Element e) {
public ElementContext(long elapsed, int mouseX, int mouseY, int parentX, int parentY, Element e) {
this.mouseX = mouseX - e.x;
this.mouseY = mouseY - e.y;
this.originX = parentX + e.x;
this.originY = parentY + e.y;
this.elementWidth = e.width;
this.elementHeight = e.height;
this.elementScale = e.scale;
@@ -15,6 +15,15 @@ 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);
}
/**
* Renders elements whose parent's top-left corner is at
* ({@code parentX}, {@code parentY}) on the screen.
*/
public static void render(List<Element> elements, long elapsed, RenderScope renderScope, int mouseX, int mouseY,
int parentX, int parentY) {
for (Element element : elements) {
element.playAnimationFrame(elapsed);
@@ -22,7 +31,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, element));
element.render(renderScope, new ElementContext(elapsed, mouseX, mouseY, parentX, parentY, element));
renderScope.getMatrixStack().pop();
}
}
@@ -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));
}
}
@@ -35,6 +35,7 @@ public abstract class SaturnScreen {
elements.clear();
width = provider.getWidth();
height = provider.getHeight();
Popup.reset(width, height);
ui(); // abstraction to render the saturn ui and also render extra stuff here
}
@@ -54,6 +55,7 @@ public abstract class SaturnScreen {
renderScope.getMatrixStack().scale(0.5f, 0.5f);
ElementRenderer.render(new ArrayList<>(elements), elapsed, renderScope, mouseX, mouseY);
Popup.render(renderScope, elapsed, mouseX, mouseY);
renderScope.getMatrixStack().pop();
}
@@ -66,7 +68,9 @@ public abstract class SaturnScreen {
mouseX *= 2;
mouseY *= 2;
ElementRenderer.mouseClicked(elements, mouseX, mouseY, button);
if (!Popup.mouseClicked(mouseX, mouseY, button)) {
ElementRenderer.mouseClicked(elements, mouseX, mouseY, button);
}
return false;
}
@@ -75,7 +79,9 @@ public abstract class SaturnScreen {
mouseX *= 2;
mouseY *= 2;
ElementRenderer.mouseDragged(elements, mouseX, mouseY, button, deltaX, deltaY);
if (!Popup.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) {
ElementRenderer.mouseDragged(elements, mouseX, mouseY, button, deltaX, deltaY);
}
return false;
}
@@ -84,6 +90,8 @@ public abstract class SaturnScreen {
mouseX *= 2;
mouseY *= 2;
// Everything hears the release, so no element is left thinking it's being dragged.
Popup.mouseReleased(mouseX, mouseY, button);
ElementRenderer.mouseReleased(elements, mouseX, mouseY, button);
return false;
@@ -97,7 +105,9 @@ public abstract class SaturnScreen {
mouseX *= 2;
mouseY *= 2;
ElementRenderer.mouseScrolled(elements, mouseX, mouseY, horizontalAmount, verticalAmount);
if (!Popup.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)) {
ElementRenderer.mouseScrolled(elements, mouseX, mouseY, horizontalAmount, verticalAmount);
}
return false;
}
@@ -1,7 +1,5 @@
package org.saturnclient.ui.components;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.saturnclient.config.Theme;
@@ -35,7 +33,6 @@ public final class ColorGrid {
*/
public static int draw(Scroll scroll, Map<String, Property> properties, int width, int y) {
int tileWidth = (width - GAP * (COLUMNS - 1)) / COLUMNS;
List<Element> inputs = new ArrayList<>();
int i = 0;
for (Map.Entry<String, Property> entry : properties.entrySet()) {
@@ -48,13 +45,10 @@ public final class ColorGrid {
ColorInput input = new ColorInput(color);
scroll.draw(new Tile(entry.getKey(), tileWidth, input.width).position(x, tileY));
inputs.add(input.position(x + tileWidth - INSET - input.width, tileY + (TILE_HEIGHT - input.height) / 2));
scroll.draw(input.position(x + tileWidth - INSET - input.width, tileY + (TILE_HEIGHT - input.height) / 2));
i++;
}
// Inputs go on after every tile, so an open color picker covers the tiles below it.
inputs.forEach(scroll::draw);
int rows = (i + COLUMNS - 1) / COLUMNS;
return y + rows * TILE_HEIGHT + Math.max(0, rows - 1) * GAP;
}
@@ -100,7 +100,7 @@ public class CosmeticTile extends Element {
renderScope.drawRoundedRectangle(x, y, badgeWidth, BADGE_HEIGHT, 6, background);
if (check) {
renderScope.drawTexture(Textures.CHECK, x + 7, y + (BADGE_HEIGHT - icon) / 2, 0, 0, icon, icon, foreground);
renderScope.drawTexture(Textures.ICON_CHECK, x + 7, y + (BADGE_HEIGHT - icon) / 2, 0, 0, icon, icon, foreground);
}
int textY = y + (BADGE_HEIGHT - (int) (Fonts.getHeight() * BADGE_SCALE)) / 2;
@@ -1,7 +1,5 @@
package org.saturnclient.ui.components;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -49,15 +47,6 @@ public final class PropertyList {
* @return the y below the last row
*/
public static int draw(Scroll scroll, Map<String, Property> properties, Set<String> skip, int width, int y) {
// Controls go on after every row, so an open color picker covers the rows below it.
List<Element> controls = new ArrayList<>();
y = drawRows(scroll, properties, skip, width, y, controls);
controls.forEach(scroll::draw);
return y;
}
private static int drawRows(Scroll scroll, Map<String, Property> properties, Set<String> skip, int width, int y,
List<Element> controls) {
for (Map.Entry<String, Property> entry : properties.entrySet()) {
String name = entry.getKey();
Property prop = entry.getValue();
@@ -68,7 +57,7 @@ public final class PropertyList {
if (prop instanceof NamespaceProperty namespace) {
y = drawSection(scroll, name, y + SECTION_GAP) + 4;
y = drawRows(scroll, namespace.value, Set.of(), width, y, controls);
y = draw(scroll, namespace.value, Set.of(), width, y);
continue;
}
@@ -78,7 +67,7 @@ public final class PropertyList {
}
scroll.draw(new Row(name, prop.description, width, control.width).position(0, y));
controls.add(control.position(width - control.width, y + (ROW_HEIGHT - control.height) / 2));
scroll.draw(control.position(width - control.width, y + (ROW_HEIGHT - control.height) / 2));
y += ROW_HEIGHT;
}
@@ -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);
}
}
@@ -41,7 +41,9 @@ public class AnimationStagger extends Element {
public void render(RenderScope renderScope, ElementContext ctx) {
ElementRenderer.render(children, ctx.elapsed, renderScope,
ctx.mouseX,
ctx.mouseY);
ctx.mouseY,
ctx.originX,
ctx.originY);
}
@Override
@@ -80,7 +80,9 @@ public class Scroll extends Element {
ElementRenderer.render(children, ctx.elapsed, renderScope,
ctx.mouseX - padding,
ctx.mouseY - padding + offset);
ctx.mouseY - padding + offset,
ctx.originX + padding,
ctx.originY + padding - offset);
renderScope.getMatrixStack().pop();
renderScope.disableScissor();
@@ -41,6 +41,9 @@ public class Textures {
.ofSaturn("textures/gui/components/chevron_left.svg");
public static final IdentifierRef ICON_CHEVRON_RIGHT = IdentifierRef
.ofSaturn("textures/gui/components/chevron_right.svg");
public static final IdentifierRef ICON_CHEVRON_DOWN = IdentifierRef
.ofSaturn("textures/gui/components/chevron_down.svg");
public static final IdentifierRef ICON_CHECK = IdentifierRef.ofSaturn("textures/gui/components/check.svg");
public static final IdentifierRef LOGO = IdentifierRef.ofSaturn("textures/logo/logo.png");
public static final IdentifierRef REALISTIC_LOGO = IdentifierRef.ofSaturn("textures/logo/realistic.png");
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>

After

Width:  |  Height:  |  Size: 210 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 207 B