Redesign the mod menu, settings and cosmetics screens #6

Merged
selimaj-dev merged 13 commits from ui-redesign into master 2026-09-27 16:27:35 +00:00
100 changed files with 3483 additions and 1410 deletions
@@ -1,8 +1,12 @@
package org.saturnclient.config;
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;
@@ -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<AnimationConfig> ALL = List.of(logo, mainMenu, modMenu, shiftMenu, cosmeticsMenu,
emoteWheel);
public static void init(ConfigManager parent) {
config = new ConfigManager(parent, "Animations");
@@ -39,18 +47,36 @@ 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);
this.stagger = Property.integer(stagger);
this.curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back");
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.curve(1);
}
/** 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<String, Property> getProperties() {
Map<String, Property> 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);
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");
}
}
@@ -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() {
@@ -27,11 +29,12 @@ 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");
config.property("Background blur", backgroundBlur, "How much the game behind Saturn's screens is blurred");
// Initialize a sub namespace for theme
Theme.init(config);
@@ -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
*/
@@ -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);
@@ -28,26 +32,55 @@ 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);
/**
* 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<Preset> 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.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 = new ConfigManager(parent, "UI Style");
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");
}
/**
@@ -64,6 +97,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.
*/
@@ -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);
@@ -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(
@@ -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");
}
}
@@ -7,11 +7,42 @@ 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;
this.isReset = true;
}
@Override
public JsonNode toJson() {
return JsonNodeFactory.instance.numberNode(this.value);
@@ -7,11 +7,46 @@ 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;
this.isReset = true;
}
@Override
public JsonNode toJson() {
return JsonNodeFactory.instance.numberNode(this.value);
@@ -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);
@@ -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;
}
}
@@ -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();
@@ -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<String, Property> value) {
@@ -41,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);
}
@@ -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);
@@ -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);
@@ -1,5 +1,6 @@
package org.saturnclient.mod;
import java.util.LinkedHashMap;
import java.util.Map;
import org.saturnclient.config.property.Property;
@@ -12,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;
@@ -23,14 +24,22 @@ 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<String, Property> props = new LinkedHashMap<>();
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<String, Property> props, String name, Property prop, String description) {
prop.description = description;
props.put(name, prop);
}
public ModLayout() {
@@ -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());
}
@@ -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());
}
@@ -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());
}
@@ -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;
@@ -22,10 +22,10 @@ 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 SelectProperty curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back");
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 CurveProperty curve = Property.curve(1);
// --- State Variables ---
private static boolean isFreeLooking = false;
@@ -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 ---
@@ -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(
@@ -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"));
}
// ---------------------------------------------------------------
@@ -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());
}
@@ -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;
}
@@ -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"));
}
// ---------------------------------------------------------------
@@ -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());
}
@@ -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;
@@ -18,11 +18,10 @@ 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 SelectProperty curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic",
"Ease In Out Back");
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 CurveProperty curve = Property.curve(1);
public ZoomMod() {
super(
@@ -31,12 +30,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"));
}
// ---------------------------------------------------------------
@@ -1,20 +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, 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 = 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);
}
@@ -15,6 +15,14 @@ public class ElementRenderer {
}
public static void render(List<Element> elements, long elapsed, RenderScope renderScope, int mouseX, int mouseY) {
render(elements, elapsed, renderScope, mouseX, mouseY, ElementContext.Frame.SCREEN);
}
/**
* Renders a container's children within its {@code frame}.
*/
public static void render(List<Element> elements, long elapsed, RenderScope renderScope, int mouseX, int mouseY,
ElementContext.Frame frame) {
for (Element element : elements) {
element.playAnimationFrame(elapsed);
@@ -22,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, element));
element.render(renderScope, new ElementContext(elapsed, mouseX, mouseY, frame, element));
renderScope.getMatrixStack().pop();
}
}
@@ -0,0 +1,82 @@
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;
/**
* 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;
/** 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;
/** 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();
/**
* 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;
}
}
@@ -0,0 +1,153 @@
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 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 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);
int below = ownerContext.originY + owner.height + OWNER_GAP;
int above = ownerContext.originY - OWNER_GAP - popup.height;
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 + offsetX, 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));
}
}
@@ -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(
"<svg xmlns='http://www.w3.org/2000/svg' width='%d' height='%d'>" +
"<rect width='%d' height='%d' rx='%d' ry='%d' fill='white'/>" +
"</svg>",
width, height,
String rect = String.format(
"<rect width='%d' height='%d' rx='%d' ry='%d' fill='white'/>",
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,
"<rect x='%.1f' y='%.1f' width='%.1f' height='%.1f' rx='%.1f' ry='%.1f' " +
"fill='none' stroke='white' stroke-width='%d'/>",
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(
"<svg xmlns='http://www.w3.org/2000/svg' width='%d' height='%d'>%s</svg>",
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);
}
@@ -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<Element> 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,10 +38,24 @@ 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();
height = provider.getHeight();
Popup.reset(width, height);
ui(); // abstraction to render the saturn ui and also render extra stuff here
}
@@ -54,6 +75,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 +88,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 +99,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 +110,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 +125,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;
}
@@ -0,0 +1,79 @@
package org.saturnclient.ui.components;
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<String, Property> properties, int width, int y) {
int tileWidth = (width - GAP * (COLUMNS - 1)) / COLUMNS;
int i = 0;
for (Map.Entry<String, Property> 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));
scroll.draw(input.position(x + tileWidth - INSET - input.width, tileY + (TILE_HEIGHT - input.height) / 2));
i++;
}
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);
}
}
}
@@ -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();
}
}
}
@@ -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.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;
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;
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -0,0 +1,154 @@
package org.saturnclient.ui.components;
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.Slider;
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<String, Property> properties, Set<String> skip, int width, int y) {
for (Map.Entry<String, Property> entry : properties.entrySet()) {
String name = entry.getKey();
Property prop = entry.getValue();
if (skip.contains(name)) {
continue;
}
if (prop instanceof NamespaceProperty namespace) {
y = drawSection(scroll, name, y + SECTION_GAP) + 4;
y = draw(scroll, namespace.value, Set.of(), width, y);
continue;
}
Element control = createControl(prop);
if (control == null) {
continue;
}
scroll.draw(new Row(name, prop.description, width, control.width).position(0, y));
scroll.draw(control.position(width - control.width, y + (ROW_HEIGHT - control.height) / 2));
y += ROW_HEIGHT;
}
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).
*/
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 p.hasRange() ? Slider.of(p) : new IntInput(p);
} else if (prop instanceof FloatProperty 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) {
return new KeybindingSelector(p);
}
return null;
}
/**
* 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, String description, int width, int controlWidth) {
this.name = name;
this.description = description;
this.controlWidth = controlWidth;
this.width = width;
this.height = ROW_HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
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.drawRectangle(0, height - 1, width, 1, Theme.withAlpha(0.06f, Theme.FOREGROUND.value));
}
}
}
@@ -2,165 +2,119 @@ 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;
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;
import org.saturnclient.ui.screens.ModSettings;
/**
* 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 (mod instanceof HudMod h) {
Providers.saturn.getClient().setScreen(new ConfigEditor(mod.getConfig(), h));
} else {
Providers.saturn.getClient().setScreen(new ConfigEditor(mod.getConfig()));
}
if (settingsClicked) {
Providers.saturn.getClient().setScreen(new ModSettings(mod));
} else {
mod.setEnabled(!mod.isEnabled());
}
}
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;
}
}
}
@@ -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<Item> items;
private final int active;
private final IntConsumer onSelect;
public SectionNav(String heading, List<Item> 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);
}
}
@@ -2,56 +2,51 @@ 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.Utils;
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;
import org.saturnclient.ui.screens.store.CloakStore;
import org.saturnclient.ui.screens.CosmeticsMenu;
/**
* 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 CosmeticsMenu())),
new Item(Textures.ICON_HUD_EDITOR, () -> Providers.saturn.getClient().setScreen(new HudEditor())),
new Item(Textures.ICON_SETTINGS, () -> Providers.saturn.getClient().setScreen(new ClientSettings())),
};
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 +54,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;
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -1,183 +1,62 @@
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;
/** 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 = 58;
this.height = 20;
syncFromColor();
}
@Override
public void click(int mouseX, int mouseY) {
open = !open;
}
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
if (open) {
handlePickerClick(mouseX - width - 8, mouseY);
}
}
@Override
public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
if (open) {
handlePickerDrag(mouseX - width - 8, mouseY);
}
}
@Override
public void mouseReleased(double mouseX, double mouseY, int button) {
draggingOpacity = false;
this.width = 116;
this.height = Field.HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
// Draw the main color rectangle
renderScope.drawRoundedRectangle(0, 0, width, height, 6, prop.value);
originX = ctx.originX;
originY = ctx.originY;
if (!open)
return;
Field.drawBox(renderScope, width, height, ctx.isHovering(), Popup.isOpenFor(this));
// Draw the picker relative to (width + 8, 0)
int pickerX = width + 8;
int pickerY = 0;
int swatchInset = (height - SWATCH) / 2;
renderScope.drawRoundedRectangle(swatchInset, swatchInset, SWATCH, SWATCH, 5, 1, prop.value,
Theme.withAlpha(0.3f, Theme.FOREGROUND.value));
renderColorSquare(renderScope, pickerX, pickerY);
renderOpacitySlider(renderScope, pickerX, pickerY - SLIDER_HEIGHT - 10);
renderHueSlider(renderScope, pickerX, pickerY + PICKER_HEIGHT + 10);
renderScope.drawText(Field.TEXT_SCALE, toHex(prop.value), swatchInset + SWATCH + 8,
Field.textY(height, Field.TEXT_SCALE), Theme.FONT.value, Theme.FOREGROUND.value);
}
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);
@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);
}
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);
/**
* #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 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 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();
}
private void syncFromColor() {
int rgb = prop.value & 0xFFFFFF;
float[] hsb = Color.RGBtoHSB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, null);
hue = hsb[0];
selectedX = (int) (hsb[1] * PICKER_WIDTH);
selectedY = (int) ((1f - hsb[2]) * PICKER_HEIGHT);
int alpha = (prop.value >> 24) & 0xFF;
if (alpha == 0)
alpha = 255;
opacity = alpha / 255f;
}
}
}
@@ -0,0 +1,164 @@
package org.saturnclient.ui.components.inputs;
import java.awt.Color;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.ColorProperty;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
/**
* A color picker shown as a popup: opacity across the top, a
* saturation/brightness square, and hue across the bottom. Each part can be
* clicked or dragged.
*/
public class ColorPicker extends Element {
private enum Part {
OPACITY, SQUARE, HUE
}
private static final int PADDING = 10;
private static final int SIZE = 100;
private static final int SLIDER_HEIGHT = 10;
private static final int GAP = 10;
private static final int OPACITY_Y = PADDING;
private static final int SQUARE_Y = OPACITY_Y + SLIDER_HEIGHT + GAP;
private static final int HUE_Y = SQUARE_Y + SIZE + GAP;
private final ColorProperty prop;
private float hue;
private float saturation;
private float brightness;
private float opacity;
/** The part being dragged, or null */
private Part dragging = null;
public ColorPicker(ColorProperty prop) {
this.prop = prop;
this.width = SIZE + PADDING * 2;
this.height = HUE_Y + SLIDER_HEIGHT + PADDING;
syncFromColor();
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.97f, Theme.BACKGROUND.value), Theme.withAlpha(0.14f, Theme.FOREGROUND.value));
renderOpacity(renderScope);
renderSquare(renderScope);
renderHue(renderScope);
}
private void renderOpacity(RenderScope renderScope) {
int rgb = prop.value & 0xFFFFFF;
for (int x = 0; x < SIZE; x++) {
int alpha = (int) (x / (float) SIZE * 255) & 0xFF;
renderScope.fill(PADDING + x, OPACITY_Y, PADDING + x + 1, OPACITY_Y + SLIDER_HEIGHT, (alpha << 24) | rgb);
}
drawMarker(renderScope, PADDING + (int) (opacity * SIZE), OPACITY_Y);
}
private void renderSquare(RenderScope renderScope) {
for (int x = 0; x < SIZE; x++) {
for (int y = 0; y < SIZE; y++) {
int color = Color.HSBtoRGB(hue, x / (float) SIZE, 1f - y / (float) SIZE);
renderScope.fill(PADDING + x, SQUARE_Y + y, PADDING + x + 1, SQUARE_Y + y + 1, 0xFF000000 | color);
}
}
int selX = PADDING + (int) (saturation * SIZE) - 4;
int selY = SQUARE_Y + (int) ((1f - brightness) * SIZE) - 4;
renderScope.fill(selX, selY, selX + 8, selY + 8, 0xFF000000 | prop.value);
renderScope.drawBorder(selX, selY, 8, 8, 0xFFFFFFFF);
}
private void renderHue(RenderScope renderScope) {
for (int x = 0; x < SIZE; x++) {
int color = Color.HSBtoRGB(x / (float) SIZE, 1f, 1f);
renderScope.fill(PADDING + x, HUE_Y, PADDING + x + 1, HUE_Y + SLIDER_HEIGHT, 0xFF000000 | color);
}
drawMarker(renderScope, PADDING + (int) (hue * SIZE), HUE_Y);
}
private static void drawMarker(RenderScope renderScope, int x, int sliderY) {
renderScope.fill(x - 2, sliderY - 2, x + 2, sliderY + SLIDER_HEIGHT + 2, 0xFFFFFFFF);
renderScope.fill(x - 1, sliderY - 1, x + 1, sliderY + SLIDER_HEIGHT + 1, 0xFF000000);
}
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
dragging = getPartAt(mouseX, mouseY);
update(mouseX, mouseY);
}
@Override
public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
update(mouseX, mouseY);
}
@Override
public void mouseReleased(double mouseX, double mouseY, int button) {
dragging = null;
}
private Part getPartAt(double mouseX, double mouseY) {
if (mouseX < PADDING - 4 || mouseX > PADDING + SIZE + 4) {
return null;
}
if (mouseY >= OPACITY_Y - 4 && mouseY <= OPACITY_Y + SLIDER_HEIGHT + 4) {
return Part.OPACITY;
} else if (mouseY >= SQUARE_Y && mouseY <= SQUARE_Y + SIZE) {
return Part.SQUARE;
} else if (mouseY >= HUE_Y - 4 && mouseY <= HUE_Y + SLIDER_HEIGHT + 4) {
return Part.HUE;
}
return null;
}
/**
* Moves the dragged part to the mouse, even when the mouse has left it.
*/
private void update(double mouseX, double mouseY) {
if (dragging == null) {
return;
}
float x = clamp01((float) (mouseX - PADDING) / SIZE);
switch (dragging) {
case OPACITY -> opacity = x;
case HUE -> hue = x;
case SQUARE -> {
saturation = x;
brightness = 1f - clamp01((float) (mouseY - SQUARE_Y) / SIZE);
}
}
int rgb = Color.HSBtoRGB(hue, saturation, brightness) & 0xFFFFFF;
prop.value = ((int) (opacity * 255) << 24) | rgb;
}
private void syncFromColor() {
int rgb = prop.value & 0xFFFFFF;
float[] hsb = Color.RGBtoHSB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, null);
hue = hsb[0];
saturation = hsb[1];
brightness = hsb[2];
int alpha = (prop.value >>> 24) & 0xFF;
opacity = (alpha == 0 ? 255 : alpha) / 255f;
}
private static float clamp01(float value) {
return Math.max(0f, Math.min(1f, value));
}
}
@@ -0,0 +1,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<Element> 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);
}
}
@@ -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<Double, Double> 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);
}
}
@@ -1,17 +1,29 @@
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.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);
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;
}
@@ -5,12 +5,18 @@ 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 {
String text = "";
public int cursorPosition = 0;
/** Scale the text is drawn at */
protected float textScale = Field.TEXT_SCALE;
/** Horizontal inset of the text on both sides */
protected int textX = 10;
public abstract void checkReset();
public abstract void backspace();
@@ -41,6 +47,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 +55,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;
@@ -64,28 +71,44 @@ 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(0.6f, visibleText, 4, 4, Theme.FONT.value, Theme.PRIMARY_FG.value);
renderScope.drawText(textScale, visibleText, textX, textY, Theme.FONT.value, Theme.FOREGROUND.value);
if (focused) {
int cursorX = 4 +
(int) (Fonts.getWidth(visibleText.substring(0, cursorPosition - scrollOffset), Theme.FONT.value)
* 0.6f);
renderScope.drawRectangle(cursorX, 2, 1, height - 4, Theme.ACCENT.value);
renderScope.drawRectangle(getCursorX(visibleText, scrollOffset), textY - 1, 1, textHeight + 2,
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 +125,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
}
}
@@ -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);
}
@@ -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 ? "<NONE>"
: name != null ? name.toUpperCase() : "<Invalid-Key>",
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
@@ -0,0 +1,122 @@
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;
/** 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;
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);
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 on the last frame, or -1.
*/
public int getHovered() {
return hovered;
}
/**
* 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)));
}
}
@@ -0,0 +1,64 @@
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.elements.Field;
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<String> onChange;
public SearchInput(String placeholder, Consumer<String> onChange) {
this.placeholder = placeholder;
this.onChange = onChange;
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) {
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());
if (text.isEmpty() && !focused) {
renderScope.drawText(textScale, placeholder, textX, Field.textY(height, textScale),
Theme.getBodyFont(), Theme.getMutedFg());
return;
}
drawText(renderScope);
}
}
@@ -1,40 +1,66 @@
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;
import org.saturnclient.ui.Popup;
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;
/**
* A dropdown for a {@link SelectProperty}: the current value, opening a list
* of every value when clicked.
*/
public class Select extends Element {
private SelectProperty prop;
private static final int TEXT_X = 12;
private static final int ARROW = 16;
private static final int ARROW_INSET = 10;
private final SelectProperty prop;
/** The context this was last drawn with, to open the list next to it */
private ElementContext lastContext;
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);
lastContext = ctx;
boolean open = Popup.isOpenFor(this);
String text = prop.getSelection();
Field.drawBox(renderScope, width, height, ctx.isHovering(), open);
renderScope.drawText(0.6f, text, (int) (width - ((float) Fonts.getWidth(text, Theme.FONT.value) * 0.6f)) / 2, 1,
Theme.FONT.value, Theme.FOREGROUND.value);
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);
renderScope.drawText(0.8f, ">", width - 10, 0, 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 (Utils.isHovering(mouseX - (width - 10), mouseY, 10, height, 1.0f)) {
prop.next();
} else if (Utils.isHovering(mouseX, mouseY, 10, height, 1.0f)) {
prop.prev();
// 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();
});
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);
}
}
}
@@ -0,0 +1,138 @@
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 static final int MAX_DECIMALS = 3;
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;
/**
* @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 = Double.parseDouble(step);
this.unit = unit;
this.value = value;
this.onChange = onChange;
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, 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, Float.toString(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;
}
}
@@ -7,20 +7,37 @@ 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);
}
/**
* 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
@@ -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();
}
}
@@ -41,7 +41,8 @@ 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.childFrame());
}
@Override
@@ -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<String> options;
private final Consumer<String> onSelect;
private String selected;
public ChipGroup(List<String> options, String selected, Consumer<String> 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;
}
}
@@ -0,0 +1,23 @@
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 line filling the element: horizontal by default, or vertical when
* given a width of 1 and a height.
*/
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, height, Theme.getBorder());
}
}
@@ -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);
}
}
@@ -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<String> text;
private final float textScale;
private final IntSupplier font;
private final IntSupplier color;
public Label(Supplier<String> 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());
}
}
@@ -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());
}
}
@@ -4,23 +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<Element> 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;
@@ -30,84 +47,57 @@ 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();
updateMaxScroll();
int offset = getOffset();
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();
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,
ctx.childFrame(padding, padding - offset, padding, height - padding));
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
@@ -117,17 +107,89 @@ public class Scroll extends Element {
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
ElementRenderer.mouseClicked(children, mouseX - padding, mouseY - padding + scroll, 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;
}
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));
}
}
@@ -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();
}
}
@@ -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++;
}
}
}
@@ -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;
}
@@ -25,6 +25,26 @@ 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 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");
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");
public static final IdentifierRef LOGO_TEXT = IdentifierRef.ofSaturn("textures/logo/text.png");
@@ -0,0 +1,163 @@
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 Section section = Section.GENERAL;
public ClientSettings() {
super("Settings", Sidebar.SETTINGS);
}
@Override
protected void panel() {
List<SectionNav.Item> 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();
int listWidth = scrollContentWidth(width);
switch (section) {
case GENERAL -> PropertyList.draw(scroll, getGeneralProperties(), Set.of(), listWidth, 0);
case UI_STYLE -> drawUiStyle(scroll, listWidth);
case ANIMATIONS -> drawAnimations(scroll, listWidth);
}
draw(scroll.dimensions(scrollWidth(width), contentHeight - LIST_Y).position(x, contentY + LIST_Y));
}
private void drawUiStyle(Scroll scroll, int width) {
Map<String, Property> 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);
}
/**
* 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.
*/
private static Map<String, Property> 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<String> getNames(Map<String, Property> properties, Predicate<Property> 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<String, Property> properties = switch (section) {
case GENERAL -> getGeneralProperties();
case UI_STYLE -> Theme.config.getProperties();
case ANIMATIONS -> AnimationConfig.config.getProperties();
};
properties.values().forEach(Property::reset);
init();
}
}
@@ -1,173 +0,0 @@
package org.saturnclient.ui.screens;
import java.util.Map;
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.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.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;
public ConfigEditor(ConfigManager config) {
super("Config Editor");
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;
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(1, this.provider::close).center(width, height, -(scrollWidth / 2 + 20), 0));
}
private int drawProperties(Scroll configScroll, Map<String, Property> properties, int row, int col, int w) {
for (Map.Entry<String, Property> 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<String, Property> 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);
}
}
@@ -0,0 +1,232 @@
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<List<String>> owned;
final Function<SaturnPlayer, String> equipped;
final Consumer<String> equip;
final Consumer<String> claim;
final Function<String, IdentifierRef> 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<List<String>> owned,
Function<SaturnPlayer, String> equipped, Consumer<String> equip, Consumer<String> claim,
Function<String, IdentifierRef> 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<String> ids() {
List<String> 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<String, String> 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 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(scrollContentWidth(width)));
}
draw(scroll.dimensions(scrollWidth(width), 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<String> 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());
}
}
}
@@ -1,56 +1,147 @@
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.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.ImageTexture;
import org.saturnclient.ui.elements.ChipGroup;
import org.saturnclient.ui.elements.Label;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.resources.Textures;
import org.saturnclient.ui.resources.Fonts;
public class ModMenu extends PanelScreen {
private static final String ALL = "All";
private static final int COLUMNS = 3;
private static final int CARD_GAP = 10;
private String category = ALL;
private String query = "";
private Scroll scroll;
private int gridWidth;
public class ModMenu extends SaturnScreen {
public ModMenu() {
super("Mod Menu");
super("Mod Menu", Sidebar.MODS);
}
@Override
public void ui() {
int p = 10;
int g = 10;
protected void panel() {
// The search box starts empty after a resize, so the query does too.
query = "";
gridWidth = scrollContentWidth(contentWidth);
Scroll scroll = new Scroll(p);
// Header
draw(new Label(() -> "Mods", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value)
.position(contentX, contentY + 2));
int row = 0;
int col = 0;
draw(new Label(this::getEnabledSummary, 0.66f, Theme::getBodyFont, Theme::getMutedFg)
.position(contentX, contentY + 28));
AnimationStagger moduleStagger = new AnimationStagger(AnimationConfig.modMenu);
SearchInput search = new SearchInput("Search mods", text -> {
query = text;
refresh(false);
});
draw(search.position(contentX + contentWidth - search.width, contentY + 2));
// Categories
int chipsY = contentY + 56;
ChipGroup categories = new ChipGroup(getCategories(), category, selected -> {
category = selected;
refresh(false);
});
draw(categories.position(contentX, chipsY));
// Mods
int scrollY = chipsY + categories.height + 14;
scroll = new Scroll(0).noBackground();
scroll.dimensions(scrollWidth(contentWidth), panelY + panelHeight - 12 - scrollY);
refresh(true);
draw(scroll.position(contentX, scrollY));
}
/**
* Fills the grid with the mods that match the category and search query.
*/
private void refresh(boolean animate) {
scroll.clear();
List<Mod> 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<String> getCategories() {
List<String> categories = new ArrayList<>();
categories.add(ALL);
for (Mod mod : ModManager.MODS) {
moduleStagger.draw(
new SaturnModule(mod)
.position((160 + g) * col, (50 + g) * row)
.animation(new SlideFade(AnimationConfig.modMenu, -12)));
if (col == 2) {
col = 0;
row++;
} else {
col++;
for (String tag : mod.getTags()) {
if (!categories.contains(tag)) {
categories.add(tag);
}
}
}
scroll.draw(moduleStagger);
return categories;
}
int scrollWidth = 480 + 10 + (g * 2) + (p * 2);
draw(scroll.dimensions(scrollWidth, 350).center(width, height));
draw(new Sidebar(0, this.provider::close).center(width, height, -(scrollWidth / 2 + 20), 0));
draw(new ImageTexture(Textures.LOGO_TEXT_BIG).dimensions(180, 18).position(width - 180 - 20, height - 18 - 20));
private String getEnabledSummary() {
long enabled = ModManager.MODS.stream().filter(Mod::isEnabled).count();
return enabled + " of " + ModManager.MODS.size() + " enabled";
}
}
@@ -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 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), scrollContentWidth(listWidth),
0);
draw(scroll.dimensions(scrollWidth(listWidth), 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<String, Property> entry : mod.getConfig().getProperties().entrySet()) {
if (!entry.getKey().equals(ENABLED)) {
entry.getValue().reset();
}
}
init();
}
}
@@ -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)));
@@ -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() {
@@ -23,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)));
@@ -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)));
@@ -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(2, this.provider::close)
.center(width, height, -(scrollWidth / 2 + 20), 0));
draw(new TabMenu(0,
new TabMenuComponent(Textures.CLOAK, () -> {
}),
new TabMenuComponent(Textures.HAT, () -> {
Providers.saturn.getClient().setScreen(new HatMenu());
}))
.center(width, height, 0, -195));
}
}
@@ -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(2, this.provider::close)
.center(width, height, -(scrollWidth / 2 + 20), 0));
draw(new TabMenu(1,
new TabMenuComponent(Textures.CLOAK, () -> {
Providers.saturn.getClient().setScreen(new CloakMenu());
}),
new TabMenuComponent(Textures.HAT, () -> {
}))
.center(width, height, 0, -195));
}
}
@@ -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(5, this.provider::close)
.center(width, height, -((scrollWidth - 30) / 2 + 20), 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");
}
}
}
@@ -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(5, this.provider::close)
.center(width, height, -((scrollWidth - 30) / 2 + 20), 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");
}
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>

