From 6bfdfc40c93733fa36728eec080d602bfcd4c954 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 18:02:41 +0200 Subject: [PATCH] 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 --- .../org/saturnclient/ui/ElementContext.java | 6 +- .../org/saturnclient/ui/ElementRenderer.java | 11 +- src/main/java/org/saturnclient/ui/Popup.java | 140 ++++++++++++ .../org/saturnclient/ui/SaturnScreen.java | 16 +- .../saturnclient/ui/components/ColorGrid.java | 8 +- .../ui/components/CosmeticTile.java | 2 +- .../ui/components/PropertyList.java | 15 +- .../ui/components/inputs/ColorInput.java | 213 ++---------------- .../ui/components/inputs/ColorPicker.java | 164 ++++++++++++++ .../ui/components/inputs/OptionList.java | 113 ++++++++++ .../ui/components/inputs/Select.java | 44 ++-- .../ui/elements/AnimationStagger.java | 4 +- .../org/saturnclient/ui/elements/Scroll.java | 4 +- .../saturnclient/ui/resources/Textures.java | 3 + .../textures/gui/components/check.svg | 1 + .../textures/gui/components/chevron_down.svg | 1 + 16 files changed, 509 insertions(+), 236 deletions(-) create mode 100644 src/main/java/org/saturnclient/ui/Popup.java create mode 100644 src/main/java/org/saturnclient/ui/components/inputs/ColorPicker.java create mode 100644 src/main/java/org/saturnclient/ui/components/inputs/OptionList.java create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/check.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/chevron_down.svg diff --git a/src/main/java/org/saturnclient/ui/ElementContext.java b/src/main/java/org/saturnclient/ui/ElementContext.java index 2077161..c9c3638 100644 --- a/src/main/java/org/saturnclient/ui/ElementContext.java +++ b/src/main/java/org/saturnclient/ui/ElementContext.java @@ -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; diff --git a/src/main/java/org/saturnclient/ui/ElementRenderer.java b/src/main/java/org/saturnclient/ui/ElementRenderer.java index 7744ef6..4dade83 100644 --- a/src/main/java/org/saturnclient/ui/ElementRenderer.java +++ b/src/main/java/org/saturnclient/ui/ElementRenderer.java @@ -15,6 +15,15 @@ public class ElementRenderer { } public static void render(List 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 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(); } } diff --git a/src/main/java/org/saturnclient/ui/Popup.java b/src/main/java/org/saturnclient/ui/Popup.java new file mode 100644 index 0000000..14859ea --- /dev/null +++ b/src/main/java/org/saturnclient/ui/Popup.java @@ -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)); + } +} diff --git a/src/main/java/org/saturnclient/ui/SaturnScreen.java b/src/main/java/org/saturnclient/ui/SaturnScreen.java index 4f99701..47aa2e7 100644 --- a/src/main/java/org/saturnclient/ui/SaturnScreen.java +++ b/src/main/java/org/saturnclient/ui/SaturnScreen.java @@ -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; } diff --git a/src/main/java/org/saturnclient/ui/components/ColorGrid.java b/src/main/java/org/saturnclient/ui/components/ColorGrid.java index 3f785fc..260eb8d 100644 --- a/src/main/java/org/saturnclient/ui/components/ColorGrid.java +++ b/src/main/java/org/saturnclient/ui/components/ColorGrid.java @@ -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 properties, int width, int y) { int tileWidth = (width - GAP * (COLUMNS - 1)) / COLUMNS; - List inputs = new ArrayList<>(); int i = 0; for (Map.Entry 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; } diff --git a/src/main/java/org/saturnclient/ui/components/CosmeticTile.java b/src/main/java/org/saturnclient/ui/components/CosmeticTile.java index 2d25666..f0cbfe5 100644 --- a/src/main/java/org/saturnclient/ui/components/CosmeticTile.java +++ b/src/main/java/org/saturnclient/ui/components/CosmeticTile.java @@ -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; diff --git a/src/main/java/org/saturnclient/ui/components/PropertyList.java b/src/main/java/org/saturnclient/ui/components/PropertyList.java index de0a1ba..8012304 100644 --- a/src/main/java/org/saturnclient/ui/components/PropertyList.java +++ b/src/main/java/org/saturnclient/ui/components/PropertyList.java @@ -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 properties, Set skip, int width, int y) { - // Controls go on after every row, so an open color picker covers the rows below it. - List controls = new ArrayList<>(); - y = drawRows(scroll, properties, skip, width, y, controls); - controls.forEach(scroll::draw); - return y; - } - - private static int drawRows(Scroll scroll, Map properties, Set skip, int width, int y, - List controls) { for (Map.Entry 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; } diff --git a/src/main/java/org/saturnclient/ui/components/inputs/ColorInput.java b/src/main/java/org/saturnclient/ui/components/inputs/ColorInput.java index f8dc4cc..c050de0 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/ColorInput.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/ColorInput.java @@ -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; - } -} \ No newline at end of file +} diff --git a/src/main/java/org/saturnclient/ui/components/inputs/ColorPicker.java b/src/main/java/org/saturnclient/ui/components/inputs/ColorPicker.java new file mode 100644 index 0000000..8c7f701 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/inputs/ColorPicker.java @@ -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)); + } +} diff --git a/src/main/java/org/saturnclient/ui/components/inputs/OptionList.java b/src/main/java/org/saturnclient/ui/components/inputs/OptionList.java new file mode 100644 index 0000000..7c509fc --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/inputs/OptionList.java @@ -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))); + } +} diff --git a/src/main/java/org/saturnclient/ui/components/inputs/Select.java b/src/main/java/org/saturnclient/ui/components/inputs/Select.java index d6faa39..fd67806 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/Select.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/Select.java @@ -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); } } diff --git a/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java b/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java index 0a8f123..edbd87f 100644 --- a/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java +++ b/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java @@ -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 diff --git a/src/main/java/org/saturnclient/ui/elements/Scroll.java b/src/main/java/org/saturnclient/ui/elements/Scroll.java index a9ddef1..d7d6940 100644 --- a/src/main/java/org/saturnclient/ui/elements/Scroll.java +++ b/src/main/java/org/saturnclient/ui/elements/Scroll.java @@ -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(); diff --git a/src/main/java/org/saturnclient/ui/resources/Textures.java b/src/main/java/org/saturnclient/ui/resources/Textures.java index 04878c8..8372396 100644 --- a/src/main/java/org/saturnclient/ui/resources/Textures.java +++ b/src/main/java/org/saturnclient/ui/resources/Textures.java @@ -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"); diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/check.svg b/src/main/resources/assets/saturnclient/textures/gui/components/check.svg new file mode 100644 index 0000000..6bb8a38 --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/check.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/chevron_down.svg b/src/main/resources/assets/saturnclient/textures/gui/components/chevron_down.svg new file mode 100644 index 0000000..7ac2e2a --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/chevron_down.svg @@ -0,0 +1 @@ +