Redesign client settings

ClientSettings replaces ConfigEditor: a section list on the left (General,
UI Style, Animations) and the chosen section's settings on the right, each
with a Reset for that section. UI Style shows four theme presets, the
theme colors as a two-column grid, then the radii and font.

New pieces: SectionNav, ThemePresetCard, ColorGrid and Theme.PRESETS.
Theme keeps its config like AnimationConfig does, Divider can be vertical,
and PropertyList's section titles are public.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-27 17:25:47 +02:00
co-authored by claude
parent df09271e81
commit a413648be9
12 changed files with 436 additions and 49 deletions
@@ -1,11 +1,15 @@
package org.saturnclient.config;
import java.util.List;
import org.saturnclient.config.property.ColorProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.config.property.SelectProperty;
public class Theme {
public static ConfigManager config;
// Main UI background (slightly warm dark to match Saturn tone)
public static final ColorProperty BACKGROUND = Property.color(0xFF12100D);
@@ -34,8 +38,37 @@ public class Theme {
// Font
public static final SelectProperty FONT = Property.font(2);
/**
* A full set of theme colors that can be applied in one go.
*/
public record Preset(String name, int background, int foreground, int primary, int primaryFg, int accent,
int accentFg, int scroll) {
public void apply() {
BACKGROUND.value = background;
FOREGROUND.value = foreground;
PRIMARY.value = primary;
PRIMARY_FG.value = primaryFg;
ACCENT.value = accent;
ACCENT_FG.value = accentFg;
SCROLL.value = scroll;
}
public boolean isActive() {
return BACKGROUND.value == background && FOREGROUND.value == foreground && PRIMARY.value == primary
&& PRIMARY_FG.value == primaryFg && ACCENT.value == accent && ACCENT_FG.value == accentFg
&& SCROLL.value == scroll;
}
}
public static final List<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 = new ConfigManager(parent, "UI Style");
config.property("Background", BACKGROUND);
config.property("Foreground", FOREGROUND);
config.property("Primary", PRIMARY);
@@ -0,0 +1,85 @@
package org.saturnclient.ui.components;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.ColorProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.components.inputs.ColorInput;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.resources.Fonts;
/**
* Lays out the color properties of a config as a two-column grid of tiles,
* each with the color's name and its input.
*/
public final class ColorGrid {
private static final int COLUMNS = 2;
private static final int TILE_HEIGHT = 48;
private static final int GAP = 8;
private static final int INSET = 8;
private ColorGrid() {
}
/**
* Adds a tile for every {@link ColorProperty} in {@code properties} to
* {@code scroll}, starting at {@code y}.
*
* @return the y below the grid
*/
public static int draw(Scroll scroll, Map<String, Property> properties, int width, int y) {
int tileWidth = (width - GAP * (COLUMNS - 1)) / COLUMNS;
List<Element> inputs = new ArrayList<>();
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));
inputs.add(input.position(x + tileWidth - INSET - input.width, tileY + (TILE_HEIGHT - input.height) / 2));
i++;
}
// Inputs go on after every tile, so an open color picker covers the tiles below it.
inputs.forEach(scroll::draw);
int rows = (i + COLUMNS - 1) / COLUMNS;
return y + rows * TILE_HEIGHT + Math.max(0, rows - 1) * GAP;
}
private static class Tile extends Element {
private static final float NAME_SCALE = 0.7f;
private final String name;
private final int inputWidth;
Tile(String name, int width, int inputWidth) {
this.name = name;
this.inputWidth = inputWidth;
this.width = width;
this.height = TILE_HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.72f, Theme.PRIMARY.value), Theme.withAlpha(0.06f, Theme.FOREGROUND.value));
String text = Fonts.ellipsize(name, Theme.FONT.value, NAME_SCALE, width - inputWidth - INSET * 2 - 12);
int nameY = (height - (int) (Fonts.getHeight() * NAME_SCALE)) / 2;
renderScope.drawText(NAME_SCALE, text, 12, nameY, Theme.FONT.value, Theme.FOREGROUND.value);
}
}
}
@@ -66,11 +66,7 @@ public final class PropertyList {
}
if (prop instanceof NamespaceProperty namespace) {
y += SECTION_GAP;
String title = name.toUpperCase(Locale.ROOT);
scroll.draw(new Label(() -> title, SECTION_SCALE, () -> Theme.FONT.value, Theme::getMutedFg)
.position(0, y));
y += (int) (Fonts.getHeight() * SECTION_SCALE) + 4;
y = drawSection(scroll, name, y + SECTION_GAP) + 4;
y = drawRows(scroll, namespace.value, Set.of(), width, y, controls);
continue;
}
@@ -88,6 +84,17 @@ public final class PropertyList {
return y;
}
/**
* Adds a section title, such as a namespace's name, at {@code y}.
*
* @return the y below the title
*/
public static int drawSection(Scroll scroll, String name, int y) {
String title = name.toUpperCase(Locale.ROOT);
scroll.draw(new Label(() -> title, SECTION_SCALE, () -> Theme.FONT.value, Theme::getMutedFg).position(0, y));
return y + (int) (Fonts.getHeight() * SECTION_SCALE);
}
/**
* The input for a property, or null for types without one (strings).
*/
@@ -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,14 +2,13 @@ package org.saturnclient.ui.components;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.common.ref.asset.IdentifierRef;
import org.saturnclient.config.Config;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.elements.Panel;
import org.saturnclient.ui.resources.Textures;
import org.saturnclient.ui.screens.ConfigEditor;
import org.saturnclient.ui.screens.ClientSettings;
import org.saturnclient.ui.screens.HudEditor;
import org.saturnclient.ui.screens.ModMenu;
import org.saturnclient.ui.screens.cosmetics.CloakMenu;
@@ -40,8 +39,7 @@ public class Sidebar extends Element {
new Item(Textures.ICON_MODS, () -> Providers.saturn.getClient().setScreen(new ModMenu())),
new Item(Textures.ICON_COSMETICS, () -> Providers.saturn.getClient().setScreen(new CloakMenu())),
new Item(Textures.ICON_HUD_EDITOR, () -> Providers.saturn.getClient().setScreen(new HudEditor())),
new Item(Textures.ICON_SETTINGS,
() -> Providers.saturn.getClient().setScreen(new ConfigEditor(Config.config))),
new Item(Textures.ICON_SETTINGS, () -> Providers.saturn.getClient().setScreen(new ClientSettings())),
};
private final int active;
@@ -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();
}
}
@@ -6,7 +6,8 @@ import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
/**
* A faint horizontal line across the element's width.
* A faint line filling the element: horizontal by default, or vertical when
* given a width of 1 and a height.
*/
public class Divider extends Element {
@@ -17,6 +18,6 @@ public class Divider extends Element {
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRectangle(0, 0, width, 1, Theme.getBorder());
renderScope.drawRectangle(0, 0, width, height, Theme.getBorder());
}
}
@@ -33,6 +33,9 @@ public class Textures {
public static final IdentifierRef ICON_CLOSE = IdentifierRef.ofSaturn("textures/gui/components/close.svg");
public static final IdentifierRef ICON_SEARCH = IdentifierRef.ofSaturn("textures/gui/components/search.svg");
public static final IdentifierRef ICON_SLIDERS = IdentifierRef.ofSaturn("textures/gui/components/sliders.svg");
public static final IdentifierRef ICON_PALETTE = IdentifierRef.ofSaturn("textures/gui/components/palette.svg");
public static final IdentifierRef ICON_ANIMATIONS = IdentifierRef
.ofSaturn("textures/gui/components/animations.svg");
public static final IdentifierRef ICON_RESET = IdentifierRef.ofSaturn("textures/gui/components/reset.svg");
public static final IdentifierRef ICON_CHEVRON_LEFT = IdentifierRef
.ofSaturn("textures/gui/components/chevron_left.svg");
@@ -0,0 +1,151 @@
package org.saturnclient.ui.screens;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.saturnclient.common.ref.asset.IdentifierRef;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.config.Config;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.ColorProperty;
import org.saturnclient.config.property.NamespaceProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.ui.PanelScreen;
import org.saturnclient.ui.components.ColorGrid;
import org.saturnclient.ui.components.PropertyList;
import org.saturnclient.ui.components.SectionNav;
import org.saturnclient.ui.components.Sidebar;
import org.saturnclient.ui.components.ThemePresetCard;
import org.saturnclient.ui.elements.ActionButton;
import org.saturnclient.ui.elements.Divider;
import org.saturnclient.ui.elements.Label;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.resources.Textures;
/**
* The client's own settings, split into sections picked from a list on the left.
*/
public class ClientSettings extends PanelScreen {
private enum Section {
GENERAL("General", "Title screen, emotes and cosmetics", Textures.ICON_SLIDERS),
UI_STYLE("UI Style", "Colors, corners and font for every Saturn screen", Textures.ICON_PALETTE),
ANIMATIONS("Animations", "How long menu animations take, and their easing", Textures.ICON_ANIMATIONS);
final String title;
final String description;
final IdentifierRef icon;
Section(String title, String description, IdentifierRef icon) {
this.title = title;
this.description = description;
this.icon = icon;
}
}
private static final int NAV_WIDTH = 170;
private static final int NAV_GAP = 16;
private static final int LIST_Y = 58;
private static final int SECTION_GAP = 22;
private static final int PRESET_GAP = 10;
private static final int SCROLLBAR_SPACE = 14;
private Section section = Section.GENERAL;
public ClientSettings() {
super("Settings", Sidebar.SETTINGS);
}
@Override
protected void panel() {
List<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();
switch (section) {
case GENERAL -> PropertyList.draw(scroll, getGeneralProperties(), Set.of(), width, 0);
case UI_STYLE -> drawUiStyle(scroll, width);
case ANIMATIONS -> PropertyList.draw(scroll, AnimationConfig.config.getProperties(), Set.of(), width, 0);
}
draw(scroll.dimensions(width + SCROLLBAR_SPACE, contentHeight - LIST_Y).position(x, contentY + LIST_Y));
}
private void drawUiStyle(Scroll scroll, int width) {
Map<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);
}
/**
* 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,37 +0,0 @@
package org.saturnclient.ui.screens;
import java.util.Set;
import org.saturnclient.config.ConfigManager;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.PanelScreen;
import org.saturnclient.ui.components.PropertyList;
import org.saturnclient.ui.components.Sidebar;
import org.saturnclient.ui.elements.Label;
import org.saturnclient.ui.elements.Scroll;
/**
* Edits every property of a config, such as the client's own settings.
*/
public class ConfigEditor extends PanelScreen {
private static final int SCROLLBAR_SPACE = 14;
private static final int LIST_Y = 44;
private final ConfigManager config;
public ConfigEditor(ConfigManager config) {
super("Config Editor", Sidebar.SETTINGS);
this.config = config;
}
@Override
protected void panel() {
draw(new Label(() -> "Settings", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value)
.position(contentX, contentY + 2));
Scroll scroll = new Scroll(0).noBackground();
PropertyList.draw(scroll, config.getProperties(), Set.of(), contentWidth, 0);
draw(scroll.dimensions(contentWidth + SCROLLBAR_SPACE, contentHeight - LIST_Y)
.position(contentX, contentY + LIST_Y));
}
}
@@ -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"><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