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 @@ +