Dialog UI builder for servers

This commit is contained in:
Anuken
2026-08-03 16:07:46 -04:00
parent 81d2043d06
commit 75515288cd
13 changed files with 1247 additions and 87 deletions
-79
View File
@@ -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<Dialog> 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<Integer, Dialog> 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
+14
View File
@@ -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;
+77
View File
@@ -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);
}
+5 -2
View File
@@ -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<DatagramPacket> 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();
+195 -5
View File
@@ -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<Dialog> followUpMenus = new IntMap<>();
private static IntMap<MenuDialog> menuDialogs = new IntMap<>();
private static final Seq<MenuListener> menuListeners = new Seq<>();
private static final Seq<MenuBuilderListener> menuBuilderListeners = new Seq<>();
private static final Seq<TextInputListener> 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<Integer, Dialog> 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<String, Element> idToElement;
Cons<MenuResult> 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);
}
@@ -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;
}
}
@@ -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<String, Object> 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 +
'}';
}
}
@@ -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<T extends NodeBuilder<T>>{
final UiKey type;
final Seq<Entry> 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<Entry> 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<Entry> 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<T extends ContainerBuilder<T>> extends NodeBuilder<T>{
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>{
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>{
PaneBuilder(){ super(UiKey.pane); }
public PaneBuilder style(String style){ return prop(UiKey.style, style); }
}
public static class LabelBuilder extends NodeBuilder<LabelBuilder>{
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>{
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>{
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>{
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>{
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>{
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>{
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>{
SpaceBuilder(){ super(UiKey.space); }
}
public static class DefaultsBuilder extends NodeBuilder<DefaultsBuilder>{
DefaultsBuilder(){ super(UiKey.defaults); }
}
public static class ButtonTableBuilder extends ContainerBuilder<ButtonTableBuilder>{
ButtonTableBuilder(){ super(UiKey.buttonTable); }
public ButtonTableBuilder style(String style){ return prop(UiKey.style, style); }
public ButtonTableBuilder clicked(String result){ return prop(UiKey.clicked, result); }
}
}
@@ -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() ? "<eof>" : peek()) + "'");
pos++;
}
private boolean atEnd(){
return pos >= src.length();
}
private char peek(){
return src.charAt(pos);
}
}
+18
View File
@@ -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();
}
@@ -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<Class<?>, ObjectMap<String, Object>> styles = new ObjectMap<>();
public static <T> @Nullable T get(Class<T> 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);
}
}
}
}
@@ -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<String, Element> build(Table root, NodeBuilder<?> builder){
return build(root, builder, null);
}
public static ObjectMap<String, Element> build(Table root, NodeBuilder<?> builder, @Nullable Cons<MenuResult> resultListener){
BuildContext ctx = new BuildContext(resultListener);
buildInto(root, builder.entries, ctx);
return ctx.idElements;
}
private static void buildInto(Table table, Seq<Entry> entries, BuildContext ctx){
// defaults{} only affects cells added later within this same block, not nested table/pane blocks
Seq<Entry> 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<Table> 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<Button> cell = table.add(btn);
buildInto(btn, node.entries, ctx); // build the button's own contents into it, like table/pane
wireButton(btn, node, ctx);
yield cell;
}
default -> null;
};
}
/** Looks up a style by the node's "style" value (if set) and applies it via the given setter; no-op if absent/unknown. */
private static <S> void applyStyle(NodeBuilder<?> node, Class<S> styleType, Cons<S> setter){
String name = node.str(UiKey.style);
if(name == null) return;
S style = UiStyleLookup.get(styleType, name);
if(style != null) setter.get(style);
}
/** Sets up the click callback and button group, if present.*/
private static void wireButton(Button element, NodeBuilder<?> node, BuildContext ctx){
String group = node.str(UiKey.group);
if(group != null){
ctx.group(group).add(element);
}
element.setChecked(node.bool(UiKey.checked));
String result = node.str(UiKey.clicked);
if(result != null && ctx != null && ctx.resultListener != null){
element.clicked(() -> {
MenuResult res = new MenuResult(result);
for(var entry : ctx.idElements){
Element el = entry.value;
if(el instanceof Slider s) res.values.put(entry.key, s.getValue());
else if(el instanceof TextField f) res.values.put(entry.key, f.getText());
else if(el instanceof CheckBox c) res.values.put(entry.key, c.isChecked());
//the only thing distinguishing buttons that can be checked is that they have a style for it; it's just not visible otherwise.
else if(el instanceof Button b && b.getStyle().checked != null) res.values.put(entry.key, b.isChecked());
}
ctx.resultListener.get(res);
});
}
}
/** Properties that apply to the table itself rather than to a cell (found loose in a table's body). */
private static void applyTableProp(Table table, UiKey key, Object value){
switch(key){
case background -> table.setBackground(findDrawable((String)value));
case margin -> table.margin((Float)value);
case align -> table.align(parseAlign((String)value));
default -> {}
}
}
private static void applyCellProp(Cell<?> cell, UiKey key, Object value){
if(value == Boolean.TRUE){
switch(key){
case grow -> cell.grow();
case growX -> cell.growX();
case growY -> cell.growY();
case fill -> cell.fill();
case fillX -> cell.fillX();
case fillY -> cell.fillY();
case expand -> cell.expand();
case expandX -> cell.expandX();
case expandY -> cell.expandY();
case uniform -> cell.uniform();
case uniformX -> cell.uniformX();
case uniformY -> cell.uniformY();
default -> {}
}
}else{
switch(key){
case width -> cell.width((Float)value);
case height -> cell.height((Float)value);
case size -> cell.size((Float)value);
case minWidth -> cell.minWidth((Float)value);
case maxWidth -> cell.maxWidth((Float)value);
case minHeight -> cell.minHeight((Float)value);
case maxHeight -> cell.maxHeight((Float)value);
case pad -> cell.pad((Float)value);
case padTop -> cell.padTop((Float)value);
case padLeft -> cell.padLeft((Float)value);
case padBottom -> cell.padBottom((Float)value);
case padRight -> cell.padRight((Float)value);
case align -> cell.align(parseAlign((String)value));
case colspan -> cell.colspan(((Float)value).intValue());
default -> {}
}
}
}
private static Drawable findDrawable(String name){
if(Core.atlas.has(name)){
return Core.atlas.drawable(name);
}
//icons are a fallback (TODO: bad idea?)
var result = Icon.icons.get(name);
if(result == null) return Core.atlas.drawable("error");
return result;
}
private static int parseAlign(String value){
int result = 0;
for(String part : value.split("\\s+")){
result |= switch(part){
case "top" -> Align.top;
case "bottom" -> Align.bottom;
case "left" -> Align.left;
case "right" -> Align.right;
case "center" -> Align.center;
default -> 0;
};
}
return result == 0 ? Align.center : result;
}
private static class BuildContext{
final @Nullable Cons<MenuResult> resultListener;
final ObjectMap<String, Element> idElements = new ObjectMap<>();
final ObjectMap<String, ButtonGroup<Button>> buttonGroups = new ObjectMap<>();
BuildContext(@Nullable Cons<MenuResult> resultListener){
this.resultListener = resultListener;
}
ButtonGroup<Button> group(String name){
return buttonGroups.get(name, ButtonGroup::new);
}
}
}
+1 -1
View File
@@ -26,4 +26,4 @@ org.gradle.caching=true
org.gradle.internal.http.socketTimeout=100000
org.gradle.internal.http.connectionTimeout=100000
android.enableR8.fullMode=false
archash=b0880b136e
archash=04127890cd