From 4ac7c60d076006a3ab78cdd46cdf0218d8643bce Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 16:46:17 +0200 Subject: [PATCH 01/13] Add rounded borders to RenderScope drawRoundedBorder draws an outline inset into the given bounds so it lines up with a rounded rectangle of the same size and radius, and a new drawRoundedRectangle overload draws both with separate background and border colors. Both shapes share one SVG-to-texture helper. Co-Authored-By: Claude Opus 5.5 --- .../java/org/saturnclient/ui/RenderScope.java | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/saturnclient/ui/RenderScope.java b/src/main/java/org/saturnclient/ui/RenderScope.java index ed4513f..0bbc677 100644 --- a/src/main/java/org/saturnclient/ui/RenderScope.java +++ b/src/main/java/org/saturnclient/ui/RenderScope.java @@ -3,6 +3,7 @@ package org.saturnclient.ui; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.Locale; import org.saturnclient.common.ref.asset.IdentifierRef; import org.saturnclient.common.ref.asset.SpriteRef; @@ -36,19 +37,66 @@ public interface RenderScope { radius = Math.min(radius, width / 2); radius = Math.min(radius, height / 2); - String svg = String.format( - "" + - "" + - "", - width, height, + String rect = String.format( + "", width, height, radius, radius); + drawSvgShape(rect, "rounded_" + width + "_" + height + "_" + radius, x, y, width, height, color); + } + + /** + * Draws a rounded outline that stays inside the given bounds, so it lines up + * with a {@link #drawRoundedRectangle} of the same size and radius. + */ + default void drawRoundedBorder(int x, int y, int width, int height, int radius, int thickness, int color) { + if (color == 0 || thickness <= 0) + return; + + radius = Math.min(radius, width / 2); + radius = Math.min(radius, height / 2); + thickness = Math.min(thickness, Math.min(width, height) / 2); + + // The stroke is centered on the path, so inset it by half its width. + float inset = thickness / 2f; + float innerRadius = Math.max(0, radius - inset); + + String rect = String.format(Locale.ROOT, + "", + inset, inset, + width - (float) thickness, height - (float) thickness, + innerRadius, innerRadius, + thickness); + + drawSvgShape(rect, "rounded_border_" + width + "_" + height + "_" + radius + "_" + thickness, + x, y, width, height, color); + } + + /** + * Draws a rounded rectangle filled with {@code background} and outlined with + * {@code border}. Either color can be 0 to skip that part. + */ + default void drawRoundedRectangle(int x, int y, int width, int height, int radius, int thickness, + int background, int border) { + drawRoundedRectangle(x, y, width, height, radius, background); + drawRoundedBorder(x, y, width, height, radius, thickness, border); + } + + /** + * Renders {@code shape} (SVG elements in a {@code width} x {@code height} + * canvas, drawn in white) to a cached texture and draws it tinted with + * {@code color}. + */ + private void drawSvgShape(String shape, String cacheKey, int x, int y, int width, int height, int color) { + String svg = String.format( + "%s", + width, height, shape); + InputStream svgStream = new ByteArrayInputStream(svg.getBytes(StandardCharsets.UTF_8)); drawTexture(SvgTexture.getSvg(svgStream, - IdentifierRef.ofSaturn( - "textures/gui/tmp_rect/rounded_" + width + "_" + height + "_" + radius), + IdentifierRef.ofSaturn("textures/gui/tmp_rect/" + cacheKey), width * 4, height * 4), x, y, 0, 0, width, height, color); } -- 2.54.0 From dfebd2367d49a77f8765377d9272eff3768bdbd2 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 16:59:51 +0200 Subject: [PATCH 02/13] Redesign the mod menu The mod menu is now a translucent panel beside a navigation rail, with a header showing how many mods are enabled, a search box, category filters built from the mods' tags and a three-column grid of mod cards. Each card shows the mod's icon, name and description with a settings button and an on/off switch. The layout shrinks to fit smaller windows. New reusable pieces: Panel, Label, ChipGroup, SearchInput, Toggle.drawSwitch, Theme helpers for panel, border, muted text and body font, and Fonts.ellipsize. The sidebar and card icons are Lucide SVGs, and seven mod icons were redrawn in the same style. Scroll can skip its background and be cleared, and no longer passes clicks outside its bounds to children scrolled out of view. Input checks overflow and places the cursor at the scale its text is drawn at. Screens that are not redesigned yet move the wider sidebar so it keeps its gap. Co-Authored-By: Claude Opus 5.5 --- .../java/org/saturnclient/config/Theme.java | 29 +++ .../ui/components/SaturnModule.java | 195 +++++++----------- .../saturnclient/ui/components/Sidebar.java | 159 +++++++------- .../ui/components/inputs/Input.java | 28 ++- .../ui/components/inputs/SearchInput.java | 79 +++++++ .../ui/components/inputs/Toggle.java | 20 ++ .../saturnclient/ui/elements/ChipGroup.java | 85 ++++++++ .../org/saturnclient/ui/elements/Label.java | 33 +++ .../org/saturnclient/ui/elements/Panel.java | 22 ++ .../org/saturnclient/ui/elements/Scroll.java | 33 ++- .../org/saturnclient/ui/resources/Fonts.java | 18 ++ .../saturnclient/ui/resources/Textures.java | 9 + .../saturnclient/ui/screens/ConfigEditor.java | 2 +- .../org/saturnclient/ui/screens/ModMenu.java | 161 ++++++++++++--- .../ui/screens/cosmetics/CloakMenu.java | 4 +- .../ui/screens/cosmetics/HatMenu.java | 4 +- .../ui/screens/store/CloakStore.java | 4 +- .../ui/screens/store/HatStore.java | 4 +- .../textures/gui/components/close.svg | 1 + .../textures/gui/components/cosmetics.svg | 1 + .../textures/gui/components/hud_editor.svg | 1 + .../textures/gui/components/mods.svg | 1 + .../textures/gui/components/search.svg | 1 + .../textures/gui/components/settings.svg | 1 + .../textures/gui/components/sliders.svg | 1 + .../saturnclient/textures/gui/mod/clock.svg | 8 +- .../textures/gui/mod/crosshair.svg | 2 +- .../textures/gui/mod/nametags.svg | 8 +- .../saturnclient/textures/gui/mod/ping.svg | 8 +- .../saturnclient/textures/gui/mod/sprint.svg | 36 +--- .../saturnclient/textures/gui/mod/tps.svg | 8 +- .../saturnclient/textures/gui/mod/zoom.svg | 2 +- 32 files changed, 669 insertions(+), 299 deletions(-) create mode 100644 src/main/java/org/saturnclient/ui/components/inputs/SearchInput.java create mode 100644 src/main/java/org/saturnclient/ui/elements/ChipGroup.java create mode 100644 src/main/java/org/saturnclient/ui/elements/Label.java create mode 100644 src/main/java/org/saturnclient/ui/elements/Panel.java create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/close.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/cosmetics.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/hud_editor.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/mods.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/search.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/settings.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/sliders.svg diff --git a/src/main/java/org/saturnclient/config/Theme.java b/src/main/java/org/saturnclient/config/Theme.java index 70ad3af..8a9af41 100644 --- a/src/main/java/org/saturnclient/config/Theme.java +++ b/src/main/java/org/saturnclient/config/Theme.java @@ -64,6 +64,35 @@ public class Theme { return hover ? ACCENT_FG.value : PRIMARY_FG.value; } + /** + * Translucent background for panels drawn over the world + */ + public static int getPanelBg() { + return withAlpha(0.82f, BACKGROUND.value); + } + + /** + * Faint outline for panels, cards and inputs + */ + public static int getBorder() { + return withAlpha(0.08f, FOREGROUND.value); + } + + /** + * Secondary text: descriptions, counts and placeholders + */ + public static int getMutedFg() { + return withAlpha(0.65f, FOREGROUND.value); + } + + /** + * Font for secondary text. Inter bold is too heavy there, so it becomes + * regular Inter; other fonts are kept. + */ + public static int getBodyFont() { + return FONT.value == 2 ? 1 : FONT.value; + } + /** * Multiplies existing alpha with 0–255 alpha. */ diff --git a/src/main/java/org/saturnclient/ui/components/SaturnModule.java b/src/main/java/org/saturnclient/ui/components/SaturnModule.java index 3a753e3..1af513f 100644 --- a/src/main/java/org/saturnclient/ui/components/SaturnModule.java +++ b/src/main/java/org/saturnclient/ui/components/SaturnModule.java @@ -7,141 +7,100 @@ import org.saturnclient.mod.Mod; import org.saturnclient.ui.Element; import org.saturnclient.ui.ElementContext; import org.saturnclient.ui.RenderScope; -import org.saturnclient.ui.Utils; +import org.saturnclient.ui.components.inputs.Toggle; import org.saturnclient.ui.resources.Fonts; import org.saturnclient.ui.resources.Textures; import org.saturnclient.ui.screens.ConfigEditor; +/** + * A mod's card in the mod menu: icon, name, description, a settings button and + * an on/off switch. Clicking anywhere but the settings button toggles the mod. + */ public class SaturnModule extends Element { + public static final int HEIGHT = 76; + + private static final int PADDING = 12; + private static final int ICON_TILE = 44; + private static final int ICON = 22; + private static final int SETTINGS = 32; + private static final int SETTINGS_ICON = 17; + private static final int GAP = 12; + private static final float NAME_SCALE = 0.74f; + private static final float DESCRIPTION_SCALE = 0.64f; private final Mod mod; - static int p = 10; // padding - static int s = 14; // settings icon size - static int expand = 5; // settings icon click area - - public SaturnModule(Mod mod) { + public SaturnModule(Mod mod, int width) { this.mod = mod; - this.width = 160; - this.height = 50; + this.width = width; + this.height = HEIGHT; } @Override public void render(RenderScope renderScope, ElementContext ctx) { - boolean hovered = ctx.isHovering(); boolean enabled = mod.isEnabled(); + boolean settingsHovered = ctx.isHovering(getSettingsX(), getSettingsY(), SETTINGS, SETTINGS); + boolean hovered = ctx.isHovering() && !settingsHovered; - int iconX = width - s - p; - int iconY = height - s - p; + int border = enabled ? Theme.withAlpha(hovered ? 0.7f : 0.4f, Theme.ACCENT.value) + : Theme.withAlpha(hovered ? 0.22f : 0.06f, Theme.FOREGROUND.value); - boolean settingsHover = ctx.isHovering( - getSettingsX(), - getSettingsY(), - getSettingsHitBox(), - getSettingsHitBox()); + // Card + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1, + Theme.withAlpha(0.72f, Theme.PRIMARY.value), border); - // Resolve theme colors - int bgColor = Theme.PRIMARY.value; - int fgColor = hovered && !settingsHover ? Theme.ACCENT.value : Theme.PRIMARY_FG.value; - - int iconBg = enabled ? Theme.ACCENT.value : Theme.PRIMARY.value; - int iconFg = enabled ? Theme.ACCENT_FG.value : fgColor; - - int settingsColor = settingsHover - ? Theme.ACCENT.value - : Theme.withAlpha(160, fgColor); - - int h = height - (p * 2); - - // Main background - renderScope.drawRoundedRectangle( - 0, 0, - width, height, - Theme.WIDGET_RADIUS.value, - bgColor); - - // Icon background - renderScope.drawRoundedRectangle( - p, p, - h, h, - Theme.WIDGET_RADIUS.value, - iconBg); - - // Icon - renderScope.drawTexture( - mod.getIconTexture(), - p + (p / 2), - p + (p / 2), - 0, 0, - h - p, - h - p, - iconFg); - - // Module name - renderScope.drawText( - 0.6f, - mod.getName(), - p + h + 4, - p + 1, - Theme.FONT.value, - fgColor); - - // Version (right aligned) - String version = mod.getVersion(); - int versionWidth = (int) (Fonts.getWidth(version, Theme.FONT.value) * 0.35f); - - renderScope.drawText( - 0.35f, - version, - width - p - versionWidth, - p + 1, - Theme.FONT.value, - fgColor); - - // Tags - int xt = p + h + 4; - for (String tag : mod.getTags()) { - int tagWidth = (int) (Fonts.getWidth(tag, Theme.FONT.value) * 0.4f); - - renderScope.drawRoundedRectangle( - xt, - p + (h / 2), - tagWidth + 6, - 12, - 5, - Theme.ACCENT.value); - - renderScope.drawText( - 0.4f, - tag, - xt + 2, - p + (h / 2) + 3, - Theme.FONT.value, - Theme.ACCENT_FG.value); - - xt += tagWidth + 9; + if (enabled) { + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, + Theme.withAlpha(0.06f, Theme.ACCENT.value)); } - // Settings icon - renderScope.drawTexture( - Textures.SETTINGS, - iconX, - iconY, - 0, 0, - s, s, - settingsColor); + // Icon + int tileY = (height - ICON_TILE) / 2; + renderScope.drawRoundedRectangle(PADDING, tileY, ICON_TILE, ICON_TILE, Theme.WIDGET_RADIUS.value, + enabled ? Theme.withAlpha(0.18f, Theme.ACCENT.value) : Theme.withAlpha(0.06f, Theme.FOREGROUND.value)); + + renderScope.drawTexture(mod.getIconTexture(), + PADDING + (ICON_TILE - ICON) / 2, tileY + (ICON_TILE - ICON) / 2, + 0, 0, ICON, ICON, Theme.PRIMARY_FG.value); + + // Name and description + int textX = PADDING + ICON_TILE + GAP; + int textWidth = getSettingsX() - GAP - textX; + int nameHeight = (int) (Fonts.getHeight() * NAME_SCALE); + int descriptionHeight = (int) (Fonts.getHeight() * DESCRIPTION_SCALE); + int nameY = (height - nameHeight - 5 - descriptionHeight) / 2; + + renderScope.drawText(NAME_SCALE, + Fonts.ellipsize(mod.getName(), Theme.FONT.value, NAME_SCALE, textWidth), + textX, nameY, Theme.FONT.value, Theme.FOREGROUND.value); + + String description = mod.getDescription() == null ? "" : mod.getDescription(); + renderScope.drawText(DESCRIPTION_SCALE, + Fonts.ellipsize(description, Theme.getBodyFont(), DESCRIPTION_SCALE, textWidth), + textX, nameY + nameHeight + 5, Theme.getBodyFont(), Theme.getMutedFg()); + + // Settings button + if (settingsHovered) { + renderScope.drawRoundedRectangle(getSettingsX(), getSettingsY(), SETTINGS, SETTINGS, + Theme.WIDGET_RADIUS.value, Theme.withAlpha(0.08f, Theme.FOREGROUND.value)); + } + + int settingsIconOffset = (SETTINGS - SETTINGS_ICON) / 2; + renderScope.drawTexture(Textures.ICON_SLIDERS, + getSettingsX() + settingsIconOffset, getSettingsY() + settingsIconOffset, + 0, 0, SETTINGS_ICON, SETTINGS_ICON, + settingsHovered ? Theme.ACCENT.value : Theme.getMutedFg()); + + // Switch + Toggle.drawSwitch(renderScope, getSwitchX(), (height - Toggle.SWITCH_HEIGHT) / 2, enabled); } @Override public void click(int mouseX, int mouseY) { - boolean settingsHover = Utils.isHovering( - mouseX - getSettingsX(), - mouseY - getSettingsY(), - getSettingsHitBox(), - getSettingsHitBox(), - 1.0f); + boolean settingsClicked = mouseX >= getSettingsX() && mouseX <= getSettingsX() + SETTINGS + && mouseY >= getSettingsY() && mouseY <= getSettingsY() + SETTINGS; - if (settingsHover) { + if (settingsClicked) { if (mod instanceof HudMod h) { Providers.saturn.getClient().setScreen(new ConfigEditor(mod.getConfig(), h)); } else { @@ -152,15 +111,15 @@ public class SaturnModule extends Element { } } + private int getSwitchX() { + return width - PADDING - Toggle.SWITCH_WIDTH; + } + private int getSettingsX() { - return width - s - p - expand; + return getSwitchX() - 8 - SETTINGS; } private int getSettingsY() { - return height - s - p - expand; + return (height - SETTINGS) / 2; } - - private int getSettingsHitBox() { - return s + expand * 2; - } -} \ No newline at end of file +} diff --git a/src/main/java/org/saturnclient/ui/components/Sidebar.java b/src/main/java/org/saturnclient/ui/components/Sidebar.java index 006b5a5..e263189 100644 --- a/src/main/java/org/saturnclient/ui/components/Sidebar.java +++ b/src/main/java/org/saturnclient/ui/components/Sidebar.java @@ -7,51 +7,48 @@ import org.saturnclient.config.Theme; import org.saturnclient.ui.Element; import org.saturnclient.ui.ElementContext; import org.saturnclient.ui.RenderScope; -import org.saturnclient.ui.Utils; +import org.saturnclient.ui.elements.Panel; import org.saturnclient.ui.resources.Textures; import org.saturnclient.ui.screens.ConfigEditor; +import org.saturnclient.ui.screens.HudEditor; import org.saturnclient.ui.screens.ModMenu; import org.saturnclient.ui.screens.cosmetics.CloakMenu; -import org.saturnclient.ui.screens.store.CloakStore; +/** + * The navigation rail beside Saturn's screens: the logo, one button per screen, + * and a close button at the bottom. + */ public class Sidebar extends Element { - public static class SidebarComponent { - Runnable onClick; - boolean end; - IdentifierRef sprite; + public static final int WIDTH = 60; - public SidebarComponent(IdentifierRef sprite, Runnable onClick, boolean end) { - this.onClick = onClick; - this.end = end; - this.sprite = sprite; - } + // Values for the constructor's `active` + public static final int MODS = 0; + public static final int COSMETICS = 1; + public static final int HUD_EDITOR = 2; + public static final int SETTINGS = 3; + + private static final int LOGO = 32; + private static final int BUTTON = 44; + private static final int ICON = 22; + private static final int GAP = 8; + private static final int PADDING = 12; + + private record Item(IdentifierRef icon, Runnable onClick) { } - Runnable onClose; - SidebarComponent[] components = { - new Sidebar.SidebarComponent(Textures.MODS_TAB, () -> { - Providers.saturn.getClient().setScreen(new ModMenu()); - }, false), - new Sidebar.SidebarComponent(Textures.SETTINGS, () -> { - Providers.saturn.getClient().setScreen(new ConfigEditor(Config.config)); - }, false), - - new Sidebar.SidebarComponent(Textures.SHIRT, () -> { - Providers.saturn.getClient().setScreen(new CloakMenu()); - }, false), - - new Sidebar.SidebarComponent(Textures.CLOSE, () -> { - onClose.run(); - }, true), - - new Sidebar.SidebarComponent(Textures.STORE, () -> { - Providers.saturn.getClient().setScreen(new CloakStore()); - }, true), + private final Item[] items = { + new Item(Textures.ICON_MODS, () -> Providers.saturn.getClient().setScreen(new ModMenu())), + new Item(Textures.ICON_COSMETICS, () -> Providers.saturn.getClient().setScreen(new CloakMenu())), + new Item(Textures.ICON_HUD_EDITOR, () -> Providers.saturn.getClient().setScreen(new HudEditor())), + new Item(Textures.ICON_SETTINGS, + () -> Providers.saturn.getClient().setScreen(new ConfigEditor(Config.config))), }; - int active = 0; + + private final int active; + private final Runnable onClose; public Sidebar(int active, Runnable onClose) { - this.width = 30; + this.width = WIDTH; this.height = 350; this.active = active; this.onClose = onClose; @@ -59,63 +56,75 @@ public class Sidebar extends Element { @Override public void render(RenderScope renderScope, ElementContext ctx) { - int topRow = 0; - int bottomRow = 0; + Panel.drawBackground(renderScope, width, height); - int gap = 5; // 5px gap between icons - int padding = 10; - int sp = 30 - padding + gap; - int s = 30 - (padding * 2); - int s2 = s + padding; + renderScope.drawTexture(Textures.LOGO, (width - LOGO) / 2, PADDING, 0, 0, LOGO, LOGO); - renderScope.drawRoundedRectangle(0, 0, width, height, Theme.BG_RADIUS.value, Theme.BACKGROUND.value); + int x = getButtonX(); - int i = 0; + for (int i = 0; i < items.length; i++) { + int y = getItemY(i); + boolean selected = i == active; + boolean hovered = ctx.isHovering(x, y, BUTTON, BUTTON); - for (SidebarComponent component : components) { - int y = component.end ? ((height - (sp * bottomRow)) - (s * 2)) : (padding + (sp * topRow)); - boolean selected = ctx.isHovering(0, y - (padding / 2), s2, s2) || active == i; - - renderScope.drawRoundedRectangle(padding / 2, y - (padding / 2), s + padding, - s + padding, Theme.WIDGET_RADIUS.value, Theme.getBg(selected)); - - renderScope.drawTexture(component.sprite, padding, y, 0, 0, s, s, Theme.getFg(selected)); - - if (component.end) { - bottomRow += 1; - } else { - topRow += 1; + if (selected) { + renderScope.drawRoundedRectangle(x, y, BUTTON, BUTTON, Theme.WIDGET_RADIUS.value, Theme.ACCENT.value); + } else if (hovered) { + renderScope.drawRoundedRectangle(x, y, BUTTON, BUTTON, Theme.WIDGET_RADIUS.value, + Theme.withAlpha(0.08f, Theme.FOREGROUND.value)); } - i++; + drawIcon(renderScope, items[i].icon(), x, y, + selected ? Theme.ACCENT_FG.value + : hovered ? Theme.FOREGROUND.value : Theme.withAlpha(0.8f, Theme.FOREGROUND.value)); } + + int closeY = getCloseY(); + boolean closeHovered = ctx.isHovering(x, closeY, BUTTON, BUTTON); + + renderScope.drawRoundedRectangle(x, closeY, BUTTON, BUTTON, Theme.WIDGET_RADIUS.value, + Theme.withAlpha(closeHovered ? 0.12f : 0.06f, Theme.FOREGROUND.value)); + + drawIcon(renderScope, Textures.ICON_CLOSE, x, closeY, + closeHovered ? Theme.FOREGROUND.value : Theme.withAlpha(0.8f, Theme.FOREGROUND.value)); } @Override public void click(int mouseX, int mouseY) { - int topRow = 0; - int bottomRow = 0; + int x = getButtonX(); - int gap = 5; - int padding = 10; - int sp = 30 - padding + gap; - int s = 30 - (padding * 2); - int s2 = s + padding; + if (mouseX < x || mouseX > x + BUTTON) { + return; + } - for (SidebarComponent component : components) { - int y = component.end ? ((height - (sp * bottomRow)) - (s * 2)) : (padding + (sp * topRow)); - boolean hovering = Utils.isHovering(mouseX - (padding / 2), mouseY - (y - (padding / 2)), s2, s2, scale); + for (int i = 0; i < items.length; i++) { + int y = getItemY(i); - if (hovering) { - component.onClick.run(); - break; - } - - if (component.end) { - bottomRow += 1; - } else { - topRow += 1; + if (mouseY >= y && mouseY <= y + BUTTON) { + items[i].onClick().run(); + return; } } + + if (mouseY >= getCloseY() && mouseY <= getCloseY() + BUTTON) { + onClose.run(); + } + } + + private static void drawIcon(RenderScope renderScope, IdentifierRef icon, int x, int y, int color) { + int offset = (BUTTON - ICON) / 2; + renderScope.drawTexture(icon, x + offset, y + offset, 0, 0, ICON, ICON, color); + } + + private int getButtonX() { + return (width - BUTTON) / 2; + } + + private int getItemY(int index) { + return PADDING + LOGO + 14 + index * (BUTTON + GAP); + } + + private int getCloseY() { + return height - PADDING - BUTTON; } } diff --git a/src/main/java/org/saturnclient/ui/components/inputs/Input.java b/src/main/java/org/saturnclient/ui/components/inputs/Input.java index 8947416..421c759 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/Input.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/Input.java @@ -11,6 +11,11 @@ public abstract class Input extends Element { String text = ""; public int cursorPosition = 0; + /** Scale the text is drawn at */ + protected float textScale = 0.6f; + /** Horizontal inset of the text on both sides */ + protected int textX = 4; + public abstract void checkReset(); public abstract void backspace(); @@ -41,6 +46,7 @@ public abstract class Input extends Element { @Override public void click(int mouseX, int mouseY) { + mouseX -= textX; int scrollOffset = getScrollOffset(); String visibleText = text.substring(scrollOffset); @@ -48,7 +54,7 @@ public abstract class Input extends Element { float textWidth = 0; for (int i = 0; i < visibleText.length(); i++) { - textWidth = Fonts.getWidth(visibleText.substring(0, i + 1), Theme.FONT.value) * 0.6f; + textWidth = Fonts.getWidth(visibleText.substring(0, i + 1), Theme.FONT.value) * textScale; if (textWidth > mouseX) { break; @@ -68,24 +74,32 @@ public abstract class Input extends Element { int scrollOffset = getScrollOffset(); String visibleText = getVisibleText(scrollOffset); - renderScope.drawText(0.6f, visibleText, 4, 4, Theme.FONT.value, Theme.PRIMARY_FG.value); + renderScope.drawText(textScale, visibleText, textX, 4, Theme.FONT.value, Theme.PRIMARY_FG.value); if (focused) { - int cursorX = 4 + - (int) (Fonts.getWidth(visibleText.substring(0, cursorPosition - scrollOffset), Theme.FONT.value) - * 0.6f); + int cursorX = getCursorX(visibleText, scrollOffset); renderScope.drawRectangle(cursorX, 2, 1, height - 4, Theme.ACCENT.value); } } + protected int getCursorX(String visibleText, int scrollOffset) { + return textX + (int) (Fonts.getWidth(visibleText.substring(0, cursorPosition - scrollOffset), + Theme.FONT.value) * textScale); + } + + /** Width available to the text, in unscaled font units */ + private float getTextSpace() { + return (width - textX * 2) / textScale; + } + protected int getScrollOffset() { if (text.isEmpty()) { return 0; } int offset = 0; - int maxWidth = width - 4; // Available width for text + float maxWidth = getTextSpace(); while (offset < cursorPosition) { // Ensure cursor remains visible String visibleText = text.substring(offset, cursorPosition); if (Fonts.getWidth(visibleText, Theme.FONT.value) > maxWidth) { @@ -102,7 +116,7 @@ public abstract class Input extends Element { // Ensure text does not overflow past the available width for (int i = 1; i <= visibleText.length(); i++) { - if (Fonts.getWidth(visibleText.substring(0, i), Theme.FONT.value) > width - 4) { + if (Fonts.getWidth(visibleText.substring(0, i), Theme.FONT.value) > getTextSpace()) { return visibleText.substring(0, i - 1); // Trim overflow } } diff --git a/src/main/java/org/saturnclient/ui/components/inputs/SearchInput.java b/src/main/java/org/saturnclient/ui/components/inputs/SearchInput.java new file mode 100644 index 0000000..f515408 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/inputs/SearchInput.java @@ -0,0 +1,79 @@ +package org.saturnclient.ui.components.inputs; + +import java.util.function.Consumer; + +import org.saturnclient.config.Theme; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.resources.Fonts; +import org.saturnclient.ui.resources.Textures; + +/** + * A free-text search box with an icon and placeholder, reporting every change. + */ +public class SearchInput extends Input { + private static final int ICON_SIZE = 16; + private static final int ICON_X = 12; + + private final String placeholder; + private final Consumer onChange; + + public SearchInput(String placeholder, Consumer onChange) { + this.placeholder = placeholder; + this.onChange = onChange; + this.textScale = 0.68f; + this.textX = ICON_X + ICON_SIZE + 8; + this.width = 240; + this.height = 36; + } + + @Override + public void charTyped(char chr) { + if (text.length() >= 64) { + return; + } + + text = text.substring(0, cursorPosition) + chr + text.substring(cursorPosition); + cursorPosition++; + onChange.accept(text); + } + + @Override + public void backspace() { + onChange.accept(text); + } + + @Override + public void checkReset() { + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + int border = focused ? Theme.withAlpha(0.6f, Theme.ACCENT.value) + : ctx.isHovering() ? Theme.withAlpha(0.25f, Theme.FOREGROUND.value) + : Theme.withAlpha(0.1f, Theme.FOREGROUND.value); + + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1, + Theme.withAlpha(0.06f, Theme.FOREGROUND.value), border); + + renderScope.drawTexture(Textures.ICON_SEARCH, ICON_X, (height - ICON_SIZE) / 2, 0, 0, + ICON_SIZE, ICON_SIZE, Theme.getMutedFg()); + + int textHeight = (int) (Fonts.getHeight() * textScale); + int textY = (height - textHeight) / 2; + + if (text.isEmpty() && !focused) { + renderScope.drawText(textScale, placeholder, textX, textY, Theme.getBodyFont(), Theme.getMutedFg()); + return; + } + + int scrollOffset = getScrollOffset(); + String visibleText = getVisibleText(scrollOffset); + renderScope.drawText(textScale, visibleText, textX, textY, Theme.FONT.value, Theme.FOREGROUND.value); + + if (focused) { + renderScope.drawRectangle(getCursorX(visibleText, scrollOffset), textY - 1, 1, textHeight + 2, + Theme.ACCENT.value); + } + } +} diff --git a/src/main/java/org/saturnclient/ui/components/inputs/Toggle.java b/src/main/java/org/saturnclient/ui/components/inputs/Toggle.java index c55f940..0987723 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/Toggle.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/Toggle.java @@ -23,6 +23,26 @@ public class Toggle extends Element { prop.value ? Theme.ACCENT.value : Theme.PRIMARY.value); } + public static final int SWITCH_WIDTH = 42; + public static final int SWITCH_HEIGHT = 24; + + /** + * Draws an on/off switch: a pill track with a round knob on the right when on. + */ + public static void drawSwitch(RenderScope renderScope, int x, int y, boolean on) { + int inset = 3; + int knob = SWITCH_HEIGHT - inset * 2; + + renderScope.drawRoundedRectangle(x, y, SWITCH_WIDTH, SWITCH_HEIGHT, SWITCH_HEIGHT / 2, + on ? Theme.ACCENT.value : Theme.withAlpha(0.16f, Theme.FOREGROUND.value)); + + renderScope.drawRoundedRectangle( + on ? x + SWITCH_WIDTH - inset - knob : x + inset, + y + inset, + knob, knob, knob / 2, + on ? Theme.ACCENT_FG.value : Theme.withAlpha(0.8f, Theme.PRIMARY_FG.value)); + } + @Override public void click(int mouseX, int mouseY) { prop.value = !prop.value; diff --git a/src/main/java/org/saturnclient/ui/elements/ChipGroup.java b/src/main/java/org/saturnclient/ui/elements/ChipGroup.java new file mode 100644 index 0000000..6f23659 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/elements/ChipGroup.java @@ -0,0 +1,85 @@ +package org.saturnclient.ui.elements; + +import java.util.List; +import java.util.function.Consumer; + +import org.saturnclient.config.Theme; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.resources.Fonts; + +/** + * A row of pill buttons where exactly one is selected, such as category filters. + */ +public class ChipGroup extends Element { + private static final int HEIGHT = 28; + private static final int PADDING = 14; + private static final int GAP = 8; + private static final float TEXT_SCALE = 0.62f; + + private final List options; + private final Consumer onSelect; + private String selected; + + public ChipGroup(List options, String selected, Consumer onSelect) { + this.options = options; + this.selected = selected; + this.onSelect = onSelect; + this.height = HEIGHT; + this.width = chipX(options.size()) - GAP; + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + int font = Theme.FONT.value; + int textY = (HEIGHT - (int) (Fonts.getHeight() * TEXT_SCALE)) / 2; + + for (int i = 0; i < options.size(); i++) { + String option = options.get(i); + int x = chipX(i); + int w = chipWidth(option); + boolean active = option.equals(selected); + boolean hovered = ctx.isHovering(x, 0, w, HEIGHT); + + if (active) { + renderScope.drawRoundedRectangle(x, 0, w, HEIGHT, HEIGHT / 2, Theme.ACCENT.value); + } else { + renderScope.drawRoundedRectangle(x, 0, w, HEIGHT, HEIGHT / 2, 1, + Theme.withAlpha(0.05f, Theme.FOREGROUND.value), + hovered ? Theme.withAlpha(0.25f, Theme.FOREGROUND.value) : Theme.getBorder()); + } + + renderScope.drawText(TEXT_SCALE, option, x + PADDING, textY, font, + active ? Theme.ACCENT_FG.value : Theme.PRIMARY_FG.value); + } + } + + @Override + public void click(int mouseX, int mouseY) { + for (int i = 0; i < options.size(); i++) { + String option = options.get(i); + int x = chipX(i); + + if (mouseX >= x && mouseX <= x + chipWidth(option)) { + if (!option.equals(selected)) { + selected = option; + onSelect.accept(option); + } + return; + } + } + } + + private int chipX(int index) { + int x = 0; + for (int i = 0; i < index; i++) { + x += chipWidth(options.get(i)) + GAP; + } + return x; + } + + private static int chipWidth(String option) { + return (int) (Fonts.getWidth(option, Theme.FONT.value) * TEXT_SCALE) + PADDING * 2; + } +} diff --git a/src/main/java/org/saturnclient/ui/elements/Label.java b/src/main/java/org/saturnclient/ui/elements/Label.java new file mode 100644 index 0000000..a0be75a --- /dev/null +++ b/src/main/java/org/saturnclient/ui/elements/Label.java @@ -0,0 +1,33 @@ +package org.saturnclient.ui.elements; + +import java.util.function.IntSupplier; +import java.util.function.Supplier; + +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.resources.Fonts; + +/** + * A line of text whose content, font and color are read on every frame, so it + * follows state and theme changes without rebuilding the screen. + */ +public class Label extends Element { + private final Supplier text; + private final float textScale; + private final IntSupplier font; + private final IntSupplier color; + + public Label(Supplier text, float textScale, IntSupplier font, IntSupplier color) { + this.text = text; + this.textScale = textScale; + this.font = font; + this.color = color; + this.height = (int) (Fonts.getHeight() * textScale); + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + renderScope.drawText(textScale, text.get(), 0, 0, font.getAsInt(), color.getAsInt()); + } +} diff --git a/src/main/java/org/saturnclient/ui/elements/Panel.java b/src/main/java/org/saturnclient/ui/elements/Panel.java new file mode 100644 index 0000000..2b3369e --- /dev/null +++ b/src/main/java/org/saturnclient/ui/elements/Panel.java @@ -0,0 +1,22 @@ +package org.saturnclient.ui.elements; + +import org.saturnclient.config.Theme; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; + +/** + * The translucent, outlined surface that screens lay their content on. + */ +public class Panel extends Element { + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + drawBackground(renderScope, width, height); + } + + public static void drawBackground(RenderScope renderScope, int width, int height) { + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.BG_RADIUS.value, 1, + Theme.getPanelBg(), Theme.getBorder()); + } +} diff --git a/src/main/java/org/saturnclient/ui/elements/Scroll.java b/src/main/java/org/saturnclient/ui/elements/Scroll.java index 31daaa3..fe6fcf3 100644 --- a/src/main/java/org/saturnclient/ui/elements/Scroll.java +++ b/src/main/java/org/saturnclient/ui/elements/Scroll.java @@ -17,6 +17,7 @@ public class Scroll extends Element { private static IntProperty scrollBarPadding = Property.integer(5); int padding = 0; + boolean background = true; protected List children = new ArrayList<>(); int scroll = 0; @@ -30,12 +31,31 @@ public class Scroll extends Element { ElementRenderer.draw(children, element); } + /** + * Skips the scroll's own background, for when it sits on a {@link Panel}. + */ + public Scroll noBackground() { + this.background = false; + return this; + } + + /** + * Removes every child and scrolls back to the top. + */ + public void clear() { + children.clear(); + scroll = 0; + maxScroll = 0; + } + @Override public void render(RenderScope renderScope, ElementContext ctx) { calculateMaxScroll(); - renderScope.drawRoundedRectangle(0, 0, width, height, - Theme.BG_RADIUS.value, Theme.BACKGROUND.value); + if (background) { + renderScope.drawRoundedRectangle(0, 0, width, height, + Theme.BG_RADIUS.value, Theme.BACKGROUND.value); + } renderScope.enableScissor(padding, padding, width - padding, height - padding); renderScope.getMatrixStack().push(); @@ -117,6 +137,15 @@ public class Scroll extends Element { @Override public void mouseClicked(double mouseX, double mouseY, int button) { + // Children scrolled out of view still have positions under the rest of the + // screen, so only clicks inside the scroll's own bounds may reach them. + if (mouseX < 0 || mouseY < 0 || mouseX > width || mouseY > height) { + for (Element child : children) { + child.focused = false; + } + return; + } + ElementRenderer.mouseClicked(children, mouseX - padding, mouseY - padding + scroll, button); } diff --git a/src/main/java/org/saturnclient/ui/resources/Fonts.java b/src/main/java/org/saturnclient/ui/resources/Fonts.java index 4c66770..def6d4b 100644 --- a/src/main/java/org/saturnclient/ui/resources/Fonts.java +++ b/src/main/java/org/saturnclient/ui/resources/Fonts.java @@ -44,6 +44,24 @@ public class Fonts { return 18; } + /** + * Shortens {@code text} with a trailing "…" so it fits in {@code maxWidth} + * when drawn at {@code scale}. + */ + public static String ellipsize(String text, int font, float scale, int maxWidth) { + if (getWidth(text, font) * scale <= maxWidth) { + return text; + } + + String ellipsis = "…"; + int end = text.length(); + while (end > 0 && getWidth(text.substring(0, end).stripTrailing() + ellipsis, font) * scale > maxWidth) { + end--; + } + + return text.substring(0, end).stripTrailing() + ellipsis; + } + public static int centerX(int w, String text, int font) { return (w - Fonts.getWidth(text, font)) / 2; } diff --git a/src/main/java/org/saturnclient/ui/resources/Textures.java b/src/main/java/org/saturnclient/ui/resources/Textures.java index 6058e95..68bb46b 100644 --- a/src/main/java/org/saturnclient/ui/resources/Textures.java +++ b/src/main/java/org/saturnclient/ui/resources/Textures.java @@ -25,6 +25,15 @@ public class Textures { public static final IdentifierRef INFO = IdentifierRef.ofSaturn("textures/gui/components/info.png"); public static final IdentifierRef CHECK = IdentifierRef.ofSaturn("textures/gui/components/check.png"); + // Lucide icons (SVG, drawn in white so they can be tinted) + public static final IdentifierRef ICON_MODS = IdentifierRef.ofSaturn("textures/gui/components/mods.svg"); + public static final IdentifierRef ICON_COSMETICS = IdentifierRef.ofSaturn("textures/gui/components/cosmetics.svg"); + public static final IdentifierRef ICON_HUD_EDITOR = IdentifierRef.ofSaturn("textures/gui/components/hud_editor.svg"); + public static final IdentifierRef ICON_SETTINGS = IdentifierRef.ofSaturn("textures/gui/components/settings.svg"); + public static final IdentifierRef ICON_CLOSE = IdentifierRef.ofSaturn("textures/gui/components/close.svg"); + public static final IdentifierRef ICON_SEARCH = IdentifierRef.ofSaturn("textures/gui/components/search.svg"); + public static final IdentifierRef ICON_SLIDERS = IdentifierRef.ofSaturn("textures/gui/components/sliders.svg"); + public static final IdentifierRef LOGO = IdentifierRef.ofSaturn("textures/logo/logo.png"); public static final IdentifierRef REALISTIC_LOGO = IdentifierRef.ofSaturn("textures/logo/realistic.png"); public static final IdentifierRef LOGO_TEXT = IdentifierRef.ofSaturn("textures/logo/text.png"); diff --git a/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java b/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java index e21245a..fb31348 100644 --- a/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java +++ b/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java @@ -63,7 +63,7 @@ public class ConfigEditor extends SaturnScreen { draw(scroll.dimensions(scrollWidth, 350).center(width, height)); - draw(new Sidebar(1, this.provider::close).center(width, height, -(scrollWidth / 2 + 20), 0)); + draw(new Sidebar(Sidebar.SETTINGS, this.provider::close).center(width, height, -(scrollWidth / 2 + 35), 0)); } private int drawProperties(Scroll configScroll, Map properties, int row, int col, int w) { diff --git a/src/main/java/org/saturnclient/ui/screens/ModMenu.java b/src/main/java/org/saturnclient/ui/screens/ModMenu.java index a531eca..ef6eadb 100644 --- a/src/main/java/org/saturnclient/ui/screens/ModMenu.java +++ b/src/main/java/org/saturnclient/ui/screens/ModMenu.java @@ -1,56 +1,171 @@ package org.saturnclient.ui.screens; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + import org.saturnclient.config.AnimationConfig; +import org.saturnclient.config.Theme; import org.saturnclient.mod.Mod; import org.saturnclient.mod.ModManager; import org.saturnclient.ui.SaturnScreen; import org.saturnclient.ui.anim.SlideFade; import org.saturnclient.ui.components.SaturnModule; import org.saturnclient.ui.components.Sidebar; +import org.saturnclient.ui.components.inputs.SearchInput; import org.saturnclient.ui.elements.AnimationStagger; +import org.saturnclient.ui.elements.ChipGroup; import org.saturnclient.ui.elements.ImageTexture; +import org.saturnclient.ui.elements.Label; +import org.saturnclient.ui.elements.Panel; import org.saturnclient.ui.elements.Scroll; +import org.saturnclient.ui.resources.Fonts; import org.saturnclient.ui.resources.Textures; public class ModMenu extends SaturnScreen { + private static final String ALL = "All"; + + private static final int MAX_WIDTH = 900; + private static final int MAX_HEIGHT = 580; + private static final int MARGIN = 40; + private static final int GAP = 12; + private static final int PADDING = 20; + private static final int COLUMNS = 3; + private static final int CARD_GAP = 10; + private static final int SCROLLBAR_SPACE = 14; + + private String category = ALL; + private String query = ""; + + private Scroll scroll; + private int gridWidth; + public ModMenu() { super("Mod Menu"); } @Override public void ui() { - int p = 10; - int g = 10; + // The search box starts empty after a resize, so the query does too. + query = ""; - Scroll scroll = new Scroll(p); + int panelWidth = Math.min(MAX_WIDTH, width - MARGIN * 2 - Sidebar.WIDTH - GAP); + int panelHeight = Math.min(MAX_HEIGHT, height - MARGIN * 2); + int left = (width - (Sidebar.WIDTH + GAP + panelWidth)) / 2; + int top = (height - panelHeight) / 2; + int panelX = left + Sidebar.WIDTH + GAP; + int contentX = panelX + PADDING; + gridWidth = panelWidth - PADDING * 2; - int row = 0; - int col = 0; + draw(new Sidebar(Sidebar.MODS, this.provider::close) + .dimensions(Sidebar.WIDTH, panelHeight) + .position(left, top)); - AnimationStagger moduleStagger = new AnimationStagger(AnimationConfig.modMenu); + draw(new Panel().dimensions(panelWidth, panelHeight).position(panelX, top)); - for (Mod mod : ModManager.MODS) { - moduleStagger.draw( - new SaturnModule(mod) - .position((160 + g) * col, (50 + g) * row) - .animation(new SlideFade(AnimationConfig.modMenu, -12))); + // Header + draw(new Label(() -> "Mods", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value) + .position(contentX, top + PADDING + 2)); - if (col == 2) { - col = 0; - row++; - } else { - col++; - } - } + draw(new Label(this::getEnabledSummary, 0.66f, Theme::getBodyFont, Theme::getMutedFg) + .position(contentX, top + PADDING + 28)); - scroll.draw(moduleStagger); + SearchInput search = new SearchInput("Search mods", text -> { + query = text; + refresh(false); + }); + draw(search.position(panelX + panelWidth - PADDING - search.width, top + PADDING + 2)); - int scrollWidth = 480 + 10 + (g * 2) + (p * 2); + // Categories + int chipsY = top + PADDING + 56; + ChipGroup categories = new ChipGroup(getCategories(), category, selected -> { + category = selected; + refresh(false); + }); + draw(categories.position(contentX, chipsY)); - draw(scroll.dimensions(scrollWidth, 350).center(width, height)); - - draw(new Sidebar(0, this.provider::close).center(width, height, -(scrollWidth / 2 + 20), 0)); + // Mods + int scrollY = chipsY + categories.height + 14; + scroll = new Scroll(0).noBackground(); + scroll.dimensions(gridWidth + SCROLLBAR_SPACE, top + panelHeight - 12 - scrollY); + refresh(true); + draw(scroll.position(contentX, scrollY)); draw(new ImageTexture(Textures.LOGO_TEXT_BIG).dimensions(180, 18).position(width - 180 - 20, height - 18 - 20)); } + + /** + * Fills the grid with the mods that match the category and search query. + */ + private void refresh(boolean animate) { + scroll.clear(); + + List mods = ModManager.MODS.stream().filter(this::matches).toList(); + + if (mods.isEmpty()) { + String message = "No mods match “" + query + "”"; + float scale = 0.7f; + int x = (gridWidth - (int) (Fonts.getWidth(message, Theme.getBodyFont()) * scale)) / 2; + + scroll.draw(new Label(() -> message, scale, Theme::getBodyFont, Theme::getMutedFg).position(x, 60)); + return; + } + + int cardWidth = (gridWidth - CARD_GAP * (COLUMNS - 1)) / COLUMNS; + AnimationStagger grid = new AnimationStagger(AnimationConfig.modMenu); + + for (int i = 0; i < mods.size(); i++) { + int col = i % COLUMNS; + int row = i / COLUMNS; + + SaturnModule card = new SaturnModule(mods.get(i), cardWidth); + card.position((cardWidth + CARD_GAP) * col, (SaturnModule.HEIGHT + CARD_GAP) * row); + + if (animate) { + card.animation(new SlideFade(AnimationConfig.modMenu, -12)); + } + + grid.draw(card); + } + + scroll.draw(grid); + } + + private boolean matches(Mod mod) { + if (!category.equals(ALL) && !List.of(mod.getTags()).contains(category)) { + return false; + } + + String q = query.trim().toLowerCase(Locale.ROOT); + if (q.isEmpty()) { + return true; + } + + String description = mod.getDescription() == null ? "" : mod.getDescription(); + return mod.getName().toLowerCase(Locale.ROOT).contains(q) + || description.toLowerCase(Locale.ROOT).contains(q); + } + + /** + * "All", then every mod tag in the order it first appears. + */ + private static List getCategories() { + List categories = new ArrayList<>(); + categories.add(ALL); + + for (Mod mod : ModManager.MODS) { + for (String tag : mod.getTags()) { + if (!categories.contains(tag)) { + categories.add(tag); + } + } + } + + return categories; + } + + private String getEnabledSummary() { + long enabled = ModManager.MODS.stream().filter(Mod::isEnabled).count(); + return enabled + " of " + ModManager.MODS.size() + " enabled"; + } } diff --git a/src/main/java/org/saturnclient/ui/screens/cosmetics/CloakMenu.java b/src/main/java/org/saturnclient/ui/screens/cosmetics/CloakMenu.java index db0b936..79699d5 100644 --- a/src/main/java/org/saturnclient/ui/screens/cosmetics/CloakMenu.java +++ b/src/main/java/org/saturnclient/ui/screens/cosmetics/CloakMenu.java @@ -68,8 +68,8 @@ public class CloakMenu extends SaturnScreen { .position(scroll.x + (scrollWidth - 220), scroll.y + 40) .animation(new Fade(500))); - draw(new Sidebar(2, this.provider::close) - .center(width, height, -(scrollWidth / 2 + 20), 0)); + draw(new Sidebar(Sidebar.COSMETICS, this.provider::close) + .center(width, height, -(scrollWidth / 2 + 35), 0)); draw(new TabMenu(0, new TabMenuComponent(Textures.CLOAK, () -> { diff --git a/src/main/java/org/saturnclient/ui/screens/cosmetics/HatMenu.java b/src/main/java/org/saturnclient/ui/screens/cosmetics/HatMenu.java index 6493e23..1d2244d 100644 --- a/src/main/java/org/saturnclient/ui/screens/cosmetics/HatMenu.java +++ b/src/main/java/org/saturnclient/ui/screens/cosmetics/HatMenu.java @@ -68,8 +68,8 @@ public class HatMenu extends SaturnScreen { .position(scroll.x + (scrollWidth - 220), scroll.y + 40) .animation(new Fade(500))); - draw(new Sidebar(2, this.provider::close) - .center(width, height, -(scrollWidth / 2 + 20), 0)); + draw(new Sidebar(Sidebar.COSMETICS, this.provider::close) + .center(width, height, -(scrollWidth / 2 + 35), 0)); draw(new TabMenu(1, new TabMenuComponent(Textures.CLOAK, () -> { diff --git a/src/main/java/org/saturnclient/ui/screens/store/CloakStore.java b/src/main/java/org/saturnclient/ui/screens/store/CloakStore.java index bc62890..c6c2e7d 100644 --- a/src/main/java/org/saturnclient/ui/screens/store/CloakStore.java +++ b/src/main/java/org/saturnclient/ui/screens/store/CloakStore.java @@ -98,8 +98,8 @@ public class CloakStore extends SaturnScreen { .dimensions(scrollWidth, 350) .center(width, height, 15, 0)); - draw(new Sidebar(5, this.provider::close) - .center(width, height, -((scrollWidth - 30) / 2 + 20), 0)); + draw(new Sidebar(Sidebar.COSMETICS, this.provider::close) + .center(width, height, -((scrollWidth - 30) / 2 + 35), 0)); draw(new TabMenu(0, new TabMenuComponent(Textures.CLOAK, () -> { diff --git a/src/main/java/org/saturnclient/ui/screens/store/HatStore.java b/src/main/java/org/saturnclient/ui/screens/store/HatStore.java index c2540ec..aec83e8 100644 --- a/src/main/java/org/saturnclient/ui/screens/store/HatStore.java +++ b/src/main/java/org/saturnclient/ui/screens/store/HatStore.java @@ -98,8 +98,8 @@ public class HatStore extends SaturnScreen { .dimensions(scrollWidth, 350) .center(width, height, 15, 0)); - draw(new Sidebar(5, this.provider::close) - .center(width, height, -((scrollWidth - 30) / 2 + 20), 0)); + draw(new Sidebar(Sidebar.COSMETICS, this.provider::close) + .center(width, height, -((scrollWidth - 30) / 2 + 35), 0)); draw(new TabMenu(1, new TabMenuComponent(Textures.CLOAK, () -> { diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/close.svg b/src/main/resources/assets/saturnclient/textures/gui/components/close.svg new file mode 100644 index 0000000..c509233 --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/close.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/cosmetics.svg b/src/main/resources/assets/saturnclient/textures/gui/components/cosmetics.svg new file mode 100644 index 0000000..8eca3bb --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/cosmetics.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/hud_editor.svg b/src/main/resources/assets/saturnclient/textures/gui/components/hud_editor.svg new file mode 100644 index 0000000..3978faf --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/hud_editor.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/mods.svg b/src/main/resources/assets/saturnclient/textures/gui/components/mods.svg new file mode 100644 index 0000000..65d3912 --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/mods.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/search.svg b/src/main/resources/assets/saturnclient/textures/gui/components/search.svg new file mode 100644 index 0000000..8c7fb8d --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/search.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/settings.svg b/src/main/resources/assets/saturnclient/textures/gui/components/settings.svg new file mode 100644 index 0000000..75ba448 --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/settings.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/sliders.svg b/src/main/resources/assets/saturnclient/textures/gui/components/sliders.svg new file mode 100644 index 0000000..a433070 --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/sliders.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/mod/clock.svg b/src/main/resources/assets/saturnclient/textures/gui/mod/clock.svg index c928bd6..a260617 100644 --- a/src/main/resources/assets/saturnclient/textures/gui/mod/clock.svg +++ b/src/main/resources/assets/saturnclient/textures/gui/mod/clock.svg @@ -1,7 +1 @@ - - - - - - - \ No newline at end of file + diff --git a/src/main/resources/assets/saturnclient/textures/gui/mod/crosshair.svg b/src/main/resources/assets/saturnclient/textures/gui/mod/crosshair.svg index bb0cb50..572ac84 100644 --- a/src/main/resources/assets/saturnclient/textures/gui/mod/crosshair.svg +++ b/src/main/resources/assets/saturnclient/textures/gui/mod/crosshair.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/main/resources/assets/saturnclient/textures/gui/mod/nametags.svg b/src/main/resources/assets/saturnclient/textures/gui/mod/nametags.svg index 876a0f0..233afdd 100644 --- a/src/main/resources/assets/saturnclient/textures/gui/mod/nametags.svg +++ b/src/main/resources/assets/saturnclient/textures/gui/mod/nametags.svg @@ -1,7 +1 @@ - - - - - - - \ No newline at end of file + diff --git a/src/main/resources/assets/saturnclient/textures/gui/mod/ping.svg b/src/main/resources/assets/saturnclient/textures/gui/mod/ping.svg index 6eaafc9..a230b11 100644 --- a/src/main/resources/assets/saturnclient/textures/gui/mod/ping.svg +++ b/src/main/resources/assets/saturnclient/textures/gui/mod/ping.svg @@ -1,7 +1 @@ - - - - - - - \ No newline at end of file + diff --git a/src/main/resources/assets/saturnclient/textures/gui/mod/sprint.svg b/src/main/resources/assets/saturnclient/textures/gui/mod/sprint.svg index 35e91e7..11515b0 100644 --- a/src/main/resources/assets/saturnclient/textures/gui/mod/sprint.svg +++ b/src/main/resources/assets/saturnclient/textures/gui/mod/sprint.svg @@ -1,35 +1 @@ - - - - -Created by potrace 1.15, written by Peter Selinger 2001-2017 - - - - - - + diff --git a/src/main/resources/assets/saturnclient/textures/gui/mod/tps.svg b/src/main/resources/assets/saturnclient/textures/gui/mod/tps.svg index f2ad6c9..7c5fed6 100644 --- a/src/main/resources/assets/saturnclient/textures/gui/mod/tps.svg +++ b/src/main/resources/assets/saturnclient/textures/gui/mod/tps.svg @@ -1,7 +1 @@ - - - - - - performance_line - \ No newline at end of file + diff --git a/src/main/resources/assets/saturnclient/textures/gui/mod/zoom.svg b/src/main/resources/assets/saturnclient/textures/gui/mod/zoom.svg index 320a59d..e0c3ce8 100644 --- a/src/main/resources/assets/saturnclient/textures/gui/mod/zoom.svg +++ b/src/main/resources/assets/saturnclient/textures/gui/mod/zoom.svg @@ -1 +1 @@ - \ No newline at end of file + -- 2.54.0 From df09271e8166d680b1bbb3b831cceb9153d34464 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 17:13:39 +0200 Subject: [PATCH 03/13] Redesign mod settings A mod's settings button now opens ModSettings: a header with the mod's icon, name, version, tags and description, a Reset button and an on/off switch, then its properties as rows, with nested namespaces as titled sections and a live preview card beside them for HUD mods. PanelScreen holds the sidebar-and-panel frame that the mod menu, mod settings and config editor share. PropertyList lays out any config as rows. The inputs share one field look: Toggle is a switch, Select steps with arrows, and the number, keybinding and color inputs are boxes. The color picker opens to the left and closes on outside clicks. Every property type now implements reset(), which was a no-op, and ModLayout keeps its settings in order instead of Map.of's. ConfigEditor moves onto the same frame and rows and drops its HUD preview, which added a new element every frame. Co-Authored-By: Claude Opus 5.5 --- .../config/property/BoolProperty.java | 6 + .../config/property/ColorProperty.java | 6 + .../config/property/FloatProperty.java | 6 + .../config/property/IntProperty.java | 6 + .../config/property/KeybindingProperty.java | 6 + .../config/property/NamespaceProperty.java | 5 + .../config/property/SelectProperty.java | 6 + .../config/property/StringProperty.java | 6 + .../java/org/saturnclient/mod/ModLayout.java | 20 +- .../java/org/saturnclient/ui/PanelScreen.java | 64 +++++++ .../ui/components/HudPreviewCard.java | 54 ++++++ .../saturnclient/ui/components/ModHeader.java | 91 +++++++++ .../ui/components/PropertyList.java | 138 ++++++++++++++ .../ui/components/SaturnModule.java | 9 +- .../ui/components/inputs/ColorInput.java | 62 +++++- .../ui/components/inputs/FloatInput.java | 6 +- .../ui/components/inputs/Input.java | 23 ++- .../ui/components/inputs/IntInput.java | 6 +- .../components/inputs/KeybindingSelector.java | 34 +++- .../ui/components/inputs/SearchInput.java | 25 +-- .../ui/components/inputs/Select.java | 38 ++-- .../ui/components/inputs/Toggle.java | 15 +- .../ui/elements/ActionButton.java | 56 ++++++ .../org/saturnclient/ui/elements/Divider.java | 22 +++ .../org/saturnclient/ui/elements/Field.java | 44 +++++ .../ui/elements/SwitchButton.java | 59 ++++++ .../saturnclient/ui/resources/Textures.java | 5 + .../saturnclient/ui/screens/ConfigEditor.java | 176 ++---------------- .../org/saturnclient/ui/screens/ModMenu.java | 43 +---- .../saturnclient/ui/screens/ModSettings.java | 97 ++++++++++ .../textures/gui/components/chevron_left.svg | 1 + .../textures/gui/components/chevron_right.svg | 1 + .../textures/gui/components/reset.svg | 1 + 33 files changed, 860 insertions(+), 277 deletions(-) create mode 100644 src/main/java/org/saturnclient/ui/PanelScreen.java create mode 100644 src/main/java/org/saturnclient/ui/components/HudPreviewCard.java create mode 100644 src/main/java/org/saturnclient/ui/components/ModHeader.java create mode 100644 src/main/java/org/saturnclient/ui/components/PropertyList.java create mode 100644 src/main/java/org/saturnclient/ui/elements/ActionButton.java create mode 100644 src/main/java/org/saturnclient/ui/elements/Divider.java create mode 100644 src/main/java/org/saturnclient/ui/elements/Field.java create mode 100644 src/main/java/org/saturnclient/ui/elements/SwitchButton.java create mode 100644 src/main/java/org/saturnclient/ui/screens/ModSettings.java create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/chevron_left.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/chevron_right.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/reset.svg diff --git a/src/main/java/org/saturnclient/config/property/BoolProperty.java b/src/main/java/org/saturnclient/config/property/BoolProperty.java index 231f314..45025d3 100644 --- a/src/main/java/org/saturnclient/config/property/BoolProperty.java +++ b/src/main/java/org/saturnclient/config/property/BoolProperty.java @@ -12,6 +12,12 @@ public class BoolProperty extends Property { this.defaultValue = defaultValue; } + @Override + public void reset() { + this.value = defaultValue; + this.isReset = true; + } + @Override public JsonNode toJson() { return JsonNodeFactory.instance.booleanNode(this.value); diff --git a/src/main/java/org/saturnclient/config/property/ColorProperty.java b/src/main/java/org/saturnclient/config/property/ColorProperty.java index e25de45..5ee2cab 100644 --- a/src/main/java/org/saturnclient/config/property/ColorProperty.java +++ b/src/main/java/org/saturnclient/config/property/ColorProperty.java @@ -12,6 +12,12 @@ public class ColorProperty extends Property { this.defaultValue = defaultValue; } + @Override + public void reset() { + this.value = defaultValue; + this.isReset = true; + } + @Override public JsonNode toJson() { return JsonNodeFactory.instance.textNode( diff --git a/src/main/java/org/saturnclient/config/property/FloatProperty.java b/src/main/java/org/saturnclient/config/property/FloatProperty.java index 6b1dd6e..316e90c 100644 --- a/src/main/java/org/saturnclient/config/property/FloatProperty.java +++ b/src/main/java/org/saturnclient/config/property/FloatProperty.java @@ -12,6 +12,12 @@ public class FloatProperty extends Property { this.defaultValue = defaultValue; } + @Override + public void reset() { + this.value = defaultValue; + this.isReset = true; + } + @Override public JsonNode toJson() { return JsonNodeFactory.instance.numberNode(this.value); diff --git a/src/main/java/org/saturnclient/config/property/IntProperty.java b/src/main/java/org/saturnclient/config/property/IntProperty.java index 8160455..66d1a84 100644 --- a/src/main/java/org/saturnclient/config/property/IntProperty.java +++ b/src/main/java/org/saturnclient/config/property/IntProperty.java @@ -12,6 +12,12 @@ public class IntProperty extends Property { this.defaultValue = defaultValue; } + @Override + public void reset() { + this.value = defaultValue; + this.isReset = true; + } + @Override public JsonNode toJson() { return JsonNodeFactory.instance.numberNode(this.value); diff --git a/src/main/java/org/saturnclient/config/property/KeybindingProperty.java b/src/main/java/org/saturnclient/config/property/KeybindingProperty.java index 10f4422..ec98a5c 100644 --- a/src/main/java/org/saturnclient/config/property/KeybindingProperty.java +++ b/src/main/java/org/saturnclient/config/property/KeybindingProperty.java @@ -15,6 +15,12 @@ public class KeybindingProperty extends Property { this.defaultValue = defaultValue; } + @Override + public void reset() { + this.value = defaultValue; + this.isReset = true; + } + @Override public JsonNode toJson() { return JsonNodeFactory.instance.numberNode(this.value); diff --git a/src/main/java/org/saturnclient/config/property/NamespaceProperty.java b/src/main/java/org/saturnclient/config/property/NamespaceProperty.java index bf71ef7..8b52176 100644 --- a/src/main/java/org/saturnclient/config/property/NamespaceProperty.java +++ b/src/main/java/org/saturnclient/config/property/NamespaceProperty.java @@ -13,6 +13,11 @@ public class NamespaceProperty extends Property { this.value = value; } + @Override + public void reset() { + this.value.values().forEach(Property::reset); + } + @Override public JsonNode toJson() { ObjectNode nested = JsonNodeFactory.instance.objectNode(); diff --git a/src/main/java/org/saturnclient/config/property/SelectProperty.java b/src/main/java/org/saturnclient/config/property/SelectProperty.java index 4edb555..7676732 100644 --- a/src/main/java/org/saturnclient/config/property/SelectProperty.java +++ b/src/main/java/org/saturnclient/config/property/SelectProperty.java @@ -14,6 +14,12 @@ public class SelectProperty extends Property { this.availableValues = availableValues; } + @Override + public void reset() { + this.value = defaultValue; + this.isReset = true; + } + @Override public JsonNode toJson() { return JsonNodeFactory.instance.numberNode(this.value); diff --git a/src/main/java/org/saturnclient/config/property/StringProperty.java b/src/main/java/org/saturnclient/config/property/StringProperty.java index e09d387..70e2462 100644 --- a/src/main/java/org/saturnclient/config/property/StringProperty.java +++ b/src/main/java/org/saturnclient/config/property/StringProperty.java @@ -12,6 +12,12 @@ public class StringProperty extends Property { this.defaultValue = defaultValue; } + @Override + public void reset() { + this.value = defaultValue; + this.isReset = true; + } + @Override public JsonNode toJson() { return JsonNodeFactory.instance.textNode(this.value); diff --git a/src/main/java/org/saturnclient/mod/ModLayout.java b/src/main/java/org/saturnclient/mod/ModLayout.java index ab0c8b6..e7df485 100644 --- a/src/main/java/org/saturnclient/mod/ModLayout.java +++ b/src/main/java/org/saturnclient/mod/ModLayout.java @@ -1,5 +1,6 @@ package org.saturnclient.mod; +import java.util.LinkedHashMap; import java.util.Map; import org.saturnclient.config.property.Property; @@ -23,14 +24,17 @@ public class ModLayout { public int height = 0; public NamedProperty prop() { - return Property.namespace(Map.of( - "X", x, - "Y", y, - "Scale", scale, - "Background Color", bgColor, - "Foreground Color", fgColor, - "Corner Radius", radius, - "Font", font)).named("In-Game Display"); + // A LinkedHashMap, unlike Map.of, keeps the order the settings are shown in. + Map props = new LinkedHashMap<>(); + props.put("X", x); + props.put("Y", y); + props.put("Scale", scale); + props.put("Background Color", bgColor); + props.put("Foreground Color", fgColor); + props.put("Corner Radius", radius); + props.put("Font", font); + + return Property.namespace(props).named("In-Game Display"); } public ModLayout() { diff --git a/src/main/java/org/saturnclient/ui/PanelScreen.java b/src/main/java/org/saturnclient/ui/PanelScreen.java new file mode 100644 index 0000000..65d8fbb --- /dev/null +++ b/src/main/java/org/saturnclient/ui/PanelScreen.java @@ -0,0 +1,64 @@ +package org.saturnclient.ui; + +import org.saturnclient.ui.components.Sidebar; +import org.saturnclient.ui.elements.ImageTexture; +import org.saturnclient.ui.elements.Panel; +import org.saturnclient.ui.resources.Textures; + +/** + * A screen laid out as the sidebar beside one panel, centered and shrunk to + * fit smaller windows. Subclasses fill the panel in {@link #panel()}, using the + * content bounds this class computes. + */ +public abstract class PanelScreen extends SaturnScreen { + private static final int MAX_WIDTH = 900; + private static final int MAX_HEIGHT = 580; + private static final int MARGIN = 40; + private static final int GAP = 12; + + protected static final int PADDING = 20; + + private final int sidebarItem; + + protected int panelX, panelY, panelWidth, panelHeight; + /** The panel's area inside its padding */ + protected int contentX, contentY, contentWidth, contentHeight; + + /** + * @param sidebarItem the {@link Sidebar} item to highlight + */ + public PanelScreen(String title, int sidebarItem) { + super(title); + this.sidebarItem = sidebarItem; + } + + @Override + public final void ui() { + panelWidth = Math.min(MAX_WIDTH, width - MARGIN * 2 - Sidebar.WIDTH - GAP); + panelHeight = Math.min(MAX_HEIGHT, height - MARGIN * 2); + + int left = (width - (Sidebar.WIDTH + GAP + panelWidth)) / 2; + panelX = left + Sidebar.WIDTH + GAP; + panelY = (height - panelHeight) / 2; + + contentX = panelX + PADDING; + contentY = panelY + PADDING; + contentWidth = panelWidth - PADDING * 2; + contentHeight = panelHeight - PADDING * 2; + + draw(new Sidebar(sidebarItem, this.provider::close) + .dimensions(Sidebar.WIDTH, panelHeight) + .position(left, panelY)); + + draw(new Panel().dimensions(panelWidth, panelHeight).position(panelX, panelY)); + + panel(); + + draw(new ImageTexture(Textures.LOGO_TEXT_BIG).dimensions(180, 18).position(width - 180 - 20, height - 18 - 20)); + } + + /** + * Draws the panel's content. + */ + protected abstract void panel(); +} diff --git a/src/main/java/org/saturnclient/ui/components/HudPreviewCard.java b/src/main/java/org/saturnclient/ui/components/HudPreviewCard.java new file mode 100644 index 0000000..d1e062c --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/HudPreviewCard.java @@ -0,0 +1,54 @@ +package org.saturnclient.ui.components; + +import org.saturnclient.config.Theme; +import org.saturnclient.mod.HudMod; +import org.saturnclient.mod.ModLayout; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; + +/** + * A card showing a HUD mod as it looks in game, following its settings live. + * The mod is drawn at its in-game scale, shrunk if needed to fit the card. + */ +public class HudPreviewCard extends Element { + private static final int PADDING = 16; + private static final int TITLE_HEIGHT = 24; + private static final float TITLE_SCALE = 0.6f; + + private final HudMod mod; + private final HudModPreview preview; + + public HudPreviewCard(HudMod mod) { + this.mod = mod; + this.preview = new HudModPreview(mod); + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value + 4, 1, + Theme.withAlpha(0.72f, Theme.PRIMARY.value), Theme.withAlpha(0.06f, Theme.FOREGROUND.value)); + + renderScope.drawText(TITLE_SCALE, "PREVIEW", PADDING, PADDING, Theme.FONT.value, Theme.getMutedFg()); + + ModLayout layout = mod.getDimensions(); + if (layout.width <= 0 || layout.height <= 0) { + return; + } + + // HUD mods are laid out in GUI pixels, which are two Saturn units. + int areaWidth = width - PADDING * 2; + int areaHeight = height - PADDING * 2 - TITLE_HEIGHT; + float scale = Math.min(layout.scale.value * 2, + Math.min((float) areaWidth / layout.width, (float) areaHeight / layout.height)); + + float x = PADDING + (areaWidth - layout.width * scale) / 2; + float y = PADDING + TITLE_HEIGHT + (areaHeight - layout.height * scale) / 2; + + renderScope.getMatrixStack().push(); + renderScope.getMatrixStack().translate(x, y); + renderScope.getMatrixStack().scale(scale, scale); + preview.render(renderScope, ctx); + renderScope.getMatrixStack().pop(); + } +} diff --git a/src/main/java/org/saturnclient/ui/components/ModHeader.java b/src/main/java/org/saturnclient/ui/components/ModHeader.java new file mode 100644 index 0000000..c0b70bf --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/ModHeader.java @@ -0,0 +1,91 @@ +package org.saturnclient.ui.components; + +import org.saturnclient.config.Theme; +import org.saturnclient.mod.Mod; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.resources.Fonts; + +/** + * The top of a mod's settings: its icon, name, version and tags, and description. + */ +public class ModHeader extends Element { + public static final int HEIGHT = 60; + + private static final int ICON = 30; + private static final int TEXT_X = HEIGHT + 16; + private static final float NAME_SCALE = 1.2f; + private static final float DESCRIPTION_SCALE = 0.7f; + + private static final int CHIP_HEIGHT = 22; + private static final int CHIP_PADDING = 8; + private static final int CHIP_GAP = 6; + private static final float CHIP_SCALE = 0.58f; + + private final Mod mod; + + public ModHeader(Mod mod, int width) { + this.mod = mod; + this.width = width; + this.height = HEIGHT; + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + boolean enabled = mod.isEnabled(); + + // Icon + renderScope.drawRoundedRectangle(0, 0, HEIGHT, HEIGHT, Theme.WIDGET_RADIUS.value + 4, + enabled ? Theme.withAlpha(0.18f, Theme.ACCENT.value) : Theme.withAlpha(0.06f, Theme.FOREGROUND.value)); + renderScope.drawTexture(mod.getIconTexture(), (HEIGHT - ICON) / 2, (HEIGHT - ICON) / 2, 0, 0, ICON, ICON, + Theme.PRIMARY_FG.value); + + // Name, then the version and tags as chips + int nameHeight = (int) (Fonts.getHeight() * NAME_SCALE); + int nameY = 4; + renderScope.drawText(NAME_SCALE, mod.getName(), TEXT_X, nameY, Theme.FONT.value, Theme.FOREGROUND.value); + + int chipX = TEXT_X + (int) (Fonts.getWidth(mod.getName(), Theme.FONT.value) * NAME_SCALE) + 12; + int chipY = nameY + (nameHeight - CHIP_HEIGHT) / 2; + + if (mod.getVersion() != null) { + chipX = drawChip(renderScope, mod.getVersion(), chipX, chipY, false) + CHIP_GAP; + } + + for (String tag : mod.getTags()) { + chipX = drawChip(renderScope, tag, chipX, chipY, true) + CHIP_GAP; + } + + // Description + if (mod.getDescription() != null) { + String description = Fonts.ellipsize(mod.getDescription(), Theme.getBodyFont(), DESCRIPTION_SCALE, + width - TEXT_X); + renderScope.drawText(DESCRIPTION_SCALE, description, TEXT_X, nameY + nameHeight + 8, + Theme.getBodyFont(), Theme.getMutedFg()); + } + } + + /** + * @return the x after the chip + */ + private int drawChip(RenderScope renderScope, String text, int x, int y, boolean outlined) { + int width = CHIP_PADDING * 2 + (int) (Fonts.getWidth(text, Theme.FONT.value) * CHIP_SCALE); + + if (x + width > this.width) { + return x; + } + + if (outlined) { + renderScope.drawRoundedBorder(x, y, width, CHIP_HEIGHT, 6, 1, Theme.withAlpha(0.14f, Theme.FOREGROUND.value)); + } else { + renderScope.drawRoundedRectangle(x, y, width, CHIP_HEIGHT, 6, Theme.withAlpha(0.07f, Theme.FOREGROUND.value)); + } + + int textY = y + (CHIP_HEIGHT - (int) (Fonts.getHeight() * CHIP_SCALE)) / 2; + renderScope.drawText(CHIP_SCALE, text, x + CHIP_PADDING, textY, Theme.FONT.value, + outlined ? Theme.PRIMARY_FG.value : Theme.getMutedFg()); + + return x + width; + } +} diff --git a/src/main/java/org/saturnclient/ui/components/PropertyList.java b/src/main/java/org/saturnclient/ui/components/PropertyList.java new file mode 100644 index 0000000..b576c02 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/PropertyList.java @@ -0,0 +1,138 @@ +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; + +import org.saturnclient.config.Theme; +import org.saturnclient.config.property.BoolProperty; +import org.saturnclient.config.property.ColorProperty; +import org.saturnclient.config.property.FloatProperty; +import org.saturnclient.config.property.IntProperty; +import org.saturnclient.config.property.KeybindingProperty; +import org.saturnclient.config.property.NamespaceProperty; +import org.saturnclient.config.property.Property; +import org.saturnclient.config.property.SelectProperty; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.components.inputs.ColorInput; +import org.saturnclient.ui.components.inputs.FloatInput; +import org.saturnclient.ui.components.inputs.IntInput; +import org.saturnclient.ui.components.inputs.KeybindingSelector; +import org.saturnclient.ui.components.inputs.Select; +import org.saturnclient.ui.components.inputs.Toggle; +import org.saturnclient.ui.elements.Label; +import org.saturnclient.ui.elements.Scroll; +import org.saturnclient.ui.resources.Fonts; + +/** + * Lays out config properties as rows of name and control, one per property. + * A nested namespace becomes a titled section. + */ +public final class PropertyList { + public static final int ROW_HEIGHT = 56; + + private static final int SECTION_GAP = 18; + private static final float SECTION_SCALE = 0.6f; + + private PropertyList() { + } + + /** + * Adds rows for {@code properties} to {@code scroll}, starting at {@code y}. + * + * @param skip names of top-level properties to leave out + * @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(); + + if (skip.contains(name)) { + continue; + } + + if (prop instanceof NamespaceProperty namespace) { + y += SECTION_GAP; + String title = name.toUpperCase(Locale.ROOT); + scroll.draw(new Label(() -> title, SECTION_SCALE, () -> Theme.FONT.value, Theme::getMutedFg) + .position(0, y)); + y += (int) (Fonts.getHeight() * SECTION_SCALE) + 4; + y = drawRows(scroll, namespace.value, Set.of(), width, y, controls); + continue; + } + + Element control = createControl(prop); + if (control == null) { + continue; + } + + scroll.draw(new Row(name, width, control.width).position(0, y)); + controls.add(control.position(width - control.width, y + (ROW_HEIGHT - control.height) / 2)); + y += ROW_HEIGHT; + } + + return y; + } + + /** + * The input for a property, or null for types without one (strings). + */ + private static Element createControl(Property prop) { + if (prop instanceof BoolProperty p) { + return new Toggle(p); + } else if (prop instanceof ColorProperty p) { + return new ColorInput(p); + } else if (prop instanceof IntProperty p) { + return new IntInput(p); + } else if (prop instanceof FloatProperty p) { + return new FloatInput(p); + } else if (prop instanceof SelectProperty p) { + return new Select(p); + } else if (prop instanceof KeybindingProperty p) { + return new KeybindingSelector(p); + } + + return null; + } + + /** + * A row's name and the line under it. The control is a separate element on + * top, so it gets the clicks and key presses. + */ + private static class Row extends Element { + private static final float NAME_SCALE = 0.74f; + + private final String name; + private final int controlWidth; + + Row(String name, int width, int controlWidth) { + this.name = name; + this.controlWidth = controlWidth; + this.width = width; + this.height = ROW_HEIGHT; + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + int nameY = (height - (int) (Fonts.getHeight() * NAME_SCALE)) / 2; + String text = Fonts.ellipsize(name, Theme.FONT.value, NAME_SCALE, width - controlWidth - 16); + + renderScope.drawText(NAME_SCALE, text, 0, nameY, Theme.FONT.value, Theme.FOREGROUND.value); + renderScope.drawRectangle(0, height - 1, width, 1, Theme.withAlpha(0.06f, Theme.FOREGROUND.value)); + } + } +} diff --git a/src/main/java/org/saturnclient/ui/components/SaturnModule.java b/src/main/java/org/saturnclient/ui/components/SaturnModule.java index 1af513f..280df8e 100644 --- a/src/main/java/org/saturnclient/ui/components/SaturnModule.java +++ b/src/main/java/org/saturnclient/ui/components/SaturnModule.java @@ -2,7 +2,6 @@ package org.saturnclient.ui.components; import org.saturnclient.common.provider.Providers; import org.saturnclient.config.Theme; -import org.saturnclient.mod.HudMod; import org.saturnclient.mod.Mod; import org.saturnclient.ui.Element; import org.saturnclient.ui.ElementContext; @@ -10,7 +9,7 @@ import org.saturnclient.ui.RenderScope; import org.saturnclient.ui.components.inputs.Toggle; import org.saturnclient.ui.resources.Fonts; import org.saturnclient.ui.resources.Textures; -import org.saturnclient.ui.screens.ConfigEditor; +import org.saturnclient.ui.screens.ModSettings; /** * A mod's card in the mod menu: icon, name, description, a settings button and @@ -101,11 +100,7 @@ public class SaturnModule extends Element { && mouseY >= getSettingsY() && mouseY <= getSettingsY() + SETTINGS; if (settingsClicked) { - if (mod instanceof HudMod h) { - Providers.saturn.getClient().setScreen(new ConfigEditor(mod.getConfig(), h)); - } else { - Providers.saturn.getClient().setScreen(new ConfigEditor(mod.getConfig())); - } + Providers.saturn.getClient().setScreen(new ModSettings(mod)); } else { mod.setEnabled(!mod.isEnabled()); } 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 df2f0be..f8dc4cc 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/ColorInput.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/ColorInput.java @@ -2,10 +2,12 @@ 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; +import org.saturnclient.ui.elements.Field; public class ColorInput extends Element { public ColorProperty prop; @@ -26,10 +28,19 @@ public class ColorInput extends Element { 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; + public ColorInput(ColorProperty prop) { this.prop = prop; - this.width = 58; - this.height = 20; + this.width = 116; + this.height = Field.HEIGHT; syncFromColor(); } @@ -40,15 +51,23 @@ public class ColorInput extends Element { @Override public void mouseClicked(double mouseX, double mouseY, int button) { - if (open) { - handlePickerClick(mouseX - width - 8, mouseY); + 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 - width - 8, mouseY); + handlePickerDrag(mouseX - PICKER_X, mouseY); } } @@ -59,16 +78,26 @@ public class ColorInput extends Element { @Override public void render(RenderScope renderScope, ElementContext ctx) { - // Draw the main color rectangle - renderScope.drawRoundedRectangle(0, 0, width, height, 6, prop.value); + Field.drawBox(renderScope, width, height, ctx.isHovering(), open); + + int swatchY = (height - SWATCH) / 2; + renderScope.drawRoundedRectangle(swatchY, swatchY, SWATCH, SWATCH, 5, 1, prop.value, + Theme.withAlpha(0.3f, Theme.FOREGROUND.value)); + + renderScope.drawText(Field.TEXT_SCALE, toHex(prop.value), swatchY + SWATCH + 8, + Field.textY(height, Field.TEXT_SCALE), Theme.FONT.value, Theme.FOREGROUND.value); if (!open) return; - // Draw the picker relative to (width + 8, 0) - int pickerX = width + 8; + 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); @@ -138,6 +167,11 @@ public class ColorInput extends Element { } } + 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) @@ -169,6 +203,16 @@ public class ColorInput extends Element { updateColorFromSelection(); } + /** + * #RRGGBB, or #AARRGGBB when the color is translucent. + */ + private static String toHex(int color) { + int alpha = (color >>> 24) & 0xFF; + return alpha == 0xFF + ? 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); diff --git a/src/main/java/org/saturnclient/ui/components/inputs/FloatInput.java b/src/main/java/org/saturnclient/ui/components/inputs/FloatInput.java index cdabac0..4e10b34 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/FloatInput.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/FloatInput.java @@ -2,14 +2,14 @@ package org.saturnclient.ui.components.inputs; import org.saturnclient.config.ConfigManager; import org.saturnclient.config.property.FloatProperty; -import org.saturnclient.ui.resources.Fonts; +import org.saturnclient.ui.elements.Field; public class FloatInput extends Input { public FloatProperty prop; public FloatInput(FloatProperty prop) { - this.width = 120; - this.height = Fonts.getHeight(); + this.width = 96; + this.height = Field.HEIGHT; this.prop = prop; this.text = String.valueOf(prop.value); } diff --git a/src/main/java/org/saturnclient/ui/components/inputs/Input.java b/src/main/java/org/saturnclient/ui/components/inputs/Input.java index 421c759..2cd215c 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/Input.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/Input.java @@ -5,6 +5,7 @@ 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; public abstract class Input extends Element { @@ -12,9 +13,9 @@ public abstract class Input extends Element { public int cursorPosition = 0; /** Scale the text is drawn at */ - protected float textScale = 0.6f; + protected float textScale = Field.TEXT_SCALE; /** Horizontal inset of the text on both sides */ - protected int textX = 4; + protected int textX = 10; public abstract void checkReset(); @@ -70,16 +71,24 @@ public abstract class Input extends Element { public void render(RenderScope renderScope, ElementContext ctx) { checkReset(); - renderScope.drawRoundedRectangle(0, 0, width, height, 10, Theme.PRIMARY.value); + Field.drawBox(renderScope, width, height, ctx.isHovering(), focused); + drawText(renderScope); + } + + /** + * Draws the visible part of the text, and the cursor while focused. + */ + protected void drawText(RenderScope renderScope) { + int textHeight = (int) (Fonts.getHeight() * textScale); + int textY = Field.textY(height, textScale); int scrollOffset = getScrollOffset(); String visibleText = getVisibleText(scrollOffset); - renderScope.drawText(textScale, visibleText, textX, 4, Theme.FONT.value, Theme.PRIMARY_FG.value); + renderScope.drawText(textScale, visibleText, textX, textY, Theme.FONT.value, Theme.FOREGROUND.value); if (focused) { - int cursorX = getCursorX(visibleText, scrollOffset); - - renderScope.drawRectangle(cursorX, 2, 1, height - 4, Theme.ACCENT.value); + renderScope.drawRectangle(getCursorX(visibleText, scrollOffset), textY - 1, 1, textHeight + 2, + Theme.ACCENT.value); } } diff --git a/src/main/java/org/saturnclient/ui/components/inputs/IntInput.java b/src/main/java/org/saturnclient/ui/components/inputs/IntInput.java index 365cd74..64f73a9 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/IntInput.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/IntInput.java @@ -2,14 +2,14 @@ package org.saturnclient.ui.components.inputs; import org.saturnclient.config.ConfigManager; import org.saturnclient.config.property.IntProperty; -import org.saturnclient.ui.resources.Fonts; +import org.saturnclient.ui.elements.Field; public class IntInput extends Input { public IntProperty prop; public IntInput(IntProperty prop) { - this.width = 120; - this.height = Fonts.getHeight(); + this.width = 96; + this.height = Field.HEIGHT; this.prop = prop; this.text = String.valueOf(prop.value); } diff --git a/src/main/java/org/saturnclient/ui/components/inputs/KeybindingSelector.java b/src/main/java/org/saturnclient/ui/components/inputs/KeybindingSelector.java index 67b21bd..60461ad 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/KeybindingSelector.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/KeybindingSelector.java @@ -7,27 +7,41 @@ import org.saturnclient.config.property.KeybindingProperty; import org.saturnclient.ui.Element; import org.saturnclient.ui.ElementContext; import org.saturnclient.ui.RenderScope; -import org.saturnclient.ui.resources.Fonts; +import org.saturnclient.ui.elements.Field; +/** + * Shows a key binding. Click it, then press a key to bind it, or backspace to + * unbind. + */ public class KeybindingSelector extends Element { KeybindingProperty prop; public KeybindingSelector(KeybindingProperty prop) { this.prop = prop; - this.width = 120; - this.height = Fonts.getHeight(); + this.width = 96; + this.height = Field.HEIGHT; } @Override public void render(RenderScope renderScope, ElementContext ctx) { - int textColor = focused ? 0xFFFFFF : 0xAAAAAA; - renderScope.drawRoundedRectangle(0, 0, width, height, 10, 0xFF000000); - String name = Providers.GLFW.getKeyName(prop.value); + Field.drawBox(renderScope, width, height, ctx.isHovering(), focused); - renderScope.drawText(0.6f, - prop.value == -1 ? "" - : name != null ? name.toUpperCase() : "", - 4, 4, Theme.FONT.value, textColor); + if (focused) { + Field.drawCenteredText(renderScope, "Press a key", width, height, Theme.getBodyFont(), + Theme.getMutedFg()); + return; + } + + Field.drawCenteredText(renderScope, getKeyLabel(), width, height, Theme.FONT.value, Theme.FOREGROUND.value); + } + + private String getKeyLabel() { + if (prop.value == -1) { + return "None"; + } + + String name = Providers.GLFW.getKeyName(prop.value); + return name != null ? name.toUpperCase() : "Key " + prop.value; } @Override diff --git a/src/main/java/org/saturnclient/ui/components/inputs/SearchInput.java b/src/main/java/org/saturnclient/ui/components/inputs/SearchInput.java index f515408..c757f2b 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/SearchInput.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/SearchInput.java @@ -5,7 +5,7 @@ import java.util.function.Consumer; import org.saturnclient.config.Theme; import org.saturnclient.ui.ElementContext; import org.saturnclient.ui.RenderScope; -import org.saturnclient.ui.resources.Fonts; +import org.saturnclient.ui.elements.Field; import org.saturnclient.ui.resources.Textures; /** @@ -21,7 +21,6 @@ public class SearchInput extends Input { public SearchInput(String placeholder, Consumer onChange) { this.placeholder = placeholder; this.onChange = onChange; - this.textScale = 0.68f; this.textX = ICON_X + ICON_SIZE + 8; this.width = 240; this.height = 36; @@ -49,31 +48,17 @@ public class SearchInput extends Input { @Override public void render(RenderScope renderScope, ElementContext ctx) { - int border = focused ? Theme.withAlpha(0.6f, Theme.ACCENT.value) - : ctx.isHovering() ? Theme.withAlpha(0.25f, Theme.FOREGROUND.value) - : Theme.withAlpha(0.1f, Theme.FOREGROUND.value); - - renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1, - Theme.withAlpha(0.06f, Theme.FOREGROUND.value), border); + Field.drawBox(renderScope, width, height, ctx.isHovering(), focused); renderScope.drawTexture(Textures.ICON_SEARCH, ICON_X, (height - ICON_SIZE) / 2, 0, 0, ICON_SIZE, ICON_SIZE, Theme.getMutedFg()); - int textHeight = (int) (Fonts.getHeight() * textScale); - int textY = (height - textHeight) / 2; - if (text.isEmpty() && !focused) { - renderScope.drawText(textScale, placeholder, textX, textY, Theme.getBodyFont(), Theme.getMutedFg()); + renderScope.drawText(textScale, placeholder, textX, Field.textY(height, textScale), + Theme.getBodyFont(), Theme.getMutedFg()); return; } - int scrollOffset = getScrollOffset(); - String visibleText = getVisibleText(scrollOffset); - renderScope.drawText(textScale, visibleText, textX, textY, Theme.FONT.value, Theme.FOREGROUND.value); - - if (focused) { - renderScope.drawRectangle(getCursorX(visibleText, scrollOffset), textY - 1, 1, textHeight + 2, - Theme.ACCENT.value); - } + drawText(renderScope); } } 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 698b6cb..d6faa39 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/Select.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/Select.java @@ -5,36 +5,50 @@ import org.saturnclient.config.property.SelectProperty; import org.saturnclient.ui.Element; import org.saturnclient.ui.ElementContext; import org.saturnclient.ui.RenderScope; -import org.saturnclient.ui.Utils; +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. + */ public class Select extends Element { - private SelectProperty prop; + private static final int ARROW = 16; + private static final int ARROW_INSET = 8; + + private final SelectProperty prop; public Select(SelectProperty prop) { this.prop = prop; - this.width = 150; - this.height = 15; + this.width = 200; + this.height = Field.HEIGHT; } @Override public void render(RenderScope renderScope, ElementContext ctx) { - renderScope.drawText(0.8f, "<", 0, 0, Theme.FONT.value, Theme.FOREGROUND.value); + Field.drawBox(renderScope, width, height, ctx.isHovering(), false); - String text = prop.getSelection(); + boolean prevHovered = ctx.isHovering(0, 0, width / 3, height); + boolean nextHovered = ctx.isHovering() && !prevHovered; + int arrowY = (height - ARROW) / 2; - renderScope.drawText(0.6f, text, (int) (width - ((float) Fonts.getWidth(text, Theme.FONT.value) * 0.6f)) / 2, 1, - Theme.FONT.value, Theme.FOREGROUND.value); + 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()); - renderScope.drawText(0.8f, ">", width - 10, 0, 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); } @Override public void click(int mouseX, int mouseY) { - if (Utils.isHovering(mouseX - (width - 10), mouseY, 10, height, 1.0f)) { - prop.next(); - } else if (Utils.isHovering(mouseX, mouseY, 10, height, 1.0f)) { + if (mouseX < width / 3) { prop.prev(); + } else { + prop.next(); } } } diff --git a/src/main/java/org/saturnclient/ui/components/inputs/Toggle.java b/src/main/java/org/saturnclient/ui/components/inputs/Toggle.java index 0987723..ee4d445 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/Toggle.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/Toggle.java @@ -7,25 +7,22 @@ import org.saturnclient.ui.ElementContext; import org.saturnclient.ui.RenderScope; public class Toggle extends Element { + public static final int SWITCH_WIDTH = 42; + public static final int SWITCH_HEIGHT = 24; + private BoolProperty prop; public Toggle(BoolProperty prop) { this.prop = prop; - this.width = 60; - this.height = 30; + this.width = SWITCH_WIDTH; + this.height = SWITCH_HEIGHT; } @Override public void render(RenderScope renderScope, ElementContext ctx) { - renderScope.drawRoundedRectangle(0, 0, 60, 30, Theme.WIDGET_RADIUS.value, - Theme.withAlpha(0.5f, prop.value ? Theme.ACCENT.value : Theme.PRIMARY.value)); - renderScope.drawRoundedRectangle(prop.value ? 30 : 0, 0, 30, 30, Theme.WIDGET_RADIUS.value, - prop.value ? Theme.ACCENT.value : Theme.PRIMARY.value); + drawSwitch(renderScope, 0, 0, prop.value); } - public static final int SWITCH_WIDTH = 42; - public static final int SWITCH_HEIGHT = 24; - /** * Draws an on/off switch: a pill track with a round knob on the right when on. */ diff --git a/src/main/java/org/saturnclient/ui/elements/ActionButton.java b/src/main/java/org/saturnclient/ui/elements/ActionButton.java new file mode 100644 index 0000000..8fef9d3 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/elements/ActionButton.java @@ -0,0 +1,56 @@ +package org.saturnclient.ui.elements; + +import org.saturnclient.common.ref.asset.IdentifierRef; +import org.saturnclient.config.Theme; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.resources.Fonts; + +/** + * A secondary button with an icon and a label: outlined, or plain text that + * lights up on hover (for links like "back"). + */ +public class ActionButton extends Element { + private static final int HEIGHT = 34; + private static final int PADDING = 12; + private static final int ICON = 15; + private static final int ICON_GAP = 7; + private static final float TEXT_SCALE = 0.66f; + + private final IdentifierRef icon; + private final String text; + private final boolean outlined; + private final Runnable onClick; + + public ActionButton(IdentifierRef icon, String text, boolean outlined, Runnable onClick) { + this.icon = icon; + this.text = text; + this.outlined = outlined; + this.onClick = onClick; + this.height = HEIGHT; + this.width = PADDING * 2 + ICON + ICON_GAP + (int) (Fonts.getWidth(text, Theme.FONT.value) * TEXT_SCALE); + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + boolean hovered = ctx.isHovering(); + + if (outlined) { + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1, + hovered ? Theme.withAlpha(0.06f, Theme.FOREGROUND.value) : 0, + Theme.withAlpha(hovered ? 0.25f : 0.12f, Theme.FOREGROUND.value)); + } + + int color = hovered ? Theme.FOREGROUND.value : outlined ? Theme.PRIMARY_FG.value : Theme.getMutedFg(); + + renderScope.drawTexture(icon, PADDING, (height - ICON) / 2, 0, 0, ICON, ICON, color); + renderScope.drawText(TEXT_SCALE, text, PADDING + ICON + ICON_GAP, Field.textY(height, TEXT_SCALE), + Theme.FONT.value, color); + } + + @Override + public void click(int mouseX, int mouseY) { + onClick.run(); + } +} diff --git a/src/main/java/org/saturnclient/ui/elements/Divider.java b/src/main/java/org/saturnclient/ui/elements/Divider.java new file mode 100644 index 0000000..abb2c26 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/elements/Divider.java @@ -0,0 +1,22 @@ +package org.saturnclient.ui.elements; + +import org.saturnclient.config.Theme; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; + +/** + * A faint horizontal line across the element's width. + */ +public class Divider extends Element { + + public Divider(int width) { + this.width = width; + this.height = 1; + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + renderScope.drawRectangle(0, 0, width, 1, Theme.getBorder()); + } +} diff --git a/src/main/java/org/saturnclient/ui/elements/Field.java b/src/main/java/org/saturnclient/ui/elements/Field.java new file mode 100644 index 0000000..4c857f2 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/elements/Field.java @@ -0,0 +1,44 @@ +package org.saturnclient.ui.elements; + +import org.saturnclient.config.Theme; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.resources.Fonts; + +/** + * The shared look of input fields: a faint box whose outline brightens on hover + * and turns to the accent color while focused. + */ +public final class Field { + /** Height of the fields in settings rows */ + public static final int HEIGHT = 32; + public static final float TEXT_SCALE = 0.68f; + + private Field() { + } + + public static void drawBox(RenderScope renderScope, int width, int height, boolean hovered, boolean focused) { + int border = focused ? Theme.withAlpha(0.6f, Theme.ACCENT.value) + : hovered ? Theme.withAlpha(0.25f, Theme.FOREGROUND.value) + : Theme.withAlpha(0.1f, Theme.FOREGROUND.value); + + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1, + Theme.withAlpha(0.06f, Theme.FOREGROUND.value), border); + } + + /** + * The y at which text drawn at {@code scale} is vertically centered in a + * field of the given height. + */ + public static int textY(int height, float scale) { + return (height - (int) (Fonts.getHeight() * scale)) / 2; + } + + /** + * Draws {@code text} centered in a field of the given size. + */ + public static void drawCenteredText(RenderScope renderScope, String text, int width, int height, int font, + int color) { + int x = (width - (int) (Fonts.getWidth(text, font) * TEXT_SCALE)) / 2; + renderScope.drawText(TEXT_SCALE, text, x, textY(height, TEXT_SCALE), font, color); + } +} diff --git a/src/main/java/org/saturnclient/ui/elements/SwitchButton.java b/src/main/java/org/saturnclient/ui/elements/SwitchButton.java new file mode 100644 index 0000000..a5bb6ba --- /dev/null +++ b/src/main/java/org/saturnclient/ui/elements/SwitchButton.java @@ -0,0 +1,59 @@ +package org.saturnclient.ui.elements; + +import java.util.function.BooleanSupplier; + +import org.saturnclient.config.Theme; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.components.inputs.Toggle; +import org.saturnclient.ui.resources.Fonts; + +/** + * A labelled on/off switch ("Enabled" / "Disabled") that tints itself with the + * accent color while on. + */ +public class SwitchButton extends Element { + private static final int HEIGHT = 34; + private static final int PADDING_LEFT = 14; + private static final int PADDING_RIGHT = 5; + private static final int GAP = 10; + private static final float TEXT_SCALE = 0.66f; + + private final String onText; + private final String offText; + private final BooleanSupplier value; + private final Runnable onToggle; + + public SwitchButton(String onText, String offText, BooleanSupplier value, Runnable onToggle) { + this.onText = onText; + this.offText = offText; + this.value = value; + this.onToggle = onToggle; + this.height = HEIGHT; + + // Sized for the longer label, so the button doesn't change width when toggled + int textWidth = Math.max(Fonts.getWidth(onText, Theme.FONT.value), Fonts.getWidth(offText, Theme.FONT.value)); + this.width = PADDING_LEFT + (int) (textWidth * TEXT_SCALE) + GAP + Toggle.SWITCH_WIDTH + PADDING_RIGHT; + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + boolean on = value.getAsBoolean(); + float tint = ctx.isHovering() ? 0.1f : 0.06f; + + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, + on ? Theme.withAlpha(tint * 2.5f, Theme.ACCENT.value) : Theme.withAlpha(tint, Theme.FOREGROUND.value)); + + renderScope.drawText(TEXT_SCALE, on ? onText : offText, PADDING_LEFT, Field.textY(height, TEXT_SCALE), + Theme.FONT.value, on ? Theme.ACCENT.value : Theme.PRIMARY_FG.value); + + Toggle.drawSwitch(renderScope, width - PADDING_RIGHT - Toggle.SWITCH_WIDTH, + (height - Toggle.SWITCH_HEIGHT) / 2, on); + } + + @Override + public void click(int mouseX, int mouseY) { + onToggle.run(); + } +} diff --git a/src/main/java/org/saturnclient/ui/resources/Textures.java b/src/main/java/org/saturnclient/ui/resources/Textures.java index 68bb46b..f6194e7 100644 --- a/src/main/java/org/saturnclient/ui/resources/Textures.java +++ b/src/main/java/org/saturnclient/ui/resources/Textures.java @@ -33,6 +33,11 @@ public class Textures { public static final IdentifierRef ICON_CLOSE = IdentifierRef.ofSaturn("textures/gui/components/close.svg"); public static final IdentifierRef ICON_SEARCH = IdentifierRef.ofSaturn("textures/gui/components/search.svg"); public static final IdentifierRef ICON_SLIDERS = IdentifierRef.ofSaturn("textures/gui/components/sliders.svg"); + public static final IdentifierRef ICON_RESET = IdentifierRef.ofSaturn("textures/gui/components/reset.svg"); + public static final IdentifierRef ICON_CHEVRON_LEFT = IdentifierRef + .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 LOGO = IdentifierRef.ofSaturn("textures/logo/logo.png"); public static final IdentifierRef REALISTIC_LOGO = IdentifierRef.ofSaturn("textures/logo/realistic.png"); diff --git a/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java b/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java index fb31348..1bb38ab 100644 --- a/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java +++ b/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java @@ -1,173 +1,37 @@ package org.saturnclient.ui.screens; -import java.util.Map; +import java.util.Set; import org.saturnclient.config.ConfigManager; import org.saturnclient.config.Theme; -import org.saturnclient.config.property.BoolProperty; -import org.saturnclient.config.property.ColorProperty; -import org.saturnclient.config.property.FloatProperty; -import org.saturnclient.config.property.IntProperty; -import org.saturnclient.config.property.KeybindingProperty; -import org.saturnclient.config.property.NamespaceProperty; -import org.saturnclient.config.property.Property; -import org.saturnclient.config.property.SelectProperty; -import org.saturnclient.mod.HudMod; -import org.saturnclient.mod.ModLayout; -import org.saturnclient.ui.RenderScope; -import org.saturnclient.ui.SaturnScreen; -import org.saturnclient.ui.components.HudModPreview; +import org.saturnclient.ui.PanelScreen; +import org.saturnclient.ui.components.PropertyList; import org.saturnclient.ui.components.Sidebar; -import org.saturnclient.ui.components.inputs.FloatInput; -import org.saturnclient.ui.components.inputs.ColorInput; -import org.saturnclient.ui.components.inputs.IntInput; -import org.saturnclient.ui.components.inputs.KeybindingSelector; -import org.saturnclient.ui.components.inputs.Select; -import org.saturnclient.ui.components.inputs.Toggle; +import org.saturnclient.ui.elements.Label; import org.saturnclient.ui.elements.Scroll; -import org.saturnclient.ui.elements.Text; -import org.saturnclient.ui.resources.Fonts; -public class ConfigEditor extends SaturnScreen { - private ConfigManager config; - private HudMod hudMod; - private HudModPreview preview; +/** + * Edits every property of a config, such as the client's own settings. + */ +public class ConfigEditor extends PanelScreen { + private static final int SCROLLBAR_SPACE = 14; + private static final int LIST_Y = 44; + + private final ConfigManager config; public ConfigEditor(ConfigManager config) { - super("Config Editor"); - + super("Config Editor", Sidebar.SETTINGS); this.config = config; } - public ConfigEditor(ConfigManager config, HudMod hudMod) { - super("Config Editor"); - - this.config = config; - this.hudMod = hudMod; - } - @Override - public void ui() { - int p = 10; - int g = 10; + protected void panel() { + draw(new Label(() -> "Settings", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value) + .position(contentX, contentY + 2)); - Scroll scroll = new Scroll(p); - - drawProperties(scroll, config.getProperties(), 0, 0, 480 + 10 + (g * 2)); - - int scrollWidth = 480 + 10 + (g * 2) + (p * 2); - - if (this.hudMod != null) { - this.preview = new HudModPreview(this.hudMod); - } - - draw(scroll.dimensions(scrollWidth, 350).center(width, height)); - - draw(new Sidebar(Sidebar.SETTINGS, this.provider::close).center(width, height, -(scrollWidth / 2 + 35), 0)); - } - - private int drawProperties(Scroll configScroll, Map properties, int row, int col, int w) { - for (Map.Entry propEntry : properties.entrySet()) { - Property prop = propEntry.getValue(); - String propName = propEntry.getKey(); - boolean full = isFull(prop); - - // Always reset to col 0 if full-width component needs to be drawn mid-row - if (full && col != 0) { - col = 0; - row++; - } - - int modX = (w / 2) * col; // two-column layout - int modY = 25 * row; - - if (prop instanceof NamespaceProperty) { - configScroll.draw( - new Text(propName) - .position(Fonts.centerX(520, propName, Theme.FONT.value), modY + 1) - .scale(0.7f)); - } else { - configScroll.draw( - new Text(propName) - .position(modX, modY + 1) - .scale(0.7f)); - } - - // instanceof dispatch - if (prop instanceof BoolProperty p) { - configScroll.draw( - new Toggle(p) - .position(modX + (w / 2) - 40, modY) - .scale(0.5f)); - } - - else if (prop instanceof ColorProperty p) { - configScroll.draw( - new ColorInput(p) - .position(w / 2, modY)); - } - - else if (prop instanceof IntProperty p) { - configScroll.draw( - new IntInput(p) - .position(w / 2, modY)); - } - - else if (prop instanceof FloatProperty p) { - configScroll.draw( - new FloatInput(p) - .position(w / 2, modY)); - } - - else if (prop instanceof NamespaceProperty p) { - Map nestedProperties = p.value; - row = drawProperties(configScroll, nestedProperties, row + 1, col, w); - } - - else if (prop instanceof SelectProperty p) { - configScroll.draw( - new Select(p) - .position(w / 2, modY)); - } - - else if (prop instanceof KeybindingProperty p) { - configScroll.draw( - new KeybindingSelector(p) - .position(w / 2, modY)); - } - - // STRING → intentionally no component (same as before) - - if (full) { - row++; // full-width takes a whole row - col = 0; - } else { - col++; - if (col > 1) { // max 2 columns - col = 0; - row++; - } - } - } - - return row; - } - - public static boolean isFull(Property prop) { - return !(prop instanceof BoolProperty); - } - - @Override - public void render(RenderScope renderScope, int mouseX, int mouseY, float delta, long elapsed) { - if (this.preview != null) { - ModLayout layout = hudMod.getDimensions(); - draw(this.preview - .dimensions(layout.width, (int) (layout.height * layout.scale.value * 2.0f)) - .scale(1.0f) // Because the centering won't work properly - .center(width, height, (530 / 2) + 40, 0) - .scale(layout.scale.value * 2.0f)); - } - - super.render(renderScope, mouseX, mouseY, delta, elapsed); + Scroll scroll = new Scroll(0).noBackground(); + PropertyList.draw(scroll, config.getProperties(), Set.of(), contentWidth, 0); + draw(scroll.dimensions(contentWidth + SCROLLBAR_SPACE, contentHeight - LIST_Y) + .position(contentX, contentY + LIST_Y)); } } diff --git a/src/main/java/org/saturnclient/ui/screens/ModMenu.java b/src/main/java/org/saturnclient/ui/screens/ModMenu.java index ef6eadb..eb2e742 100644 --- a/src/main/java/org/saturnclient/ui/screens/ModMenu.java +++ b/src/main/java/org/saturnclient/ui/screens/ModMenu.java @@ -8,28 +8,20 @@ import org.saturnclient.config.AnimationConfig; import org.saturnclient.config.Theme; import org.saturnclient.mod.Mod; import org.saturnclient.mod.ModManager; -import org.saturnclient.ui.SaturnScreen; +import org.saturnclient.ui.PanelScreen; import org.saturnclient.ui.anim.SlideFade; import org.saturnclient.ui.components.SaturnModule; import org.saturnclient.ui.components.Sidebar; import org.saturnclient.ui.components.inputs.SearchInput; import org.saturnclient.ui.elements.AnimationStagger; import org.saturnclient.ui.elements.ChipGroup; -import org.saturnclient.ui.elements.ImageTexture; import org.saturnclient.ui.elements.Label; -import org.saturnclient.ui.elements.Panel; import org.saturnclient.ui.elements.Scroll; import org.saturnclient.ui.resources.Fonts; -import org.saturnclient.ui.resources.Textures; -public class ModMenu extends SaturnScreen { +public class ModMenu extends PanelScreen { private static final String ALL = "All"; - private static final int MAX_WIDTH = 900; - private static final int MAX_HEIGHT = 580; - private static final int MARGIN = 40; - private static final int GAP = 12; - private static final int PADDING = 20; private static final int COLUMNS = 3; private static final int CARD_GAP = 10; private static final int SCROLLBAR_SPACE = 14; @@ -41,43 +33,30 @@ public class ModMenu extends SaturnScreen { private int gridWidth; public ModMenu() { - super("Mod Menu"); + super("Mod Menu", Sidebar.MODS); } @Override - public void ui() { + protected void panel() { // The search box starts empty after a resize, so the query does too. query = ""; - - int panelWidth = Math.min(MAX_WIDTH, width - MARGIN * 2 - Sidebar.WIDTH - GAP); - int panelHeight = Math.min(MAX_HEIGHT, height - MARGIN * 2); - int left = (width - (Sidebar.WIDTH + GAP + panelWidth)) / 2; - int top = (height - panelHeight) / 2; - int panelX = left + Sidebar.WIDTH + GAP; - int contentX = panelX + PADDING; - gridWidth = panelWidth - PADDING * 2; - - draw(new Sidebar(Sidebar.MODS, this.provider::close) - .dimensions(Sidebar.WIDTH, panelHeight) - .position(left, top)); - - draw(new Panel().dimensions(panelWidth, panelHeight).position(panelX, top)); + gridWidth = contentWidth; // Header draw(new Label(() -> "Mods", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value) - .position(contentX, top + PADDING + 2)); + .position(contentX, contentY + 2)); draw(new Label(this::getEnabledSummary, 0.66f, Theme::getBodyFont, Theme::getMutedFg) - .position(contentX, top + PADDING + 28)); + .position(contentX, contentY + 28)); SearchInput search = new SearchInput("Search mods", text -> { query = text; refresh(false); }); - draw(search.position(panelX + panelWidth - PADDING - search.width, top + PADDING + 2)); + draw(search.position(contentX + contentWidth - search.width, contentY + 2)); // Categories - int chipsY = top + PADDING + 56; + int chipsY = contentY + 56; ChipGroup categories = new ChipGroup(getCategories(), category, selected -> { category = selected; refresh(false); @@ -87,11 +66,9 @@ public class ModMenu extends SaturnScreen { // Mods int scrollY = chipsY + categories.height + 14; scroll = new Scroll(0).noBackground(); - scroll.dimensions(gridWidth + SCROLLBAR_SPACE, top + panelHeight - 12 - scrollY); + scroll.dimensions(gridWidth + SCROLLBAR_SPACE, panelY + panelHeight - 12 - scrollY); refresh(true); draw(scroll.position(contentX, scrollY)); - - draw(new ImageTexture(Textures.LOGO_TEXT_BIG).dimensions(180, 18).position(width - 180 - 20, height - 18 - 20)); } /** diff --git a/src/main/java/org/saturnclient/ui/screens/ModSettings.java b/src/main/java/org/saturnclient/ui/screens/ModSettings.java new file mode 100644 index 0000000..9d924b9 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/screens/ModSettings.java @@ -0,0 +1,97 @@ +package org.saturnclient.ui.screens; + +import java.util.Map; +import java.util.Set; + +import org.saturnclient.common.provider.Providers; +import org.saturnclient.config.property.Property; +import org.saturnclient.mod.HudMod; +import org.saturnclient.mod.Mod; +import org.saturnclient.ui.PanelScreen; +import org.saturnclient.ui.components.HudPreviewCard; +import org.saturnclient.ui.components.ModHeader; +import org.saturnclient.ui.components.PropertyList; +import org.saturnclient.ui.components.Sidebar; +import org.saturnclient.ui.elements.ActionButton; +import org.saturnclient.ui.elements.Divider; +import org.saturnclient.ui.elements.Scroll; +import org.saturnclient.ui.elements.SwitchButton; +import org.saturnclient.ui.resources.Textures; + +/** + * One mod's settings: a header with the mod's details and its on/off switch, + * the rest of its properties below, and a live preview for HUD mods. + */ +public class ModSettings extends PanelScreen { + /** + * Every mod's first property. It's shown as the header's switch, so the list + * leaves it out. + */ + private static final String ENABLED = "Enabled"; + + private static final int HEADER_Y = 40; + private static final int PREVIEW_WIDTH = 280; + private static final int PREVIEW_MAX_HEIGHT = 220; + private static final int COLUMN_GAP = 22; + private static final int SCROLLBAR_SPACE = 14; + + private final Mod mod; + + public ModSettings(Mod mod) { + super(mod.getName() + " Settings", Sidebar.MODS); + this.mod = mod; + } + + @Override + protected void panel() { + draw(new ActionButton(Textures.ICON_CHEVRON_LEFT, "Mods", false, + () -> Providers.saturn.getClient().setScreen(new ModMenu())) + .position(contentX - 12, contentY - 4)); + + // Header: the mod's details, then Reset and the on/off switch on the right + int headerY = contentY + HEADER_Y; + + SwitchButton enabled = new SwitchButton("Enabled", "Disabled", mod::isEnabled, + () -> mod.setEnabled(!mod.isEnabled())); + int buttonY = headerY + (ModHeader.HEIGHT - enabled.height) / 2; + draw(enabled.position(contentX + contentWidth - enabled.width, buttonY)); + + ActionButton reset = new ActionButton(Textures.ICON_RESET, "Reset", true, this::reset); + draw(reset.position(enabled.x - 10 - reset.width, buttonY)); + + draw(new ModHeader(mod, reset.x - 16 - contentX).position(contentX, headerY)); + + int dividerY = headerY + ModHeader.HEIGHT + 16; + draw(new Divider(contentWidth).position(contentX, dividerY)); + + // Body: the properties, with a preview beside them for HUD mods + int bodyY = dividerY + 1; + int bodyHeight = contentY + contentHeight - bodyY; + int listWidth = contentWidth; + + if (mod instanceof HudMod hudMod) { + listWidth -= PREVIEW_WIDTH + COLUMN_GAP; + draw(new HudPreviewCard(hudMod) + .dimensions(PREVIEW_WIDTH, Math.min(PREVIEW_MAX_HEIGHT, bodyHeight - 16)) + .position(contentX + contentWidth - PREVIEW_WIDTH, bodyY + 16)); + } + + Scroll scroll = new Scroll(0).noBackground(); + PropertyList.draw(scroll, mod.getConfig().getProperties(), Set.of(ENABLED), listWidth, 0); + draw(scroll.dimensions(listWidth + SCROLLBAR_SPACE, bodyHeight).position(contentX, bodyY)); + } + + /** + * Puts every setting except the on/off switch back to its default, then + * rebuilds the screen so the inputs show the new values. + */ + private void reset() { + for (Map.Entry entry : mod.getConfig().getProperties().entrySet()) { + if (!entry.getKey().equals(ENABLED)) { + entry.getValue().reset(); + } + } + + init(); + } +} diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/chevron_left.svg b/src/main/resources/assets/saturnclient/textures/gui/components/chevron_left.svg new file mode 100644 index 0000000..f8492e1 --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/chevron_left.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/chevron_right.svg b/src/main/resources/assets/saturnclient/textures/gui/components/chevron_right.svg new file mode 100644 index 0000000..a8cbb0e --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/chevron_right.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/reset.svg b/src/main/resources/assets/saturnclient/textures/gui/components/reset.svg new file mode 100644 index 0000000..6d27dbc --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/reset.svg @@ -0,0 +1 @@ + -- 2.54.0 From a413648be95ae0f1d023aadc0300f7e987e244bf Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 17:25:47 +0200 Subject: [PATCH 04/13] Redesign client settings ClientSettings replaces ConfigEditor: a section list on the left (General, UI Style, Animations) and the chosen section's settings on the right, each with a Reset for that section. UI Style shows four theme presets, the theme colors as a two-column grid, then the radii and font. New pieces: SectionNav, ThemePresetCard, ColorGrid and Theme.PRESETS. Theme keeps its config like AnimationConfig does, Divider can be vertical, and PropertyList's section titles are public. Co-Authored-By: Claude Opus 5.5 --- .../java/org/saturnclient/config/Theme.java | 35 +++- .../saturnclient/ui/components/ColorGrid.java | 85 ++++++++++ .../ui/components/PropertyList.java | 17 +- .../ui/components/SectionNav.java | 84 ++++++++++ .../saturnclient/ui/components/Sidebar.java | 6 +- .../ui/components/ThemePresetCard.java | 60 +++++++ .../org/saturnclient/ui/elements/Divider.java | 5 +- .../saturnclient/ui/resources/Textures.java | 3 + .../ui/screens/ClientSettings.java | 151 ++++++++++++++++++ .../saturnclient/ui/screens/ConfigEditor.java | 37 ----- .../textures/gui/components/animations.svg | 1 + .../textures/gui/components/palette.svg | 1 + 12 files changed, 436 insertions(+), 49 deletions(-) create mode 100644 src/main/java/org/saturnclient/ui/components/ColorGrid.java create mode 100644 src/main/java/org/saturnclient/ui/components/SectionNav.java create mode 100644 src/main/java/org/saturnclient/ui/components/ThemePresetCard.java create mode 100644 src/main/java/org/saturnclient/ui/screens/ClientSettings.java delete mode 100644 src/main/java/org/saturnclient/ui/screens/ConfigEditor.java create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/animations.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/palette.svg diff --git a/src/main/java/org/saturnclient/config/Theme.java b/src/main/java/org/saturnclient/config/Theme.java index 8a9af41..dbfca5f 100644 --- a/src/main/java/org/saturnclient/config/Theme.java +++ b/src/main/java/org/saturnclient/config/Theme.java @@ -1,11 +1,15 @@ package org.saturnclient.config; +import java.util.List; + import org.saturnclient.config.property.ColorProperty; import org.saturnclient.config.property.IntProperty; import org.saturnclient.config.property.Property; import org.saturnclient.config.property.SelectProperty; public class Theme { + public static ConfigManager config; + // Main UI background (slightly warm dark to match Saturn tone) public static final ColorProperty BACKGROUND = Property.color(0xFF12100D); @@ -34,8 +38,37 @@ public class Theme { // Font public static final SelectProperty FONT = Property.font(2); + /** + * A full set of theme colors that can be applied in one go. + */ + public record Preset(String name, int background, int foreground, int primary, int primaryFg, int accent, + int accentFg, int scroll) { + + public void apply() { + BACKGROUND.value = background; + FOREGROUND.value = foreground; + PRIMARY.value = primary; + PRIMARY_FG.value = primaryFg; + ACCENT.value = accent; + ACCENT_FG.value = accentFg; + SCROLL.value = scroll; + } + + public boolean isActive() { + return BACKGROUND.value == background && FOREGROUND.value == foreground && PRIMARY.value == primary + && PRIMARY_FG.value == primaryFg && ACCENT.value == accent && ACCENT_FG.value == accentFg + && SCROLL.value == scroll; + } + } + + public static final List PRESETS = List.of( + new Preset("Saturn", 0xFF12100D, 0xFFF2E6D2, 0xFF1C1915, 0xFFEAD9B8, 0xFFE2BF7D, 0xFF1A140A, 0xFFE2BF7D), + new Preset("Midnight", 0xFF0E1117, 0xFFE4EAF5, 0xFF171C26, 0xFFC9D4E8, 0xFF8FB3FF, 0xFF0A1224, 0xFF8FB3FF), + new Preset("Amethyst", 0xFF120F17, 0xFFEDE4F7, 0xFF1C1724, 0xFFD8C9EB, 0xFFC79BFF, 0xFF1A0F2A, 0xFFC79BFF), + new Preset("Emerald", 0xFF0D1210, 0xFFE2F2EA, 0xFF16201B, 0xFFC4E0D2, 0xFF7FD1A8, 0xFF0A1F15, 0xFF7FD1A8)); + public static void init(ConfigManager parent) { - ConfigManager config = new ConfigManager(parent, "UI Style"); + config = new ConfigManager(parent, "UI Style"); config.property("Background", BACKGROUND); config.property("Foreground", FOREGROUND); config.property("Primary", PRIMARY); diff --git a/src/main/java/org/saturnclient/ui/components/ColorGrid.java b/src/main/java/org/saturnclient/ui/components/ColorGrid.java new file mode 100644 index 0000000..3f785fc --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/ColorGrid.java @@ -0,0 +1,85 @@ +package org.saturnclient.ui.components; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.saturnclient.config.Theme; +import org.saturnclient.config.property.ColorProperty; +import org.saturnclient.config.property.Property; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.components.inputs.ColorInput; +import org.saturnclient.ui.elements.Scroll; +import org.saturnclient.ui.resources.Fonts; + +/** + * Lays out the color properties of a config as a two-column grid of tiles, + * each with the color's name and its input. + */ +public final class ColorGrid { + private static final int COLUMNS = 2; + private static final int TILE_HEIGHT = 48; + private static final int GAP = 8; + private static final int INSET = 8; + + private ColorGrid() { + } + + /** + * Adds a tile for every {@link ColorProperty} in {@code properties} to + * {@code scroll}, starting at {@code y}. + * + * @return the y below the grid + */ + 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()) { + if (!(entry.getValue() instanceof ColorProperty color)) { + continue; + } + + int x = (i % COLUMNS) * (tileWidth + GAP); + int tileY = y + (i / COLUMNS) * (TILE_HEIGHT + GAP); + + 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)); + 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; + } + + private static class Tile extends Element { + private static final float NAME_SCALE = 0.7f; + + private final String name; + private final int inputWidth; + + Tile(String name, int width, int inputWidth) { + this.name = name; + this.inputWidth = inputWidth; + this.width = width; + this.height = TILE_HEIGHT; + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1, + Theme.withAlpha(0.72f, Theme.PRIMARY.value), Theme.withAlpha(0.06f, Theme.FOREGROUND.value)); + + String text = Fonts.ellipsize(name, Theme.FONT.value, NAME_SCALE, width - inputWidth - INSET * 2 - 12); + int nameY = (height - (int) (Fonts.getHeight() * NAME_SCALE)) / 2; + renderScope.drawText(NAME_SCALE, text, 12, nameY, Theme.FONT.value, Theme.FOREGROUND.value); + } + } +} diff --git a/src/main/java/org/saturnclient/ui/components/PropertyList.java b/src/main/java/org/saturnclient/ui/components/PropertyList.java index b576c02..91e1a39 100644 --- a/src/main/java/org/saturnclient/ui/components/PropertyList.java +++ b/src/main/java/org/saturnclient/ui/components/PropertyList.java @@ -66,11 +66,7 @@ public final class PropertyList { } if (prop instanceof NamespaceProperty namespace) { - y += SECTION_GAP; - String title = name.toUpperCase(Locale.ROOT); - scroll.draw(new Label(() -> title, SECTION_SCALE, () -> Theme.FONT.value, Theme::getMutedFg) - .position(0, y)); - y += (int) (Fonts.getHeight() * SECTION_SCALE) + 4; + y = drawSection(scroll, name, y + SECTION_GAP) + 4; y = drawRows(scroll, namespace.value, Set.of(), width, y, controls); continue; } @@ -88,6 +84,17 @@ public final class PropertyList { return y; } + /** + * Adds a section title, such as a namespace's name, at {@code y}. + * + * @return the y below the title + */ + public static int drawSection(Scroll scroll, String name, int y) { + String title = name.toUpperCase(Locale.ROOT); + scroll.draw(new Label(() -> title, SECTION_SCALE, () -> Theme.FONT.value, Theme::getMutedFg).position(0, y)); + return y + (int) (Fonts.getHeight() * SECTION_SCALE); + } + /** * The input for a property, or null for types without one (strings). */ diff --git a/src/main/java/org/saturnclient/ui/components/SectionNav.java b/src/main/java/org/saturnclient/ui/components/SectionNav.java new file mode 100644 index 0000000..307c785 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/SectionNav.java @@ -0,0 +1,84 @@ +package org.saturnclient.ui.components; + +import java.util.List; +import java.util.function.IntConsumer; + +import org.saturnclient.common.ref.asset.IdentifierRef; +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; + +/** + * A vertical list of a screen's sections under a small heading, with the + * current one highlighted. + */ +public class SectionNav extends Element { + public record Item(IdentifierRef icon, String name) { + } + + private static final float HEADING_SCALE = 0.6f; + private static final int HEADING_HEIGHT = 26; + private static final int ITEM_HEIGHT = 40; + private static final int ITEM_GAP = 4; + private static final int ICON = 17; + private static final float TEXT_SCALE = 0.7f; + + private final String heading; + private final List items; + private final int active; + private final IntConsumer onSelect; + + public SectionNav(String heading, List items, int active, int width, IntConsumer onSelect) { + this.heading = heading; + this.items = items; + this.active = active; + this.onSelect = onSelect; + this.width = width; + this.height = HEADING_HEIGHT + items.size() * (ITEM_HEIGHT + ITEM_GAP); + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + renderScope.drawText(HEADING_SCALE, heading, 10, 0, Theme.FONT.value, Theme.getMutedFg()); + + for (int i = 0; i < items.size(); i++) { + Item item = items.get(i); + int y = getItemY(i); + boolean selected = i == active; + boolean hovered = ctx.isHovering(0, y, width, ITEM_HEIGHT); + + if (selected || hovered) { + renderScope.drawRoundedRectangle(0, y, width, ITEM_HEIGHT, Theme.WIDGET_RADIUS.value, + Theme.withAlpha(selected ? 0.08f : 0.04f, Theme.FOREGROUND.value)); + } + + renderScope.drawTexture(item.icon(), 12, y + (ITEM_HEIGHT - ICON) / 2, 0, 0, ICON, ICON, + selected ? Theme.ACCENT.value : Theme.getMutedFg()); + + String name = Fonts.ellipsize(item.name(), Theme.FONT.value, TEXT_SCALE, width - 12 - ICON - 10 - 8); + renderScope.drawText(TEXT_SCALE, name, 12 + ICON + 10, y + Field.textY(ITEM_HEIGHT, TEXT_SCALE), + Theme.FONT.value, selected ? Theme.FOREGROUND.value : Theme.PRIMARY_FG.value); + } + } + + @Override + public void click(int mouseX, int mouseY) { + for (int i = 0; i < items.size(); i++) { + int y = getItemY(i); + + if (mouseY >= y && mouseY <= y + ITEM_HEIGHT) { + if (i != active) { + onSelect.accept(i); + } + return; + } + } + } + + private int getItemY(int index) { + return HEADING_HEIGHT + index * (ITEM_HEIGHT + ITEM_GAP); + } +} diff --git a/src/main/java/org/saturnclient/ui/components/Sidebar.java b/src/main/java/org/saturnclient/ui/components/Sidebar.java index e263189..636ed9c 100644 --- a/src/main/java/org/saturnclient/ui/components/Sidebar.java +++ b/src/main/java/org/saturnclient/ui/components/Sidebar.java @@ -2,14 +2,13 @@ package org.saturnclient.ui.components; import org.saturnclient.common.provider.Providers; import org.saturnclient.common.ref.asset.IdentifierRef; -import org.saturnclient.config.Config; 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.Panel; import org.saturnclient.ui.resources.Textures; -import org.saturnclient.ui.screens.ConfigEditor; +import org.saturnclient.ui.screens.ClientSettings; import org.saturnclient.ui.screens.HudEditor; import org.saturnclient.ui.screens.ModMenu; import org.saturnclient.ui.screens.cosmetics.CloakMenu; @@ -40,8 +39,7 @@ public class Sidebar extends Element { new Item(Textures.ICON_MODS, () -> Providers.saturn.getClient().setScreen(new ModMenu())), new Item(Textures.ICON_COSMETICS, () -> Providers.saturn.getClient().setScreen(new CloakMenu())), new Item(Textures.ICON_HUD_EDITOR, () -> Providers.saturn.getClient().setScreen(new HudEditor())), - new Item(Textures.ICON_SETTINGS, - () -> Providers.saturn.getClient().setScreen(new ConfigEditor(Config.config))), + new Item(Textures.ICON_SETTINGS, () -> Providers.saturn.getClient().setScreen(new ClientSettings())), }; private final int active; diff --git a/src/main/java/org/saturnclient/ui/components/ThemePresetCard.java b/src/main/java/org/saturnclient/ui/components/ThemePresetCard.java new file mode 100644 index 0000000..108f7ec --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/ThemePresetCard.java @@ -0,0 +1,60 @@ +package org.saturnclient.ui.components; + +import org.saturnclient.config.Theme; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.resources.Fonts; + +/** + * A theme preset: a small mock-up of a panel in its colors, and its name. + * Clicking it applies the preset. + */ +public class ThemePresetCard extends Element { + public static final int HEIGHT = 92; + + private static final int PADDING = 10; + private static final int PREVIEW_HEIGHT = 44; + private static final float NAME_SCALE = 0.68f; + + private final Theme.Preset preset; + private final Runnable onApply; + + /** + * @param onApply runs after the preset is applied, to refresh what shows the + * old colors + */ + public ThemePresetCard(Theme.Preset preset, int width, Runnable onApply) { + this.preset = preset; + this.onApply = onApply; + this.width = width; + this.height = HEIGHT; + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + boolean active = preset.isActive(); + int border = active ? Theme.ACCENT.value + : Theme.withAlpha(ctx.isHovering() ? 0.22f : 0.06f, Theme.FOREGROUND.value); + + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, active ? 2 : 1, + Theme.withAlpha(0.72f, Theme.PRIMARY.value), border); + + // A panel in the preset's colors: a line of text over an accent and a widget + int previewWidth = width - PADDING * 2; + renderScope.drawRoundedRectangle(PADDING, PADDING, previewWidth, PREVIEW_HEIGHT, 8, preset.background()); + renderScope.drawRoundedRectangle(PADDING + 8, PADDING + 8, previewWidth * 6 / 10, 5, 3, + Theme.withAlpha(0.8f, preset.foreground())); + renderScope.drawRoundedRectangle(PADDING + 8, PADDING + PREVIEW_HEIGHT - 20, 34, 12, 4, preset.accent()); + renderScope.drawRoundedRectangle(PADDING + 47, PADDING + PREVIEW_HEIGHT - 20, 22, 12, 4, preset.primary()); + + int nameY = PADDING + PREVIEW_HEIGHT + (height - PADDING - PREVIEW_HEIGHT - (int) (Fonts.getHeight() * NAME_SCALE)) / 2; + renderScope.drawText(NAME_SCALE, preset.name(), PADDING, nameY, Theme.FONT.value, Theme.FOREGROUND.value); + } + + @Override + public void click(int mouseX, int mouseY) { + preset.apply(); + onApply.run(); + } +} diff --git a/src/main/java/org/saturnclient/ui/elements/Divider.java b/src/main/java/org/saturnclient/ui/elements/Divider.java index abb2c26..664a112 100644 --- a/src/main/java/org/saturnclient/ui/elements/Divider.java +++ b/src/main/java/org/saturnclient/ui/elements/Divider.java @@ -6,7 +6,8 @@ import org.saturnclient.ui.ElementContext; import org.saturnclient.ui.RenderScope; /** - * A faint horizontal line across the element's width. + * A faint line filling the element: horizontal by default, or vertical when + * given a width of 1 and a height. */ public class Divider extends Element { @@ -17,6 +18,6 @@ public class Divider extends Element { @Override public void render(RenderScope renderScope, ElementContext ctx) { - renderScope.drawRectangle(0, 0, width, 1, Theme.getBorder()); + renderScope.drawRectangle(0, 0, width, height, Theme.getBorder()); } } diff --git a/src/main/java/org/saturnclient/ui/resources/Textures.java b/src/main/java/org/saturnclient/ui/resources/Textures.java index f6194e7..04878c8 100644 --- a/src/main/java/org/saturnclient/ui/resources/Textures.java +++ b/src/main/java/org/saturnclient/ui/resources/Textures.java @@ -33,6 +33,9 @@ public class Textures { public static final IdentifierRef ICON_CLOSE = IdentifierRef.ofSaturn("textures/gui/components/close.svg"); public static final IdentifierRef ICON_SEARCH = IdentifierRef.ofSaturn("textures/gui/components/search.svg"); public static final IdentifierRef ICON_SLIDERS = IdentifierRef.ofSaturn("textures/gui/components/sliders.svg"); + public static final IdentifierRef ICON_PALETTE = IdentifierRef.ofSaturn("textures/gui/components/palette.svg"); + public static final IdentifierRef ICON_ANIMATIONS = IdentifierRef + .ofSaturn("textures/gui/components/animations.svg"); public static final IdentifierRef ICON_RESET = IdentifierRef.ofSaturn("textures/gui/components/reset.svg"); public static final IdentifierRef ICON_CHEVRON_LEFT = IdentifierRef .ofSaturn("textures/gui/components/chevron_left.svg"); diff --git a/src/main/java/org/saturnclient/ui/screens/ClientSettings.java b/src/main/java/org/saturnclient/ui/screens/ClientSettings.java new file mode 100644 index 0000000..d002207 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/screens/ClientSettings.java @@ -0,0 +1,151 @@ +package org.saturnclient.ui.screens; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.saturnclient.common.ref.asset.IdentifierRef; +import org.saturnclient.config.AnimationConfig; +import org.saturnclient.config.Config; +import org.saturnclient.config.Theme; +import org.saturnclient.config.property.ColorProperty; +import org.saturnclient.config.property.NamespaceProperty; +import org.saturnclient.config.property.Property; +import org.saturnclient.ui.PanelScreen; +import org.saturnclient.ui.components.ColorGrid; +import org.saturnclient.ui.components.PropertyList; +import org.saturnclient.ui.components.SectionNav; +import org.saturnclient.ui.components.Sidebar; +import org.saturnclient.ui.components.ThemePresetCard; +import org.saturnclient.ui.elements.ActionButton; +import org.saturnclient.ui.elements.Divider; +import org.saturnclient.ui.elements.Label; +import org.saturnclient.ui.elements.Scroll; +import org.saturnclient.ui.resources.Textures; + +/** + * The client's own settings, split into sections picked from a list on the left. + */ +public class ClientSettings extends PanelScreen { + private enum Section { + GENERAL("General", "Title screen, emotes and cosmetics", Textures.ICON_SLIDERS), + UI_STYLE("UI Style", "Colors, corners and font for every Saturn screen", Textures.ICON_PALETTE), + ANIMATIONS("Animations", "How long menu animations take, and their easing", Textures.ICON_ANIMATIONS); + + final String title; + final String description; + final IdentifierRef icon; + + Section(String title, String description, IdentifierRef icon) { + this.title = title; + this.description = description; + this.icon = icon; + } + } + + private static final int NAV_WIDTH = 170; + private static final int NAV_GAP = 16; + private static final int LIST_Y = 58; + private static final int SECTION_GAP = 22; + private static final int PRESET_GAP = 10; + private static final int SCROLLBAR_SPACE = 14; + + private Section section = Section.GENERAL; + + public ClientSettings() { + super("Settings", Sidebar.SETTINGS); + } + + @Override + protected void panel() { + List items = Stream.of(Section.values()) + .map(s -> new SectionNav.Item(s.icon, s.title)) + .toList(); + + draw(new SectionNav("SATURN CLIENT", items, section.ordinal(), NAV_WIDTH, i -> { + section = Section.values()[i]; + init(); + }).position(contentX, contentY + 4)); + + int dividerX = contentX + NAV_WIDTH + NAV_GAP; + draw(new Divider(1).dimensions(1, panelHeight).position(dividerX, panelY)); + + int x = dividerX + 1 + NAV_GAP + 4; + int width = contentX + contentWidth - x; + + // Header + draw(new Label(() -> section.title, 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value) + .position(x, contentY + 2)); + draw(new Label(() -> section.description, 0.66f, Theme::getBodyFont, Theme::getMutedFg) + .position(x, contentY + 28)); + + ActionButton reset = new ActionButton(Textures.ICON_RESET, "Reset", true, this::reset); + draw(reset.position(x + width - reset.width, contentY + 4)); + + // The section's settings + Scroll scroll = new Scroll(0).noBackground(); + + switch (section) { + case GENERAL -> PropertyList.draw(scroll, getGeneralProperties(), Set.of(), width, 0); + case UI_STYLE -> drawUiStyle(scroll, width); + case ANIMATIONS -> PropertyList.draw(scroll, AnimationConfig.config.getProperties(), Set.of(), width, 0); + } + + draw(scroll.dimensions(width + SCROLLBAR_SPACE, contentHeight - LIST_Y).position(x, contentY + LIST_Y)); + } + + private void drawUiStyle(Scroll scroll, int width) { + Map properties = Theme.config.getProperties(); + + int y = PropertyList.drawSection(scroll, "Presets", 0) + 10; + int cardWidth = (width - PRESET_GAP * (Theme.PRESETS.size() - 1)) / Theme.PRESETS.size(); + + for (int i = 0; i < Theme.PRESETS.size(); i++) { + // Rebuild so the color inputs show the preset's colors + scroll.draw(new ThemePresetCard(Theme.PRESETS.get(i), cardWidth, this::init) + .position(i * (cardWidth + PRESET_GAP), y)); + } + + y += ThemePresetCard.HEIGHT + SECTION_GAP; + y = PropertyList.drawSection(scroll, "Colors", y) + 10; + y = ColorGrid.draw(scroll, properties, width, y) + SECTION_GAP; + + y = PropertyList.drawSection(scroll, "Shape & type", y) + 4; + PropertyList.draw(scroll, properties, getNames(properties, p -> p instanceof ColorProperty), width, y); + } + + /** + * The top-level settings. The namespaces have sections of their own. + */ + private static Map getGeneralProperties() { + return Config.config.getProperties().entrySet().stream() + .filter(e -> !(e.getValue() instanceof NamespaceProperty)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, + LinkedHashMap::new)); + } + + private static Set getNames(Map properties, Predicate filter) { + return properties.entrySet().stream() + .filter(e -> filter.test(e.getValue())) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + /** + * Puts the current section's settings back to their defaults. + */ + private void reset() { + Map properties = switch (section) { + case GENERAL -> getGeneralProperties(); + case UI_STYLE -> Theme.config.getProperties(); + case ANIMATIONS -> AnimationConfig.config.getProperties(); + }; + + properties.values().forEach(Property::reset); + init(); + } +} diff --git a/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java b/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java deleted file mode 100644 index 1bb38ab..0000000 --- a/src/main/java/org/saturnclient/ui/screens/ConfigEditor.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.saturnclient.ui.screens; - -import java.util.Set; - -import org.saturnclient.config.ConfigManager; -import org.saturnclient.config.Theme; -import org.saturnclient.ui.PanelScreen; -import org.saturnclient.ui.components.PropertyList; -import org.saturnclient.ui.components.Sidebar; -import org.saturnclient.ui.elements.Label; -import org.saturnclient.ui.elements.Scroll; - -/** - * Edits every property of a config, such as the client's own settings. - */ -public class ConfigEditor extends PanelScreen { - private static final int SCROLLBAR_SPACE = 14; - private static final int LIST_Y = 44; - - private final ConfigManager config; - - public ConfigEditor(ConfigManager config) { - super("Config Editor", Sidebar.SETTINGS); - this.config = config; - } - - @Override - protected void panel() { - draw(new Label(() -> "Settings", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value) - .position(contentX, contentY + 2)); - - Scroll scroll = new Scroll(0).noBackground(); - PropertyList.draw(scroll, config.getProperties(), Set.of(), contentWidth, 0); - draw(scroll.dimensions(contentWidth + SCROLLBAR_SPACE, contentHeight - LIST_Y) - .position(contentX, contentY + LIST_Y)); - } -} diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/animations.svg b/src/main/resources/assets/saturnclient/textures/gui/components/animations.svg new file mode 100644 index 0000000..7c5fed6 --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/animations.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/palette.svg b/src/main/resources/assets/saturnclient/textures/gui/components/palette.svg new file mode 100644 index 0000000..4993031 --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/palette.svg @@ -0,0 +1 @@ + -- 2.54.0 From 7264c7b38bb5566a7713fa3b6f76b37339eaf66b Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 17:33:25 +0200 Subject: [PATCH 05/13] Redesign cosmetics CosmeticsMenu replaces the cloak and hat menus and stores: the player on the left, turned by dragging, and on the right Cloaks and Hats tabs over a grid of every cosmetic. Clicking an owned one equips it, and clicking one not owned yet claims it, which is free. The equipped one is outlined and badged, and the grid asks to connect when there's no Saturn session. SkinPreview is now a drag-to-rotate player view, and its EntityDrawer draws the player filling a box at a given yaw, returning false when there's no player. CosmeticPreview and TabMenu go with the old screens. Co-Authored-By: Claude Opus 5.5 --- .../ui/components/CosmeticPreview.java | 43 ---- .../ui/components/CosmeticTile.java | 125 ++++++++++ .../saturnclient/ui/components/Sidebar.java | 4 +- .../ui/components/SkinPreview.java | 58 ++++- .../org/saturnclient/ui/elements/TabMenu.java | 63 ----- .../ui/screens/CosmeticsMenu.java | 233 ++++++++++++++++++ .../saturnclient/ui/screens/ShiftMenu.java | 3 +- .../saturnclient/ui/screens/TitleMenu.java | 3 +- .../ui/screens/cosmetics/CloakMenu.java | 82 ------ .../ui/screens/cosmetics/HatMenu.java | 82 ------ .../ui/screens/store/CloakStore.java | 142 ----------- .../ui/screens/store/HatStore.java | 142 ----------- 12 files changed, 412 insertions(+), 568 deletions(-) delete mode 100644 src/main/java/org/saturnclient/ui/components/CosmeticPreview.java create mode 100644 src/main/java/org/saturnclient/ui/components/CosmeticTile.java delete mode 100644 src/main/java/org/saturnclient/ui/elements/TabMenu.java create mode 100644 src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java delete mode 100644 src/main/java/org/saturnclient/ui/screens/cosmetics/CloakMenu.java delete mode 100644 src/main/java/org/saturnclient/ui/screens/cosmetics/HatMenu.java delete mode 100644 src/main/java/org/saturnclient/ui/screens/store/CloakStore.java delete mode 100644 src/main/java/org/saturnclient/ui/screens/store/HatStore.java diff --git a/src/main/java/org/saturnclient/ui/components/CosmeticPreview.java b/src/main/java/org/saturnclient/ui/components/CosmeticPreview.java deleted file mode 100644 index 81935ad..0000000 --- a/src/main/java/org/saturnclient/ui/components/CosmeticPreview.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.saturnclient.ui.components; - -import org.saturnclient.common.ref.asset.IdentifierRef; -import org.saturnclient.config.Theme; -import org.saturnclient.ui.Element; -import org.saturnclient.ui.ElementContext; -import org.saturnclient.ui.RenderScope; - -public class CosmeticPreview extends Element { - static int padding = 14; - - IdentifierRef sprite; - private Runnable onClick; - private boolean isSelected; - - public CosmeticPreview(boolean isSelected, IdentifierRef sprite, Runnable onClick) { - this.sprite = sprite; - this.onClick = onClick; - this.isSelected = isSelected; - - this.width = 50; - this.height = 111; - } - - @Override - public void render(RenderScope renderScope, ElementContext ctx) { - renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, - isSelected ? Theme.ACCENT.value : Theme.PRIMARY.value); - - int texWidth = width - padding; - int texHeight = height - padding; - - renderScope.drawTexture(sprite, padding / 2, padding / 2, 0, 0, texWidth, texHeight, - Theme.FOREGROUND.value); - } - - @Override - public void click(int mouseX, int mouseY) { - if (onClick != null) { - onClick.run(); - } - } -} diff --git a/src/main/java/org/saturnclient/ui/components/CosmeticTile.java b/src/main/java/org/saturnclient/ui/components/CosmeticTile.java new file mode 100644 index 0000000..2d25666 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/CosmeticTile.java @@ -0,0 +1,125 @@ +package org.saturnclient.ui.components; + +import java.util.function.BooleanSupplier; + +import org.saturnclient.common.ref.asset.IdentifierRef; +import org.saturnclient.config.Theme; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.resources.Fonts; +import org.saturnclient.ui.resources.Textures; + +/** + * One cosmetic in the cosmetics grid: its preview and name. It shows whether + * it's equipped, and cosmetics not owned yet are dimmed with a "Get" label. + */ +public class CosmeticTile extends Element { + public static final int HEIGHT = 168; + + private static final int INSET = 8; + private static final int IMAGE_AREA = 124; + private static final float NAME_SCALE = 0.62f; + private static final float BADGE_SCALE = 0.54f; + private static final int BADGE_HEIGHT = 20; + /** How long a claim may take before it can be tried again, in ms */ + private static final long CLAIM_TIMEOUT = 5000; + + private final String name; + private final IdentifierRef preview; + private final float previewAspect; + private final BooleanSupplier owned; + private final BooleanSupplier equipped; + private final Runnable onEquip; + private final Runnable onClaim; + + private long claimedAt = 0; + + /** + * @param previewAspect the preview texture's width divided by its height + */ + public CosmeticTile(String name, IdentifierRef preview, float previewAspect, int width, BooleanSupplier owned, + BooleanSupplier equipped, Runnable onEquip, Runnable onClaim) { + this.name = name; + this.preview = preview; + this.previewAspect = previewAspect; + this.owned = owned; + this.equipped = equipped; + this.onEquip = onEquip; + this.onClaim = onClaim; + this.width = width; + this.height = HEIGHT; + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + boolean isOwned = owned.getAsBoolean(); + boolean isEquipped = equipped.getAsBoolean(); + boolean hovered = ctx.isHovering(); + + int border = isEquipped ? Theme.ACCENT.value + : Theme.withAlpha(hovered ? 0.22f : 0.06f, Theme.FOREGROUND.value); + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, isEquipped ? 2 : 1, + Theme.withAlpha(0.72f, Theme.PRIMARY.value), border); + + // Preview, fitted into its area + int areaWidth = width - INSET * 2; + renderScope.drawRoundedRectangle(INSET, INSET, areaWidth, IMAGE_AREA, Theme.WIDGET_RADIUS.value - 2, + Theme.withAlpha(0.05f, Theme.FOREGROUND.value)); + + int imageHeight = IMAGE_AREA - 12; + int imageWidth = (int) (imageHeight * previewAspect); + if (imageWidth > areaWidth - 12) { + imageWidth = areaWidth - 12; + imageHeight = (int) (imageWidth / previewAspect); + } + + renderScope.drawTexture(preview, INSET + (areaWidth - imageWidth) / 2, INSET + (IMAGE_AREA - imageHeight) / 2, + 0, 0, imageWidth, imageHeight, isOwned ? 0xFFFFFFFF : Theme.withAlpha(0.35f, 0xFFFFFFFF)); + + // Name + int nameY = INSET + IMAGE_AREA + (HEIGHT - INSET - IMAGE_AREA - (int) (Fonts.getHeight() * NAME_SCALE)) / 2; + renderScope.drawText(NAME_SCALE, Fonts.ellipsize(name, Theme.FONT.value, NAME_SCALE, width - 24), 12, nameY, + Theme.FONT.value, isOwned ? Theme.FOREGROUND.value : Theme.getMutedFg()); + + // Badge in the preview's corner + if (isEquipped) { + drawBadge(renderScope, "On", Theme.ACCENT.value, Theme.ACCENT_FG.value, true); + } else if (!isOwned) { + drawBadge(renderScope, isClaiming() ? "Claiming" : "Get", hovered ? Theme.ACCENT.value : Theme.PRIMARY.value, + hovered ? Theme.ACCENT_FG.value : Theme.PRIMARY_FG.value, false); + } + } + + private void drawBadge(RenderScope renderScope, String text, int background, int foreground, boolean check) { + int icon = check ? 10 : 0; + int badgeWidth = 14 + icon + (check ? 4 : 0) + (int) (Fonts.getWidth(text, Theme.FONT.value) * BADGE_SCALE); + int x = width - INSET - 6 - badgeWidth; + int y = INSET + 6; + + 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); + } + + int textY = y + (BADGE_HEIGHT - (int) (Fonts.getHeight() * BADGE_SCALE)) / 2; + renderScope.drawText(BADGE_SCALE, text, x + 7 + icon + (check ? 4 : 0), textY, Theme.FONT.value, foreground); + } + + @Override + public void click(int mouseX, int mouseY) { + if (owned.getAsBoolean()) { + if (!equipped.getAsBoolean()) { + onEquip.run(); + } + } else if (!isClaiming()) { + claimedAt = System.currentTimeMillis(); + onClaim.run(); + } + } + + private boolean isClaiming() { + return System.currentTimeMillis() - claimedAt < CLAIM_TIMEOUT; + } +} diff --git a/src/main/java/org/saturnclient/ui/components/Sidebar.java b/src/main/java/org/saturnclient/ui/components/Sidebar.java index 636ed9c..1aabfae 100644 --- a/src/main/java/org/saturnclient/ui/components/Sidebar.java +++ b/src/main/java/org/saturnclient/ui/components/Sidebar.java @@ -11,7 +11,7 @@ import org.saturnclient.ui.resources.Textures; import org.saturnclient.ui.screens.ClientSettings; import org.saturnclient.ui.screens.HudEditor; import org.saturnclient.ui.screens.ModMenu; -import org.saturnclient.ui.screens.cosmetics.CloakMenu; +import org.saturnclient.ui.screens.CosmeticsMenu; /** * The navigation rail beside Saturn's screens: the logo, one button per screen, @@ -37,7 +37,7 @@ public class Sidebar extends Element { private final Item[] items = { new Item(Textures.ICON_MODS, () -> Providers.saturn.getClient().setScreen(new ModMenu())), - new Item(Textures.ICON_COSMETICS, () -> Providers.saturn.getClient().setScreen(new CloakMenu())), + new Item(Textures.ICON_COSMETICS, () -> Providers.saturn.getClient().setScreen(new CosmeticsMenu())), new Item(Textures.ICON_HUD_EDITOR, () -> Providers.saturn.getClient().setScreen(new HudEditor())), new Item(Textures.ICON_SETTINGS, () -> Providers.saturn.getClient().setScreen(new ClientSettings())), }; diff --git a/src/main/java/org/saturnclient/ui/components/SkinPreview.java b/src/main/java/org/saturnclient/ui/components/SkinPreview.java index 1686c75..48a2b06 100644 --- a/src/main/java/org/saturnclient/ui/components/SkinPreview.java +++ b/src/main/java/org/saturnclient/ui/components/SkinPreview.java @@ -1,26 +1,68 @@ package org.saturnclient.ui.components; +import org.saturnclient.config.Theme; import org.saturnclient.ui.Element; import org.saturnclient.ui.ElementContext; import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.resources.Fonts; +/** + * The player, wearing their cosmetics, filling the element. Dragging it turns + * the player around. + */ public class SkinPreview extends Element { public static EntityDrawer DRAWER; - private boolean negativeAngle = false; - private float angle = 0.0f; - public SkinPreview(float angle, boolean negativeAngle) { - this.angle = angle; - this.negativeAngle = negativeAngle; + /** Degrees turned per GUI pixel dragged */ + private static final float DRAG_SPEED = 1.2f; + private static final float HINT_SCALE = 0.66f; + + private float yaw; + private boolean dragging = false; + + /** + * @param yaw degrees the player starts turned by: 0 faces the viewer, 180 + * shows their back + */ + public SkinPreview(float yaw) { + this.yaw = yaw; } @Override public void render(RenderScope renderScope, ElementContext ctx) { - DRAWER.render(renderScope, negativeAngle, angle, scale, ctx); + if (DRAWER != null && DRAWER.render(renderScope, width, height, yaw)) { + return; + } + + String hint = "Join a world to preview"; + int x = (width - (int) (Fonts.getWidth(hint, Theme.getBodyFont()) * HINT_SCALE)) / 2; + renderScope.drawText(HINT_SCALE, hint, x, height / 2, Theme.getBodyFont(), Theme.getMutedFg()); + } + + @Override + public void mouseClicked(double mouseX, double mouseY, int button) { + dragging = mouseX >= 0 && mouseX <= width && mouseY >= 0 && mouseY <= height; + } + + @Override + public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + if (dragging) { + yaw = (yaw - (float) deltaX * DRAG_SPEED) % 360; + } + } + + @Override + public void mouseReleased(double mouseX, double mouseY, int button) { + dragging = false; } public static interface EntityDrawer { - public void render(RenderScope renderScope, boolean negativeAngle, float angle, float scale, - ElementContext ctx); + /** + * Draws the client's player filling a {@code width} x {@code height} box, + * turned by {@code yaw} degrees (0 faces the viewer). + * + * @return false if there's no player to draw, such as on the title screen + */ + public boolean render(RenderScope renderScope, int width, int height, float yaw); } } diff --git a/src/main/java/org/saturnclient/ui/elements/TabMenu.java b/src/main/java/org/saturnclient/ui/elements/TabMenu.java deleted file mode 100644 index 6998d11..0000000 --- a/src/main/java/org/saturnclient/ui/elements/TabMenu.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.saturnclient.ui.elements; - -import org.saturnclient.common.ref.asset.IdentifierRef; -import org.saturnclient.config.Theme; -import org.saturnclient.ui.Element; -import org.saturnclient.ui.ElementContext; -import org.saturnclient.ui.RenderScope; -import org.saturnclient.ui.Utils; - -public class TabMenu extends Element { - public static class TabMenuComponent { - Runnable onClick; - IdentifierRef sprite; - - public TabMenuComponent(IdentifierRef sprite, Runnable onClick) { - this.onClick = onClick; - this.sprite = sprite; - } - } - - TabMenuComponent[] components; - public int selectedScreen = 0; - - public TabMenu(int active, TabMenuComponent... components) { - this.components = components; - width = 25 * components.length + 6; - height = 30; - this.selectedScreen = active; - } - - @Override - public void render(RenderScope renderScope, ElementContext ctx) { - renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, Theme.PRIMARY.value); - - int col = 0; - for (TabMenuComponent component : components) { - int x = 25 * col + 5; - - boolean hovering = ctx.isHovering(x, 5, 20, 20) || selectedScreen == col; - - if (hovering) { - renderScope.drawRoundedRectangle(x, 5, 20, 20, Theme.WIDGET_RADIUS.value, Theme.ACCENT.value); - } - - renderScope.drawTexture(component.sprite, x + 5, 10, 0, 0, 10, 10, - hovering ? Theme.ACCENT_FG.value : Theme.PRIMARY_FG.value); - - col++; - } - } - - @Override - public void click(int mouseX, int mouseY) { - int col = 0; - for (TabMenuComponent component : components) { - int x = 25 * col + 5; - if (Utils.isHovering(mouseX - x, mouseY - 5, 20, 20, 1.0f)) { - component.onClick.run(); - } - col++; - } - } -} diff --git a/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java b/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java new file mode 100644 index 0000000..738a3d3 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java @@ -0,0 +1,233 @@ +package org.saturnclient.ui.screens; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.saturnclient.client.ServiceClient; +import org.saturnclient.client.player.SaturnPlayer; +import org.saturnclient.common.ref.asset.IdentifierRef; +import org.saturnclient.config.AnimationConfig; +import org.saturnclient.config.Theme; +import org.saturnclient.cosmetics.Cloaks; +import org.saturnclient.cosmetics.Hats; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.PanelScreen; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.anim.SlideFade; +import org.saturnclient.ui.components.CosmeticTile; +import org.saturnclient.ui.components.Sidebar; +import org.saturnclient.ui.components.SkinPreview; +import org.saturnclient.ui.elements.AnimationStagger; +import org.saturnclient.ui.elements.ChipGroup; +import org.saturnclient.ui.elements.Label; +import org.saturnclient.ui.elements.Scroll; +import org.saturnclient.ui.resources.Fonts; +import org.saturnclient.ui.resources.Textures; + +/** + * Cloaks and hats: the player on the left, turned by dragging, and a grid of + * every cosmetic on the right. Clicking an owned cosmetic equips it; clicking + * one not owned yet claims it, which is free. + */ +public class CosmeticsMenu extends PanelScreen { + private enum Kind { + CLOAKS("Cloaks", "Cloak", Cloaks.ALL_CLOAKS, () -> Cloaks.availableCloaks, player -> player.cloak, + Cloaks::setCloak, ServiceClient::buyCloak, Textures::getCloakPreview, 0.5f, 150f), + HATS("Hats", "Hat", Hats.ALL_HATS, () -> Hats.availableHats, player -> player.hat, + Hats::setHat, ServiceClient::buyHat, Textures::getHatPreview, 1f, -25f); + + final String title; + final String singular; + final String[] all; + final Supplier> owned; + final Function equipped; + final Consumer equip; + final Consumer claim; + final Function preview; + /** Width divided by height of the preview textures */ + final float previewAspect; + /** The angle the player starts at, showing off this kind of cosmetic */ + final float yaw; + + Kind(String title, String singular, String[] all, Supplier> owned, + Function equipped, Consumer equip, Consumer claim, + Function preview, float previewAspect, float yaw) { + this.title = title; + this.singular = singular; + this.all = all; + this.owned = owned; + this.equipped = equipped; + this.equip = equip; + this.claim = claim; + this.preview = preview; + this.previewAspect = previewAspect; + this.yaw = yaw; + } + + /** + * "" (nothing), then every cosmetic of this kind. + */ + List ids() { + List ids = new ArrayList<>(); + ids.add(""); + ids.addAll(List.of(all)); + return ids; + } + + String getEquipped() { + SaturnPlayer player = SaturnPlayer.get(); + return player == null ? "" : equipped.apply(player); + } + } + + /** Names that aren't just the id with each word capitalized */ + private static final Map NAMES = Map.of( + "", "None", + "amg", "AMG", + "amg_petronas", "AMG Petronas", + "bmw", "BMW", + "redbull", "Red Bull"); + + private static final int STAGE_WIDTH = 300; + private static final int STAGE_PADDING = 16; + private static final int STAGE_FOOTER = 70; + private static final int COLUMN_GAP = 20; + private static final int COLUMNS = 4; + private static final int TILE_GAP = 10; + private static final int GRID_Y = 58; + private static final int SCROLLBAR_SPACE = 14; + private static final float SMALL_SCALE = 0.6f; + + private Kind kind = Kind.CLOAKS; + + public CosmeticsMenu() { + super("Cosmetics", Sidebar.COSMETICS); + } + + @Override + protected void panel() { + drawStage(); + + int x = contentX + STAGE_WIDTH + COLUMN_GAP; + int width = contentX + contentWidth - x; + + // Header + draw(new Label(() -> "Cosmetics", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value) + .position(x, contentY + 2)); + draw(new Label(this::getOwnedSummary, 0.66f, Theme::getBodyFont, Theme::getMutedFg) + .position(x, contentY + 28)); + + ChipGroup tabs = new ChipGroup(List.of(Kind.CLOAKS.title, Kind.HATS.title), kind.title, selected -> { + kind = selected.equals(Kind.HATS.title) ? Kind.HATS : Kind.CLOAKS; + init(); + }); + draw(tabs.position(x + width - tabs.width, contentY + 4)); + + // Grid + Scroll scroll = new Scroll(0).noBackground(); + int gridHeight = contentHeight - GRID_Y; + + if (SaturnPlayer.get() == null) { + String message = "Connect to Saturn to change your cosmetics"; + int messageX = (width - (int) (Fonts.getWidth(message, Theme.getBodyFont()) * 0.7f)) / 2; + scroll.draw(new Label(() -> message, 0.7f, Theme::getBodyFont, Theme::getMutedFg) + .position(messageX, 60)); + } else { + scroll.draw(createGrid(width)); + } + + draw(scroll.dimensions(width + SCROLLBAR_SPACE, gridHeight).position(x, contentY + GRID_Y)); + } + + /** + * The left column: the player, and what they have equipped. + */ + private void drawStage() { + draw(new Stage().dimensions(STAGE_WIDTH, contentHeight).position(contentX, contentY)); + + int previewY = contentY + STAGE_PADDING + 24; + int previewHeight = contentHeight - STAGE_PADDING - 24 - STAGE_FOOTER; + draw(new SkinPreview(kind.yaw).dimensions(STAGE_WIDTH, previewHeight).position(contentX, previewY)); + + int footerY = contentY + contentHeight - STAGE_FOOTER + 8; + draw(new Label(() -> getName(kind.getEquipped()), 1f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value) + .position(contentX + STAGE_PADDING + 4, footerY)); + draw(new Label(() -> kind.singular + " · Equipped", 0.64f, Theme::getBodyFont, Theme::getMutedFg) + .position(contentX + STAGE_PADDING + 4, footerY + 26)); + } + + private Element createGrid(int width) { + int tileWidth = (width - TILE_GAP * (COLUMNS - 1)) / COLUMNS; + AnimationStagger grid = new AnimationStagger(AnimationConfig.cosmeticsMenu); + List ids = kind.ids(); + + for (int i = 0; i < ids.size(); i++) { + String id = ids.get(i); + Kind k = kind; + + CosmeticTile tile = new CosmeticTile(getName(id), k.preview.apply(id), k.previewAspect, tileWidth, + () -> k.owned.get().contains(id), + () -> k.getEquipped().equals(id), + () -> k.equip.accept(id), + () -> k.claim.accept(id)); + + tile.position((i % COLUMNS) * (tileWidth + TILE_GAP), (i / COLUMNS) * (CosmeticTile.HEIGHT + TILE_GAP)); + tile.animation(new SlideFade(AnimationConfig.cosmeticsMenu, -12)); + grid.draw(tile); + } + + return grid; + } + + private String getOwnedSummary() { + // The owned list also holds "", for nothing equipped. + long owned = List.of(kind.all).stream().filter(kind.owned.get()::contains).count(); + return owned + " of " + kind.all.length + " " + kind.title.toLowerCase(Locale.ROOT) + + " owned · every cosmetic is free to claim"; + } + + private static String getName(String id) { + String name = NAMES.get(id); + if (name != null) { + return name; + } + + StringBuilder builder = new StringBuilder(); + for (String word : id.split("_")) { + if (!builder.isEmpty()) { + builder.append(' '); + } + builder.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + return builder.toString(); + } + + /** + * The darker card behind the player, with its heading and a hint that it + * can be turned. + */ + private static class Stage extends Element { + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value + 4, 1, + Theme.withAlpha(0.45f, 0xFF000000), Theme.withAlpha(0.05f, Theme.FOREGROUND.value)); + + renderScope.drawText(SMALL_SCALE, "PREVIEW", STAGE_PADDING, STAGE_PADDING, Theme.FONT.value, + Theme.getMutedFg()); + + String hint = "Drag to rotate"; + int hintWidth = (int) (Fonts.getWidth(hint, Theme.getBodyFont()) * SMALL_SCALE); + renderScope.drawText(SMALL_SCALE, hint, width - STAGE_PADDING - hintWidth, STAGE_PADDING, + Theme.getBodyFont(), Theme.getMutedFg()); + + renderScope.drawRectangle(STAGE_PADDING, height - STAGE_FOOTER, width - STAGE_PADDING * 2, 1, + Theme.getBorder()); + } + } +} diff --git a/src/main/java/org/saturnclient/ui/screens/ShiftMenu.java b/src/main/java/org/saturnclient/ui/screens/ShiftMenu.java index c727068..2915e68 100644 --- a/src/main/java/org/saturnclient/ui/screens/ShiftMenu.java +++ b/src/main/java/org/saturnclient/ui/screens/ShiftMenu.java @@ -11,7 +11,6 @@ import org.saturnclient.ui.elements.Button; import org.saturnclient.ui.elements.ImageTexture; import org.saturnclient.ui.elements.TextureButton; import org.saturnclient.ui.resources.Textures; -import org.saturnclient.ui.screens.cosmetics.CloakMenu; public class ShiftMenu extends SaturnScreen { public ShiftMenu() { @@ -51,7 +50,7 @@ public class ShiftMenu extends SaturnScreen { .animation(new Fade(AnimationConfig.shiftMenu))); stagger.draw(new TextureButton(Textures.COSMETICS, () -> { - Providers.saturn.getClient().setScreen(new CloakMenu()); + Providers.saturn.getClient().setScreen(new CosmeticsMenu()); }).dimensions(btnHeight, btnHeight).position(btnWidth + btnHeight + (spacing * 2), 0) .animation(new Fade(AnimationConfig.shiftMenu))); diff --git a/src/main/java/org/saturnclient/ui/screens/TitleMenu.java b/src/main/java/org/saturnclient/ui/screens/TitleMenu.java index a2bbace..bb61896 100644 --- a/src/main/java/org/saturnclient/ui/screens/TitleMenu.java +++ b/src/main/java/org/saturnclient/ui/screens/TitleMenu.java @@ -14,7 +14,6 @@ import org.saturnclient.ui.elements.ImageTexture; import org.saturnclient.ui.elements.TextureButton; import org.saturnclient.ui.resources.Fonts; import org.saturnclient.ui.resources.Textures; -import org.saturnclient.ui.screens.cosmetics.CloakMenu; public class TitleMenu extends SaturnScreen { public TitleMenu() { @@ -68,7 +67,7 @@ public class TitleMenu extends SaturnScreen { .animation(new SlideFade(AnimationConfig.mainMenu, s + 8))); saturnStagger.draw(new TextureButton(Textures.CLOAK, () -> { - Providers.saturn.getClient().setScreen(new CloakMenu()); + Providers.saturn.getClient().setScreen(new CosmeticsMenu()); }).dimensions(s, s).position((s + 2) * 2, 0) .animation(new SlideFade(AnimationConfig.mainMenu, s + 8))); diff --git a/src/main/java/org/saturnclient/ui/screens/cosmetics/CloakMenu.java b/src/main/java/org/saturnclient/ui/screens/cosmetics/CloakMenu.java deleted file mode 100644 index 79699d5..0000000 --- a/src/main/java/org/saturnclient/ui/screens/cosmetics/CloakMenu.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.saturnclient.ui.screens.cosmetics; - -import org.saturnclient.client.player.SaturnPlayer; -import org.saturnclient.common.provider.Providers; -import org.saturnclient.config.AnimationConfig; -import org.saturnclient.cosmetics.Cloaks; -import org.saturnclient.ui.SaturnScreen; -import org.saturnclient.ui.anim.Fade; -import org.saturnclient.ui.anim.SlideY; -import org.saturnclient.ui.components.CosmeticPreview; -import org.saturnclient.ui.components.Sidebar; -import org.saturnclient.ui.components.SkinPreview; -import org.saturnclient.ui.elements.AnimationStagger; -import org.saturnclient.ui.elements.Scroll; -import org.saturnclient.ui.elements.TabMenu; -import org.saturnclient.ui.elements.TabMenu.TabMenuComponent; -import org.saturnclient.ui.resources.Textures; - -public class CloakMenu extends SaturnScreen { - public CloakMenu() { - super("Cloaks Menu"); - } - - @Override - public void ui() { - int p = 10; - int g = 10; - - Scroll scroll = new Scroll(p); - - int row = 0; - int col = 0; - - SaturnPlayer player = SaturnPlayer.get(); - - AnimationStagger stagger = new AnimationStagger(AnimationConfig.cosmeticsMenu); - - if (player != null) { - for (String cloak : Cloaks.availableCloaks) { - - stagger.draw( - new CosmeticPreview( - cloak == player.cloak, - Textures.getCloakPreview(cloak), - () -> Cloaks.setCloak(cloak)) - .position((50 + g) * col, (111 + g) * row) - .animation(new SlideY(AnimationConfig.cosmeticsMenu, 14))); - - if (col == 5) { - col = 0; - row++; - } else { - col++; - } - } - } - - scroll.draw(stagger); - - int scrollWidth = 480 + 10 + (g * 2) + (p * 2); - - draw(scroll - .dimensions(scrollWidth, 350) - .center(width, height)); - - draw(new SkinPreview(-30.0f, false) - .scale(3.5f) - .position(scroll.x + (scrollWidth - 220), scroll.y + 40) - .animation(new Fade(500))); - - draw(new Sidebar(Sidebar.COSMETICS, this.provider::close) - .center(width, height, -(scrollWidth / 2 + 35), 0)); - - draw(new TabMenu(0, - new TabMenuComponent(Textures.CLOAK, () -> { - }), - new TabMenuComponent(Textures.HAT, () -> { - Providers.saturn.getClient().setScreen(new HatMenu()); - })) - .center(width, height, 0, -195)); - } -} diff --git a/src/main/java/org/saturnclient/ui/screens/cosmetics/HatMenu.java b/src/main/java/org/saturnclient/ui/screens/cosmetics/HatMenu.java deleted file mode 100644 index 1d2244d..0000000 --- a/src/main/java/org/saturnclient/ui/screens/cosmetics/HatMenu.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.saturnclient.ui.screens.cosmetics; - -import org.saturnclient.client.player.SaturnPlayer; -import org.saturnclient.common.provider.Providers; -import org.saturnclient.config.AnimationConfig; -import org.saturnclient.cosmetics.Hats; -import org.saturnclient.ui.SaturnScreen; -import org.saturnclient.ui.anim.Fade; -import org.saturnclient.ui.anim.SlideY; -import org.saturnclient.ui.components.CosmeticPreview; -import org.saturnclient.ui.components.Sidebar; -import org.saturnclient.ui.components.SkinPreview; -import org.saturnclient.ui.elements.AnimationStagger; -import org.saturnclient.ui.elements.Scroll; -import org.saturnclient.ui.elements.TabMenu; -import org.saturnclient.ui.elements.TabMenu.TabMenuComponent; -import org.saturnclient.ui.resources.Textures; - -public class HatMenu extends SaturnScreen { - public HatMenu() { - super("Hats Menu"); - } - - @Override - public void ui() { - int p = 10; - int g = 10; - - Scroll scroll = new Scroll(p); - - int row = 0; - int col = 0; - - SaturnPlayer player = SaturnPlayer.get(); - - AnimationStagger stagger = new AnimationStagger(AnimationConfig.cosmeticsMenu); - - if (player != null) { - for (String hat : Hats.availableHats) { - stagger.draw( - new CosmeticPreview( - hat == player.hat, - Textures.getHatPreview(hat), - () -> Hats.setHat(hat)) - .dimensions(50, 50) - .position((50 + g) * col, (50 + g) * row) - .animation(new SlideY(AnimationConfig.cosmeticsMenu, 14))); - - if (col == 5) { - col = 0; - row++; - } else { - col++; - } - } - } - - scroll.draw(stagger); - - int scrollWidth = 480 + 10 + (g * 2) + (p * 2); - - draw(scroll - .dimensions(scrollWidth, 350) - .center(width, height)); - - draw(new SkinPreview(170f, true) - .scale(3.5f) - .position(scroll.x + (scrollWidth - 220), scroll.y + 40) - .animation(new Fade(500))); - - draw(new Sidebar(Sidebar.COSMETICS, this.provider::close) - .center(width, height, -(scrollWidth / 2 + 35), 0)); - - draw(new TabMenu(1, - new TabMenuComponent(Textures.CLOAK, () -> { - Providers.saturn.getClient().setScreen(new CloakMenu()); - }), - new TabMenuComponent(Textures.HAT, () -> { - })) - .center(width, height, 0, -195)); - } -} diff --git a/src/main/java/org/saturnclient/ui/screens/store/CloakStore.java b/src/main/java/org/saturnclient/ui/screens/store/CloakStore.java deleted file mode 100644 index c6c2e7d..0000000 --- a/src/main/java/org/saturnclient/ui/screens/store/CloakStore.java +++ /dev/null @@ -1,142 +0,0 @@ -package org.saturnclient.ui.screens.store; - -// import org.saturnclient.cosmetics.cloak.Cloaks; -import org.saturnclient.client.ServiceClient; -import org.saturnclient.client.player.SaturnPlayer; -import org.saturnclient.common.provider.Providers; -import org.saturnclient.config.AnimationConfig; -import org.saturnclient.config.Theme; -import org.saturnclient.cosmetics.Cloaks; -import org.saturnclient.ui.SaturnScreen; -import org.saturnclient.ui.anim.Fade; -import org.saturnclient.ui.anim.SlideY; -import org.saturnclient.ui.components.CosmeticPreview; -import org.saturnclient.ui.components.Sidebar; -import org.saturnclient.ui.elements.AnimationStagger; -import org.saturnclient.ui.elements.ImageTexture; -import org.saturnclient.ui.elements.Scroll; -import org.saturnclient.ui.elements.TabMenu; -import org.saturnclient.ui.elements.Text; -import org.saturnclient.ui.elements.TabMenu.TabMenuComponent; -import org.saturnclient.ui.resources.Fonts; -import org.saturnclient.ui.resources.Textures; - -// import com.mojang.blaze3d.systems.RenderSystem; - -public class CloakStore extends SaturnScreen { - private long lastPurchaseTime = 0; - - public CloakStore() { - super("Cloak Store"); - } - - public CloakStore(long lastPurchaseTime) { - super("Cloak Store"); - this.lastPurchaseTime = lastPurchaseTime; - } - - @Override - public void ui() { - int p = 10; - int gx = 10; - int gy = 20; - - Scroll scroll = new Scroll(p); - - int row = 0; - int col = 0; - - SaturnPlayer player = SaturnPlayer.get(); - - AnimationStagger stagger = new AnimationStagger(AnimationConfig.cosmeticsMenu); - - if (player != null) { - for (String cloak : Cloaks.ALL_CLOAKS) { - if (!Cloaks.availableCloaks.contains(cloak)) { - - int x = (50 + gx) * col; - int y = (111 + gy) * row; - - stagger.draw( - new CosmeticPreview( - cloak == player.cloak, - Textures.getCloakPreview(cloak), - () -> handlePurchase(cloak)) - .position(x, y) - .animation(new SlideY(AnimationConfig.cosmeticsMenu, 16))); - - String t = "100"; - - stagger.draw( - new Text(t) - .position(x + Fonts.centerX(50, t, Theme.FONT.value), y + 113) - .scale(0.5f) - .animation(new Fade(400))); - - stagger.draw( - new ImageTexture(Textures.COINS) - .dimensions(16, 16) - .position(x + Fonts.getWidth(t, Theme.FONT.value) - 1, y + 112) - .scale(0.5f) - .animation(new Fade(400))); - - if (col == 8) { - col = 0; - row++; - } else { - col++; - } - } - } - } - - scroll.draw(stagger); - - int scrollWidth = 480 + 40 + (gx * 2) + (p * 2); - - draw(scroll - .dimensions(scrollWidth, 350) - .center(width, height, 15, 0)); - - draw(new Sidebar(Sidebar.COSMETICS, this.provider::close) - .center(width, height, -((scrollWidth - 30) / 2 + 35), 0)); - - draw(new TabMenu(0, - new TabMenuComponent(Textures.CLOAK, () -> { - }), - new TabMenuComponent(Textures.HAT, () -> { - Providers.saturn.getClient().setScreen(new HatStore()); - })) - .center(width, height, 0, -195)); - } - - private void handlePurchase(String cloak) { - long now = System.currentTimeMillis(); - - if (now - lastPurchaseTime >= 3000) { - // Utils.notify(NotificationKind.Info, "Purchase processing", "Please wait"); - - ServiceClient.buyCloak(cloak); - lastPurchaseTime = now; - - new Thread(() -> { - try { - Thread.sleep(5000); - } catch (InterruptedException ignored) { - } - - // RenderSystem.recordRenderCall(() -> { - // MinecraftProvider.PROVIDER.setScreen(new CloakStore(now)); - // // Utils.notify(NotificationKind.Success, - // // "Purchase complete", - // // "Congrats, enjoy your new cloak!"); - // }); - }).start(); - - } else { - // Utils.notify(NotificationKind.Error, - // "Timeout error", - // "Please wait 3 seconds"); - } - } -} diff --git a/src/main/java/org/saturnclient/ui/screens/store/HatStore.java b/src/main/java/org/saturnclient/ui/screens/store/HatStore.java deleted file mode 100644 index aec83e8..0000000 --- a/src/main/java/org/saturnclient/ui/screens/store/HatStore.java +++ /dev/null @@ -1,142 +0,0 @@ -package org.saturnclient.ui.screens.store; - -import org.saturnclient.client.ServiceClient; -import org.saturnclient.client.player.SaturnPlayer; -import org.saturnclient.common.provider.Providers; -import org.saturnclient.config.AnimationConfig; -import org.saturnclient.config.Theme; -import org.saturnclient.cosmetics.Hats; -import org.saturnclient.ui.SaturnScreen; -import org.saturnclient.ui.anim.Fade; -import org.saturnclient.ui.anim.SlideY; -import org.saturnclient.ui.components.CosmeticPreview; -import org.saturnclient.ui.components.Sidebar; -import org.saturnclient.ui.elements.AnimationStagger; -import org.saturnclient.ui.elements.ImageTexture; -import org.saturnclient.ui.elements.Scroll; -import org.saturnclient.ui.elements.TabMenu; -import org.saturnclient.ui.elements.Text; -import org.saturnclient.ui.elements.TabMenu.TabMenuComponent; -import org.saturnclient.ui.resources.Fonts; -import org.saturnclient.ui.resources.Textures; - -// import com.mojang.blaze3d.systems.RenderSystem; - -public class HatStore extends SaturnScreen { - private long lastPurchaseTime = 0; - - public HatStore() { - super("Hat Store"); - } - - public HatStore(long lastPurchaseTime) { - super("Hat Store"); - this.lastPurchaseTime = lastPurchaseTime; - } - - @Override - public void ui() { - int p = 10; - int gx = 10; - int gy = 20; - - Scroll scroll = new Scroll(p); - - int row = 0; - int col = 0; - - SaturnPlayer player = SaturnPlayer.get(); - - AnimationStagger stagger = new AnimationStagger(AnimationConfig.cosmeticsMenu); - - if (player != null) { - for (String hat : Hats.ALL_HATS) { - if (!Hats.availableHats.contains(hat)) { - - int x = (50 + gx) * col; - int y = (50 + gy) * row; - - stagger.draw( - new CosmeticPreview( - hat == player.hat, - Textures.getHatPreview(hat), - () -> handlePurchase(hat)) - .dimensions(50, 50) - .position(x, y) - .animation(new SlideY(AnimationConfig.cosmeticsMenu, 16))); - - String t = "50"; - - stagger.draw( - new Text(t) - .position(x + Fonts.centerX(50, t, Theme.FONT.value), y + 53) - .scale(0.5f) - .animation(new Fade(400))); - - stagger.draw( - new ImageTexture(Textures.COINS) - .dimensions(16, 16) - .position(x + Fonts.getWidth(t, Theme.FONT.value) + 4, y + 52) - .scale(0.5f) - .animation(new Fade(400))); - - if (col == 8) { - col = 0; - row++; - } else { - col++; - } - } - } - } - - scroll.draw(stagger); - - int scrollWidth = 480 + 40 + (gx * 2) + (p * 2); - - draw(scroll - .dimensions(scrollWidth, 350) - .center(width, height, 15, 0)); - - draw(new Sidebar(Sidebar.COSMETICS, this.provider::close) - .center(width, height, -((scrollWidth - 30) / 2 + 35), 0)); - - draw(new TabMenu(1, - new TabMenuComponent(Textures.CLOAK, () -> { - Providers.saturn.getClient().setScreen(new CloakStore()); - }), - new TabMenuComponent(Textures.HAT, () -> { - })) - .center(width, height, 0, -195)); - } - - private void handlePurchase(String hat) { - long now = System.currentTimeMillis(); - - if (now - lastPurchaseTime >= 3000) { - // Utils.notify(NotificationKind.Info, "Purchase processing", "Please wait"); - - ServiceClient.buyHat(hat); - lastPurchaseTime = now; - - new Thread(() -> { - try { - Thread.sleep(5000); - } catch (InterruptedException ignored) { - } - - // RenderSystem.recordRenderCall(() -> { - // MinecraftProvider.PROVIDER.setScreen(new HatStore(now)); - // // Utils.notify(NotificationKind.Success, - // // "Purchase complete", - // // "Congrats, enjoy your new hat!"); - // }); - }).start(); - - } else { - // Utils.notify(NotificationKind.Error, - // "Timeout error", - // "Please wait 3 seconds"); - } - } -} -- 2.54.0 From 06a90d8e518cd37f8308d2fd937e135bbfc62ace Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 17:39:27 +0200 Subject: [PATCH 06/13] Edit ranged settings with sliders IntProperty and FloatProperty can take an optional range, step and unit. PropertyList shows those settings as a Slider: a track to click or drag, snapping to the step, with the value and unit beside it. Zoom and freelook levels and durations, fullbright brightness, the theme radii, animation timings and HUD scale and radius get ranges; the rest stay number boxes. Co-Authored-By: Claude Opus 5.5 --- .../saturnclient/config/AnimationConfig.java | 4 +- .../java/org/saturnclient/config/Theme.java | 4 +- .../config/property/FloatProperty.java | 25 ++++ .../config/property/IntProperty.java | 29 ++++ .../java/org/saturnclient/mod/ModLayout.java | 4 +- .../saturnclient/mod/mods/FreelookMod.java | 6 +- .../saturnclient/mod/mods/FullbrightMod.java | 2 +- .../org/saturnclient/mod/mods/ZoomMod.java | 6 +- .../ui/components/PropertyList.java | 5 +- .../ui/components/inputs/Slider.java | 134 ++++++++++++++++++ 10 files changed, 204 insertions(+), 15 deletions(-) create mode 100644 src/main/java/org/saturnclient/ui/components/inputs/Slider.java diff --git a/src/main/java/org/saturnclient/config/AnimationConfig.java b/src/main/java/org/saturnclient/config/AnimationConfig.java index acaadda..5451773 100644 --- a/src/main/java/org/saturnclient/config/AnimationConfig.java +++ b/src/main/java/org/saturnclient/config/AnimationConfig.java @@ -42,8 +42,8 @@ public class AnimationConfig { public final SelectProperty curve; public AnimationConfig(boolean enabled, int delay, int stagger) { - this.duration = Property.integer(delay); - this.stagger = Property.integer(stagger); + this.duration = Property.integer(delay).range(0, 2000, 50).unit("ms"); + this.stagger = Property.integer(stagger).range(0, 300, 10).unit("ms"); this.curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back"); } diff --git a/src/main/java/org/saturnclient/config/Theme.java b/src/main/java/org/saturnclient/config/Theme.java index dbfca5f..67f79e6 100644 --- a/src/main/java/org/saturnclient/config/Theme.java +++ b/src/main/java/org/saturnclient/config/Theme.java @@ -32,8 +32,8 @@ public class Theme { public static final ColorProperty SCROLL = Property.color(0xFFE2BF7D); // UI metrics - public static final IntProperty BG_RADIUS = Property.integer(12); - public static final IntProperty WIDGET_RADIUS = Property.integer(10); + public static final IntProperty BG_RADIUS = Property.integer(12).range(0, 24); + public static final IntProperty WIDGET_RADIUS = Property.integer(10).range(0, 20); // Font public static final SelectProperty FONT = Property.font(2); diff --git a/src/main/java/org/saturnclient/config/property/FloatProperty.java b/src/main/java/org/saturnclient/config/property/FloatProperty.java index 316e90c..413999d 100644 --- a/src/main/java/org/saturnclient/config/property/FloatProperty.java +++ b/src/main/java/org/saturnclient/config/property/FloatProperty.java @@ -7,11 +7,36 @@ public class FloatProperty extends Property { public float value; public final float defaultValue; + /** Bounds for a slider, when the setting has a sensible range */ + public Float min, max; + public float step = 0.1f; + /** Shown after the value, like "×" */ + public String unit = ""; + public FloatProperty(float defaultValue) { this.value = defaultValue; this.defaultValue = defaultValue; } + /** + * Gives the setting a range, so it's edited with a slider. + */ + public FloatProperty range(float min, float max, float step) { + this.min = min; + this.max = max; + this.step = step; + return this; + } + + public FloatProperty unit(String unit) { + this.unit = unit; + return this; + } + + public boolean hasRange() { + return min != null && max != null; + } + @Override public void reset() { this.value = defaultValue; diff --git a/src/main/java/org/saturnclient/config/property/IntProperty.java b/src/main/java/org/saturnclient/config/property/IntProperty.java index 66d1a84..3a07ed8 100644 --- a/src/main/java/org/saturnclient/config/property/IntProperty.java +++ b/src/main/java/org/saturnclient/config/property/IntProperty.java @@ -7,11 +7,40 @@ public class IntProperty extends Property { public int value; public final int defaultValue; + /** Bounds for a slider, when the setting has a sensible range */ + public Integer min, max; + public int step = 1; + /** Shown after the value, like "ms" */ + public String unit = ""; + public IntProperty(int defaultValue) { this.value = defaultValue; this.defaultValue = defaultValue; } + /** + * Gives the setting a range, so it's edited with a slider. + */ + public IntProperty range(int min, int max) { + return range(min, max, 1); + } + + public IntProperty range(int min, int max, int step) { + this.min = min; + this.max = max; + this.step = step; + return this; + } + + public IntProperty unit(String unit) { + this.unit = unit; + return this; + } + + public boolean hasRange() { + return min != null && max != null; + } + @Override public void reset() { this.value = defaultValue; diff --git a/src/main/java/org/saturnclient/mod/ModLayout.java b/src/main/java/org/saturnclient/mod/ModLayout.java index e7df485..2f79c30 100644 --- a/src/main/java/org/saturnclient/mod/ModLayout.java +++ b/src/main/java/org/saturnclient/mod/ModLayout.java @@ -13,10 +13,10 @@ import org.saturnclient.config.property.NamedProperty; public class ModLayout { public IntProperty x = Property.integer(0); public IntProperty y = Property.integer(0); - public FloatProperty scale = Property.floatProp(1.0f); + public FloatProperty scale = Property.floatProp(1.0f).range(0.5f, 4f, 0.1f).unit("×"); public ColorProperty bgColor = Property.color(0x00000000); public ColorProperty fgColor = Property.color(0xFFffffff); - public IntProperty radius = Property.integer(0); + public IntProperty radius = Property.integer(0).range(0, 20); public SelectProperty font = Property.font(0); public boolean renderBackground = true; diff --git a/src/main/java/org/saturnclient/mod/mods/FreelookMod.java b/src/main/java/org/saturnclient/mod/mods/FreelookMod.java index 53b6067..46f75d1 100644 --- a/src/main/java/org/saturnclient/mod/mods/FreelookMod.java +++ b/src/main/java/org/saturnclient/mod/mods/FreelookMod.java @@ -22,9 +22,9 @@ public class FreelookMod extends Mod { private static final BoolProperty toggle = Property.bool(true); private static final KeybindingProperty freelookKey = Property.keybinding(GLFWProvider.GLFW_KEY_H); - public static final FloatProperty zoomLevel = Property.floatProp(2.0f); - public static final IntProperty zoomInDuration = Property.integer(500); - public static final IntProperty zoomOutDuration = Property.integer(300); + public static final FloatProperty zoomLevel = Property.floatProp(2.0f).range(1f, 5f, 0.1f).unit("×"); + public static final IntProperty zoomInDuration = Property.integer(500).range(0, 2000, 50).unit("ms"); + public static final IntProperty zoomOutDuration = Property.integer(300).range(0, 2000, 50).unit("ms"); public static final SelectProperty curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back"); // --- State Variables --- diff --git a/src/main/java/org/saturnclient/mod/mods/FullbrightMod.java b/src/main/java/org/saturnclient/mod/mods/FullbrightMod.java index 9b34c5b..1ca00d9 100644 --- a/src/main/java/org/saturnclient/mod/mods/FullbrightMod.java +++ b/src/main/java/org/saturnclient/mod/mods/FullbrightMod.java @@ -19,7 +19,7 @@ import org.saturnclient.mod.ModSpec; public class FullbrightMod extends Mod { private static final BoolProperty enabled = Property.bool(false); - private static final IntProperty brightness = Property.integer(100); + private static final IntProperty brightness = Property.integer(100).range(0, 1000, 10); public FullbrightMod() { super( diff --git a/src/main/java/org/saturnclient/mod/mods/ZoomMod.java b/src/main/java/org/saturnclient/mod/mods/ZoomMod.java index 92a66c8..f2f899f 100644 --- a/src/main/java/org/saturnclient/mod/mods/ZoomMod.java +++ b/src/main/java/org/saturnclient/mod/mods/ZoomMod.java @@ -18,9 +18,9 @@ public class ZoomMod extends Mod { private static final BoolProperty enabled = Property.bool(false); private static final BoolProperty toggle = Property.bool(false); public static final KeybindingProperty zoomKey = Property.keybinding(GLFWProvider.GLFW_KEY_C); - public static final FloatProperty zoomLevel = Property.floatProp(3.0f); - public static final IntProperty zoomInDuration = Property.integer(700); - public static final IntProperty zoomOutDuration = Property.integer(300); + public static final FloatProperty zoomLevel = Property.floatProp(3.0f).range(1f, 10f, 0.5f).unit("×"); + public static final IntProperty zoomInDuration = Property.integer(700).range(0, 2000, 50).unit("ms"); + public static final IntProperty zoomOutDuration = Property.integer(300).range(0, 2000, 50).unit("ms"); public static final SelectProperty curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back"); diff --git a/src/main/java/org/saturnclient/ui/components/PropertyList.java b/src/main/java/org/saturnclient/ui/components/PropertyList.java index 91e1a39..96b49b9 100644 --- a/src/main/java/org/saturnclient/ui/components/PropertyList.java +++ b/src/main/java/org/saturnclient/ui/components/PropertyList.java @@ -23,6 +23,7 @@ import org.saturnclient.ui.components.inputs.FloatInput; import org.saturnclient.ui.components.inputs.IntInput; import org.saturnclient.ui.components.inputs.KeybindingSelector; import org.saturnclient.ui.components.inputs.Select; +import org.saturnclient.ui.components.inputs.Slider; import org.saturnclient.ui.components.inputs.Toggle; import org.saturnclient.ui.elements.Label; import org.saturnclient.ui.elements.Scroll; @@ -104,9 +105,9 @@ public final class PropertyList { } else if (prop instanceof ColorProperty p) { return new ColorInput(p); } else if (prop instanceof IntProperty p) { - return new IntInput(p); + return p.hasRange() ? Slider.of(p) : new IntInput(p); } else if (prop instanceof FloatProperty p) { - return new FloatInput(p); + return p.hasRange() ? Slider.of(p) : new FloatInput(p); } else if (prop instanceof SelectProperty p) { return new Select(p); } else if (prop instanceof KeybindingProperty p) { diff --git a/src/main/java/org/saturnclient/ui/components/inputs/Slider.java b/src/main/java/org/saturnclient/ui/components/inputs/Slider.java new file mode 100644 index 0000000..b9f24ab --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/inputs/Slider.java @@ -0,0 +1,134 @@ +package org.saturnclient.ui.components.inputs; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Locale; +import java.util.function.DoubleConsumer; +import java.util.function.DoubleSupplier; + +import org.saturnclient.config.Theme; +import org.saturnclient.config.property.FloatProperty; +import org.saturnclient.config.property.IntProperty; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.elements.Field; + +/** + * Edits a number within a range by clicking or dragging along a track, with + * the value shown beside it. + */ +public class Slider extends Element { + private static final int TRACK_HEIGHT = 6; + private static final int KNOB = 16; + private static final int VALUE_WIDTH = 72; + private static final int VALUE_GAP = 14; + + private final double min, max, step; + private final int decimals; + private final String unit; + private final DoubleSupplier value; + private final DoubleConsumer onChange; + + private boolean dragging = false; + + private Slider(double min, double max, double step, String unit, DoubleSupplier value, DoubleConsumer onChange) { + this.min = min; + this.max = max; + this.step = step; + this.unit = unit; + this.value = value; + this.onChange = onChange; + this.decimals = Math.max(0, BigDecimal.valueOf(step).stripTrailingZeros().scale()); + this.width = 280; + this.height = Field.HEIGHT; + } + + public static Slider of(IntProperty prop) { + return new Slider(prop.min, prop.max, prop.step, prop.unit, () -> prop.value, + v -> prop.value = (int) Math.round(v)); + } + + public static Slider of(FloatProperty prop) { + return new Slider(prop.min, prop.max, prop.step, prop.unit, () -> prop.value, + v -> prop.value = (float) v); + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + double current = value.getAsDouble(); + float progress = (float) Math.max(0, Math.min(1, (current - min) / (max - min))); + int trackWidth = getTrackWidth(); + int trackY = (height - TRACK_HEIGHT) / 2; + int fillWidth = (int) (trackWidth * progress); + + renderScope.drawRoundedRectangle(KNOB / 2, trackY, trackWidth, TRACK_HEIGHT, TRACK_HEIGHT / 2, + Theme.withAlpha(0.14f, Theme.FOREGROUND.value)); + + if (fillWidth > 0) { + renderScope.drawRoundedRectangle(KNOB / 2, trackY, fillWidth, TRACK_HEIGHT, TRACK_HEIGHT / 2, + Theme.ACCENT.value); + } + + boolean knobHovered = dragging || ctx.isHovering(fillWidth, 0, KNOB, height); + renderScope.drawRoundedRectangle(fillWidth, (height - KNOB) / 2, KNOB, KNOB, KNOB / 2, 3, + knobHovered ? Theme.ACCENT.value : Theme.FOREGROUND.value, Theme.BACKGROUND.value); + + // Value + int valueX = width - VALUE_WIDTH; + renderScope.drawRoundedRectangle(valueX, 0, VALUE_WIDTH, height, Theme.WIDGET_RADIUS.value, + Theme.withAlpha(0.07f, Theme.FOREGROUND.value)); + + renderScope.getMatrixStack().push(); + renderScope.getMatrixStack().translate(valueX, 0); + Field.drawCenteredText(renderScope, format(current), VALUE_WIDTH, height, Theme.FONT.value, + Theme.FOREGROUND.value); + renderScope.getMatrixStack().pop(); + } + + @Override + public void mouseClicked(double mouseX, double mouseY, int button) { + dragging = mouseX >= 0 && mouseX <= getTrackWidth() + KNOB && mouseY >= 0 && mouseY <= height; + + if (dragging) { + setFromMouse(mouseX); + } + } + + @Override + public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + if (dragging) { + setFromMouse(mouseX); + } + } + + @Override + public void mouseReleased(double mouseX, double mouseY, int button) { + dragging = false; + } + + private void setFromMouse(double mouseX) { + double progress = Math.max(0, Math.min(1, (mouseX - KNOB / 2.0) / getTrackWidth())); + double raw = min + progress * (max - min); + double snapped = min + Math.round((raw - min) / step) * step; + + // Snapping in floating point can leave 2.9999999, so round to the step's decimals. + double rounded = BigDecimal.valueOf(snapped).setScale(decimals, RoundingMode.HALF_UP).doubleValue(); + onChange.accept(Math.max(min, Math.min(max, rounded))); + } + + private String format(double current) { + String number = String.format(Locale.ROOT, "%." + decimals + "f", current); + + if (unit.isEmpty()) { + return number; + } + + // Word units get a space ("700 ms"); symbols don't ("3.0×"). + return Character.isLetter(unit.charAt(0)) ? number + " " + unit : number + unit; + } + + private int getTrackWidth() { + return width - VALUE_WIDTH - VALUE_GAP - KNOB; + } +} -- 2.54.0 From 33c09b69223a2165198d5a363954e26e379ed82a Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 17:47:48 +0200 Subject: [PATCH 07/13] Describe every setting under its name Properties can carry a one-line description, set with NamedProperty.description for mods, a ConfigManager.property overload for the client's config, and a helper in ModLayout. PropertyList rows show it under the setting's name in muted text. Every mod, HUD layout, general, UI style and animation setting now has one. Co-Authored-By: Claude Opus 5.5 --- .../saturnclient/config/AnimationConfig.java | 6 ++-- .../java/org/saturnclient/config/Config.java | 10 +++--- .../saturnclient/config/ConfigManager.java | 8 +++++ .../java/org/saturnclient/config/Theme.java | 20 +++++------ .../config/property/NamedProperty.java | 8 +++++ .../config/property/Property.java | 3 ++ .../java/org/saturnclient/mod/ModLayout.java | 19 +++++++---- .../mod/mods/ArmorDisplayMod.java | 2 +- .../org/saturnclient/mod/mods/ClockMod.java | 4 +-- .../saturnclient/mod/mods/CoordinatesMod.java | 2 +- .../saturnclient/mod/mods/FreelookMod.java | 12 +++---- .../saturnclient/mod/mods/FullbrightMod.java | 2 +- .../mod/mods/HealthDisplayMod.java | 4 +-- .../saturnclient/mod/mods/KeystrokesMod.java | 8 ++--- .../saturnclient/mod/mods/NametagsMod.java | 20 +++++------ .../saturnclient/mod/mods/SpeedometerMod.java | 6 ++-- .../org/saturnclient/mod/mods/ZoomMod.java | 12 +++---- .../ui/components/PropertyList.java | 33 +++++++++++++++---- 18 files changed, 111 insertions(+), 68 deletions(-) diff --git a/src/main/java/org/saturnclient/config/AnimationConfig.java b/src/main/java/org/saturnclient/config/AnimationConfig.java index 5451773..180673b 100644 --- a/src/main/java/org/saturnclient/config/AnimationConfig.java +++ b/src/main/java/org/saturnclient/config/AnimationConfig.java @@ -49,8 +49,8 @@ public class AnimationConfig { public void init(String name) { // config.property("Enable " + name + " Animations", this.enabled); - config.property(name + " Duration", this.duration); - config.property(name + " Stagger", this.stagger); - config.property(name + " Curve", this.curve); + config.property(name + " Duration", this.duration, "How long the animation takes"); + config.property(name + " Stagger", this.stagger, "Delay between items appearing one after another"); + config.property(name + " Curve", this.curve, "Easing of the animation"); } } diff --git a/src/main/java/org/saturnclient/config/Config.java b/src/main/java/org/saturnclient/config/Config.java index d8424b5..5a4e6d2 100644 --- a/src/main/java/org/saturnclient/config/Config.java +++ b/src/main/java/org/saturnclient/config/Config.java @@ -27,11 +27,11 @@ public class Config { File configFile = new File(Providers.saturn.getClient().getRunDirectory(), "saturn.json"); config = new ConfigManager(configFile, "Saturn Client"); - config.property("Realistic logo", realisticLogo); - config.property("Saturn client title screen", saturnTitleScreen); - config.property("Open Emote Wheel", openEmoteWheel); - config.property("Cloak Physics", cloakPhysics); - config.property("Stagger Animations", stagger); + config.property("Realistic logo", realisticLogo, "Use the realistic Saturn logo"); + config.property("Saturn client title screen", saturnTitleScreen, "Replace the title screen with Saturn's"); + config.property("Open Emote Wheel", openEmoteWheel, "The key that opens the emote wheel"); + config.property("Cloak Physics", cloakPhysics, "Let cloaks swing as you move"); + config.property("Stagger Animations", stagger, "Animate list items one after another"); // Initialize a sub namespace for theme Theme.init(config); diff --git a/src/main/java/org/saturnclient/config/ConfigManager.java b/src/main/java/org/saturnclient/config/ConfigManager.java index b133cb6..f844fe0 100644 --- a/src/main/java/org/saturnclient/config/ConfigManager.java +++ b/src/main/java/org/saturnclient/config/ConfigManager.java @@ -70,6 +70,14 @@ public class ConfigManager { return value; } + /** + * Add a property with the line shown under its name + */ + public Property property(String name, Property value, String description) { + value.description = description; + return property(name, value); + } + /** * Add empty namespace */ diff --git a/src/main/java/org/saturnclient/config/Theme.java b/src/main/java/org/saturnclient/config/Theme.java index 67f79e6..b3f0d41 100644 --- a/src/main/java/org/saturnclient/config/Theme.java +++ b/src/main/java/org/saturnclient/config/Theme.java @@ -69,18 +69,18 @@ public class Theme { public static void init(ConfigManager parent) { config = new ConfigManager(parent, "UI Style"); - config.property("Background", BACKGROUND); - config.property("Foreground", FOREGROUND); - config.property("Primary", PRIMARY); - config.property("Primary Foreground", PRIMARY_FG); - config.property("Accent", ACCENT); - config.property("Accent Foreground", ACCENT_FG); - config.property("Scrollbar Color", SCROLL); + config.property("Background", BACKGROUND, "Panels and screens"); + config.property("Foreground", FOREGROUND, "Text"); + config.property("Primary", PRIMARY, "Buttons and cards"); + config.property("Primary Foreground", PRIMARY_FG, "Text on buttons and cards"); + config.property("Accent", ACCENT, "Selected and enabled items"); + config.property("Accent Foreground", ACCENT_FG, "Text on the accent color"); + config.property("Scrollbar Color", SCROLL, "The scrollbar's handle"); - config.property("Background Radius", BG_RADIUS); - config.property("Widget Radius", WIDGET_RADIUS); + config.property("Background Radius", BG_RADIUS, "Corner rounding of panels"); + config.property("Widget Radius", WIDGET_RADIUS, "Corner rounding of buttons and cards"); - config.property("Font", FONT); + config.property("Font", FONT, "Font of every Saturn screen"); } /** diff --git a/src/main/java/org/saturnclient/config/property/NamedProperty.java b/src/main/java/org/saturnclient/config/property/NamedProperty.java index ddf2dc2..be6cf39 100644 --- a/src/main/java/org/saturnclient/config/property/NamedProperty.java +++ b/src/main/java/org/saturnclient/config/property/NamedProperty.java @@ -8,4 +8,12 @@ public class NamedProperty { this.name = name; this.prop = value; } + + /** + * Sets the line shown under the setting's name. + */ + public NamedProperty description(String description) { + this.prop.description = description; + return this; + } } \ No newline at end of file diff --git a/src/main/java/org/saturnclient/config/property/Property.java b/src/main/java/org/saturnclient/config/property/Property.java index becdb3b..09820dd 100644 --- a/src/main/java/org/saturnclient/config/property/Property.java +++ b/src/main/java/org/saturnclient/config/property/Property.java @@ -7,6 +7,9 @@ import java.util.Map; public abstract class Property { public boolean isReset = false; + /** One line under the setting's name saying what it does, or "" */ + public String description = ""; + // ---------- Factory Methods ---------- public static NamespaceProperty namespace(Map value) { diff --git a/src/main/java/org/saturnclient/mod/ModLayout.java b/src/main/java/org/saturnclient/mod/ModLayout.java index 2f79c30..eb380cf 100644 --- a/src/main/java/org/saturnclient/mod/ModLayout.java +++ b/src/main/java/org/saturnclient/mod/ModLayout.java @@ -26,17 +26,22 @@ public class ModLayout { public NamedProperty prop() { // A LinkedHashMap, unlike Map.of, keeps the order the settings are shown in. Map props = new LinkedHashMap<>(); - props.put("X", x); - props.put("Y", y); - props.put("Scale", scale); - props.put("Background Color", bgColor); - props.put("Foreground Color", fgColor); - props.put("Corner Radius", radius); - props.put("Font", font); + put(props, "X", x, "Distance from the left of the screen"); + put(props, "Y", y, "Distance from the top of the screen"); + put(props, "Scale", scale, "Size of the display"); + put(props, "Background Color", bgColor, "Fill behind the display"); + put(props, "Foreground Color", fgColor, "Color of the text"); + put(props, "Corner Radius", radius, "Rounding of the background's corners"); + put(props, "Font", font, "Font of the text"); return Property.namespace(props).named("In-Game Display"); } + private static void put(Map props, String name, Property prop, String description) { + prop.description = description; + props.put(name, prop); + } + public ModLayout() { } diff --git a/src/main/java/org/saturnclient/mod/mods/ArmorDisplayMod.java b/src/main/java/org/saturnclient/mod/mods/ArmorDisplayMod.java index 40210a0..84c72b8 100644 --- a/src/main/java/org/saturnclient/mod/mods/ArmorDisplayMod.java +++ b/src/main/java/org/saturnclient/mod/mods/ArmorDisplayMod.java @@ -32,7 +32,7 @@ public class ArmorDisplayMod extends Mod implements HudMod { .tags("Utility") .requires(Providers.feature::player), enabled.named("Enabled"), - useMainHand.named("Use Main Hand"), + useMainHand.named("Use Main Hand").description("Also show the item in your main hand"), layout.prop()); } diff --git a/src/main/java/org/saturnclient/mod/mods/ClockMod.java b/src/main/java/org/saturnclient/mod/mods/ClockMod.java index b04645f..2f69237 100644 --- a/src/main/java/org/saturnclient/mod/mods/ClockMod.java +++ b/src/main/java/org/saturnclient/mod/mods/ClockMod.java @@ -31,8 +31,8 @@ public class ClockMod extends Mod implements HudMod { .version("v0.2.0") .tags("Utility"), enabled.named("Enabled"), - format.named("Format"), - showSeconds.named("Show seconds"), + format.named("Format").description("24-hour or 12-hour time"), + showSeconds.named("Show seconds").description("Include seconds in the time"), layout.prop()); } diff --git a/src/main/java/org/saturnclient/mod/mods/CoordinatesMod.java b/src/main/java/org/saturnclient/mod/mods/CoordinatesMod.java index 6fb6934..e1a9a2f 100644 --- a/src/main/java/org/saturnclient/mod/mods/CoordinatesMod.java +++ b/src/main/java/org/saturnclient/mod/mods/CoordinatesMod.java @@ -33,7 +33,7 @@ public class CoordinatesMod extends Mod implements HudMod { .tags("Utility") .requires(Providers.feature::player), enabled.named("Enabled"), - displayMethod.named("Display Method"), + displayMethod.named("Display Method").description("One line or one per axis, with or without labels"), layout.prop()); } diff --git a/src/main/java/org/saturnclient/mod/mods/FreelookMod.java b/src/main/java/org/saturnclient/mod/mods/FreelookMod.java index 46f75d1..c4bba29 100644 --- a/src/main/java/org/saturnclient/mod/mods/FreelookMod.java +++ b/src/main/java/org/saturnclient/mod/mods/FreelookMod.java @@ -43,12 +43,12 @@ public class FreelookMod extends Mod { .version("v0.3.1") .requires(Providers.feature::render), enabled.named("Enabled"), - toggle.named("Toggle freelook"), - freelookKey.named("Freelook Keybinding"), - zoomLevel.named("Zoom Level"), - zoomInDuration.named("Zoom In Duration"), - zoomOutDuration.named("Zoom Out Duration"), - curve.named("Curve")); + toggle.named("Toggle freelook").description("Press the key once instead of holding it"), + freelookKey.named("Freelook Keybinding").description("The key that starts freelook"), + zoomLevel.named("Zoom Level").description("Zoom when freelook starts in first person"), + zoomInDuration.named("Zoom In Duration").description("How long that zoom takes to ease in"), + zoomOutDuration.named("Zoom Out Duration").description("How long that zoom takes to ease out"), + curve.named("Curve").description("Easing of the zoom")); } // --- Lifecycle Methods --- diff --git a/src/main/java/org/saturnclient/mod/mods/FullbrightMod.java b/src/main/java/org/saturnclient/mod/mods/FullbrightMod.java index 1ca00d9..fa66c8c 100644 --- a/src/main/java/org/saturnclient/mod/mods/FullbrightMod.java +++ b/src/main/java/org/saturnclient/mod/mods/FullbrightMod.java @@ -28,7 +28,7 @@ public class FullbrightMod extends Mod { .tags("Camera") .version("v0.2.0"), enabled.named("Enabled"), - brightness.named("Brightness %")); + brightness.named("Brightness %").description("How bright the world gets")); } // --------------------------------------------------------------- diff --git a/src/main/java/org/saturnclient/mod/mods/HealthDisplayMod.java b/src/main/java/org/saturnclient/mod/mods/HealthDisplayMod.java index da15115..822e120 100644 --- a/src/main/java/org/saturnclient/mod/mods/HealthDisplayMod.java +++ b/src/main/java/org/saturnclient/mod/mods/HealthDisplayMod.java @@ -31,8 +31,8 @@ public class HealthDisplayMod extends Mod implements HudMod { .tags("Utility") .requires(Providers.feature::player), enabled.named("Enabled"), - displayMode.named("Display mode"), - decimals.named("Decimals"), + displayMode.named("Display mode").description("Show health points or hearts"), + decimals.named("Decimals").description("Decimal places shown"), layout.prop()); } diff --git a/src/main/java/org/saturnclient/mod/mods/KeystrokesMod.java b/src/main/java/org/saturnclient/mod/mods/KeystrokesMod.java index 50b9eca..eb0507f 100644 --- a/src/main/java/org/saturnclient/mod/mods/KeystrokesMod.java +++ b/src/main/java/org/saturnclient/mod/mods/KeystrokesMod.java @@ -54,11 +54,11 @@ public class KeystrokesMod extends Mod implements HudMod { .tags("Utility") .requires(Providers.feature::player), enabled.named("Enabled"), - showMouse.named("Show mouse clicks"), - showSpace.named("Show space"), + showMouse.named("Show mouse clicks").description("Show the left and right mouse buttons"), + showSpace.named("Show space").description("Show the space bar"), layout.prop(), - clickFg.named("Clicked fg"), - clickBg.named("Clicked bg")); + clickFg.named("Clicked fg").description("Text color of a pressed key"), + clickBg.named("Clicked bg").description("Background of a pressed key")); layout.renderBackground = false; } diff --git a/src/main/java/org/saturnclient/mod/mods/NametagsMod.java b/src/main/java/org/saturnclient/mod/mods/NametagsMod.java index 409e6a0..0a066da 100644 --- a/src/main/java/org/saturnclient/mod/mods/NametagsMod.java +++ b/src/main/java/org/saturnclient/mod/mods/NametagsMod.java @@ -58,16 +58,16 @@ public class NametagsMod extends Mod { .tags("Camera", "Utility") .version("v0.2.0"), enabled.named("Enabled"), - healthDisplay.named("Display health"), - players.named("Players"), - hostile.named("Hostile"), - passive.named("Passive"), - heartEmoji.named("Heart emoji"), - obfuscate.named("Obfuscate enemy names"), - unit.named("Unit"), - format.named("Format"), - nameColor.named("Name color"), - healthColor.named("Health color")); + healthDisplay.named("Display health").description("Show health after the name"), + players.named("Players").description("Show health on players"), + hostile.named("Hostile").description("Show health on hostile mobs"), + passive.named("Passive").description("Show health on passive mobs"), + heartEmoji.named("Heart emoji").description("Add a heart after the health"), + obfuscate.named("Obfuscate enemy names").description("Scramble names into unreadable text"), + unit.named("Unit").description("Show health points or hearts"), + format.named("Format").description("Value, value out of total, or percent"), + nameColor.named("Name color").description("Color of the name"), + healthColor.named("Health color").description("Color of the health")); } // --------------------------------------------------------------- diff --git a/src/main/java/org/saturnclient/mod/mods/SpeedometerMod.java b/src/main/java/org/saturnclient/mod/mods/SpeedometerMod.java index b0579e5..013b894 100644 --- a/src/main/java/org/saturnclient/mod/mods/SpeedometerMod.java +++ b/src/main/java/org/saturnclient/mod/mods/SpeedometerMod.java @@ -34,9 +34,9 @@ public class SpeedometerMod extends Mod implements HudMod { .tags("Utility") .requires(Providers.feature::player), enabled.named("Enabled"), - axis.named("Speed type"), - unitText.named("Unit text"), - showLabel.named("Show speed label"), + axis.named("Speed type").description("All movement, only horizontal or only vertical"), + unitText.named("Unit text").description("Unit shown after the speed"), + showLabel.named("Show speed label").description("Show “Speed:” before the value"), layout.prop()); } diff --git a/src/main/java/org/saturnclient/mod/mods/ZoomMod.java b/src/main/java/org/saturnclient/mod/mods/ZoomMod.java index f2f899f..a0f0768 100644 --- a/src/main/java/org/saturnclient/mod/mods/ZoomMod.java +++ b/src/main/java/org/saturnclient/mod/mods/ZoomMod.java @@ -31,12 +31,12 @@ public class ZoomMod extends Mod { .tags("Camera", "Utility") .version("v0.2.0"), enabled.named("Enabled"), - toggle.named("Toggle zoom"), - zoomKey.named("Zoom Keybinding"), - zoomLevel.named("Zoom Level"), - zoomInDuration.named("Zoom In Duration"), - zoomOutDuration.named("Zoom Out Duration"), - curve.named("Curve")); + toggle.named("Toggle zoom").description("Press the key once instead of holding it"), + zoomKey.named("Zoom Keybinding").description("The key that zooms in"), + zoomLevel.named("Zoom Level").description("How far the camera zooms in"), + zoomInDuration.named("Zoom In Duration").description("How long zooming in takes"), + zoomOutDuration.named("Zoom Out Duration").description("How long zooming back out takes"), + curve.named("Curve").description("Easing of the zoom")); } // --------------------------------------------------------------- diff --git a/src/main/java/org/saturnclient/ui/components/PropertyList.java b/src/main/java/org/saturnclient/ui/components/PropertyList.java index 96b49b9..de0a1ba 100644 --- a/src/main/java/org/saturnclient/ui/components/PropertyList.java +++ b/src/main/java/org/saturnclient/ui/components/PropertyList.java @@ -77,7 +77,7 @@ public final class PropertyList { continue; } - scroll.draw(new Row(name, width, control.width).position(0, y)); + 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)); y += ROW_HEIGHT; } @@ -118,17 +118,22 @@ public final class PropertyList { } /** - * A row's name and the line under it. The control is a separate element on - * top, so it gets the clicks and key presses. + * A row's name, its description under it, and the line below the row. The + * control is a separate element on top, so it gets the clicks and key + * presses. */ private static class Row extends Element { private static final float NAME_SCALE = 0.74f; + private static final float DESCRIPTION_SCALE = 0.62f; + private static final int LINE_GAP = 4; private final String name; + private final String description; private final int controlWidth; - Row(String name, int width, int controlWidth) { + Row(String name, String description, int width, int controlWidth) { this.name = name; + this.description = description; this.controlWidth = controlWidth; this.width = width; this.height = ROW_HEIGHT; @@ -136,10 +141,24 @@ public final class PropertyList { @Override public void render(RenderScope renderScope, ElementContext ctx) { - int nameY = (height - (int) (Fonts.getHeight() * NAME_SCALE)) / 2; - String text = Fonts.ellipsize(name, Theme.FONT.value, NAME_SCALE, width - controlWidth - 16); + int textWidth = width - controlWidth - 16; + int nameHeight = (int) (Fonts.getHeight() * NAME_SCALE); + int descriptionHeight = (int) (Fonts.getHeight() * DESCRIPTION_SCALE); + boolean described = !description.isEmpty(); + + int blockHeight = described ? nameHeight + LINE_GAP + descriptionHeight : nameHeight; + int nameY = (height - blockHeight) / 2; + + renderScope.drawText(NAME_SCALE, Fonts.ellipsize(name, Theme.FONT.value, NAME_SCALE, textWidth), + 0, nameY, Theme.FONT.value, Theme.FOREGROUND.value); + + if (described) { + int font = Theme.getBodyFont(); + renderScope.drawText(DESCRIPTION_SCALE, + Fonts.ellipsize(description, font, DESCRIPTION_SCALE, textWidth), + 0, nameY + nameHeight + LINE_GAP, font, Theme.getMutedFg()); + } - renderScope.drawText(NAME_SCALE, text, 0, nameY, Theme.FONT.value, Theme.FOREGROUND.value); renderScope.drawRectangle(0, height - 1, width, 1, Theme.withAlpha(0.06f, Theme.FOREGROUND.value)); } } -- 2.54.0 From 351507d60d08a13ce553315e51989a7cb46ada3b Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 17:54:12 +0200 Subject: [PATCH 08/13] Fix scrolling and keep lists clear of the scrollbar Scroll kept its position as an int, so wheel and trackpad deltas were truncated: small ones were lost in one direction and a wheel notch moved one unit. It now keeps a fractional position, scrolls 24 units a notch, sizes and places its thumb consistently, and the thumb can be dragged or the track clicked. PanelScreen's scrollWidth and scrollContentWidth keep every scrolling list clear of the bar. Sliders read a float step from its text, so 0.1 no longer shows 17 decimals, and show at most three; number boxes show at most three too. Co-Authored-By: Claude Opus 5.5 --- .../java/org/saturnclient/ui/PanelScreen.java | 18 ++ .../ui/components/inputs/FloatInput.java | 16 +- .../ui/components/inputs/Slider.java | 14 +- .../org/saturnclient/ui/elements/Scroll.java | 166 +++++++++++------- .../ui/screens/ClientSettings.java | 10 +- .../ui/screens/CosmeticsMenu.java | 5 +- .../org/saturnclient/ui/screens/ModMenu.java | 5 +- .../saturnclient/ui/screens/ModSettings.java | 6 +- 8 files changed, 152 insertions(+), 88 deletions(-) diff --git a/src/main/java/org/saturnclient/ui/PanelScreen.java b/src/main/java/org/saturnclient/ui/PanelScreen.java index 65d8fbb..2bf84a2 100644 --- a/src/main/java/org/saturnclient/ui/PanelScreen.java +++ b/src/main/java/org/saturnclient/ui/PanelScreen.java @@ -3,6 +3,7 @@ package org.saturnclient.ui; import org.saturnclient.ui.components.Sidebar; import org.saturnclient.ui.elements.ImageTexture; import org.saturnclient.ui.elements.Panel; +import org.saturnclient.ui.elements.Scroll; import org.saturnclient.ui.resources.Textures; /** @@ -18,6 +19,9 @@ public abstract class PanelScreen extends SaturnScreen { protected static final int PADDING = 20; + /** How far a scroll reaches past its content area, into the padding, for its bar */ + private static final int SCROLL_OVERHANG = 14; + private final int sidebarItem; protected int panelX, panelY, panelWidth, panelHeight; @@ -61,4 +65,18 @@ public abstract class PanelScreen extends SaturnScreen { * Draws the panel's content. */ protected abstract void panel(); + + /** + * The width of a scroll filling {@code width} of the content area. + */ + protected static int scrollWidth(int width) { + return width + SCROLL_OVERHANG; + } + + /** + * The width left for a scroll's children, clear of its scrollbar. + */ + protected static int scrollContentWidth(int width) { + return scrollWidth(width) - Scroll.SCROLLBAR_SPACE; + } } diff --git a/src/main/java/org/saturnclient/ui/components/inputs/FloatInput.java b/src/main/java/org/saturnclient/ui/components/inputs/FloatInput.java index 4e10b34..bc2908e 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/FloatInput.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/FloatInput.java @@ -1,5 +1,8 @@ package org.saturnclient.ui.components.inputs; +import java.math.BigDecimal; +import java.math.RoundingMode; + import org.saturnclient.config.ConfigManager; import org.saturnclient.config.property.FloatProperty; import org.saturnclient.ui.elements.Field; @@ -11,7 +14,16 @@ public class FloatInput extends Input { this.width = 96; this.height = Field.HEIGHT; this.prop = prop; - this.text = String.valueOf(prop.value); + this.text = format(prop.value); + } + + /** + * The value with at most three decimals and no trailing zeros, since floats + * like 1.2000000476837158 can't be stored exactly. + */ + private static String format(float value) { + return new BigDecimal(Float.toString(value)).setScale(3, RoundingMode.HALF_UP).stripTrailingZeros() + .toPlainString(); } @Override @@ -51,7 +63,7 @@ public class FloatInput extends Input { @Override public void checkReset() { if (prop.isReset) { - this.text = String.valueOf(prop.value); + this.text = format(prop.value); this.cursorPosition = 0; prop.isReset = false; } diff --git a/src/main/java/org/saturnclient/ui/components/inputs/Slider.java b/src/main/java/org/saturnclient/ui/components/inputs/Slider.java index b9f24ab..13f1965 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/Slider.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/Slider.java @@ -23,6 +23,7 @@ public class Slider extends Element { private static final int KNOB = 16; private static final int VALUE_WIDTH = 72; private static final int VALUE_GAP = 14; + private static final int MAX_DECIMALS = 3; private final double min, max, step; private final int decimals; @@ -32,25 +33,28 @@ public class Slider extends Element { private boolean dragging = false; - private Slider(double min, double max, double step, String unit, DoubleSupplier value, DoubleConsumer onChange) { + /** + * @param step as text, so a float step like 0.1 isn't read as 0.10000000149 + */ + private Slider(double min, double max, String step, String unit, DoubleSupplier value, DoubleConsumer onChange) { this.min = min; this.max = max; - this.step = step; + this.step = Double.parseDouble(step); this.unit = unit; this.value = value; this.onChange = onChange; - this.decimals = Math.max(0, BigDecimal.valueOf(step).stripTrailingZeros().scale()); + this.decimals = Math.min(MAX_DECIMALS, Math.max(0, new BigDecimal(step).stripTrailingZeros().scale())); this.width = 280; this.height = Field.HEIGHT; } public static Slider of(IntProperty prop) { - return new Slider(prop.min, prop.max, prop.step, prop.unit, () -> prop.value, + return new Slider(prop.min, prop.max, Integer.toString(prop.step), prop.unit, () -> prop.value, v -> prop.value = (int) Math.round(v)); } public static Slider of(FloatProperty prop) { - return new Slider(prop.min, prop.max, prop.step, prop.unit, () -> prop.value, + return new Slider(prop.min, prop.max, Float.toString(prop.step), prop.unit, () -> prop.value, v -> prop.value = (float) v); } diff --git a/src/main/java/org/saturnclient/ui/elements/Scroll.java b/src/main/java/org/saturnclient/ui/elements/Scroll.java index fe6fcf3..a9ddef1 100644 --- a/src/main/java/org/saturnclient/ui/elements/Scroll.java +++ b/src/main/java/org/saturnclient/ui/elements/Scroll.java @@ -4,24 +4,40 @@ import java.util.ArrayList; import java.util.List; import org.saturnclient.config.Theme; -import org.saturnclient.config.property.IntProperty; -import org.saturnclient.config.property.Property; import org.saturnclient.ui.Element; import org.saturnclient.ui.ElementContext; import org.saturnclient.ui.ElementRenderer; import org.saturnclient.ui.RenderScope; +/** + * A vertically scrolling container. It scrolls with the mouse wheel or by + * dragging its scrollbar, which shows along its right edge when the children + * don't fit. + */ public class Scroll extends Element { - private static IntProperty scrollBarRadius = Property.integer(10); - private static IntProperty scrollBarWidth = Property.integer(5); - private static IntProperty scrollBarPadding = Property.integer(5); + /** Units scrolled per mouse wheel notch */ + private static final double SCROLL_SPEED = 24; + /** Space left below the last child when scrolled to the end */ + private static final int END_SPACE = 10; + + private static final int BAR_WIDTH = 5; + private static final int BAR_INSET = 5; + private static final int MIN_THUMB = 20; + + /** Width to keep clear at the right of the content for the scrollbar and its click area */ + public static final int SCROLLBAR_SPACE = BAR_WIDTH + BAR_INSET * 2 + 4; int padding = 0; boolean background = true; protected List children = new ArrayList<>(); - int scroll = 0; - int maxScroll = 0; + + /** Kept fractional so small trackpad deltas add up instead of being dropped */ + private double scroll = 0; + private int maxScroll = 0; + + /** Where in the thumb it was grabbed, while it's being dragged */ + private Integer thumbGrab = null; public Scroll(int padding) { this.padding = padding; @@ -50,7 +66,8 @@ public class Scroll extends Element { @Override public void render(RenderScope renderScope, ElementContext ctx) { - calculateMaxScroll(); + updateMaxScroll(); + int offset = getOffset(); if (background) { renderScope.drawRoundedRectangle(0, 0, width, height, @@ -59,75 +76,27 @@ public class Scroll extends Element { renderScope.enableScissor(padding, padding, width - padding, height - padding); renderScope.getMatrixStack().push(); - renderScope.getMatrixStack().translate(padding, -scroll + padding); + renderScope.getMatrixStack().translate(padding, -offset + padding); ElementRenderer.render(children, ctx.elapsed, renderScope, ctx.mouseX - padding, - ctx.mouseY - padding + scroll); + ctx.mouseY - padding + offset); renderScope.getMatrixStack().pop(); renderScope.disableScissor(); if (maxScroll > 0) { - renderScope.drawRoundedRectangle( - width - scrollBarWidth.value - scrollBarPadding.value, - calculateScrollBarY(), - scrollBarWidth.value, - calculateScrollBarHeight(), - scrollBarRadius.value, - Theme.SCROLL.value); + boolean active = thumbGrab != null || ctx.isHovering(getBarX() - BAR_INSET, 0, BAR_WIDTH + BAR_INSET * 2, + height); + + renderScope.drawRoundedRectangle(getBarX(), getThumbY(), BAR_WIDTH, getThumbHeight(), BAR_WIDTH / 2, + active ? Theme.SCROLL.value : Theme.withAlpha(0.7f, Theme.SCROLL.value)); } } @Override public void scroll(int mouseX, int mouseY, double horizontalAmount, double verticalAmount) { - scroll -= verticalAmount; - if (scroll < 0) { - scroll = 0; - } else if (scroll > maxScroll) { - scroll = maxScroll; - } - } - - @Override - public Element dimensions(int width, int height) { - super.dimensions(width, height); - - maxScroll = 0; - for (Element element : children) { - maxScroll = Math.max(maxScroll, (element.y + element.height + padding) - (height - padding * 2)); - } - - return this; - } - - int calculateScrollBarHeight() { - if (maxScroll <= 0) - return height - (scrollBarPadding.value * 2); - return Math.max(20, (height * height) / (height + maxScroll + 10)) - (scrollBarPadding.value * 2); - } - - int calculateScrollBarY() { - int scrollBarHeight = calculateScrollBarHeight(); - if (maxScroll <= 0) - return scrollBarPadding.value; - int y = (scroll * (height - scrollBarHeight - scrollBarPadding.value * 2)) / maxScroll + scrollBarPadding.value; - return Math.min(y, height - scrollBarHeight - scrollBarPadding.value); - } - - private void calculateMaxScroll() { - maxScroll = 0; - - for (Element element : children) { - int bottom = element.y + element.height; - maxScroll = Math.max(maxScroll, bottom - height + padding); - } - - if (maxScroll > 0) { - maxScroll = maxScroll + 10; - } else { - maxScroll = 0; - } + setScroll(scroll - verticalAmount * SCROLL_SPEED); } @Override @@ -146,17 +115,80 @@ public class Scroll extends Element { return; } - ElementRenderer.mouseClicked(children, mouseX - padding, mouseY - padding + scroll, button); + if (maxScroll > 0 && mouseX >= getBarX() - BAR_INSET) { + int thumbY = getThumbY(); + int thumbHeight = getThumbHeight(); + + // Grab the thumb where it was clicked, or center it on a click in the track. + boolean onThumb = mouseY >= thumbY && mouseY <= thumbY + thumbHeight; + thumbGrab = onThumb ? (int) mouseY - thumbY : thumbHeight / 2; + dragThumb(mouseY); + return; + } + + ElementRenderer.mouseClicked(children, mouseX - padding, mouseY - padding + getOffset(), button); } @Override public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { - ElementRenderer.mouseDragged(children, mouseX - padding, mouseY - padding + scroll, button, deltaX, + if (thumbGrab != null) { + dragThumb(mouseY); + return; + } + + ElementRenderer.mouseDragged(children, mouseX - padding, mouseY - padding + getOffset(), button, deltaX, deltaY); } @Override public void mouseReleased(double mouseX, double mouseY, int button) { - ElementRenderer.mouseReleased(children, mouseX - padding, mouseY - padding + scroll, button); + thumbGrab = null; + ElementRenderer.mouseReleased(children, mouseX - padding, mouseY - padding + getOffset(), button); + } + + private void dragThumb(double mouseY) { + int travel = getTrackHeight() - getThumbHeight(); + if (travel > 0) { + setScroll((mouseY - thumbGrab - BAR_INSET) / travel * maxScroll); + } + } + + private void setScroll(double value) { + scroll = Math.max(0, Math.min(maxScroll, value)); + } + + private int getOffset() { + return (int) Math.round(scroll); + } + + private void updateMaxScroll() { + int bottom = 0; + for (Element element : children) { + bottom = Math.max(bottom, element.y + element.height); + } + + int overflow = bottom + padding * 2 - height; + maxScroll = overflow > 0 ? overflow + END_SPACE : 0; + + // The content may have shrunk, such as after filtering. + setScroll(scroll); + } + + private int getBarX() { + return width - BAR_WIDTH - BAR_INSET; + } + + private int getTrackHeight() { + return height - BAR_INSET * 2; + } + + private int getThumbHeight() { + int track = getTrackHeight(); + return Math.max(MIN_THUMB, (int) ((long) track * height / (height + maxScroll))); + } + + private int getThumbY() { + int travel = getTrackHeight() - getThumbHeight(); + return BAR_INSET + (int) (travel * (scroll / maxScroll)); } } diff --git a/src/main/java/org/saturnclient/ui/screens/ClientSettings.java b/src/main/java/org/saturnclient/ui/screens/ClientSettings.java index d002207..db3fd4e 100644 --- a/src/main/java/org/saturnclient/ui/screens/ClientSettings.java +++ b/src/main/java/org/saturnclient/ui/screens/ClientSettings.java @@ -52,7 +52,6 @@ public class ClientSettings extends PanelScreen { private static final int LIST_Y = 58; private static final int SECTION_GAP = 22; private static final int PRESET_GAP = 10; - private static final int SCROLLBAR_SPACE = 14; private Section section = Section.GENERAL; @@ -88,14 +87,15 @@ public class ClientSettings extends PanelScreen { // The section's settings Scroll scroll = new Scroll(0).noBackground(); + int listWidth = scrollContentWidth(width); switch (section) { - case GENERAL -> PropertyList.draw(scroll, getGeneralProperties(), Set.of(), width, 0); - case UI_STYLE -> drawUiStyle(scroll, width); - case ANIMATIONS -> PropertyList.draw(scroll, AnimationConfig.config.getProperties(), Set.of(), width, 0); + case GENERAL -> PropertyList.draw(scroll, getGeneralProperties(), Set.of(), listWidth, 0); + case UI_STYLE -> drawUiStyle(scroll, listWidth); + case ANIMATIONS -> PropertyList.draw(scroll, AnimationConfig.config.getProperties(), Set.of(), listWidth, 0); } - draw(scroll.dimensions(width + SCROLLBAR_SPACE, contentHeight - LIST_Y).position(x, contentY + LIST_Y)); + draw(scroll.dimensions(scrollWidth(width), contentHeight - LIST_Y).position(x, contentY + LIST_Y)); } private void drawUiStyle(Scroll scroll, int width) { diff --git a/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java b/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java index 738a3d3..8fa0556 100644 --- a/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java +++ b/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java @@ -101,7 +101,6 @@ public class CosmeticsMenu extends PanelScreen { private static final int COLUMNS = 4; private static final int TILE_GAP = 10; private static final int GRID_Y = 58; - private static final int SCROLLBAR_SPACE = 14; private static final float SMALL_SCALE = 0.6f; private Kind kind = Kind.CLOAKS; @@ -139,10 +138,10 @@ public class CosmeticsMenu extends PanelScreen { scroll.draw(new Label(() -> message, 0.7f, Theme::getBodyFont, Theme::getMutedFg) .position(messageX, 60)); } else { - scroll.draw(createGrid(width)); + scroll.draw(createGrid(scrollContentWidth(width))); } - draw(scroll.dimensions(width + SCROLLBAR_SPACE, gridHeight).position(x, contentY + GRID_Y)); + draw(scroll.dimensions(scrollWidth(width), gridHeight).position(x, contentY + GRID_Y)); } /** diff --git a/src/main/java/org/saturnclient/ui/screens/ModMenu.java b/src/main/java/org/saturnclient/ui/screens/ModMenu.java index eb2e742..05f433a 100644 --- a/src/main/java/org/saturnclient/ui/screens/ModMenu.java +++ b/src/main/java/org/saturnclient/ui/screens/ModMenu.java @@ -24,7 +24,6 @@ public class ModMenu extends PanelScreen { private static final int COLUMNS = 3; private static final int CARD_GAP = 10; - private static final int SCROLLBAR_SPACE = 14; private String category = ALL; private String query = ""; @@ -40,7 +39,7 @@ public class ModMenu extends PanelScreen { protected void panel() { // The search box starts empty after a resize, so the query does too. query = ""; - gridWidth = contentWidth; + gridWidth = scrollContentWidth(contentWidth); // Header draw(new Label(() -> "Mods", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value) @@ -66,7 +65,7 @@ public class ModMenu extends PanelScreen { // Mods int scrollY = chipsY + categories.height + 14; scroll = new Scroll(0).noBackground(); - scroll.dimensions(gridWidth + SCROLLBAR_SPACE, panelY + panelHeight - 12 - scrollY); + scroll.dimensions(scrollWidth(contentWidth), panelY + panelHeight - 12 - scrollY); refresh(true); draw(scroll.position(contentX, scrollY)); } diff --git a/src/main/java/org/saturnclient/ui/screens/ModSettings.java b/src/main/java/org/saturnclient/ui/screens/ModSettings.java index 9d924b9..f6b096a 100644 --- a/src/main/java/org/saturnclient/ui/screens/ModSettings.java +++ b/src/main/java/org/saturnclient/ui/screens/ModSettings.java @@ -33,7 +33,6 @@ public class ModSettings extends PanelScreen { private static final int PREVIEW_WIDTH = 280; private static final int PREVIEW_MAX_HEIGHT = 220; private static final int COLUMN_GAP = 22; - private static final int SCROLLBAR_SPACE = 14; private final Mod mod; @@ -77,8 +76,9 @@ public class ModSettings extends PanelScreen { } Scroll scroll = new Scroll(0).noBackground(); - PropertyList.draw(scroll, mod.getConfig().getProperties(), Set.of(ENABLED), listWidth, 0); - draw(scroll.dimensions(listWidth + SCROLLBAR_SPACE, bodyHeight).position(contentX, bodyY)); + PropertyList.draw(scroll, mod.getConfig().getProperties(), Set.of(ENABLED), scrollContentWidth(listWidth), + 0); + draw(scroll.dimensions(scrollWidth(listWidth), bodyHeight).position(contentX, bodyY)); } /** -- 2.54.0 From 6bfdfc40c93733fa36728eec080d602bfcd4c954 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 18:02:41 +0200 Subject: [PATCH 09/13] Add dropdowns and a popup color picker Popup draws one element above the rest of the screen and gets mouse events first; clicking or scrolling outside closes it. ElementContext now carries each element's position on screen so an input inside a scroll can open a popup beside itself. Select is a dropdown: the current value opens an OptionList with the current option ticked, scrolling for long lists and opening upward when there's no room below. The color picker moved out of ColorInput into a ColorPicker popup, so scroll areas no longer clip it and later rows no longer draw over it, and all three of its parts can be dragged. Co-Authored-By: Claude Opus 5.5 --- .../org/saturnclient/ui/ElementContext.java | 6 +- .../org/saturnclient/ui/ElementRenderer.java | 11 +- src/main/java/org/saturnclient/ui/Popup.java | 140 ++++++++++++ .../org/saturnclient/ui/SaturnScreen.java | 16 +- .../saturnclient/ui/components/ColorGrid.java | 8 +- .../ui/components/CosmeticTile.java | 2 +- .../ui/components/PropertyList.java | 15 +- .../ui/components/inputs/ColorInput.java | 213 ++---------------- .../ui/components/inputs/ColorPicker.java | 164 ++++++++++++++ .../ui/components/inputs/OptionList.java | 113 ++++++++++ .../ui/components/inputs/Select.java | 44 ++-- .../ui/elements/AnimationStagger.java | 4 +- .../org/saturnclient/ui/elements/Scroll.java | 4 +- .../saturnclient/ui/resources/Textures.java | 3 + .../textures/gui/components/check.svg | 1 + .../textures/gui/components/chevron_down.svg | 1 + 16 files changed, 509 insertions(+), 236 deletions(-) create mode 100644 src/main/java/org/saturnclient/ui/Popup.java create mode 100644 src/main/java/org/saturnclient/ui/components/inputs/ColorPicker.java create mode 100644 src/main/java/org/saturnclient/ui/components/inputs/OptionList.java create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/check.svg create mode 100644 src/main/resources/assets/saturnclient/textures/gui/components/chevron_down.svg diff --git a/src/main/java/org/saturnclient/ui/ElementContext.java b/src/main/java/org/saturnclient/ui/ElementContext.java index 2077161..c9c3638 100644 --- a/src/main/java/org/saturnclient/ui/ElementContext.java +++ b/src/main/java/org/saturnclient/ui/ElementContext.java @@ -2,13 +2,17 @@ package org.saturnclient.ui; public class ElementContext { public int mouseX, mouseY; + /** The element's top-left corner on the screen, for placing popups next to it */ + public int originX, originY; public int elementWidth, elementHeight; public float elementScale; public long elapsed; - public ElementContext(long elapsed, int mouseX, int mouseY, Element e) { + public ElementContext(long elapsed, int mouseX, int mouseY, int parentX, int parentY, Element e) { this.mouseX = mouseX - e.x; this.mouseY = mouseY - e.y; + this.originX = parentX + e.x; + this.originY = parentY + e.y; this.elementWidth = e.width; this.elementHeight = e.height; this.elementScale = e.scale; diff --git a/src/main/java/org/saturnclient/ui/ElementRenderer.java b/src/main/java/org/saturnclient/ui/ElementRenderer.java index 7744ef6..4dade83 100644 --- a/src/main/java/org/saturnclient/ui/ElementRenderer.java +++ b/src/main/java/org/saturnclient/ui/ElementRenderer.java @@ -15,6 +15,15 @@ public class ElementRenderer { } public static void render(List elements, long elapsed, RenderScope renderScope, int mouseX, int mouseY) { + render(elements, elapsed, renderScope, mouseX, mouseY, 0, 0); + } + + /** + * Renders elements whose parent's top-left corner is at + * ({@code parentX}, {@code parentY}) on the screen. + */ + public static void render(List elements, long elapsed, RenderScope renderScope, int mouseX, int mouseY, + int parentX, int parentY) { for (Element element : elements) { element.playAnimationFrame(elapsed); @@ -22,7 +31,7 @@ public class ElementRenderer { renderScope.setOpacity(element.opacity); renderScope.getMatrixStack().translate(element.x, element.y); renderScope.getMatrixStack().scale(element.scale, element.scale); - element.render(renderScope, new ElementContext(elapsed, mouseX, mouseY, element)); + element.render(renderScope, new ElementContext(elapsed, mouseX, mouseY, parentX, parentY, element)); renderScope.getMatrixStack().pop(); } } diff --git a/src/main/java/org/saturnclient/ui/Popup.java b/src/main/java/org/saturnclient/ui/Popup.java new file mode 100644 index 0000000..14859ea --- /dev/null +++ b/src/main/java/org/saturnclient/ui/Popup.java @@ -0,0 +1,140 @@ +package org.saturnclient.ui; + +import java.util.List; + +/** + * A single element drawn above the rest of the screen, such as a dropdown's + * options or a color picker. It gets mouse events before anything else, and a + * click or scroll outside it closes it. + * + * Only one screen is open at a time, so the popup is kept here rather than on + * the screen; {@link SaturnScreen} forwards to it and clears it when it's + * rebuilt. + */ +public final class Popup { + private static final int SCREEN_MARGIN = 8; + private static final int OWNER_GAP = 4; + + private static Element popup; + private static Element owner; + private static int screenWidth, screenHeight; + + private Popup() { + } + + /** + * Opens {@code popup} with its top-left corner at ({@code x}, {@code y}) on + * the screen, moved as needed to stay on it. + * + * @param owner the element that opened it, for {@link #isOpenFor} + */ + public static void open(Element owner, Element popup, int x, int y) { + Popup.owner = owner; + Popup.popup = popup; + + popup.x = clamp(x, popup.width, screenWidth); + popup.y = clamp(y, popup.height, screenHeight); + } + + /** + * Opens {@code popup} just below its owner, or above it when there isn't + * room below. + * + * @param originX the owner's top-left corner on the screen + * @param originY + */ + public static void openBelow(Element owner, Element popup, int originX, int originY) { + int y = originY + owner.height + OWNER_GAP; + + if (y + popup.height > screenHeight - SCREEN_MARGIN) { + y = originY - OWNER_GAP - popup.height; + } + + open(owner, popup, originX, y); + } + + public static void close() { + popup = null; + owner = null; + } + + public static boolean isOpenFor(Element element) { + return popup != null && owner == element; + } + + // --------------------------------------------------------------- + // Called by SaturnScreen + // --------------------------------------------------------------- + + static void reset(int width, int height) { + close(); + screenWidth = width; + screenHeight = height; + } + + static void render(RenderScope renderScope, long elapsed, int mouseX, int mouseY) { + if (popup != null) { + ElementRenderer.render(List.of(popup), elapsed, renderScope, mouseX, mouseY); + } + } + + /** + * @return whether the popup took the click; a click outside it closes it + * and is taken too, so it doesn't reach what's underneath + */ + static boolean mouseClicked(double mouseX, double mouseY, int button) { + if (popup == null) { + return false; + } + + if (isInside(mouseX, mouseY)) { + ElementRenderer.mouseClicked(List.of(popup), mouseX, mouseY, button); + } else { + close(); + } + + return true; + } + + static boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + if (popup == null) { + return false; + } + + ElementRenderer.mouseDragged(List.of(popup), mouseX, mouseY, button, deltaX, deltaY); + return true; + } + + static void mouseReleased(double mouseX, double mouseY, int button) { + if (popup != null) { + ElementRenderer.mouseReleased(List.of(popup), mouseX, mouseY, button); + } + } + + /** + * @return whether the popup took the scroll; scrolling outside it closes it, + * since it wouldn't move with what's underneath + */ + static boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + if (popup == null) { + return false; + } + + if (isInside(mouseX, mouseY)) { + ElementRenderer.mouseScrolled(List.of(popup), mouseX, mouseY, horizontalAmount, verticalAmount); + } else { + close(); + } + + return true; + } + + private static boolean isInside(double mouseX, double mouseY) { + return mouseX >= popup.x && mouseX <= popup.x + popup.width + && mouseY >= popup.y && mouseY <= popup.y + popup.height; + } + + private static int clamp(int position, int size, int screenSize) { + return Math.max(SCREEN_MARGIN, Math.min(position, screenSize - SCREEN_MARGIN - size)); + } +} diff --git a/src/main/java/org/saturnclient/ui/SaturnScreen.java b/src/main/java/org/saturnclient/ui/SaturnScreen.java index 4f99701..47aa2e7 100644 --- a/src/main/java/org/saturnclient/ui/SaturnScreen.java +++ b/src/main/java/org/saturnclient/ui/SaturnScreen.java @@ -35,6 +35,7 @@ public abstract class SaturnScreen { elements.clear(); width = provider.getWidth(); height = provider.getHeight(); + Popup.reset(width, height); ui(); // abstraction to render the saturn ui and also render extra stuff here } @@ -54,6 +55,7 @@ public abstract class SaturnScreen { renderScope.getMatrixStack().scale(0.5f, 0.5f); ElementRenderer.render(new ArrayList<>(elements), elapsed, renderScope, mouseX, mouseY); + Popup.render(renderScope, elapsed, mouseX, mouseY); renderScope.getMatrixStack().pop(); } @@ -66,7 +68,9 @@ public abstract class SaturnScreen { mouseX *= 2; mouseY *= 2; - ElementRenderer.mouseClicked(elements, mouseX, mouseY, button); + if (!Popup.mouseClicked(mouseX, mouseY, button)) { + ElementRenderer.mouseClicked(elements, mouseX, mouseY, button); + } return false; } @@ -75,7 +79,9 @@ public abstract class SaturnScreen { mouseX *= 2; mouseY *= 2; - ElementRenderer.mouseDragged(elements, mouseX, mouseY, button, deltaX, deltaY); + if (!Popup.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) { + ElementRenderer.mouseDragged(elements, mouseX, mouseY, button, deltaX, deltaY); + } return false; } @@ -84,6 +90,8 @@ public abstract class SaturnScreen { mouseX *= 2; mouseY *= 2; + // Everything hears the release, so no element is left thinking it's being dragged. + Popup.mouseReleased(mouseX, mouseY, button); ElementRenderer.mouseReleased(elements, mouseX, mouseY, button); return false; @@ -97,7 +105,9 @@ public abstract class SaturnScreen { mouseX *= 2; mouseY *= 2; - ElementRenderer.mouseScrolled(elements, mouseX, mouseY, horizontalAmount, verticalAmount); + if (!Popup.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)) { + ElementRenderer.mouseScrolled(elements, mouseX, mouseY, horizontalAmount, verticalAmount); + } return false; } diff --git a/src/main/java/org/saturnclient/ui/components/ColorGrid.java b/src/main/java/org/saturnclient/ui/components/ColorGrid.java index 3f785fc..260eb8d 100644 --- a/src/main/java/org/saturnclient/ui/components/ColorGrid.java +++ b/src/main/java/org/saturnclient/ui/components/ColorGrid.java @@ -1,7 +1,5 @@ package org.saturnclient.ui.components; -import java.util.ArrayList; -import java.util.List; import java.util.Map; import org.saturnclient.config.Theme; @@ -35,7 +33,6 @@ public final class ColorGrid { */ public static int draw(Scroll scroll, Map properties, int width, int y) { int tileWidth = (width - GAP * (COLUMNS - 1)) / COLUMNS; - List inputs = new ArrayList<>(); int i = 0; for (Map.Entry entry : properties.entrySet()) { @@ -48,13 +45,10 @@ public final class ColorGrid { ColorInput input = new ColorInput(color); scroll.draw(new Tile(entry.getKey(), tileWidth, input.width).position(x, tileY)); - inputs.add(input.position(x + tileWidth - INSET - input.width, tileY + (TILE_HEIGHT - input.height) / 2)); + scroll.draw(input.position(x + tileWidth - INSET - input.width, tileY + (TILE_HEIGHT - input.height) / 2)); i++; } - // Inputs go on after every tile, so an open color picker covers the tiles below it. - inputs.forEach(scroll::draw); - int rows = (i + COLUMNS - 1) / COLUMNS; return y + rows * TILE_HEIGHT + Math.max(0, rows - 1) * GAP; } diff --git a/src/main/java/org/saturnclient/ui/components/CosmeticTile.java b/src/main/java/org/saturnclient/ui/components/CosmeticTile.java index 2d25666..f0cbfe5 100644 --- a/src/main/java/org/saturnclient/ui/components/CosmeticTile.java +++ b/src/main/java/org/saturnclient/ui/components/CosmeticTile.java @@ -100,7 +100,7 @@ public class CosmeticTile extends Element { renderScope.drawRoundedRectangle(x, y, badgeWidth, BADGE_HEIGHT, 6, background); if (check) { - renderScope.drawTexture(Textures.CHECK, x + 7, y + (BADGE_HEIGHT - icon) / 2, 0, 0, icon, icon, foreground); + renderScope.drawTexture(Textures.ICON_CHECK, x + 7, y + (BADGE_HEIGHT - icon) / 2, 0, 0, icon, icon, foreground); } int textY = y + (BADGE_HEIGHT - (int) (Fonts.getHeight() * BADGE_SCALE)) / 2; diff --git a/src/main/java/org/saturnclient/ui/components/PropertyList.java b/src/main/java/org/saturnclient/ui/components/PropertyList.java index de0a1ba..8012304 100644 --- a/src/main/java/org/saturnclient/ui/components/PropertyList.java +++ b/src/main/java/org/saturnclient/ui/components/PropertyList.java @@ -1,7 +1,5 @@ package org.saturnclient.ui.components; -import java.util.ArrayList; -import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -49,15 +47,6 @@ public final class PropertyList { * @return the y below the last row */ public static int draw(Scroll scroll, Map properties, Set skip, int width, int y) { - // Controls go on after every row, so an open color picker covers the rows below it. - List controls = new ArrayList<>(); - y = drawRows(scroll, properties, skip, width, y, controls); - controls.forEach(scroll::draw); - return y; - } - - private static int drawRows(Scroll scroll, Map properties, Set skip, int width, int y, - List controls) { for (Map.Entry entry : properties.entrySet()) { String name = entry.getKey(); Property prop = entry.getValue(); @@ -68,7 +57,7 @@ public final class PropertyList { if (prop instanceof NamespaceProperty namespace) { y = drawSection(scroll, name, y + SECTION_GAP) + 4; - y = drawRows(scroll, namespace.value, Set.of(), width, y, controls); + y = draw(scroll, namespace.value, Set.of(), width, y); continue; } @@ -78,7 +67,7 @@ public final class PropertyList { } scroll.draw(new Row(name, prop.description, width, control.width).position(0, y)); - controls.add(control.position(width - control.width, y + (ROW_HEIGHT - control.height) / 2)); + scroll.draw(control.position(width - control.width, y + (ROW_HEIGHT - control.height) / 2)); y += ROW_HEIGHT; } diff --git a/src/main/java/org/saturnclient/ui/components/inputs/ColorInput.java b/src/main/java/org/saturnclient/ui/components/inputs/ColorInput.java index f8dc4cc..c050de0 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/ColorInput.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/ColorInput.java @@ -1,206 +1,53 @@ package org.saturnclient.ui.components.inputs; -import java.awt.Color; - import org.saturnclient.config.Theme; import org.saturnclient.config.property.ColorProperty; import org.saturnclient.ui.Element; import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.Popup; import org.saturnclient.ui.RenderScope; import org.saturnclient.ui.elements.Field; +/** + * Shows a color as a swatch and its hex code, and opens a {@link ColorPicker} + * beside it when clicked. + */ public class ColorInput extends Element { + private static final int SWATCH = 20; + private static final int PICKER_GAP = 8; + public ColorProperty prop; - // Picker state - private boolean open = false; - private float hue = 0f; - private float opacity = 1f; - private int selectedX = 0; - private int selectedY = 0; - - private boolean draggingOpacity = false; - - // Dimensions - private static final int PICKER_WIDTH = 100; - private static final int PICKER_HEIGHT = 100; - private static final int SLIDER_WIDTH = 100; - private static final int SLIDER_HEIGHT = 10; - private static final int HUE_SLIDER_HEIGHT = 10; - - // The picker opens to the left, since the input sits at the right of its row. - private static final int PICKER_X = -PICKER_WIDTH - 12; - private static final int SWATCH = 20; - - // Bounds of the picker's backdrop, relative to the color square - private static final int BACKDROP_PADDING = 8; - private static final int BACKDROP_TOP = -SLIDER_HEIGHT - 10 - BACKDROP_PADDING; - private static final int BACKDROP_BOTTOM = PICKER_HEIGHT + 10 + HUE_SLIDER_HEIGHT + BACKDROP_PADDING; + /** Where this was last drawn on the screen, to open the picker beside it */ + private int originX, originY; public ColorInput(ColorProperty prop) { this.prop = prop; this.width = 116; this.height = Field.HEIGHT; - syncFromColor(); - } - - @Override - public void click(int mouseX, int mouseY) { - open = !open; - } - - @Override - public void mouseClicked(double mouseX, double mouseY, int button) { - if (!open) { - return; - } - - boolean onInput = mouseX >= 0 && mouseX <= width && mouseY >= 0 && mouseY <= height; - if (!onInput && !isOverPicker(mouseX - PICKER_X, mouseY)) { - open = false; - return; - } - - handlePickerClick(mouseX - PICKER_X, mouseY); - } - - @Override - public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { - if (open) { - handlePickerDrag(mouseX - PICKER_X, mouseY); - } - } - - @Override - public void mouseReleased(double mouseX, double mouseY, int button) { - draggingOpacity = false; } @Override public void render(RenderScope renderScope, ElementContext ctx) { - Field.drawBox(renderScope, width, height, ctx.isHovering(), open); + originX = ctx.originX; + originY = ctx.originY; - int swatchY = (height - SWATCH) / 2; - renderScope.drawRoundedRectangle(swatchY, swatchY, SWATCH, SWATCH, 5, 1, prop.value, + Field.drawBox(renderScope, width, height, ctx.isHovering(), Popup.isOpenFor(this)); + + int swatchInset = (height - SWATCH) / 2; + renderScope.drawRoundedRectangle(swatchInset, swatchInset, SWATCH, SWATCH, 5, 1, prop.value, Theme.withAlpha(0.3f, Theme.FOREGROUND.value)); - renderScope.drawText(Field.TEXT_SCALE, toHex(prop.value), swatchY + SWATCH + 8, + renderScope.drawText(Field.TEXT_SCALE, toHex(prop.value), swatchInset + SWATCH + 8, Field.textY(height, Field.TEXT_SCALE), Theme.FONT.value, Theme.FOREGROUND.value); - - if (!open) - return; - - int pickerX = PICKER_X; - int pickerY = 0; - - // Backdrop behind the picker and its two sliders - renderScope.drawRoundedRectangle(pickerX - BACKDROP_PADDING, BACKDROP_TOP, - PICKER_WIDTH + BACKDROP_PADDING * 2, BACKDROP_BOTTOM - BACKDROP_TOP, - Theme.WIDGET_RADIUS.value, 1, Theme.withAlpha(0.95f, Theme.BACKGROUND.value), Theme.getBorder()); - - renderColorSquare(renderScope, pickerX, pickerY); - renderOpacitySlider(renderScope, pickerX, pickerY - SLIDER_HEIGHT - 10); - renderHueSlider(renderScope, pickerX, pickerY + PICKER_HEIGHT + 10); } - private void renderColorSquare(RenderScope renderScope, int xStart, int yStart) { - for (int x = 0; x < PICKER_WIDTH; x++) { - for (int y = 0; y < PICKER_HEIGHT; y++) { - float saturation = x / (float) PICKER_WIDTH; - float value = 1f - y / (float) PICKER_HEIGHT; - int color = Color.HSBtoRGB(hue, saturation, value); - int alpha = (int) (opacity * 255) & 0xFF; - color = (alpha << 24) | (color & 0xFFFFFF); - renderScope.fill(xStart + x, yStart + y, xStart + x + 1, yStart + y + 1, color); - } - } - - // Draw selection square - int selX = xStart + selectedX - 4; - int selY = yStart + selectedY - 4; - renderScope.fill(selX, selY, selX + 8, selY + 8, 0xFF000000 | prop.value); - renderScope.drawBorder(selX, selY, 8, 8, 0xFFFFFFFF); - } - - private void renderOpacitySlider(RenderScope renderScope, int xStart, int yStart) { - for (int x = 0; x < SLIDER_WIDTH; x++) { - float localOpacity = x / (float) SLIDER_WIDTH; - int alpha = (int) (localOpacity * 255) & 0xFF; - int color = (alpha << 24) | (prop.value & 0xFFFFFF); - renderScope.fill(xStart + x, yStart, xStart + x + 1, yStart + SLIDER_HEIGHT, color); - } - - int knobX = xStart + (int) (opacity * SLIDER_WIDTH); - renderScope.fill(knobX - 1, yStart - 2, knobX + 1, yStart + SLIDER_HEIGHT + 2, 0xFF000000); - } - - private void renderHueSlider(RenderScope renderScope, int xStart, int yStart) { - for (int x = 0; x < SLIDER_WIDTH; x++) { - float localHue = x / (float) SLIDER_WIDTH; - int color = Color.HSBtoRGB(localHue, 1f, 1f); - renderScope.fill(xStart + x, yStart, xStart + x + 1, yStart + HUE_SLIDER_HEIGHT, 0xFF000000 | color); - } - - int knobX = xStart + (int) (hue * SLIDER_WIDTH); - renderScope.fill(knobX - 1, yStart - 2, knobX + 1, yStart + HUE_SLIDER_HEIGHT + 2, 0xFF000000); - } - - private void handlePickerClick(double mouseX, double mouseY) { - // Color square - if (mouseX >= 0 && mouseX < PICKER_WIDTH && mouseY >= 0 && mouseY < PICKER_HEIGHT) { - selectedX = (int) mouseX; - selectedY = (int) mouseY; - updateColorFromSelection(); - } - - // Opacity slider - int opY = -SLIDER_HEIGHT - 10; - if (mouseX >= 0 && mouseX < SLIDER_WIDTH && mouseY >= opY && mouseY <= opY + SLIDER_HEIGHT) { - updateOpacity(mouseX); - draggingOpacity = true; - } - - // Hue slider - int hueY = PICKER_HEIGHT + 10; - if (mouseX >= 0 && mouseX < SLIDER_WIDTH && mouseY >= hueY && mouseY <= hueY + HUE_SLIDER_HEIGHT) { - updateHue(mouseX); - } - } - - private static boolean isOverPicker(double x, double y) { - return x >= -BACKDROP_PADDING && x <= PICKER_WIDTH + BACKDROP_PADDING - && y >= BACKDROP_TOP && y <= BACKDROP_BOTTOM; - } - - private void handlePickerDrag(double mouseX, double mouseY) { - // Opacity - if (draggingOpacity) - updateOpacity(mouseX); - - // Color square - if (mouseX >= 0 && mouseX < PICKER_WIDTH && mouseY >= 0 && mouseY < PICKER_HEIGHT) { - selectedX = (int) mouseX; - selectedY = (int) mouseY; - updateColorFromSelection(); - } - } - - private void updateColorFromSelection() { - float saturation = selectedX / (float) PICKER_WIDTH; - float value = 1f - selectedY / (float) PICKER_HEIGHT; - int color = Color.HSBtoRGB(hue, saturation, value); - int alpha = (int) (opacity * 255) & 0xFF; - prop.value = (alpha << 24) | (color & 0xFFFFFF); - } - - private void updateHue(double mouseX) { - hue = (float) Math.max(0, Math.min(1, mouseX / SLIDER_WIDTH)); - updateColorFromSelection(); - } - - private void updateOpacity(double mouseX) { - opacity = (float) Math.max(0, Math.min(1, mouseX / SLIDER_WIDTH)); - updateColorFromSelection(); + @Override + public void click(int mouseX, int mouseY) { + // The input sits at the right of its row, so the picker opens to its left, + // centered on it. While it's open, a click here closes it instead. + ColorPicker picker = new ColorPicker(prop); + Popup.open(this, picker, originX - PICKER_GAP - picker.width, originY + (height - picker.height) / 2); } /** @@ -212,16 +59,4 @@ public class ColorInput extends Element { ? String.format("#%06X", color & 0xFFFFFF) : String.format("#%08X", color); } - - private void syncFromColor() { - int rgb = prop.value & 0xFFFFFF; - float[] hsb = Color.RGBtoHSB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, null); - hue = hsb[0]; - selectedX = (int) (hsb[1] * PICKER_WIDTH); - selectedY = (int) ((1f - hsb[2]) * PICKER_HEIGHT); - int alpha = (prop.value >> 24) & 0xFF; - if (alpha == 0) - alpha = 255; - opacity = alpha / 255f; - } -} \ No newline at end of file +} diff --git a/src/main/java/org/saturnclient/ui/components/inputs/ColorPicker.java b/src/main/java/org/saturnclient/ui/components/inputs/ColorPicker.java new file mode 100644 index 0000000..8c7f701 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/inputs/ColorPicker.java @@ -0,0 +1,164 @@ +package org.saturnclient.ui.components.inputs; + +import java.awt.Color; + +import org.saturnclient.config.Theme; +import org.saturnclient.config.property.ColorProperty; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; + +/** + * A color picker shown as a popup: opacity across the top, a + * saturation/brightness square, and hue across the bottom. Each part can be + * clicked or dragged. + */ +public class ColorPicker extends Element { + private enum Part { + OPACITY, SQUARE, HUE + } + + private static final int PADDING = 10; + private static final int SIZE = 100; + private static final int SLIDER_HEIGHT = 10; + private static final int GAP = 10; + + private static final int OPACITY_Y = PADDING; + private static final int SQUARE_Y = OPACITY_Y + SLIDER_HEIGHT + GAP; + private static final int HUE_Y = SQUARE_Y + SIZE + GAP; + + private final ColorProperty prop; + + private float hue; + private float saturation; + private float brightness; + private float opacity; + + /** The part being dragged, or null */ + private Part dragging = null; + + public ColorPicker(ColorProperty prop) { + this.prop = prop; + this.width = SIZE + PADDING * 2; + this.height = HUE_Y + SLIDER_HEIGHT + PADDING; + syncFromColor(); + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1, + Theme.withAlpha(0.97f, Theme.BACKGROUND.value), Theme.withAlpha(0.14f, Theme.FOREGROUND.value)); + + renderOpacity(renderScope); + renderSquare(renderScope); + renderHue(renderScope); + } + + private void renderOpacity(RenderScope renderScope) { + int rgb = prop.value & 0xFFFFFF; + for (int x = 0; x < SIZE; x++) { + int alpha = (int) (x / (float) SIZE * 255) & 0xFF; + renderScope.fill(PADDING + x, OPACITY_Y, PADDING + x + 1, OPACITY_Y + SLIDER_HEIGHT, (alpha << 24) | rgb); + } + + drawMarker(renderScope, PADDING + (int) (opacity * SIZE), OPACITY_Y); + } + + private void renderSquare(RenderScope renderScope) { + for (int x = 0; x < SIZE; x++) { + for (int y = 0; y < SIZE; y++) { + int color = Color.HSBtoRGB(hue, x / (float) SIZE, 1f - y / (float) SIZE); + renderScope.fill(PADDING + x, SQUARE_Y + y, PADDING + x + 1, SQUARE_Y + y + 1, 0xFF000000 | color); + } + } + + int selX = PADDING + (int) (saturation * SIZE) - 4; + int selY = SQUARE_Y + (int) ((1f - brightness) * SIZE) - 4; + renderScope.fill(selX, selY, selX + 8, selY + 8, 0xFF000000 | prop.value); + renderScope.drawBorder(selX, selY, 8, 8, 0xFFFFFFFF); + } + + private void renderHue(RenderScope renderScope) { + for (int x = 0; x < SIZE; x++) { + int color = Color.HSBtoRGB(x / (float) SIZE, 1f, 1f); + renderScope.fill(PADDING + x, HUE_Y, PADDING + x + 1, HUE_Y + SLIDER_HEIGHT, 0xFF000000 | color); + } + + drawMarker(renderScope, PADDING + (int) (hue * SIZE), HUE_Y); + } + + private static void drawMarker(RenderScope renderScope, int x, int sliderY) { + renderScope.fill(x - 2, sliderY - 2, x + 2, sliderY + SLIDER_HEIGHT + 2, 0xFFFFFFFF); + renderScope.fill(x - 1, sliderY - 1, x + 1, sliderY + SLIDER_HEIGHT + 1, 0xFF000000); + } + + @Override + public void mouseClicked(double mouseX, double mouseY, int button) { + dragging = getPartAt(mouseX, mouseY); + update(mouseX, mouseY); + } + + @Override + public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + update(mouseX, mouseY); + } + + @Override + public void mouseReleased(double mouseX, double mouseY, int button) { + dragging = null; + } + + private Part getPartAt(double mouseX, double mouseY) { + if (mouseX < PADDING - 4 || mouseX > PADDING + SIZE + 4) { + return null; + } + + if (mouseY >= OPACITY_Y - 4 && mouseY <= OPACITY_Y + SLIDER_HEIGHT + 4) { + return Part.OPACITY; + } else if (mouseY >= SQUARE_Y && mouseY <= SQUARE_Y + SIZE) { + return Part.SQUARE; + } else if (mouseY >= HUE_Y - 4 && mouseY <= HUE_Y + SLIDER_HEIGHT + 4) { + return Part.HUE; + } + + return null; + } + + /** + * Moves the dragged part to the mouse, even when the mouse has left it. + */ + private void update(double mouseX, double mouseY) { + if (dragging == null) { + return; + } + + float x = clamp01((float) (mouseX - PADDING) / SIZE); + + switch (dragging) { + case OPACITY -> opacity = x; + case HUE -> hue = x; + case SQUARE -> { + saturation = x; + brightness = 1f - clamp01((float) (mouseY - SQUARE_Y) / SIZE); + } + } + + int rgb = Color.HSBtoRGB(hue, saturation, brightness) & 0xFFFFFF; + prop.value = ((int) (opacity * 255) << 24) | rgb; + } + + private void syncFromColor() { + int rgb = prop.value & 0xFFFFFF; + float[] hsb = Color.RGBtoHSB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, null); + hue = hsb[0]; + saturation = hsb[1]; + brightness = hsb[2]; + + int alpha = (prop.value >>> 24) & 0xFF; + opacity = (alpha == 0 ? 255 : alpha) / 255f; + } + + private static float clamp01(float value) { + return Math.max(0f, Math.min(1f, value)); + } +} diff --git a/src/main/java/org/saturnclient/ui/components/inputs/OptionList.java b/src/main/java/org/saturnclient/ui/components/inputs/OptionList.java new file mode 100644 index 0000000..7c509fc --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/inputs/OptionList.java @@ -0,0 +1,113 @@ +package org.saturnclient.ui.components.inputs; + +import java.util.function.IntConsumer; + +import org.saturnclient.config.Theme; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.elements.Field; +import org.saturnclient.ui.resources.Fonts; +import org.saturnclient.ui.resources.Textures; + +/** + * A dropdown's list of options, shown as a popup. Long lists scroll, and the + * current option is ticked. + */ +public class OptionList extends Element { + private static final int ITEM_HEIGHT = 30; + private static final int PADDING = 4; + private static final int MAX_VISIBLE = 8; + private static final int CHECK = 14; + + private final String[] options; + private final int selected; + private final IntConsumer onPick; + + /** Index of the first visible option */ + private int first; + /** Wheel movement not yet turned into whole options, so trackpads scroll smoothly */ + private double pendingScroll = 0; + + public OptionList(String[] options, int selected, int width, IntConsumer onPick) { + this.options = options; + this.selected = selected; + this.onPick = onPick; + this.width = width; + this.height = Math.min(options.length, MAX_VISIBLE) * ITEM_HEIGHT + PADDING * 2; + + // Start with the current option in view, a few rows down if possible + this.first = clampFirst(selected - MAX_VISIBLE / 2 + 1); + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1, + Theme.withAlpha(0.97f, Theme.BACKGROUND.value), Theme.withAlpha(0.14f, Theme.FOREGROUND.value)); + + int visible = Math.min(options.length, MAX_VISIBLE); + int hovered = getIndexAt(ctx.mouseX, ctx.mouseY); + + for (int row = 0; row < visible; row++) { + int index = first + row; + int y = PADDING + row * ITEM_HEIGHT; + boolean isSelected = index == selected; + + if (index == hovered) { + renderScope.drawRoundedRectangle(PADDING, y, width - PADDING * 2, ITEM_HEIGHT, + Theme.WIDGET_RADIUS.value - 3, Theme.withAlpha(0.07f, Theme.FOREGROUND.value)); + } + + int textWidth = width - PADDING * 2 - 12 - CHECK - 10; + String text = Fonts.ellipsize(options[index], Theme.FONT.value, Field.TEXT_SCALE, textWidth); + renderScope.drawText(Field.TEXT_SCALE, text, PADDING + 10, y + Field.textY(ITEM_HEIGHT, Field.TEXT_SCALE), + Theme.FONT.value, isSelected ? Theme.ACCENT.value : Theme.FOREGROUND.value); + + if (isSelected) { + renderScope.drawTexture(Textures.ICON_CHECK, width - PADDING - 10 - CHECK, + y + (ITEM_HEIGHT - CHECK) / 2, 0, 0, CHECK, CHECK, Theme.ACCENT.value); + } + } + + // A thin bar showing where in a long list this is + if (options.length > MAX_VISIBLE) { + int track = height - PADDING * 2; + int thumb = Math.max(16, track * MAX_VISIBLE / options.length); + int thumbY = PADDING + (track - thumb) * first / (options.length - MAX_VISIBLE); + renderScope.drawRoundedRectangle(width - 4, thumbY, 2, thumb, 1, + Theme.withAlpha(0.3f, Theme.FOREGROUND.value)); + } + } + + @Override + public void click(int mouseX, int mouseY) { + int index = getIndexAt(mouseX, mouseY); + if (index >= 0) { + onPick.accept(index); + } + } + + @Override + public void scroll(int mouseX, int mouseY, double horizontalAmount, double verticalAmount) { + pendingScroll -= verticalAmount; + int whole = (int) pendingScroll; + pendingScroll -= whole; + first = clampFirst(first + whole); + } + + /** + * The option under the mouse, or -1. + */ + private int getIndexAt(int mouseX, int mouseY) { + if (mouseX < PADDING || mouseX > width - PADDING || mouseY < PADDING || mouseY >= height - PADDING) { + return -1; + } + + int index = first + (mouseY - PADDING) / ITEM_HEIGHT; + return index < options.length ? index : -1; + } + + private int clampFirst(int value) { + return Math.max(0, Math.min(value, options.length - Math.min(options.length, MAX_VISIBLE))); + } +} diff --git a/src/main/java/org/saturnclient/ui/components/inputs/Select.java b/src/main/java/org/saturnclient/ui/components/inputs/Select.java index d6faa39..fd67806 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/Select.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/Select.java @@ -4,21 +4,26 @@ import org.saturnclient.config.Theme; import org.saturnclient.config.property.SelectProperty; import org.saturnclient.ui.Element; import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.Popup; import org.saturnclient.ui.RenderScope; import org.saturnclient.ui.elements.Field; import org.saturnclient.ui.resources.Fonts; import org.saturnclient.ui.resources.Textures; /** - * Steps through a {@link SelectProperty}'s values with arrows on either side. - * Clicking the left third goes back; anywhere else goes forward. + * A dropdown for a {@link SelectProperty}: the current value, opening a list + * of every value when clicked. */ public class Select extends Element { + private static final int TEXT_X = 12; private static final int ARROW = 16; - private static final int ARROW_INSET = 8; + private static final int ARROW_INSET = 10; private final SelectProperty prop; + /** Where this was last drawn on the screen, to open the list under it */ + private int originX, originY; + public Select(SelectProperty prop) { this.prop = prop; this.width = 200; @@ -27,28 +32,29 @@ public class Select extends Element { @Override public void render(RenderScope renderScope, ElementContext ctx) { - Field.drawBox(renderScope, width, height, ctx.isHovering(), false); + originX = ctx.originX; + originY = ctx.originY; + boolean open = Popup.isOpenFor(this); - boolean prevHovered = ctx.isHovering(0, 0, width / 3, height); - boolean nextHovered = ctx.isHovering() && !prevHovered; - int arrowY = (height - ARROW) / 2; + Field.drawBox(renderScope, width, height, ctx.isHovering(), open); - renderScope.drawTexture(Textures.ICON_CHEVRON_LEFT, ARROW_INSET, arrowY, 0, 0, ARROW, ARROW, - prevHovered ? Theme.ACCENT.value : Theme.getMutedFg()); - renderScope.drawTexture(Textures.ICON_CHEVRON_RIGHT, width - ARROW_INSET - ARROW, arrowY, 0, 0, ARROW, ARROW, - nextHovered ? Theme.ACCENT.value : Theme.getMutedFg()); + int textWidth = width - TEXT_X - ARROW_INSET - ARROW - 8; + String text = Fonts.ellipsize(prop.getSelection(), Theme.FONT.value, Field.TEXT_SCALE, textWidth); + renderScope.drawText(Field.TEXT_SCALE, text, TEXT_X, Field.textY(height, Field.TEXT_SCALE), Theme.FONT.value, + Theme.FOREGROUND.value); - int textSpace = width - (ARROW_INSET + ARROW + 4) * 2; - String text = Fonts.ellipsize(prop.getSelection(), Theme.FONT.value, Field.TEXT_SCALE, textSpace); - Field.drawCenteredText(renderScope, text, width, height, Theme.FONT.value, Theme.FOREGROUND.value); + renderScope.drawTexture(Textures.ICON_CHEVRON_DOWN, width - ARROW_INSET - ARROW, (height - ARROW) / 2, 0, 0, + ARROW, ARROW, open ? Theme.ACCENT.value : Theme.getMutedFg()); } @Override public void click(int mouseX, int mouseY) { - if (mouseX < width / 3) { - prop.prev(); - } else { - prop.next(); - } + // While the list is open, a click here goes to the popup, which closes it. + OptionList list = new OptionList(prop.availableValues, prop.value, width, index -> { + prop.setSelection(index); + Popup.close(); + }); + + Popup.openBelow(this, list, originX, originY); } } diff --git a/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java b/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java index 0a8f123..edbd87f 100644 --- a/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java +++ b/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java @@ -41,7 +41,9 @@ public class AnimationStagger extends Element { public void render(RenderScope renderScope, ElementContext ctx) { ElementRenderer.render(children, ctx.elapsed, renderScope, ctx.mouseX, - ctx.mouseY); + ctx.mouseY, + ctx.originX, + ctx.originY); } @Override diff --git a/src/main/java/org/saturnclient/ui/elements/Scroll.java b/src/main/java/org/saturnclient/ui/elements/Scroll.java index a9ddef1..d7d6940 100644 --- a/src/main/java/org/saturnclient/ui/elements/Scroll.java +++ b/src/main/java/org/saturnclient/ui/elements/Scroll.java @@ -80,7 +80,9 @@ public class Scroll extends Element { ElementRenderer.render(children, ctx.elapsed, renderScope, ctx.mouseX - padding, - ctx.mouseY - padding + offset); + ctx.mouseY - padding + offset, + ctx.originX + padding, + ctx.originY + padding - offset); renderScope.getMatrixStack().pop(); renderScope.disableScissor(); diff --git a/src/main/java/org/saturnclient/ui/resources/Textures.java b/src/main/java/org/saturnclient/ui/resources/Textures.java index 04878c8..8372396 100644 --- a/src/main/java/org/saturnclient/ui/resources/Textures.java +++ b/src/main/java/org/saturnclient/ui/resources/Textures.java @@ -41,6 +41,9 @@ public class Textures { .ofSaturn("textures/gui/components/chevron_left.svg"); public static final IdentifierRef ICON_CHEVRON_RIGHT = IdentifierRef .ofSaturn("textures/gui/components/chevron_right.svg"); + public static final IdentifierRef ICON_CHEVRON_DOWN = IdentifierRef + .ofSaturn("textures/gui/components/chevron_down.svg"); + public static final IdentifierRef ICON_CHECK = IdentifierRef.ofSaturn("textures/gui/components/check.svg"); public static final IdentifierRef LOGO = IdentifierRef.ofSaturn("textures/logo/logo.png"); public static final IdentifierRef REALISTIC_LOGO = IdentifierRef.ofSaturn("textures/logo/realistic.png"); diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/check.svg b/src/main/resources/assets/saturnclient/textures/gui/components/check.svg new file mode 100644 index 0000000..6bb8a38 --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/check.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/chevron_down.svg b/src/main/resources/assets/saturnclient/textures/gui/components/chevron_down.svg new file mode 100644 index 0000000..7ac2e2a --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/chevron_down.svg @@ -0,0 +1 @@ + -- 2.54.0 From 8b58fe919d4b34bf76a6f283869da3a9641eb07c Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 18:05:26 +0200 Subject: [PATCH 10/13] Open dropdowns within the visible part of their list ElementContext carries the part of the screen where an element can be seen, passed down in a Frame and narrowed by each Scroll to its own box. A dropdown opens below only if its list fits in that part, otherwise above if it fits there, or else on the side with more room, so one at the bottom of a scrolled list no longer opens past the list's edge. Co-Authored-By: Claude Opus 5.5 --- .../org/saturnclient/ui/ElementContext.java | 36 +++++++++++++++++-- .../org/saturnclient/ui/ElementRenderer.java | 9 +++-- src/main/java/org/saturnclient/ui/Popup.java | 25 +++++++------ .../ui/components/inputs/Select.java | 9 +++-- .../ui/elements/AnimationStagger.java | 3 +- .../org/saturnclient/ui/elements/Scroll.java | 3 +- 6 files changed, 58 insertions(+), 27 deletions(-) diff --git a/src/main/java/org/saturnclient/ui/ElementContext.java b/src/main/java/org/saturnclient/ui/ElementContext.java index c9c3638..d78f6cf 100644 --- a/src/main/java/org/saturnclient/ui/ElementContext.java +++ b/src/main/java/org/saturnclient/ui/ElementContext.java @@ -1,24 +1,54 @@ package org.saturnclient.ui; public class ElementContext { + /** + * Where a container's children are drawn: the screen position of their + * (0, 0), and the part of the screen, top to bottom, where they can be seen. + */ + public record Frame(int x, int y, int visibleTop, int visibleBottom) { + /** The whole screen, for top-level elements */ + public static final Frame SCREEN = new Frame(0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE); + } + public int mouseX, mouseY; /** The element's top-left corner on the screen, for placing popups next to it */ public int originX, originY; + /** The part of the screen, top to bottom, where the element can be seen */ + public int visibleTop, visibleBottom; public int elementWidth, elementHeight; public float elementScale; public long elapsed; - public ElementContext(long elapsed, int mouseX, int mouseY, int parentX, int parentY, Element e) { + public ElementContext(long elapsed, int mouseX, int mouseY, Frame frame, Element e) { this.mouseX = mouseX - e.x; this.mouseY = mouseY - e.y; - this.originX = parentX + e.x; - this.originY = parentY + e.y; + this.originX = frame.x() + e.x; + this.originY = frame.y() + e.y; + this.visibleTop = frame.visibleTop(); + this.visibleBottom = frame.visibleBottom(); this.elementWidth = e.width; this.elementHeight = e.height; this.elementScale = e.scale; this.elapsed = elapsed; } + /** + * The frame for this element's children, drawn {@code dx}, {@code dy} from + * its corner and seen only between {@code clipTop} and {@code clipBottom} + * (relative to its corner). + */ + public Frame childFrame(int dx, int dy, int clipTop, int clipBottom) { + return new Frame(originX + dx, originY + dy, + Math.max(visibleTop, originY + clipTop), Math.min(visibleBottom, originY + clipBottom)); + } + + /** + * The frame for children drawn at this element's corner and not clipped. + */ + public Frame childFrame() { + return new Frame(originX, originY, visibleTop, visibleBottom); + } + public boolean isHovering() { return Utils.isHovering(mouseX, mouseY, elementWidth, elementHeight, elementScale); } diff --git a/src/main/java/org/saturnclient/ui/ElementRenderer.java b/src/main/java/org/saturnclient/ui/ElementRenderer.java index 4dade83..ec2cd96 100644 --- a/src/main/java/org/saturnclient/ui/ElementRenderer.java +++ b/src/main/java/org/saturnclient/ui/ElementRenderer.java @@ -15,15 +15,14 @@ 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); + render(elements, elapsed, renderScope, mouseX, mouseY, ElementContext.Frame.SCREEN); } /** - * Renders elements whose parent's top-left corner is at - * ({@code parentX}, {@code parentY}) on the screen. + * Renders a container's children within its {@code frame}. */ public static void render(List elements, long elapsed, RenderScope renderScope, int mouseX, int mouseY, - int parentX, int parentY) { + ElementContext.Frame frame) { for (Element element : elements) { element.playAnimationFrame(elapsed); @@ -31,7 +30,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, parentX, parentY, element)); + element.render(renderScope, new ElementContext(elapsed, mouseX, mouseY, frame, element)); renderScope.getMatrixStack().pop(); } } diff --git a/src/main/java/org/saturnclient/ui/Popup.java b/src/main/java/org/saturnclient/ui/Popup.java index 14859ea..c487e4f 100644 --- a/src/main/java/org/saturnclient/ui/Popup.java +++ b/src/main/java/org/saturnclient/ui/Popup.java @@ -37,20 +37,25 @@ public final class Popup { } /** - * Opens {@code popup} just below its owner, or above it when there isn't - * room below. + * Opens {@code popup} just below its owner if it fits in the part of the + * screen where the owner can be seen, such as a scroll's box; otherwise + * above it if it fits there, or else on the side with more room. * - * @param originX the owner's top-left corner on the screen - * @param originY + * @param ownerContext the owner's context from its last render */ - public static void openBelow(Element owner, Element popup, int originX, int originY) { - int y = originY + owner.height + OWNER_GAP; + public static void openBelow(Element owner, Element popup, ElementContext ownerContext) { + int top = Math.max(ownerContext.visibleTop, SCREEN_MARGIN); + int bottom = Math.min(ownerContext.visibleBottom, screenHeight - SCREEN_MARGIN); - if (y + popup.height > screenHeight - SCREEN_MARGIN) { - y = originY - OWNER_GAP - popup.height; - } + int below = ownerContext.originY + owner.height + OWNER_GAP; + int above = ownerContext.originY - OWNER_GAP - popup.height; - open(owner, popup, originX, y); + boolean fitsBelow = below + popup.height <= bottom; + boolean fitsAbove = above >= top; + boolean moreRoomBelow = bottom - below >= ownerContext.originY - OWNER_GAP - top; + + int y = fitsBelow || (!fitsAbove && moreRoomBelow) ? below : above; + open(owner, popup, ownerContext.originX, y); } public static void close() { 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 fd67806..e5f6cfc 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/Select.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/Select.java @@ -21,8 +21,8 @@ public class Select extends Element { private final SelectProperty prop; - /** Where this was last drawn on the screen, to open the list under it */ - private int originX, originY; + /** The context this was last drawn with, to open the list next to it */ + private ElementContext lastContext; public Select(SelectProperty prop) { this.prop = prop; @@ -32,8 +32,7 @@ public class Select extends Element { @Override public void render(RenderScope renderScope, ElementContext ctx) { - originX = ctx.originX; - originY = ctx.originY; + lastContext = ctx; boolean open = Popup.isOpenFor(this); Field.drawBox(renderScope, width, height, ctx.isHovering(), open); @@ -55,6 +54,6 @@ public class Select extends Element { Popup.close(); }); - Popup.openBelow(this, list, originX, originY); + Popup.openBelow(this, list, lastContext); } } diff --git a/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java b/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java index edbd87f..80dba99 100644 --- a/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java +++ b/src/main/java/org/saturnclient/ui/elements/AnimationStagger.java @@ -42,8 +42,7 @@ public class AnimationStagger extends Element { ElementRenderer.render(children, ctx.elapsed, renderScope, ctx.mouseX, ctx.mouseY, - ctx.originX, - ctx.originY); + ctx.childFrame()); } @Override diff --git a/src/main/java/org/saturnclient/ui/elements/Scroll.java b/src/main/java/org/saturnclient/ui/elements/Scroll.java index d7d6940..4726555 100644 --- a/src/main/java/org/saturnclient/ui/elements/Scroll.java +++ b/src/main/java/org/saturnclient/ui/elements/Scroll.java @@ -81,8 +81,7 @@ public class Scroll extends Element { ElementRenderer.render(children, ctx.elapsed, renderScope, ctx.mouseX - padding, ctx.mouseY - padding + offset, - ctx.originX + padding, - ctx.originY + padding - offset); + ctx.childFrame(padding, padding - offset, padding, height - padding)); renderScope.getMatrixStack().pop(); renderScope.disableScissor(); -- 2.54.0 From 55b78989ce55a517d226c4661c148a1b499e21e3 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 18:12:30 +0200 Subject: [PATCH 11/13] Group animation settings per menu The Animations section shows each menu's duration, stagger and curve under the menu's name instead of one flat list of 18 rows. AnimationConfig keeps its name and lists every menu's config; it's saved under the same keys as before, so existing settings keep loading. Co-Authored-By: Claude Opus 5.5 --- .../saturnclient/config/AnimationConfig.java | 26 +++++++++++++++++++ .../ui/screens/ClientSettings.java | 14 +++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/saturnclient/config/AnimationConfig.java b/src/main/java/org/saturnclient/config/AnimationConfig.java index 180673b..32861de 100644 --- a/src/main/java/org/saturnclient/config/AnimationConfig.java +++ b/src/main/java/org/saturnclient/config/AnimationConfig.java @@ -1,5 +1,9 @@ package org.saturnclient.config; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + import org.saturnclient.config.property.IntProperty; import org.saturnclient.config.property.Property; import org.saturnclient.config.property.SelectProperty; @@ -25,6 +29,10 @@ public class AnimationConfig { // Emote wheel public static final AnimationConfig emoteWheel = new AnimationConfig(true, 700, 40); + /** Every menu's animation, in the order they're shown in the settings */ + public static final List ALL = List.of(logo, mainMenu, modMenu, shiftMenu, cosmeticsMenu, + emoteWheel); + public static void init(ConfigManager parent) { config = new ConfigManager(parent, "Animations"); @@ -47,7 +55,25 @@ public class AnimationConfig { this.curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back"); } + /** The menu this animates, like "Mod Menu" */ + public String name; + + /** + * This animation's settings under short names ("Duration"), for showing + * them grouped under {@link #name}. They're saved under longer names + * ("Mod Menu Duration"), which stay as they are so saved settings keep + * loading. + */ + public Map getProperties() { + Map properties = new LinkedHashMap<>(); + properties.put("Duration", duration); + properties.put("Stagger", stagger); + properties.put("Curve", curve); + return properties; + } + public void init(String name) { + this.name = name; // config.property("Enable " + name + " Animations", this.enabled); config.property(name + " Duration", this.duration, "How long the animation takes"); config.property(name + " Stagger", this.stagger, "Delay between items appearing one after another"); diff --git a/src/main/java/org/saturnclient/ui/screens/ClientSettings.java b/src/main/java/org/saturnclient/ui/screens/ClientSettings.java index db3fd4e..e6cfbb3 100644 --- a/src/main/java/org/saturnclient/ui/screens/ClientSettings.java +++ b/src/main/java/org/saturnclient/ui/screens/ClientSettings.java @@ -92,7 +92,7 @@ public class ClientSettings extends PanelScreen { switch (section) { case GENERAL -> PropertyList.draw(scroll, getGeneralProperties(), Set.of(), listWidth, 0); case UI_STYLE -> drawUiStyle(scroll, listWidth); - case ANIMATIONS -> PropertyList.draw(scroll, AnimationConfig.config.getProperties(), Set.of(), listWidth, 0); + case ANIMATIONS -> drawAnimations(scroll, listWidth); } draw(scroll.dimensions(scrollWidth(width), contentHeight - LIST_Y).position(x, contentY + LIST_Y)); @@ -118,6 +118,18 @@ public class ClientSettings extends PanelScreen { PropertyList.draw(scroll, properties, getNames(properties, p -> p instanceof ColorProperty), width, y); } + /** + * Each menu's animation settings under the menu's name. + */ + private static void drawAnimations(Scroll scroll, int width) { + int y = 0; + + for (AnimationConfig animation : AnimationConfig.ALL) { + y = PropertyList.drawSection(scroll, animation.name, y) + 4; + y = PropertyList.draw(scroll, animation.getProperties(), Set.of(), width, y) + SECTION_GAP; + } + } + /** * The top-level settings. The namespaces have sections of their own. */ -- 2.54.0 From 85c7057ba18150a23f39df4228ab70bc36b1323a Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 18:19:18 +0200 Subject: [PATCH 12/13] Preview easing curves beside curve dropdowns CurveProperty holds the easing curves in the order Animation.getCurve takes them, replacing three copies of the list in zoom, freelook and the animation config; it's saved the same way. Its dropdown opens a CurveOptions popup: a CurvePreview graph beside the list, with a dot running along the hovered curve, or the current one. Co-Authored-By: Claude Opus 5.5 --- .../saturnclient/config/AnimationConfig.java | 6 +- .../config/property/CurveProperty.java | 12 +++ .../config/property/Property.java | 4 + .../saturnclient/mod/mods/FreelookMod.java | 4 +- .../org/saturnclient/mod/mods/ZoomMod.java | 5 +- src/main/java/org/saturnclient/ui/Popup.java | 10 ++- .../ui/components/inputs/CurveOptions.java | 52 +++++++++++ .../ui/components/inputs/CurvePreview.java | 89 +++++++++++++++++++ .../ui/components/inputs/OptionList.java | 11 ++- .../ui/components/inputs/Select.java | 9 +- 10 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/saturnclient/config/property/CurveProperty.java create mode 100644 src/main/java/org/saturnclient/ui/components/inputs/CurveOptions.java create mode 100644 src/main/java/org/saturnclient/ui/components/inputs/CurvePreview.java diff --git a/src/main/java/org/saturnclient/config/AnimationConfig.java b/src/main/java/org/saturnclient/config/AnimationConfig.java index 32861de..3f429fb 100644 --- a/src/main/java/org/saturnclient/config/AnimationConfig.java +++ b/src/main/java/org/saturnclient/config/AnimationConfig.java @@ -4,9 +4,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.saturnclient.config.property.CurveProperty; import org.saturnclient.config.property.IntProperty; import org.saturnclient.config.property.Property; -import org.saturnclient.config.property.SelectProperty; public class AnimationConfig { public static ConfigManager config; @@ -47,12 +47,12 @@ public class AnimationConfig { // Animation properties public final IntProperty duration; public final IntProperty stagger; - public final SelectProperty curve; + public final CurveProperty curve; public AnimationConfig(boolean enabled, int delay, int stagger) { this.duration = Property.integer(delay).range(0, 2000, 50).unit("ms"); this.stagger = Property.integer(stagger).range(0, 300, 10).unit("ms"); - this.curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back"); + this.curve = Property.curve(1); } /** The menu this animates, like "Mod Menu" */ diff --git a/src/main/java/org/saturnclient/config/property/CurveProperty.java b/src/main/java/org/saturnclient/config/property/CurveProperty.java new file mode 100644 index 0000000..12818d7 --- /dev/null +++ b/src/main/java/org/saturnclient/config/property/CurveProperty.java @@ -0,0 +1,12 @@ +package org.saturnclient.config.property; + +/** + * An animation's easing curve. The options are in the order + * {@code Animation.getCurve} takes them, and it's saved like any select. + */ +public class CurveProperty extends SelectProperty { + + public CurveProperty(int defaultValue) { + super(defaultValue, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back"); + } +} diff --git a/src/main/java/org/saturnclient/config/property/Property.java b/src/main/java/org/saturnclient/config/property/Property.java index 09820dd..c380a7b 100644 --- a/src/main/java/org/saturnclient/config/property/Property.java +++ b/src/main/java/org/saturnclient/config/property/Property.java @@ -44,6 +44,10 @@ public abstract class Property { return select(value, "Default", "Inter", "Inter bold"); } + public static CurveProperty curve(int value) { + return new CurveProperty(value); + } + public static SelectProperty select(Integer value, String... availableValues) { return new SelectProperty(value, availableValues); } diff --git a/src/main/java/org/saturnclient/mod/mods/FreelookMod.java b/src/main/java/org/saturnclient/mod/mods/FreelookMod.java index c4bba29..2e48d8e 100644 --- a/src/main/java/org/saturnclient/mod/mods/FreelookMod.java +++ b/src/main/java/org/saturnclient/mod/mods/FreelookMod.java @@ -5,11 +5,11 @@ import java.util.function.Function; import org.saturnclient.common.provider.Providers; import org.saturnclient.common.provider.GLFWProvider; import org.saturnclient.config.property.BoolProperty; +import org.saturnclient.config.property.CurveProperty; import org.saturnclient.config.property.FloatProperty; import org.saturnclient.config.property.IntProperty; import org.saturnclient.config.property.KeybindingProperty; import org.saturnclient.config.property.Property; -import org.saturnclient.config.property.SelectProperty; import org.saturnclient.mod.Mod; import org.saturnclient.mod.ModSpec; import org.saturnclient.common.feature.RenderFeature; @@ -25,7 +25,7 @@ public class FreelookMod extends Mod { public static final FloatProperty zoomLevel = Property.floatProp(2.0f).range(1f, 5f, 0.1f).unit("×"); public static final IntProperty zoomInDuration = Property.integer(500).range(0, 2000, 50).unit("ms"); public static final IntProperty zoomOutDuration = Property.integer(300).range(0, 2000, 50).unit("ms"); - public static final SelectProperty curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back"); + public static final CurveProperty curve = Property.curve(1); // --- State Variables --- private static boolean isFreeLooking = false; diff --git a/src/main/java/org/saturnclient/mod/mods/ZoomMod.java b/src/main/java/org/saturnclient/mod/mods/ZoomMod.java index a0f0768..c403a34 100644 --- a/src/main/java/org/saturnclient/mod/mods/ZoomMod.java +++ b/src/main/java/org/saturnclient/mod/mods/ZoomMod.java @@ -4,11 +4,11 @@ import java.util.function.Function; import org.saturnclient.common.provider.GLFWProvider; import org.saturnclient.config.property.BoolProperty; +import org.saturnclient.config.property.CurveProperty; import org.saturnclient.config.property.FloatProperty; import org.saturnclient.config.property.IntProperty; import org.saturnclient.config.property.KeybindingProperty; import org.saturnclient.config.property.Property; -import org.saturnclient.config.property.SelectProperty; import org.saturnclient.mod.Mod; import org.saturnclient.mod.ModSpec; import org.saturnclient.ui.anim.Animation; @@ -21,8 +21,7 @@ public class ZoomMod extends Mod { public static final FloatProperty zoomLevel = Property.floatProp(3.0f).range(1f, 10f, 0.5f).unit("×"); public static final IntProperty zoomInDuration = Property.integer(700).range(0, 2000, 50).unit("ms"); public static final IntProperty zoomOutDuration = Property.integer(300).range(0, 2000, 50).unit("ms"); - public static final SelectProperty curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", - "Ease In Out Back"); + public static final CurveProperty curve = Property.curve(1); public ZoomMod() { super( diff --git a/src/main/java/org/saturnclient/ui/Popup.java b/src/main/java/org/saturnclient/ui/Popup.java index c487e4f..ab92e19 100644 --- a/src/main/java/org/saturnclient/ui/Popup.java +++ b/src/main/java/org/saturnclient/ui/Popup.java @@ -44,6 +44,14 @@ public final class Popup { * @param ownerContext the owner's context from its last render */ public static void openBelow(Element owner, Element popup, ElementContext ownerContext) { + openBelow(owner, popup, ownerContext, 0); + } + + /** + * Like {@link #openBelow(Element, Element, ElementContext)}, with the popup + * moved {@code offsetX} from the owner's left edge. + */ + public static void openBelow(Element owner, Element popup, ElementContext ownerContext, int offsetX) { int top = Math.max(ownerContext.visibleTop, SCREEN_MARGIN); int bottom = Math.min(ownerContext.visibleBottom, screenHeight - SCREEN_MARGIN); @@ -55,7 +63,7 @@ public final class Popup { boolean moreRoomBelow = bottom - below >= ownerContext.originY - OWNER_GAP - top; int y = fitsBelow || (!fitsAbove && moreRoomBelow) ? below : above; - open(owner, popup, ownerContext.originX, y); + open(owner, popup, ownerContext.originX + offsetX, y); } public static void close() { diff --git a/src/main/java/org/saturnclient/ui/components/inputs/CurveOptions.java b/src/main/java/org/saturnclient/ui/components/inputs/CurveOptions.java new file mode 100644 index 0000000..29a7f3d --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/inputs/CurveOptions.java @@ -0,0 +1,52 @@ +package org.saturnclient.ui.components.inputs; + +import java.util.List; + +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.ElementRenderer; +import org.saturnclient.ui.RenderScope; + +/** + * A curve dropdown's popup: a {@link CurvePreview} beside the list of curves, + * showing the hovered curve, or the current one when none is hovered. + */ +public class CurveOptions extends Element { + public static final int GAP = 8; + + private final List children; + + public CurveOptions(OptionList list, int current) { + CurvePreview preview = new CurvePreview(() -> list.getHovered() >= 0 ? list.getHovered() : current); + + preview.position(0, 0); + list.position(preview.width + GAP, 0); + this.children = List.of(preview, list); + + this.width = list.x + list.width; + this.height = Math.max(preview.height, list.height); + } + + /** + * How far left of the list this popup starts, so the list can line up + * under the dropdown. + */ + public int getListX() { + return children.get(1).x; + } + + @Override + public void render(RenderScope renderScope, ElementContext ctx) { + ElementRenderer.render(children, ctx.elapsed, renderScope, ctx.mouseX, ctx.mouseY, ctx.childFrame()); + } + + @Override + public void mouseClicked(double mouseX, double mouseY, int button) { + ElementRenderer.mouseClicked(children, mouseX, mouseY, button); + } + + @Override + public void scroll(int mouseX, int mouseY, double horizontalAmount, double verticalAmount) { + ElementRenderer.mouseScrolled(children, mouseX, mouseY, horizontalAmount, verticalAmount); + } +} diff --git a/src/main/java/org/saturnclient/ui/components/inputs/CurvePreview.java b/src/main/java/org/saturnclient/ui/components/inputs/CurvePreview.java new file mode 100644 index 0000000..a5f2b45 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/inputs/CurvePreview.java @@ -0,0 +1,89 @@ +package org.saturnclient.ui.components.inputs; + +import java.util.function.Function; +import java.util.function.IntSupplier; + +import org.saturnclient.config.Theme; +import org.saturnclient.ui.Element; +import org.saturnclient.ui.ElementContext; +import org.saturnclient.ui.RenderScope; +import org.saturnclient.ui.anim.Animation; + +/** + * A graph of an easing curve, with a dot running along it on a loop to show + * how the animation moves. + */ +public class CurvePreview extends Element { + private static final int PADDING = 14; + private static final int TITLE_HEIGHT = 20; + private static final float TITLE_SCALE = 0.56f; + private static final int DOT = 7; + + /** How long the dot takes to run the curve, then how long it rests at the end, in ms */ + private static final int RUN = 1200; + private static final int REST = 500; + + private final IntSupplier curve; + + /** + * @param curve the curve to show, as an index {@link Animation#getCurve} + * takes; read every frame + */ + public CurvePreview(IntSupplier curve) { + this.curve = curve; + this.width = 190; + this.height = 150; + } + + @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)); + + renderScope.drawText(TITLE_SCALE, "PREVIEW", PADDING, PADDING - 2, Theme.FONT.value, Theme.getMutedFg()); + + Function ease = Animation.getCurve(curve.getAsInt()); + + int graphX = PADDING; + int graphY = PADDING + TITLE_HEIGHT; + int graphWidth = width - PADDING * 2; + int graphHeight = height - PADDING * 2 - TITLE_HEIGHT; + + // Curves like "back" overshoot, so fit the graph to the curve's own range. + double min = 0, max = 1; + for (int i = 0; i <= graphWidth; i++) { + double value = ease.apply(i / (double) graphWidth); + min = Math.min(min, value); + max = Math.max(max, value); + } + + double range = max - min; + int startY = toY(0, min, range, graphY, graphHeight); + int endY = toY(1, min, range, graphY, graphHeight); + + // Where the animation starts and ends + int guide = Theme.withAlpha(0.1f, Theme.FOREGROUND.value); + renderScope.fill(graphX, startY, graphX + graphWidth, startY + 1, guide); + renderScope.fill(graphX, endY, graphX + graphWidth, endY + 1, guide); + + // The curve, as small squares close enough to read as a line + int steps = graphWidth * 3; + for (int i = 0; i <= steps; i++) { + double t = i / (double) steps; + int x = graphX + (int) (t * graphWidth); + int y = toY(ease.apply(t), min, range, graphY, graphHeight); + renderScope.fill(x - 1, y - 1, x + 1, y + 1, Theme.ACCENT.value); + } + + // The dot, looping along it + double t = Math.min(1.0, (ctx.elapsed % (RUN + REST)) / (double) RUN); + int dotX = graphX + (int) (t * graphWidth); + int dotY = toY(ease.apply(t), min, range, graphY, graphHeight); + renderScope.drawRoundedRectangle(dotX - DOT / 2, dotY - DOT / 2, DOT, DOT, DOT / 2, 2, + Theme.FOREGROUND.value, Theme.BACKGROUND.value); + } + + private static int toY(double value, double min, double range, int graphY, int graphHeight) { + return graphY + graphHeight - (int) ((value - min) / range * graphHeight); + } +} diff --git a/src/main/java/org/saturnclient/ui/components/inputs/OptionList.java b/src/main/java/org/saturnclient/ui/components/inputs/OptionList.java index 7c509fc..83a73d7 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/OptionList.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/OptionList.java @@ -28,6 +28,8 @@ public class OptionList extends Element { private int first; /** Wheel movement not yet turned into whole options, so trackpads scroll smoothly */ private double pendingScroll = 0; + /** The option under the mouse on the last frame, or -1 */ + private int hovered = -1; public OptionList(String[] options, int selected, int width, IntConsumer onPick) { this.options = options; @@ -46,7 +48,7 @@ public class OptionList extends Element { 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); + hovered = getIndexAt(ctx.mouseX, ctx.mouseY); for (int row = 0; row < visible; row++) { int index = first + row; @@ -95,6 +97,13 @@ public class OptionList extends Element { first = clampFirst(first + whole); } + /** + * The option under the mouse on the last frame, or -1. + */ + public int getHovered() { + return hovered; + } + /** * The option under the mouse, or -1. */ 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 e5f6cfc..09ba70e 100644 --- a/src/main/java/org/saturnclient/ui/components/inputs/Select.java +++ b/src/main/java/org/saturnclient/ui/components/inputs/Select.java @@ -1,6 +1,7 @@ package org.saturnclient.ui.components.inputs; import org.saturnclient.config.Theme; +import org.saturnclient.config.property.CurveProperty; import org.saturnclient.config.property.SelectProperty; import org.saturnclient.ui.Element; import org.saturnclient.ui.ElementContext; @@ -54,6 +55,12 @@ public class Select extends Element { Popup.close(); }); - Popup.openBelow(this, list, lastContext); + if (prop instanceof CurveProperty) { + // The preview goes left of the list, so the list still lines up under this. + CurveOptions options = new CurveOptions(list, prop.value); + Popup.openBelow(this, options, lastContext, -options.getListX()); + } else { + Popup.openBelow(this, list, lastContext); + } } } -- 2.54.0 From c3667f8f66bd82453b62a04704006682fbf41509 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 27 Sep 2026 18:26:42 +0200 Subject: [PATCH 13/13] Make the background blur adjustable A "Background blur" setting (0-10, default 5) sets how much the game behind Saturn's screens is blurred. Screens now only say whether to blur, and SaturnScreen.getBackgroundBlur gives the setting's strength, faded in over the screen's first 700 ms. The title menu still doesn't blur. Co-Authored-By: Claude Opus 5.5 --- .../java/org/saturnclient/config/Config.java | 3 +++ .../org/saturnclient/ui/SaturnScreen.java | 22 ++++++++++++++++++- .../saturnclient/ui/screens/TitleMenu.java | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/saturnclient/config/Config.java b/src/main/java/org/saturnclient/config/Config.java index 5a4e6d2..01a0ed2 100644 --- a/src/main/java/org/saturnclient/config/Config.java +++ b/src/main/java/org/saturnclient/config/Config.java @@ -6,6 +6,7 @@ import org.saturnclient.common.provider.GLFWProvider; import org.saturnclient.common.provider.Providers; import org.saturnclient.common.ref.asset.IdentifierRef; import org.saturnclient.config.property.BoolProperty; +import org.saturnclient.config.property.IntProperty; import org.saturnclient.config.property.KeybindingProperty; import org.saturnclient.config.property.Property; import org.saturnclient.ui.resources.Textures; @@ -17,6 +18,7 @@ public class Config { public static BoolProperty saturnTitleScreen = Property.bool(true); public static BoolProperty cloakPhysics = Property.bool(true); public static BoolProperty stagger = Property.bool(true); + public static IntProperty backgroundBlur = Property.integer(5).range(0, 10); public static KeybindingProperty openEmoteWheel = Property.keybinding(GLFWProvider.GLFW_KEY_B); public static IdentifierRef getLogo() { @@ -32,6 +34,7 @@ public class Config { config.property("Open Emote Wheel", openEmoteWheel, "The key that opens the emote wheel"); config.property("Cloak Physics", cloakPhysics, "Let cloaks swing as you move"); config.property("Stagger Animations", stagger, "Animate list items one after another"); + config.property("Background blur", backgroundBlur, "How much the game behind Saturn's screens is blurred"); // Initialize a sub namespace for theme Theme.init(config); diff --git a/src/main/java/org/saturnclient/ui/SaturnScreen.java b/src/main/java/org/saturnclient/ui/SaturnScreen.java index 47aa2e7..aea1cfe 100644 --- a/src/main/java/org/saturnclient/ui/SaturnScreen.java +++ b/src/main/java/org/saturnclient/ui/SaturnScreen.java @@ -1,10 +1,16 @@ package org.saturnclient.ui; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; +import org.saturnclient.config.Config; + public abstract class SaturnScreen { + /** How long the background blur takes to fade in, in ms */ + private static final int BLUR_FADE_IN = 700; + public static interface ScreenProvider { public void close(); @@ -22,7 +28,8 @@ public abstract class SaturnScreen { public List elements = new ArrayList<>(); public float backgroundOpacity = 1.0f; public Instant start = null; - public int backgroundBlur = 10; + /** Whether the game behind this screen is blurred, by the client's background blur setting */ + public boolean blurBackground = true; // Provider public ScreenProvider provider; @@ -31,6 +38,19 @@ public abstract class SaturnScreen { this.title = title; } + /** + * How much to blur the game behind this screen right now: the client's + * setting, faded in over the screen's first moments. + */ + public int getBackgroundBlur() { + if (!blurBackground || start == null) { + return 0; + } + + long elapsed = Duration.between(start, Instant.now()).toMillis(); + return Math.round(Config.backgroundBlur.value * Math.min(elapsed / (float) BLUR_FADE_IN, 1f)); + } + public void init() { elements.clear(); width = provider.getWidth(); diff --git a/src/main/java/org/saturnclient/ui/screens/TitleMenu.java b/src/main/java/org/saturnclient/ui/screens/TitleMenu.java index bb61896..d134334 100644 --- a/src/main/java/org/saturnclient/ui/screens/TitleMenu.java +++ b/src/main/java/org/saturnclient/ui/screens/TitleMenu.java @@ -22,7 +22,7 @@ public class TitleMenu extends SaturnScreen { @Override public void ui() { - backgroundBlur = 0; + blurBackground = false; draw(new ImageTexture(Textures.LOGO_TEXT).dimensions(98, 10).center(width, height, 0, -40) .animation(new Fade(AnimationConfig.logo.duration.value))); -- 2.54.0