Redesign mod settings

A mod's settings button now opens ModSettings: a header with the mod's
icon, name, version, tags and description, a Reset button and an on/off
switch, then its properties as rows, with nested namespaces as titled
sections and a live preview card beside them for HUD mods.

PanelScreen holds the sidebar-and-panel frame that the mod menu, mod
settings and config editor share. PropertyList lays out any config as
rows. The inputs share one field look: Toggle is a switch, Select steps
with arrows, and the number, keybinding and color inputs are boxes. The
color picker opens to the left and closes on outside clicks.

Every property type now implements reset(), which was a no-op, and
ModLayout keeps its settings in order instead of Map.of's. ConfigEditor
moves onto the same frame and rows and drops its HUD preview, which added
a new element every frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-27 17:13:39 +02:00
co-authored by claude
parent dfebd2367d
commit df09271e81
33 changed files with 860 additions and 277 deletions
@@ -2,10 +2,12 @@ package org.saturnclient.ui.components.inputs;
import java.awt.Color;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.ColorProperty;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.elements.Field;
public class ColorInput extends Element {
public ColorProperty prop;
@@ -26,10 +28,19 @@ public class ColorInput extends Element {
private static final int SLIDER_HEIGHT = 10;
private static final int HUE_SLIDER_HEIGHT = 10;
// The picker opens to the left, since the input sits at the right of its row.
private static final int PICKER_X = -PICKER_WIDTH - 12;
private static final int SWATCH = 20;
// Bounds of the picker's backdrop, relative to the color square
private static final int BACKDROP_PADDING = 8;
private static final int BACKDROP_TOP = -SLIDER_HEIGHT - 10 - BACKDROP_PADDING;
private static final int BACKDROP_BOTTOM = PICKER_HEIGHT + 10 + HUE_SLIDER_HEIGHT + BACKDROP_PADDING;
public ColorInput(ColorProperty prop) {
this.prop = prop;
this.width = 58;
this.height = 20;
this.width = 116;
this.height = Field.HEIGHT;
syncFromColor();
}
@@ -40,15 +51,23 @@ public class ColorInput extends Element {
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
if (open) {
handlePickerClick(mouseX - width - 8, mouseY);
if (!open) {
return;
}
boolean onInput = mouseX >= 0 && mouseX <= width && mouseY >= 0 && mouseY <= height;
if (!onInput && !isOverPicker(mouseX - PICKER_X, mouseY)) {
open = false;
return;
}
handlePickerClick(mouseX - PICKER_X, mouseY);
}
@Override
public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
if (open) {
handlePickerDrag(mouseX - width - 8, mouseY);
handlePickerDrag(mouseX - PICKER_X, mouseY);
}
}
@@ -59,16 +78,26 @@ public class ColorInput extends Element {
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
// Draw the main color rectangle
renderScope.drawRoundedRectangle(0, 0, width, height, 6, prop.value);
Field.drawBox(renderScope, width, height, ctx.isHovering(), open);
int swatchY = (height - SWATCH) / 2;
renderScope.drawRoundedRectangle(swatchY, swatchY, SWATCH, SWATCH, 5, 1, prop.value,
Theme.withAlpha(0.3f, Theme.FOREGROUND.value));
renderScope.drawText(Field.TEXT_SCALE, toHex(prop.value), swatchY + SWATCH + 8,
Field.textY(height, Field.TEXT_SCALE), Theme.FONT.value, Theme.FOREGROUND.value);
if (!open)
return;
// Draw the picker relative to (width + 8, 0)
int pickerX = width + 8;
int pickerX = PICKER_X;
int pickerY = 0;
// Backdrop behind the picker and its two sliders
renderScope.drawRoundedRectangle(pickerX - BACKDROP_PADDING, BACKDROP_TOP,
PICKER_WIDTH + BACKDROP_PADDING * 2, BACKDROP_BOTTOM - BACKDROP_TOP,
Theme.WIDGET_RADIUS.value, 1, Theme.withAlpha(0.95f, Theme.BACKGROUND.value), Theme.getBorder());
renderColorSquare(renderScope, pickerX, pickerY);
renderOpacitySlider(renderScope, pickerX, pickerY - SLIDER_HEIGHT - 10);
renderHueSlider(renderScope, pickerX, pickerY + PICKER_HEIGHT + 10);
@@ -138,6 +167,11 @@ public class ColorInput extends Element {
}
}
private static boolean isOverPicker(double x, double y) {
return x >= -BACKDROP_PADDING && x <= PICKER_WIDTH + BACKDROP_PADDING
&& y >= BACKDROP_TOP && y <= BACKDROP_BOTTOM;
}
private void handlePickerDrag(double mouseX, double mouseY) {
// Opacity
if (draggingOpacity)
@@ -169,6 +203,16 @@ public class ColorInput extends Element {
updateColorFromSelection();
}
/**
* #RRGGBB, or #AARRGGBB when the color is translucent.
*/
private static String toHex(int color) {
int alpha = (color >>> 24) & 0xFF;
return alpha == 0xFF
? String.format("#%06X", color & 0xFFFFFF)
: String.format("#%08X", color);
}
private void syncFromColor() {
int rgb = prop.value & 0xFFFFFF;
float[] hsb = Color.RGBtoHSB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, null);
@@ -2,14 +2,14 @@ package org.saturnclient.ui.components.inputs;
import org.saturnclient.config.ConfigManager;
import org.saturnclient.config.property.FloatProperty;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.elements.Field;
public class FloatInput extends Input {
public FloatProperty prop;
public FloatInput(FloatProperty prop) {
this.width = 120;
this.height = Fonts.getHeight();
this.width = 96;
this.height = Field.HEIGHT;
this.prop = prop;
this.text = String.valueOf(prop.value);
}
@@ -5,6 +5,7 @@ import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.elements.Field;
import org.saturnclient.ui.resources.Fonts;
public abstract class Input extends Element {
@@ -12,9 +13,9 @@ public abstract class Input extends Element {
public int cursorPosition = 0;
/** Scale the text is drawn at */
protected float textScale = 0.6f;
protected float textScale = Field.TEXT_SCALE;
/** Horizontal inset of the text on both sides */
protected int textX = 4;
protected int textX = 10;
public abstract void checkReset();
@@ -70,16 +71,24 @@ public abstract class Input extends Element {
public void render(RenderScope renderScope, ElementContext ctx) {
checkReset();
renderScope.drawRoundedRectangle(0, 0, width, height, 10, Theme.PRIMARY.value);
Field.drawBox(renderScope, width, height, ctx.isHovering(), focused);
drawText(renderScope);
}
/**
* Draws the visible part of the text, and the cursor while focused.
*/
protected void drawText(RenderScope renderScope) {
int textHeight = (int) (Fonts.getHeight() * textScale);
int textY = Field.textY(height, textScale);
int scrollOffset = getScrollOffset();
String visibleText = getVisibleText(scrollOffset);
renderScope.drawText(textScale, visibleText, textX, 4, Theme.FONT.value, Theme.PRIMARY_FG.value);
renderScope.drawText(textScale, visibleText, textX, textY, Theme.FONT.value, Theme.FOREGROUND.value);
if (focused) {
int cursorX = getCursorX(visibleText, scrollOffset);
renderScope.drawRectangle(cursorX, 2, 1, height - 4, Theme.ACCENT.value);
renderScope.drawRectangle(getCursorX(visibleText, scrollOffset), textY - 1, 1, textHeight + 2,
Theme.ACCENT.value);
}
}
@@ -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
@@ -5,7 +5,7 @@ import java.util.function.Consumer;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.elements.Field;
import org.saturnclient.ui.resources.Textures;
/**
@@ -21,7 +21,6 @@ public class SearchInput extends Input {
public SearchInput(String placeholder, Consumer<String> onChange) {
this.placeholder = placeholder;
this.onChange = onChange;
this.textScale = 0.68f;
this.textX = ICON_X + ICON_SIZE + 8;
this.width = 240;
this.height = 36;
@@ -49,31 +48,17 @@ public class SearchInput extends Input {
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
int border = focused ? Theme.withAlpha(0.6f, Theme.ACCENT.value)
: ctx.isHovering() ? Theme.withAlpha(0.25f, Theme.FOREGROUND.value)
: Theme.withAlpha(0.1f, Theme.FOREGROUND.value);
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.06f, Theme.FOREGROUND.value), border);
Field.drawBox(renderScope, width, height, ctx.isHovering(), focused);
renderScope.drawTexture(Textures.ICON_SEARCH, ICON_X, (height - ICON_SIZE) / 2, 0, 0,
ICON_SIZE, ICON_SIZE, Theme.getMutedFg());
int textHeight = (int) (Fonts.getHeight() * textScale);
int textY = (height - textHeight) / 2;
if (text.isEmpty() && !focused) {
renderScope.drawText(textScale, placeholder, textX, textY, Theme.getBodyFont(), Theme.getMutedFg());
renderScope.drawText(textScale, placeholder, textX, Field.textY(height, textScale),
Theme.getBodyFont(), Theme.getMutedFg());
return;
}
int scrollOffset = getScrollOffset();
String visibleText = getVisibleText(scrollOffset);
renderScope.drawText(textScale, visibleText, textX, textY, Theme.FONT.value, Theme.FOREGROUND.value);
if (focused) {
renderScope.drawRectangle(getCursorX(visibleText, scrollOffset), textY - 1, 1, textHeight + 2,
Theme.ACCENT.value);
}
drawText(renderScope);
}
}
@@ -5,36 +5,50 @@ import org.saturnclient.config.property.SelectProperty;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.Utils;
import org.saturnclient.ui.elements.Field;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
/**
* Steps through a {@link SelectProperty}'s values with arrows on either side.
* Clicking the left third goes back; anywhere else goes forward.
*/
public class Select extends Element {
private SelectProperty prop;
private static final int ARROW = 16;
private static final int ARROW_INSET = 8;
private final SelectProperty prop;
public Select(SelectProperty prop) {
this.prop = prop;
this.width = 150;
this.height = 15;
this.width = 200;
this.height = Field.HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawText(0.8f, "<", 0, 0, Theme.FONT.value, Theme.FOREGROUND.value);
Field.drawBox(renderScope, width, height, ctx.isHovering(), false);
String text = prop.getSelection();
boolean prevHovered = ctx.isHovering(0, 0, width / 3, height);
boolean nextHovered = ctx.isHovering() && !prevHovered;
int arrowY = (height - ARROW) / 2;
renderScope.drawText(0.6f, text, (int) (width - ((float) Fonts.getWidth(text, Theme.FONT.value) * 0.6f)) / 2, 1,
Theme.FONT.value, Theme.FOREGROUND.value);
renderScope.drawTexture(Textures.ICON_CHEVRON_LEFT, ARROW_INSET, arrowY, 0, 0, ARROW, ARROW,
prevHovered ? Theme.ACCENT.value : Theme.getMutedFg());
renderScope.drawTexture(Textures.ICON_CHEVRON_RIGHT, width - ARROW_INSET - ARROW, arrowY, 0, 0, ARROW, ARROW,
nextHovered ? Theme.ACCENT.value : Theme.getMutedFg());
renderScope.drawText(0.8f, ">", width - 10, 0, Theme.FONT.value, Theme.FOREGROUND.value);
int textSpace = width - (ARROW_INSET + ARROW + 4) * 2;
String text = Fonts.ellipsize(prop.getSelection(), Theme.FONT.value, Field.TEXT_SCALE, textSpace);
Field.drawCenteredText(renderScope, text, width, height, Theme.FONT.value, Theme.FOREGROUND.value);
}
@Override
public void click(int mouseX, int mouseY) {
if (Utils.isHovering(mouseX - (width - 10), mouseY, 10, height, 1.0f)) {
prop.next();
} else if (Utils.isHovering(mouseX, mouseY, 10, height, 1.0f)) {
if (mouseX < width / 3) {
prop.prev();
} else {
prop.next();
}
}
}
@@ -7,25 +7,22 @@ import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
public class Toggle extends Element {
public static final int SWITCH_WIDTH = 42;
public static final int SWITCH_HEIGHT = 24;
private BoolProperty prop;
public Toggle(BoolProperty prop) {
this.prop = prop;
this.width = 60;
this.height = 30;
this.width = SWITCH_WIDTH;
this.height = SWITCH_HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, 60, 30, Theme.WIDGET_RADIUS.value,
Theme.withAlpha(0.5f, prop.value ? Theme.ACCENT.value : Theme.PRIMARY.value));
renderScope.drawRoundedRectangle(prop.value ? 30 : 0, 0, 30, 30, Theme.WIDGET_RADIUS.value,
prop.value ? Theme.ACCENT.value : Theme.PRIMARY.value);
drawSwitch(renderScope, 0, 0, prop.value);
}
public static final int SWITCH_WIDTH = 42;
public static final int SWITCH_HEIGHT = 24;
/**
* Draws an on/off switch: a pill track with a round knob on the right when on.
*/