Path: blob/master/src/library/classes/uiFormBuilder.ts
1784 views
/* eslint-disable @typescript-eslint/ban-types */1import { MessageFormData, MessageFormResponse, ActionFormData, ActionFormResponse, ModalFormData, ModalFormResponse, FormCancelationReason, FormResponse } from "@minecraft/server-ui";2import { Form, FormData, UIAction, MessageForm, ActionForm, SubmitAction, ModalForm, UIFormName, MenuContext as MenuContextType, DynamicElem, LocalizedText } from "../@types/classes/uiFormBuilder";3import { Player, RawMessage } from "@minecraft/server";4import { setTickTimeout, contentLog } from "@notbeer-api";56abstract class UIForm<T extends {}> {7private readonly form: Form<T>;8protected readonly cancelAction?: UIAction<T, void>;910constructor(form: Form<T>) {11this.form = form;12this.cancelAction = form.cancel;13}1415protected abstract build(form: Form<T>, resEl: <S>(elem: DynamicElem<T, S>) => S, errorFmt?: RawMessage): FormData;1617public abstract enter(player: Player, ctx: MenuContextType<T>, error?: LocalizedText): void;1819// eslint-disable-next-line @typescript-eslint/no-unused-vars20public exit(player: Player, ctx: MenuContextType<T>) {21/**/22}2324protected handleCancel(response: FormResponse, player: Player, ctx: MenuContext<T>) {25if (!response.canceled) return false;26if (response.cancelationReason == FormCancelationReason.UserBusy) {27setTickTimeout(() => this.enter(player, ctx));28} else {29ctx.goto(undefined);30this.cancelAction?.(ctx, player);31}32return true;33}3435protected buildFormData(player: Player, ctx: MenuContext<T>, error?: LocalizedText) {36const resolve = <S>(elem: DynamicElem<T, S>): S => this.resolve(elem, player, ctx);37if (typeof error === "string") {38error = { rawtext: [{ text: "§c" }, { translate: error }, { text: "§r" }] };39} else if (error) {40error = { rawtext: [{ text: "§c" }, ...error.rawtext!, { text: "§r" }] };41}42return this.build(this.form, resolve, error);43}4445protected resolve<S>(element: DynamicElem<T, S>, player: Player, ctx: MenuContext<T>) {46return element instanceof Function ? element(ctx, player) : element;47}48}4950class MessageUIForm<T extends {}> extends UIForm<T> {51private readonly action1: UIAction<T, void>;52private readonly action2: UIAction<T, void>;5354constructor(form: MessageForm<T>) {55super(form);56this.action1 = form.button2.action;57this.action2 = form.button1.action;58}5960protected build(form: MessageForm<T>, resEl: <S>(elem: DynamicElem<T, S>) => S) {61const formData = new MessageFormData();62formData.title(resEl(form.title));63formData.body(resEl(form.message));64formData.button1(resEl(form.button2.text));65formData.button2(resEl(form.button1.text));66return formData;67}6869enter(player: Player, ctx: MenuContext<T>) {70this.buildFormData(player, ctx)71.show(player)72.then((response: MessageFormResponse) => {73if (this.handleCancel(response, player, ctx)) return;74ctx.goto(undefined);75if (response.selection == 0) {76this.action1(ctx, player);77} else if (response.selection == 1) {78this.action2(ctx, player);79}80});81}82}8384class ActionUIForm<T extends {}> extends UIForm<T> {85private actions: UIAction<T, void>[] = []; // Changes between builds8687protected build(form: ActionForm<T>, resEl: <S>(elem: DynamicElem<T, S>) => S, errorFmt?: RawMessage) {88this.actions = [];89const formData = new ActionFormData();90formData.title(errorFmt ?? resEl(form.title));9192if (form.message) formData.body(resEl(form.message));93if (resEl((ctx) => (<MenuContext<T>>ctx).canGoBack())) {94formData.button("<< Back");95this.actions.push((ctx) => ctx.back());96}97for (const button of resEl(form.buttons)) {98formData.button(resEl(button.text), resEl(button.icon));99this.actions.push(button.action);100}101return formData;102}103104enter(player: Player, ctx: MenuContext<T>, error?: LocalizedText) {105const form = this.buildFormData(player, ctx, error);106const actions = this.actions;107form.show(player).then((response: ActionFormResponse) => {108if (this.handleCancel(response, player, ctx)) return;109ctx.goto(undefined);110actions[response.selection]?.(ctx, player);111});112}113}114115class ModalUIForm<T extends {}> extends UIForm<T> {116private readonly submit: SubmitAction<T>;117private inputNames: string[] = []; // Changes between builds118119constructor(form: ModalForm<T>) {120super(form);121this.submit = form.submit;122}123124protected build(form: ModalForm<T>, resEl: <S>(elem: DynamicElem<T, S>) => S, errorFmt?: RawMessage) {125this.inputNames = [];126const formData = new ModalFormData();127formData.title(errorFmt ?? resEl(form.title));128129const formInputs = resEl(form.inputs);130for (const id in formInputs) {131const input = formInputs[id as UIFormName];132133if (input.type == "dropdown") {134formData.dropdown(resEl(input.name), resEl(input.options), { defaultValueIndex: resEl(input.default) });135} else if (input.type == "slider") {136formData.slider(resEl(input.name), resEl(input.min), resEl(input.max), { valueStep: resEl(input.step ?? 1), defaultValue: resEl(input.default) });137} else if (input.type == "textField") {138formData.textField(resEl(input.name), resEl(input.placeholder), { defaultValue: resEl(input.default) });139} else if (input.type == "toggle") {140formData.toggle(resEl(input.name), { defaultValue: resEl(input.default) });141}142this.inputNames.push(id);143}144return formData;145}146147enter(player: Player, ctx: MenuContext<T>, error: LocalizedText) {148const form = this.buildFormData(player, ctx, error);149const inputNames = this.inputNames;150form.show(player).then((response: ModalFormResponse) => {151if (this.handleCancel(response, player, ctx)) return;152const inputs: { [key: string]: string | number | boolean } = {};153for (const i in response.formValues) {154inputs[inputNames[i]] = response.formValues[i];155}156ctx.goto(undefined);157this.submit(ctx, player, inputs);158});159}160}161162class MenuContext<T extends {}> implements MenuContextType<T> {163private stack: `$${string}`[] = [];164private data: T = {} as T;165166constructor(private player: Player) {}167168getData<S extends keyof T>(key: S): T[S] {169return this.data[key];170}171172setData<S extends keyof T>(key: S, value: T[S]) {173this.data[key] = value;174}175176goto(menu?: UIFormName) {177if (menu && this.stack[this.stack.length - 1] === "$___confirmMenu___") {178throw Error("Can't go to another form from a confirmation menu!");179}180this._goto(menu);181}182183back() {184this.stack.pop();185this._goto(this.stack.pop());186}187188returnto(menu: UIFormName) {189let popped: string | undefined;190// eslint-disable-next-line no-cond-assign191while ((popped = this.stack.pop())) {192if (popped === menu) {193this._goto(menu);194return;195}196}197this._goto(undefined);198}199200confirm(title: string, message: string, yes: UIAction<T, void>, no?: UIAction<T, void>) {201this.stack.push("$___confirmMenu___");202const form = new MessageUIForm({203title,204message,205button1: { text: "No", action: no ?? ((ctx) => ctx.back()) },206button2: { text: "Yes", action: yes },207});208form.enter(this.player, this);209}210211error(errorMessage: LocalizedText) {212this._goto(this.stack[this.stack.length - 1], errorMessage);213}214215canGoBack() {216return this.stack.length > 1;217}218219get currentMenu() {220return this.stack[this.stack.length - 1];221}222223private _goto(menu?: UIFormName, error?: LocalizedText) {224if (menu && menu !== this.stack[this.stack.length - 1]) this.stack.push(menu);225if (this.stack.length >= 64) throw Error("UI Stack overflow!");226UIForms.goto(menu, this.player, this, error);227}228}229230class UIFormBuilder {231private forms = new Map<UIFormName, UIForm<{}>>();232private active = new Map<Player, UIForm<{}>>();233234/**235* Register a UI Form to be displayed to users.236* @param name The name of the UI form237* @param form The layout of the UI form238*/239register<T extends {}>(name: UIFormName, form: Form<T>) {240if (this.forms.has(name)) {241throw `UIForm by the name ${name} has already been registered.`;242}243if ("button1" in form) {244this.forms.set(name, new MessageUIForm(form));245} else if ("buttons" in form) {246this.forms.set(name, new ActionUIForm(form));247} else if ("inputs" in form) {248this.forms.set(name, new ModalUIForm(form));249}250}251252/**253* Displays a UI form registered as `name` to `player`.254* @param name The name of the UI form255* @param player The player the UI form must be shown to256* @param data Context data to be made available to the UI form's elements257* @returns True if another form is already being displayed. Otherwise false.258*/259show<T extends {}>(name: UIFormName, player: Player, data?: T) {260if (this.displayingUI(player)) {261return true;262}263const ctx = new MenuContext<T>(player);264Object.entries(data ?? {}).forEach((e) => ctx.setData(e[0] as keyof T, e[1] as (typeof data)[keyof T]));265ctx.goto(name);266return false;267}268269/**270* Go from one UI form to another.271* @internal272* @param name The name of the UI form to go to273* @param player The player to display the UI form to274* @param ctx The context to be passed to the UI form275*/276goto(name: UIFormName, player: Player, ctx: MenuContextType<{}>, error?: LocalizedText) {277if (this.active.has(player)) {278this.active.get(player).exit(player, ctx);279this.active.delete(player);280}281282if (!name) {283return;284} else if (this.forms.has(name)) {285contentLog.debug("UI going to", name, "for", player.name);286const form = this.forms.get(name);287this.active.set(player, form);288form.enter(player, ctx, error);289return form;290} else {291throw new TypeError(`Menu "${name}" has not been registered!`);292}293}294295/**296* @param player The player being tested297* @param ui The name of the UI to test for, if you want to be specific298* @returns Whether the UI, or any at all is being displayed.299*/300displayingUI(player: Player, ui?: UIFormName) {301if (!this.active.has(player)) return false;302if (!ui) return true;303const form = this.active.get(player);304for (const registered of this.forms.values()) {305if (registered == form) return true;306}307return false;308}309}310311export const UIForms = new UIFormBuilder();312313314