After

Width:  |  Height:  |  Size: 317 B

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

After

Width:  |  Height:  |  Size: 210 B

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

After

Width:  |  Height:  |  Size: 207 B

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

After

Width:  |  Height:  |  Size: 209 B

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

After

Width:  |  Height:  |  Size: 208 B

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

After

Width:  |  Height:  |  Size: 227 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z"/></svg>

After

Width:  |  Height:  |  Size: 372 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 9-3 3 3 3"/><path d="m9 5 3-3 3 3"/><path d="m15 19-3 3-3-3"/><path d="m19 9 3 3-3 3"/><path d="M2 12h20"/><path d="M12 2v20"/></svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg>

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r="1.5"/><circle cx="17.5" cy="10.5" r="1.5"/><circle cx="8.5" cy="7.5" r="1.5"/><circle cx="6.5" cy="12.5" r="1.5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>

After

Width:  |  Height:  |  Size: 555 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>

After

Width:  |  Height:  |  Size: 264 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.34-4.34"/></svg>

After

Width:  |  Height:  |  Size: 242 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 791 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 4h-7"/><path d="M10 4H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 20h-5"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/></svg>

After

Width:  |  Height:  |  Size: 361 B

@@ -1,7 +1 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
␍<g id="SVGRepo_bgCarrier" stroke-width="0"/>
␍<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
␍<g id="SVGRepo_iconCarrier"> <path d="M12 7V12L10.5 14.5M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </g>
␍</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>

