From 21837b2865a0ba0aa9bcf20991e58a62f3916438 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 23:49:02 +0000 Subject: [PATCH] WIP slider & colour picker --- GUI_SYSTEM.md | 231 ++++ .../lib/client/gui/AmbleColorPicker.java | 1001 +++++++++++++++++ .../amble/lib/client/gui/AmbleContainer.java | 8 + .../dev/amble/lib/client/gui/AmbleSlider.java | 484 ++++++++ .../dev/amble/lib/client/gui/Draggable.java | 24 + .../amble/lib/client/gui/lua/LuaElement.java | 223 ++++ .../client/gui/registry/AmbleGuiRegistry.java | 2 + .../java/dev/amble/lib/script/LuaScript.java | 16 + .../assets/litmus/gui/skin_changer.json | 50 +- .../assets/litmus/script/username_changer.lua | 20 +- 10 files changed, 2036 insertions(+), 23 deletions(-) create mode 100644 src/main/java/dev/amble/lib/client/gui/AmbleColorPicker.java create mode 100644 src/main/java/dev/amble/lib/client/gui/AmbleSlider.java create mode 100644 src/main/java/dev/amble/lib/client/gui/Draggable.java diff --git a/GUI_SYSTEM.md b/GUI_SYSTEM.md index 2b2f026..231864f 100644 --- a/GUI_SYSTEM.md +++ b/GUI_SYSTEM.md @@ -10,6 +10,8 @@ AmbleKit provides a declarative JSON-based GUI system that lets you create Minec - [Background Types](#background-types) - [Text Elements](#text-elements) - [Text Input Elements](#text-input-elements) +- [Slider Elements](#slider-elements) +- [Color Picker Elements](#color-picker-elements) - [Entity Display Elements](#entity-display-elements) - [Buttons & Interactivity](#buttons--interactivity) - [Lua Script Integration](#lua-script-integration) @@ -328,6 +330,235 @@ end --- +## Slider Elements + +Create interactive slider controls using the `slider` property. Sliders allow users to select a value within a configurable range by dragging a thumb along a track. + +### Basic Slider + +```json +{ + "id": "mymod:volume_slider", + "slider": true, + "min": 0, + "max": 100, + "value": 50, + "layout": [150, 20], + "background": [30, 30, 40] +} +``` + +### Slider Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `slider` | boolean | required | Must be `true` to create a slider | +| `min` | float | `0` | Minimum value | +| `max` | float | `1` | Maximum value | +| `value` | float | `0` | Initial value | +| `step` | float | `0` | Step increment for snapping (0 = continuous) | + +### Visual Customization + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `track_color` | [r,g,b] or [r,g,b,a] | dark gray | Color of the unfilled track | +| `track_filled_color` | [r,g,b] or [r,g,b,a] | blue | Color of the filled track portion | +| `thumb_color` | [r,g,b] or [r,g,b,a] | light gray | Color of the thumb | +| `thumb_hover_color` | [r,g,b] or [r,g,b,a] | white | Color of thumb when hovered | +| `border_color` | [r,g,b] or [r,g,b,a] | gray | Border color when unfocused | +| `focused_border_color` | [r,g,b] or [r,g,b,a] | light blue | Border color when focused | +| `track_height` | integer | `4` | Height of the track in pixels | +| `thumb_width` | integer | `8` | Width of the thumb in pixels | +| `thumb_height` | integer | `16` | Height of the thumb in pixels | + +### Styled Slider Example + +```json +{ + "id": "mymod:brightness_slider", + "slider": true, + "min": 0, + "max": 100, + "value": 75, + "step": 5, + "layout": [200, 24], + "background": [20, 20, 30], + "track_color": [40, 40, 50], + "track_filled_color": [80, 140, 200], + "thumb_color": [180, 180, 200], + "thumb_hover_color": [220, 220, 255], + "border_color": [60, 60, 80], + "focused_border_color": [100, 140, 220], + "script": "mymod:brightness_handler" +} +``` + +### Keyboard Controls + +| Key | Action | +|-----|--------| +| `←` / `↓` | Decrease value by step (or 1%) | +| `→` / `↑` | Increase value by step (or 1%) | +| `Ctrl+←` / `Ctrl+↓` | Decrease by 10× step | +| `Ctrl+→` / `Ctrl+↑` | Increase by 10× step | +| `Home` | Set to minimum value | +| `End` | Set to maximum value | +| `Tab` | Move focus to next element | + +### Reading Slider Values in Lua + +```lua +function onValueChanged(self, value) + -- Called whenever the slider value changes + local mc = self:minecraft() + mc:sendMessage("Value changed to: " .. value, false) +end + +function onClick(self, mouseX, mouseY, button) + local root = getRoot(self) + local slider = findById(root, "mymod:volume_slider") + + if slider then + local value = slider:getValue() + local min = slider:getMin() + local max = slider:getMax() + end +end +``` + +### LuaElement Slider API + +| Method | Description | +|--------|-------------| +| `self:getValue()` | Get the current value | +| `self:setValue(value)` | Set the value (clamped to min/max) | +| `self:getMin()` | Get the minimum value | +| `self:setMin(min)` | Set the minimum value | +| `self:getMax()` | Get the maximum value | +| `self:setMax(max)` | Set the maximum value | +| `self:getStep()` | Get the step increment | +| `self:setStep(step)` | Set the step increment | + +--- + +## Color Picker Elements + +Create interactive color pickers using the `color_picker` property. Color pickers display a small color swatch that expands into a full color selection popup with a hue bar, saturation/value square, and input fields. + +### Basic Color Picker + +```json +{ + "id": "mymod:text_color", + "color_picker": true, + "initial_color": [255, 128, 0], + "layout": [24, 24], + "background": [30, 30, 40] +} +``` + +### Color Picker Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `color_picker` | boolean | required | Must be `true` to create a color picker | +| `initial_color` | [r,g,b] or [r,g,b,a] | white | Initial color value | +| `include_alpha` | boolean | `false` | Whether to show an alpha slider | + +### Visual Customization + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `border_collapsed` | [r,g,b] or [r,g,b,a] | gray | Border color when collapsed | +| `border_collapsed_hover` | [r,g,b] or [r,g,b,a] | light gray | Border color when hovered | +| `border_expanded` | [r,g,b] or [r,g,b,a] | dark gray | Border color of expanded popup | +| `background_expanded` | [r,g,b,a] | dark semi-transparent | Background of expanded popup | +| `popup_width` | integer | `180` | Width of expanded popup in pixels | +| `popup_height` | integer | `150` | Height of expanded popup in pixels | + +### Color Picker with Alpha + +```json +{ + "id": "mymod:highlight_color", + "color_picker": true, + "initial_color": [255, 255, 0, 128], + "include_alpha": true, + "layout": [30, 30], + "background": [25, 25, 35], + "border_collapsed": [70, 70, 90], + "border_collapsed_hover": [100, 100, 140], + "background_expanded": [35, 35, 50, 245], + "popup_width": 200, + "popup_height": 160 +} +``` + +### Expanded Popup Features + +When expanded, the color picker displays: + +1. **Saturation/Value Square** - Click and drag to select saturation (horizontal) and brightness (vertical) +2. **Hue Bar** - Vertical rainbow bar for selecting the base hue +3. **Alpha Bar** (optional) - Horizontal bar for selecting transparency +4. **Hex Input** - Text field accepting `RRGGBB` or `RRGGBBAA` format +5. **RGB/A Inputs** - Individual numeric fields for each color component (auto-clamps 0-255) + +### Keyboard Controls + +| Key | Action | +|-----|--------| +| `Escape` | Close the expanded popup | +| `Enter` | Apply changes and close popup | +| `Tab` | Cycle through input fields | +| `Shift+Tab` | Cycle backwards through input fields | + +### Reading Color Values in Lua + +```lua +function onColorChanged(self, r, g, b, a) + -- Called whenever the color changes + local hex = self:getColorHex() + local mc = self:minecraft() + mc:sendMessage("Color changed to #" .. hex, false) +end + +function onClick(self, mouseX, mouseY, button) + local root = getRoot(self) + local picker = findById(root, "mymod:text_color") + + if picker then + -- Get color as hex string + local hex = picker:getColorHex() + + -- Get color as RGBA array + local rgba = picker:getColorRGBA() + local r, g, b, a = rgba[1], rgba[2], rgba[3], rgba[4] + + -- Set color programmatically + picker:setColorRGBA(255, 0, 128, 255) + -- Or by hex + picker:setColorHex("FF0080") + end +end +``` + +### LuaElement Color Picker API + +| Method | Description | +|--------|-------------| +| `self:getColorRGBA()` | Get color as `{r, g, b, a}` array (0-255) | +| `self:setColorRGBA(r,g,b,a)` | Set color from RGBA values | +| `self:getColorHex()` | Get color as hex string (`RRGGBB` or `RRGGBBAA`) | +| `self:setColorHex(hex)` | Set color from hex string | +| `self:isPickerExpanded()` | Check if popup is open | +| `self:setPickerExpanded(bool)` | Open or close the popup | +| `self:isIncludeAlpha()` | Check if alpha slider is shown | +| `self:setIncludeAlpha(bool)` | Show/hide alpha slider | + +--- + ## Entity Display Elements Display living entities within a GUI element using the `entity_uuid` property. This renders the entity in an inventory-screen style, perfect for character viewers, mob displays, or player previews. diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleColorPicker.java b/src/main/java/dev/amble/lib/client/gui/AmbleColorPicker.java new file mode 100644 index 0000000..4b2ef9f --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleColorPicker.java @@ -0,0 +1,1001 @@ +package dev.amble.lib.client.gui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.lua.LuaElement; +import dev.amble.lib.client.gui.registry.AmbleElementParser; +import dev.amble.lib.script.LuaScript; +import dev.amble.lib.script.ScriptManager; +import dev.amble.lib.script.lua.LuaBinder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; +import org.lwjgl.glfw.GLFW; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +import java.awt.*; + +/** + * A color picker element with collapsed swatch and expanded picker overlay. + *

+ * Features: + *

+ */ +@Getter +@NoArgsConstructor +public class AmbleColorPicker extends AmbleContainer implements Focusable { + + // Current color (stored as RGBA) + private int red = 255; + private int green = 255; + private int blue = 255; + private int alpha = 255; + + // HSV representation for the picker + private float hue = 0f; + private float saturation = 0f; + private float brightness = 1f; + + // Expanded state + @Setter + private boolean expanded = false; + + // Focus state + @Setter + private boolean focused = false; + + // Alpha support + @Setter + private boolean includeAlpha = false; + + // Visual customization - collapsed state + @Setter + private Color borderCollapsed = new Color(100, 100, 100); + @Setter + private Color borderCollapsedHover = new Color(150, 150, 150); + + // Visual customization - expanded state + @Setter + private Color borderExpanded = new Color(60, 60, 60); + @Setter + private Color backgroundExpanded = new Color(40, 40, 50, 240); + + // Popup dimensions + @Setter + private int popupWidth = 180; + @Setter + private int popupHeight = 150; + + // Interaction state + private boolean isHovered = false; + private boolean isDraggingHue = false; + private boolean isDraggingSV = false; + private boolean isDraggingAlpha = false; + + // Input field state (simplified - we'll render inline) + private String hexInputText = "FFFFFF"; + private String rInputText = "255"; + private String gInputText = "255"; + private String bInputText = "255"; + private String aInputText = "255"; + + // Which input is focused (0=none, 1=hex, 2=r, 3=g, 4=b, 5=a) + private int focusedInput = 0; + private int cursorPosition = 0; + private long lastCursorBlink = 0; + private boolean cursorVisible = true; + + // Layout constants + private static final int HUE_BAR_WIDTH = 16; + private static final int ALPHA_BAR_HEIGHT = 12; + private static final int INPUT_HEIGHT = 14; + private static final int LABEL_WIDTH = 14; + private static final int CURSOR_BLINK_RATE = 530; + + // Script support + @Setter + private @Nullable LuaScript script; + + /** + * Sets the color from RGBA values (0-255 each). + */ + public void setColor(int r, int g, int b, int a) { + this.red = clamp(r, 0, 255); + this.green = clamp(g, 0, 255); + this.blue = clamp(b, 0, 255); + this.alpha = clamp(a, 0, 255); + updateHSVFromRGB(); + updateInputTexts(); + onColorChanged(); + } + + /** + * Sets the color from RGBA values (0-255 each) without triggering callback. + */ + private void setColorInternal(int r, int g, int b, int a) { + this.red = clamp(r, 0, 255); + this.green = clamp(g, 0, 255); + this.blue = clamp(b, 0, 255); + this.alpha = clamp(a, 0, 255); + updateHSVFromRGB(); + updateInputTexts(); + } + + /** + * Sets the color from a hex string (with or without #, with or without alpha). + */ + public void setColorHex(String hex) { + hex = hex.replace("#", ""); + try { + if (hex.length() == 6) { + int r = Integer.parseInt(hex.substring(0, 2), 16); + int g = Integer.parseInt(hex.substring(2, 4), 16); + int b = Integer.parseInt(hex.substring(4, 6), 16); + setColor(r, g, b, alpha); + } else if (hex.length() == 8) { + int r = Integer.parseInt(hex.substring(0, 2), 16); + int g = Integer.parseInt(hex.substring(2, 4), 16); + int b = Integer.parseInt(hex.substring(4, 6), 16); + int a = Integer.parseInt(hex.substring(6, 8), 16); + setColor(r, g, b, a); + } + } catch (NumberFormatException e) { + // Invalid hex, ignore + } + } + + /** + * Gets the current color as a hex string. + */ + public String getColorHex() { + if (includeAlpha) { + return String.format("%02X%02X%02X%02X", red, green, blue, alpha); + } + return String.format("%02X%02X%02X", red, green, blue); + } + + /** + * Gets the current color as a java.awt.Color. + */ + public Color getColor() { + return new Color(red, green, blue, alpha); + } + + private void updateHSVFromRGB() { + float[] hsv = Color.RGBtoHSB(red, green, blue, null); + this.hue = hsv[0]; + this.saturation = hsv[1]; + this.brightness = hsv[2]; + } + + private void updateRGBFromHSV() { + int rgb = Color.HSBtoRGB(hue, saturation, brightness); + this.red = (rgb >> 16) & 0xFF; + this.green = (rgb >> 8) & 0xFF; + this.blue = rgb & 0xFF; + updateInputTexts(); + } + + private void updateInputTexts() { + hexInputText = String.format("%02X%02X%02X", red, green, blue); + rInputText = String.valueOf(red); + gInputText = String.valueOf(green); + bInputText = String.valueOf(blue); + aInputText = String.valueOf(alpha); + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + Rectangle layout = getLayout(); + isHovered = isHovered(mouseX, mouseY); + + // Render collapsed swatch + renderCollapsedSwatch(context, layout, mouseX, mouseY); + + // Render expanded popup if open + if (expanded) { + renderExpandedPopup(context, layout, mouseX, mouseY, delta); + } + + // Render children + for (AmbleElement child : getChildren()) { + if (child.isVisible()) { + child.render(context, mouseX, mouseY, delta); + } + } + } + + private void renderCollapsedSwatch(DrawContext context, Rectangle layout, int mouseX, int mouseY) { + // Draw color swatch + context.fill(layout.x + 1, layout.y + 1, layout.x + layout.width - 1, layout.y + layout.height - 1, + new Color(red, green, blue, alpha).getRGB()); + + // Draw checkerboard pattern behind if alpha < 255 (to show transparency) + if (alpha < 255) { + renderCheckerboard(context, layout.x + 1, layout.y + 1, layout.width - 2, layout.height - 2); + // Re-draw color with alpha + context.fill(layout.x + 1, layout.y + 1, layout.x + layout.width - 1, layout.y + layout.height - 1, + new Color(red, green, blue, alpha).getRGB()); + } + + // Draw border + Color border = (isHovered || expanded) ? borderCollapsedHover : borderCollapsed; + drawBorder(context, layout.x, layout.y, layout.width, layout.height, border); + } + + private void renderExpandedPopup(DrawContext context, Rectangle layout, int mouseX, int mouseY, float delta) { + // Calculate popup position (below the swatch) + int popupX = layout.x; + int popupY = layout.y + layout.height + 2; + + // Calculate actual popup height based on whether alpha is included + int actualPopupHeight = popupHeight + (includeAlpha ? ALPHA_BAR_HEIGHT + 4 : 0); + + // Draw popup background + context.fill(popupX, popupY, popupX + popupWidth, popupY + actualPopupHeight, backgroundExpanded.getRGB()); + drawBorder(context, popupX, popupY, popupWidth, actualPopupHeight, borderExpanded); + + int padding = 4; + int innerX = popupX + padding; + int innerY = popupY + padding; + int innerWidth = popupWidth - padding * 2; + + // Calculate SV square dimensions + int svSize = innerWidth - HUE_BAR_WIDTH - 4; + int svX = innerX; + int svY = innerY; + + // Render SV square + renderSVSquare(context, svX, svY, svSize, mouseX, mouseY); + + // Render hue bar + int hueX = svX + svSize + 4; + int hueY = innerY; + int hueHeight = svSize; + renderHueBar(context, hueX, hueY, HUE_BAR_WIDTH, hueHeight, mouseX, mouseY); + + // Calculate input area Y position + int inputY = svY + svSize + 4; + + // Render alpha bar if enabled + if (includeAlpha) { + renderAlphaBar(context, innerX, inputY, innerWidth, ALPHA_BAR_HEIGHT, mouseX, mouseY); + inputY += ALPHA_BAR_HEIGHT + 4; + } + + // Render input fields + renderInputFields(context, innerX, inputY, innerWidth, mouseX, mouseY, delta); + } + + private void renderSVSquare(DrawContext context, int x, int y, int size, int mouseX, int mouseY) { + // Draw the SV gradient + for (int py = 0; py < size; py++) { + for (int px = 0; px < size; px++) { + float s = (float) px / size; + float v = 1.0f - (float) py / size; + int rgb = Color.HSBtoRGB(hue, s, v); + context.fill(x + px, y + py, x + px + 1, y + py + 1, rgb | 0xFF000000); + } + } + + // Draw border + drawBorder(context, x, y, size, size, new Color(80, 80, 80)); + + // Draw crosshair at current position + int crossX = x + (int) (saturation * size); + int crossY = y + (int) ((1 - brightness) * size); + int crossSize = 4; + + // White outline + context.fill(crossX - crossSize - 1, crossY, crossX + crossSize + 2, crossY + 1, 0xFFFFFFFF); + context.fill(crossX, crossY - crossSize - 1, crossX + 1, crossY + crossSize + 2, 0xFFFFFFFF); + // Black center + context.fill(crossX - crossSize, crossY, crossX + crossSize + 1, crossY + 1, 0xFF000000); + context.fill(crossX, crossY - crossSize, crossX + 1, crossY + crossSize + 1, 0xFF000000); + } + + private void renderHueBar(DrawContext context, int x, int y, int width, int height, int mouseX, int mouseY) { + // Draw hue gradient (vertical rainbow) + for (int py = 0; py < height; py++) { + float h = (float) py / height; + int rgb = Color.HSBtoRGB(h, 1f, 1f); + context.fill(x, y + py, x + width, y + py + 1, rgb | 0xFF000000); + } + + // Draw border + drawBorder(context, x, y, width, height, new Color(80, 80, 80)); + + // Draw selector at current hue + int selectorY = y + (int) (hue * height); + context.fill(x - 1, selectorY - 1, x + width + 1, selectorY + 2, 0xFFFFFFFF); + context.fill(x, selectorY, x + width, selectorY + 1, 0xFF000000); + } + + private void renderAlphaBar(DrawContext context, int x, int y, int width, int height, int mouseX, int mouseY) { + // Draw checkerboard background + renderCheckerboard(context, x, y, width, height); + + // Draw alpha gradient + for (int px = 0; px < width; px++) { + int a = (int) ((float) px / width * 255); + int color = (a << 24) | (red << 16) | (green << 8) | blue; + context.fill(x + px, y, x + px + 1, y + height, color); + } + + // Draw border + drawBorder(context, x, y, width, height, new Color(80, 80, 80)); + + // Draw selector at current alpha + int selectorX = x + (int) ((float) alpha / 255 * width); + context.fill(selectorX - 1, y - 1, selectorX + 2, y + height + 1, 0xFFFFFFFF); + context.fill(selectorX, y, selectorX + 1, y + height, 0xFF000000); + } + + private void renderCheckerboard(DrawContext context, int x, int y, int width, int height) { + int checkSize = 4; + for (int py = 0; py < height; py += checkSize) { + for (int px = 0; px < width; px += checkSize) { + boolean isLight = ((px / checkSize) + (py / checkSize)) % 2 == 0; + int color = isLight ? 0xFFCCCCCC : 0xFF999999; + int x2 = Math.min(x + px + checkSize, x + width); + int y2 = Math.min(y + py + checkSize, y + height); + context.fill(x + px, y + py, x2, y2, color); + } + } + } + + private void renderInputFields(DrawContext context, int x, int y, int width, int mouseX, int mouseY, float delta) { + TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + int fieldHeight = INPUT_HEIGHT; + int spacing = 2; + + // Update cursor blink + long now = System.currentTimeMillis(); + if (now - lastCursorBlink > CURSOR_BLINK_RATE) { + cursorVisible = !cursorVisible; + lastCursorBlink = now; + } + + // Hex input (top row) + int hexWidth = width; + renderInputField(context, textRenderer, x, y, hexWidth, fieldHeight, "#", hexInputText, focusedInput == 1, mouseX, mouseY); + + // RGB inputs (bottom row) + int rgbY = y + fieldHeight + spacing; + int fieldWidth = (width - spacing * 2 - (includeAlpha ? spacing : 0)) / (includeAlpha ? 4 : 3); + + renderInputField(context, textRenderer, x, rgbY, fieldWidth, fieldHeight, "R", rInputText, focusedInput == 2, mouseX, mouseY); + renderInputField(context, textRenderer, x + fieldWidth + spacing, rgbY, fieldWidth, fieldHeight, "G", gInputText, focusedInput == 3, mouseX, mouseY); + renderInputField(context, textRenderer, x + (fieldWidth + spacing) * 2, rgbY, fieldWidth, fieldHeight, "B", bInputText, focusedInput == 4, mouseX, mouseY); + + if (includeAlpha) { + renderInputField(context, textRenderer, x + (fieldWidth + spacing) * 3, rgbY, fieldWidth, fieldHeight, "A", aInputText, focusedInput == 5, mouseX, mouseY); + } + } + + private void renderInputField(DrawContext context, TextRenderer textRenderer, int x, int y, int width, int height, + String label, String text, boolean isFocused, int mouseX, int mouseY) { + // Background + context.fill(x, y, x + width, y + height, isFocused ? 0xFF303040 : 0xFF252530); + + // Border + Color borderColor = isFocused ? new Color(80, 140, 200) : new Color(60, 60, 70); + drawBorder(context, x, y, width, height, borderColor); + + // Label + int labelX = x + 2; + int textY = y + (height - textRenderer.fontHeight) / 2; + context.drawText(textRenderer, label, labelX, textY, 0xFF808090, false); + + // Text + int textX = labelX + textRenderer.getWidth(label) + 2; + int maxTextWidth = width - (textX - x) - 2; + + // Scissor to prevent text overflow + context.enableScissor(textX, y, x + width - 2, y + height); + context.drawText(textRenderer, text, textX, textY, 0xFFFFFFFF, false); + + // Cursor + if (isFocused && cursorVisible) { + int cursorX = textX + textRenderer.getWidth(text.substring(0, Math.min(cursorPosition, text.length()))); + context.fill(cursorX, textY - 1, cursorX + 1, textY + textRenderer.fontHeight + 1, 0xFFFFFFFF); + } + + context.disableScissor(); + } + + private void drawBorder(DrawContext context, int x, int y, int w, int h, Color color) { + int c = color.getRGB(); + context.fill(x, y, x + w, y + 1, c); + context.fill(x, y + h - 1, x + w, y + h, c); + context.fill(x, y, x + 1, y + h, c); + context.fill(x + w - 1, y, x + w, y + h, c); + } + + @Override + public void onClick(double mouseX, double mouseY, int button) { + if (button != 0) { + super.onClick(mouseX, mouseY, button); + return; + } + + Rectangle layout = getLayout(); + + // Check if clicking on collapsed swatch + if (!expanded && isHovered(mouseX, mouseY)) { + expanded = true; + focusedInput = 0; + return; + } + + // Check if clicking outside expanded popup + if (expanded) { + int popupX = layout.x; + int popupY = layout.y + layout.height + 2; + int actualPopupHeight = popupHeight + (includeAlpha ? ALPHA_BAR_HEIGHT + 4 : 0); + + boolean inSwatch = mouseX >= layout.x && mouseX <= layout.x + layout.width && + mouseY >= layout.y && mouseY <= layout.y + layout.height; + boolean inPopup = mouseX >= popupX && mouseX <= popupX + popupWidth && + mouseY >= popupY && mouseY <= popupY + actualPopupHeight; + + if (!inSwatch && !inPopup) { + expanded = false; + focusedInput = 0; + applyInputTexts(); + return; + } + + // Handle clicks within popup + if (inPopup) { + handlePopupClick(mouseX, mouseY, popupX, popupY); + } + } + + super.onClick(mouseX, mouseY, button); + } + + private void handlePopupClick(double mouseX, double mouseY, int popupX, int popupY) { + int padding = 4; + int innerX = popupX + padding; + int innerY = popupY + padding; + int innerWidth = popupWidth - padding * 2; + int svSize = innerWidth - HUE_BAR_WIDTH - 4; + + // Check SV square + if (mouseX >= innerX && mouseX < innerX + svSize && + mouseY >= innerY && mouseY < innerY + svSize) { + isDraggingSV = true; + focusedInput = 0; + updateSVFromMouse(mouseX, mouseY, innerX, innerY, svSize); + return; + } + + // Check hue bar + int hueX = innerX + svSize + 4; + if (mouseX >= hueX && mouseX < hueX + HUE_BAR_WIDTH && + mouseY >= innerY && mouseY < innerY + svSize) { + isDraggingHue = true; + focusedInput = 0; + updateHueFromMouse(mouseY, innerY, svSize); + return; + } + + // Check alpha bar + int inputY = innerY + svSize + 4; + if (includeAlpha) { + if (mouseX >= innerX && mouseX < innerX + innerWidth && + mouseY >= inputY && mouseY < inputY + ALPHA_BAR_HEIGHT) { + isDraggingAlpha = true; + focusedInput = 0; + updateAlphaFromMouse(mouseX, innerX, innerWidth); + return; + } + inputY += ALPHA_BAR_HEIGHT + 4; + } + + // Check input fields + handleInputFieldClick(mouseX, mouseY, innerX, inputY, innerWidth); + } + + private void handleInputFieldClick(double mouseX, double mouseY, int x, int y, int width) { + int fieldHeight = INPUT_HEIGHT; + int spacing = 2; + + // Hex field + if (mouseY >= y && mouseY < y + fieldHeight) { + focusedInput = 1; + cursorPosition = hexInputText.length(); + resetCursorBlink(); + return; + } + + // RGB fields + int rgbY = y + fieldHeight + spacing; + if (mouseY >= rgbY && mouseY < rgbY + fieldHeight) { + int fieldWidth = (width - spacing * 2 - (includeAlpha ? spacing : 0)) / (includeAlpha ? 4 : 3); + + if (mouseX >= x && mouseX < x + fieldWidth) { + focusedInput = 2; + cursorPosition = rInputText.length(); + } else if (mouseX >= x + fieldWidth + spacing && mouseX < x + fieldWidth * 2 + spacing) { + focusedInput = 3; + cursorPosition = gInputText.length(); + } else if (mouseX >= x + (fieldWidth + spacing) * 2 && mouseX < x + fieldWidth * 3 + spacing * 2) { + focusedInput = 4; + cursorPosition = bInputText.length(); + } else if (includeAlpha && mouseX >= x + (fieldWidth + spacing) * 3) { + focusedInput = 5; + cursorPosition = aInputText.length(); + } + resetCursorBlink(); + } + } + + private void resetCursorBlink() { + cursorVisible = true; + lastCursorBlink = System.currentTimeMillis(); + } + + @Override + public void onRelease(double mouseX, double mouseY, int button) { + if (button == 0) { + if (isDraggingSV || isDraggingHue || isDraggingAlpha) { + onColorChanged(); + } + isDraggingSV = false; + isDraggingHue = false; + isDraggingAlpha = false; + } + super.onRelease(mouseX, mouseY, button); + } + + /** + * Handles mouse drag for color selection. + */ + public void onMouseDragged(double mouseX, double mouseY, int button) { + if (button != 0 || !expanded) return; + + Rectangle layout = getLayout(); + int popupX = layout.x; + int popupY = layout.y + layout.height + 2; + int padding = 4; + int innerX = popupX + padding; + int innerY = popupY + padding; + int innerWidth = popupWidth - padding * 2; + int svSize = innerWidth - HUE_BAR_WIDTH - 4; + + if (isDraggingSV) { + updateSVFromMouse(mouseX, mouseY, innerX, innerY, svSize); + } else if (isDraggingHue) { + updateHueFromMouse(mouseY, innerY, svSize); + } else if (isDraggingAlpha) { + updateAlphaFromMouse(mouseX, innerX, innerWidth); + } + } + + private void updateSVFromMouse(double mouseX, double mouseY, int x, int y, int size) { + saturation = clamp((float) (mouseX - x) / size, 0f, 1f); + brightness = 1f - clamp((float) (mouseY - y) / size, 0f, 1f); + updateRGBFromHSV(); + } + + private void updateHueFromMouse(double mouseY, int y, int height) { + hue = clamp((float) (mouseY - y) / height, 0f, 1f); + updateRGBFromHSV(); + } + + private void updateAlphaFromMouse(double mouseX, int x, int width) { + alpha = clamp((int) ((mouseX - x) / width * 255), 0, 255); + aInputText = String.valueOf(alpha); + } + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } + + @Override + public boolean onKeyPressed(int keyCode, int scanCode, int modifiers) { + if (!expanded) return false; + + // Handle Escape to close + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + expanded = false; + focusedInput = 0; + applyInputTexts(); + return true; + } + + // Handle Tab to cycle inputs + if (keyCode == GLFW.GLFW_KEY_TAB) { + applyInputTexts(); + int maxInput = includeAlpha ? 5 : 4; + if ((modifiers & GLFW.GLFW_MOD_SHIFT) != 0) { + focusedInput = focusedInput <= 1 ? maxInput : focusedInput - 1; + } else { + focusedInput = focusedInput >= maxInput ? 1 : focusedInput + 1; + } + cursorPosition = getCurrentInputText().length(); + resetCursorBlink(); + return true; + } + + // Handle Enter to apply and close + if (keyCode == GLFW.GLFW_KEY_ENTER) { + applyInputTexts(); + expanded = false; + focusedInput = 0; + return true; + } + + // Handle text input for focused field + if (focusedInput > 0) { + return handleTextInput(keyCode, modifiers); + } + + return false; + } + + private boolean handleTextInput(int keyCode, int modifiers) { + String text = getCurrentInputText(); + + switch (keyCode) { + case GLFW.GLFW_KEY_BACKSPACE: + if (cursorPosition > 0) { + text = text.substring(0, cursorPosition - 1) + text.substring(cursorPosition); + cursorPosition--; + setCurrentInputText(text); + resetCursorBlink(); + } + return true; + + case GLFW.GLFW_KEY_DELETE: + if (cursorPosition < text.length()) { + text = text.substring(0, cursorPosition) + text.substring(cursorPosition + 1); + setCurrentInputText(text); + resetCursorBlink(); + } + return true; + + case GLFW.GLFW_KEY_LEFT: + if (cursorPosition > 0) { + cursorPosition--; + resetCursorBlink(); + } + return true; + + case GLFW.GLFW_KEY_RIGHT: + if (cursorPosition < text.length()) { + cursorPosition++; + resetCursorBlink(); + } + return true; + + case GLFW.GLFW_KEY_HOME: + cursorPosition = 0; + resetCursorBlink(); + return true; + + case GLFW.GLFW_KEY_END: + cursorPosition = text.length(); + resetCursorBlink(); + return true; + } + + return false; + } + + @Override + public boolean onCharTyped(char chr, int modifiers) { + if (!expanded || focusedInput == 0) return false; + + String text = getCurrentInputText(); + int maxLength = focusedInput == 1 ? (includeAlpha ? 8 : 6) : 3; + + // Validate character based on input type + boolean valid; + if (focusedInput == 1) { + // Hex input - allow 0-9, A-F + valid = (chr >= '0' && chr <= '9') || (chr >= 'A' && chr <= 'F') || (chr >= 'a' && chr <= 'f'); + } else { + // RGB/A input - allow 0-9 only + valid = chr >= '0' && chr <= '9'; + } + + if (valid && text.length() < maxLength) { + char toInsert = focusedInput == 1 ? Character.toUpperCase(chr) : chr; + text = text.substring(0, cursorPosition) + toInsert + text.substring(cursorPosition); + cursorPosition++; + setCurrentInputText(text); + resetCursorBlink(); + + // Auto-apply for immediate feedback + applyInputTexts(); + return true; + } + + return false; + } + + private String getCurrentInputText() { + return switch (focusedInput) { + case 1 -> hexInputText; + case 2 -> rInputText; + case 3 -> gInputText; + case 4 -> bInputText; + case 5 -> aInputText; + default -> ""; + }; + } + + private void setCurrentInputText(String text) { + switch (focusedInput) { + case 1 -> hexInputText = text; + case 2 -> rInputText = text; + case 3 -> gInputText = text; + case 4 -> bInputText = text; + case 5 -> aInputText = text; + } + } + + private void applyInputTexts() { + try { + // Apply hex if it's complete + if (hexInputText.length() == 6 || (includeAlpha && hexInputText.length() == 8)) { + setColorHex(hexInputText); + return; + } + + // Apply RGB values + int r = rInputText.isEmpty() ? 0 : clamp(Integer.parseInt(rInputText), 0, 255); + int g = gInputText.isEmpty() ? 0 : clamp(Integer.parseInt(gInputText), 0, 255); + int b = bInputText.isEmpty() ? 0 : clamp(Integer.parseInt(bInputText), 0, 255); + int a = aInputText.isEmpty() ? 255 : clamp(Integer.parseInt(aInputText), 0, 255); + + setColorInternal(r, g, b, includeAlpha ? a : alpha); + } catch (NumberFormatException e) { + // Invalid input, restore from current color + updateInputTexts(); + } + } + + @Override + public boolean canFocus() { + return true; + } + + @Override + public void onFocusChanged(boolean focused) { + this.focused = focused; + if (!focused && expanded) { + // Don't close on focus loss - let click-outside handle it + } + } + + /** + * Called when the color changes. + */ + protected void onColorChanged() { + if (script != null && script.onColorChanged() != null && !script.onColorChanged().isnil()) { + Varargs args = LuaValue.varargsOf(new LuaValue[]{ + LuaBinder.bind(new LuaElement(this)), + LuaValue.valueOf(red), + LuaValue.valueOf(green), + LuaValue.valueOf(blue), + LuaValue.valueOf(alpha) + }); + + try { + script.onColorChanged().invoke(args); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onColorChanged script for AmbleColorPicker {}:", id(), e); + } + } + } + + /** + * Checks if a point is within the expanded popup area. + */ + public boolean isInExpandedArea(double mouseX, double mouseY) { + if (!expanded) return false; + + Rectangle layout = getLayout(); + int popupX = layout.x; + int popupY = layout.y + layout.height + 2; + int actualPopupHeight = popupHeight + (includeAlpha ? ALPHA_BAR_HEIGHT + 4 : 0); + + return mouseX >= popupX && mouseX <= popupX + popupWidth && + mouseY >= popupY && mouseY <= popupY + actualPopupHeight; + } + + // ===== Builder ===== + + public static Builder colorPickerBuilder() { + return new Builder(); + } + + public static class Builder extends AbstractBuilder { + + @Override + protected AmbleColorPicker create() { + return new AmbleColorPicker(); + } + + @Override + protected Builder self() { + return this; + } + + public Builder color(int r, int g, int b, int a) { + container.setColorInternal(r, g, b, a); + return this; + } + + public Builder color(int r, int g, int b) { + container.setColorInternal(r, g, b, 255); + return this; + } + + public Builder colorHex(String hex) { + container.setColorHex(hex); + return this; + } + + public Builder includeAlpha(boolean includeAlpha) { + container.setIncludeAlpha(includeAlpha); + return this; + } + + public Builder borderCollapsed(Color color) { + container.setBorderCollapsed(color); + return this; + } + + public Builder borderCollapsedHover(Color color) { + container.setBorderCollapsedHover(color); + return this; + } + + public Builder borderExpanded(Color color) { + container.setBorderExpanded(color); + return this; + } + + public Builder backgroundExpanded(Color color) { + container.setBackgroundExpanded(color); + return this; + } + + public Builder popupWidth(int width) { + container.setPopupWidth(width); + return this; + } + + public Builder popupHeight(int height) { + container.setPopupHeight(height); + return this; + } + + public Builder script(LuaScript script) { + container.setScript(script); + return this; + } + } + + // ===== Parser ===== + + /** + * Parser for AmbleColorPicker elements. + *

+ * This parser handles JSON objects that have the "color_picker" property set to true. + *

+ * Supported JSON properties: + *

+ */ + public static class Parser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + if (!json.has("color_picker") || !json.get("color_picker").getAsBoolean()) { + return null; + } + + AmbleColorPicker picker = AmbleColorPicker.colorPickerBuilder().build(); + picker.copyFrom(base); + + // Parse initial color + if (json.has("initial_color")) { + JsonArray colorArray = json.get("initial_color").getAsJsonArray(); + int r = colorArray.get(0).getAsInt(); + int g = colorArray.get(1).getAsInt(); + int b = colorArray.get(2).getAsInt(); + int a = colorArray.size() > 3 ? colorArray.get(3).getAsInt() : 255; + picker.setColorInternal(r, g, b, a); + } + + // Parse include_alpha + if (json.has("include_alpha")) { + picker.setIncludeAlpha(json.get("include_alpha").getAsBoolean()); + } + + // Parse border colors + if (json.has("border_collapsed")) { + picker.setBorderCollapsed(parseColor(json.get("border_collapsed").getAsJsonArray())); + } + if (json.has("border_collapsed_hover")) { + picker.setBorderCollapsedHover(parseColor(json.get("border_collapsed_hover").getAsJsonArray())); + } + if (json.has("border_expanded")) { + picker.setBorderExpanded(parseColor(json.get("border_expanded").getAsJsonArray())); + } + if (json.has("background_expanded")) { + picker.setBackgroundExpanded(parseColor(json.get("background_expanded").getAsJsonArray())); + } + + // Parse popup dimensions + if (json.has("popup_width")) { + picker.setPopupWidth(json.get("popup_width").getAsInt()); + } + if (json.has("popup_height")) { + picker.setPopupHeight(json.get("popup_height").getAsInt()); + } + + // Parse script + if (json.has("script")) { + Identifier scriptId = new Identifier(json.get("script").getAsString()) + .withPrefixedPath("script/") + .withSuffixedPath(".lua"); + LuaScript script = ScriptManager.getInstance().load( + scriptId, + MinecraftClient.getInstance().getResourceManager() + ); + picker.setScript(script); + } + + return picker; + } + + private Color parseColor(JsonArray colorArray) { + int r = colorArray.get(0).getAsInt(); + int g = colorArray.get(1).getAsInt(); + int b = colorArray.get(2).getAsInt(); + int a = colorArray.size() > 3 ? colorArray.get(3).getAsInt() : 255; + return new Color(r, g, b, a); + } + + @Override + public int priority() { + return 90; + } + } +} + diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java index 443fa85..933c40f 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java @@ -218,6 +218,14 @@ public boolean mouseDragged(double mouseX, double mouseY, int button, double del textInput.onMouseDragged(mouseX, mouseY, button); return true; } + if (focusedElement instanceof AmbleSlider slider) { + slider.onMouseDragged(mouseX, mouseY, button); + return true; + } + if (focusedElement instanceof AmbleColorPicker colorPicker) { + colorPicker.onMouseDragged(mouseX, mouseY, button); + return true; + } return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleSlider.java b/src/main/java/dev/amble/lib/client/gui/AmbleSlider.java new file mode 100644 index 0000000..31cc086 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleSlider.java @@ -0,0 +1,484 @@ +package dev.amble.lib.client.gui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.lua.LuaElement; +import dev.amble.lib.client.gui.registry.AmbleElementParser; +import dev.amble.lib.script.LuaScript; +import dev.amble.lib.script.ScriptManager; +import dev.amble.lib.script.lua.LuaBinder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +import java.awt.*; + +/** + * A slider element that allows users to select a value within a range. + *

+ * Features: + *

+ */ +@Getter +@NoArgsConstructor +public class AmbleSlider extends AmbleContainer implements Focusable { + + // Value properties + @Setter + private float min = 0.0f; + @Setter + private float max = 1.0f; + private float value = 0.0f; + + // Focus state + @Setter + private boolean focused = false; + + // Visual customization + @Setter + private Color trackColor = new Color(60, 60, 60); + @Setter + private Color trackFilledColor = new Color(80, 140, 200); + @Setter + private Color thumbColor = new Color(200, 200, 200); + @Setter + private Color thumbHoverColor = new Color(255, 255, 255); + @Setter + private Color borderColor = new Color(100, 100, 100); + @Setter + private Color focusedBorderColor = new Color(80, 160, 255); + + @Setter + private int trackHeight = 4; + @Setter + private int thumbWidth = 8; + @Setter + private int thumbHeight = 16; + + // Interaction state + private boolean isDragging = false; + private boolean isThumbHovered = false; + + // Step for keyboard navigation (0 = continuous) + @Setter + private float step = 0.0f; + + // Script support + @Setter + private @Nullable LuaScript script; + + /** + * Sets the current value, clamping to min/max range. + */ + public void setValue(float value) { + float oldValue = this.value; + this.value = Math.max(min, Math.min(max, value)); + if (oldValue != this.value) { + onValueChanged(); + } + } + + /** + * Gets the value as a normalized 0-1 range. + */ + public float getNormalizedValue() { + if (max == min) return 0; + return (value - min) / (max - min); + } + + /** + * Sets the value from a normalized 0-1 range. + */ + public void setNormalizedValue(float normalized) { + setValue(min + normalized * (max - min)); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + // Render background + getBackground().render(context, getLayout()); + + Rectangle layout = getLayout(); + int padding = getPadding(); + + // Calculate track area + int trackX = layout.x + padding; + int trackY = layout.y + (layout.height - trackHeight) / 2; + int trackWidth = layout.width - padding * 2; + + // Draw track background + context.fill(trackX, trackY, trackX + trackWidth, trackY + trackHeight, trackColor.getRGB()); + + // Draw filled portion of track + int filledWidth = (int) (trackWidth * getNormalizedValue()); + context.fill(trackX, trackY, trackX + filledWidth, trackY + trackHeight, trackFilledColor.getRGB()); + + // Calculate thumb position + int thumbX = trackX + filledWidth - thumbWidth / 2; + int thumbY = layout.y + (layout.height - thumbHeight) / 2; + + // Clamp thumb within track bounds + thumbX = Math.max(trackX - thumbWidth / 2, Math.min(trackX + trackWidth - thumbWidth / 2, thumbX)); + + // Check if thumb is hovered + isThumbHovered = mouseX >= thumbX && mouseX <= thumbX + thumbWidth && + mouseY >= thumbY && mouseY <= thumbY + thumbHeight; + + // Draw thumb + Color currentThumbColor = (isThumbHovered || isDragging) ? thumbHoverColor : thumbColor; + context.fill(thumbX, thumbY, thumbX + thumbWidth, thumbY + thumbHeight, currentThumbColor.getRGB()); + + // Draw border + Color border = focused ? focusedBorderColor : borderColor; + drawBorder(context, layout, border); + + // Render children + for (AmbleElement child : getChildren()) { + if (child.isVisible()) { + child.render(context, mouseX, mouseY, delta); + } + } + } + + private void drawBorder(DrawContext context, Rectangle layout, Color color) { + int x = layout.x; + int y = layout.y; + int w = layout.width; + int h = layout.height; + int c = color.getRGB(); + + // Top + context.fill(x, y, x + w, y + 1, c); + // Bottom + context.fill(x, y + h - 1, x + w, y + h, c); + // Left + context.fill(x, y, x + 1, y + h, c); + // Right + context.fill(x + w - 1, y, x + w, y + h, c); + } + + @Override + public void onClick(double mouseX, double mouseY, int button) { + if (button == 0 && isHovered(mouseX, mouseY)) { + isDragging = true; + updateValueFromMouse(mouseX); + } + super.onClick(mouseX, mouseY, button); + } + + @Override + public void onRelease(double mouseX, double mouseY, int button) { + if (button == 0) { + isDragging = false; + } + super.onRelease(mouseX, mouseY, button); + } + + /** + * Handles mouse drag for value adjustment. + */ + public void onMouseDragged(double mouseX, double mouseY, int button) { + if (isDragging && button == 0) { + updateValueFromMouse(mouseX); + } + } + + private void updateValueFromMouse(double mouseX) { + Rectangle layout = getLayout(); + int padding = getPadding(); + int trackX = layout.x + padding; + int trackWidth = layout.width - padding * 2; + + // Calculate normalized value from mouse position + float normalized = (float) (mouseX - trackX) / trackWidth; + normalized = Math.max(0, Math.min(1, normalized)); + + // Apply step if set + if (step > 0) { + float range = max - min; + float steps = range / step; + normalized = Math.round(normalized * steps) / steps; + } + + setNormalizedValue(normalized); + } + + @Override + public boolean onKeyPressed(int keyCode, int scanCode, int modifiers) { + if (!focused) return false; + + float increment = step > 0 ? step : (max - min) / 100f; + + // Handle Ctrl for larger steps + if ((modifiers & org.lwjgl.glfw.GLFW.GLFW_MOD_CONTROL) != 0) { + increment *= 10; + } + + switch (keyCode) { + case org.lwjgl.glfw.GLFW.GLFW_KEY_LEFT: + case org.lwjgl.glfw.GLFW.GLFW_KEY_DOWN: + setValue(value - increment); + return true; + case org.lwjgl.glfw.GLFW.GLFW_KEY_RIGHT: + case org.lwjgl.glfw.GLFW.GLFW_KEY_UP: + setValue(value + increment); + return true; + case org.lwjgl.glfw.GLFW.GLFW_KEY_HOME: + setValue(min); + return true; + case org.lwjgl.glfw.GLFW.GLFW_KEY_END: + setValue(max); + return true; + } + + return false; + } + + @Override + public boolean onCharTyped(char chr, int modifiers) { + // Slider doesn't handle character input + return false; + } + + @Override + public boolean canFocus() { + return true; + } + + @Override + public void onFocusChanged(boolean focused) { + this.focused = focused; + } + + /** + * Called when the value changes. + */ + protected void onValueChanged() { + if (script != null && script.onValueChanged() != null && !script.onValueChanged().isnil()) { + Varargs args = LuaValue.varargsOf(new LuaValue[]{ + LuaBinder.bind(new LuaElement(this)), + LuaValue.valueOf(value) + }); + + try { + script.onValueChanged().invoke(args); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onValueChanged script for AmbleSlider {}:", id(), e); + } + } + } + + // ===== Builder ===== + + public static Builder sliderBuilder() { + return new Builder(); + } + + public static class Builder extends AbstractBuilder { + + @Override + protected AmbleSlider create() { + return new AmbleSlider(); + } + + @Override + protected Builder self() { + return this; + } + + public Builder min(float min) { + container.setMin(min); + return this; + } + + public Builder max(float max) { + container.setMax(max); + return this; + } + + public Builder value(float value) { + container.setValue(value); + return this; + } + + public Builder step(float step) { + container.setStep(step); + return this; + } + + public Builder trackColor(Color color) { + container.setTrackColor(color); + return this; + } + + public Builder trackFilledColor(Color color) { + container.setTrackFilledColor(color); + return this; + } + + public Builder thumbColor(Color color) { + container.setThumbColor(color); + return this; + } + + public Builder thumbHoverColor(Color color) { + container.setThumbHoverColor(color); + return this; + } + + public Builder borderColor(Color color) { + container.setBorderColor(color); + return this; + } + + public Builder focusedBorderColor(Color color) { + container.setFocusedBorderColor(color); + return this; + } + + public Builder trackHeight(int height) { + container.setTrackHeight(height); + return this; + } + + public Builder thumbWidth(int width) { + container.setThumbWidth(width); + return this; + } + + public Builder thumbHeight(int height) { + container.setThumbHeight(height); + return this; + } + + public Builder script(LuaScript script) { + container.setScript(script); + return this; + } + } + + // ===== Parser ===== + + /** + * Parser for AmbleSlider elements. + *

+ * This parser handles JSON objects that have the "slider" property set to true. + *

+ * Supported JSON properties: + *

    + *
  • {@code slider} - Boolean, must be true to create a slider
  • + *
  • {@code min} - Float minimum value (default: 0)
  • + *
  • {@code max} - Float maximum value (default: 1)
  • + *
  • {@code value} - Float initial value (default: 0)
  • + *
  • {@code step} - Float step increment for snapping (default: 0 = continuous)
  • + *
  • {@code track_color} - Color array [r,g,b] or [r,g,b,a]
  • + *
  • {@code track_filled_color} - Color array for filled portion
  • + *
  • {@code thumb_color} - Color array for thumb
  • + *
  • {@code thumb_hover_color} - Color array for hovered thumb
  • + *
  • {@code border_color} - Color array for border
  • + *
  • {@code focused_border_color} - Color array for focused border
  • + *
  • {@code track_height} - Integer height of track in pixels
  • + *
  • {@code thumb_width} - Integer width of thumb in pixels
  • + *
  • {@code thumb_height} - Integer height of thumb in pixels
  • + *
  • {@code script} - Script ID for event handling
  • + *
+ */ + public static class Parser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + if (!json.has("slider") || !json.get("slider").getAsBoolean()) { + return null; + } + + AmbleSlider slider = AmbleSlider.sliderBuilder().build(); + slider.copyFrom(base); + + // Parse min/max/value + if (json.has("min")) { + slider.setMin(json.get("min").getAsFloat()); + } + if (json.has("max")) { + slider.setMax(json.get("max").getAsFloat()); + } + if (json.has("value")) { + slider.setValue(json.get("value").getAsFloat()); + } + if (json.has("step")) { + slider.setStep(json.get("step").getAsFloat()); + } + + // Parse colors + if (json.has("track_color")) { + slider.setTrackColor(parseColor(json.get("track_color").getAsJsonArray())); + } + if (json.has("track_filled_color")) { + slider.setTrackFilledColor(parseColor(json.get("track_filled_color").getAsJsonArray())); + } + if (json.has("thumb_color")) { + slider.setThumbColor(parseColor(json.get("thumb_color").getAsJsonArray())); + } + if (json.has("thumb_hover_color")) { + slider.setThumbHoverColor(parseColor(json.get("thumb_hover_color").getAsJsonArray())); + } + if (json.has("border_color")) { + slider.setBorderColor(parseColor(json.get("border_color").getAsJsonArray())); + } + if (json.has("focused_border_color")) { + slider.setFocusedBorderColor(parseColor(json.get("focused_border_color").getAsJsonArray())); + } + + // Parse dimensions + if (json.has("track_height")) { + slider.setTrackHeight(json.get("track_height").getAsInt()); + } + if (json.has("thumb_width")) { + slider.setThumbWidth(json.get("thumb_width").getAsInt()); + } + if (json.has("thumb_height")) { + slider.setThumbHeight(json.get("thumb_height").getAsInt()); + } + + // Parse script + if (json.has("script")) { + Identifier scriptId = new Identifier(json.get("script").getAsString()) + .withPrefixedPath("script/") + .withSuffixedPath(".lua"); + LuaScript script = ScriptManager.getInstance().load( + scriptId, + MinecraftClient.getInstance().getResourceManager() + ); + slider.setScript(script); + } + + return slider; + } + + private Color parseColor(JsonArray colorArray) { + int r = colorArray.get(0).getAsInt(); + int g = colorArray.get(1).getAsInt(); + int b = colorArray.get(2).getAsInt(); + int a = colorArray.size() > 3 ? colorArray.get(3).getAsInt() : 255; + return new Color(r, g, b, a); + } + + @Override + public int priority() { + return 85; + } + } +} + diff --git a/src/main/java/dev/amble/lib/client/gui/Draggable.java b/src/main/java/dev/amble/lib/client/gui/Draggable.java new file mode 100644 index 0000000..59bb417 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/Draggable.java @@ -0,0 +1,24 @@ +package dev.amble.lib.client.gui; + +/** + * Interface for GUI elements that support mouse drag interactions. + *

+ * Implementing this interface allows elements to receive mouse drag events + * from the screen's drag handler. This is used by elements like sliders, + * color pickers, and text inputs for selection dragging. + */ +public interface Draggable { + + /** + * Called when the mouse is dragged while this element is the drag target. + *

+ * This method is called continuously while the mouse button is held down + * and the mouse is being moved. + * + * @param mouseX the current mouse X position + * @param mouseY the current mouse Y position + * @param button the mouse button being held (0 = left, 1 = right, 2 = middle) + */ + void onMouseDragged(double mouseX, double mouseY, int button); +} + diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java index 918a24f..1ae61cb 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java @@ -568,6 +568,229 @@ public void setEntityScale(float scale) { } } + // ===== AmbleSlider methods ===== + + /** + * Gets the current slider value. + * Only works if the underlying element is an AmbleSlider. + * + * @return the current value, or 0 if not a slider + */ + @LuaExpose + public float getValue() { + if (element instanceof AmbleSlider slider) { + return slider.getValue(); + } + return 0; + } + + /** + * Sets the slider value. + * Only works if the underlying element is an AmbleSlider. + * + * @param value the value to set (will be clamped to min/max) + */ + @LuaExpose + public void setValue(float value) { + if (element instanceof AmbleSlider slider) { + slider.setValue(value); + } + } + + /** + * Gets the slider minimum value. + * Only works if the underlying element is an AmbleSlider. + * + * @return the minimum value, or 0 if not a slider + */ + @LuaExpose + public float getMin() { + if (element instanceof AmbleSlider slider) { + return slider.getMin(); + } + return 0; + } + + /** + * Sets the slider minimum value. + * Only works if the underlying element is an AmbleSlider. + * + * @param min the minimum value + */ + @LuaExpose + public void setMin(float min) { + if (element instanceof AmbleSlider slider) { + slider.setMin(min); + } + } + + /** + * Gets the slider maximum value. + * Only works if the underlying element is an AmbleSlider. + * + * @return the maximum value, or 0 if not a slider + */ + @LuaExpose + public float getMax() { + if (element instanceof AmbleSlider slider) { + return slider.getMax(); + } + return 0; + } + + /** + * Sets the slider maximum value. + * Only works if the underlying element is an AmbleSlider. + * + * @param max the maximum value + */ + @LuaExpose + public void setMax(float max) { + if (element instanceof AmbleSlider slider) { + slider.setMax(max); + } + } + + /** + * Gets the slider step value. + * Only works if the underlying element is an AmbleSlider. + * + * @return the step value, or 0 if not a slider + */ + @LuaExpose + public float getStep() { + if (element instanceof AmbleSlider slider) { + return slider.getStep(); + } + return 0; + } + + /** + * Sets the slider step value. + * Only works if the underlying element is an AmbleSlider. + * + * @param step the step value (0 = continuous) + */ + @LuaExpose + public void setStep(float step) { + if (element instanceof AmbleSlider slider) { + slider.setStep(step); + } + } + + // ===== AmbleColorPicker methods ===== + + /** + * Gets the current color as RGB values. + * Only works if the underlying element is an AmbleColorPicker. + * + * @return array of [r, g, b, a] values (0-255), or null if not a color picker + */ + @LuaExpose + public int[] getColorRGBA() { + if (element instanceof AmbleColorPicker picker) { + return new int[] { picker.getRed(), picker.getGreen(), picker.getBlue(), picker.getAlpha() }; + } + return null; + } + + /** + * Sets the color from RGBA values. + * Only works if the underlying element is an AmbleColorPicker. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setColorRGBA(int r, int g, int b, int a) { + if (element instanceof AmbleColorPicker picker) { + picker.setColor(r, g, b, a); + } + } + + /** + * Gets the current color as a hex string. + * Only works if the underlying element is an AmbleColorPicker. + * + * @return hex string (RRGGBB or RRGGBBAA), or null if not a color picker + */ + @LuaExpose + public String getColorHex() { + if (element instanceof AmbleColorPicker picker) { + return picker.getColorHex(); + } + return null; + } + + /** + * Sets the color from a hex string. + * Only works if the underlying element is an AmbleColorPicker. + * + * @param hex hex string (with or without #, 6 or 8 characters) + */ + @LuaExpose + public void setColorHex(String hex) { + if (element instanceof AmbleColorPicker picker) { + picker.setColorHex(hex); + } + } + + /** + * Checks if the color picker is expanded. + * Only works if the underlying element is an AmbleColorPicker. + * + * @return true if expanded, false otherwise + */ + @LuaExpose + public boolean isPickerExpanded() { + if (element instanceof AmbleColorPicker picker) { + return picker.isExpanded(); + } + return false; + } + + /** + * Sets the expanded state of the color picker. + * Only works if the underlying element is an AmbleColorPicker. + * + * @param expanded true to expand, false to collapse + */ + @LuaExpose + public void setPickerExpanded(boolean expanded) { + if (element instanceof AmbleColorPicker picker) { + picker.setExpanded(expanded); + } + } + + /** + * Checks if the color picker includes alpha support. + * Only works if the underlying element is an AmbleColorPicker. + * + * @return true if alpha is included, false otherwise + */ + @LuaExpose + public boolean isIncludeAlpha() { + if (element instanceof AmbleColorPicker picker) { + return picker.isIncludeAlpha(); + } + return false; + } + + /** + * Sets whether the color picker includes alpha support. + * Only works if the underlying element is an AmbleColorPicker. + * + * @param includeAlpha true to include alpha slider + */ + @LuaExpose + public void setIncludeAlpha(boolean includeAlpha) { + if (element instanceof AmbleColorPicker picker) { + picker.setIncludeAlpha(includeAlpha); + } + } + /** * Returns the underlying AmbleElement wrapped by this LuaElement. * This method is for internal use only and should not be called from Lua scripts. diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index 187cafe..3736723 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -37,6 +37,8 @@ private AmbleGuiRegistry() { registerParser(new AmbleText.Parser()); registerParser(new AmbleEntityDisplay.Parser()); registerParser(new AmbleTextInput.Parser()); + registerParser(new AmbleSlider.Parser()); + registerParser(new AmbleColorPicker.Parser()); } /** diff --git a/src/main/java/dev/amble/lib/script/LuaScript.java b/src/main/java/dev/amble/lib/script/LuaScript.java index 9471d24..80b9a6c 100644 --- a/src/main/java/dev/amble/lib/script/LuaScript.java +++ b/src/main/java/dev/amble/lib/script/LuaScript.java @@ -72,4 +72,20 @@ public LuaValue onRelease() { public LuaValue onHover() { return getCallback("onHover"); } + + // ===== Value change callbacks ===== + + /** + * Called when a slider's value changes. + */ + public LuaValue onValueChanged() { + return getCallback("onValueChanged"); + } + + /** + * Called when a color picker's color changes. + */ + public LuaValue onColorChanged() { + return getCallback("onColorChanged"); + } } diff --git a/src/test/resources/assets/litmus/gui/skin_changer.json b/src/test/resources/assets/litmus/gui/skin_changer.json index 7e5fc3d..236f767 100644 --- a/src/test/resources/assets/litmus/gui/skin_changer.json +++ b/src/test/resources/assets/litmus/gui/skin_changer.json @@ -97,7 +97,7 @@ "placeholder": "Enter username...", "max_length": 16, "layout": [ - 154, + 130, 20 ], "background": [ @@ -128,6 +128,18 @@ 120 ] }, + { + "id": "litmus:name_color_picker", + "color_picker": true, + "initial_color": [255, 255, 255], + "include_alpha": false, + "layout": [20, 20], + "background": [20, 20, 30, 255], + "border_collapsed": [80, 80, 100], + "border_collapsed_hover": [100, 140, 220], + "background_expanded": [30, 30, 45, 245], + "border_expanded": [60, 60, 80] + }, { "id": "litmus:set_skin_btn", "script": "litmus:skin_changer", @@ -157,56 +169,56 @@ ] }, { - "id": "litmus:slim_toggle_btn", - "script": "litmus:skin_changer_slim", + "id": "litmus:set_username_btn", + "script": "litmus:username_changer", "hover_background": [ + 100, 80, - 80, - 120 + 70 ], "press_background": [ + 70, 50, - 50, - 80 + 40 ], "layout": [ 154, 20 ], "background": [ + 80, 60, - 60, - 90 + 50 ], - "text": "§fModel: Classic", + "text": "§fSet Username", "text_alignment": [ "centre", "centre" ] }, { - "id": "litmus:set_username_btn", - "script": "litmus:username_changer", + "id": "litmus:slim_toggle_btn", + "script": "litmus:skin_changer_slim", "hover_background": [ - 100, 80, - 70 + 80, + 120 ], "press_background": [ - 70, 50, - 40 + 50, + 80 ], "layout": [ 154, 20 ], "background": [ - 80, 60, - 50 + 60, + 90 ], - "text": "§fSet Username", + "text": "§fModel: Classic", "text_alignment": [ "centre", "centre" diff --git a/src/test/resources/assets/litmus/script/username_changer.lua b/src/test/resources/assets/litmus/script/username_changer.lua index 4c1786b..3cc4ba0 100644 --- a/src/test/resources/assets/litmus/script/username_changer.lua +++ b/src/test/resources/assets/litmus/script/username_changer.lua @@ -1,5 +1,5 @@ -- Username Changer Script --- Handles the "Set Username" button functionality +-- Handles the "Set Username" button functionality with color support local GuiUtils = require("litmus:gui_utils") @@ -9,10 +9,22 @@ function onClick(self, mouseX, mouseY, button) local username, input, statusText = GuiUtils.validateInput(self, "litmus:username_input", "litmus:status_text") if not username then return end - -- Set the username using the Lua API - if mc:setUsername(mc:username(), username) then + -- Get color from color picker + local root = getRoot(self) + local colorPicker = findById(root, "litmus:name_color_picker") + + local colorHex = "FFFFFF" + if colorPicker then + colorHex = colorPicker:getColorHex() + end + + -- Build JSON text component with the selected color + local jsonText = '{"text":"' .. username .. '","color":"#' .. colorHex .. '"}' + + -- Set the username using the Lua API with JSON text + if mc:setUsernameJson(mc:username(), jsonText) then -- Success feedback - GuiUtils.onSuccess(mc, input, statusText, "Username set to: " .. username) + GuiUtils.onSuccess(mc, input, statusText, "Username set to: §#" .. colorHex .. username) else if statusText then statusText:setText("§cFailed to set username!")