From f90000670aa353ee5355b6e85408969ef21485a2 Mon Sep 17 00:00:00 2001 From: Matt Atlas Date: Wed, 27 Sep 2023 23:02:45 +0200 Subject: [PATCH] i alone am the honoured one --- aurorastation.dme | 4 + code/__defines/misc.dm | 12 +- code/_helpers/lists.dm | 12 + code/_helpers/text.dm | 38 +++ code/game/verbs/ooc.dm | 15 +- .../client/preference_setup/global/01_ui.dm | 15 ++ code/modules/client/preferences.dm | 3 + code/modules/mob/mob.dm | 6 +- code/modules/tgui_input/alert.dm | 133 ++++++++++ code/modules/tgui_input/list.dm | 142 ++++++++++ code/modules/tgui_input/number.dm | 154 +++++++++++ code/modules/tgui_input/text.dm | 157 +++++++++++ tgui/packages/tgui/interfaces/AlertModal.tsx | 151 +++++++++++ .../tgui/interfaces/ListInputModal.tsx | 247 ++++++++++++++++++ .../tgui/interfaces/NumberInputModal.tsx | 118 +++++++++ .../tgui/interfaces/TextInputModal.tsx | 121 +++++++++ 16 files changed, 1313 insertions(+), 15 deletions(-) create mode 100644 code/modules/tgui_input/alert.dm create mode 100644 code/modules/tgui_input/list.dm create mode 100644 code/modules/tgui_input/number.dm create mode 100644 code/modules/tgui_input/text.dm create mode 100644 tgui/packages/tgui/interfaces/AlertModal.tsx create mode 100644 tgui/packages/tgui/interfaces/ListInputModal.tsx create mode 100644 tgui/packages/tgui/interfaces/NumberInputModal.tsx create mode 100644 tgui/packages/tgui/interfaces/TextInputModal.tsx diff --git a/aurorastation.dme b/aurorastation.dme index 53538d66a14..59e4bc35029 100644 --- a/aurorastation.dme +++ b/aurorastation.dme @@ -3373,6 +3373,10 @@ #include "code\modules\tgui\states\reverse_contained.dm" #include "code\modules\tgui\states\self.dm" #include "code\modules\tgui\states\zlevel.dm" +#include "code\modules\tgui_input\alert.dm" +#include "code\modules\tgui_input\list.dm" +#include "code\modules\tgui_input\number.dm" +#include "code\modules\tgui_input\text.dm" #include "code\modules\tgui_panel\audio.dm" #include "code\modules\tgui_panel\external.dm" #include "code\modules\tgui_panel\tgui_panel.dm" diff --git a/code/__defines/misc.dm b/code/__defines/misc.dm index d7a591234c8..4438441448b 100644 --- a/code/__defines/misc.dm +++ b/code/__defines/misc.dm @@ -60,12 +60,12 @@ // 0x1 is free. // 0x2 is free. -#define PROGRESS_BARS 0x4 -#define PARALLAX_IS_STATIC 0x8 -#define FLOATING_MESSAGES 0x10 -#define HOTKEY_DEFAULT 0x20 -#define FULLSCREEN_MODE 0x40 -#define ACCENT_TAG_TEXT 0x80 +#define PROGRESS_BARS 0x4 +#define PARALLAX_IS_STATIC 0x8 +#define FLOATING_MESSAGES 0x10 +#define HOTKEY_DEFAULT 0x20 +#define FULLSCREEN_MODE 0x40 +#define ACCENT_TAG_TEXT 0x80 #define TOGGLES_DEFAULT (SOUND_ADMINHELP | SOUND_MIDI | SOUND_LOBBY | CHAT_OOC | CHAT_DEAD | CHAT_GHOSTEARS | CHAT_GHOSTSIGHT | CHAT_PRAYER | CHAT_RADIO | CHAT_ATTACKLOGS | CHAT_LOOC | CHAT_GHOSTLOOC) diff --git a/code/_helpers/lists.dm b/code/_helpers/lists.dm index dbfb648f357..4162c84bd7f 100644 --- a/code/_helpers/lists.dm +++ b/code/_helpers/lists.dm @@ -851,3 +851,15 @@ . += M else . += flatten_list(M) + +///takes an input_key, as text, and the list of keys already used, outputting a replacement key in the format of "[input_key] ([number_of_duplicates])" if it finds a duplicate +///use this for lists of things that might have the same name, like mobs or objects, that you plan on giving to a player as input +/proc/avoid_assoc_duplicate_keys(input_key, list/used_key_list) + if(!input_key || !istype(used_key_list)) + return + if(used_key_list[input_key]) + used_key_list[input_key]++ + input_key = "[input_key] ([used_key_list[input_key]])" + else + used_key_list[input_key] = 1 + return input_key diff --git a/code/_helpers/text.dm b/code/_helpers/text.dm index 6f5281e2445..6e65cd01bf7 100644 --- a/code/_helpers/text.dm +++ b/code/_helpers/text.dm @@ -767,3 +767,41 @@ return "[text]es" return "[text]s" return "" + +/** + * Used to get a properly sanitized input. Returns null if cancel is pressed. + * + * Arguments + ** user - Target of the input prompt. + ** message - The text inside of the prompt. + ** title - The window title of the prompt. + ** max_length - If you intend to impose a length limit - default is 1024. + ** no_trim - Prevents the input from being trimmed if you intend to parse newlines or whitespace. +*/ +/proc/stripped_input(mob/user, message = "", title = "", default = "", max_length=MAX_MESSAGE_LEN, no_trim=FALSE) + var/user_input = input(user, message, title, default) as text|null + if(isnull(user_input)) // User pressed cancel + return + if(no_trim) + return copytext(html_encode(user_input), 1, max_length) + else + return trim(html_encode(user_input), max_length) //trim is "outside" because html_encode can expand single symbols into multiple symbols (such as turning < into <) + +/** + * Used to get a properly sanitized input in a larger box. Works very similarly to stripped_input. + * + * Arguments + ** user - Target of the input prompt. + ** message - The text inside of the prompt. + ** title - The window title of the prompt. + ** max_length - If you intend to impose a length limit - default is 1024. + ** no_trim - Prevents the input from being trimmed if you intend to parse newlines or whitespace. +*/ +/proc/stripped_multiline_input(mob/user, message = "", title = "", default = "", max_length=MAX_MESSAGE_LEN, no_trim=FALSE) + var/user_input = input(user, message, title, default) as message|null + if(isnull(user_input)) // User pressed cancel + return + if(no_trim) + return copytext(html_encode(user_input), 1, max_length) + else + return trim(html_encode(user_input), max_length) diff --git a/code/game/verbs/ooc.dm b/code/game/verbs/ooc.dm index 21ad9f1c01d..a5524e2e85e 100644 --- a/code/game/verbs/ooc.dm +++ b/code/game/verbs/ooc.dm @@ -1,5 +1,5 @@ -/client/verb/ooc(msg as text) +/client/verb/ooc() set name = "OOC" set category = "OOC" @@ -7,13 +7,16 @@ to_chat(usr, "Speech is currently admin-disabled.") return - if(!mob) return + if(!mob) + return + if(IsGuestKey(key)) to_chat(src, "Guests may not use OOC.") return - msg = sanitize(msg) - if(!msg) return + var/msg = tgui_input_text(src, "Enter an OOC message.", "OOC") + if(!msg) + return if(!(prefs.toggles & CHAT_OOC)) to_chat(src, "You have OOC muted.") @@ -62,7 +65,7 @@ else to_chat(target, "" + create_text_tag("OOC", target) + " [display_name]: [msg]") -/client/verb/looc(msg as text) +/client/verb/looc() set name = "LOOC" set desc = "Local OOC, seen only by those in view." set category = "OOC" @@ -78,7 +81,7 @@ to_chat(src, "Guests may not use OOC.") return - msg = sanitize(msg) + var/msg = tgui_input_text(src, "Enter an LOOC message.", "OOC") msg = process_chat_markup(msg, list("*")) if(!msg) return diff --git a/code/modules/client/preference_setup/global/01_ui.dm b/code/modules/client/preference_setup/global/01_ui.dm index aea218a72dd..26607d1e340 100644 --- a/code/modules/client/preference_setup/global/01_ui.dm +++ b/code/modules/client/preference_setup/global/01_ui.dm @@ -87,6 +87,9 @@ dat += "Tooltip Style: [pref.tooltip_style]
" dat += "TGUI Fancy: [pref.tgui_fancy ? "ON" : "OFF"]
" dat += "TGUI Lock: [pref.tgui_lock ? "ON" : "OFF"]
" + dat += "TGUI Inputs: [pref.tgui_inputs ? "ON" : "OFF"]
" + dat += "TGUI Input Large Buttons: [pref.tgui_inputs_large_buttons ? "ON" : "OFF"]
" + dat += "TGUI Input Swapped Buttons: [pref.tgui_inputs_swapped_buttons ? "ON" : "OFF"]
" dat += "FPS: [pref.clientfps] - reset
" if(can_select_ooc_color(user)) dat += "OOC Color: " @@ -124,6 +127,18 @@ pref.tgui_lock = !pref.tgui_lock return TOPIC_REFRESH + else if(href_list["tgui_inputs"]) + pref.tgui_inputs = !pref.tgui_inputs + return TOPIC_REFRESH + + else if(href_list["tgui_inputs_large"]) + pref.tgui_inputs_large_buttons = !pref.tgui_inputs_large_buttons + return TOPIC_REFRESH + + else if(href_list["tgui_inputs_swapped"]) + pref.tgui_inputs_swapped_buttons = !pref.tgui_inputs_swapped_buttons + return TOPIC_REFRESH + else if(href_list["select_ooc_color"]) var/new_ooccolor = input(user, "Choose OOC color:", "Global Preference") as color|null if(new_ooccolor && can_select_ooc_color(user) && CanUseTopic(user)) diff --git a/code/modules/client/preferences.dm b/code/modules/client/preferences.dm index 147056ea845..5a2e6bcf038 100644 --- a/code/modules/client/preferences.dm +++ b/code/modules/client/preferences.dm @@ -28,6 +28,9 @@ var/list/preferences_datums = list() var/UI_style_alpha = 255 var/tgui_fancy = TRUE var/tgui_lock = FALSE + var/tgui_inputs = TRUE + var/tgui_inputs_large_buttons = FALSE + var/tgui_inputs_swapped_buttons = FALSE //Style for popup tooltips var/tooltip_style = "Midnight" var/motd_hash = "" //Hashes for the new server greeting window. diff --git a/code/modules/mob/mob.dm b/code/modules/mob/mob.dm index 999f4db0b1b..11db400f608 100644 --- a/code/modules/mob/mob.dm +++ b/code/modules/mob/mob.dm @@ -91,7 +91,7 @@ set name = ".Say" set hidden = TRUE SStyping.set_indicator_state(client, TRUE) - var/message = input("","say (text)") as text|null + var/message = tgui_input_text(src, "Enter a Say message." ,"Say") SStyping.set_indicator_state(client, FALSE) if (message) say_verb(message) @@ -100,7 +100,7 @@ set name = ".Me" set hidden = TRUE SStyping.set_indicator_state(client, TRUE) - var/message = input("","me (text)") as text|null + var/message = tgui_input_text(src, "Enter a Me message.", "Me") SStyping.set_indicator_state(client, FALSE) if (message) me_verb(message) @@ -109,7 +109,7 @@ set name = ".Whisper" set hidden = TRUE SStyping.set_indicator_state(client, TRUE) - var/message = input("","me (text)") as text|null + var/message = tgui_input_text(src, "Enter a Whisper message.", "Whisper") SStyping.set_indicator_state(client, FALSE) if (message) whisper(message) diff --git a/code/modules/tgui_input/alert.dm b/code/modules/tgui_input/alert.dm new file mode 100644 index 00000000000..c43f7e3e332 --- /dev/null +++ b/code/modules/tgui_input/alert.dm @@ -0,0 +1,133 @@ +/** + * Creates a TGUI alert window and returns the user's response. + * + * This proc should be used to create alerts that the caller will wait for a response from. + * Arguments: + * * user - The user to show the alert to. + * * message - The content of the alert, shown in the body of the TGUI window. + * * title - The of the alert modal, shown on the top of the TGUI window. + * * buttons - The options that can be chosen by the user, each string is assigned a button on the UI. + * * timeout - The timeout of the alert, after which the modal will close and qdel itself. Set to zero for no timeout. + * * autofocus - The bool that controls if this alert should grab window focus. + */ +/proc/tgui_alert(mob/user, message = "", title, list/buttons = list("Ok"), timeout = 0, autofocus = TRUE) + if (!user) + user = usr + if (!istype(user)) + if (istype(user, /client)) + var/client/client = user + user = client.mob + else + return + // A gentle nudge - you should not be using TGUI alert for anything other than a simple message. + if(length(buttons) > 3) + log_tgui(user, "Error: TGUI Alert initiated with too many buttons. Use a list.", "TguiAlert") + return tgui_input_list(user, message, title, buttons, timeout, autofocus) + // Client does NOT have tgui_input on: Returns regular input + if(!user.client.prefs.tgui_inputs) + if(length(buttons) == 2) + return alert(user, message, title, buttons[1], buttons[2]) + if(length(buttons) == 3) + return alert(user, message, title, buttons[1], buttons[2], buttons[3]) + var/datum/tgui_alert/alert = new(user, message, title, buttons, timeout, autofocus) + alert.ui_interact(user) + alert.wait() + if (alert) + . = alert.choice + qdel(alert) + +/** + * # tgui_alert + * + * Datum used for instantiating and using a TGUI-controlled modal that prompts the user with + * a message and has buttons for responses. + */ +/datum/tgui_alert + /// The title of the TGUI window + var/title + /// The textual body of the TGUI window + var/message + /// The list of buttons (responses) provided on the TGUI window + var/list/buttons + /// The button that the user has pressed, null if no selection has been made + var/choice + /// The time at which the tgui_alert was created, for displaying timeout progress. + var/start_time + /// The lifespan of the tgui_alert, after which the window will close and delete itself. + var/timeout + /// The bool that controls if this modal should grab window focus + var/autofocus + /// Boolean field describing if the tgui_alert was closed by the user. + var/closed + +/datum/tgui_alert/New(mob/user, message, title, list/buttons, timeout, autofocus) + src.autofocus = autofocus + src.buttons = buttons.Copy() + src.message = message + src.title = title + if (timeout) + src.timeout = timeout + start_time = world.time + QDEL_IN(src, timeout) + +/datum/tgui_alert/Destroy(force, ...) + SStgui.close_uis(src) + QDEL_NULL(buttons) + return ..() + +/** + * Waits for a user's response to the tgui_alert's prompt before returning. Returns early if + * the window was closed by the user. + */ +/datum/tgui_alert/proc/wait() + while (!choice && !closed && !QDELETED(src)) + stoplag(1) + +/datum/tgui_alert/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "AlertModal") + ui.open() + +/datum/tgui_alert/ui_close(mob/user) + . = ..() + closed = TRUE + +/datum/tgui_alert/ui_state(mob/user) + return always_state + +/datum/tgui_alert/ui_static_data(mob/user) + var/list/data = list() + data["autofocus"] = autofocus + data["buttons"] = buttons + data["message"] = message + data["large_buttons"] = user.client.prefs.tgui_inputs_large_buttons + data["swapped_buttons"] = user.client.prefs.tgui_inputs_swapped_buttons + data["title"] = title + return data + +/datum/tgui_alert/ui_data(mob/user) + var/list/data = list() + if(timeout) + data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + return data + +/datum/tgui_alert/ui_act(action, list/params) + . = ..() + if (.) + return + switch(action) + if("choose") + if (!(params["choice"] in buttons)) + CRASH("[usr] entered a non-existent button choice: [params["choice"]]") + set_choice(params["choice"]) + closed = TRUE + SStgui.close_uis(src) + return TRUE + if("cancel") + closed = TRUE + SStgui.close_uis(src) + return TRUE + +/datum/tgui_alert/proc/set_choice(choice) + src.choice = choice diff --git a/code/modules/tgui_input/list.dm b/code/modules/tgui_input/list.dm new file mode 100644 index 00000000000..8d55fbf2c40 --- /dev/null +++ b/code/modules/tgui_input/list.dm @@ -0,0 +1,142 @@ +/** + * Creates a TGUI input list window and returns the user's response. + * + * This proc should be used to create alerts that the caller will wait for a response from. + * Arguments: + * * user - The user to show the input box to. + * * message - The content of the input box, shown in the body of the TGUI window. + * * title - The title of the input box, shown on the top of the TGUI window. + * * items - The options that can be chosen by the user, each string is assigned a button on the UI. + * * default - If an option is already preselected on the UI. Current values, etc. + * * timeout - The timeout of the input box, after which the menu will close and qdel itself. Set to zero for no timeout. + */ +/proc/tgui_input_list(mob/user, message, title = "Select", list/items, default, timeout = 0) + if (!user) + user = usr + if(!length(items)) + return + if (!istype(user)) + if (istype(user, /client)) + var/client/client = user + user = client.mob + else + return + /// Client does NOT have tgui_input on: Returns regular input + if(!user.client.prefs.tgui_inputs) + return input(user, message, title, default) as null|anything in items + var/datum/tgui_list_input/input = new(user, message, title, items, default, timeout) + input.ui_interact(user) + input.wait() + if (input) + . = input.choice + qdel(input) + +/** + * # tgui_list_input + * + * Datum used for instantiating and using a TGUI-controlled list input that prompts the user with + * a message and shows a list of selectable options + */ +/datum/tgui_list_input + /// The title of the TGUI window + var/title + /// The textual body of the TGUI window + var/message + /// The list of items (responses) provided on the TGUI window + var/list/items + /// Buttons (strings specifically) mapped to the actual value (e.g. a mob or a verb) + var/list/items_map + /// The button that the user has pressed, null if no selection has been made + var/choice + /// The default button to be selected + var/default + /// The time at which the tgui_list_input was created, for displaying timeout progress. + var/start_time + /// The lifespan of the tgui_list_input, after which the window will close and delete itself. + var/timeout + /// Boolean field describing if the tgui_list_input was closed by the user. + var/closed + +/datum/tgui_list_input/New(mob/user, message, title, list/items, default, timeout) + src.title = title + src.message = message + src.items = list() + src.items_map = list() + src.default = default + var/list/repeat_items = list() + // Gets rid of illegal characters + var/static/regex/whitelistedWords = regex(@{"([^\u0020-\u8000]+)"}) + for(var/i in items) + if(!i) + continue + var/string_key = whitelistedWords.Replace("[i]", "") + //avoids duplicated keys E.g: when areas have the same name + string_key = avoid_assoc_duplicate_keys(string_key, repeat_items) + src.items += string_key + src.items_map[string_key] = i + if (timeout) + src.timeout = timeout + start_time = world.time + QDEL_IN(src, timeout) + +/datum/tgui_list_input/Destroy(force, ...) + SStgui.close_uis(src) + QDEL_NULL(items) + return ..() + +/** + * Waits for a user's response to the tgui_list_input's prompt before returning. Returns early if + * the window was closed by the user. + */ +/datum/tgui_list_input/proc/wait() + while (!choice && !closed) + stoplag(1) + +/datum/tgui_list_input/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "ListInputModal") + ui.open() + +/datum/tgui_list_input/ui_close(mob/user) + . = ..() + closed = TRUE + +/datum/tgui_list_input/ui_state(mob/user) + return always_state + +/datum/tgui_list_input/ui_static_data(mob/user) + var/list/data = list() + data["init_value"] = default || items[1] + data["items"] = items + data["large_buttons"] = user.client.prefs.tgui_inputs_large_buttons + data["swapped_buttons"] = user.client.prefs.tgui_inputs_swapped_buttons + data["message"] = message + data["title"] = title + return data + +/datum/tgui_list_input/ui_data(mob/user) + var/list/data = list() + if(timeout) + data["timeout"] = clamp((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS), 0, 1) + return data + +/datum/tgui_list_input/ui_act(action, list/params) + . = ..() + if (.) + return + switch(action) + if("submit") + if (!(params["entry"] in items)) + return + set_choice(items_map[params["entry"]]) + closed = TRUE + SStgui.close_uis(src) + return TRUE + if("cancel") + closed = TRUE + SStgui.close_uis(src) + return TRUE + +/datum/tgui_list_input/proc/set_choice(choice) + src.choice = choice diff --git a/code/modules/tgui_input/number.dm b/code/modules/tgui_input/number.dm new file mode 100644 index 00000000000..ff38dfb8b9c --- /dev/null +++ b/code/modules/tgui_input/number.dm @@ -0,0 +1,154 @@ +/** + * Creates a TGUI window with a number input. Returns the user's response as num | null. + * + * This proc should be used to create windows for number entry that the caller will wait for a response from. + * If tgui fancy chat is turned off: Will return a normal input. If a max or min value is specified, will + * validate the input inside the UI and ui_act. + * + * Arguments: + * * user - The user to show the number input to. + * * message - The content of the number input, shown in the body of the TGUI window. + * * title - The title of the number input modal, shown on the top of the TGUI window. + * * default - The default (or current) value, shown as a placeholder. Users can press refresh with this. + * * max_value - Specifies a maximum value. If none is set, any number can be entered. Pressing "max" defaults to 1000. + * * min_value - Specifies a minimum value. Often 0. + * * timeout - The timeout of the number input, after which the modal will close and qdel itself. Set to zero for no timeout. + * * round_value - whether the inputted number is rounded down into an integer. + */ +/proc/tgui_input_number(mob/user, message, title = "Number Input", default = 0, max_value = 10000, min_value = 0, timeout = 0, round_value = TRUE) + if (!user) + user = usr + if (!istype(user)) + if (istype(user, /client)) + var/client/client = user + user = client.mob + else + return + // Client does NOT have tgui_input on: Returns regular input + if(!user.client.prefs.tgui_inputs) + var/input_number = input(user, message, title, default) as null|num + return clamp(round_value ? round(input_number) : input_number, min_value, max_value) + var/datum/tgui_input_number/number_input = new(user, message, title, default, max_value, min_value, timeout, round_value) + number_input.ui_interact(user) + number_input.wait() + if (number_input) + . = number_input.entry + qdel(number_input) + +/** + * # tgui_input_number + * + * Datum used for instantiating and using a TGUI-controlled number input that prompts the user with + * a message and has an input for number entry. + */ +/datum/tgui_input_number + /// Boolean field describing if the tgui_input_number was closed by the user. + var/closed + /// The default (or current) value, shown as a default. Users can press reset with this. + var/default + /// The entry that the user has return_typed in. + var/entry + /// The maximum value that can be entered. + var/max_value + /// The prompt's body, if any, of the TGUI window. + var/message + /// The minimum value that can be entered. + var/min_value + /// Whether the submitted number is rounded down into an integer. + var/round_value + /// The time at which the number input was created, for displaying timeout progress. + var/start_time + /// The lifespan of the number input, after which the window will close and delete itself. + var/timeout + /// The title of the TGUI window + var/title + +/datum/tgui_input_number/New(mob/user, message, title, default, max_value, min_value, timeout, round_value) + src.default = default + src.max_value = max_value + src.message = message + src.min_value = min_value + src.title = title + src.round_value = round_value + if (timeout) + src.timeout = timeout + start_time = world.time + QDEL_IN(src, timeout) + /// Checks for empty numbers - bank accounts, etc. + if(max_value == 0) + src.min_value = 0 + if(default) + src.default = 0 + /// Sanity check + if(default < min_value) + src.default = min_value + if(default > max_value) + CRASH("Default value is greater than max value.") + +/datum/tgui_input_number/Destroy(force, ...) + SStgui.close_uis(src) + return ..() + +/** + * Waits for a user's response to the tgui_input_number's prompt before returning. Returns early if + * the window was closed by the user. + */ +/datum/tgui_input_number/proc/wait() + while (!entry && !closed && !QDELETED(src)) + stoplag(1) + +/datum/tgui_input_number/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "NumberInputModal") + ui.open() + +/datum/tgui_input_number/ui_close(mob/user) + . = ..() + closed = TRUE + +/datum/tgui_input_number/ui_state(mob/user) + return always_state + +/datum/tgui_input_number/ui_static_data(mob/user) + var/list/data = list() + data["init_value"] = default // Default is a reserved keyword + data["large_buttons"] = user.client.prefs.tgui_inputs_large_buttons + data["swapped_buttons"] = user.client.prefs.tgui_inputs_swapped_buttons + data["max_value"] = max_value + data["message"] = message + data["min_value"] = min_value + data["title"] = title + data["round_value"] = round_value + return data + +/datum/tgui_input_number/ui_data(mob/user) + var/list/data = list() + if(timeout) + data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + return data + +/datum/tgui_input_number/ui_act(action, list/params) + . = ..() + if (.) + return + switch(action) + if("submit") + if(!isnum(params["entry"])) + CRASH("A non number was input into tgui input number by [usr]") + var/choice = round_value ? round(params["entry"]) : params["entry"] + if(choice > max_value) + CRASH("A number greater than the max value was input into tgui input number by [usr]") + if(choice < min_value) + CRASH("A number less than the min value was input into tgui input number by [usr]") + set_entry(choice) + closed = TRUE + SStgui.close_uis(src) + return TRUE + if("cancel") + closed = TRUE + SStgui.close_uis(src) + return TRUE + +/datum/tgui_input_number/proc/set_entry(entry) + src.entry = entry diff --git a/code/modules/tgui_input/text.dm b/code/modules/tgui_input/text.dm new file mode 100644 index 00000000000..64d62a919bd --- /dev/null +++ b/code/modules/tgui_input/text.dm @@ -0,0 +1,157 @@ +/** + * Creates a TGUI window with a text input. Returns the user's response. + * + * This proc should be used to create windows for text entry that the caller will wait for a response from. + * If tgui fancy chat is turned off: Will return a normal input. If max_length is specified, will return + * stripped_multiline_input. + * + * Arguments: + * * user - The user to show the text input to. + * * message - The content of the text input, shown in the body of the TGUI window. + * * title - The title of the text input modal, shown on the top of the TGUI window. + * * default - The default (or current) value, shown as a placeholder. + * * max_length - Specifies a max length for input. MAX_MESSAGE_LEN is default (1024) + * * multiline - Bool that determines if the input box is much larger. Good for large messages, laws, etc. + * * encode - Toggling this determines if input is filtered via html_encode. Setting this to FALSE gives raw input. + * * timeout - The timeout of the textbox, after which the modal will close and qdel itself. Set to zero for no timeout. + */ +/proc/tgui_input_text(mob/user, message = "", title = "Text Input", default, max_length = MAX_MESSAGE_LEN, multiline = FALSE, encode = TRUE, timeout = 0) + if (!user) + user = usr + if (!istype(user)) + if (istype(user, /client)) + var/client/client = user + user = client.mob + else + return + // Client does NOT have tgui_input on: Returns regular input + if(!user.client.prefs.tgui_inputs) + if(encode) + if(multiline) + return stripped_multiline_input(user, message, title, default, max_length) + else + return stripped_input(user, message, title, default, max_length) + else + if(multiline) + return input(user, message, title, default) as message|null + else + return input(user, message, title, default) as text|null + var/datum/tgui_input_text/text_input = new(user, message, title, default, max_length, multiline, encode, timeout) + text_input.ui_interact(user) + text_input.wait() + if (text_input) + . = text_input.entry + qdel(text_input) + +/** + * tgui_input_text + * + * Datum used for instantiating and using a TGUI-controlled text input that prompts the user with + * a message and has an input for text entry. + */ +/datum/tgui_input_text + /// Boolean field describing if the tgui_input_text was closed by the user. + var/closed + /// The default (or current) value, shown as a default. + var/default + /// Whether the input should be stripped using html_encode + var/encode + /// The entry that the user has return_typed in. + var/entry + /// The maximum length for text entry + var/max_length + /// The prompt's body, if any, of the TGUI window. + var/message + /// Multiline input for larger input boxes. + var/multiline + /// The time at which the text input was created, for displaying timeout progress. + var/start_time + /// The lifespan of the text input, after which the window will close and delete itself. + var/timeout + /// The title of the TGUI window + var/title + +/datum/tgui_input_text/New(mob/user, message, title, default, max_length, multiline, encode, timeout) + src.default = default + src.encode = encode + src.max_length = max_length + src.message = message + src.multiline = multiline + src.title = title + if (timeout) + src.timeout = timeout + start_time = world.time + QDEL_IN(src, timeout) + +/datum/tgui_input_text/Destroy(force, ...) + SStgui.close_uis(src) + return ..() + +/** + * Waits for a user's response to the tgui_input_text's prompt before returning. Returns early if + * the window was closed by the user. + */ +/datum/tgui_input_text/proc/wait() + while (!entry && !closed && !QDELETED(src)) + stoplag(1) + +/datum/tgui_input_text/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "TextInputModal") + ui.open() + +/datum/tgui_input_text/ui_close(mob/user) + . = ..() + closed = TRUE + +/datum/tgui_input_text/ui_state(mob/user) + return always_state + +/datum/tgui_input_text/ui_static_data(mob/user) + var/list/data = list() + data["large_buttons"] = user.client.prefs.tgui_inputs_large_buttons + data["swapped_buttons"] = user.client.prefs.tgui_inputs_swapped_buttons + data["max_length"] = max_length + data["message"] = message + data["multiline"] = multiline + data["placeholder"] = default // Default is a reserved keyword + data["title"] = title + return data + +/datum/tgui_input_text/ui_data(mob/user) + var/list/data = list() + if(timeout) + data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + return data + +/datum/tgui_input_text/ui_act(action, list/params) + . = ..() + if (.) + return + switch(action) + if("submit") + if(max_length) + if(length(params["entry"]) > max_length) + CRASH("[usr] typed a text string longer than the max length") + if(encode && (length(html_encode(params["entry"])) > max_length)) + to_chat(usr, SPAN_NOTICE("Your message was clipped due to special character usage.")) + set_entry(params["entry"]) + closed = TRUE + SStgui.close_uis(src) + return TRUE + if("cancel") + closed = TRUE + SStgui.close_uis(src) + return TRUE + +/** + * Sets the return value for the tgui text proc. + * If html encoding is enabled, the text will be encoded. + * This can sometimes result in a string that is longer than the max length. + * If the string is longer than the max length, it will be clipped. + */ +/datum/tgui_input_text/proc/set_entry(entry) + if(!isnull(entry)) + var/converted_entry = encode ? html_encode(entry) : entry + src.entry = trim(converted_entry, max_length) diff --git a/tgui/packages/tgui/interfaces/AlertModal.tsx b/tgui/packages/tgui/interfaces/AlertModal.tsx new file mode 100644 index 00000000000..ee53a0f1b63 --- /dev/null +++ b/tgui/packages/tgui/interfaces/AlertModal.tsx @@ -0,0 +1,151 @@ +import { Loader } from './common/Loader'; +import { useBackend, useLocalState } from '../backend'; +import { KEY_ENTER, KEY_ESCAPE, KEY_LEFT, KEY_RIGHT, KEY_SPACE, KEY_TAB } from '../../common/keycodes'; +import { Autofocus, Box, Button, Flex, Section, Stack } from '../components'; +import { Window } from '../layouts'; + +type AlertModalData = { + autofocus: boolean; + buttons: string[]; + large_buttons: boolean; + message: string; + swapped_buttons: boolean; + timeout: number; + title: string; +}; + +const KEY_DECREMENT = -1; +const KEY_INCREMENT = 1; + +export const AlertModal = (props, context) => { + const { act, data } = useBackend(context); + const { + autofocus, + buttons = [], + large_buttons, + message = '', + timeout, + title, + } = data; + const [selected, setSelected] = useLocalState(context, 'selected', 0); + // Dynamically sets window dimensions + const windowHeight = + 115 + + (message.length > 30 ? Math.ceil(message.length / 4) : 0) + + (message.length && large_buttons ? 5 : 0); + const windowWidth = 325 + (buttons.length > 2 ? 55 : 0); + const onKey = (direction: number) => { + if (selected === 0 && direction === KEY_DECREMENT) { + setSelected(buttons.length - 1); + } else if (selected === buttons.length - 1 && direction === KEY_INCREMENT) { + setSelected(0); + } else { + setSelected(selected + direction); + } + }; + + return ( + + {!!timeout && } + { + const keyCode = window.event ? e.which : e.keyCode; + /** + * Simulate a click when pressing space or enter, + * allow keyboard navigation, override tab behavior + */ + if (keyCode === KEY_SPACE || keyCode === KEY_ENTER) { + act('choose', { choice: buttons[selected] }); + } else if (keyCode === KEY_ESCAPE) { + act('cancel'); + } else if (keyCode === KEY_LEFT) { + e.preventDefault(); + onKey(KEY_DECREMENT); + } else if (keyCode === KEY_TAB || keyCode === KEY_RIGHT) { + e.preventDefault(); + onKey(KEY_INCREMENT); + } + }}> +
+ + + + {message} + + + + {!!autofocus && } + + + +
+
+
+ ); +}; + +/** + * Displays a list of buttons ordered by user prefs. + * Technically this handles more than 2 buttons, but you + * should just be using a list input in that case. + */ +const ButtonDisplay = (props, context) => { + const { data } = useBackend(context); + const { buttons = [], large_buttons, swapped_buttons } = data; + const { selected } = props; + + return ( + + {buttons?.map((button, index) => + !!large_buttons && buttons.length < 3 ? ( + + + + ) : ( + + + + ) + )} + + ); +}; + +/** + * Displays a button with variable sizing. + */ +const AlertButton = (props, context) => { + const { act, data } = useBackend(context); + const { large_buttons } = data; + const { button, selected } = props; + const buttonWidth = button.length > 7 ? button.length : 7; + + return ( + + ); +}; diff --git a/tgui/packages/tgui/interfaces/ListInputModal.tsx b/tgui/packages/tgui/interfaces/ListInputModal.tsx new file mode 100644 index 00000000000..7cb207c9dc2 --- /dev/null +++ b/tgui/packages/tgui/interfaces/ListInputModal.tsx @@ -0,0 +1,247 @@ +import { Loader } from './common/Loader'; +import { InputButtons } from './common/InputButtons'; +import { Button, Input, Section, Stack } from '../components'; +import { useBackend, useLocalState } from '../backend'; +import { KEY_A, KEY_DOWN, KEY_ESCAPE, KEY_ENTER, KEY_UP, KEY_Z } from '../../common/keycodes'; +import { Window } from '../layouts'; + +type ListInputData = { + init_value: string; + items: string[]; + large_buttons: boolean; + message: string; + timeout: number; + title: string; +}; + +export const ListInputModal = (props, context) => { + const { act, data } = useBackend(context); + const { + items = [], + message = '', + init_value, + large_buttons, + timeout, + title, + } = data; + const [selected, setSelected] = useLocalState( + context, + 'selected', + items.indexOf(init_value) + ); + const [searchBarVisible, setSearchBarVisible] = useLocalState( + context, + 'searchBarVisible', + items.length > 9 + ); + const [searchQuery, setSearchQuery] = useLocalState( + context, + 'searchQuery', + '' + ); + // User presses up or down on keyboard + // Simulates clicking an item + const onArrowKey = (key: number) => { + const len = filteredItems.length - 1; + if (key === KEY_DOWN) { + if (selected === null || selected === len) { + setSelected(0); + document!.getElementById('0')?.scrollIntoView(); + } else { + setSelected(selected + 1); + document!.getElementById((selected + 1).toString())?.scrollIntoView(); + } + } else if (key === KEY_UP) { + if (selected === null || selected === 0) { + setSelected(len); + document!.getElementById(len.toString())?.scrollIntoView(); + } else { + setSelected(selected - 1); + document!.getElementById((selected - 1).toString())?.scrollIntoView(); + } + } + }; + // User selects an item with mouse + const onClick = (index: number) => { + if (index === selected) { + return; + } + setSelected(index); + }; + // User presses a letter key and searchbar is visible + const onFocusSearch = () => { + setSearchBarVisible(false); + setSearchBarVisible(true); + }; + // User presses a letter key with no searchbar visible + const onLetterSearch = (key: number) => { + const keyChar = String.fromCharCode(key); + const foundItem = items.find((item) => { + return item?.toLowerCase().startsWith(keyChar?.toLowerCase()); + }); + if (foundItem) { + const foundIndex = items.indexOf(foundItem); + setSelected(foundIndex); + document!.getElementById(foundIndex.toString())?.scrollIntoView(); + } + }; + // User types into search bar + const onSearch = (query: string) => { + if (query === searchQuery) { + return; + } + setSearchQuery(query); + setSelected(0); + document!.getElementById('0')?.scrollIntoView(); + }; + // User presses the search button + const onSearchBarToggle = () => { + setSearchBarVisible(!searchBarVisible); + setSearchQuery(''); + }; + const filteredItems = items.filter((item) => + item?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + // Dynamically changes the window height based on the message. + const windowHeight = + 325 + Math.ceil(message.length / 3) + (large_buttons ? 5 : 0); + // Grabs the cursor when no search bar is visible. + if (!searchBarVisible) { + setTimeout(() => document!.getElementById(selected.toString())?.focus(), 1); + } + + return ( + + {timeout && } + { + const keyCode = window.event ? event.which : event.keyCode; + if (keyCode === KEY_DOWN || keyCode === KEY_UP) { + event.preventDefault(); + onArrowKey(keyCode); + } + if (keyCode === KEY_ENTER) { + event.preventDefault(); + act('submit', { entry: filteredItems[selected] }); + } + if (!searchBarVisible && keyCode >= KEY_A && keyCode <= KEY_Z) { + event.preventDefault(); + onLetterSearch(keyCode); + } + if (keyCode === KEY_ESCAPE) { + event.preventDefault(); + act('cancel'); + } + }}> +
onSearchBarToggle()} + /> + } + className="ListInput__Section" + fill + title={message}> + + + + + {searchBarVisible && ( + + )} + + + + +
+
+
+ ); +}; + +/** + * Displays the list of selectable items. + * If a search query is provided, filters the items. + */ +const ListDisplay = (props, context) => { + const { act } = useBackend(context); + const { filteredItems, onClick, onFocusSearch, searchBarVisible, selected } = + props; + + return ( +
+ {filteredItems.map((item, index) => { + return ( + + ); + })} +
+ ); +}; + +/** + * Renders a search bar input. + * Closing the bar defaults input to an empty string. + */ +const SearchBar = (props, context) => { + const { act } = useBackend(context); + const { filteredItems, onSearch, searchQuery, selected } = props; + + return ( + { + event.preventDefault(); + act('submit', { entry: filteredItems[selected] }); + }} + onInput={(_, value) => onSearch(value)} + placeholder="Search..." + value={searchQuery} + /> + ); +}; diff --git a/tgui/packages/tgui/interfaces/NumberInputModal.tsx b/tgui/packages/tgui/interfaces/NumberInputModal.tsx new file mode 100644 index 00000000000..76f1306d159 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NumberInputModal.tsx @@ -0,0 +1,118 @@ +import { Loader } from './common/Loader'; +import { InputButtons } from './common/InputButtons'; +import { KEY_ENTER, KEY_ESCAPE } from '../../common/keycodes'; +import { useBackend, useLocalState } from '../backend'; +import { Box, Button, RestrictedInput, Section, Stack } from '../components'; +import { Window } from '../layouts'; + +type NumberInputData = { + init_value: number; + large_buttons: boolean; + max_value: number | null; + message: string; + min_value: number | null; + timeout: number; + title: string; + round_value: boolean; +}; + +export const NumberInputModal = (props, context) => { + const { act, data } = useBackend(context); + const { init_value, large_buttons, message = '', timeout, title } = data; + const [input, setInput] = useLocalState(context, 'input', init_value); + const onChange = (value: number) => { + if (value === input) { + return; + } + setInput(value); + }; + const onClick = (value: number) => { + if (value === input) { + return; + } + setInput(value); + }; + // Dynamically changes the window height based on the message. + const windowHeight = + 140 + + (message.length > 30 ? Math.ceil(message.length / 3) : 0) + + (message.length && large_buttons ? 5 : 0); + + return ( + + {timeout && } + { + const keyCode = window.event ? event.which : event.keyCode; + if (keyCode === KEY_ENTER) { + act('submit', { entry: input }); + } + if (keyCode === KEY_ESCAPE) { + act('cancel'); + } + }}> +
+ + + {message} + + + + + + + + +
+
+
+ ); +}; + +/** Gets the user input and invalidates if there's a constraint. */ +const InputArea = (props, context) => { + const { act, data } = useBackend(context); + const { min_value, max_value, init_value, round_value } = data; + const { input, onClick, onChange } = props; + return ( + + +