diff --git a/core/src/mindustry/core/UI.java b/core/src/mindustry/core/UI.java index f629548331..d475132f02 100644 --- a/core/src/mindustry/core/UI.java +++ b/core/src/mindustry/core/UI.java @@ -34,7 +34,6 @@ import static arc.scene.actions.Actions.*; import static mindustry.Vars.*; public class UI implements ApplicationListener, Loadable{ - private static final StringBuilder buffer = new StringBuilder(); public static String billions, millions, thousands; @@ -81,8 +80,6 @@ public class UI implements ApplicationListener, Loadable{ public CampaignCompleteDialog campaignComplete; public CampaignRulesDialog campaignRules; - public IntMap followUpMenus; - public Cursor drillCursor, unloadCursor, targetCursor, repairCursor; private @Nullable Element lastAnnouncement; @@ -229,7 +226,6 @@ public class UI implements ApplicationListener, Loadable{ fullText = new FullTextDialog(); campaignComplete = new CampaignCompleteDialog(); campaignRules = new CampaignRulesDialog(); - followUpMenus = new IntMap<>(); Group group = Core.scene.root; @@ -664,81 +660,6 @@ public class UI implements ApplicationListener, Loadable{ dialog.show(); } - // TODO REPLACE INTEGER WITH arc.fun.IntCons(int, T) or something like that. - public Dialog newMenuDialog(String title, String message, String[][] options, Cons2 buttonListener){ - return new Dialog(title){{ - setFillParent(true); - removeChild(titleTable); - cont.add(titleTable).width(400f); - - cont.row(); - cont.image().width(400f).pad(2).colspan(2).height(4f).color(Pal.accent).bottom(); - cont.row(); - cont.pane(table -> { - table.add(message).width(400f).wrap().get().setAlignment(Align.center); - table.row(); - - int option = 0; - for(var optionsRow : options){ - if(optionsRow.length == 0) continue; - Table buttonRow = table.row().table().get().row(); - int fullWidth = 400 - (optionsRow.length - 1) * 8; // adjust to count padding as well - int width = fullWidth / optionsRow.length; - int lastWidth = fullWidth - width * (optionsRow.length - 1); // take the rest of space for uneven table - - for(int i = 0; i < optionsRow.length; i++){ - if(optionsRow[i] == null) continue; - - String optionName = optionsRow[i]; - int finalOption = option; - buttonRow.button(optionName, () -> buttonListener.get(finalOption, this)) - .size(i == optionsRow.length - 1 ? lastWidth : width, 50).pad(4); - option++; - } - } - }).growX(); - }}; - } - - /** Shows a menu that fires a callback when an option is selected. If nothing is selected, -1 is returned. */ - public void showMenu(String title, String message, String[][] options, Intc callback){ - Dialog dialog = newMenuDialog(title, message, options, (option, myself) -> { - callback.get(option); - myself.hide(); - }); - dialog.closeOnBack(() -> callback.get(-1)); - dialog.show(); - } - - /** Shows a menu that hides when another followUp-menu is shown or when nothing is selected. - * @see UI#showMenu(String, String, String[][], Intc) */ - public void showFollowUpMenu(int menuId, String title, String message, String[][] options, Intc callback) { - Dialog dialog = newMenuDialog(title, message, options, (option, myself) -> { - callback.get(option); - if(!state.isGame()){ - myself.hide(); - } - }); - dialog.closeOnBack(() -> { - followUpMenus.remove(menuId); - callback.get(-1); - }); - - Dialog oldDialog = followUpMenus.remove(menuId); - if(oldDialog != null){ - dialog.show(Core.scene, null); - oldDialog.hide(null); - }else{ - dialog.show(); - } - followUpMenus.put(menuId, dialog); - } - - public void hideFollowUpMenu(int menuId) { - if(!followUpMenus.containsKey(menuId)) return; - followUpMenus.remove(menuId).hide(); - } - /** * Finds all :name: in a string and replaces them with the icon, if such exists. * Based on TextFormatter::simpleFormat diff --git a/core/src/mindustry/game/EventType.java b/core/src/mindustry/game/EventType.java index a764d4edea..2da853508a 100644 --- a/core/src/mindustry/game/EventType.java +++ b/core/src/mindustry/game/EventType.java @@ -11,6 +11,7 @@ import mindustry.mod.data.*; import mindustry.net.*; import mindustry.net.Packets.*; import mindustry.type.*; +import mindustry.ui.builder.*; import mindustry.world.*; import mindustry.world.blocks.environment.*; import mindustry.world.blocks.storage.CoreBlock.*; @@ -208,6 +209,19 @@ public class EventType{ } } + /** Consider using Menus.registerMenu instead. */ + public static class MenuBuilderOptionChooseEvent{ + public final Player player; + public final int menuId; + public final MenuResult result; + + public MenuBuilderOptionChooseEvent(Player player, int menuId, MenuResult result){ + this.player = player; + this.menuId = menuId; + this.result = result; + } + } + /** Consider using Menus.registerTextInput instead. */ public static class TextInputEvent{ public final Player player; diff --git a/core/src/mindustry/io/TypeIO.java b/core/src/mindustry/io/TypeIO.java index c96c4cf0f4..5db5e78500 100644 --- a/core/src/mindustry/io/TypeIO.java +++ b/core/src/mindustry/io/TypeIO.java @@ -24,6 +24,8 @@ import mindustry.logic.*; import mindustry.net.Administration.*; import mindustry.net.Packets.*; import mindustry.type.*; +import mindustry.ui.builder.*; +import mindustry.ui.builder.UiBuilder.*; import mindustry.world.*; import mindustry.world.blocks.*; import mindustry.world.blocks.payloads.*; @@ -268,6 +270,81 @@ public class TypeIO{ }; } + public static void writeUiBuilder(Writes writes, NodeBuilder node){ + node.write(writes); + } + + public static NodeBuilder readUiBuilder(Reads reads){ + return NodeBuilder.read(reads); + } + + public static void writeMenuResult(Writes writes, MenuResult result){ + writes.l(result.token); + + writeString(writes, result.result); + + writes.s(Math.min(MenuResult.maxTotalValues, result.values.size)); + int i = 0; + for(var entry : result.values){ + if(++i > MenuResult.maxTotalValues){ + break; + } + + writes.str(entry.key); + if(entry.value instanceof Float f){ + writes.b(0); + writes.f(f); + }else if(entry.value instanceof Boolean b){ + writes.b(1); + writes.bool(b); + }else if(entry.value instanceof String s){ + writes.b(2); + writes.str(s); + }else{ + throw new ArcRuntimeException("Unknown object type: " + entry.value); + } + } + } + + public static MenuResult readMenuResult(Reads read){ + MenuResult result = new MenuResult(); + + result.token = read.l(); + + //would be nice to count bytes but sadly that's not really viable here + int remainingChars = MenuResult.maxTotalStringLen; + + byte exists = read.b(); + if(exists != 0){ + result.result = read.str(MenuResult.maxResultLen); + remainingChars -= result.result.length(); + } //else, no result, remains null + + int entries = read.us(); + if(entries > MenuResult.maxTotalValues) throw new ArcRuntimeException("UI result entry count exceeds max entries: " + entries); + + for(int i = 0; i < entries; i++){ + if(remainingChars <= 0) throw new ArcRuntimeException("Too many long strings in MenuResult, reduce your amount of elements/IDs."); + String key = read.str(Math.min(remainingChars, MenuResult.maxValueLen)); + remainingChars -= key.length(); + + byte type = read.b(); + + switch(type){ + case 0 -> result.values.put(key, read.f()); + case 1 -> result.values.put(key, read.bool()); + case 2 -> { + if(remainingChars <= 0) throw new ArcRuntimeException("Too many long strings in MenuResult, reduce your amount of elements/IDs."); + String value = read.str(Math.min(remainingChars, MenuResult.maxValueLen)); + remainingChars -= value.length(); + result.values.put(key, value); + } + default -> throw new ArcRuntimeException("Unknown UI entry type: " + type); + } + } + return result; + } + public static void writePayload(Writes writes, Payload payload){ Payload.write(payload, writes); } diff --git a/core/src/mindustry/net/ArcNetProvider.java b/core/src/mindustry/net/ArcNetProvider.java index b0b88d7d41..86a2749c69 100644 --- a/core/src/mindustry/net/ArcNetProvider.java +++ b/core/src/mindustry/net/ArcNetProvider.java @@ -27,6 +27,8 @@ import java.util.concurrent.*; import static mindustry.Vars.*; public class ArcNetProvider implements NetProvider{ + public static final int clientReadBufferSize = 20_000; + final Client client; final Prov packetSupplier = () -> new DatagramPacket(new byte[512], 512); @@ -59,7 +61,7 @@ public class ArcNetProvider implements NetProvider{ packetSpamLimit = Config.packetSpamLimit.num(); }); - client = new Client(16384, 16384, new PacketSerializer()){ + client = new Client(16384, clientReadBufferSize, new PacketSerializer()){ @Override public void handleNetException(ArcNetException e){ //allow occasional UDP network errors @@ -107,7 +109,8 @@ public class ArcNetProvider implements NetProvider{ } }); - server = new Server(32768, 16384, new PacketSerializer()); + //include extra 16kb headroom for when the write buffer is full + server = new Server(clientReadBufferSize + 16_000, 16384, new PacketSerializer()); server.setMulticast(multicastGroup, multicastPort); server.setDiscoveryHandler((address, handler) -> { ByteBuffer buffer = NetworkIO.writeServerData(); diff --git a/core/src/mindustry/ui/Menus.java b/core/src/mindustry/ui/Menus.java index a268aa7f96..9054380a7e 100644 --- a/core/src/mindustry/ui/Menus.java +++ b/core/src/mindustry/ui/Menus.java @@ -1,40 +1,145 @@ package mindustry.ui; import arc.*; +import arc.func.*; +import arc.scene.*; +import arc.scene.ui.*; +import arc.scene.ui.layout.*; import arc.struct.*; import arc.util.*; import mindustry.annotations.Annotations.*; import mindustry.game.EventType.*; import mindustry.gen.*; +import mindustry.graphics.*; +import mindustry.ui.builder.*; +import mindustry.ui.builder.UiBuilder.*; +import mindustry.ui.dialogs.*; import static mindustry.Vars.*; -/** Class for handling menus and notifications across the network. Unstable API! */ +/** Class for handling menus and notifications across the network. */ public class Menus{ + private static IntMap followUpMenus = new IntMap<>(); + private static IntMap menuDialogs = new IntMap<>(); + private static final Seq menuListeners = new Seq<>(); + private static final Seq menuBuilderListeners = new Seq<>(); private static final Seq textInputListeners = new Seq<>(); - /** Register a *global* menu listener. If no option is chosen, the option is returned as -1. */ + /** Register a global menu listener. If no option is chosen, the option is returned as -1. This API is soft-deprecated; use {@link MenuBuilder} and {@link #registerMenuBuilder} instead. */ public static int registerMenu(MenuListener listener){ menuListeners.add(listener); return menuListeners.size - 1; } + /** Register a global menu builder listener. This is not the same as the 'old' MenuListener API; use it with {@link MenuBuilder}. */ + public static int registerMenuBuilder(MenuBuilderListener listener){ + menuBuilderListeners.add(listener); + return menuBuilderListeners.size - 1; + } + /** Register a *global* text input listener. If no text is provided, the text is returned as null. */ public static int registerTextInput(TextInputListener listener){ textInputListeners.add(listener); return textInputListeners.size - 1; } + //use the MenuBuilder version instead of invoking this with Call + @Remote(variants = Variant.both) + public static void menuBuilder(int id, long token, @Nullable String title, boolean hideOnClick, boolean hidePrevious, boolean fillScreen, NodeBuilder builder){ + MenuDialog dialog = new MenuDialog(); + dialog.setFillParent(fillScreen); + if(title != null){ + dialog.title.setText(title); + }else{ + dialog.titleTable.remove(); + } + + dialog.addCloseButton(); + + dialog.update(() -> { + if(!net.active()){ + dialog.hide(); + } + }); + + dialog.hidden(() -> { + menuDialogs.remove(id); + //let the server know nothing was selected + if(!dialog.wasHidden){ + var result = new MenuResult(); + result.token = token; + if(net.client()) Call.menuBuilderChoose(player, id, result); + } + }); + + dialog.originalListener = result -> { + dialog.wasHidden = true; //don't trigger the hidden listener + result.token = token; + if(net.client()) Call.menuBuilderChoose(player, id, result); + + if(hideOnClick){ + dialog.hide(); + } + }; + + dialog.idToElement = UiTreeBuilder.build(dialog.cont, builder, dialog.originalListener); + + var oldDialog = menuDialogs.remove(id); + + if(hidePrevious && oldDialog != null){ + //old dialog exists, replace it (show with no action) + dialog.show(Core.scene, null); + oldDialog.hide(null); + }else{ + dialog.show(); + } + + menuDialogs.put(id, dialog); + } + + @Remote(variants = Variant.both) + public static void menuBuilderUpdate(int id, String tableId, NodeBuilder builder){ + MenuDialog dialog = menuDialogs.get(id); + if(dialog == null) return; + Element element = dialog.idToElement.get(tableId); + if(!(element instanceof Table table)) return; + table.clear(); + var newElements = UiTreeBuilder.build(table, builder, result -> { + //forward results to original listener + dialog.originalListener.get(result); + }); + //save new element IDs + dialog.idToElement.putAll(newElements); + } + + + @Remote(variants = Variant.both) + public static void hideMenuBuilder(int menuId) { + if(!menuDialogs.containsKey(menuId)) return; + menuDialogs.remove(menuId).hide(); + } + //do not invoke any of the methods below directly, use Call + @Remote(targets = Loc.both, called = Loc.both) + public static void menuBuilderChoose(@Nullable Player player, int menuId, MenuResult results){ + if(player != null){ + Events.fire(new MenuBuilderOptionChooseEvent(player, menuId, results)); + + if(menuId >= 0 && menuId < menuBuilderListeners.size){ + menuBuilderListeners.get(menuId).get(player, results); + } + } + } + @Remote(variants = Variant.both) public static void menu(int menuId, String title, String message, String[][] options){ if(title == null) title = ""; if(message == null) message = ""; if(options == null) options = new String[0][0]; - ui.showMenu(title, message, options, (option) -> Call.menuChoose(player, menuId, option)); + showMenu(title, message, options, (option) -> Call.menuChoose(player, menuId, option)); } @Remote(variants = Variant.both) @@ -43,12 +148,82 @@ public class Menus{ if(message == null) message = ""; if(options == null) options = new String[0][0]; - ui.showFollowUpMenu(menuId, title, message, options, (option) -> Call.menuChoose(player, menuId, option)); + showFollowUpMenu(menuId, title, message, options, (option) -> Call.menuChoose(player, menuId, option)); } @Remote(variants = Variant.both) public static void hideFollowUpMenu(int menuId) { - ui.hideFollowUpMenu(menuId); + if(!followUpMenus.containsKey(menuId)) return; + followUpMenus.remove(menuId).hide(); + } + + static Dialog newMenuDialog(String title, String message, String[][] options, Cons2 buttonListener){ + return new Dialog(title){{ + setFillParent(true); + removeChild(titleTable); + cont.add(titleTable).width(400f); + + cont.row(); + cont.image().width(400f).pad(2).colspan(2).height(4f).color(Pal.accent).bottom(); + cont.row(); + cont.pane(table -> { + table.add(message).width(400f).wrap().get().setAlignment(Align.center); + table.row(); + + int option = 0; + for(var optionsRow : options){ + if(optionsRow.length == 0) continue; + Table buttonRow = table.row().table().get().row(); + int fullWidth = 400 - (optionsRow.length - 1) * 8; // adjust to count padding as well + int width = fullWidth / optionsRow.length; + int lastWidth = fullWidth - width * (optionsRow.length - 1); // take the rest of space for uneven table + + for(int i = 0; i < optionsRow.length; i++){ + if(optionsRow[i] == null) continue; + + String optionName = optionsRow[i]; + int finalOption = option; + buttonRow.button(optionName, () -> buttonListener.get(finalOption, this)) + .size(i == optionsRow.length - 1 ? lastWidth : width, 50).pad(4); + option++; + } + } + }).growX(); + }}; + } + + /** Shows a menu that fires a callback when an option is selected. If nothing is selected, -1 is returned. */ + static void showMenu(String title, String message, String[][] options, Intc callback){ + Dialog dialog = newMenuDialog(title, message, options, (option, myself) -> { + callback.get(option); + myself.hide(); + }); + dialog.closeOnBack(() -> callback.get(-1)); + dialog.show(); + } + + /** Shows a menu that hides when another followUp-menu is shown or when nothing is selected. + * @see #showMenu(String, String, String[][], Intc) */ + static void showFollowUpMenu(int menuId, String title, String message, String[][] options, Intc callback) { + Dialog dialog = newMenuDialog(title, message, options, (option, myself) -> { + callback.get(option); + if(!state.isGame()){ + myself.hide(); + } + }); + dialog.closeOnBack(() -> { + followUpMenus.remove(menuId); + callback.get(-1); + }); + + Dialog oldDialog = followUpMenus.remove(menuId); + if(oldDialog != null){ + dialog.show(Core.scene, null); + oldDialog.hide(null); + }else{ + dialog.show(); + } + followUpMenus.put(menuId, dialog); } @Remote(targets = Loc.both, called = Loc.both) @@ -208,10 +383,25 @@ public class Menus{ } } + static class MenuDialog extends BaseDialog{ + //retained for easy lookup if an element needs to be replaced + ObjectMap idToElement; + Cons originalListener; + boolean wasHidden; + + public MenuDialog(){ + super(""); + } + } + public interface MenuListener{ void get(Player player, int option); } + public interface MenuBuilderListener{ + void get(Player player, MenuResult result); + } + public interface TextInputListener{ void get(Player player, @Nullable String text); } diff --git a/core/src/mindustry/ui/builder/MenuBuilder.java b/core/src/mindustry/ui/builder/MenuBuilder.java new file mode 100644 index 0000000000..3940a362f0 --- /dev/null +++ b/core/src/mindustry/ui/builder/MenuBuilder.java @@ -0,0 +1,82 @@ +package mindustry.ui.builder; + +import arc.util.*; +import mindustry.gen.*; +import mindustry.ui.builder.UiBuilder.*; + +/** Builder for presenting a complex menu to the player. */ +public class MenuBuilder{ + /** Title string; if null, no title is shown at all (the orange bar is removed too) */ + public @Nullable String title; + /** If false, the dialog will not immediately be hidden when a button with a click listener is clicked. keep in mind that users will be able to spam buttons. */ + public boolean hideOnClick = true; + /** If true (default), existing menus with this ID are hidden. */ + public boolean hideExisting = true; + /** If true (default), the dialog fills the screen. If you're showing something small, make it false. For large complex menus, this has to be true in order to work properly. */ + public boolean fillScreen = true; + /** Unique value that is passed to the result after menu is closed. */ + public long token; + /** ID for menu callback; you can reuse this as long as you have unique keys for button results. */ + public int id; + /** contents of the dialog, must not be null */ + public NodeBuilder ui; + + /** @return a menu constructed from a builder. */ + public static MenuBuilder of(NodeBuilder ui){ + var builder = new MenuBuilder(); + builder.ui = ui; + return builder; + } + + /** @return a menu constructed from a DSL string (see UI DSL docs). */ + public static MenuBuilder of(String dslString){ + var builder = new MenuBuilder(); + builder.ui = UiDslParser.parse(dslString); + return builder; + } + + /** Submits this menu to the relevant player. */ + public void show(Player player){ + Call.menuBuilder(player.con, id, token, title, hideOnClick, hideExisting, fillScreen, ui); + } + + /** Submits this menu to all players. */ + public void showAll(){ + Call.menuBuilder(id, token, title, hideOnClick, hideExisting, fillScreen, ui); + } + + /** Rebuilds a specific Table with the specified elementId. This can be used for updating dialog content based on user interaction.*/ + public void update(Player player, String elementId){ + Call.menuBuilderUpdate(player.con, id, elementId, ui); + } + + public MenuBuilder hideExisting(boolean hideExisting){ + this.hideExisting = hideExisting; + return this; + } + + public MenuBuilder token(long token){ + this.token = token; + return this; + } + + public MenuBuilder title(String title){ + this.title = title; + return this; + } + + public MenuBuilder hideOnClick(boolean hideOnClick){ + this.hideOnClick = hideOnClick; + return this; + } + + public MenuBuilder fillScreen(boolean fillScreen){ + this.fillScreen = fillScreen; + return this; + } + + public MenuBuilder id(int id){ + this.id = id; + return this; + } +} diff --git a/core/src/mindustry/ui/builder/MenuResult.java b/core/src/mindustry/ui/builder/MenuResult.java new file mode 100644 index 0000000000..8a1d5a2fb5 --- /dev/null +++ b/core/src/mindustry/ui/builder/MenuResult.java @@ -0,0 +1,73 @@ +package mindustry.ui.builder; + +import arc.struct.*; +import arc.util.*; + +public class MenuResult{ + /** Maximum length of result string */ + public static final int maxResultLen = 500; + /** Maximum length of a single value in chars. */ + public static final int maxValueLen = 1000; + /** Maximum total length of all string values in chars. This includes IDs of result values. */ + public static final int maxTotalStringLen = 6000; + /** Maximum values count of any type that can be sent. */ + public static final int maxTotalValues = 500; + + /** Token passed to MenuBuilder. */ + public long token; + /** Button that was actually clicked, or null if the dialog was closed (back button). */ + public @Nullable String result = null; + /** Maps ID to element value (slider: value, text field: text, checkbox: checked) */ + public ObjectMap values = new ObjectMap<>(); + + /** Implies cancelled results. */ + public MenuResult(){ + } + + public MenuResult(@Nullable String result){ + this.result = result; + } + + /** @return if the dialog was closed with no choice being made. */ + public boolean wasCancelled(){ + return result == null; + } + + /** @return whether the result ID matches the specified parameter. */ + public boolean is(String text){ + return Structs.eq(text, result); + } + + public String getString(String id, String defaultValue){ + return values.get(id) instanceof String s ? s : defaultValue; + } + + public @Nullable String getString(String id){ + return values.get(id) instanceof String s ? s : null; + } + + public float getFloat(String id, float def){ + return values.get(id) instanceof Float s ? s : def; + } + + public float getFloat(String id){ + return getFloat(id, 0f); + } + + public boolean getBool(String id, boolean def){ + return values.get(id) instanceof Boolean s ? s : def; + } + + public boolean getBool(String id){ + return getBool(id, false); + } + + @Override + public String toString(){ + return "MenuResult{" + + "token=" + token + + ", result='" + result + '\'' + + ", values=" + values + + '}'; + } +} \ No newline at end of file diff --git a/core/src/mindustry/ui/builder/UiBuilder.java b/core/src/mindustry/ui/builder/UiBuilder.java new file mode 100644 index 0000000000..7edba82d41 --- /dev/null +++ b/core/src/mindustry/ui/builder/UiBuilder.java @@ -0,0 +1,301 @@ +package mindustry.ui.builder; + +import arc.struct.*; +import arc.util.*; +import arc.util.io.*; + +/** Builder-style API for building dialogs on servers. It is recommended to statically import all the methods in this class. */ +public class UiBuilder{ + + public static TableBuilder parse(String source){ return UiDslParser.parse(source); } + public static TableBuilder table(){ return new TableBuilder(); } + public static PaneBuilder pane(){ return new PaneBuilder(); } + public static LabelBuilder label(String text){ return new LabelBuilder().text(text); } + public static ImageBuilder image(){ return new ImageBuilder(); } + public static ImageBuilder image(String region){ return new ImageBuilder().region(region); } + public static ButtonBuilder button(String text){ return new ButtonBuilder().text(text); } + public static ImageButtonBuilder imageButton(String icon){ return new ImageButtonBuilder().icon(icon); } + public static FieldBuilder field(String text){ return new FieldBuilder().text(text); } + public static CheckBuilder check(){ return new CheckBuilder(); } + public static CheckBuilder check(String text){ return new CheckBuilder().text(text); } + public static SliderBuilder slider(float min, float max, float step){ return new SliderBuilder().min(min).max(max).step(step); } + public static SpaceBuilder space(){ return new SpaceBuilder(); } + public static DefaultsBuilder defaults(){ return new DefaultsBuilder(); } + public static ButtonTableBuilder buttonTable(){ return new ButtonTableBuilder(); } + + public static abstract class NodeBuilder>{ + final UiKey type; + final Seq entries = new Seq<>(); + + NodeBuilder(UiKey type){ + this.type = type; + } + + @SuppressWarnings("unchecked") + private T self(){ + return (T)this; + } + + protected T prop(UiKey key, String value){ entries.add(new Entry(key, value)); return self(); } + protected T prop(UiKey key, float value){ entries.add(new Entry(key, value)); return self(); } + protected T prop(UiKey key, boolean value){ entries.add(new Entry(key, value)); return self(); } + + public T id(String id){ return prop(UiKey.id, id); } + public T grow(){ return prop(UiKey.grow, true); } + public T growX(){ return prop(UiKey.growX, true); } + public T growY(){ return prop(UiKey.growY, true); } + public T fill(){ return prop(UiKey.fill, true); } + public T fillX(){ return prop(UiKey.fillX, true); } + public T fillY(){ return prop(UiKey.fillY, true); } + public T expand(){ return prop(UiKey.expand, true); } + public T expandX(){ return prop(UiKey.expandX, true); } + public T expandY(){ return prop(UiKey.expandY, true); } + public T width(float w){ return prop(UiKey.width, w); } + public T height(float h){ return prop(UiKey.height, h); } + public T size(float s){ return prop(UiKey.size, s); } + public T minWidth(float w){ return prop(UiKey.minWidth, w); } + public T maxWidth(float w){ return prop(UiKey.maxWidth, w); } + public T minHeight(float h){ return prop(UiKey.minHeight, h); } + public T maxHeight(float h){ return prop(UiKey.maxHeight, h); } + public T pad(float p){ return prop(UiKey.pad, p); } + public T padTop(float p){ return prop(UiKey.padTop, p); } + public T padLeft(float p){ return prop(UiKey.padLeft, p); } + public T padBottom(float p){ return prop(UiKey.padBottom, p); } + public T padRight(float p){ return prop(UiKey.padRight, p); } + public T align(String align){ return prop(UiKey.align, align); } + public T colspan(int c){ return prop(UiKey.colspan, (float)c); } + public T uniform(){ return prop(UiKey.uniform, true); } + public T uniformX(){ return prop(UiKey.uniformX, true); } + public T uniformY(){ return prop(UiKey.uniformY, true); } + + public void write(Writes out){ + out.b(type.ordinal()); + writeEntries(out, entries); + } + + static void writeEntries(Writes out, Seq entries){ + out.s(entries.size); + for(Entry e : entries) writeEntry(out, e); + } + + public static NodeBuilder read(Reads in){ + UiKey type = UiKey.all[in.ub()]; + NodeBuilder node = create(type); + readEntries(in, node.entries); + return node; + } + + static void readEntries(Reads in, Seq entries){ + int count = in.us(); + for(int i = 0; i < count; i++) entries.add(readEntry(in)); + } + + static NodeBuilder create(UiKey type){ + return switch(type){ + case table -> new TableBuilder(); + case pane -> new PaneBuilder(); + case label -> new LabelBuilder(); + case image -> new ImageBuilder(); + case button -> new ButtonBuilder(); + case imageButton -> new ImageButtonBuilder(); + case field -> new FieldBuilder(); + case check -> new CheckBuilder(); + case slider -> new SliderBuilder(); + case space -> new SpaceBuilder(); + case defaults -> new DefaultsBuilder(); + case buttonTable -> new ButtonTableBuilder(); + default -> throw new IllegalArgumentException("Unknown node type " + type); + }; + } + + static void writeEntry(Writes out, Entry e){ + out.s(e.key.ordinal()); + if(e.value instanceof String s){ + out.b(0); + out.str(s); + }else if(e.value instanceof Float f){ + out.b(1); + out.f(f); + }else if(e.value instanceof Boolean b){ + out.b(2); + out.bool(b); + }else if(e.value instanceof NodeBuilder node){ + out.b(3); + node.write(out); + }else{ + throw new ArcRuntimeException("Unsupported entry value type: " + e.value); + } + } + + static Entry readEntry(Reads in){ + UiKey key = UiKey.all[in.us()]; + int tag = in.ub(); + Object value = switch(tag){ + case 0 -> in.str(); + case 1 -> in.f(); + case 2 -> in.bool(); + case 3 -> NodeBuilder.read(in); + default -> throw new ArcRuntimeException("Unknown entry value tag " + tag); + }; + return new Entry(key, value); + } + + String str(UiKey key){ + for(Entry e : entries) if(e.key == key && e.value instanceof String s) return s; + return null; + } + + String str(UiKey key, String def){ + String v = str(key); + return v != null ? v : def; + } + + Float num(UiKey key){ + for(Entry e : entries) if(e.key == key && e.value instanceof Float f) return f; + return null; + } + + float num(UiKey key, float def){ + Float v = num(key); + return v != null ? v : def; + } + + boolean bool(UiKey key){ + return bool(key, false); + } + + boolean bool(UiKey key, boolean def){ + for(Entry e : entries) if(e.key == key && e.value instanceof Boolean b) return b; + return def; + } + } + + static class Entry{ + final UiKey key; + final Object value; + + Entry(UiKey key, Object value){ + this.key = key; + this.value = value; + } + + @Override + public String toString(){ + return "Entry{" + + "key=" + key + + ", value=" + value + + '}'; + } + } + + /** Shared behavior for node types that can contain children (table, pane). */ + public static abstract class ContainerBuilder> extends NodeBuilder{ + ContainerBuilder(UiKey type){ + super(type); + } + + @SuppressWarnings("unchecked") + private T self(){ + return (T)this; + } + + public T row(){ + entries.add(new Entry(UiKey.row, true)); + return self(); + } + + /** Generic nesting point - append any already-configured node as a child. */ + public T add(NodeBuilder child){ + entries.add(new Entry(child.type, child)); + return self(); + } + } + + public static class TableBuilder extends ContainerBuilder{ + TableBuilder(){ super(UiKey.table); } + + public TableBuilder background(String name){ return prop(UiKey.background, name); } + public TableBuilder margin(float m){ return prop(UiKey.margin, m); } + public TableBuilder wrap(boolean wrap){ return prop(UiKey.wrap, wrap); } + } + + public static class PaneBuilder extends ContainerBuilder{ + PaneBuilder(){ super(UiKey.pane); } + + public PaneBuilder style(String style){ return prop(UiKey.style, style); } + } + + public static class LabelBuilder extends NodeBuilder{ + LabelBuilder(){ super(UiKey.label); } + + public LabelBuilder text(String text){ return prop(UiKey.text, text); } + public LabelBuilder wrap(){ return prop(UiKey.wrap, true); } + public LabelBuilder style(String style){ return prop(UiKey.style, style); } + } + + public static class ImageBuilder extends NodeBuilder{ + ImageBuilder(){ super(UiKey.image); } + + public ImageBuilder region(String region){ return prop(UiKey.region, region); } + public ImageBuilder scaling(Scaling scaling){ return prop(UiKey.scaling, scaling.name()); } + } + + public static class ButtonBuilder extends NodeBuilder{ + ButtonBuilder(){ super(UiKey.button); } + + public ButtonBuilder text(String text){ return prop(UiKey.text, text); } + public ButtonBuilder icon(String icon){ return prop(UiKey.icon, icon); } + public ButtonBuilder style(String style){ return prop(UiKey.style, style); } + public ButtonBuilder clicked(String result){ return prop(UiKey.clicked, result); } + } + + public static class ImageButtonBuilder extends NodeBuilder{ + ImageButtonBuilder(){ super(UiKey.imageButton); } + + public ImageButtonBuilder icon(String icon){ return prop(UiKey.icon, icon); } + public ImageButtonBuilder style(String style){ return prop(UiKey.style, style); } + public ImageButtonBuilder clicked(String result){ return prop(UiKey.clicked, result); } + } + + public static class FieldBuilder extends NodeBuilder{ + FieldBuilder(){ super(UiKey.field); } + + public FieldBuilder text(String text){ return prop(UiKey.text, text); } + public FieldBuilder hint(String hint){ return prop(UiKey.hint, hint); } + public FieldBuilder maxLength(int length){ return prop(UiKey.maxLength, (float)length); } + public FieldBuilder style(String style){ return prop(UiKey.style, style); } + } + + public static class CheckBuilder extends NodeBuilder{ + CheckBuilder(){ super(UiKey.check); } + + public CheckBuilder text(String text){ return prop(UiKey.text, text); } + public CheckBuilder checked(boolean checked){ return prop(UiKey.checked, checked); } + public CheckBuilder style(String style){ return prop(UiKey.style, style); } + } + + public static class SliderBuilder extends NodeBuilder{ + SliderBuilder(){ super(UiKey.slider); } + + public SliderBuilder min(float min){ return prop(UiKey.min, min); } + public SliderBuilder max(float max){ return prop(UiKey.max, max); } + public SliderBuilder step(float step){ return prop(UiKey.step, step); } + public SliderBuilder defaultValue(float value){ return prop(UiKey.defaultValue, value); } + public SliderBuilder style(String style){ return prop(UiKey.style, style); } + } + + public static class SpaceBuilder extends NodeBuilder{ + SpaceBuilder(){ super(UiKey.space); } + } + + public static class DefaultsBuilder extends NodeBuilder{ + DefaultsBuilder(){ super(UiKey.defaults); } + } + + public static class ButtonTableBuilder extends ContainerBuilder{ + ButtonTableBuilder(){ super(UiKey.buttonTable); } + + public ButtonTableBuilder style(String style){ return prop(UiKey.style, style); } + public ButtonTableBuilder clicked(String result){ return prop(UiKey.clicked, result); } + } +} \ No newline at end of file diff --git a/core/src/mindustry/ui/builder/UiDslParser.java b/core/src/mindustry/ui/builder/UiDslParser.java new file mode 100644 index 0000000000..7367b161a9 --- /dev/null +++ b/core/src/mindustry/ui/builder/UiDslParser.java @@ -0,0 +1,172 @@ +package mindustry.ui.builder; + +import arc.util.*; +import arc.util.serialization.*; +import mindustry.ui.builder.UiBuilder.*; + +/** Parses String -> UiBuilders. */ +public class UiDslParser{ + private final String src; + private int pos; + private boolean lastQuoted; // whether the most recently parsed value was a quoted string + + private UiDslParser(String src){ + this.src = src; + } + + /** Parses source text into a root TableBuilder (the implicit root table's body). */ + public static TableBuilder parse(String source){ + TableBuilder root = new TableBuilder(); + UiDslParser p = new UiDslParser(source); + p.parseStatementsInto(root); + p.skipWs(); + if(!p.atEnd()) throw new SerializationException("Unexpected character '" + p.peek() + "' at index " + p.pos); + return root; + } + + private void parseStatementsInto(NodeBuilder node){ + skipWs(); + while(!atEnd() && peek() != '}'){ + parseStatement(node); + skipWs(); + } + } + + private void parseStatement(NodeBuilder node){ + skipWs(); + if(atEnd() || peek() == '"') throw new SerializationException("Expected identifier at index " + pos); + String ident = readBareToken(); + if(ident.isEmpty()) throw new SerializationException("Unexpected character '" + peek() + "' at index " + pos); + + if(ident.equals("row")){ + node.entries.add(new Entry(UiKey.row, true)); + return; + } + + //TODO: throws on the client upon invalid value, that's probably fine? + UiKey key = UiKey.valueOf(ident); + //TODO hack: everything before the row ordinal is a node type + boolean isNodeType = key.ordinal() < UiKey.row.ordinal(); + skipWs(); + + //"ident: value" - shorthand node (label: "text") or plain property (width: 100) + if(!atEnd() && peek() == ':'){ + pos++; + String raw = parseValue(); + + if(isNodeType){ + NodeBuilder child = NodeBuilder.create(key); + child.entries.add(new Entry(getShorthandType(key), raw)); + skipWs(); + if(!atEnd() && peek() == '{'){ + pos++; + parseStatementsInto(child); + expect('}'); + } + node.entries.add(new Entry(child.type, child)); + }else{ + node.entries.add(new Entry(key, coerce(raw))); + } + return; + } + + // "ident { ... }" - a node with a block + if(!atEnd() && peek() == '{'){ + pos++; + if(!isNodeType) throw new SerializationException("Unknown node type '" + ident + "' at index " + pos); + NodeBuilder child = NodeBuilder.create(key); + parseStatementsInto(child); + expect('}'); + node.entries.add(new Entry(child.type, child)); + return; + } + + // bare "ident" - childless node (e.g. "space") + if(!isNodeType) throw new SerializationException("Unknown node type '" + ident + "' at index " + pos); + node.entries.add(new Entry(key, NodeBuilder.create(key))); + } + + /** for statements such as image: "cat", the key type is region, not text. */ + private static UiKey getShorthandType(UiKey key){ + return switch(key){ + case image -> UiKey.region; + default -> UiKey.text; + }; + } + + /** Converts a parsed bare token into the most specific type it looks like; quoted strings stay strings. */ + private Object coerce(String raw){ + if(lastQuoted) return raw; + if(raw.equals("true")) return Boolean.TRUE; + if(raw.equals("false")) return Boolean.FALSE; + + float value = Strings.parseFloat(raw, Float.NaN); + if(!Float.isNaN(value)){ + return value; + }else{ + return raw; + } + } + + private String parseValue(){ + skipWs(); + if(!atEnd() && peek() == '"'){ + lastQuoted = true; + return parseString(); + } + lastQuoted = false; + String tok = readBareToken(); + if(tok.isEmpty()) throw new SerializationException("Expected value at index " + pos); + return tok; + } + + private String parseString(){ + pos++; // opening quote + StringBuilder sb = new StringBuilder(); + while(!atEnd() && peek() != '"'){ + char c = src.charAt(pos); + if(c == '\\' && pos + 1 < src.length()){ sb.append(src.charAt(pos + 1)); pos += 2; continue; } + sb.append(c); + pos++; + } + if(atEnd()) throw new SerializationException("Unterminated string"); + pos++; // closing quote + return sb.toString(); + } + + // reads until whitespace/brace/colon; covers identifiers, numbers, and bare-word values alike + private String readBareToken(){ + int start = pos; + while(!atEnd()){ + char c = src.charAt(pos); + if(Character.isWhitespace(c) || c == '{' || c == '}' || c == ':') break; + pos++; + } + return src.substring(start, pos); + } + + private void skipWs(){ + while(!atEnd()){ + char c = src.charAt(pos); + if(Character.isWhitespace(c)){ pos++; continue; } + if(c == '/' && pos + 1 < src.length() && src.charAt(pos + 1) == '/'){ // line comment + while(!atEnd() && src.charAt(pos) != '\n') pos++; + continue; + } + break; + } + } + + private void expect(char c){ + if(atEnd() || peek() != c) throw new SerializationException("Expected '" + c + "', found '" + (atEnd() ? "" : peek()) + "'"); + pos++; + } + + private boolean atEnd(){ + return pos >= src.length(); + } + + private char peek(){ + return src.charAt(pos); + } +} \ No newline at end of file diff --git a/core/src/mindustry/ui/builder/UiKey.java b/core/src/mindustry/ui/builder/UiKey.java new file mode 100644 index 0000000000..649f669928 --- /dev/null +++ b/core/src/mindustry/ui/builder/UiKey.java @@ -0,0 +1,18 @@ +package mindustry.ui.builder; + +/** Enum used for encoding properties as single bytes instead of passing strings around. */ +public enum UiKey{ + // node types + table, pane, label, image, button, imageButton, field, check, slider, space, defaults, buttonTable, row, //NOTE: all node types must be before 'row' + + // node-specific properties + text, wrap, region, icon, scaling, background, margin, id, hint, maxLength, checked, min, max, step, defaultValue, clicked, style, group, + + // cell properties + grow, growX, growY, fill, fillX, fillY, expand, expandX, expandY, + width, height, size, minWidth, maxWidth, minHeight, maxHeight, + pad, padTop, padLeft, padBottom, padRight, + align, colspan, uniform, uniformX, uniformY; + + public static final UiKey[] all = values(); +} \ No newline at end of file diff --git a/core/src/mindustry/ui/builder/UiStyleLookup.java b/core/src/mindustry/ui/builder/UiStyleLookup.java new file mode 100644 index 0000000000..d19d2301bf --- /dev/null +++ b/core/src/mindustry/ui/builder/UiStyleLookup.java @@ -0,0 +1,25 @@ +package mindustry.ui.builder; + +import arc.struct.*; +import arc.util.*; +import mindustry.ui.*; + +/** Grabs style fields by name from {@link Styles}. */ +public class UiStyleLookup{ + private static final ObjectMap, ObjectMap> styles = new ObjectMap<>(); + + public static @Nullable T get(Class type, String name){ + if(styles.isEmpty()) init(); + return (T)styles.get(type, ObjectMap::new).get(name); + } + + private static void init(){ + for(var field : Styles.class.getFields()){ + try{ + styles.get(field.getType(), ObjectMap::new).put(field.getName(), field.get(null)); + }catch(Exception e){ + Log.err(e); + } + } + } +} diff --git a/core/src/mindustry/ui/builder/UiTreeBuilder.java b/core/src/mindustry/ui/builder/UiTreeBuilder.java new file mode 100644 index 0000000000..1dbf2ee107 --- /dev/null +++ b/core/src/mindustry/ui/builder/UiTreeBuilder.java @@ -0,0 +1,284 @@ +package mindustry.ui.builder; + +import arc.*; +import arc.func.*; +import arc.scene.*; +import arc.scene.style.*; +import arc.scene.ui.*; +import arc.scene.ui.Button.*; +import arc.scene.ui.CheckBox.*; +import arc.scene.ui.ImageButton.*; +import arc.scene.ui.Label.*; +import arc.scene.ui.ScrollPane.*; +import arc.scene.ui.Slider.*; +import arc.scene.ui.TextButton.*; +import arc.scene.ui.TextField.*; +import arc.scene.ui.layout.*; +import arc.struct.*; +import arc.util.*; +import mindustry.gen.*; +import mindustry.ui.builder.UiBuilder.*; + +/** Builds a UI into an existing Table from a NodeBuilder tree. */ +public class UiTreeBuilder{ + + public static ObjectMap build(Table root, NodeBuilder builder){ + return build(root, builder, null); + } + + public static ObjectMap build(Table root, NodeBuilder builder, @Nullable Cons resultListener){ + BuildContext ctx = new BuildContext(resultListener); + buildInto(root, builder.entries, ctx); + return ctx.idElements; + } + + private static void buildInto(Table table, Seq entries, BuildContext ctx){ + // defaults{} only affects cells added later within this same block, not nested table/pane blocks + Seq defaults = new Seq<>(); + + for(Entry entry : entries){ + if(entry.key == UiKey.row){ + table.row(); + + }else if(entry.value instanceof NodeBuilder node){ + if(entry.key == UiKey.defaults){ + for(Entry e : node.entries) defaults.add(e); + continue; + } + + Cell cell = addNode(table, entry.key, node, ctx); + if(cell == null) continue; // unknown/unsupported node - skip for forward compat + + for(Entry d : defaults) applyCellProp(cell, d.key, d.value); + for(Entry e : node.entries){ + if(!(e.value instanceof NodeBuilder)) applyCellProp(cell, e.key, e.value); + } + + String id = node.str(UiKey.id); + if(id != null){ + cell.name(id); // assigns element.name via Cell.name() + Object element = cell.get(); + if(element instanceof Element el) ctx.idElements.put(id, el); + } + + }else{ + applyTableProp(table, entry.key, entry.value); // a loose prop on this table's own body (background, margin, ...) + } + } + } + + /** Constructs the element for one node, adds it to the parent table, and returns its cell. */ + private static Cell addNode(Table table, UiKey type, NodeBuilder node, BuildContext ctx){ + return switch(type){ + case table -> { + String bg = node.str(UiKey.background); + Table t = node.bool(UiKey.wrap) ? new WrapTable() : new Table(); + if(bg != null) t.background(findDrawable(bg)); + Float margin = node.num(UiKey.margin); + if(margin != null) t.margin(margin); + + Cell cell = table.add(t); + buildInto(t, node.entries, ctx); // recurse with a fresh defaults scope + yield cell; + } + case pane -> { + Table inner = new Table(); + buildInto(inner, node.entries, ctx); + ScrollPane pane = new ScrollPane(inner); + applyStyle(node, ScrollPaneStyle.class, pane::setStyle); + yield table.add(pane); + } + case label -> { + Label label = new Label(node.str(UiKey.text, "")); // Label resolves @bundle keys itself + label.setWrap(node.bool(UiKey.wrap)); + applyStyle(node, LabelStyle.class, label::setStyle); + yield table.add(label); + } + case image -> { + Image image = new Image(findDrawable(node.str(UiKey.region, node.str(UiKey.icon, "error")))); + String scl = node.str(UiKey.scaling); + if(scl != null){ + try{ + Scaling scaling = Scaling.valueOf(scl); + image.setScaling(scaling); + }catch(Exception ignored){} + } + yield table.add(image); + } + case button -> { + TextButton btn = new TextButton(node.str(UiKey.text, "")); + applyStyle(node, TextButtonStyle.class, btn::setStyle); + String icon = node.str(UiKey.icon); + if(icon != null){ + btn.add(new Image(findDrawable(icon)).setScaling(Scaling.fit)).size(32f); + btn.getCells().reverse(); + } + wireButton(btn, node, ctx); + yield table.add(btn); + } + case imageButton -> { + String icon = node.str(UiKey.icon); + ImageButton btn = new ImageButton(icon != null ? findDrawable(icon) : null); + applyStyle(node, ImageButtonStyle.class, btn::setStyle); + wireButton(btn, node, ctx); + yield table.add(btn); + } + case field -> { + TextField field = new TextField(node.str(UiKey.text, "")); + String hint = node.str(UiKey.hint); + if(hint != null) field.setMessageText(hint); + Float maxLen = node.num(UiKey.maxLength); + if(maxLen != null) field.setMaxLength(maxLen.intValue()); + applyStyle(node, TextFieldStyle.class, field::setStyle); + yield table.add(field); + } + case check -> { + CheckBox box = new CheckBox(node.str(UiKey.text, "")); + box.setChecked(node.bool(UiKey.checked)); + String group = node.str(UiKey.group); + if(group != null) ctx.group(group).add(box); + applyStyle(node, CheckBoxStyle.class, box::setStyle); + yield table.add(box); + } + case slider -> { + Slider slider = new Slider(node.num(UiKey.min, 0f), node.num(UiKey.max, 1f), node.num(UiKey.step, 0.1f), false); + Float def = node.num(UiKey.defaultValue); + if(def != null) slider.setValue(def); + applyStyle(node, SliderStyle.class, slider::setStyle); + yield table.add(slider); + } + case space -> table.add(); + case buttonTable -> { + Button btn = new Button(); + applyStyle(node, ButtonStyle.class, btn::setStyle); + Cell