Before

Width:  |  Height:  |  Size: 681 B

After

Width:  |  Height:  |  Size: 238 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus-icon lucide-plus"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M22 12h-4"/><path d="M6 12H2"/><path d="M12 6V2"/><path d="M12 22v-4"/></svg>

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 295 B

@@ -1,7 +1 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="64px" height="64px" viewBox="0 -0.5 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
␍<g id="SVGRepo_bgCarrier" stroke-width="0"/>
␍<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
␍<g id="SVGRepo_iconCarrier"> <path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 7V17C3.5 18.1046 4.39543 19 5.5 19H19.5C20.6046 19 21.5 18.1046 21.5 17V7C21.5 5.89543 20.6046 5 19.5 5H5.5C4.39543 5 3.5 5.89543 3.5 7Z" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M15.5 10H18.5" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M15.5 13H18.5" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 10C11.5 11.1046 10.6046 12 9.5 12C8.39543 12 7.5 11.1046 7.5 10C7.5 8.89543 8.39543 8 9.5 8C10.0304 8 10.5391 8.21071 10.9142 8.58579C11.2893 8.96086 11.5 9.46957 11.5 10Z" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M5.5 16C8.283 12.863 11.552 13.849 13.5 16" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> </g>
␍</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="5" rx="2"/><circle cx="8" cy="12" r="2"/><path d="M14 10h4"/><path d="M14 14h4"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 302 B

