Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sisilicon
GitHub Repository: sisilicon/worldedit-be
Path: blob/master/src/library/classes/uiFormBuilder.ts
1784 views
1
/* eslint-disable @typescript-eslint/ban-types */
2
import { MessageFormData, MessageFormResponse, ActionFormData, ActionFormResponse, ModalFormData, ModalFormResponse, FormCancelationReason, FormResponse } from "@minecraft/server-ui";
3
import { Form, FormData, UIAction, MessageForm, ActionForm, SubmitAction, ModalForm, UIFormName, MenuContext as MenuContextType, DynamicElem, LocalizedText } from "../@types/classes/uiFormBuilder";
4
import { Player, RawMessage } from "@minecraft/server";
5
import { setTickTimeout, contentLog } from "@notbeer-api";
6
7
abstract class UIForm<T extends {}> {
8
private readonly form: Form<T>;
9
protected readonly cancelAction?: UIAction<T, void>;
10
11
constructor(form: Form<T>) {
12
this.form = form;
13
this.cancelAction = form.cancel;
14
}
15
16
protected abstract build(form: Form<T>, resEl: <S>(elem: DynamicElem<T, S>) => S, errorFmt?: RawMessage): FormData;
17
18
public abstract enter(player: Player, ctx: MenuContextType<T>, error?: LocalizedText): void;
19
20
// eslint-disable-next-line @typescript-eslint/no-unused-vars
21
public exit(player: Player, ctx: MenuContextType<T>) {
22
/**/
23
}
24
25
protected handleCancel(response: FormResponse, player: Player, ctx: MenuContext<T>) {
26
if (!response.canceled) return false;
27
if (response.cancelationReason == FormCancelationReason.UserBusy) {
28
setTickTimeout(() => this.enter(player, ctx));
29
} else {
30
ctx.goto(undefined);
31
this.cancelAction?.(ctx, player);
32
}
33
return true;
34
}
35
36
protected buildFormData(player: Player, ctx: MenuContext<T>, error?: LocalizedText) {
37
const resolve = <S>(elem: DynamicElem<T, S>): S => this.resolve(elem, player, ctx);
38
if (typeof error === "string") {
39
error = { rawtext: [{ text: "§c" }, { translate: error }, { text: "§r" }] };
40
} else if (error) {
41
error = { rawtext: [{ text: "§c" }, ...error.rawtext!, { text: "§r" }] };
42
}
43
return this.build(this.form, resolve, error);
44
}
45
46
protected resolve<S>(element: DynamicElem<T, S>, player: Player, ctx: MenuContext<T>) {
47
return element instanceof Function ? element(ctx, player) : element;
48
}
49
}
50
51
class MessageUIForm<T extends {}> extends UIForm<T> {
52
private readonly action1: UIAction<T, void>;
53
private readonly action2: UIAction<T, void>;
54
55
constructor(form: MessageForm<T>) {
56
super(form);
57
this.action1 = form.button2.action;
58
this.action2 = form.button1.action;
59
}
60
61
protected build(form: MessageForm<T>, resEl: <S>(elem: DynamicElem<T, S>) => S) {
62
const formData = new MessageFormData();
63
formData.title(resEl(form.title));
64
formData.body(resEl(form.message));
65
formData.button1(resEl(form.button2.text));
66
formData.button2(resEl(form.button1.text));
67
return formData;
68
}
69
70
enter(player: Player, ctx: MenuContext<T>) {
71
this.buildFormData(player, ctx)
72
.show(player)
73
.then((response: MessageFormResponse) => {
74
if (this.handleCancel(response, player, ctx)) return;
75
ctx.goto(undefined);
76
if (response.selection == 0) {
77
this.action1(ctx, player);
78
} else if (response.selection == 1) {
79
this.action2(ctx, player);
80
}
81
});
82
}
83
}
84
85
class ActionUIForm<T extends {}> extends UIForm<T> {
86
private actions: UIAction<T, void>[] = []; // Changes between builds
87
88
protected build(form: ActionForm<T>, resEl: <S>(elem: DynamicElem<T, S>) => S, errorFmt?: RawMessage) {
89
this.actions = [];
90
const formData = new ActionFormData();
91
formData.title(errorFmt ?? resEl(form.title));
92
93
if (form.message) formData.body(resEl(form.message));
94
if (resEl((ctx) => (<MenuContext<T>>ctx).canGoBack())) {
95
formData.button("<< Back");
96
this.actions.push((ctx) => ctx.back());
97
}
98
for (const button of resEl(form.buttons)) {
99
formData.button(resEl(button.text), resEl(button.icon));
100
this.actions.push(button.action);
101
}
102
return formData;
103
}
104
105
enter(player: Player, ctx: MenuContext<T>, error?: LocalizedText) {
106
const form = this.buildFormData(player, ctx, error);
107
const actions = this.actions;
108
form.show(player).then((response: ActionFormResponse) => {
109
if (this.handleCancel(response, player, ctx)) return;
110
ctx.goto(undefined);
111
actions[response.selection]?.(ctx, player);
112
});
113
}
114
}
115
116
class ModalUIForm<T extends {}> extends UIForm<T> {
117
private readonly submit: SubmitAction<T>;
118
private inputNames: string[] = []; // Changes between builds
119
120
constructor(form: ModalForm<T>) {
121
super(form);
122
this.submit = form.submit;
123
}
124
125
protected build(form: ModalForm<T>, resEl: <S>(elem: DynamicElem<T, S>) => S, errorFmt?: RawMessage) {
126
this.inputNames = [];
127
const formData = new ModalFormData();
128
formData.title(errorFmt ?? resEl(form.title));
129
130
const formInputs = resEl(form.inputs);
131
for (const id in formInputs) {
132
const input = formInputs[id as UIFormName];
133
134
if (input.type == "dropdown") {
135
formData.dropdown(resEl(input.name), resEl(input.options), { defaultValueIndex: resEl(input.default) });
136
} else if (input.type == "slider") {
137
formData.slider(resEl(input.name), resEl(input.min), resEl(input.max), { valueStep: resEl(input.step ?? 1), defaultValue: resEl(input.default) });
138
} else if (input.type == "textField") {
139
formData.textField(resEl(input.name), resEl(input.placeholder), { defaultValue: resEl(input.default) });
140
} else if (input.type == "toggle") {
141
formData.toggle(resEl(input.name), { defaultValue: resEl(input.default) });
142
}
143
this.inputNames.push(id);
144
}
145
return formData;
146
}
147
148
enter(player: Player, ctx: MenuContext<T>, error: LocalizedText) {
149
const form = this.buildFormData(player, ctx, error);
150
const inputNames = this.inputNames;
151
form.show(player).then((response: ModalFormResponse) => {
152
if (this.handleCancel(response, player, ctx)) return;
153
const inputs: { [key: string]: string | number | boolean } = {};
154
for (const i in response.formValues) {
155
inputs[inputNames[i]] = response.formValues[i];
156
}
157
ctx.goto(undefined);
158
this.submit(ctx, player, inputs);
159
});
160
}
161
}
162
163
class MenuContext<T extends {}> implements MenuContextType<T> {
164
private stack: `$${string}`[] = [];
165
private data: T = {} as T;
166
167
constructor(private player: Player) {}
168
169
getData<S extends keyof T>(key: S): T[S] {
170
return this.data[key];
171
}
172
173
setData<S extends keyof T>(key: S, value: T[S]) {
174
this.data[key] = value;
175
}
176
177
goto(menu?: UIFormName) {
178
if (menu && this.stack[this.stack.length - 1] === "$___confirmMenu___") {
179
throw Error("Can't go to another form from a confirmation menu!");
180
}
181
this._goto(menu);
182
}
183
184
back() {
185
this.stack.pop();
186
this._goto(this.stack.pop());
187
}
188
189
returnto(menu: UIFormName) {
190
let popped: string | undefined;
191
// eslint-disable-next-line no-cond-assign
192
while ((popped = this.stack.pop())) {
193
if (popped === menu) {
194
this._goto(menu);
195
return;
196
}
197
}
198
this._goto(undefined);
199
}
200
201
confirm(title: string, message: string, yes: UIAction<T, void>, no?: UIAction<T, void>) {
202
this.stack.push("$___confirmMenu___");
203
const form = new MessageUIForm({
204
title,
205
message,
206
button1: { text: "No", action: no ?? ((ctx) => ctx.back()) },
207
button2: { text: "Yes", action: yes },
208
});
209
form.enter(this.player, this);
210
}
211
212
error(errorMessage: LocalizedText) {
213
this._goto(this.stack[this.stack.length - 1], errorMessage);
214
}
215
216
canGoBack() {
217
return this.stack.length > 1;
218
}
219
220
get currentMenu() {
221
return this.stack[this.stack.length - 1];
222
}
223
224
private _goto(menu?: UIFormName, error?: LocalizedText) {
225
if (menu && menu !== this.stack[this.stack.length - 1]) this.stack.push(menu);
226
if (this.stack.length >= 64) throw Error("UI Stack overflow!");
227
UIForms.goto(menu, this.player, this, error);
228
}
229
}
230
231
class UIFormBuilder {
232
private forms = new Map<UIFormName, UIForm<{}>>();
233
private active = new Map<Player, UIForm<{}>>();
234
235
/**
236
* Register a UI Form to be displayed to users.
237
* @param name The name of the UI form
238
* @param form The layout of the UI form
239
*/
240
register<T extends {}>(name: UIFormName, form: Form<T>) {
241
if (this.forms.has(name)) {
242
throw `UIForm by the name ${name} has already been registered.`;
243
}
244
if ("button1" in form) {
245
this.forms.set(name, new MessageUIForm(form));
246
} else if ("buttons" in form) {
247
this.forms.set(name, new ActionUIForm(form));
248
} else if ("inputs" in form) {
249
this.forms.set(name, new ModalUIForm(form));
250
}
251
}
252
253
/**
254
* Displays a UI form registered as `name` to `player`.
255
* @param name The name of the UI form
256
* @param player The player the UI form must be shown to
257
* @param data Context data to be made available to the UI form's elements
258
* @returns True if another form is already being displayed. Otherwise false.
259
*/
260
show<T extends {}>(name: UIFormName, player: Player, data?: T) {
261
if (this.displayingUI(player)) {
262
return true;
263
}
264
const ctx = new MenuContext<T>(player);
265
Object.entries(data ?? {}).forEach((e) => ctx.setData(e[0] as keyof T, e[1] as (typeof data)[keyof T]));
266
ctx.goto(name);
267
return false;
268
}
269
270
/**
271
* Go from one UI form to another.
272
* @internal
273
* @param name The name of the UI form to go to
274
* @param player The player to display the UI form to
275
* @param ctx The context to be passed to the UI form
276
*/
277
goto(name: UIFormName, player: Player, ctx: MenuContextType<{}>, error?: LocalizedText) {
278
if (this.active.has(player)) {
279
this.active.get(player).exit(player, ctx);
280
this.active.delete(player);
281
}
282
283
if (!name) {
284
return;
285
} else if (this.forms.has(name)) {
286
contentLog.debug("UI going to", name, "for", player.name);
287
const form = this.forms.get(name);
288
this.active.set(player, form);
289
form.enter(player, ctx, error);
290
return form;
291
} else {
292
throw new TypeError(`Menu "${name}" has not been registered!`);
293
}
294
}
295
296
/**
297
* @param player The player being tested
298
* @param ui The name of the UI to test for, if you want to be specific
299
* @returns Whether the UI, or any at all is being displayed.
300
*/
301
displayingUI(player: Player, ui?: UIFormName) {
302
if (!this.active.has(player)) return false;
303
if (!ui) return true;
304
const form = this.active.get(player);
305
for (const registered of this.forms.values()) {
306
if (registered == form) return true;
307
}
308
return false;
309
}
310
}
311
312
export const UIForms = new UIFormBuilder();
313
314