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]>
This commit is contained in:
2026-09-27 16:59:51 +02:00
co-authored by claude
parent 4ac7c60d07
commit dfebd2367d
32 changed files with 669 additions and 299 deletions
@@ -64,6 +64,35 @@ public class Theme {
return hover ? ACCENT_FG.value : PRIMARY_FG.value;
}
/**
* Translucent background for panels drawn over the world
*/
public static int getPanelBg() {
return withAlpha(0.82f, BACKGROUND.value);
}
/**
* Faint outline for panels, cards and inputs
*/
public static int getBorder() {
return withAlpha(0.08f, FOREGROUND.value);
}
/**
* Secondary text: descriptions, counts and placeholders
*/
public static int getMutedFg() {
return withAlpha(0.65f, FOREGROUND.value);
}
/**
* Font for secondary text. Inter bold is too heavy there, so it becomes
* regular Inter; other fonts are kept.
*/
public static int getBodyFont() {
return FONT.value == 2 ? 1 : FONT.value;
}
/**
* Multiplies existing alpha with 0–255 alpha.
*/
@@ -7,141 +7,100 @@ 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;
/**
* A mod's card in the mod menu: icon, name, description, a settings button and
* an on/off switch. Clicking anywhere but the settings button toggles the mod.
*/
public class SaturnModule extends Element {
public static final int HEIGHT = 76;
private static final int PADDING = 12;
private static final int ICON_TILE = 44;
private static final int ICON = 22;
private static final int SETTINGS = 32;
private static final int SETTINGS_ICON = 17;
private static final int GAP = 12;
private static final float NAME_SCALE = 0.74f;
private static final float DESCRIPTION_SCALE = 0.64f;
private final Mod mod;
static int p = 10; // padding
static int s = 14; // settings icon size
static int expand = 5; // settings icon click area
public SaturnModule(Mod mod) {
public SaturnModule(Mod mod, int width) {
this.mod = mod;
this.width = 160;
this.height = 50;
this.width = width;
this.height = HEIGHT;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
boolean hovered = ctx.isHovering();
boolean enabled = mod.isEnabled();
boolean settingsHovered = ctx.isHovering(getSettingsX(), getSettingsY(), SETTINGS, SETTINGS);
boolean hovered = ctx.isHovering() && !settingsHovered;
int iconX = width - s - p;
int iconY = height - s - p;
int border = enabled ? Theme.withAlpha(hovered ? 0.7f : 0.4f, Theme.ACCENT.value)
: Theme.withAlpha(hovered ? 0.22f : 0.06f, Theme.FOREGROUND.value);
boolean settingsHover = ctx.isHovering(
getSettingsX(),
getSettingsY(),
getSettingsHitBox(),
getSettingsHitBox());
// Card
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.withAlpha(0.72f, Theme.PRIMARY.value), border);
// Resolve theme colors
int bgColor = Theme.PRIMARY.value;
int fgColor = hovered && !settingsHover ? Theme.ACCENT.value : Theme.PRIMARY_FG.value;
int iconBg = enabled ? Theme.ACCENT.value : Theme.PRIMARY.value;
int iconFg = enabled ? Theme.ACCENT_FG.value : fgColor;
int settingsColor = settingsHover
? Theme.ACCENT.value
: Theme.withAlpha(160, fgColor);
int h = height - (p * 2);
// Main background
renderScope.drawRoundedRectangle(
0, 0,
width, height,
Theme.WIDGET_RADIUS.value,
bgColor);
// Icon background
renderScope.drawRoundedRectangle(
p, p,
h, h,
Theme.WIDGET_RADIUS.value,
iconBg);
// Icon
renderScope.drawTexture(
mod.getIconTexture(),
p + (p / 2),
p + (p / 2),
0, 0,
h - p,
h - p,
iconFg);
// Module name
renderScope.drawText(
0.6f,
mod.getName(),
p + h + 4,
p + 1,
Theme.FONT.value,
fgColor);
// Version (right aligned)
String version = mod.getVersion();
int versionWidth = (int) (Fonts.getWidth(version, Theme.FONT.value) * 0.35f);
renderScope.drawText(
0.35f,
version,
width - p - versionWidth,
p + 1,
Theme.FONT.value,
fgColor);
// Tags
int xt = p + h + 4;
for (String tag : mod.getTags()) {
int tagWidth = (int) (Fonts.getWidth(tag, Theme.FONT.value) * 0.4f);
renderScope.drawRoundedRectangle(
xt,
p + (h / 2),
tagWidth + 6,
12,
5,
Theme.ACCENT.value);
renderScope.drawText(
0.4f,
tag,
xt + 2,
p + (h / 2) + 3,
Theme.FONT.value,
Theme.ACCENT_FG.value);
xt += tagWidth + 9;
if (enabled) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value,
Theme.withAlpha(0.06f, Theme.ACCENT.value));
}
// Settings icon
renderScope.drawTexture(
Textures.SETTINGS,
iconX,
iconY,
0, 0,
s, s,
settingsColor);
// Icon
int tileY = (height - ICON_TILE) / 2;
renderScope.drawRoundedRectangle(PADDING, tileY, ICON_TILE, ICON_TILE, Theme.WIDGET_RADIUS.value,
enabled ? Theme.withAlpha(0.18f, Theme.ACCENT.value) : Theme.withAlpha(0.06f, Theme.FOREGROUND.value));
renderScope.drawTexture(mod.getIconTexture(),
PADDING + (ICON_TILE - ICON) / 2, tileY + (ICON_TILE - ICON) / 2,
0, 0, ICON, ICON, Theme.PRIMARY_FG.value);
// Name and description
int textX = PADDING + ICON_TILE + GAP;
int textWidth = getSettingsX() - GAP - textX;
int nameHeight = (int) (Fonts.getHeight() * NAME_SCALE);
int descriptionHeight = (int) (Fonts.getHeight() * DESCRIPTION_SCALE);
int nameY = (height - nameHeight - 5 - descriptionHeight) / 2;
renderScope.drawText(NAME_SCALE,
Fonts.ellipsize(mod.getName(), Theme.FONT.value, NAME_SCALE, textWidth),
textX, nameY, Theme.FONT.value, Theme.FOREGROUND.value);
String description = mod.getDescription() == null ? "" : mod.getDescription();
renderScope.drawText(DESCRIPTION_SCALE,
Fonts.ellipsize(description, Theme.getBodyFont(), DESCRIPTION_SCALE, textWidth),
textX, nameY + nameHeight + 5, Theme.getBodyFont(), Theme.getMutedFg());
// Settings button
if (settingsHovered) {
renderScope.drawRoundedRectangle(getSettingsX(), getSettingsY(), SETTINGS, SETTINGS,
Theme.WIDGET_RADIUS.value, Theme.withAlpha(0.08f, Theme.FOREGROUND.value));
}
int settingsIconOffset = (SETTINGS - SETTINGS_ICON) / 2;
renderScope.drawTexture(Textures.ICON_SLIDERS,
getSettingsX() + settingsIconOffset, getSettingsY() + settingsIconOffset,
0, 0, SETTINGS_ICON, SETTINGS_ICON,
settingsHovered ? Theme.ACCENT.value : Theme.getMutedFg());
// Switch
Toggle.drawSwitch(renderScope, getSwitchX(), (height - Toggle.SWITCH_HEIGHT) / 2, enabled);
}
@Override
public void click(int mouseX, int mouseY) {
boolean settingsHover = Utils.isHovering(
mouseX - getSettingsX(),
mouseY - getSettingsY(),
getSettingsHitBox(),
getSettingsHitBox(),
1.0f);
boolean settingsClicked = mouseX >= getSettingsX() && mouseX <= getSettingsX() + SETTINGS
&& mouseY >= getSettingsY() && mouseY <= getSettingsY() + SETTINGS;
if (settingsHover) {
if (settingsClicked) {
if (mod instanceof HudMod h) {
Providers.saturn.getClient().setScreen(new ConfigEditor(mod.getConfig(), h));
} else {
@@ -152,15 +111,15 @@ public class SaturnModule extends Element {
}
}
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;
}
}
@@ -7,51 +7,48 @@ 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.HudEditor;
import org.saturnclient.ui.screens.ModMenu;
import org.saturnclient.ui.screens.cosmetics.CloakMenu;
import org.saturnclient.ui.screens.store.CloakStore;
/**
* The navigation rail beside Saturn's screens: the logo, one button per screen,
* and a close button at the bottom.
*/
public class Sidebar extends Element {
public static class SidebarComponent {
Runnable onClick;
boolean end;
IdentifierRef sprite;
public static final int WIDTH = 60;
public SidebarComponent(IdentifierRef sprite, Runnable onClick, boolean end) {
this.onClick = onClick;
this.end = end;
this.sprite = sprite;
}
// Values for the constructor's `active`
public static final int MODS = 0;
public static final int COSMETICS = 1;
public static final int HUD_EDITOR = 2;
public static final int SETTINGS = 3;
private static final int LOGO = 32;
private static final int BUTTON = 44;
private static final int ICON = 22;
private static final int GAP = 8;
private static final int PADDING = 12;
private record Item(IdentifierRef icon, Runnable onClick) {
}
Runnable onClose;
SidebarComponent[] components = {
new Sidebar.SidebarComponent(Textures.MODS_TAB, () -> {
Providers.saturn.getClient().setScreen(new ModMenu());
}, false),
new Sidebar.SidebarComponent(Textures.SETTINGS, () -> {
Providers.saturn.getClient().setScreen(new ConfigEditor(Config.config));
}, false),
new Sidebar.SidebarComponent(Textures.SHIRT, () -> {
Providers.saturn.getClient().setScreen(new CloakMenu());
}, false),
new Sidebar.SidebarComponent(Textures.CLOSE, () -> {
onClose.run();
}, true),
new Sidebar.SidebarComponent(Textures.STORE, () -> {
Providers.saturn.getClient().setScreen(new CloakStore());
}, true),
private final Item[] items = {
new Item(Textures.ICON_MODS, () -> Providers.saturn.getClient().setScreen(new ModMenu())),
new Item(Textures.ICON_COSMETICS, () -> Providers.saturn.getClient().setScreen(new CloakMenu())),
new Item(Textures.ICON_HUD_EDITOR, () -> Providers.saturn.getClient().setScreen(new HudEditor())),
new Item(Textures.ICON_SETTINGS,
() -> Providers.saturn.getClient().setScreen(new ConfigEditor(Config.config))),
};
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 +56,75 @@ public class Sidebar extends Element {
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
int topRow = 0;
int bottomRow = 0;
Panel.drawBackground(renderScope, width, height);
int gap = 5; // 5px gap between icons
int padding = 10;
int sp = 30 - padding + gap;
int s = 30 - (padding * 2);
int s2 = s + padding;
renderScope.drawTexture(Textures.LOGO, (width - LOGO) / 2, PADDING, 0, 0, LOGO, LOGO);
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.BG_RADIUS.value, Theme.BACKGROUND.value);
int x = getButtonX();
int i = 0;
for (int i = 0; i < items.length; i++) {
int y = getItemY(i);
boolean selected = i == active;
boolean hovered = ctx.isHovering(x, y, BUTTON, BUTTON);
for (SidebarComponent component : components) {
int y = component.end ? ((height - (sp * bottomRow)) - (s * 2)) : (padding + (sp * topRow));
boolean selected = ctx.isHovering(0, y - (padding / 2), s2, s2) || active == i;
renderScope.drawRoundedRectangle(padding / 2, y - (padding / 2), s + padding,
s + padding, Theme.WIDGET_RADIUS.value, Theme.getBg(selected));
renderScope.drawTexture(component.sprite, padding, y, 0, 0, s, s, Theme.getFg(selected));
if (component.end) {
bottomRow += 1;
} else {
topRow += 1;
if (selected) {
renderScope.drawRoundedRectangle(x, y, BUTTON, BUTTON, Theme.WIDGET_RADIUS.value, Theme.ACCENT.value);
} else if (hovered) {
renderScope.drawRoundedRectangle(x, y, BUTTON, BUTTON, Theme.WIDGET_RADIUS.value,
Theme.withAlpha(0.08f, Theme.FOREGROUND.value));
}
i++;
drawIcon(renderScope, items[i].icon(), x, y,
selected ? Theme.ACCENT_FG.value
: hovered ? Theme.FOREGROUND.value : Theme.withAlpha(0.8f, Theme.FOREGROUND.value));
}
int closeY = getCloseY();
boolean closeHovered = ctx.isHovering(x, closeY, BUTTON, BUTTON);
renderScope.drawRoundedRectangle(x, closeY, BUTTON, BUTTON, Theme.WIDGET_RADIUS.value,
Theme.withAlpha(closeHovered ? 0.12f : 0.06f, Theme.FOREGROUND.value));
drawIcon(renderScope, Textures.ICON_CLOSE, x, closeY,
closeHovered ? Theme.FOREGROUND.value : Theme.withAlpha(0.8f, Theme.FOREGROUND.value));
}
@Override
public void click(int mouseX, int mouseY) {
int topRow = 0;
int bottomRow = 0;
int x = getButtonX();
int gap = 5;
int padding = 10;
int sp = 30 - padding + gap;
int s = 30 - (padding * 2);
int s2 = s + padding;
if (mouseX < x || mouseX > x + BUTTON) {
return;
}
for (SidebarComponent component : components) {
int y = component.end ? ((height - (sp * bottomRow)) - (s * 2)) : (padding + (sp * topRow));
boolean hovering = Utils.isHovering(mouseX - (padding / 2), mouseY - (y - (padding / 2)), s2, s2, scale);
for (int i = 0; i < items.length; i++) {
int y = getItemY(i);
if (hovering) {
component.onClick.run();
break;
}
if (component.end) {
bottomRow += 1;
} else {
topRow += 1;
if (mouseY >= y && mouseY <= y + BUTTON) {
items[i].onClick().run();
return;
}
}
if (mouseY >= getCloseY() && mouseY <= getCloseY() + BUTTON) {
onClose.run();
}
}
private static void drawIcon(RenderScope renderScope, IdentifierRef icon, int x, int y, int color) {
int offset = (BUTTON - ICON) / 2;
renderScope.drawTexture(icon, x + offset, y + offset, 0, 0, ICON, ICON, color);
}
private int getButtonX() {
return (width - BUTTON) / 2;
}
private int getItemY(int index) {
return PADDING + LOGO + 14 + index * (BUTTON + GAP);
}
private int getCloseY() {
return height - PADDING - BUTTON;
}
}
@@ -11,6 +11,11 @@ public abstract class Input extends Element {
String text = "";
public int cursorPosition = 0;
/** Scale the text is drawn at */
protected float textScale = 0.6f;
/** Horizontal inset of the text on both sides */
protected int textX = 4;
public abstract void checkReset();
public abstract void backspace();
@@ -41,6 +46,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 +54,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;
@@ -68,24 +74,32 @@ public abstract class Input extends Element {
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, 4, Theme.FONT.value, Theme.PRIMARY_FG.value);
if (focused) {
int cursorX = 4 +
(int) (Fonts.getWidth(visibleText.substring(0, cursorPosition - scrollOffset), Theme.FONT.value)
* 0.6f);
int cursorX = getCursorX(visibleText, scrollOffset);
renderScope.drawRectangle(cursorX, 2, 1, height - 4, 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 +116,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
}
}
@@ -0,0 +1,79 @@
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.resources.Fonts;
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.textScale = 0.68f;
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) {
int border = focused ? Theme.withAlpha(0.6f, Theme.ACCENT.value)
: ctx.isHovering() ? 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);
renderScope.drawTexture(Textures.ICON_SEARCH, ICON_X, (height - ICON_SIZE) / 2, 0, 0,
ICON_SIZE, ICON_SIZE, Theme.getMutedFg());
int textHeight = (int) (Fonts.getHeight() * textScale);
int textY = (height - textHeight) / 2;
if (text.isEmpty() && !focused) {
renderScope.drawText(textScale, placeholder, textX, textY, Theme.getBodyFont(), Theme.getMutedFg());
return;
}
int scrollOffset = getScrollOffset();
String visibleText = getVisibleText(scrollOffset);
renderScope.drawText(textScale, visibleText, textX, textY, Theme.FONT.value, Theme.FOREGROUND.value);
if (focused) {
renderScope.drawRectangle(getCursorX(visibleText, scrollOffset), textY - 1, 1, textHeight + 2,
Theme.ACCENT.value);
}
}
}
@@ -23,6 +23,26 @@ public class Toggle extends Element {
prop.value ? Theme.ACCENT.value : Theme.PRIMARY.value);
}
public static final int SWITCH_WIDTH = 42;
public static final int SWITCH_HEIGHT = 24;
/**
* 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
public void click(int mouseX, int mouseY) {
prop.value = !prop.value;
@@ -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;
}
}
@@ -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());
}
}
@@ -0,0 +1,22 @@
package org.saturnclient.ui.elements;
import org.saturnclient.config.Theme;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
/**
* The translucent, outlined surface that screens lay their content on.
*/
public class Panel extends Element {
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
drawBackground(renderScope, width, height);
}
public static void drawBackground(RenderScope renderScope, int width, int height) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.BG_RADIUS.value, 1,
Theme.getPanelBg(), Theme.getBorder());
}
}
@@ -17,6 +17,7 @@ public class Scroll extends Element {
private static IntProperty scrollBarPadding = Property.integer(5);
int padding = 0;
boolean background = true;
protected List<Element> children = new ArrayList<>();
int scroll = 0;
@@ -30,12 +31,31 @@ 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();
renderScope.drawRoundedRectangle(0, 0, width, height,
Theme.BG_RADIUS.value, Theme.BACKGROUND.value);
if (background) {
renderScope.drawRoundedRectangle(0, 0, width, height,
Theme.BG_RADIUS.value, Theme.BACKGROUND.value);
}
renderScope.enableScissor(padding, padding, width - padding, height - padding);
renderScope.getMatrixStack().push();
@@ -117,6 +137,15 @@ public class Scroll extends Element {
@Override
public void mouseClicked(double mouseX, double mouseY, int 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;
}
ElementRenderer.mouseClicked(children, mouseX - padding, mouseY - padding + scroll, button);
}
@@ -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;
}
@@ -25,6 +25,15 @@ public class Textures {
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 LOGO = IdentifierRef.ofSaturn("textures/logo/logo.png");
public static final IdentifierRef REALISTIC_LOGO = IdentifierRef.ofSaturn("textures/logo/realistic.png");
public static final IdentifierRef LOGO_TEXT = IdentifierRef.ofSaturn("textures/logo/text.png");
@@ -63,7 +63,7 @@ public class ConfigEditor extends SaturnScreen {
draw(scroll.dimensions(scrollWidth, 350).center(width, height));
draw(new Sidebar(1, this.provider::close).center(width, height, -(scrollWidth / 2 + 20), 0));
draw(new Sidebar(Sidebar.SETTINGS, this.provider::close).center(width, height, -(scrollWidth / 2 + 35), 0));
}
private int drawProperties(Scroll configScroll, Map<String, Property> properties, int row, int col, int w) {
@@ -1,56 +1,171 @@
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.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.ChipGroup;
import org.saturnclient.ui.elements.ImageTexture;
import org.saturnclient.ui.elements.Label;
import org.saturnclient.ui.elements.Panel;
import org.saturnclient.ui.elements.Scroll;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
public class ModMenu extends SaturnScreen {
private static final String ALL = "All";
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;
private static final int PADDING = 20;
private static final int COLUMNS = 3;
private static final int CARD_GAP = 10;
private static final int SCROLLBAR_SPACE = 14;
private String category = ALL;
private String query = "";
private Scroll scroll;
private int gridWidth;
public ModMenu() {
super("Mod Menu");
}
@Override
public void ui() {
int p = 10;
int g = 10;
// The search box starts empty after a resize, so the query does too.
query = "";
Scroll scroll = new Scroll(p);
int panelWidth = Math.min(MAX_WIDTH, width - MARGIN * 2 - Sidebar.WIDTH - GAP);
int panelHeight = Math.min(MAX_HEIGHT, height - MARGIN * 2);
int left = (width - (Sidebar.WIDTH + GAP + panelWidth)) / 2;
int top = (height - panelHeight) / 2;
int panelX = left + Sidebar.WIDTH + GAP;
int contentX = panelX + PADDING;
gridWidth = panelWidth - PADDING * 2;
int row = 0;
int col = 0;
draw(new Sidebar(Sidebar.MODS, this.provider::close)
.dimensions(Sidebar.WIDTH, panelHeight)
.position(left, top));
AnimationStagger moduleStagger = new AnimationStagger(AnimationConfig.modMenu);
draw(new Panel().dimensions(panelWidth, panelHeight).position(panelX, top));
for (Mod mod : ModManager.MODS) {
moduleStagger.draw(
new SaturnModule(mod)
.position((160 + g) * col, (50 + g) * row)
.animation(new SlideFade(AnimationConfig.modMenu, -12)));
// Header
draw(new Label(() -> "Mods", 1.15f, () -> Theme.FONT.value, () -> Theme.FOREGROUND.value)
.position(contentX, top + PADDING + 2));
if (col == 2) {
col = 0;
row++;
} else {
col++;
}
}
draw(new Label(this::getEnabledSummary, 0.66f, Theme::getBodyFont, Theme::getMutedFg)
.position(contentX, top + PADDING + 28));
scroll.draw(moduleStagger);
SearchInput search = new SearchInput("Search mods", text -> {
query = text;
refresh(false);
});
draw(search.position(panelX + panelWidth - PADDING - search.width, top + PADDING + 2));
int scrollWidth = 480 + 10 + (g * 2) + (p * 2);
// Categories
int chipsY = top + PADDING + 56;
ChipGroup categories = new ChipGroup(getCategories(), category, selected -> {
category = selected;
refresh(false);
});
draw(categories.position(contentX, chipsY));
draw(scroll.dimensions(scrollWidth, 350).center(width, height));
draw(new Sidebar(0, this.provider::close).center(width, height, -(scrollWidth / 2 + 20), 0));
// Mods
int scrollY = chipsY + categories.height + 14;
scroll = new Scroll(0).noBackground();
scroll.dimensions(gridWidth + SCROLLBAR_SPACE, top + panelHeight - 12 - scrollY);
refresh(true);
draw(scroll.position(contentX, scrollY));
draw(new ImageTexture(Textures.LOGO_TEXT_BIG).dimensions(180, 18).position(width - 180 - 20, height - 18 - 20));
}
/**
* 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) {
for (String tag : mod.getTags()) {
if (!categories.contains(tag)) {
categories.add(tag);
}
}
}
return categories;
}
private String getEnabledSummary() {
long enabled = ModManager.MODS.stream().filter(Mod::isEnabled).count();
return enabled + " of " + ModManager.MODS.size() + " enabled";
}
}
@@ -68,8 +68,8 @@ public class CloakMenu extends SaturnScreen {
.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 Sidebar(Sidebar.COSMETICS, this.provider::close)
.center(width, height, -(scrollWidth / 2 + 35), 0));
draw(new TabMenu(0,
new TabMenuComponent(Textures.CLOAK, () -> {
@@ -68,8 +68,8 @@ public class HatMenu extends SaturnScreen {
.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 Sidebar(Sidebar.COSMETICS, this.provider::close)
.center(width, height, -(scrollWidth / 2 + 35), 0));
draw(new TabMenu(1,
new TabMenuComponent(Textures.CLOAK, () -> {
@@ -98,8 +98,8 @@ public class CloakStore extends SaturnScreen {
.dimensions(scrollWidth, 350)
.center(width, height, 15, 0));
draw(new Sidebar(5, this.provider::close)
.center(width, height, -((scrollWidth - 30) / 2 + 20), 0));
draw(new Sidebar(Sidebar.COSMETICS, this.provider::close)
.center(width, height, -((scrollWidth - 30) / 2 + 35), 0));
draw(new TabMenu(0,
new TabMenuComponent(Textures.CLOAK, () -> {
@@ -98,8 +98,8 @@ public class HatStore extends SaturnScreen {
.dimensions(scrollWidth, 350)
.center(width, height, 15, 0));
draw(new Sidebar(5, this.provider::close)
.center(width, height, -((scrollWidth - 30) / 2 + 20), 0));
draw(new Sidebar(Sidebar.COSMETICS, this.provider::close)
.center(width, height, -((scrollWidth - 30) / 2 + 35), 0));
draw(new TabMenu(1,
new TabMenuComponent(Textures.CLOAK, () -> {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>

After

Width:  |  Height:  |  Size: 227 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z"/></svg>

After

Width:  |  Height:  |  Size: 372 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 9-3 3 3 3"/><path d="m9 5 3-3 3 3"/><path d="m15 19-3 3-3-3"/><path d="m19 9 3 3-3 3"/><path d="M2 12h20"/><path d="M12 2v20"/></svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg>

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.34-4.34"/></svg>

After

Width:  |  Height:  |  Size: 242 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 791 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 4h-7"/><path d="M10 4H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 20h-5"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/></svg>

After

Width:  |  Height:  |  Size: 361 B

@@ -1,7 +1 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
␍<g id="SVGRepo_bgCarrier" stroke-width="0"/>
␍<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
␍<g id="SVGRepo_iconCarrier"> <path d="M12 7V12L10.5 14.5M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </g>
␍</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>

Before

Width:  |  Height:  |  Size: 681 B

After

Width:  |  Height:  |  Size: 238 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus-icon lucide-plus"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M22 12h-4"/><path d="M6 12H2"/><path d="M12 6V2"/><path d="M12 22v-4"/></svg>

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 295 B

@@ -1,7 +1 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="64px" height="64px" viewBox="0 -0.5 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
␍<g id="SVGRepo_bgCarrier" stroke-width="0"/>
␍<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
␍<g id="SVGRepo_iconCarrier"> <path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 7V17C3.5 18.1046 4.39543 19 5.5 19H19.5C20.6046 19 21.5 18.1046 21.5 17V7C21.5 5.89543 20.6046 5 19.5 5H5.5C4.39543 5 3.5 5.89543 3.5 7Z" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M15.5 10H18.5" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path d="M15.5 13H18.5" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 10C11.5 11.1046 10.6046 12 9.5 12C8.39543 12 7.5 11.1046 7.5 10C7.5 8.89543 8.39543 8 9.5 8C10.0304 8 10.5391 8.21071 10.9142 8.58579C11.2893 8.96086 11.5 9.46957 11.5 10Z" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M5.5 16C8.283 12.863 11.552 13.849 13.5 16" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/> </g>
␍</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="5" rx="2"/><circle cx="8" cy="12" r="2"/><path d="M14 10h4"/><path d="M14 14h4"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 302 B

@@ -1,7 +1 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 76 76" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" enable-background="new 0 0 76.00 76.00" xml:space="preserve" fill="#000000">
␍<g id="SVGRepo_bgCarrier" stroke-width="0"/>
␍<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
␍<g id="SVGRepo_iconCarrier"> <path fill="#ffffff" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 41,35L 41,57L 35,57L 35,35L 41,35 Z M 33,44L 33,57L 27,57L 27,44L 33,44 Z M 19,57L 19,51L 25,51L 25,57L 19,57 Z M 57,19L 57,57L 51,57L 51,19L 57,19 Z M 49,27L 49,57L 43,57L 43,27L 49,27 Z "/> </g>
␍</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20h.01"/><path d="M7 20v-4"/><path d="M12 20v-8"/><path d="M17 20V8"/><path d="M22 4v16"/></svg>

Before

Width:  |  Height:  |  Size: 874 B

After

Width:  |  Height:  |  Size: 285 B

@@ -1,35 +1 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1167.000000pt" height="1280.000000pt" viewBox="0 0 1167.000000 1280.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.15, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1280.000000) scale(0.100000,-0.100000)"
fill="white" stroke="none">
<path d="M9270 12789 c-229 -29 -473 -124 -657 -256 -272 -194 -468 -494 -539
-825 -25 -114 -25 -369 -1 -491 94 -472 445 -861 918 -1021 167 -56 260 -70
459 -70 199 0 292 14 459 70 400 135 727 449 864 829 50 141 70 257 71 425 1
176 -11 259 -59 406 -165 505 -614 864 -1166 934 -96 12 -254 11 -349 -1z"/>
<path d="M5145 10889 c-80 -12 -193 -69 -252 -127 -42 -41 -1667 -2180 -1780
-2342 -24 -35 -54 -97 -69 -143 -23 -69 -27 -100 -27 -197 -1 -103 2 -124 29
-200 70 -200 212 -329 394 -362 128 -22 284 28 378 123 27 27 397 512 823
1078 l774 1030 443 0 443 1 -19 -27 c-11 -16 -769 -1026 -1684 -2246 l-1664
-2219 -1215 -1 -1214 -2 -68 -27 c-93 -38 -164 -87 -239 -166 -323 -343 -232
-974 171 -1186 134 -70 29 -66 1631 -66 1549 0 1541 0 1705 51 124 38 264 118
331 190 18 19 321 405 674 859 354 454 648 831 655 838 10 11 162 -154 768
-832 417 -465 757 -852 757 -860 0 -9 -140 -728 -311 -1598 -223 -1137 -312
-1611 -316 -1681 -21 -363 196 -688 508 -762 302 -71 612 129 718 465 11 36
180 886 376 1890 195 1004 376 1926 401 2049 40 196 45 239 46 355 0 197 -40
357 -124 486 -27 42 -731 859 -1325 1537 l-55 62 728 968 c400 532 728 969
729 971 1 1 211 -240 467 -535 256 -296 489 -560 517 -586 63 -59 178 -115
260 -127 144 -20 301 37 430 156 98 91 1610 1844 1649 1912 57 100 77 181 76
317 0 87 -5 137 -18 180 -49 163 -154 293 -292 361 -77 39 -79 39 -193 39
-104 -1 -122 -3 -174 -27 -32 -15 -77 -41 -100 -58 -24 -17 -328 -358 -677
-760 -349 -401 -638 -728 -641 -727 -4 1 -354 402 -780 891 -440 506 -796 907
-827 931 -30 24 -80 55 -111 71 -127 62 -38 58 -1401 60 -685 1 -1272 -2
-1305 -7z"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.8 19.6A2 2 0 1 0 14 16H2"/><path d="M17.5 8a2.5 2.5 0 1 1 2 4H2"/><path d="M9.8 4.4A2 2 0 1 1 11 8H2"/></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 299 B

@@ -1,7 +1 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="64px" height="64px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="#000000">
␍<g id="SVGRepo_bgCarrier" stroke-width="0"/>
␍<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
␍<g id="SVGRepo_iconCarrier"> <title>performance_line</title> <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="Development" transform="translate(-864.000000, 0.000000)" fill-rule="nonzero"> <g id="performance_line" transform="translate(864.000000, 0.000000)"> <path d="M24,0 L24,24 L0,24 L0,0 L24,0 Z M12.5934901,23.257841 L12.5819402,23.2595131 L12.5108777,23.2950439 L12.4918791,23.2987469 L12.4918791,23.2987469 L12.4767152,23.2950439 L12.4056548,23.2595131 C12.3958229,23.2563662 12.3870493,23.2590235 12.3821421,23.2649074 L12.3780323,23.275831 L12.360941,23.7031097 L12.3658947,23.7234994 L12.3769048,23.7357139 L12.4804777,23.8096931 L12.4953491,23.8136134 L12.4953491,23.8136134 L12.5071152,23.8096931 L12.6106902,23.7357139 L12.6232938,23.7196733 L12.6232938,23.7196733 L12.6266527,23.7031097 L12.609561,23.275831 C12.6075724,23.2657013 12.6010112,23.2592993 12.5934901,23.257841 L12.5934901,23.257841 Z M12.8583906,23.1452862 L12.8445485,23.1473072 L12.6598443,23.2396597 L12.6498822,23.2499052 L12.6498822,23.2499052 L12.6471943,23.2611114 L12.6650943,23.6906389 L12.6699349,23.7034178 L12.6699349,23.7034178 L12.678386,23.7104931 L12.8793402,23.8032389 C12.8914285,23.8068999 12.9022333,23.8029875 12.9078286,23.7952264 L12.9118235,23.7811639 L12.8776777,23.1665331 C12.8752882,23.1545897 12.8674102,23.1470016 12.8583906,23.1452862 L12.8583906,23.1452862 Z M12.1430473,23.1473072 C12.1332178,23.1423925 12.1221763,23.1452606 12.1156365,23.1525954 L12.1099173,23.1665331 L12.0757714,23.7811639 C12.0751323,23.7926639 12.0828099,23.8018602 12.0926481,23.8045676 L12.108256,23.8032389 L12.3092106,23.7104931 L12.3186497,23.7024347 L12.3186497,23.7024347 L12.3225043,23.6906389 L12.340401,23.2611114 L12.337245,23.2485176 L12.337245,23.2485176 L12.3277531,23.2396597 L12.1430473,23.1473072 Z" id="MingCute" fill-rule="nonzero"> </path> <path d="M19,3 C20.1046,3 21,3.89543 21,5 L21,19 C21,20.1046 20.1046,21 19,21 L5,21 C3.89543,21 3,20.1046 3,19 L3,5 C3,3.89543 3.89543,3 5,3 L19,3 Z M19,5 L5,5 L5,19 L19,19 L19,5 Z M12.0371,7.72855 C12.3082739,6.86097522 13.4777299,6.77813919 13.8878385,7.54487333 L13.9389,7.6547 L15.677,12 L17,12 C17.5523,12 18,12.4477 18,13 C18,13.51285 17.613973,13.9355092 17.1166239,13.9932725 L17,14 L15.0068,14 C14.6351,14 14.297096,13.796366 14.1213836,13.4760677 L14.069,13.3651 L13.1158,10.9821 L11.4619,16.2746 C11.2030273,17.1027636 10.1072568,17.2277874 9.65460915,16.5302559 L9.59448,16.425 L8.38197,14 L7,14 C6.44772,14 6,13.5523 6,13 C6,12.48715 6.38604429,12.0644908 6.88337975,12.0067275 L7,12 L8.99382,12 C9.33388222,12 9.64798889,12.1707457 9.83374702,12.4490272 L9.89719,12.5583 L10.2853,13.3345 L12.0371,7.72855 Z" id="形状" fill="#ffffff"> </path> </g> </g> </g> </g>
␍</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 317 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.34-4.34"/><path d="M11 8v6"/><path d="M8 11h6"/></svg>

Before

Width:  |  Height:  |  Size: 289 B

After

Width:  |  Height:  |  Size: 280 B