@@ -1,7 +1 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 76 76" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" enable-background="new 0 0 76.00 76.00" xml:space="preserve" fill="#000000">
␍<g id="SVGRepo_bgCarrier" stroke-width="0"/>
␍<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
␍<g id="SVGRepo_iconCarrier"> <path fill="#ffffff" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 41,35L 41,57L 35,57L 35,35L 41,35 Z M 33,44L 33,57L 27,57L 27,44L 33,44 Z M 19,57L 19,51L 25,51L 25,57L 19,57 Z M 57,19L 57,57L 51,57L 51,19L 57,19 Z M 49,27L 49,57L 43,57L 43,27L 49,27 Z "/> </g>
␍</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20h.01"/><path d="M7 20v-4"/><path d="M12 20v-8"/><path d="M17 20V8"/><path d="M22 4v16"/></svg>

Before

Width:  |  Height:  |  Size: 874 B

After

Width:  |  Height:  |  Size: 285 B

@@ -1,35 +1 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1167.000000pt" height="1280.000000pt" viewBox="0 0 1167.000000 1280.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.15, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1280.000000) scale(0.100000,-0.100000)"
fill="white" stroke="none">
<path d="M9270 12789 c-229 -29 -473 -124 -657 -256 -272 -194 -468 -494 -539
-825 -25 -114 -25 -369 -1 -491 94 -472 445 -861 918 -1021 167 -56 260 -70
459 -70 199 0 292 14 459 70 400 135 727 449 864 829 50 141 70 257 71 425 1
176 -11 259 -59 406 -165 505 -614 864 -1166 934 -96 12 -254 11 -349 -1z"/>
<path d="M5145 10889 c-80 -12 -193 -69 -252 -127 -42 -41 -1667 -2180 -1780
-2342 -24 -35 -54 -97 -69 -143 -23 -69 -27 -100 -27 -197 -1 -103 2 -124 29
-200 70 -200 212 -329 394 -362 128 -22 284 28 378 123 27 27 397 512 823
1078 l774 1030 443 0 443 1 -19 -27 c-11 -16 -769 -1026 -1684 -2246 l-1664
-2219 -1215 -1 -1214 -2 -68 -27 c-93 -38 -164 -87 -239 -166 -323 -343 -232
-974 171 -1186 134 -70 29 -66 1631 -66 1549 0 1541 0 1705 51 124 38 264 118
331 190 18 19 321 405 674 859 354 454 648 831 655 838 10 11 162 -154 768
-832 417 -465 757 -852 757 -860 0 -9 -140 -728 -311 -1598 -223 -1137 -312
-1611 -316 -1681 -21 -363 196 -688 508 -762 302 -71 612 129 718 465 11 36
180 886 376 1890 195 1004 376 1926 401 2049 40 196 45 239 46 355 0 197 -40
357 -124 486 -27 42 -731 859 -1325 1537 l-55 62 728 968 c400 532 728 969
729 971 1 1 211 -240 467 -535 256 -296 489 -560 517 -586 63 -59 178 -115
260 -127 144 -20 301 37 430 156 98 91 1610 1844 1649 1912 57 100 77 181 76
317 0 87 -5 137 -18 180 -49 163 -154 293 -292 361 -77 39 -79 39 -193 39
-104 -1 -122 -3 -174 -27 -32 -15 -77 -41 -100 -58 -24 -17 -328 -358 -677
-760 -349 -401 -638 -728 -641 -727 -4 1 -354 402 -780 891 -440 506 -796 907
-827 931 -30 24 -80 55 -111 71 -127 62 -38 58 -1401 60 -685 1 -1272 -2
-1305 -7z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.8 19.6A2 2 0 1 0 14 16H2"/><path d="M17.5 8a2.5 2.5 0 1 1 2 4H2"/><path d="M9.8 4.4A2 2 0 1 1 11 8H2"/></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 299 B

