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 (
+
+
+
+
+ onChange(value)}
+ onEnter={(_, value) => act('submit', { entry: value })}
+ value={input}
+ />
+
+
+
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/TextInputModal.tsx b/tgui/packages/tgui/interfaces/TextInputModal.tsx
new file mode 100644
index 00000000000..d290df58c0c
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/TextInputModal.tsx
@@ -0,0 +1,121 @@
+import { Loader } from './common/Loader';
+import { InputButtons } from './common/InputButtons';
+import { useBackend, useLocalState } from '../backend';
+import { KEY_ENTER, KEY_ESCAPE } from '../../common/keycodes';
+import { Box, Section, Stack, TextArea } from '../components';
+import { Window } from '../layouts';
+
+type TextInputData = {
+ large_buttons: boolean;
+ max_length: number;
+ message: string;
+ multiline: boolean;
+ placeholder: string;
+ timeout: number;
+ title: string;
+};
+
+export const sanitizeMultiline = (toSanitize: string) => {
+ return toSanitize.replace(/(\n|\r\n){3,}/, '\n\n');
+};
+
+export const removeAllSkiplines = (toSanitize: string) => {
+ return toSanitize.replace(/[\r\n]+/, '');
+};
+
+export const TextInputModal = (props, context) => {
+ const { act, data } = useBackend(context);
+ const {
+ large_buttons,
+ max_length,
+ message = '',
+ multiline,
+ placeholder,
+ timeout,
+ title,
+ } = data;
+ const [input, setInput] = useLocalState(
+ context,
+ 'input',
+ placeholder || ''
+ );
+ const onType = (value: string) => {
+ if (value === input) {
+ return;
+ }
+ const sanitizedInput = multiline
+ ? sanitizeMultiline(value)
+ : removeAllSkiplines(value);
+ setInput(sanitizedInput);
+ };
+
+ const visualMultiline = multiline || input.length >= 30;
+ // Dynamically changes the window height based on the message.
+ const windowHeight =
+ 135 +
+ (message.length > 30 ? Math.ceil(message.length / 4) : 0) +
+ (visualMultiline ? 75 : 0) +
+ (message.length && large_buttons ? 5 : 0);
+
+ return (
+
+ {timeout && }
+ {
+ const keyCode = window.event ? event.which : event.keyCode;
+ if (keyCode === KEY_ENTER && (!visualMultiline || !event.shiftKey)) {
+ 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 { max_length, multiline } = data;
+ const { input, onType } = props;
+
+ const visualMultiline = multiline || input.length >= 30;
+
+ return (
+