Redesign the mod menu

The mod menu is now a translucent panel beside a navigation rail, with a
header showing how many mods are enabled, a search box, category filters
built from the mods' tags and a three-column grid of mod cards. Each card
shows the mod's icon, name and description with a settings button and an
on/off switch. The layout shrinks to fit smaller windows.

New reusable pieces: Panel, Label, ChipGroup, SearchInput,
Toggle.drawSwitch, Theme helpers for panel, border, muted text and body
font, and Fonts.ellipsize. The sidebar and card icons are Lucide SVGs, and
seven mod icons were redrawn in the same style.

Scroll can skip its background and be cleared, and no longer passes clicks
outside its bounds to children scrolled out of view. Input checks overflow
and places the cursor at the scale its text is drawn at. Screens that are
not redesigned yet move the wider sidebar so it keeps its gap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-27 16:59:51 +02:00
co-authored by claude
parent 4ac7c60d07
commit dfebd2367d
32 changed files with 669 additions and 299 deletions
@@ -7,141 +7,100 @@ import org.saturnclient.mod.Mod;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.Utils;
import org.saturnclient.ui.components.inputs.Toggle;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
import org.saturnclient.ui.screens.ConfigEditor;
/**
* A mod's card in the mod menu: icon, name, description, a settings button and
* an on/off switch. Clicking anywhere but the settings button toggles the mod.
*/
public class SaturnModule extends Element {
public static final int HEIGHT = 76;
private static final int PADDING = 12;
private static final int ICON_TILE = 44;
private static final int ICON = 22;
private static final int SETTINGS = 32;
private static final int SETTINGS_ICON = 17;
private static final int GAP = 12;
private static final float NAME_SCALE = 0.74f;
private static final float DESCRIPTION_SCALE = 0.64f;
private final Mod mod;
static int p = 10; // padding
static int s = 14; // settings icon size
static int expand = 5; // settings icon click area
public SaturnModule(Mod mod) {
public SaturnModule(Mod mod, int width) {
this.mod = mod;
this.width = 160;
this.height = 50;
this.width = width;
this.height = HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
boolean hovered = ctx.isHovering();
boolean enabled = mod.isEnabled();
boolean settingsHovered = ctx.isHovering(getSettingsX(), getSettingsY(), SETTINGS, SETTINGS);
boolean hovered = ctx.isHovering() && !settingsHovered;
int iconX = width - s - p;
int iconY = height - s - p;
int border = enabled ? Theme.withAlpha(hovered ? 0.7f : 0.4f, Theme.ACCENT.value)
: Theme.withAlpha(hovered ? 0.22f : 0.06f, Theme.FOREGROUND.value);
boolean settingsHover = ctx.isHovering(
getSettingsX(),
getSettingsY(),
getSettingsHitBox(),
getSettingsHitBox());
// Card
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.72f, Theme.PRIMARY.value), border);
// Resolve theme colors
int bgColor = Theme.PRIMARY.value;
int fgColor = hovered && !settingsHover ? Theme.ACCENT.value : Theme.PRIMARY_FG.value;
int iconBg = enabled ? Theme.ACCENT.value : Theme.PRIMARY.value;
int iconFg = enabled ? Theme.ACCENT_FG.value : fgColor;
int settingsColor = settingsHover
? Theme.ACCENT.value
: Theme.withAlpha(160, fgColor);
int h = height - (p * 2);
// Main background
renderScope.drawRoundedRectangle(
0, 0,
width, height,
Theme.WIDGET_RADIUS.value,
bgColor);
// Icon background
renderScope.drawRoundedRectangle(
p, p,
h, h,
Theme.WIDGET_RADIUS.value,
iconBg);
// Icon
renderScope.drawTexture(
mod.getIconTexture(),
p + (p / 2),
p + (p / 2),
0, 0,
h - p,
h - p,
iconFg);
// Module name
renderScope.drawText(
0.6f,
mod.getName(),
p + h + 4,
p + 1,
Theme.FONT.value,
fgColor);
// Version (right aligned)
String version = mod.getVersion();
int versionWidth = (int) (Fonts.getWidth(version, Theme.FONT.value) * 0.35f);
renderScope.drawText(
0.35f,
version,
width - p - versionWidth,
p + 1,
Theme.FONT.value,
fgColor);
// Tags
int xt = p + h + 4;
for (String tag : mod.getTags()) {
int tagWidth = (int) (Fonts.getWidth(tag, Theme.FONT.value) * 0.4f);
renderScope.drawRoundedRectangle(
xt,
p + (h / 2),
tagWidth + 6,
12,
5,
Theme.ACCENT.value);
renderScope.drawText(
0.4f,
tag,
xt + 2,
p + (h / 2) + 3,
Theme.FONT.value,
Theme.ACCENT_FG.value);
xt += tagWidth + 9;
if (enabled) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value,
Theme.withAlpha(0.06f, Theme.ACCENT.value));
}
// Settings icon
renderScope.drawTexture(
Textures.SETTINGS,
iconX,
iconY,
0, 0,
s, s,
settingsColor);
// Icon
int tileY = (height - ICON_TILE) / 2;
renderScope.drawRoundedRectangle(PADDING, tileY, ICON_TILE, ICON_TILE, Theme.WIDGET_RADIUS.value,
enabled ? Theme.withAlpha(0.18f, Theme.ACCENT.value) : Theme.withAlpha(0.06f, Theme.FOREGROUND.value));
renderScope.drawTexture(mod.getIconTexture(),
PADDING + (ICON_TILE - ICON) / 2, tileY + (ICON_TILE - ICON) / 2,
0, 0, ICON, ICON, Theme.PRIMARY_FG.value);
// Name and description
int textX = PADDING + ICON_TILE + GAP;
int textWidth = getSettingsX() - GAP - textX;
int nameHeight = (int) (Fonts.getHeight() * NAME_SCALE);
int descriptionHeight = (int) (Fonts.getHeight() * DESCRIPTION_SCALE);
int nameY = (height - nameHeight - 5 - descriptionHeight) / 2;
renderScope.drawText(NAME_SCALE,
Fonts.ellipsize(mod.getName(), Theme.FONT.value, NAME_SCALE, textWidth),
textX, nameY, Theme.FONT.value, Theme.FOREGROUND.value);
String description = mod.getDescription() == null ? "" : mod.getDescription();
renderScope.drawText(DESCRIPTION_SCALE,
Fonts.ellipsize(description, Theme.getBodyFont(), DESCRIPTION_SCALE, textWidth),
textX, nameY + nameHeight + 5, Theme.getBodyFont(), Theme.getMutedFg());
// Settings button
if (settingsHovered) {
renderScope.drawRoundedRectangle(getSettingsX(), getSettingsY(), SETTINGS, SETTINGS,
Theme.WIDGET_RADIUS.value, Theme.withAlpha(0.08f, Theme.FOREGROUND.value));
}
int settingsIconOffset = (SETTINGS - SETTINGS_ICON) / 2;
renderScope.drawTexture(Textures.ICON_SLIDERS,
getSettingsX() + settingsIconOffset, getSettingsY() + settingsIconOffset,
0, 0, SETTINGS_ICON, SETTINGS_ICON,
settingsHovered ? Theme.ACCENT.value : Theme.getMutedFg());
// Switch
Toggle.drawSwitch(renderScope, getSwitchX(), (height - Toggle.SWITCH_HEIGHT) / 2, enabled);
}
@Override
public void click(int mouseX, int mouseY) {
boolean settingsHover = Utils.isHovering(
mouseX - getSettingsX(),
mouseY - getSettingsY(),
getSettingsHitBox(),
getSettingsHitBox(),
1.0f);
boolean settingsClicked = mouseX >= getSettingsX() && mouseX <= getSettingsX() + SETTINGS
&& mouseY >= getSettingsY() && mouseY <= getSettingsY() + SETTINGS;
if (settingsHover) {
if (settingsClicked) {
if (mod instanceof HudMod h) {
Providers.saturn.getClient().setScreen(new ConfigEditor(mod.getConfig(), h));
} else {
@@ -152,15 +111,15 @@ public class SaturnModule extends Element {
}
}
private int getSwitchX() {
return width - PADDING - Toggle.SWITCH_WIDTH;
}
private int getSettingsX() {
return width - s - p - expand;
return getSwitchX() - 8 - SETTINGS;
}
private int getSettingsY() {
return height - s - p - expand;
return (height - SETTINGS) / 2;
}
private int getSettingsHitBox() {
return s + expand * 2;
}
}
}
@@ -7,51 +7,48 @@ import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.Utils;
import org.saturnclient.ui.elements.Panel;
import org.saturnclient.ui.resources.Textures;
import org.saturnclient.ui.screens.ConfigEditor;
import org.saturnclient.ui.screens.HudEditor;
import org.saturnclient.ui.screens.ModMenu;
import org.saturnclient.ui.screens.cosmetics.CloakMenu;
import org.saturnclient.ui.screens.store.CloakStore;
/**
* The navigation rail beside Saturn's screens: the logo, one button per screen,
* and a close button at the bottom.
*/
public class Sidebar extends Element {
public static class SidebarComponent {
Runnable onClick;
boolean end;
IdentifierRef sprite;
public static final int WIDTH = 60;
public SidebarComponent(IdentifierRef sprite, Runnable onClick, boolean end) {
this.onClick = onClick;
this.end = end;
this.sprite = sprite;
}
// Values for the constructor's `active`
public static final int MODS = 0;
public static final int COSMETICS = 1;
public static final int HUD_EDITOR = 2;
public static final int SETTINGS = 3;
private static final int LOGO = 32;
private static final int BUTTON = 44;
private static final int ICON = 22;
private static final int GAP = 8;
private static final int PADDING = 12;
private record Item(IdentifierRef icon, Runnable onClick) {
}
Runnable onClose;
SidebarComponent[] components = {
new Sidebar.SidebarComponent(Textures.MODS_TAB, () -> {
Providers.saturn.getClient().setScreen(new ModMenu());
}, false),
new Sidebar.SidebarComponent(Textures.SETTINGS, () -> {
Providers.saturn.getClient().setScreen(new ConfigEditor(Config.config));
}, false),
new Sidebar.SidebarComponent(Textures.SHIRT, () -> {
Providers.saturn.getClient().setScreen(new CloakMenu());
}, false),
new Sidebar.SidebarComponent(Textures.CLOSE, () -> {
onClose.run();
}, true),
new Sidebar.SidebarComponent(Textures.STORE, () -> {
Providers.saturn.getClient().setScreen(new CloakStore());
}, true),
private final Item[] items = {
new Item(Textures.ICON_MODS, () -> Providers.saturn.getClient().setScreen(new ModMenu())),
new Item(Textures.ICON_COSMETICS, () -> Providers.saturn.getClient().setScreen(new CloakMenu())),
new Item(Textures.ICON_HUD_EDITOR, () -> Providers.saturn.getClient().setScreen(new HudEditor())),
new Item(Textures.ICON_SETTINGS,
() -> Providers.saturn.getClient().setScreen(new ConfigEditor(Config.config))),
};
int active = 0;
private final int active;
private final Runnable onClose;
public Sidebar(int active, Runnable onClose) {
this.width = 30;
this.width = WIDTH;
this.height = 350;
this.active = active;
this.onClose = onClose;
@@ -59,63 +56,75 @@ public class Sidebar extends Element {
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
int topRow = 0;
int bottomRow = 0;
Panel.drawBackground(renderScope, width, height);
int gap = 5; // 5px gap between icons
int padding = 10;
int sp = 30 - padding + gap;
int s = 30 - (padding * 2);
int s2 = s + padding;
renderScope.drawTexture(Textures.LOGO, (width - LOGO) / 2, PADDING, 0, 0, LOGO, LOGO);
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.BG_RADIUS.value, Theme.BACKGROUND.value);
int x = getButtonX();
int i = 0;
for (int i = 0; i < items.length; i++) {
int y = getItemY(i);
boolean selected = i == active;
boolean hovered = ctx.isHovering(x, y, BUTTON, BUTTON);
for (SidebarComponent component : components) {
int y = component.end ? ((height - (sp * bottomRow)) - (s * 2)) : (padding + (sp * topRow));
boolean selected = ctx.isHovering(0, y - (padding / 2), s2, s2) || active == i;
renderScope.drawRoundedRectangle(padding / 2, y - (padding / 2), s + padding,
s + padding, Theme.WIDGET_RADIUS.value, Theme.getBg(selected));
renderScope.drawTexture(component.sprite, padding, y, 0, 0, s, s, Theme.getFg(selected));
if (component.end) {
bottomRow += 1;
} else {
topRow += 1;
if (selected) {
renderScope.drawRoundedRectangle(x, y, BUTTON, BUTTON, Theme.WIDGET_RADIUS.value, Theme.ACCENT.value);
} else if (hovered) {
renderScope.drawRoundedRectangle(x, y, BUTTON, BUTTON, Theme.WIDGET_RADIUS.value,
Theme.withAlpha(0.08f, Theme.FOREGROUND.value));
}
i++;
drawIcon(renderScope, items[i].icon(), x, y,
selected ? Theme.ACCENT_FG.value
: hovered ? Theme.FOREGROUND.value : Theme.withAlpha(0.8f, Theme.FOREGROUND.value));
}
int closeY = getCloseY();
boolean closeHovered = ctx.isHovering(x, closeY, BUTTON, BUTTON);
renderScope.drawRoundedRectangle(x, closeY, BUTTON, BUTTON, Theme.WIDGET_RADIUS.value,
Theme.withAlpha(closeHovered ? 0.12f : 0.06f, Theme.FOREGROUND.value));
drawIcon(renderScope, Textures.ICON_CLOSE, x, closeY,
closeHovered ? Theme.FOREGROUND.value : Theme.withAlpha(0.8f, Theme.FOREGROUND.value));
}
@Override
public void click(int mouseX, int mouseY) {
int topRow = 0;
int bottomRow = 0;
int x = getButtonX();
int gap = 5;
int padding = 10;
int sp = 30 - padding + gap;
int s = 30 - (padding * 2);
int s2 = s + padding;
if (mouseX < x || mouseX > x + BUTTON) {
return;
}
for (SidebarComponent component : components) {
int y = component.end ? ((height - (sp * bottomRow)) - (s * 2)) : (padding + (sp * topRow));
boolean hovering = Utils.isHovering(mouseX - (padding / 2), mouseY - (y - (padding / 2)), s2, s2, scale);
for (int i = 0; i < items.length; i++) {
int y = getItemY(i);
if (hovering) {
component.onClick.run();
break;
}
if (component.end) {
bottomRow += 1;
} else {
topRow += 1;
if (mouseY >= y && mouseY <= y + BUTTON) {
items[i].onClick().run();
return;
}
}
if (mouseY >= getCloseY() && mouseY <= getCloseY() + BUTTON) {
onClose.run();
}
}
private static void drawIcon(RenderScope renderScope, IdentifierRef icon, int x, int y, int color) {
int offset = (BUTTON - ICON) / 2;
renderScope.drawTexture(icon, x + offset, y + offset, 0, 0, ICON, ICON, color);
}
private int getButtonX() {
return (width - BUTTON) / 2;
}
private int getItemY(int index) {
return PADDING + LOGO + 14 + index * (BUTTON + GAP);
}
private int getCloseY() {
return height - PADDING - BUTTON;
}
}
@@ -11,6 +11,11 @@ public abstract class Input extends Element {
String text = "";
public int cursorPosition = 0;
/** Scale the text is drawn at */
protected float textScale = 0.6f;
/** Horizontal inset of the text on both sides */
protected int textX = 4;
public abstract void checkReset();
public abstract void backspace();
@@ -41,6 +46,7 @@ public abstract class Input extends Element {
@Override
public void click(int mouseX, int mouseY) {
mouseX -= textX;
int scrollOffset = getScrollOffset();
String visibleText = text.substring(scrollOffset);
@@ -48,7 +54,7 @@ public abstract class Input extends Element {
float textWidth = 0;
for (int i = 0; i < visibleText.length(); i++) {
textWidth = Fonts.getWidth(visibleText.substring(0, i + 1), Theme.FONT.value) * 0.6f;
textWidth = Fonts.getWidth(visibleText.substring(0, i + 1), Theme.FONT.value) * textScale;
if (textWidth > mouseX) {
break;
@@ -68,24 +74,32 @@ public abstract class Input extends Element {
int scrollOffset = getScrollOffset();
String visibleText = getVisibleText(scrollOffset);
renderScope.drawText(0.6f, visibleText, 4, 4, Theme.FONT.value, Theme.PRIMARY_FG.value);
renderScope.drawText(textScale, visibleText, textX, 4, Theme.FONT.value, Theme.PRIMARY_FG.value);
if (focused) {
int cursorX = 4 +
(int) (Fonts.getWidth(visibleText.substring(0, cursorPosition - scrollOffset), Theme.FONT.value)
* 0.6f);
int cursorX = getCursorX(visibleText, scrollOffset);
renderScope.drawRectangle(cursorX, 2, 1, height - 4, Theme.ACCENT.value);
}
}
protected int getCursorX(String visibleText, int scrollOffset) {
return textX + (int) (Fonts.getWidth(visibleText.substring(0, cursorPosition - scrollOffset),
Theme.FONT.value) * textScale);
}
/** Width available to the text, in unscaled font units */
private float getTextSpace() {
return (width - textX * 2) / textScale;
}
protected int getScrollOffset() {
if (text.isEmpty()) {
return 0;
}
int offset = 0;
int maxWidth = width - 4; // Available width for text
float maxWidth = getTextSpace();
while (offset < cursorPosition) { // Ensure cursor remains visible
String visibleText = text.substring(offset, cursorPosition);
if (Fonts.getWidth(visibleText, Theme.FONT.value) > maxWidth) {
@@ -102,7 +116,7 @@ public abstract class Input extends Element {
// Ensure text does not overflow past the available width
for (int i = 1; i <= visibleText.length(); i++) {
if (Fonts.getWidth(visibleText.substring(0, i), Theme.FONT.value) > width - 4) {
if (Fonts.getWidth(visibleText.substring(0, i), Theme.FONT.value) > getTextSpace()) {
return visibleText.substring(0, i - 1); // Trim overflow
}
}
@@ -0,0 +1,79 @@
package org.saturnclient.ui.components.inputs;
import java.util.function.Consumer;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
/**
* A free-text search box with an icon and placeholder, reporting every change.
*/
public class SearchInput extends Input {
private static final int ICON_SIZE = 16;
private static final int ICON_X = 12;
private final String placeholder;
private final Consumer<String> onChange;
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;
}
@Override
public void charTyped(char chr) {
if (text.length() >= 64) {
return;
}
text = text.substring(0, cursorPosition) + chr + text.substring(cursorPosition);
cursorPosition++;
onChange.accept(text);
}
@Override
public void backspace() {
onChange.accept(text);
}
@Override
public void checkReset() {
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
int border = focused ? Theme.withAlpha(0.6f, Theme.ACCENT.value)
: ctx.isHovering() ? Theme.withAlpha(0.25f, Theme.FOREGROUND.value)
: Theme.withAlpha(0.1f, Theme.FOREGROUND.value);
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.06f, Theme.FOREGROUND.value), border);
renderScope.drawTexture(Textures.ICON_SEARCH, ICON_X, (height - ICON_SIZE) / 2, 0, 0,
ICON_SIZE, ICON_SIZE, Theme.getMutedFg());
int textHeight = (int) (Fonts.getHeight() * textScale);
int textY = (height - textHeight) / 2;
if (text.isEmpty() && !focused) {
renderScope.drawText(textScale, placeholder, textX, textY, Theme.getBodyFont(), Theme.getMutedFg());
return;
}
int scrollOffset = getScrollOffset();
String visibleText = getVisibleText(scrollOffset);
renderScope.drawText(textScale, visibleText, textX, textY, Theme.FONT.value, Theme.FOREGROUND.value);
if (focused) {
renderScope.drawRectangle(getCursorX(visibleText, scrollOffset), textY - 1, 1, textHeight + 2,
Theme.ACCENT.value);
}
}
}
@@ -23,6 +23,26 @@ public class Toggle extends Element {
prop.value ? Theme.ACCENT.value : Theme.PRIMARY.value);
}
public static final int SWITCH_WIDTH = 42;
public static final int SWITCH_HEIGHT = 24;
/**
* Draws an on/off switch: a pill track with a round knob on the right when on.
*/
public static void drawSwitch(RenderScope renderScope, int x, int y, boolean on) {
int inset = 3;
int knob = SWITCH_HEIGHT - inset * 2;
renderScope.drawRoundedRectangle(x, y, SWITCH_WIDTH, SWITCH_HEIGHT, SWITCH_HEIGHT / 2,
on ? Theme.ACCENT.value : Theme.withAlpha(0.16f, Theme.FOREGROUND.value));
renderScope.drawRoundedRectangle(
on ? x + SWITCH_WIDTH - inset - knob : x + inset,
y + inset,
knob, knob, knob / 2,
on ? Theme.ACCENT_FG.value : Theme.withAlpha(0.8f, Theme.PRIMARY_FG.value));
}
@Override
public void click(int mouseX, int mouseY) {
prop.value = !prop.value;