@@ -1,7 +1 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="64px" height="64px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="#000000">
␍<g id="SVGRepo_bgCarrier" stroke-width="0"/>
␍<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
␍<g id="SVGRepo_iconCarrier"> <title>performance_line</title> <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="Development" transform="translate(-864.000000, 0.000000)" fill-rule="nonzero"> <g id="performance_line" transform="translate(864.000000, 0.000000)"> <path d="M24,0 L24,24 L0,24 L0,0 L24,0 Z M12.5934901,23.257841 L12.5819402,23.2595131 L12.5108777,23.2950439 L12.4918791,23.2987469 L12.4918791,23.2987469 L12.4767152,23.2950439 L12.4056548,23.2595131 C12.3958229,23.2563662 12.3870493,23.2590235 12.3821421,23.2649074 L12.3780323,23.275831 L12.360941,23.7031097 L12.3658947,23.7234994 L12.3769048,23.7357139 L12.4804777,23.8096931 L12.4953491,23.8136134 L12.4953491,23.8136134 L12.5071152,23.8096931 L12.6106902,23.7357139 L12.6232938,23.7196733 L12.6232938,23.7196733 L12.6266527,23.7031097 L12.609561,23.275831 C12.6075724,23.2657013 12.6010112,23.2592993 12.5934901,23.257841 L12.5934901,23.257841 Z M12.8583906,23.1452862 L12.8445485,23.1473072 L12.6598443,23.2396597 L12.6498822,23.2499052 L12.6498822,23.2499052 L12.6471943,23.2611114 L12.6650943,23.6906389 L12.6699349,23.7034178 L12.6699349,23.7034178 L12.678386,23.7104931 L12.8793402,23.8032389 C12.8914285,23.8068999 12.9022333,23.8029875 12.9078286,23.7952264 L12.9118235,23.7811639 L12.8776777,23.1665331 C12.8752882,23.1545897 12.8674102,23.1470016 12.8583906,23.1452862 L12.8583906,23.1452862 Z M12.1430473,23.1473072 C12.1332178,23.1423925 12.1221763,23.1452606 12.1156365,23.1525954 L12.1099173,23.1665331 L12.0757714,23.7811639 C12.0751323,23.7926639 12.0828099,23.8018602 12.0926481,23.8045676 L12.108256,23.8032389 L12.3092106,23.7104931 L12.3186497,23.7024347 L12.3186497,23.7024347 L12.3225043,23.6906389 L12.340401,23.2611114 L12.337245,23.2485176 L12.337245,23.2485176 L12.3277531,23.2396597 L12.1430473,23.1473072 Z" id="MingCute" fill-rule="nonzero"> </path> <path d="M19,3 C20.1046,3 21,3.89543 21,5 L21,19 C21,20.1046 20.1046,21 19,21 L5,21 C3.89543,21 3,20.1046 3,19 L3,5 C3,3.89543 3.89543,3 5,3 L19,3 Z M19,5 L5,5 L5,19 L19,19 L19,5 Z M12.0371,7.72855 C12.3082739,6.86097522 13.4777299,6.77813919 13.8878385,7.54487333 L13.9389,7.6547 L15.677,12 L17,12 C17.5523,12 18,12.4477 18,13 C18,13.51285 17.613973,13.9355092 17.1166239,13.9932725 L17,14 L15.0068,14 C14.6351,14 14.297096,13.796366 14.1213836,13.4760677 L14.069,13.3651 L13.1158,10.9821 L11.4619,16.2746 C11.2030273,17.1027636 10.1072568,17.2277874 9.65460915,16.5302559 L9.59448,16.425 L8.38197,14 L7,14 C6.44772,14 6,13.5523 6,13 C6,12.48715 6.38604429,12.0644908 6.88337975,12.0067275 L7,12 L8.99382,12 C9.33388222,12 9.64798889,12.1707457 9.83374702,12.4490272 L9.89719,12.5583 L10.2853,13.3345 L12.0371,7.72855 Z" id="形状" fill="#ffffff"> </path> </g> </g> </g> </g>
␍</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 317 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.34-4.34"/><path d="M11 8v6"/><path d="M8 11h6"/></svg>

Before

Width:  |  Height:  |  Size: 289 B

After

Width:  |  Height:  |  Size: 280 B