diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3ce5adb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.idea
+vendor
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b8..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/PMServerUI.iml b/.idea/PMServerUI.iml
deleted file mode 100644
index 67452d9..0000000
--- a/.idea/PMServerUI.iml
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/discord.xml b/.idea/discord.xml
deleted file mode 100644
index 912db82..0000000
--- a/.idea/discord.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index bf789b9..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/php.xml b/.idea/php.xml
deleted file mode 100644
index 9c0dab8..0000000
--- a/.idea/php.xml
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/phpspec.xml b/.idea/phpspec.xml
deleted file mode 100644
index 33107fd..0000000
--- a/.idea/phpspec.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index d843f34..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/.poggit.yml b/.poggit.yml
index 4a3012f..6fe256d 100644
--- a/.poggit.yml
+++ b/.poggit.yml
@@ -1,4 +1,4 @@
---- # Poggit-CI Manifest. Open the CI at https://poggit.pmmp.io/ci/DavyCraft648/PMServerUI
+--- # Poggit-CI Manifest. Open the CI at https://poggit.pmmp.io/ci/Azvyl/PMServerUI
build-by-default: true
branches:
- master
diff --git a/LICENSE b/LICENSE
index d832b61..949c4b2 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,7 @@
MIT License
Copyright (c) 2025 DavyCraft648
+Copyright (c) 2026 Azvyl
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 11d38ff..7d379ee 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ library that other plugins *include* in their code.
following command:
```bash
- composer require davycraft648/pmserver-ui
+ composer require azvyl/pmserver-ui
```
After running the command, your `composer.json` file will be updated. You can inspect it to see the new dependency
@@ -42,7 +42,7 @@ library that other plugins *include* in their code.
{
"require": {
// other dependencies ...
- "davycraft648/pmserver-ui": "^1.0"
+ "azvyl/pmserver-ui": "^2.0"
}
}
```
@@ -56,15 +56,15 @@ library that other plugins *include* in their code.
projects:
YourPlugin:
libs:
- - src: DavyCraft648/PMServerUI/PMServerUI
- version: ^1.0.2
+ - src: Azvyl/PMServerUI/PMServerUI
+ version: ^2.0.0
```
---
## Example Usage (For Developers)
-See plugin example [here](https://github.com/DavyCraft648/PMServerUI-Example)
+See plugin example [here](https://github.com/Azvyl/PMServerUI-Example)
---
diff --git a/composer.json b/composer.json
index e6070e5..b31f2f8 100644
--- a/composer.json
+++ b/composer.json
@@ -1,5 +1,5 @@
{
- "name": "davycraft648/pmserver-ui",
+ "name": "azvyl/pmserver-ui",
"description": "Integrate UI features available in the Script API to PocketMine-MP",
"type": "library",
"license": "MIT",
@@ -12,7 +12,7 @@
"extra": {
"virion": {
"spec": "3.0",
- "namespace-root": "DavyCraft648\\PMServerUI"
+ "namespace-root": "Azvyl\\PMServerUI"
}
}
}
\ No newline at end of file
diff --git a/src/DavyCraft648/PMServerUI/PMServerUI.php b/src/Azvyl/PMServerUI/PMServerUI.php
similarity index 62%
rename from src/DavyCraft648/PMServerUI/PMServerUI.php
rename to src/Azvyl/PMServerUI/PMServerUI.php
index 504a940..34efd1d 100644
--- a/src/DavyCraft648/PMServerUI/PMServerUI.php
+++ b/src/Azvyl/PMServerUI/PMServerUI.php
@@ -1,19 +1,11 @@
getName()} tries to register PMServerUI that has been registered by " . self::$plugin->getName());
+ return;
}
self::$plugin = $plugin;
self::$logger = new \PrefixedLogger($plugin->getLogger(), "PMServerUI");
self::$uiManager = new UIManager($plugin);
+ self::$dduiManager = new DDUIManager($plugin);
//TODO: make options easier to configure.
- self::$tl = $tl;
//crash on unhandled error.
//kick player sending malformed response.
//validate response length before performing json_decode.
@@ -44,20 +36,24 @@ public static function register(Plugin $plugin, bool $tl = false): void{
//disable player input.
//enforce string dropdown.
- if(__NAMESPACE__ === "DavyCraft648\\PMServerUI"){
+ if(__NAMESPACE__ === "Azvyl\\PMServerUI"){
self::getLogger()->notice("It is recommended to shade virions, go to https://poggit.pmmp.io/virion to see virion documentation.");
}
}
- public static function getPlugin(): Plugin{
+ public static function getPlugin() : Plugin{
return self::$plugin ?? throw new \LogicException("PMServerUI has not been registered");
}
- public static function getLogger(): \PrefixedLogger{
+ public static function getLogger() : \PrefixedLogger{
return self::$logger ?? throw new \LogicException("PMServerUI has not been registered");
}
- public static function getUIManager(): UIManager{
+ public static function getUIManager() : UIManager{
return self::$uiManager ?? throw new \LogicException("PMServerUI has not been registered");
}
+
+ public static function getDDUIManager() : DDUIManager{
+ return self::$dduiManager ?? throw new \LogicException("PMServerUI has not been registered");
+ }
}
\ No newline at end of file
diff --git a/src/DavyCraft648/PMServerUI/Promise.php b/src/Azvyl/PMServerUI/Promise.php
similarity index 51%
rename from src/DavyCraft648/PMServerUI/Promise.php
rename to src/Azvyl/PMServerUI/Promise.php
index e067e73..4779655 100644
--- a/src/DavyCraft648/PMServerUI/Promise.php
+++ b/src/Azvyl/PMServerUI/Promise.php
@@ -1,56 +1,59 @@
*/
private array $onFulfilledCallbacks = [];
+ /** @var array */
private array $onRejectedCallbacks = [];
+ /**
+ * @param null|callable(callable(TValue):void, callable(\Throwable):void):void $executor
+ */
public function __construct(?callable $executor = null){
if($executor !== null){
$this->run($executor);
}
}
- public function run(callable $executor): void{
+ /**
+ * @param callable(callable(TValue):void, callable(\Throwable):void):void $executor
+ */
+ public function run(callable $executor) : void{
try{
- $executor(fn(mixed ...$value) => $this->resolve(...$value), fn(\Throwable $t) => $this->reject($t));
+ $executor(fn(mixed $value) => $this->resolve($value), fn(\Throwable $t) => $this->reject($t));
}catch(\Throwable $e){
$this->reject($e);
}
}
- public function resolve(mixed ...$value): void{
+ /** @param TValue $value */
+ public function resolve(mixed $value) : void{
if($this->isResolved || $this->isRejected) return;
$this->isResolved = true;
$this->result = $value;
foreach($this->onFulfilledCallbacks as $cb){
- $cb(...$value);
+ $cb($value);
}
$this->onFulfilledCallbacks = [];
}
- public function reject(\Throwable $reason): void{
+ public function reject(\Throwable $reason) : void{
if($this->isResolved || $this->isRejected) return;
$this->isRejected = true;
@@ -63,12 +66,18 @@ public function reject(\Throwable $reason): void{
$this->onRejectedCallbacks = [];
}
- public function then(?callable $onFulfilled = null, ?callable $onRejected = null): Promise{
- return new Promise(function($resolve, $reject) use ($onFulfilled, $onRejected){
- $handleFulfilled = function(...$value) use ($onFulfilled, $resolve, $reject){
+ /**
+ * @template TNext
+ * @param null|callable(TValue):TNext $onFulfilled
+ * @param null|callable(\Throwable):TNext $onRejected
+ * @return Promise
+ */
+ public function then(?callable $onFulfilled = null, ?callable $onRejected = null) : Promise{
+ return new self(function($resolve, $reject) use ($onFulfilled, $onRejected){
+ $handleFulfilled = function(mixed $value) use ($onFulfilled, $resolve, $reject){
if($onFulfilled){
try{
- $result = $onFulfilled(...$value);
+ $result = $onFulfilled($value);
$resolve($result);
}catch(\Throwable $e){
$reject($e);
@@ -78,7 +87,7 @@ public function then(?callable $onFulfilled = null, ?callable $onRejected = null
}
};
- $handleRejected = function($reason) use ($onRejected, $resolve, $reject){
+ $handleRejected = function(\Throwable $reason) use ($onRejected, $resolve, $reject){
if($onRejected){
try{
$result = $onRejected($reason);
@@ -94,7 +103,7 @@ public function then(?callable $onFulfilled = null, ?callable $onRejected = null
if($this->isResolved){
$handleFulfilled($this->result);
}elseif($this->isRejected){
- $handleRejected($this->error);
+ $handleRejected($this->error ?? new \RuntimeException("Promise rejected without reason"));
}else{
$this->onFulfilledCallbacks[] = $handleFulfilled;
$this->onRejectedCallbacks[] = $handleRejected;
@@ -102,7 +111,11 @@ public function then(?callable $onFulfilled = null, ?callable $onRejected = null
});
}
- public function catch(?callable $onRejected = null): Promise{
+ /**
+ * @param null|callable(\Throwable):TValue $onRejected
+ * @return Promise
+ */
+ public function catch(?callable $onRejected = null) : Promise{
return $this->then(null, $onRejected);
}
-}
\ No newline at end of file
+}
diff --git a/src/Azvyl/PMServerUI/UIRawMessage.php b/src/Azvyl/PMServerUI/UIRawMessage.php
new file mode 100644
index 0000000..36515e8
--- /dev/null
+++ b/src/Azvyl/PMServerUI/UIRawMessage.php
@@ -0,0 +1,67 @@
+rawtext !== null){
+ Utils::validateArrayValueType($this->rawtext, fn(UIRawMessage $_) => null);
+ }
+ if(is_array($this->with)){
+ Utils::validateArrayValueType($this->with, fn(string $_) => null);
+ }
+ }
+
+ /** Encodes this value into the Bedrock RawMessage payload format for network packets. */
+ public function encode() : array{
+ $entries = [];
+
+ if($this->translate !== null){
+ $entry = ["translate" => $this->translate];
+
+ if($this->with !== null){
+ if($this->with instanceof UIRawMessage){
+ $entry["with"] = $this->with->encode();
+ }elseif(is_array($this->with)){
+ $entry["with"] = ["rawtext" => array_map(function($v){
+ if($v instanceof UIRawMessage){
+ $enc = $v->encode();
+ return $enc["rawtext"][0] ?? [];
+ }
+ return ["text" => (string)$v];
+ }, $this->with)];
+ }
+ }
+
+ $entries[] = $entry;
+ }
+
+ if($this->text !== null && $this->translate === null && $this->rawtext === null){
+ $entries[] = ["text" => $this->text];
+ }
+
+ if($this->rawtext !== null){
+ foreach($this->rawtext as $child){
+ if(!($child instanceof UIRawMessage)) continue;
+ $childEnc = $child->encode();
+ $childEntries = $childEnc["rawtext"] ?? [];
+ foreach($childEntries as $ce){
+ $entries[] = $ce;
+ }
+ }
+ }
+
+ return ["rawtext" => $entries];
+ }
+}
diff --git a/src/Azvyl/PMServerUI/ddui/CustomForm.php b/src/Azvyl/PMServerUI/ddui/CustomForm.php
new file mode 100644
index 0000000..449be2a
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/CustomForm.php
@@ -0,0 +1,244 @@
+ */
+ private \WeakReference $playerRef;
+ /** @var Observable|Observable|string|UIRawMessage */
+ private mixed $title;
+ /** @var int|null */
+ private ?int $formId = null;
+ /** @var array */
+ private array $elements = [];
+ private bool $showing = false;
+ private bool $closeButtonEnabled = false;
+ private CustomFormPayloadComposer $payloadComposer;
+
+ /**
+ * Create a CustomForm for a specific player.
+ *
+ * @param Player $player The player to show the form to.
+ * @param Observable|Observable|string|UIRawMessage $title The title of the form.
+ */
+ public static function create(Player $player, Observable|string|UIRawMessage $title) : self{
+ $instance = new self();
+ $instance->playerRef = \WeakReference::create($player);
+ $instance->title = $title;
+ $instance->elements = [];
+ $instance->showing = false;
+ $instance->closeButtonEnabled = false;
+ $instance->payloadComposer = new CustomFormPayloadComposer();
+ return $instance;
+ }
+
+ /**
+ * Inserts a button into the Custom form. onClick is called when the button is pressed.
+ *
+ * @param Observable|Observable|string|UIRawMessage $label The text to display on the button.
+ * @param callable() : void $onClick The function to call when the button is clicked.
+ * @param bool|Observable $disabled
+ * @param Observable|Observable|string|UIRawMessage|null $tooltip The tooltip to display when hovering over the button.
+ * @param bool|Observable $visible
+ */
+ public function button(Observable|string|UIRawMessage $label, callable $onClick, bool|Observable $disabled = null, Observable|string|UIRawMessage $tooltip = null, bool|Observable $visible = null) : self{
+ $this->elements[] = new ButtonElement($label, $onClick, $disabled, $tooltip, $visible);
+ return $this;
+ }
+
+ /**
+ * Close the form programmatically.
+ *
+ * Sends a close packet to the client. This method expects the form to be currently shown; it will throw if not.
+ */
+ public function close() : void{
+ if(!$this->showing){
+ throw new \RuntimeException("Form is not open");
+ }
+ $player = $this->playerRef->get();
+ if($player === null){
+ throw new \RuntimeException("Player reference is gone");
+ }
+ $fid = $this->formId;
+ $player->getNetworkSession()->sendDataPacket(ClientboundDataDrivenUICloseScreenPacket::create($fid));
+ $this->showing = false;
+ }
+
+ /** Enable the standard close (X) control shown by the client. */
+ public function closeButton() : self{
+ $this->closeButtonEnabled = true;
+ return $this;
+ }
+
+ /**
+ * Inserts a divider (i.e. a line) into the Custom form.
+ *
+ * @param bool|Observable $visible Whether the divider is visible.
+ */
+ public function divider(bool|Observable $visible = null) : self{
+ $this->elements[] = new DividerElement($visible);
+ return $this;
+ }
+
+ /**
+ * Inserts a dropdown into the Custom form with the provided items. The value is based on the items value that
+ * selected.
+ *
+ * @param Observable|Observable|string|UIRawMessage $label The text to display above the dropdown.
+ * @param Observable|Observable $value The currently selected index in the dropdown.
+ * @param DropdownItem[] $items An array of DropdownItem to show in the dropdown.
+ * @param Observable|Observable|string|UIRawMessage|null $description
+ * @param bool|Observable $disabled
+ * @param bool|Observable $visible
+ */
+ public function dropdown(Observable|string|UIRawMessage $label, Observable $value, array $items, Observable|string|UIRawMessage $description = null, bool|Observable $disabled = null, bool|Observable $visible = null) : self{ // TODO: verify if $description can be Observable
+ $this->elements[] = new DropdownElement($label, $value, $items, $description, $disabled, $visible);
+ return $this;
+ }
+
+ /**
+ * Inserts a label (i.e. medium-sized text) into the Custom form.
+ *
+ * @param Observable|Observable|string|UIRawMessage $text The text to display in the label.
+ * @param bool|Observable $visible
+ */
+ public function label(Observable|string|UIRawMessage $text, bool|Observable $visible = null) : self{
+ $this->elements[] = new LabelElement($text, $visible);
+ return $this;
+ }
+
+ /**
+ * Inserts a header (i.e. large-sized text) into the Custom form.
+ *
+ * @param Observable|Observable|string|UIRawMessage $text The text to display in the header.
+ * @param bool|Observable $visible Whether the header is visible.
+ */
+ public function header(Observable|string|UIRawMessage $text, bool|Observable $visible = null) : self{
+ $this->elements[] = new HeaderElement($text, $visible);
+ return $this;
+ }
+
+ /** Returns true if this CustomForm has been shown and not yet acknowledged/closed. */
+ public function isShowing() : bool{
+ return $this->showing;
+ }
+
+ /**
+ * Shows the form to the player. Will return false if the client was busy (i.e. in another menu or this one is open).
+ * Will throw if the user disconnects.
+ *
+ * @return Promise
+ */
+ public function show() : Promise{ // TODO: return Promise
+ /** @var Promise $promise */
+ $promise = new Promise(); // TODO: reject instead off throwing exception
+ if($this->showing){
+ throw new \RuntimeException("Form is already open");
+ }
+ $player = $this->playerRef->get();
+ if($player === null){
+ throw new \RuntimeException("Player reference is gone");
+ }
+ $manager = PMServerUI::getDDUIManager();
+ $playerUuid = $player->getUniqueId()->getBytes();
+ if($manager->hasActiveForm($playerUuid)){
+ throw new \RuntimeException("Another DDUI form is already active for this player");
+ }
+ $formId = $manager->nextFormId();
+
+ $this->formId = $formId;
+ $player->getNetworkSession()->sendDataPacket(ClientboundDataDrivenUIShowScreenPacket::create("minecraft:custom_form", $formId, null));
+
+ $mapValue = $this->payloadComposer->compose($manager, $playerUuid, $formId, $this->title, $this->closeButtonEnabled, $this->elements);
+ $updateCount = $manager->nextUpdateCountFor($playerUuid);
+
+ $player->getNetworkSession()->sendDataPacket(ClientboundDataStorePacket::create([
+ new ClientboundDataStoreChange('minecraft', 'custom_form_data', $updateCount, $mapValue),
+ new ClientboundDataStoreChange('minecraft', 'ddui_form_active', $updateCount, new BoolDataStorePropertyValue(true))
+ ]));
+
+ $this->showing = true;
+
+ $manager->registerFormPromise($playerUuid, $formId, $promise);
+
+ return $promise;
+ }
+
+ /**
+ * Creates a slider that lets players pick a number between minValue and maxValue.
+ *
+ * @param Observable|Observable|string|UIRawMessage $label The text to display above the slider.
+ * @param Observable|Observable $value The current value observable (client-writable).
+ * @param Observable|Observable|int|float $minValue The minimum selectable value.
+ * @param Observable|Observable|int|float $maxValue The maximum selectable value.
+ * @param Observable|Observable|string|UIRawMessage|null $description Optional description shown in the UI.
+ * @param bool|Observable|null $disabled
+ * @param Observable|Observable|int|float|null $step The step size (increment) for the slider.
+ * @param bool|Observable|null $visible
+ */
+ public function slider(Observable|string|UIRawMessage $label, Observable $value, int|float|Observable $minValue, int|float|Observable $maxValue, Observable|string|UIRawMessage $description = null, bool|Observable $disabled = null, int|float|Observable $step = null, bool|Observable $visible = null) : self{
+ $this->elements[] = new SliderElement($label, $value, $minValue, $maxValue, $description, $disabled, $step, $visible);
+ return $this;
+ }
+
+ /**
+ * Inserts a space into the Custom form.
+ *
+ * @param bool|Observable|null $visible Whether the spacer is visible.
+ */
+ public function spacer(bool|Observable $visible = null) : self{
+ $this->elements[] = new SpacerElement($visible);
+ return $this;
+ }
+
+ /**
+ * Inserts a text field into the Custom for that players can enter text into.
+ *
+ * @param Observable|Observable|string|UIRawMessage $label The label shown above the text field.
+ * @param Observable $text The text observable bound to the field (client-writable).
+ * @param Observable|Observable|string|UIRawMessage|null $description Optional description shown in the UI.
+ * @param bool|Observable|null $disabled
+ * @param bool|Observable|null $visible
+ */
+ public function textField(Observable|string|UIRawMessage $label, Observable $text, Observable|string|UIRawMessage $description = null, bool|Observable $disabled = null, bool|Observable $visible = null) : self{
+ $this->elements[] = new TextFieldElement($label, $text, $description, $disabled, $visible);
+ return $this;
+ }
+
+ /**
+ * Inserts an on/off toggle that players can interact with into the Custom form.
+ *
+ * @param Observable|Observable|string|UIRawMessage $label The label shown beside the toggle.
+ * @param Observable $toggled The boolean observable bound to the toggle (client-writable).
+ * @param Observable|Observable|string|UIRawMessage|null $description Optional description shown in the UI.
+ * @param bool|Observable|null $disabled
+ * @param bool|Observable|null $visible
+ */
+ public function toggle(Observable|string|UIRawMessage $label, Observable $toggled, Observable|string|UIRawMessage $description = null, bool|Observable $disabled = null, bool|Observable $visible = null) : self{
+ $this->elements[] = new ToggleElement($label, $toggled, $description, $disabled, $visible);
+ return $this;
+ }
+}
diff --git a/src/Azvyl/PMServerUI/ddui/CustomFormPayloadComposer.php b/src/Azvyl/PMServerUI/ddui/CustomFormPayloadComposer.php
new file mode 100644
index 0000000..3d5526d
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/CustomFormPayloadComposer.php
@@ -0,0 +1,67 @@
+ $elements
+ */
+ public function compose(
+ DDUIManager $manager,
+ string $playerUuid,
+ int $formId,
+ Observable|string|UIRawMessage $title,
+ bool $closeButtonEnabled,
+ array $elements,
+ ) : MapDataStorePropertyValue{
+ $context = new CustomFormRenderContext($manager, $playerUuid, $formId);
+
+ $closeButtonEntries = [
+ new DataStoreMapEntry('button_visible', new BoolDataStorePropertyValue($closeButtonEnabled)),
+ new DataStoreMapEntry('label', new StringDataStorePropertyValue('Close')),
+ new DataStoreMapEntry('onClick', new Int64DataStorePropertyValue(0)),
+ ];
+
+ $layoutEntries = [];
+ foreach($elements as $index => $element){
+ $elementEntries = $element->buildEntries($context, $index);
+ $visible = true;
+ $disabled = false;
+ foreach($elementEntries as $entry){
+ $key = $entry->getKey();
+ $value = $entry->getValue();
+ if(!$value instanceof BoolDataStorePropertyValue){
+ continue;
+ }
+ if($key === 'visible'){
+ $visible = $value->getValue();
+ }elseif($key === 'disabled'){
+ $disabled = $value->getValue();
+ }
+ }
+ $manager->registerElementInteractionState($playerUuid, $formId, $index, $visible, $disabled);
+ $layoutEntries[] = new DataStoreMapEntry((string) $index, new MapDataStorePropertyValue($elementEntries));
+ }
+ $layoutEntries[] = new DataStoreMapEntry('length', new Int64DataStorePropertyValue(count($elements)));
+
+ $titleText = $context->resolveText($title, 'title');
+
+ return new MapDataStorePropertyValue([
+ new DataStoreMapEntry('closeButton', new MapDataStorePropertyValue($closeButtonEntries)),
+ new DataStoreMapEntry('layout', new MapDataStorePropertyValue($layoutEntries)),
+ new DataStoreMapEntry('title', $context->toTextPropertyValue($titleText)),
+ ]);
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/CustomFormRenderContext.php b/src/Azvyl/PMServerUI/ddui/CustomFormRenderContext.php
new file mode 100644
index 0000000..ead39cb
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/CustomFormRenderContext.php
@@ -0,0 +1,133 @@
+manager->registerObservableBinding($this->playerUuid, $this->formId, $path, $observable);
+ }
+
+ public function registerClickHandler(string $path, callable $handler) : void{
+ $this->manager->registerClickHandler($this->playerUuid, $this->formId, $path, $handler);
+ }
+
+ public function resolveBool(bool|Observable|null $value, string $path, bool $default = false) : bool{
+ if($value instanceof Observable){
+ $this->registerBinding($path, $value);
+ return (bool)$value->getData();
+ }
+ if($value === null){
+ return $default;
+ }
+ return (bool)$value;
+ }
+
+ public function resolveInt(int|float|Observable|null $value, string $path, int $default = 0) : int{
+ if($value instanceof Observable){
+ $this->registerBinding($path, $value);
+ return (int)$value->getData();
+ }
+ if($value === null){
+ return $default;
+ }
+ return (int)$value;
+ }
+
+ public function resolveText(Observable|string|UIRawMessage|null $value, string $path, string|UIRawMessage $default = '') : string|UIRawMessage{
+ if($value instanceof Observable){
+ $this->registerBinding($path, $value);
+ $value = $value->getData();
+ }
+
+ if($value instanceof UIRawMessage){
+ return $value;
+ }
+
+ if($value === null){
+ return $default;
+ }
+
+ return (string)$value;
+ }
+
+ public function toTextPropertyValue(mixed $value) : DataStorePropertyValue{
+ if($value instanceof UIRawMessage){
+ return $this->toPropertyValue($value->encode());
+ }
+ return new StringDataStorePropertyValue((string)($value ?? ''));
+ }
+
+ public function toPropertyValue(mixed $value) : DataStorePropertyValue{
+ if($value === null){
+ return new NoneDataStorePropertyValue();
+ }
+
+ if(is_array($value)){
+ $isList = array_keys($value) === range(0, count($value) - 1);
+ if($isList){
+ $entries = [];
+ foreach($value as $item){
+ $entries[] = $this->toPropertyValue($item);
+ }
+ return new ListDataStorePropertyValue($entries);
+ }
+
+ $mapEntries = [];
+ foreach($value as $key => $entryValue){
+ if($key === 'with' && is_array($entryValue) && array_keys($entryValue) === range(0, count($entryValue) - 1)){
+ $rawTextEntries = [];
+ foreach($entryValue as $item){
+ $rawTextEntries[] = $this->toPropertyValue($item);
+ }
+ $mapEntries[] = new DataStoreMapEntry('with', new MapDataStorePropertyValue([
+ new DataStoreMapEntry('rawtext', new ListDataStorePropertyValue($rawTextEntries)),
+ ]));
+ continue;
+ }
+
+ $mapEntries[] = new DataStoreMapEntry((string)$key, $this->toPropertyValue($entryValue));
+ }
+
+ return new MapDataStorePropertyValue($mapEntries);
+ }
+
+ if(is_string($value)){
+ return new StringDataStorePropertyValue($value);
+ }
+ if(is_int($value) || is_float($value)){
+ return new Int64DataStorePropertyValue((int)$value);
+ }
+ if(is_bool($value)){
+ return new BoolDataStorePropertyValue($value);
+ }
+
+ return new NoneDataStorePropertyValue();
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/DDUI.php b/src/Azvyl/PMServerUI/ddui/DDUI.php
new file mode 100644
index 0000000..4ad9549
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/DDUI.php
@@ -0,0 +1,9 @@
+ */
+ private array $updateCounts = [];
+
+ /** @var array */
+ private array $activeFormIds = [];
+
+ /**
+ * Per-player promises for open DDUI forms.
+ * Structure: [playerUuid => [formId => Promise]]
+ *
+ * @var array>>
+ */
+ private array $formPromises = [];
+
+ /**
+ * Bindings of observables for shown forms.
+ * Structure: [playerUuid => [formId => [path => Observable]]]
+ *
+ * @var array>>
+ */
+ private array $bindings = [];
+
+ /**
+ * Reverse index of bindings by observable object id.
+ * Structure: [spl_object_id => [['player' => playerUuid, 'form' => formId, 'path' => path], ...]]
+ *
+ * @var array>>
+ */
+ private array $bindingsByObservable = [];
+
+ /** Click handlers for buttons: [playerUuid => [formId => [path => callable]]] */
+ private array $clickHandlers = [];
+
+ /** Path update counters per player and path to produce pathUpdateCount */
+ private array $pathUpdateCounts = [];
+
+ /**
+ * Per-element interaction state used to validate inbound updates.
+ * Structure: [playerUuid => [formId => [elementIndex => ['visible' => bool, 'disabled' => bool]]]]
+ *
+ * @var array>>
+ */
+ private array $elementInteractionState = [];
+
+ public function __construct(Plugin $plugin){
+ $plManager = Server::getInstance()->getPluginManager();
+
+ $plManager->registerEvent(PlayerQuitEvent::class, function(PlayerQuitEvent $event) : void{
+ $this->clearPlayerState($event->getPlayer()->getUniqueId()->getBytes(), "Player disconnected");
+ }, EventPriority::LOWEST, $plugin);
+
+ $plManager->registerEvent(DataPacketDecodeEvent::class, function(DataPacketDecodeEvent $event) : void{
+ if($event->getPacketId() === ServerboundDataStorePacket::NETWORK_ID || $event->getPacketId() === ServerboundDataDrivenScreenClosedPacket::NETWORK_ID){
+ $event->uncancel();
+ }
+ }, EventPriority::LOWEST, $plugin, true);
+
+ $plManager->registerEvent(DataPacketReceiveEvent::class, function(DataPacketReceiveEvent $event) : void{
+ $packet = $event->getPacket();
+ $player = $event->getOrigin()->getPlayer();
+ if($player === null){
+ return;
+ }
+
+ if($packet instanceof ServerboundDataStorePacket){
+ $this->handleServerboundDataStore($event, $player, $packet);
+ }elseif($packet instanceof ServerboundDataDrivenScreenClosedPacket){
+ $this->handleServerboundScreenClosed($event, $player, $packet);
+ }
+ }, EventPriority::LOW, $plugin);
+
+ // Test whether it works if more than 1 plugin uses the PMServerUI virion or another plugin implements its own DDUI packet sending
+ // TODO: sync form id with DataPacketSendEvent?
+ }
+
+ public function hasActiveForm(string $playerUuid) : bool{
+ return isset($this->activeFormIds[$playerUuid]);
+ }
+
+ /**
+ * @param DataStore[] $entries
+ */
+ public function sendDataStoreEntries(Player $player, array $entries) : void{
+ $player->getNetworkSession()->sendDataPacket(ClientboundDataStorePacket::create($entries));
+ }
+
+ /**
+ * Register a promise for a shown form so DDUIManager can resolve/reject it.
+ *
+ * @param string $playerUuid raw uuid bytes
+ * @param int $formId
+ * @param Promise $promise
+ */
+ public function registerFormPromise(string $playerUuid, int $formId, Promise $promise) : void{
+ if(isset($this->activeFormIds[$playerUuid]) && $this->activeFormIds[$playerUuid] !== $formId){
+ throw new \RuntimeException("A DDUI form is already active for this player");
+ }
+ $this->formPromises[$playerUuid][$formId] = $promise;
+ $this->activeFormIds[$playerUuid] = $formId;
+ }
+
+ /**
+ * Register an observable binding for a shown form path.
+ * The path is the DataStore path, e.g. "title" or "layout[3].visible".
+ */
+ public function registerObservableBinding(string $playerUuid, int $formId, string $path, Observable $observable) : void{
+ $this->bindings[$playerUuid][$formId][$path] = $observable;
+ $id = spl_object_id($observable);
+ $this->bindingsByObservable[$id][] = ['player' => $playerUuid, 'form' => $formId, 'path' => $path];
+ }
+
+ /**
+ * Unregister bindings for a form (called when form closes).
+ */
+ public function unregisterBindingsForForm(string $playerUuid, int $formId) : void{
+ if(!isset($this->bindings[$playerUuid][$formId])) return;
+ foreach($this->bindings[$playerUuid][$formId] as $path => $obs){
+ $id = spl_object_id($obs);
+ if(isset($this->bindingsByObservable[$id])){
+ $this->bindingsByObservable[$id] = array_filter($this->bindingsByObservable[$id], function($e) use ($playerUuid, $formId, $path){
+ return !($e['player'] === $playerUuid && $e['form'] === $formId && $e['path'] === $path);
+ });
+ if($this->bindingsByObservable[$id] === []){
+ unset($this->bindingsByObservable[$id]);
+ }
+ }
+ }
+ unset($this->bindings[$playerUuid][$formId]);
+ }
+
+ /**
+ * Register a click handler for a specific button path in a shown form.
+ */
+ public function registerClickHandler(string $playerUuid, int $formId, string $path, callable $handler) : void{
+ $this->clickHandlers[$playerUuid][$formId][$path] = $handler;
+ }
+
+ public function registerElementInteractionState(string $playerUuid, int $formId, int $elementIndex, bool $visible, bool $disabled) : void{
+ $this->elementInteractionState[$playerUuid][$formId][$elementIndex] = [
+ 'visible' => $visible,
+ 'disabled' => $disabled,
+ ];
+ }
+
+ private function getDataStoreValue(DataStoreValue $val) : mixed{
+ if(method_exists($val, 'getValue')){
+ return $val->getValue();
+ }
+ return null;
+ }
+
+ /**
+ * Called by Observable when server code sets an observable value. Sends DataStore updates to all bound clients.
+ */
+ public function notifyObservableChanged(Observable $observable) : void{
+ $id = spl_object_id($observable);
+ if(!isset($this->bindingsByObservable[$id])) return;
+
+ foreach($this->bindingsByObservable[$id] as $entry){
+ $playerUuid = $entry['player'];
+ $formId = $entry['form'];
+ $path = $entry['path'];
+
+ try{
+ $this->sendObservableUpdateToBinding($playerUuid, $formId, $path, $observable->getData());
+ }catch(\Throwable $t){
+ PMServerUI::getLogger()->logException($t);
+ }
+ }
+ }
+
+ private function handleServerboundDataStore(DataPacketReceiveEvent $event, Player $player, ServerboundDataStorePacket $packet) : void{
+ try{
+ $update = $packet->getUpdate();
+ if($update->getName() !== "minecraft" || $update->getProperty() !== "custom_form_data"){
+ return;
+ }
+
+ $path = $update->getPath();
+ $playerUuid = $player->getUniqueId()->getBytes();
+ $formId = $this->activeFormIds[$playerUuid] ?? null;
+ if($formId === null){
+ return;
+ }
+
+ if($this->isInteractionBlockedByState($playerUuid, $formId, $path)){
+ $event->cancel();
+ return;
+ }
+
+ if($path === "closeButton.onClick"){
+ $event->cancel();
+ $player->getNetworkSession()->sendDataPacket(ClientboundDataDrivenUICloseScreenPacket::create($formId));
+ return;
+ }
+
+ if(isset($this->clickHandlers[$playerUuid][$formId][$path])){
+ $event->cancel();
+ ($this->clickHandlers[$playerUuid][$formId][$path])($update->getData());
+ return;
+ }
+
+ if(isset($this->bindings[$playerUuid][$formId][$path])){
+ $event->cancel();
+ $observable = $this->bindings[$playerUuid][$formId][$path];
+ if(!$observable->isClientWritable()){
+ return;
+ }
+ $newValue = $this->getDataStoreValue($update->getData());
+ $this->trackElementInteractionPathUpdate($playerUuid, $formId, $path, $newValue);
+ $observable->applyClientUpdate($newValue);
+ $this->notifyObservableChangedFromClient($observable, $playerUuid, $formId, $path);
+ }
+ }catch(\Throwable $t){
+ PMServerUI::getLogger()->logException($t);
+ }
+ }
+
+ private function notifyObservableChangedFromClient(Observable $observable, string $sourcePlayerUuid, int $sourceFormId, string $sourcePath) : void{
+ $id = spl_object_id($observable);
+ if(!isset($this->bindingsByObservable[$id])){
+ return;
+ }
+
+ $value = $observable->getData();
+ foreach($this->bindingsByObservable[$id] as $entry){
+ $playerUuid = $entry['player'];
+ $formId = $entry['form'];
+ $path = $entry['path'];
+
+ if($playerUuid === $sourcePlayerUuid && $formId === $sourceFormId && $path === $sourcePath){
+ continue;
+ }
+
+ try{
+ $this->sendObservableUpdateToBinding($playerUuid, $formId, $path, $value);
+ }catch(\Throwable $t){
+ PMServerUI::getLogger()->logException($t);
+ }
+ }
+ }
+
+ private function sendObservableUpdateToBinding(string $playerUuid, int $formId, string $path, bool|float|int|string|UIRawMessage $value) : void{
+ if(($this->activeFormIds[$playerUuid] ?? null) !== $formId){
+ return;
+ }
+
+ $player = Server::getInstance()->getPlayerByRawUUID($playerUuid);
+ if($player === null){
+ return;
+ }
+
+ $data = $this->toDataStoreValue($value);
+ if($data === null){
+ PMServerUI::getLogger()->warning("Unimplemented data type for DataStoreValue conversion: " . get_debug_type($value));
+ return;
+ }
+
+ $updateCount = $this->updateCounts[$playerUuid];// use the same value as previously sent to open the form
+ $pathUpdate = ($this->pathUpdateCounts[$playerUuid][$path] ?? 0) + 1;
+ $this->pathUpdateCounts[$playerUuid][$path] = $pathUpdate;
+ $this->trackElementInteractionPathUpdate($playerUuid, $formId, $path, $value);
+
+ $this->sendDataStoreEntries($player, [new DataStoreUpdate(
+ "minecraft",
+ "custom_form_data",
+ $path,
+ $data,
+ $updateCount,
+ $pathUpdate
+ )]);
+ }
+
+ private function toDataStoreValue(bool|float|int|string|UIRawMessage $value) : ?DataStoreValue{
+ if(is_bool($value)){
+ return new BoolDataStoreValue($value);
+ }
+ if(is_int($value) || is_float($value)){
+ return new DoubleDataStoreValue((float) $value);
+ }
+ if(is_string($value)){
+ return new StringDataStoreValue($value);
+ }
+ if($value instanceof UIRawMessage){
+ return new StringDataStoreValue(json_encode($value->encode(), JSON_UNESCAPED_SLASHES));
+ }
+
+ return null;
+ }
+
+ private function handleServerboundScreenClosed(DataPacketReceiveEvent $event, Player $player, ServerboundDataDrivenScreenClosedPacket $packet) : void{
+ $event->cancel();
+ $playerUuid = $player->getUniqueId()->getBytes();
+ $updateCount = $this->nextUpdateCountFor($playerUuid);
+
+ try{
+ $this->sendDataStoreEntries($player, [
+ new ClientboundDataStoreChange("minecraft", "ddui_form_active", $updateCount, new BoolDataStorePropertyValue(false)),
+ new ClientboundDataStoreChange("minecraft", "custom_form_data", $updateCount, new NoneDataStorePropertyValue()),
+ ]);
+
+ $formId = $packet->getFormId();
+ if(isset($this->formPromises[$playerUuid][$formId])){
+ $this->formPromises[$playerUuid][$formId]->resolve(true);
+ unset($this->formPromises[$playerUuid][$formId]);
+ if($this->formPromises[$playerUuid] === []){
+ unset($this->formPromises[$playerUuid]);
+ }
+ }
+
+ $this->unregisterBindingsForForm($playerUuid, $formId);
+ unset($this->clickHandlers[$playerUuid][$formId]);
+
+ if(($this->activeFormIds[$playerUuid] ?? null) === $formId){
+ unset($this->activeFormIds[$playerUuid]);
+ }
+ unset($this->elementInteractionState[$playerUuid][$formId]);
+ if(($this->elementInteractionState[$playerUuid] ?? []) === []){
+ unset($this->elementInteractionState[$playerUuid]);
+ }
+ unset($this->pathUpdateCounts[$playerUuid]);
+ }catch(\Throwable $t){
+ PMServerUI::getLogger()->logException($t);
+ }
+ }
+
+ private function clearPlayerState(string $playerUuid, string $reason) : void{
+ unset($this->updateCounts[$playerUuid], $this->activeFormIds[$playerUuid], $this->pathUpdateCounts[$playerUuid], $this->clickHandlers[$playerUuid], $this->bindings[$playerUuid], $this->elementInteractionState[$playerUuid]);
+ if(!isset($this->formPromises[$playerUuid])){
+ return;
+ }
+ foreach($this->formPromises[$playerUuid] as $promise){
+ try{
+ $promise->reject(new \RuntimeException($reason));
+ }catch(\Throwable $t){
+ PMServerUI::getLogger()->logException($t);
+ }
+ }
+ unset($this->formPromises[$playerUuid]);
+ }
+
+ /** Generate and return a new unique form id. */
+ public function nextFormId() : int{
+ return $this->formIdCounter++;
+ }
+
+ /** Increment and return the next update count for a given player UUID (raw bytes string). */
+ public function nextUpdateCountFor(string $playerUuid) : int{
+ $this->updateCounts[$playerUuid] = ($this->updateCounts[$playerUuid] ?? 0) + 1;
+ return $this->updateCounts[$playerUuid];
+ }
+
+ private function isInteractionBlockedByState(string $playerUuid, int $formId, string $path) : bool{
+ if(!preg_match('/^layout\[(\d+)]\.(.+)$/', $path, $matches)){
+ return false;
+ }
+
+ $property = $matches[2];
+ if($property === 'visible' || $property === 'disabled'){
+ return false;
+ }
+
+ $elementIndex = (int) $matches[1];
+ $state = $this->elementInteractionState[$playerUuid][$formId][$elementIndex] ?? null;
+ if($state === null){
+ return false;
+ }
+
+ return $state['disabled'] || !$state['visible'];
+ }
+
+ private function trackElementInteractionPathUpdate(string $playerUuid, int $formId, string $path, mixed $value) : void{
+ if(!preg_match('/^layout\[(\d+)]\.(visible|disabled)$/', $path, $matches)){
+ return;
+ }
+
+ $elementIndex = (int) $matches[1];
+ $property = $matches[2];
+ $state = $this->elementInteractionState[$playerUuid][$formId][$elementIndex] ?? ['visible' => true, 'disabled' => false];
+ $state[$property] = (bool) $value;
+ $this->elementInteractionState[$playerUuid][$formId][$elementIndex] = $state;
+ }
+}
diff --git a/src/Azvyl/PMServerUI/ddui/DataDrivenScreenClosedReason.php b/src/Azvyl/PMServerUI/ddui/DataDrivenScreenClosedReason.php
new file mode 100644
index 0000000..a0f0759
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/DataDrivenScreenClosedReason.php
@@ -0,0 +1,15 @@
+|Observable|string|UIRawMessage $text
+ */
+ public function body(Observable|string|UIRawMessage $text) : self{
+ // TODO: to be implemented
+ return $this;
+ }
+
+ /**
+ * Sets the data for the top button in the form.
+ *
+ * @param Observable|Observable|string|UIRawMessage $label
+ * @param Observable|Observable|string|UIRawMessage|null $tooltip
+ */
+ public function button1(Observable|string|UIRawMessage $label, Observable|string|UIRawMessage $tooltip = null) : self{
+ // TODO: to be implemented
+ return $this;
+ }
+
+ /**
+ * Sets the data for the bottom button in the form.
+ *
+ * @param Observable|Observable|string|UIRawMessage $label
+ * @param Observable|Observable|string|UIRawMessage|null $tooltip
+ */
+ public function button2(Observable|string|UIRawMessage $label, Observable|string|UIRawMessage $tooltip = null) : self{
+ // TODO: to be implemented
+ return $this;
+ }
+
+ /** Closes the form. Will throw an error if the form is not open. */
+ public function close() : void{
+ // TODO: to be implemented
+ }
+
+ /** Returns true if the message box is currently being shown to the player. */
+ public function isShowing() : bool{
+ // TODO: to be implemented
+ return false;
+ }
+
+ /**
+ * Show this message box to the player. Will return a result even if the client was busy (i.e. in another menu).
+ * Will throw if the user disconnects.
+ *
+ * @return Promise
+ */
+ public function show() : Promise{
+ /** @var Promise $promise */
+ $promise = new Promise();
+ //TODO: to be implemented
+ return $promise;
+ }
+}
diff --git a/src/Azvyl/PMServerUI/ddui/MessageBoxResult.php b/src/Azvyl/PMServerUI/ddui/MessageBoxResult.php
new file mode 100644
index 0000000..547da94
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/MessageBoxResult.php
@@ -0,0 +1,13 @@
+ */
+ private array $listeners = [];
+
+ private int $nextListenerId = 1;
+
+ /**
+ * @param T $data
+ * @param bool $clientWritable
+ */
+ private function __construct(private bool|float|int|string|UIRawMessage $data, private readonly bool $clientWritable = false){}
+
+ /**
+ * Create an observable.
+ *
+ * @param T $data
+ * @param bool $clientWritable
+ *
+ * @return Observable
+ */
+ public static function create(bool|float|int|string|UIRawMessage $data, bool $clientWritable = false) : self{
+ return new self($data, $clientWritable);
+ }
+
+ /**
+ * Get the data.
+ *
+ * @return T
+ */
+ public function getData() : bool|float|int|string|UIRawMessage{
+ return $this->data;
+ }
+
+ // TODO: getFilteredText()
+
+ /**
+ * Set the data and notify subscribers.
+ *
+ * @param T $data
+ */
+ public function setData(bool|float|int|string|UIRawMessage $data) : void{
+ if($data === $this->data){
+ return;
+ }
+ $this->data = $data;
+ foreach($this->listeners as $listener){
+ try{
+ $listener($data);
+ }catch(\Throwable $t){
+ PMServerUI::getLogger()->logException($t);
+ }
+ }
+
+ try{
+ PMServerUI::getDDUIManager()->notifyObservableChanged($this);
+ }catch(\Throwable $e){
+ PMServerUI::getLogger()->logException($e);
+ }
+ }
+
+ /**
+ * @internal
+ * Apply a value received from the client. This updates the observable and notifies listeners
+ * but DOES NOT echo the value back to the client (to avoid feedback loops).
+ *
+ * @param T $data
+ */
+ public function applyClientUpdate(bool|float|int|string|UIRawMessage $data) : void{
+ $this->data = $data;
+ foreach($this->listeners as $listener){
+ $listener($data);
+ }
+ }
+
+ /**
+ * Subscribe to changes. Returns a Subscription which can be used to unsubscribe.
+ *
+ * @param callable(T): void $listener
+ *
+ * @return Subscription
+ */
+ public function subscribe(callable $listener) : Subscription{
+ $id = $this->nextListenerId++;
+ $this->listeners[$id] = $listener;
+ return new Subscription(function() use ($id) : void{
+ unset($this->listeners[$id]);
+ });
+ }
+
+ public function isClientWritable() : bool{
+ return $this->clientWritable;
+ }
+}
diff --git a/src/Azvyl/PMServerUI/ddui/PlayerLeftError.php b/src/Azvyl/PMServerUI/ddui/PlayerLeftError.php
new file mode 100644
index 0000000..f97e83f
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/PlayerLeftError.php
@@ -0,0 +1,10 @@
+onUnsubscribe = $onUnsubscribe;
+ }
+
+ public function unsubscribe() : void{
+ if(!$this->active){
+ return;
+ }
+ $this->active = false;
+ if(is_callable($this->onUnsubscribe)){
+ ($this->onUnsubscribe)();
+ }
+ }
+
+ public function isActive() : bool{
+ return $this->active;
+ }
+}
diff --git a/src/Azvyl/PMServerUI/ddui/TextFilteringError.php b/src/Azvyl/PMServerUI/ddui/TextFilteringError.php
new file mode 100644
index 0000000..9ec967a
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/TextFilteringError.php
@@ -0,0 +1,24 @@
+onClick = $onClick(...);
+ }
+
+ public function buildEntries(CustomFormRenderContext $context, int $index) : array{
+ $label = $context->resolveText($this->label, $context->path($index, 'label'));
+ $tooltip = $context->resolveText($this->tooltip, $context->path($index, 'tooltip'));
+ $visible = $context->resolveBool($this->visible, $context->path($index, 'visible'), true);
+ $disabled = $context->resolveBool($this->disabled, $context->path($index, 'disabled'), false);
+
+ $context->registerClickHandler($context->path($index, 'onClick'), function() : void{
+ try{
+ ($this->onClick)();
+ }catch(\Throwable $t){
+ PMServerUI::getLogger()->logException($t);
+ }
+ });
+
+ return [
+ new DataStoreMapEntry('button_visible', new BoolDataStorePropertyValue(true)),
+ new DataStoreMapEntry('disabled', new BoolDataStorePropertyValue($disabled)),
+ new DataStoreMapEntry('label', $context->toTextPropertyValue($label)),
+ new DataStoreMapEntry('onClick', new Int64DataStorePropertyValue(0)),
+ new DataStoreMapEntry('tooltip', $context->toTextPropertyValue($tooltip)),
+ new DataStoreMapEntry('visible', new BoolDataStorePropertyValue($visible)),
+ ];
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/elements/CustomFormElement.php b/src/Azvyl/PMServerUI/ddui/elements/CustomFormElement.php
new file mode 100644
index 0000000..19d6824
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/elements/CustomFormElement.php
@@ -0,0 +1,17 @@
+
+ */
+ public function buildEntries(CustomFormRenderContext $context, int $index) : array;
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/elements/DividerElement.php b/src/Azvyl/PMServerUI/ddui/elements/DividerElement.php
new file mode 100644
index 0000000..1c5ca96
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/elements/DividerElement.php
@@ -0,0 +1,23 @@
+resolveBool($this->visible, $context->path($index, 'visible'), true);
+ return [
+ new DataStoreMapEntry('divider_visible', new BoolDataStorePropertyValue(true)),
+ new DataStoreMapEntry('visible', new BoolDataStorePropertyValue($visible)),
+ ];
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/elements/DropdownElement.php b/src/Azvyl/PMServerUI/ddui/elements/DropdownElement.php
new file mode 100644
index 0000000..85ac6e8
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/elements/DropdownElement.php
@@ -0,0 +1,58 @@
+ $items
+ */
+ public function __construct(
+ private Observable|string|UIRawMessage $label,
+ private Observable $value,
+ private array $items,
+ private Observable|string|UIRawMessage|null $description = null,
+ private bool|Observable|null $disabled = null,
+ private bool|Observable|null $visible = null,
+ ){}
+
+ public function buildEntries(CustomFormRenderContext $context, int $index) : array{
+ $description = $context->resolveText($this->description, $context->path($index, 'description'));
+ $label = $context->resolveText($this->label, $context->path($index, 'label'));
+ $visible = $context->resolveBool($this->visible, $context->path($index, 'visible'), true);
+ $disabled = $context->resolveBool($this->disabled, $context->path($index, 'disabled'), false);
+ $value = $context->resolveInt($this->value, $context->path($index, 'value'));
+
+ $itemEntries = [];
+ foreach($this->items as $itemIndex => $item){
+ $itemMap = [
+ new DataStoreMapEntry('label', $context->toTextPropertyValue($item->label)),
+ new DataStoreMapEntry('value', new Int64DataStorePropertyValue((int)$item->value)),
+ ];
+ $itemEntries[] = new DataStoreMapEntry((string)$itemIndex, new MapDataStorePropertyValue($itemMap));
+ }
+ $itemEntries[] = new DataStoreMapEntry('length', new Int64DataStorePropertyValue(count($this->items)));
+
+ return [
+ new DataStoreMapEntry('description', $context->toTextPropertyValue($description)),
+ new DataStoreMapEntry('disabled', new BoolDataStorePropertyValue($disabled)),
+ new DataStoreMapEntry('dropdown_visible', new BoolDataStorePropertyValue(true)),
+ new DataStoreMapEntry('visible', new BoolDataStorePropertyValue($visible)),
+ new DataStoreMapEntry('label', $context->toTextPropertyValue($label)),
+ new DataStoreMapEntry('items', new MapDataStorePropertyValue($itemEntries)),
+ new DataStoreMapEntry('value', new Int64DataStorePropertyValue($value)),
+ ];
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/elements/HeaderElement.php b/src/Azvyl/PMServerUI/ddui/elements/HeaderElement.php
new file mode 100644
index 0000000..5e54d22
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/elements/HeaderElement.php
@@ -0,0 +1,30 @@
+resolveText($this->text, $context->path($index, 'text'));
+ $visible = $context->resolveBool($this->visible, $context->path($index, 'visible'), true);
+
+ return [
+ new DataStoreMapEntry('header_visible', new BoolDataStorePropertyValue(true)),
+ new DataStoreMapEntry('visible', new BoolDataStorePropertyValue($visible)),
+ new DataStoreMapEntry('text', $context->toTextPropertyValue($text)),
+ ];
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/elements/LabelElement.php b/src/Azvyl/PMServerUI/ddui/elements/LabelElement.php
new file mode 100644
index 0000000..a823ce3
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/elements/LabelElement.php
@@ -0,0 +1,30 @@
+resolveText($this->text, $context->path($index, 'text'));
+ $visible = $context->resolveBool($this->visible, $context->path($index, 'visible'), true);
+
+ return [
+ new DataStoreMapEntry('label_visible', new BoolDataStorePropertyValue(true)),
+ new DataStoreMapEntry('text', $context->toTextPropertyValue($text)),
+ new DataStoreMapEntry('visible', new BoolDataStorePropertyValue($visible)),
+ ];
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/elements/SliderElement.php b/src/Azvyl/PMServerUI/ddui/elements/SliderElement.php
new file mode 100644
index 0000000..071cdf0
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/elements/SliderElement.php
@@ -0,0 +1,49 @@
+resolveText($this->description, $context->path($index, 'description'));
+ $label = $context->resolveText($this->label, $context->path($index, 'label'));
+ $visible = $context->resolveBool($this->visible, $context->path($index, 'visible'), true);
+ $disabled = $context->resolveBool($this->disabled, $context->path($index, 'disabled'), false);
+ $min = $context->resolveInt($this->min, $context->path($index, 'minValue'), 0);
+ $max = $context->resolveInt($this->max, $context->path($index, 'maxValue'), 100);
+ $step = $context->resolveInt($this->step, $context->path($index, 'step'), 1);
+ $value = $context->resolveInt($this->value, $context->path($index, 'value'));
+
+ return [
+ new DataStoreMapEntry('description', $context->toTextPropertyValue($description)),
+ new DataStoreMapEntry('slider_visible', new BoolDataStorePropertyValue(true)),
+ new DataStoreMapEntry('disabled', new BoolDataStorePropertyValue($disabled)),
+ new DataStoreMapEntry('label', $context->toTextPropertyValue($label)),
+ new DataStoreMapEntry('maxValue', new Int64DataStorePropertyValue($max)),
+ new DataStoreMapEntry('minValue', new Int64DataStorePropertyValue($min)),
+ new DataStoreMapEntry('step', new Int64DataStorePropertyValue($step)),
+ new DataStoreMapEntry('value', new Int64DataStorePropertyValue($value)),
+ new DataStoreMapEntry('visible', new BoolDataStorePropertyValue($visible)),
+ ];
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/elements/SpacerElement.php b/src/Azvyl/PMServerUI/ddui/elements/SpacerElement.php
new file mode 100644
index 0000000..17b442a
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/elements/SpacerElement.php
@@ -0,0 +1,23 @@
+resolveBool($this->visible, $context->path($index, 'visible'), true);
+ return [
+ new DataStoreMapEntry('spacer_visible', new BoolDataStorePropertyValue(true)),
+ new DataStoreMapEntry('visible', new BoolDataStorePropertyValue($visible)),
+ ];
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/elements/TextFieldElement.php b/src/Azvyl/PMServerUI/ddui/elements/TextFieldElement.php
new file mode 100644
index 0000000..365e861
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/elements/TextFieldElement.php
@@ -0,0 +1,39 @@
+resolveText($this->description, $context->path($index, 'description'));
+ $label = $context->resolveText($this->label, $context->path($index, 'label'));
+ $text = $context->resolveText($this->text, $context->path($index, 'text'));
+ $visible = $context->resolveBool($this->visible, $context->path($index, 'visible'), true);
+ $disabled = $context->resolveBool($this->disabled, $context->path($index, 'disabled'), false);
+
+ return [
+ new DataStoreMapEntry('description', $context->toTextPropertyValue($description)),
+ new DataStoreMapEntry('disabled', new BoolDataStorePropertyValue($disabled)),
+ new DataStoreMapEntry('visible', new BoolDataStorePropertyValue($visible)),
+ new DataStoreMapEntry('label', $context->toTextPropertyValue($label)),
+ new DataStoreMapEntry('text', $context->toTextPropertyValue($text)),
+ new DataStoreMapEntry('textfield_visible', new BoolDataStorePropertyValue(true)),
+ ];
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/elements/ToggleElement.php b/src/Azvyl/PMServerUI/ddui/elements/ToggleElement.php
new file mode 100644
index 0000000..03a34c8
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/elements/ToggleElement.php
@@ -0,0 +1,39 @@
+resolveText($this->description, $context->path($index, 'description'));
+ $label = $context->resolveText($this->label, $context->path($index, 'label'));
+ $toggled = $context->resolveBool($this->toggled, $context->path($index, 'toggled'), false);
+ $visible = $context->resolveBool($this->visible, $context->path($index, 'visible'), true);
+ $disabled = $context->resolveBool($this->disabled, $context->path($index, 'disabled'), false);
+
+ return [
+ new DataStoreMapEntry('description', $context->toTextPropertyValue($description)),
+ new DataStoreMapEntry('visible', new BoolDataStorePropertyValue($visible)),
+ new DataStoreMapEntry('disabled', new BoolDataStorePropertyValue($disabled)),
+ new DataStoreMapEntry('label', $context->toTextPropertyValue($label)),
+ new DataStoreMapEntry('toggle_visible', new BoolDataStorePropertyValue(true)),
+ new DataStoreMapEntry('toggled', new BoolDataStorePropertyValue($toggled)),
+ ];
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/packets/ClientboundDataStorePacket.php b/src/Azvyl/PMServerUI/ddui/packets/ClientboundDataStorePacket.php
new file mode 100644
index 0000000..2a8f43f
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/packets/ClientboundDataStorePacket.php
@@ -0,0 +1,39 @@
+ $values
+ */
+ public static function create(array $values) : self{
+ $result = new self;
+ $result->values = $values;
+ return $result;
+ }
+
+ protected function decodePayload(ByteBufferReader $in) : void{
+ $this->values = [];
+ for($i = 0, $len = VarInt::readUnsignedInt($in); $i < $len; ++$i){
+ $this->values[] = match(VarInt::readUnsignedInt($in)){
+ DataStoreType::UPDATE => DataStoreUpdate::read($in),
+ DataStoreType::CHANGE => DataStoreChange::read($in),
+ DataStoreType::REMOVAL => DataStoreRemoval::read($in),
+ default => throw new PacketDecodeException("Unknown DataStore type"),
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Azvyl/PMServerUI/ddui/packets/types/BoolDataStorePropertyValue.php b/src/Azvyl/PMServerUI/ddui/packets/types/BoolDataStorePropertyValue.php
new file mode 100644
index 0000000..fc31124
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/packets/types/BoolDataStorePropertyValue.php
@@ -0,0 +1,30 @@
+value; }
+
+ public function getTypeId() : int{ return self::ID; }
+
+ protected function writePayload(ByteBufferWriter $out) : void{
+ CommonTypes::putBool($out, $this->value);
+ }
+
+ public static function read(ByteBufferReader $in) : self{
+ return new self(CommonTypes::getBool($in));
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/packets/types/DataStoreChange.php b/src/Azvyl/PMServerUI/ddui/packets/types/DataStoreChange.php
new file mode 100644
index 0000000..a52cf07
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/packets/types/DataStoreChange.php
@@ -0,0 +1,57 @@
+name; }
+
+ public function getProperty() : string{ return $this->property; }
+
+ public function getUpdateCount() : int{ return $this->updateCount; }
+
+ public function getNewValue() : DataStorePropertyValue{ return $this->newValue; }
+
+ public static function read(ByteBufferReader $in) : self{
+ $name = CommonTypes::getString($in);
+ $property = CommonTypes::getString($in);
+ $updateCount = LE::readUnsignedInt($in);
+
+ $data = DataStorePropertyValue::read($in);
+
+ return new self(
+ $name,
+ $property,
+ $updateCount,
+ $data,
+ );
+ }
+
+ public function write(ByteBufferWriter $out) : void{
+ CommonTypes::putString($out, $this->name);
+ CommonTypes::putString($out, $this->property);
+ LE::writeUnsignedInt($out, $this->updateCount);
+ $this->newValue->writeWithType($out);
+ }
+}
\ No newline at end of file
diff --git a/src/Azvyl/PMServerUI/ddui/packets/types/DataStoreMapEntry.php b/src/Azvyl/PMServerUI/ddui/packets/types/DataStoreMapEntry.php
new file mode 100644
index 0000000..f3b177d
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/packets/types/DataStoreMapEntry.php
@@ -0,0 +1,32 @@
+key; }
+
+ public function getValue() : DataStorePropertyValue{ return $this->value; }
+
+ public static function read(ByteBufferReader $in) : self{
+ $key = CommonTypes::getString($in);
+ $value = DataStorePropertyValue::read($in);
+ return new self($key, $value);
+ }
+
+ public function write(ByteBufferWriter $out) : void{
+ CommonTypes::putString($out, $this->key);
+ $this->value->writeWithType($out);
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/packets/types/DataStorePropertyType.php b/src/Azvyl/PMServerUI/ddui/packets/types/DataStorePropertyType.php
new file mode 100644
index 0000000..0e00036
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/packets/types/DataStorePropertyType.php
@@ -0,0 +1,15 @@
+getTypeId());
+ $this->writePayload($out);
+ }
+
+ public static function read(ByteBufferReader $in) : self{
+ $type = LE::readSignedInt($in);
+ return match($type){
+ DataStorePropertyType::NONE => NoneDataStorePropertyValue::read($in),
+ DataStorePropertyType::BOOL => BoolDataStorePropertyValue::read($in),
+ DataStorePropertyType::INT64 => Int64DataStorePropertyValue::read($in),
+ DataStorePropertyType::STRING => StringDataStorePropertyValue::read($in),
+ DataStorePropertyType::LIST => ListDataStorePropertyValue::read($in),
+ DataStorePropertyType::MAP => MapDataStorePropertyValue::read($in),
+ default => throw new PacketDecodeException("Unknown DataStorePropertyType"),
+ };
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/packets/types/Int64DataStorePropertyValue.php b/src/Azvyl/PMServerUI/ddui/packets/types/Int64DataStorePropertyValue.php
new file mode 100644
index 0000000..fbe8b30
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/packets/types/Int64DataStorePropertyValue.php
@@ -0,0 +1,30 @@
+value; }
+
+ public function getTypeId() : int{ return self::ID; }
+
+ protected function writePayload(ByteBufferWriter $out) : void{
+ LE::writeSignedLong($out, $this->value);
+ }
+
+ public static function read(ByteBufferReader $in) : self{
+ return new self(LE::readSignedLong($in));
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/packets/types/ListDataStorePropertyValue.php b/src/Azvyl/PMServerUI/ddui/packets/types/ListDataStorePropertyValue.php
new file mode 100644
index 0000000..8b120bd
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/packets/types/ListDataStorePropertyValue.php
@@ -0,0 +1,46 @@
+ $entries
+ */
+ public function __construct(
+ private readonly array $entries,
+ ){}
+
+ /**
+ * @return DataStorePropertyValue[]
+ * @phpstan-return list
+ */
+ public function getEntries() : array{ return $this->entries; }
+
+ public function getTypeId() : int{ return self::ID; }
+
+ protected function writePayload(ByteBufferWriter $out) : void{
+ VarInt::writeUnsignedInt($out, count($this->entries));
+ foreach($this->entries as $entry){
+ $entry->writeWithType($out);
+ }
+ }
+
+ public static function read(ByteBufferReader $in) : self{
+ $entries = [];
+ for($i = 0, $len = VarInt::readUnsignedInt($in); $i < $len; ++$i){
+ $entries[] = DataStorePropertyValue::read($in);
+ }
+ return new self($entries);
+ }
+}
+
+
diff --git a/src/Azvyl/PMServerUI/ddui/packets/types/MapDataStorePropertyValue.php b/src/Azvyl/PMServerUI/ddui/packets/types/MapDataStorePropertyValue.php
new file mode 100644
index 0000000..cd1abde
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/packets/types/MapDataStorePropertyValue.php
@@ -0,0 +1,46 @@
+ $entries
+ */
+ public function __construct(
+ private readonly array $entries,
+ ){}
+
+ /**
+ * @return DataStoreMapEntry[]
+ * @phpstan-return list
+ */
+ public function getEntries() : array{ return $this->entries; }
+
+ public function getTypeId() : int{ return self::ID; }
+
+ protected function writePayload(ByteBufferWriter $out) : void{
+ VarInt::writeUnsignedInt($out, count($this->entries));
+ foreach($this->entries as $entry){
+ $entry->write($out);
+ }
+ }
+
+ public static function read(ByteBufferReader $in) : self{
+ $entries = [];
+ for($i = 0, $len = VarInt::readUnsignedInt($in); $i < $len; ++$i){
+ $entries[] = DataStoreMapEntry::read($in);
+ }
+ return new self($entries);
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ddui/packets/types/NoneDataStorePropertyValue.php b/src/Azvyl/PMServerUI/ddui/packets/types/NoneDataStorePropertyValue.php
new file mode 100644
index 0000000..0a9a4c5
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ddui/packets/types/NoneDataStorePropertyValue.php
@@ -0,0 +1,21 @@
+value; }
+
+ public function getTypeId() : int{ return self::ID; }
+
+ protected function writePayload(ByteBufferWriter $out) : void{
+ CommonTypes::putString($out, $this->value);
+ }
+
+ public static function read(ByteBufferReader $in) : self{
+ return new self(CommonTypes::getString($in));
+ }
+}
+
diff --git a/src/Azvyl/PMServerUI/ui/ActionFormData.php b/src/Azvyl/PMServerUI/ui/ActionFormData.php
new file mode 100644
index 0000000..ceb5ae3
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ui/ActionFormData.php
@@ -0,0 +1,83 @@
+title = $title instanceof UIRawMessage ? $title->encode() : $title;
+ return $this;
+ }
+
+ public function body(string|UIRawMessage $body) : self{
+ $this->body = $body instanceof UIRawMessage ? $body->encode() : $body;
+ return $this;
+ }
+
+ public function button(string|UIRawMessage $text, ?string $iconPath = null, ?string $iconUrl = null) : self{
+ $this->elements[] = [
+ 'type' => 'button',
+ 'text' => $text instanceof UIRawMessage ? $text->encode() : $text,
+ 'image' => $iconPath !== null || $iconUrl !== null ? [
+ 'data' => $iconPath ?? $iconUrl,
+ 'type' => $iconPath !== null ? 'path' : 'url',
+ ] : null,
+ ];
+ $this->buttonCount++;
+ return $this;
+ }
+
+ public function divider() : self{
+ $this->elements[] = ['type' => 'divider', 'text' => ''];
+ return $this;
+ }
+
+ public function header(string|UIRawMessage $text) : self{
+ $this->elements[] = ['type' => 'header', 'text' => $text instanceof UIRawMessage ? $text->encode() : $text];
+ return $this;
+ }
+
+ public function label(string|UIRawMessage $text) : self{
+ $this->elements[] = ['type' => 'label', 'text' => $text instanceof UIRawMessage ? $text->encode() : $text];
+ return $this;
+ }
+
+ /** @internal */
+ public function toPacketFormData() : array{
+ return [
+ 'type' => 'form',
+ 'title' => $this->title ?? '',
+ 'content' => $this->body ?? '',
+ 'elements' => $this->elements,
+ ];
+ }
+
+ /** @internal */
+ public function processResponse(string $rawData = null, FormCancelationReason $cancelReason = null) : ActionFormResponse{
+ if($cancelReason !== null){
+ return new ActionFormResponse($cancelReason, null);
+ }
+ if($rawData !== null){
+ if(!is_numeric($rawData)){
+ throw new FormValidationException("Expected int, got $rawData");
+ }
+ $data = (int)$rawData;
+ if($data < 0 || $data >= $this->buttonCount){
+ throw new FormValidationException("Button $data does not exist");
+ }
+ return new ActionFormResponse(null, $data);
+ }
+ throw new \InvalidArgumentException("Expected rawData to be non-null");
+ }
+}
diff --git a/src/Azvyl/PMServerUI/ui/ActionFormResponse.php b/src/Azvyl/PMServerUI/ui/ActionFormResponse.php
new file mode 100644
index 0000000..86f5e74
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ui/ActionFormResponse.php
@@ -0,0 +1,15 @@
+title = $title instanceof UIRawMessage ? $title->encode() : $title;
+ return $this;
+ }
+
+ public function body(string|UIRawMessage $body) : self{
+ $this->body = $body instanceof UIRawMessage ? $body->encode() : $body;
+ return $this;
+ }
+
+ public function button1(string|UIRawMessage $text) : self{
+ $this->button1 = $text instanceof UIRawMessage ? $text->encode() : $text;
+ return $this;
+ }
+
+ public function button2(string|UIRawMessage $text) : self{
+ $this->button2 = $text instanceof UIRawMessage ? $text->encode() : $text;
+ return $this;
+ }
+
+ /** @internal */
+ public function toPacketFormData() : array{
+ return [
+ 'type' => 'modal',
+ 'title' => $this->title ?? '',
+ 'content' => $this->body ?? '',
+ 'button1' => $this->button1 ?? '',
+ 'button2' => $this->button2 ?? '',
+ ];
+ }
+
+ /** @internal */
+ public function processResponse(string $rawData = null, FormCancelationReason $cancelReason = null) : MessageFormResponse{
+ if($cancelReason !== null){
+ return new MessageFormResponse($cancelReason, null);
+ }
+ if($rawData !== null){
+ $trimmed = trim($rawData);
+ if($trimmed === "true" || $trimmed === "false"){
+ return new MessageFormResponse(null, $trimmed === "true" ? 0 : 1);
+ }
+ throw new FormValidationException("Expected bool, got $trimmed");
+ }
+ throw new \InvalidArgumentException("Expected rawData to be non-null");
+ }
+}
diff --git a/src/Azvyl/PMServerUI/ui/MessageFormResponse.php b/src/Azvyl/PMServerUI/ui/MessageFormResponse.php
new file mode 100644
index 0000000..3d739bd
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ui/MessageFormResponse.php
@@ -0,0 +1,15 @@
+> */
+ private array $controls = [];
+ /** @var array */
+ private array $validators = [];
+
+ public function title(string|UIRawMessage $title) : self{
+ $this->title = $title instanceof UIRawMessage ? $title->encode() : $title;
+ return $this;
+ }
+
+ public function divider() : self{
+ $this->controls[] = ['type' => 'divider', 'text' => ''];
+ $this->validators[] = null;
+ return $this;
+ }
+
+ /**
+ * @param string[]|UIRawMessage[] $items
+ */
+ public function dropdown(string|UIRawMessage $label, array $items, int $defaultIndex = null, string|UIRawMessage $tooltip = null) : self{
+ $dropdownElement = [
+ 'type' => 'dropdown',
+ 'text' => $label instanceof UIRawMessage ? $label->encode() : $label,
+ 'options' => array_map(fn(string|UIRawMessage $item) => $item instanceof UIRawMessage ? $item->encode() : $item, $items)
+ ];
+ if($defaultIndex !== null){
+ $dropdownElement['default'] = $defaultIndex;
+ }
+ if($tooltip !== null){
+ $dropdownElement['tooltip'] = $tooltip instanceof UIRawMessage ? $tooltip->encode() : $tooltip;
+ }
+
+ $this->controls[] = $dropdownElement;
+ $this->validators[] = static function($value) use ($items) : int{
+ if(!is_int($value)){
+ throw new FormValidationException("Expected integer index for dropdown response, got " . gettype($value));
+ }
+ $maxIndex = count($items) - 1;
+ if($value < 0 || $value > $maxIndex){
+ throw new FormValidationException("Dropdown response index out of range (0..$maxIndex), got $value");
+ }
+ return $value;
+ };
+
+ return $this;
+ }
+
+ public function header(string|UIRawMessage $text) : self{
+ $this->controls[] = ['type' => 'header', 'text' => $text instanceof UIRawMessage ? $text->encode() : $text];
+ $this->validators[] = null;
+ return $this;
+ }
+
+ public function label(string|UIRawMessage $text) : self{
+ $this->controls[] = ['type' => 'label', 'text' => $text instanceof UIRawMessage ? $text->encode() : $text];
+ $this->validators[] = null;
+ return $this;
+ }
+
+ public function slider(string|UIRawMessage $label, int $min, int $max, int $default = null, string|UIRawMessage $tooltip = null, int $step = null) : self{
+ $sliderElement = [
+ 'type' => 'slider',
+ 'text' => $label instanceof UIRawMessage ? $label->encode() : $label,
+ 'min' => (float)$min,
+ 'max' => (float)$max,
+ 'step' => (float)($step ?? 1.0),
+ 'timeout' => 100.0,//TODO: Find out what this does (1.26.0.29)
+ ];
+ if($default !== null){
+ $sliderElement['default'] = $default;
+ }
+ if($tooltip !== null){
+ $sliderElement['tooltip'] = $tooltip instanceof UIRawMessage ? $tooltip->encode() : $tooltip;
+ }
+ $this->controls[] = $sliderElement;
+ $this->validators[] = static function($value) use ($min, $max) : float{
+ if(!is_int($value) && !is_float($value)){
+ throw new FormValidationException("Expected numeric value for slider response, got " . gettype($value));
+ }
+ $numeric = (float)$value;
+ if($numeric < $min || $numeric > $max){
+ throw new FormValidationException("Slider response out of range ($min..$max), got $numeric");
+ }
+ return $numeric;
+ };
+
+ return $this;
+ }
+
+ public function submitButton(string|UIRawMessage $text) : self{
+ $this->submit = $text instanceof UIRawMessage ? $text->encode() : $text;
+ return $this;
+ }
+
+ public function textField(string|UIRawMessage $label, string|UIRawMessage $placeholderText = null, string|UIRawMessage $default = null, string|UIRawMessage $tooltip = null) : self{
+ $textFieldElement = [
+ 'type' => 'input',
+ 'text' => $label instanceof UIRawMessage ? $label->encode() : $label,
+ 'placeholder' => $placeholderText instanceof UIRawMessage ? $placeholderText->encode() : $placeholderText ?? "",
+ ];
+ if($default !== null){
+ $textFieldElement['default'] = $default instanceof UIRawMessage ? $default->encode() : $default;
+ }
+ if($tooltip !== null){
+ $textFieldElement['tooltip'] = $tooltip instanceof UIRawMessage ? $tooltip->encode() : $tooltip;
+ }
+ $this->controls[] = $textFieldElement;
+ $this->validators[] = static function($value) : string{
+ if(!is_string($value)){
+ throw new FormValidationException("Expected string for text field response, got " . gettype($value));
+ }
+ return $value;
+ };
+
+ return $this;
+ }
+
+ public function toggle(string|UIRawMessage $label, bool $default = null, string|UIRawMessage $tooltip = null) : self{
+ $toggleElement = [
+ 'type' => 'toggle',
+ 'text' => $label instanceof UIRawMessage ? $label->encode() : $label,
+ ];
+ if($default !== null){
+ $toggleElement['default'] = $default;
+ }
+ if($tooltip !== null){
+ $toggleElement['tooltip'] = $tooltip instanceof UIRawMessage ? $tooltip->encode() : $tooltip;
+ }
+ $this->controls[] = $toggleElement;
+ $this->validators[] = static function($value) : bool{
+ if(!is_bool($value)){
+ throw new FormValidationException("Expected boolean for toggle response, got " . gettype($value));
+ }
+ return $value;
+ };
+
+ return $this;
+ }
+
+ /** @internal */
+ public function toPacketFormData() : array{
+ $data = [
+ 'type' => 'custom_form',
+ 'icon' => null,
+ 'title' => $this->title ?? '',
+ 'content' => $this->controls,
+ ];
+ if($this->submit !== null){
+ $data['submit'] = $this->submit;
+ }
+ return $data;
+ }
+
+ /** @internal */
+ public function processResponse(string $rawData = null, FormCancelationReason $cancelReason = null) : ModalFormResponse{
+ if($cancelReason !== null){
+ return new ModalFormResponse($cancelReason, null);
+ }
+ if($rawData !== null){
+ try{
+ $data = json_decode($rawData, true, 2, JSON_THROW_ON_ERROR);
+ }catch(\JsonException $e){
+ throw PacketHandlingException::wrap($e, "Failed to decode form response data");
+ }
+ if(!is_array($data)){
+ throw new FormValidationException("Expected array, got $rawData");
+ }
+
+ $controlCount = count($this->controls);
+ $interactiveCount = 0;
+ foreach($this->validators as $v){
+ if($v !== null) $interactiveCount++;
+ }
+ $actual = count($data);
+
+ if($actual === $controlCount){
+ $results = [];
+ foreach($this->validators as $index => $validator){
+ if($validator === null){
+ $results[] = null;
+ continue;
+ }
+ if(!array_key_exists($index, $data)){
+ throw new FormValidationException("Missing expected result element at index $index");
+ }
+ $results[] = $validator($data[$index]);
+ }
+ return new ModalFormResponse(null, $results);
+ }elseif($actual < $controlCount){
+ // TODO: Verify this with 1.21.70 clients
+ $filteredData = array_values(array_filter($data, static fn($value) => $value !== null));
+ if(count($filteredData) === $interactiveCount){
+ $results = [];
+ $interactiveIndex = 0;
+ foreach($this->validators as $validator){
+ if($validator === null){
+ $results[] = null;
+ continue;
+ }
+ $results[] = $validator($filteredData[$interactiveIndex]);
+ $interactiveIndex++;
+ }
+ return new ModalFormResponse(null, $results);
+ }
+ }
+
+ throw new FormValidationException("Unexpected number of result elements: expected either $controlCount or $interactiveCount (compact), got $actual");
+ }
+ throw new \InvalidArgumentException("Expected rawData to be non-null");
+ }
+}
diff --git a/src/Azvyl/PMServerUI/ui/ModalFormResponse.php b/src/Azvyl/PMServerUI/ui/ModalFormResponse.php
new file mode 100644
index 0000000..3d74794
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ui/ModalFormResponse.php
@@ -0,0 +1,19 @@
+|null $formValues
+ */
+ public function __construct(?FormCancelationReason $cancelationReason, public ?array $formValues){
+ parent::__construct($cancelationReason, $cancelationReason !== null);
+ }
+}
diff --git a/src/Azvyl/PMServerUI/ui/ServerUI.php b/src/Azvyl/PMServerUI/ui/ServerUI.php
new file mode 100644
index 0000000..f4dea20
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ui/ServerUI.php
@@ -0,0 +1,30 @@
+
+ */
+ final public function show(Player $player) : Promise{
+ return PMServerUI::getUIManager()->___send($player, $this);
+ }
+
+ abstract public function toPacketFormData() : array;
+
+ abstract public function processResponse(string $rawData = null, FormCancelationReason $cancelReason = null) : FormResponse;
+
+}
\ No newline at end of file
diff --git a/src/Azvyl/PMServerUI/ui/UIManager.php b/src/Azvyl/PMServerUI/ui/UIManager.php
new file mode 100644
index 0000000..29d4362
--- /dev/null
+++ b/src/Azvyl/PMServerUI/ui/UIManager.php
@@ -0,0 +1,131 @@
+}>> */
+ private array $playerForms = [];
+
+ public function __construct(Plugin $plugin){
+ $plManager = Server::getInstance()->getPluginManager();
+ $plManager->registerEvent(PlayerQuitEvent::class, function(PlayerQuitEvent $event) : void{
+ $player = $event->getPlayer();
+ $playerId = $player->getId();
+ if(isset($this->playerForms[$playerId])){
+ foreach($this->playerForms[$playerId] as [, $promise]){
+ try{
+ $promise->reject(FormRejectError::create(FormRejectReason::PlayerQuit, "Player disconnected"));
+ }catch(\Throwable $t){
+ PMServerUI::getLogger()->logException($t);
+ }
+ }
+ unset($this->playerForms[$playerId]);
+ }
+ }, EventPriority::LOWEST, $plugin);
+ $plManager->registerEvent(DataPacketReceiveEvent::class, function(DataPacketReceiveEvent $event) : void{
+ $packet = $event->getPacket();
+ if($packet instanceof ModalFormResponsePacket){
+ $player = $event->getOrigin()->getPlayer();
+ if($player === null){
+ return;
+ }
+ $playerId = $player->getId();
+ if(isset($this->playerForms[$playerId][$packet->formId])){
+ [$ui, $promise] = $this->playerForms[$playerId][$packet->formId];
+ unset($this->playerForms[$playerId][$packet->formId]);
+ $event->cancel();
+
+ try{
+ if($packet->cancelReason !== null){
+ $reason = FormCancelationReason::cases()[$packet->cancelReason] ?? null;
+ if($reason !== null){
+ $promise->resolve($ui->processResponse(cancelReason: $reason));
+ }else{
+ throw new FormValidationException("Player {$player->getName()} sent unknown cancel reason");
+ }
+ }elseif($packet->formData !== null){
+ $maxFormResponseSize = 10 * 1024;
+ if(strlen($packet->formData) > $maxFormResponseSize){
+ throw new PacketHandlingException("Form response data too large, refusing to decode (received" . strlen($packet->formData) . " bytes, max $maxFormResponseSize bytes)");
+ }
+ $trimmedData = trim($packet->formData);
+ if($trimmedData === "null" || $trimmedData === ""){
+ throw new FormValidationException("Form response can't be null without cancel reason");
+ }
+ $promise->resolve($ui->processResponse(rawData: $packet->formData));
+ }else{
+ throw new PacketHandlingException("Expected either formData or cancelReason to be set in ModalFormResponsePacket");
+ }
+ }catch(\Throwable $t){
+ PMServerUI::getLogger()->logException($t);
+ try{
+ if($t instanceof FormValidationException){
+ $promise->reject(FormRejectError::create(FormRejectReason::MalformedResponse, "Failed to process form: {$t->getMessage()}", previous: $t));
+ return;
+ }
+ $promise->reject(FormRejectError::create(FormRejectReason::ServerShutdown, "Crashed when handling packet: {$t->getMessage()}", previous: $t));
+ }catch(\Throwable $t){
+ PMServerUI::getLogger()->logException($t);
+ }
+ }
+ }
+ }
+ }, EventPriority::LOW, $plugin);
+ }
+
+ /** Close all forms for a player. */
+ public function closeAllForms(Player $player) : void{
+ throw new \RuntimeException("Not implemented yet");
+ }
+
+ /**
+ * @internal
+ * @param Promise $promise
+ */
+ public function ___track(Player $player, int $formId, ServerUI $ui, Promise $promise) : void{
+ // Note: A vanilla client should only have one form open at a time
+ if(($this->playerForms[$player->getId()] ?? []) !== []){
+ PMServerUI::getLogger()->debug("Player {$player->getName()} ({$player->getId()}) had multiple open forms. This may indicate a suspicious client or a bug in a UI plugin.");
+ }
+ $this->playerForms[$player->getId()][$formId] = [$ui, $promise];
+ }
+
+ /**
+ * @internal
+ * @return Promise
+ */
+ public function ___send(Player $player, ServerUI $ui) : Promise{
+ /** @var Promise $promise */
+ $promise = new Promise();
+ (function(UIManager $UIManager, ServerUI $ui, Promise $promise) : void{
+ /** @noinspection PhpUndefinedFieldInspection */
+ $id = $this->formIdCounter++;//$this is Player instance due to closure binding
+ /** @noinspection PhpUndefinedMethodInspection */
+ if($this->getNetworkSession()->sendDataPacket(ModalFormRequestPacket::create($id, json_encode($ui->toPacketFormData(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)))){
+ /** @noinspection PhpParamsInspection */
+ $UIManager->___track($this, $id, $ui, $promise);
+ }
+ })->call($player, $this, $ui, $promise);
+ return $promise;
+ }
+}
diff --git a/src/DavyCraft648/PMServerUI/ActionFormData.php b/src/DavyCraft648/PMServerUI/ActionFormData.php
deleted file mode 100644
index ffeb1f0..0000000
--- a/src/DavyCraft648/PMServerUI/ActionFormData.php
+++ /dev/null
@@ -1,97 +0,0 @@
-handler = new FormDataHandler(["content" => "", "elements" => null, "title" => "", "type" => "form"], ActionFormResponse::class, $this->buttonCount);
- }
-
- public static function create(): ActionFormData{
- return new self();
- }
-
- /**
- * Method that sets the body text for the modal form.
- */
- public function body(Translatable|string $bodyText): ActionFormData{
- $this->handler->add("content", FormDataHandler::tryTl($bodyText));
- return $this;
- }
-
- /**
- * Adds a button to this form with an icon from a resource pack.
- */
- public function button(Translatable|string $text, ?string $iconPath = null, string $iconType = "path"): ActionFormData{
- $this->handler->arrAdd("elements", ["image" => $iconPath !== null ? ["data" => $iconPath, "type" => $iconType] : null, "text" => FormDataHandler::tryTl($text), "type" => "button"]);
- $this->buttonCount++;
- return $this;
- }
-
- /**
- * Adds a section divider to the form.
- */
- public function divider(): ActionFormData{
- $this->handler->arrAdd("elements", ["text" => "", "type" => "divider"]);
- return $this;
- }
-
- /**
- * Adds a header to the form.
- *
- * @param Translatable|string $text Text to display.
- */
- public function header(Translatable|string $text): ActionFormData{
- $this->handler->arrAdd("elements", ["text" => FormDataHandler::tryTl($text), "type" => "header"]);
- return $this;
- }
-
- /**
- * Adds a label to the form.
- *
- * @param Translatable|string $text Text to display.
- */
- public function label(Translatable|string $text): ActionFormData{
- $this->handler->arrAdd("elements", ["text" => FormDataHandler::tryTl($text), "type" => "label"]);
- return $this;
- }
-
- /**
- * Creates and shows this modal popup form. Returns asynchronously when the player confirms or cancels the dialog.
- *
- * @param Player $player Player to show this dialog to.
- */
- public function show(Player $player): Promise{
- return $this->handler->show($player);
- }
-
- /**
- * This builder method sets the title for the modal dialog.
- */
- public function title(Translatable|string $titleText): ActionFormData{
- $this->handler->add("title", FormDataHandler::tryTl($titleText));
- return $this;
- }
-}
\ No newline at end of file
diff --git a/src/DavyCraft648/PMServerUI/ActionFormResponse.php b/src/DavyCraft648/PMServerUI/ActionFormResponse.php
deleted file mode 100644
index 8e68cba..0000000
--- a/src/DavyCraft648/PMServerUI/ActionFormResponse.php
+++ /dev/null
@@ -1,42 +0,0 @@
-= $buttonCount){
- throw new FormValidationException("Button $data does not exist");
- }
- return $data;
- }
-}
\ No newline at end of file
diff --git a/src/DavyCraft648/PMServerUI/FormCancelationReason.php b/src/DavyCraft648/PMServerUI/FormCancelationReason.php
deleted file mode 100644
index cf90159..0000000
--- a/src/DavyCraft648/PMServerUI/FormCancelationReason.php
+++ /dev/null
@@ -1,20 +0,0 @@
-data[$key] = $value;
- }
-
- public function arrAdd(string $key, mixed $value): void{
- if(!isset($this->data[$key]) || !is_array($this->data[$key])){
- $this->data[$key] = [];
- }
- $this->data[$key][] = $value;
- }
-
- public static function tryTl(Translatable|string $value): array|string{
- return PMServerUI::$tl && $value instanceof Translatable ? ["rawtext" => [self::getTl($value)]] : (string)$value;
- }
-
- private static function getTl(Translatable $translatable): array{
- $params = [];
- foreach($translatable->getParameters() as $param){
- $params[] = $param instanceof Translatable ? self::getTl($param) : ["text" => $param];
- }
- $ret = ["translate" => $translatable->getText()];
- if($params !== []){
- $ret["with"] = ["rawtext" => $params];
- }
- return $ret;
- }
-
- public function show(Player $player): Promise{
- $promise = new Promise();
- (function(FormDataHandler $form, Promise $promise): void{
- /** @noinspection PhpUndefinedFieldInspection */
- $id = $this->formIdCounter++;
- /** @noinspection PhpUndefinedMethodInspection */
- if($this->getNetworkSession()->sendDataPacket(ModalFormRequestPacket::create($id, json_encode($form, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)))){
- /** @noinspection PhpParamsInspection */
- PMServerUI::getUIManager()->trackForm($this, $id, $form, $promise);
- }
- })->call($player, $this, $promise);
- return $promise;
- }
-
- public function handleCancel(Player $player, int $cancelReason, Promise $promise): void{
- $reason = FormCancelationReason::cases()[$cancelReason] ?? null;
- if($reason !== null){
- $promise->resolve($player, (new \ReflectionClass($this->responseClass))->newInstance($reason, true, null));
- }else{
- throw new FormValidationException("Player {$player->getName()} sent unknown cancel reason");
- }
- }
-
- public function handleResponse(Player $player, ?string $rawData, Promise $promise): void{
- if($rawData === null || $rawData === "null" || $rawData === ""){
- throw new FormValidationException("Form response can't be null without cancel reason");
- }
- $promise->resolve($player, (new \ReflectionClass($this->responseClass))->newInstance(null, false, [$this->responseClass, "validate"]($rawData, $this->sharedInfo)));
- }
-
- public function informCrash(\Throwable $t, Promise $promise): void{
- if($t instanceof FormValidationException){
- $promise->reject(FormRejectError::create(FormRejectReason::MalformedResponse, "Failed to process form: {$t->getMessage()}", previous: $t));
- return;
- }
- $promise->reject(FormRejectError::create(FormRejectReason::ServerShutdown, "Crashed when handling packet: {$t->getMessage()}", previous: $t));
- }
-
- public function informQuit(Player $player, Promise $promise): void{
- $promise->reject(FormRejectError::create(FormRejectReason::PlayerQuit, "Player {$player->getName()} quit before responding"));
- }
-
- public function jsonSerialize(): array{
- return $this->data;
- }
-}
\ No newline at end of file
diff --git a/src/DavyCraft648/PMServerUI/FormRejectError.php b/src/DavyCraft648/PMServerUI/FormRejectError.php
deleted file mode 100644
index c8e1d94..0000000
--- a/src/DavyCraft648/PMServerUI/FormRejectError.php
+++ /dev/null
@@ -1,26 +0,0 @@
-handler = new FormDataHandler(["button1" => "", "button2" => "", "content" => "", "title" => "", "type" => "modal"], MessageFormResponse::class);
- }
-
- public static function create(): MessageFormData{
- return new self();
- }
-
- /**
- * Method that sets the body text for the modal form.
- */
- public function body(Translatable|string $bodyText): MessageFormData{
- $this->handler->add("content", FormDataHandler::tryTl($bodyText));
- return $this;
- }
-
- /**
- * Method that sets the text for the first button of the dialog.
- */
- public function button1(Translatable|string $text): MessageFormData{
- $this->handler->add("button1", FormDataHandler::tryTl($text));
- return $this;
- }
-
- /**
- * This method sets the text for the second button on the dialog.
- */
- public function button2(Translatable|string $text): MessageFormData{
- $this->handler->add("button2", FormDataHandler::tryTl($text));
- return $this;
- }
-
- /**
- * Creates and shows this modal popup form. Returns asynchronously when the player confirms or cancels the dialog.
- *
- * @param Player $player Player to show this dialog to.
- */
- public function show(Player $player): Promise{
- return $this->handler->show($player);
- }
-
- /**
- * This builder method sets the title for the modal dialog.
- */
- public function title(Translatable|string $titleText): MessageFormData{
- $this->handler->add("title", FormDataHandler::tryTl($titleText));
- return $this;
- }
-}
\ No newline at end of file
diff --git a/src/DavyCraft648/PMServerUI/MessageFormResponse.php b/src/DavyCraft648/PMServerUI/MessageFormResponse.php
deleted file mode 100644
index e4ba48f..0000000
--- a/src/DavyCraft648/PMServerUI/MessageFormResponse.php
+++ /dev/null
@@ -1,37 +0,0 @@
-handler = new FormDataHandler(["content" => null, "icon" => null, "title" => "", "type" => "custom_form"], ModalFormResponse::class, $this->elements);
- }
-
- public static function create(): ModalFormData{
- return new self();
- }
-
- /**
- * Adds a section divider to the form.
- */
- public function divider(): ModalFormData{
- $this->handler->arrAdd("content", ["text" => "", "type" => "divider"]);
- $this->elements[] = "divider";
- return $this;
- }
-
- /**
- * Adds a dropdown with choices to the form.
- *
- * @param Translatable|string $label The label to display for the dropdown.
- * @param (Translatable|string)[] $items The selectable items for the dropdown.
- * @param int|null $default The default selected item index. It will be zero in case of not setting this value.
- * @param Translatable|string|null $tooltip It will show an exclamation icon that will display a tooltip if it is hovered.
- */
- public function dropdown(Translatable|string $label, array $items, ?int $default = null, Translatable|string|null $tooltip = null): ModalFormData{
- $items = array_values($items);
- foreach($items as $i => $item){
- if(!$item instanceof Translatable && !is_string($item)){
- throw new \InvalidArgumentException("Each item in the \$items array must be an instance of Translatable or a string");
- }
- $items[$i] = FormDataHandler::tryTl($item);
- }
- $val = ["options" => $items, "text" => FormDataHandler::tryTl($label), "type" => "dropdown"];
- if($default !== null){
- if(!isset($items[$default])){
- throw new \InvalidArgumentException("No option at index $default, cannot set as default");
- }
- $val["default"] = $default;
- }
- if($tooltip !== null){
- $val["tooltip"] = FormDataHandler::tryTl($tooltip);
- }
- $this->handler->arrAdd("content", $val);
- $this->elements[] = ["dropdown", count($items)];
- return $this;
- }
-
- /**
- * Adds a header to the form.
- *
- * @param Translatable|string $text Text to display.
- */
- public function header(Translatable|string $text): ModalFormData{
- $this->handler->arrAdd("content", ["text" => FormDataHandler::tryTl($text), "type" => "header"]);
- $this->elements[] = "header";
- return $this;
- }
-
- /**
- * Adds a label to the form.
- *
- * @param Translatable|string $text Text to display.
- */
- public function label(Translatable|string $text): ModalFormData{
- $this->handler->arrAdd("content", ["text" => FormDataHandler::tryTl($text), "type" => "label"]);
- $this->elements[] = "label";
- return $this;
- }
-
- /**
- * Creates and shows this modal popup form. Returns asynchronously when the player confirms or cancels the dialog.
- *
- * @param Player $player Player to show this dialog to.
- */
- public function show(Player $player): Promise{
- return $this->handler->show($player);
- }
-
- /**
- * Adds a numeric slider to the form.
- *
- * @param Translatable|string $label The label to display for the slider.
- * @param int $minimum The minimum selectable possible value.
- * @param int $maximum The maximum selectable possible value.
- * @param int|null $step Defines the increment of values that the slider generates when moved. It will be '1' in case of not providing this.
- * @param int|null $default The default value for the slider.
- * @param Translatable|string|null $tooltip It will show an exclamation icon that will display a tooltip if it is hovered.
- */
- public function slider(Translatable|string $label, int $minimum, int $maximum, ?int $step = null, ?int $default = null, Translatable|string|null $tooltip = null): ModalFormData{
- if($minimum > $maximum){
- throw new \InvalidArgumentException("Slider min value should be less than max value");
- }
- $val = ["max" => $maximum, "min" => $minimum, "step" => 1, "text" => FormDataHandler::tryTl($label), "type" => "slider"];
- if($step !== null){
- if($step <= 0){
- throw new \InvalidArgumentException("Step must be greater than zero");
- }
- $val["step"] = $step;
- }
- if($default !== null){
- if($default > $maximum || $default < $minimum){
- throw new \InvalidArgumentException("Default must be in range $minimum ... $maximum");
- }
- $val["default"] = $default;
- }
- if($tooltip !== null){
- $val["tooltip"] = FormDataHandler::tryTl($tooltip);
- }
- $this->handler->arrAdd("content", $val);
- $this->elements[] = ["slider", $minimum, $maximum];
- return $this;
- }
-
- public function submitButton(Translatable|string $submitButtonText): ModalFormData{
- $this->handler->add("submit", FormDataHandler::tryTl($submitButtonText));
- return $this;
- }
-
- /**
- * Adds a textbox to the form.
- *
- * @param Translatable|string $label The label to display for the textfield.
- * @param Translatable|string|null $placeholder The place holder text to display.
- * @param string|null $default The default value for the textfield.
- * @param Translatable|string|null $tooltip It will show an exclamation icon that will display a tooltip if it is hovered.
- */
- public function textField(Translatable|string $label, Translatable|string|null $placeholder = null, ?string $default = null, Translatable|string|null $tooltip = null): ModalFormData{
- $val = ["text" => FormDataHandler::tryTl($label), "type" => "input"];
- if($placeholder !== null){
- $val["placeholder"] = FormDataHandler::tryTl($placeholder);
- }
- if($default !== null){
- $val["default"] = $default;
- }
- if($tooltip !== null){
- $val["tooltip"] = FormDataHandler::tryTl($tooltip);
- }
- $this->handler->arrAdd("content", $val);
- $this->elements[] = "input";
- return $this;
- }
-
- /**
- * This builder method sets the title for the modal dialog.
- */
- public function title(Translatable|string $titleText): ModalFormData{
- $this->handler->add("title", FormDataHandler::tryTl($titleText));
- return $this;
- }
-
- /**
- * Adds a toggle checkbox button to the form.
- *
- * @param Translatable|string $label The label to display for the toggle.
- * @param bool|null $default The default value for the toggle.
- * @param Translatable|string|null $tooltip It will show an exclamation icon that will display a tooltip if it is hovered.
- */
- public function toggle(Translatable|string $label, ?bool $default = null, Translatable|string|null $tooltip = null): ModalFormData{
- $val = ["text" => FormDataHandler::tryTl($label), "type" => "toggle"];
- if($default !== null){
- $val["default"] = $default;
- }
- if($tooltip !== null){
- $val["tooltip"] = FormDataHandler::tryTl($tooltip);
- }
- $this->handler->arrAdd("content", $val);
- $this->elements[] = "toggle";
- return $this;
- }
-}
\ No newline at end of file
diff --git a/src/DavyCraft648/PMServerUI/ModalFormResponse.php b/src/DavyCraft648/PMServerUI/ModalFormResponse.php
deleted file mode 100644
index 2e7a3a9..0000000
--- a/src/DavyCraft648/PMServerUI/ModalFormResponse.php
+++ /dev/null
@@ -1,118 +0,0 @@
- $expected){
- throw new FormValidationException("Too many result elements, expected $expected, got $actual");
- }elseif($actual < $expected){
- //In 1.21.70, the client doesn't send nulls for labels, so we need to polyfill them here to
- //maintain the old behaviour
- $noLabelsIndexMapping = [];
- foreach($elements as $index => $element){
- if($element === "label"){
- $noLabelsIndexMapping[] = $index;
- }
- }
- $expectedWithoutLabels = count($noLabelsIndexMapping);
- if($actual !== $expectedWithoutLabels){
- throw new FormValidationException("Wrong number of result elements, expected either $expected (with label values) or $expectedWithoutLabels (without label values, 1.21.70), got $actual");
- }
-
- //polyfill the missing nulls
- $mappedData = array_fill(0, $expected, null);
- foreach($data as $givenIndex => $value){
- $internalIndex = $noLabelsIndexMapping[$givenIndex] ?? null;
- if($internalIndex === null){
- throw new FormValidationException("Can't map given offset $givenIndex to an internal element offset (while correcting for labels)");
- }
- //set the appropriate values according to the given index
- //this could (?) still leave unexpected nulls, but the validation below will catch that
- $mappedData[$internalIndex] = $value;
- }
- if(count($mappedData) !== $expected){
- throw new AssumptionFailedError("This should always match");
- }
- $data = $mappedData;
- }
-
- foreach($data as $index => $value){
- if(!isset($elements[$index])){
- throw new FormValidationException("Element at offset $index does not exist");
- }
- $element = $elements[$index];
- if(is_array($element)){
- if($element[0] === "dropdown"){
- if(!is_int($value)){
- throw new FormValidationException("Dropdown: Expected int, got " . gettype($value));
- }
- if($value < 0 || $value >= $element[1]){
- throw new FormValidationException("Dropdown: Option $value does not exist");
- }
- }elseif($element[0] === "slider"){
- if(!is_float($value) && !is_int($value)){
- throw new FormValidationException("Slider: Expected float, got " . gettype($value));
- }
- if($value < $element[1] || $value > $element[2]){
- throw new FormValidationException("Slider: Value $value is out of bounds (min $element[1], max $element[2])");
- }
- }
- }elseif($element === "input"){
- if(!is_string($value)){
- throw new FormValidationException("Input: Expected string, got " . gettype($value));
- }
- }elseif($element === "toggle"){
- if(!is_bool($value)){
- throw new FormValidationException("Toggle: Expected bool, got " . gettype($value));
- }
- }elseif($value !== null){
- throw new FormValidationException("The value of $element must be null");
- }
- }
- return $data;
- }
-}
\ No newline at end of file
diff --git a/src/DavyCraft648/PMServerUI/UIManager.php b/src/DavyCraft648/PMServerUI/UIManager.php
deleted file mode 100644
index a676546..0000000
--- a/src/DavyCraft648/PMServerUI/UIManager.php
+++ /dev/null
@@ -1,85 +0,0 @@
-getPluginManager();
- $plManager->registerEvent(PlayerQuitEvent::class, function(PlayerQuitEvent $event): void{
- $player = $event->getPlayer();
- if(!isset($this->playerForms[$player->getId()])){
- return;
- }
- /**
- * @var FormDataHandler $form
- * @var Promise $promise
- */
- foreach($this->playerForms[$player->getId()] as [$form, $promise]){
- $form->informQuit($player, $promise);
- }
- unset($this->playerForms[$player->getId()]);
- }, EventPriority::LOWEST, $plugin);
- $plManager->registerEvent(DataPacketReceiveEvent::class, function(DataPacketReceiveEvent $event): void{
- $packet = $event->getPacket();
- if($packet instanceof ModalFormResponsePacket){
- $player = $event->getOrigin()->getPlayer();
- /**
- * @var FormDataHandler $form
- * @var Promise $promise
- */
- [$form, $promise] = $this->playerForms[$player->getId()][$packet->formId] ?? [null, null];
- if($form !== null){
- unset($this->playerForms[$player->getId()][$packet->formId]);
- $event->cancel();
-
- try{
- if($packet->cancelReason !== null){
- $form->handleCancel($player, $packet->cancelReason, $promise);
- }elseif($packet->formData !== null){
- $form->handleResponse($player, trim($packet->formData), $promise);
- }else{
- throw new PacketHandlingException("Expected either formData or cancelReason to be set in ModalFormResponsePacket");
- }
- }catch(\Throwable $e){
- PMServerUI::getLogger()->logException($e);
- try{
- $form->informCrash($e, $promise);
- }catch(\Throwable $t){
- PMServerUI::getLogger()->logException($t);
- }
- }
- }
- }
- }, EventPriority::LOW, $plugin);
- }
-
- public function trackForm(Player $player, int $formId, FormDataHandler $handler, Promise $promise): void{
- $this->playerForms[$player->getId()][$formId] = [$handler, $promise];
- }
-}
\ No newline at end of file
diff --git a/virion.yml b/virion.yml
index 0d760ad..c7038e1 100644
--- a/virion.yml
+++ b/virion.yml
@@ -1,6 +1,6 @@
name: PMServerUI
-version: 1.0.2
-antigen: DavyCraft648\PMServerUI
+version: 2.0.0
+antigen: Azvyl\PMServerUI
api: 5.0.0
-authors: [DavyCraft648]
+authors: [Azvyl]
description: Integrate UI features available in the Script API to PocketMine-MP