diff --git a/build.gradle b/build.gradle index aec5ac6..b4a3273 100644 --- a/build.gradle +++ b/build.gradle @@ -113,6 +113,7 @@ tasks.test { jvmArgs "-Djava.util.logging.manager=com.hypixel.hytale.logger.backend.HytaleLogManager" testLogging { + showStandardStreams = true events "passed", "failed", "skipped" } } \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/HyUIPlugin.java b/src/main/java/au/ellie/hyui/HyUIPlugin.java index a0e4ceb..b9bf665 100644 --- a/src/main/java/au/ellie/hyui/HyUIPlugin.java +++ b/src/main/java/au/ellie/hyui/HyUIPlugin.java @@ -26,18 +26,10 @@ import au.ellie.hyui.utils.HyvatarUtils; import au.ellie.hyui.utils.MultiHudWrapper; import au.ellie.hyui.utils.PngDownloadUtils; -import com.hypixel.hytale.component.Ref; -import com.hypixel.hytale.component.Store; -import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.protocol.Asset; import com.hypixel.hytale.protocol.Packet; -import com.hypixel.hytale.protocol.packets.setup.AssetFinalize; import com.hypixel.hytale.protocol.packets.setup.AssetInitialize; -import com.hypixel.hytale.protocol.packets.setup.AssetPart; -import com.hypixel.hytale.protocol.packets.setup.RequestCommonAssetsRebuild; -import com.hypixel.hytale.server.core.asset.common.events.SendCommonAssetsEvent; import com.hypixel.hytale.server.core.entity.entities.Player; -import com.hypixel.hytale.server.core.entity.entities.player.hud.HudManager; import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager; import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent; import com.hypixel.hytale.server.core.io.PacketHandler; @@ -46,36 +38,36 @@ import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; import com.hypixel.hytale.server.core.universe.PlayerRef; -import com.hypixel.hytale.server.core.universe.world.World; -import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import javax.annotation.Nonnull; import java.io.IOException; -import java.util.*; +import java.util.Deque; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ConcurrentMap; public class HyUIPlugin extends JavaPlugin { - private static HyUIPluginLogger instance; - + private static HyUIPluginLogger logger; + private static final boolean ADD_CMDS = false; + private static final ConcurrentMap> PENDING_ASSETS = new ConcurrentHashMap<>(); private static final ConcurrentMap REBUILD_SCHEDULED = new ConcurrentHashMap<>(); - + public static HyUIPluginLogger getLog() { - if (instance == null) - instance = new HyUIPluginLogger(); - return instance; + if (logger == null) + logger = new HyUIPluginLogger(); + + return logger; } public HyUIPlugin(@Nonnull JavaPluginInit init) { super(init); - if (instance == null) - instance = new HyUIPluginLogger(); } @Override @@ -83,9 +75,9 @@ protected void setup() { // Intercept: AssetFinalize, RequestCommonAssetsRebuild, AssetPart, AssetInitialize PacketAdapters.registerOutbound((PacketHandler handler, Packet packet) -> { var packetName = packet.getClass().getSimpleName(); - if (!(handler instanceof GamePacketHandler h)) { + if (!(handler instanceof GamePacketHandler h)) return; - } + switch (packetName) { case "RequestCommonAssetsRebuild": { var pRef = h.getPlayerRef(); @@ -99,11 +91,10 @@ protected void setup() { break; } } - }); if (ADD_CMDS) { - instance.logFinest("Setting up plugin " + this.getName()); + getLog().logFinest("Setting up plugin " + this.getName()); this.getCommandRegistry().registerCommand(new HyUITestGuiCommand()); this.getCommandRegistry().registerCommand(new HyUIAddHudCommand()); this.getCommandRegistry().registerCommand(new HyUIRemHudCommand()); @@ -112,34 +103,33 @@ protected void setup() { this.getCommandRegistry().registerCommand(new HyUITemplateRuntimeCommand()); this.getCommandRegistry().registerCommand(new HyUIBountyCommand()); this.getCommandRegistry().registerCommand(new HyUITabsCommand()); - + this.getEventRegistry().registerGlobal(PlayerReadyEvent.class, event -> { - instance.logFinest("Player ready event triggered for " + event.getPlayer().getDisplayName()); - - var player = event.getPlayer(); - if (player == null) return; + getLog().logFinest("Player ready event triggered for " + event.getPlayer().getDisplayName()); - Ref ref = event.getPlayerRef(); + var player = event.getPlayer(); + var ref = event.getPlayerRef(); if (!ref.isValid()) return; - Store store = ref.getStore(); - World world = store.getExternalData().getWorld(); + var store = ref.getStore(); + var world = store.getExternalData().getWorld(); + world.execute(() -> { - PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + var playerRef = store.getComponent(ref, PlayerRef.getComponentType()); try { PngDownloadUtils.prefetchPngForPlayer(playerRef, HyvatarUtils.buildRenderUrl( - player.getDisplayName(), - HyvatarUtils.RenderType.HEAD, + player.getDisplayName(), + HyvatarUtils.RenderType.HEAD, 64, null, null), 18000); - } catch (IOException e) { - throw new RuntimeException(e); - } catch (InterruptedException e) { + } catch (IOException | InterruptedException e) { throw new RuntimeException(e); } - String html = "Pages/HudTest.html"; + + var html = "Pages/HudTest.html"; var tp = new TemplateProcessor(); tp.setVariable("playerName", player.getDisplayName()); - var hud = HudBuilder.detachedHud() + + HudBuilder.detachedHud() .loadHtml(html, tp) .withRefreshRate(1000) .onRefresh((h) -> { @@ -152,19 +142,18 @@ protected void setup() { }); } - } private static void enqueueAsset(PlayerRef playerRef, Asset asset) { - if (playerRef == null || asset == null) { + if (playerRef == null || asset == null) return; - } - PENDING_ASSETS.computeIfAbsent(playerRef, ref -> new ConcurrentLinkedDeque<>()).push(asset); + + PENDING_ASSETS.computeIfAbsent(playerRef, _ -> new ConcurrentLinkedDeque<>()).push(asset); } private static void scheduleReopenAfterRebuild(PlayerRef playerRef) { - if (playerRef == null || !playerRef.isValid() - || playerRef.getReference() == null + if (playerRef == null || !playerRef.isValid() + || playerRef.getReference() == null || !playerRef.getReference().isValid()) { return; } diff --git a/src/main/java/au/ellie/hyui/HyUIPluginLogger.java b/src/main/java/au/ellie/hyui/HyUIPluginLogger.java index 944b398..364d14a 100644 --- a/src/main/java/au/ellie/hyui/HyUIPluginLogger.java +++ b/src/main/java/au/ellie/hyui/HyUIPluginLogger.java @@ -21,15 +21,18 @@ import com.hypixel.hytale.logger.HytaleLogger; public class HyUIPluginLogger { - - private final HytaleLogger internalLogger = HytaleLogger.forEnclosingClass(); - + public static final boolean IS_DEV = "true".equals(System.getenv("HYUI_DEV")); + private final HytaleLogger internalLogger = HytaleLogger.forEnclosingClass(); public HyUIPluginLogger() { - + } - + + public void logWarn(String message) { + internalLogger.atWarning().log(message); + } + public void logFinest(String message) { internalLogger.atFinest().log(message); } diff --git a/src/main/java/au/ellie/hyui/assets/DynamicImageAsset.java b/src/main/java/au/ellie/hyui/assets/DynamicImageAsset.java index 7757bcb..1a6e83d 100644 --- a/src/main/java/au/ellie/hyui/assets/DynamicImageAsset.java +++ b/src/main/java/au/ellie/hyui/assets/DynamicImageAsset.java @@ -125,7 +125,7 @@ protected CompletableFuture getBlob0() { public static void sendToPlayer(PacketHandler handler, CommonAsset asset) { byte[] allBytes = asset.getBlob().join(); byte[][] parts = ArrayUtil.split(allBytes, 2621440); - + ToClientPacket[] packets = new ToClientPacket[1 + parts.length]; packets[0] = new AssetInitialize(asset.toPacket(), allBytes.length); diff --git a/src/main/java/au/ellie/hyui/builders/ActionButtonBuilder.java b/src/main/java/au/ellie/hyui/builders/ActionButtonBuilder.java index 3eb1c3d..ff72ce1 100644 --- a/src/main/java/au/ellie/hyui/builders/ActionButtonBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/ActionButtonBuilder.java @@ -31,6 +31,7 @@ import com.hypixel.hytale.server.core.ui.builder.EventData; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; + import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -114,98 +115,98 @@ public ActionButtonBuilder withActionName(String actionName) { * Adds an event listener for the Activating event. */ public ActionButtonBuilder onActivating(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Activating, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Activating, callback); } /** * Adds an event listener for the Activating event with context. */ public ActionButtonBuilder onActivating(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.Activating, Void.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.Activating, callback); } /** * Adds an event listener for the DoubleClicking event. */ public ActionButtonBuilder onDoubleClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event. */ public ActionButtonBuilder onDoubleClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event with context. */ public ActionButtonBuilder onDoubleClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public ActionButtonBuilder onRightClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public ActionButtonBuilder onRightClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event with context. */ public ActionButtonBuilder onRightClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the MouseEntered event. */ public ActionButtonBuilder onMouseEntered(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event. */ public ActionButtonBuilder onMouseEntered(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event with context. */ public ActionButtonBuilder onMouseEntered(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseExited event. */ public ActionButtonBuilder onMouseExited(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event. */ public ActionButtonBuilder onMouseExited(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event with context. */ public ActionButtonBuilder onMouseExited(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } @Override @@ -283,27 +284,27 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding Activating event binding: " + eventId + " for " + selector); events.addEventBinding(CustomUIEventBindingType.Activating, selector, EventData.of("Action", UIEventActions.BUTTON_CLICKED) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.DoubleClicking) { HyUIPlugin.getLog().logFinest("Adding DoubleClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.DoubleClicking, selector, EventData.of("Action", UIEventActions.DOUBLE_CLICKING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.RightClicking) { HyUIPlugin.getLog().logFinest("Adding RightClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.RightClicking, selector, EventData.of("Action", UIEventActions.RIGHT_CLICKING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.MouseEntered) { HyUIPlugin.getLog().logFinest("Adding MouseEntered event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseEntered, selector, EventData.of("Action", UIEventActions.MOUSE_ENTERED) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.MouseExited) { HyUIPlugin.getLog().logFinest("Adding MouseExited event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseExited, selector, EventData.of("Action", UIEventActions.MOUSE_EXITED) - .append("Target", eventId), false); + .append("Target", eventId), false); } }); } diff --git a/src/main/java/au/ellie/hyui/builders/ButtonBuilder.java b/src/main/java/au/ellie/hyui/builders/ButtonBuilder.java index 0b47279..378d285 100644 --- a/src/main/java/au/ellie/hyui/builders/ButtonBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/ButtonBuilder.java @@ -21,16 +21,15 @@ import au.ellie.hyui.HyUIPlugin; import au.ellie.hyui.elements.BackgroundSupported; import au.ellie.hyui.elements.LayoutModeSupported; -import au.ellie.hyui.events.MouseEventData; -import au.ellie.hyui.events.UIEventActions; import au.ellie.hyui.elements.UIElements; +import au.ellie.hyui.events.MouseEventData; import au.ellie.hyui.events.UIContext; +import au.ellie.hyui.events.UIEventActions; import au.ellie.hyui.theme.Theme; import au.ellie.hyui.types.ButtonStyle; import au.ellie.hyui.types.ButtonStyleState; import au.ellie.hyui.types.TextButtonStyle; import au.ellie.hyui.types.TextButtonStyleState; -import au.ellie.hyui.utils.PropertyBatcher; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.builder.EventData; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; @@ -43,10 +42,10 @@ import java.util.function.Consumer; /** - * Builder for creating button UI elements. + * Builder for creating button UI elements. * Buttons are interactive elements that can trigger actions when clicked. */ -public class ButtonBuilder extends UIElementBuilder implements +public class ButtonBuilder extends UIElementBuilder implements LayoutModeSupported, BackgroundSupported { private String text; @@ -62,7 +61,7 @@ public ButtonBuilder() { super(UIElements.BUTTON, UIElements.BUTTON); withWrappingGroup(true); } - + /** * You do not need to call this. */ @@ -78,7 +77,7 @@ public ButtonBuilder(Theme theme) { /** * You do not need to call this. - * + * * @param theme * @param elementPath */ @@ -124,7 +123,7 @@ public static ButtonBuilder textButton() { } /** - * Creates a ButtonBuilder instance for a secondary text button styled with the GAME_THEME and + * Creates a ButtonBuilder instance for a secondary text button styled with the GAME_THEME and * the SECONDARY_TEXT_BUTTON element. * * @return a ButtonBuilder configured for creating a secondary text button with predefined theme and style. @@ -132,9 +131,9 @@ public static ButtonBuilder textButton() { public static ButtonBuilder secondaryTextButton() { return new ButtonBuilder(Theme.GAME_THEME, UIElements.SECONDARY_TEXT_BUTTON); } - + /** - * Creates a ButtonBuilder instance for a small secondary text button styled with the GAME_THEME and + * Creates a ButtonBuilder instance for a small secondary text button styled with the GAME_THEME and * the SMALL_SECONDARY_TEXT_BUTTON element. * * @return a ButtonBuilder configured for creating a small secondary text button with predefined theme and style. @@ -142,9 +141,9 @@ public static ButtonBuilder secondaryTextButton() { public static ButtonBuilder smallSecondaryTextButton() { return new ButtonBuilder(Theme.GAME_THEME, UIElements.SMALL_SECONDARY_TEXT_BUTTON); } - + /** - * Creates a ButtonBuilder instance for a tertiary text button styled with the GAME_THEME and + * Creates a ButtonBuilder instance for a tertiary text button styled with the GAME_THEME and * the TERTIARY_TEXT_BUTTON element. * * @return a ButtonBuilder configured for creating a tertiary text button with predefined theme and style. @@ -154,7 +153,7 @@ public static ButtonBuilder tertiaryTextButton() { } /** - * Creates a ButtonBuilder instance for a small tertiary text button styled with the GAME_THEME and + * Creates a ButtonBuilder instance for a small tertiary text button styled with the GAME_THEME and * the SMALL_TERTIARY_TEXT_BUTTON element. * * @return a ButtonBuilder configured for creating a small tertiary text button with predefined theme and style. @@ -162,9 +161,9 @@ public static ButtonBuilder tertiaryTextButton() { public static ButtonBuilder smallTertiaryTextButton() { return new ButtonBuilder(Theme.GAME_THEME, UIElements.SMALL_TERTIARY_TEXT_BUTTON); } - + /** - * Creates a ButtonBuilder instance for a cancel text button styled with the GAME_THEME and + * Creates a ButtonBuilder instance for a cancel text button styled with the GAME_THEME and * the CANCEL_TEXT_BUTTON element. * * @return a ButtonBuilder configured for creating a cancel text button with predefined theme and style. @@ -180,7 +179,7 @@ public static ButtonBuilder backButton() { public static ButtonBuilder rawButton() { return new ButtonBuilder(Theme.GAME_THEME, UIElements.BUTTON); } - + /** * Sets the text for the button being built. Replaces any other text. * @@ -250,110 +249,109 @@ public HyUIPatchStyle getBackground() { } /** - * Adds an event listener to this button. This allows the button to respond to specific UI events - * that are triggered during interaction. + * Adds an event listener to this button. * - * @param type the type of UI event to listen for, specified as a {@link CustomUIEventBindingType} - * @param callback a callback function to handle the event, expressed as a {@link Consumer} + * @param type the type of event to listen for + * @param callback the callback to execute when the event is triggered * @return the current instance of {@code ButtonBuilder} for method chaining */ public ButtonBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, Void.class, callback); + return addEventListenerWithContext(type, (_, _, _) -> callback.accept(null)); } /** * Adds an event listener to this button with access to the UI context. * - * @param type the type of UI event to listen for, specified as a {@link CustomUIEventBindingType} - * @param callback a callback function to handle the event, expressed as a {@link BiConsumer} + * @param type the type of event to listen for + * @param callback the callback to execute when the event is triggered, which accepts the UI context as a parameter * @return the current instance of {@code ButtonBuilder} for method chaining */ public ButtonBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, Void.class, callback); + return addEventListenerWithContext(type, (_, context, _) -> callback.accept(null, context)); } /** * Adds an event listener for the DoubleClicking event. */ public ButtonBuilder onDoubleClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event. */ public ButtonBuilder onDoubleClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event with context. */ public ButtonBuilder onDoubleClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public ButtonBuilder onRightClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public ButtonBuilder onRightClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event with context. */ public ButtonBuilder onRightClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the MouseEntered event. */ public ButtonBuilder onMouseEntered(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event. */ public ButtonBuilder onMouseEntered(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event with context. */ public ButtonBuilder onMouseEntered(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseExited event. */ public ButtonBuilder onMouseExited(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event. */ public ButtonBuilder onMouseExited(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event with context. */ public ButtonBuilder onMouseExited(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } @Override @@ -424,10 +422,10 @@ protected Set getSupportedStyleProperties() { protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { String selector = getSelector(); if (selector == null) return; - + // Make sure we apply the layout mode to the wrapping group, not the button itself. applyLayoutMode(commands, "#" + getEffectiveId()); - + if (text != null && isTextButtonElement()) { HyUIPlugin.getLog().logFinest("Setting Text: " + text + " for " + selector); commands.set(selector + ".Text", text); @@ -443,7 +441,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { commands.set(selector + ".Overscroll", overscroll); } - if ( hyUIStyle == null && typedStyle == null && style != null && !isBackButton()) { + if (hyUIStyle == null && typedStyle == null && style != null && !isBackButton()) { HyUIPlugin.getLog().logFinest("Setting Style: " + style + " for " + selector); commands.set(selector + ".Style", style); } @@ -452,41 +450,41 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { if (listener.type() == CustomUIEventBindingType.Activating) { String eventId = getEffectiveId(); HyUIPlugin.getLog().logFinest("Adding Activating event binding: " + eventId + " for " + selector); - events.addEventBinding(CustomUIEventBindingType.Activating, selector, + events.addEventBinding(CustomUIEventBindingType.Activating, selector, EventData.of("Action", UIEventActions.BUTTON_CLICKED) - .append("Target", eventId), + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.DoubleClicking && !isBackButton()) { String eventId = getEffectiveId(); HyUIPlugin.getLog().logFinest("Adding DoubleClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.DoubleClicking, selector, EventData.of("Action", UIEventActions.DOUBLE_CLICKING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.RightClicking && !isBackButton()) { String eventId = getEffectiveId(); HyUIPlugin.getLog().logFinest("Adding RightClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.RightClicking, selector, EventData.of("Action", UIEventActions.RIGHT_CLICKING) - .append("Target", eventId), false); - } else if (listener.type() == CustomUIEventBindingType.MouseEntered && !isBackButton()) { + .append("Target", eventId), false); + } else if (listener.type() == CustomUIEventBindingType.MouseEntered && !isBackButton()) { String eventId = getEffectiveId(); HyUIPlugin.getLog().logFinest("Adding MouseEntered event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseEntered, selector, EventData.of("Action", UIEventActions.MOUSE_ENTERED) - .append("Target", eventId), false); - } else if (listener.type() == CustomUIEventBindingType.MouseExited && !isBackButton()) { + .append("Target", eventId), false); + } else if (listener.type() == CustomUIEventBindingType.MouseExited && !isBackButton()) { String eventId = getEffectiveId(); HyUIPlugin.getLog().logFinest("Adding MouseExited event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseExited, selector, EventData.of("Action", UIEventActions.MOUSE_EXITED) - .append("Target", eventId), false); + .append("Target", eventId), false); } }); } private boolean isTextButtonElement() { var typeSelector = getButtonTypeSelector(elementPath); - + return typeSelector.contains("TextButton"); } diff --git a/src/main/java/au/ellie/hyui/builders/CheckBoxBuilder.java b/src/main/java/au/ellie/hyui/builders/CheckBoxBuilder.java index 0114da3..bebab2b 100644 --- a/src/main/java/au/ellie/hyui/builders/CheckBoxBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/CheckBoxBuilder.java @@ -19,10 +19,9 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; -import au.ellie.hyui.elements.UIElements; -import au.ellie.hyui.utils.PropertyBatcher; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.builder.EventData; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; @@ -41,7 +40,7 @@ public class CheckBoxBuilder extends UIElementBuilder { /** * Constructs a new instance of {@code CheckBoxBuilder}. - * The builder is pre-configured to create a checkbox UI element with a label, + * The builder is pre-configured to create a checkbox UI element with a label, * wrapped within a grouping element. */ public CheckBoxBuilder() { @@ -91,7 +90,7 @@ public CheckBoxBuilder withUncheckedStyle(HyUIStyle uncheckedStyle) { }*/ /** - * Registers an event listener for the checkbox being constructed. The listener is triggered + * Registers an event listener for the checkbox being constructed. The listener is triggered * based on the specified event type and executes the provided callback when the event occurs. * * @param type the type of UI event to bind the listener to, such as value changes or activation @@ -99,7 +98,7 @@ public CheckBoxBuilder withUncheckedStyle(HyUIStyle uncheckedStyle) { * @return the {@code CheckBoxBuilder} instance for method chaining, allowing further configuration */ public CheckBoxBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, Boolean.class, callback); + return addEventListenerWithContext(type, callback); } /** @@ -110,7 +109,7 @@ public CheckBoxBuilder addEventListener(CustomUIEventBindingType type, Consumer< * @return This CheckBoxBuilder instance for method chaining. */ public CheckBoxBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, Boolean.class, callback); + return addEventListenerWithContext(type, callback); } @Override @@ -168,27 +167,28 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { commands.set(selector + " #CheckBox.Value", value); } - if ( hyUIStyle == null && typedStyle == null && style != null) { + if (hyUIStyle == null && typedStyle == null && style != null) { HyUIPlugin.getLog().logFinest("Setting Style: " + style + " for " + selector); commands.set(selector + ".Style", style); - } + } // WE CANNOT set the underlying style of the child checkbox element. /*else if (hyUIStyle == null && typedStyle != null) { PropertyBatcher.endSet(selector + ".Style", filterStyleDocument(typedStyle.toBsonDocument()), commands); }*/ - + if (listeners.isEmpty()) { // To handle data back to the .getValue, we need to add at least one listener. - addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> {}); + addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> { + }); } listeners.forEach(listener -> { if (listener.type() == CustomUIEventBindingType.ValueChanged) { String eventId = getEffectiveId(); HyUIPlugin.getLog().logFinest("Adding ValueChanged event binding for " + selector + " #CheckBox with eventId: " + eventId); - events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector + " #CheckBox", + events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector + " #CheckBox", EventData.of("@ValueBool", selector + " #CheckBox.Value") - .append("Target", eventId) - .append("Action", UIEventActions.VALUE_CHANGED), + .append("Target", eventId) + .append("Action", UIEventActions.VALUE_CHANGED), false); } }); diff --git a/src/main/java/au/ellie/hyui/builders/ColorPickerBuilder.java b/src/main/java/au/ellie/hyui/builders/ColorPickerBuilder.java index 2f0ecd7..8d88c26 100644 --- a/src/main/java/au/ellie/hyui/builders/ColorPickerBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/ColorPickerBuilder.java @@ -19,10 +19,10 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.types.ColorFormat; +import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; -import au.ellie.hyui.elements.UIElements; +import au.ellie.hyui.types.ColorFormat; import au.ellie.hyui.utils.PropertyBatcher; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.builder.EventData; @@ -45,7 +45,7 @@ public class ColorPickerBuilder extends UIElementBuilder { /** * Constructs a new instance of {@code ColorPickerBuilder}, initializing it with * default configuration for creating a ColorPicker UI element. - * + *

* The builder is pre-configured with: * - The element type defined by {@code UIElements.COLOR_PICKER}. * - A wrapping group setting enabled. @@ -64,7 +64,7 @@ public ColorPickerBuilder() { * For example, "#FFFFFF" for white or "#000000" for black. You * may add the alpha channel to the end like "#FFFFFF(0.5)". * @return the {@code ColorPickerBuilder} instance with the updated value, - * allowing for method chaining. + * allowing for method chaining. */ public ColorPickerBuilder withValue(String hexColor) { this.value = hexColor; @@ -109,15 +109,15 @@ public ColorPickerBuilder withResetTransparencyWhenChangingColor(boolean resetTr * Adds an event listener to the ColorPicker UI element for handling specific types of events. * The specified callback will be invoked when the event of the provided type occurs. * - * @param type the type of event to listen for, represented by {@code CustomUIEventBindingType}. - * This determines which event the callback will respond to. + * @param type the type of event to listen for, represented by {@code CustomUIEventBindingType}. + * This determines which event the callback will respond to. * @param callback a {@code Consumer} function to handle the event. * It will be triggered with the event data when the event occurs. * @return the {@code ColorPickerBuilder} instance with the event listener added, - * allowing for method chaining. + * allowing for method chaining. */ public ColorPickerBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, String.class, callback); + return addEventListenerWithContext(type, callback); } /** @@ -128,7 +128,7 @@ public ColorPickerBuilder addEventListener(CustomUIEventBindingType type, Consum * @return This ColorPickerBuilder instance for method chaining. */ public ColorPickerBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, String.class, callback); + return addEventListenerWithContext(type, callback); } @Override @@ -196,7 +196,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { commands.set(selector + ".ResetTransparencyWhenChangingColor", resetTransparencyWhenChangingColor); } - if ( hyUIStyle == null && typedStyle == null && style != null) { + if (hyUIStyle == null && typedStyle == null && style != null) { HyUIPlugin.getLog().logFinest("Setting Style: " + style + " for " + selector); commands.set(selector + ".Style", style); } else if (hyUIStyle == null && typedStyle != null) { @@ -204,16 +204,17 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { } if (listeners.isEmpty()) { // To handle data back to the .getValue, we need to add at least one listener. - addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> {}); + addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> { + }); } listeners.forEach(listener -> { if (listener.type() == CustomUIEventBindingType.ValueChanged) { String eventId = getEffectiveId(); HyUIPlugin.getLog().logFinest("Adding ValueChanged event binding for " + selector + " with eventId: " + eventId); - events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, + events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, EventData.of("@Value", selector + ".Value") - .append("Target", eventId) - .append("Action", UIEventActions.VALUE_CHANGED), + .append("Target", eventId) + .append("Action", UIEventActions.VALUE_CHANGED), false); } }); diff --git a/src/main/java/au/ellie/hyui/builders/ColorPickerDropdownBoxBuilder.java b/src/main/java/au/ellie/hyui/builders/ColorPickerDropdownBoxBuilder.java index 55909ea..9494add 100644 --- a/src/main/java/au/ellie/hyui/builders/ColorPickerDropdownBoxBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/ColorPickerDropdownBoxBuilder.java @@ -19,11 +19,11 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.MouseEventData; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; import au.ellie.hyui.types.ColorFormat; -import au.ellie.hyui.elements.UIElements; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.builder.EventData; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; @@ -71,21 +71,21 @@ public ColorPickerDropdownBoxBuilder withResetTransparencyWhenChangingColor(bool * Adds an event listener for the RightClicking event. */ public ColorPickerDropdownBoxBuilder onRightClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public ColorPickerDropdownBoxBuilder onRightClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event with context. */ public ColorPickerDropdownBoxBuilder onRightClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } @Override @@ -149,7 +149,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding RightClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.RightClicking, selector, EventData.of("Action", UIEventActions.RIGHT_CLICKING) - .append("Target", eventId), + .append("Target", eventId), false); } }); diff --git a/src/main/java/au/ellie/hyui/builders/CustomButtonBuilder.java b/src/main/java/au/ellie/hyui/builders/CustomButtonBuilder.java index 8c9ad41..331c780 100644 --- a/src/main/java/au/ellie/hyui/builders/CustomButtonBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/CustomButtonBuilder.java @@ -147,95 +147,95 @@ public CustomButtonBuilder withDisabledLabelStyle(HyUIStyle style) { } public CustomButtonBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, Void.class, callback); + return addEventListenerWithContext(type, callback); } public CustomButtonBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, Void.class, callback); + return addEventListenerWithContext(type, callback); } /** * Adds an event listener for the DoubleClicking event. */ public CustomButtonBuilder onDoubleClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event. */ public CustomButtonBuilder onDoubleClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event with context. */ public CustomButtonBuilder onDoubleClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public CustomButtonBuilder onRightClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public CustomButtonBuilder onRightClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event with context. */ public CustomButtonBuilder onRightClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the MouseEntered event. */ public CustomButtonBuilder onMouseEntered(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event. */ public CustomButtonBuilder onMouseEntered(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event with context. */ public CustomButtonBuilder onMouseEntered(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseExited event. */ public CustomButtonBuilder onMouseExited(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event. */ public CustomButtonBuilder onMouseExited(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event with context. */ public CustomButtonBuilder onMouseExited(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } @Override diff --git a/src/main/java/au/ellie/hyui/builders/DropdownBoxBuilder.java b/src/main/java/au/ellie/hyui/builders/DropdownBoxBuilder.java index 919f66e..b6823a8 100644 --- a/src/main/java/au/ellie/hyui/builders/DropdownBoxBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/DropdownBoxBuilder.java @@ -73,10 +73,10 @@ public static DropdownBoxBuilder dropdownBox() { /** * Sets the initial selected value for the dropdown box. - * + *

* WARNING: The value must correspond to the "name" of one of the entries added via {@link #addEntry} or {@link #withEntries}. * If the value does not exist in the entries, the dropdown may exhibit unexpected behavior or fail to show the selection. - * + * * @param value The name of the entry to select. * @return This builder instance for method chaining. */ @@ -174,11 +174,11 @@ public DropdownBoxBuilder addEntry(String name, String label) { } public DropdownBoxBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, String.class, callback); + return addEventListenerWithContext(type, callback); } public DropdownBoxBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, String.class, callback); + return addEventListenerWithContext(type, callback); } @Override @@ -317,7 +317,8 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { } if (listeners.isEmpty()) { // To handle data back to the .getValue, we need to add at least one listener. - addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> {}); + addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> { + }); } listeners.forEach(listener -> { if (listener.type() == CustomUIEventBindingType.ValueChanged) { diff --git a/src/main/java/au/ellie/hyui/builders/DynamicPaneBuilder.java b/src/main/java/au/ellie/hyui/builders/DynamicPaneBuilder.java index 65f56fe..95d8e80 100644 --- a/src/main/java/au/ellie/hyui/builders/DynamicPaneBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/DynamicPaneBuilder.java @@ -19,7 +19,6 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.MouseEventData; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; @@ -82,49 +81,49 @@ public DynamicPaneBuilder withResizerBackground(HyUIPatchStyle resizerBackground * Adds an event listener for the MouseButtonReleased event. */ public DynamicPaneBuilder onMouseButtonReleased(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseButtonReleased, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseButtonReleased, callback); } /** * Adds an event listener for the MouseButtonReleased event. */ public DynamicPaneBuilder onMouseButtonReleased(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseButtonReleased, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseButtonReleased, callback); } /** * Adds an event listener for the MouseButtonReleased event with context. */ public DynamicPaneBuilder onMouseButtonReleased(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseButtonReleased, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseButtonReleased, callback); } /** * Adds an event listener for the Validating event. */ public DynamicPaneBuilder onValidating(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Validating, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Validating, callback); } /** * Adds an event listener for the Dismissing event. */ public DynamicPaneBuilder onDismissing(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Dismissing, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Dismissing, callback); } /** * Adds an event listener for the Scrolled event. */ public DynamicPaneBuilder onScrolled(Consumer callback) { - return addEventListener(CustomUIEventBindingType.ValueChanged, Float.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } /** * Adds an event listener for the Scrolled event with context. */ public DynamicPaneBuilder onScrolled(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, Float.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } @Override @@ -185,23 +184,23 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding MouseButtonReleased event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseButtonReleased, selector, EventData.of("Action", UIEventActions.MOUSE_BUTTON_RELEASED) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.Validating) { HyUIPlugin.getLog().logFinest("Adding Validating event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.Validating, selector, EventData.of("Action", UIEventActions.VALIDATING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.Dismissing) { HyUIPlugin.getLog().logFinest("Adding Dismissing event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.Dismissing, selector, EventData.of("Action", UIEventActions.DISMISSING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.ValueChanged) { // Scrolled event uses ValueChanged type HyUIPlugin.getLog().logFinest("Adding Scrolled event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, EventData.of("Action", UIEventActions.SCROLLED) - .append("Target", eventId), false); + .append("Target", eventId), false); } }); } diff --git a/src/main/java/au/ellie/hyui/builders/DynamicPaneContainerBuilder.java b/src/main/java/au/ellie/hyui/builders/DynamicPaneContainerBuilder.java index db6b813..5046f00 100644 --- a/src/main/java/au/ellie/hyui/builders/DynamicPaneContainerBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/DynamicPaneContainerBuilder.java @@ -19,7 +19,6 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; import au.ellie.hyui.types.LayoutMode; @@ -66,28 +65,28 @@ public DynamicPaneContainerBuilder withLayoutMode(LayoutMode layoutMode) { * Adds an event listener for the Validating event. */ public DynamicPaneContainerBuilder onValidating(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Validating, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Validating, callback); } /** * Adds an event listener for the Dismissing event. */ public DynamicPaneContainerBuilder onDismissing(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Dismissing, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Dismissing, callback); } /** * Adds an event listener for the Scrolled event. */ public DynamicPaneContainerBuilder onScrolled(Consumer callback) { - return addEventListener(CustomUIEventBindingType.ValueChanged, Float.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } /** * Adds an event listener for the Scrolled event with context. */ public DynamicPaneContainerBuilder onScrolled(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, Float.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } @Override @@ -122,18 +121,18 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding Validating event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.Validating, selector, EventData.of("Action", UIEventActions.VALIDATING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.Dismissing) { HyUIPlugin.getLog().logFinest("Adding Dismissing event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.Dismissing, selector, EventData.of("Action", UIEventActions.DISMISSING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.ValueChanged) { // Scrolled event uses ValueChanged type HyUIPlugin.getLog().logFinest("Adding Scrolled event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, EventData.of("Action", UIEventActions.SCROLLED) - .append("Target", eventId), false); + .append("Target", eventId), false); } }); } diff --git a/src/main/java/au/ellie/hyui/builders/EventListenerBuilder.java b/src/main/java/au/ellie/hyui/builders/EventListenerBuilder.java new file mode 100644 index 0000000..949c995 --- /dev/null +++ b/src/main/java/au/ellie/hyui/builders/EventListenerBuilder.java @@ -0,0 +1,38 @@ +package au.ellie.hyui.builders; + +import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.html.HtmlParser; +import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +public class EventListenerBuilder { + private final List> eventListeners; + private final UIElementBuilder builder; + private final HtmlParser parser; + + public EventListenerBuilder(UIElementBuilder builder, HtmlParser parser) { + this.eventListeners = new ArrayList<>(); + this.builder = builder; + this.parser = parser; + } + + public void add(CustomUIEventBindingType type, String callback) { + eventListeners.add(Map.entry(builder.getEventTypeMapped(type), callback)); + } + + public void build() { + for (var entry : eventListeners) { + builder.addEventListenerWithContext(entry.getKey(), (data, context) -> { + var eventCallback = parser.getEventByName(entry.getValue()); + if (eventCallback != null) + eventCallback.accept(data, context, entry.getKey()); + else + HyUIPlugin.getLog().logWarn("No event found with name: " + entry.getValue() + " for event type: " + entry.getKey()); + }); + } + } +} diff --git a/src/main/java/au/ellie/hyui/builders/FloatSliderBuilder.java b/src/main/java/au/ellie/hyui/builders/FloatSliderBuilder.java index 2007354..951f658 100644 --- a/src/main/java/au/ellie/hyui/builders/FloatSliderBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/FloatSliderBuilder.java @@ -19,10 +19,10 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.MouseEventData; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; -import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.utils.PropertyBatcher; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.Value; @@ -75,41 +75,39 @@ public FloatSliderBuilder withValue(float value) { } public FloatSliderBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, Float.class, callback); + return addEventListenerWithContext(type, callback); } public FloatSliderBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, Float.class, callback); + return addEventListenerWithContext(type, callback); } /** * Adds an event listener for the MouseButtonReleased event. */ public FloatSliderBuilder onMouseButtonReleased(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseButtonReleased, MouseEventData.class, - callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseButtonReleased, callback); } - + /** * Adds an event listener for the MouseButtonReleased event. */ public FloatSliderBuilder onMouseButtonReleased(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseButtonReleased, MouseEventData.class, - callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseButtonReleased, callback); } - + /** * Adds an event listener for the ValueChanged event. */ public FloatSliderBuilder onValueChanged(Consumer callback) { - return addEventListener(CustomUIEventBindingType.ValueChanged, Float.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } /** * Adds an event listener for the ValueChanged event with context. */ public FloatSliderBuilder onValueChanged(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, Float.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } @Override @@ -180,18 +178,19 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { commands.set(selector + ".Value", value); } - if ( hyUIStyle == null && typedStyle == null && style != null) { + if (hyUIStyle == null && typedStyle == null && style != null) { HyUIPlugin.getLog().logFinest("Setting Style for FloatSlider " + selector); commands.set(selector + ".Style", style); } else if (hyUIStyle == null && typedStyle != null) { PropertyBatcher.endSet(selector + ".Style", filterStyleDocument(typedStyle.toBsonDocument()), commands); - } else if ( hyUIStyle == null && typedStyle == null ) { + } else if (hyUIStyle == null && typedStyle == null) { HyUIPlugin.getLog().logFinest("Setting Style for FloatSlider to DefaultSliderStyle " + selector); commands.set(selector + ".Style", Value.ref("Common.ui", "DefaultSliderStyle")); } if (listeners.isEmpty()) { // To handle data back to the .getValue, we need to add at least one listener. - addEventListener(CustomUIEventBindingType.ValueChanged, (Float v, UIContext ctx) -> {}); + addEventListener(CustomUIEventBindingType.ValueChanged, (Float v, UIContext ctx) -> { + }); } // Register event listeners @@ -201,7 +200,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding MouseButtonReleased event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseButtonReleased, selector, EventData.of("Action", UIEventActions.MOUSE_BUTTON_RELEASED) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.ValueChanged) { HyUIPlugin.getLog().logFinest("Adding ValueChanged event binding for " + selector + " with eventId: " + eventId); events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, diff --git a/src/main/java/au/ellie/hyui/builders/FloatSliderNumberFieldBuilder.java b/src/main/java/au/ellie/hyui/builders/FloatSliderNumberFieldBuilder.java index 7d9b167..7e88047 100644 --- a/src/main/java/au/ellie/hyui/builders/FloatSliderNumberFieldBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/FloatSliderNumberFieldBuilder.java @@ -19,9 +19,9 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; -import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.types.InputFieldStyle; import au.ellie.hyui.types.SliderStyle; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; @@ -94,11 +94,11 @@ public FloatSliderNumberFieldBuilder withValue(float value) { } public FloatSliderNumberFieldBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, Float.class, callback); + return addEventListenerWithContext(type, callback); } public FloatSliderNumberFieldBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, Float.class, callback); + return addEventListenerWithContext(type, callback); } @Override @@ -177,7 +177,8 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { } if (listeners.isEmpty()) { - addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> {}); + addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> { + }); } listeners.forEach(listener -> { if (listener.type() == CustomUIEventBindingType.ValueChanged) { diff --git a/src/main/java/au/ellie/hyui/builders/GroupBuilder.java b/src/main/java/au/ellie/hyui/builders/GroupBuilder.java index 7c328c9..75d6d76 100644 --- a/src/main/java/au/ellie/hyui/builders/GroupBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/GroupBuilder.java @@ -23,7 +23,6 @@ import au.ellie.hyui.elements.LayoutModeSupported; import au.ellie.hyui.elements.ScrollbarStyleSupported; import au.ellie.hyui.elements.UIElements; -import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; import au.ellie.hyui.theme.Theme; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; @@ -32,18 +31,16 @@ import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; import java.util.Set; -import java.util.function.BiConsumer; -import java.util.function.Consumer; /** - * Builder for creating group UI elements. + * Builder for creating group UI elements. * Groups can be used to organize and layout other UI elements. - * + *

* This directly translates to a {@code Group {}} */ -public class GroupBuilder extends UIElementBuilder implements - LayoutModeSupported, - BackgroundSupported, +public class GroupBuilder extends UIElementBuilder implements + LayoutModeSupported, + BackgroundSupported, ScrollbarStyleSupported { private String layoutMode; private String scrollbarStyleReference; @@ -65,10 +62,10 @@ public GroupBuilder(Theme theme) { public static GroupBuilder group() { return new GroupBuilder(); } - + /** * Sets the layout mode for the group. - * + * * @param layoutMode The layout mode to set. * @return This builder instance for method chaining. */ @@ -77,12 +74,12 @@ public GroupBuilder withLayoutMode(String layoutMode) { this.layoutMode = layoutMode; return this; } - + @Override public String getLayoutMode() { return this.layoutMode; } - + @Override public GroupBuilder withScrollbarStyle(String document, String styleReference) { this.scrollbarStyleDocument = document; @@ -104,14 +101,14 @@ public String getScrollbarStyleDocument() { * Adds an event listener for the Validating event. */ public GroupBuilder onValidating(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Validating, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Validating, callback); } /** * Adds an event listener for the Dismissing event. */ public GroupBuilder onDismissing(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Dismissing, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Dismissing, callback); } @Override @@ -144,13 +141,13 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding Validating event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.Validating, selector, EventData.of("Action", UIEventActions.VALIDATING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.Dismissing) { HyUIPlugin.getLog().logFinest("Adding Dismissing event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.Dismissing, selector, EventData.of("Action", UIEventActions.DISMISSING) - .append("Target", eventId), false); - } + .append("Target", eventId), false); + } }); } } diff --git a/src/main/java/au/ellie/hyui/builders/HyUIHud.java b/src/main/java/au/ellie/hyui/builders/HyUIHud.java index 3761f33..807883a 100644 --- a/src/main/java/au/ellie/hyui/builders/HyUIHud.java +++ b/src/main/java/au/ellie/hyui/builders/HyUIHud.java @@ -42,7 +42,7 @@ import java.util.function.Consumer; /** - * A HUD for Hytale. + * A HUD for Hytale. * It is important to store references to your existing HUDs to assist with updating elements. */ public class HyUIHud extends CustomUIHud implements UIContext { @@ -55,8 +55,8 @@ public class HyUIHud extends CustomUIHud implements UIContext { private static final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); private ScheduledFuture refreshTask; - - public HyUIHud(String name, PlayerRef playerRef, + + public HyUIHud(String name, PlayerRef playerRef, String uiFile, List> elements, List> editCallbacks, @@ -66,29 +66,30 @@ public HyUIHud(String name, PlayerRef playerRef, InterfaceBuilder rootElementBuilder) { super(playerRef); this.name = name; - this.delegate = new HyUInterface(uiFile, elements, editCallbacks, templateHtml, templateProcessor, runtimeTemplateUpdatesEnabled, rootElementBuilder) {}; + this.delegate = new HyUInterface(uiFile, elements, editCallbacks, templateHtml, templateProcessor, runtimeTemplateUpdatesEnabled, rootElementBuilder, null) { + }; } private void startRefreshTask() { if (refreshTask == null || refreshTask.isCancelled()) { refreshTask = scheduler.scheduleAtFixedRate( - this::checkRefreshes, - 100, - 100, + this::checkRefreshes, + 100, + 100, TimeUnit.MILLISECONDS); } } - + private void checkRefreshes() { if (isHidden) { HyUIPlugin.getLog().logFinest("Hidden HUD. Not refreshing."); return; } - + PlayerRef playerRef = getPlayerRef(); if (!playerRef.isValid()) { HyUIPlugin.getLog().logFinest("Player is invalid, cancelling refresh task for HUD."); - + // Player is no longer valid, cancel task and cleanup. if (refreshTask != null) { HyUIPlugin.getLog().logFinest("Player is invalid, cancelling refresh task for HUD."); @@ -104,21 +105,16 @@ private void checkRefreshes() { long now = System.currentTimeMillis(); long rate = getRefreshRateMs(); - if (rate > 0) { - if (now - lastRefreshTime >= rate) { - if (refreshTask.isCancelled()) { - return; - } - triggerRefresh(); - refreshOrRerender(true, false); - lastRefreshTime = now; - if (refreshTask.isCancelled()) { - return; - } - } + if (rate > 0 && now - lastRefreshTime >= rate) { + if (refreshTask.isCancelled()) + return; + + triggerRefresh(); + refreshOrRerender(true, false); + lastRefreshTime = now; } } - + @Override public void build(UICommandBuilder uiCommandBuilder) { // We cannot use the UIEventBuilder from the super since this is a HUD. @@ -155,19 +151,19 @@ public Optional> getByIdRaw(String id) { /** * Updates the HUD with the provided builder. * The builder can be a completely new configuration. - * + * * @param updatedHudBuilder The builder containing updated HUD configuration. */ public void update(HudBuilder updatedHudBuilder) { - this.configureFrom(updatedHudBuilder); + configureFrom(updatedHudBuilder); refreshOrRerender(true, false); } /** - * Remove the HUD from its parent multi-HUD. + * Remove the HUD from its parent multi-HUD. * This will remove it from the screen for the player. * and stop refreshing it. - * + *

* You can later associate it with another, or the same multi-HUD and show it. */ public void remove() { @@ -188,7 +184,7 @@ public void remove() { * Remove the HUD from its parent multi-HUD. This does NOT check thread access. * This will remove it from the screen for the player. * and stop refreshing it. - * + *

* You can later associate it with another, or the same multi-HUD and show it. */ public void removeUnsafe() { @@ -203,7 +199,7 @@ public void removeUnsafe() { /** * Adds the HUD to its parent multi-HUD. * Begins refresh task. - * + * */ public void add() { this.safeAdd(); @@ -213,7 +209,7 @@ public void add() { HyUIPlugin.getLog().logFinest("HUD added: " + this.name); startRefreshTask(); } - + /** * Adds the HUD to its parent multi-HUD. This does NOT check thread access. * Begins refresh task. @@ -244,7 +240,7 @@ public void hide() { public void hideUnsafe() { setVisibilityOnFirstElement(false, true); } - + /** * Shows the UI to the player if it has previously been hidden. */ @@ -299,7 +295,7 @@ public void refreshOrRerender(boolean shouldRerender, boolean unsafe) { } } - + @Override public List getCommandLog() { return delegate.getCommandLog(); @@ -317,17 +313,23 @@ public Optional getPage() { /** * Not implemented. + * * @param shouldClear Not implemented. */ @Override - public void updatePage(boolean shouldClear) {} - + public void updatePage(boolean shouldClear) { + } + + @Override + public void updatePageThreadsafe(Player playerComponent, boolean shouldClear) { + } + private void setVisibilityOnFirstElement(boolean value, boolean unsafe) { for (UIElementBuilder element : delegate.getElements()) { element.withVisible(value); break; } - + HyUIPlugin.getLog().logFinest("REDRAW: HUD SET VISIBILITY from single hud"); this.refreshOrRerender(false, unsafe); // this.update(false, builder); @@ -384,7 +386,7 @@ private Player getPlayer() { } /** - * Reloads a dynamic image by its element ID. This will forcibly invalidate the image + * Reloads a dynamic image by its element ID. This will forcibly invalidate the image * and re-download (cache still applies to all downloads for 15 seconds!). * * @param dynamicImageElementId The ID of the dynamic image element. @@ -405,25 +407,25 @@ public void reloadImage(String dynamicImageElementId) { public void reopenFromAsset(Player player, PlayerRef playerRef, Store store, Asset asset) { if (delegate.willReopenFromAsset(player, playerRef, store, asset)) { // Remove ours. - if (refreshTask != null && !refreshTask.isCancelled()) { + if (refreshTask != null && !refreshTask.isCancelled()) refreshTask.cancel(false); - } + delegate.releaseDynamicImages(playerRef.getUuid()); var newHudBuilder = (HudBuilder) delegate.reopenFromAsset(player, playerRef, store, asset); this.configureFrom(newHudBuilder); // Get dynamic images working... newHudBuilder.sendDynamicImageIfNeeded(playerRef); - + // Forcibly rebuild from scratch. - UICommandBuilder uiCommandBuilder = new UICommandBuilder(); + var uiCommandBuilder = new UICommandBuilder(); delegate.buildFromCommandBuilder(uiCommandBuilder, false, new UIEventBuilder()); this.update(false, uiCommandBuilder); - + // What the fuck Hytale if (refreshRateMs <= 0) // Why do you make Ellie do this? refreshOrRerender(true, true); - + // Finally, start refresh task again. // Dry your tears Ellie, it will all be over soon... startRefreshTask(); diff --git a/src/main/java/au/ellie/hyui/builders/HyUIPage.java b/src/main/java/au/ellie/hyui/builders/HyUIPage.java index 8d032de..fe31213 100644 --- a/src/main/java/au/ellie/hyui/builders/HyUIPage.java +++ b/src/main/java/au/ellie/hyui/builders/HyUIPage.java @@ -25,18 +25,26 @@ import au.ellie.hyui.html.TemplateProcessor; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.function.consumer.TriConsumer; import com.hypixel.hytale.protocol.Asset; import com.hypixel.hytale.protocol.packets.interface_.CustomPage; import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; +import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.entity.entities.player.pages.InteractiveCustomUIPage; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + import javax.annotation.Nonnull; -import java.util.*; -import java.util.concurrent.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.function.Function; @@ -48,7 +56,7 @@ public class HyUIPage extends InteractiveCustomUIPage implement private static final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); private ScheduledFuture refreshTask; private BiConsumer onDismissListener; - + public HyUIPage(PlayerRef playerRef, CustomPageLifetime lifetime, String uiFile, @@ -58,17 +66,21 @@ public HyUIPage(PlayerRef playerRef, TemplateProcessor templateProcessor, boolean runtimeTemplateUpdatesEnabled, BiConsumer onDismissListener, - InterfaceBuilder rootElementBuilder) { + InterfaceBuilder rootElementBuilder, + Map> eventListeners + + ) { super(playerRef, lifetime, DynamicPageData.CODEC); this.onDismissListener = onDismissListener; - this.delegate = new HyUInterface(uiFile, elements, editCallbacks, templateHtml, templateProcessor, runtimeTemplateUpdatesEnabled, rootElementBuilder) {}; + this.delegate = new HyUInterface(uiFile, elements, editCallbacks, templateHtml, templateProcessor, runtimeTemplateUpdatesEnabled, rootElementBuilder, eventListeners) { + }; } public void reopenFromAsset(Player player, PlayerRef ref, Store store, Asset asset) { //this.close(); delegate.reopenFromAsset(player, ref, store, asset); } - + private void startRefreshTask() { if (refreshTask == null || refreshTask.isCancelled()) { refreshTask = scheduler.scheduleAtFixedRate( @@ -128,7 +140,7 @@ public Optional getValue(String id) { public Optional getPage() { return Optional.of(this); } - + public void close() { stopRefreshTask(); super.close(); @@ -138,18 +150,27 @@ public void close() { onDismissListener.accept(this, true); } } - + @Override public void updatePage(boolean shouldClear) { Ref ref = this.playerRef.getReference(); - if (ref != null) { - Store store = ref.getStore(); - Player playerComponent = (Player)store.getComponent(ref, Player.getComponentType()); - UICommandBuilder commandBuilder = new UICommandBuilder(); - UIEventBuilder eventBuilder = new UIEventBuilder(); - delegate.build(ref, commandBuilder, eventBuilder, ref.getStore(), !shouldClear); - playerComponent.getPageManager().updateCustomPage(new CustomPage(this.getClass().getName(), false, shouldClear, this.lifetime, commandBuilder.getCommands(), eventBuilder.getEvents())); - } + if (ref == null) + return; + + var playerComponent = ref.getStore().getComponent(ref, Player.getComponentType()); + updatePageThreadsafe(playerComponent, shouldClear); + } + + @Override + public void updatePageThreadsafe(Player playerComponent, boolean shouldClear) { + Ref ref = this.playerRef.getReference(); + if (ref == null) + return; + + UICommandBuilder commandBuilder = new UICommandBuilder(); + UIEventBuilder eventBuilder = new UIEventBuilder(); + delegate.build(ref, commandBuilder, eventBuilder, ref.getStore(), !shouldClear); + playerComponent.getPageManager().updateCustomPage(new CustomPage(this.getClass().getName(), false, shouldClear, this.lifetime, commandBuilder.getCommands(), eventBuilder.getEvents())); } public long getRefreshRateMs() { @@ -186,11 +207,14 @@ public Optional> getByIdRaw(String id) { } /** - * Reloads a dynamic image by its element ID. This will forcibly invalidate the image + * Reloads a dynamic image by its element ID. This will forcibly invalidate the image * and re-download (cache still applies to all downloads for 15 seconds!). - * + * * @param dynamicImageElementId The ID of the dynamic image element. - * @param shouldClearPage Whether to clear the page after reloading the image. + * @param shouldClearPage Whether to clear the page after reloading the image. + * Reloads a dynamic image by its element ID. This will forcibly invalidate the image + * + * @param shouldClearPage Whether to clear the page after reloading the image. */ public void reloadImage(String dynamicImageElementId, boolean shouldClearPage) { reloadImage(dynamicImageElementId, shouldClearPage, true); @@ -200,8 +224,8 @@ public void reloadImage(String dynamicImageElementId, boolean shouldClearPage) { * Reloads a dynamic image by its element ID. * * @param dynamicImageElementId The ID of the dynamic image element. - * @param shouldClearPage Whether to clear the page after reloading the image. - * @param forceDownload Whether to force a re-download of the image. + * @param shouldClearPage Whether to clear the page after reloading the image. + * @param forceDownload Whether to force a re-download of the image. */ public void reloadImage(String dynamicImageElementId, boolean shouldClearPage, boolean forceDownload) { Ref ref = this.playerRef.getReference(); @@ -230,7 +254,7 @@ public void onDismiss(@Nonnull Ref ref, @Nonnull Store onDismissListener.accept(this, false); } } - + @Override public void build(@Nonnull Ref ref, @Nonnull UICommandBuilder uiCommandBuilder, @Nonnull UIEventBuilder uiEventBuilder, @Nonnull Store store) { startRefreshTask(); diff --git a/src/main/java/au/ellie/hyui/builders/HyUInterface.java b/src/main/java/au/ellie/hyui/builders/HyUInterface.java index 469497a..393e017 100644 --- a/src/main/java/au/ellie/hyui/builders/HyUInterface.java +++ b/src/main/java/au/ellie/hyui/builders/HyUInterface.java @@ -18,6 +18,7 @@ package au.ellie.hyui.builders; +import au.ellie.hyui.HyUIPlugin; import au.ellie.hyui.HyUIPluginLogger; import au.ellie.hyui.elements.UIType; import au.ellie.hyui.events.*; @@ -25,6 +26,7 @@ import au.ellie.hyui.html.TemplateProcessor; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.function.consumer.TriConsumer; import com.hypixel.hytale.protocol.Asset; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.entity.entities.Player; @@ -32,20 +34,14 @@ import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; -import au.ellie.hyui.HyUIPlugin; import javax.annotation.Nonnull; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.function.BiConsumer; -import java.util.UUID; public abstract class HyUInterface implements UIContext { + private final Map> eventListener; + protected final Set dirtyValueIds = new HashSet<>(); protected String uiFile; protected List> elements; @@ -54,10 +50,9 @@ public abstract class HyUInterface implements UIContext { protected List commandLog = new ArrayList<>(); protected String templateHtml; protected TemplateProcessor templateProcessor; + protected final InterfaceBuilder rootElementBuilder; protected boolean hasBuilt; protected boolean runtimeTemplateUpdatesEnabled; - protected final InterfaceBuilder rootElementBuilder; - protected final Set dirtyValueIds = new HashSet<>(); public HyUInterface(String uiFile, List> elements, @@ -65,10 +60,13 @@ public HyUInterface(String uiFile, String templateHtml, TemplateProcessor templateProcessor, boolean runtimeTemplateUpdatesEnabled, - InterfaceBuilder rootElementBuilder) { + InterfaceBuilder rootElementBuilder, + Map> eventListeners + ) { this.uiFile = uiFile; this.elements = elements; this.editCallbacks = editCallbacks; + this.eventListener = eventListeners; this.templateHtml = templateHtml; this.templateProcessor = templateProcessor; this.runtimeTemplateUpdatesEnabled = runtimeTemplateUpdatesEnabled; @@ -97,8 +95,13 @@ public Optional getPage() { } @Override - public void updatePage(boolean shouldClose) {} - + public void updatePage(boolean shouldClose) { + } + + @Override + public void updatePageThreadsafe(Player playerComponent, boolean shouldClear) { + } + public void build(@Nonnull Ref ref, @Nonnull UICommandBuilder uiCommandBuilder, @Nonnull UIEventBuilder uiEventBuilder, @@ -111,7 +114,7 @@ public void build(@Nonnull Ref ref, @Nonnull UIEventBuilder uiEventBuilder, @Nonnull Store store, boolean updateOnly) { - + HyUIPlugin.getLog().logFinest("REBUILD: HyUInterface build updateOnly=" + updateOnly); HyUIPlugin.getLog().logFinest("Building HyUInterface" + (uiFile != null ? " from file: " + uiFile : "")); @@ -276,13 +279,13 @@ protected void handleElementEvents(UIElementBuilder element, DynamicPageData continue; } - if (listener.type() == CustomUIEventBindingType.Activating || - listener.type() == CustomUIEventBindingType.Dismissing || + if (listener.type() == CustomUIEventBindingType.Activating || + listener.type() == CustomUIEventBindingType.Dismissing || listener.type() == CustomUIEventBindingType.Validating) { - ((UIEventListener) listener).callback().accept(null, context); + ((UIEventListener) listener).callback().accept(null, context, listener.type()); continue; } - if (isSlotEventRelated(listener.type()) || + if (isSlotEventRelated(listener.type()) || listener.type() == CustomUIEventBindingType.SelectedTabChanged || listener.type() == CustomUIEventBindingType.MouseButtonReleased || listener.type() == CustomUIEventBindingType.MouseEntered || @@ -291,7 +294,7 @@ protected void handleElementEvents(UIElementBuilder element, DynamicPageData listener.type() == CustomUIEventBindingType.RightClicking ) { Object payload = buildEventPayload(listener.type(), data); - ((UIEventListener) listener).callback().accept(payload, context); + ((UIEventListener) listener).callback().accept(payload, context, listener.type()); continue; } String rawValue = element.usesRefValue() ? data.getValue("RefValue") : data.getValue("Value"); @@ -301,21 +304,19 @@ protected void handleElementEvents(UIElementBuilder element, DynamicPageData if (finalValue != null && userId != null && listener.type() != CustomUIEventBindingType.FocusGained) { //Object previous = elementValues.get(userId); //if (!Objects.equals(previous, finalValue)) { - elementValues.put(userId, finalValue); - dirtyValueIds.add(userId); + elementValues.put(userId, finalValue); + dirtyValueIds.add(userId); //} } - if (finalValue != null) { - ((UIEventListener) listener).callback().accept(finalValue, context); - } + if (finalValue != null) + ((UIEventListener) listener).callback().accept(finalValue, context, listener.type()); } } - List> children = new ArrayList<>(element.children); - for (UIElementBuilder child : children) { + var children = new ArrayList<>(element.children); + for (var child : children) handleElementEvents(child, data, context); - } } private boolean isSlotEventRelated(CustomUIEventBindingType type) { @@ -345,7 +346,8 @@ private Object buildEventPayload(CustomUIEventBindingType type, DynamicPageData case SlotClickPressWhileDragging -> SlotClickPressWhileDraggingEventData.from(data); case SelectedTabChanged -> SelectedTabChangedEventData.from(data); // Only RightClicking and DoubleClicking has event data, but we wrap it in the same event data. - case MouseButtonReleased, MouseEntered, MouseExited, DoubleClicking, RightClicking -> MouseEventData.from(data); + case MouseButtonReleased, MouseEntered, MouseExited, DoubleClicking, RightClicking -> + MouseEventData.from(data); default -> null; }; } @@ -450,10 +452,10 @@ private void refreshTemplate(UIContext context) { //return; } HyUIPlugin.getLog().logFinest("REBUILD: Template refresh"); - HtmlParser parser = new HtmlParser(); - String processedHtml = templateProcessor.process(templateHtml, context); + HtmlParser parser = new HtmlParser(eventListener); + String processedHtml = templateProcessor.setTemplate(templateHtml).process(context); List> updatedElements = parser.parse(processedHtml); - + this.elements = mergeElementLists(this.elements, updatedElements); applyRuntimeValues(this.elements, context); reapplyTabSelections(this.elements, context); @@ -463,7 +465,7 @@ private void refreshTemplate(UIContext context) { } private List> mergeElementLists(List> currentElements, - List> updatedElements) { + List> updatedElements) { /* for (var e : updatedElements) { HyUIPlugin.getLog().logInfo("UPDATED ELEMENT: \n\n" + e); @@ -593,10 +595,10 @@ public InterfaceBuilder reopenFromAsset(Player player, PlayerRef ref, Store reopenFromAsset(Player player, PlayerRef ref, Store reopenFromAsset(Player player, PlayerRef ref, Store store, Asset asset) { if (rootElementBuilder instanceof PageBuilder pageBuilder) { // TODO: EndsWith or some parsing? - if (uiFile != null && asset.name.contains(uiFile)) { + if (uiFile != null && asset.name.contains(uiFile)) return true; - } + // Generally the resource html path is more specific than the asset name. - if (pageBuilder.htmlFilePath.contains(asset.name)) { - return true; - } + return pageBuilder.htmlFilePath != null && pageBuilder.htmlFilePath.contains(asset.name); } else if (rootElementBuilder instanceof HudBuilder hudBuilder) { - if (uiFile != null && asset.name.contains(uiFile)) { + if (uiFile != null && asset.name.contains(uiFile)) return true; - } + // Generally the resource html path is more specific than the asset name. - if (hudBuilder.htmlFilePath.contains(asset.name)) { - return true; - } + return hudBuilder.htmlFilePath != null && hudBuilder.htmlFilePath.contains(asset.name); } + return false; } } diff --git a/src/main/java/au/ellie/hyui/builders/InterfaceBuilder.java b/src/main/java/au/ellie/hyui/builders/InterfaceBuilder.java index 6f07670..097c246 100644 --- a/src/main/java/au/ellie/hyui/builders/InterfaceBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/InterfaceBuilder.java @@ -26,6 +26,7 @@ import au.ellie.hyui.html.TemplateProcessor; import au.ellie.hyui.utils.HyvatarUtils; import au.ellie.hyui.utils.PngDownloadUtils; +import com.hypixel.hytale.function.consumer.TriConsumer; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; @@ -33,10 +34,10 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -45,8 +46,9 @@ import java.util.function.Consumer; public abstract class InterfaceBuilder> { - protected final Map> elementRegistry = new LinkedHashMap<>(); + protected final Map> eventListeners = new HashMap<>(); protected final List> editCallbacks = new ArrayList<>(); + protected final Map> elementRegistry = new LinkedHashMap<>(); protected String uiFile; protected String templateHtml; protected TemplateProcessor templateProcessor; @@ -88,10 +90,10 @@ public T fromHtml(String html, UIType style) { this.templateProcessor = null; this.runtimeTemplateUpdatesEnabled = false; html = addUIStyleToHtml(html, style); - new HtmlParser().parseToInterface(this, html); + new HtmlParser(eventListeners).parseToInterface(this, html); return self(); } - + /** * Parses the provided HTML template into this interface with variable substitution and a specific style. * @@ -103,13 +105,13 @@ public T fromHtml(String html, UIType style) { public T fromTemplate(String html, TemplateProcessor template, UIType style) { this.templateHtml = html; this.templateProcessor = template; - HtmlParser parser = new HtmlParser(); + HtmlParser parser = new HtmlParser(eventListeners); parser.setTemplateProcessor(template); html = addUIStyleToHtml(html, style); parser.parseToInterface(this, html); return self(); } - + /** * Parses the provided HTML string into this interface. * @@ -119,7 +121,7 @@ public T fromTemplate(String html, TemplateProcessor template, UIType style) { public T fromHtml(String html) { return fromHtml(html, UIType.NONE); } - + /** * Parses the provided HTML template into this interface with variable substitution. * @@ -140,13 +142,12 @@ public T fromHtml(String html) { public T fromTemplate(String html, TemplateProcessor template) { return fromTemplate(html, template, UIType.NONE); } - + private String loadHtmlFromResources(String resourceFileName) { - if (!resourceFileName.equals("/Common/UI/Custom/Pages/Styles/hywind.html")) { + if (!resourceFileName.equals("/Common/UI/Custom/Pages/Styles/hywind.html")) htmlFilePath = resourceFileName; - } String normalized = resourceFileName.startsWith("/") ? resourceFileName.substring(1) : resourceFileName; - + List candidatePaths = List.of( Paths.get("src/main/resources").resolve(normalized), Paths.get("..", "src", "main", "resources").resolve(normalized), @@ -177,16 +178,15 @@ private String loadHtmlFromResources(String resourceFileName) { private String addUIStyleToHtml(String html, UIType style) { uiStyleFilePath = null; - if (Objects.requireNonNull(style) == UIType.HYWIND) { + if (Objects.requireNonNull(style) == UIType.HYWIND) uiStyleFilePath = "/Common/UI/Custom/Pages/Styles/hywind.html"; - - } else { + else return html; - } - String contents = loadHtmlFromResources(uiStyleFilePath); + + var contents = loadHtmlFromResources(uiStyleFilePath); return contents + "\n\n" + html; } - + /** * Loads an HTML file from resources under Common/UI/Custom and parses it into this interface. * @@ -196,7 +196,7 @@ private String addUIStyleToHtml(String html, UIType style) { public T loadHtml(String resourcePath) { return loadHtml(resourcePath, UIType.NONE); } - + /** * Loads an HTML file from resources under Common/UI/Custom and parses it into this interface. * @@ -208,7 +208,7 @@ public T loadHtml(String resourcePath, UIType style) { String html = loadHtmlFromResources(resolveCustomResourcePath(resourcePath)); return fromHtml(html, style); } - + /** * Loads an HTML template from resources under Common/UI/Custom with a template processor. * @@ -232,7 +232,7 @@ public T loadHtml(String resourcePath, TemplateProcessor template, UIType style) String html = loadHtmlFromResources(resolveCustomResourcePath(resourcePath)); return fromTemplate(html, template, style); } - + /** * Loads an HTML template from resources under Common/UI/Custom with variable substitution. * @@ -257,7 +257,7 @@ public T loadHtml(String resourcePath, Map variables, UIType style) { String html = loadHtmlFromResources(resolveCustomResourcePath(resourcePath)); return fromTemplate(html, variables, style); } - + public T enableRuntimeTemplateUpdates(boolean enabled) { this.runtimeTemplateUpdatesEnabled = enabled; return self(); @@ -278,7 +278,7 @@ public T enableAsyncImageLoading(boolean enabled) { public T fromTemplate(String html, Map variables) { return fromTemplate(html, variables, UIType.NONE); } - + /** * Parses the provided HTML template into this interface with variable substitution and a specific style. * @@ -290,7 +290,7 @@ public T fromTemplate(String html, Map variables) { public T fromTemplate(String html, Map variables, UIType style) { return fromTemplate(html, new TemplateProcessor().setVariables(variables), style); } - + private String resolveCustomResourcePath(String resourcePath) { if (resourcePath == null || resourcePath.isBlank()) { throw new IllegalArgumentException("Resource path cannot be null or blank."); @@ -301,6 +301,7 @@ private String resolveCustomResourcePath(String resourcePath) { /** * Add an element inside the root node (#HyUIRoot) of the interface. + * * @param element The element to add to the root node. * @return Self, for chaining. */ @@ -361,9 +362,9 @@ private void linkTabContentToNavigation(TabContentBuilder content) { } private boolean isMatchingNavigation(TabContentBuilder content, TabNavigationBuilder navigation) { - if (!navigation.hasTab(content.getTabId())) { + if (!navigation.hasTab(content.getTabId())) return false; - } + String navId = content.getTabNavigationId(); return navId == null || navId.isBlank() || navId.equals(navigation.getId()); } @@ -373,20 +374,20 @@ private void applyInitialTabVisibility(TabContentBuilder content, TabNavigationB if (selectedTabId == null || selectedTabId.isBlank()) { var tabs = navigation.getTabs(); if (!tabs.isEmpty()) { - selectedTabId = tabs.get(0).id(); + selectedTabId = tabs.getFirst().id(); navigation.withSelectedTab(selectedTabId); } } - if (selectedTabId != null && !selectedTabId.isBlank()) { + + if (selectedTabId != null && !selectedTabId.isBlank()) content.withVisible(selectedTabId.equals(content.getTabId())); - } } public > Optional getById(String id, Class clazz) { UIElementBuilder builder = elementRegistry.get(id); - if (builder != null && clazz.isInstance(builder)) { + if (clazz.isInstance(builder)) return Optional.of(clazz.cast(builder)); - } + return Optional.empty(); } @@ -395,59 +396,82 @@ public > Optional getById(String id, Class c * Note: This only works for elements created using the builder API or via HYUIML (.fromHtml). * It does not work for elements defined in a .ui file loaded via .fromFile. * - * @param id The ID of the element. - * @param type The type of event to listen for. - * @param valueClass The class of the value associated with the event. - * @param callback The callback to execute when the event occurs. - * @param The type of the value. + * @param id The ID of the element. + * @param type The type of event to listen for. + * @param callback The callback to execute when the event occurs. + * @param The type of the value. * @return This builder instance for method chaining. */ - public T addEventListener(String id, CustomUIEventBindingType type, Class valueClass, Consumer callback) { + public T addEventListener(String id, CustomUIEventBindingType type, BiConsumer callback) { UIElementBuilder element = elementRegistry.get(id); - if (element == null) { + if (element == null) throw new IllegalArgumentException("No element found with ID '" + id + "'."); - } - element.addEventListener(type, valueClass, callback); + + element.addEventListenerWithContext(type, callback); return self(); } - public T addEventListener(String id, CustomUIEventBindingType type, Consumer callback) { - return addEventListener(id, type, Object.class, callback); + /** + * @see #addEventListener(String, CustomUIEventBindingType, BiConsumer) + */ + @SuppressWarnings("unchecked") + public T addEventListener(String id, CustomUIEventBindingType type, Consumer callback) { + return addEventListener(id, type, (value, _) -> callback.accept((V) value)); + } + + /** + * @see #addEventListener(String, CustomUIEventBindingType, BiConsumer) + */ + public T addEventListener(String id, CustomUIEventBindingType type, Runnable callback) { + return addEventListener(id, type, (_, _) -> callback.run()); } /** - * Adds an event listener with access to the UI context. - * Note: This only works for elements created using the builder API or via HYUIML (.fromHtml). + * Registers a custom event listener that can be used on the template. * - * @param id The ID of the element. - * @param type The type of event to listen for. - * @param valueClass The class of the value associated with the event. - * @param callback The callback to execute when the event occurs. - * @param The type of the value. + * @param id An ID used to reference this event listener in the template (e.g. "myCustomEvent"). + * @param callback The callback to execute when the event is triggered, with access to the UI context. + * @param The type of the value. * @return This builder instance for method chaining. */ - public T addEventListener(String id, CustomUIEventBindingType type, Class valueClass, BiConsumer callback) { - UIElementBuilder element = elementRegistry.get(id); - if (element == null) { - throw new IllegalArgumentException("No element found with ID '" + id + "'."); - } - element.addEventListenerWithContext(type, valueClass, callback); + @SuppressWarnings("unchecked") + public T registerEventListener(String id, TriConsumer callback) { + this.eventListeners.put(id, (TriConsumer) callback); return self(); } - public T addEventListener(String id, CustomUIEventBindingType type, BiConsumer callback) { - return addEventListener(id, type, Object.class, callback); + /** + * @see #registerEventListener(String, TriConsumer) + */ + @SuppressWarnings("unchecked") + public T registerEventListener(String id, BiConsumer callback) { + return registerEventListener(id, (data, context, _) -> callback.accept((V) data, context)); + } + + /** + * @see #registerEventListener(String, TriConsumer) + */ + @SuppressWarnings("unchecked") + public T registerEventListener(String id, Consumer callback) { + return registerEventListener(id, (data, _, _) -> callback.accept((V) data)); + } + + /** + * @see #registerEventListener(String, TriConsumer) + */ + public T registerEventListener(String id, Runnable callback) { + return registerEventListener(id, (data, _, _) -> callback.run()); } public T editElement(Consumer callback) { return this.editElement((uiCommandBuilder, _) -> callback.accept(uiCommandBuilder)); } - + public T editElement(BiConsumer callback) { this.editCallbacks.add(callback); return self(); } - + public void sendDynamicImageIfNeeded(PlayerRef pRef) { if (pRef == null || !pRef.isValid()) { return; @@ -490,14 +514,14 @@ public void sendDynamicImageIfNeededAsync(PlayerRef pRef, Consumer> getTopLevelElements() { @@ -556,11 +581,11 @@ public List> getTopLevelElements() { /** * Get all elements in the element registry for this builder. - * + * * @return a list of all elements, irrespective of top-level. */ public List> getElements() { - return elementRegistry.values().stream().toList(); + return elementRegistry.values().stream().toList(); } public String getTemplateHtml() { diff --git a/src/main/java/au/ellie/hyui/builders/ItemGridBuilder.java b/src/main/java/au/ellie/hyui/builders/ItemGridBuilder.java index 12f44ea..826c3e6 100644 --- a/src/main/java/au/ellie/hyui/builders/ItemGridBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/ItemGridBuilder.java @@ -23,7 +23,6 @@ import au.ellie.hyui.elements.LayoutModeSupported; import au.ellie.hyui.elements.ScrollbarStyleSupported; import au.ellie.hyui.elements.UIElements; -import au.ellie.hyui.events.UIEventActions; import au.ellie.hyui.types.ItemGridInfoDisplayMode; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.inventory.ItemStack; @@ -38,6 +37,8 @@ import java.util.List; import java.util.Set; +import static com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType.*; + /** * Builder for the ItemGrid UI element. */ @@ -190,12 +191,13 @@ public ItemGridBuilder addSlot(ItemGridSlot slot) { /** * Retrieves an unmodifiable list of slots in the item grid. + * * @return An unmodifiable list of slots */ public List getSlots() { return Collections.unmodifiableList(this.slots); } - + public ItemGridBuilder updateSlot(ItemGridSlot updatedSlot, Integer index) { if (updatedSlot == null || index == null || index < 0 || index >= this.slots.size()) { return this; @@ -203,7 +205,7 @@ public ItemGridBuilder updateSlot(ItemGridSlot updatedSlot, Integer index) { this.slots.set(index, updatedSlot); return this; } - + public ItemGridBuilder removeSlot(Integer index) { if (index == null || index < 0 || index >= this.slots.size()) { return this; @@ -211,7 +213,7 @@ public ItemGridBuilder removeSlot(Integer index) { this.slots.remove(index.intValue()); return this; } - + public ItemGridSlot getSlot(Integer index) { if (index == null || index < 0 || index >= this.slots.size()) { return null; @@ -223,37 +225,39 @@ public ItemGridSlot getSlot(Integer index) { * Adds an event listener for the SlotDoubleClicking event. */ public ItemGridBuilder onSlotDoubleClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.SlotDoubleClicking, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.SlotDoubleClicking, callback); } /** * Adds an event listener for the DragCancelled event. */ public ItemGridBuilder onDragCancelled(Runnable callback) { - return addEventListener(CustomUIEventBindingType.DragCancelled, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.DragCancelled, callback); } /** * Adds an event listener for the SlotMouseEntered event. */ public ItemGridBuilder onSlotMouseEntered(Runnable callback) { - return addEventListener(CustomUIEventBindingType.SlotMouseEntered, Void.class, v -> callback.run()); + return addEventListener(SlotMouseEntered, callback); } /** * Adds an event listener for the SlotMouseExited event. */ public ItemGridBuilder onSlotMouseExited(Runnable callback) { - return addEventListener(CustomUIEventBindingType.SlotMouseExited, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.SlotMouseExited, callback); } @Override protected boolean supportsStyling() { return true; } - + @Override - protected boolean isStyleWhitelist() { return true; } + protected boolean isStyleWhitelist() { + return true; + } protected Set getSupportedStyleProperties() { return StylePropertySets.merge( @@ -279,6 +283,20 @@ protected Set getSupportedStyleProperties() { ); } + @Override + protected CustomUIEventBindingType getEventTypeMapped(CustomUIEventBindingType type) { + return switch (type) { + case Activating -> SlotClicking; + case DoubleClicking -> SlotDoubleClicking; + case MouseEntered -> SlotMouseEntered; + case MouseExited -> SlotMouseExited; + case Dropped -> SlotMouseDragCompleted; + case DragCancelled -> SlotMouseDragExited; + case MouseButtonReleased -> SlotClickReleaseWhileDragging; + default -> type; + }; + } + @Override protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { String selector = getSelector(); @@ -286,7 +304,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { applyLayoutMode(commands, selector); applyScrollbarStyle(commands, selector); - + if (backgroundMode != null) { HyUIPlugin.getLog().logFinest("Setting BackgroundMode: " + backgroundMode + " for " + selector); commands.set(selector + ".BackgroundMode", backgroundMode); @@ -341,7 +359,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { listeners.forEach(listener -> { CustomUIEventBindingType type = listener.type(); - if (type == CustomUIEventBindingType.Activating + if (type == CustomUIEventBindingType.Activating || type == CustomUIEventBindingType.RightClicking || type == CustomUIEventBindingType.DoubleClicking || type == CustomUIEventBindingType.MouseEntered @@ -355,7 +373,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { || type == CustomUIEventBindingType.FocusLost || type == CustomUIEventBindingType.KeyDown || type == CustomUIEventBindingType.SelectedTabChanged - ) + ) return; String eventId = getEffectiveId(); HyUIPlugin.getLog().logFinest("Adding " + type.name()); @@ -394,7 +412,7 @@ public static ItemStack getItemStack(ItemGridSlot slot) { return null; } try { - return (ItemStack)ITEM_STACK_FIELD.get(slot); + return (ItemStack) ITEM_STACK_FIELD.get(slot); } catch (IllegalAccessException e) { HyUIPlugin.getLog().logFinest("Unable to access ItemGridSlot.itemStack."); return null; diff --git a/src/main/java/au/ellie/hyui/builders/ItemSlotButtonBuilder.java b/src/main/java/au/ellie/hyui/builders/ItemSlotButtonBuilder.java index d8ebd31..b12d1d6 100644 --- a/src/main/java/au/ellie/hyui/builders/ItemSlotButtonBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/ItemSlotButtonBuilder.java @@ -20,14 +20,15 @@ import au.ellie.hyui.HyUIPlugin; import au.ellie.hyui.elements.LayoutModeSupported; +import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.MouseEventData; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; -import au.ellie.hyui.elements.UIElements; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.builder.EventData; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; + import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -35,7 +36,7 @@ /** * Builder for ItemSlotButton UI elements. */ -public class ItemSlotButtonBuilder extends UIElementBuilder +public class ItemSlotButtonBuilder extends UIElementBuilder implements LayoutModeSupported { private String itemId; private String layoutMode; @@ -61,95 +62,95 @@ public String getLayoutMode() { } public ItemSlotButtonBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, Void.class, callback); + return addEventListenerWithContext(type, callback); } public ItemSlotButtonBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, Void.class, callback); + return addEventListenerWithContext(type, callback); } /** * Adds an event listener for the DoubleClicking event. */ public ItemSlotButtonBuilder onDoubleClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event. */ public ItemSlotButtonBuilder onDoubleClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event with context. */ public ItemSlotButtonBuilder onDoubleClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public ItemSlotButtonBuilder onRightClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public ItemSlotButtonBuilder onRightClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event with context. */ public ItemSlotButtonBuilder onRightClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the MouseEntered event. */ public ItemSlotButtonBuilder onMouseEntered(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event. */ public ItemSlotButtonBuilder onMouseEntered(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event with context. */ public ItemSlotButtonBuilder onMouseEntered(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseExited event. */ public ItemSlotButtonBuilder onMouseExited(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event. */ public ItemSlotButtonBuilder onMouseExited(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event with context. */ public ItemSlotButtonBuilder onMouseExited(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } @Override @@ -180,7 +181,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { if (selector == null) return; applyLayoutMode(commands, selector); - + if (layoutMode != null) { HyUIPlugin.getLog().logFinest("Setting LayoutMode: " + layoutMode + " for " + selector); commands.set(selector + ".LayoutMode", layoutMode); diff --git a/src/main/java/au/ellie/hyui/builders/MenuItemBuilder.java b/src/main/java/au/ellie/hyui/builders/MenuItemBuilder.java index f0b711e..f230e05 100644 --- a/src/main/java/au/ellie/hyui/builders/MenuItemBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/MenuItemBuilder.java @@ -115,84 +115,84 @@ public MenuItemBuilder withIconAnchor(HyUIAnchor iconAnchor) { * Adds an event listener for the DoubleClicking event. */ public MenuItemBuilder onDoubleClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event. */ public MenuItemBuilder onDoubleClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event with context. */ public MenuItemBuilder onDoubleClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public MenuItemBuilder onRightClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public MenuItemBuilder onRightClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event with context. */ public MenuItemBuilder onRightClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the MouseEntered event. */ public MenuItemBuilder onMouseEntered(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event. */ public MenuItemBuilder onMouseEntered(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event with context. */ public MenuItemBuilder onMouseEntered(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseExited event. */ public MenuItemBuilder onMouseExited(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event. */ public MenuItemBuilder onMouseExited(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event with context. */ public MenuItemBuilder onMouseExited(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } @Override @@ -260,22 +260,22 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding DoubleClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.DoubleClicking, selector, EventData.of("Action", UIEventActions.DOUBLE_CLICKING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.RightClicking) { HyUIPlugin.getLog().logFinest("Adding RightClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.RightClicking, selector, EventData.of("Action", UIEventActions.RIGHT_CLICKING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.MouseEntered) { HyUIPlugin.getLog().logFinest("Adding MouseEntered event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseEntered, selector, EventData.of("Action", UIEventActions.MOUSE_ENTERED) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.MouseExited) { HyUIPlugin.getLog().logFinest("Adding MouseExited event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseExited, selector, EventData.of("Action", UIEventActions.MOUSE_EXITED) - .append("Target", eventId), false); + .append("Target", eventId), false); } }); } diff --git a/src/main/java/au/ellie/hyui/builders/NativeTabButtonBuilder.java b/src/main/java/au/ellie/hyui/builders/NativeTabButtonBuilder.java index 5bac983..79d0c53 100644 --- a/src/main/java/au/ellie/hyui/builders/NativeTabButtonBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/NativeTabButtonBuilder.java @@ -23,7 +23,6 @@ import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; import au.ellie.hyui.types.LayoutMode; -import au.ellie.hyui.utils.PropertyBatcher; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.builder.EventData; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; @@ -94,98 +93,98 @@ public NativeTabButtonBuilder withDisabled(boolean disabled) { * Adds an event listener for the Activating event. */ public NativeTabButtonBuilder onActivating(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Activating, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Activating, callback); } /** * Adds an event listener for the Activating event with context. */ public NativeTabButtonBuilder onActivating(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.Activating, Void.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.Activating, callback); } /** * Adds an event listener for the DoubleClicking event. */ public NativeTabButtonBuilder onDoubleClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event. */ public NativeTabButtonBuilder onDoubleClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event with context. */ public NativeTabButtonBuilder onDoubleClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public NativeTabButtonBuilder onRightClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public NativeTabButtonBuilder onRightClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event with context. */ public NativeTabButtonBuilder onRightClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the MouseEntered event. */ public NativeTabButtonBuilder onMouseEntered(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event. */ public NativeTabButtonBuilder onMouseEntered(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event with context. */ public NativeTabButtonBuilder onMouseEntered(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseExited event. */ public NativeTabButtonBuilder onMouseExited(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event. */ public NativeTabButtonBuilder onMouseExited(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event with context. */ public NativeTabButtonBuilder onMouseExited(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } @Override @@ -248,27 +247,27 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding Activating event binding: " + eventId + " for " + selector); events.addEventBinding(CustomUIEventBindingType.Activating, selector, EventData.of("Action", UIEventActions.BUTTON_CLICKED) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.DoubleClicking) { HyUIPlugin.getLog().logFinest("Adding DoubleClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.DoubleClicking, selector, EventData.of("Action", UIEventActions.DOUBLE_CLICKING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.RightClicking) { HyUIPlugin.getLog().logFinest("Adding RightClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.RightClicking, selector, EventData.of("Action", UIEventActions.RIGHT_CLICKING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.MouseEntered) { HyUIPlugin.getLog().logFinest("Adding MouseEntered event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseEntered, selector, EventData.of("Action", UIEventActions.MOUSE_ENTERED) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.MouseExited) { HyUIPlugin.getLog().logFinest("Adding MouseExited event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseExited, selector, EventData.of("Action", UIEventActions.MOUSE_EXITED) - .append("Target", eventId), false); + .append("Target", eventId), false); } }); } diff --git a/src/main/java/au/ellie/hyui/builders/NativeTabNavigationBuilder.java b/src/main/java/au/ellie/hyui/builders/NativeTabNavigationBuilder.java index 70f800d..ee0cd1e 100644 --- a/src/main/java/au/ellie/hyui/builders/NativeTabNavigationBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/NativeTabNavigationBuilder.java @@ -84,14 +84,14 @@ public NativeTabNavigationBuilder withTabs(List tabs) { * Adds an event listener for the SelectedTabChanged event. */ public NativeTabNavigationBuilder onSelectedTabChanged(Consumer callback) { - return addEventListener(CustomUIEventBindingType.SelectedTabChanged, SelectedTabChangedEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.SelectedTabChanged, callback); } /** * Adds an event listener for the SelectedTabChanged event with context. */ public NativeTabNavigationBuilder onSelectedTabChanged(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.SelectedTabChanged, SelectedTabChangedEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.SelectedTabChanged, callback); } @Override @@ -171,7 +171,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding SelectedTabChanged event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.SelectedTabChanged, selector, EventData.of("Action", UIEventActions.SELECTED_TAB_CHANGED) - .append("Target", eventId), false); + .append("Target", eventId), false); } }); } diff --git a/src/main/java/au/ellie/hyui/builders/NumberFieldBuilder.java b/src/main/java/au/ellie/hyui/builders/NumberFieldBuilder.java index bcfc925..a85d35d 100644 --- a/src/main/java/au/ellie/hyui/builders/NumberFieldBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/NumberFieldBuilder.java @@ -19,14 +19,13 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.MouseEventData; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; -import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.theme.Theme; import au.ellie.hyui.types.InputFieldStyle; import au.ellie.hyui.types.NumberFieldFormat; -import au.ellie.hyui.utils.BsonDocumentHelper; import au.ellie.hyui.utils.PropertyBatcher; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.Value; @@ -39,12 +38,12 @@ import java.util.function.Consumer; /** - * A builder class for constructing a number input field UI element. This class extends - * the UIElementBuilder to provide functionality specific to creating and customizing + * A builder class for constructing a number input field UI element. This class extends + * the UIElementBuilder to provide functionality specific to creating and customizing * number input fields. - * - * The NumberFieldBuilder supports setting the initial numeric value, attaching event - * listeners, and integrating with specific themes and styles. It facilitates the seamless + *

+ * The NumberFieldBuilder supports setting the initial numeric value, attaching event + * listeners, and integrating with specific themes and styles. It facilitates the seamless * generation of commands and events during the UI build phase. */ public class NumberFieldBuilder extends UIElementBuilder { @@ -58,6 +57,7 @@ public class NumberFieldBuilder extends UIElementBuilder { /** * Do not use. Instead, use the static .numberInput(). + * * @param theme */ public NumberFieldBuilder(Theme theme) { @@ -164,15 +164,15 @@ public NumberFieldBuilder withPlaceholderStyle(InputFieldStyle style) { /** * Adds an event listener to the number field builder. The only type it accepts will be ValueChanged. * - * @param type the type of the event to listen for, represented by {@code CustomUIEventBindingType}. + * @param type the type of the event to listen for, represented by {@code CustomUIEventBindingType}. * This defines the specific event binding, such as {@code ValueChanged}. - * @param callback the function to execute when the specified event occurs. The callback receives - * a {@code Double} value, which typically represents the current numeric value + * @param callback the function to execute when the specified event occurs. The callback receives + * a {@code Double} value, which typically represents the current numeric value * associated with the event. * @return the current instance of {@code NumberFieldBuilder}, enabling method chaining. */ public NumberFieldBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, Double.class, callback); + return addEventListenerWithContext(type, callback); } /** @@ -183,28 +183,28 @@ public NumberFieldBuilder addEventListener(CustomUIEventBindingType type, Consum * @return This NumberFieldBuilder instance for method chaining. */ public NumberFieldBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, Double.class, callback); + return addEventListenerWithContext(type, callback); } /** * Adds an event listener for the RightClicking event. */ public NumberFieldBuilder onRightClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public NumberFieldBuilder onRightClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event with context. */ public NumberFieldBuilder onRightClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } @Override @@ -303,20 +303,20 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Setting Format: " + format + " for " + selector); commands.set(selector + ".Format", format); } - - if ( hyUIStyle == null && typedStyle == null && style != null) { + + if (hyUIStyle == null && typedStyle == null && style != null) { HyUIPlugin.getLog().logFinest("Setting Style: " + style + " for " + selector); commands.set(selector + ".Style", style); } else if (hyUIStyle == null && typedStyle != null) { PropertyBatcher.endSet(selector + ".Style", filterStyleDocument(typedStyle.toBsonDocument()), commands); - } else if ( hyUIStyle == null && typedStyle == null ) { + } else if (hyUIStyle == null && typedStyle == null) { commands.set(selector + ".Style", Value.ref("Common.ui", "DefaultInputFieldStyle")); } if (!secondaryStyles.containsKey("PlaceholderStyle")) { commands.set(selector + ".PlaceholderStyle", Value.ref("Common.ui", "DefaultInputFieldPlaceholderStyle")); } - + commands.set(selector + ".Background", Value.ref("Common.ui", "InputBoxBackground")); if (anchor == null || anchor.getHeight() == null || anchor.getHeight() < 38) { @@ -340,17 +340,17 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { if (listener.type() == CustomUIEventBindingType.ValueChanged) { String eventId = getEffectiveId(); HyUIPlugin.getLog().logFinest("Adding ValueChanged event binding for " + selector + " with eventId: " + eventId); - events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, + events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, EventData.of("@ValueDouble", selector + ".Value") - .append("Target", eventId) - .append("Action", UIEventActions.VALUE_CHANGED), + .append("Target", eventId) + .append("Action", UIEventActions.VALUE_CHANGED), false); } else if (listener.type() == CustomUIEventBindingType.RightClicking) { String eventId = getEffectiveId(); HyUIPlugin.getLog().logFinest("Adding RightClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.RightClicking, selector, EventData.of("Action", UIEventActions.RIGHT_CLICKING) - .append("Target", eventId), + .append("Target", eventId), false); } }); diff --git a/src/main/java/au/ellie/hyui/builders/PageBuilder.java b/src/main/java/au/ellie/hyui/builders/PageBuilder.java index dd59d3f..3e577ad 100644 --- a/src/main/java/au/ellie/hyui/builders/PageBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/PageBuilder.java @@ -18,28 +18,16 @@ package au.ellie.hyui.builders; -import au.ellie.hyui.HyUIPlugin; import au.ellie.hyui.events.PageRefreshResult; -import au.ellie.hyui.events.UIContext; -import au.ellie.hyui.html.HtmlParser; +import com.hypixel.hytale.component.Store; import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; -import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; -import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; -import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.entity.entities.Player; -import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager; -import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import javax.annotation.Nonnull; -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.Optional; import java.util.function.BiConsumer; -import java.util.function.Consumer; import java.util.function.Function; /** @@ -63,7 +51,7 @@ public PageBuilder(PlayerRef playerRef) { // No matter what happens, we need at least an empty UI file to begin with. fromFile("Pages/EllieAU_HyUI_Placeholder.ui"); } - + /** * Constructs a new instance of the PageBuilder class without dependency on player. */ @@ -131,7 +119,7 @@ public PageBuilder onRefresh(Function listener) { * Registers a callback to be triggered when the page is dismissed. * The second boolean argument of the listener indicates if it was closed * by the player, or code that called close. - * + *

* True = closed by code, false = closed by player. * * @param listener The listener callback. @@ -141,7 +129,7 @@ public PageBuilder onDismiss(BiConsumer listener) { this.onDismissListener = listener; return this; } - + /** * Opens a custom UI page for the associated player using the provided store. * This method retrieves the player's page manager and creates a new instance @@ -163,34 +151,56 @@ public HyUIPage open(Store store) { * class, then instructs the page manager to open the page. * * @param playerRefParam The player reference for whom the page is being opened. - * @param store The store containing the entity data required to configure and display the page. + * @param store The store containing the entity data required to configure and display the page. * @return The created HyUIPage instance. */ public HyUIPage open(@Nonnull PlayerRef playerRefParam, Store store) { Player playerComponent = store.getComponent(playerRefParam.getReference(), Player.getComponentType()); - PageManager pageManager = playerComponent.getPageManager(); + return openThreadsafe(playerRefParam, playerComponent); + } + + /** + * Opens a custom UI page for the specified player reference and player component. + * This method is thread-safe and can be called from any thread. It creates a new instance + * of the HyUIPage based on the specified parameters and fields defined in the + * class, then instructs the player's page manager to open the page. + * + * @param playerRefParam The player reference for whom the page is being opened. + * @param playerComponent The player component associated with the player reference. + * @return The created HyUIPage instance. + */ + public HyUIPage openThreadsafe(@Nonnull PlayerRef playerRefParam, Player playerComponent) { this.lastPage = new HyUIPage( - playerRefParam, - lifetime, - uiFile, - getTopLevelElements(), - editCallbacks, - templateHtml, - templateProcessor, + playerRefParam, + lifetime, + uiFile, + getTopLevelElements(), + editCallbacks, + templateHtml, + templateProcessor, runtimeTemplateUpdatesEnabled, onDismissListener, - this); + this, + eventListeners + ); this.lastPage.setRefreshRateMs(refreshRateMs); this.lastPage.setRefreshListener(refreshListener); + + var ref = playerRefParam.getReference(); + var store = ref.getStore(); + + var pageManager = playerComponent.getPageManager(); + pageManager.openCustomPage(ref, store, this.lastPage); + if (asyncImageLoadingEnabled) { - HyUIPage openedPage = this.lastPage; + var world = store.getExternalData().getWorld(); sendDynamicImageIfNeededAsync(playerRefParam, dynamicImage -> { String id = dynamicImage.getId(); - if (id == null || id.isBlank() || openedPage == null) { + if (id == null || id.isBlank() || openedPage == null) return; - } + world.execute(() -> openedPage.reloadImage(id, false, false)); }); pageManager.openCustomPage(playerRefParam.getReference(), store, this.lastPage); @@ -201,8 +211,10 @@ public HyUIPage open(@Nonnull PlayerRef playerRefParam, Store store return this.lastPage; } + /** * Retrieves the list of logged UI commands from the last opened page. + * * @return A list of strings representing the logged commands, or an empty list if no page has been opened. */ public List getCommandLog() { @@ -215,7 +227,7 @@ public List getCommandLog() { /** * Reloads a dynamic image by its element ID on the last opened page. * - * @param id The ID of the dynamic image element. + * @param id The ID of the dynamic image element. * @param shouldClearPage Whether to clear the page after reloading the image. */ public void reloadImage(String id, boolean shouldClearPage) { diff --git a/src/main/java/au/ellie/hyui/builders/PanelBuilder.java b/src/main/java/au/ellie/hyui/builders/PanelBuilder.java index 72d36c1..b4f58fe 100644 --- a/src/main/java/au/ellie/hyui/builders/PanelBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/PanelBuilder.java @@ -19,7 +19,6 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.elements.BackgroundSupported; import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; @@ -50,28 +49,28 @@ public static PanelBuilder panel() { * Adds an event listener for the Validating event. */ public PanelBuilder onValidating(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Validating, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Validating, callback); } /** * Adds an event listener for the Dismissing event. */ public PanelBuilder onDismissing(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Dismissing, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Dismissing, callback); } /** * Adds an event listener for the Scrolled event. */ public PanelBuilder onScrolled(Consumer callback) { - return addEventListener(CustomUIEventBindingType.ValueChanged, Float.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } /** * Adds an event listener for the Scrolled event with context. */ public PanelBuilder onScrolled(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, Float.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } @Override @@ -101,18 +100,18 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding Validating event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.Validating, selector, EventData.of("Action", UIEventActions.VALIDATING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.Dismissing) { HyUIPlugin.getLog().logFinest("Adding Dismissing event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.Dismissing, selector, EventData.of("Action", UIEventActions.DISMISSING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.ValueChanged) { // Scrolled event uses ValueChanged type HyUIPlugin.getLog().logFinest("Adding Scrolled event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, EventData.of("Action", UIEventActions.SCROLLED) - .append("Target", eventId), false); + .append("Target", eventId), false); } }); } diff --git a/src/main/java/au/ellie/hyui/builders/ReorderableListBuilder.java b/src/main/java/au/ellie/hyui/builders/ReorderableListBuilder.java index 4fbb708..0989e15 100644 --- a/src/main/java/au/ellie/hyui/builders/ReorderableListBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/ReorderableListBuilder.java @@ -21,10 +21,12 @@ import au.ellie.hyui.elements.LayoutModeSupported; import au.ellie.hyui.elements.ScrollbarStyleSupported; import au.ellie.hyui.elements.UIElements; +import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; -import java.util.Set; +import static com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType.ElementReordered; +import static com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType.ValueChanged; /** * Builder for ReorderableList UI elements. @@ -95,6 +97,14 @@ protected boolean isStyleWhitelist() { return true; } + @Override + protected CustomUIEventBindingType getEventTypeMapped(CustomUIEventBindingType type) { + if (type == ValueChanged) + return ElementReordered; + + return type; + } + @Override protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { String selector = getSelector(); diff --git a/src/main/java/au/ellie/hyui/builders/ReorderableListGripBuilder.java b/src/main/java/au/ellie/hyui/builders/ReorderableListGripBuilder.java index 62fdbf9..3a3b736 100644 --- a/src/main/java/au/ellie/hyui/builders/ReorderableListGripBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/ReorderableListGripBuilder.java @@ -49,28 +49,28 @@ public static ReorderableListGripBuilder reorderableListGrip() { * Adds an event listener for the Validating event. */ public ReorderableListGripBuilder onValidating(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Validating, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Validating, callback); } /** * Adds an event listener for the Dismissing event. */ public ReorderableListGripBuilder onDismissing(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Dismissing, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Dismissing, callback); } /** * Adds an event listener for the Scrolled event. */ public ReorderableListGripBuilder onScrolled(Consumer callback) { - return addEventListener(CustomUIEventBindingType.ValueChanged, Float.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } /** * Adds an event listener for the Scrolled event with context. */ public ReorderableListGripBuilder onScrolled(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, Float.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } @Override @@ -100,18 +100,18 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding Validating event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.Validating, selector, EventData.of("Action", UIEventActions.VALIDATING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.Dismissing) { HyUIPlugin.getLog().logFinest("Adding Dismissing event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.Dismissing, selector, EventData.of("Action", UIEventActions.DISMISSING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.ValueChanged) { // Scrolled event uses ValueChanged type HyUIPlugin.getLog().logFinest("Adding Scrolled event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, EventData.of("Action", UIEventActions.SCROLLED) - .append("Target", eventId), false); + .append("Target", eventId), false); } }); } diff --git a/src/main/java/au/ellie/hyui/builders/SliderBuilder.java b/src/main/java/au/ellie/hyui/builders/SliderBuilder.java index 4d386cf..6a58ced 100644 --- a/src/main/java/au/ellie/hyui/builders/SliderBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/SliderBuilder.java @@ -19,10 +19,10 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.MouseEventData; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; -import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.theme.Theme; import au.ellie.hyui.utils.PropertyBatcher; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; @@ -89,7 +89,7 @@ public SliderBuilder withStep(int step) { this.step = step; return this; } - + public SliderBuilder withValue(int value) { this.value = value; return this; @@ -104,30 +104,28 @@ public SliderBuilder withIsReadOnly(boolean isReadOnly) { * Adds an event listener for the MouseButtonReleased event. */ public SliderBuilder onMouseButtonReleased(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseButtonReleased, MouseEventData.class, - callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseButtonReleased, callback); } /** * Adds an event listener for the MouseButtonReleased event. */ public SliderBuilder onMouseButtonReleased(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseButtonReleased, MouseEventData.class, - callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseButtonReleased, callback); } /** * Adds an event listener for the ValueChanged event. */ public SliderBuilder onValueChanged(Consumer callback) { - return addEventListener(CustomUIEventBindingType.ValueChanged, Integer.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } /** * Adds an event listener for the ValueChanged event with context. */ public SliderBuilder onValueChanged(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, Integer.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } /** @@ -140,7 +138,7 @@ public SliderBuilder onValueChanged(BiConsumer callback) { * @return the current instance of {@code SliderBuilder}, enabling method chaining. */ public SliderBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, Integer.class, callback); + return addEventListenerWithContext(type, callback); } /** @@ -151,7 +149,7 @@ public SliderBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListenerWithContext(type, Integer.class, callback); + return addEventListenerWithContext(type, callback); } @Override @@ -189,12 +187,12 @@ protected Set getSupportedStyleProperties() { ) ); } - + @Override protected boolean usesRefValue() { return true; } - + @Override protected Object parseValue(String rawValue) { try { @@ -203,7 +201,7 @@ protected Object parseValue(String rawValue) { return null; } } - + @Override protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { String selector = getSelector(); @@ -226,7 +224,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { commands.set(selector + ".IsReadOnly", isReadOnly); } - if ( hyUIStyle == null && typedStyle == null && style != null) { + if (hyUIStyle == null && typedStyle == null && style != null) { HyUIPlugin.getLog().logFinest("Setting Style for Slider " + selector); commands.set(selector + ".Style", style); } else if (hyUIStyle == null && typedStyle != null) { @@ -237,7 +235,8 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { } if (listeners.isEmpty()) { // To handle data back to the .getValue, we need to add at least one listener. - addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> {}); + addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> { + }); } // Register event listeners @@ -247,13 +246,13 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding MouseButtonReleased event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseButtonReleased, selector, EventData.of("Action", UIEventActions.MOUSE_BUTTON_RELEASED) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.ValueChanged) { HyUIPlugin.getLog().logFinest("Adding ValueChanged event binding for " + selector + " with eventId: " + eventId); events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, EventData.of("@ValueInt", selector + ".Value") - .append("Target", eventId) - .append("Action", UIEventActions.VALUE_CHANGED), false); + .append("Target", eventId) + .append("Action", UIEventActions.VALUE_CHANGED), false); } }); } diff --git a/src/main/java/au/ellie/hyui/builders/SliderNumberFieldBuilder.java b/src/main/java/au/ellie/hyui/builders/SliderNumberFieldBuilder.java index 0fb0625..88e6e7f 100644 --- a/src/main/java/au/ellie/hyui/builders/SliderNumberFieldBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/SliderNumberFieldBuilder.java @@ -19,9 +19,9 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; -import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.types.InputFieldStyle; import au.ellie.hyui.types.SliderStyle; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; @@ -88,11 +88,11 @@ public SliderNumberFieldBuilder withValue(int value) { } public SliderNumberFieldBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, Integer.class, callback); + return addEventListenerWithContext(type, callback); } public SliderNumberFieldBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, Integer.class, callback); + return addEventListenerWithContext(type, callback); } @Override @@ -168,7 +168,8 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { } if (listeners.isEmpty()) { - addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> {}); + addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> { + }); } listeners.forEach(listener -> { if (listener.type() == CustomUIEventBindingType.ValueChanged) { diff --git a/src/main/java/au/ellie/hyui/builders/TabNavigationBuilder.java b/src/main/java/au/ellie/hyui/builders/TabNavigationBuilder.java index 44b6669..e474e43 100644 --- a/src/main/java/au/ellie/hyui/builders/TabNavigationBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/TabNavigationBuilder.java @@ -29,20 +29,24 @@ import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; +import static com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType.SelectedTabChanged; +import static com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType.ValueChanged; + /** * Builder for creating tab navigation UI elements. * Creates a horizontal row of tab buttons for navigation between different content sections. - * + *

* Example usage: + *

  * TabNavigationBuilder.tabNavigation()
  *     .withId("main-tabs")
  *     .addTab("inventory", "Inventory")
  *     .addTab("stats", "Statistics", "stats-content")
  *     .addTab("settings", "Settings")
  *     .withSelectedTab("inventory")
+ * 
*/ public class TabNavigationBuilder extends UIElementBuilder implements LayoutModeSupported, BackgroundSupported { @@ -67,6 +71,7 @@ public Tab withContentId(String contentId) { private int tabsVersion = 0; private int lastBuiltTabsVersion = -1; private final List> tabButtons = new ArrayList<>(); + public TabNavigationBuilder() { super(UIElements.GROUP, "Group"); } @@ -304,6 +309,13 @@ protected boolean preserveChildrenOnTemplateMerge() { return true; } + @Override + protected CustomUIEventBindingType getEventTypeMapped(CustomUIEventBindingType type) { + if (type == ValueChanged) + return SelectedTabChanged; + + return type; + } @Override protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { @@ -315,7 +327,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { if ((selectedTabId == null || !hasTab(selectedTabId)) && !tabs.isEmpty()) { selectedTabId = tabs.get(0).id(); } - + // TODO Proper hash check on objects. boolean tabButtonsMissing = tabsVersion > lastBuiltTabsVersion || (tabButtons.isEmpty() && children.isEmpty()) || tabButtons.size() != children.size(); if (tabButtonsMissing) { @@ -338,7 +350,7 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { tabButton.withId(tab.id()); applyTabButtonText(tabButton, tab.label()); - tabButton.addEventListenerWithContext(CustomUIEventBindingType.Activating, Void.class, (_, ctx) -> { + tabButton.addEventListenerWithContext(CustomUIEventBindingType.Activating, (_, ctx) -> { applyTabSelection(ctx, tab.id()); ctx.updatePage(true); }); diff --git a/src/main/java/au/ellie/hyui/builders/TextFieldBuilder.java b/src/main/java/au/ellie/hyui/builders/TextFieldBuilder.java index 601ef58..dfeae57 100644 --- a/src/main/java/au/ellie/hyui/builders/TextFieldBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/TextFieldBuilder.java @@ -19,12 +19,11 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.elements.ScrollbarStyleSupported; +import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.events.MouseEventData; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; -import au.ellie.hyui.elements.BackgroundSupported; -import au.ellie.hyui.elements.ScrollbarStyleSupported; -import au.ellie.hyui.elements.UIElements; import au.ellie.hyui.theme.Theme; import au.ellie.hyui.types.InputFieldDecorationStyle; import au.ellie.hyui.types.InputFieldStyle; @@ -68,7 +67,7 @@ public class TextFieldBuilder extends UIElementBuilder /** * DO NOT USE UNLESS YOU KNOW WHAT YOU ARE DOING. - * + *

* Not normally used, only used when creating a text field element from scratch. */ public TextFieldBuilder() { @@ -78,8 +77,9 @@ public TextFieldBuilder() { /** * DO NOT USE UNLESS YOU KNOW WHAT YOU ARE DOING. - * + *

* Constructor for creating a text field element with a specified theme. + * * @param theme The theme to use for the text field element. */ public TextFieldBuilder(Theme theme) { @@ -92,9 +92,10 @@ public TextFieldBuilder(Theme theme) { /** * DO NOT USE UNLESS YOU KNOW WHAT YOU ARE DOING. - * + *

* Constructor for creating a text field element with a specified theme and element path. - * @param theme The theme to use for the text field element. + * + * @param theme The theme to use for the text field element. * @param elementPath The path to the UI element definition file. */ public TextFieldBuilder(Theme theme, String elementPath) { @@ -112,6 +113,7 @@ private TextFieldBuilder(Theme theme, String elementPath, String typeSelector) { /** * Creates a text input field with the game theme. + * * @return A new TextFieldBuilder instance configured for text input. */ public static TextFieldBuilder textInput() { @@ -120,6 +122,7 @@ public static TextFieldBuilder textInput() { /** * Creates a multiline text input field with the game theme. + * * @return A new TextFieldBuilder instance configured for multiline input. */ public static TextFieldBuilder multilineTextField() { @@ -133,6 +136,7 @@ public static TextFieldBuilder multilineTextField() { /** * Creates a compact text field element. + * * @return A new TextFieldBuilder instance configured for compact text input. */ public static TextFieldBuilder compactTextField() { @@ -143,6 +147,7 @@ public static TextFieldBuilder compactTextField() { /** * Sets the initial value of the text field. + * * @param value The initial value to set for the text field. * @return This TextFieldBuilder instance for method chaining. */ @@ -154,6 +159,7 @@ public TextFieldBuilder withValue(String value) { /** * Sets the placeholder text for the text field. + * * @param placeholderText The placeholder text to set. * @return This TextFieldBuilder instance for method chaining. */ @@ -164,6 +170,7 @@ public TextFieldBuilder withPlaceholderText(String placeholderText) { /** * Sets the style for the placeholder text. + * * @param placeholderStyle The style reference for the placeholder text. * @return This TextFieldBuilder instance for method chaining. */ @@ -183,6 +190,7 @@ public TextFieldBuilder withPlaceholderStyle(HyUIStyle placeholderStyle) { /** * Sets the style for the placeholder text. + * * @param placeholderStyle The style reference for the placeholder text. * @return This TextFieldBuilder instance for method chaining. */ @@ -192,6 +200,7 @@ public TextFieldBuilder withPlaceholderStyle(InputFieldStyle placeholderStyle) { /** * Sets the input field decoration style. + * * @param decoration The decoration style to apply. * @return This TextFieldBuilder instance for method chaining. */ @@ -202,6 +211,7 @@ public TextFieldBuilder withDecoration(InputFieldDecorationStyle decoration) { /** * Sets the maximum length of the text field. + * * @param maxLength The maximum length to set. * @return This TextFieldBuilder instance for method chaining. */ @@ -212,6 +222,7 @@ public TextFieldBuilder withMaxLength(int maxLength) { /** * Sets the maximum visible lines for the text field. + * * @param maxVisibleLines The maximum number of visible lines to set. * @return This TextFieldBuilder instance for method chaining. */ @@ -222,6 +233,7 @@ public TextFieldBuilder withMaxVisibleLines(int maxVisibleLines) { /** * Sets whether the text field is read-only. + * * @param readOnly Whether the field should be read-only. * @return This TextFieldBuilder instance for method chaining. */ @@ -232,6 +244,7 @@ public TextFieldBuilder withReadOnly(boolean readOnly) { /** * Sets whether the text field is in password mode. + * * @param password Whether password mode should be enabled. * @return This TextFieldBuilder instance for method chaining. */ @@ -242,6 +255,7 @@ public TextFieldBuilder withPassword(boolean password) { /** * Sets the character to display in password mode. + * * @param passwordChar The password character to set. * @return This TextFieldBuilder instance for method chaining. */ @@ -252,6 +266,7 @@ public TextFieldBuilder withPasswordChar(String passwordChar) { /** * Sets whether the text field should automatically grow with content. + * * @param autoGrow Whether the field should automatically grow. * @return This TextFieldBuilder instance for method chaining. */ @@ -262,6 +277,7 @@ public TextFieldBuilder withAutoGrow(boolean autoGrow) { /** * Sets the background patch style for the text field. + * * @param background The background patch style to use. * @return This TextFieldBuilder instance for method chaining. */ @@ -280,6 +296,7 @@ public HyUIPatchStyle getBackground() { /** * Sets the background style reference for the text field. + * * @param styleReference The style reference (e.g., "InputBoxBackground"). * @return This TextFieldBuilder instance for method chaining. */ @@ -290,7 +307,8 @@ public TextFieldBuilder withBackground(String styleReference) { /** * Sets the background style reference for the text field from a document. - * @param document The style document (e.g., "Common.ui"). + * + * @param document The style document (e.g., "Common.ui"). * @param styleReference The style reference (e.g., "InputBoxBackground"). * @return This TextFieldBuilder instance for method chaining. */ @@ -303,6 +321,7 @@ public TextFieldBuilder withBackground(String document, String styleReference) { /** * Sets the padding used for the text contents. + * * @param padding The content padding to apply. * @return This TextFieldBuilder instance for method chaining. */ @@ -345,14 +364,14 @@ public String getScrollbarStyleDocument() { /** * Adds an event listener to the text field builder for handling a specific type of UI event. * - * @param type The type of the event to bind the listener to. This specifies what kind of UI event - * should trigger the provided callback. - * @param callback The function to be executed when the specified event is triggered. The callback + * @param type The type of the event to bind the listener to. This specifies what kind of UI event + * should trigger the provided callback. + * @param callback The function to be executed when the specified event is triggered. The callback * processes a string argument associated with the event. * @return This TextFieldBuilder instance for method chaining. */ public TextFieldBuilder addEventListener(CustomUIEventBindingType type, Consumer callback) { - return addEventListener(type, String.class, callback); + return addEventListenerWithContext(type, callback); } /** @@ -363,28 +382,28 @@ public TextFieldBuilder addEventListener(CustomUIEventBindingType type, Consumer * @return This TextFieldBuilder instance for method chaining. */ public TextFieldBuilder addEventListener(CustomUIEventBindingType type, BiConsumer callback) { - return addEventListenerWithContext(type, String.class, callback); + return addEventListenerWithContext(type, callback); } /** * Adds an event listener for the RightClicking event. */ public TextFieldBuilder onRightClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public TextFieldBuilder onRightClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event with context. */ public TextFieldBuilder onRightClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } @Override @@ -490,17 +509,21 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { if (backgroundStyleReference != null && backgroundStyleDocument != null) { commands.set(selector + ".Background", Value.ref(backgroundStyleDocument, backgroundStyleReference)); } - + applyScrollbarStyle(commands, selector); if (contentPadding != null) { - if (contentPadding.getLeft() != null) commands.set(selector + ".ContentPadding.Left", contentPadding.getLeft()); - if (contentPadding.getTop() != null) commands.set(selector + ".ContentPadding.Top", contentPadding.getTop()); - if (contentPadding.getRight() != null) commands.set(selector + ".ContentPadding.Right", contentPadding.getRight()); - if (contentPadding.getBottom() != null) commands.set(selector + ".ContentPadding.Bottom", contentPadding.getBottom()); + if (contentPadding.getLeft() != null) + commands.set(selector + ".ContentPadding.Left", contentPadding.getLeft()); + if (contentPadding.getTop() != null) + commands.set(selector + ".ContentPadding.Top", contentPadding.getTop()); + if (contentPadding.getRight() != null) + commands.set(selector + ".ContentPadding.Right", contentPadding.getRight()); + if (contentPadding.getBottom() != null) + commands.set(selector + ".ContentPadding.Bottom", contentPadding.getBottom()); } - if ( hyUIStyle == null && typedStyle == null && style != null) { + if (hyUIStyle == null && typedStyle == null && style != null) { HyUIPlugin.getLog().logFinest("Setting Style: " + style + " for " + selector); commands.set(selector + ".Style", style); } else if (hyUIStyle == null && typedStyle != null) { @@ -508,12 +531,16 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { } if (listeners.isEmpty()) { // To handle data back to the .getValue, we need to add at least one listener. - addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> {}); + addEventListener(CustomUIEventBindingType.ValueChanged, (_, _) -> { + }); if (!isMultiline) { - addEventListener(CustomUIEventBindingType.FocusLost, (_, _) -> {}); - addEventListener(CustomUIEventBindingType.FocusGained, (_, _) -> {}); + addEventListener(CustomUIEventBindingType.FocusLost, (_, _) -> { + }); + addEventListener(CustomUIEventBindingType.FocusGained, (_, _) -> { + }); // Causes target element in custom UI event binding is not marked as bindable. - addEventListener(CustomUIEventBindingType.Validating, (_, _) -> {}); + addEventListener(CustomUIEventBindingType.Validating, (_, _) -> { + }); } } listeners.forEach(listener -> { diff --git a/src/main/java/au/ellie/hyui/builders/ToggleButtonBuilder.java b/src/main/java/au/ellie/hyui/builders/ToggleButtonBuilder.java index 5ec1aaa..498961b 100644 --- a/src/main/java/au/ellie/hyui/builders/ToggleButtonBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/ToggleButtonBuilder.java @@ -23,10 +23,7 @@ import au.ellie.hyui.events.MouseEventData; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventActions; -import au.ellie.hyui.events.UIEventListener; import au.ellie.hyui.types.ButtonStyle; -import au.ellie.hyui.utils.ParseUtils; -import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBinding; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.ui.builder.EventData; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; @@ -49,7 +46,7 @@ public ToggleButtonBuilder() { this.initialValue = false; } - + public static ToggleButtonBuilder toggleButton() { return new ToggleButtonBuilder(); } @@ -68,112 +65,112 @@ public ToggleButtonBuilder withIsChecked(boolean isChecked) { * Adds an event listener for the ValueChanged event (toggle state change). */ public ToggleButtonBuilder onValueChanged(Consumer callback) { - return addEventListener(CustomUIEventBindingType.ValueChanged, Boolean.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } /** * Adds an event listener for the ValueChanged event with context. */ public ToggleButtonBuilder onValueChanged(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, Boolean.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.ValueChanged, callback); } /** * Adds an event listener for the Activating event. */ public ToggleButtonBuilder onActivating(Runnable callback) { - return addEventListener(CustomUIEventBindingType.Activating, Void.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.Activating, callback); } /** * Adds an event listener for the Activating event with context. */ public ToggleButtonBuilder onActivating(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.Activating, Void.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.Activating, callback); } /** * Adds an event listener for the DoubleClicking event. */ public ToggleButtonBuilder onDoubleClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event. */ public ToggleButtonBuilder onDoubleClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the DoubleClicking event with context. */ public ToggleButtonBuilder onDoubleClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.DoubleClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public ToggleButtonBuilder onRightClicking(Runnable callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event. */ public ToggleButtonBuilder onRightClicking(Consumer callback) { - return addEventListener(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the RightClicking event with context. */ public ToggleButtonBuilder onRightClicking(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.RightClicking, callback); } /** * Adds an event listener for the MouseEntered event. */ public ToggleButtonBuilder onMouseEntered(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event. */ public ToggleButtonBuilder onMouseEntered(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseEntered event with context. */ public ToggleButtonBuilder onMouseEntered(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseEntered, callback); } /** * Adds an event listener for the MouseExited event. */ public ToggleButtonBuilder onMouseExited(Runnable callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, v -> callback.run()); + return addEventListener(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event. */ public ToggleButtonBuilder onMouseExited(Consumer callback) { - return addEventListener(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } /** * Adds an event listener for the MouseExited event with context. */ public ToggleButtonBuilder onMouseExited(BiConsumer callback) { - return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, MouseEventData.class, callback); + return addEventListenerWithContext(CustomUIEventBindingType.MouseExited, callback); } @Override @@ -210,12 +207,12 @@ protected void applyRuntimeValue(Object value) { this.initialValue = next; } } - + @Override protected Object parseValue(String rawValue) { return Boolean.parseBoolean(rawValue); } - + @Override protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { String selector = getSelector(); @@ -228,7 +225,8 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { if (listeners.isEmpty()) { // To handle data back to the .getValue, we need to add at least one listener. - listeners.add(new UIEventListener(CustomUIEventBindingType.ValueChanged, (Boolean v, UIContext ctx) -> {})); + addEventListener(CustomUIEventBindingType.ValueChanged, () -> { + }); } // Register event listeners @@ -238,33 +236,33 @@ protected void onBuild(UICommandBuilder commands, UIEventBuilder events) { HyUIPlugin.getLog().logFinest("Adding ValueChanged event binding for " + selector + " with eventId: " + eventId); events.addEventBinding(CustomUIEventBindingType.ValueChanged, selector, EventData.of("@ValueBool", selector + ".IsChecked") - .append("Target", eventId) - .append("Action", UIEventActions.VALUE_CHANGED), false); + .append("Target", eventId) + .append("Action", UIEventActions.VALUE_CHANGED), false); } else if (listener.type() == CustomUIEventBindingType.Activating) { HyUIPlugin.getLog().logFinest("Adding Activating event binding: " + eventId + " for " + selector); events.addEventBinding(CustomUIEventBindingType.Activating, selector, EventData.of("Action", UIEventActions.BUTTON_CLICKED) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.DoubleClicking) { HyUIPlugin.getLog().logFinest("Adding DoubleClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.DoubleClicking, selector, EventData.of("Action", UIEventActions.DOUBLE_CLICKING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.RightClicking) { HyUIPlugin.getLog().logFinest("Adding RightClicking event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.RightClicking, selector, EventData.of("Action", UIEventActions.RIGHT_CLICKING) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.MouseEntered) { HyUIPlugin.getLog().logFinest("Adding MouseEntered event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseEntered, selector, EventData.of("Action", UIEventActions.MOUSE_ENTERED) - .append("Target", eventId), false); + .append("Target", eventId), false); } else if (listener.type() == CustomUIEventBindingType.MouseExited) { HyUIPlugin.getLog().logFinest("Adding MouseExited event binding for " + selector); events.addEventBinding(CustomUIEventBindingType.MouseExited, selector, EventData.of("Action", UIEventActions.MOUSE_EXITED) - .append("Target", eventId), false); + .append("Target", eventId), false); } }); } diff --git a/src/main/java/au/ellie/hyui/builders/UIElementBuilder.java b/src/main/java/au/ellie/hyui/builders/UIElementBuilder.java index a723873..efd4e6e 100644 --- a/src/main/java/au/ellie/hyui/builders/UIElementBuilder.java +++ b/src/main/java/au/ellie/hyui/builders/UIElementBuilder.java @@ -19,39 +19,35 @@ package au.ellie.hyui.builders; import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.theme.Theme; +import au.ellie.hyui.elements.BackgroundSupported; import au.ellie.hyui.events.UIContext; import au.ellie.hyui.events.UIEventListener; -import au.ellie.hyui.elements.BackgroundSupported; +import au.ellie.hyui.theme.Theme; import au.ellie.hyui.types.HyUIBsonSerializable; -import au.ellie.hyui.types.LabelSpan; import au.ellie.hyui.types.MouseWheelScrollBehaviourType; import au.ellie.hyui.types.TextTooltipStyle; import au.ellie.hyui.utils.BsonDocumentHelper; import au.ellie.hyui.utils.PropertyBatcher; -import com.hypixel.hytale.codec.EmptyExtraInfo; +import com.hypixel.hytale.function.consumer.TriConsumer; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.Message; import com.hypixel.hytale.server.core.ui.Value; import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; import org.bson.BsonArray; import org.bson.BsonDocument; import org.bson.BsonValue; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + /** - * A builder class for constructing UI elements with a hierarchical structure and configurable - * properties. The {@code UIElementBuilder} class provides an API for specifying attributes such - * as styles, visibility, children, tooltips, custom callbacks, and more. This class is intended + * A builder class for constructing UI elements with a hierarchical structure and configurable + * properties. The {@code UIElementBuilder} class provides an API for specifying attributes such + * as styles, visibility, children, tooltips, custom callbacks, and more. This class is intended * to be extended and further customized. */ public abstract class UIElementBuilder> implements BackgroundSupported { @@ -110,7 +106,7 @@ public abstract class UIElementBuilder> implements ); private static int idCounter = 0; - + protected boolean isUpdateOnly = false; public UIElementBuilder(String elementPath, String typeSelector) { @@ -134,7 +130,7 @@ protected boolean supportsStyling() { protected boolean preserveChildrenOnTemplateMerge() { return false; } - + public T withUiFile(String uiFilePath) { this.uiFilePath = uiFilePath; return (T) this; @@ -144,7 +140,7 @@ public T addChild(UIElementBuilder child) { this.children.add(child); return (T) this; } - + public String getEffectiveId() { return id; } @@ -212,7 +208,7 @@ public List> getListeners() { /** * Parses the raw value received from a UI event into the appropriate type for this element. * Defaults to returning the raw value as a string. - * + * * @param rawValue The raw string value from the UI event. * @return The parsed value object, or null if parsing fails or is not supported. */ @@ -269,6 +265,7 @@ private String sanitizeId(String id) { /** * Deprecated. For removal. + * * @param style the style to apply to the element * @return the builder instance for method chaining */ @@ -281,7 +278,7 @@ public T withStyle(String style) { /** * Applies the specified style to the current UI element if styling is supported. - * + * * @param style the {@code HyUIStyle} instance to be applied to the UI element * @return the builder instance of type {@code T} for method chaining */ @@ -309,7 +306,7 @@ public T withStyle(HyUIBsonSerializable style) { /** * Sets the parent selector for the UI element being built. - * The parent selector specifies the selector of the parent element + * The parent selector specifies the selector of the parent element * in which this element will be nested. * * @param parentSelector the selector of the parent element @@ -624,7 +621,7 @@ public T editElementAfter(BiConsumer callback) { * * @param callback a {@code BiConsumer} that accepts a {@code UICommandBuilder} instance * and a {@code String} as parameters. The {@code UICommandBuilder} - * is used to modify the UI commands, and the {@code String} represents + * is used to modify the UI commands, and the {@code String} represents * the element's path or identifier. * @return the current builder instance of type {@code T} for method chaining */ @@ -664,18 +661,18 @@ protected void build(UICommandBuilder commands, UIEventBuilder events, boolean u StringBuilder paddingMarkup = new StringBuilder(); if (padding.getLeft() != null) paddingMarkup.append("Left: ").append(padding.getLeft()); if (padding.getTop() != null) { - if (paddingMarkup.length() > 0) paddingMarkup.append(", "); + if (!paddingMarkup.isEmpty()) paddingMarkup.append(", "); paddingMarkup.append("Top: ").append(padding.getTop()); } if (padding.getRight() != null) { - if (paddingMarkup.length() > 0) paddingMarkup.append(", "); + if (!paddingMarkup.isEmpty()) paddingMarkup.append(", "); paddingMarkup.append("Right: ").append(padding.getRight()); } if (padding.getBottom() != null) { - if (paddingMarkup.length() > 0) paddingMarkup.append(", "); + if (!paddingMarkup.isEmpty()) paddingMarkup.append(", "); paddingMarkup.append("Bottom: ").append(padding.getBottom()); } - if (paddingMarkup.length() > 0) { + if (!paddingMarkup.isEmpty()) { inlineMarkup.append("Padding: (").append(paddingMarkup).append("); "); } } @@ -716,8 +713,8 @@ protected void buildBase(UICommandBuilder commands, UIEventBuilder events, boole // If it's a file but NOT wrapped, we need to set the ID of the root element in that file // if it's not already correct. if (!wrapInGroup && !id.equals(typeSelector != null ? typeSelector.replace("#", "") : "")) { - // We might need to rename the element we just appended. - // Let's assume for now that if it's not wrapped, it's a singleton or handled by user. + // We might need to rename the element we just appended. + // Let's assume for now that if it's not wrapped, it's a singleton or handled by user. } } else if (hasCustomInlineContent()) { String inline = generateCustomInlineContent(); @@ -729,7 +726,7 @@ protected void buildBase(UICommandBuilder commands, UIEventBuilder events, boole commands.appendInline(parentSelector, inline); } } - + if (anchor != null) { HyUIPlugin.getLog().logFinest("Setting Anchor for " + selector); commands.setObject(selector + ".Anchor", anchor.toHytaleAnchor()); @@ -748,7 +745,8 @@ protected void buildBase(UICommandBuilder commands, UIEventBuilder events, boole if (padding.getLeft() != null) commands.set(groupSelector + ".Padding.Left", padding.getLeft()); if (padding.getTop() != null) commands.set(groupSelector + ".Padding.Top", padding.getTop()); if (padding.getRight() != null) commands.set(groupSelector + ".Padding.Right", padding.getRight()); - if (padding.getBottom() != null) commands.set(groupSelector + ".Padding.Bottom", padding.getBottom()); + if (padding.getBottom() != null) + commands.set(groupSelector + ".Padding.Bottom", padding.getBottom()); } } @@ -840,7 +838,7 @@ protected void buildBase(UICommandBuilder commands, UIEventBuilder events, boole HyUIPlugin.getLog().logFinest("Setting Overscroll for " + selector); commands.set(selector + ".Overscroll", overscroll); } - + // Cannot set for checkbox builder. if (typedStyle != null && !(this instanceof CheckBoxBuilder) && !(hyUIStyle != null && hyUIStyle.getStyleReference() != null)) { BsonDocumentHelper doc = PropertyBatcher.beginSet(); @@ -967,23 +965,22 @@ private static void filterBsonDocument(BsonDocument document, Set suppor } @SuppressWarnings("unchecked") - public T addEventListener(CustomUIEventBindingType type, Class valueClass, Consumer callback) { - return addEventListenerInternal(type, callback); + public T addEventListener(CustomUIEventBindingType type, Runnable callback) { + return addEventListenerWithContext(type, (val, _, _) -> callback.run()); } @SuppressWarnings("unchecked") - public T addEventListenerWithContext(CustomUIEventBindingType type, Class valueClass, BiConsumer callback) { - return addEventListenerInternal(type, callback); + public T addEventListenerWithContext(CustomUIEventBindingType type, Consumer callback) { + return addEventListenerWithContext(type, (val, _, _) -> ((Consumer) callback).accept(val)); } @SuppressWarnings("unchecked") - protected T addEventListenerInternal(CustomUIEventBindingType type, Consumer callback) { - this.listeners.add(new UIEventListener<>(type, (val, ctx) -> ((Consumer) callback).accept(val))); - return (T) this; + public T addEventListenerWithContext(CustomUIEventBindingType type, BiConsumer callback) { + return addEventListenerWithContext(type, (val, context, _) -> ((BiConsumer) callback).accept(val, context)); } @SuppressWarnings("unchecked") - protected T addEventListenerInternal(CustomUIEventBindingType type, BiConsumer callback) { + public T addEventListenerWithContext(CustomUIEventBindingType type, TriConsumer callback) { this.listeners.add(new UIEventListener<>(type, callback)); return (T) this; } @@ -1001,10 +998,10 @@ protected String generateBasicInlineMarkup() { if (id != null && !wrapInGroup) { sb.append(" #").append(id); } - + sb.append(" {"); sb.append("}"); - + return sb.toString(); } @@ -1019,8 +1016,8 @@ protected String getSelector() { * Applies the provided style settings to the given command builder while handling unsupported properties. * * @param commands The UICommandBuilder used to set style properties. - * @param prefix A string used as a prefix for property keys when applying the styles. - * @param style An instance of HyUIStyle containing the properties to be applied to the command builder. + * @param prefix A string used as a prefix for property keys when applying the styles. + * @param style An instance of HyUIStyle containing the properties to be applied to the command builder. */ protected void applyStyle(UICommandBuilder commands, String prefix, HyUIStyle style, BsonDocumentHelper doc) { if (style.getStyleReference() != null) { @@ -1032,7 +1029,7 @@ protected void applyStyle(UICommandBuilder commands, String prefix, HyUIStyle st boolean whitelist = isStyleWhitelist(); Set supported = whitelist ? getSupportedStyleProperties() : Set.of(); java.util.function.Predicate isAllowed = property -> !whitelist || supported.contains(property); - + if (style.getFontSize() != null && isAllowed.test("FontSize")) { HyUIPlugin.getLog().logFinest("Setting Style FontSize: " + style.getFontSize() + " for " + prefix); doc.set("FontSize", style.getFontSize().doubleValue()); @@ -1082,7 +1079,7 @@ protected void applyStyle(UICommandBuilder commands, String prefix, HyUIStyle st doc.set("Alignment", style.getAlignment().name()); } } - + protected void applyRawStyleProperties(UICommandBuilder commands, String prefix, HyUIStyle style) { boolean whitelist = isStyleWhitelist(); Set supported = whitelist ? getSupportedStyleProperties() : Set.of(); @@ -1103,18 +1100,30 @@ protected void applyRawStyleProperties(UICommandBuilder commands, String prefix, } }); } + protected String getWrappingGroupId() { return id; } + /** + * Maps the provided event binding type to a potentially different type. This method can be overridden + * by subclasses to provide custom mapping logic for event types. By default, it returns the input type unchanged. + * + * @param type the original event binding type + * @return the mapped event binding type + */ + protected CustomUIEventBindingType getEventTypeMapped(CustomUIEventBindingType type) { + return type; + } + /** * Builds the child elements of the current UI element and registers their commands - * and events within the provided builders. If the current element has a selector, + * and events within the provided builders. If the current element has a selector, * each child is nested inside that selector during the build process. * - * @param commands an instance of {@code UICommandBuilder} used for constructing + * @param commands an instance of {@code UICommandBuilder} used for constructing * UI commands associated with the child elements - * @param events an instance of {@code UIEventBuilder} used for setting up event + * @param events an instance of {@code UIEventBuilder} used for setting up event * handling for the child elements */ protected void buildChildren(UICommandBuilder commands, UIEventBuilder events, boolean updateOnly) { @@ -1137,7 +1146,7 @@ private void executeBuild(UICommandBuilder commands, UIEventBuilder events, bool for (BiConsumer callback : editBeforeCallbacks) { callback.accept(commands, selector); } - + onBuild(commands, events); buildChildren(commands, events, updateOnly); diff --git a/src/main/java/au/ellie/hyui/commands/AbstractDevCommand.java b/src/main/java/au/ellie/hyui/commands/AbstractDevCommand.java new file mode 100644 index 0000000..57983dc --- /dev/null +++ b/src/main/java/au/ellie/hyui/commands/AbstractDevCommand.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.commands; + +import au.ellie.hyui.HyUIPluginLogger; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.protocol.GameMode; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.checkerframework.checker.nullness.compatqual.NonNullDecl; + +import java.util.concurrent.CompletableFuture; + +import static com.hypixel.hytale.server.core.command.commands.player.inventory.InventorySeeCommand.MESSAGE_COMMANDS_ERRORS_PLAYER_NOT_IN_WORLD; + +public abstract class AbstractDevCommand extends AbstractAsyncCommand { + public AbstractDevCommand(@NonNullDecl String name, @NonNullDecl String description) { + super(name, description); + if (!HyUIPluginLogger.IS_DEV) + return; + + this.setPermissionGroup(GameMode.Adventure); + } + + @NonNullDecl + @Override + protected CompletableFuture executeAsync(@NonNullDecl CommandContext commandContext) { + if (!HyUIPluginLogger.IS_DEV) + return CompletableFuture.completedFuture(null); + + var sender = commandContext.sender(); + if (!(sender instanceof Player player)) + return CompletableFuture.completedFuture(null); + + var ref = player.getReference(); + if (ref == null || !ref.isValid()) { + commandContext.sendMessage(MESSAGE_COMMANDS_ERRORS_PLAYER_NOT_IN_WORLD); + return CompletableFuture.completedFuture(null); + } + + return executeDev(player, ref, commandContext); + } + + @NonNullDecl + abstract CompletableFuture executeDev(Player player, Ref ref, CommandContext commandContext); +} diff --git a/src/main/java/au/ellie/hyui/commands/HyUIAddHudCommand.java b/src/main/java/au/ellie/hyui/commands/HyUIAddHudCommand.java index f38e8d7..c6cb215 100644 --- a/src/main/java/au/ellie/hyui/commands/HyUIAddHudCommand.java +++ b/src/main/java/au/ellie/hyui/commands/HyUIAddHudCommand.java @@ -21,15 +21,11 @@ import au.ellie.hyui.builders.HudBuilder; import au.ellie.hyui.builders.HyUIHud; import au.ellie.hyui.builders.LabelBuilder; -import au.ellie.hyui.HyUIPluginLogger; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; -import com.hypixel.hytale.protocol.GameMode; import com.hypixel.hytale.server.core.command.system.CommandContext; -import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.universe.PlayerRef; -import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import org.checkerframework.checker.nullness.compatqual.NonNullDecl; @@ -37,9 +33,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; -import static com.hypixel.hytale.server.core.command.commands.player.inventory.InventorySeeCommand.MESSAGE_COMMANDS_ERRORS_PLAYER_NOT_IN_WORLD; - -public class HyUIAddHudCommand extends AbstractAsyncCommand { +public class HyUIAddHudCommand extends AbstractDevCommand { public static final List HUD_INSTANCES = new ArrayList<>(); @@ -47,56 +41,33 @@ public class HyUIAddHudCommand extends AbstractAsyncCommand { public HyUIAddHudCommand() { super("add", "Adds a new HTML HUD"); - if (!HyUIPluginLogger.IS_DEV) { - return; - } - this.setPermissionGroup(GameMode.Adventure); } @NonNullDecl - @Override - protected CompletableFuture executeAsync(CommandContext commandContext) { - if (!HyUIPluginLogger.IS_DEV) { - return CompletableFuture.completedFuture(null); - } - var sender = commandContext.sender(); - if (sender instanceof Player player) { - Ref ref = player.getReference(); - if (ref != null && ref.isValid()) { - Store store = ref.getStore(); - World world = store.getExternalData().getWorld(); - return CompletableFuture.runAsync(() -> { - PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); - if (playerRef != null) { - addHud(playerRef, store); - } - }, world); - } else { - commandContext.sendMessage(MESSAGE_COMMANDS_ERRORS_PLAYER_NOT_IN_WORLD); - return CompletableFuture.completedFuture(null); - } - } else { - return CompletableFuture.completedFuture(null); - } + protected CompletableFuture executeDev(Player player, Ref ref, CommandContext commandContext) { + var store = ref.getStore(); + var world = store.getExternalData().getWorld(); + + return CompletableFuture.runAsync(() -> { + var playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + if (playerRef != null) + addHud(playerRef, store); + }, world); } private void addHud(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } - String html = """ -
-
- - + var html = """ +
+
+ + +
-
- """; + """; if (TEST == null) { - /*HyUIHud hud = HudBuilder.detachedHud() .fromFile("Pages/replicate.ui") .editElement(uiCommandBuilder -> { @@ -110,6 +81,7 @@ private void addHud(PlayerRef playerRef, Store store) { TEST = hud;*/ } + var hud2 = HudBuilder.detachedHud() .fromHtml(html) .withRefreshRate(5000) @@ -118,10 +90,12 @@ private void addHud(PlayerRef playerRef, Store store) { builder.withText("Hello, World! " + System.currentTimeMillis()); }); //playerRef.sendMessage(Message.raw("HUD Refreshed!")); - }) - .show(playerRef, store); - hud2.getById("Hello", LabelBuilder.class).ifPresent((builder) -> { - builder.withText("Hello, BAD! " + System.currentTimeMillis()); - }); + }).show(playerRef); + + hud2.getById("Hello", LabelBuilder.class).ifPresent((builder) -> + builder.withText("Hello, BAD! " + System.currentTimeMillis()) + ); + + HUD_INSTANCES.add(hud2); } } diff --git a/src/main/java/au/ellie/hyui/commands/HyUIBountyCommand.java b/src/main/java/au/ellie/hyui/commands/HyUIBountyCommand.java index 1ad5cdd..842f4e0 100644 --- a/src/main/java/au/ellie/hyui/commands/HyUIBountyCommand.java +++ b/src/main/java/au/ellie/hyui/commands/HyUIBountyCommand.java @@ -18,31 +18,24 @@ package au.ellie.hyui.commands; -import au.ellie.hyui.HyUIPluginLogger; -import au.ellie.hyui.builders.ButtonBuilder; -import au.ellie.hyui.builders.GroupBuilder; -import au.ellie.hyui.builders.LabelBuilder; -import au.ellie.hyui.builders.PageBuilder; +import au.ellie.hyui.builders.*; import au.ellie.hyui.html.TemplateProcessor; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; -import com.hypixel.hytale.protocol.GameMode; import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.command.system.CommandContext; -import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.universe.PlayerRef; -import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import org.checkerframework.checker.nullness.compatqual.NonNullDecl; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import java.util.List; -public class HyUIBountyCommand extends AbstractAsyncCommand { +public class HyUIBountyCommand extends AbstractDevCommand { private static final DemoMode DEMO_MODE = DemoMode.TemplateRuntime; private enum DemoMode { @@ -54,92 +47,72 @@ private enum DemoMode { public HyUIBountyCommand() { super("bounty", "Opens the HyUI Bounty Board tutorial page"); - if (!HyUIPluginLogger.IS_DEV) { - return; - } - this.setPermissionGroup(GameMode.Adventure); } @NonNullDecl - @Override - protected CompletableFuture executeAsync(CommandContext commandContext) { - if (!HyUIPluginLogger.IS_DEV) { - return CompletableFuture.completedFuture(null); - } - var sender = commandContext.sender(); - if (sender instanceof Player player) { - Ref ref = player.getReference(); - if (ref != null && ref.isValid()) { - Store store = ref.getStore(); - World world = store.getExternalData().getWorld(); - return CompletableFuture.runAsync(() -> { - PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); - if (playerRef != null) { - openBountyBoardDemo(playerRef, store); - } - }, world); - } - } - return CompletableFuture.completedFuture(null); + protected CompletableFuture executeDev(Player player, Ref ref, CommandContext commandContext) { + var store = ref.getStore(); + var world = store.getExternalData().getWorld(); + + return CompletableFuture.runAsync(() -> { + var playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + if (playerRef != null) + openBountyBoardDemo(playerRef, store); + }, world); } private void openBountyBoard(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } - - String html = """ -
-
-
-
- - - - - - -
- -

Showing 3 bounties

- -
-
-

Slime Cleanup

-

Lvl 1

- -
- -
-

Bandit Camp

-

Lvl 4

- + var html = """ +
+
+
+
+ + + + + +
- -
-

Wisp Hunt

-

Lvl 7

- + +

Showing 3 bounties

+ +
+
+

Slime Cleanup

+

Lvl 1

+ +
+ +
+

Bandit Camp

+

Lvl 4

+ +
+ +
+

Wisp Hunt

+

Lvl 7

+ +
-
- """; - - AtomicBoolean compact = new AtomicBoolean(false); - AtomicInteger counter = new AtomicInteger(3); + """; - PageBuilder builder = PageBuilder.pageForPlayer(playerRef) - .fromHtml(html) - .withLifetime(CustomPageLifetime.CanDismiss); + var compact = new AtomicBoolean(false); + var counter = new AtomicInteger(3); + var builder = PageBuilder.pageForPlayer(playerRef) + .fromHtml(html) + .withLifetime(CustomPageLifetime.CanDismiss); builder.addEventListener("toggle-mode", CustomUIEventBindingType.Activating, (ignored, ctx) -> { - boolean newState = !compact.get(); + var newState = !compact.get(); compact.set(newState); ctx.getById("list", GroupBuilder.class).ifPresent(list -> { @@ -158,7 +131,7 @@ private void openBountyBoard(PlayerRef playerRef, Store store) { }); builder.addEventListener("minLevel", CustomUIEventBindingType.ValueChanged, (data, ctx) -> { - String level = String.valueOf(data); + var level = String.valueOf(data); ctx.getById("summary", LabelBuilder.class).ifPresent(label -> { label.withText("Min level: " + level + " (still 3 bounties)"); }); @@ -166,9 +139,9 @@ private void openBountyBoard(PlayerRef playerRef, Store store) { }); builder.addEventListener("add-bounty", CustomUIEventBindingType.Activating, (ignored, ctx) -> { - int next = counter.incrementAndGet(); - String title = "Urgent Bounty #" + next; - int level = 2 + (next % 6); + var next = counter.incrementAndGet(); + var title = "Urgent Bounty #" + next; + var level = 2 + (next % 6); ctx.getById("list", GroupBuilder.class).ifPresent(list -> { list.addChild(buildBountyCard(title, level)); @@ -194,87 +167,68 @@ private void openBountyBoardDemo(PlayerRef playerRef, Store store) } private void openBountyBoardWithTemplateProcessor(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } - - List bounties = List.of( - new Bounty("Slime Cleanup", 1, "Common"), - new Bounty("Bandit Camp", 4, "Uncommon"), - new Bounty("Wisp Hunt", 7, "Rare") + var bounties = List.of( + new Bounty("Slime Cleanup", 1, "Common"), + new Bounty("Bandit Camp", 4, "Uncommon"), + new Bounty("Wisp Hunt", 7, "Rare") ); - String html = """ -
-
-
-

{{$summary}}

- -
- {{#each bounties}} - {{@bountyCard:title={{$title}},level={{$level}},rarity={{$rarity}}}} - {{/each}} + var html = """ +
+
+
+

{{$summary}}

+ +
+ +
-
- """; - - TemplateProcessor template = createBountyTemplate(bounties); + """; + var template = createBountyTemplate(bounties); PageBuilder.pageForPlayer(playerRef) - .fromTemplate(html, template) - .withLifetime(CustomPageLifetime.CanDismiss) - .open(store); + .fromTemplate(html, template) + .withLifetime(CustomPageLifetime.CanDismiss) + .open(store); } private void openBountyBoardFromTemplateFile(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } - - List bounties = List.of( - new Bounty("Slime Cleanup", 1, "Common"), - new Bounty("Bandit Camp", 4, "Uncommon"), - new Bounty("Wisp Hunt", 7, "Rare") + var bounties = List.of( + new Bounty("Slime Cleanup", 1, "Common"), + new Bounty("Bandit Camp", 4, "Uncommon"), + new Bounty("Wisp Hunt", 7, "Rare") ); - TemplateProcessor template = createBountyTemplate(bounties); - + var template = createBountyTemplate(bounties); PageBuilder.pageForPlayer(playerRef) - .loadHtml("Pages/BountyBoard.html", template) - .withLifetime(CustomPageLifetime.CanDismiss) - .open(store); + .loadHtml("Pages/BountyBoard.html", template) + .withLifetime(CustomPageLifetime.CanDismiss) + .open(store); } private void openBountyBoardWithRuntimeTemplateUpdates(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } - - List bounties = List.of( - new Bounty("Slime Cleanup", 1, "Common"), - new Bounty("Bandit Camp", 4, "Uncommon"), - new Bounty("Wisp Hunt", 7, "Rare") + var bounties = List.of( + new Bounty("Slime Cleanup", 1, "Common"), + new Bounty("Bandit Camp", 4, "Uncommon"), + new Bounty("Wisp Hunt", 7, "Rare") ); - TemplateProcessor template = createBountyTemplate(bounties); - + var template = createBountyTemplate(bounties); PageBuilder builder = PageBuilder.pageForPlayer(playerRef) - .loadHtml("Pages/BountyRuntime.html", template) - .enableRuntimeTemplateUpdates(true) - .withLifetime(CustomPageLifetime.CanDismiss); + .loadHtml("Pages/BountyRuntime.html", template) + .enableRuntimeTemplateUpdates(true) + .withLifetime(CustomPageLifetime.CanDismiss); - builder.addEventListener("region", CustomUIEventBindingType.ValueChanged, (value, ctx) -> { - // If we don't save our state here for minLevel, it will reset to default upon updatePage(true) calling. + builder.registerEventListener("refresh", (value, ctx) -> { + // If we don't save our state here for object, it will reset to default upon updatePage(true) calling. // YOU are responsible for tracking state. NOT HYUI! - ctx.updatePage(true); - }); - builder.addEventListener("minLevel", CustomUIEventBindingType.ValueChanged, (value, ctx) -> { ctx.updatePage(false); }); - builder.addEventListener("close-board", CustomUIEventBindingType.Activating, (ignored, ctx) -> { - ctx.getPage().ifPresent(page -> page.close()); + + builder.registerEventListener("close", (ignored, ctx) -> { + ctx.getPage().ifPresent(HyUIPage::close); }); builder.open(store); @@ -282,38 +236,36 @@ private void openBountyBoardWithRuntimeTemplateUpdates(PlayerRef playerRef, Stor private TemplateProcessor createBountyTemplate(List bounties) { return new TemplateProcessor() - .setVariable("title", "Bounty Board") - .setVariable("summary", "Showing " + bounties.size() + " bounties") - .setVariable("bounties", bounties) - .registerComponent("bountyCard", """ -
-

{{$title}}

-

Lvl {{$level}}

- {{#if level >= 6 || rarity == Rare}} -

Priority

- {{else}} -

Standard

- {{/if}} - -
- """); + .setVariable("title", "Bounty Board") + .setVariable("summary", "Showing " + bounties.size() + " bounties") + .setVariable("bounties", bounties) + .registerComponent("bountyCard", """ +
+

{{$title}}

+

Lvl {{$level}}

+

Priority

+

Standard

+ +
+ """); } private static GroupBuilder buildBountyCard(String title, int level) { return GroupBuilder.group() - .withLayoutMode("Left") - .addChild(LabelBuilder.label() - .withText(title) - .withFlexWeight(2) - ) - .addChild(LabelBuilder.label() - .withText("Lvl " + level) - .withFlexWeight(1) - ) - .addChild(ButtonBuilder.smallTertiaryTextButton() - .withText("Track") - ); + .withLayoutMode("Left") + .addChild(LabelBuilder.label() + .withText(title) + .withFlexWeight(2) + ) + .addChild(LabelBuilder.label() + .withText("Lvl " + level) + .withFlexWeight(1) + ) + .addChild(ButtonBuilder.smallTertiaryTextButton() + .withText("Track") + ); } - private record Bounty(String title, int level, String rarity) {} + private record Bounty(String title, int level, String rarity) { + } } diff --git a/src/main/java/au/ellie/hyui/commands/HyUIRemHudCommand.java b/src/main/java/au/ellie/hyui/commands/HyUIRemHudCommand.java index 2913d8d..0f61459 100644 --- a/src/main/java/au/ellie/hyui/commands/HyUIRemHudCommand.java +++ b/src/main/java/au/ellie/hyui/commands/HyUIRemHudCommand.java @@ -18,45 +18,34 @@ package au.ellie.hyui.commands; -import au.ellie.hyui.builders.HyUIHud; -import au.ellie.hyui.HyUIPluginLogger; -import com.hypixel.hytale.protocol.GameMode; +import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.server.core.Message; import com.hypixel.hytale.server.core.command.system.CommandContext; -import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import org.checkerframework.checker.nullness.compatqual.NonNullDecl; import java.util.concurrent.CompletableFuture; -public class HyUIRemHudCommand extends AbstractAsyncCommand { +public class HyUIRemHudCommand extends AbstractDevCommand { public HyUIRemHudCommand() { super("rem", "Removes the last added HTML HUD"); - if (!HyUIPluginLogger.IS_DEV) { - return; - } - this.setPermissionGroup(GameMode.Adventure); } @NonNullDecl @Override - protected CompletableFuture executeAsync(CommandContext commandContext) { - if (!HyUIPluginLogger.IS_DEV) { - return CompletableFuture.completedFuture(null); - } - if (commandContext.sender() instanceof Player) { + protected CompletableFuture executeDev(Player player, Ref ref, CommandContext commandContext) { + if (HyUIAddHudCommand.TEST != null) HyUIAddHudCommand.TEST.remove(); - if (HyUIAddHudCommand.HUD_INSTANCES.isEmpty()) { - commandContext.sendMessage(Message.raw("No HUDs to remove!")); - return CompletableFuture.completedFuture(null); - } - - HyUIHud lastHud = HyUIAddHudCommand.HUD_INSTANCES.remove(HyUIAddHudCommand.HUD_INSTANCES.size() - 1); - lastHud.remove(); + if (HyUIAddHudCommand.HUD_INSTANCES.isEmpty()) + commandContext.sendMessage(Message.raw("No HUDs to remove!")); + else { commandContext.sendMessage(Message.raw("Removed last HUD.")); + HyUIAddHudCommand.HUD_INSTANCES.removeLast().remove(); } + return CompletableFuture.completedFuture(null); } } diff --git a/src/main/java/au/ellie/hyui/commands/HyUIShowcaseCommand.java b/src/main/java/au/ellie/hyui/commands/HyUIShowcaseCommand.java index c5cccc9..dcac524 100644 --- a/src/main/java/au/ellie/hyui/commands/HyUIShowcaseCommand.java +++ b/src/main/java/au/ellie/hyui/commands/HyUIShowcaseCommand.java @@ -18,21 +18,17 @@ package au.ellie.hyui.commands; -import au.ellie.hyui.HyUIPluginLogger; import au.ellie.hyui.builders.DynamicImageBuilder; import au.ellie.hyui.builders.PageBuilder; import au.ellie.hyui.html.TemplateProcessor; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; -import com.hypixel.hytale.protocol.GameMode; import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.Message; import com.hypixel.hytale.server.core.command.system.CommandContext; -import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.universe.PlayerRef; -import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import org.checkerframework.checker.nullness.compatqual.NonNullDecl; @@ -42,71 +38,55 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; -public class HyUIShowcaseCommand extends AbstractAsyncCommand { +public class HyUIShowcaseCommand extends AbstractDevCommand { public HyUIShowcaseCommand() { super("showcase", "Opens the HyUI 0.5.0 feature showcase"); - if (!HyUIPluginLogger.IS_DEV) { - return; - } - this.setPermissionGroup(GameMode.Adventure); } @NonNullDecl @Override - protected CompletableFuture executeAsync(CommandContext commandContext) { - if (!HyUIPluginLogger.IS_DEV) { - return CompletableFuture.completedFuture(null); - } - var sender = commandContext.sender(); - if (sender instanceof Player player) { - Ref ref = player.getReference(); - if (ref != null && ref.isValid()) { - Store store = ref.getStore(); - World world = store.getExternalData().getWorld(); - return CompletableFuture.runAsync(() -> { - PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); - if (playerRef != null) { - openShowcase(player, playerRef, store); - } - }, world); - } - } - return CompletableFuture.completedFuture(null); - } + protected CompletableFuture executeDev(Player player, Ref ref, CommandContext commandContext) { + var store = ref.getStore(); + var world = store.getExternalData().getWorld(); + + return CompletableFuture.runAsync(() -> { + var playerRef = store.getComponent(ref, PlayerRef.getComponentType()); - private void openShowcase(Player player, PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } + if (playerRef != null) + openShowcase(playerRef, store); + }, world); + } + private void openShowcase(PlayerRef playerRef, Store store) { // Track click count for demo button - AtomicInteger clickCount = new AtomicInteger(0); + var clickCount = new AtomicInteger(0); // Create the template processor with variables and components - TemplateProcessor template = createTemplateProcessor(playerRef); + var template = createTemplateProcessor(playerRef); + var html = createShowcaseHtml(); - String html = createShowcaseHtml(); - - PageBuilder builder = PageBuilder.pageForPlayer(playerRef) + var builder = PageBuilder.pageForPlayer(playerRef) .fromTemplate(html, template) .enableAsyncImageLoading(true) .withLifetime(CustomPageLifetime.CanDismiss); // Interactive demo button - builder.addEventListener("demo-button", CustomUIEventBindingType.Activating, (data, uiCtx) -> { - int count = clickCount.incrementAndGet(); + builder.registerEventListener("demo-button", (_, uiCtx) -> { + var count = clickCount.incrementAndGet(); playerRef.sendMessage(Message.raw("[Showcase] Button clicked " + count + " times!")); + uiCtx.updatePage(true); }); + builder.addEventListener("head-url-button", CustomUIEventBindingType.Activating, (data, ctx) -> { ctx.getValue("head-url-input", String.class).ifPresent(url -> { - if (url.isBlank()) { + if (url.isBlank()) return; - } + ctx.getById("player-head-image", DynamicImageBuilder.class) .ifPresent(image -> image.withImageUrl(url)); - ctx.getPage().ifPresent(page -> page.reloadImage("player-head-image", true) ); + ctx.getPage().ifPresent(page -> page.reloadImage("player-head-image", true)); ctx.updatePage(true); }); }); @@ -115,10 +95,7 @@ private void openShowcase(Player player, PlayerRef playerRef, Store } private TemplateProcessor createTemplateProcessor(PlayerRef playerRef) { - if (!HyUIPluginLogger.IS_DEV) { - return null; - } - List items = List.of( + var items = List.of( new ShowcaseItem("Crude Pickaxe", 6, "Common", new ShowcaseMeta("Starter", "Crafted")), new ShowcaseItem("Stone Hammer", 12, "Rare", new ShowcaseMeta("Journeyman", "Loot")), new ShowcaseItem("Voidblade", 22, "Epic", new ShowcaseMeta("Legend", "Boss Drop")) @@ -151,37 +128,29 @@ private TemplateProcessor createTemplateProcessor(PlayerRef playerRef) { // Register reusable components .registerComponent("statCard", """ -
-

{{$label}}

-

{{$value}}

-
- """) +
+

{{$label}}

+

{{$value}}

+
+ """) .registerComponent("featureItem", """ -
-

*

-

{{$text}}

-
- """) +
+

*

+

{{$text}}

+
+ """) .registerComponent("showcaseItem", """ -
-

{{$name}} (Tier: {{$meta.tier}})

- {{#if power >= minPower && rarity != Common}} -

Power {{$power}}

- {{else}} -

Power {{$power}}

- {{/if}} - {{#if meta.source contains "Craft" || rarity == Epic}} -

Highlight

- {{/if}} -
- """); +
+

{{$name}} (Tier: {{$meta.tier}})

+

Power {{$power}}

+

Power {{$power}}

+

Highlight

+
+ """); } private String createShowcaseHtml() { - if (!HyUIPluginLogger.IS_DEV) { - return ""; - } return """
@@ -189,7 +158,7 @@ private String createShowcaseHtml() {

HyUI 0.5.0 Feature Showcase

-

Welcome, {{$playerName|Guest}}!

+

Welcome, {{$playerName??Guest}}!

@@ -218,7 +187,7 @@ private String createShowcaseHtml() {

Upper: {{$playerName|upper}}

Lower: {{$playerName|lower}}

-

Default: {{$missing|Not Set}}

+

Default: {{$missing??"Not Set"}}

@@ -229,18 +198,11 @@ private String createShowcaseHtml() {

2. Loops + Conditionals

- {{#each items}} - {{@showcaseItem:name={{$name}},meta.tier={{$meta.tier}},power={{$power}},rarity={{$rarity}},meta.source={{$meta.source}}}} - {{/each}} +
- {{#if !isAdmin}} -

Admin mode disabled

- {{else}} -

Admin mode enabled

- {{/if}} - {{#if showcaseTags contains "Legend"}} -

Legend tag active

- {{/if}} +

Admin mode disabled

+

Admin mode enabled

+

Legend tag active

@@ -272,19 +234,19 @@ private String createShowcaseHtml() {

3. Reusable Components

- {{@statCard:label=Blocks Placed,value=12.847}} - {{@statCard:label=Creatures Found,value=23}} - {{@statCard:label=Recipes Learned,value=156}} - {{@statCard:label=Zones Explored,value=4/6}} + + + +

4. New in HyUI 0.5.0

- {{@featureItem:text=TemplateProcessor for variable interpolation}} - {{@featureItem:text=Built-in filters (upper/lower/number/percent)}} - {{@featureItem:text=Reusable components with parameters}} - {{@featureItem:text=TimerLabelBuilder with multiple formats}} + + + +
@@ -306,7 +268,7 @@ private String createShowcaseHtml() {
- +

Click to test event handling

@@ -430,69 +392,9 @@ private String createShowcaseHtml() { """; } - private static final class ShowcaseItem { - private final String name; - private final int power; - private final String rarity; - private final ShowcaseMeta meta; - - private ShowcaseItem(String name, int power, String rarity, ShowcaseMeta meta) { - this.name = name; - this.power = power; - this.rarity = rarity; - this.meta = meta; - } - - public String getName() { - if (!HyUIPluginLogger.IS_DEV) { - return null; - } - return name; - } - - public int getPower() { - if (!HyUIPluginLogger.IS_DEV) { - return 0; - } - return power; - } - - public String getRarity() { - if (!HyUIPluginLogger.IS_DEV) { - return null; - } - return rarity; - } - - public ShowcaseMeta getMeta() { - if (!HyUIPluginLogger.IS_DEV) { - return null; - } - return meta; - } + private record ShowcaseItem(String name, int power, String rarity, ShowcaseMeta meta) { } - private static final class ShowcaseMeta { - private final String tier; - private final String source; - - private ShowcaseMeta(String tier, String source) { - this.tier = tier; - this.source = source; - } - - public String getTier() { - if (!HyUIPluginLogger.IS_DEV) { - return null; - } - return tier; - } - - public String getSource() { - if (!HyUIPluginLogger.IS_DEV) { - return null; - } - return source; - } + private record ShowcaseMeta(String tier, String source) { } } diff --git a/src/main/java/au/ellie/hyui/commands/HyUITabsCommand.java b/src/main/java/au/ellie/hyui/commands/HyUITabsCommand.java index e74ce9d..b6df065 100644 --- a/src/main/java/au/ellie/hyui/commands/HyUITabsCommand.java +++ b/src/main/java/au/ellie/hyui/commands/HyUITabsCommand.java @@ -18,125 +18,101 @@ package au.ellie.hyui.commands; -import au.ellie.hyui.HyUIPluginLogger; import au.ellie.hyui.builders.ButtonBuilder; import au.ellie.hyui.builders.PageBuilder; import au.ellie.hyui.builders.TabNavigationBuilder; import au.ellie.hyui.html.TemplateProcessor; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; -import com.hypixel.hytale.protocol.GameMode; import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.command.system.CommandContext; -import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.universe.PlayerRef; -import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import org.checkerframework.checker.nullness.compatqual.NonNullDecl; import java.util.concurrent.CompletableFuture; -public class HyUITabsCommand extends AbstractAsyncCommand { +public class HyUITabsCommand extends AbstractDevCommand { public HyUITabsCommand() { super("tabs", "Opens the HyUI tabs tutorial demo"); - if (!HyUIPluginLogger.IS_DEV) { - return; - } - this.setPermissionGroup(GameMode.Adventure); } @NonNullDecl @Override - protected CompletableFuture executeAsync(CommandContext commandContext) { - if (!HyUIPluginLogger.IS_DEV) { - return CompletableFuture.completedFuture(null); - } - var sender = commandContext.sender(); - if (sender instanceof Player player) { - Ref ref = player.getReference(); - if (ref != null && ref.isValid()) { - Store store = ref.getStore(); - World world = store.getExternalData().getWorld(); - return CompletableFuture.runAsync(() -> { - PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); - if (playerRef != null) { - openTabsDemo(playerRef, store); - } - }, world); - } - } - return CompletableFuture.completedFuture(null); + protected CompletableFuture executeDev(Player player, Ref ref, CommandContext commandContext) { + var store = ref.getStore(); + var world = store.getExternalData().getWorld(); + + return CompletableFuture.runAsync(() -> { + var playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + if (playerRef != null) + openTabsDemo(playerRef, store); + }, world); } private void openTabsDemo(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } - - String html = """ -
-
-
- - -
-

Blueprint drafts live here.

+ var html = """ +
+
+
+ + +
+

Blueprint drafts live here.

+
+ +
+

Material stacks and salvage.

+ +
+ +
+

Workbench tools and kits.

+
+ +
- -
-

Material stacks and salvage.

- {{@myComponent}} -
- - {{#if isAdmin}} -
-

Workbench tools and kits.

-
- {{/if}} - -
-
- """; + """; - TemplateProcessor template = new TemplateProcessor() + var template = new TemplateProcessor() .setVariable("isAdmin", false) .registerComponent("mySubComponent", """ -

Hello subComponent!

- """) +

Hello subComponent!

+ """) .registerComponent("myComponent", """ -
- {{@mySubComponent}} -
- """); +
+ +
+ """); PageBuilder.detachedPage() .withLifetime(CustomPageLifetime.CanDismiss) .fromHtml(html) .open(playerRef, store); - PageBuilder builder = PageBuilder.pageForPlayer(playerRef) + + var builder = PageBuilder.pageForPlayer(playerRef) .fromTemplate(html, template) .enableRuntimeTemplateUpdates(true) .withLifetime(CustomPageLifetime.CanDismiss); builder.addEventListener("upgrade-tabs", CustomUIEventBindingType.Activating, (data, ctx) -> { ctx.getById("workshop-tabs", TabNavigationBuilder.class).ifPresent(nav -> { - TabNavigationBuilder.Tab existing = nav.getTab("materials"); - if (existing == null) { + var existing = nav.getTab("materials"); + if (existing == null) return; - } ButtonBuilder customButton = ButtonBuilder.smallTertiaryTextButton(); - TabNavigationBuilder.Tab updated = new TabNavigationBuilder.Tab( + var updated = new TabNavigationBuilder.Tab( existing.id(), "Materials+", existing.contentId(), diff --git a/src/main/java/au/ellie/hyui/commands/HyUITemplateRuntimeCommand.java b/src/main/java/au/ellie/hyui/commands/HyUITemplateRuntimeCommand.java index 8d909b8..3d37593 100644 --- a/src/main/java/au/ellie/hyui/commands/HyUITemplateRuntimeCommand.java +++ b/src/main/java/au/ellie/hyui/commands/HyUITemplateRuntimeCommand.java @@ -19,105 +19,81 @@ package au.ellie.hyui.commands; import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.HyUIPluginLogger; import au.ellie.hyui.builders.HyvatarImageBuilder; import au.ellie.hyui.builders.PageBuilder; import au.ellie.hyui.html.TemplateProcessor; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; -import com.hypixel.hytale.protocol.GameMode; import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.command.system.CommandContext; -import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.universe.PlayerRef; -import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import org.checkerframework.checker.nullness.compatqual.NonNullDecl; import java.util.concurrent.CompletableFuture; -public class HyUITemplateRuntimeCommand extends AbstractAsyncCommand { +public class HyUITemplateRuntimeCommand extends AbstractDevCommand { public HyUITemplateRuntimeCommand() { super("tr", "Shows live template values based on element IDs"); - if (!HyUIPluginLogger.IS_DEV) { - return; - } - this.setPermissionGroup(GameMode.Adventure); } @NonNullDecl @Override - protected CompletableFuture executeAsync(CommandContext commandContext) { - if (!HyUIPluginLogger.IS_DEV) { - return CompletableFuture.completedFuture(null); - } - var sender = commandContext.sender(); - if (sender instanceof Player player) { - Ref ref = player.getReference(); - if (ref != null && ref.isValid()) { - Store store = ref.getStore(); - World world = store.getExternalData().getWorld(); - return CompletableFuture.runAsync(() -> { - PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); - if (playerRef != null) { - openTemplateRuntimeDemo(playerRef, store); - } - }, world); + protected CompletableFuture executeDev(Player player, Ref ref, CommandContext commandContext) { + var store = ref.getStore(); + var world = store.getExternalData().getWorld(); + + return CompletableFuture.runAsync(() -> { + PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + if (playerRef != null) { + openTemplateRuntimeDemo(playerRef, store); } - } - return CompletableFuture.completedFuture(null); + }, world); } private void openTemplateRuntimeDemo(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } - - String html = """ + var html = """

Template Runtime Values

-

Other value: {{$other|unset}}

- +

Other value: {{$other ?? unset}}

+

Can Move

Other

-
- +
- +

Live Form

-

Name: {{$displayName|Unknown}}

-

Level: {{$level|0}}

-

Volume: {{$volume|0}}

-

Color: {{$favColor|#ffffff}}

- {{#if termsAccepted}} -

Terms accepted

- {{else}} -

Terms not accepted

- {{/if}} +

Name: {{$displayName ?? Unknown}}

+

Level: {{$level ?? 0}}

+

Volume: {{$volume ?? 0}}

+

Color: {{$favColor ?? #ffffff}}

+

Terms accepted

+

Terms not accepted

- +
@@ -125,18 +101,18 @@ private void openTemplateRuntimeDemo(PlayerRef playerRef, Store sto
- + - +
- + - +
- +

Change any field to reprocess the template and update live values.

@@ -144,19 +120,20 @@ private void openTemplateRuntimeDemo(PlayerRef playerRef, Store sto """; - TemplateProcessor template = new TemplateProcessor() + var template = new TemplateProcessor() .setVariable("playerName", playerRef.getUsername()) .setVariable("displayName", playerRef.getUsername()); - PageBuilder builder = PageBuilder.pageForPlayer(playerRef) + var builder = PageBuilder.pageForPlayer(playerRef) .fromTemplate(html, template) .enableRuntimeTemplateUpdates(true) .withLifetime(CustomPageLifetime.CanDismiss); - builder.addEventListener("other", CustomUIEventBindingType.ValueChanged, (value, ctx) -> { - HyUIPlugin.getLog().logFinest("REBUILD: template runtime update (other)"); + builder.registerEventListener("refresh", (value, ctx) -> { + HyUIPlugin.getLog().logFinest("REBUILD: template runtime update"); ctx.updatePage(false); }); + builder.addEventListener("displayName", CustomUIEventBindingType.FocusLost, (_, ctx) -> { HyUIPlugin.getLog().logFinest("REBUILD: template runtime update (displayName)"); ctx.getValue("displayName", String.class).ifPresent(name -> { @@ -169,22 +146,6 @@ private void openTemplateRuntimeDemo(PlayerRef playerRef, Store sto }); }); }); - builder.addEventListener("level", CustomUIEventBindingType.ValueChanged, (value, ctx) -> { - HyUIPlugin.getLog().logFinest("REBUILD: template runtime update (level)"); - ctx.updatePage(false); - }); - builder.addEventListener("volume", CustomUIEventBindingType.ValueChanged, (value, ctx) -> { - HyUIPlugin.getLog().logFinest("REBUILD: template runtime update (volume)"); - ctx.updatePage(false); - }); - builder.addEventListener("favColor", CustomUIEventBindingType.ValueChanged, (value, ctx) -> { - HyUIPlugin.getLog().logFinest("REBUILD: template runtime update (favColor)"); - ctx.updatePage(false); - }); - builder.addEventListener("termsAccepted", CustomUIEventBindingType.ValueChanged, (value, ctx) -> { - HyUIPlugin.getLog().logFinest("REBUILD: template runtime update (termsAccepted)"); - ctx.updatePage(false); - }); builder.open(store); } diff --git a/src/main/java/au/ellie/hyui/commands/HyUITestGuiCommand.java b/src/main/java/au/ellie/hyui/commands/HyUITestGuiCommand.java index 46ea253..5dfb043 100644 --- a/src/main/java/au/ellie/hyui/commands/HyUITestGuiCommand.java +++ b/src/main/java/au/ellie/hyui/commands/HyUITestGuiCommand.java @@ -19,101 +19,64 @@ package au.ellie.hyui.commands; import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.HyUIPluginLogger; import au.ellie.hyui.builders.*; import au.ellie.hyui.events.PageRefreshResult; import au.ellie.hyui.events.SlotMouseDragCompletedEventData; +import au.ellie.hyui.events.UIContext; import au.ellie.hyui.html.TemplateProcessor; import au.ellie.hyui.types.*; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; -import com.hypixel.hytale.protocol.GameMode; import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.Message; import com.hypixel.hytale.server.core.command.system.CommandContext; -import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.inventory.ItemStack; import com.hypixel.hytale.server.core.ui.ItemGridSlot; import com.hypixel.hytale.server.core.universe.PlayerRef; -import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import org.checkerframework.checker.nullness.compatqual.NonNullDecl; -import java.awt.*; +import java.awt.Color; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; -import static com.hypixel.hytale.server.core.command.commands.player.inventory.InventorySeeCommand.MESSAGE_COMMANDS_ERRORS_PLAYER_NOT_IN_WORLD; - -public class HyUITestGuiCommand extends AbstractAsyncCommand { +public class HyUITestGuiCommand extends AbstractDevCommand { public HyUITestGuiCommand() { super("t", "Opens the HyUI Test GUI"); - if (!HyUIPluginLogger.IS_DEV) { - return; - } - this.setPermissionGroup(GameMode.Adventure); } @NonNullDecl @Override - protected CompletableFuture executeAsync(CommandContext commandContext) { - if (!HyUIPluginLogger.IS_DEV) { - return CompletableFuture.completedFuture(null); - } - if (HyUIPluginLogger.IS_DEV) { - var sender = commandContext.sender(); - if (sender instanceof Player player) { - player.getWorldMapTracker().tick(0); - Ref ref = player.getReference(); - if (ref != null && ref.isValid()) { - Store store = ref.getStore(); - World world = store.getExternalData().getWorld(); - return CompletableFuture.runAsync(() -> { - PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); - if (playerRef != null) { - openReplicate(playerRef, store); - } - }, world); - } else { - commandContext.sendMessage(MESSAGE_COMMANDS_ERRORS_PLAYER_NOT_IN_WORLD); - return CompletableFuture.completedFuture(null); - } - } else { - return CompletableFuture.completedFuture(null); - } - } else { - return CompletableFuture.completedFuture(null); - } + protected CompletableFuture executeDev(Player player, Ref ref, CommandContext commandContext) { + var store = ref.getStore(); + var world = store.getExternalData().getWorld(); + + return CompletableFuture.runAsync(() -> { + var playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + if (playerRef != null) + openReplicate(playerRef, store); + }, world); } - + private void openReplicate(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } new PageBuilder(playerRef) .loadHtml("Pages/Replicate.html", new TemplateProcessor() .setVariable("playerName", "Elyra")) .enableRuntimeTemplateUpdates(true) .open(store); } - + private void openTestGuiMinimal(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } new PageBuilder(playerRef) .fromFile("Pages/EllieAU_HyUI_Placeholder.ui") .open(store); } - private void openHtmlTestGui(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } + private void openHtmlTestGui(PlayerRef playerRef, Store store) { // Resource file: Common/UI/Custom/Pages/HyUIHtmlTest.html /*html = """
@@ -231,8 +194,8 @@ private void openHtmlTestGui(PlayerRef playerRef, Store store) { //HyUIHud hudInstance = HudBuilder.detachedHud() // .fromHtml(html) // .show(playerRef, store); - AtomicInteger clicks = new AtomicInteger(); - PageBuilder builder = PageBuilder.detachedPage() + var clicks = new AtomicInteger(); + var builder = PageBuilder.detachedPage() .loadHtml("Pages/HyUIHtmlTest.html") /*.addEventListener("itemgrid", CustomUIEventBindingType.Dropped, (data, ctx) -> { HyUIPlugin.getLog().logInfo("Item dropped on grid."); @@ -259,9 +222,9 @@ private void openHtmlTestGui(PlayerRef playerRef, Store store) { }) .addEventListener("btn1", CustomUIEventBindingType.Activating, (data, ctx) -> { playerRef.sendMessage(Message.raw("Button clicked via PageBuilder ID lookup!: " + - ctx.getValue("myInput", String.class).orElse("N/A"))); + ctx.getValue("myInput", String.class).orElse("N/A"))); HyUIPlugin.getLog().logFinest("Clicked button."); - ctx.getById("label", LabelBuilder.class).ifPresent(lb -> { + ctx.getById("label", LabelBuilder.class).ifPresent(lb -> { lb.withText("ClicksA: " + String.valueOf(clicks.incrementAndGet())); HyUIPlugin.getLog().logFinest("Found label builder."); ctx.updatePage(true); @@ -278,14 +241,14 @@ private void openHtmlTestGui(PlayerRef playerRef, Store store) { playerRef.sendMessage(Message.raw("Dropdown VALUE: " + val2)); // SETTING VALUE //lb.withValue("Entry3"); - + }); }) - .addEventListener("myInput", CustomUIEventBindingType.ValueChanged, String.class, (val) -> { + .addEventListener("myInput", CustomUIEventBindingType.ValueChanged, (Class val) -> { playerRef.sendMessage(Message.raw("Input changed to: " + val)); }) - .addEventListener("myDropdown", CustomUIEventBindingType.ValueChanged, String.class, (val) -> { + .addEventListener("myDropdown", CustomUIEventBindingType.ValueChanged, (String val) -> { playerRef.sendMessage(Message.raw("Dropdown changed to: " + val)); }); @@ -296,19 +259,14 @@ private void openHtmlTestGui(PlayerRef playerRef, Store store) { }); });*/ builder.open(playerRef, store); - for (String s : builder.getCommandLog()) { + for (String s : builder.getCommandLog()) HyUIPlugin.getLog().logFinest(s); - } } private void openHtmlTestGui2(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } - - PageBuilder builder = PageBuilder.detachedPage() + var builder = PageBuilder.detachedPage() .loadHtml("Pages/ItemGridTest.html") - .addEventListener("itemgrid", CustomUIEventBindingType.SlotMouseDragCompleted, SlotMouseDragCompletedEventData.class, (data, ctx) -> { + .addEventListener("itemgrid", CustomUIEventBindingType.SlotMouseDragCompleted, (SlotMouseDragCompletedEventData data, UIContext _) -> { playerRef.sendMessage(Message.raw("Mouse drag completed on item grid: " + data.getSlotIndex())); playerRef.sendMessage(Message.raw("Mouse drag completed on item grid: " + data.getItemStackId())); }) @@ -317,33 +275,30 @@ private void openHtmlTestGui2(PlayerRef playerRef, Store store) { a.ifPresent(aDouble -> HyUIPlugin.getLog().logFinest("Price input is: " + aDouble)); }) .withLifetime(CustomPageLifetime.CanDismiss); - + builder.getById("itemgrid", ItemGridBuilder.class).ifPresent(ig -> { ig.addSlot(new ItemGridSlot(new ItemStack("Ore_Gold", 25))); ig.addSlot(new ItemGridSlot(new ItemStack("Ore_Iron", 25))); }); - for (CustomUIEventBindingType typeName : CustomUIEventBindingType.values()) { + + for (var typeName : CustomUIEventBindingType.values()) { builder.addEventListener("itemgrid", typeName, (data, ctx) -> { playerRef.sendMessage(Message.raw("Event triggered: " + typeName.name())); }); } - for (CustomUIEventBindingType typeName : CustomUIEventBindingType.values()) { + + for (var typeName : CustomUIEventBindingType.values()) { builder.addEventListener("itemslot", typeName, (data, ctx) -> { playerRef.sendMessage(Message.raw("Event triggered: " + typeName.name())); }); } + builder.open(playerRef, store); - for (String s : builder.getCommandLog()) { + for (String s : builder.getCommandLog()) HyUIPlugin.getLog().logFinest(s); - } } - private void openTestGuiFromScratch(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } - PageBuilder.detachedPage() .withLifetime(CustomPageLifetime.CanDismiss) .addElement(PageOverlayBuilder.pageOverlay() @@ -360,11 +315,8 @@ private void openTestGuiFromScratch(PlayerRef playerRef, Store stor .open(playerRef, store); } - private void openTestGui(PlayerRef playerRef, Store store) { - if (!HyUIPluginLogger.IS_DEV) { - return; - } + private void openTestGui(PlayerRef playerRef, Store store) { new PageBuilder(playerRef) .fromFile("Pages/EllieAU_HyUI_Placeholder.ui") .editElement((commandBuilder) -> { @@ -385,7 +337,7 @@ private void openTestGui(PlayerRef playerRef, Store store) { }) .withTooltipTextSpan(Message.raw("This button has a tooltip now!")) .withStyle(new HyUIStyle().setTextColor("#00FF00").setFontSize(16)) - .addEventListener(CustomUIEventBindingType.Activating, (ignored, ctx) -> { + .addEventListenerWithContext(CustomUIEventBindingType.Activating, (ignored, ctx) -> { String text = ctx.getValue("MyTextField", String.class).orElse("N/A"); Double num = ctx.getValue("ANum", Double.class).orElse(0.0); playerRef.sendMessage(Message.raw("Text Field: " + text + ", Num: " + num)); @@ -490,16 +442,16 @@ private void openTestGui(PlayerRef playerRef, Store store) { .addChild(ButtonBuilder.textButton() .withText("Button with Icon") .withStyle(ButtonStyle.primaryStyle() - .withDefault( - new ButtonStyleState() - .withBackground( - new HyUIPatchStyle().setTexturePath("Pages/Assets/Tab.png"))) - .withHovered( - new ButtonStyleState() - .withBackground( - new HyUIPatchStyle().setTexturePath("Pages/Assets/TabOverlay.png")) - ) - ) + .withDefault( + new ButtonStyleState() + .withBackground( + new HyUIPatchStyle().setTexturePath("Pages/Assets/Tab.png"))) + .withHovered( + new ButtonStyleState() + .withBackground( + new HyUIPatchStyle().setTexturePath("Pages/Assets/TabOverlay.png")) + ) + ) .onDoubleClicking((mouseEventData) -> { playerRef.sendMessage(Message.raw("Button with Icon double clicked: " + mouseEventData)); }) @@ -512,7 +464,7 @@ private void openTestGui(PlayerRef playerRef, Store store) { .onMouseExited((mouseEventData) -> { HyUIPlugin.getLog().logFinest("Button with Icon mouse exited: " + mouseEventData); })) - //.withItemIcon(ItemIconBuilder.itemIcon().withItemId("Items/IronSword.png"))) + //.withItemIcon(ItemIconBuilder.itemIcon().withItemId("Items/IronSword.png"))) .addChild(ContainerBuilder.container() .withId("MyContainer") .withTitleText("Custom Title") @@ -634,14 +586,15 @@ private void openTestGui(PlayerRef playerRef, Store store) { .addChild(LabelBuilder.label() .withText("Pane B")))) .addChild(NativeTabNavigationBuilder.nativeTabNavigation() - .withId("NativeTabNavigationExample") - .withSelectedTab("NativeTabOne") - .withStyle(DefaultStyles.textTopTabsStyle()) - .withAllowUnselection(false) - .onSelectedTabChanged((a) -> {}) - .withAnchor(new HyUIAnchor().setWidth(260).setHeight(36)) - .addTab(new NativeTab().withId("NativeTabOne").withText("HELLO")) - .addTab(new NativeTab().withId("NativeTabTwo").withText("SECOND")) + .withId("NativeTabNavigationExample") + .withSelectedTab("NativeTabOne") + .withStyle(DefaultStyles.textTopTabsStyle()) + .withAllowUnselection(false) + .onSelectedTabChanged((a) -> { + }) + .withAnchor(new HyUIAnchor().setWidth(260).setHeight(36)) + .addTab(new NativeTab().withId("NativeTabOne").withText("HELLO")) + .addTab(new NativeTab().withId("NativeTabTwo").withText("SECOND")) /*.addChild(NativeTabButtonBuilder.nativeTabButton() .withId("NativeTabOne") diff --git a/src/main/java/au/ellie/hyui/commands/HyUIUpdateHudCommand.java b/src/main/java/au/ellie/hyui/commands/HyUIUpdateHudCommand.java index 68bd6b2..f50819f 100644 --- a/src/main/java/au/ellie/hyui/commands/HyUIUpdateHudCommand.java +++ b/src/main/java/au/ellie/hyui/commands/HyUIUpdateHudCommand.java @@ -18,60 +18,38 @@ package au.ellie.hyui.commands; -import au.ellie.hyui.builders.HyUIHud; import au.ellie.hyui.builders.LabelBuilder; -import au.ellie.hyui.HyUIPluginLogger; import com.hypixel.hytale.component.Ref; -import com.hypixel.hytale.component.Store; -import com.hypixel.hytale.protocol.GameMode; import com.hypixel.hytale.server.core.command.system.CommandContext; -import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; import com.hypixel.hytale.server.core.entity.entities.Player; -import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import org.checkerframework.checker.nullness.compatqual.NonNullDecl; import java.util.concurrent.CompletableFuture; -public class HyUIUpdateHudCommand extends AbstractAsyncCommand { +public class HyUIUpdateHudCommand extends AbstractDevCommand { public HyUIUpdateHudCommand() { super("update", "Updates the label in the HUD"); - if (!HyUIPluginLogger.IS_DEV) { - return; - } - this.setPermissionGroup(GameMode.Adventure); } @NonNullDecl - @Override - protected CompletableFuture executeAsync(CommandContext commandContext) { - if (!HyUIPluginLogger.IS_DEV) { - return CompletableFuture.completedFuture(null); - } - var sender = commandContext.sender(); - if (sender instanceof Player player) { - Ref ref = player.getReference(); - if (ref != null && ref.isValid()) { - Store store = ref.getStore(); - World world = store.getExternalData().getWorld(); - return CompletableFuture.runAsync(() -> { - updateHuds(); - }, world); - } - } - return CompletableFuture.completedFuture(null); + protected CompletableFuture executeDev(Player player, Ref ref, CommandContext commandContext) { + var store = ref.getStore(); + var world = store.getExternalData().getWorld(); + + return CompletableFuture.runAsync(this::updateHuds, world); } private void updateHuds() { - if (!HyUIPluginLogger.IS_DEV) { - return; - } - long millis = System.currentTimeMillis(); - for (HyUIHud hud : HyUIAddHudCommand.HUD_INSTANCES) { + var millis = System.currentTimeMillis(); + + for (var hud : HyUIAddHudCommand.HUD_INSTANCES) { hud.getById("Hello", LabelBuilder.class).ifPresent(label -> { label.withText(String.valueOf(millis)); }); + + hud.refreshOrRerender(false, false); } } } diff --git a/src/main/java/au/ellie/hyui/events/UIContext.java b/src/main/java/au/ellie/hyui/events/UIContext.java index 9f6ac3b..90aedf9 100644 --- a/src/main/java/au/ellie/hyui/events/UIContext.java +++ b/src/main/java/au/ellie/hyui/events/UIContext.java @@ -18,10 +18,9 @@ package au.ellie.hyui.events; -import au.ellie.hyui.builders.HyUIHud; import au.ellie.hyui.builders.HyUIPage; -import au.ellie.hyui.builders.LabelBuilder; import au.ellie.hyui.builders.UIElementBuilder; +import com.hypixel.hytale.server.core.entity.entities.Player; import java.util.List; import java.util.Optional; @@ -32,9 +31,11 @@ public interface UIContext { /** * Retrieves the list of logged UI commands. + * * @return A list of strings representing the logged commands. */ List getCommandLog(); + /** * Retrieves the current value of an element by its ID. * @@ -75,16 +76,25 @@ default Optional getValueAs(String id, Class type) { /** * Updates the page associated with this context, rebuilding it if necessary. * Does not update HUDs. + * * @param shouldClear Whether to clear the page before rebuilding. */ void updatePage(boolean shouldClear); + /** + * Updates the page associated with this context in a thread-safe manner, rebuilding it if necessary. + * + * @param playerComponent + * @param shouldClear + */ + void updatePageThreadsafe(Player playerComponent, boolean shouldClear); + /** * Retrieves the builder for a particular element, cast to the specified builder. * - * @param id The ID of the element. + * @param id The ID of the element. * @param clazz The class of the type to cast to. - * @param The expected type of the value. + * @param The expected type of the value. * @return An Optional containing the builder, or empty if not found or if casting fails. */ > Optional getById(String id, Class clazz); @@ -101,9 +111,9 @@ default Optional getValueAs(String id, Class type) { * Retrieves the builder for a particular element, cast to the specified builder type. * This is useful for builders that extend a different self-typed base. * - * @param id The ID of the element. + * @param id The ID of the element. * @param clazz The class of the type to cast to. - * @param The expected type of the builder. + * @param The expected type of the builder. * @return An Optional containing the builder, or empty if not found or if casting fails. */ default > Optional getByIdAs(String id, Class clazz) { diff --git a/src/main/java/au/ellie/hyui/events/UIEventListener.java b/src/main/java/au/ellie/hyui/events/UIEventListener.java index 4affdec..2457fa9 100644 --- a/src/main/java/au/ellie/hyui/events/UIEventListener.java +++ b/src/main/java/au/ellie/hyui/events/UIEventListener.java @@ -18,8 +18,9 @@ package au.ellie.hyui.events; +import com.hypixel.hytale.function.consumer.TriConsumer; import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; -import java.util.function.BiConsumer; -public record UIEventListener(CustomUIEventBindingType type, BiConsumer callback) { +public record UIEventListener(CustomUIEventBindingType type, + TriConsumer callback) { } diff --git a/src/main/java/au/ellie/hyui/html/HtmlParser.java b/src/main/java/au/ellie/hyui/html/HtmlParser.java index 37a02a7..9abc4c0 100644 --- a/src/main/java/au/ellie/hyui/html/HtmlParser.java +++ b/src/main/java/au/ellie/hyui/html/HtmlParser.java @@ -19,10 +19,13 @@ package au.ellie.hyui.html; import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.builders.LabelBuilder; import au.ellie.hyui.builders.InterfaceBuilder; +import au.ellie.hyui.builders.LabelBuilder; import au.ellie.hyui.builders.UIElementBuilder; +import au.ellie.hyui.events.UIContext; import au.ellie.hyui.html.handlers.*; +import com.hypixel.hytale.function.consumer.TriConsumer; +import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; @@ -31,15 +34,19 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * A modular parser that converts HTML/XML-like language to HyUI builders. */ public class HtmlParser { + private final Map> eventListener; private final List handlers = new ArrayList<>(); private TemplateProcessor templateProcessor; - - public HtmlParser() { + + public HtmlParser(Map> eventListener) { + this.eventListener = eventListener; + // Register default handlers registerHandler(new ItemGridHandler()); registerHandler(new TabContentHandler()); @@ -93,7 +100,7 @@ public void setTemplateProcessor(TemplateProcessor processor) { public TemplateProcessor getTemplateProcessor() { return templateProcessor; } - + /** * Parses the HTML string and adds elements to the InterfaceBuilder. * @@ -117,7 +124,7 @@ public List> parse(String html) { // Apply template processing if a processor is set String processedHtml = html; if (templateProcessor != null) { - processedHtml = templateProcessor.process(html); + processedHtml = templateProcessor.setTemplate(html).process(); HyUIPlugin.getLog().logFinest("Processed template: " + processedHtml); } Document doc = Jsoup.parseBodyFragment(processedHtml); @@ -136,10 +143,10 @@ public List> parseChildren(Element parent) { List> builders = new ArrayList<>(); for (Node child : parent.childNodes()) { HyUIPlugin.getLog().logFinest("Parsing child node: " + child.nodeName()); - + if (child instanceof Element) { HyUIPlugin.getLog().logFinest("Parsing ELEMENT node: " + child.nodeName()); - + UIElementBuilder builder = handleElement((Element) child); if (builder != null) { HyUIPlugin.getLog().logFinest("Parsed element: " + builder.getClass().getSimpleName()); @@ -163,4 +170,8 @@ public UIElementBuilder handleElement(Element element) { } return null; } + + public TriConsumer getEventByName(String value) { + return eventListener != null ? eventListener.get(value) : null; + } } diff --git a/src/main/java/au/ellie/hyui/html/TagHandler.java b/src/main/java/au/ellie/hyui/html/TagHandler.java index 93f61a6..b2751cf 100644 --- a/src/main/java/au/ellie/hyui/html/TagHandler.java +++ b/src/main/java/au/ellie/hyui/html/TagHandler.java @@ -20,17 +20,11 @@ import au.ellie.hyui.HyUIPlugin; import au.ellie.hyui.builders.*; -import au.ellie.hyui.elements.BackgroundSupported; import au.ellie.hyui.elements.LayoutModeSupported; -import au.ellie.hyui.types.ButtonStyle; -import au.ellie.hyui.types.CheckBoxStyle; -import au.ellie.hyui.types.ColorPickerDropdownBoxStyle; -import au.ellie.hyui.types.ColorPickerStyle; -import au.ellie.hyui.types.DefaultStyles; -import au.ellie.hyui.types.InputFieldStyle; -import au.ellie.hyui.types.SliderStyle; +import au.ellie.hyui.types.*; import au.ellie.hyui.utils.ParseUtils; import au.ellie.hyui.utils.StyleUtils; +import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; import com.hypixel.hytale.server.core.Message; import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; @@ -68,7 +62,7 @@ public interface TagHandler { * @param builder The builder to apply attributes to. * @param element The HTML element containing the attributes. */ - default void applyCommonAttributes(UIElementBuilder builder, Element element) { + default void applyCommonAttributes(UIElementBuilder builder, Element element, HtmlParser parser) { if (element.hasAttr("id")) { builder.withId(element.attr("id")); } @@ -83,9 +77,13 @@ default void applyCommonAttributes(UIElementBuilder builder, Element element) if (element.hasAttr("data-hyui-flexweight")) { try { builder.withFlexWeight(Integer.parseInt(element.attr("data-hyui-flexweight"))); - } catch (NumberFormatException ignored) {} + } catch (NumberFormatException ignored) { + } } + // Default to visible unless explicitly hidden by style or attribute + builder.withVisible(true); + boolean defaultStyleApplied = applyDefaultStyleIfRequested(builder, element); if (element.hasAttr("style")) { @@ -122,6 +120,71 @@ default void applyCommonAttributes(UIElementBuilder builder, Element element) } } + var events = new EventListenerBuilder(builder, parser); + for (var attr : element.attributes()) { + var key = attr.getKey(); + if (!key.startsWith("@")) + continue; + + var types = key.substring(1).split(":"); + var callback = attr.getValue(); + var baseEvent = types[0].toLowerCase(); + var typeEvent = types.length > 1 ? types[1].toLowerCase() : ""; + + switch (baseEvent) { + case "key" -> events.add(CustomUIEventBindingType.KeyDown, callback); + case "activate" -> events.add(CustomUIEventBindingType.Activating, callback); + case "change" -> events.add(CustomUIEventBindingType.ValueChanged, callback); + case "mouse" -> { + switch (typeEvent) { + case "enter" -> events.add(CustomUIEventBindingType.MouseEntered, callback); + case "leave" -> events.add(CustomUIEventBindingType.MouseExited, callback); + case "press" -> events.add(CustomUIEventBindingType.SlotClickPressWhileDragging, callback); + case "release" -> events.add(CustomUIEventBindingType.MouseButtonReleased, callback); + default -> { + events.add(CustomUIEventBindingType.MouseEntered, callback); + events.add(CustomUIEventBindingType.MouseExited, callback); + } + } + } + case "click" -> { + switch (typeEvent) { + case "left" -> events.add(CustomUIEventBindingType.Activating, callback); + case "right" -> events.add(CustomUIEventBindingType.RightClicking, callback); + case "double" -> events.add(CustomUIEventBindingType.DoubleClicking, callback); + default -> { + events.add(CustomUIEventBindingType.Activating, callback); + events.add(CustomUIEventBindingType.RightClicking, callback); + events.add(CustomUIEventBindingType.DoubleClicking, callback); + } + } + } + case "drag" -> { + switch (typeEvent) { + case "complete" -> events.add(CustomUIEventBindingType.Dropped, callback); + case "cancel" -> events.add(CustomUIEventBindingType.DragCancelled, callback); + default -> { + events.add(CustomUIEventBindingType.Dropped, callback); + events.add(CustomUIEventBindingType.DragCancelled, callback); + } + } + } + case "validate" -> events.add(CustomUIEventBindingType.Validating, callback); + case "dismiss" -> events.add(CustomUIEventBindingType.Dismissing, callback); + case "focus" -> { + switch (typeEvent) { + case "gain" -> events.add(CustomUIEventBindingType.FocusGained, callback); + case "lost" -> events.add(CustomUIEventBindingType.FocusLost, callback); + default -> { + events.add(CustomUIEventBindingType.FocusGained, callback); + events.add(CustomUIEventBindingType.FocusLost, callback); + } + } + } + } + } + events.build(); + if (element.tagName().equalsIgnoreCase("img") || element.tagName().equalsIgnoreCase("hyvatar")) { HyUIAnchor anchor = builder.getAnchor(); if (anchor == null) { @@ -255,7 +318,8 @@ private ParsedStyles getStylesAnchorsPadding(Map styles, UIEleme try { parsed.style.setLetterSpacing(Integer.parseInt(value)); parsed.hasStyle = true; - } catch (NumberFormatException ignored) {} + } catch (NumberFormatException ignored) { + } break; case "white-space": if (value.equalsIgnoreCase("nowrap")) { diff --git a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java index fab8c2e..b2148f1 100644 --- a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java +++ b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java @@ -18,28 +18,35 @@ package au.ellie.hyui.html; -import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.events.UIContext; import au.ellie.hyui.builders.UIElementBuilder; - +import au.ellie.hyui.events.UIContext; +import au.ellie.hyui.html.template.Evaluator; +import au.ellie.hyui.html.template.Lexer; +import au.ellie.hyui.html.template.Parser; +import au.ellie.hyui.html.template.context.ExecutionPolicy; +import au.ellie.hyui.html.template.context.FilterRegistry; +import au.ellie.hyui.html.template.context.VariableHandler.CachingVariableHandler; +import au.ellie.hyui.html.template.context.VariableHandler.EphemeralVariableHandler; +import au.ellie.hyui.html.template.context.VariableHandler.NonNullVariableHandler; +import au.ellie.hyui.html.template.context.VariableStack; +import au.ellie.hyui.html.template.context.VariableStack.VariableScope; +import au.ellie.hyui.html.template.item.Node; +import au.ellie.hyui.html.template.item.Token; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import java.io.IOException; import java.io.InputStream; -import java.lang.reflect.Array; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.function.Function; import java.util.function.Supplier; -import java.util.regex.Matcher; -import java.util.regex.Pattern; + +import static au.ellie.hyui.html.template.context.VariableStack.NULL_SENTINEL; +import static au.ellie.hyui.html.template.item.Symbols.SCOPE_ROOT_NAME; /** * Preprocessor for HyUIML templates that supports variable interpolation and component inclusion. @@ -72,76 +79,109 @@ * */ public class TemplateProcessor { - - // Pattern for {{$variable}} or {{$variable|default}} or {{$variable|filter}} - private static final Pattern VARIABLE_PATTERN = Pattern.compile( - "\\{\\{\\$([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)(?:\\|([^}]*))?\\}\\}" - ); - - private static final String EACH_START = "{{#each"; - private static final String EACH_END = "{{/each}}"; - private static final String IF_START = "{{#if"; - private static final String IF_END = "{{/if}}"; - private static final String ELSE_TAG = "{{else}}"; - private static final int MAX_COMPONENT_DEPTH = 20; - private final Map variables = new HashMap<>(); - private final Map components = new HashMap<>(); - private final Map> filters = new HashMap<>(); + private final Map components = new HashMap<>(); + private final FilterRegistry filterRegistry = new FilterRegistry(); + private final CachedComponent root = new CachedComponent(); + private ValueResolver valueResolver; - private static final Object NULL_SENTINEL = new Object(); private boolean preferDynamicValues; - @FunctionalInterface - public interface ValueResolver { - Optional resolve(String name); - } + /** + * Sets the template string to be processed. + * + * @param template The template string + */ + public TemplateProcessor setTemplate(String template) { + root.setTemplate(template); - public TemplateProcessor() { - // Register default filters - registerFilter("upper", String::toUpperCase); - registerFilter("lower", String::toLowerCase); - registerFilter("trim", String::trim); - registerFilter("capitalize", this::capitalize); - registerFilter("number", this::formatNumber); - registerFilter("percent", this::formatPercent); + return this; } /** - * Sets a template variable from any object. + * Registers a template variable backed by an arbitrary object. + *

* - * @param name Variable name (without $) - * @param value Variable value (will be converted to string) - * @return This processor for chaining + * @param name Variable name (without the '$' prefix) + * @param value Variable value + * @return This {@link TemplateProcessor} instance, allowing method chaining */ public TemplateProcessor setVariable(String name, Object value) { - variables.put(name, value); - return this; + return setVariable(name, value, ExecutionPolicy.CACHED); } /** - * Sets a template variable from any object. + * Registers a template variable backed by an arbitrary object. + *

+ * The behavior of the evaluation is controlled by the provided {@link ExecutionPolicy}. * - * @param name Variable name (without $) - * @param value Supplier that provides the variable value - * @return This processor for chaining + * @param name The variable name (without the '$' prefix) + * @param value Variable value + * @param policy The execution policy controlling how the value is evaluated and retained + * @return This {@link TemplateProcessor} instance, allowing method chaining */ - public TemplateProcessor setVariable(String name, Supplier value) { - variables.put(name, value); + public TemplateProcessor setVariable(String name, Object value, ExecutionPolicy policy) { + var result = switch (policy) { + case CACHED -> new CachingVariableHandler(value); + case NON_NULL -> new NonNullVariableHandler(value); + case EPHEMERAL -> new EphemeralVariableHandler(value); + default -> value; + }; + + variables.put(name, result); return this; } /** - * Sets multiple variables at once. + * Registers a template variable whose value is evaluated lazily using a {@link Supplier}. + *

+ * The behavior of the evaluation is controlled by the provided {@link ExecutionPolicy}. + * + * @param name The variable name (without the '$' prefix) + * @param value A {@link Supplier} that provides the variable's value + * @param policy The execution policy controlling how the value is evaluated and retained + * @return This {@link TemplateProcessor} instance, allowing method chaining + */ + public TemplateProcessor setVariable(String name, Supplier value, ExecutionPolicy policy) { + return setVariable(name, (Object) value, policy); + } + + /** + * Registers a template variable whose value is evaluated lazily using a {@link Function}. + *

+ * The behavior of the evaluation is controlled by the provided {@link ExecutionPolicy}. + * + * @param name The variable name (without the '$' prefix) + * @param value A {@link Function} that provides the variable's value + * @param policy The execution policy controlling how the value is evaluated and retained + * @return This {@link TemplateProcessor} instance, allowing method chaining + */ + public TemplateProcessor setVariable(String name, Function value, ExecutionPolicy policy) { + return setVariable(name, (Object) value, policy); + } + + /** + * Sets multiple template variables at once. * * @param vars Map of variable names to values * @return This processor for chaining */ - @SuppressWarnings("unchecked") public TemplateProcessor setVariables(Map vars) { - for (Map.Entry entry : vars.entrySet()) { - setVariable(entry.getKey(), entry.getValue()); - } + for (Map.Entry entry : vars.entrySet()) + setVariable(entry.getKey(), entry.getValue(), ExecutionPolicy.CACHED); + + return this; + } + + /** + * Register a new filter. + * + * @param name The name of the filter. + * @param filter The filter implementation. + */ + public TemplateProcessor registerFilter(String name, FilterRegistry.Filter filter) { + filterRegistry.register(name, filter); + return this; } @@ -152,8 +192,19 @@ public TemplateProcessor setVariables(Map vars) { * @param template Component HTML template * @return This processor for chaining */ - public TemplateProcessor registerComponent(String name, String template) { - components.put(name, template); + public TemplateProcessor registerComponent(@Nonnull String name, @Nonnull String template) { + assert !name.isEmpty() : "Component name cannot be empty."; + assert !template.isEmpty() : "Component template cannot be empty."; + + + var cache = components.computeIfAbsent(name, _ -> new CachedComponent()); + var updated = cache.setTemplate(template); + + // Invalidate other components cache + if (updated && root.invalidate()) + for (Map.Entry entry : components.entrySet()) + entry.getValue().invalidate(); + return this; } @@ -165,69 +216,83 @@ public TemplateProcessor registerComponent(String name, String template) { * @return This processor for chaining */ public TemplateProcessor registerComponentFromFile(String name, String resourcePath) { - if (resourcePath == null || resourcePath.isBlank()) { + if (resourcePath == null || resourcePath.isBlank()) throw new IllegalArgumentException("Resource path cannot be null or blank."); - } + String trimmed = resourcePath.startsWith("/") ? resourcePath.substring(1) : resourcePath; - String template = loadHtmlFromResources( "/Common/UI/Custom/" + trimmed); + String template = loadHtmlFromResources("/Common/UI/Custom/" + trimmed); return registerComponent(name, template); } /** - * Registers a custom filter function. + * Process a template with the current variables. * - * @param name Filter name - * @param filter Filter function - * @return This processor for chaining + * @return The processed template. */ - public TemplateProcessor registerFilter(String name, Function filter) { - filters.put(name, filter); - return this; - } - - /** - * Processes the template, substituting variables and including components. - * - * @param template The template string - * @return Processed HTML string - */ - public String process(String template) { - return processTemplate(template, new HashMap<>(variables), 0); + public String process() { + return process((Map) null); } /** * Processes the template using the provided UI context to resolve element IDs. * - * @param template The template string * @param context The UI context for runtime values * @return Processed HTML string */ - public String process(String template, UIContext context) { + public String process(@Nullable UIContext context) { ValueResolver previousResolver = this.valueResolver; boolean previousPreferDynamic = this.preferDynamicValues; this.valueResolver = name -> { - if (context == null) { + if (context == null) return Optional.empty(); - } + Optional value = context.getValue(name); - if (value.isPresent()) { + if (value.isPresent()) return value; - } + return hasElement(context, name) ? Optional.of(NULL_SENTINEL) : Optional.empty(); }; - this.preferDynamicValues = true; + + this.preferDynamicValues = true; try { - return processTemplate(template, new HashMap<>(variables), 0); + return process(new HashMap<>(variables)); } finally { this.valueResolver = previousResolver; this.preferDynamicValues = previousPreferDynamic; } } + /** + * Processes the template with additional variables that can override existing ones. + * + * @param additionalVariables Additional variables to use during processing + * @return The processed template. + */ + public String process(@Nullable Map additionalVariables) { + // Inject additional variables, this allows for per-call variable overrides + Map parameters = additionalVariables == null ? variables : new HashMap<>(variables); + if (additionalVariables != null) + parameters.putAll(additionalVariables); + + var rootAst = this.root.getAst(); + var scope = new VariableScope(SCOPE_ROOT_NAME, parameters); + var stack = new VariableStack(scope, valueResolver, preferDynamicValues); + + return new Evaluator(stack, filterRegistry, components).evaluate(rootAst); + } + + // ===== Internal ===== + + /** + * Load HTML content from resource files. + * + * @param resourceFileName The resource file name/path. + * @return The HTML content as a string. + */ private String loadHtmlFromResources(String resourceFileName) { - if (resourceFileName == null || resourceFileName.isBlank()) { + if (resourceFileName == null || resourceFileName.isBlank()) throw new IllegalArgumentException("Resource path cannot be null or blank."); - } + String normalized = resourceFileName.startsWith("/") ? resourceFileName.substring(1) : resourceFileName; List candidatePaths = List.of( Paths.get("src/main/resources").resolve(normalized), @@ -236,6 +301,7 @@ private String loadHtmlFromResources(String resourceFileName) { Paths.get("..", "build", "resources", "main").resolve(normalized), Paths.get(normalized) ); + for (Path path : candidatePaths) { if (Files.isRegularFile(path)) { try { @@ -245,894 +311,94 @@ private String loadHtmlFromResources(String resourceFileName) { } } } + String resourceLookup = resourceFileName.startsWith("/") ? resourceFileName : "/" + resourceFileName; try (InputStream inputStream = TemplateProcessor.class.getResourceAsStream(resourceLookup)) { - if (inputStream == null) { + if (inputStream == null) throw new IllegalArgumentException("Resource not found: " + resourceFileName); - } + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException("Failed to load HTML from resource: " + resourceFileName, e); } } - private String processTemplate(String template, Map scope, int componentDepth) { - String result = template; - - // Process control structures first so false branches aren't expanded. - result = processEachBlocks(result, scope, componentDepth); - result = processIfBlocks(result, scope, componentDepth); - - // Expand components with the current scope. - result = processComponents(result, scope, componentDepth); - - // Process control structures again for blocks inside component templates. - result = processEachBlocks(result, scope, componentDepth); - result = processIfBlocks(result, scope, componentDepth); - - // Then process variables. - result = processVariables(result, scope); - - return result; - } - - private String processVariables(String template, Map scope) { - Matcher matcher = VARIABLE_PATTERN.matcher(template); - StringBuilder result = new StringBuilder(); - - while (matcher.find()) { - String varName = matcher.group(1); - String filterOrDefault = matcher.group(2); - - Object rawValue = resolveVariable(scope, varName); - String value = rawValue != null ? String.valueOf(rawValue) : ""; - - // Apply filter or use default value - if (filterOrDefault != null && !filterOrDefault.isEmpty()) { - if (filters.containsKey(filterOrDefault)) { - // It's a filter - value = filters.get(filterOrDefault).apply(value); - } else if (value.isEmpty()) { - // It's a default value - value = filterOrDefault; - } - } - - HyUIPlugin.getLog().logFinest("Template variable: $" + varName + " = " + value); - matcher.appendReplacement(result, Matcher.quoteReplacement(value)); - } - matcher.appendTail(result); - - return result.toString(); - } - - private String processEachBlocks(String template, Map scope, int componentDepth) { - StringBuilder result = new StringBuilder(); - int index = 0; - - while (true) { - int start = template.indexOf(EACH_START, index); - if (start < 0) { - result.append(template.substring(index)); - break; - } - - result.append(template, index, start); - - int startClose = template.indexOf("}}", start); - if (startClose < 0) { - result.append(template.substring(start)); - break; - } - - String listName = template.substring(start + EACH_START.length(), startClose).trim(); - int end = findMatchingEnd(template, startClose + 2, EACH_START, EACH_END); - if (end < 0) { - result.append(template.substring(start)); - break; - } - - String inner = template.substring(startClose + 2, end); - Object listObj = resolveVariable(scope, listName); - Iterable items = toIterable(listObj); - - for (Object item : items) { - Map childScope = new HashMap<>(scope); - - // Ignore primitive types for model variable extraction - if (!item.getClass().isPrimitive()) { - childScope.putAll(extractModelVariables(item)); - } - - childScope.put("item", item); - result.append(processTemplate(inner, childScope, componentDepth)); - } - - index = end + EACH_END.length(); - } - - return result.toString(); + /** + * Check if the UI context has an element with the given name. + * + * @param context UI context + * @param name Element name + */ + @SuppressWarnings("unchecked") + private boolean hasElement(UIContext context, String name) { + return context.getById(name, UIElementBuilder.class).isPresent(); } - private String processIfBlocks(String template, Map scope, int componentDepth) { - StringBuilder result = new StringBuilder(); - int index = 0; - - while (true) { - int start = template.indexOf(IF_START, index); - if (start < 0) { - result.append(template.substring(index)); - break; - } - - result.append(template, index, start); - - int startClose = template.indexOf("}}", start); - if (startClose < 0) { - result.append(template.substring(start)); - break; - } - - String conditionName = template.substring(start + IF_START.length(), startClose).trim(); - int end = findMatchingEnd(template, startClose + 2, IF_START, IF_END); - if (end < 0) { - result.append(template.substring(start)); - break; - } - - int elseIndex = findElseIndex(template, startClose + 2, end); - String trueBlock; - String falseBlock = ""; - - if (elseIndex >= 0) { - trueBlock = template.substring(startClose + 2, elseIndex); - falseBlock = template.substring(elseIndex + ELSE_TAG.length(), end); - } else { - trueBlock = template.substring(startClose + 2, end); - } - - boolean conditionResult = evaluateCondition(conditionName, scope); - String chosen = conditionResult ? trueBlock : falseBlock; - result.append(processTemplate(chosen, scope, componentDepth)); - - index = end + IF_END.length(); - } + // ===== Interface ===== - return result.toString(); + @FunctionalInterface + public interface ValueResolver { + Optional resolve(String name); } - private String processComponents(String template, Map scope, int componentDepth) { - StringBuilder result = new StringBuilder(); - int index = 0; - - while (true) { - int start = template.indexOf("{{@", index); - if (start < 0) { - result.append(template.substring(index)); - break; - } - - result.append(template, index, start); - int cursor = start + 3; - int depth = 1; + public static class CachedComponent { + private List ast; + private String template; - while (cursor < template.length()) { - if (template.startsWith("{{", cursor)) { - depth++; - cursor += 2; - continue; - } - if (template.startsWith("}}", cursor)) { - depth--; - if (depth == 0) { - break; - } - cursor += 2; - continue; - } - cursor++; - } - - if (depth != 0) { - result.append(template.substring(start)); - break; - } - - String content = template.substring(start + 3, cursor).trim(); - String componentName; - String paramsStr = null; - int colonIndex = content.indexOf(':'); - if (colonIndex >= 0) { - componentName = content.substring(0, colonIndex).trim(); - paramsStr = content.substring(colonIndex + 1).trim(); - } else { - componentName = content.trim(); - } - - String componentHtml = components.get(componentName); - if (componentHtml == null) { - HyUIPlugin.getLog().logFinest("Unknown component: @" + componentName); - result.append(""); - index = cursor + 2; - continue; - } - - if (paramsStr != null && !paramsStr.isEmpty()) { - Map params = parseParams(paramsStr); - for (Map.Entry param : params.entrySet()) { - String rawValue = param.getValue(); - String value = processVariables(rawValue, scope); - HyUIPlugin.getLog().logFinest("Component param @" + componentName + " " + param.getKey() - + " raw=" + rawValue + " -> " + value + " scope=" + scope.keySet()); - componentHtml = componentHtml.replace("{{$" + param.getKey() + "}}", value); - } - } - - HyUIPlugin.getLog().logFinest("Including component: @" + componentName); - if (componentDepth >= MAX_COMPONENT_DEPTH) { - HyUIPlugin.getLog().logFinest("Component recursion limit hit for @" + componentName); - result.append(""); - } else { - result.append(processTemplate(componentHtml, scope, componentDepth + 1)); - } - index = cursor + 2; + public CachedComponent() { + this.template = ""; } - return result.toString(); - } - - private Map parseParams(String paramsStr) { - Map params = new HashMap<>(); - for (String param : paramsStr.split(",")) { - String[] parts = param.trim().split("=", 2); - if (parts.length == 2) { - params.put(parts[0].trim(), parts[1].trim()); - } + /** + * Gets the current template string for this component. + */ + public String getTemplate() { + return template; } - return params; - } - private boolean evaluateCondition(String rawCondition, Map scope) { - String condition = rawCondition != null ? rawCondition.trim() : ""; - if (condition.isEmpty()) { - return false; - } - - return evaluateLogical(condition, scope); - } - - private boolean evaluateLogical(String condition, Map scope) { - for (String orPart : splitByOperator(condition, "||")) { - if (evaluateAnd(orPart, scope)) { + /** + * Sets the template string for this component + * and invalidates the cached AST if the template has changed. + * + * @param template The new template string + * @return True if the template was updated, false if the template was unchanged. + */ + public boolean setTemplate(String template) { + if (!Objects.equals(template, this.template)) { + this.template = template; + this.ast = null; // Invalidate cache return true; } - } - return false; - } - - private boolean evaluateAnd(String condition, Map scope) { - for (String andPart : splitByOperator(condition, "&&")) { - if (!evaluateUnary(andPart, scope)) { - return false; - } - } - return true; - } - - private boolean evaluateUnary(String condition, Map scope) { - String trimmed = condition.trim(); - if (trimmed.startsWith("!")) { - return !evaluateUnary(trimmed.substring(1), scope); - } - - return evaluateComparison(trimmed, scope); - } - - private boolean evaluateComparison(String condition, Map scope) { - Matcher containsMatcher = Pattern.compile("(.+?)\\s+contains\\s+(.+)").matcher(condition); - if (containsMatcher.matches()) { - Object left = resolveOperand(containsMatcher.group(1).trim(), scope); - Object right = resolveOperand(containsMatcher.group(2).trim(), scope); - return containsValue(left, right); - } - - Matcher matcher = Pattern.compile("(.+?)(==|!=|>=|<=|>|<)(.+)").matcher(condition); - if (matcher.matches()) { - Object left = resolveOperand(matcher.group(1).trim(), scope); - Object right = resolveOperand(matcher.group(3).trim(), scope); - String operator = matcher.group(2); - return compareValues(left, right, operator); - } - - Object value = resolveOperand(condition, scope); - return isTruthy(value); - } - - private Object resolveOperand(String token, Map scope) { - if (token == null) { - return null; - } - String trimmed = token.trim(); - if (trimmed.isEmpty()) { - return ""; - } - - if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) - || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { - return trimmed.substring(1, trimmed.length() - 1); - } - - if ("null".equalsIgnoreCase(trimmed)) { - return null; - } - - if ("true".equalsIgnoreCase(trimmed) || "false".equalsIgnoreCase(trimmed)) { - return Boolean.parseBoolean(trimmed); - } - - try { - if (trimmed.contains(".")) { - return Double.parseDouble(trimmed); - } - return Long.parseLong(trimmed); - } catch (NumberFormatException ignored) { - // Not a number literal. - } - - if (hasVariable(scope, trimmed)) { - return resolveVariable(scope, trimmed); - } - - return trimmed; - } - - private boolean compareValues(Object left, Object right, String operator) { - if (left == null || right == null) { - if ("==".equals(operator)) { - return left == right; - } - if ("!=".equals(operator)) { - return left != right; - } - return false; - } - - Double leftNum = toNumber(left); - Double rightNum = toNumber(right); - if (leftNum != null && rightNum != null) { - return switch (operator) { - case "==" -> Double.compare(leftNum, rightNum) == 0; - case "!=" -> Double.compare(leftNum, rightNum) != 0; - case ">" -> leftNum > rightNum; - case "<" -> leftNum < rightNum; - case ">=" -> leftNum >= rightNum; - case "<=" -> leftNum <= rightNum; - default -> false; - }; - } - - if (left instanceof Boolean || right instanceof Boolean) { - boolean leftVal = left instanceof Boolean ? (Boolean) left : Boolean.parseBoolean(left.toString()); - boolean rightVal = right instanceof Boolean ? (Boolean) right : Boolean.parseBoolean(right.toString()); - return switch (operator) { - case "==" -> leftVal == rightVal; - case "!=" -> leftVal != rightVal; - default -> false; - }; - } - - String leftStr = String.valueOf(left); - String rightStr = String.valueOf(right); - return switch (operator) { - case "==" -> leftStr.equals(rightStr); - case "!=" -> !leftStr.equals(rightStr); - default -> false; - }; - } - - private Double toNumber(Object value) { - if (value instanceof Number number) { - return number.doubleValue(); - } - try { - return Double.parseDouble(value.toString()); - } catch (NumberFormatException e) { - return null; - } - } - - private boolean containsValue(Object left, Object right) { - if (left == null || right == null) { - return false; - } - - if (left instanceof CharSequence seq) { - return seq.toString().contains(String.valueOf(right)); - } - - if (left instanceof Map map) { - return map.containsKey(right); - } - - if (left instanceof Iterable iterable) { - for (Object item : iterable) { - if (item == null && right == null) { - return true; - } - if (item != null && item.equals(right)) { - return true; - } - } - return false; - } - - if (left.getClass().isArray()) { - int length = Array.getLength(left); - for (int i = 0; i < length; i++) { - Object item = Array.get(left, i); - if (item == null && right == null) { - return true; - } - if (item != null && item.equals(right)) { - return true; - } - } - return false; - } - return left.toString().contains(String.valueOf(right)); - } - - private List splitByOperator(String input, String operator) { - List parts = new java.util.ArrayList<>(); - boolean inSingle = false; - boolean inDouble = false; - int start = 0; - - for (int i = 0; i < input.length(); i++) { - char c = input.charAt(i); - if (c == '"' && !inSingle) { - inDouble = !inDouble; - continue; - } - if (c == '\'' && !inDouble) { - inSingle = !inSingle; - continue; - } - - if (!inSingle && !inDouble && input.startsWith(operator, i)) { - parts.add(input.substring(start, i)); - start = i + operator.length(); - i += operator.length() - 1; - } - } - - parts.add(input.substring(start)); - return parts; - } - - private boolean hasVariable(Map scope, String name) { - if (name == null || name.isBlank()) { return false; } - if (scope.containsKey(name)) { - return true; - } - - Optional resolved = resolveDynamicValue(name); - if (resolved.isPresent()) { - return true; - } - - int dotIndex = name.indexOf('.'); - if (dotIndex > 0) { - String root = name.substring(0, dotIndex); - return scope.containsKey(root); - } - - return false; - } - - private Object resolveVariable(Map scope, String name) { - if (name == null || name.isBlank()) { - return null; - } - - if (preferDynamicValues) { - Optional resolved = resolveDynamicValue(name); - if (resolved.isPresent() && resolved.get() != NULL_SENTINEL) { - return resolved.get(); - } - } - - if (scope.containsKey(name)) { - var value = scope.get(name); - return value instanceof Supplier supplier ? supplier.get() : value; - } - - Optional resolved = resolveDynamicValue(name); - if (resolved.isPresent()) { - Object value = resolved.get(); - return value == NULL_SENTINEL ? null : value; - } - - String[] path = name.split("\\."); - if (path.length == 0) { - return null; - } - - String first = path[0]; - if (!scope.containsKey(first)) { - return null; - } - - Object current = scope.get(first); - if (current instanceof Supplier supplier) - current = supplier.get(); - - for (int i = 1; i < path.length; i++) { - if (current == null) { - return null; - } - current = getPropertyValue(current, path[i]); - } - - return current; - } - - private Optional resolveDynamicValue(String name) { - if (valueResolver == null) { - return Optional.empty(); - } - return valueResolver.resolve(name); - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private boolean hasElement(UIContext context, String name) { - return context.getById(name, (Class) UIElementBuilder.class).isPresent(); - } - - private Iterable toIterable(Object value) { - if (value == null) { - return List.of(); - } - if (value instanceof Iterable iterable) { - return iterable; - } - if (value.getClass().isArray()) { - int length = Array.getLength(value); - List list = new java.util.ArrayList<>(length); - for (int i = 0; i < length; i++) { - list.add(Array.get(value, i)); - } - return list; - } - return List.of(); - } - - private int findMatchingEnd(String template, int searchFrom, String startTag, String endTag) { - int depth = 1; - int index = searchFrom; - - while (index < template.length()) { - int nextStart = template.indexOf(startTag, index); - int nextEnd = template.indexOf(endTag, index); - - if (nextEnd < 0) { - return -1; - } - - if (nextStart != -1 && nextStart < nextEnd) { - depth++; - index = nextStart + startTag.length(); - } else { - depth--; - if (depth == 0) { - return nextEnd; - } - index = nextEnd + endTag.length(); - } - } - - return -1; - } - - private int findElseIndex(String template, int searchFrom, int endIndex) { - int depth = 1; - int index = searchFrom; - - while (index < endIndex) { - int nextStart = template.indexOf(IF_START, index); - int nextEnd = template.indexOf(IF_END, index); - int nextElse = template.indexOf(ELSE_TAG, index); - - int next = minPositive(nextStart, nextEnd, nextElse); - if (next < 0 || next >= endIndex) { - return -1; - } - - if (next == nextStart) { - depth++; - index = nextStart + IF_START.length(); - } else if (next == nextEnd) { - depth--; - if (depth == 0) { - return -1; - } - index = nextEnd + IF_END.length(); - } else { - if (depth == 1) { - return nextElse; - } - index = nextElse + ELSE_TAG.length(); - } - } - - return -1; - } - - private int minPositive(int... values) { - int min = Integer.MAX_VALUE; - for (int value : values) { - if (value >= 0 && value < min) { - min = value; - } - } - return min == Integer.MAX_VALUE ? -1 : min; - } - - private Map extractModelVariables(Object item) { - Map values = new HashMap<>(); - if (item == null) { - return values; - } - - if (item instanceof Map map) { - for (Map.Entry entry : map.entrySet()) { - if (entry.getKey() instanceof String key) { - values.put(key, entry.getValue()); - } - } - return values; - } - - for (Field field : item.getClass().getFields()) { - if (values.containsKey(field.getName())) { - continue; - } - extractVarsFromField(item, values, field); - } - - for (Method method : item.getClass().getMethods()) { - extractVarsFromMethod(item, values, method); - } - - for (Field field : item.getClass().getDeclaredFields()) { - if (field.isSynthetic() || values.containsKey(field.getName())) { - continue; - } - extractVarsFromField(item, values, field); - } - - for (Method method : item.getClass().getDeclaredMethods()) { - extractVarsFromMethod(item, values, method); - } - - return values; - } - - private void extractVarsFromField(Object item, Map values, Field field) { - try { - field.setAccessible(true); - } catch (Exception ignored) { - // For whatever reason we can't access it, ignore the field. - return; - } - - values.put(field.getName(), (Supplier)() -> { - try { - return field.get(Modifier.isStatic(field.getModifiers()) ? null : item); - } catch (IllegalAccessException | IllegalArgumentException ignored) { - return ""; - } - }); - } + /** + * Invalidates the cached AST for this component. + * Should be called if the template is modified externally after being set. + * + * @return True if the cache was invalidated, false if it was already null. + */ + public boolean invalidate() { + boolean wasCached = this.ast != null; + ast = null; - private void extractVarsFromMethod(Object item, Map values, Method method) { - if (method.getParameterCount() != 0) { - return; - } - String name = method.getName(); - if (name.equals("getClass")) { - return; + return wasCached; } - String propName = null; - if (name.startsWith("get") && name.length() > 3) { - propName = decapitalize(name.substring(3)); - } else if (name.startsWith("is") && name.length() > 2) { - propName = decapitalize(name.substring(2)); - } - - if (propName != null && !values.containsKey(propName)) { - try { - method.setAccessible(true); - } catch (Exception ignored) { - // For whatever reason we can't access it, ignore the method. - return; + /** + * Retrieve the AST for this component, + * parsing the template if it hasn't been parsed yet. + * + * @return The built template processor. + */ + public List getAst() { + if (ast == null) { + List tokens = new Lexer(template).tokenize(); + ast = new Parser(tokens, template).parse(); } - values.put(propName, (Supplier)() -> { - try { - return method.invoke(Modifier.isStatic(method.getModifiers()) ? null : item); - } catch (Exception ignored) { - return ""; - } - }); - } - } - - private Object getPropertyValue(Object target, String name) { - if (target == null || name == null || name.isBlank()) { - return null; - } - - if (target instanceof Map map) { - return map.get(name); - } - - if (target instanceof List list) { - Integer index = parseIndex(name); - if (index != null && index >= 0 && index < list.size()) { - return list.get(index); - } - return null; - } - - if (target.getClass().isArray()) { - Integer index = parseIndex(name); - if (index != null && index >= 0 && index < Array.getLength(target)) { - return Array.get(target, index); - } - return null; - } - - try { - Field field = target.getClass().getField(name); - if (!field.canAccess(target)) { - field.setAccessible(true); - } - return field.get(target); - } catch (NoSuchFieldException | IllegalAccessException ignored) { - // Fall back to getters. + return ast; } - - String suffix = name.substring(0, 1).toUpperCase() + name.substring(1); - for (String prefix : new String[] {"get", "is"}) { - try { - Method method = target.getClass().getMethod(prefix + suffix); - if (method.getParameterCount() == 0) { - if (!method.canAccess(target)) { - method.setAccessible(true); - } - return method.invoke(target); - } - } catch (Exception ignored) { - // Try next getter. - } - } - - try { - Field field = target.getClass().getDeclaredField(name); - if (!field.canAccess(target)) { - field.setAccessible(true); - } - return field.get(target); - } catch (NoSuchFieldException | IllegalAccessException ignored) { - // Ignore. - } - - for (String prefix : new String[] {"get", "is"}) { - try { - Method method = target.getClass().getDeclaredMethod(prefix + suffix); - if (method.getParameterCount() == 0) { - if (!method.canAccess(target)) { - method.setAccessible(true); - } - return method.invoke(target); - } - } catch (Exception ignored) { - // Ignore. - } - } - - return null; - } - - private Integer parseIndex(String value) { - if (value == null || value.isBlank()) { - return null; - } - for (int i = 0; i < value.length(); i++) { - if (!Character.isDigit(value.charAt(i))) { - return null; - } - } - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - return null; - } - } - - private String decapitalize(String value) { - if (value == null || value.isEmpty()) { - return value; - } - if (value.length() > 1 && Character.isUpperCase(value.charAt(0)) && Character.isUpperCase(value.charAt(1))) { - return value; - } - return Character.toLowerCase(value.charAt(0)) + value.substring(1); - } - - private boolean isTruthy(Object value) { - if (value == null) { - return false; - } - if (value instanceof Boolean bool) { - return bool; - } - if (value instanceof Number number) { - return number.doubleValue() != 0; - } - if (value instanceof CharSequence seq) { - String text = seq.toString().trim(); - return !text.isEmpty() && !"false".equalsIgnoreCase(text); - } - if (value instanceof Iterable iterable) { - return iterable.iterator().hasNext(); - } - if (value.getClass().isArray()) { - return Array.getLength(value) > 0; - } - return true; - } - - // Default filters - private String capitalize(String value) { - if (value == null || value.isEmpty()) return value; - return value.substring(0, 1).toUpperCase() + value.substring(1).toLowerCase(); - } - - private String formatNumber(String value) { - try { - double num = Double.parseDouble(value); - if (num == (long) num) { - return String.format("%,d", (long) num); - } - return String.format("%,.2f", num); - } catch (NumberFormatException e) { - return value; - } - } - - private String formatPercent(String value) { - try { - double num = Double.parseDouble(value); - return String.format("%.0f%%", num * 100); - } catch (NumberFormatException e) { - return value; - } - } - - /** - * Creates a new TemplateProcessor with common game-related variables. - * - * @param playerName The player's name - * @return A new TemplateProcessor with player variable set - */ - public static TemplateProcessor forPlayer(String playerName) { - return new TemplateProcessor().setVariable("playerName", playerName); } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/BlockSelectorHandler.java b/src/main/java/au/ellie/hyui/html/handlers/BlockSelectorHandler.java index 40fc050..436ec0b 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/BlockSelectorHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/BlockSelectorHandler.java @@ -50,7 +50,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { builder.withValue(element.attr("value")); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/ButtonHandler.java b/src/main/java/au/ellie/hyui/html/handlers/ButtonHandler.java index 8a95554..6298bc2 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/ButtonHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/ButtonHandler.java @@ -139,7 +139,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { applyItemSlotButtonAttributes(itemSlotButtonBuilder, element); } applyButtonStateAttributes(builder, element); - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); applyNativeTabButtonAttributes(builder, element); return builder; @@ -334,7 +334,7 @@ private HyUIStyle parseLabelStyle(String styleValue) { } hasStyle = true; } - + } break; case "font-style": diff --git a/src/main/java/au/ellie/hyui/html/handlers/ColorPickerDropdownBoxHandler.java b/src/main/java/au/ellie/hyui/html/handlers/ColorPickerDropdownBoxHandler.java index aa19c35..953169e 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/ColorPickerDropdownBoxHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/ColorPickerDropdownBoxHandler.java @@ -18,11 +18,11 @@ package au.ellie.hyui.html.handlers; -import au.ellie.hyui.types.ColorFormat; import au.ellie.hyui.builders.ColorPickerDropdownBoxBuilder; import au.ellie.hyui.builders.UIElementBuilder; import au.ellie.hyui.html.HtmlParser; import au.ellie.hyui.html.TagHandler; +import au.ellie.hyui.types.ColorFormat; import org.jsoup.nodes.Element; public class ColorPickerDropdownBoxHandler implements TagHandler { @@ -60,17 +60,17 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { if (element.hasAttr("data-hyui-reset-transparency-when-changing-color")) { builder.withResetTransparencyWhenChangingColor( - Boolean.parseBoolean(element.attr("data-hyui-reset-transparency-when-changing-color"))); + Boolean.parseBoolean(element.attr("data-hyui-reset-transparency-when-changing-color"))); } else if (element.hasAttr("reset-transparency-when-changing-color")) { builder.withResetTransparencyWhenChangingColor( - Boolean.parseBoolean(element.attr("reset-transparency-when-changing-color"))); + Boolean.parseBoolean(element.attr("reset-transparency-when-changing-color"))); } if (!element.hasClass("default-style")) { // Force default style and override after. element.addClass("default-style"); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/DivHandler.java b/src/main/java/au/ellie/hyui/html/handlers/DivHandler.java index 71e989f..b77a216 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/DivHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/DivHandler.java @@ -62,7 +62,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { builder = GroupBuilder.group(); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); applyScrollbarStyle(builder, element); for (Node childNode : element.childNodes()) { diff --git a/src/main/java/au/ellie/hyui/html/handlers/HotkeyLabelHandler.java b/src/main/java/au/ellie/hyui/html/handlers/HotkeyLabelHandler.java index 2a0029b..0f6b2ce 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/HotkeyLabelHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/HotkeyLabelHandler.java @@ -46,7 +46,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { builder.withInputBindingKeyPrefix(element.attr("input-binding-key-prefix")); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/HyvatarHandler.java b/src/main/java/au/ellie/hyui/html/handlers/HyvatarHandler.java index 061825a..16d71df 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/HyvatarHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/HyvatarHandler.java @@ -59,7 +59,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { builder.withCape(element.attr("cape")); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/ImgHandler.java b/src/main/java/au/ellie/hyui/html/handlers/ImgHandler.java index 89909e9..237cf65 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/ImgHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/ImgHandler.java @@ -49,9 +49,9 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { } builder = image; } - - applyCommonAttributes(builder, element); - + + applyCommonAttributes(builder, element, parser); + return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/InputHandler.java b/src/main/java/au/ellie/hyui/html/handlers/InputHandler.java index d1aa210..0dd2ed2 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/InputHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/InputHandler.java @@ -106,7 +106,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { } if (element.hasAttr("data-hyui-reset-transparency-when-changing-color")) { colorBuilder.withResetTransparencyWhenChangingColor( - Boolean.parseBoolean(element.attr("data-hyui-reset-transparency-when-changing-color"))); + Boolean.parseBoolean(element.attr("data-hyui-reset-transparency-when-changing-color"))); } break; case "submit": @@ -115,7 +115,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { } if (builder != null) { - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); } return builder; @@ -241,7 +241,7 @@ private void applyIntSliderValues(SliderBuilder builder, String value, String mi ParseUtils.parseInt(step).ifPresent(builder::withStep); } } - + private void applyIntSliderValues(SliderNumberFieldBuilder builder, String value, String min, String max, String step) { if (value != null && !value.isBlank()) { ParseUtils.parseInt(value).ifPresent(builder::withValue); @@ -256,7 +256,7 @@ private void applyIntSliderValues(SliderNumberFieldBuilder builder, String value ParseUtils.parseInt(step).ifPresent(builder::withStep); } } - + private void applyNumberFieldAnchor(SliderNumberFieldBuilder builder, Element element) { HyUIAnchor anchor = parseAnchor(element, "data-hyui-number-field-anchor-"); if (anchor != null) { diff --git a/src/main/java/au/ellie/hyui/html/handlers/ItemGridHandler.java b/src/main/java/au/ellie/hyui/html/handlers/ItemGridHandler.java index 7002d1d..11e4bd2 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/ItemGridHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/ItemGridHandler.java @@ -63,7 +63,8 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { if (element.hasAttr("data-hyui-slots-per-row")) { try { builder.withSlotsPerRow(Integer.parseInt(element.attr("data-hyui-slots-per-row"))); - } catch (NumberFormatException ignored) {} + } catch (NumberFormatException ignored) { + } } if (element.hasAttr("data-hyui-info-display")) { String infoDisplayValue = element.attr("data-hyui-info-display"); @@ -88,7 +89,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { element.attr("data-hyui-display-item-quantity"))); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); for (Element child : element.children()) { if (child.hasClass("item-grid-slot")) { @@ -109,7 +110,8 @@ private ItemGridSlot parseSlot(Element element) { if (element.hasAttr("data-hyui-quantity")) { try { quantity = Integer.parseInt(element.attr("data-hyui-quantity")); - } catch (NumberFormatException ignored) {} + } catch (NumberFormatException ignored) { + } } ItemStack stack = createItemStack(element.attr("data-hyui-item-id"), quantity); if (stack != null) { diff --git a/src/main/java/au/ellie/hyui/html/handlers/ItemIconHandler.java b/src/main/java/au/ellie/hyui/html/handlers/ItemIconHandler.java index fff9b89..0733657 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/ItemIconHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/ItemIconHandler.java @@ -39,9 +39,9 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { } else if (element.hasAttr("src")) { builder.withItemId(element.attr("src")); } - - applyCommonAttributes(builder, element); - + + applyCommonAttributes(builder, element, parser); + return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/ItemSlotHandler.java b/src/main/java/au/ellie/hyui/html/handlers/ItemSlotHandler.java index 00ab3b6..509e0d5 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/ItemSlotHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/ItemSlotHandler.java @@ -49,7 +49,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { element.attr("data-hyui-show-quantity"))); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/LabelHandler.java b/src/main/java/au/ellie/hyui/html/handlers/LabelHandler.java index 1861bff..62030cf 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/LabelHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/LabelHandler.java @@ -64,7 +64,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { builder.withText(element.text()); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } @@ -76,7 +76,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { } else { builder.withText(readDirectText(element)); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } diff --git a/src/main/java/au/ellie/hyui/html/handlers/LabeledCheckBoxHandler.java b/src/main/java/au/ellie/hyui/html/handlers/LabeledCheckBoxHandler.java index aed78df..213f7c0 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/LabeledCheckBoxHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/LabeledCheckBoxHandler.java @@ -34,7 +34,7 @@ public boolean canHandle(Element element) { @Override public UIElementBuilder handle(Element element, HtmlParser parser) { LabeledCheckBoxBuilder builder = LabeledCheckBoxBuilder.labeledCheckBox(); - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/MenuItemHandler.java b/src/main/java/au/ellie/hyui/html/handlers/MenuItemHandler.java index a7630d9..8c9820e 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/MenuItemHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/MenuItemHandler.java @@ -65,7 +65,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { builder.withIconAnchor(anchor); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } diff --git a/src/main/java/au/ellie/hyui/html/handlers/ProgressBarHandler.java b/src/main/java/au/ellie/hyui/html/handlers/ProgressBarHandler.java index 66eca59..489d05f 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/ProgressBarHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/ProgressBarHandler.java @@ -18,7 +18,6 @@ package au.ellie.hyui.html.handlers; -import au.ellie.hyui.HyUIPlugin; import au.ellie.hyui.builders.ProgressBarBuilder; import au.ellie.hyui.builders.UIElementBuilder; import au.ellie.hyui.html.HtmlParser; @@ -96,7 +95,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { } } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/ReorderableListGripHandler.java b/src/main/java/au/ellie/hyui/html/handlers/ReorderableListGripHandler.java index f7d8b06..dcacc20 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/ReorderableListGripHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/ReorderableListGripHandler.java @@ -34,7 +34,7 @@ public boolean canHandle(Element element) { @Override public UIElementBuilder handle(Element element, HtmlParser parser) { ReorderableListGripBuilder builder = ReorderableListGripBuilder.reorderableListGrip(); - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/ReorderableListHandler.java b/src/main/java/au/ellie/hyui/html/handlers/ReorderableListHandler.java index 622f31a..6c3e4d6 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/ReorderableListHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/ReorderableListHandler.java @@ -18,8 +18,8 @@ package au.ellie.hyui.html.handlers; -import au.ellie.hyui.builders.ReorderableListBuilder; import au.ellie.hyui.builders.LabelBuilder; +import au.ellie.hyui.builders.ReorderableListBuilder; import au.ellie.hyui.builders.UIElementBuilder; import au.ellie.hyui.elements.ScrollbarStyleSupported; import au.ellie.hyui.html.HtmlParser; @@ -40,7 +40,7 @@ public boolean canHandle(Element element) { @Override public UIElementBuilder handle(Element element, HtmlParser parser) { ReorderableListBuilder builder = ReorderableListBuilder.reorderableList(); - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); applyScrollbarStyle(builder, element); for (Node childNode : element.childNodes()) { diff --git a/src/main/java/au/ellie/hyui/html/handlers/SceneBlurHandler.java b/src/main/java/au/ellie/hyui/html/handlers/SceneBlurHandler.java index 6a691fd..035f408 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/SceneBlurHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/SceneBlurHandler.java @@ -34,7 +34,7 @@ public boolean canHandle(Element element) { @Override public UIElementBuilder handle(Element element, HtmlParser parser) { SceneBlurBuilder builder = SceneBlurBuilder.sceneBlur(); - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/SelectHandler.java b/src/main/java/au/ellie/hyui/html/handlers/SelectHandler.java index 9eed6ab..c106b79 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/SelectHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/SelectHandler.java @@ -89,7 +89,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { } } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/SpriteHandler.java b/src/main/java/au/ellie/hyui/html/handlers/SpriteHandler.java index 9d0f2c7..b1a3519 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/SpriteHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/SpriteHandler.java @@ -53,7 +53,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) { .ifPresent(builder::withFramesPerSecond); } - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); return builder; } } diff --git a/src/main/java/au/ellie/hyui/html/handlers/TabContentHandler.java b/src/main/java/au/ellie/hyui/html/handlers/TabContentHandler.java index 5590a90..4083e6c 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/TabContentHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/TabContentHandler.java @@ -37,7 +37,7 @@ public boolean canHandle(Element element) { public UIElementBuilder handle(Element element, HtmlParser parser) { TabContentBuilder builder = TabContentBuilder.tabContent(); - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); if (element.hasAttr("data-hyui-tab-id")) { builder.withTabId(element.attr("data-hyui-tab-id")); diff --git a/src/main/java/au/ellie/hyui/html/handlers/TabNavigationHandler.java b/src/main/java/au/ellie/hyui/html/handlers/TabNavigationHandler.java index de5ece0..3afb753 100644 --- a/src/main/java/au/ellie/hyui/html/handlers/TabNavigationHandler.java +++ b/src/main/java/au/ellie/hyui/html/handlers/TabNavigationHandler.java @@ -18,15 +18,11 @@ package au.ellie.hyui.html.handlers; -import au.ellie.hyui.builders.HyUIAnchor; -import au.ellie.hyui.builders.HyUIPatchStyle; -import au.ellie.hyui.builders.NativeTabNavigationBuilder; -import au.ellie.hyui.builders.TabNavigationBuilder; -import au.ellie.hyui.builders.UIElementBuilder; +import au.ellie.hyui.builders.*; import au.ellie.hyui.html.HtmlParser; import au.ellie.hyui.html.TagHandler; -import au.ellie.hyui.types.NativeTab; import au.ellie.hyui.types.DefaultStyles; +import au.ellie.hyui.types.NativeTab; import au.ellie.hyui.utils.ParseUtils; import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; @@ -37,11 +33,11 @@ /** * Handler for tab navigation elements in HYUIML. - * + *

* Supports: * - <nav class="tabs"> or <nav class="tab-navigation"> * - <div class="tabs"> or <div class="tab-navigation"> - * + *

* Structure: *

  * <nav class="tabs">
@@ -50,13 +46,13 @@
  *     <button data-tab="tab3">Tab 3</button>
  * </nav>
  * 
- * + *

* Or simplified: *

  * <nav class="tabs" data-tabs="inventory:Inventory:inventory-content,stats:Statistics:stats-content" data-selected="inventory">
  * </nav>
  * 
- * + *

* Content can be linked with a third entry in data-tabs or a data-tab-content attribute: *

  * <button data-tab="inventory" data-tab-content="inventory-content">Inventory</button>
@@ -68,7 +64,7 @@ public class TabNavigationHandler implements TagHandler {
     public boolean canHandle(Element element) {
         String tagName = element.tagName().toLowerCase();
         return (tagName.equals("nav") || tagName.equals("div")) &&
-               (element.hasClass("tabs") || element.hasClass("tab-navigation") || element.hasClass("native-tab-navigation"));
+                (element.hasClass("tabs") || element.hasClass("tab-navigation") || element.hasClass("native-tab-navigation"));
     }
 
     @Override
@@ -85,7 +81,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) {
             }
 
             applyNativeTabStyle(builder, element);
-            applyCommonAttributes(builder, element);
+            applyCommonAttributes(builder, element, parser);
 
             for (Node childNode : element.childNodes()) {
                 if (childNode instanceof Element childElement) {
@@ -111,7 +107,7 @@ public UIElementBuilder handle(Element element, HtmlParser parser) {
         // Custom tab navigation system
         TabNavigationBuilder builder = TabNavigationBuilder.tabNavigation();
 
-        applyCommonAttributes(builder, element);
+        applyCommonAttributes(builder, element, parser);
 
         String selectedTabId = element.hasAttr("data-selected") ? element.attr("data-selected").trim() : null;
         if (selectedTabId != null && selectedTabId.isBlank()) {
diff --git a/src/main/java/au/ellie/hyui/html/handlers/TextAreaHandler.java b/src/main/java/au/ellie/hyui/html/handlers/TextAreaHandler.java
index 9470307..c982ea4 100644
--- a/src/main/java/au/ellie/hyui/html/handlers/TextAreaHandler.java
+++ b/src/main/java/au/ellie/hyui/html/handlers/TextAreaHandler.java
@@ -39,7 +39,7 @@ public boolean canHandle(Element element) {
     public UIElementBuilder handle(Element element, HtmlParser parser) {
         TextFieldBuilder builder = TextFieldBuilder.multilineTextField();
         applyTextAreaAttributes(builder, element);
-        applyCommonAttributes(builder, element);
+        applyCommonAttributes(builder, element, parser);
         return builder;
     }
 
diff --git a/src/main/java/au/ellie/hyui/html/handlers/TimerHandler.java b/src/main/java/au/ellie/hyui/html/handlers/TimerHandler.java
index 40af976..3c3e93f 100644
--- a/src/main/java/au/ellie/hyui/html/handlers/TimerHandler.java
+++ b/src/main/java/au/ellie/hyui/html/handlers/TimerHandler.java
@@ -27,11 +27,11 @@
 
 /**
  * Handler for timer elements in HYUIML.
- *
+ * 

* Supports: * - <timer> tag * - <span class="timer"> tag - * + *

* Attributes: * - value: Time value in milliseconds (default: 0) * - format: Display format (hms, ms, seconds, human, milliseconds) @@ -51,7 +51,7 @@ public boolean canHandle(Element element) { public UIElementBuilder handle(Element element, HtmlParser parser) { TimerLabelBuilder builder = TimerLabelBuilder.timerLabel(); - applyCommonAttributes(builder, element); + applyCommonAttributes(builder, element, parser); // Parse time value if (element.hasAttr("value")) { diff --git a/src/main/java/au/ellie/hyui/html/template/Evaluator.java b/src/main/java/au/ellie/hyui/html/template/Evaluator.java new file mode 100644 index 0000000..f4e0949 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/Evaluator.java @@ -0,0 +1,534 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template; + +import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.html.TemplateProcessor.CachedComponent; +import au.ellie.hyui.html.template.context.FilterRegistry; +import au.ellie.hyui.html.template.context.SlotSupplier; +import au.ellie.hyui.html.template.context.VariableStack; +import au.ellie.hyui.html.template.context.VariableStack.VariableScope; +import au.ellie.hyui.html.template.exception.EvaluationException; +import au.ellie.hyui.html.template.item.Node; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.ExpressionAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.FlagAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.MixedAttributeNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.ComponentBlockNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.ConditionalBlockNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.ForBlockNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.SlotBlockNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.*; +import au.ellie.hyui.html.template.item.Node.MarkerNode; +import au.ellie.hyui.html.template.item.Symbols; +import au.ellie.hyui.utils.NumericUtils; +import au.ellie.hyui.utils.ReflectionUtils; + +import java.util.*; +import java.util.Map.Entry; +import java.util.function.Supplier; + +import static au.ellie.hyui.html.template.item.Attribute.inlineAttributes; +import static au.ellie.hyui.html.template.item.Symbols.*; +import static au.ellie.hyui.utils.ObjectUtils.*; + +public class Evaluator { + private final static Stack STACK = new Stack<>(); + + private final FilterRegistry filterRegistry; + private final VariableStack contextStack; + private final Map components; + + public Evaluator(VariableStack context, FilterRegistry filterRegistry, Map components) { + this.components = components; + this.contextStack = context; + this.filterRegistry = filterRegistry; + } + + /** + * Evaluate a list of AST nodes and return the resulting string. + * + * @param nodes The list of AST nodes to evaluate. + * @return The resulting string after evaluation. + */ + public String evaluate(List nodes) { + var result = new StringBuilder(); + + for (Node node : nodes) + result.append(evaluateNode(node)); + + return result.toString().replaceAll("\\n+$", ""); + } + + /** + * Evaluate a single AST node and return the resulting string. + * + * @param node The AST node to evaluate. + * @return The resulting string after evaluation. + */ + private String evaluateNode(Node node) { + return switch (node) { + case CommentNode _ -> ""; + case MarkerNode marker -> { + if (marker.inside() != null) + yield evaluateNode(marker.inside()); + yield ""; + } + case TextNode text -> text.content(); + case ExpressionNode expr -> { + var value = evaluateExpression(expr); + yield value == null ? "" : value.toString(); + } + case ConditionalBlockNode ifBlock -> evaluateIfBlock(ifBlock); + case ForBlockNode eachBlock -> evaluateEachBlock(eachBlock); + case SlotBlockNode slotBlockNode -> evaluateSlotBlock(slotBlockNode); + case ComponentBlockNode component -> evaluateComponent(component); + case AttributeValueNode attributeValueNode -> evaluateAttributeString(attributeValueNode); + + default -> throw new EvaluationException("Unexpected value", node); + }; + } + + /** + * Evaluate an expression node and return the resulting value. + * + * @param node The expression node to evaluate. + * @return The resulting value after evaluation. + */ + private Object evaluateExpression(ExpressionNode node) { + return switch (node) { + case CommentNode _ -> ""; + case TextNode literal -> literal.content(); + case LiteralNode literal -> literal.value(); + case PropertyAccessNode prop -> evaluatePropertyAccess(prop); + case BinaryOpNode binary -> evaluateBinaryOp(binary); + case PipeNode pipe -> evaluatePipe(pipe); + case DefaultNode def -> evaluateDefault(def); + case VariableNode var -> evaluateVariable(var); + }; + } + + /** + * Evaluate a variable reference and return its value from the context stack. + * + * @param var The variable node to evaluate. + * @return The value of the variable, or null if not found. + */ + private Object evaluateVariable(VariableNode var) { + var result = contextStack.getVariable(var.name(), () -> { + for (String key : contextStack.getScopeKeys()) { + // Prevent accessing variables from scopes + if (key.startsWith(Symbols.HTML_SLOT_KEY)) + continue; + + try { + return ReflectionUtils.getObjectProperty(contextStack.getVariable(key), var.name()); + } catch (Exception _) { + // Ignore and return null + } + } + + return null; + }); + + // Convert negated to boolean and negate + if (var.negated()) + result = !toBoolean(result); + + return result; + } + + /** + * Evaluate a property access on an object. + * + * @param node The property access node. + * @return The value of the accessed property, or null if not found. + */ + private Object evaluatePropertyAccess(PropertyAccessNode node) { + var obj = evaluateExpression(node.object()); + if (obj == null) return null; + + var property = node.property(); + + try { + return ReflectionUtils.getObjectProperty(obj, property); + } catch (Exception _) { + HyUIPlugin.getLog().logWarn("Error accessing property " + property + " on " + obj.getClass()); + } + + return null; + } + + /** + * Evaluate a `binary` operation between two expressions. + * + * @param node The binary operation node. + * @return The result of the binary operation. + */ + private Object evaluateBinaryOp(BinaryOpNode node) { + Supplier right = () -> evaluateExpression(node.right()); + var left = evaluateExpression(node.left()); + + return switch (node.operator()) { + case Symbols.EQUALS -> evaluateEquals(left, right.get()); + case Symbols.NOT_EQUALS -> !evaluateEquals(left, right.get()); + case Symbols.LESS_THAN -> evaluateComparison(node, left, right.get()) < 0; + case Symbols.GREATER_THAN -> evaluateComparison(node, left, right.get()) > 0; + case Symbols.LESS_THAN_EQUALS -> evaluateComparison(node, left, right.get()) <= 0; + case Symbols.GREATER_THAN_EQUALS -> evaluateComparison(node, left, right.get()) >= 0; + case Symbols.AND -> toBoolean(left) && toBoolean(right.get()); + case Symbols.OR -> toBoolean(left) || toBoolean(right.get()); + case Symbols.KEYWORD_IN -> containedIn(left, right.get()); + case Symbols.KEYWORD_NOT_IN -> !containedIn(left, right.get()); + default -> throw new EvaluationException("Unknown operator " + node.operator(), node); + }; + } + + /** + * Evaluate `equality` between two values. + * + * @param left Left value of equation + * @param right Right value of equation + * @return True if equal, false otherwise + */ + private boolean evaluateEquals(Object left, Object right) { + if (left == null && right == null) return true; + if (left == null || right == null) return false; + + var leftNum = NumericUtils.toNumber(left); + var rightNum = NumericUtils.toNumber(right); + + if (leftNum != null && rightNum != null) + return NumericUtils.equals(leftNum, rightNum); + + return Objects.equals(left, right); + } + + /** + * Evaluate comparison between two values. + * + * @param left Left value of comparison + * @param right Right value of comparison + * @return Negative if left < right, 0 if left == right, positive if left > right + */ + @SuppressWarnings("unchecked") + private int evaluateComparison(Node node, Object left, Object right) { + if (left == null && right == null) return 0; + if (left == null) return -1; + if (right == null) return 1; + + var leftNum = NumericUtils.toNumber(left); + var rightNum = NumericUtils.toNumber(right); + + if (leftNum != null && rightNum != null) + return NumericUtils.compare(leftNum, rightNum); + + if (left instanceof Comparable && left.getClass().isInstance(right)) { + var leftComp = (Comparable) left; + return leftComp.compareTo(right); + } + + throw new EvaluationException("Cannot compare " + left.getClass().getSimpleName() + + " and " + right.getClass().getSimpleName(), node); + } + + /** + * Evaluate a `pipe` expression (filter application). + * + * @param node The pipe node + * @return The result of the filter application + */ + private Object evaluatePipe(PipeNode node) { + var value = evaluateExpression(node.expression()); + var filter = filterRegistry.get(node.filterName()); + + return filter.apply(value); + } + + /** + * Evaluate a `default` expression and return the first non-null, non-empty alternative. + * + * @param node The default node to evaluate. + * @return The first non-null, non-empty alternative value, or null if none found. + */ + private Object evaluateDefault(DefaultNode node) { + for (ExpressionNode alternative : node.alternatives()) { + var value = evaluateExpression(alternative); + if (value != null && !value.toString().isEmpty()) + return value; + } + + return null; + } + + /** + * Evaluate an `if` / `else` block node and return the resulting string. + * + * @param node The `if` block node to evaluate. + * @return The resulting string after evaluation. + */ + private String evaluateIfBlock(ConditionalBlockNode node) { + var rendered = new HashMap(); + var result = new StringBuilder(); + + for (var branch : node.branches()) { + var conditionValue = evaluateExpression(branch.condition()); + + if (toBoolean(conditionValue)) { + for (Node child : branch.body()) { + result.append(evaluateNode(child)); + + switch (child) { + case ComponentBlockNode c -> rendered.put(c.tag(), rendered.getOrDefault(c.tag(), 0) + 1); + case SlotBlockNode s -> rendered.put(s.name(), rendered.getOrDefault(s.name(), 0) + 1); + default -> { + // Ignore other nodes + } + } + } + + break; + } + } + + // Dirty fix for conditionally rendering components and slots that are used in other branches but not rendered in the taken branch + for (var entry : node.getTags().entrySet()) { + var count = entry.getValue() - rendered.getOrDefault(entry.getKey(), 0); + while (count > 0) { + result.append("<").append(entry.getKey()).append(" style=\"display:none\">"); + count--; + } + } + + return result.toString(); + } + + /** + * Evaluate an `each` block node and return the resulting string. + * + * @param node The `each` block node to evaluate. + * @return The resulting string after evaluation. + */ + private String evaluateEachBlock(ForBlockNode node) { + var collectionValue = evaluateExpression(node.collection()); + + if (collectionValue == null) + return ""; + + var index = 0; + var items = toIterable(collectionValue); + var result = new StringBuilder(); + + for (Object item : items) { + var scope = new VariableScope(SCOPE_FOR_NAME); + Object itemIndex = index; + + // If iterating over a Map, extract key and value + if (item instanceof Map.Entry) { + itemIndex = ((Entry) item).getKey(); + item = ((Entry) item).getValue(); + } + + // Add item and optionally index to scope + scope.putKeyed(node.itemName(), item); + if (node.indexName() != null) + scope.putKeyed(node.indexName(), itemIndex); + + contextStack.pushScope(scope); + try { + for (Node child : node.body()) + result.append(evaluateNode(child)); + } finally { + contextStack.popScope(); + } + + index++; + } + + return result.toString(); + } + + /** + * Evaluate a `component` element node and return the resulting string. + * + * @param component The `component` element node to evaluate. + * @return The resulting string after evaluation. + */ + private String evaluateComponent(ComponentBlockNode component) { + var tagName = component.tag(); + + // Handle template tag as renderless wrapper + if (tagName.equals(Symbols.HTML_TAG_TEMPLATE)) { + var result = new StringBuilder(); + for (Node child : component.children()) + result.append(evaluateNode(child)); + + return result.toString(); + } + + // Tag is not a registered component, or is already being evaluated (prevent infinite recursion) + // and should be rendered as normal HTML element instead + if (!components.containsKey(tagName) || STACK.contains(tagName)) + return evaluateComponentString(component); + + // Attributes + var context = new HashMap(); + for (var attribute : component.attributes()) { + switch (attribute) { + case DynamicAttributeNode dynamicAttributeNode -> + context.put(attribute.getName(), evaluateExpression(dynamicAttributeNode.expression())); + case MixedAttributeNode mixedAttr -> { + var builder = new StringBuilder(); + for (var part : mixedAttr.parts()) { + if (part instanceof String text) + builder.append(text); + else if (part instanceof Node node) { + var value = evaluateNode(node); + if (value != null && !value.isEmpty()) + builder.append(value); + } + } + + context.put(mixedAttr.name(), builder.toString()); + } + case ExpressionAttributeNode expressionAttributeNode -> { + var evaluatedValue = evaluateNode(expressionAttributeNode.expressions()); + if (!evaluatedValue.isEmpty()) + inlineAttributes(evaluatedValue, context); + } + case FlagAttributeNode _ -> context.put(attribute.getName(), true); + } + } + + // Children + var scope = new VariableScope(SCOPE_COMPONENT_PREFIX + tagName, context); + for (var child : component.children()) { + var slotName = Symbols.HTML_SLOT_DEFAULT; + if (child instanceof SlotBlockNode slot) + slotName = slot.name(); + + // Saved as "slot.{slotName}" in component scope + scope.computeIfAbsent(Symbols.HTML_SLOT_KEY + slotName, key -> { + scope.getKeys().add(key); + return new SlotSupplier(this::evaluateNode); + }).add(child); + } + + STACK.push(tagName); + contextStack.pushScope(scope); + try { + var cachedComponent = components.get(tagName); + return evaluate(cachedComponent.getAst()); + } finally { + STACK.pop(); + contextStack.popScope(); + } + } + + /** + * Evaluate a `slot` block node and return the resulting string. + * + * @param slotBlockNode The `slot` block node to evaluate. + */ + private String evaluateSlotBlock(SlotBlockNode slotBlockNode) { + var slotName = slotBlockNode.name(); + + if (slotBlockNode.output()) { + var content = contextStack.getVariable(Symbols.HTML_SLOT_KEY + slotName, () -> null); + if (content != null) + return content.toString(); + } + + return evaluate(slotBlockNode.children()); + } + + /** + * Evaluate a component as a string without processing it as a component. + * + * @param component The component element node to evaluate as a string. + * @return The resulting string representation of the component. + */ + private String evaluateComponentString(ComponentBlockNode component) { + var isVoid = VOID_ELEMENTS.contains(component.tag().toLowerCase()); + var isEmpty = component.children().isEmpty() && component.attributes().isEmpty(); + + // Skip rendering completely empty non-void elements with no attributes + // These cause flexbox layout issues and are typically unintentional + if (!isVoid && isEmpty) + return ""; + + var sb = new StringBuilder(); + sb.append("<").append(component.tag()); + + for (var attribute : component.attributes()) { + var attrStr = evaluateAttributeString(attribute).trim(); + if (!attrStr.isEmpty()) + sb.append(" ").append(attrStr); + } + + if (isVoid) { + sb.append("/>"); + return sb.toString(); + } + + sb.append(">"); + + for (Node child : component.children()) + sb.append(evaluateNode(child)); + + sb.append(""); + + return sb.toString(); + } + + /** + * Evaluate an attribute value node and return the resulting string. + * + * @param attributeValueNode The attribute value node to evaluate. + * @return The resulting string after evaluation. + */ + private String evaluateAttributeString(AttributeValueNode attributeValueNode) { + var sb = new StringBuilder(); + + switch (attributeValueNode) { + case DynamicAttributeNode dynamic -> + sb.append(dynamic.getName()).append("=\"").append(evaluateNode(dynamic.expression())).append("\""); + case MixedAttributeNode mixedAttr -> { + var builder = new StringBuilder(); + for (var part : mixedAttr.parts()) { + if (part instanceof String text) + builder.append(text); + else if (part instanceof Node node) { + var value = evaluateNode(node); + if (value != null && !value.isEmpty()) + builder.append(value); + } + } + + sb.append(mixedAttr.getName()).append("=\"").append(builder).append("\""); + } + case FlagAttributeNode flag -> sb.append(flag.getName()); + case ExpressionAttributeNode expression -> sb.append(evaluateNode(expression.expressions())); + } + + return sb.toString(); + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/Lexer.java b/src/main/java/au/ellie/hyui/html/template/Lexer.java new file mode 100644 index 0000000..c035795 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/Lexer.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template; + +import au.ellie.hyui.html.template.item.Token; +import au.ellie.hyui.html.template.item.Token.Type; +import au.ellie.hyui.utils.StringReader; +import au.ellie.hyui.utils.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +import static au.ellie.hyui.html.template.item.Symbols.KEYWORDS; +import static au.ellie.hyui.html.template.item.Symbols.OPERATORS; + +public class Lexer { + private final StringReader reader; + + public Lexer(String input) { + this.reader = new StringReader(input); + } + + /** + * Tokenize the input string into a list of tokens + */ + public List tokenize() { + var tokens = new ArrayList(); + + outer: + while (reader.hasNext()) { + if (isNumberType()) + tokens.add(tokenizeNumber()); + else { + var operator = tokenizeArray(OPERATORS, Type.OPERATOR); + if (operator != null) { + tokens.add(operator); + continue; + } + + var keyword = tokenizeArray(KEYWORDS, Type.KEYWORD); + if (keyword != null) { + tokens.add(keyword); + continue; + } + + for (var entry : Token.TOKEN_MAPPER.entrySet()) { + if (reader.startsWith(entry.getKey())) { + tokens.add(new Token(entry.getValue(), entry.getKey(), reader.getPosition())); + reader.skip(entry.getKey()); + continue outer; + } + } + + if (reader.peek() == ' ') + tokens.add(tokenizeSpacer()); + else + tokens.add(tokenizeText()); + } + + + } + + tokens.add(new Token(Type.EOI, "\0", reader.getPosition())); + + return tokens; + } + + /** + * Tokenize a number (integer or decimal) + */ + private Token tokenizeNumber() { + var start = reader.getPosition(); + var builder = new StringBuilder(); + + // Handle optional leading minus sign + if (reader.peek() == '-') + builder.append(reader.advance()); + + var hasDecimal = false; + while (reader.hasNext()) { + var current = reader.peek(); + + if (Character.isDigit(current)) { + builder.append(reader.advance()); + } else if (current == '.') { + if (hasDecimal) + break; + + hasDecimal = true; + builder.append(reader.advance()); + } else + break; + } + + return new Token(Type.NUMBER, builder.toString(), start); + } + + /** + * Tokenize a comparator operator + */ + private Token tokenizeArray(String[] symbols, Type type) { + var symbol = reader.filter(symbols); + Token result = null; + + if (symbol != null) { + result = new Token(type, symbol, reader.getPosition()); + reader.skip(symbol); + } + + return result; + } + + /** + * Tokenize a spacer (sequence of spaces/tabs) + */ + private Token tokenizeSpacer() { + var start = reader.getPosition(); + var builder = new StringBuilder(); + + while (reader.hasNext() && (reader.peek() == ' ' || reader.peek() == '\t')) + builder.append(reader.advance()); + + return new Token(Type.SPACER, builder.toString(), start); + } + + /** + * Tokenize plain text until the next expression or HTML tag + */ + private Token tokenizeText() { + int start = reader.getPosition(); + var builder = new StringBuilder(); + + do { + builder.append(reader.advance()); + } while (reader.hasNext() && StringUtils.isAsciiLetter(reader.peek())); + + return new Token(Type.TEXT, builder.toString(), start); + } + + // ===== Helper ===== + + /** + * Check if the current position starts a number + */ + private boolean isNumberType() { + char current = reader.peek(); + return Character.isDigit(current) || + (current == '-' && Character.isDigit(reader.next())); + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/Parser.java b/src/main/java/au/ellie/hyui/html/template/Parser.java new file mode 100644 index 0000000..fcbd4fb --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/Parser.java @@ -0,0 +1,1151 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template; + +import au.ellie.hyui.html.template.exception.ParserException; +import au.ellie.hyui.html.template.item.Attribute; +import au.ellie.hyui.html.template.item.Attribute.ConditionAttribute; +import au.ellie.hyui.html.template.item.Attribute.ControlAttribute; +import au.ellie.hyui.html.template.item.Attribute.ParsedAttributes; +import au.ellie.hyui.html.template.item.Node; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.ExpressionAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.FlagAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.MixedAttributeNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.ComponentBlockNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.ConditionalBlockNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.ConditionalBlockNode.ConditionalBranch; +import au.ellie.hyui.html.template.item.Node.BlockNode.ForBlockNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.SlotBlockNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.*; +import au.ellie.hyui.html.template.item.Node.MarkerNode; +import au.ellie.hyui.html.template.item.Token; +import au.ellie.hyui.html.template.item.Token.Type; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Stack; +import java.util.function.Consumer; + +import static au.ellie.hyui.html.template.item.Symbols.*; +import static au.ellie.hyui.utils.ObjectUtils.mutableListOf; + +public class Parser { + private final Stack> stack = new Stack<>(); + private final List tokens; + private final String source; + private int pos = 0; + + public Parser(List tokens) { + this(tokens, null); + } + + public Parser(List tokens, String source) { + this.tokens = tokens; + this.source = source; + } + + /** + * Parse the tokens into an AST + */ + public List parse() { + var nodes = new ArrayList(); + stack.push(nodes); + + while (hasNext()) { + var node = parseNode(); + if (node != null) + nodes.add(node); + } + + postProcessNodes(nodes, List.of( + this::optimizeTextNodes, + this::optimizeConditionalChains + )); + + return stack.pop(); + } + + /** + * Parse and return the next node from the tokens list + * Unknown tokens are parsed as text nodes + */ + private Node parseNode() { + // Handle mustache expressions + if (match(Type.OPEN_EXPRESSION)) + return parseMustacheExpression(); + + // Handle HTML/component tags + if (match(Type.OPEN_ANGLE_BRACKET)) + return parseTag(); + + // Others tokens are treated as text + if (hasNext()) + return new TextNode(advance().value()); + + return null; + } + + /** + * Parse mustache expression + *
+     *   {{if}}, {{$var}}, {{expr}}
+     * 
+ */ + private Node parseMustacheExpression() { + advance(); // consume {{ + skipWhitespace(); + + // Parse control flow blocks + if (match(Type.KEYWORD)) { + if (consumeSymbol(Type.KEYWORD, KEYWORD_IF)) + return parseConditionalBlock(); + else if (consumeSymbol(Type.KEYWORD, KEYWORD_FOR)) + return parseForBlock(); + } + + // Parse expression + var expr = parseExpression(true); + + skipWhitespace(); + expect(Type.CLOSE_EXPRESSION, "Expected '}}' after expression"); + + return expr; + } + + /** + * Parse a conditional block + *
+     *   {{#if condition}}...{{else}}...{{/if}}
+     * 
+ */ + private Node parseConditionalBlock() { + // Parse branches (if, else-if, else) + var branches = parseConditionalBranch(KEYWORD_IF, null); + + skipWhitespace(); + expect(Type.CLOSE_EXPRESSION, "Expected '}}' after closing if tag"); + + // Clean whitespace for standalone tags + var indent = cleanStandaloneLineWhitespace(); + var node = new ConditionalBlockNode(KEYWORD_IF, branches); + + stack.pop(); + return indent ? new MarkerNode(NEW_LINE, node) : node; + } + + /** + * Recursively parse conditional branches (if, else-if, else) and return a list of branches in reverse order + * This allows for proper nesting of else-if and else blocks within the AST + * + * @param keyword The keyword of the current branch being parsed (if, else-if, else) + * @param list The list to accumulate branches into (used for recursion) + */ + private List parseConditionalBranch(String keyword, List list) { + var branch = (String) null; + if (list == null) + list = new ArrayList<>(); + + // Parse control attribute + var control = parseConditionAttribute(keyword, Type.CLOSE_EXPRESSION); + var body = new ArrayList(); + + cleanStandaloneLineWhitespace(); + + stack.push(body); + while (hasNext()) { + if (consume(Type.OPEN_EXPRESSION)) { + int savedPos = pos - 1; + skipWhitespace(); + + // Looking for else token + if (consumeSymbol(Type.KEYWORD, KEYWORD_ELSE)) { + branch = KEYWORD_ELSE; + skipWhitespace(); + + // Support else-if by checking for if after else + if (consumeSymbol(Type.KEYWORD, KEYWORD_IF)) { + branch = KEYWORD_ELSE_IF; + skipWhitespace(); + } + + break; + } + + // Looking for end token + if (consume(Type.SLASH)) { + skipWhitespace(); + + if (consumeSymbol(Type.KEYWORD, KEYWORD_IF)) + break; + } + + pos = savedPos; + } + + var node = parseNode(); + if (node != null) + body.add(node); + } + + if (branch != null) { + stack.pop(); + parseConditionalBranch(branch, list); + } + + list.addFirst(new ConditionalBranch(control.condition(), body)); + return list; + } + + /** + * Parse a loop block + *
+     *   {{#for $item in $list}}...{{/for}}
+     * 
+ */ + private Node parseForBlock() { + // Parse control attribute + var control = parseControlAttribute(Type.CLOSE_EXPRESSION); + cleanStandaloneLineWhitespace(); + + // Parse body + var body = new ArrayList(); + + stack.push(body); + while (hasNext()) { + if (consume(Type.OPEN_EXPRESSION)) { + int savedPos = pos - 1; + skipWhitespace(); + + if (consume(Type.SLASH)) { + skipWhitespace(); + + if (consumeSymbol(Type.KEYWORD, KEYWORD_FOR)) + break; + } + + pos = savedPos; + } + + var node = parseNode(); + if (node != null) + body.add(node); + } + + skipWhitespace(); + expect(Type.CLOSE_EXPRESSION, "Expected '}}' after closing for tag"); + + // Clean whitespace for standalone tags + var indent = cleanStandaloneLineWhitespace(); + var node = new ForBlockNode(control.itemName(), control.indexName(), control.collection(), body); + + stack.pop(); + return indent ? new MarkerNode(NEW_LINE, node) : node; + } + + /** + * Parse an expression (variable, property access, operators, etc.) + */ + private ExpressionNode parseExpression(boolean initial) { + return parseNullCoalescingExpression(initial); + } + + /** + * Parse null coalescing expression + */ + private ExpressionNode parseNullCoalescingExpression(boolean initial) { + var alternatives = new ArrayList(); + + do { + alternatives.add(parseOrExpression(initial)); + initial = false; + } while (consumeSymbol(Type.OPERATOR, NULL_COALESCING)); + + return alternatives.size() == 1 ? alternatives.getFirst() : new DefaultNode(alternatives); + } + + /** + * Parse OR expression + */ + private ExpressionNode parseOrExpression(boolean initial) { + var left = parseAndExpression(initial); + + while (consumeSymbol(Type.OPERATOR, OR)) { + var right = parseAndExpression(initial); + left = new BinaryOpNode(left, OR, right); + } + + return left; + } + + /** + * Parse AND expression + */ + private ExpressionNode parseAndExpression(boolean initial) { + var left = parseComparisonExpression(initial); + + while (consumeSymbol(Type.OPERATOR, AND)) { + var right = parseComparisonExpression(initial); + left = new BinaryOpNode(left, AND, right); + } + + return left; + } + + /** + * Parse comparison expression + */ + private ExpressionNode parseComparisonExpression(boolean initial) { + var left = parsePipeExpression(initial); + + while (matchAll(Type.EXCLAMATION, Type.ASSIGN) || match(Type.CLOSE_ANGLE_BRACKET, Type.OPEN_ANGLE_BRACKET, Type.ASSIGN) || matchSymbol(Type.KEYWORD, KEYWORD_IN, KEYWORD_NOT_IN)) { + var op = advance().value(); + if (consume(Type.ASSIGN)) + op += ASSIGN; + + var right = parsePipeExpression(false); + left = new BinaryOpNode(left, op, right); + } + + return left; + } + + /** + * Parse pipe expression + */ + private ExpressionNode parsePipeExpression(boolean initial) { + var expr = parsePrimaryExpression(initial); + skipWhitespace(); + + while (consume(Type.PIPE)) { + skipWhitespace(); + + var token = expect(Type.TEXT, "Expected filter name after '|'"); + skipWhitespace(); + + expr = new PipeNode(expr, token.value()); + } + + return expr; + } + + /** + * Parse primary expression (literals, variables, property access) + */ + private ExpressionNode parsePrimaryExpression(boolean initial) { + skipWhitespace(); + + // Boolean literals true + if (consumeSymbol(Type.KEYWORD, KEYWORD_TRUE)) + return new LiteralNode(true); + + // Boolean literals false + if (consumeSymbol(Type.KEYWORD, KEYWORD_FALSE)) + return new LiteralNode(false); + + // Numbers + if (match(Type.NUMBER)) + return parseNumberLiteral(); + + // Backslash + if (consume(Type.BACK_SLASH)) { + var token = peek(); + if (token.type() == Type.QUOTE || token.type() == Type.SINGLE_QUOTE) + return parseStringLiteral(token.type(), true); + + return new LiteralNode(token.value()); + } + + // String literals with double quotes + if (match(Type.QUOTE)) + return parseStringLiteral(Type.QUOTE, false); + + // String literals with single quotes + if (match(Type.SINGLE_QUOTE)) + return parseStringLiteral(Type.SINGLE_QUOTE, false); + + // Variables and property access + if (match(Type.VARIABLE, Type.EXCLAMATION)) + return parseVariable(); + + // Text + if (match(Type.TEXT) && !initial) + return new LiteralNode(advance().value()); + + throw error("Unexpected token in expression"); + } + + /** + * Parse a variable reference, including property access + *
+     *   $var
+     *   $var.property
+     *   $var.property.subproperty
+     * 
+ */ + private ExpressionNode parseVariable() { + var reversed = consume(Type.EXCLAMATION); + advance(); // consume $ + + var varName = joinTokens(Type.TEXT, Type.NUMBER, Type.COLON, Type.KEYWORD); + if (varName.isEmpty()) + throw error("Expected variable name after '$'"); + + // Check for property access + ExpressionNode expr = new VariableNode(varName, reversed); + + while (match(Type.DOT)) { + advance(); // consume . + + var property = joinTokens(Type.TEXT, Type.NUMBER, Type.COLON, Type.KEYWORD); + if (property.isEmpty()) + throw error("Expected property name after '.'"); + + expr = new PropertyAccessNode(expr, property); + } + + return expr; + } + + /** + * Parse a number literal + */ + private LiteralNode parseNumberLiteral() { + var num = advance().value(); + if (num.contains(".")) + return new LiteralNode(Double.parseDouble(num)); + + return new LiteralNode(Integer.parseInt(num)); + } + + /** + * Parse a string literal + * + * @param quoteType The type of quote used (QUOTE or SINGLE_QUOTE) + * @param escaped Whether the string started with an escaped quote (\") + */ + private LiteralNode parseStringLiteral(Type quoteType, boolean escaped) { + advance(); // consume opening quote + + var builder = new StringBuilder(); + while (hasNext()) { + if (matchAll(Type.BACK_SLASH, quoteType)) { + advance(); // consume backslash + if (escaped) { + advance(); // consume quote + break; + } + + builder.append(advance().value()); + } else if (!escaped && match(quoteType)) { + advance(); // consume quote + break; + } else + builder.append(advance().value()); + } + + return new LiteralNode(builder.toString()); + } + + /** + * Parse HTML/component tags + */ + private Node parseTag() { + advance(); // consume < + + // Html comments + if (match(Type.EXCLAMATION, Type.MARKER_COMMENTS)) + return parseComment(); + + // Check for slot input syntax: <:name> + if (match(Type.COLON)) + return parseSlotTag(true); + + // Check for closing tag + if (match(Type.SLASH)) + return parseClosingTag(); + + // Parse tag name + var tagName = joinTokens(Type.TEXT, Type.NUMBER, Type.KEYWORD); + if (tagName.isEmpty()) + throw error("Expected tag name after '<'"); + + // Check if it's a slot tag + if (tagName.equals(HTML_TAG_SLOT)) + return parseSlotTag(false); + + // Parse attributes (including control flow attributes) + var parsed = parseAttributes(); + var attributes = parsed.attributes(); + + skipWhitespaceAndNewlines(); + + // Check for self-closing tag + if (consume(Type.SLASH)) { + expect(Type.CLOSE_ANGLE_BRACKET, String.format(""" + Expected '>' after '/' but found '%s'. + If you have a comparison in an attribute value, make sure it's properly quoted. + """, peek().value()) + ); + + return parsed.build( + new ComponentBlockNode(tagName, attributes, mutableListOf()) + ); + } + + expect(Type.CLOSE_ANGLE_BRACKET, String.format(""" + Expected '>' after '/' but found '%s'. + If you have a comparison in an attribute value, make sure it's properly quoted. + """, peek().value()) + ); + + // Check if this is a void element (cannot have children or closing tag) + if (VOID_ELEMENTS.contains(tagName.toLowerCase())) + return parsed.build( + new ComponentBlockNode(tagName, attributes, mutableListOf()) + ); + + // Parse children + var children = new ArrayList(); + + stack.push(children); + while (hasNext()) { + // Check for closing tag + if (consume(Type.OPEN_ANGLE_BRACKET)) { + int savedPos = pos - 1; + + if (consume(Type.SLASH)) { + skipWhitespace(); + + if (joinTokens(Type.TEXT, Type.NUMBER, Type.KEYWORD).equals(tagName)) { + expect(Type.CLOSE_ANGLE_BRACKET, "Expected '>' after closing tag"); + break; + } + } + + pos = savedPos; + } + + var child = parseNode(); + if (child != null) + children.add(child); + } + stack.pop(); + + return parsed.build( + new ComponentBlockNode(tagName, attributes, children) + ); + } + + /** + * Parse an HTML comment + */ + private Node parseComment() { + expect(Type.EXCLAMATION, "Expected '!' after '<' for comment"); + expect(Type.MARKER_COMMENTS, "Expected '--' after ' + * input: <:name> + * output: <slot> or <slot:name> + * + */ + private Node parseSlotTag(boolean input) { + String slotName = HTML_SLOT_DEFAULT; + + // Check for named slot: + if (consume(Type.COLON)) { + var itemName = joinTokens(Type.TEXT, Type.NUMBER, Type.KEYWORD); + if (!itemName.isEmpty()) + slotName = itemName; + else if (!input) + throw error("Expected slot name after 'slot:'"); + } + + var parsed = parseAttributes(); + var attributes = parsed.attributes(); + + // Check for self-closing + if (consume(Type.SLASH)) { + expect(Type.CLOSE_ANGLE_BRACKET, "Expected '>' after '/'"); + return parsed.build( + new SlotBlockNode(slotName, attributes, mutableListOf(), !input) + ); + } + + expect(Type.CLOSE_ANGLE_BRACKET, "Expected '>' after slot tag"); + + // Parse default content + var children = new ArrayList(); + + stack.push(children); + while (hasNext()) { + if (consume(Type.OPEN_ANGLE_BRACKET)) { + int savedPos = pos - 1; + + if (consume(Type.SLASH)) { + skipWhitespace(); + + if (input || consumeSymbol(Type.TEXT, "slot")) { + if (consume(Type.COLON)) { + var closeName = joinTokens(Type.TEXT, Type.NUMBER, Type.KEYWORD); + if (closeName.equals(slotName)) { + skipWhitespace(); + expect(Type.CLOSE_ANGLE_BRACKET, "Expected '>'"); + break; + } + } else if (!input) { + skipWhitespace(); + expect(Type.CLOSE_ANGLE_BRACKET, "Expected '>'"); + break; + } + } + } + + pos = savedPos; + } + + var child = parseNode(); + if (child != null) + children.add(child); + } + stack.pop(); + + return parsed.build( + new SlotBlockNode(slotName, attributes, children, !input) + ); + } + + /** + * Parse closing tag (removed from the AST) + */ + private Node parseClosingTag() { + advance(); // consume / + skipWhitespace(); + + if (joinTokens(Type.TEXT, Type.NUMBER, Type.KEYWORD).isEmpty()) { + if (consume(Type.COLON)) + joinTokens(Type.TEXT, Type.NUMBER, Type.KEYWORD); + } + + expect(Type.CLOSE_ANGLE_BRACKET, "Expected '>' after closing tag"); + return null; + } + + /** + * Parse tag attributes and extract control flow attributes (for, if) + */ + private ParsedAttributes parseAttributes() { + var attributes = new ArrayList(); + List flowAttributes = new ArrayList<>(); + skipWhitespaceAndNewlines(); + + while (hasNext() && !match(Type.CLOSE_ANGLE_BRACKET, Type.SLASH)) { + // Check for dynamic attribute with curly braces + if (match(Type.OPEN_EXPRESSION)) { + var expr = parseMustacheExpression(); + attributes.add(new ExpressionAttributeNode(expr)); + + skipWhitespaceAndNewlines(); + continue; + } + + // Parse attribute name + var name = joinTokens(Type.TEXT, Type.NUMBER, Type.KEYWORD, Type.AT); + if (name.isEmpty()) + break; + + skipWhitespace(); + + // Check for attribute value + if (consume(Type.ASSIGN)) { + skipWhitespace(); + + // Flow attributes: for="..." + if (name.equals(KEYWORD_FOR) && match(Type.QUOTE, Type.SINGLE_QUOTE)) { + var quoteType = advance().type(); + + flowAttributes.add(parseControlAttribute(quoteType)); + skipWhitespaceAndNewlines(); + continue; + } + + // Flow attributes like if="$condition" + if (KEYWORD_CONDITIONALS.contains(name) && match(Type.QUOTE, Type.SINGLE_QUOTE)) { + var quoteType = advance().type(); + + flowAttributes.add(parseConditionAttribute(name, quoteType)); + skipWhitespaceAndNewlines(); + continue; + } + + // Dynamic attribute: attr={expr} + if (consume(Type.OPEN_EXPRESSION)) { + var expr = parseExpression(true); + + skipWhitespace(); + expect(Type.CLOSE_EXPRESSION, "Expected '}}' after attribute expression"); + + attributes.add(new DynamicAttributeNode(name, expr)); + } + + // mixed attribute: attr="value {{$expr}} value" + else if (match(Type.QUOTE, Type.SINGLE_QUOTE)) { + var quoteType = advance().type(); + var builder = new StringBuilder(); + var parts = new ArrayList<>(); + + while (hasNext() && !match(quoteType)) { + if (match(Type.OPEN_EXPRESSION)) { + if (!builder.isEmpty()) { + parts.add(builder.toString()); + builder.setLength(0); + } + + parts.add(parseMustacheExpression()); + } else + builder.append(advance().value()); + } + + // Remaining static part + if (!builder.isEmpty()) + parts.add(builder.toString()); + + expect(quoteType, "Expected closing quote"); + attributes.add(new MixedAttributeNode(name, parts)); + } + + // Unquoted value + else { + var value = joinTokens(Type.TEXT, Type.NUMBER, Type.KEYWORD); + attributes.add(new MixedAttributeNode(name, mutableListOf(value))); + } + } else if (name.equals(KEYWORD_ELSE)) + flowAttributes.add(new ConditionAttribute(KEYWORD_ELSE, new LiteralNode(true))); + else + attributes.add(new FlagAttributeNode(name)); + + skipWhitespaceAndNewlines(); + } + + return new ParsedAttributes(attributes, flowAttributes); + } + + /** + * Parse the value of a "for" block + * Supports three syntaxes: + * - for="$items" (item name defaults to "item", no index) + * - for="$item in $items" (custom item name, no index) + * - for="$item, $index in $items" (custom item name and index name) + * + * @param delimiter The expected delimiter type to end the attribute value + */ + private ControlAttribute parseControlAttribute(Type delimiter) { + skipWhitespace(); + + var itemName = "item"; + var indexName = (String) null; + ExpressionNode collection; + + // Try to detect if this is "$item in $collection" or "$item, $index in $collection" syntax + if (match(Type.VARIABLE)) { + var firstVar = parseVariable(); + skipWhitespace(); + + // Check for comma (indicates index is present) + if (consume(Type.COMMA)) { + skipWhitespace(); + + // Parse index variable + if (!match(Type.VARIABLE)) + throw error("Expected index variable after comma in for block"); + + var indexVar = parseVariable(); + if (!(indexVar instanceof VariableNode(String indexVarName, boolean reversed))) + throw error("Expected variable for index in for block"); + + indexName = indexVarName; + skipWhitespace(); + + // Expect "in" keyword + if (!matchSymbol(Type.KEYWORD, KEYWORD_IN)) + throw error("Expected 'in' keyword in for block"); + } + + // Check for "in" keyword + if (consumeSymbol(Type.KEYWORD, KEYWORD_IN)) { + skipWhitespace(); + + // Parse collection + collection = parseExpression(false); + + // Extract item name from first variable + if (!(firstVar instanceof VariableNode(String name, boolean reversed))) + throw error("Expected variable for item in for block"); + + itemName = name; + } + + // Simplified syntax: "{{for $collection}}" + else + collection = firstVar; + + } else + throw error("Expected variable in for block"); + + skipWhitespace(); + + expect(delimiter, "Expected delimiter around `for` block"); + return new ControlAttribute(collection, itemName, indexName); + } + + /** + * Parse the value of an "if" block + * + * @param delimiter The expected delimiter type to end the attribute value + */ + private ConditionAttribute parseConditionAttribute(String keyword, Type delimiter) { + skipWhitespace(); + + // Parse condition expression, or use "true" if no condition is provided (e.g. {{if}} or if="") + // This allows for else-if and else blocks without conditions + var condition = (ExpressionNode) null; + if (match(delimiter)) + condition = new LiteralNode(true); + else + condition = parseExpression(true); + + skipWhitespace(); + + expect(delimiter, "Expected delimiter around `if` block"); + return new ConditionAttribute(keyword, condition); + } + + // ===== Navigation ===== + + private boolean hasNext() { + return pos < tokens.size() && !peek().match(Type.EOI); + } + + private Token peek() { + return peek(pos); + } + + private Token peek(int index) { + if (index < 0 || index >= tokens.size()) + return null; + + return tokens.get(index); + } + + private Token advance() { + if (hasNext()) + return tokens.get(pos++); + + return tokens.getLast(); + } + + private boolean consume(Type... type) { + if (match(type)) { + advance(); + return true; + } + + return false; + } + + private boolean consumeSymbol(Type type, String... symbols) { + if (matchSymbol(type, symbols)) { + advance(); + return true; + } + + return false; + } + + private boolean match(Type... types) { + if (!hasNext()) + return false; + + for (var type : types) + if (peek().type() == type) + return true; + + return false; + } + + private boolean matchAll(Type... types) { + int index = 0; + + for (var type : types) { + var token = peek(pos + index++); + + if (token == null || token.type() != type) + return false; + } + + return true; + } + + private boolean matchSymbol(Type type, String... symbols) { + if (!hasNext() || !match(type)) + return false; + + return peek().match(symbols); + } + + private Token expect(Type type, String message) { + if (!match(type)) + throw error(message); + + return advance(); + } + + private void skipWhitespace() { + while (match(Type.SPACER)) + advance(); + } + + /** + * Skip all whitespace including newlines + */ + private void skipWhitespaceAndNewlines() { + while (match(Type.SPACER, Type.NEW_LINE)) + advance(); + } + + // ===== Helper ===== + + /** + * Create a ParserException with source context + */ + private ParserException error(String message) { + return new ParserException(message, peek(), source); + } + + /** + * Remove whitespace for standalone block tags. + *

+ * If a block tag ({{if}}, {{/if}}, etc.) is alone on a line, + * remove the entire line including leading/trailing whitespace + * + * @return true if whitespace was removed, false otherwise + */ + private boolean cleanStandaloneLineWhitespace() { + var list = stack.peek(); + + // Check previous nodes - look backwards for whitespace and newline + // Stop if we find any non-whitespace text before the block tag on the same line + var nodesToRemove = 0; + for (int i = list.size() - 1; i >= 0; i--) { + Node node = list.get(i); + + if (node instanceof TextNode(String content)) { + if (content.matches("^[ \\t]+$")) + nodesToRemove++; + else if (content.equals("\n")) + break; + else + return false; + } else if (node instanceof MarkerNode(String content, Node _)) { + if (content.equals(NEW_LINE)) + break; + + return false; + } else + return false; + } + + var checkPos = pos; + var tokensToSkip = 0; + + // Check after the block tag using the tokens + while (checkPos < tokens.size()) { + Token token = tokens.get(checkPos); + + if (token.match(Type.SPACER)) { + tokensToSkip++; + checkPos++; + } else if (token.match(Type.NEW_LINE)) { + tokensToSkip++; + break; + } else + return false; + } + + // Remove the nodes from the list + for (var i = 0; i < nodesToRemove; i++) + list.removeLast(); + + // Skip the tokens after the block tag + pos += tokensToSkip; + return true; + } + + /** + * Join consecutive tokens of the given types into a single string + */ + private String joinTokens(Type... tokens) { + var builder = new StringBuilder(); + while (match(tokens)) + builder.append(advance().value()); + + return builder.toString(); + } + + // ==== Optimization Passes ==== + + /** + * Apply post-processing functions to a node and its children recursively + * + * @param nodes The list of nodes to process + * @param postProcessors The list of post-processing functions to apply + */ + private void postProcessNodes(List nodes, List>> postProcessors) { + // Apply post-processing functions to children first + for (var node : nodes) { + switch (node) { + case ConditionalBlockNode ifNode -> { + for (var branch : ifNode.branches()) + postProcessNodes(branch.body(), postProcessors); + } + case ForBlockNode forNode -> postProcessNodes(forNode.body(), postProcessors); + case ComponentBlockNode componentNode -> postProcessNodes(componentNode.children(), postProcessors); + case SlotBlockNode slotNode -> postProcessNodes(slotNode.children(), postProcessors); + default -> { + // No children to process + } + } + } + + // Then apply post-processing functions to the node itself + for (var processor : postProcessors) + processor.accept(nodes); + } + + /** + * Merge consecutive TextNodes in the given list into single TextNodes + * + * @param nodes The list of nodes to optimize + */ + @SuppressWarnings("unchecked") + private void optimizeTextNodes(List nodes) { + if (nodes.isEmpty()) + return; + + var result = new ArrayList(); + var textBuilder = new StringBuilder(); + + for (var node : nodes) { + if (node instanceof TextNode(String content)) + textBuilder.append(content); + else { + if (!textBuilder.isEmpty()) { + result.add((T) new TextNode(textBuilder.toString())); + textBuilder.setLength(0); + } + + result.add(node); + } + } + + if (!textBuilder.isEmpty()) + result.add((T) new TextNode(textBuilder.toString())); + + nodes.clear(); + nodes.addAll(result); + } + + /** + * Group consecutive if/else-if/else elements into proper conditional chains. + * This ensures that only the first matching condition renders. + * + * @param nodes The list of nodes to optimize + */ + private void optimizeConditionalChains(List nodes) { + if (nodes.isEmpty()) + return; + + var result = new ArrayList(); + var spaces = new ArrayList(); + var condition = (ConditionalBlockNode) null; + + for (var node : nodes) { + // Check if this is a conditional block that can be part of a chain + if (node instanceof ConditionalBlockNode conditionalBlockNode) { + if (condition == null) + condition = conditionalBlockNode; + else if (!Objects.equals(conditionalBlockNode.name(), KEYWORD_IF)) + condition.branches().addAll(conditionalBlockNode.branches()); + else { + result.add(condition); + condition = conditionalBlockNode; + } + + continue; + } + + // Special handling for whitespace nodes between conditional blocks + if (condition != null) { + var isSpace = node instanceof TextNode(String content) && content.isBlank(); + + if (!isSpace) { + result.add(condition); + condition = null; + } else + spaces.add(result.size()); + } + + result.add(node); + } + + // Add any remaining condition at the end + if (condition != null) + result.add(condition); + + // Clear whitespace + for (var spaceIndex : spaces.reversed()) + result.remove(spaceIndex.intValue()); + + nodes.clear(); + nodes.addAll(result); + } +} \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/html/template/context/ExecutionPolicy.java b/src/main/java/au/ellie/hyui/html/template/context/ExecutionPolicy.java new file mode 100644 index 0000000..67b67f2 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/ExecutionPolicy.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.context; + +/** + * Execution policy for lazily evaluated variables. + */ +public enum ExecutionPolicy { + /** + * No caching. + * The value with be evaluated on every request. + */ + DYNAMIC, + + /** + * Cache the value after the first evaluation. + */ + CACHED, + + /** + * Evaluate the value only once, deleting it afterward. + * This is useful for one-time action. + */ + EPHEMERAL, + + /** + * Evaluate the value until the first null result, + * then delete it from the stack. + */ + NON_NULL +} \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/html/template/context/FilterRegistry.java b/src/main/java/au/ellie/hyui/html/template/context/FilterRegistry.java new file mode 100644 index 0000000..4db1909 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/FilterRegistry.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.context; + +import au.ellie.hyui.utils.ParseUtils; +import au.ellie.hyui.utils.StringUtils; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +public class FilterRegistry { + + private final Map filters = new HashMap<>(); + + public FilterRegistry() { + register("uppercase", value -> value == null ? null : value.toString().toUpperCase(), "upper"); + register("lowercase", value -> value == null ? null : value.toString().toLowerCase(), "lower"); + register("capitalize", value -> StringUtils.capitalize(value.toString())); + register("capitalizeAll", value -> StringUtils.capitalizeAll(value.toString())); + register("trim", value -> value == null ? null : value.toString().trim()); + register("length", value -> switch (value) { + case null -> 0; + case String s -> s.length(); + case Collection c -> c.size(); + case Map m -> m.size(); + default -> value.toString().length(); + }); + register("number", value -> { + var num = ParseUtils.parseDouble(value.toString()); + if (num.isEmpty()) + return value; + + var numValue = num.get(); + if (numValue % 1 == 0) + return String.format(Locale.ENGLISH, "%,d", numValue.longValue()); + + return String.format("%,.2f", numValue); + }); + register("percent", value -> { + var num = ParseUtils.parseDouble(value.toString()); + if (num.isEmpty()) + return value; + + return String.format(Locale.ENGLISH, "%.0f%%", num.get() * 100); + }); + } + + /** + * Register a new filter. + * + * @param name The name of the filter. + * @param filter The filter implementation. + */ + public void register(String name, Filter filter, String... aliases) { + filters.put(name, filter); + + for (String alias : aliases) + filters.put(alias, filter); + } + + /** + * Get a filter by name. + * + * @param name The name of the filter. + * @return The filter implementation. + * @throws RuntimeException If the filter is not found. + */ + public Filter get(String name) { + Filter filter = filters.get(name); + if (filter == null) + throw new RuntimeException("Unknown filter: " + name); + + return filter; + } + + @FunctionalInterface + public interface Filter { + Object apply(Object value); + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/context/SlotSupplier.java b/src/main/java/au/ellie/hyui/html/template/context/SlotSupplier.java new file mode 100644 index 0000000..362ff7e --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/SlotSupplier.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.context; + +import au.ellie.hyui.html.template.item.Node; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +public class SlotSupplier implements Supplier { + private final Function handler; + private List nodes; + + public SlotSupplier(Function handler) { + this.handler = handler; + } + + public void add(Node node) { + if (nodes == null) + nodes = new ArrayList<>(); + + nodes.add(node); + } + + @Override + public String get() { + var result = new StringBuilder(); + for (Node node : nodes) + result.append(handler.apply(node)); + + return result.toString().trim(); + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java b/src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java new file mode 100644 index 0000000..df0a89a --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java @@ -0,0 +1,89 @@ +package au.ellie.hyui.html.template.context; + +import au.ellie.hyui.html.template.context.VariableStack.VariableScope; + +public interface VariableHandler { + + /** + * Retrieve the variable stored in the handler + */ + Object get(); + + /** + * Process the value after transformation, + * allowing for custom handling of the variable. + * + * @param key The key associated with the variable + * @param value The stored value after transformation + * @param scope The {@link VariableScope} used to retrieve the variable + */ + void handle(String key, Object value, VariableScope scope); + + /** + * A simple implementation of VariableHandler that caches the value. + * This handle simply stores the value in the {@link VariableScope} without any additional processing. + */ + class CachingVariableHandler implements VariableHandler { + private final Object cachedValue; + + public CachingVariableHandler(Object value) { + this.cachedValue = value; + } + + @Override + public Object get() { + return cachedValue; + } + + @Override + public void handle(String key, Object value, VariableScope scope) { + scope.put(key, value); + } + } + + /** + * A simple implementation of VariableHandler that delete the variable if the value is null. + * This handle remove the key from the {@link VariableScope} if the value is null, otherwise it does nothing. + */ + class NonNullVariableHandler implements VariableHandler { + private final Object cachedValue; + + public NonNullVariableHandler(Object value) { + this.cachedValue = value; + } + + @Override + public Object get() { + return cachedValue; + } + + @Override + public void handle(String key, Object value, VariableScope scope) { + if (value == null) + scope.remove(key); + } + } + + /** + * A simple implementation of VariableHandler that delete the variable after the first access. + * This handle remove the key from the {@link VariableScope} after the first retrieval of the variable, + * allowing for one-time use variables. + */ + class EphemeralVariableHandler implements VariableHandler { + private final Object cachedValue; + + public EphemeralVariableHandler(Object value) { + this.cachedValue = value; + } + + @Override + public Object get() { + return cachedValue; + } + + @Override + public void handle(String key, Object value, VariableScope scope) { + scope.remove(key); + } + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java new file mode 100644 index 0000000..161d869 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.context; + +import au.ellie.hyui.html.TemplateProcessor.ValueResolver; +import au.ellie.hyui.utils.LambdaUtils; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.*; +import java.util.function.Function; +import java.util.function.Supplier; + +public class VariableStack { + public static final Object NULL_SENTINEL = new Object(); + + private final ArrayDeque stack = new ArrayDeque<>(); + private final ValueResolver valueResolver; + private final boolean preferDynamicValues; + + public VariableStack(VariableScope globalScope, @Nullable ValueResolver valueResolver, boolean preferDynamicValues) { + this.valueResolver = valueResolver; + this.preferDynamicValues = preferDynamicValues; + + pushScope(globalScope); + } + + /** + * Push a new scope onto the stack. + * + * @param scope Scope to push + */ + public void pushScope(VariableScope scope) { + stack.push(scope); + } + + /** + * Pop the current scope from the stack. + * Cannot pop the global scope. + */ + public void popScope() { + if (stack.size() > 1) + stack.pop(); + else + throw new IllegalStateException("Cannot pop the global scope"); + } + + /** + * Retrieve a variable from the stack. + * + * @param name Variable name + * @return Variable value, or null if not found + */ + public Object getVariable(String name) { + return getVariable(name, null); + } + + /** + * Retrieve a variable from the stack, with a default value. + * + * @param name Variable name + * @param defaultValue Default value if variable not found + * @return Variable value, or defaultValue if not found + */ + public Object getVariable(String name, Supplier defaultValue) { + // Check dynamic values first if preferred + if (preferDynamicValues && valueResolver != null) { + Optional resolved = valueResolver.resolve(name); + if (resolved.isPresent() && resolved.get() != NULL_SENTINEL) + return resolved.get(); + } + + for (VariableScope scope : stack) { + if (scope.containsKey(name)) { + var object = scope.get(name); + if (object == null) + return null; + + if (object instanceof VariableHandler handler) { + object = resolveVariable(handler.get(), defaultValue); + handler.handle(name, object, scope); + } else + object = resolveVariable(object, defaultValue); + + if (object instanceof Optional optional) + return optional.orElse(null); + + return object; + } + } + + // Check dynamic values last if not preferred + if (valueResolver != null) { + Optional resolved = valueResolver.resolve(name); + if (resolved.isPresent()) { + Object value = resolved.get(); + return value == NULL_SENTINEL ? null : value; + } + } + + return defaultValue.get(); + } + + /** + * Get the name of the current scope. + */ + @Nonnull + public String getScopeName() { + var scope = stack.peek(); + + return scope != null ? scope.getName() : "none"; + } + + /** + * Check if the current scope has the given name. + * + * @param name Scope name to check + */ + public boolean isScope(String name) { + var scope = stack.peek(); + + return scope != null && scope.getName().equals(name); + } + + /** + * Get the keys of the current scope, if available. + */ + public Set getScopeKeys() { + var scope = stack.peek(); + + return scope != null ? scope.getKeys() : null; + } + + /** + * Resolve a variable that may be a function or supplier. + * + * @param object The variable value to resolve + * @param defaultValue Default value supplier for function calls + * @return Resolved variable value + */ + private Object resolveVariable(Object object, Supplier defaultValue) { + if (LambdaUtils.isFunction(object)) + object = LambdaUtils.call(object, this, defaultValue); + + return object; + } + + // ===== Scope ===== + + public static class VariableScope { + + private final Map content; + private final Set keys; + private final String name; + + public VariableScope(String name) { + this(name, new HashMap<>(), new HashSet<>()); + } + + public VariableScope(String name, Map content) { + this(name, content, new HashSet<>()); + } + + public VariableScope(String name, Map content, @Nonnull Set keys) { + this.name = name; + this.content = content; + this.keys = keys; + } + + public String getName() { + return name; + } + + public Map getContent() { + return content; + } + + public Set getKeys() { + return keys; + } + + public boolean containsKey(String key) { + return content.containsKey(key); + } + + public Object get(String key) { + return content.get(key); + } + + @SuppressWarnings("unchecked") + public T computeIfAbsent(String key, Function defaultValue) { + return (T) content.computeIfAbsent(key, defaultValue); + } + + public void put(String key, Object value) { + content.put(key, value); + } + + public void remove(String key) { + content.remove(key); + keys.remove(key); + } + + public void putKeyed(String key, Object value) { + content.put(key, value); + keys.add(key); + } + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/exception/EvaluationException.java b/src/main/java/au/ellie/hyui/html/template/exception/EvaluationException.java new file mode 100644 index 0000000..56730ce --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/exception/EvaluationException.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.exception; + +import au.ellie.hyui.html.template.item.Node; + +public class EvaluationException extends RuntimeException { + + /** + * The node in cause at the time of the exception + */ + public final Node node; + + public EvaluationException(String message, Node node) { + super(message); + this.node = node; + } + + /** + * Exception thrown when a component is not found in the context during evaluation + */ + public static class ComponentNotFoundException extends EvaluationException { + + /** + * The tag of the component that was found + */ + public final String tag; + + public ComponentNotFoundException(String message, Node node, String tag) { + super(message, node); + this.tag = tag; + } + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/exception/ParserException.java b/src/main/java/au/ellie/hyui/html/template/exception/ParserException.java new file mode 100644 index 0000000..aaaf39e --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/exception/ParserException.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.exception; + +import au.ellie.hyui.html.template.item.Token; + +import javax.annotation.Nonnull; + +public class ParserException extends RuntimeException { + + /** + * The token in cause at the time of the exception + */ + public final Token token; + + /** + * The source template text (optional) + */ + public final String source; + + /** + * Create a parser exception with position information + * + * @param message The error message + * @param token The token where the error occurred + */ + public ParserException(String message, @Nonnull Token token) { + this(message, token, null); + } + + /** + * Create a parser exception with position information and source text + * + * @param message The error message + * @param token The token where the error occurred + * @param source The original template source text (used for line/column info) + */ + public ParserException(String message, Token token, String source) { + super(formatMessage(message, token, source)); + this.token = token; + this.source = source; + } + + /** + * Format the exception message. If source is provided, include line + * and column information with a caret pointing to the error location + * + * @param message The error message + * @param token The token where the error occurred + * @param source The original template source text (optional) + */ + private static String formatMessage(String message, Token token, String source) { + var position = token.position(); + if (source == null) + return String.format("%s at position %d (token: %s)", message, position, token); + + var lineCol = getLineAndColumn(source, position); + var line = lineCol[0]; + var col = lineCol[1]; + + // Get the line content and create a caret pointer + var lineContent = getLine(source, position); + var caret = " ".repeat(Math.max(0, col - 1)) + "^"; + + return String.format(""" + %s at line %d, column %d + %s + %s + """, + message, line, col, lineContent, caret + ); + } + + /** + * Calculate line and column number from character position + * + * @param source The original template source text + * @param position The character index in the source text (0-based) + * @return array with [line, column] (1-based) + */ + private static int[] getLineAndColumn(String source, int position) { + var line = 1; + var col = 1; + + for (var i = 0; i < position && i < source.length(); i++) { + if (source.charAt(i) == '\n') { + line++; + col = 1; + } else + col++; + } + + return new int[]{line, col}; + } + + /** + * Extract the line containing the given position + * + * @param source The original template source text + * @param position The character index in the source text (0-based) + */ + private static String getLine(String source, int position) { + if (position < 0 || position >= source.length()) + return ""; + + // Find start of line + var start = position; + while (start > 0 && source.charAt(start - 1) != '\n') + start--; + + // Find end of line + var end = position; + while (end < source.length() && source.charAt(end) != '\n') + end++; + + return source.substring(start, end) + .replace('\t', ' '); + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/item/Attribute.java b/src/main/java/au/ellie/hyui/html/template/item/Attribute.java new file mode 100644 index 0000000..145a6a9 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/item/Attribute.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.item; + +import au.ellie.hyui.html.template.item.Node.AttributeValueNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.ConditionalBlockNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.ConditionalBlockNode.ConditionalBranch; +import au.ellie.hyui.html.template.item.Node.BlockNode.ForBlockNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode; +import au.ellie.hyui.utils.StringReader; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static au.ellie.hyui.utils.ObjectUtils.mutableListOf; + +public interface Attribute { + + /** + * Hold extracted loop/conditional attributes from HTML elements + */ + record ControlAttribute(ExpressionNode collection, String itemName, String indexName) implements Attribute { + } + + /** + * Hold extracted condition attribute from HTML elements + */ + record ConditionAttribute(String name, ExpressionNode condition) implements Attribute { + } + + /** + * Record to hold parsed attributes along with control flow attributes + */ + record ParsedAttributes( + List attributes, + List flows + ) { + + /** + * Sort the control flow attributes + * + * @return A sorted list of control flow attributes + */ + public List sortedFlowAttributes() { + return flows.stream() + .sorted((a, b) -> { + if (a instanceof ControlAttribute && b instanceof ConditionAttribute) + return -1; + if (a instanceof ConditionAttribute && b instanceof ControlAttribute) + return 1; + return 0; + }).collect(Collectors.toCollection(ArrayList::new)); + } + + /** + * Build a node with the parsed attributes and control flow attributes + * Handles grouping of if/else-if/else chains + * + * @param base The base node to wrap with control flow nodes + * @return The final node with all control flow nodes applied + */ + public Node build(Node base) { + Node result = base; + + for (var attr : sortedFlowAttributes()) { + result = switch (attr) { + case ControlAttribute(ExpressionNode collection, String itemName, String indexName) -> + new ForBlockNode(itemName, indexName, collection, mutableListOf(result)); + case ConditionAttribute(String name, ExpressionNode condition) -> { + var branch = new ConditionalBranch(condition, mutableListOf(result)); + yield new ConditionalBlockNode(name, mutableListOf(branch)); + } + default -> result; + }; + } + + return result; + } + } + + /** + * Parse inline attributes from evaluated expression content. + * + * @param content The evaluated content containing attributes + * @param context The context map to add attributes to + */ + static void inlineAttributes(String content, Map context) { + var reader = new StringReader(content.trim()); + + while (reader.hasNext()) { + reader.skipWhitespace(); + if (!reader.hasNext()) + break; + + // Read attribute name (until whitespace or '=') + var name = reader.readWhile(c -> !Character.isWhitespace(c) && c != '='); + if (name.isEmpty()) + break; + + reader.skipWhitespace(); + + // Switch between flag and key-value attribute + if (reader.consume('=')) { + reader.skipWhitespace(); + + context.put(name, reader.readValue()); + } else + context.put(name, true); + } + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/item/Node.java b/src/main/java/au/ellie/hyui/html/template/item/Node.java new file mode 100644 index 0000000..426eed2 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/item/Node.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.item; + +import org.checkerframework.checker.nullness.compatqual.NonNullDecl; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public interface Node { + + // ---- Expression Nodes ---- + + sealed interface ExpressionNode extends Node { + /** + * Represents plain text in the template + */ + record TextNode(String content) implements ExpressionNode { + } + + /** + * Represents a literal value (string, number, boolean) + */ + record LiteralNode(Object value) implements ExpressionNode { + } + + /** + * Represents a variable reference + */ + record VariableNode(String name, boolean negated) implements ExpressionNode { + } + + /** + * Represents accessing a property of an object + */ + record PropertyAccessNode(ExpressionNode object, String property) implements ExpressionNode { + } + + /** + * Represents a binary operation between two expressions + */ + record BinaryOpNode(ExpressionNode left, String operator, ExpressionNode right) implements ExpressionNode { + } + + /** + * Represents applying a filter to an expression + */ + record PipeNode(ExpressionNode expression, String filterName) implements ExpressionNode { + } + + /** + * Represents a list of alternative expressions (like coalesce) + */ + record DefaultNode(List alternatives) implements ExpressionNode { + } + + /** + * Represents a comment block in the template + */ + record CommentNode(String content) implements ExpressionNode { + } + } + + // ---- Control Flow Nodes ---- + + interface BlockNode extends Node { + /** + * Represents an if / else-if / else control structure + */ + class ConditionalBlockNode implements BlockNode { + private final List branches; + private final String name; + + private Map tags; + + public ConditionalBlockNode(String name, List branches) { + this.branches = branches; + this.name = name; + } + + public Map getTags() { + if (tags == null) { + tags = new HashMap<>(); + + for (var branch : branches) { + var local = getLocal(branch); + + for (var entry : local.entrySet()) { + var tag = entry.getKey(); + var count = entry.getValue(); + if (tags.containsKey(tag) && tags.get(tag) >= count) + count = tags.get(tag); + + tags.put(tag, count); + } + } + } + + return tags; + } + + @NonNullDecl + private HashMap getLocal(ConditionalBranch branch) { + var local = new HashMap(); + for (var node : branch.body) { + switch (node) { + case ComponentBlockNode c -> { + if (!c.tag.equals(Symbols.HTML_TAG_TEMPLATE)) + local.put(c.tag, local.getOrDefault(c.tag, 0) + 1); + } + case SlotBlockNode s -> local.put(s.name, local.getOrDefault(s.name, 0) + 1); + default -> { + // Ignore other nodes + } + } + } + return local; + } + + public String name() { + return name; + } + + public List branches() { + return branches; + } + + public record ConditionalBranch(ExpressionNode condition, List body) { + } + } + + /** + * Represents an `each` control structure + */ + record ForBlockNode(String itemName, String indexName, ExpressionNode collection, + List body) implements BlockNode { + } + + /** + * Represents an HTML element with attributes and children + */ + record ComponentBlockNode(String tag, List attributes, + List children) implements BlockNode { + } + + /** + * Represents an HTML slot element with attributes and children + */ + record SlotBlockNode(String name, List attributes, + List children, boolean output) implements BlockNode { + } + } + + // ---- Component Nodes ---- + + sealed interface AttributeValueNode extends Node { + String getName(); + + record MixedAttributeNode(String name, List parts) implements AttributeValueNode { + public String getName() { + return name; + } + } + + record DynamicAttributeNode(String name, ExpressionNode expression) implements AttributeValueNode { + public String getName() { + return name; + } + } + + record FlagAttributeNode(String name) implements AttributeValueNode { + public String getName() { + return name; + } + } + + record ExpressionAttributeNode(Node expressions) implements AttributeValueNode { + public String getName() { + return ""; + } + } + } + + // ---- Markers ---- + + record MarkerNode(String content, Node inside) implements Node { + } +} + diff --git a/src/main/java/au/ellie/hyui/html/template/item/Symbols.java b/src/main/java/au/ellie/hyui/html/template/item/Symbols.java new file mode 100644 index 0000000..62abc60 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/item/Symbols.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.item; + +import java.util.Set; + +public class Symbols { + + // Template delimiters + public final static String CLOSE_ANGLE_BRACKET = ">"; + public final static String OPEN_ANGLE_BRACKET = "<"; + public final static String CLOSE_EXPRESSION = "}}"; + public final static String OPEN_EXPRESSION = "{{"; + public final static String MARKER_COMMENTS = "--"; + + // Global symbols + public final static String SINGLE_QUOTE = "'"; + public final static String EXCLAMATION = "!"; + public final static String BACK_SLASH = "\\"; + public final static String NEW_LINE = "\n"; + public final static String VARIABLE = "$"; + public final static String ASSIGN = "="; + public final static String COLON = ":"; + public final static String COMMA = ","; + public final static String QUOTE = "\""; + public final static String SLASH = "/"; + public final static String PIPE = "|"; + public final static String DOT = "."; + public final static String AT = "@"; + + // Logical operators + public final static String NULL_COALESCING = "??"; + public final static String AND = "&&"; + public final static String OR = "||"; + + // List of all Operators + public final static String[] OPERATORS = new String[]{ + NULL_COALESCING, + AND, + OR, + }; + + // Comparison operators + public final static String EQUALS = "=="; + public final static String NOT_EQUALS = "!="; + public final static String LESS_THAN = "<"; + public final static String GREATER_THAN = ">"; + public final static String LESS_THAN_EQUALS = "<="; + public final static String GREATER_THAN_EQUALS = ">="; + + // Keywords + public final static String KEYWORD_NOT_IN = "not in"; + public final static String KEYWORD_IN = "in"; + + public final static String KEYWORD_FOR = "for"; + public final static String KEYWORD_ELSE_IF = "else-if"; + public final static String KEYWORD_ELSE = "else"; + public final static String KEYWORD_IF = "if"; + + public final static String KEYWORD_FALSE = "false"; + public final static String KEYWORD_TRUE = "true"; + + // List of all Keywords + public final static String[] KEYWORDS = new String[]{ + KEYWORD_NOT_IN, + KEYWORD_IN, + KEYWORD_FOR, + KEYWORD_ELSE_IF, + KEYWORD_ELSE, + KEYWORD_IF, + KEYWORD_FALSE, + KEYWORD_TRUE, + }; + + public final static Set KEYWORD_CONDITIONALS = Set.of( + KEYWORD_ELSE_IF, + KEYWORD_ELSE, + KEYWORD_IF + ); + + // Html slot related symbols + public static final String HTML_TAG_TEMPLATE = "template"; + public static final String HTML_TAG_SLOT = "slot"; + + public static final String HTML_SLOT_DEFAULT = "default"; + public static final String HTML_SLOT_KEY = "slot:"; + + // Scope names + public static final String SCOPE_COMPONENT_PREFIX = "component:"; + public static final String SCOPE_ROOT_NAME = "root"; + public static final String SCOPE_FOR_NAME = "for"; + + // Void elements that do not require a closing tag + public static final java.util.Set VOID_ELEMENTS = java.util.Set.of( + "area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "param", "source", "track", "wbr" + ); +} diff --git a/src/main/java/au/ellie/hyui/html/template/item/Token.java b/src/main/java/au/ellie/hyui/html/template/item/Token.java new file mode 100644 index 0000000..f12a4ba --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/item/Token.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.item; + +import java.util.HashMap; +import java.util.Map; + +import static au.ellie.hyui.html.template.item.Token.Type.*; + +public record Token(Type type, String value, int position) { + + /** + * Check if the token matches the given type and value + * + * @param type The type to check + * @param symbols The values to check + */ + public boolean match(Type type, String... symbols) { + if (this.type != type) + return false; + + if (symbols.length == 0) + return true; + + return this.match(symbols); + } + + /** + * Check if the token matches one of the given values + * + * @param symbols The values to check + */ + public boolean match(String... symbols) { + for (var symbol : symbols) + if (this.value.equals(symbol)) + return true; + + return false; + } + + /** + * Token types + */ + public enum Type { + // Template delimiters + CLOSE_ANGLE_BRACKET, + OPEN_ANGLE_BRACKET, + CLOSE_EXPRESSION, + OPEN_EXPRESSION, + MARKER_COMMENTS, + + // Global tokens + SINGLE_QUOTE, + EXCLAMATION, + BACK_SLASH, + NEW_LINE, + VARIABLE, + ASSIGN, + COLON, + COMMA, + QUOTE, + SLASH, + PIPE, + DOT, + AT, + + // Special + OPERATOR, + KEYWORD, + SPACER, + NUMBER, + TEXT, + + // INTERNAL + EOI + } + + public static final Map TOKEN_MAPPER = new HashMap<>() {{ + put(Symbols.CLOSE_ANGLE_BRACKET, CLOSE_ANGLE_BRACKET); + put(Symbols.OPEN_ANGLE_BRACKET, OPEN_ANGLE_BRACKET); + put(Symbols.CLOSE_EXPRESSION, CLOSE_EXPRESSION); + put(Symbols.OPEN_EXPRESSION, OPEN_EXPRESSION); + put(Symbols.MARKER_COMMENTS, MARKER_COMMENTS); + put(Symbols.EXCLAMATION, EXCLAMATION); + put(Symbols.BACK_SLASH, BACK_SLASH); + put(Symbols.NEW_LINE, NEW_LINE); + put(Symbols.VARIABLE, VARIABLE); + put(Symbols.ASSIGN, ASSIGN); + put(Symbols.COLON, COLON); + put(Symbols.COMMA, COMMA); + put(Symbols.QUOTE, QUOTE); + put(Symbols.SINGLE_QUOTE, SINGLE_QUOTE); + put(Symbols.SLASH, SLASH); + put(Symbols.PIPE, PIPE); + put(Symbols.DOT, DOT); + put(Symbols.AT, AT); + }}; +} diff --git a/src/main/java/au/ellie/hyui/utils/LambdaUtils.java b/src/main/java/au/ellie/hyui/utils/LambdaUtils.java new file mode 100644 index 0000000..88b7939 --- /dev/null +++ b/src/main/java/au/ellie/hyui/utils/LambdaUtils.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.utils; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.concurrent.Callable; +import java.util.function.*; + +/** + * Quick utility for detecting and calling multiple type of lambda/function objects, + * including Java functional interfaces and Kotlin functions. + */ +public class LambdaUtils { + + /** + * Check if an object is a callable function/lambda + * + * @param obj The object to check + */ + public static boolean isFunction(Object obj) { + if (obj == null) + return false; + + // Check Kotlin functions + if (obj.getClass().getName().startsWith("kotlin.jvm.functions.Function")) + return true; + + // Check Java functional interfaces + return obj instanceof Supplier || obj instanceof Function || + obj instanceof BiFunction || obj instanceof Consumer || + obj instanceof BiConsumer || obj instanceof Predicate || + obj instanceof Runnable || obj instanceof Callable; + } + + /** + * Call a function with the given arguments + * Automatically detects the function type and calls it appropriately + * + * @param source The function object to call + * @param args Arguments to pass to the function + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static Object call(Object source, Object... args) { + switch (source) { + case Supplier supplier -> { + return supplier.get(); + } + case Function function when args.length >= 1 -> { + return function.apply(args[0]); + } + case BiFunction biFunction when args.length >= 2 -> { + return biFunction.apply(args[0], args[1]); + } + case Consumer consumer when args.length >= 1 -> { + consumer.accept(args[0]); + return null; + } + case BiConsumer biConsumer when args.length >= 2 -> { + biConsumer.accept(args[0], args[1]); + return null; + } + case Runnable runnable -> { + runnable.run(); + return null; + } + case Callable callable -> { + try { + return callable.call(); + } catch (Exception e) { + throw new RuntimeException("Error calling Callable", e); + } + } + case Predicate predicate when args.length >= 1 -> { + return predicate.test(args[0]); + } + default -> { + // Continue to reflection fallback + } + } + + try { + return callViaReflection(source, args); + } catch (Exception e) { + throw new RuntimeException("Failed to call function", e); + } + } + + /** + * Call a function using reflection (fallback method) + * + * @param function The function object to call + * @param args Arguments to pass to the function + * @return The result of the function call + */ + private static Object callViaReflection(Object function, Object... args) throws Exception { + var clazz = function.getClass(); + + // Try to find "invoke" method (Kotlin) + try { + var invokeMethod = clazz.getMethod("invoke"); + + return invokeMethod.invoke(function, args); + } catch (NoSuchMethodException e) { + // Continue to SAM method search + } + + // Find the single abstract method + var sam = findSingleAbstractMethod(clazz); + if (sam != null) + return sam.invoke(function, args); + + throw new IllegalArgumentException("Cannot find callable method on " + clazz); + } + + /** + * Find the single abstract method (SAM) of a class, if it exists + * + * @param clazz The class to inspect + * @return The single abstract method, or null if there are none or more than one + */ + private static Method findSingleAbstractMethod(Class clazz) { + Method abstractMethod = null; + for (var method : clazz.getMethods()) { + if (Modifier.isAbstract(method.getModifiers()) && + !method.isDefault() && + !method.getDeclaringClass().equals(Object.class)) { + + if (abstractMethod != null) + return null; // More than one abstract method + + abstractMethod = method; + } + } + + return abstractMethod; + } +} \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/utils/NumericUtils.java b/src/main/java/au/ellie/hyui/utils/NumericUtils.java new file mode 100644 index 0000000..876caf9 --- /dev/null +++ b/src/main/java/au/ellie/hyui/utils/NumericUtils.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.utils; + +import javax.annotation.Nullable; + +public class NumericUtils { + + // Epsilon value for comparing floating-point numbers thanks of how number + // are represented in computers, two floating-point numbers that are very close + // may not be exactly equal due to precision issues. + private static final double EPSILON = 1e-9; + + /** + * Convert an object to a Number if possible. + * Supports Number and String types. + * + * @param value The object to convert + * @return Number if conversion is successful, or null if it cannot be converted + */ + public static Number toNumber(@Nullable Object value) { + switch (value) { + case Number num -> { + return num; + } + + case String str -> { + try { + if (str.contains(".")) + return Double.parseDouble(str); + else + return Long.parseLong(str); + } catch (NumberFormatException ignored) { + return null; + } + } + + case null, default -> { + return null; + } + } + } + + /** + * Convert a number to double. + * + * @return 0.0 if num is null + */ + public static double toDouble(Number num) { + if (num == null) + return 0.0; + + return num.doubleValue(); + } + + /** + * Convert a number to long. + * + * @return 0.0 if num is null + */ + public static long toLong(Number num) { + if (num == null) + return 0L; + + return num.longValue(); + } + + /** + * Compare two objects as numbers. + * Supports Number and String types. + * + * @return A negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater than the second. + */ + public static int compare(Object left, Object right) { + var leftNum = toNumber(left); + var rightNum = toNumber(right); + + if (leftNum == null && rightNum == null) return 0; + if (leftNum == null) return -1; + if (rightNum == null) return 1; + + // if at least one of the two is a floating-point type, compare with epsilon + if (isFloatingPoint(leftNum) || isFloatingPoint(rightNum)) + return compareWithEpsilon(toDouble(leftNum), toDouble(rightNum)); + + // otherwise, compare as long + return Long.compare(toLong(leftNum), toLong(rightNum)); + } + + /** + * Check if two objects are numerically equal. + * + * @return true if both are null or if they are numerically equal (considering epsilon for floating-point), false otherwise + */ + public static boolean equals(Object left, Object right) { + return compare(left, right) == 0; + } + + /** + * Check if a number is a floating-point type (Double or Float). + */ + private static boolean isFloatingPoint(Number num) { + return num instanceof Double || num instanceof Float; + } + + /** + * Compare two doubles with an epsilon tolerance. + */ + private static int compareWithEpsilon(double a, double b) { + if (Math.abs(a - b) < EPSILON) + return 0; + + return Double.compare(a, b); + } +} \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/utils/ObjectUtils.java b/src/main/java/au/ellie/hyui/utils/ObjectUtils.java new file mode 100644 index 0000000..13bf9bb --- /dev/null +++ b/src/main/java/au/ellie/hyui/utils/ObjectUtils.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.utils; + +import java.util.*; + +public class ObjectUtils { + + /** + * Convert an object to a boolean value. + * + * @param value The object to convert + * @return The boolean value + */ + public static boolean toBoolean(Object value) { + return switch (value) { + case null -> false; + case Boolean b -> b; + case Number n -> n.doubleValue() != 0; + case String s -> !s.isEmpty(); + case Collection c -> !c.isEmpty(); + case Map m -> !m.isEmpty(); + default -> true; + }; + } + + /** + * Convert an object to an iterable or throw an exception. + * + * @param value The object to convert + * @return The iterable + */ + public static Iterable toIterable(Object value) { + if (value instanceof Iterable iterable) + return iterable; + + if (value instanceof Map map) + return map.entrySet(); + + if (value.getClass().isArray()) + return Arrays.asList((Object[]) value); + + throw new RuntimeException("Cannot iterate over " + value.getClass()); + } + + /** + * Evaluate if needle is in haystack. + * + * @param needle Object to search for + * @param haystack Object to search in + * @return True if needle is in haystack, false otherwise + */ + public static boolean containedIn(Object needle, Object haystack) { + return switch (haystack) { + case Collection collection -> collection.contains(needle); + case Map map -> map.containsKey(needle); + case String str when needle != null -> str.contains(needle.toString()); + case null, default -> false; + }; + } + + /** + * Create a mutable list from the given items. + * + * @param items The items to include in the list + * @param The type of the items + * @return A mutable list containing the items + */ + public static List mutableListOf(T... items) { + return new ArrayList<>(Arrays.asList(items)); + } +} diff --git a/src/main/java/au/ellie/hyui/utils/ReflectionUtils.java b/src/main/java/au/ellie/hyui/utils/ReflectionUtils.java new file mode 100644 index 0000000..30366f1 --- /dev/null +++ b/src/main/java/au/ellie/hyui/utils/ReflectionUtils.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.utils; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Utility class for reflection-related operations. + * Based on the work of Dr Heinz M. Kabutz + */ +public class ReflectionUtils { + + /** + * Get the value of a property from an object, + * supporting both Map access and reflection. + * + * @param obj Object to access + * @param propertyName Name of the property to retrieve + * @return The value of the property, or null if not found + * @throws Exception If an error occurs during reflection access + */ + public static Object getObjectProperty(Object obj, String propertyName) throws Exception { + // Map access + if (obj instanceof Map map) + return map.get(propertyName); + + // Reflection access + var clazz = obj.getClass(); + + try { + var field = clazz.getDeclaredField(propertyName); + field.setAccessible(true); + + return field.get(obj); + } catch (NoSuchFieldException e) { + var propName = propertyName.substring(0, 1).toUpperCase() + propertyName.substring(1); + var methodNames = new ArrayList() {{ + add(propertyName); + add("get" + propName); + add("is" + propName); + }}; + + // Open methods + for (var name : methodNames) { + var method = ReflectionUtils.getPublicMethod(clazz, name); + + if (method.isPresent()) + return method.get().invoke(obj); + } + } + + return null; + } + + /** + * Get a truly public method (i.e., a method that is public and declared in a public class or interface) + * from the given class or its superclasses/interfaces, avoiding calling method on packages with restricted access. + * + * @param clazz Class to inspect + * @param name Method name + * @param paramTypes Parameter types + * @return Optional containing the Method if found, or empty otherwise + */ + public static Optional getPublicMethod(Class clazz, String name, Class... paramTypes) { + if (clazz == null) + return Optional.empty(); + + List results = new ArrayList<>(); + findPublicMethods(clazz, results, name, paramTypes); + + return results.stream() + .filter(method -> matches(method, name, paramTypes)) + .reduce((m1, m2) -> { + Class r1 = m1.getReturnType(); + Class r2 = m2.getReturnType(); + + return r1 != r2 && r1.isAssignableFrom(r2) ? m2 : m1; + }); + } + + /** + * Recursively find truly public methods in the class hierarchy. + * + * @param clazz Class to inspect + * @param results List to store found methods + * @param name Method name + * @param paramTypes Parameter types + */ + private static void findPublicMethods(Class clazz, List results, String name, Class... paramTypes) { + if (clazz == null) + return; + + Method[] methods = clazz.getMethods(); + for (Method method : methods) + if (matches(method, name, paramTypes) && isPublic(method)) + results.add(method); + + for (Class intf : clazz.getInterfaces()) + findPublicMethods(intf, results, name, paramTypes); + + findPublicMethods(clazz.getSuperclass(), results, name, paramTypes); + } + + /** + * Check if a method is truly public. + * + * @param method Method to check + * @return True if the method is truly public, false otherwise + */ + private static boolean isPublic(Method method) { + return Modifier.isPublic(method.getModifiers() + & method.getDeclaringClass().getModifiers()); + } + + /** + * Check if a method matches the given name and parameter types. + * + * @param method Method to check + * @param name Method name + * @param paramTypes Parameter types + * @return True if the method matches, false otherwise + */ + private static boolean matches(Method method, String name, Class... paramTypes) { + return method.getName().equals(name) + && Arrays.equals(method.getParameterTypes(), paramTypes); + } +} \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/utils/StringReader.java b/src/main/java/au/ellie/hyui/utils/StringReader.java new file mode 100644 index 0000000..5ac6bf9 --- /dev/null +++ b/src/main/java/au/ellie/hyui/utils/StringReader.java @@ -0,0 +1,551 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.utils; + +import java.util.function.Predicate; + +/** + * A comprehensive string reader utility for parsing text character-by-character. + * Provides both low-level character access and high-level reading operations. + *

+ * Example usage: + *

+ * var reader = new StringReader("hello world");
+ * reader.skip(6);                    // skip "hello "
+ * var word = reader.readWord();      // reads "world"
+ * 
+ */ +public class StringReader { + private final String input; + private final int length; + private int position; + + /** + * Create a new StringReader for the given input + * + * @param input The string to read from + */ + public StringReader(String input) { + this.input = input; + this.length = input.length(); + this.position = 0; + } + + // ========== Navigation ========== + + /** + * Check if we've reached the end of the input + * + * @return false if at end, true otherwise + */ + public boolean hasNext() { + return position < length; + } + + /** + * Get the current position in the string + * + * @return The current position index + */ + public int getPosition() { + return position; + } + + /** + * Set the position to a specific index + * Clamps the position to be within the bounds of the string (0 to length) + * + * @param newPosition The new position + */ + public void setPosition(int newPosition) { + this.position = Math.max(0, Math.min(newPosition, length)); + } + + /** + * Get the remaining length from current position + * + * @return Number of characters remaining + */ + public int remaining() { + return length - position; + } + + // ========== Character Access ========== + + /** + * Get the current character without advancing position + * + * @return The current character, or '\0' if at end + */ + public char current() { + return position < length ? input.charAt(position) : '\0'; + } + + /** + * Alias for current() - peek at current character + * + * @return The current character, or '\0' if at end + */ + public char peek() { + return current(); + } + + /** + * Peek at the character at a specific offset from current position + * + * @param offset The offset from current position (0 = current, 1 = next, etc.) + * @return The character at that offset, or '\0' if out of bounds + */ + public char peek(int offset) { + int index = position + offset; + return index >= 0 && index < length ? input.charAt(index) : '\0'; + } + + /** + * Get the next character (1 position ahead) without advancing + * + * @return The next character, or '\0' if at or past end + */ + public char next() { + return peek(1); + } + + /** + * Get the previous character (1 position back) without changing position + * + * @return The previous character, or '\0' if at start + */ + public char previous() { + return peek(-1); + } + + /** + * Advance position by one and return the character we just passed + * + * @return The character at the previous position, or '\0' if at end + */ + public char advance() { + if (!hasNext()) + return '\0'; + + return input.charAt(position++); + } + + // ========== String Matching ========== + + /** + * Check if the input starts with the given string at current position + * + * @param str The string to check for + * @return true if the string matches at current position + */ + public boolean startsWith(String str) { + return input.startsWith(str, position); + } + + /** + * Check if the input starts with any of the given strings at current position + * + * @param strings The strings to check for + * @return true if any string matches at current position + */ + public boolean startsWith(String... strings) { + for (var str : strings) + if (input.startsWith(str, position)) + return true; + return false; + } + + /** + * Find which string (if any) matches at current position + * + * @param strings The strings to check for + * @return The first matching string, or null if none match + */ + public String filter(String... strings) { + for (var str : strings) + if (input.startsWith(str, position)) + return str; + return null; + } + + /** + * Check if current character matches any of the given characters + * + * @param chars The characters to check for + * @return true if current character matches any of them + */ + public boolean match(char... chars) { + char current = current(); + for (char c : chars) + if (current == c) + return true; + + return false; + } + + // ========== Skip/Consume Operations ========== + + /** + * Move forward by one character + * + * @return The current character after skipping, or '\0' if at end + */ + public char skip() { + return skip(1); + } + + /** + * Move forward by the specified number of characters + * + * @param count Number of characters to skip + * @return The current character after skipping, or '\0' if at end + */ + public char skip(int count) { + position = Math.min(position + count, length); + return current(); + } + + /** + * Move forward by the length of the given string + * + * @param str The string whose length determines how many chars to skip + * @return The current character after skipping, or '\0' if at end + */ + public char skip(String str) { + return skip(str.length()); + } + + /** + * Consume the given string if it matches at current position + * Returns true and advances position if matched, otherwise returns false + * + * @param str The string to consume + * @return true if consumed, false if not matched + */ + public boolean consume(String str) { + if (startsWith(str)) { + skip(str); + return true; + } + + return false; + } + + /** + * Consume any of the given strings if one matches at current position + * + * @param strings The strings to try to consume + * @return The consumed string, or null if none matched + */ + public String consumeAny(String... strings) { + var matched = filter(strings); + if (matched != null) + skip(matched); + + return matched; + } + + /** + * Consume the given character if it matches the current character + * + * @param expected The character to consume + * @return true if consumed, false if not matched + */ + public boolean consume(char expected) { + if (current() == expected) { + advance(); + return true; + } + + return false; + } + + // ========== Whitespace Operations ========== + + /** + * Skip all whitespace characters from current position + * + * @return The number of whitespace characters skipped + */ + public int skipWhitespace() { + int start = position; + while (hasNext() && Character.isWhitespace(current())) + advance(); + + return position - start; + } + + /** + * Skip all non-whitespace characters from current position + * + * @return The number of characters skipped + */ + public int skipNonWhitespace() { + int start = position; + while (hasNext() && !Character.isWhitespace(current())) + advance(); + + return position - start; + } + + /** + * Skip characters while the predicate is true + * + * @param predicate Function that returns true to continue skipping + * @return The number of characters skipped + */ + public int skipWhile(Predicate predicate) { + int start = position; + while (hasNext() && predicate.test(current())) + advance(); + + return position - start; + } + + /** + * Skip characters until the predicate becomes true + * + * @param predicate Function that returns true to stop skipping + * @return The number of characters skipped + */ + public int skipUntil(Predicate predicate) { + return skipWhile(c -> !predicate.test(c)); + } + + // ========== Reading Operations ========== + + /** + * Read characters while the predicate is true + * + * @param predicate Function that returns true to continue reading + * @return The accumulated string + */ + public String readWhile(Predicate predicate) { + int start = position; + while (hasNext() && predicate.test(current())) + advance(); + + return input.substring(start, position); + } + + /** + * Read characters until the predicate becomes true + * + * @param predicate Function that returns true to stop reading + * @return The accumulated string + */ + public String readUntil(Predicate predicate) { + return readWhile(c -> !predicate.test(c)); + } + + /** + * Read a word (sequence of non-whitespace characters) + * + * @return The word, or empty string if at whitespace or end + */ + public String readWord() { + return readWhile(c -> !Character.isWhitespace(c)); + } + + /** + * Read while characters are letters + * + * @return The accumulated letters + */ + public String readLetters() { + return readWhile(Character::isLetter); + } + + /** + * Read while characters are digits + * + * @return The accumulated digits + */ + public String readDigits() { + return readWhile(Character::isDigit); + } + + /** + * Read while characters are alphanumeric + * + * @return The accumulated alphanumeric characters + */ + public String readAlphanumeric() { + return readWhile(Character::isLetterOrDigit); + } + + /** + * Read a string value (quoted or unquoted) + * Handles both single and double quotes + * + * @return The string value + */ + public String readValue() { + char current = current(); + + // Quoted value + if (current == '"' || current == '\'') + return readQuotedValue(current); + + // Unquoted value - read until whitespace + return readWord(); + } + + /** + * Read a quoted string value + * + * @param quote The quote character (" or ') + * @return The string content without quotes + */ + public String readQuotedValue(char quote) { + if (current() != quote) + return ""; + + advance(); + + int start = position; + + // Read until closing quote + while (hasNext() && current() != quote) + advance(); + + String value = input.substring(start, position); + + // Consume closing quote if present + if (hasNext()) + advance(); + + return value; + } + + /** + * Read a specific number of characters + * + * @param count Number of characters to read + * @return The substring of the specified length (or remaining if less available) + */ + public String read(int count) { + int start = position; + int end = Math.min(position + count, length); + position = end; + + return input.substring(start, end); + } + + /** + * Read from current position to the end + * + * @return The remaining string + */ + public String readRemaining() { + int start = position; + position = length; + + return input.substring(start); + } + + /** + * Read until any of the given strings is found + * + * @param delimiters The strings to stop at + * @return The string up to (but not including) the delimiter + */ + public String readUntilAny(String... delimiters) { + int start = position; + + while (hasNext()) { + if (startsWith(delimiters)) + break; + + advance(); + } + + return input.substring(start, position); + } + + // ========== Utility Methods ========== + + /** + * Get a substring from start to current position + * + * @param start The start index + * @return The substring + */ + public String substring(int start) { + return input.substring(start, position); + } + + /** + * Get a substring between two positions + * + * @param start The start index + * @param end The end index + * @return The substring + */ + public String substring(int start, int end) { + return input.substring(start, end); + } + + /** + * Reset position to the beginning + */ + public void reset() { + position = 0; + } + + /** + * Get the entire input string + * + * @return The original input string + */ + public String getInput() { + return input; + } + + /** + * Get the length of the input string + * + * @return The total length + */ + public int getLength() { + return length; + } + + /** + * Create a string representation showing current position + * + * @return A debug string showing position in the input + */ + @Override + public String toString() { + if (!hasNext()) + return String.format("StringReader[pos=%d, at end]: \"%s\"", position, input); + + // Show context around current position + int contextStart = Math.max(0, position - 10); + int contextEnd = Math.min(length, position + 10); + + String before = input.substring(contextStart, position); + String after = input.substring(position, contextEnd); + + return String.format("StringReader[pos=%d/%d]: \"%sâ–¶%s\"", + position, length, before, after); + } +} diff --git a/src/main/java/au/ellie/hyui/utils/StringUtils.java b/src/main/java/au/ellie/hyui/utils/StringUtils.java new file mode 100644 index 0000000..9f9abcb --- /dev/null +++ b/src/main/java/au/ellie/hyui/utils/StringUtils.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.utils; + +/** + * Utility class for string manipulation. + *

+ * Used instead of Hytale's StringUtils to support space + * and dot as word separators in capitalizeAll. + */ +public class StringUtils { + + /** + * Capitalize the first letter of the string. + * + * @param str The string to capitalize + */ + public static String capitalize(String str) { + if (str == null || str.isEmpty()) + return str; + + return capitalizeUnsafe(str); + } + + /** + * Capitalize the first letter of each word in the string. + * + * @param str The string to capitalize + */ + public static String capitalizeAll(String str) { + if (str == null || str.isEmpty()) + return str; + + String[] words = str.split("[\\s\\.]+"); + StringBuilder result = new StringBuilder(); + + for (String word : words) { + if (word.isEmpty()) + continue; + + result.append(capitalizeUnsafe(word)).append(" "); + } + + return result.toString().trim(); + } + + /** + * Check if the character is an ASCII letter (a-z or A-Z). + * + * @param c The character to check + */ + public static boolean isAsciiLetter(char c) { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'; + } + + /** + * Capitalize the first letter of the string + * without checking for null or empty. + * + * @param str The string to capitalize + */ + private static String capitalizeUnsafe(String str) { + return str.substring(0, 1).toUpperCase() + str.substring(1); + } +} diff --git a/src/main/java/au/ellie/hyui/utils/multiplehud/MultipleHUD.java b/src/main/java/au/ellie/hyui/utils/multiplehud/MultipleHUD.java index 42e3b31..4841872 100644 --- a/src/main/java/au/ellie/hyui/utils/multiplehud/MultipleHUD.java +++ b/src/main/java/au/ellie/hyui/utils/multiplehud/MultipleHUD.java @@ -3,8 +3,6 @@ import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.entity.entities.player.hud.CustomUIHud; -import com.hypixel.hytale.server.core.plugin.JavaPlugin; -import com.hypixel.hytale.server.core.plugin.JavaPluginInit; import com.hypixel.hytale.server.core.universe.PlayerRef; import javax.annotation.Nonnull; diff --git a/src/main/resources/Common/UI/Custom/Pages/BountyBoard.html b/src/main/resources/Common/UI/Custom/Pages/BountyBoard.html index 6fe8d7d..5cdd6db 100644 --- a/src/main/resources/Common/UI/Custom/Pages/BountyBoard.html +++ b/src/main/resources/Common/UI/Custom/Pages/BountyBoard.html @@ -22,9 +22,7 @@

{{$summary}}

- {{#each bounties}} - {{@bountyCard:title={{$title}},level={{$level}},rarity={{$rarity}}}} - {{/each}} +
diff --git a/src/main/resources/Common/UI/Custom/Pages/BountyRuntime.html b/src/main/resources/Common/UI/Custom/Pages/BountyRuntime.html index 0abfd7e..181c7ff 100644 --- a/src/main/resources/Common/UI/Custom/Pages/BountyRuntime.html +++ b/src/main/resources/Common/UI/Custom/Pages/BountyRuntime.html @@ -21,36 +21,27 @@
- + + + - + - +
-

Filters: {{$region|Unknown}} / Min {{$minLevel|1}}

+

Filters: {{$region ?? "Unknown"}} / Min {{$minLevel ?? 1}}

- {{#if minLevel >= 6}} -

Elite filters on. Good luck.

- {{else}} -

Standard filters.

- {{/if}} +

Elite filters on. Good luck.

+

Standard filters.

- {{#if region == Tundra}} -

Tundra picks are icy.

- {{else}} - - {{/if}} +

Tundra picks are icy.

- {{#each bounties}} - {{@bountyCard:title={{$title}},level={{$level}},rarity={{$rarity}}}} - {{/each}} +
diff --git a/src/main/resources/Common/UI/Custom/Pages/ItemGridTest.html b/src/main/resources/Common/UI/Custom/Pages/ItemGridTest.html index a471499..655150d 100644 --- a/src/main/resources/Common/UI/Custom/Pages/ItemGridTest.html +++ b/src/main/resources/Common/UI/Custom/Pages/ItemGridTest.html @@ -17,13 +17,15 @@ -->
-
+
@@ -32,10 +34,10 @@

Please enter your desired Buy It Now price:

- - - - + + + +
@@ -54,7 +56,8 @@
-
+
+
\ No newline at end of file diff --git a/src/main/resources/Common/UI/Custom/Pages/Replicate.html b/src/main/resources/Common/UI/Custom/Pages/Replicate.html index e7e989a..67dd2b8 100644 --- a/src/main/resources/Common/UI/Custom/Pages/Replicate.html +++ b/src/main/resources/Common/UI/Custom/Pages/Replicate.html @@ -1,38 +1,38 @@ - +
-
-

{{$playerName}} whats going on  Hey there  

+
+

{{$playerName}} whats going on  Hey there  

+
+ +
+
+

Lobby:

+

Jogadores:

+

Vitórias:

+

Kills:

- -
-
-

Lobby:

-

Jogadores:

-

Vitórias:

-

Kills:

-
-
-

Some Lobby

-

123

-

4565

-

25

-
+
+

Some Lobby

+

123

+

4565

+

25

+
diff --git a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java index c3a0c7f..8de1526 100644 --- a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java +++ b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java @@ -1,345 +1,2080 @@ package au.ellie.hyui.html; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; -import java.util.List; -import java.util.Map; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static au.ellie.hyui.html.template.context.ExecutionPolicy.*; +import static au.ellie.hyui.html.template.item.Symbols.SCOPE_FOR_NAME; +import static org.junit.jupiter.api.Assertions.*; class TemplateProcessorTest { - private TemplateProcessor processor; + // ========== UTILITY METHODS ========== + + /** + * Normalizes indentation by removing common leading whitespace from all lines. + * This allows templates in tests to be written with natural indentation. + */ + private static String normalize(String input) { + if (input == null || input.isBlank()) return ""; + + var lines = input.lines().toList(); + var minIndent = lines.stream() + .filter(line -> !line.isBlank()) + .mapToInt(line -> { + int i = 0; + while (i < line.length() && Character.isWhitespace(line.charAt(i))) i++; + return i; + }) + .min() + .orElse(0); + + return String.join("\n", lines.stream() + .map(line -> line.length() >= minIndent ? line.substring(minIndent) : line) + .dropWhile(String::isBlank) + .toList()) + .stripTrailing(); + } + @BeforeEach void setUp() { processor = new TemplateProcessor(); } - /* -------------------------------------------------- - * Variable substitution - * -------------------------------------------------- */ + // ========== TEST DATA RECORDS ========== + + record Address(String city, String country) { + } + + record Person(String name, int age, Address address) { + } + + record User(String name, String lastName) { + } + + record Category(String name, List items) { + } - @Test - void replacesSimpleVariable() { - processor.setVariable("name", "Ellie"); + record Item(String name, boolean active, String display, int count) { + } - assertEquals( - "Hello Ellie!", - processor.process("Hello {{$name}}!") - ); + record Product(String name, List tags) { + public String getTagsWithPrefix(String prefix) { + return tags.stream().map(tag -> prefix + tag).collect(Collectors.joining(", ")); + } } - @Test - void usesDefaultValueWhenVariableIsMissing() { - assertEquals( - "Score: 0", - processor.process("Score: {{$score|0}}") - ); + static class Modulator { + public int size; + + public int increment() { + size = (size + 1) % 2; + return size; + } } - /* -------------------------------------------------- - * Filters - * -------------------------------------------------- */ + // ========== BASIC FUNCTIONALITY ========== @Nested - class StringFilters { + @DisplayName("Basic Template Processing") + class BasicProcessing { + + @Test + @DisplayName("Should return plain text unchanged") + void plainText() { + String template = "
Hello! World
"; + assertEquals("
Hello! World
", processor.setTemplate(template).process()); + } @Test - void upper_convertsToUppercase() { - processor.setVariable("value", "hello"); + @DisplayName("Should replace simple variables") + void simpleVariable() { + processor.setVariable("name", "John"); - assertEquals( - "HELLO", - processor.process("{{$value|upper}}") - ); + processor.setTemplate("Hello {{$name}}!"); + assertEquals("Hello John!", processor.process()); } @Test - void lower_convertsToLowercase() { - processor.setVariable("value", "HeLLo"); + @DisplayName("Should handle missing variables as empty strings") + void missingVariable() { + processor.setTemplate("Hello {{$name}}!"); + assertEquals("Hello !", processor.process()); + } - assertEquals( - "hello", - processor.process("{{$value|lower}}") - ); + @Test + @DisplayName("Should support variables with hyphens and underscores") + void variableNaming() { + processor.setVariable("my-var", "value1"); + processor.setVariable("my_var", "value2"); + + processor.setTemplate("{{$my-var}} {{$my_var}}"); + assertEquals("value1 value2", processor.process()); } @Test - void trim_removesLeadingAndTrailingWhitespace() { - processor.setVariable("value", " hello "); + @DisplayName("Should preserve intentional whitespace in HTML") + void whitespacePreservation() { + processor.setTemplate("
Hello
"); + assertEquals("
Hello
", processor.process()); + } - assertEquals( - "hello", - processor.process("{{$value|trim}}") - ); + @Test + @DisplayName("Should render empty non-void elements with attributes") + void emptyElements() { + // Non-void elements WITH ATTRIBUTES should render with closing tags + processor.setTemplate("
"); + assertEquals("
", processor.process()); + + processor.setTemplate(""); + assertEquals("", processor.process()); + + processor.setTemplate("

"); + assertEquals("

", processor.process()); } @Test - void capitalize_capitalizesFirstLetterOnly() { - processor.setVariable("value", "hello world"); + @DisplayName("Should skip completely empty elements with no attributes") + void completelyEmptyElements() { + // Completely empty elements with no attributes should be skipped (prevents flexbox issues) + processor.setTemplate("
"); + assertEquals("", processor.process()); - assertEquals( - "Hello world", - processor.process("{{$value|capitalize}}") - ); + processor.setTemplate(""); + assertEquals("", processor.process()); + + processor.setTemplate("

"); + assertEquals("", processor.process()); + + // But elements with content should still render + processor.setTemplate("
Content
"); + assertEquals("
Content
", processor.process()); + } + + @Test + @DisplayName("Should render void elements as self-closing") + void voidElements() { + // Void elements should be self-closing + processor.setTemplate(""); + assertEquals("", processor.process()); + + processor.setTemplate("
"); + assertEquals("
", processor.process()); + + processor.setTemplate(""); + assertEquals("", processor.process()); + + processor.setTemplate("
"); + assertEquals("
", processor.process()); + } + + @Test + @DisplayName("Should handle void elements without self-closing slash") + void voidElementsWithoutSlash() { + // Void elements should work with or without the trailing / + processor.setTemplate(""); + assertEquals("", processor.process()); + + processor.setTemplate("
"); + assertEquals("
", processor.process()); + + processor.setTemplate(""); + assertEquals("", processor.process()); + + processor.setTemplate("
"); + assertEquals("
", processor.process()); + } + + @Test + @DisplayName("Should handle multiple empty elements correctly") + void multipleEmptyElements() { + String template = """ +
+
+
+ """; + String result = processor.setTemplate(template).process(); + + // All divs should have closing tags + assertTrue(result.contains("
")); + assertTrue(result.contains("
")); + assertTrue(result.contains("
")); } } + // ========== LITERALS ========== + @Nested - class NumberFilters { + @DisplayName("Literal Values") + class Literals { @Test - void number_formatsNumber() { - processor.setVariable("value", 1234); + @DisplayName("Should handle string literals with escaping") + void stringLiterals() { + processor.setTemplate("{{\"Hello World\"}}"); + assertEquals("Hello World", processor.process()); - assertEquals( - "1,234", - processor.process("{{$value|number}}") - ); + processor.setTemplate("{{\"Hello \\\"World\\\"\"}}"); + assertEquals("Hello \"World\"", processor.process()); } @Test - void percent_formatsPercent() { - processor.setVariable("value", 0.125); + @DisplayName("Should handle single-quoted string literals") + void singleQuotedStringLiterals() { + processor.setTemplate("{{'Hello World'}}"); + assertEquals("Hello World", processor.process()); - assertEquals( - "13%", - processor.process("{{$value|percent}}") - ); + processor.setTemplate("{{'Hello \\'World\\''}}"); + assertEquals("Hello 'World'", processor.process()); + } + + @ParameterizedTest + @CsvSource({ + "Alice, true, false", + "Bob, false, true" + }) + @DisplayName("Should handle single quotes in comparisons") + void singleQuotesInComparisons(String name, String result, String notResult) { + processor.setVariable("name", name); + processor.setTemplate("{{if $name == 'Alice'}}true{{else}}false{{/if}}"); + assertEquals(result, processor.process()); + + processor.setTemplate("{{if $name != 'Alice'}}true{{else}}false{{/if}}"); + assertEquals(notResult, processor.process()); + } + + @ParameterizedTest + @CsvSource({"{{42}}", "{{3.14}}", "{{-5}}"}) + @DisplayName("Should handle numeric literals") + void numericLiterals(String template) { + processor.setTemplate(template); + var result = processor.process(); + + assertNotNull(result); + assertFalse(result.isBlank()); + } + + @ParameterizedTest + @CsvSource({ + ", No value", + "value, Has value" + }) + @DisplayName("Should handle negated boolean") + void reversedLiterals(String value, String expected) { + processor.setVariable("value", value); + processor.setTemplate(normalize(""" + {{if !$value}}No value{{else}}Has value{{/if}} + """)); + + assertEquals(expected, processor.process()); + } + + @Test + @DisplayName("Should handle boolean literals") + void booleanLiterals() { + processor.setTemplate("{{true}} {{false}}"); + assertEquals("true false", processor.process()); } } - /* -------------------------------------------------- - * If blocks - * -------------------------------------------------- */ + // ========== PROPERTY ACCESS ========== @Nested - class IfBlocks { + @DisplayName("Property Access") + class PropertyAccess { @Test - void rendersTrueBranch() { - processor.setVariable("loggedIn", true); + @DisplayName("Should access record properties") + void recordProperties() { + processor.setVariable("user", new Person("Alice", 30, null)); - String template = """ - {{#if loggedIn}} - Welcome back! - {{else}} - Please log in - {{/if}} - """; + processor.setTemplate("{{$user.name}} is {{$user.age}}"); + assertEquals("Alice is 30", processor.process()); + } - assertEquals( - "Welcome back!", - processor.process(template).trim() - ); + @Test + @DisplayName("Should access map properties") + void mapProperties() { + processor.setVariable("user", Map.of("name", "Bob", "age", 25)); + + processor.setTemplate("{{$user.name}} is {{$user.age}}"); + assertEquals("Bob is 25", processor.process()); } @Test - void rendersFalseBranch() { - processor.setVariable("loggedIn", false); + @DisplayName("Should access nested properties") + void nestedProperties() { + processor.setVariable("user", new Person("Charlie", 21, new Address("Paris", "France"))); - String template = """ - {{#if loggedIn}} - Welcome! - {{else}} - Please log in + processor.setTemplate("{{$user.address.city}}, {{$user.address.country}}"); + assertEquals("Paris, France", processor.process()); + } + + @Test + @DisplayName("Should return empty string for missing properties") + void missingProperties() { + processor.setVariable("user", new Person("Dave", 32, null)); + + processor.setTemplate("{{$user.id}}"); + assertEquals("", processor.process()); + } + + @ParameterizedTest + @CsvSource({ + ", default", + "Dave, Dave", + }) + @DisplayName("Should return empty string for missing properties") + void missingProperties(String value, String expected) { + processor.setVariable("user", Optional.ofNullable(value)); + + processor.setTemplate("{{$user ?? \"default\"}}"); + assertEquals(expected, processor.process()); + } + + @ParameterizedTest + @CsvSource({ + "false, 0, ", + "true, 1, value_1 - value_1" + }) + @DisplayName("Should ensure the variable is evaluated only once and cached") + void policyCachedEvaluation(boolean condition, int value, String expected) { + AtomicInteger evaluations = new AtomicInteger(); + + processor.setVariable("enabled", condition); + processor.setVariable("secret", () -> { + evaluations.incrementAndGet(); + return "value_" + evaluations; + }, CACHED); + + processor.setTemplate(""" + {{if $enabled}} + {{$secret}} - {{$secret}} {{/if}} - """; + """); + assertEquals(expected != null ? expected : "", processor.process()); + assertEquals(value, evaluations.get()); + } - assertEquals( - "Please log in", - processor.process(template).trim() - ); + @ParameterizedTest + @CsvSource({ + "false, 0, ", + "true, 2, value_1 - value_2" + }) + @DisplayName("Should ensure the variable is evaluated every time it's accessed") + void policyDynamicEvaluation(boolean condition, int value, String expected) { + AtomicInteger evaluations = new AtomicInteger(); + + processor.setVariable("enabled", condition); + processor.setVariable("secret", (_) -> "value_" + evaluations.incrementAndGet(), DYNAMIC); + + processor.setTemplate(""" + {{if $enabled}} + {{$secret}} - {{$secret}} + {{/if}} + """); + assertEquals(expected != null ? expected : "", processor.process()); + assertEquals(value, evaluations.get()); + } + + @ParameterizedTest + @CsvSource({ + "false, secret value", + "true, secret already revealed: disappeared" + }) + @DisplayName("Should ensure the variable is evaluated only once and then removed") + void policyEphemeralEvaluation(boolean condition, String expected) { + processor.setVariable("condition", condition); + processor.setVariable("secret", (_) -> "secret value", EPHEMERAL); + + processor.setTemplate(""" + {{if $condition && $secret}}{{/if}} + {{$secret ?? "secret already revealed: disappeared"}} + """); + assertEquals(expected, processor.process().trim()); } @Test - void withoutElse_rendersNothingWhenFalse() { - processor.setVariable("enabled", false); + @DisplayName("Should ensure the variable is evaluated until it returns null, then removed") + void policyNonNullEvaluation() { + AtomicInteger evaluations = new AtomicInteger(); - String template = """ - Before - {{#if enabled}} - Enabled - {{/if}} - After - """; + processor.setVariable("loop", List.of(1, 2, 3, 4)); + processor.setVariable("secret", (_) -> { + var value = evaluations.incrementAndGet(); + if (value == 3) + return null; // Simulate a value that disappears after 2 uses + + return "This is a value that can only be twice once, " + (2 - value) + " remaining"; + }, NON_NULL); + + processor.setTemplate(""" + {{for $loop}} + {{$secret ?? "disappeared"}} + {{/for}} + """); + assertEquals(normalize(""" + This is a value that can only be twice once, 1 remaining + This is a value that can only be twice once, 0 remaining + disappeared + disappeared + """), processor.process()); + } + } + + // ========== COMPARISON OPERATORS ========== + + @Nested + @DisplayName("Comparison Operators") + class ComparisonOperators { + + @ParameterizedTest + @CsvSource({ + "5, ==, 5, true", "5, ==, 3, false", + "5, !=, 3, true", "5, !=, 5, false", + "5, <, 10, true", "5, <, 3, false", + "5, >, 3, true", "5, >, 10, false", + "5, <=, 5, true", "5, <=, 3, false", + "5, >=, 5, true", "5, >=, 3, true" + }) + @DisplayName("Should evaluate comparison operators correctly") + void comparisonOperators(int left, String op, int right, boolean expected) { + processor.setVariable("a", left); + processor.setVariable("b", right); + + processor.setTemplate("{{$a " + op + " $b}}"); + assertEquals(String.valueOf(expected), processor.process()); + } + + @Test + @DisplayName("Should compare strings") + void stringComparison() { + processor.setVariable("name", "Alice"); + + processor.setTemplate("{{$name == \"Alice\"}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{$name == \"Bob\"}}"); + assertEquals("false", processor.process()); + } + + @Test + @DisplayName("Should compare optional") + void optionalComparison() { + processor.setVariable("value", Optional.of(5)); + + processor.setTemplate("{{if $value >= 5}}true{{/if}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{$value < 2}}"); + assertEquals("false", processor.process()); + } - String result = processor.process(template) - .replaceAll("\\s+", " ") - .trim(); + @Test + @DisplayName("Should handle numeric type mixing (int, long, double)") + void numericTypeMixing() { + processor.setVariable("a", 5); + processor.setVariable("b", 5.0); + + processor.setTemplate("{{$a == $b}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{$a != $b}}"); + assertEquals("false", processor.process()); + } + + @Test + @DisplayName("Should handle floating-point comparison with epsilon") + void floatingPointEpsilon() { + processor.setVariable("a", 0.1 + 0.2); + processor.setVariable("b", 0.3); + + processor.setTemplate("{{$a == $b}}"); + assertEquals("true", processor.process()); + } - assertEquals("Before After", result); + @Test + @DisplayName("Should handle null comparisons") + void nullComparison() { + processor.setTemplate("{{$value == $missing}}"); + assertEquals("true", processor.process()); } } - /* -------------------------------------------------- - * Each blocks - * -------------------------------------------------- */ + // ========== LOGICAL OPERATORS ========== @Nested - class EachBlocks { + @DisplayName("Logical Operators") + class LogicalOperators { @Test - void iteratesOverList() { - processor.setVariable("items", List.of("A", "B", "C")); + @DisplayName("Should evaluate AND operator") + void andOperator() { + processor.setVariable("a", true); + processor.setVariable("b", true); + processor.setVariable("c", false); - String template = """ - {{#each items}} - {{$item}} - {{/each}} - """; + processor.setTemplate("{{$a && $b}}"); + assertEquals("true", processor.process()); - String result = processor.process(template) - .replaceAll("\\s+", ""); + processor.setTemplate("{{$a && $c}}"); + assertEquals("false", processor.process()); - assertEquals( - "ABC", - result - ); + processor.setTemplate("{{$c && $c}}"); + assertEquals("false", processor.process()); } @Test - void exposesMapEntriesAsVariables() { - processor.setVariable( - "users", - List.of( - Map.of("name", "Alice"), - Map.of("name", "Bob") - ) - ); + @DisplayName("Should evaluate OR operator") + void orOperator() { + processor.setVariable("a", true); + processor.setVariable("b", false); - String template = """ - {{#each users}} - {{$name}} - {{/each}} - """; + processor.setTemplate("{{$a || $b}}"); + assertEquals("true", processor.process()); - assertEquals( - "AliceBob", - processor.process(template).replaceAll("\\s+", "") - ); + processor.setTemplate("{{$b || $b}}"); + assertEquals("false", processor.process()); + } + + @Test + @DisplayName("Should combine AND and OR operators") + void combinedLogicalOperators() { + processor.setVariable("a", true); + processor.setVariable("b", false); + processor.setVariable("c", true); + + processor.setTemplate("{{$a && $b || $c}}"); + assertEquals("true", processor.process()); + } + + @ParameterizedTest + @CsvSource({ + "'', false", + "Hello, true", + "0, false", + "5, true" + }) + @DisplayName("Should evaluate truthiness correctly") + void truthiness(String value, boolean isTruthy) { + if (value.isEmpty()) + processor.setVariable("val", ""); + else if (value.matches("\\d+")) + processor.setVariable("val", Integer.parseInt(value)); + else + processor.setVariable("val", value); + + processor.setTemplate("{{if $val}}true{{/if}}"); + assertEquals(isTruthy ? "true" : "", processor.process()); } } - /* -------------------------------------------------- - * Components - * -------------------------------------------------- */ + // ========== IN OPERATOR ========== @Nested - class Components { + @DisplayName("IN Operator") + class InOperator { @Test - void expandsComponentWithParameters() { - processor.registerComponent( - "button", - "" - ); + @DisplayName("Should check presence in list") + void listContains() { + processor.setVariable("items", List.of("apple", "banana", "cherry")); - assertEquals( - "", - processor.process("{{@button:text=Click Me,id=myBtn}}") - ); + processor.setTemplate("{{\"apple\" in $items}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{\"orange\" in $items}}"); + assertEquals("false", processor.process()); } @Test - void componentCanAccessVariablesFromScope() { - processor - .setVariable("label", "Submit") - .registerComponent("button", ""); + @DisplayName("Should check key presence in map") + void mapContainsKey() { + processor.setVariable("user", Map.of("name", "Alice", "age", 30)); - assertEquals( - "", - processor.process("{{@button}}") + processor.setTemplate("{{\"name\" in $user}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{\"email\" in $user}}"); + assertEquals("false", processor.process()); + } + + @Test + @DisplayName("Should check substring in string") + void stringContains() { + processor.setVariable("text", "Hello World"); + + processor.setTemplate("{{\"World\" in $text}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{\"Java\" in $text}}"); + assertEquals("false", processor.process()); + } + } + + // ========== FILTERS ========== + + @Nested + @DisplayName("Filter transformations") + class Filters { + + @ParameterizedTest + @CsvSource({ + "john, uppercase, JOHN", + "JANE, lowercase, jane", + "alice, capitalize, Alice", + "' Hello ', trim, Hello" + }) + @DisplayName("Should apply built-in filters") + void builtInFilters(String input, String filter, String expected) { + processor.setVariable("value", input); + + processor.setTemplate("{{$value | " + filter + "}}"); + assertEquals(expected, processor.process()); + } + + @Test + @DisplayName("Should chain multiple filters") + void chainedFilters() { + processor.setVariable("name", " john doe "); + + processor.setTemplate("{{$name | trim | uppercase}}"); + assertEquals("JOHN DOE", processor.process()); + } + + @Test + @DisplayName("Should support custom filters") + void customFilter() { + processor.registerFilter("reverse", value -> + value == null ? null : new StringBuilder(value.toString()).reverse().toString() ); + processor.setVariable("text", "Hello"); + + processor.setTemplate("{{$text | reverse}}"); + assertEquals("olleH", processor.process()); + } + + @Test + @DisplayName("Should apply length filter to strings and collections") + void lengthFilter() { + processor.setVariable("text", "Hello"); + processor.setVariable("items", List.of("a", "b", "c")); + + processor.setTemplate("{{$text | length}}"); + assertEquals("5", processor.process()); + + processor.setTemplate("{{$items | length}}"); + assertEquals("3", processor.process()); + } + + @Test + @DisplayName("Should format numbers with number filter") + void number_formatsNumber() { + processor.setVariable("value", 1234); + + processor.setTemplate("{{$value | number}}"); + assertEquals("1,234", processor.process()); + } + + @Test + @DisplayName("Should format percentages with percent filter") + void percent_formatsPercent() { + processor.setVariable("value", 0.125); + + processor.setTemplate("{{$value | percent}}"); + assertEquals("13%", processor.process()); } } - /* -------------------------------------------------- - * Supplier laziness - * -------------------------------------------------- */ + // ========== DEFAULT VALUES ========== @Nested - class SupplierEvaluation { + @DisplayName("Default Values (Nullish Coalescing)") + class DefaultValues { @Test - void supplierIsNotEvaluatedWhenIfConditionIsFalse() { - AtomicInteger evaluations = new AtomicInteger(); + @DisplayName("Should use first non-null value") + void firstNonNull() { + processor.setVariable("name", "Alice"); - processor - .setVariable("enabled", false) - .setVariable("secret", () -> { - evaluations.incrementAndGet(); - return "SHOULD NOT HAPPEN"; - }); + processor.setTemplate("{{$name ?? \"Guest\"}}"); + assertEquals("Alice", processor.process()); + } - String template = """ - {{#if enabled}} - {{$secret}} + @Test + @DisplayName("Should fallback to default when variable is null") + void fallbackToDefault() { + processor.setTemplate("{{$name ?? \"Guest\"}}"); + assertEquals("Guest", processor.process()); + } + + @Test + @DisplayName("Should chain multiple defaults") + void chainedDefaults() { + processor.setVariable("b", "Value B"); + + processor.setTemplate("{{$a ?? $b ?? \"Default\"}}"); + assertEquals("Value B", processor.process()); + + processor.setTemplate("{{$a ?? $c ?? \"Default\"}}"); + assertEquals("Default", processor.process()); + } + + @Test + @DisplayName("Should combine defaults with filters") + void defaultsWithFilters() { + processor.setTemplate("{{$name | uppercase ?? \"GUEST\"}}"); + assertEquals("GUEST", processor.process()); + + processor.setVariable("name", "john"); + assertEquals("JOHN", processor.process()); + } + + @Test + @DisplayName("Should handle complex expressions with defaults, filters, and properties") + void complexDefaultExpression() { + processor.setVariable("user", new User(null, "Doe")); + + processor.setTemplate("{{$user.firstName | uppercase ?? $user.lastName | uppercase ?? \"GUEST\"}}"); + assertEquals("DOE", processor.process()); + } + } + + // ========== IF BLOCKS ========== + + @Nested + @DisplayName("Conditional Blocks (if)") + class IfBlocks { + + @ParameterizedTest + @CsvSource({ + "true,
Visible
", + "false,
" + }) + @DisplayName("Should evaluate content based on condition") + void renderWhenTrue(boolean show, String excected) { + processor.setVariable("show", show); + + processor.setTemplate("{{if $show}}
Visible
{{/if}}"); + assertEquals(excected == null ? "" : excected, processor.process()); + } + + @Test + @DisplayName("Should evaluate complex conditions") + void complexConditions() { + processor.setVariable("enabled", true); + processor.setVariable("count", 5); + + processor.setTemplate("{{if $enabled && $count > 3}}
Show
{{/if}}"); + assertEquals("
Show
", processor.process()); + } + + @ParameterizedTest + @CsvSource({ + "true, 5, enabled", + "false, 3, biggest", + "false, 1, low", + }) + @DisplayName("Should evaluate multiple conditions") + void multipleonditions(boolean enabled, int count, String expected) { + processor.setVariable("enabled", enabled); + processor.setVariable("count", count); + + processor.setTemplate("{{if $enabled}}enabled{{else if $count >= 3}}biggest{{else}}low{{/if}}"); + assertEquals(expected, processor.process()); + } + + @Test + @DisplayName("Should support nested if blocks") + void nestedIf() { + processor.setVariable("outer", true); + processor.setVariable("inner", true); + + processor.setTemplate(normalize(""" + {{if $outer}} + Outer + {{if $inner}} + Inner {{/if}} - """; + {{/if}} + """)); + + String result = processor.process(); + assertTrue(result.contains("Outer")); + assertTrue(result.contains("Inner")); - assertEquals("", processor.process(template).trim()); - assertEquals(0, evaluations.get(), "Supplier must not be evaluated"); + processor.setVariable("inner", false); + + result = processor.process(); + assertTrue(result.contains("Outer")); + assertFalse(result.contains("Inner")); } @Test - void supplierIsEvaluatedWhenIfConditionIsTrue() { - AtomicInteger evaluations = new AtomicInteger(); + @DisplayName("Should handle complex if with comparisons") + void complexIfComparison() { + processor.setVariable("score", 85); + + processor.setTemplate(normalize(""" + {{if $score >= 90}} + A + {{/if}} + {{if $score >= 80 && $score < 90}} + B + {{/if}} + {{if $score < 80}} + C + {{/if}} + """)); - processor - .setVariable("enabled", true) - .setVariable("value", () -> { - evaluations.incrementAndGet(); - return "OK"; - }); + String result = processor.process(); + assertTrue(result.contains("B")); + assertFalse(result.contains("A")); + assertFalse(result.contains("C")); + } - String template = """ - {{#if enabled}} - {{$value}} + @ParameterizedTest + @CsvSource({ + "true, true, Welcome back!", + "true, true, Welcome back!", + "false, true, Rendering is disabled", + }) + @DisplayName("Should render inner else branch when condition switch") + void rendersIfElseBranch(boolean render, boolean loggedIn, String expected) { + processor.setVariable("render", render); + processor.setVariable("loggedIn", loggedIn); + + processor.setTemplate(""" + {{if $render}} + {{if $loggedIn}} + Welcome back! + {{else}} + Please log in {{/if}} - """; + {{else}} + Rendering is disabled + {{/if}} + """); - assertEquals("OK", processor.process(template).trim()); - assertEquals(1, evaluations.get()); + assertEquals(expected, processor.process().trim()); + } + + @Test + @DisplayName("Should render only first matching element in if/else-if/else chain") + void elseIfChainRendersOnlyFirst() { + processor.setVariable("score", 85); + + processor.setTemplate(normalize(""" +
=A
+
=B
+
=C
+
=F
+ """)); + + String result = processor.process(); + assertFalse(result.contains(">=A<")); + assertTrue(result.contains(">=B<")); + assertFalse(result.contains(">=C<")); + assertFalse(result.contains(">=F<")); + } + + @Test + @DisplayName("Should render else when all conditions in chain fail") + void elseInChainWhenAllFail() { + processor.setVariable("score", 65); + + processor.setTemplate(normalize(""" +
A
+
B
+
C
+
F
+ """)); + + String result = processor.process(); + assertFalse(result.contains(">A<")); + assertFalse(result.contains(">B<")); + assertFalse(result.contains(">C<")); + assertTrue(result.contains(">F<")); + } + + @Test + @DisplayName("Should render first condition in chain when it matches") + void ifChainFirstMatches() { + processor.setVariable("value", 100); + + processor.setTemplate(normalize(""" +

Excellent

+

Good

+

Average

+

Poor

+ """)); + + String result = processor.process(); + assertTrue(result.contains("Excellent")); + assertFalse(result.contains("Good")); + assertFalse(result.contains("Average")); + assertFalse(result.contains("Poor")); } - } - /* -------------------------------------------------- - * Combined scenario - * -------------------------------------------------- */ - - @Test - void complexTemplateRendersCorrectly() { - processor - .setVariable("player", "Ellie") - .setVariable("online", true) - .setVariable("scores", List.of(10, 20)) - .registerComponent("score", "
  • {{$item}}
  • "); - - String template = """ -

    Hello {{$player}}

    - - {{#if online}} -
      - {{#each scores}} - {{@score}} - {{/each}} -
    - {{else}} - Offline - {{/if}} - """; - - String result = processor.process(template) - .replaceAll("\\s+", ""); - - assertEquals( - "

    HelloEllie

    • 10
    • 20
    ", - result - ); + @Test + @DisplayName("Should render middle else-if in chain when it's first match") + void ifChainMiddleMatches() { + processor.setVariable("value", 75); + + processor.setTemplate(normalize(""" +

    Excellent

    +

    Good

    +

    Average

    +

    Poor

    + """)); + + String result = processor.process(); + assertFalse(result.contains("Excellent")); + assertTrue(result.contains("Good")); + assertFalse(result.contains("Average")); + assertFalse(result.contains("Poor")); + } + + @Test + @DisplayName("Should handle complex conditions in else-if chain") + void complexElseIfChain() { + processor.setVariable("age", 25); + processor.setVariable("member", true); + + processor.setTemplate(normalize(""" +

    Child ticket

    +

    Senior ticket

    +

    Member ticket

    +

    Regular ticket

    + """)); + + String result = processor.process(); + assertFalse(result.contains("Child")); + assertFalse(result.contains("Senior")); + assertTrue(result.contains("Member")); + assertFalse(result.contains("Regular")); + } + + @Test + @DisplayName("Should handle if/else-if/else chains within for loops") + void ifElseIfChainInFor() { + processor.setVariable("scores", List.of(95, 85, 75, 65)); + + processor.setTemplate(normalize(""" + {{for $score, $idx in $scores}} +
    {{$idx}}: A
    +
    {{$idx}}: B
    +
    {{$idx}}: C
    +
    {{$idx}}: F
    + {{/for}} + """)); + + String result = processor.process(); + assertTrue(result.contains("0: A")); + assertTrue(result.contains("1: B")); + assertTrue(result.contains("2: C")); + assertTrue(result.contains("3: F")); + // Ensure no duplicates + assertEquals(1, result.split("0:").length - 1); + assertEquals(1, result.split("1:").length - 1); + assertEquals(1, result.split("2:").length - 1); + assertEquals(1, result.split("3:").length - 1); + } + + @Test + @DisplayName("Should handle standalone if without chain") + void standaloneIfWithoutChain() { + processor.setVariable("show", true); + + processor.setTemplate("
    Content
    "); + + String result = processor.process(); + assertTrue(result.contains("Content")); + } + + @Test + @DisplayName("Should handle standalone else-if (acts like if when not in chain)") + void standaloneElseIf() { + processor.setVariable("score", 85); + + processor.setTemplate("
    = 80\">B
    "); + + String result = processor.process(); + assertTrue(result.contains(">B<")); + } + + @Test + @DisplayName("Should handle if/else-if without final else") + void ifElseIfWithoutElse() { + processor.setVariable("score", 85); + + processor.setTemplate(normalize(""" +
    A
    +
    B
    +
    C
    + """)); + + String result = processor.process(); + assertFalse(result.contains(">A<")); + assertTrue(result.contains(">B<")); + assertFalse(result.contains(">C<")); + } + + @Test + @DisplayName("Should handle inline if blocks in attribute values") + void inlineIfInAttributeValue() { + processor.setVariable("mode", "sell"); + + processor.setTemplate(""); + + String result = processor.process(); + assertTrue(result.contains("type=\"Primary\"")); + assertFalse(result.contains("Slate")); + } + + @Test + @DisplayName("Should handle inline if blocks in attribute values with different condition") + void inlineIfInAttributeValueAlternate() { + processor.setVariable("mode", "buy"); + + processor.setTemplate(""); + + String result = processor.process(); + assertTrue(result.contains("type=\"Slate\"")); + assertFalse(result.contains("Primary")); + } + + @Test + @DisplayName("Should handle inline if blocks with single quotes in attribute values") + void inlineIfWithSingleQuotesInAttributeValue() { + processor.setVariable("mode", "sell"); + + processor.setTemplate(""); + + String result = processor.process(); + assertTrue(result.contains("type=\"Primary\"")); + assertFalse(result.contains("Slate")); + } + + @Test + @DisplayName("Should handle inline if blocks with single quotes - else branch") + void inlineIfWithSingleQuotesInAttributeValueElse() { + processor.setVariable("mode", "buy"); + + processor.setTemplate(""); + + String result = processor.process(); + assertTrue(result.contains("type=\"Slate\"")); + assertFalse(result.contains("Primary")); + } + + @Test + @DisplayName("Should handle inline if blocks without else in attribute values") + void inlineIfWithoutElseInAttributeValue() { + processor.setVariable("active", true); + + processor.setTemplate("
    Content
    "); + + String result = processor.process(); + assertTrue(result.contains("class=\"base active\"")); + } + + @Test + @DisplayName("Should handle inline if blocks with newlines and whitespace in attribute values") + void inlineIfWithNewlinesInAttributeValue() { + processor.setVariable("mode", "sell"); + + // Test case matching user's scenario with newlines inside attribute + String template = "Sell "; + + processor.setTemplate(template); + + String result = processor.process(); + assertTrue(result.contains("type=\"Primary\"")); + assertFalse(result.contains("Slate")); + } + + @Test + @DisplayName("Should handle inline if blocks with newlines - else branch") + void inlineIfWithNewlinesInAttributeValueElseBranch() { + processor.setVariable("mode", "buy"); + + // Test case matching user's scenario with newlines inside attribute + String template = " Buy "; + + processor.setTemplate(template); + + String result = processor.process(); + assertTrue(result.contains("type=\"Slate\"")); + assertFalse(result.contains("Primary")); + } + + @Test + @DisplayName("Should handle input elements with expression in max attribute") + void inputWithMaxExpression() { + // Create a simple object with properties + var item = new Item("sword", true, "Sword", 50); + processor.setVariable("item", item); + + // This template has max="{{$item.count}}" which used to cause issues + // because the > after max gets tokenized with = as >= (COMPARATOR token) + String template = ""; + + processor.setTemplate(template); + + String result = processor.process(); + assertTrue(result.contains("max=\"50\"")); + assertTrue(result.contains("id=\"sell-slider-sword\"")); + } + } + + // ========== FOR BLOCKS ========== + + @Nested + @DisplayName("Iteration Blocks (for)") + class ForBlocks { + + @Test + @DisplayName("Should iterate with default item name") + void iterateWithDefaultName() { + processor.setVariable("items", List.of("A", "B", "C")); + + processor.setTemplate("{{for $items}}{{$item}} {{/for}}"); + assertEquals("A B C ", processor.process()); + } + + @Test + @DisplayName("Should iterate with custom item name") + void iterateWithCustomName() { + processor.setVariable("items", List.of("A", "B", "C")); + + processor.setTemplate("{{for $element in $items}}{{$element}} {{/for}}"); + assertEquals("A B C ", processor.process()); + } + + @Test + @DisplayName("Should iterate over records with property access") + void iterateRecords() { + processor.setVariable("items", List.of(new Item("First", false, "First", 1), new Item("Second", true, "Second", 2))); + + processor.setTemplate("{{for $items}}{{$item.name}}:{{$item.count}} {{/for}}"); + assertEquals("First:1 Second:2 ", processor.process()); + + processor.setTemplate("{{for $product in $items}}{{$product.name}}:{{$product.count}} {{/for}}"); + assertEquals("First:1 Second:2 ", processor.process()); + } + + @Test + @DisplayName("Should handle empty collections") + void emptyCollection() { + processor.setVariable("items", List.of()); + + processor.setTemplate("{{for $items}}{{$item}}{{/for}}"); + assertEquals("", processor.process()); + } + + @Test + @DisplayName("Should access global variables inside loops") + void globalVariablesInLoop() { + processor.setVariable("prefix", "Item"); + processor.setVariable("numbers", List.of(1, 2, 3)); + + processor.setTemplate("{{for $numbers}}{{$prefix}} {{$item}} {{/for}}"); + assertEquals("Item 1 Item 2 Item 3 ", processor.process()); + + processor.setTemplate("{{for $num in $numbers}}Number {{$num}} {{/for}}"); + assertEquals("Number 1 Number 2 Number 3 ", processor.process()); + } + + @Test + @DisplayName("Should support nested loops with custom names to avoid conflicts") + void nestedLoopsWithCustomNames() { + processor.setVariable("categories", List.of( + new Category("Fruits", List.of("Apple", "Banana")), + new Category("Vegetables", List.of("Carrot", "Lettuce")) + )); + + processor.setTemplate(normalize(""" + {{for $cat in $categories}} + {{$cat.name}}: + {{for $product in $cat.items}} + - {{$product}} + {{/for}} + {{/for}} + """)); + + assertEquals(normalize(""" + Fruits: + - Apple + - Banana + Vegetables: + - Carrot + - Lettuce + """), processor.process()); + } + + @Test + @DisplayName("Should handle null values in collections") + void nullValuesInCollection() { + processor.setVariable("items", new ArrayList<>() {{ + add("A"); + add(null); + add("C"); + }}); + + processor.setTemplate("{{for $items}}{{$item}},{{/for}}"); + assertEquals("A,,C,", processor.process()); + } + + @Test + @DisplayName("Should iterate with new syntax: $item in $items") + void iterateWithNewSyntaxItemIn() { + processor.setVariable("items", List.of("A", "B", "C")); + + processor.setTemplate("{{for $elem in $items}}{{$elem}} {{/for}}"); + assertEquals("A B C ", processor.process()); + } + + @Test + @DisplayName("Should iterate with new syntax: $item, $index in $items") + void iterateWithNewSyntaxItemIndexIn() { + processor.setVariable("items", List.of("A", "B", "C")); + + processor.setTemplate("{{for $elem, $i in $items}}{{$i}}:{{$elem}} {{/for}}"); + assertEquals("0:A 1:B 2:C ", processor.process()); + } + + @Test + @DisplayName("Should iterate with index using default item name") + void iterateWithIndexDefaultName() { + processor.setVariable("items", List.of("X", "Y", "Z")); + + processor.setTemplate("{{for $item, $idx in $items}}[{{$idx}}]={{$item}} {{/for}}"); + assertEquals("[0]=X [1]=Y [2]=Z ", processor.process()); + } + + @Test + @DisplayName("Should support index in nested loops") + void indexInNestedLoops() { + processor.setVariable("categories", List.of( + new Category("A", List.of("a1", "a2")), + new Category("B", List.of("b1", "b2")) + )); + + processor.setTemplate(normalize(""" + {{for $cat, $i in $categories}} + {{$i}}.{{$cat.name}}: + {{for $item, $j in $cat.items}} + {{$i}}.{{$j}}: {{$item}} + {{/for}} + {{/for}} + """)); + + assertEquals(normalize(""" + 0.A: + 0.0: a1 + 0.1: a2 + 1.B: + 1.0: b1 + 1.1: b2 + """), processor.process()); + } + + @Test + @DisplayName("Should support new syntax in attributes: for=\"$item in $items\"") + void attributeSyntaxItemIn() { + processor.setVariable("items", List.of("A", "B", "C")); + + processor.setTemplate("
    {{$elem}}
    "); + assertEquals("
    A
    B
    C
    ", processor.process()); + } + + @Test + @DisplayName("Should support new syntax in attributes: for=\"$item, $index in $items\"") + void attributeSyntaxItemIndexIn() { + processor.setVariable("items", List.of("A", "B", "C")); + + processor.setTemplate("
    {{$i}}:{{$elem}}
    "); + assertEquals("
    0:A
    1:B
    2:C
    ", processor.process()); + } + + @Test + @DisplayName("Should support shorthand syntax in attributes: for=\"$items\"") + void attributeSyntaxShorthand() { + processor.setVariable("items", List.of("A", "B", "C")); + + processor.setTemplate("
    {{$item}}
    "); + assertEquals("
    A
    B
    C
    ", processor.process()); + } + + @Test + @DisplayName("Should support map entry iteration with index") + void loopsWithMapIndex() { + processor.setVariable("categories", new LinkedHashMap>() {{ + put("Fruits", List.of("Apple", "Banana")); + put("Vegetables", List.of("Carrot", "Lettuce")); + }}); + + processor.setTemplate(normalize(""" + {{for $items, $cat in $categories}} + {{$cat}}: + {{for $product in $items}} + - {{$product}} + {{/for}} + {{/for}} + """)); + + assertEquals(normalize(""" + Fruits: + - Apple + - Banana + Vegetables: + - Carrot + - Lettuce + """), processor.process()); + } + } + + // ========== FUNCTION CALLS ========== + + @Nested + @DisplayName("Function Calls in Templates") + class FunctionCalls { + + @Test + @DisplayName("Should call function with arguments") + void callFunctionWithArguments() { + processor.setVariable("products", List.of( + new TemplateProcessorTest.Product("Weapon", List.of("sword", "axe")), + new Product("Potion", List.of("healing", "mana")) + )); + processor.setVariable("tags", (stack) -> { + if (!stack.isScope(SCOPE_FOR_NAME)) + return ""; + + String key = stack.getScopeKeys().iterator().next(); + Object value = stack.getVariable(key); + if (!(value instanceof Product product)) + return ""; + + return product.getTagsWithPrefix("tag_"); + }, DYNAMIC); + + processor.setTemplate(normalize(""" + {{for $product in $products}} + {{$name}}: {{$tags}} + {{/for}} + """)); + + assertEquals(normalize(""" + Weapon: tag_sword, tag_axe + Potion: tag_healing, tag_mana + """), processor.process()); + } + + @Test + @DisplayName("Should call function with arguments and dynamic variables") + void callFunctionWithArgumentsAndDynamic() { + final var modulator = new Modulator(); + + processor.setVariable("list", List.of(1, 2, 3, 4)); + processor.setVariable("modulation", (_) -> modulator.increment(), DYNAMIC); + processor.setVariable("style", (stack) -> (int) stack.getVariable("key") < 3 ? "color: red;" : null, DYNAMIC); + + processor.registerComponent("module", """ +
    Module {{$key}} -> Active : {{ $active ?? false }}
    + """); + + processor.setTemplate(normalize(""" + + """)); + + assertEquals(normalize(""" +
    Module 1 -> Active : false
    +
    Module 2 -> Active : true
    +
    Module 3 -> Active : false
    +
    Module 4 -> Active : false
    + """.replace("\n", "")), processor.process()); + } + } + + // ========== COMBINED BLOCKS ========== + + @Nested + @DisplayName("Combined Conditional and Iteration") + class CombinedBlocks { + + @Test + @DisplayName("Should combine if and for blocks") + void ifInsideFor() { + processor.setVariable("items", List.of( + new Item("First", true, null, 0), + new Item("Second", false, null, 0), + new Item("Third", true, null, 0) + )); + + processor.setTemplate(normalize(""" + {{for $items}} + {{if $item.active}} +
    {{$item.name}}
    + {{/if}} + {{/for}} + """)); + + assertEquals(normalize(""" +
    First
    +
    Third
    + """), processor.process()); + } + + @Test + @DisplayName("Should handle complex real-world template") + void complexRealWorldTemplate() { + processor.setVariable("preset-active", "preset_01"); + processor.setVariable("render", true); + processor.setVariable("preset-list", List.of( + new Item("preset_01", true, "Test name", 0), + new Item("preset_02", true, "Test name 02", 1) + )); + + processor.setTemplate(""" +
    +
    + +
    + + {{if $render && $preset-list.size > 1}} +
    + +
    + {{/if}} +
    + """); + + assertEquals(normalize(""" +
    +
    + +
    + +
    + +
    +
    + """), processor.process()); + } + } + + // ========== COMPONENTS ========== + + @Nested + @DisplayName("Components") + class Components { + + @Test + @DisplayName("Should expand component with parameters") + void expandsComponentWithParameters() { + processor.setVariable("number", 12.847); + processor.registerComponent("statCard", """ +
    +

    {{$label}}

    +

    {{$value}}

    +
    + """); + + processor.setTemplate(""); + assertEquals(normalize(""" +
    +

    Blocks Placed

    +

    12.847

    +
    + """), processor.process()); + } + + @Test + @DisplayName("Should expand component inside another component with parameters") + void expandsComponentWithinComponent() { + processor.setVariable("text", "Deep Component"); + processor.registerComponent("panel", """ +
    + + +
    + """); + processor.registerComponent("view", """ + {{ $content ?? "undefined" }} + """); + + processor.setTemplate(""); + + assertEquals(normalize(""" +
    + Deep Component + undefined +
    + """), processor.process()); + } + + @Test + @DisplayName("Should allow components to access variables from global scope") + void componentCanAccessVariablesFromGlobalScope() { + processor.setVariable("label", "Submit"); + processor.registerComponent("submit", ""); + + processor.setTemplate(""); + + assertEquals( + "", + processor.process() + ); + } + + @Test + @DisplayName("Should prioritize local scope over global scope in components") + void componentPrioritizeVariableFromLocalScope() { + processor.setVariable("label", "global scope"); + processor.registerComponent("submit", ""); + + processor.setTemplate(""); + assertEquals( + "", + processor.process() + ); + } + + @Test + @DisplayName("Should evaluate inline if blocks in component attributes") + void componentWithInlineIfInAttribute() { + processor.setVariable("mode", "buy"); + processor.setVariable("buyPrice", "100"); + processor.setVariable("sellPrice", "50"); + processor.registerComponent("priceDisplay", "
    {{$amount}}
    "); + + processor.setTemplate(""); + + var result = processor.process(); + assertTrue(result.contains("
    100
    "), "Should show buy price when mode is buy: " + result); + } + + @Test + @DisplayName("Should evaluate inline if blocks in component attributes - else branch") + void componentWithInlineIfInAttributeElseBranch() { + processor.setVariable("mode", "sell"); + processor.setVariable("buyPrice", "100"); + processor.setVariable("sellPrice", "50"); + processor.registerComponent("priceDisplay", "
    {{$amount}}
    "); + + processor.setTemplate(""); + + var result = processor.process(); + assertTrue(result.contains("
    50
    "), "Should show sell price when mode is sell: " + result); + } + + @Test + @DisplayName("Should evaluate complex expressions in component attributes") + void componentWithComplexAttributeExpressions() { + processor.setVariable("type", "secondary"); + processor.setVariable("variant", "Dense"); + processor.setVariable("playerAmount", 0); + processor.registerComponent("testBtn", """ + + """); + + processor.setTemplate(""" + + Sell + + """); + + var result = processor.process(); + assertTrue(result.contains("data-type=\"@secondary-Dense\""), "Should have correct data-type: " + result); + assertTrue(result.contains("class=\"p-2 hidden\""), "Should have hidden class when playerAmount is 0: " + result); + } + + @Test + @DisplayName("Should handle undefined variables in component attributes gracefully") + void componentWithUndefinedVariablesInAttributes() { + processor.registerComponent("priceDisplay", "
    {{$amount}}
    "); + + processor.setTemplate(""); + var result = processor.process(); + + assertTrue(result.contains("
    "), + "Should have empty amount when variables are undefined: " + result); + } + + @Test + @DisplayName("Should show actual behavior with partially defined variables") + void componentWithPartiallyDefinedVariables() { + processor.setVariable("mode", "buy"); + processor.setVariable("sellPrice", "50"); + processor.registerComponent("priceDisplay", "
    Price: {{$amount}}
    "); + + processor.setTemplate(""); + var result = processor.process(); + + assertTrue(result.contains("
    Price:
    "), + "Should have empty amount when buyPrice is undefined: " + result); + } + + @Test + @DisplayName("Should allow components to pass existing parameters") + void componentCanPassExistingParameters() { + processor.registerComponent("button", ""); + + processor.setTemplate("", + processor.process() + ); + } + + @Test + @DisplayName("Should allow components to use children as content") + void componentCanPassChildren() { + processor.registerComponent("panel", "
    "); + processor.registerComponent("bigButton", "

    "); + + processor.setTemplate("Deep Big Button"); + assertEquals( + "

    Deep Big Button

    ", + processor.process() + ); + } + + @Test + @DisplayName("Should allow default slot content when no children are provided") + void defaultSlot() { + processor.registerComponent("panel", "

    Default

    "); + + processor.setTemplate(""); + assertEquals( + "

    Default

    ", + processor.process() + ); + } + + @Test + @DisplayName("Should handle named slots with default content") + void complexSlotHandling() { + processor.registerComponent("panel", """ +
    +

    Default Header

    + {{if $slot:default}} +
    + Default Content +
    + {{/if}} + {{if $slot:footer}} +
    + {{/if}} +
    + """); + + processor.setTemplate(""" + + Custom Content + <:header>Custom Header + <:footer>Custom Footer + + """); + + assertEquals(normalize(""" +
    +

    Custom Header

    +
    + Custom Content +
    +
    Custom Footer
    +
    + """), + processor.process() + ); + } + + @Test + @DisplayName("Should handle control flow as attributes") + void componentAttributesFlow() { + processor.setVariable("items", List.of("A", "B", "C")); + processor.registerComponent("bigButton", "

    "); + processor.registerComponent("panel", """ +
    + +
    + """); + + processor.setTemplate(""" + + Button {{$key}} secret + + """); + assertEquals(normalize(""" +
    +

    Button A

    Button B secret

    Button C

    +
    + """), processor.process()); + } + } + + // ========== TEMPLATE TAG ========== + + @Nested + @DisplayName("Template Tag (Renderless Wrapper)") + class TemplateTag { + + @Test + @DisplayName("Should render template tag children without wrapper element") + void renderChildrenWithoutWrapper() { + processor.setTemplate(""); + assertEquals("
    Content
    ", processor.process()); + } + + @Test + @DisplayName("Should use template tag with if attribute") + void templateWithIf() { + processor.setVariable("show", true); + + processor.setTemplate(""); + assertEquals("
    Visible
    ", processor.process()); + + processor.setVariable("show", false); + assertEquals("", processor.process()); + } + + @Test + @DisplayName("Should use template tag with if/else-if/else chain") + void templateWithConditionalChain() { + processor.setVariable("status", "warning"); + + processor.setTemplate(normalize(""" + + + + """)); + + var result = processor.process(); + assertTrue(result.contains("Success")); + assertFalse(result.contains("Warning")); + assertFalse(result.contains("Error")); + assertFalse(result.contains("{{$item}}"); + assertEquals("ABC", processor.process()); + } + + @Test + @DisplayName("Should use template tag with for and index") + void templateWithForAndIndex() { + processor.setVariable("items", List.of("X", "Y", "Z")); + + processor.setTemplate(""); + assertEquals("

    0:X

    1:Y

    2:Z

    ", processor.process()); + } + + @Test + @DisplayName("Should use template tag for multiple children without wrapper") + void templateWithMultipleChildren() { + processor.setVariable("show", true); + + processor.setTemplate(normalize(""" + + """)); + + var result = processor.process(); + assertTrue(result.contains("

    Title

    ")); + assertTrue(result.contains("

    Paragraph

    ")); + assertTrue(result.contains("Span")); + assertFalse(result.contains(" +
    Outer
    + + + """)); + + var result = processor.process(); + assertTrue(result.contains("
    Outer
    ")); + assertTrue(result.contains("
    Inner
    ")); + assertFalse(result.contains(" +
    {{$item.name}}
    + + {{/for}} + """)); + + var result = processor.process(); + assertTrue(result.contains("First")); + assertFalse(result.contains("Second")); + assertTrue(result.contains("Third")); + assertFalse(result.contains(" + + + + + """)); + + var result = processor.process(); + assertTrue(result.contains("0: A")); + assertTrue(result.contains("1: B")); + assertTrue(result.contains("2: C")); + assertFalse(result.contains(""); + assertEquals("", processor.process()); + } + + @Test + @DisplayName("Should handle self-closing template tag") + void selfClosingTemplate() { + processor.setTemplate("