Fix UI bugs and bring the remaining screens into the new style #7

Merged
selimaj-dev merged 5 commits from ui-fixes into master 2026-09-27 17:15:01 +00:00
7 changed files with 256 additions and 89 deletions
Showing only changes of commit 05dbec028a - Show all commits
@@ -8,6 +8,7 @@ import java.util.Optional;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.emotes.core.Emote;
import org.saturnclient.emotes.core.EmoteRegistry;
import org.saturnclient.ui.Utils;
/**
* Saturn's emotes, loaded from {@code assets/saturnclient/player_animations/<id>.json} (Emotecraft
@@ -23,6 +24,13 @@ public class Emotes {
/** The emotes that loaded, in wheel order. */
public static List<String> availableEmotes = new ArrayList<>();
/**
* The emote's name as shown to players.
*/
public static String getName(String id) {
return id.equals("tpose") ? "T-Pose" : Utils.nameFromId(id);
}
public static void initialize() {
for (String id : IDS) {
String path = "/assets/saturnclient/player_animations/" + id + ".json";
@@ -47,6 +47,24 @@ public class Utils {
};
}
/**
* A display name from an id: each underscore-separated word capitalized, so
* "black_hole_white" becomes "Black Hole White".
*/
public static String nameFromId(String id) {
StringBuilder builder = new StringBuilder();
for (String word : id.split("_")) {
if (word.isEmpty()) {
continue;
}
if (!builder.isEmpty()) {
builder.append(' ');
}
builder.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return builder.toString();
}
public static <T> T getOrNull(List<T> list, int index) {
return (index >= 0 && index < list.size()) ? list.get(index) : null;
}
@@ -0,0 +1,68 @@
package org.saturnclient.ui.components;
import org.saturnclient.common.ref.asset.IdentifierRef;
import org.saturnclient.config.Theme;
import org.saturnclient.cosmetics.Emotes;
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.resources.Textures;
/**
* One emote in the emote wheel: its preview in its own colors and its name.
* An emote without a preview shows an icon instead.
*/
public class EmoteTile extends Element {
public static final int SIZE = 86;
private static final int INSET = 6;
private static final int NAME_AREA = 20;
private static final int PLACEHOLDER = 26;
private static final float NAME_SCALE = 0.56f;
private final String name;
/** The preview, or null when the emote has none */
private final IdentifierRef preview;
private final Runnable onPlay;
public EmoteTile(String emote, Runnable onPlay) {
IdentifierRef texture = Textures.getEmotePreview(emote);
this.name = Emotes.getName(emote);
this.preview = Textures.exists(texture) ? texture : null;
this.onPlay = onPlay;
this.width = SIZE;
this.height = SIZE;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
boolean hovered = ctx.isHovering();
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, hovered ? 2 : 1,
Theme.withAlpha(0.72f, Theme.PRIMARY.value),
hovered ? Theme.ACCENT.value : Theme.withAlpha(0.06f, Theme.FOREGROUND.value));
int imageSize = height - INSET * 2 - NAME_AREA;
if (preview != null) {
renderScope.drawTexture(preview, (width - imageSize) / 2, INSET, 0, 0, imageSize, imageSize);
} else {
int iconY = INSET + (imageSize - PLACEHOLDER) / 2;
renderScope.drawTexture(Textures.ICON_EMOTE, (width - PLACEHOLDER) / 2, iconY, 0, 0, PLACEHOLDER,
PLACEHOLDER, Theme.getMutedFg());
}
String text = Fonts.ellipsize(name, Theme.FONT.value, NAME_SCALE, width - INSET * 2);
int textX = (width - (int) (Fonts.getWidth(text, Theme.FONT.value) * NAME_SCALE)) / 2;
int textY = height - INSET - NAME_AREA + (NAME_AREA - (int) (Fonts.getHeight() * NAME_SCALE)) / 2;
renderScope.drawText(NAME_SCALE, text, textX, textY, Theme.FONT.value,
hovered ? Theme.FOREGROUND.value : Theme.PRIMARY_FG.value);
}
@Override
public void click(int mouseX, int mouseY) {
onPlay.run();
}
}
@@ -1,5 +1,9 @@
package org.saturnclient.ui.resources;
import java.io.IOException;
import java.io.InputStream;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.common.ref.asset.IdentifierRef;
public class Textures {
@@ -43,6 +47,7 @@ public class Textures {
.ofSaturn("textures/gui/components/chevron_right.svg");
public static final IdentifierRef ICON_CHEVRON_DOWN = IdentifierRef
.ofSaturn("textures/gui/components/chevron_down.svg");
public static final IdentifierRef ICON_EMOTE = IdentifierRef.ofSaturn("textures/gui/components/emote.svg");
public static final IdentifierRef ICON_CHECK = IdentifierRef.ofSaturn("textures/gui/components/check.svg");
public static final IdentifierRef LOGO = IdentifierRef.ofSaturn("textures/logo/logo.png");
@@ -61,6 +66,18 @@ public class Textures {
public static final IdentifierRef MOD = IdentifierRef.ofSaturn("textures/gui/components/mod.png");
public static final IdentifierRef MOD_BG = IdentifierRef.ofSaturn("textures/gui/components/mod_bg.png");
/**
* Whether a texture file is in the resources, for falling back when an
* optional one (like an emote's preview) is missing.
*/
public static boolean exists(IdentifierRef texture) {
try (InputStream in = Providers.saturn.getClient().getResource(texture)) {
return in != null;
} catch (IOException e) {
return false;
}
}
public static IdentifierRef getCloakPreview(String cloak) {
return IdentifierRef.ofSaturn("textures/gui/cloak/" + (cloak.isEmpty() ? "none" : cloak) + ".png");
}
@@ -19,6 +19,7 @@ import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.PanelScreen;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.Utils;
import org.saturnclient.ui.anim.SlideFade;
import org.saturnclient.ui.components.CosmeticTile;
import org.saturnclient.ui.components.Sidebar;
@@ -193,18 +194,7 @@ public class CosmeticsMenu extends PanelScreen {
private static String getName(String id) {
String name = NAMES.get(id);
if (name != null) {
return name;
}
StringBuilder builder = new StringBuilder();
for (String word : id.split("_")) {
if (!builder.isEmpty()) {
builder.append(' ');
}
builder.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return builder.toString();
return name != null ? name : Utils.nameFromId(id);
}
/**
@@ -1,21 +1,43 @@
package org.saturnclient.ui.screens;
import org.saturnclient.cosmetics.Emotes;
import java.util.List;
import org.saturnclient.client.ServiceClient;
import org.saturnclient.common.provider.Providers;
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.cosmetics.Emotes;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.Utils;
import org.saturnclient.ui.anim.Fade;
import org.saturnclient.ui.anim.SlideY;
import org.saturnclient.ui.components.EmoteTile;
import org.saturnclient.ui.elements.AnimationStagger;
import org.saturnclient.ui.elements.ImageTexture;
import org.saturnclient.ui.elements.TextureButton;
import org.saturnclient.ui.elements.Label;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
/**
* A 3x3 wheel of emotes to play, with the page controls in the middle. The
* emote wheel key opens and closes it (in the version's key handler).
*/
public class EmoteWheel extends SaturnScreen {
int page = 1;
private static final int GAP = 10;
private static final int GRID = EmoteTile.SIZE * 3 + GAP * 2;
private static final int LOGO = 48;
/** The grid's cells in the order emotes fill them, around the middle one */
private static final int[][] SLOTS = {
{ 0, 0 }, { 1, 0 }, { 2, 0 },
{ 0, 1 }, { 2, 1 },
{ 0, 2 }, { 1, 2 }, { 2, 2 } };
private int page = 0;
public EmoteWheel() {
super("Emote Wheel");
@@ -23,88 +45,131 @@ public class EmoteWheel extends SaturnScreen {
@Override
public void ui() {
int row = 0;
int col = 0;
// =============================
// Logo
// =============================
draw(new ImageTexture(Textures.LOGO_TEXT)
.dimensions(98, 10)
.center(width, height, 0, -105)
.animation(new Fade(AnimationConfig.logo.duration.value)));
List<String> emotes = Emotes.availableEmotes;
int left = (width - GRID) / 2;
int top = (height - GRID) / 2 + LOGO / 2;
draw(new ImageTexture(Config.getLogo())
.dimensions(98, 98)
.center(width, height, 0, -149)
.animation(new SlideY(AnimationConfig.logo, -20)));
.dimensions(LOGO, LOGO)
.position((width - LOGO) / 2, top - 20 - LOGO)
.animation(new SlideY(AnimationConfig.logo, -12)));
// =============================
// Navigation Buttons
// =============================
draw(new TextureButton(Textures.LEFT, () -> {
if (page > 1) {
page--;
elements.clear();
ui();
}
}).dimensions(30, 30)
.center(width, height, -50, 105)
.animation(new Fade(500)));
draw(new TextureButton(Textures.RIGHT, () -> {
if ((page * 8) < Emotes.availableEmotes.size()) {
page++;
elements.clear();
ui();
}
}).dimensions(30, 30)
.center(width, height, 50, 105)
.animation(new Fade(500)));
// =============================
// Emote Grid (Staggered)
// =============================
AnimationStagger emoteStagger = new AnimationStagger(AnimationConfig.emoteWheel);
while (row < 3) {
int idx = (row * 3 + col) + ((page - 1) * 8);
if (row == 1 && col == 1) {
col++;
if (emotes.isEmpty()) {
String message = "No emotes available";
int x = (width - (int) (Fonts.getWidth(message, Theme.getBodyFont()) * 0.7f)) / 2;
draw(new Label(() -> message, 0.7f, Theme::getBodyFont, Theme::getMutedFg).position(x, top + GRID / 2));
return;
}
String emote = Utils.getOrNull(Emotes.availableEmotes, idx);
int pages = (emotes.size() + SLOTS.length - 1) / SLOTS.length;
page = Math.min(page, pages - 1);
emoteStagger.draw(
new TextureButton(Textures.getEmotePreview(emote), () -> {
AnimationStagger grid = new AnimationStagger(AnimationConfig.emoteWheel);
for (int slot = 0; slot < SLOTS.length; slot++) {
int index = page * SLOTS.length + slot;
if (index >= emotes.size()) {
break;
}
String emote = emotes.get(index);
grid.draw(new EmoteTile(emote, () -> play(emote))
.position(SLOTS[slot][0] * (EmoteTile.SIZE + GAP), SLOTS[slot][1] * (EmoteTile.SIZE + GAP))
.animation(new SlideY(AnimationConfig.emoteWheel, 12)));
}
draw(grid.position(left, top));
draw(new PageControl(page, pages, () -> turnPage(-1), () -> turnPage(1))
.position(left + EmoteTile.SIZE + GAP, top + EmoteTile.SIZE + GAP)
.animation(new Fade(AnimationConfig.emoteWheel)));
}
private void play(String emote) {
Providers.saturn.playEmote(ServiceClient.uuid, emote);
ServiceClient.emote(emote);
provider.close();
})
.dimensions(70, 70)
.center(width, height,
-80 + (col * 80),
-35 + (row * 80))
.animation(new SlideY(AnimationConfig.emoteWheel, 12)));
if (col < 2) {
col++;
} else {
col = 0;
row++;
}
}
draw(emoteStagger);
private void turnPage(int direction) {
page += direction;
init();
}
@Override
public boolean shouldPause() {
return false;
}
/**
* The middle of the wheel: "Emotes", and the page with arrows to turn it
* when there's more than one.
*/
private static class PageControl extends Element {
private static final int ARROW = 16;
private static final int ARROW_AREA = 26;
private final int page;
private final int pages;
private final Runnable onPrevious;
private final Runnable onNext;
PageControl(int page, int pages, Runnable onPrevious, Runnable onNext) {
this.page = page;
this.pages = pages;
this.onPrevious = onPrevious;
this.onNext = onNext;
this.width = EmoteTile.SIZE;
this.height = EmoteTile.SIZE;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.getPanelBg(), Theme.getBorder());
float titleScale = 0.54f;
drawCentered(renderScope, "EMOTES", titleScale, pages > 1 ? 24 : (height - 10) / 2, Theme.getMutedFg());
if (pages <= 1) {
return;
}
drawCentered(renderScope, (page + 1) + " / " + pages, 0.66f, 48, Theme.FOREGROUND.value);
int arrowY = 48 + ((int) (Fonts.getHeight() * 0.66f) - ARROW) / 2;
drawArrow(renderScope, ctx, Textures.ICON_CHEVRON_LEFT, 4, arrowY, canGoBack());
drawArrow(renderScope, ctx, Textures.ICON_CHEVRON_RIGHT, width - 4 - ARROW, arrowY, canGoForward());
}
private void drawArrow(RenderScope renderScope, ElementContext ctx, IdentifierRef icon, int x, int y,
boolean enabled) {
boolean hovered = enabled && ctx.isHovering(x - 5, 0, ARROW_AREA, height);
int color = !enabled ? Theme.withAlpha(0.2f, Theme.FOREGROUND.value)
: hovered ? Theme.ACCENT.value : Theme.getMutedFg();
renderScope.drawTexture(icon, x, y, 0, 0, ARROW, ARROW, color);
}
private void drawCentered(RenderScope renderScope, String text, float scale, int y, int color) {
int x = (width - (int) (Fonts.getWidth(text, Theme.FONT.value) * scale)) / 2;
renderScope.drawText(scale, text, x, y, Theme.FONT.value, color);
}
@Override
public void click(int mouseX, int mouseY) {
if (mouseX < ARROW_AREA && canGoBack()) {
onPrevious.run();
} else if (mouseX > width - ARROW_AREA && canGoForward()) {
onNext.run();
}
}
private boolean canGoBack() {
return page > 0;
}
private boolean canGoForward() {
return page < pages - 1;
}
}
}
@@ -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="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>

After

Width:  |  Height:  |  Size: 328 B