Preview easing curves beside curve dropdowns

CurveProperty holds the easing curves in the order Animation.getCurve
takes them, replacing three copies of the list in zoom, freelook and the
animation config; it's saved the same way. Its dropdown opens a
CurveOptions popup: a CurvePreview graph beside the list, with a dot
running along the hovered curve, or the current one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-27 18:19:18 +02:00
co-authored by claude
parent 55b78989ce
commit 85c7057ba1
10 changed files with 191 additions and 11 deletions
@@ -4,9 +4,9 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.saturnclient.config.property.CurveProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.config.property.SelectProperty;
public class AnimationConfig {
public static ConfigManager config;
@@ -47,12 +47,12 @@ public class AnimationConfig {
// Animation properties
public final IntProperty duration;
public final IntProperty stagger;
public final SelectProperty curve;
public final CurveProperty curve;
public AnimationConfig(boolean enabled, int delay, int stagger) {
this.duration = Property.integer(delay).range(0, 2000, 50).unit("ms");
this.stagger = Property.integer(stagger).range(0, 300, 10).unit("ms");
this.curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back");
this.curve = Property.curve(1);
}
/** The menu this animates, like "Mod Menu" */
@@ -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");
}
}
@@ -44,6 +44,10 @@ public abstract class Property {
return select(value, "Default", "Inter", "Inter bold");
}
public static CurveProperty curve(int value) {
return new CurveProperty(value);
}
public static SelectProperty select(Integer value, String... availableValues) {
return new SelectProperty(value, availableValues);
}
@@ -5,11 +5,11 @@ import java.util.function.Function;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.common.provider.GLFWProvider;
import org.saturnclient.config.property.BoolProperty;
import org.saturnclient.config.property.CurveProperty;
import org.saturnclient.config.property.FloatProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.KeybindingProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.config.property.SelectProperty;
import org.saturnclient.mod.Mod;
import org.saturnclient.mod.ModSpec;
import org.saturnclient.common.feature.RenderFeature;
@@ -25,7 +25,7 @@ public class FreelookMod extends Mod {
public static final FloatProperty zoomLevel = Property.floatProp(2.0f).range(1f, 5f, 0.1f).unit("×");
public static final IntProperty zoomInDuration = Property.integer(500).range(0, 2000, 50).unit("ms");
public static final IntProperty zoomOutDuration = Property.integer(300).range(0, 2000, 50).unit("ms");
public static final SelectProperty curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back");
public static final CurveProperty curve = Property.curve(1);
// --- State Variables ---
private static boolean isFreeLooking = false;
@@ -4,11 +4,11 @@ import java.util.function.Function;
import org.saturnclient.common.provider.GLFWProvider;
import org.saturnclient.config.property.BoolProperty;
import org.saturnclient.config.property.CurveProperty;
import org.saturnclient.config.property.FloatProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.KeybindingProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.config.property.SelectProperty;
import org.saturnclient.mod.Mod;
import org.saturnclient.mod.ModSpec;
import org.saturnclient.ui.anim.Animation;
@@ -21,8 +21,7 @@ public class ZoomMod extends Mod {
public static final FloatProperty zoomLevel = Property.floatProp(3.0f).range(1f, 10f, 0.5f).unit("×");
public static final IntProperty zoomInDuration = Property.integer(700).range(0, 2000, 50).unit("ms");
public static final IntProperty zoomOutDuration = Property.integer(300).range(0, 2000, 50).unit("ms");
public static final SelectProperty curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic",
"Ease In Out Back");
public static final CurveProperty curve = Property.curve(1);
public ZoomMod() {
super(
+9 -1
View File
@@ -44,6 +44,14 @@ public final class Popup {
* @param ownerContext the owner's context from its last render
*/
public static void openBelow(Element owner, Element popup, ElementContext ownerContext) {
openBelow(owner, popup, ownerContext, 0);
}
/**
* Like {@link #openBelow(Element, Element, ElementContext)}, with the popup
* moved {@code offsetX} from the owner's left edge.
*/
public static void openBelow(Element owner, Element popup, ElementContext ownerContext, int offsetX) {
int top = Math.max(ownerContext.visibleTop, SCREEN_MARGIN);
int bottom = Math.min(ownerContext.visibleBottom, screenHeight - SCREEN_MARGIN);
@@ -55,7 +63,7 @@ public final class Popup {
boolean moreRoomBelow = bottom - below >= ownerContext.originY - OWNER_GAP - top;
int y = fitsBelow || (!fitsAbove && moreRoomBelow) ? below : above;
open(owner, popup, ownerContext.originX, y);
open(owner, popup, ownerContext.originX + offsetX, y);
}
public static void close() {
@@ -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);
}
}
@@ -28,6 +28,8 @@ public class OptionList extends Element {
private int first;
/** Wheel movement not yet turned into whole options, so trackpads scroll smoothly */
private double pendingScroll = 0;
/** The option under the mouse on the last frame, or -1 */
private int hovered = -1;
public OptionList(String[] options, int selected, int width, IntConsumer onPick) {
this.options = options;
@@ -46,7 +48,7 @@ public class OptionList extends Element {
Theme.withAlpha(0.97f, Theme.BACKGROUND.value), Theme.withAlpha(0.14f, Theme.FOREGROUND.value));
int visible = Math.min(options.length, MAX_VISIBLE);
int hovered = getIndexAt(ctx.mouseX, ctx.mouseY);
hovered = getIndexAt(ctx.mouseX, ctx.mouseY);
for (int row = 0; row < visible; row++) {
int index = first + row;
@@ -95,6 +97,13 @@ public class OptionList extends Element {
first = clampFirst(first + whole);
}
/**
* The option under the mouse on the last frame, or -1.
*/
public int getHovered() {
return hovered;
}
/**
* The option under the mouse, or -1.
*/
@@ -1,6 +1,7 @@
package org.saturnclient.ui.components.inputs;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.CurveProperty;
import org.saturnclient.config.property.SelectProperty;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
@@ -54,6 +55,12 @@ public class Select extends Element {
Popup.close();
});
Popup.openBelow(this, list, lastContext);
if (prop instanceof CurveProperty) {
// The preview goes left of the list, so the list still lines up under this.
CurveOptions options = new CurveOptions(list, prop.value);
Popup.openBelow(this, options, lastContext, -options.getListX());
} else {
Popup.openBelow(this, list, lastContext);
}
}
}