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
@@ -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();
}
}