Compare commits

...
22 Commits
Author SHA1 Message Date
selimaj-dev ef0ead6bf8 Merge pull request 'Fix UI bugs and bring the remaining screens into the new style' (#7) from ui-fixes into master
Reviewed-on: #7
2026-09-27 17:15:00 +00:00
selimaj-devandclaude 136621cc8d Remove UI code and icons nothing uses
TextureButton, ClickableTexture, Text, Notification and the FadeOut and
SlideX animations only Notification used, Utils.getOrNull and the
commented-out notify, Element.duration, the 17 PNG icons the Lucide SVGs
replaced, and the Textures constants for them and for files that don't
exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 19:14:17 +02:00
selimaj-devandclaude 25d288da92 Rework the HUD editor
HUDs are drawn as they are in game, background included, through the
new HudModPreview.draw, and the world behind the editor isn't blurred.
Each has an outline that turns the accent color with the mod's name while
hovered or dragged, and a visible resize handle that's easier to grab.
Scale stays within its range and HUDs stay on screen, and a hint says how
to use the editor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 19:09:06 +02:00
selimaj-devandclaude f0971ff128 Use one button style and an icon-only dock
Buttons no longer have a primary style: every button is the translucent
outlined surface that brightens on hover, and only an icon button's icon
takes the accent color. The title screen's dock shows just its icons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 19:06:01 +02:00
selimaj-devandclaude 2f4ea77e6f Restyle buttons, the Shift menu and the title screen
Button is now primary (accent fill) or secondary (a translucent outlined
surface), and IconButton is a square secondary button with a Lucide icon,
replacing the PNG TextureButtons. Neither is scaled with Element.scale any
more, which multiplied click positions instead of dividing them.

The Shift menu is the HUD editor, the mod menu (its button was labelled
"Settings") and cosmetics. The title screen centers the logo over a
compact stack of the game's menus, with the player, a dock of Saturn's
screens and the version along the bottom. The player chip shows the
player's face from their skin and whether they're connected to Saturn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 19:01:42 +02:00
selimaj-devandclaude 05dbec028a Fix and restyle the emote wheel
The slot index was taken before the middle slot was skipped, so page one
showed emotes 0-4 and 6-8: emote 5 never appeared and emote 8 showed on
two pages. Emotes now fill the eight cells around the middle in order,
and slots past the last emote aren't drawn, so they can't play a null
emote.

Emotes show as EmoteTiles: the preview in its own colors (it was tinted,
almost black on hover), the name under it, an accent outline on hover, and
an icon when an emote has no preview. The middle cell holds the page and
its arrows when there's more than one page. Emote names and cosmetic names
share Utils.nameFromId.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 18:43:27 +02:00
selimaj-dev caf472da96 Merge pull request 'Redesign the mod menu, settings and cosmetics screens' (#6) from ui-redesign into master
Reviewed-on: #6
2026-09-27 16:27:35 +00:00
selimaj-devandclaude c3667f8f66 Make the background blur adjustable
A "Background blur" setting (0-10, default 5) sets how much the game
behind Saturn's screens is blurred. Screens now only say whether to blur,
and SaturnScreen.getBackgroundBlur gives the setting's strength, faded in
over the screen's first 700 ms. The title menu still doesn't blur.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 18:26:42 +02:00
selimaj-devandclaude 85c7057ba1 Preview easing curves beside curve dropdowns
CurveProperty holds the easing curves in the order Animation.getCurve
takes them, replacing three copies of the list in zoom, freelook and the
animation config; it's saved the same way. Its dropdown opens a
CurveOptions popup: a CurvePreview graph beside the list, with a dot
running along the hovered curve, or the current one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 18:19:18 +02:00
selimaj-devandclaude 55b78989ce Group animation settings per menu
The Animations section shows each menu's duration, stagger and curve
under the menu's name instead of one flat list of 18 rows.
AnimationConfig keeps its name and lists every menu's config; it's saved
under the same keys as before, so existing settings keep loading.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 18:12:30 +02:00
selimaj-devandclaude 8b58fe919d Open dropdowns within the visible part of their list
ElementContext carries the part of the screen where an element can be
seen, passed down in a Frame and narrowed by each Scroll to its own box.
A dropdown opens below only if its list fits in that part, otherwise above
if it fits there, or else on the side with more room, so one at the
bottom of a scrolled list no longer opens past the list's edge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 18:05:26 +02:00
selimaj-devandclaude 6bfdfc40c9 Add dropdowns and a popup color picker
Popup draws one element above the rest of the screen and gets mouse
events first; clicking or scrolling outside closes it. ElementContext now
carries each element's position on screen so an input inside a scroll can
open a popup beside itself.

Select is a dropdown: the current value opens an OptionList with the
current option ticked, scrolling for long lists and opening upward when
there's no room below. The color picker moved out of ColorInput into a
ColorPicker popup, so scroll areas no longer clip it and later rows no
longer draw over it, and all three of its parts can be dragged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 18:02:41 +02:00
selimaj-devandclaude 351507d60d Fix scrolling and keep lists clear of the scrollbar
Scroll kept its position as an int, so wheel and trackpad deltas were
truncated: small ones were lost in one direction and a wheel notch moved
one unit. It now keeps a fractional position, scrolls 24 units a notch,
sizes and places its thumb consistently, and the thumb can be dragged or
the track clicked. PanelScreen's scrollWidth and scrollContentWidth keep
every scrolling list clear of the bar.

Sliders read a float step from its text, so 0.1 no longer shows 17
decimals, and show at most three; number boxes show at most three too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 17:54:12 +02:00
selimaj-devandclaude 33c09b6922 Describe every setting under its name
Properties can carry a one-line description, set with
NamedProperty.description for mods, a ConfigManager.property overload for
the client's config, and a helper in ModLayout. PropertyList rows show it
under the setting's name in muted text. Every mod, HUD layout, general,
UI style and animation setting now has one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 17:47:48 +02:00
selimaj-devandclaude 06a90d8e51 Edit ranged settings with sliders
IntProperty and FloatProperty can take an optional range, step and unit.
PropertyList shows those settings as a Slider: a track to click or drag,
snapping to the step, with the value and unit beside it. Zoom and freelook
levels and durations, fullbright brightness, the theme radii, animation
timings and HUD scale and radius get ranges; the rest stay number boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 17:39:27 +02:00
selimaj-devandclaude 7264c7b38b Redesign cosmetics
CosmeticsMenu replaces the cloak and hat menus and stores: the player on
the left, turned by dragging, and on the right Cloaks and Hats tabs over a
grid of every cosmetic. Clicking an owned one equips it, and clicking one
not owned yet claims it, which is free. The equipped one is outlined and
badged, and the grid asks to connect when there's no Saturn session.

SkinPreview is now a drag-to-rotate player view, and its EntityDrawer draws
the player filling a box at a given yaw, returning false when there's no
player. CosmeticPreview and TabMenu go with the old screens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 17:33:25 +02:00
selimaj-devandclaude a413648be9 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]>
2026-09-27 17:25:47 +02:00
selimaj-devandclaude df09271e81 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]>
2026-09-27 17:13:39 +02:00
selimaj-devandclaude dfebd2367d 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]>
2026-09-27 16:59:51 +02:00
selimaj-devandclaude 4ac7c60d07 Add rounded borders to RenderScope
drawRoundedBorder draws an outline inset into the given bounds so it lines
up with a rounded rectangle of the same size and radius, and a new
drawRoundedRectangle overload draws both with separate background and border
colors. Both shapes share one SVG-to-texture helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 16:46:17 +02:00
selimaj-dev 15fa973553 Merge pull request 'Load emotes with the Saturn emote library' (#5) from emote-library into master
Reviewed-on: #5
2026-09-26 19:17:20 +00:00
selimaj-devandclaude b2cf0cbc38 Load emotes with the Saturn emote library
- Emotes loads assets/saturnclient/player_animations/*.json with
  emotes-core into Saturn's own EmoteRegistry (not the library's shared
  one, so other mods can't replace them), and lists the ones that loaded
  for the wheel.
- The emote wheel now also sends the emote to the server, so other
  players see it. It only played locally before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:16:20 +02:00
137 changed files with 4190 additions and 1876 deletions
+1
View File
@@ -8,4 +8,5 @@ java {
dependencies {
implementation "dev.selimaj:session-java:0.2.0"
implementation "org.saturnclient:emotes-core:${emotes_version}"
}
@@ -22,6 +22,12 @@ public interface SaturnProvider {
/** The Saturn Client mod version, e.g. {@code 0.1.0-beta3+1.21.11}. */
public String getModVersion();
/**
* The signed-in player's 64x64 skin texture, or the default skin until
* theirs has loaded.
*/
public IdentifierRef getPlayerSkin();
/** Shows a warning toast. Safe to call from any thread. */
public void showWarning(String title, String message);
}
@@ -1,8 +1,12 @@
package org.saturnclient.config;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.saturnclient.config.property.CurveProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.config.property.SelectProperty;
public class AnimationConfig {
public static ConfigManager config;
@@ -25,6 +29,10 @@ public class AnimationConfig {
// Emote wheel
public static final AnimationConfig emoteWheel = new AnimationConfig(true, 700, 40);
/** Every menu's animation, in the order they're shown in the settings */
public static final List<AnimationConfig> ALL = List.of(logo, mainMenu, modMenu, shiftMenu, cosmeticsMenu,
emoteWheel);
public static void init(ConfigManager parent) {
config = new ConfigManager(parent, "Animations");
@@ -39,18 +47,36 @@ public class AnimationConfig {
// Animation properties
public final IntProperty duration;
public final IntProperty stagger;
public final SelectProperty curve;
public final CurveProperty curve;
public AnimationConfig(boolean enabled, int delay, int stagger) {
this.duration = Property.integer(delay);
this.stagger = Property.integer(stagger);
this.curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back");
this.duration = Property.integer(delay).range(0, 2000, 50).unit("ms");
this.stagger = Property.integer(stagger).range(0, 300, 10).unit("ms");
this.curve = Property.curve(1);
}
/** The menu this animates, like "Mod Menu" */
public String name;
/**
* This animation's settings under short names ("Duration"), for showing
* them grouped under {@link #name}. They're saved under longer names
* ("Mod Menu Duration"), which stay as they are so saved settings keep
* loading.
*/
public Map<String, Property> getProperties() {
Map<String, Property> properties = new LinkedHashMap<>();
properties.put("Duration", duration);
properties.put("Stagger", stagger);
properties.put("Curve", curve);
return properties;
}
public void init(String name) {
this.name = name;
// config.property("Enable " + name + " Animations", this.enabled);
config.property(name + " Duration", this.duration);
config.property(name + " Stagger", this.stagger);
config.property(name + " Curve", this.curve);
config.property(name + " Duration", this.duration, "How long the animation takes");
config.property(name + " Stagger", this.stagger, "Delay between items appearing one after another");
config.property(name + " Curve", this.curve, "Easing of the animation");
}
}
@@ -6,6 +6,7 @@ import org.saturnclient.common.provider.GLFWProvider;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.common.ref.asset.IdentifierRef;
import org.saturnclient.config.property.BoolProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.KeybindingProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.ui.resources.Textures;
@@ -17,6 +18,7 @@ public class Config {
public static BoolProperty saturnTitleScreen = Property.bool(true);
public static BoolProperty cloakPhysics = Property.bool(true);
public static BoolProperty stagger = Property.bool(true);
public static IntProperty backgroundBlur = Property.integer(5).range(0, 10);
public static KeybindingProperty openEmoteWheel = Property.keybinding(GLFWProvider.GLFW_KEY_B);
public static IdentifierRef getLogo() {
@@ -27,11 +29,12 @@ public class Config {
File configFile = new File(Providers.saturn.getClient().getRunDirectory(), "saturn.json");
config = new ConfigManager(configFile, "Saturn Client");
config.property("Realistic logo", realisticLogo);
config.property("Saturn client title screen", saturnTitleScreen);
config.property("Open Emote Wheel", openEmoteWheel);
config.property("Cloak Physics", cloakPhysics);
config.property("Stagger Animations", stagger);
config.property("Realistic logo", realisticLogo, "Use the realistic Saturn logo");
config.property("Saturn client title screen", saturnTitleScreen, "Replace the title screen with Saturn's");
config.property("Open Emote Wheel", openEmoteWheel, "The key that opens the emote wheel");
config.property("Cloak Physics", cloakPhysics, "Let cloaks swing as you move");
config.property("Stagger Animations", stagger, "Animate list items one after another");
config.property("Background blur", backgroundBlur, "How much the game behind Saturn's screens is blurred");
// Initialize a sub namespace for theme
Theme.init(config);
@@ -70,6 +70,14 @@ public class ConfigManager {
return value;
}
/**
* Add a property with the line shown under its name
*/
public Property property(String name, Property value, String description) {
value.description = description;
return property(name, value);
}
/**
* Add empty namespace
*/
@@ -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);
@@ -28,26 +32,55 @@ public class Theme {
public static final ColorProperty SCROLL = Property.color(0xFFE2BF7D);
// UI metrics
public static final IntProperty BG_RADIUS = Property.integer(12);
public static final IntProperty WIDGET_RADIUS = Property.integer(10);
public static final IntProperty BG_RADIUS = Property.integer(12).range(0, 24);
public static final IntProperty WIDGET_RADIUS = Property.integer(10).range(0, 20);
// 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.property("Background", BACKGROUND);
config.property("Foreground", FOREGROUND);
config.property("Primary", PRIMARY);
config.property("Primary Foreground", PRIMARY_FG);
config.property("Accent", ACCENT);
config.property("Accent Foreground", ACCENT_FG);
config.property("Scrollbar Color", SCROLL);
config = new ConfigManager(parent, "UI Style");
config.property("Background", BACKGROUND, "Panels and screens");
config.property("Foreground", FOREGROUND, "Text");
config.property("Primary", PRIMARY, "Buttons and cards");
config.property("Primary Foreground", PRIMARY_FG, "Text on buttons and cards");
config.property("Accent", ACCENT, "Selected and enabled items");
config.property("Accent Foreground", ACCENT_FG, "Text on the accent color");
config.property("Scrollbar Color", SCROLL, "The scrollbar's handle");
config.property("Background Radius", BG_RADIUS);
config.property("Widget Radius", WIDGET_RADIUS);
config.property("Background Radius", BG_RADIUS, "Corner rounding of panels");
config.property("Widget Radius", WIDGET_RADIUS, "Corner rounding of buttons and cards");
config.property("Font", FONT);
config.property("Font", FONT, "Font of every Saturn screen");
}
/**
@@ -64,6 +97,35 @@ public class Theme {
return hover ? ACCENT_FG.value : PRIMARY_FG.value;
}
/**
* Translucent background for panels drawn over the world
*/
public static int getPanelBg() {
return withAlpha(0.82f, BACKGROUND.value);
}
/**
* Faint outline for panels, cards and inputs
*/
public static int getBorder() {
return withAlpha(0.08f, FOREGROUND.value);
}
/**
* Secondary text: descriptions, counts and placeholders
*/
public static int getMutedFg() {
return withAlpha(0.65f, FOREGROUND.value);
}
/**
* Font for secondary text. Inter bold is too heavy there, so it becomes
* regular Inter; other fonts are kept.
*/
public static int getBodyFont() {
return FONT.value == 2 ? 1 : FONT.value;
}
/**
* Multiplies existing alpha with 0–255 alpha.
*/
@@ -12,6 +12,12 @@ public class BoolProperty extends Property {
this.defaultValue = defaultValue;
}
@Override
public void reset() {
this.value = defaultValue;
this.isReset = true;
}
@Override
public JsonNode toJson() {
return JsonNodeFactory.instance.booleanNode(this.value);
@@ -12,6 +12,12 @@ public class ColorProperty extends Property {
this.defaultValue = defaultValue;
}
@Override
public void reset() {
this.value = defaultValue;
this.isReset = true;
}
@Override
public JsonNode toJson() {
return JsonNodeFactory.instance.textNode(
@@ -0,0 +1,12 @@
package org.saturnclient.config.property;
/**
* An animation's easing curve. The options are in the order
* {@code Animation.getCurve} takes them, and it's saved like any select.
*/
public class CurveProperty extends SelectProperty {
public CurveProperty(int defaultValue) {
super(defaultValue, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back");
}
}
@@ -7,11 +7,42 @@ public class FloatProperty extends Property {
public float value;
public final float defaultValue;
/** Bounds for a slider, when the setting has a sensible range */
public Float min, max;
public float step = 0.1f;
/** Shown after the value, like "×" */
public String unit = "";
public FloatProperty(float defaultValue) {
this.value = defaultValue;
this.defaultValue = defaultValue;
}
/**
* Gives the setting a range, so it's edited with a slider.
*/
public FloatProperty range(float min, float max, float step) {
this.min = min;
this.max = max;
this.step = step;
return this;
}
public FloatProperty unit(String unit) {
this.unit = unit;
return this;
}
public boolean hasRange() {
return min != null && max != null;
}
@Override
public void reset() {
this.value = defaultValue;
this.isReset = true;
}
@Override
public JsonNode toJson() {
return JsonNodeFactory.instance.numberNode(this.value);
@@ -7,11 +7,46 @@ public class IntProperty extends Property {
public int value;
public final int defaultValue;
/** Bounds for a slider, when the setting has a sensible range */
public Integer min, max;
public int step = 1;
/** Shown after the value, like "ms" */
public String unit = "";
public IntProperty(int defaultValue) {
this.value = defaultValue;
this.defaultValue = defaultValue;
}
/**
* Gives the setting a range, so it's edited with a slider.
*/
public IntProperty range(int min, int max) {
return range(min, max, 1);
}
public IntProperty range(int min, int max, int step) {
this.min = min;
this.max = max;
this.step = step;
return this;
}
public IntProperty unit(String unit) {
this.unit = unit;
return this;
}
public boolean hasRange() {
return min != null && max != null;
}
@Override
public void reset() {
this.value = defaultValue;
this.isReset = true;
}
@Override
public JsonNode toJson() {
return JsonNodeFactory.instance.numberNode(this.value);
@@ -15,6 +15,12 @@ public class KeybindingProperty extends Property {
this.defaultValue = defaultValue;
}
@Override
public void reset() {
this.value = defaultValue;
this.isReset = true;
}
@Override
public JsonNode toJson() {
return JsonNodeFactory.instance.numberNode(this.value);
@@ -8,4 +8,12 @@ public class NamedProperty {
this.name = name;
this.prop = value;
}
/**
* Sets the line shown under the setting's name.
*/
public NamedProperty description(String description) {
this.prop.description = description;
return this;
}
}
@@ -13,6 +13,11 @@ public class NamespaceProperty extends Property {
this.value = value;
}
@Override
public void reset() {
this.value.values().forEach(Property::reset);
}
@Override
public JsonNode toJson() {
ObjectNode nested = JsonNodeFactory.instance.objectNode();
@@ -7,6 +7,9 @@ import java.util.Map;
public abstract class Property {
public boolean isReset = false;
/** One line under the setting's name saying what it does, or "" */
public String description = "";
// ---------- Factory Methods ----------
public static NamespaceProperty namespace(Map<String, Property> value) {
@@ -41,6 +44,10 @@ public abstract class Property {
return select(value, "Default", "Inter", "Inter bold");
}
public static CurveProperty curve(int value) {
return new CurveProperty(value);
}
public static SelectProperty select(Integer value, String... availableValues) {
return new SelectProperty(value, availableValues);
}
@@ -14,6 +14,12 @@ public class SelectProperty extends Property {
this.availableValues = availableValues;
}
@Override
public void reset() {
this.value = defaultValue;
this.isReset = true;
}
@Override
public JsonNode toJson() {
return JsonNodeFactory.instance.numberNode(this.value);
@@ -12,6 +12,12 @@ public class StringProperty extends Property {
this.defaultValue = defaultValue;
}
@Override
public void reset() {
this.value = defaultValue;
this.isReset = true;
}
@Override
public JsonNode toJson() {
return JsonNodeFactory.instance.textNode(this.value);
@@ -1,17 +1,53 @@
package org.saturnclient.cosmetics;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
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
* format). They live in Saturn's own registry rather than the emote library's shared one, so another
* mod using the library can't replace them.
*/
public class Emotes {
private static final List<String> IDS = List.of("tpose", "back_flip", "facepalm", "cry", "giga_chad",
"front_flip");
private static final EmoteRegistry REGISTRY = new EmoteRegistry();
/** 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() {
availableEmotes.add("tpose");
availableEmotes.add("back_flip");
availableEmotes.add("facepalm");
availableEmotes.add("cry");
availableEmotes.add("giga_chad");
availableEmotes.add("front_flip");
for (String id : IDS) {
String path = "/assets/saturnclient/player_animations/" + id + ".json";
try (InputStream in = Emotes.class.getResourceAsStream(path)) {
if (in == null) {
Providers.saturn.logError("Missing emote " + path);
continue;
}
REGISTRY.loadEmotecraft(id, in);
availableEmotes.add(id);
} catch (Exception e) {
Providers.saturn.logError("Failed to load emote " + id, e);
}
}
}
public static Optional<Emote> get(String id) {
return REGISTRY.get(id);
}
}
@@ -1,5 +1,6 @@
package org.saturnclient.mod;
import java.util.LinkedHashMap;
import java.util.Map;
import org.saturnclient.config.property.Property;
@@ -12,10 +13,10 @@ import org.saturnclient.config.property.NamedProperty;
public class ModLayout {
public IntProperty x = Property.integer(0);
public IntProperty y = Property.integer(0);
public FloatProperty scale = Property.floatProp(1.0f);
public FloatProperty scale = Property.floatProp(1.0f).range(0.5f, 4f, 0.1f).unit("×");
public ColorProperty bgColor = Property.color(0x00000000);
public ColorProperty fgColor = Property.color(0xFFffffff);
public IntProperty radius = Property.integer(0);
public IntProperty radius = Property.integer(0).range(0, 20);
public SelectProperty font = Property.font(0);
public boolean renderBackground = true;
@@ -23,14 +24,22 @@ public class ModLayout {
public int height = 0;
public NamedProperty prop() {
return Property.namespace(Map.of(
"X", x,
"Y", y,
"Scale", scale,
"Background Color", bgColor,
"Foreground Color", fgColor,
"Corner Radius", radius,
"Font", font)).named("In-Game Display");
// A LinkedHashMap, unlike Map.of, keeps the order the settings are shown in.
Map<String, Property> props = new LinkedHashMap<>();
put(props, "X", x, "Distance from the left of the screen");
put(props, "Y", y, "Distance from the top of the screen");
put(props, "Scale", scale, "Size of the display");
put(props, "Background Color", bgColor, "Fill behind the display");
put(props, "Foreground Color", fgColor, "Color of the text");
put(props, "Corner Radius", radius, "Rounding of the background's corners");
put(props, "Font", font, "Font of the text");
return Property.namespace(props).named("In-Game Display");
}
private static void put(Map<String, Property> props, String name, Property prop, String description) {
prop.description = description;
props.put(name, prop);
}
public ModLayout() {
@@ -32,7 +32,7 @@ public class ArmorDisplayMod extends Mod implements HudMod {
.tags("Utility")
.requires(Providers.feature::player),
enabled.named("Enabled"),
useMainHand.named("Use Main Hand"),
useMainHand.named("Use Main Hand").description("Also show the item in your main hand"),
layout.prop());
}
@@ -31,8 +31,8 @@ public class ClockMod extends Mod implements HudMod {
.version("v0.2.0")
.tags("Utility"),
enabled.named("Enabled"),
format.named("Format"),
showSeconds.named("Show seconds"),
format.named("Format").description("24-hour or 12-hour time"),
showSeconds.named("Show seconds").description("Include seconds in the time"),
layout.prop());
}
@@ -33,7 +33,7 @@ public class CoordinatesMod extends Mod implements HudMod {
.tags("Utility")
.requires(Providers.feature::player),
enabled.named("Enabled"),
displayMethod.named("Display Method"),
displayMethod.named("Display Method").description("One line or one per axis, with or without labels"),
layout.prop());
}
@@ -5,11 +5,11 @@ import java.util.function.Function;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.common.provider.GLFWProvider;
import org.saturnclient.config.property.BoolProperty;
import org.saturnclient.config.property.CurveProperty;
import org.saturnclient.config.property.FloatProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.KeybindingProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.config.property.SelectProperty;
import org.saturnclient.mod.Mod;
import org.saturnclient.mod.ModSpec;
import org.saturnclient.common.feature.RenderFeature;
@@ -22,10 +22,10 @@ public class FreelookMod extends Mod {
private static final BoolProperty toggle = Property.bool(true);
private static final KeybindingProperty freelookKey = Property.keybinding(GLFWProvider.GLFW_KEY_H);
public static final FloatProperty zoomLevel = Property.floatProp(2.0f);
public static final IntProperty zoomInDuration = Property.integer(500);
public static final IntProperty zoomOutDuration = Property.integer(300);
public static final SelectProperty curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic", "Ease In Out Back");
public static final FloatProperty zoomLevel = Property.floatProp(2.0f).range(1f, 5f, 0.1f).unit("×");
public static final IntProperty zoomInDuration = Property.integer(500).range(0, 2000, 50).unit("ms");
public static final IntProperty zoomOutDuration = Property.integer(300).range(0, 2000, 50).unit("ms");
public static final CurveProperty curve = Property.curve(1);
// --- State Variables ---
private static boolean isFreeLooking = false;
@@ -43,12 +43,12 @@ public class FreelookMod extends Mod {
.version("v0.3.1")
.requires(Providers.feature::render),
enabled.named("Enabled"),
toggle.named("Toggle freelook"),
freelookKey.named("Freelook Keybinding"),
zoomLevel.named("Zoom Level"),
zoomInDuration.named("Zoom In Duration"),
zoomOutDuration.named("Zoom Out Duration"),
curve.named("Curve"));
toggle.named("Toggle freelook").description("Press the key once instead of holding it"),
freelookKey.named("Freelook Keybinding").description("The key that starts freelook"),
zoomLevel.named("Zoom Level").description("Zoom when freelook starts in first person"),
zoomInDuration.named("Zoom In Duration").description("How long that zoom takes to ease in"),
zoomOutDuration.named("Zoom Out Duration").description("How long that zoom takes to ease out"),
curve.named("Curve").description("Easing of the zoom"));
}
// --- Lifecycle Methods ---
@@ -19,7 +19,7 @@ import org.saturnclient.mod.ModSpec;
public class FullbrightMod extends Mod {
private static final BoolProperty enabled = Property.bool(false);
private static final IntProperty brightness = Property.integer(100);
private static final IntProperty brightness = Property.integer(100).range(0, 1000, 10);
public FullbrightMod() {
super(
@@ -28,7 +28,7 @@ public class FullbrightMod extends Mod {
.tags("Camera")
.version("v0.2.0"),
enabled.named("Enabled"),
brightness.named("Brightness %"));
brightness.named("Brightness %").description("How bright the world gets"));
}
// ---------------------------------------------------------------
@@ -31,8 +31,8 @@ public class HealthDisplayMod extends Mod implements HudMod {
.tags("Utility")
.requires(Providers.feature::player),
enabled.named("Enabled"),
displayMode.named("Display mode"),
decimals.named("Decimals"),
displayMode.named("Display mode").description("Show health points or hearts"),
decimals.named("Decimals").description("Decimal places shown"),
layout.prop());
}
@@ -54,11 +54,11 @@ public class KeystrokesMod extends Mod implements HudMod {
.tags("Utility")
.requires(Providers.feature::player),
enabled.named("Enabled"),
showMouse.named("Show mouse clicks"),
showSpace.named("Show space"),
showMouse.named("Show mouse clicks").description("Show the left and right mouse buttons"),
showSpace.named("Show space").description("Show the space bar"),
layout.prop(),
clickFg.named("Clicked fg"),
clickBg.named("Clicked bg"));
clickFg.named("Clicked fg").description("Text color of a pressed key"),
clickBg.named("Clicked bg").description("Background of a pressed key"));
layout.renderBackground = false;
}
@@ -58,16 +58,16 @@ public class NametagsMod extends Mod {
.tags("Camera", "Utility")
.version("v0.2.0"),
enabled.named("Enabled"),
healthDisplay.named("Display health"),
players.named("Players"),
hostile.named("Hostile"),
passive.named("Passive"),
heartEmoji.named("Heart emoji"),
obfuscate.named("Obfuscate enemy names"),
unit.named("Unit"),
format.named("Format"),
nameColor.named("Name color"),
healthColor.named("Health color"));
healthDisplay.named("Display health").description("Show health after the name"),
players.named("Players").description("Show health on players"),
hostile.named("Hostile").description("Show health on hostile mobs"),
passive.named("Passive").description("Show health on passive mobs"),
heartEmoji.named("Heart emoji").description("Add a heart after the health"),
obfuscate.named("Obfuscate enemy names").description("Scramble names into unreadable text"),
unit.named("Unit").description("Show health points or hearts"),
format.named("Format").description("Value, value out of total, or percent"),
nameColor.named("Name color").description("Color of the name"),
healthColor.named("Health color").description("Color of the health"));
}
// ---------------------------------------------------------------
@@ -34,9 +34,9 @@ public class SpeedometerMod extends Mod implements HudMod {
.tags("Utility")
.requires(Providers.feature::player),
enabled.named("Enabled"),
axis.named("Speed type"),
unitText.named("Unit text"),
showLabel.named("Show speed label"),
axis.named("Speed type").description("All movement, only horizontal or only vertical"),
unitText.named("Unit text").description("Unit shown after the speed"),
showLabel.named("Show speed label").description("Show “Speed:” before the value"),
layout.prop());
}
@@ -4,11 +4,11 @@ import java.util.function.Function;
import org.saturnclient.common.provider.GLFWProvider;
import org.saturnclient.config.property.BoolProperty;
import org.saturnclient.config.property.CurveProperty;
import org.saturnclient.config.property.FloatProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.KeybindingProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.config.property.SelectProperty;
import org.saturnclient.mod.Mod;
import org.saturnclient.mod.ModSpec;
import org.saturnclient.ui.anim.Animation;
@@ -18,11 +18,10 @@ public class ZoomMod extends Mod {
private static final BoolProperty enabled = Property.bool(false);
private static final BoolProperty toggle = Property.bool(false);
public static final KeybindingProperty zoomKey = Property.keybinding(GLFWProvider.GLFW_KEY_C);
public static final FloatProperty zoomLevel = Property.floatProp(3.0f);
public static final IntProperty zoomInDuration = Property.integer(700);
public static final IntProperty zoomOutDuration = Property.integer(300);
public static final SelectProperty curve = Property.select(1, "Ease In Out Cubic", "Ease Out Cubic",
"Ease In Out Back");
public static final FloatProperty zoomLevel = Property.floatProp(3.0f).range(1f, 10f, 0.5f).unit("×");
public static final IntProperty zoomInDuration = Property.integer(700).range(0, 2000, 50).unit("ms");
public static final IntProperty zoomOutDuration = Property.integer(300).range(0, 2000, 50).unit("ms");
public static final CurveProperty curve = Property.curve(1);
public ZoomMod() {
super(
@@ -31,12 +30,12 @@ public class ZoomMod extends Mod {
.tags("Camera", "Utility")
.version("v0.2.0"),
enabled.named("Enabled"),
toggle.named("Toggle zoom"),
zoomKey.named("Zoom Keybinding"),
zoomLevel.named("Zoom Level"),
zoomInDuration.named("Zoom In Duration"),
zoomOutDuration.named("Zoom Out Duration"),
curve.named("Curve"));
toggle.named("Toggle zoom").description("Press the key once instead of holding it"),
zoomKey.named("Zoom Keybinding").description("The key that zooms in"),
zoomLevel.named("Zoom Level").description("How far the camera zooms in"),
zoomInDuration.named("Zoom In Duration").description("How long zooming in takes"),
zoomOutDuration.named("Zoom Out Duration").description("How long zooming back out takes"),
curve.named("Curve").description("Easing of the zoom"));
}
// ---------------------------------------------------------------
@@ -11,7 +11,6 @@ public class Element {
public boolean focused;
public Animation animation;
public float scale = 1.0f;
public Integer duration = null; // Element duration, will fade out if not null
public void render(RenderScope renderScope, ElementContext ctx) {
}
@@ -1,20 +1,54 @@
package org.saturnclient.ui;
public class ElementContext {
/**
* Where a container's children are drawn: the screen position of their
* (0, 0), and the part of the screen, top to bottom, where they can be seen.
*/
public record Frame(int x, int y, int visibleTop, int visibleBottom) {
/** The whole screen, for top-level elements */
public static final Frame SCREEN = new Frame(0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE);
}
public int mouseX, mouseY;
/** The element's top-left corner on the screen, for placing popups next to it */
public int originX, originY;
/** The part of the screen, top to bottom, where the element can be seen */
public int visibleTop, visibleBottom;
public int elementWidth, elementHeight;
public float elementScale;
public long elapsed;
public ElementContext(long elapsed, int mouseX, int mouseY, Element e) {
public ElementContext(long elapsed, int mouseX, int mouseY, Frame frame, Element e) {
this.mouseX = mouseX - e.x;
this.mouseY = mouseY - e.y;
this.originX = frame.x() + e.x;
this.originY = frame.y() + e.y;
this.visibleTop = frame.visibleTop();
this.visibleBottom = frame.visibleBottom();
this.elementWidth = e.width;
this.elementHeight = e.height;
this.elementScale = e.scale;
this.elapsed = elapsed;
}
/**
* The frame for this element's children, drawn {@code dx}, {@code dy} from
* its corner and seen only between {@code clipTop} and {@code clipBottom}
* (relative to its corner).
*/
public Frame childFrame(int dx, int dy, int clipTop, int clipBottom) {
return new Frame(originX + dx, originY + dy,
Math.max(visibleTop, originY + clipTop), Math.min(visibleBottom, originY + clipBottom));
}
/**
* The frame for children drawn at this element's corner and not clipped.
*/
public Frame childFrame() {
return new Frame(originX, originY, visibleTop, visibleBottom);
}
public boolean isHovering() {
return Utils.isHovering(mouseX, mouseY, elementWidth, elementHeight, elementScale);
}
@@ -15,6 +15,14 @@ public class ElementRenderer {
}
public static void render(List<Element> elements, long elapsed, RenderScope renderScope, int mouseX, int mouseY) {
render(elements, elapsed, renderScope, mouseX, mouseY, ElementContext.Frame.SCREEN);
}
/**
* Renders a container's children within its {@code frame}.
*/
public static void render(List<Element> elements, long elapsed, RenderScope renderScope, int mouseX, int mouseY,
ElementContext.Frame frame) {
for (Element element : elements) {
element.playAnimationFrame(elapsed);
@@ -22,7 +30,7 @@ public class ElementRenderer {
renderScope.setOpacity(element.opacity);
renderScope.getMatrixStack().translate(element.x, element.y);
renderScope.getMatrixStack().scale(element.scale, element.scale);
element.render(renderScope, new ElementContext(elapsed, mouseX, mouseY, element));
element.render(renderScope, new ElementContext(elapsed, mouseX, mouseY, frame, element));
renderScope.getMatrixStack().pop();
}
}
@@ -0,0 +1,82 @@
package org.saturnclient.ui;
import org.saturnclient.ui.components.Sidebar;
import org.saturnclient.ui.elements.ImageTexture;
import org.saturnclient.ui.elements.Panel;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.resources.Textures;
/**
* A screen laid out as the sidebar beside one panel, centered and shrunk to
* fit smaller windows. Subclasses fill the panel in {@link #panel()}, using the
* content bounds this class computes.
*/
public abstract class PanelScreen extends SaturnScreen {
private static final int MAX_WIDTH = 900;
private static final int MAX_HEIGHT = 580;
private static final int MARGIN = 40;
private static final int GAP = 12;
protected static final int PADDING = 20;
/** How far a scroll reaches past its content area, into the padding, for its bar */
private static final int SCROLL_OVERHANG = 14;
private final int sidebarItem;
protected int panelX, panelY, panelWidth, panelHeight;
/** The panel's area inside its padding */
protected int contentX, contentY, contentWidth, contentHeight;
/**
* @param sidebarItem the {@link Sidebar} item to highlight
*/
public PanelScreen(String title, int sidebarItem) {
super(title);
this.sidebarItem = sidebarItem;
}
@Override
public final void ui() {
panelWidth = Math.min(MAX_WIDTH, width - MARGIN * 2 - Sidebar.WIDTH - GAP);
panelHeight = Math.min(MAX_HEIGHT, height - MARGIN * 2);
int left = (width - (Sidebar.WIDTH + GAP + panelWidth)) / 2;
panelX = left + Sidebar.WIDTH + GAP;
panelY = (height - panelHeight) / 2;
contentX = panelX + PADDING;
contentY = panelY + PADDING;
contentWidth = panelWidth - PADDING * 2;
contentHeight = panelHeight - PADDING * 2;
draw(new Sidebar(sidebarItem, this.provider::close)
.dimensions(Sidebar.WIDTH, panelHeight)
.position(left, panelY));
draw(new Panel().dimensions(panelWidth, panelHeight).position(panelX, panelY));
panel();
draw(new ImageTexture(Textures.LOGO_TEXT_BIG).dimensions(180, 18).position(width - 180 - 20, height - 18 - 20));
}
/**
* Draws the panel's content.
*/
protected abstract void panel();
/**
* The width of a scroll filling {@code width} of the content area.
*/
protected static int scrollWidth(int width) {
return width + SCROLL_OVERHANG;
}
/**
* The width left for a scroll's children, clear of its scrollbar.
*/
protected static int scrollContentWidth(int width) {
return scrollWidth(width) - Scroll.SCROLLBAR_SPACE;
}
}
@@ -0,0 +1,153 @@
package org.saturnclient.ui;
import java.util.List;
/**
* A single element drawn above the rest of the screen, such as a dropdown's
* options or a color picker. It gets mouse events before anything else, and a
* click or scroll outside it closes it.
*
* Only one screen is open at a time, so the popup is kept here rather than on
* the screen; {@link SaturnScreen} forwards to it and clears it when it's
* rebuilt.
*/
public final class Popup {
private static final int SCREEN_MARGIN = 8;
private static final int OWNER_GAP = 4;
private static Element popup;
private static Element owner;
private static int screenWidth, screenHeight;
private Popup() {
}
/**
* Opens {@code popup} with its top-left corner at ({@code x}, {@code y}) on
* the screen, moved as needed to stay on it.
*
* @param owner the element that opened it, for {@link #isOpenFor}
*/
public static void open(Element owner, Element popup, int x, int y) {
Popup.owner = owner;
Popup.popup = popup;
popup.x = clamp(x, popup.width, screenWidth);
popup.y = clamp(y, popup.height, screenHeight);
}
/**
* Opens {@code popup} just below its owner if it fits in the part of the
* screen where the owner can be seen, such as a scroll's box; otherwise
* above it if it fits there, or else on the side with more room.
*
* @param ownerContext the owner's context from its last render
*/
public static void openBelow(Element owner, Element popup, ElementContext ownerContext) {
openBelow(owner, popup, ownerContext, 0);
}
/**
* Like {@link #openBelow(Element, Element, ElementContext)}, with the popup
* moved {@code offsetX} from the owner's left edge.
*/
public static void openBelow(Element owner, Element popup, ElementContext ownerContext, int offsetX) {
int top = Math.max(ownerContext.visibleTop, SCREEN_MARGIN);
int bottom = Math.min(ownerContext.visibleBottom, screenHeight - SCREEN_MARGIN);
int below = ownerContext.originY + owner.height + OWNER_GAP;
int above = ownerContext.originY - OWNER_GAP - popup.height;
boolean fitsBelow = below + popup.height <= bottom;
boolean fitsAbove = above >= top;
boolean moreRoomBelow = bottom - below >= ownerContext.originY - OWNER_GAP - top;
int y = fitsBelow || (!fitsAbove && moreRoomBelow) ? below : above;
open(owner, popup, ownerContext.originX + offsetX, y);
}
public static void close() {
popup = null;
owner = null;
}
public static boolean isOpenFor(Element element) {
return popup != null && owner == element;
}
// ---------------------------------------------------------------
// Called by SaturnScreen
// ---------------------------------------------------------------
static void reset(int width, int height) {
close();
screenWidth = width;
screenHeight = height;
}
static void render(RenderScope renderScope, long elapsed, int mouseX, int mouseY) {
if (popup != null) {
ElementRenderer.render(List.of(popup), elapsed, renderScope, mouseX, mouseY);
}
}
/**
* @return whether the popup took the click; a click outside it closes it
* and is taken too, so it doesn't reach what's underneath
*/
static boolean mouseClicked(double mouseX, double mouseY, int button) {
if (popup == null) {
return false;
}
if (isInside(mouseX, mouseY)) {
ElementRenderer.mouseClicked(List.of(popup), mouseX, mouseY, button);
} else {
close();
}
return true;
}
static boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
if (popup == null) {
return false;
}
ElementRenderer.mouseDragged(List.of(popup), mouseX, mouseY, button, deltaX, deltaY);
return true;
}
static void mouseReleased(double mouseX, double mouseY, int button) {
if (popup != null) {
ElementRenderer.mouseReleased(List.of(popup), mouseX, mouseY, button);
}
}
/**
* @return whether the popup took the scroll; scrolling outside it closes it,
* since it wouldn't move with what's underneath
*/
static boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
if (popup == null) {
return false;
}
if (isInside(mouseX, mouseY)) {
ElementRenderer.mouseScrolled(List.of(popup), mouseX, mouseY, horizontalAmount, verticalAmount);
} else {
close();
}
return true;
}
private static boolean isInside(double mouseX, double mouseY) {
return mouseX >= popup.x && mouseX <= popup.x + popup.width
&& mouseY >= popup.y && mouseY <= popup.y + popup.height;
}
private static int clamp(int position, int size, int screenSize) {
return Math.max(SCREEN_MARGIN, Math.min(position, screenSize - SCREEN_MARGIN - size));
}
}
@@ -3,6 +3,7 @@ package org.saturnclient.ui;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import org.saturnclient.common.ref.asset.IdentifierRef;
import org.saturnclient.common.ref.asset.SpriteRef;
@@ -36,19 +37,66 @@ public interface RenderScope {
radius = Math.min(radius, width / 2);
radius = Math.min(radius, height / 2);
String svg = String.format(
"<svg xmlns='http://www.w3.org/2000/svg' width='%d' height='%d'>" +
"<rect width='%d' height='%d' rx='%d' ry='%d' fill='white'/>" +
"</svg>",
width, height,
String rect = String.format(
"<rect width='%d' height='%d' rx='%d' ry='%d' fill='white'/>",
width, height,
radius, radius);
drawSvgShape(rect, "rounded_" + width + "_" + height + "_" + radius, x, y, width, height, color);
}
/**
* Draws a rounded outline that stays inside the given bounds, so it lines up
* with a {@link #drawRoundedRectangle} of the same size and radius.
*/
default void drawRoundedBorder(int x, int y, int width, int height, int radius, int thickness, int color) {
if (color == 0 || thickness <= 0)
return;
radius = Math.min(radius, width / 2);
radius = Math.min(radius, height / 2);
thickness = Math.min(thickness, Math.min(width, height) / 2);
// The stroke is centered on the path, so inset it by half its width.
float inset = thickness / 2f;
float innerRadius = Math.max(0, radius - inset);
String rect = String.format(Locale.ROOT,
"<rect x='%.1f' y='%.1f' width='%.1f' height='%.1f' rx='%.1f' ry='%.1f' " +
"fill='none' stroke='white' stroke-width='%d'/>",
inset, inset,
width - (float) thickness, height - (float) thickness,
innerRadius, innerRadius,
thickness);
drawSvgShape(rect, "rounded_border_" + width + "_" + height + "_" + radius + "_" + thickness,
x, y, width, height, color);
}
/**
* Draws a rounded rectangle filled with {@code background} and outlined with
* {@code border}. Either color can be 0 to skip that part.
*/
default void drawRoundedRectangle(int x, int y, int width, int height, int radius, int thickness,
int background, int border) {
drawRoundedRectangle(x, y, width, height, radius, background);
drawRoundedBorder(x, y, width, height, radius, thickness, border);
}
/**
* Renders {@code shape} (SVG elements in a {@code width} x {@code height}
* canvas, drawn in white) to a cached texture and draws it tinted with
* {@code color}.
*/
private void drawSvgShape(String shape, String cacheKey, int x, int y, int width, int height, int color) {
String svg = String.format(
"<svg xmlns='http://www.w3.org/2000/svg' width='%d' height='%d'>%s</svg>",
width, height, shape);
InputStream svgStream = new ByteArrayInputStream(svg.getBytes(StandardCharsets.UTF_8));
drawTexture(SvgTexture.getSvg(svgStream,
IdentifierRef.ofSaturn(
"textures/gui/tmp_rect/rounded_" + width + "_" + height + "_" + radius),
IdentifierRef.ofSaturn("textures/gui/tmp_rect/" + cacheKey),
width * 4, height * 4), x, y, 0, 0, width, height, color);
}
@@ -1,10 +1,16 @@
package org.saturnclient.ui;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.saturnclient.config.Config;
public abstract class SaturnScreen {
/** How long the background blur takes to fade in, in ms */
private static final int BLUR_FADE_IN = 700;
public static interface ScreenProvider {
public void close();
@@ -22,7 +28,8 @@ public abstract class SaturnScreen {
public List<Element> elements = new ArrayList<>();
public float backgroundOpacity = 1.0f;
public Instant start = null;
public int backgroundBlur = 10;
/** Whether the game behind this screen is blurred, by the client's background blur setting */
public boolean blurBackground = true;
// Provider
public ScreenProvider provider;
@@ -31,10 +38,24 @@ public abstract class SaturnScreen {
this.title = title;
}
/**
* How much to blur the game behind this screen right now: the client's
* setting, faded in over the screen's first moments.
*/
public int getBackgroundBlur() {
if (!blurBackground || start == null) {
return 0;
}
long elapsed = Duration.between(start, Instant.now()).toMillis();
return Math.round(Config.backgroundBlur.value * Math.min(elapsed / (float) BLUR_FADE_IN, 1f));
}
public void init() {
elements.clear();
width = provider.getWidth();
height = provider.getHeight();
Popup.reset(width, height);
ui(); // abstraction to render the saturn ui and also render extra stuff here
}
@@ -54,6 +75,7 @@ public abstract class SaturnScreen {
renderScope.getMatrixStack().scale(0.5f, 0.5f);
ElementRenderer.render(new ArrayList<>(elements), elapsed, renderScope, mouseX, mouseY);
Popup.render(renderScope, elapsed, mouseX, mouseY);
renderScope.getMatrixStack().pop();
}
@@ -66,7 +88,9 @@ public abstract class SaturnScreen {
mouseX *= 2;
mouseY *= 2;
ElementRenderer.mouseClicked(elements, mouseX, mouseY, button);
if (!Popup.mouseClicked(mouseX, mouseY, button)) {
ElementRenderer.mouseClicked(elements, mouseX, mouseY, button);
}
return false;
}
@@ -75,7 +99,9 @@ public abstract class SaturnScreen {
mouseX *= 2;
mouseY *= 2;
ElementRenderer.mouseDragged(elements, mouseX, mouseY, button, deltaX, deltaY);
if (!Popup.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) {
ElementRenderer.mouseDragged(elements, mouseX, mouseY, button, deltaX, deltaY);
}
return false;
}
@@ -84,6 +110,8 @@ public abstract class SaturnScreen {
mouseX *= 2;
mouseY *= 2;
// Everything hears the release, so no element is left thinking it's being dragged.
Popup.mouseReleased(mouseX, mouseY, button);
ElementRenderer.mouseReleased(elements, mouseX, mouseY, button);
return false;
@@ -97,7 +125,9 @@ public abstract class SaturnScreen {
mouseX *= 2;
mouseY *= 2;
ElementRenderer.mouseScrolled(elements, mouseX, mouseY, horizontalAmount, verticalAmount);
if (!Popup.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)) {
ElementRenderer.mouseScrolled(elements, mouseX, mouseY, horizontalAmount, verticalAmount);
}
return false;
}
+16 -14
View File
@@ -1,7 +1,5 @@
package org.saturnclient.ui;
import java.util.List;
import org.saturnclient.common.provider.GLFWProvider;
public class Utils {
@@ -47,17 +45,21 @@ public class Utils {
};
}
public static <T> T getOrNull(List<T> list, int index) {
return (index >= 0 && index < list.size()) ? list.get(index) : null;
/**
* 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 void notify(NotificationKind kind, String title, String toast)
// {
// if (SaturnClient.client.currentScreen instanceof SaturnScreen) {
// ((SaturnScreen) SaturnClient.client.currentScreen)
// .draw(new Notification(SaturnClient.client.currentScreen.width,
// SaturnClient.client.currentScreen.height, kind,
// title, toast));
// }
// }
}
@@ -1,27 +0,0 @@
package org.saturnclient.ui.anim;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.ui.Element;
public class FadeOut extends Animation {
float targetOpacity;
public FadeOut(int duration) {
super(duration);
}
public FadeOut(AnimationConfig config) {
super(config);
}
@Override
public void tick(double progress, Element element) {
element.opacity = targetOpacity - (float) progress;
}
@Override
public void init(Element element) {
targetOpacity = element.opacity;
// element.opacity = 1.0f;
}
}
@@ -1,39 +0,0 @@
package org.saturnclient.ui.anim;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.ui.Element;
public class SlideX extends Animation {
int offset = 20;
double startX;
public SlideX(AnimationConfig config) {
super(config);
}
public SlideX(AnimationConfig config, int offset) {
super(config);
this.offset = offset;
}
public SlideX(int duration) {
super(duration);
}
public SlideX(int duration, int offset) {
super(duration);
this.offset = offset;
}
@Override
public void init(Element element) {
startX = element.x - offset;
element.x = (int) startX;
}
@Override
public void tick(double progress, Element element) {
double currentX = startX + offset * progress;
element.x = (int) currentX;
}
}
@@ -0,0 +1,54 @@
package org.saturnclient.ui.components;
import org.saturnclient.client.ServiceClient;
import org.saturnclient.common.provider.Providers;
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;
/**
* The signed-in player: their skin's face, their name, and a dot that's green
* while connected to Saturn.
*/
public class AccountChip extends Element {
public static final int HEIGHT = 40;
private static final int HEAD = 28;
private static final int PADDING = 6;
private static final int DOT = 7;
private static final float NAME_SCALE = 0.7f;
private static final int CONNECTED = 0xFF7FD1A8;
private final String name;
public AccountChip() {
String username = Providers.saturn.getClient().getUsername();
this.name = username != null ? username : "Player";
this.height = HEIGHT;
this.width = PADDING + HEAD + 10 + (int) (Fonts.getWidth(name, Theme.FONT.value) * NAME_SCALE) + 10 + DOT + 12;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1, Theme.getPanelBg(),
Theme.getBorder());
// The face, then the hat layer over it, from the 64x64 skin
IdentifierRef skin = Providers.saturn.getPlayerSkin();
int headY = (height - HEAD) / 2;
renderScope.drawTexture(skin, PADDING, headY, 8, 8, HEAD, HEAD, 8, 8, 64, 64);
renderScope.drawTexture(skin, PADDING, headY, 40, 8, HEAD, HEAD, 8, 8, 64, 64);
int nameX = PADDING + HEAD + 10;
renderScope.drawText(NAME_SCALE, name, nameX, Field.textY(height, NAME_SCALE), Theme.FONT.value,
Theme.FOREGROUND.value);
int dotX = nameX + (int) (Fonts.getWidth(name, Theme.FONT.value) * NAME_SCALE) + 10;
renderScope.drawRoundedRectangle(dotX, (height - DOT) / 2, DOT, DOT, DOT / 2,
ServiceClient.isConnected() ? CONNECTED : Theme.withAlpha(0.3f, Theme.FOREGROUND.value));
}
}
@@ -0,0 +1,79 @@
package org.saturnclient.ui.components;
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;
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));
scroll.draw(input.position(x + tileWidth - INSET - input.width, tileY + (TILE_HEIGHT - input.height) / 2));
i++;
}
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);
}
}
}
@@ -1,43 +0,0 @@
package org.saturnclient.ui.components;
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;
public class CosmeticPreview extends Element {
static int padding = 14;
IdentifierRef sprite;
private Runnable onClick;
private boolean isSelected;
public CosmeticPreview(boolean isSelected, IdentifierRef sprite, Runnable onClick) {
this.sprite = sprite;
this.onClick = onClick;
this.isSelected = isSelected;
this.width = 50;
this.height = 111;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value,
isSelected ? Theme.ACCENT.value : Theme.PRIMARY.value);
int texWidth = width - padding;
int texHeight = height - padding;
renderScope.drawTexture(sprite, padding / 2, padding / 2, 0, 0, texWidth, texHeight,
Theme.FOREGROUND.value);
}
@Override
public void click(int mouseX, int mouseY) {
if (onClick != null) {
onClick.run();
}
}
}
@@ -0,0 +1,125 @@
package org.saturnclient.ui.components;
import java.util.function.BooleanSupplier;
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.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
/**
* One cosmetic in the cosmetics grid: its preview and name. It shows whether
* it's equipped, and cosmetics not owned yet are dimmed with a "Get" label.
*/
public class CosmeticTile extends Element {
public static final int HEIGHT = 168;
private static final int INSET = 8;
private static final int IMAGE_AREA = 124;
private static final float NAME_SCALE = 0.62f;
private static final float BADGE_SCALE = 0.54f;
private static final int BADGE_HEIGHT = 20;
/** How long a claim may take before it can be tried again, in ms */
private static final long CLAIM_TIMEOUT = 5000;
private final String name;
private final IdentifierRef preview;
private final float previewAspect;
private final BooleanSupplier owned;
private final BooleanSupplier equipped;
private final Runnable onEquip;
private final Runnable onClaim;
private long claimedAt = 0;
/**
* @param previewAspect the preview texture's width divided by its height
*/
public CosmeticTile(String name, IdentifierRef preview, float previewAspect, int width, BooleanSupplier owned,
BooleanSupplier equipped, Runnable onEquip, Runnable onClaim) {
this.name = name;
this.preview = preview;
this.previewAspect = previewAspect;
this.owned = owned;
this.equipped = equipped;
this.onEquip = onEquip;
this.onClaim = onClaim;
this.width = width;
this.height = HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
boolean isOwned = owned.getAsBoolean();
boolean isEquipped = equipped.getAsBoolean();
boolean hovered = ctx.isHovering();
int border = isEquipped ? Theme.ACCENT.value
: Theme.withAlpha(hovered ? 0.22f : 0.06f, Theme.FOREGROUND.value);
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, isEquipped ? 2 : 1,
Theme.withAlpha(0.72f, Theme.PRIMARY.value), border);
// Preview, fitted into its area
int areaWidth = width - INSET * 2;
renderScope.drawRoundedRectangle(INSET, INSET, areaWidth, IMAGE_AREA, Theme.WIDGET_RADIUS.value - 2,
Theme.withAlpha(0.05f, Theme.FOREGROUND.value));
int imageHeight = IMAGE_AREA - 12;
int imageWidth = (int) (imageHeight * previewAspect);
if (imageWidth > areaWidth - 12) {
imageWidth = areaWidth - 12;
imageHeight = (int) (imageWidth / previewAspect);
}
renderScope.drawTexture(preview, INSET + (areaWidth - imageWidth) / 2, INSET + (IMAGE_AREA - imageHeight) / 2,
0, 0, imageWidth, imageHeight, isOwned ? 0xFFFFFFFF : Theme.withAlpha(0.35f, 0xFFFFFFFF));
// Name
int nameY = INSET + IMAGE_AREA + (HEIGHT - INSET - IMAGE_AREA - (int) (Fonts.getHeight() * NAME_SCALE)) / 2;
renderScope.drawText(NAME_SCALE, Fonts.ellipsize(name, Theme.FONT.value, NAME_SCALE, width - 24), 12, nameY,
Theme.FONT.value, isOwned ? Theme.FOREGROUND.value : Theme.getMutedFg());
// Badge in the preview's corner
if (isEquipped) {
drawBadge(renderScope, "On", Theme.ACCENT.value, Theme.ACCENT_FG.value, true);
} else if (!isOwned) {
drawBadge(renderScope, isClaiming() ? "Claiming" : "Get", hovered ? Theme.ACCENT.value : Theme.PRIMARY.value,
hovered ? Theme.ACCENT_FG.value : Theme.PRIMARY_FG.value, false);
}
}
private void drawBadge(RenderScope renderScope, String text, int background, int foreground, boolean check) {
int icon = check ? 10 : 0;
int badgeWidth = 14 + icon + (check ? 4 : 0) + (int) (Fonts.getWidth(text, Theme.FONT.value) * BADGE_SCALE);
int x = width - INSET - 6 - badgeWidth;
int y = INSET + 6;
renderScope.drawRoundedRectangle(x, y, badgeWidth, BADGE_HEIGHT, 6, background);
if (check) {
renderScope.drawTexture(Textures.ICON_CHECK, x + 7, y + (BADGE_HEIGHT - icon) / 2, 0, 0, icon, icon, foreground);
}
int textY = y + (BADGE_HEIGHT - (int) (Fonts.getHeight() * BADGE_SCALE)) / 2;
renderScope.drawText(BADGE_SCALE, text, x + 7 + icon + (check ? 4 : 0), textY, Theme.FONT.value, foreground);
}
@Override
public void click(int mouseX, int mouseY) {
if (owned.getAsBoolean()) {
if (!equipped.getAsBoolean()) {
onEquip.run();
}
} else if (!isClaiming()) {
claimedAt = System.currentTimeMillis();
onClaim.run();
}
}
private boolean isClaiming() {
return System.currentTimeMillis() - claimedAt < CLAIM_TIMEOUT;
}
}
@@ -0,0 +1,67 @@
package org.saturnclient.ui.components;
import java.util.List;
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;
/**
* A glass bar of square icon buttons.
*/
public class Dock extends Element {
public record Item(IdentifierRef icon, Runnable onClick) {
}
public static final int HEIGHT = 52;
private static final int PADDING = 6;
private static final int ITEM = HEIGHT - PADDING * 2;
private static final int ICON = 18;
private static final int GAP = 4;
private final List<Item> items;
public Dock(List<Item> items) {
this.items = items;
this.height = HEIGHT;
this.width = PADDING * 2 + items.size() * ITEM + (items.size() - 1) * GAP;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value + 4, 1, Theme.getPanelBg(),
Theme.withAlpha(0.1f, Theme.FOREGROUND.value));
for (int i = 0; i < items.size(); i++) {
int x = getItemX(i);
boolean hovered = ctx.isHovering(x, PADDING, ITEM, ITEM);
if (hovered) {
renderScope.drawRoundedRectangle(x, PADDING, ITEM, ITEM, Theme.WIDGET_RADIUS.value,
Theme.withAlpha(0.08f, Theme.FOREGROUND.value));
}
int offset = (ITEM - ICON) / 2;
renderScope.drawTexture(items.get(i).icon(), x + offset, PADDING + offset, 0, 0, ICON, ICON,
hovered ? Theme.ACCENT.value : Theme.PRIMARY_FG.value);
}
}
@Override
public void click(int mouseX, int mouseY) {
for (int i = 0; i < items.size(); i++) {
int x = getItemX(i);
if (mouseX >= x && mouseX <= x + ITEM) {
items.get(i).onClick().run();
return;
}
}
}
private static int getItemX(int index) {
return PADDING + index * (ITEM + GAP);
}
}
@@ -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();
}
}
@@ -15,13 +15,21 @@ public class HudModPreview extends Element {
@Override
public void render(RenderScope scope, ElementContext ctx) {
ModLayout layout = feature.getDimensions();
draw(scope, feature);
}
/**
* Draws a HUD mod with sample content and the background it has in game,
* at the origin and its layout's unscaled size.
*/
public static void draw(RenderScope scope, HudMod mod) {
ModLayout layout = mod.getDimensions();
if (layout.renderBackground) {
scope.drawRoundedRectangle(0, 0, layout.width, layout.height,
layout.radius.value, layout.bgColor.value);
}
feature.renderDummy(scope);
mod.renderDummy(scope);
}
}
@@ -0,0 +1,54 @@
package org.saturnclient.ui.components;
import org.saturnclient.config.Theme;
import org.saturnclient.mod.HudMod;
import org.saturnclient.mod.ModLayout;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
/**
* A card showing a HUD mod as it looks in game, following its settings live.
* The mod is drawn at its in-game scale, shrunk if needed to fit the card.
*/
public class HudPreviewCard extends Element {
private static final int PADDING = 16;
private static final int TITLE_HEIGHT = 24;
private static final float TITLE_SCALE = 0.6f;
private final HudMod mod;
private final HudModPreview preview;
public HudPreviewCard(HudMod mod) {
this.mod = mod;
this.preview = new HudModPreview(mod);
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value + 4, 1,
Theme.withAlpha(0.72f, Theme.PRIMARY.value), Theme.withAlpha(0.06f, Theme.FOREGROUND.value));
renderScope.drawText(TITLE_SCALE, "PREVIEW", PADDING, PADDING, Theme.FONT.value, Theme.getMutedFg());
ModLayout layout = mod.getDimensions();
if (layout.width <= 0 || layout.height <= 0) {
return;
}
// HUD mods are laid out in GUI pixels, which are two Saturn units.
int areaWidth = width - PADDING * 2;
int areaHeight = height - PADDING * 2 - TITLE_HEIGHT;
float scale = Math.min(layout.scale.value * 2,
Math.min((float) areaWidth / layout.width, (float) areaHeight / layout.height));
float x = PADDING + (areaWidth - layout.width * scale) / 2;
float y = PADDING + TITLE_HEIGHT + (areaHeight - layout.height * scale) / 2;
renderScope.getMatrixStack().push();
renderScope.getMatrixStack().translate(x, y);
renderScope.getMatrixStack().scale(scale, scale);
preview.render(renderScope, ctx);
renderScope.getMatrixStack().pop();
}
}
@@ -0,0 +1,91 @@
package org.saturnclient.ui.components;
import org.saturnclient.config.Theme;
import org.saturnclient.mod.Mod;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.resources.Fonts;
/**
* The top of a mod's settings: its icon, name, version and tags, and description.
*/
public class ModHeader extends Element {
public static final int HEIGHT = 60;
private static final int ICON = 30;
private static final int TEXT_X = HEIGHT + 16;
private static final float NAME_SCALE = 1.2f;
private static final float DESCRIPTION_SCALE = 0.7f;
private static final int CHIP_HEIGHT = 22;
private static final int CHIP_PADDING = 8;
private static final int CHIP_GAP = 6;
private static final float CHIP_SCALE = 0.58f;
private final Mod mod;
public ModHeader(Mod mod, int width) {
this.mod = mod;
this.width = width;
this.height = HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
boolean enabled = mod.isEnabled();
// Icon
renderScope.drawRoundedRectangle(0, 0, HEIGHT, HEIGHT, Theme.WIDGET_RADIUS.value + 4,
enabled ? Theme.withAlpha(0.18f, Theme.ACCENT.value) : Theme.withAlpha(0.06f, Theme.FOREGROUND.value));
renderScope.drawTexture(mod.getIconTexture(), (HEIGHT - ICON) / 2, (HEIGHT - ICON) / 2, 0, 0, ICON, ICON,
Theme.PRIMARY_FG.value);
// Name, then the version and tags as chips
int nameHeight = (int) (Fonts.getHeight() * NAME_SCALE);
int nameY = 4;
renderScope.drawText(NAME_SCALE, mod.getName(), TEXT_X, nameY, Theme.FONT.value, Theme.FOREGROUND.value);
int chipX = TEXT_X + (int) (Fonts.getWidth(mod.getName(), Theme.FONT.value) * NAME_SCALE) + 12;
int chipY = nameY + (nameHeight - CHIP_HEIGHT) / 2;
if (mod.getVersion() != null) {
chipX = drawChip(renderScope, mod.getVersion(), chipX, chipY, false) + CHIP_GAP;
}
for (String tag : mod.getTags()) {
chipX = drawChip(renderScope, tag, chipX, chipY, true) + CHIP_GAP;
}
// Description
if (mod.getDescription() != null) {
String description = Fonts.ellipsize(mod.getDescription(), Theme.getBodyFont(), DESCRIPTION_SCALE,
width - TEXT_X);
renderScope.drawText(DESCRIPTION_SCALE, description, TEXT_X, nameY + nameHeight + 8,
Theme.getBodyFont(), Theme.getMutedFg());
}
}
/**
* @return the x after the chip
*/
private int drawChip(RenderScope renderScope, String text, int x, int y, boolean outlined) {
int width = CHIP_PADDING * 2 + (int) (Fonts.getWidth(text, Theme.FONT.value) * CHIP_SCALE);
if (x + width > this.width) {
return x;
}
if (outlined) {
renderScope.drawRoundedBorder(x, y, width, CHIP_HEIGHT, 6, 1, Theme.withAlpha(0.14f, Theme.FOREGROUND.value));
} else {
renderScope.drawRoundedRectangle(x, y, width, CHIP_HEIGHT, 6, Theme.withAlpha(0.07f, Theme.FOREGROUND.value));
}
int textY = y + (CHIP_HEIGHT - (int) (Fonts.getHeight() * CHIP_SCALE)) / 2;
renderScope.drawText(CHIP_SCALE, text, x + CHIP_PADDING, textY, Theme.FONT.value,
outlined ? Theme.PRIMARY_FG.value : Theme.getMutedFg());
return x + width;
}
}
@@ -0,0 +1,154 @@
package org.saturnclient.ui.components;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.BoolProperty;
import org.saturnclient.config.property.ColorProperty;
import org.saturnclient.config.property.FloatProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.KeybindingProperty;
import org.saturnclient.config.property.NamespaceProperty;
import org.saturnclient.config.property.Property;
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.components.inputs.ColorInput;
import org.saturnclient.ui.components.inputs.FloatInput;
import org.saturnclient.ui.components.inputs.IntInput;
import org.saturnclient.ui.components.inputs.KeybindingSelector;
import org.saturnclient.ui.components.inputs.Select;
import org.saturnclient.ui.components.inputs.Slider;
import org.saturnclient.ui.components.inputs.Toggle;
import org.saturnclient.ui.elements.Label;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.resources.Fonts;
/**
* Lays out config properties as rows of name and control, one per property.
* A nested namespace becomes a titled section.
*/
public final class PropertyList {
public static final int ROW_HEIGHT = 56;
private static final int SECTION_GAP = 18;
private static final float SECTION_SCALE = 0.6f;
private PropertyList() {
}
/**
* Adds rows for {@code properties} to {@code scroll}, starting at {@code y}.
*
* @param skip names of top-level properties to leave out
* @return the y below the last row
*/
public static int draw(Scroll scroll, Map<String, Property> properties, Set<String> skip, int width, int y) {
for (Map.Entry<String, Property> entry : properties.entrySet()) {
String name = entry.getKey();
Property prop = entry.getValue();
if (skip.contains(name)) {
continue;
}
if (prop instanceof NamespaceProperty namespace) {
y = drawSection(scroll, name, y + SECTION_GAP) + 4;
y = draw(scroll, namespace.value, Set.of(), width, y);
continue;
}
Element control = createControl(prop);
if (control == null) {
continue;
}
scroll.draw(new Row(name, prop.description, width, control.width).position(0, y));
scroll.draw(control.position(width - control.width, y + (ROW_HEIGHT - control.height) / 2));
y += ROW_HEIGHT;
}
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).
*/
private static Element createControl(Property prop) {
if (prop instanceof BoolProperty p) {
return new Toggle(p);
} else if (prop instanceof ColorProperty p) {
return new ColorInput(p);
} else if (prop instanceof IntProperty p) {
return p.hasRange() ? Slider.of(p) : new IntInput(p);
} else if (prop instanceof FloatProperty p) {
return p.hasRange() ? Slider.of(p) : new FloatInput(p);
} else if (prop instanceof SelectProperty p) {
return new Select(p);
} else if (prop instanceof KeybindingProperty p) {
return new KeybindingSelector(p);
}
return null;
}
/**
* A row's name, its description under it, and the line below the row. The
* control is a separate element on top, so it gets the clicks and key
* presses.
*/
private static class Row extends Element {
private static final float NAME_SCALE = 0.74f;
private static final float DESCRIPTION_SCALE = 0.62f;
private static final int LINE_GAP = 4;
private final String name;
private final String description;
private final int controlWidth;
Row(String name, String description, int width, int controlWidth) {
this.name = name;
this.description = description;
this.controlWidth = controlWidth;
this.width = width;
this.height = ROW_HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
int textWidth = width - controlWidth - 16;
int nameHeight = (int) (Fonts.getHeight() * NAME_SCALE);
int descriptionHeight = (int) (Fonts.getHeight() * DESCRIPTION_SCALE);
boolean described = !description.isEmpty();
int blockHeight = described ? nameHeight + LINE_GAP + descriptionHeight : nameHeight;
int nameY = (height - blockHeight) / 2;
renderScope.drawText(NAME_SCALE, Fonts.ellipsize(name, Theme.FONT.value, NAME_SCALE, textWidth),
0, nameY, Theme.FONT.value, Theme.FOREGROUND.value);
if (described) {
int font = Theme.getBodyFont();
renderScope.drawText(DESCRIPTION_SCALE,
Fonts.ellipsize(description, font, DESCRIPTION_SCALE, textWidth),
0, nameY + nameHeight + LINE_GAP, font, Theme.getMutedFg());
}
renderScope.drawRectangle(0, height - 1, width, 1, Theme.withAlpha(0.06f, Theme.FOREGROUND.value));
}
}
}
@@ -2,165 +2,119 @@ package org.saturnclient.ui.components;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.config.Theme;
import org.saturnclient.mod.HudMod;
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;
import org.saturnclient.ui.screens.ModSettings;
/**
* 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 (mod instanceof HudMod h) {
Providers.saturn.getClient().setScreen(new ConfigEditor(mod.getConfig(), h));
} else {
Providers.saturn.getClient().setScreen(new ConfigEditor(mod.getConfig()));
}
if (settingsClicked) {
Providers.saturn.getClient().setScreen(new ModSettings(mod));
} else {
mod.setEnabled(!mod.isEnabled());
}
}
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;
}
}
}
@@ -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,56 +2,51 @@ 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.Utils;
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;
import org.saturnclient.ui.screens.store.CloakStore;
import org.saturnclient.ui.screens.CosmeticsMenu;
/**
* 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 CosmeticsMenu())),
new Item(Textures.ICON_HUD_EDITOR, () -> Providers.saturn.getClient().setScreen(new HudEditor())),
new Item(Textures.ICON_SETTINGS, () -> Providers.saturn.getClient().setScreen(new ClientSettings())),
};
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 +54,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;
}
}
@@ -1,26 +1,68 @@
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;
/**
* The player, wearing their cosmetics, filling the element. Dragging it turns
* the player around.
*/
public class SkinPreview extends Element {
public static EntityDrawer DRAWER;
private boolean negativeAngle = false;
private float angle = 0.0f;
public SkinPreview(float angle, boolean negativeAngle) {
this.angle = angle;
this.negativeAngle = negativeAngle;
/** Degrees turned per GUI pixel dragged */
private static final float DRAG_SPEED = 1.2f;
private static final float HINT_SCALE = 0.66f;
private float yaw;
private boolean dragging = false;
/**
* @param yaw degrees the player starts turned by: 0 faces the viewer, 180
* shows their back
*/
public SkinPreview(float yaw) {
this.yaw = yaw;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
DRAWER.render(renderScope, negativeAngle, angle, scale, ctx);
if (DRAWER != null && DRAWER.render(renderScope, width, height, yaw)) {
return;
}
String hint = "Join a world to preview";
int x = (width - (int) (Fonts.getWidth(hint, Theme.getBodyFont()) * HINT_SCALE)) / 2;
renderScope.drawText(HINT_SCALE, hint, x, height / 2, Theme.getBodyFont(), Theme.getMutedFg());
}
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
dragging = mouseX >= 0 && mouseX <= width && mouseY >= 0 && mouseY <= height;
}
@Override
public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
if (dragging) {
yaw = (yaw - (float) deltaX * DRAG_SPEED) % 360;
}
}
@Override
public void mouseReleased(double mouseX, double mouseY, int button) {
dragging = false;
}
public static interface EntityDrawer {
public void render(RenderScope renderScope, boolean negativeAngle, float angle, float scale,
ElementContext ctx);
/**
* Draws the client's player filling a {@code width} x {@code height} box,
* turned by {@code yaw} degrees (0 faces the viewer).
*
* @return false if there's no player to draw, such as on the title screen
*/
public boolean render(RenderScope renderScope, int width, int height, float yaw);
}
}
@@ -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();
}
}
@@ -1,183 +1,62 @@
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.Popup;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.elements.Field;
/**
* Shows a color as a swatch and its hex code, and opens a {@link ColorPicker}
* beside it when clicked.
*/
public class ColorInput extends Element {
private static final int SWATCH = 20;
private static final int PICKER_GAP = 8;
public ColorProperty prop;
// Picker state
private boolean open = false;
private float hue = 0f;
private float opacity = 1f;
private int selectedX = 0;
private int selectedY = 0;
private boolean draggingOpacity = false;
// Dimensions
private static final int PICKER_WIDTH = 100;
private static final int PICKER_HEIGHT = 100;
private static final int SLIDER_WIDTH = 100;
private static final int SLIDER_HEIGHT = 10;
private static final int HUE_SLIDER_HEIGHT = 10;
/** Where this was last drawn on the screen, to open the picker beside it */
private int originX, originY;
public ColorInput(ColorProperty prop) {
this.prop = prop;
this.width = 58;
this.height = 20;
syncFromColor();
}
@Override
public void click(int mouseX, int mouseY) {
open = !open;
}
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
if (open) {
handlePickerClick(mouseX - width - 8, mouseY);
}
}
@Override
public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
if (open) {
handlePickerDrag(mouseX - width - 8, mouseY);
}
}
@Override
public void mouseReleased(double mouseX, double mouseY, int button) {
draggingOpacity = false;
this.width = 116;
this.height = Field.HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
// Draw the main color rectangle
renderScope.drawRoundedRectangle(0, 0, width, height, 6, prop.value);
originX = ctx.originX;
originY = ctx.originY;
if (!open)
return;
Field.drawBox(renderScope, width, height, ctx.isHovering(), Popup.isOpenFor(this));
// Draw the picker relative to (width + 8, 0)
int pickerX = width + 8;
int pickerY = 0;
int swatchInset = (height - SWATCH) / 2;
renderScope.drawRoundedRectangle(swatchInset, swatchInset, SWATCH, SWATCH, 5, 1, prop.value,
Theme.withAlpha(0.3f, Theme.FOREGROUND.value));
renderColorSquare(renderScope, pickerX, pickerY);
renderOpacitySlider(renderScope, pickerX, pickerY - SLIDER_HEIGHT - 10);
renderHueSlider(renderScope, pickerX, pickerY + PICKER_HEIGHT + 10);
renderScope.drawText(Field.TEXT_SCALE, toHex(prop.value), swatchInset + SWATCH + 8,
Field.textY(height, Field.TEXT_SCALE), Theme.FONT.value, Theme.FOREGROUND.value);
}
private void renderColorSquare(RenderScope renderScope, int xStart, int yStart) {
for (int x = 0; x < PICKER_WIDTH; x++) {
for (int y = 0; y < PICKER_HEIGHT; y++) {
float saturation = x / (float) PICKER_WIDTH;
float value = 1f - y / (float) PICKER_HEIGHT;
int color = Color.HSBtoRGB(hue, saturation, value);
int alpha = (int) (opacity * 255) & 0xFF;
color = (alpha << 24) | (color & 0xFFFFFF);
renderScope.fill(xStart + x, yStart + y, xStart + x + 1, yStart + y + 1, color);
}
}
// Draw selection square
int selX = xStart + selectedX - 4;
int selY = yStart + selectedY - 4;
renderScope.fill(selX, selY, selX + 8, selY + 8, 0xFF000000 | prop.value);
renderScope.drawBorder(selX, selY, 8, 8, 0xFFFFFFFF);
@Override
public void click(int mouseX, int mouseY) {
// The input sits at the right of its row, so the picker opens to its left,
// centered on it. While it's open, a click here closes it instead.
ColorPicker picker = new ColorPicker(prop);
Popup.open(this, picker, originX - PICKER_GAP - picker.width, originY + (height - picker.height) / 2);
}
private void renderOpacitySlider(RenderScope renderScope, int xStart, int yStart) {
for (int x = 0; x < SLIDER_WIDTH; x++) {
float localOpacity = x / (float) SLIDER_WIDTH;
int alpha = (int) (localOpacity * 255) & 0xFF;
int color = (alpha << 24) | (prop.value & 0xFFFFFF);
renderScope.fill(xStart + x, yStart, xStart + x + 1, yStart + SLIDER_HEIGHT, color);
}
int knobX = xStart + (int) (opacity * SLIDER_WIDTH);
renderScope.fill(knobX - 1, yStart - 2, knobX + 1, yStart + SLIDER_HEIGHT + 2, 0xFF000000);
/**
* #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 renderHueSlider(RenderScope renderScope, int xStart, int yStart) {
for (int x = 0; x < SLIDER_WIDTH; x++) {
float localHue = x / (float) SLIDER_WIDTH;
int color = Color.HSBtoRGB(localHue, 1f, 1f);
renderScope.fill(xStart + x, yStart, xStart + x + 1, yStart + HUE_SLIDER_HEIGHT, 0xFF000000 | color);
}
int knobX = xStart + (int) (hue * SLIDER_WIDTH);
renderScope.fill(knobX - 1, yStart - 2, knobX + 1, yStart + HUE_SLIDER_HEIGHT + 2, 0xFF000000);
}
private void handlePickerClick(double mouseX, double mouseY) {
// Color square
if (mouseX >= 0 && mouseX < PICKER_WIDTH && mouseY >= 0 && mouseY < PICKER_HEIGHT) {
selectedX = (int) mouseX;
selectedY = (int) mouseY;
updateColorFromSelection();
}
// Opacity slider
int opY = -SLIDER_HEIGHT - 10;
if (mouseX >= 0 && mouseX < SLIDER_WIDTH && mouseY >= opY && mouseY <= opY + SLIDER_HEIGHT) {
updateOpacity(mouseX);
draggingOpacity = true;
}
// Hue slider
int hueY = PICKER_HEIGHT + 10;
if (mouseX >= 0 && mouseX < SLIDER_WIDTH && mouseY >= hueY && mouseY <= hueY + HUE_SLIDER_HEIGHT) {
updateHue(mouseX);
}
}
private void handlePickerDrag(double mouseX, double mouseY) {
// Opacity
if (draggingOpacity)
updateOpacity(mouseX);
// Color square
if (mouseX >= 0 && mouseX < PICKER_WIDTH && mouseY >= 0 && mouseY < PICKER_HEIGHT) {
selectedX = (int) mouseX;
selectedY = (int) mouseY;
updateColorFromSelection();
}
}
private void updateColorFromSelection() {
float saturation = selectedX / (float) PICKER_WIDTH;
float value = 1f - selectedY / (float) PICKER_HEIGHT;
int color = Color.HSBtoRGB(hue, saturation, value);
int alpha = (int) (opacity * 255) & 0xFF;
prop.value = (alpha << 24) | (color & 0xFFFFFF);
}
private void updateHue(double mouseX) {
hue = (float) Math.max(0, Math.min(1, mouseX / SLIDER_WIDTH));
updateColorFromSelection();
}
private void updateOpacity(double mouseX) {
opacity = (float) Math.max(0, Math.min(1, mouseX / SLIDER_WIDTH));
updateColorFromSelection();
}
private void syncFromColor() {
int rgb = prop.value & 0xFFFFFF;
float[] hsb = Color.RGBtoHSB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, null);
hue = hsb[0];
selectedX = (int) (hsb[1] * PICKER_WIDTH);
selectedY = (int) ((1f - hsb[2]) * PICKER_HEIGHT);
int alpha = (prop.value >> 24) & 0xFF;
if (alpha == 0)
alpha = 255;
opacity = alpha / 255f;
}
}
}
@@ -0,0 +1,164 @@
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;
/**
* A color picker shown as a popup: opacity across the top, a
* saturation/brightness square, and hue across the bottom. Each part can be
* clicked or dragged.
*/
public class ColorPicker extends Element {
private enum Part {
OPACITY, SQUARE, HUE
}
private static final int PADDING = 10;
private static final int SIZE = 100;
private static final int SLIDER_HEIGHT = 10;
private static final int GAP = 10;
private static final int OPACITY_Y = PADDING;
private static final int SQUARE_Y = OPACITY_Y + SLIDER_HEIGHT + GAP;
private static final int HUE_Y = SQUARE_Y + SIZE + GAP;
private final ColorProperty prop;
private float hue;
private float saturation;
private float brightness;
private float opacity;
/** The part being dragged, or null */
private Part dragging = null;
public ColorPicker(ColorProperty prop) {
this.prop = prop;
this.width = SIZE + PADDING * 2;
this.height = HUE_Y + SLIDER_HEIGHT + PADDING;
syncFromColor();
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.97f, Theme.BACKGROUND.value), Theme.withAlpha(0.14f, Theme.FOREGROUND.value));
renderOpacity(renderScope);
renderSquare(renderScope);
renderHue(renderScope);
}
private void renderOpacity(RenderScope renderScope) {
int rgb = prop.value & 0xFFFFFF;
for (int x = 0; x < SIZE; x++) {
int alpha = (int) (x / (float) SIZE * 255) & 0xFF;
renderScope.fill(PADDING + x, OPACITY_Y, PADDING + x + 1, OPACITY_Y + SLIDER_HEIGHT, (alpha << 24) | rgb);
}
drawMarker(renderScope, PADDING + (int) (opacity * SIZE), OPACITY_Y);
}
private void renderSquare(RenderScope renderScope) {
for (int x = 0; x < SIZE; x++) {
for (int y = 0; y < SIZE; y++) {
int color = Color.HSBtoRGB(hue, x / (float) SIZE, 1f - y / (float) SIZE);
renderScope.fill(PADDING + x, SQUARE_Y + y, PADDING + x + 1, SQUARE_Y + y + 1, 0xFF000000 | color);
}
}
int selX = PADDING + (int) (saturation * SIZE) - 4;
int selY = SQUARE_Y + (int) ((1f - brightness) * SIZE) - 4;
renderScope.fill(selX, selY, selX + 8, selY + 8, 0xFF000000 | prop.value);
renderScope.drawBorder(selX, selY, 8, 8, 0xFFFFFFFF);
}
private void renderHue(RenderScope renderScope) {
for (int x = 0; x < SIZE; x++) {
int color = Color.HSBtoRGB(x / (float) SIZE, 1f, 1f);
renderScope.fill(PADDING + x, HUE_Y, PADDING + x + 1, HUE_Y + SLIDER_HEIGHT, 0xFF000000 | color);
}
drawMarker(renderScope, PADDING + (int) (hue * SIZE), HUE_Y);
}
private static void drawMarker(RenderScope renderScope, int x, int sliderY) {
renderScope.fill(x - 2, sliderY - 2, x + 2, sliderY + SLIDER_HEIGHT + 2, 0xFFFFFFFF);
renderScope.fill(x - 1, sliderY - 1, x + 1, sliderY + SLIDER_HEIGHT + 1, 0xFF000000);
}
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
dragging = getPartAt(mouseX, mouseY);
update(mouseX, mouseY);
}
@Override
public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
update(mouseX, mouseY);
}
@Override
public void mouseReleased(double mouseX, double mouseY, int button) {
dragging = null;
}
private Part getPartAt(double mouseX, double mouseY) {
if (mouseX < PADDING - 4 || mouseX > PADDING + SIZE + 4) {
return null;
}
if (mouseY >= OPACITY_Y - 4 && mouseY <= OPACITY_Y + SLIDER_HEIGHT + 4) {
return Part.OPACITY;
} else if (mouseY >= SQUARE_Y && mouseY <= SQUARE_Y + SIZE) {
return Part.SQUARE;
} else if (mouseY >= HUE_Y - 4 && mouseY <= HUE_Y + SLIDER_HEIGHT + 4) {
return Part.HUE;
}
return null;
}
/**
* Moves the dragged part to the mouse, even when the mouse has left it.
*/
private void update(double mouseX, double mouseY) {
if (dragging == null) {
return;
}
float x = clamp01((float) (mouseX - PADDING) / SIZE);
switch (dragging) {
case OPACITY -> opacity = x;
case HUE -> hue = x;
case SQUARE -> {
saturation = x;
brightness = 1f - clamp01((float) (mouseY - SQUARE_Y) / SIZE);
}
}
int rgb = Color.HSBtoRGB(hue, saturation, brightness) & 0xFFFFFF;
prop.value = ((int) (opacity * 255) << 24) | rgb;
}
private void syncFromColor() {
int rgb = prop.value & 0xFFFFFF;
float[] hsb = Color.RGBtoHSB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, null);
hue = hsb[0];
saturation = hsb[1];
brightness = hsb[2];
int alpha = (prop.value >>> 24) & 0xFF;
opacity = (alpha == 0 ? 255 : alpha) / 255f;
}
private static float clamp01(float value) {
return Math.max(0f, Math.min(1f, value));
}
}
@@ -0,0 +1,52 @@
package org.saturnclient.ui.components.inputs;
import java.util.List;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.ElementRenderer;
import org.saturnclient.ui.RenderScope;
/**
* A curve dropdown's popup: a {@link CurvePreview} beside the list of curves,
* showing the hovered curve, or the current one when none is hovered.
*/
public class CurveOptions extends Element {
public static final int GAP = 8;
private final List<Element> children;
public CurveOptions(OptionList list, int current) {
CurvePreview preview = new CurvePreview(() -> list.getHovered() >= 0 ? list.getHovered() : current);
preview.position(0, 0);
list.position(preview.width + GAP, 0);
this.children = List.of(preview, list);
this.width = list.x + list.width;
this.height = Math.max(preview.height, list.height);
}
/**
* How far left of the list this popup starts, so the list can line up
* under the dropdown.
*/
public int getListX() {
return children.get(1).x;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
ElementRenderer.render(children, ctx.elapsed, renderScope, ctx.mouseX, ctx.mouseY, ctx.childFrame());
}
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
ElementRenderer.mouseClicked(children, mouseX, mouseY, button);
}
@Override
public void scroll(int mouseX, int mouseY, double horizontalAmount, double verticalAmount) {
ElementRenderer.mouseScrolled(children, mouseX, mouseY, horizontalAmount, verticalAmount);
}
}
@@ -0,0 +1,89 @@
package org.saturnclient.ui.components.inputs;
import java.util.function.Function;
import java.util.function.IntSupplier;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.anim.Animation;
/**
* A graph of an easing curve, with a dot running along it on a loop to show
* how the animation moves.
*/
public class CurvePreview extends Element {
private static final int PADDING = 14;
private static final int TITLE_HEIGHT = 20;
private static final float TITLE_SCALE = 0.56f;
private static final int DOT = 7;
/** How long the dot takes to run the curve, then how long it rests at the end, in ms */
private static final int RUN = 1200;
private static final int REST = 500;
private final IntSupplier curve;
/**
* @param curve the curve to show, as an index {@link Animation#getCurve}
* takes; read every frame
*/
public CurvePreview(IntSupplier curve) {
this.curve = curve;
this.width = 190;
this.height = 150;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.97f, Theme.BACKGROUND.value), Theme.withAlpha(0.14f, Theme.FOREGROUND.value));
renderScope.drawText(TITLE_SCALE, "PREVIEW", PADDING, PADDING - 2, Theme.FONT.value, Theme.getMutedFg());
Function<Double, Double> ease = Animation.getCurve(curve.getAsInt());
int graphX = PADDING;
int graphY = PADDING + TITLE_HEIGHT;
int graphWidth = width - PADDING * 2;
int graphHeight = height - PADDING * 2 - TITLE_HEIGHT;
// Curves like "back" overshoot, so fit the graph to the curve's own range.
double min = 0, max = 1;
for (int i = 0; i <= graphWidth; i++) {
double value = ease.apply(i / (double) graphWidth);
min = Math.min(min, value);
max = Math.max(max, value);
}
double range = max - min;
int startY = toY(0, min, range, graphY, graphHeight);
int endY = toY(1, min, range, graphY, graphHeight);
// Where the animation starts and ends
int guide = Theme.withAlpha(0.1f, Theme.FOREGROUND.value);
renderScope.fill(graphX, startY, graphX + graphWidth, startY + 1, guide);
renderScope.fill(graphX, endY, graphX + graphWidth, endY + 1, guide);
// The curve, as small squares close enough to read as a line
int steps = graphWidth * 3;
for (int i = 0; i <= steps; i++) {
double t = i / (double) steps;
int x = graphX + (int) (t * graphWidth);
int y = toY(ease.apply(t), min, range, graphY, graphHeight);
renderScope.fill(x - 1, y - 1, x + 1, y + 1, Theme.ACCENT.value);
}
// The dot, looping along it
double t = Math.min(1.0, (ctx.elapsed % (RUN + REST)) / (double) RUN);
int dotX = graphX + (int) (t * graphWidth);
int dotY = toY(ease.apply(t), min, range, graphY, graphHeight);
renderScope.drawRoundedRectangle(dotX - DOT / 2, dotY - DOT / 2, DOT, DOT, DOT / 2, 2,
Theme.FOREGROUND.value, Theme.BACKGROUND.value);
}
private static int toY(double value, double min, double range, int graphY, int graphHeight) {
return graphY + graphHeight - (int) ((value - min) / range * graphHeight);
}
}
@@ -1,17 +1,29 @@
package org.saturnclient.ui.components.inputs;
import java.math.BigDecimal;
import java.math.RoundingMode;
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);
this.text = format(prop.value);
}
/**
* The value with at most three decimals and no trailing zeros, since floats
* like 1.2000000476837158 can't be stored exactly.
*/
private static String format(float value) {
return new BigDecimal(Float.toString(value)).setScale(3, RoundingMode.HALF_UP).stripTrailingZeros()
.toPlainString();
}
@Override
@@ -51,7 +63,7 @@ public class FloatInput extends Input {
@Override
public void checkReset() {
if (prop.isReset) {
this.text = String.valueOf(prop.value);
this.text = format(prop.value);
this.cursorPosition = 0;
prop.isReset = false;
}
@@ -5,12 +5,18 @@ 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 {
String text = "";
public int cursorPosition = 0;
/** Scale the text is drawn at */
protected float textScale = Field.TEXT_SCALE;
/** Horizontal inset of the text on both sides */
protected int textX = 10;
public abstract void checkReset();
public abstract void backspace();
@@ -41,6 +47,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 +55,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;
@@ -64,28 +71,44 @@ 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(0.6f, visibleText, 4, 4, Theme.FONT.value, Theme.PRIMARY_FG.value);
renderScope.drawText(textScale, visibleText, textX, textY, Theme.FONT.value, Theme.FOREGROUND.value);
if (focused) {
int cursorX = 4 +
(int) (Fonts.getWidth(visibleText.substring(0, cursorPosition - scrollOffset), Theme.FONT.value)
* 0.6f);
renderScope.drawRectangle(cursorX, 2, 1, height - 4, Theme.ACCENT.value);
renderScope.drawRectangle(getCursorX(visibleText, scrollOffset), textY - 1, 1, textHeight + 2,
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 +125,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
}
}
@@ -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
@@ -0,0 +1,122 @@
package org.saturnclient.ui.components.inputs;
import java.util.function.IntConsumer;
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;
import org.saturnclient.ui.resources.Textures;
/**
* A dropdown's list of options, shown as a popup. Long lists scroll, and the
* current option is ticked.
*/
public class OptionList extends Element {
private static final int ITEM_HEIGHT = 30;
private static final int PADDING = 4;
private static final int MAX_VISIBLE = 8;
private static final int CHECK = 14;
private final String[] options;
private final int selected;
private final IntConsumer onPick;
/** Index of the first visible option */
private int first;
/** Wheel movement not yet turned into whole options, so trackpads scroll smoothly */
private double pendingScroll = 0;
/** The option under the mouse on the last frame, or -1 */
private int hovered = -1;
public OptionList(String[] options, int selected, int width, IntConsumer onPick) {
this.options = options;
this.selected = selected;
this.onPick = onPick;
this.width = width;
this.height = Math.min(options.length, MAX_VISIBLE) * ITEM_HEIGHT + PADDING * 2;
// Start with the current option in view, a few rows down if possible
this.first = clampFirst(selected - MAX_VISIBLE / 2 + 1);
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.97f, Theme.BACKGROUND.value), Theme.withAlpha(0.14f, Theme.FOREGROUND.value));
int visible = Math.min(options.length, MAX_VISIBLE);
hovered = getIndexAt(ctx.mouseX, ctx.mouseY);
for (int row = 0; row < visible; row++) {
int index = first + row;
int y = PADDING + row * ITEM_HEIGHT;
boolean isSelected = index == selected;
if (index == hovered) {
renderScope.drawRoundedRectangle(PADDING, y, width - PADDING * 2, ITEM_HEIGHT,
Theme.WIDGET_RADIUS.value - 3, Theme.withAlpha(0.07f, Theme.FOREGROUND.value));
}
int textWidth = width - PADDING * 2 - 12 - CHECK - 10;
String text = Fonts.ellipsize(options[index], Theme.FONT.value, Field.TEXT_SCALE, textWidth);
renderScope.drawText(Field.TEXT_SCALE, text, PADDING + 10, y + Field.textY(ITEM_HEIGHT, Field.TEXT_SCALE),
Theme.FONT.value, isSelected ? Theme.ACCENT.value : Theme.FOREGROUND.value);
if (isSelected) {
renderScope.drawTexture(Textures.ICON_CHECK, width - PADDING - 10 - CHECK,
y + (ITEM_HEIGHT - CHECK) / 2, 0, 0, CHECK, CHECK, Theme.ACCENT.value);
}
}
// A thin bar showing where in a long list this is
if (options.length > MAX_VISIBLE) {
int track = height - PADDING * 2;
int thumb = Math.max(16, track * MAX_VISIBLE / options.length);
int thumbY = PADDING + (track - thumb) * first / (options.length - MAX_VISIBLE);
renderScope.drawRoundedRectangle(width - 4, thumbY, 2, thumb, 1,
Theme.withAlpha(0.3f, Theme.FOREGROUND.value));
}
}
@Override
public void click(int mouseX, int mouseY) {
int index = getIndexAt(mouseX, mouseY);
if (index >= 0) {
onPick.accept(index);
}
}
@Override
public void scroll(int mouseX, int mouseY, double horizontalAmount, double verticalAmount) {
pendingScroll -= verticalAmount;
int whole = (int) pendingScroll;
pendingScroll -= whole;
first = clampFirst(first + whole);
}
/**
* The option under the mouse on the last frame, or -1.
*/
public int getHovered() {
return hovered;
}
/**
* The option under the mouse, or -1.
*/
private int getIndexAt(int mouseX, int mouseY) {
if (mouseX < PADDING || mouseX > width - PADDING || mouseY < PADDING || mouseY >= height - PADDING) {
return -1;
}
int index = first + (mouseY - PADDING) / ITEM_HEIGHT;
return index < options.length ? index : -1;
}
private int clampFirst(int value) {
return Math.max(0, Math.min(value, options.length - Math.min(options.length, MAX_VISIBLE)));
}
}
@@ -0,0 +1,64 @@
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.elements.Field;
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.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) {
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());
if (text.isEmpty() && !focused) {
renderScope.drawText(textScale, placeholder, textX, Field.textY(height, textScale),
Theme.getBodyFont(), Theme.getMutedFg());
return;
}
drawText(renderScope);
}
}
@@ -1,40 +1,66 @@
package org.saturnclient.ui.components.inputs;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.CurveProperty;
import org.saturnclient.config.property.SelectProperty;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.Popup;
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;
/**
* A dropdown for a {@link SelectProperty}: the current value, opening a list
* of every value when clicked.
*/
public class Select extends Element {
private SelectProperty prop;
private static final int TEXT_X = 12;
private static final int ARROW = 16;
private static final int ARROW_INSET = 10;
private final SelectProperty prop;
/** The context this was last drawn with, to open the list next to it */
private ElementContext lastContext;
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);
lastContext = ctx;
boolean open = Popup.isOpenFor(this);
String text = prop.getSelection();
Field.drawBox(renderScope, width, height, ctx.isHovering(), open);
renderScope.drawText(0.6f, text, (int) (width - ((float) Fonts.getWidth(text, Theme.FONT.value) * 0.6f)) / 2, 1,
Theme.FONT.value, Theme.FOREGROUND.value);
int textWidth = width - TEXT_X - ARROW_INSET - ARROW - 8;
String text = Fonts.ellipsize(prop.getSelection(), Theme.FONT.value, Field.TEXT_SCALE, textWidth);
renderScope.drawText(Field.TEXT_SCALE, text, TEXT_X, Field.textY(height, Field.TEXT_SCALE), Theme.FONT.value,
Theme.FOREGROUND.value);
renderScope.drawText(0.8f, ">", width - 10, 0, Theme.FONT.value, Theme.FOREGROUND.value);
renderScope.drawTexture(Textures.ICON_CHEVRON_DOWN, width - ARROW_INSET - ARROW, (height - ARROW) / 2, 0, 0,
ARROW, ARROW, open ? Theme.ACCENT.value : Theme.getMutedFg());
}
@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)) {
prop.prev();
// While the list is open, a click here goes to the popup, which closes it.
OptionList list = new OptionList(prop.availableValues, prop.value, width, index -> {
prop.setSelection(index);
Popup.close();
});
if (prop instanceof CurveProperty) {
// The preview goes left of the list, so the list still lines up under this.
CurveOptions options = new CurveOptions(list, prop.value);
Popup.openBelow(this, options, lastContext, -options.getListX());
} else {
Popup.openBelow(this, list, lastContext);
}
}
}
@@ -0,0 +1,138 @@
package org.saturnclient.ui.components.inputs;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Locale;
import java.util.function.DoubleConsumer;
import java.util.function.DoubleSupplier;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.FloatProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.elements.Field;
/**
* Edits a number within a range by clicking or dragging along a track, with
* the value shown beside it.
*/
public class Slider extends Element {
private static final int TRACK_HEIGHT = 6;
private static final int KNOB = 16;
private static final int VALUE_WIDTH = 72;
private static final int VALUE_GAP = 14;
private static final int MAX_DECIMALS = 3;
private final double min, max, step;
private final int decimals;
private final String unit;
private final DoubleSupplier value;
private final DoubleConsumer onChange;
private boolean dragging = false;
/**
* @param step as text, so a float step like 0.1 isn't read as 0.10000000149
*/
private Slider(double min, double max, String step, String unit, DoubleSupplier value, DoubleConsumer onChange) {
this.min = min;
this.max = max;
this.step = Double.parseDouble(step);
this.unit = unit;
this.value = value;
this.onChange = onChange;
this.decimals = Math.min(MAX_DECIMALS, Math.max(0, new BigDecimal(step).stripTrailingZeros().scale()));
this.width = 280;
this.height = Field.HEIGHT;
}
public static Slider of(IntProperty prop) {
return new Slider(prop.min, prop.max, Integer.toString(prop.step), prop.unit, () -> prop.value,
v -> prop.value = (int) Math.round(v));
}
public static Slider of(FloatProperty prop) {
return new Slider(prop.min, prop.max, Float.toString(prop.step), prop.unit, () -> prop.value,
v -> prop.value = (float) v);
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
double current = value.getAsDouble();
float progress = (float) Math.max(0, Math.min(1, (current - min) / (max - min)));
int trackWidth = getTrackWidth();
int trackY = (height - TRACK_HEIGHT) / 2;
int fillWidth = (int) (trackWidth * progress);
renderScope.drawRoundedRectangle(KNOB / 2, trackY, trackWidth, TRACK_HEIGHT, TRACK_HEIGHT / 2,
Theme.withAlpha(0.14f, Theme.FOREGROUND.value));
if (fillWidth > 0) {
renderScope.drawRoundedRectangle(KNOB / 2, trackY, fillWidth, TRACK_HEIGHT, TRACK_HEIGHT / 2,
Theme.ACCENT.value);
}
boolean knobHovered = dragging || ctx.isHovering(fillWidth, 0, KNOB, height);
renderScope.drawRoundedRectangle(fillWidth, (height - KNOB) / 2, KNOB, KNOB, KNOB / 2, 3,
knobHovered ? Theme.ACCENT.value : Theme.FOREGROUND.value, Theme.BACKGROUND.value);
// Value
int valueX = width - VALUE_WIDTH;
renderScope.drawRoundedRectangle(valueX, 0, VALUE_WIDTH, height, Theme.WIDGET_RADIUS.value,
Theme.withAlpha(0.07f, Theme.FOREGROUND.value));
renderScope.getMatrixStack().push();
renderScope.getMatrixStack().translate(valueX, 0);
Field.drawCenteredText(renderScope, format(current), VALUE_WIDTH, height, Theme.FONT.value,
Theme.FOREGROUND.value);
renderScope.getMatrixStack().pop();
}
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
dragging = mouseX >= 0 && mouseX <= getTrackWidth() + KNOB && mouseY >= 0 && mouseY <= height;
if (dragging) {
setFromMouse(mouseX);
}
}
@Override
public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
if (dragging) {
setFromMouse(mouseX);
}
}
@Override
public void mouseReleased(double mouseX, double mouseY, int button) {
dragging = false;
}
private void setFromMouse(double mouseX) {
double progress = Math.max(0, Math.min(1, (mouseX - KNOB / 2.0) / getTrackWidth()));
double raw = min + progress * (max - min);
double snapped = min + Math.round((raw - min) / step) * step;
// Snapping in floating point can leave 2.9999999, so round to the step's decimals.
double rounded = BigDecimal.valueOf(snapped).setScale(decimals, RoundingMode.HALF_UP).doubleValue();
onChange.accept(Math.max(min, Math.min(max, rounded)));
}
private String format(double current) {
String number = String.format(Locale.ROOT, "%." + decimals + "f", current);
if (unit.isEmpty()) {
return number;
}
// Word units get a space ("700 ms"); symbols don't ("3.0×").
return Character.isLetter(unit.charAt(0)) ? number + " " + unit : number + unit;
}
private int getTrackWidth() {
return width - VALUE_WIDTH - VALUE_GAP - KNOB;
}
}
@@ -7,20 +7,37 @@ 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);
}
/**
* 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
@@ -0,0 +1,56 @@
package org.saturnclient.ui.elements;
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.resources.Fonts;
/**
* A secondary button with an icon and a label: outlined, or plain text that
* lights up on hover (for links like "back").
*/
public class ActionButton extends Element {
private static final int HEIGHT = 34;
private static final int PADDING = 12;
private static final int ICON = 15;
private static final int ICON_GAP = 7;
private static final float TEXT_SCALE = 0.66f;
private final IdentifierRef icon;
private final String text;
private final boolean outlined;
private final Runnable onClick;
public ActionButton(IdentifierRef icon, String text, boolean outlined, Runnable onClick) {
this.icon = icon;
this.text = text;
this.outlined = outlined;
this.onClick = onClick;
this.height = HEIGHT;
this.width = PADDING * 2 + ICON + ICON_GAP + (int) (Fonts.getWidth(text, Theme.FONT.value) * TEXT_SCALE);
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
boolean hovered = ctx.isHovering();
if (outlined) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
hovered ? Theme.withAlpha(0.06f, Theme.FOREGROUND.value) : 0,
Theme.withAlpha(hovered ? 0.25f : 0.12f, Theme.FOREGROUND.value));
}
int color = hovered ? Theme.FOREGROUND.value : outlined ? Theme.PRIMARY_FG.value : Theme.getMutedFg();
renderScope.drawTexture(icon, PADDING, (height - ICON) / 2, 0, 0, ICON, ICON, color);
renderScope.drawText(TEXT_SCALE, text, PADDING + ICON + ICON_GAP, Field.textY(height, TEXT_SCALE),
Theme.FONT.value, color);
}
@Override
public void click(int mouseX, int mouseY) {
onClick.run();
}
}
@@ -41,7 +41,8 @@ public class AnimationStagger extends Element {
public void render(RenderScope renderScope, ElementContext ctx) {
ElementRenderer.render(children, ctx.elapsed, renderScope,
ctx.mouseX,
ctx.mouseY);
ctx.mouseY,
ctx.childFrame());
}
@Override
@@ -6,24 +6,41 @@ import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.resources.Fonts;
/**
* A text button: a translucent, outlined surface that brightens on hover.
*/
public class Button extends Element {
private String text;
private Runnable onClick;
public static final int HEIGHT = 40;
private static final float TEXT_SCALE = 0.72f;
private final String text;
private final Runnable onClick;
public Button(String text, Runnable onClick) {
this.text = text;
this.onClick = onClick;
this.dimensions(Fonts.getWidth(text, Theme.FONT.value) + 50, 38);
this.width = (int) (Fonts.getWidth(text, Theme.FONT.value) * TEXT_SCALE) + 48;
this.height = HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value,
ctx.isHovering() ? Theme.ACCENT.value : Theme.PRIMARY.value);
boolean hovered = ctx.isHovering();
drawSurface(renderScope, width, height, hovered);
renderScope.drawText(text, Fonts.centerX(width, text, Theme.FONT.value), Fonts.centerY(height),
Theme.FONT.value,
ctx.isHovering() ? Theme.ACCENT_FG.value : Theme.PRIMARY_FG.value);
int textX = (width - (int) (Fonts.getWidth(text, Theme.FONT.value) * TEXT_SCALE)) / 2;
renderScope.drawText(TEXT_SCALE, text, textX, Field.textY(height, TEXT_SCALE), Theme.FONT.value,
hovered ? Theme.FOREGROUND.value : Theme.PRIMARY_FG.value);
}
/**
* The button's surface, shared with {@link IconButton}.
*/
static void drawSurface(RenderScope renderScope, int width, int height, boolean hovered) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
hovered ? Theme.withAlpha(0.9f, Theme.PRIMARY.value) : Theme.getPanelBg(),
Theme.withAlpha(hovered ? 0.25f : 0.1f, Theme.FOREGROUND.value));
}
@Override
@@ -0,0 +1,85 @@
package org.saturnclient.ui.elements;
import java.util.List;
import java.util.function.Consumer;
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 row of pill buttons where exactly one is selected, such as category filters.
*/
public class ChipGroup extends Element {
private static final int HEIGHT = 28;
private static final int PADDING = 14;
private static final int GAP = 8;
private static final float TEXT_SCALE = 0.62f;
private final List<String> options;
private final Consumer<String> onSelect;
private String selected;
public ChipGroup(List<String> options, String selected, Consumer<String> onSelect) {
this.options = options;
this.selected = selected;
this.onSelect = onSelect;
this.height = HEIGHT;
this.width = chipX(options.size()) - GAP;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
int font = Theme.FONT.value;
int textY = (HEIGHT - (int) (Fonts.getHeight() * TEXT_SCALE)) / 2;
for (int i = 0; i < options.size(); i++) {
String option = options.get(i);
int x = chipX(i);
int w = chipWidth(option);
boolean active = option.equals(selected);
boolean hovered = ctx.isHovering(x, 0, w, HEIGHT);
if (active) {
renderScope.drawRoundedRectangle(x, 0, w, HEIGHT, HEIGHT / 2, Theme.ACCENT.value);
} else {
renderScope.drawRoundedRectangle(x, 0, w, HEIGHT, HEIGHT / 2, 1,
Theme.withAlpha(0.05f, Theme.FOREGROUND.value),
hovered ? Theme.withAlpha(0.25f, Theme.FOREGROUND.value) : Theme.getBorder());
}
renderScope.drawText(TEXT_SCALE, option, x + PADDING, textY, font,
active ? Theme.ACCENT_FG.value : Theme.PRIMARY_FG.value);
}
}
@Override
public void click(int mouseX, int mouseY) {
for (int i = 0; i < options.size(); i++) {
String option = options.get(i);
int x = chipX(i);
if (mouseX >= x && mouseX <= x + chipWidth(option)) {
if (!option.equals(selected)) {
selected = option;
onSelect.accept(option);
}
return;
}
}
}
private int chipX(int index) {
int x = 0;
for (int i = 0; i < index; i++) {
x += chipWidth(options.get(i)) + GAP;
}
return x;
}
private static int chipWidth(String option) {
return (int) (Fonts.getWidth(option, Theme.FONT.value) * TEXT_SCALE) + PADDING * 2;
}
}
@@ -1,30 +0,0 @@
package org.saturnclient.ui.elements;
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;
public class ClickableTexture extends Element {
IdentifierRef sprite;
private Runnable onClick;
public ClickableTexture(IdentifierRef sprite, Runnable onClick) {
this.sprite = sprite;
this.onClick = onClick;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawTexture(sprite, 0, 0, 0, 0, width, height,
ctx.isHovering() ? Theme.ACCENT.value : Theme.PRIMARY.value);
}
@Override
public void click(int mouseX, int mouseY) {
if (onClick != null) {
onClick.run();
}
}
}
@@ -0,0 +1,23 @@
package org.saturnclient.ui.elements;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
/**
* 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 {
public Divider(int width) {
this.width = width;
this.height = 1;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRectangle(0, 0, width, height, Theme.getBorder());
}
}
@@ -0,0 +1,44 @@
package org.saturnclient.ui.elements;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.resources.Fonts;
/**
* The shared look of input fields: a faint box whose outline brightens on hover
* and turns to the accent color while focused.
*/
public final class Field {
/** Height of the fields in settings rows */
public static final int HEIGHT = 32;
public static final float TEXT_SCALE = 0.68f;
private Field() {
}
public static void drawBox(RenderScope renderScope, int width, int height, boolean hovered, boolean focused) {
int border = focused ? Theme.withAlpha(0.6f, Theme.ACCENT.value)
: hovered ? 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);
}
/**
* The y at which text drawn at {@code scale} is vertically centered in a
* field of the given height.
*/
public static int textY(int height, float scale) {
return (height - (int) (Fonts.getHeight() * scale)) / 2;
}
/**
* Draws {@code text} centered in a field of the given size.
*/
public static void drawCenteredText(RenderScope renderScope, String text, int width, int height, int font,
int color) {
int x = (width - (int) (Fonts.getWidth(text, font) * TEXT_SCALE)) / 2;
renderScope.drawText(TEXT_SCALE, text, x, textY(height, TEXT_SCALE), font, color);
}
}
@@ -0,0 +1,40 @@
package org.saturnclient.ui.elements;
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;
/**
* A square {@link Button} showing an icon, which turns the accent color on
* hover.
*/
public class IconButton extends Element {
private static final float ICON_RATIO = 0.48f;
private final IdentifierRef icon;
private final Runnable onClick;
public IconButton(IdentifierRef icon, Runnable onClick) {
this.icon = icon;
this.onClick = onClick;
this.width = Button.HEIGHT;
this.height = Button.HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
boolean hovered = ctx.isHovering();
Button.drawSurface(renderScope, width, height, hovered);
int size = (int) (Math.min(width, height) * ICON_RATIO);
renderScope.drawTexture(icon, (width - size) / 2, (height - size) / 2, 0, 0, size, size,
hovered ? Theme.ACCENT.value : Theme.PRIMARY_FG.value);
}
@Override
public void click(int mouseX, int mouseY) {
onClick.run();
}
}
@@ -0,0 +1,33 @@
package org.saturnclient.ui.elements;
import java.util.function.IntSupplier;
import java.util.function.Supplier;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.resources.Fonts;
/**
* A line of text whose content, font and color are read on every frame, so it
* follows state and theme changes without rebuilding the screen.
*/
public class Label extends Element {
private final Supplier<String> text;
private final float textScale;
private final IntSupplier font;
private final IntSupplier color;
public Label(Supplier<String> text, float textScale, IntSupplier font, IntSupplier color) {
this.text = text;
this.textScale = textScale;
this.font = font;
this.color = color;
this.height = (int) (Fonts.getHeight() * textScale);
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawText(textScale, text.get(), 0, 0, font.getAsInt(), color.getAsInt());
}
}
@@ -1,59 +0,0 @@
package org.saturnclient.ui.elements;
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.anim.SlideX;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
public class Notification extends Element {
private static ColorProperty error = Property.color(0xFFbf212f);
private static ColorProperty info = Property.color(0xFF264b96);
private static ColorProperty success = Property.color(0xFF27b376);
public static enum NotificationKind {
Error,
Info,
Success,
}
NotificationKind kind;
String text;
String title;
public Notification(int width, int height, NotificationKind kind, String title, String text) {
this.kind = kind;
this.text = text;
this.title = title;
this.width = (int) Math.max(Fonts.getWidth(text, 1) * 0.6f, Fonts.getWidth(title, 1)) + 20;
this.height = 50;
this.x = width - this.width - 15;
this.y = height - this.height - 15;
this.animation = new SlideX(700, -width);
this.duration = 3000;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, 10, Theme.PRIMARY.value);
switch (kind) {
case Error:
renderScope.drawTexture(Textures.CIRCLE_X, 9, 9, 0, 0, 15, 15, error.value);
break;
case Info:
renderScope.drawTexture(Textures.INFO, 9, 9, 0, 0, 15, 15, info.value);
break;
case Success:
renderScope.drawTexture(Textures.CHECK, 9, 9, 0, 0, 15, 15, success.value);
break;
}
renderScope.drawText(0.7f, title, 32, 10, 1, -1);
renderScope.drawText(0.5f, text, 32, 28, 1, -1);
}
}
@@ -0,0 +1,22 @@
package org.saturnclient.ui.elements;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
/**
* The translucent, outlined surface that screens lay their content on.
*/
public class Panel extends Element {
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
drawBackground(renderScope, width, height);
}
public static void drawBackground(RenderScope renderScope, int width, int height) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.BG_RADIUS.value, 1,
Theme.getPanelBg(), Theme.getBorder());
}
}
@@ -4,23 +4,40 @@ import java.util.ArrayList;
import java.util.List;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.ElementRenderer;
import org.saturnclient.ui.RenderScope;
/**
* A vertically scrolling container. It scrolls with the mouse wheel or by
* dragging its scrollbar, which shows along its right edge when the children
* don't fit.
*/
public class Scroll extends Element {
private static IntProperty scrollBarRadius = Property.integer(10);
private static IntProperty scrollBarWidth = Property.integer(5);
private static IntProperty scrollBarPadding = Property.integer(5);
/** Units scrolled per mouse wheel notch */
private static final double SCROLL_SPEED = 24;
/** Space left below the last child when scrolled to the end */
private static final int END_SPACE = 10;
private static final int BAR_WIDTH = 5;
private static final int BAR_INSET = 5;
private static final int MIN_THUMB = 20;
/** Width to keep clear at the right of the content for the scrollbar and its click area */
public static final int SCROLLBAR_SPACE = BAR_WIDTH + BAR_INSET * 2 + 4;
int padding = 0;
boolean background = true;
protected List<Element> children = new ArrayList<>();
int scroll = 0;
int maxScroll = 0;
/** Kept fractional so small trackpad deltas add up instead of being dropped */
private double scroll = 0;
private int maxScroll = 0;
/** Where in the thumb it was grabbed, while it's being dragged */
private Integer thumbGrab = null;
public Scroll(int padding) {
this.padding = padding;
@@ -30,84 +47,57 @@ public class Scroll extends Element {
ElementRenderer.draw(children, element);
}
/**
* Skips the scroll's own background, for when it sits on a {@link Panel}.
*/
public Scroll noBackground() {
this.background = false;
return this;
}
/**
* Removes every child and scrolls back to the top.
*/
public void clear() {
children.clear();
scroll = 0;
maxScroll = 0;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
calculateMaxScroll();
updateMaxScroll();
int offset = getOffset();
renderScope.drawRoundedRectangle(0, 0, width, height,
Theme.BG_RADIUS.value, Theme.BACKGROUND.value);
if (background) {
renderScope.drawRoundedRectangle(0, 0, width, height,
Theme.BG_RADIUS.value, Theme.BACKGROUND.value);
}
renderScope.enableScissor(padding, padding, width - padding, height - padding);
renderScope.getMatrixStack().push();
renderScope.getMatrixStack().translate(padding, -scroll + padding);
renderScope.getMatrixStack().translate(padding, -offset + padding);
ElementRenderer.render(children, ctx.elapsed, renderScope,
ctx.mouseX - padding,
ctx.mouseY - padding + scroll);
ctx.mouseY - padding + offset,
ctx.childFrame(padding, padding - offset, padding, height - padding));
renderScope.getMatrixStack().pop();
renderScope.disableScissor();
if (maxScroll > 0) {
renderScope.drawRoundedRectangle(
width - scrollBarWidth.value - scrollBarPadding.value,
calculateScrollBarY(),
scrollBarWidth.value,
calculateScrollBarHeight(),
scrollBarRadius.value,
Theme.SCROLL.value);
boolean active = thumbGrab != null || ctx.isHovering(getBarX() - BAR_INSET, 0, BAR_WIDTH + BAR_INSET * 2,
height);
renderScope.drawRoundedRectangle(getBarX(), getThumbY(), BAR_WIDTH, getThumbHeight(), BAR_WIDTH / 2,
active ? Theme.SCROLL.value : Theme.withAlpha(0.7f, Theme.SCROLL.value));
}
}
@Override
public void scroll(int mouseX, int mouseY, double horizontalAmount, double verticalAmount) {
scroll -= verticalAmount;
if (scroll < 0) {
scroll = 0;
} else if (scroll > maxScroll) {
scroll = maxScroll;
}
}
@Override
public Element dimensions(int width, int height) {
super.dimensions(width, height);
maxScroll = 0;
for (Element element : children) {
maxScroll = Math.max(maxScroll, (element.y + element.height + padding) - (height - padding * 2));
}
return this;
}
int calculateScrollBarHeight() {
if (maxScroll <= 0)
return height - (scrollBarPadding.value * 2);
return Math.max(20, (height * height) / (height + maxScroll + 10)) - (scrollBarPadding.value * 2);
}
int calculateScrollBarY() {
int scrollBarHeight = calculateScrollBarHeight();
if (maxScroll <= 0)
return scrollBarPadding.value;
int y = (scroll * (height - scrollBarHeight - scrollBarPadding.value * 2)) / maxScroll + scrollBarPadding.value;
return Math.min(y, height - scrollBarHeight - scrollBarPadding.value);
}
private void calculateMaxScroll() {
maxScroll = 0;
for (Element element : children) {
int bottom = element.y + element.height;
maxScroll = Math.max(maxScroll, bottom - height + padding);
}
if (maxScroll > 0) {
maxScroll = maxScroll + 10;
} else {
maxScroll = 0;
}
setScroll(scroll - verticalAmount * SCROLL_SPEED);
}
@Override
@@ -117,17 +107,89 @@ public class Scroll extends Element {
@Override
public void mouseClicked(double mouseX, double mouseY, int button) {
ElementRenderer.mouseClicked(children, mouseX - padding, mouseY - padding + scroll, button);
// Children scrolled out of view still have positions under the rest of the
// screen, so only clicks inside the scroll's own bounds may reach them.
if (mouseX < 0 || mouseY < 0 || mouseX > width || mouseY > height) {
for (Element child : children) {
child.focused = false;
}
return;
}
if (maxScroll > 0 && mouseX >= getBarX() - BAR_INSET) {
int thumbY = getThumbY();
int thumbHeight = getThumbHeight();
// Grab the thumb where it was clicked, or center it on a click in the track.
boolean onThumb = mouseY >= thumbY && mouseY <= thumbY + thumbHeight;
thumbGrab = onThumb ? (int) mouseY - thumbY : thumbHeight / 2;
dragThumb(mouseY);
return;
}
ElementRenderer.mouseClicked(children, mouseX - padding, mouseY - padding + getOffset(), button);
}
@Override
public void mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
ElementRenderer.mouseDragged(children, mouseX - padding, mouseY - padding + scroll, button, deltaX,
if (thumbGrab != null) {
dragThumb(mouseY);
return;
}
ElementRenderer.mouseDragged(children, mouseX - padding, mouseY - padding + getOffset(), button, deltaX,
deltaY);
}
@Override
public void mouseReleased(double mouseX, double mouseY, int button) {
ElementRenderer.mouseReleased(children, mouseX - padding, mouseY - padding + scroll, button);
thumbGrab = null;
ElementRenderer.mouseReleased(children, mouseX - padding, mouseY - padding + getOffset(), button);
}
private void dragThumb(double mouseY) {
int travel = getTrackHeight() - getThumbHeight();
if (travel > 0) {
setScroll((mouseY - thumbGrab - BAR_INSET) / travel * maxScroll);
}
}
private void setScroll(double value) {
scroll = Math.max(0, Math.min(maxScroll, value));
}
private int getOffset() {
return (int) Math.round(scroll);
}
private void updateMaxScroll() {
int bottom = 0;
for (Element element : children) {
bottom = Math.max(bottom, element.y + element.height);
}
int overflow = bottom + padding * 2 - height;
maxScroll = overflow > 0 ? overflow + END_SPACE : 0;
// The content may have shrunk, such as after filtering.
setScroll(scroll);
}
private int getBarX() {
return width - BAR_WIDTH - BAR_INSET;
}
private int getTrackHeight() {
return height - BAR_INSET * 2;
}
private int getThumbHeight() {
int track = getTrackHeight();
return Math.max(MIN_THUMB, (int) ((long) track * height / (height + maxScroll)));
}
private int getThumbY() {
int travel = getTrackHeight() - getThumbHeight();
return BAR_INSET + (int) (travel * (scroll / maxScroll));
}
}
@@ -0,0 +1,59 @@
package org.saturnclient.ui.elements;
import java.util.function.BooleanSupplier;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.components.inputs.Toggle;
import org.saturnclient.ui.resources.Fonts;
/**
* A labelled on/off switch ("Enabled" / "Disabled") that tints itself with the
* accent color while on.
*/
public class SwitchButton extends Element {
private static final int HEIGHT = 34;
private static final int PADDING_LEFT = 14;
private static final int PADDING_RIGHT = 5;
private static final int GAP = 10;
private static final float TEXT_SCALE = 0.66f;
private final String onText;
private final String offText;
private final BooleanSupplier value;
private final Runnable onToggle;
public SwitchButton(String onText, String offText, BooleanSupplier value, Runnable onToggle) {
this.onText = onText;
this.offText = offText;
this.value = value;
this.onToggle = onToggle;
this.height = HEIGHT;
// Sized for the longer label, so the button doesn't change width when toggled
int textWidth = Math.max(Fonts.getWidth(onText, Theme.FONT.value), Fonts.getWidth(offText, Theme.FONT.value));
this.width = PADDING_LEFT + (int) (textWidth * TEXT_SCALE) + GAP + Toggle.SWITCH_WIDTH + PADDING_RIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
boolean on = value.getAsBoolean();
float tint = ctx.isHovering() ? 0.1f : 0.06f;
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value,
on ? Theme.withAlpha(tint * 2.5f, Theme.ACCENT.value) : Theme.withAlpha(tint, Theme.FOREGROUND.value));
renderScope.drawText(TEXT_SCALE, on ? onText : offText, PADDING_LEFT, Field.textY(height, TEXT_SCALE),
Theme.FONT.value, on ? Theme.ACCENT.value : Theme.PRIMARY_FG.value);
Toggle.drawSwitch(renderScope, width - PADDING_RIGHT - Toggle.SWITCH_WIDTH,
(height - Toggle.SWITCH_HEIGHT) / 2, on);
}
@Override
public void click(int mouseX, int mouseY) {
onToggle.run();
}
}
@@ -1,63 +0,0 @@
package org.saturnclient.ui.elements;
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.Utils;
public class TabMenu extends Element {
public static class TabMenuComponent {
Runnable onClick;
IdentifierRef sprite;
public TabMenuComponent(IdentifierRef sprite, Runnable onClick) {
this.onClick = onClick;
this.sprite = sprite;
}
}
TabMenuComponent[] components;
public int selectedScreen = 0;
public TabMenu(int active, TabMenuComponent... components) {
this.components = components;
width = 25 * components.length + 6;
height = 30;
this.selectedScreen = active;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, Theme.PRIMARY.value);
int col = 0;
for (TabMenuComponent component : components) {
int x = 25 * col + 5;
boolean hovering = ctx.isHovering(x, 5, 20, 20) || selectedScreen == col;
if (hovering) {
renderScope.drawRoundedRectangle(x, 5, 20, 20, Theme.WIDGET_RADIUS.value, Theme.ACCENT.value);
}
renderScope.drawTexture(component.sprite, x + 5, 10, 0, 0, 10, 10,
hovering ? Theme.ACCENT_FG.value : Theme.PRIMARY_FG.value);
col++;
}
}
@Override
public void click(int mouseX, int mouseY) {
int col = 0;
for (TabMenuComponent component : components) {
int x = 25 * col + 5;
if (Utils.isHovering(mouseX - x, mouseY - 5, 20, 20, 1.0f)) {
component.onClick.run();
}
col++;
}
}
}
@@ -1,22 +0,0 @@
package org.saturnclient.ui.elements;
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;
public class Text extends Element {
private String text;
public Text(String text) {
this.text = text;
width = Fonts.getWidth(text, Theme.FONT.value);
height = Fonts.getHeight();
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawText(this.text, 0, 0, Theme.FONT.value, Theme.FOREGROUND.value);
}
}
@@ -1,37 +0,0 @@
package org.saturnclient.ui.elements;
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;
public class TextureButton extends Element {
IdentifierRef sprite;
private Runnable onClick;
static int padding = 14;
public TextureButton(IdentifierRef sprite, Runnable onClick) {
this.sprite = sprite;
this.onClick = onClick;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value,
ctx.isHovering() ? Theme.ACCENT.value : Theme.PRIMARY.value);
int texWidth = width - padding;
int texHeight = height - padding;
renderScope.drawTexture(sprite, padding / 2, padding / 2, 0, 0, texWidth, texHeight,
ctx.isHovering() ? Theme.ACCENT_FG.value : Theme.PRIMARY_FG.value);
}
@Override
public void click(int mouseX, int mouseY) {
if (onClick != null) {
onClick.run();
}
}
}
@@ -44,6 +44,24 @@ public class Fonts {
return 18;
}
/**
* Shortens {@code text} with a trailing "…" so it fits in {@code maxWidth}
* when drawn at {@code scale}.
*/
public static String ellipsize(String text, int font, float scale, int maxWidth) {
if (getWidth(text, font) * scale <= maxWidth) {
return text;
}
String ellipsis = "…";
int end = text.length();
while (end > 0 && getWidth(text.substring(0, end).stripTrailing() + ellipsis, font) * scale > maxWidth) {
end--;
}
return text.substring(0, end).stripTrailing() + ellipsis;
}
public static int centerX(int w, String text, int font) {
return (w - Fonts.getWidth(text, font)) / 2;
}
@@ -1,29 +1,35 @@
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 {
public static final IdentifierRef CROSSHAIR_RANGE = IdentifierRef
.ofSaturn("textures/gui/hud/crosshair_range.png");
public static final IdentifierRef COSMETICS = IdentifierRef.ofSaturn("textures/gui/components/cosmetics.png");
public static final IdentifierRef HUD_ICON = IdentifierRef.ofSaturn("textures/gui/components/hud.png");
public static final IdentifierRef SEARCH = IdentifierRef.ofSaturn("textures/gui/components/search.png");
public static final IdentifierRef CLOSE = IdentifierRef.ofSaturn("textures/gui/components/close.png");
public static final IdentifierRef MODS_TAB = IdentifierRef.ofSaturn("textures/gui/components/mods_tab.png");
public static final IdentifierRef SETTINGS = IdentifierRef.ofSaturn("textures/gui/components/settings.png");
public static final IdentifierRef CLOAK = IdentifierRef.ofSaturn("textures/gui/components/cloak.png");
public static final IdentifierRef HAT = IdentifierRef.ofSaturn("textures/gui/components/hat.png");
public static final IdentifierRef RESET = IdentifierRef.ofSaturn("textures/gui/components/reset.png");
public static final IdentifierRef TOGGLE_INDICATOR = IdentifierRef
.ofSaturn("textures/gui/components/toggle_indicator.png");
public static final IdentifierRef LEFT = IdentifierRef.ofSaturn("textures/gui/components/left.png");
public static final IdentifierRef RIGHT = IdentifierRef.ofSaturn("textures/gui/components/right.png");
public static final IdentifierRef STORE = IdentifierRef.ofSaturn("textures/gui/components/store.png");
public static final IdentifierRef COINS = IdentifierRef.ofSaturn("textures/gui/components/coins.png");
public static final IdentifierRef SHIRT = IdentifierRef.ofSaturn("textures/gui/components/shirt.png");
public static final IdentifierRef CIRCLE_X = IdentifierRef.ofSaturn("textures/gui/components/circle_x.png");
public static final IdentifierRef INFO = IdentifierRef.ofSaturn("textures/gui/components/info.png");
public static final IdentifierRef CHECK = IdentifierRef.ofSaturn("textures/gui/components/check.png");
// Lucide icons (SVG, drawn in white so they can be tinted)
public static final IdentifierRef ICON_MODS = IdentifierRef.ofSaturn("textures/gui/components/mods.svg");
public static final IdentifierRef ICON_COSMETICS = IdentifierRef.ofSaturn("textures/gui/components/cosmetics.svg");
public static final IdentifierRef ICON_HUD_EDITOR = IdentifierRef.ofSaturn("textures/gui/components/hud_editor.svg");
public static final IdentifierRef ICON_SETTINGS = IdentifierRef.ofSaturn("textures/gui/components/settings.svg");
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");
public static final IdentifierRef ICON_CHEVRON_RIGHT = IdentifierRef
.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");
public static final IdentifierRef REALISTIC_LOGO = IdentifierRef.ofSaturn("textures/logo/realistic.png");
@@ -31,15 +37,17 @@ public class Textures {
public static final IdentifierRef LOGO_TEXT_BIG = IdentifierRef.ofSaturn("textures/logo/text_big.png");
public static final IdentifierRef SPLASH = IdentifierRef.ofSaturn("textures/gui/splash.png");
// Widgets
public static final IdentifierRef BUTTON_BORDER = IdentifierRef.ofSaturn("button_border");
public static final IdentifierRef BUTTON = IdentifierRef.ofSaturn("button");
public static final IdentifierRef BUTTON_OP = IdentifierRef.ofSaturn("button_op");
public static final IdentifierRef SETTINGS_BG = IdentifierRef.ofSaturn("settings_bg");
public static final IdentifierRef RECT = IdentifierRef.ofSaturn("rect");
public static final IdentifierRef RECT_BORDER = IdentifierRef.ofSaturn("rect_border");
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");
@@ -0,0 +1,163 @@
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 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();
int listWidth = scrollContentWidth(width);
switch (section) {
case GENERAL -> PropertyList.draw(scroll, getGeneralProperties(), Set.of(), listWidth, 0);
case UI_STYLE -> drawUiStyle(scroll, listWidth);
case ANIMATIONS -> drawAnimations(scroll, listWidth);
}
draw(scroll.dimensions(scrollWidth(width), 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);
}
/**
* Each menu's animation settings under the menu's name.
*/
private static void drawAnimations(Scroll scroll, int width) {
int y = 0;
for (AnimationConfig animation : AnimationConfig.ALL) {
y = PropertyList.drawSection(scroll, animation.name, y) + 4;
y = PropertyList.draw(scroll, animation.getProperties(), Set.of(), width, y) + SECTION_GAP;
}
}
/**
* 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,173 +0,0 @@
package org.saturnclient.ui.screens;
import java.util.Map;
import org.saturnclient.config.ConfigManager;
import org.saturnclient.config.Theme;
import org.saturnclient.config.property.BoolProperty;
import org.saturnclient.config.property.ColorProperty;
import org.saturnclient.config.property.FloatProperty;
import org.saturnclient.config.property.IntProperty;
import org.saturnclient.config.property.KeybindingProperty;
import org.saturnclient.config.property.NamespaceProperty;
import org.saturnclient.config.property.Property;
import org.saturnclient.config.property.SelectProperty;
import org.saturnclient.mod.HudMod;
import org.saturnclient.mod.ModLayout;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.components.HudModPreview;
import org.saturnclient.ui.components.Sidebar;
import org.saturnclient.ui.components.inputs.FloatInput;
import org.saturnclient.ui.components.inputs.ColorInput;
import org.saturnclient.ui.components.inputs.IntInput;
import org.saturnclient.ui.components.inputs.KeybindingSelector;
import org.saturnclient.ui.components.inputs.Select;
import org.saturnclient.ui.components.inputs.Toggle;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.elements.Text;
import org.saturnclient.ui.resources.Fonts;
public class ConfigEditor extends SaturnScreen {
private ConfigManager config;
private HudMod hudMod;
private HudModPreview preview;
public ConfigEditor(ConfigManager config) {
super("Config Editor");
this.config = config;
}
public ConfigEditor(ConfigManager config, HudMod hudMod) {
super("Config Editor");
this.config = config;
this.hudMod = hudMod;
}
@Override
public void ui() {
int p = 10;
int g = 10;
Scroll scroll = new Scroll(p);
drawProperties(scroll, config.getProperties(), 0, 0, 480 + 10 + (g * 2));
int scrollWidth = 480 + 10 + (g * 2) + (p * 2);
if (this.hudMod != null) {
this.preview = new HudModPreview(this.hudMod);
}
draw(scroll.dimensions(scrollWidth, 350).center(width, height));
draw(new Sidebar(1, this.provider::close).center(width, height, -(scrollWidth / 2 + 20), 0));
}
private int drawProperties(Scroll configScroll, Map<String, Property> properties, int row, int col, int w) {
for (Map.Entry<String, Property> propEntry : properties.entrySet()) {
Property prop = propEntry.getValue();
String propName = propEntry.getKey();
boolean full = isFull(prop);
// Always reset to col 0 if full-width component needs to be drawn mid-row
if (full && col != 0) {
col = 0;
row++;
}
int modX = (w / 2) * col; // two-column layout
int modY = 25 * row;
if (prop instanceof NamespaceProperty) {
configScroll.draw(
new Text(propName)
.position(Fonts.centerX(520, propName, Theme.FONT.value), modY + 1)
.scale(0.7f));
} else {
configScroll.draw(
new Text(propName)
.position(modX, modY + 1)
.scale(0.7f));
}
// instanceof dispatch
if (prop instanceof BoolProperty p) {
configScroll.draw(
new Toggle(p)
.position(modX + (w / 2) - 40, modY)
.scale(0.5f));
}
else if (prop instanceof ColorProperty p) {
configScroll.draw(
new ColorInput(p)
.position(w / 2, modY));
}
else if (prop instanceof IntProperty p) {
configScroll.draw(
new IntInput(p)
.position(w / 2, modY));
}
else if (prop instanceof FloatProperty p) {
configScroll.draw(
new FloatInput(p)
.position(w / 2, modY));
}
else if (prop instanceof NamespaceProperty p) {
Map<String, Property> nestedProperties = p.value;
row = drawProperties(configScroll, nestedProperties, row + 1, col, w);
}
else if (prop instanceof SelectProperty p) {
configScroll.draw(
new Select(p)
.position(w / 2, modY));
}
else if (prop instanceof KeybindingProperty p) {
configScroll.draw(
new KeybindingSelector(p)
.position(w / 2, modY));
}
// STRING → intentionally no component (same as before)
if (full) {
row++; // full-width takes a whole row
col = 0;
} else {
col++;
if (col > 1) { // max 2 columns
col = 0;
row++;
}
}
}
return row;
}
public static boolean isFull(Property prop) {
return !(prop instanceof BoolProperty);
}
@Override
public void render(RenderScope renderScope, int mouseX, int mouseY, float delta, long elapsed) {
if (this.preview != null) {
ModLayout layout = hudMod.getDimensions();
draw(this.preview
.dimensions(layout.width, (int) (layout.height * layout.scale.value * 2.0f))
.scale(1.0f) // Because the centering won't work properly
.center(width, height, (530 / 2) + 40, 0)
.scale(layout.scale.value * 2.0f));
}
super.render(renderScope, mouseX, mouseY, delta, elapsed);
}
}
@@ -0,0 +1,222 @@
package org.saturnclient.ui.screens;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import org.saturnclient.client.ServiceClient;
import org.saturnclient.client.player.SaturnPlayer;
import org.saturnclient.common.ref.asset.IdentifierRef;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.config.Theme;
import org.saturnclient.cosmetics.Cloaks;
import org.saturnclient.cosmetics.Hats;
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;
import org.saturnclient.ui.components.SkinPreview;
import org.saturnclient.ui.elements.AnimationStagger;
import org.saturnclient.ui.elements.ChipGroup;
import org.saturnclient.ui.elements.Label;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
/**
* Cloaks and hats: the player on the left, turned by dragging, and a grid of
* every cosmetic on the right. Clicking an owned cosmetic equips it; clicking
* one not owned yet claims it, which is free.
*/
public class CosmeticsMenu extends PanelScreen {
private enum Kind {
CLOAKS("Cloaks", "Cloak", Cloaks.ALL_CLOAKS, () -> Cloaks.availableCloaks, player -> player.cloak,
Cloaks::setCloak, ServiceClient::buyCloak, Textures::getCloakPreview, 0.5f, 150f),
HATS("Hats", "Hat", Hats.ALL_HATS, () -> Hats.availableHats, player -> player.hat,
Hats::setHat, ServiceClient::buyHat, Textures::getHatPreview, 1f, -25f);
final String title;
final String singular;
final String[] all;
final Supplier<List<String>> owned;
final Function<SaturnPlayer, String> equipped;
final Consumer<String> equip;
final Consumer<String> claim;
final Function<String, IdentifierRef> preview;
/** Width divided by height of the preview textures */
final float previewAspect;
/** The angle the player starts at, showing off this kind of cosmetic */
final float yaw;
Kind(String title, String singular, String[] all, Supplier<List<String>> owned,
Function<SaturnPlayer, String> equipped, Consumer<String> equip, Consumer<String> claim,
Function<String, IdentifierRef> preview, float previewAspect, float yaw) {
this.title = title;
this.singular = singular;
this.all = all;
this.owned = owned;
this.equipped = equipped;
this.equip = equip;
this.claim = claim;
this.preview = preview;
this.previewAspect = previewAspect;
this.yaw = yaw;
}
/**
* "" (nothing), then every cosmetic of this kind.
*/
List<String> ids() {
List<String> ids = new ArrayList<>();
ids.add("");
ids.addAll(List.of(all));
return ids;
}
String getEquipped() {
SaturnPlayer player = SaturnPlayer.get();
return player == null ? "" : equipped.apply(player);
}
}
/** Names that aren't just the id with each word capitalized */
private static final Map<String, String> NAMES = Map.of(
"", "None",
"amg", "AMG",
"amg_petronas", "AMG Petronas",
"bmw", "BMW",
"redbull", "Red Bull");
private static final int STAGE_WIDTH = 300;
private static final int STAGE_PADDING = 16;
private static final int STAGE_FOOTER = 70;
private static final int COLUMN_GAP = 20;
private static final int COLUMNS = 4;
private static final int TILE_GAP = 10;
private static final int GRID_Y = 58;
private static final float SMALL_SCALE = 0.6f;
private Kind kind = Kind.CLOAKS;
public CosmeticsMenu() {
super("Cosmetics", Sidebar.COSMETICS);
}
@Override
protected void panel() {
drawStage();
int x = contentX + STAGE_WIDTH + COLUMN_GAP;
int width = contentX + contentWidth - x;
// Header
draw(new Label(() -> "Cosmetics", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value)
.position(x, contentY + 2));
draw(new Label(this::getOwnedSummary, 0.66f, Theme::getBodyFont, Theme::getMutedFg)
.position(x, contentY + 28));
ChipGroup tabs = new ChipGroup(List.of(Kind.CLOAKS.title, Kind.HATS.title), kind.title, selected -> {
kind = selected.equals(Kind.HATS.title) ? Kind.HATS : Kind.CLOAKS;
init();
});
draw(tabs.position(x + width - tabs.width, contentY + 4));
// Grid
Scroll scroll = new Scroll(0).noBackground();
int gridHeight = contentHeight - GRID_Y;
if (SaturnPlayer.get() == null) {
String message = "Connect to Saturn to change your cosmetics";
int messageX = (width - (int) (Fonts.getWidth(message, Theme.getBodyFont()) * 0.7f)) / 2;
scroll.draw(new Label(() -> message, 0.7f, Theme::getBodyFont, Theme::getMutedFg)
.position(messageX, 60));
} else {
scroll.draw(createGrid(scrollContentWidth(width)));
}
draw(scroll.dimensions(scrollWidth(width), gridHeight).position(x, contentY + GRID_Y));
}
/**
* The left column: the player, and what they have equipped.
*/
private void drawStage() {
draw(new Stage().dimensions(STAGE_WIDTH, contentHeight).position(contentX, contentY));
int previewY = contentY + STAGE_PADDING + 24;
int previewHeight = contentHeight - STAGE_PADDING - 24 - STAGE_FOOTER;
draw(new SkinPreview(kind.yaw).dimensions(STAGE_WIDTH, previewHeight).position(contentX, previewY));
int footerY = contentY + contentHeight - STAGE_FOOTER + 8;
draw(new Label(() -> getName(kind.getEquipped()), 1f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value)
.position(contentX + STAGE_PADDING + 4, footerY));
draw(new Label(() -> kind.singular + " · Equipped", 0.64f, Theme::getBodyFont, Theme::getMutedFg)
.position(contentX + STAGE_PADDING + 4, footerY + 26));
}
private Element createGrid(int width) {
int tileWidth = (width - TILE_GAP * (COLUMNS - 1)) / COLUMNS;
AnimationStagger grid = new AnimationStagger(AnimationConfig.cosmeticsMenu);
List<String> ids = kind.ids();
for (int i = 0; i < ids.size(); i++) {
String id = ids.get(i);
Kind k = kind;
CosmeticTile tile = new CosmeticTile(getName(id), k.preview.apply(id), k.previewAspect, tileWidth,
() -> k.owned.get().contains(id),
() -> k.getEquipped().equals(id),
() -> k.equip.accept(id),
() -> k.claim.accept(id));
tile.position((i % COLUMNS) * (tileWidth + TILE_GAP), (i / COLUMNS) * (CosmeticTile.HEIGHT + TILE_GAP));
tile.animation(new SlideFade(AnimationConfig.cosmeticsMenu, -12));
grid.draw(tile);
}
return grid;
}
private String getOwnedSummary() {
// The owned list also holds "", for nothing equipped.
long owned = List.of(kind.all).stream().filter(kind.owned.get()::contains).count();
return owned + " of " + kind.all.length + " " + kind.title.toLowerCase(Locale.ROOT)
+ " owned · every cosmetic is free to claim";
}
private static String getName(String id) {
String name = NAMES.get(id);
return name != null ? name : Utils.nameFromId(id);
}
/**
* The darker card behind the player, with its heading and a hint that it
* can be turned.
*/
private static class Stage extends Element {
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value + 4, 1,
Theme.withAlpha(0.45f, 0xFF000000), Theme.withAlpha(0.05f, Theme.FOREGROUND.value));
renderScope.drawText(SMALL_SCALE, "PREVIEW", STAGE_PADDING, STAGE_PADDING, Theme.FONT.value,
Theme.getMutedFg());
String hint = "Drag to rotate";
int hintWidth = (int) (Fonts.getWidth(hint, Theme.getBodyFont()) * SMALL_SCALE);
renderScope.drawText(SMALL_SCALE, hint, width - STAGE_PADDING - hintWidth, STAGE_PADDING,
Theme.getBodyFont(), Theme.getMutedFg());
renderScope.drawRectangle(STAGE_PADDING, height - STAGE_FOOTER, width - STAGE_PADDING * 2, 1,
Theme.getBorder());
}
}
}
@@ -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,87 +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++;
}
String emote = Utils.getOrNull(Emotes.availableEmotes, idx);
emoteStagger.draw(
new TextureButton(Textures.getEmotePreview(emote), () -> {
Providers.saturn.playEmote(ServiceClient.uuid, 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++;
}
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;
}
draw(emoteStagger);
int pages = (emotes.size() + SLOTS.length - 1) / SLOTS.length;
page = Math.min(page, pages - 1);
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();
}
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;
}
}
}
@@ -1,59 +1,71 @@
package org.saturnclient.ui.screens;
import org.saturnclient.common.ref.render.MatrixStackRef;
import java.util.ArrayList;
import java.util.List;
import org.saturnclient.config.ConfigManager;
import org.saturnclient.config.Theme;
import org.saturnclient.mod.HudMod;
import org.saturnclient.mod.Mod;
import org.saturnclient.mod.ModLayout;
import org.saturnclient.mod.ModManager;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.components.HudModPreview;
import org.saturnclient.ui.resources.Fonts;
import java.util.ArrayList;
import java.util.List;
/**
* Moves and resizes the enabled HUD mods, drawn as they are in game. Drag one
* to move it, or drag its corner handle to resize it.
*
* Unlike other Saturn screens this works in GUI pixels, like the HUD itself:
* it draws without the half scale and takes the mouse unscaled.
*/
public class HudEditor extends SaturnScreen {
private static final String HINT = "Drag to move · Drag the corner to resize · Esc when done";
/** Size of the resize handle, and how far around it still grabs it */
private static final int HANDLE = 4;
private static final int HANDLE_REACH = 4;
private static final float LABEL_SCALE = 0.4f;
private static final float HINT_SCALE = 0.45f;
private final List<HudMod> hudMods = new ArrayList<>();
private HudMod draggingMod = null;
private boolean resizing = false;
private int offsetX, offsetY;
private static final int RESIZE_MARGIN = 3;
public HudEditor() {
super("HUD Editor");
for (Mod m : ModManager.ENABLED_MODS) {
if (m.isEnabled() && m instanceof HudMod) {
hudMods.add((HudMod) m);
if (m.isEnabled() && m instanceof HudMod hudMod) {
hudMods.add(hudMod);
}
}
}
@Override
public void ui() {
// Placing HUDs needs a clear view of the game.
blurBackground = false;
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button == 0) {
for (int m = hudMods.size() - 1; m >= 0; m--) {
HudMod mod = hudMods.get(m);
HudMod mod = getModAt(mouseX, mouseY);
if (mod != null) {
ModLayout dim = mod.getDimensions();
// Check if clicking the corner for resizing
if (isCorner(mouseX, mouseY, dim)) {
draggingMod = mod;
resizing = true;
offsetX = (int) (mouseX - (dim.x.value + dim.width * dim.scale.value));
offsetY = (int) (mouseY - (dim.y.value + dim.height * dim.scale.value));
return true;
}
// Check if clicking inside the mod
if (isInside(mouseX, mouseY, dim)) {
draggingMod = mod;
resizing = false;
offsetX = (int) (mouseX - dim.x.value);
offsetY = (int) (mouseY - dim.y.value);
return true;
}
draggingMod = mod;
resizing = isOnHandle(mouseX, mouseY, dim);
offsetX = (int) (mouseX - (resizing ? right(dim) : dim.x.value));
offsetY = (int) (mouseY - (resizing ? bottom(dim) : dim.y.value));
return true;
}
}
return super.mouseClicked(mouseX, mouseY, button);
}
@@ -72,57 +84,121 @@ public class HudEditor extends SaturnScreen {
ModLayout dim = draggingMod.getDimensions();
if (resizing) {
// Scale based on cursor movement
float newScale = Math.max(0.5f, ((float) (mouseX - dim.x.value) / dim.width));
dim.scale.value = newScale;
// The handle follows the cursor: the scale is the new width over the unscaled width.
float scale = (float) (mouseX - offsetX - dim.x.value) / dim.width;
float min = dim.scale.min != null ? dim.scale.min : 0.5f;
float max = dim.scale.max != null ? dim.scale.max : 4f;
dim.scale.value = Math.max(min, Math.min(max, scale));
} else {
// Move the mod
dim.x.value = (int) mouseX - offsetX;
dim.y.value = (int) mouseY - offsetY;
}
keepOnScreen(dim);
}
return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
}
@Override
public void ui() {
}
@Override
public void render(RenderScope renderScope, int mouseX, int mouseY, float delta, long elapsed) {
// The screen hands over the mouse in Saturn units, twice GUI pixels.
int guiMouseX = mouseX / 2;
int guiMouseY = mouseY / 2;
HudMod hovered = draggingMod != null ? draggingMod : getModAt(guiMouseX, guiMouseY);
for (HudMod mod : hudMods) {
ModLayout dim = mod.getDimensions();
MatrixStackRef matrices = renderScope.getMatrixStack();
matrices.push();
boolean active = mod == hovered;
matrices.translate(dim.x.value, dim.y.value);
matrices.scale(dim.scale.value, dim.scale.value);
renderScope.getMatrixStack().push();
renderScope.getMatrixStack().translate(dim.x.value, dim.y.value);
renderScope.getMatrixStack().scale(dim.scale.value, dim.scale.value);
HudModPreview.draw(renderScope, mod);
renderScope.getMatrixStack().pop();
// Render the mod
mod.renderDummy(renderScope);
drawFrame(renderScope, mod, dim, active);
}
matrices.pop();
drawHint(renderScope);
}
// Draw border around mod
int borderX = dim.x.value;
int borderY = dim.y.value;
int borderW = (int) (dim.width * dim.scale.value);
int borderH = (int) (dim.height * dim.scale.value);
/**
* The outline around a HUD, its resize handle, and its name while it's
* hovered or dragged.
*/
private void drawFrame(RenderScope renderScope, HudMod mod, ModLayout dim, boolean active) {
int x = dim.x.value;
int y = dim.y.value;
int w = right(dim) - x;
int h = bottom(dim) - y;
int color = active ? Theme.ACCENT.value : Theme.withAlpha(0.45f, Theme.FOREGROUND.value);
renderScope.drawBorder(borderX, borderY, borderW, borderH, 0xFFFFFFFF); // White border
if (active) {
renderScope.drawRectangle(x, y, w, h, Theme.withAlpha(0.08f, Theme.ACCENT.value));
}
renderScope.drawBorder(x - 1, y - 1, w + 2, h + 2, color);
renderScope.drawRectangle(x + w - HANDLE / 2, y + h - HANDLE / 2, HANDLE, HANDLE, color);
if (active && mod instanceof Mod named) {
int labelHeight = (int) (Fonts.getHeight() * LABEL_SCALE);
int labelY = y - labelHeight - 4 < 0 ? y + h + 4 : y - labelHeight - 4;
renderScope.drawText(LABEL_SCALE, named.getName(), x, labelY, Theme.FONT.value, Theme.FOREGROUND.value);
}
}
private boolean isInside(double mouseX, double mouseY, ModLayout dim) {
return mouseX >= dim.x.value && mouseX <= dim.x.value + (dim.width * dim.scale.value) &&
mouseY >= dim.y.value && mouseY <= dim.y.value + (dim.height * dim.scale.value);
private void drawHint(RenderScope renderScope) {
int guiWidth = width / 2;
int textWidth = (int) (Fonts.getWidth(HINT, Theme.getBodyFont()) * HINT_SCALE);
int textHeight = (int) (Fonts.getHeight() * HINT_SCALE);
int pillWidth = textWidth + 20;
int pillHeight = textHeight + 10;
int x = (guiWidth - pillWidth) / 2;
int y = 8;
renderScope.drawRoundedRectangle(x, y, pillWidth, pillHeight, pillHeight / 2, 1, Theme.getPanelBg(),
Theme.getBorder());
renderScope.drawText(HINT_SCALE, HINT, x + 10, y + 5, Theme.getBodyFont(), Theme.PRIMARY_FG.value);
}
private boolean isCorner(double mouseX, double mouseY, ModLayout dim) {
float rightX = dim.x.value + dim.width * dim.scale.value;
float bottomY = dim.y.value + dim.height * dim.scale.value;
/**
* The HUD under the mouse, preferring the one drawn on top, or null.
*/
private HudMod getModAt(double mouseX, double mouseY) {
for (int i = hudMods.size() - 1; i >= 0; i--) {
ModLayout dim = hudMods.get(i).getDimensions();
if (isOnHandle(mouseX, mouseY, dim) || isInside(mouseX, mouseY, dim)) {
return hudMods.get(i);
}
}
return null;
}
return Math.abs(mouseX - rightX) < RESIZE_MARGIN && Math.abs(mouseY - bottomY) < RESIZE_MARGIN;
private void keepOnScreen(ModLayout dim) {
int guiWidth = width / 2;
int guiHeight = height / 2;
int w = right(dim) - dim.x.value;
int h = bottom(dim) - dim.y.value;
dim.x.value = Math.max(0, Math.min(dim.x.value, guiWidth - w));
dim.y.value = Math.max(0, Math.min(dim.y.value, guiHeight - h));
}
private static int right(ModLayout dim) {
return dim.x.value + (int) (dim.width * dim.scale.value);
}
private static int bottom(ModLayout dim) {
return dim.y.value + (int) (dim.height * dim.scale.value);
}
private static boolean isInside(double mouseX, double mouseY, ModLayout dim) {
return mouseX >= dim.x.value && mouseX <= right(dim) && mouseY >= dim.y.value && mouseY <= bottom(dim);
}
private static boolean isOnHandle(double mouseX, double mouseY, ModLayout dim) {
return Math.abs(mouseX - right(dim)) <= HANDLE_REACH && Math.abs(mouseY - bottom(dim)) <= HANDLE_REACH;
}
@Override
@@ -1,56 +1,147 @@
package org.saturnclient.ui.screens;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.config.Theme;
import org.saturnclient.mod.Mod;
import org.saturnclient.mod.ModManager;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.PanelScreen;
import org.saturnclient.ui.anim.SlideFade;
import org.saturnclient.ui.components.SaturnModule;
import org.saturnclient.ui.components.Sidebar;
import org.saturnclient.ui.components.inputs.SearchInput;
import org.saturnclient.ui.elements.AnimationStagger;
import org.saturnclient.ui.elements.ImageTexture;
import org.saturnclient.ui.elements.ChipGroup;
import org.saturnclient.ui.elements.Label;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.resources.Textures;
import org.saturnclient.ui.resources.Fonts;
public class ModMenu extends PanelScreen {
private static final String ALL = "All";
private static final int COLUMNS = 3;
private static final int CARD_GAP = 10;
private String category = ALL;
private String query = "";
private Scroll scroll;
private int gridWidth;
public class ModMenu extends SaturnScreen {
public ModMenu() {
super("Mod Menu");
super("Mod Menu", Sidebar.MODS);
}
@Override
public void ui() {
int p = 10;
int g = 10;
protected void panel() {
// The search box starts empty after a resize, so the query does too.
query = "";
gridWidth = scrollContentWidth(contentWidth);
Scroll scroll = new Scroll(p);
// Header
draw(new Label(() -> "Mods", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value)
.position(contentX, contentY + 2));
int row = 0;
int col = 0;
draw(new Label(this::getEnabledSummary, 0.66f, Theme::getBodyFont, Theme::getMutedFg)
.position(contentX, contentY + 28));
AnimationStagger moduleStagger = new AnimationStagger(AnimationConfig.modMenu);
SearchInput search = new SearchInput("Search mods", text -> {
query = text;
refresh(false);
});
draw(search.position(contentX + contentWidth - search.width, contentY + 2));
// Categories
int chipsY = contentY + 56;
ChipGroup categories = new ChipGroup(getCategories(), category, selected -> {
category = selected;
refresh(false);
});
draw(categories.position(contentX, chipsY));
// Mods
int scrollY = chipsY + categories.height + 14;
scroll = new Scroll(0).noBackground();
scroll.dimensions(scrollWidth(contentWidth), panelY + panelHeight - 12 - scrollY);
refresh(true);
draw(scroll.position(contentX, scrollY));
}
/**
* Fills the grid with the mods that match the category and search query.
*/
private void refresh(boolean animate) {
scroll.clear();
List<Mod> mods = ModManager.MODS.stream().filter(this::matches).toList();
if (mods.isEmpty()) {
String message = "No mods match “" + query + "”";
float scale = 0.7f;
int x = (gridWidth - (int) (Fonts.getWidth(message, Theme.getBodyFont()) * scale)) / 2;
scroll.draw(new Label(() -> message, scale, Theme::getBodyFont, Theme::getMutedFg).position(x, 60));
return;
}
int cardWidth = (gridWidth - CARD_GAP * (COLUMNS - 1)) / COLUMNS;
AnimationStagger grid = new AnimationStagger(AnimationConfig.modMenu);
for (int i = 0; i < mods.size(); i++) {
int col = i % COLUMNS;
int row = i / COLUMNS;
SaturnModule card = new SaturnModule(mods.get(i), cardWidth);
card.position((cardWidth + CARD_GAP) * col, (SaturnModule.HEIGHT + CARD_GAP) * row);
if (animate) {
card.animation(new SlideFade(AnimationConfig.modMenu, -12));
}
grid.draw(card);
}
scroll.draw(grid);
}
private boolean matches(Mod mod) {
if (!category.equals(ALL) && !List.of(mod.getTags()).contains(category)) {
return false;
}
String q = query.trim().toLowerCase(Locale.ROOT);
if (q.isEmpty()) {
return true;
}
String description = mod.getDescription() == null ? "" : mod.getDescription();
return mod.getName().toLowerCase(Locale.ROOT).contains(q)
|| description.toLowerCase(Locale.ROOT).contains(q);
}
/**
* "All", then every mod tag in the order it first appears.
*/
private static List<String> getCategories() {
List<String> categories = new ArrayList<>();
categories.add(ALL);
for (Mod mod : ModManager.MODS) {
moduleStagger.draw(
new SaturnModule(mod)
.position((160 + g) * col, (50 + g) * row)
.animation(new SlideFade(AnimationConfig.modMenu, -12)));
if (col == 2) {
col = 0;
row++;
} else {
col++;
for (String tag : mod.getTags()) {
if (!categories.contains(tag)) {
categories.add(tag);
}
}
}
scroll.draw(moduleStagger);
return categories;
}
int scrollWidth = 480 + 10 + (g * 2) + (p * 2);
draw(scroll.dimensions(scrollWidth, 350).center(width, height));
draw(new Sidebar(0, this.provider::close).center(width, height, -(scrollWidth / 2 + 20), 0));
draw(new ImageTexture(Textures.LOGO_TEXT_BIG).dimensions(180, 18).position(width - 180 - 20, height - 18 - 20));
private String getEnabledSummary() {
long enabled = ModManager.MODS.stream().filter(Mod::isEnabled).count();
return enabled + " of " + ModManager.MODS.size() + " enabled";
}
}
@@ -0,0 +1,97 @@
package org.saturnclient.ui.screens;
import java.util.Map;
import java.util.Set;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.config.property.Property;
import org.saturnclient.mod.HudMod;
import org.saturnclient.mod.Mod;
import org.saturnclient.ui.PanelScreen;
import org.saturnclient.ui.components.HudPreviewCard;
import org.saturnclient.ui.components.ModHeader;
import org.saturnclient.ui.components.PropertyList;
import org.saturnclient.ui.components.Sidebar;
import org.saturnclient.ui.elements.ActionButton;
import org.saturnclient.ui.elements.Divider;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.elements.SwitchButton;
import org.saturnclient.ui.resources.Textures;
/**
* One mod's settings: a header with the mod's details and its on/off switch,
* the rest of its properties below, and a live preview for HUD mods.
*/
public class ModSettings extends PanelScreen {
/**
* Every mod's first property. It's shown as the header's switch, so the list
* leaves it out.
*/
private static final String ENABLED = "Enabled";
private static final int HEADER_Y = 40;
private static final int PREVIEW_WIDTH = 280;
private static final int PREVIEW_MAX_HEIGHT = 220;
private static final int COLUMN_GAP = 22;
private final Mod mod;
public ModSettings(Mod mod) {
super(mod.getName() + " Settings", Sidebar.MODS);
this.mod = mod;
}
@Override
protected void panel() {
draw(new ActionButton(Textures.ICON_CHEVRON_LEFT, "Mods", false,
() -> Providers.saturn.getClient().setScreen(new ModMenu()))
.position(contentX - 12, contentY - 4));
// Header: the mod's details, then Reset and the on/off switch on the right
int headerY = contentY + HEADER_Y;
SwitchButton enabled = new SwitchButton("Enabled", "Disabled", mod::isEnabled,
() -> mod.setEnabled(!mod.isEnabled()));
int buttonY = headerY + (ModHeader.HEIGHT - enabled.height) / 2;
draw(enabled.position(contentX + contentWidth - enabled.width, buttonY));
ActionButton reset = new ActionButton(Textures.ICON_RESET, "Reset", true, this::reset);
draw(reset.position(enabled.x - 10 - reset.width, buttonY));
draw(new ModHeader(mod, reset.x - 16 - contentX).position(contentX, headerY));
int dividerY = headerY + ModHeader.HEIGHT + 16;
draw(new Divider(contentWidth).position(contentX, dividerY));
// Body: the properties, with a preview beside them for HUD mods
int bodyY = dividerY + 1;
int bodyHeight = contentY + contentHeight - bodyY;
int listWidth = contentWidth;
if (mod instanceof HudMod hudMod) {
listWidth -= PREVIEW_WIDTH + COLUMN_GAP;
draw(new HudPreviewCard(hudMod)
.dimensions(PREVIEW_WIDTH, Math.min(PREVIEW_MAX_HEIGHT, bodyHeight - 16))
.position(contentX + contentWidth - PREVIEW_WIDTH, bodyY + 16));
}
Scroll scroll = new Scroll(0).noBackground();
PropertyList.draw(scroll, mod.getConfig().getProperties(), Set.of(ENABLED), scrollContentWidth(listWidth),
0);
draw(scroll.dimensions(scrollWidth(listWidth), bodyHeight).position(contentX, bodyY));
}
/**
* Puts every setting except the on/off switch back to its default, then
* rebuilds the screen so the inputs show the new values.
*/
private void reset() {
for (Map.Entry<String, Property> entry : mod.getConfig().getProperties().entrySet()) {
if (!entry.getKey().equals(ENABLED)) {
entry.getValue().reset();
}
}
init();
}
}
@@ -8,19 +8,24 @@ import org.saturnclient.ui.anim.Fade;
import org.saturnclient.ui.anim.SlideY;
import org.saturnclient.ui.elements.AnimationStagger;
import org.saturnclient.ui.elements.Button;
import org.saturnclient.ui.elements.IconButton;
import org.saturnclient.ui.elements.ImageTexture;
import org.saturnclient.ui.elements.TextureButton;
import org.saturnclient.ui.resources.Textures;
import org.saturnclient.ui.screens.cosmetics.CloakMenu;
/**
* The in-game quick menu: the logo over the HUD editor, mod menu and
* cosmetics.
*/
public class ShiftMenu extends SaturnScreen {
private static final int MODS_WIDTH = 120;
private static final int GAP = 6;
public ShiftMenu() {
super("Shift Menu");
}
@Override
public void ui() {
// Logo animations (unchanged)
draw(new ImageTexture(Textures.LOGO_TEXT)
.dimensions(98, 10)
.center(width, height, 0, -36)
@@ -31,32 +36,24 @@ public class ShiftMenu extends SaturnScreen {
.center(width, height, 0, -80)
.animation(new SlideY(AnimationConfig.logo, -20)));
// Spacing and sizes
int btnWidth = 120;
int btnHeight = 40;
int spacing = 4;
// Create stagger
AnimationStagger stagger = new AnimationStagger(AnimationConfig.shiftMenu.stagger.value);
int x = 0;
stagger.draw(new TextureButton(Textures.HUD_ICON, () -> {
Providers.saturn.getClient().setScreen(new HudEditor());
}).dimensions(btnHeight, btnHeight).position(0, 0)
.animation(new Fade(AnimationConfig.shiftMenu)));
stagger.draw(new IconButton(Textures.ICON_HUD_EDITOR,
() -> Providers.saturn.getClient().setScreen(new HudEditor()))
.position(x, 0).animation(new Fade(AnimationConfig.shiftMenu)));
x += Button.HEIGHT + GAP;
stagger.draw(new Button("Settings", () -> {
Providers.saturn.getClient().setScreen(new ModMenu());
}).dimensions(btnWidth, btnHeight).position(btnHeight + spacing, 0)
.animation(new Fade(AnimationConfig.shiftMenu)));
stagger.draw(new Button("Mods", () -> Providers.saturn.getClient().setScreen(new ModMenu()))
.dimensions(MODS_WIDTH, Button.HEIGHT)
.position(x, 0).animation(new Fade(AnimationConfig.shiftMenu)));
x += MODS_WIDTH + GAP;
stagger.draw(new TextureButton(Textures.COSMETICS, () -> {
Providers.saturn.getClient().setScreen(new CloakMenu());
}).dimensions(btnHeight, btnHeight).position(btnWidth + btnHeight + (spacing * 2), 0)
.animation(new Fade(AnimationConfig.shiftMenu)));
stagger.draw(new IconButton(Textures.ICON_COSMETICS,
() -> Providers.saturn.getClient().setScreen(new CosmeticsMenu()))
.position(x, 0).animation(new Fade(AnimationConfig.shiftMenu)));
x += Button.HEIGHT;
// Draw the stagger at the absolute center of the screen
draw(stagger.dimensions(btnWidth + (btnHeight * 2) + (spacing * 2), btnHeight).center(width, height, 0,
0));
draw(stagger.position((width - x) / 2, (height - Button.HEIGHT) / 2));
}
}
@@ -1,77 +1,114 @@
package org.saturnclient.ui.screens;
import java.util.List;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.common.ref.game.MinecraftClientRef.MinecraftScreen;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.config.Config;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.anim.Fade;
import org.saturnclient.ui.anim.SlideFade;
import org.saturnclient.ui.anim.SlideY;
import org.saturnclient.ui.components.AccountChip;
import org.saturnclient.ui.components.Dock;
import org.saturnclient.ui.elements.AnimationStagger;
import org.saturnclient.ui.elements.Button;
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;
import org.saturnclient.ui.screens.cosmetics.CloakMenu;
/**
* Saturn's title screen: the logo over the game's menus in the middle, and
* along the bottom the player, a dock of Saturn's screens and the version.
*/
public class TitleMenu extends SaturnScreen {
private static final int LOGO = 88;
private static final int WORDMARK_WIDTH = 200;
private static final int WORDMARK_HEIGHT = 20;
private static final int COLUMN_WIDTH = 280;
private static final int BUTTON_HEIGHT = 36;
private static final int GAP = 8;
private static final int EDGE = 28;
private static final float VERSION_SCALE = 0.62f;
public TitleMenu() {
super("Title Menu");
}
@Override
public void ui() {
backgroundBlur = 0;
blurBackground = false;
draw(new ImageTexture(Textures.LOGO_TEXT).dimensions(98, 10).center(width, height, 0, -40)
.animation(new Fade(AnimationConfig.logo.duration.value)));
draw(new ImageTexture(Config.getLogo()).dimensions(98, 98).center(width, height, 0, -84)
// Logo and menus, centered together a little above the middle
int menusHeight = BUTTON_HEIGHT * 3 + GAP * 2;
int blockHeight = LOGO + 16 + WORDMARK_HEIGHT + 36 + menusHeight;
int top = (height - blockHeight) / 2 - 20;
draw(new ImageTexture(Config.getLogo())
.dimensions(LOGO, LOGO)
.position((width - LOGO) / 2, top)
.animation(new SlideY(AnimationConfig.logo, -20)));
draw(new ImageTexture(Textures.LOGO_TEXT_BIG)
.dimensions(WORDMARK_WIDTH, WORDMARK_HEIGHT)
.position((width - WORDMARK_WIDTH) / 2, top + LOGO + 16)
.animation(new Fade(AnimationConfig.logo.duration.value)));
AnimationStagger mainButtonStagger = new AnimationStagger(AnimationConfig.mainMenu);
int rowHeight = BUTTON_HEIGHT + GAP;
int halfWidth = (COLUMN_WIDTH - GAP) / 2;
AnimationStagger menus = new AnimationStagger(AnimationConfig.mainMenu);
mainButtonStagger.draw(new Button("SINGLEPLAYER", () -> {
Providers.saturn.getClient().setScreen(MinecraftScreen.SelectWorld);
}).scale(0.69f).dimensions(420, 52).position(0, 0)
.animation(new SlideFade(AnimationConfig.mainMenu, -10)));
addMenuButton(menus, new Button("Singleplayer",
() -> Providers.saturn.getClient().setScreen(MinecraftScreen.SelectWorld))
.dimensions(COLUMN_WIDTH, BUTTON_HEIGHT).position(0, 0));
addMenuButton(menus, new Button("Multiplayer",
() -> Providers.saturn.getClient().setScreen(MinecraftScreen.Multiplayer))
.dimensions(COLUMN_WIDTH, BUTTON_HEIGHT).position(0, rowHeight));
addMenuButton(menus, new Button("Options",
() -> Providers.saturn.getClient().setScreen(MinecraftScreen.Options))
.dimensions(halfWidth, BUTTON_HEIGHT).position(0, rowHeight * 2));
addMenuButton(menus, new Button("Quit",
() -> Providers.saturn.getClient().scheduleStop())
.dimensions(halfWidth, BUTTON_HEIGHT).position(COLUMN_WIDTH - halfWidth, rowHeight * 2));
mainButtonStagger.draw(new Button("MULTIPLAYER", () -> {
Providers.saturn.getClient().setScreen(MinecraftScreen.Multiplayer);
}).scale(0.69f).dimensions(420, 52).position(0, 40)
.animation(new SlideFade(AnimationConfig.mainMenu, -10)));
draw(menus.position((width - COLUMN_WIDTH) / 2, top + LOGO + 16 + WORDMARK_HEIGHT + 36));
mainButtonStagger.draw(new Button("OPTIONS", () -> {
Providers.saturn.getClient().setScreen(MinecraftScreen.Options);
}).scale(0.69f).dimensions(206, 52).position(0, 80)
.animation(new SlideFade(AnimationConfig.mainMenu, -10)));
// Along the bottom: the player, Saturn's screens, the version
draw(new AccountChip()
.position(EDGE, height - EDGE - AccountChip.HEIGHT)
.animation(new Fade(AnimationConfig.mainMenu)));
mainButtonStagger.draw(new Button("QUIT", () -> {
Providers.saturn.getClient().scheduleStop();
}).scale(0.69f).dimensions(206, 52).position(146, 80)
.animation(new SlideFade(AnimationConfig.mainMenu, -10)));
Dock dock = new Dock(List.of(
new Dock.Item(Textures.ICON_MODS, () -> Providers.saturn.getClient().setScreen(new ModMenu())),
new Dock.Item(Textures.ICON_COSMETICS,
() -> Providers.saturn.getClient().setScreen(new CosmeticsMenu())),
new Dock.Item(Textures.ICON_HUD_EDITOR,
() -> Providers.saturn.getClient().setScreen(new HudEditor()))));
draw(dock.position((width - dock.width) / 2, height - EDGE - Dock.HEIGHT)
.animation(new SlideFade(AnimationConfig.mainMenu, 12)));
draw(mainButtonStagger.dimensions(289, 143).center(width, height, 0, 50));
String version = getVersionText();
int versionWidth = (int) (Fonts.getWidth(version, Theme.getBodyFont()) * VERSION_SCALE);
int versionHeight = (int) (Fonts.getHeight() * VERSION_SCALE);
draw(new Label(() -> version, VERSION_SCALE, Theme::getBodyFont, Theme::getMutedFg)
.position(width - EDGE - versionWidth, height - EDGE - (AccountChip.HEIGHT + versionHeight) / 2)
.animation(new Fade(AnimationConfig.mainMenu)));
}
int s = Fonts.getHeight() + 20;
/**
* "Saturn Client 0.1.0 · Minecraft 1.21.11", from the mod version
* "0.1.0+1.21.11".
*/
private static String getVersionText() {
String[] version = Providers.saturn.getModVersion().split("\\+", 2);
String text = "Saturn Client " + version[0];
return version.length > 1 ? text + " · Minecraft " + version[1] : text;
}
AnimationStagger saturnStagger = new AnimationStagger(AnimationConfig.mainMenu.stagger.value);
saturnStagger.draw(new TextureButton(Textures.HUD_ICON, () -> {
Providers.saturn.getClient().setScreen(new HudEditor());
}).dimensions(s, s).position(0, 0).animation(new SlideFade(AnimationConfig.mainMenu, s + 8)));
saturnStagger.draw(new TextureButton(Textures.SETTINGS, () -> {
Providers.saturn.getClient().setScreen(new ModMenu());
}).dimensions(s, s).position(s + 2, 0)
.animation(new SlideFade(AnimationConfig.mainMenu, s + 8)));
saturnStagger.draw(new TextureButton(Textures.CLOAK, () -> {
Providers.saturn.getClient().setScreen(new CloakMenu());
}).dimensions(s, s).position((s + 2) * 2, 0)
.animation(new SlideFade(AnimationConfig.mainMenu, s + 8)));
draw(saturnStagger.dimensions(((s + 2) * 2) + s, s).centerHorizontal(width, height, 0, 8));
private static void addMenuButton(AnimationStagger stagger, Element button) {
stagger.draw(button.animation(new SlideFade(AnimationConfig.mainMenu, -10)));
}
}
@@ -1,82 +0,0 @@
package org.saturnclient.ui.screens.cosmetics;
import org.saturnclient.client.player.SaturnPlayer;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.cosmetics.Cloaks;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.anim.Fade;
import org.saturnclient.ui.anim.SlideY;
import org.saturnclient.ui.components.CosmeticPreview;
import org.saturnclient.ui.components.Sidebar;
import org.saturnclient.ui.components.SkinPreview;
import org.saturnclient.ui.elements.AnimationStagger;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.elements.TabMenu;
import org.saturnclient.ui.elements.TabMenu.TabMenuComponent;
import org.saturnclient.ui.resources.Textures;
public class CloakMenu extends SaturnScreen {
public CloakMenu() {
super("Cloaks Menu");
}
@Override
public void ui() {
int p = 10;
int g = 10;
Scroll scroll = new Scroll(p);
int row = 0;
int col = 0;
SaturnPlayer player = SaturnPlayer.get();
AnimationStagger stagger = new AnimationStagger(AnimationConfig.cosmeticsMenu);
if (player != null) {
for (String cloak : Cloaks.availableCloaks) {
stagger.draw(
new CosmeticPreview(
cloak == player.cloak,
Textures.getCloakPreview(cloak),
() -> Cloaks.setCloak(cloak))
.position((50 + g) * col, (111 + g) * row)
.animation(new SlideY(AnimationConfig.cosmeticsMenu, 14)));
if (col == 5) {
col = 0;
row++;
} else {
col++;
}
}
}
scroll.draw(stagger);
int scrollWidth = 480 + 10 + (g * 2) + (p * 2);
draw(scroll
.dimensions(scrollWidth, 350)
.center(width, height));
draw(new SkinPreview(-30.0f, false)
.scale(3.5f)
.position(scroll.x + (scrollWidth - 220), scroll.y + 40)
.animation(new Fade(500)));
draw(new Sidebar(2, this.provider::close)
.center(width, height, -(scrollWidth / 2 + 20), 0));
draw(new TabMenu(0,
new TabMenuComponent(Textures.CLOAK, () -> {
}),
new TabMenuComponent(Textures.HAT, () -> {
Providers.saturn.getClient().setScreen(new HatMenu());
}))
.center(width, height, 0, -195));
}
}
@@ -1,82 +0,0 @@
package org.saturnclient.ui.screens.cosmetics;
import org.saturnclient.client.player.SaturnPlayer;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.cosmetics.Hats;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.anim.Fade;
import org.saturnclient.ui.anim.SlideY;
import org.saturnclient.ui.components.CosmeticPreview;
import org.saturnclient.ui.components.Sidebar;
import org.saturnclient.ui.components.SkinPreview;
import org.saturnclient.ui.elements.AnimationStagger;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.elements.TabMenu;
import org.saturnclient.ui.elements.TabMenu.TabMenuComponent;
import org.saturnclient.ui.resources.Textures;
public class HatMenu extends SaturnScreen {
public HatMenu() {
super("Hats Menu");
}
@Override
public void ui() {
int p = 10;
int g = 10;
Scroll scroll = new Scroll(p);
int row = 0;
int col = 0;
SaturnPlayer player = SaturnPlayer.get();
AnimationStagger stagger = new AnimationStagger(AnimationConfig.cosmeticsMenu);
if (player != null) {
for (String hat : Hats.availableHats) {
stagger.draw(
new CosmeticPreview(
hat == player.hat,
Textures.getHatPreview(hat),
() -> Hats.setHat(hat))
.dimensions(50, 50)
.position((50 + g) * col, (50 + g) * row)
.animation(new SlideY(AnimationConfig.cosmeticsMenu, 14)));
if (col == 5) {
col = 0;
row++;
} else {
col++;
}
}
}
scroll.draw(stagger);
int scrollWidth = 480 + 10 + (g * 2) + (p * 2);
draw(scroll
.dimensions(scrollWidth, 350)
.center(width, height));
draw(new SkinPreview(170f, true)
.scale(3.5f)
.position(scroll.x + (scrollWidth - 220), scroll.y + 40)
.animation(new Fade(500)));
draw(new Sidebar(2, this.provider::close)
.center(width, height, -(scrollWidth / 2 + 20), 0));
draw(new TabMenu(1,
new TabMenuComponent(Textures.CLOAK, () -> {
Providers.saturn.getClient().setScreen(new CloakMenu());
}),
new TabMenuComponent(Textures.HAT, () -> {
}))
.center(width, height, 0, -195));
}
}
@@ -1,142 +0,0 @@
package org.saturnclient.ui.screens.store;
// import org.saturnclient.cosmetics.cloak.Cloaks;
import org.saturnclient.client.ServiceClient;
import org.saturnclient.client.player.SaturnPlayer;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.config.Theme;
import org.saturnclient.cosmetics.Cloaks;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.anim.Fade;
import org.saturnclient.ui.anim.SlideY;
import org.saturnclient.ui.components.CosmeticPreview;
import org.saturnclient.ui.components.Sidebar;
import org.saturnclient.ui.elements.AnimationStagger;
import org.saturnclient.ui.elements.ImageTexture;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.elements.TabMenu;
import org.saturnclient.ui.elements.Text;
import org.saturnclient.ui.elements.TabMenu.TabMenuComponent;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
// import com.mojang.blaze3d.systems.RenderSystem;
public class CloakStore extends SaturnScreen {
private long lastPurchaseTime = 0;
public CloakStore() {
super("Cloak Store");
}
public CloakStore(long lastPurchaseTime) {
super("Cloak Store");
this.lastPurchaseTime = lastPurchaseTime;
}
@Override
public void ui() {
int p = 10;
int gx = 10;
int gy = 20;
Scroll scroll = new Scroll(p);
int row = 0;
int col = 0;
SaturnPlayer player = SaturnPlayer.get();
AnimationStagger stagger = new AnimationStagger(AnimationConfig.cosmeticsMenu);
if (player != null) {
for (String cloak : Cloaks.ALL_CLOAKS) {
if (!Cloaks.availableCloaks.contains(cloak)) {
int x = (50 + gx) * col;
int y = (111 + gy) * row;
stagger.draw(
new CosmeticPreview(
cloak == player.cloak,
Textures.getCloakPreview(cloak),
() -> handlePurchase(cloak))
.position(x, y)
.animation(new SlideY(AnimationConfig.cosmeticsMenu, 16)));
String t = "100";
stagger.draw(
new Text(t)
.position(x + Fonts.centerX(50, t, Theme.FONT.value), y + 113)
.scale(0.5f)
.animation(new Fade(400)));
stagger.draw(
new ImageTexture(Textures.COINS)
.dimensions(16, 16)
.position(x + Fonts.getWidth(t, Theme.FONT.value) - 1, y + 112)
.scale(0.5f)
.animation(new Fade(400)));
if (col == 8) {
col = 0;
row++;
} else {
col++;
}
}
}
}
scroll.draw(stagger);
int scrollWidth = 480 + 40 + (gx * 2) + (p * 2);
draw(scroll
.dimensions(scrollWidth, 350)
.center(width, height, 15, 0));
draw(new Sidebar(5, this.provider::close)
.center(width, height, -((scrollWidth - 30) / 2 + 20), 0));
draw(new TabMenu(0,
new TabMenuComponent(Textures.CLOAK, () -> {
}),
new TabMenuComponent(Textures.HAT, () -> {
Providers.saturn.getClient().setScreen(new HatStore());
}))
.center(width, height, 0, -195));
}
private void handlePurchase(String cloak) {
long now = System.currentTimeMillis();
if (now - lastPurchaseTime >= 3000) {
// Utils.notify(NotificationKind.Info, "Purchase processing", "Please wait");
ServiceClient.buyCloak(cloak);
lastPurchaseTime = now;
new Thread(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException ignored) {
}
// RenderSystem.recordRenderCall(() -> {
// MinecraftProvider.PROVIDER.setScreen(new CloakStore(now));
// // Utils.notify(NotificationKind.Success,
// // "Purchase complete",
// // "Congrats, enjoy your new cloak!");
// });
}).start();
} else {
// Utils.notify(NotificationKind.Error,
// "Timeout error",
// "Please wait 3 seconds");
}
}
}
@@ -1,142 +0,0 @@
package org.saturnclient.ui.screens.store;
import org.saturnclient.client.ServiceClient;
import org.saturnclient.client.player.SaturnPlayer;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.config.Theme;
import org.saturnclient.cosmetics.Hats;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.anim.Fade;
import org.saturnclient.ui.anim.SlideY;
import org.saturnclient.ui.components.CosmeticPreview;
import org.saturnclient.ui.components.Sidebar;
import org.saturnclient.ui.elements.AnimationStagger;
import org.saturnclient.ui.elements.ImageTexture;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.elements.TabMenu;
import org.saturnclient.ui.elements.Text;
import org.saturnclient.ui.elements.TabMenu.TabMenuComponent;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
// import com.mojang.blaze3d.systems.RenderSystem;
public class HatStore extends SaturnScreen {
private long lastPurchaseTime = 0;
public HatStore() {
super("Hat Store");
}
public HatStore(long lastPurchaseTime) {
super("Hat Store");
this.lastPurchaseTime = lastPurchaseTime;
}
@Override
public void ui() {
int p = 10;
int gx = 10;
int gy = 20;
Scroll scroll = new Scroll(p);
int row = 0;
int col = 0;
SaturnPlayer player = SaturnPlayer.get();
AnimationStagger stagger = new AnimationStagger(AnimationConfig.cosmeticsMenu);
if (player != null) {
for (String hat : Hats.ALL_HATS) {
if (!Hats.availableHats.contains(hat)) {
int x = (50 + gx) * col;
int y = (50 + gy) * row;
stagger.draw(
new CosmeticPreview(
hat == player.hat,
Textures.getHatPreview(hat),
() -> handlePurchase(hat))
.dimensions(50, 50)
.position(x, y)
.animation(new SlideY(AnimationConfig.cosmeticsMenu, 16)));
String t = "50";
stagger.draw(
new Text(t)
.position(x + Fonts.centerX(50, t, Theme.FONT.value), y + 53)
.scale(0.5f)
.animation(new Fade(400)));
stagger.draw(
new ImageTexture(Textures.COINS)
.dimensions(16, 16)
.position(x + Fonts.getWidth(t, Theme.FONT.value) + 4, y + 52)
.scale(0.5f)
.animation(new Fade(400)));
if (col == 8) {
col = 0;
row++;
} else {
col++;
}
}
}
}
scroll.draw(stagger);
int scrollWidth = 480 + 40 + (gx * 2) + (p * 2);
draw(scroll
.dimensions(scrollWidth, 350)
.center(width, height, 15, 0));
draw(new Sidebar(5, this.provider::close)
.center(width, height, -((scrollWidth - 30) / 2 + 20), 0));
draw(new TabMenu(1,
new TabMenuComponent(Textures.CLOAK, () -> {
Providers.saturn.getClient().setScreen(new CloakStore());
}),
new TabMenuComponent(Textures.HAT, () -> {
}))
.center(width, height, 0, -195));
}
private void handlePurchase(String hat) {
long now = System.currentTimeMillis();
if (now - lastPurchaseTime >= 3000) {
// Utils.notify(NotificationKind.Info, "Purchase processing", "Please wait");
ServiceClient.buyHat(hat);
lastPurchaseTime = now;
new Thread(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException ignored) {
}
// RenderSystem.recordRenderCall(() -> {
// MinecraftProvider.PROVIDER.setScreen(new HatStore(now));
// // Utils.notify(NotificationKind.Success,
// // "Purchase complete",
// // "Congrats, enjoy your new hat!");
// });
}).start();
} else {
// Utils.notify(NotificationKind.Error,
// "Timeout error",
// "Please wait 3 seconds");
}
}
}
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Some files were not shown because too many files have changed in this diff Show More