Compare commits

...
47 Commits
Author SHA1 Message Date
selimaj-dev e8f686b2d3 Merge pull request 'Remove the upgrade of opaque theme colors from before 0.1.3' (#14) from drop-color-upgrade into master
Reviewed-on: #14
2026-09-30 19:49:03 +00:00
selimaj-devandclaude df5afe99e3 Remove the upgrade of opaque theme colors from before 0.1.3
Configs saved before 0.1.3 (without a menu blur) had their preset
background and primary colors made translucent on load. It's no longer
needed, so Theme drops it, along with ConfigManager.isSaved, which only
it used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 21:47:45 +02:00
selimaj-dev 653738568a Merge pull request 'Rename the Ref methods that share a name with vanilla methods' (#13) from explicit-refs into master
Reviewed-on: #13
2026-09-30 16:03:45 +00:00
selimaj-devandclaude a6a324a61a Rename the Ref methods that share a name with vanilla methods
WindowRef.getWidth/getHeight and ItemStackRef.isEmpty/getMaxDamage
match methods of the classes they're mixed into, so the mixins can't
implement them explicitly. With Mojang's names, Window.getWidth is
also the framebuffer's width rather than the window's. They're now
getWindowWidth/getWindowHeight and isEmptyStack/getMaxItemDamage
(with getItemDamage to match).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 17:42:48 +02:00
selimaj-dev b0d75af8c4 Merge pull request 'Fix the None cloak and reorder the sidebar and title dock' (#12) from release-fixes into master
Reviewed-on: #12
2026-09-29 16:34:48 +00:00
selimaj-devandclaude c973e053aa Open Saturn's settings from the title dock and put Saturn first
The Saturn logo in the title screen's dock now opens Saturn's settings instead of the shift menu,
and it comes first: Saturn, mods, cosmetics, then Minecraft's options last. The shift menu is still
on its key in game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:34:24 +02:00
selimaj-devandclaude bbf6b88ed3 Put settings second and the HUD editor last in the sidebar
The order is now mods, settings, cosmetics, HUD editor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:34:24 +02:00
selimaj-devandclaude ba71a5b838 Draw no cloak for "None" instead of the missing texture
"None" is the empty cloak id, but getCurrentCloakTexture still built textures/cloaks/.png from it, so
the renderer drew Minecraft's missing texture. It now returns null for an empty id and for any id
this version has no texture for, and the renderer already skips a null texture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:34:24 +02:00
selimaj-dev 6d5df3057e Merge pull request 'Add seven community emotes and re-export the twerk for emotes 0.1.1' (#11) from new-emotes into master
Reviewed-on: #11
2026-09-29 16:02:00 +00:00
selimaj-devandclaude 2fceb58024 Add seven community emotes and re-export the twerk for emotes 0.1.1
Get the Griddy, Take the L, Dab, Wave, Salute, Shrug and 67, converted from Emotecraft's binary
files with the emotes repo's tools/emotecraft/convert.py. Their authors stay in each file. Their
Emotecraft bends play as the library's curved ones.

Emotes 0.1.1 stopped flipping the torso's bend, so the twerk is re-exported with its torso bends'
signs turned round. It looks the same as before. This needs Saturn on emotes 0.1.1, which the
saturnclient change pointing here does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 17:57:00 +02:00
selimaj-dev 7e98bbcd6c Merge pull request 'Add a twerk emote and drop the front flip' (#10) from twerk-emote into master
Reviewed-on: #10
2026-09-29 14:53:20 +00:00
selimaj-devandclaude 9ede612089 Add a twerk emote and drop the front flip
The twerk was measured from a 3D preview with the new emote capture tool, then made left/right
symmetric so it sways evenly instead of leaning to one side. The front flip is removed because it
plays wrongly with the new emote library.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 16:52:04 +02:00
selimaj-dev ed2914aaf7 Merge pull request 'Make old opaque theme colors translucent once' (#9) from upgrade-opaque-theme into master
Reviewed-on: #9
2026-09-28 21:10:28 +00:00
selimaj-devandclaude 69c830d466 Make old opaque theme colors translucent once
Configs saved before 0.1.3 hold the old default and preset background
and primary colors fully opaque, so glass didn't show for anyone
updating. On a config without Menu blur (new in 0.1.3), colors that are
an old default or preset get their translucent alpha; colors players
picked stay as they are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 23:05:27 +02:00
selimaj-dev 7d0bfd26c9 Merge pull request 'Add GUI scale and glass, and rework the title screen' (#8) from glass-blur into master
Reviewed-on: #8
2026-09-28 20:01:19 +00:00
selimaj-devandclaude 7cc37b7c74 Tell when glass can be drawn at all
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:57:10 +02:00
selimaj-devandclaude 524fc36bf3 Say glass is up to the render scope, not the version
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:55:47 +02:00
selimaj-devandclaude 7bc308155e Split the blur into world, menu and widget blurs
The world or panorama keeps its animated background blur, while glass
behind menus and behind widgets (buttons, the dock, the account chip)
each get a fixed blur of their own. Glass is left out when its blur is 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:35:23 +02:00
selimaj-devandclaude 4d9c629eaf Frost translucent panels with glass
Panels whose background isn't solid draw the blurred game behind them,
cut to the same rounded rectangle texture as the background, where the
version supports glass. Panel screens then leave the rest of the game
unblurred.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:43:44 +02:00
selimaj-devandclaude 5c0912f0cf Use the background and primary colors' own alpha
Panels, buttons, cards and tiles multiplied the theme's background and
primary colors by a fixed alpha, so they were translucent however the
colors were set. They now use the colors as they are, and the defaults
and presets carry the old translucency in their alpha instead. Popups
stay nearly solid so they're readable over panels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 19:56:24 +02:00
selimaj-devandclaude 3436054112 Draw the color picker from gradient textures
The picker drew its square and bars one pixel at a time, about 10,000
fills a frame. It now stretches four gradient strips, made once, over
them instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 19:45:04 +02:00
selimaj-devandclaude adedd688a7 Bring the title screen's intro and corners into line
The intro now comes in in reading order, one Main Menu stagger apart.
The account chip is as tall as the quit button, with a smaller head and
name, both sitting a little tighter in the top corners, and the Saturn
dock item opens the shift menu.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 19:39:39 +02:00
selimaj-devandclaude a7ad458627 Move the title screen's extras to its corners and dock
The account chip and a quit button go in the top corners and the version
in the bottom left, now smaller and fainter. Minecraft's options and
Saturn's settings take the HUD editor's place in the dock, and the intro
brings the menus and edges in after the logo. Faded elements now start
hidden instead of at 10% opacity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 19:20:33 +02:00
selimaj-devandclaude 320b2fd9a5 Tighten the title screen around the logo
Give the title screen the shift menu's logo and wordmark, bring the
menus up under it, and make the dock smaller with a settings item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 19:09:10 +02:00
selimaj-devandclaude a7e3e537b1 Add GUI scale sliders for Saturn's screens
The title screen, the menus and the HUD menus (emote wheel, shift menu)
each get a scale slider, relative to Minecraft's GUI scale. The HUD
editor stays in GUI pixels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 18:59:47 +02:00
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
151 changed files with 8279 additions and 2612 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,7 +1,11 @@
package org.saturnclient.common.ref.game;
/**
* An item stack. Its methods are implemented in ItemStackMixin, so their names must not match any of
* ItemStack's own methods.
*/
public interface ItemStackRef {
public boolean isEmpty();
public int getMaxDamage();
public int getDamage();
public boolean isEmptyStack();
public int getMaxItemDamage();
public int getItemDamage();
}
@@ -1,9 +1,15 @@
package org.saturnclient.common.ref.render;
/**
* The game window. Its methods are implemented in WindowMixin, so their names must not match any of
* Window's own methods.
*/
public interface WindowRef {
public int getWidth();
/** The window's width in screen coordinates */
public int getWindowWidth();
public int getHeight();
/** The window's height in screen coordinates */
public int getWindowHeight();
public int getFramebufferWidth();
@@ -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,8 @@ 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.FloatProperty;
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,8 +19,33 @@ public class Config {
public static BoolProperty saturnTitleScreen = Property.bool(true);
public static BoolProperty cloakPhysics = Property.bool(true);
public static BoolProperty stagger = Property.bool(true);
/** The world or panorama behind Saturn's screens, faded in as they open */
public static IntProperty backgroundBlur = Property.integer(5).range(0, 10);
/** Glass behind translucent menus (panels), and widgets (buttons, the dock) */
public static IntProperty menuBlur = Property.integer(8).range(0, 20);
public static IntProperty widgetBlur = Property.integer(6).range(0, 20);
// How big Saturn's screens are, relative to Minecraft's GUI scale
public static FloatProperty titleScale = guiScale();
public static FloatProperty menuScale = guiScale();
public static FloatProperty hudMenuScale = guiScale();
/** Added in 0.1.3, so a config without it was saved by an older version */
static final String MENU_BLUR = "Menu blur";
public static KeybindingProperty openEmoteWheel = Property.keybinding(GLFWProvider.GLFW_KEY_B);
private static FloatProperty guiScale() {
return Property.floatProp(1f).range(0.5f, 2f, 0.05f).unit("×");
}
/**
* Whether any glass can be drawn: a glass blur is on, and a background it
* goes behind isn't solid.
*/
public static boolean usesGlass() {
boolean translucent = Theme.getPanelBg() >>> 24 < 0xFF || Theme.PRIMARY.value >>> 24 < 0xFF;
return translucent && (menuBlur.value > 0 || widgetBlur.value > 0);
}
public static IdentifierRef getLogo() {
return realisticLogo.value ? Textures.REALISTIC_LOGO : Textures.LOGO;
}
@@ -27,11 +54,17 @@ 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("World blur", backgroundBlur, "How much the world or panorama behind Saturn's screens is blurred");
config.property(MENU_BLUR, menuBlur, "How much the game is blurred through translucent menus");
config.property("Widget blur", widgetBlur, "How much the game is blurred through translucent buttons and the dock");
config.property("Title screen scale", titleScale, "How big the title screen is, relative to Minecraft's GUI scale");
config.property("Menu scale", menuScale, "How big the mods, settings and cosmetics menus are, relative to Minecraft's GUI scale");
config.property("HUD menu scale", hudMenuScale, "How big the emote wheel and shift menu are, relative to Minecraft's GUI scale");
// 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
*/
@@ -148,10 +156,17 @@ public class ConfigManager {
* Load only a single property immediately after creation
*/
private void loadSingleProperty(String name, Property prop) {
JsonNode element = getSaved(name);
if (element != null) {
prop.loadFromJson(element);
}
}
private JsonNode getSaved(String name) {
JsonNode source = cachedJson != null ? cachedJson : loadAndCache();
if (source == null || !source.isObject())
return;
return null;
JsonNode currentScope = source;
@@ -162,16 +177,12 @@ public class ConfigManager {
if (next != null && next.isObject()) {
currentScope = next;
} else {
return;
return null;
}
}
JsonNode element = currentScope.get(name);
if (element != null) {
prop.loadFromJson(element);
}
return currentScope.get(name);
}
/**
@@ -1,19 +1,23 @@
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 {
// Main UI background (slightly warm dark to match Saturn tone)
public static final ColorProperty BACKGROUND = Property.color(0xFF12100D);
public static ConfigManager config;
// Main UI background (slightly warm dark to match Saturn tone), translucent over the world
public static final ColorProperty BACKGROUND = Property.color(0xD112100D);
// Default text color
public static final ColorProperty FOREGROUND = Property.color(0xFFF2E6D2);
// Widget default background (normal state)
public static final ColorProperty PRIMARY = Property.color(0xFF1C1915);
// Widget default background (normal state), translucent over the world
public static final ColorProperty PRIMARY = Property.color(0xB81C1915);
// Widget text color
public static final ColorProperty PRIMARY_FG = Property.color(0xFFEAD9B8);
@@ -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", 0xD112100D, 0xFFF2E6D2, 0xB81C1915, 0xFFEAD9B8, 0xFFE2BF7D, 0xFF1A140A, 0xFFE2BF7D),
new Preset("Midnight", 0xD10E1117, 0xFFE4EAF5, 0xB8171C26, 0xFFC9D4E8, 0xFF8FB3FF, 0xFF0A1224, 0xFF8FB3FF),
new Preset("Amethyst", 0xD1120F17, 0xFFEDE4F7, 0xB81C1724, 0xFFD8C9EB, 0xFFC79BFF, 0xFF1A0F2A, 0xFFC79BFF),
new Preset("Emerald", 0xD10D1210, 0xFFE2F2EA, 0xB816201B, 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,44 @@ public class Theme {
return hover ? ACCENT_FG.value : PRIMARY_FG.value;
}
/**
* Background for panels drawn over the world, as translucent as the
* background color is
*/
public static int getPanelBg() {
return BACKGROUND.value;
}
/**
* Background for popups, which sit over other panels: the background color,
* kept nearly solid so what's under it doesn't show through
*/
public static int getPopupBg() {
return withAlpha(0.97f, BACKGROUND.value | 0xFF000000);
}
/**
* 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);
@@ -38,7 +38,14 @@ public class Cloaks {
availableCloaks.add(0, "");
}
/**
* The cloak's texture, or null for no cloak ("", the "None" choice) and for a cloak this version
* has no texture for, which would otherwise draw as the missing texture.
*/
public static IdentifierRef getCurrentCloakTexture(String cloakName) {
if (cloakName == null || !Arrays.asList(ALL_CLOAKS).contains(cloakName)) {
return null;
}
if (Arrays.asList(ANIMATED_CLOAKS).contains(cloakName)) {
return IdentifierRef.ofSaturn("textures/cloaks/" + cloakName + ".gif");
} else {
@@ -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",
"twerk", "get_the_griddy", "take_the_l", "dab", "wave", "salute", "shrug", "67");
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());
}
@@ -75,16 +75,16 @@ public class ArmorDisplayMod extends Mod implements HudMod {
int row = 0;
if (useMainHand.value && !mainHand.isEmpty()) {
if (useMainHand.value && !mainHand.isEmptyStack()) {
row = renderSlot(scope, mainHand, row);
}
if (!helmet.isEmpty())
if (!helmet.isEmptyStack())
row = renderSlot(scope, helmet, row);
if (!chestplate.isEmpty())
if (!chestplate.isEmptyStack())
row = renderSlot(scope, chestplate, row);
if (!leggings.isEmpty())
if (!leggings.isEmptyStack())
row = renderSlot(scope, leggings, row);
if (!boots.isEmpty())
if (!boots.isEmptyStack())
row = renderSlot(scope, boots, row);
}
@@ -94,8 +94,8 @@ public class ArmorDisplayMod extends Mod implements HudMod {
private int renderSlot(RenderScope scope, ItemStackRef item, int row) {
scope.drawItem(item, 0, 15 * row);
if (item.getMaxDamage() > 0) {
int remaining = item.getMaxDamage() - item.getDamage();
if (item.getMaxItemDamage() > 0) {
int remaining = item.getMaxItemDamage() - item.getItemDamage();
scope.drawText(0.5f, String.valueOf(remaining),
17, (15 * row) + 3,
layout.font.value, layout.fgColor.value);
@@ -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,14 +15,27 @@ 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);
// Not faded in yet
if (element.opacity <= 0) {
continue;
}
renderScope.getMatrixStack().push();
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;
@@ -33,23 +34,116 @@ public interface RenderScope {
if (color == 0)
return;
drawTexture(getRoundedRectangle(width, height, radius), x, y, 0, 0, width, height, color);
}
/**
* The white rounded rectangle that {@link #drawRoundedRectangle} tints, as a
* cached texture.
*/
default IdentifierRef getRoundedRectangle(int width, int height, int radius) {
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);
return getSvgShape(rect, "rounded_" + width + "_" + height + "_" + radius, width, height);
}
/**
* Frosts a rounded rectangle for a translucent background to be drawn over:
* the game behind the screen, blurred by {@code blur}, cut to exactly the
* shape {@link #drawRoundedRectangle} draws. Render scopes without glass
* draw nothing.
*/
default void drawGlass(int x, int y, int width, int height, int radius, int blur) {
drawGlass(getRoundedRectangle(width, height, radius), x, y, width, height, blur);
}
/**
* Draws the game behind the screen, blurred by {@code blur}, where
* {@code mask}, stretched over the area, is opaque.
*/
default void drawGlass(IdentifierRef mask, int x, int y, int width, int height, int blur) {
}
/**
* A {@link #drawRoundedRectangle} with a border, frosted with glass blurred
* by {@code blur} when the background isn't solid. A blur of 0 leaves it
* plain, showing the screen's own background blur.
*/
default void drawGlassRectangle(int x, int y, int width, int height, int radius, int blur, int thickness,
int background, int border) {
if (blur > 0 && background >>> 24 < 0xFF) {
drawGlass(x, y, width, height, radius, blur);
}
drawRoundedRectangle(x, y, width, height, radius, thickness, background, border);
}
/**
* 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) {
drawTexture(getSvgShape(shape, cacheKey, width, height), x, y, 0, 0, width, height, color);
}
/**
* {@code shape} (SVG elements in a {@code width} x {@code height} canvas,
* drawn in white) rendered to a cached texture.
*/
private IdentifierRef getSvgShape(String shape, String cacheKey, int width, int height) {
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),
width * 4, height * 4), x, y, 0, 0, width, height, color);
return SvgTexture.getSvg(svgStream, IdentifierRef.ofSaturn("textures/gui/tmp_rect/" + cacheKey),
width * 4, height * 4);
}
void drawBorder(int x, int y, int width, int height, int color);
@@ -1,15 +1,24 @@
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;
import org.saturnclient.config.property.FloatProperty;
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();
/** The screen's width in GUI pixels */
public int getWidth();
/** The screen's height in GUI pixels */
public int getHeight();
}
@@ -17,12 +26,17 @@ public abstract class SaturnScreen {
public int width;
public int height;
public String title;
/** GUI pixels per Saturn unit, from {@link #getScale()} when the screen was built */
public float scale = 0.5f;
/** Whether a mouse button is held, so the screen isn't rebuilt under a drag */
private boolean mouseDown = false;
// Saturn Screen
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 +45,53 @@ 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));
}
/**
* The GUI scale setting this screen follows; the menu scale unless the
* screen is another kind.
*/
protected FloatProperty getScaleSetting() {
return Config.menuScale;
}
/**
* GUI pixels per Saturn unit. At 1× a Saturn unit is half a GUI pixel.
* Enlarging stops where the screen would be smaller than 320x240 GUI
* pixels, the least Minecraft's own GUI scale leaves.
*/
public float getScale() {
float fit = Math.min(provider.getWidth() / 320f, provider.getHeight() / 240f);
return 0.5f * Math.min(getScaleSetting().value, Math.max(1f, fit));
}
/**
* Rebuilds the screen if its scale setting changed, once no drag is going
* on (the slider being dragged would be replaced).
*/
public void updateScale() {
if (!mouseDown && getScale() != scale) {
init();
}
}
public void init() {
elements.clear();
width = provider.getWidth();
height = provider.getHeight();
scale = getScale();
width = (int) (provider.getWidth() / scale);
height = (int) (provider.getHeight() / scale);
Popup.reset(width, height);
ui(); // abstraction to render the saturn ui and also render extra stuff here
}
@@ -51,9 +108,10 @@ public abstract class SaturnScreen {
public void render(RenderScope renderScope, int mouseX, int mouseY, float delta, long elapsed) {
renderScope.getMatrixStack().push();
renderScope.getMatrixStack().scale(0.5f, 0.5f);
renderScope.getMatrixStack().scale(scale, scale);
ElementRenderer.render(new ArrayList<>(elements), elapsed, renderScope, mouseX, mouseY);
Popup.render(renderScope, elapsed, mouseX, mouseY);
renderScope.getMatrixStack().pop();
}
@@ -63,27 +121,35 @@ public abstract class SaturnScreen {
// --------------
public boolean mouseClicked(double mouseX, double mouseY, int button) {
mouseX *= 2;
mouseY *= 2;
mouseDown = true;
mouseX /= scale;
mouseY /= scale;
if (!Popup.mouseClicked(mouseX, mouseY, button)) {
ElementRenderer.mouseClicked(elements, mouseX, mouseY, button);
}
return false;
}
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
mouseX *= 2;
mouseY *= 2;
mouseX /= scale;
mouseY /= scale;
if (!Popup.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) {
ElementRenderer.mouseDragged(elements, mouseX, mouseY, button, deltaX, deltaY);
}
return false;
}
public boolean mouseReleased(double mouseX, double mouseY, int button) {
mouseX *= 2;
mouseY *= 2;
mouseDown = false;
mouseX /= scale;
mouseY /= scale;
// 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;
@@ -94,10 +160,12 @@ public abstract class SaturnScreen {
double mouseY,
double horizontalAmount,
double verticalAmount) {
mouseX *= 2;
mouseY *= 2;
mouseX /= scale;
mouseY /= scale;
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));
// }
// }
}
@@ -16,12 +16,12 @@ public class Fade extends Animation {
@Override
public void tick(double progress, Element element) {
element.opacity = Math.max(0.1f, targetOpacity * (float) progress);
element.opacity = targetOpacity * (float) progress;
}
@Override
public void init(Element element) {
targetOpacity = element.opacity;
element.opacity = 0.1f;
element.opacity = 0;
}
}
@@ -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,57 @@
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.Config;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.elements.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 = 32;
private static final int HEAD = 18;
private static final int PADDING = 7;
private static final int GAP = 8;
private static final int DOT = 6;
private static final float NAME_SCALE = 0.55f;
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 + GAP + (int) (Fonts.getWidth(name, Theme.FONT.value) * NAME_SCALE) + GAP + DOT
+ GAP + 2;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawGlassRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, Config.widgetBlur.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 + GAP;
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) + GAP;
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.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,69 @@
package org.saturnclient.ui.components;
import java.util.List;
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;
/**
* A glass bar of square icon buttons.
*/
public class Dock extends Element {
public record Item(IdentifierRef icon, Runnable onClick) {
}
public static final int HEIGHT = 40;
private static final int PADDING = 5;
private static final int ITEM = HEIGHT - PADDING * 2;
private static final int ICON = 15;
private static final int GAP = 3;
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.drawGlassRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value + 4, Config.widgetBlur.value, 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.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.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;
}
private int getSettingsHitBox() {
return s + expand * 2;
return (height - SETTINGS) / 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,52 @@ 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 SETTINGS = 1;
public static final int COSMETICS = 2;
public static final int HUD_EDITOR = 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),
// In the order of the constants above
private final Item[] items = {
new Item(Textures.ICON_MODS, () -> Providers.saturn.getClient().setScreen(new ModMenu())),
new Item(Textures.ICON_SETTINGS, () -> Providers.saturn.getClient().setScreen(new ClientSettings())),
new Item(Textures.ICON_COSMETICS, () -> Providers.saturn.getClient().setScreen(new CosmeticsMenu())),
new Item(Textures.ICON_HUD_EDITOR, () -> Providers.saturn.getClient().setScreen(new HudEditor())),
};
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 +55,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;
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);
if (hovering) {
component.onClick.run();
break;
if (mouseX < x || mouseX > x + BUTTON) {
return;
}
if (component.end) {
bottomRow += 1;
} else {
topRow += 1;
for (int i = 0; i < items.length; i++) {
int y = getItemY(i);
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.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);
}
@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);
}
// 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);
}
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);
}
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;
/**
* #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);
}
}
@@ -0,0 +1,197 @@
package org.saturnclient.ui.components.inputs;
import java.awt.Color;
import java.awt.image.BufferedImage;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.common.ref.asset.IdentifierRef;
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;
// Gradient strips, made once and stretched over each part, so a frame is a
// few draws instead of one per pixel. Tinting the white ones colors them.
private static final int STRIP = 256;
/** White fading out to the right: saturation, over the hue */
private static final IdentifierRef SATURATION = IdentifierRef.ofSaturn("textures/gui/color_picker/saturation");
/** Black fading in downwards: brightness, over the saturation */
private static final IdentifierRef BRIGHTNESS = IdentifierRef.ofSaturn("textures/gui/color_picker/brightness");
/** White fading in to the right, tinted with the color */
private static final IdentifierRef OPACITY = IdentifierRef.ofSaturn("textures/gui/color_picker/opacity");
private static final IdentifierRef HUE = IdentifierRef.ofSaturn("textures/gui/color_picker/hue");
private static boolean stripsRegistered = false;
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) {
registerStrips();
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.getPopupBg(), Theme.withAlpha(0.14f, Theme.FOREGROUND.value));
renderOpacity(renderScope);
renderSquare(renderScope);
renderHue(renderScope);
}
private void renderOpacity(RenderScope renderScope) {
renderScope.drawTexture(OPACITY, PADDING, OPACITY_Y, 0, 0, SIZE, SLIDER_HEIGHT,
0xFF000000 | prop.value);
drawMarker(renderScope, PADDING + (int) (opacity * SIZE), OPACITY_Y);
}
private void renderSquare(RenderScope renderScope) {
renderScope.fill(PADDING, SQUARE_Y, PADDING + SIZE, SQUARE_Y + SIZE, 0xFF000000 | Color.HSBtoRGB(hue, 1f, 1f));
renderScope.drawTexture(SATURATION, PADDING, SQUARE_Y, 0, 0, SIZE, SIZE, 0xFFFFFFFF);
renderScope.drawTexture(BRIGHTNESS, PADDING, SQUARE_Y, 0, 0, SIZE, SIZE, 0xFFFFFFFF);
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) {
renderScope.drawTexture(HUE, PADDING, HUE_Y, 0, 0, SIZE, SLIDER_HEIGHT, 0xFFFFFFFF);
drawMarker(renderScope, PADDING + (int) (hue * SIZE), HUE_Y);
}
private static void registerStrips() {
if (stripsRegistered) {
return;
}
BufferedImage saturation = new BufferedImage(STRIP, 1, BufferedImage.TYPE_INT_ARGB);
BufferedImage brightness = new BufferedImage(1, STRIP, BufferedImage.TYPE_INT_ARGB);
BufferedImage opacity = new BufferedImage(STRIP, 1, BufferedImage.TYPE_INT_ARGB);
BufferedImage hue = new BufferedImage(STRIP, 1, BufferedImage.TYPE_INT_ARGB);
for (int i = 0; i < STRIP; i++) {
int alpha = i * 255 / (STRIP - 1);
saturation.setRGB(i, 0, ((255 - alpha) << 24) | 0xFFFFFF);
brightness.setRGB(0, i, alpha << 24);
opacity.setRGB(i, 0, (alpha << 24) | 0xFFFFFF);
hue.setRGB(i, 0, 0xFF000000 | Color.HSBtoRGB(i / (float) STRIP, 1f, 1f));
}
Providers.saturn.registerBufferedImageTexture(SATURATION, saturation);
Providers.saturn.registerBufferedImageTexture(BRIGHTNESS, brightness);
Providers.saturn.registerBufferedImageTexture(OPACITY, opacity);
Providers.saturn.registerBufferedImageTexture(HUE, hue);
stripsRegistered = true;
}
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.getPopupBg(), 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.getPopupBg(), 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();
}
}
@@ -13,6 +13,8 @@ import org.saturnclient.ui.RenderScope;
public class AnimationStagger extends Element {
protected List<Element> children = new ArrayList<>();
public int delay;
/** When the first child starts, in ms */
public int start = 0;
public AnimationStagger(int delay) {
this.delay = delay;
@@ -22,13 +24,21 @@ public class AnimationStagger extends Element {
this.delay = config.stagger.value;
}
/**
* Holds the children back until {@code start} ms after the screen opens.
*/
public AnimationStagger after(int start) {
this.start = start;
return this;
}
public void draw(Element element) {
if (!Config.stagger.value) {
element.animation = null;
}
if (element.animation != null) {
element.animation.delay = delay * children.size();
element.animation.delay = start + delay * children.size();
}
ElementRenderer.draw(children, element);
@@ -41,7 +51,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
@@ -1,29 +1,47 @@
package org.saturnclient.ui.elements;
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.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.drawGlassRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, Config.widgetBlur.value, 1,
hovered ? 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();
}
}
@@ -20,9 +20,9 @@ public class ImageTexture extends Element {
if (sprite.toString().endsWith(".svg")) {
MinecraftClientRef client = Providers.saturn.getClient();
int renderWidth = (int) (width * client.getWindow().getFramebufferWidth() / client.getWindow().getWidth());
int renderWidth = (int) (width * client.getWindow().getFramebufferWidth() / client.getWindow().getWindowWidth());
int renderHeight = (int) (height * client.getWindow().getFramebufferHeight()
/ client.getWindow().getHeight());
/ client.getWindow().getWindowHeight());
IdentifierRef pngId = SvgTexture.getSvg(sprite, renderWidth * 2, renderHeight * 2);
@@ -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,24 @@
package org.saturnclient.ui.elements;
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;
/**
* The translucent, outlined surface that screens lay their content on,
* frosted over the game where glass is supported.
*/
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.drawGlassRectangle(0, 0, width, height, Theme.BG_RADIUS.value, Config.menuBlur.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();
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,109 +1,181 @@
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.property.FloatProperty;
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");
}
@Override
protected FloatProperty getScaleSetting() {
return Config.hudMenuScale;
}
@Override
public void ui() {
int row = 0;
int col = 0;
// =============================
// Logo
// =============================
draw(new ImageTexture(Textures.LOGO_TEXT)
.dimensions(98, 10)
.center(width, height, 0, -105)
.animation(new Fade(AnimationConfig.logo.duration.value)));
List<String> emotes = Emotes.availableEmotes;
int left = (width - GRID) / 2;
int top = (height - GRID) / 2 + LOGO / 2;
draw(new ImageTexture(Config.getLogo())
.dimensions(98, 98)
.center(width, height, 0, -149)
.animation(new SlideY(AnimationConfig.logo, -20)));
.dimensions(LOGO, LOGO)
.position((width - LOGO) / 2, top - 20 - LOGO)
.animation(new SlideY(AnimationConfig.logo, -12)));
// =============================
// Navigation Buttons
// =============================
draw(new TextureButton(Textures.LEFT, () -> {
if (page > 1) {
page--;
elements.clear();
ui();
}
}).dimensions(30, 30)
.center(width, height, -50, 105)
.animation(new Fade(500)));
draw(new TextureButton(Textures.RIGHT, () -> {
if ((page * 8) < Emotes.availableEmotes.size()) {
page++;
elements.clear();
ui();
}
}).dimensions(30, 30)
.center(width, height, 50, 105)
.animation(new Fade(500)));
// =============================
// Emote Grid (Staggered)
// =============================
AnimationStagger emoteStagger = new AnimationStagger(AnimationConfig.emoteWheel);
while (row < 3) {
int idx = (row * 3 + col) + ((page - 1) * 8);
if (row == 1 && col == 1) {
col++;
if (emotes.isEmpty()) {
String message = "No emotes available";
int x = (width - (int) (Fonts.getWidth(message, Theme.getBodyFont()) * 0.7f)) / 2;
draw(new Label(() -> message, 0.7f, Theme::getBodyFont, Theme::getMutedFg).position(x, top + GRID / 2));
return;
}
String emote = Utils.getOrNull(Emotes.availableEmotes, idx);
int pages = (emotes.size() + SLOTS.length - 1) / SLOTS.length;
page = Math.min(page, pages - 1);
emoteStagger.draw(
new TextureButton(Textures.getEmotePreview(emote), () -> {
Providers.saturn.playEmote(ServiceClient.uuid, emote);
provider.close();
})
.dimensions(70, 70)
.center(width, height,
-80 + (col * 80),
-35 + (row * 80))
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)));
if (col < 2) {
col++;
} else {
col = 0;
row++;
}
}
draw(emoteStagger);
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.drawGlassRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, Config.widgetBlur.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,76 @@
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,
* whatever Saturn's GUI scale.
*/
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 float getScale() {
return 1f;
}
@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);
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 +89,115 @@ 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) {
HudMod hovered = draggingMod != null ? draggingMod : getModAt(mouseX, mouseY);
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 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 = (width - 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 w = right(dim) - dim.x.value;
int h = bottom(dim) - dim.y.value;
dim.x.value = Math.max(0, Math.min(dim.x.value, width - w));
dim.y.value = Math.max(0, Math.min(dim.y.value, height - 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();
}
}
@@ -3,24 +3,35 @@ package org.saturnclient.ui.screens;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.config.Config;
import org.saturnclient.config.property.FloatProperty;
import org.saturnclient.ui.SaturnScreen;
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
protected FloatProperty getScaleSetting() {
return Config.hudMenuScale;
}
@Override
public void ui() {
// Logo animations (unchanged)
draw(new ImageTexture(Textures.LOGO_TEXT)
.dimensions(98, 10)
.center(width, height, 0, -36)
@@ -31,32 +42,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,153 @@
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.property.FloatProperty;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.anim.Animation;
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.IconButton;
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, the
* player and a quit button in the top corners, and along the bottom a dock
* (Saturn's settings, its mods and cosmetics, then Minecraft's options) and the version.
*/
public class TitleMenu extends SaturnScreen {
// The logo and wordmark as the shift menu has them: the wordmark over the logo's foot
private static final int LOGO = 98;
private static final int WORDMARK_WIDTH = 98;
private static final int WORDMARK_HEIGHT = 10;
private static final int WORDMARK_Y = 88;
/** From the logo's foot to the menus, close so they read as one block */
private static final int MENUS_GAP = 14;
private static final int COLUMN_WIDTH = 280;
private static final int BUTTON_HEIGHT = 36;
private static final int GAP = 8;
/** Space around the account chip, quit button, dock and version, from the screen's edges */
private static final int EDGE = 14;
/** The top corners sit a little tighter */
private static final int TOP_EDGE = 10;
/** As tall as the account chip beside it */
private static final int QUIT_SIZE = AccountChip.HEIGHT;
// The intro comes in in reading order, left to right and top to bottom, one step apart
private static final int STEP_ACCOUNT = 0;
private static final int STEP_QUIT = 1;
private static final int STEP_LOGO = 2;
private static final int STEP_MENUS = 3;
/** After the two menus */
private static final int STEP_VERSION = 5;
private static final int STEP_DOCK = 6;
private static final float VERSION_SCALE = 0.5f;
private static final float VERSION_ALPHA = 0.6f;
public TitleMenu() {
super("Title Menu");
}
@Override
protected FloatProperty getScaleSetting() {
return Config.titleScale;
}
@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)
.animation(new SlideY(AnimationConfig.logo, -20)));
// Logo and menus, centered together a little above the middle
int menusHeight = BUTTON_HEIGHT * 2 + GAP;
int blockHeight = LOGO + MENUS_GAP + menusHeight;
int top = (height - blockHeight) / 2 - 20;
AnimationStagger mainButtonStagger = new AnimationStagger(AnimationConfig.mainMenu);
draw(new ImageTexture(Config.getLogo())
.dimensions(LOGO, LOGO)
.position((width - LOGO) / 2, top)
.animation(step(new SlideY(AnimationConfig.logo, -20), STEP_LOGO)));
draw(new ImageTexture(Textures.LOGO_TEXT)
.dimensions(WORDMARK_WIDTH, WORDMARK_HEIGHT)
.position((width - WORDMARK_WIDTH) / 2, top + WORDMARK_Y)
.animation(step(new Fade(AnimationConfig.logo.duration.value), STEP_LOGO)));
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)));
AnimationStagger menus = new AnimationStagger(AnimationConfig.mainMenu)
.after(stepDelay(STEP_MENUS));
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)));
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, BUTTON_HEIGHT + GAP));
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)));
draw(menus.position((width - COLUMN_WIDTH) / 2, top + LOGO + MENUS_GAP));
mainButtonStagger.draw(new Button("QUIT", () -> {
Providers.saturn.getClient().scheduleStop();
}).scale(0.69f).dimensions(206, 52).position(146, 80)
.animation(new SlideFade(AnimationConfig.mainMenu, -10)));
// Top corners: the player, and quitting the game
draw(new AccountChip()
.position(TOP_EDGE, TOP_EDGE)
.animation(step(new SlideFade(AnimationConfig.mainMenu, 8), STEP_ACCOUNT)));
draw(new IconButton(Textures.ICON_CLOSE, () -> Providers.saturn.getClient().scheduleStop())
.dimensions(QUIT_SIZE, QUIT_SIZE)
.position(width - TOP_EDGE - QUIT_SIZE, TOP_EDGE)
.animation(step(new SlideFade(AnimationConfig.mainMenu, 8), STEP_QUIT)));
draw(mainButtonStagger.dimensions(289, 143).center(width, height, 0, 50));
// Along the bottom: the dock, Saturn first (its settings) and Minecraft's options last, and the version
Dock dock = new Dock(List.of(
new Dock.Item(Textures.LOGO, () -> Providers.saturn.getClient().setScreen(new ClientSettings())),
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_SETTINGS,
() -> Providers.saturn.getClient().setScreen(MinecraftScreen.Options))));
draw(dock.position((width - dock.width) / 2, height - EDGE - Dock.HEIGHT)
.animation(step(new SlideFade(AnimationConfig.mainMenu, -12), STEP_DOCK)));
int s = Fonts.getHeight() + 20;
String version = getVersionText();
int versionHeight = (int) (Fonts.getHeight() * VERSION_SCALE);
draw(new Label(() -> version, VERSION_SCALE, Theme::getBodyFont,
() -> Theme.withAlpha(VERSION_ALPHA, Theme.getMutedFg()))
.position(EDGE, height - EDGE - versionHeight)
.animation(step(new Fade(AnimationConfig.mainMenu), STEP_VERSION)));
}
AnimationStagger saturnStagger = new AnimationStagger(AnimationConfig.mainMenu.stagger.value);
/**
* When the intro's {@code step} starts, in ms: the Main Menu stagger apart,
* or all at once when staggering is off.
*/
private static int stepDelay(int step) {
return Config.stagger.value ? step * AnimationConfig.mainMenu.stagger.value : 0;
}
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)));
private static Animation step(Animation animation, int step) {
animation.delay = stepDelay(step);
return animation;
}
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)));
/**
* "Saturn 0.1.0 · 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 " + version[0];
return version.length > 1 ? text + " · " + version[1] : text;
}
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));
}
}

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