mirror of
https://github.com/Anuken/Mindustry.git
synced 2026-10-01 18:50:27 +03:00
Dialog UI builder for servers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user