From 5d798d234c3af802fb47c1890a305ae7a61f08e9 Mon Sep 17 00:00:00 2001 From: itsmeow Date: Fri, 20 Jan 2023 02:23:37 -0600 Subject: [PATCH] Add TGUI inputs and alerts (#8080) * Replace tgalert with tgui_alert (#55157) Adds TGUI-based alerts to replace the old tgalert system. Replaces all uses of tgalert with tgui_alert except for one, the 'Report Issue' button, as people were (understandably) concerned that this button using tgui will prevent a tgui bug from being easily reported. These windows have a nice little progress bar indicator of how much time they have left, and will automatically close themselves after this time elapses. Co-authored-by: Aleksej Komarov * Fixes from tg/58419 * Adds a autofocus arg to tgui_alert (#60452) Co-authored-by: Mothblocks <35135081+Mothblocks@users.noreply.github.com> * Allows you to cancel TGUI alerts (#61072) This was requested by someone downstream. Some TGUI alerts offer two options e.g. (Kill Bob, Kill Janice), In byond alerts you'd be able to cancel by pressing the X, but tgui alerts don't support this. I've added an option to enable the normal X in the top right, so you can cancel out of alerts if you enable it. * Improve Close/null response state of alert() * tgui: List Input (#56065) Ported over from https://gitlab.com/cmdevs/colonial-warfare/-/merge_requests/613 which provides an input box to select an option from a list. Has a search bar to allow filtering for results and an onKeyDown event to replicate default behaviour from the default list input boxes that BYOND provides (where you are able to type the first letter of an element in the list and it'll jump to the first element that matches and then the next and so on) Right now, it is only applied to the holopad and "Drop Bomb" verb for administrators. Credits to bobbahbrown for the Loader element from AlertModal.js which allows for a timed input (was needed on the CM codebase for timed inputs) and for the majority of the DM code. Co-authored-by: Watermelon914 <3052169-Watermelon914@users.noreply.gitlab.com> Co-authored-by: Aleksej Komarov * Refactor ListInput with new changes * Added autofocus for the first button in the TGUI list input. (#56114) Ported over from https://gitlab.com/cmdevs/colonial-warfare/-/merge_requests/647 When you open the TGUI list input, it'll auto-focus the first element so that you can easily navigate with the arrow keys without having to click on the page beforehand. Small QoL when opening the thing, don't have to click on it if you want to navigate via arrows keys, etc. * Swap buttons in ListInput for improved accessibility (#57832) Swapped OK/Cancel buttons in tgui list input, so that it stays consistent with Windows UI guidelines (was Cancel/OK before). * tgui input list improvements (#59668) pressing enter or space now selects the selected button duplicate keys no longer cause input lists to break * Fix tgui async modals (#59822) * Fix tgui async modal constructor order * Fix another bug with the async modals * Refactors tgui list inputs to be more user-friendly (#61925) Co-authored-by: Watermelon914 <3052169-Watermelon914@users.noreply.gitlab.com> * TGUI input box framework (#63190) About The Pull Request Creates the framework for two new TGUI input boxes that can be toggled via game prefs. This does not convert any actual inputs to TGUI This does not convert any tgui_list_inputs into being toggleable Example pictures Input on a hand labeler. This has a MAX_LENGTH set, so it can be invalidated. Cancel always returns null. Enter button submits, if valid. text input (OUTDATED) Multiline input on newscaster. Newer version fills the window with a section, like the others multiline Sheets from a stack number input Why It's Good For The Game 1 So I did... Much sleeker input boxes Result should be a in place swap for most occurrences of input Renders casting as text/num/null obsolete but still doable Input validation from both sides handled Prefs toggle if you don't like the look Changelog cl add: TGUI input boxes are on the way! You can find new preferences in the menu. They will be on by default. /cl * TGUI input box conversions 1 (#63313) * Update to newest versions mostly, holy shit dude, learn to atomize your PRs * Add generic themes * Add Autofocus from tg/61313 (TGUI prefs) * Fixes the inputs for the linear and exponential sustain options of the song editor. (#65008) * Who added this * Tweaks and testing verb * Revert Co-authored-by: Bobbahbrown Co-authored-by: Aleksej Komarov Co-authored-by: Wayland-Smithy <64715958+Wayland-Smithy@users.noreply.github.com> Co-authored-by: Mothblocks <35135081+Mothblocks@users.noreply.github.com> Co-authored-by: AMonkeyThatCodes <20987591+AMonkeyThatCodes@users.noreply.github.com> Co-authored-by: Watermelon914 <37270891+Watermelon914@users.noreply.github.com> Co-authored-by: Watermelon914 <3052169-Watermelon914@users.noreply.gitlab.com> Co-authored-by: LatteKat <56778689+jupyterkat@users.noreply.github.com> Co-authored-by: Aronai Sieyes Co-authored-by: Jeremiah <42397676+jlsnow301@users.noreply.github.com> Co-authored-by: Ghom <42542238+Ghommie@users.noreply.github.com> --- beestation.dme | 4 + code/datums/components/cult_ritual_item.dm | 2 +- .../machinery/computer/launchpad_control.dm | 2 +- code/game/machinery/computer/law.dm | 2 +- code/game/machinery/cryopod.dm | 2 +- code/game/machinery/launch_pad.dm | 2 +- .../items/implants/implant_explosive.dm | 8 +- code/modules/admin/admin.dm | 7 +- code/modules/admin/permissionedit.dm | 2 +- code/modules/admin/secrets.dm | 14 +- code/modules/admin/sql_ban_system.dm | 2 +- code/modules/admin/stickyban.dm | 14 +- code/modules/admin/topic.dm | 5 +- code/modules/admin/verbs/_help.dm | 2 +- code/modules/admin/verbs/debug.dm | 6 +- code/modules/admin/verbs/healall.dm | 2 +- code/modules/admin/verbs/mapping.dm | 25 +- code/modules/admin/verbs/randomverbs.dm | 6 +- .../view_variables/mass_edit_variables.dm | 2 +- .../admin/view_variables/modify_variables.dm | 2 +- .../antagonists/brainwashing/brainwashing.dm | 2 +- .../antagonists/changeling/powers/headcrab.dm | 2 +- .../antagonists/clock_cult/mobs/cogscarab.dm | 2 +- .../clock_cult/structure/eminence_beacon.dm | 2 +- .../nukeop/equipment/nuclear_challenge.dm | 2 +- .../traitor/equipment/Malf_Modules.dm | 2 +- code/modules/awaymissions/corpse.dm | 4 +- code/modules/client/client_procs.dm | 31 ++- code/modules/events/pirates.dm | 2 +- code/modules/hydroponics/grown/replicapod.dm | 2 +- code/modules/mob/dead/observer/observer.dm | 2 +- code/modules/mob/living/brain/posibrain.dm | 2 +- code/modules/mob/living/living_sentience.dm | 2 +- .../friendly/drone/drones_as_items.dm | 2 +- .../hostile/mining_mobs/elites/elite.dm | 2 +- .../chemistry/machinery/chem_dispenser.dm | 2 +- code/modules/research/destructive_analyzer.dm | 4 +- code/modules/tgui/tgui_alert.dm | 181 +++++++++++++ code/modules/tgui/tgui_input_list.dm | 202 ++++++++++++++ code/modules/tgui/tgui_input_number.dm | 207 +++++++++++++++ code/modules/tgui/tgui_input_text.dm | 211 +++++++++++++++ interface/interface.dm | 2 + tgui/packages/tgui/components/Autofocus.tsx | 19 ++ .../tgui/components/RestrictedInput.js | 154 +++++++++++ tgui/packages/tgui/components/index.js | 2 + tgui/packages/tgui/interfaces/AlertModal.tsx | 162 ++++++++++++ .../tgui/interfaces/ListInputModal.tsx | 248 ++++++++++++++++++ .../tgui/interfaces/NumberInputModal.tsx | 118 +++++++++ .../tgui/interfaces/TextInputModal.tsx | 108 ++++++++ .../tgui/interfaces/common/InputButtons.tsx | 79 ++++++ .../tgui/interfaces/common/Loader.tsx | 14 + .../tgui/styles/components/Button.scss | 4 +- .../tgui/styles/interfaces/AlertModal.scss | 28 ++ .../tgui/styles/interfaces/ListInput.scss | 29 ++ tgui/packages/tgui/styles/main.scss | 2 + 55 files changed, 1869 insertions(+), 79 deletions(-) create mode 100644 code/modules/tgui/tgui_alert.dm create mode 100644 code/modules/tgui/tgui_input_list.dm create mode 100644 code/modules/tgui/tgui_input_number.dm create mode 100644 code/modules/tgui/tgui_input_text.dm create mode 100644 tgui/packages/tgui/components/Autofocus.tsx create mode 100644 tgui/packages/tgui/components/RestrictedInput.js 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 create mode 100644 tgui/packages/tgui/interfaces/common/InputButtons.tsx create mode 100644 tgui/packages/tgui/interfaces/common/Loader.tsx create mode 100644 tgui/packages/tgui/styles/interfaces/AlertModal.scss create mode 100644 tgui/packages/tgui/styles/interfaces/ListInput.scss diff --git a/beestation.dme b/beestation.dme index 992a7d69213ca..cb1420c2a7d17 100644 --- a/beestation.dme +++ b/beestation.dme @@ -3534,7 +3534,11 @@ #include "code\modules\tgui\states.dm" #include "code\modules\tgui\status_composers.dm" #include "code\modules\tgui\tgui.dm" +#include "code\modules\tgui\tgui_alert.dm" #include "code\modules\tgui\tgui_input_emoji.dm" +#include "code\modules\tgui\tgui_input_list.dm" +#include "code\modules\tgui\tgui_input_number.dm" +#include "code\modules\tgui\tgui_input_text.dm" #include "code\modules\tgui\tgui_select_picture.dm" #include "code\modules\tgui\tgui_window.dm" #include "code\modules\tgui\states\admin.dm" diff --git a/code/datums/components/cult_ritual_item.dm b/code/datums/components/cult_ritual_item.dm index 3c4e20c717680..5a795a11f77d2 100644 --- a/code/datums/components/cult_ritual_item.dm +++ b/code/datums/components/cult_ritual_item.dm @@ -348,7 +348,7 @@ to_chat(cultist, "\"I am already here. There is no need to try to summon me now.\"") return FALSE var/confirm_final = alert(cultist, "This is the FINAL step to summon Nar'Sie; it is a long, painful ritual and the crew will be alerted to your presence.", "Are you prepared for the final battle?", "My life for Nar'Sie!", "No") - if(confirm_final == "No") + if(confirm_final == "No" || !confirm_final) to_chat(cultist, "You decide to prepare further before scribing the rune.") return if(!check_if_in_ritual_site(cultist, cult_team)) diff --git a/code/game/machinery/computer/launchpad_control.dm b/code/game/machinery/computer/launchpad_control.dm index 6805bc354f44c..b82081e41e7c4 100644 --- a/code/game/machinery/computer/launchpad_control.dm +++ b/code/game/machinery/computer/launchpad_control.dm @@ -134,7 +134,7 @@ current_pad.display_name = new_name . = TRUE if("remove") - if(usr && alert(usr, "Are you sure?", "Unlink Launchpad", "I'm Sure", "Abort") != "Abort") + if(usr && alert(usr, "Are you sure?", "Unlink Launchpad", "I'm Sure", "Abort") == "I'm Sure") launchpads -= current_pad selected_id = null . = TRUE diff --git a/code/game/machinery/computer/law.dm b/code/game/machinery/computer/law.dm index 7039d59e679e8..8d6bbbad8331e 100644 --- a/code/game/machinery/computer/law.dm +++ b/code/game/machinery/computer/law.dm @@ -40,7 +40,7 @@ current = null return M.install(current.laws, user) - if(alert("Do you wish to scramble the upload code?", "Scramble Code", "Yes", "No") == "No") + if(alert("Do you wish to scramble the upload code?", "Scramble Code", "Yes", "No") != "Yes") return message_admins("[ADMIN_LOOKUPFLW(usr)] has scrambled the upload code [GLOB.upload_code]!") GLOB.upload_code = random_code(4) diff --git a/code/game/machinery/cryopod.dm b/code/game/machinery/cryopod.dm index 86c6c8894e0c2..a6c53074c8fb3 100644 --- a/code/game/machinery/cryopod.dm +++ b/code/game/machinery/cryopod.dm @@ -362,7 +362,7 @@ GLOBAL_LIST_EMPTY(cryopod_computers) to_chat(user, "You can't put [target] into [src]. They're conscious.") return else if(target.client) - if(alert(target,"Would you like to enter cryosleep?",,"Yes","No") == "No") + if(alert(target,"Would you like to enter cryosleep?",,"Yes","No") != "Yes") return var/generic_plsnoleave_message = " Please adminhelp before leaving the round, even if there are no administrators online!" diff --git a/code/game/machinery/launch_pad.dm b/code/game/machinery/launch_pad.dm index e7e67ac9dc79f..c3fe413c9c803 100644 --- a/code/game/machinery/launch_pad.dm +++ b/code/game/machinery/launch_pad.dm @@ -391,7 +391,7 @@ . = TRUE if("remove") . = TRUE - if(usr && alert(usr, "Are you sure?", "Unlink Launchpad", "I'm Sure", "Abort") != "Abort") + if(usr && alert(usr, "Are you sure?", "Unlink Launchpad", "I'm Sure", "Abort") == "I'm Sure") pad = null if("launch") sending = TRUE diff --git a/code/game/objects/items/implants/implant_explosive.dm b/code/game/objects/items/implants/implant_explosive.dm index 3df869644276f..be032cd839c2d 100644 --- a/code/game/objects/items/implants/implant_explosive.dm +++ b/code/game/objects/items/implants/implant_explosive.dm @@ -29,13 +29,13 @@ /obj/item/implant/explosive/activate(cause) . = ..() if(!cause || !imp_in || active) - return 0 + return FALSE if(cause == "action_button" && !popup) popup = TRUE var/response = alert(imp_in, "Are you sure you want to activate your [name]? This will cause you to explode!", "[name] Confirmation", "Yes", "No") popup = FALSE - if(response == "No") - return 0 + if(response != "Yes") + return FALSE heavy = round(heavy) medium = round(medium) weak = round(weak) @@ -49,7 +49,7 @@ if(imp_in) imp_in.gib(1) qdel(src) - return + return TRUE timed_explosion() /obj/item/implant/explosive/implant(mob/living/target, mob/user, silent = FALSE, force = FALSE) diff --git a/code/modules/admin/admin.dm b/code/modules/admin/admin.dm index 5f1743ad63faa..2824c3ccfcc57 100644 --- a/code/modules/admin/admin.dm +++ b/code/modules/admin/admin.dm @@ -320,11 +320,10 @@ if (!usr.client.holder) return var/confirm = alert("End the round and restart the game world?", "End Round", "Yes", "Cancel") - if(confirm == "Cancel") + if(confirm != "Yes") return - if(confirm == "Yes") - SSticker.force_ending = 1 - SSblackbox.record_feedback("tally", "admin_verb", 1, "End Round") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! + SSticker.force_ending = 1 + SSblackbox.record_feedback("tally", "admin_verb", 1, "End Round") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! /datum/admins/proc/dynamic_mode_options(mob/user) var/dat = {" diff --git a/code/modules/admin/permissionedit.dm b/code/modules/admin/permissionedit.dm index f7d252fdd94bb..c3c4625b25036 100644 --- a/code/modules/admin/permissionedit.dm +++ b/code/modules/admin/permissionedit.dm @@ -168,7 +168,7 @@ use_db = FALSE else use_db = alert("Permanent changes are saved to the database for future rounds, temporary changes will affect only the current round", "Permanent or Temporary?", "Permanent", "Temporary", "Cancel") - if(use_db == "Cancel") + if(use_db == "Cancel" || !use_db) return if(use_db == "Permanent") use_db = TRUE diff --git a/code/modules/admin/secrets.dm b/code/modules/admin/secrets.dm index 823cae8dd95ed..c9e4c76c456bb 100644 --- a/code/modules/admin/secrets.dm +++ b/code/modules/admin/secrets.dm @@ -134,7 +134,7 @@ GLOBAL_DATUM_INIT(admin_secrets, /datum/admin_secrets, new) if(!check_rights(R_ADMIN)) return var/delete_mobs = alert("Clear all mobs?","Confirm","Yes","No","Cancel") - if(delete_mobs == "Cancel") + if(delete_mobs == "Cancel" || !delete_mobs) return log_admin("[key_name(usr)] reset the thunderdome to default with delete_mobs==[delete_mobs].", 1) @@ -454,7 +454,7 @@ GLOBAL_DATUM_INIT(admin_secrets, /datum/admin_secrets, new) if(animetype =="Yes") droptype = alert("Make the uniforms undroppable?",,"Yes","No","Cancel") - if(animetype == "Cancel" || droptype == "Cancel") + if(animetype == "Cancel" || droptype == "Cancel" || !animetype || (!droptype && animetype == "Yes")) return SSblackbox.record_feedback("nested tally", "admin_secrets_fun_used", 1, list("Chinese Cartoons")) message_admins("[key_name_admin(usr)] made everything kawaii.") @@ -695,7 +695,7 @@ GLOBAL_DATUM_INIT(admin_secrets, /datum/admin_secrets, new) if("flipmovement") if(!check_rights(R_FUN)) return - if(alert("Flip all movement controls?","Confirm","Yes","Cancel") == "Cancel") + if(alert("Flip all movement controls?","Confirm","Yes","Cancel") != "Yes") return var/list/movement_keys = SSinput.movement_keys for(var/i in 1 to movement_keys.len) @@ -707,7 +707,7 @@ GLOBAL_DATUM_INIT(admin_secrets, /datum/admin_secrets, new) if("randommovement") if(!check_rights(R_FUN)) return - if(alert("Randomize all movement controls?","Confirm","Yes","Cancel") == "Cancel") + if(alert("Randomize all movement controls?","Confirm","Yes","Cancel") != "Yes") return var/list/movement_keys = SSinput.movement_keys for(var/i in 1 to movement_keys.len) @@ -719,7 +719,7 @@ GLOBAL_DATUM_INIT(admin_secrets, /datum/admin_secrets, new) if("custommovement") if(!check_rights(R_FUN)) return - if(alert("Are you sure you want to change every movement key?","Confirm","Yes","Cancel") == "Cancel") + if(alert("Are you sure you want to change every movement key?","Confirm","Yes","Cancel") != "Yes") return var/list/movement_keys = SSinput.movement_keys var/list/new_movement = list() @@ -740,7 +740,7 @@ GLOBAL_DATUM_INIT(admin_secrets, /datum/admin_secrets, new) if("resetmovement") if(!check_rights(R_FUN)) return - if(alert("Are you sure you want to reset movement keys to default?","Confirm","Yes","Cancel") == "Cancel") + if(alert("Are you sure you want to reset movement keys to default?","Confirm","Yes","Cancel") != "Yes") return SSinput.setup_default_movement_keys() message_admins("[key_name_admin(usr)] has reset all movement keys.") @@ -820,7 +820,7 @@ GLOBAL_DATUM_INIT(admin_secrets, /datum/admin_secrets, new) if(E) E.processing = FALSE if(E.announceWhen>0) - if(alert(usr, "Would you like to alert the crew?", "Alert", "Yes", "No") == "No") + if(alert(usr, "Would you like to alert the crew?", "Alert", "Yes", "No") != "Yes") E.announceChance = 0 E.processing = TRUE if (usr) diff --git a/code/modules/admin/sql_ban_system.dm b/code/modules/admin/sql_ban_system.dm index 489c49b7ccfed..fb7da927dc0dc 100644 --- a/code/modules/admin/sql_ban_system.dm +++ b/code/modules/admin/sql_ban_system.dm @@ -819,7 +819,7 @@ to_chat(usr, "Failed to establish database connection.") return var/target = ban_target_string(player_key, player_ip, player_cid) - if(alert(usr, "Please confirm unban of [target] from [role].", "Unban confirmation", "Yes", "No") == "No") + if(alert(usr, "Please confirm unban of [target] from [role].", "Unban confirmation", "Yes", "No") != "Yes") return var/kn = key_name(usr) var/kna = key_name_admin(usr) diff --git a/code/modules/admin/stickyban.dm b/code/modules/admin/stickyban.dm index c3cabea87f0bb..936b5af674c60 100644 --- a/code/modules/admin/stickyban.dm +++ b/code/modules/admin/stickyban.dm @@ -61,7 +61,7 @@ if (!ban) to_chat(usr, "Error: No sticky ban for [ckey] found!") return - if (alert("Are you sure you want to remove the sticky ban on [ckey]?","Are you sure","Yes","No") == "No") + if (alert("Are you sure you want to remove the sticky ban on [ckey]?","Are you sure","Yes","No") != "Yes") return if (!get_stickyban_from_ckey(ckey)) to_chat(usr, "Error: The ban disappeared.") @@ -98,7 +98,7 @@ to_chat(usr, "Error: [alt] is not linked to [ckey]'s sticky ban!") return - if (alert("Are you sure you want to disassociate [alt] from [ckey]'s sticky ban? \nNote: Nothing stops byond from re-linking them, Use \[E] to exempt them","Are you sure","Yes","No") == "No") + if (alert("Are you sure you want to disassociate [alt] from [ckey]'s sticky ban? \nNote: Nothing stops byond from re-linking them, Use \[E] to exempt them","Are you sure","Yes","No") != "Yes") return //we have to do this again incase something changes @@ -180,7 +180,7 @@ to_chat(usr, "Error: [alt] is not linked to [ckey]'s sticky ban!") return - if (alert("Are you sure you want to exempt [alt] from [ckey]'s sticky ban?","Are you sure","Yes","No") == "No") + if (alert("Are you sure you want to exempt [alt] from [ckey]'s sticky ban?","Are you sure","Yes","No") != "Yes") return //we have to do this again incase something changes @@ -230,7 +230,7 @@ to_chat(usr, "Error: [alt] is not exempt from [ckey]'s sticky ban!") return - if (alert("Are you sure you want to unexempt [alt] from [ckey]'s sticky ban?","Are you sure","Yes","No") == "No") + if (alert("Are you sure you want to unexempt [alt] from [ckey]'s sticky ban?","Are you sure","Yes","No") != "Yes") return //we have to do this again incase something changes @@ -272,7 +272,7 @@ var/ckey = data["ckey"] - if (alert("Are you sure you want to put [ckey]'s stickyban on timeout until next round (or removed)?","Are you sure","Yes","No") == "No") + if (alert("Are you sure you want to put [ckey]'s stickyban on timeout until next round (or removed)?","Are you sure","Yes","No") != "Yes") return var/ban = get_stickyban_from_ckey(ckey) if (!ban) @@ -298,7 +298,7 @@ return var/ckey = data["ckey"] - if (alert("Are you sure you want to lift the timeout on [ckey]'s stickyban?","Are you sure","Yes","No") == "No") + if (alert("Are you sure you want to lift the timeout on [ckey]'s stickyban?","Are you sure","Yes","No") != "Yes") return var/ban = get_stickyban_from_ckey(ckey) @@ -323,7 +323,7 @@ if (!data["ckey"]) return var/ckey = data["ckey"] - if (alert("Are you sure you want to revert the sticky ban on [ckey] to its state at round start (or last edit)?","Are you sure","Yes","No") == "No") + if (alert("Are you sure you want to revert the sticky ban on [ckey] to its state at round start (or last edit)?","Are you sure","Yes","No") != "Yes") return var/ban = get_stickyban_from_ckey(ckey) if (!ban) diff --git a/code/modules/admin/topic.dm b/code/modules/admin/topic.dm index 6a634fcadf9bd..eddb7c1e82ba2 100644 --- a/code/modules/admin/topic.dm +++ b/code/modules/admin/topic.dm @@ -1702,10 +1702,9 @@ if(!check_rights(R_ADMIN)) return var/confirm = alert("Are you sure you want to reboot the server?", "Confirm Reboot", "Yes", "No") - if(confirm == "No") + if(confirm != "Yes") return - if(confirm == "Yes") - restart() + restart() else if(href_list["check_teams"]) if(!check_rights(R_ADMIN)) diff --git a/code/modules/admin/verbs/_help.dm b/code/modules/admin/verbs/_help.dm index 8e6a3f3de711c..89a7ddb110157 100644 --- a/code/modules/admin/verbs/_help.dm +++ b/code/modules/admin/verbs/_help.dm @@ -129,7 +129,7 @@ if("claim") if(ticket.claimee) var/confirm = alert("This ticket is already claimed, override claim?", null,"Yes", "No") - if(confirm == "No") + if(confirm != "Yes") return claim_ticket = CLAIM_OVERRIDE if("reject") diff --git a/code/modules/admin/verbs/debug.dm b/code/modules/admin/verbs/debug.dm index 8d968302b9615..39ca82623804f 100644 --- a/code/modules/admin/verbs/debug.dm +++ b/code/modules/admin/verbs/debug.dm @@ -506,7 +506,7 @@ But you can call procs that are of type /mob/living/carbon/human/proc/ for that else H = M if(H.l_store || H.r_store || H.s_store) //saves a lot of time for admins and coders alike - if(alert("Drop Items in Pockets? No will delete them.", "Robust quick dress shop", "Yes", "No") == "No") + if(alert("Drop Items in Pockets? No will delete them.", "Robust quick dress shop", "Yes", "No") != "Yes") delete_pocket = TRUE SSblackbox.record_feedback("tally", "admin_verb", 1, "Select Equipment") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! @@ -776,7 +776,7 @@ But you can call procs that are of type /mob/living/carbon/human/proc/ for that if (response == "Jump") usr.forceMove(get_turf(exists[template])) return - else if (response == "Cancel") + else if (response != "Place Another") return var/len = GLOB.ruin_landmarks.len @@ -800,7 +800,7 @@ But you can call procs that are of type /mob/living/carbon/human/proc/ for that if(ruin_size < 10 || ruin_size >= 200) return var/response = alert(src, "This will place the ruin at your current location.", "Spawn Ruin", "Spawn Ruin", "Cancel") - if (response == "Cancel") + if (response != "Spawn Ruin") return var/border_size = (world.maxx - ruin_size) / 2 generate_space_ruin(mob.x, mob.y, mob.z, border_size, border_size) diff --git a/code/modules/admin/verbs/healall.dm b/code/modules/admin/verbs/healall.dm index ae4fac4e0fe26..9033e62386e97 100644 --- a/code/modules/admin/verbs/healall.dm +++ b/code/modules/admin/verbs/healall.dm @@ -8,7 +8,7 @@ if(!check_rights(R_FUN)) to_chat(src, "You need the fun permission to use this command.") return - if(alert(src, "Confirm Heal All?","Are you sure?","Yes","No") == "No") + if(alert(src, "Confirm Heal All?","Are you sure?","Yes","No") != "Yes") return message_admins("[key_name_admin(usr)] healed all living mobs") log_admin("[key_name_admin(usr)] healed all living mobs") diff --git a/code/modules/admin/verbs/mapping.dm b/code/modules/admin/verbs/mapping.dm index 0d9b014cb5b82..63a75cfc1a859 100644 --- a/code/modules/admin/verbs/mapping.dm +++ b/code/modules/admin/verbs/mapping.dm @@ -51,7 +51,8 @@ GLOBAL_LIST_INIT(admin_verbs_debug_mapping, list( /client/proc/show_line_profiling, /client/proc/create_mapping_job_icons, /client/proc/debug_z_levels, - /client/proc/place_ruin + /client/proc/place_ruin, + /client/proc/test_tgui_inputs, )) GLOBAL_PROTECT(admin_verbs_debug_mapping) @@ -375,3 +376,25 @@ GLOBAL_VAR_INIT(say_disabled, FALSE) messages += "" to_chat(src, messages.Join("")) + +/client/proc/test_tgui_inputs() + set name = "Test TGUI Inputs" + set category = "Debug" + var/response = tgui_alert(usr, "Message Here", "Title Here", list("Button 1", "Button 2", "Button 3")) + to_chat(usr, response) + response = tgui_alert(usr, "Message Here", "Title Here", list("Yes", "No")) + to_chat(usr, response) + var/list/L = list() + for (var/obj/machinery/camera/cam in GLOB.cameranet.cameras) + L["[cam.c_tag]"] = cam + response = tgui_input_list(usr, "Message Here", "Title Here", L) + to_chat(usr, response) + response = tgui_input_number(usr, "Message Here", "Title Here", 10, 500, 2) + to_chat(usr, response) + response = tgui_input_text(usr, "Message Here", "Title Here", "Default Text", 32, FALSE) + to_chat(usr, response) + response = tgui_input_text(usr, "Message Here", "Title Here", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore\ + et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit\ + in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id\ + est laborum.", 1024, TRUE) + to_chat(usr, response) diff --git a/code/modules/admin/verbs/randomverbs.dm b/code/modules/admin/verbs/randomverbs.dm index a7626fbc3fed6..392b34326c565 100644 --- a/code/modules/admin/verbs/randomverbs.dm +++ b/code/modules/admin/verbs/randomverbs.dm @@ -639,7 +639,7 @@ Traitors and the like can also be revived with the previous role mostly intact. if ((devastation != -1) || (heavy != -1) || (light != -1) || (flash != -1) || (flames != -1)) if ((devastation > 20) || (heavy > 20) || (light > 20) || (flames > 20)) - if (alert(src, "Are you sure you want to do this? It will laaag.", "Confirmation", "Yes", "No") == "No") + if (alert(src, "Are you sure you want to do this? It will laaag.", "Confirmation", "Yes", "No") != "Yes") return explosion(O, devastation, heavy, light, flash, null, null,flames) @@ -683,7 +683,7 @@ Traitors and the like can also be revived with the previous role mostly intact. return var/confirm = alert(src, "Drop a brain?", "Confirm", "Yes", "No","Cancel") - if(confirm == "Cancel") + if(confirm == "Cancel" || !confirm) return //Due to the delay here its easy for something to have happened to the mob if(!M) @@ -800,7 +800,7 @@ Traitors and the like can also be revived with the previous role mostly intact. var/notifyplayers = alert(src, "Do you want to notify the players?", "Options", "Yes", "No", "Cancel") - if(notifyplayers == "Cancel") + if(notifyplayers == "Cancel" || !notifyplayers) return log_admin("Admin [key_name(src)] has forced the players to have random appearances.") diff --git a/code/modules/admin/view_variables/mass_edit_variables.dm b/code/modules/admin/view_variables/mass_edit_variables.dm index e29580b3b27dc..91232299f9214 100644 --- a/code/modules/admin/view_variables/mass_edit_variables.dm +++ b/code/modules/admin/view_variables/mass_edit_variables.dm @@ -143,7 +143,7 @@ if (VV_NEW_TYPE) var/many = alert(src, "Create only one [value["type"]] and assign each or a new one for each thing", "How Many", "One", "Many", "Cancel") - if (many == "Cancel") + if (many == "Cancel" || !many) return if (many == "Many") many = TRUE diff --git a/code/modules/admin/view_variables/modify_variables.dm b/code/modules/admin/view_variables/modify_variables.dm index dbc4af543f105..a74e588019b92 100644 --- a/code/modules/admin/view_variables/modify_variables.dm +++ b/code/modules/admin/view_variables/modify_variables.dm @@ -179,7 +179,7 @@ GLOBAL_PROTECT(VVpixelmovement) return var/assoc = 0 var/prompt = alert(src, "Do you want to edit the key or its assigned value?", "Associated List", "Key", "Assigned Value", "Cancel") - if (prompt == "Cancel") + if (prompt == "Cancel" || !prompt) return if (prompt == "Assigned Value") assoc = 1 diff --git a/code/modules/antagonists/brainwashing/brainwashing.dm b/code/modules/antagonists/brainwashing/brainwashing.dm index f0ce4205bfd10..57d082ec819ad 100644 --- a/code/modules/antagonists/brainwashing/brainwashing.dm +++ b/code/modules/antagonists/brainwashing/brainwashing.dm @@ -88,7 +88,7 @@ log_objective(C, objective, admin) while(alert(admin,"Add another objective?","More Brainwashing","Yes","No") == "Yes") - if(alert(admin,"Confirm Brainwashing?","Are you sure?","Yes","No") == "No") + if(alert(admin,"Confirm Brainwashing?","Are you sure?","Yes","No") != "Yes") return if(!LAZYLEN(objectives)) diff --git a/code/modules/antagonists/changeling/powers/headcrab.dm b/code/modules/antagonists/changeling/powers/headcrab.dm index 4bfb77c9906eb..85cf9dd85961a 100644 --- a/code/modules/antagonists/changeling/powers/headcrab.dm +++ b/code/modules/antagonists/changeling/powers/headcrab.dm @@ -11,7 +11,7 @@ /datum/action/changeling/headcrab/sting_action(mob/user) set waitfor = FALSE - if(alert("Are we sure we wish to kill ourself and create a headslug?",,"Yes", "No") == "No") + if(alert("Are we sure we wish to kill ourself and create a headslug?",,"Yes", "No") != "Yes") return if(isliving(user)) var/mob/living/L = user diff --git a/code/modules/antagonists/clock_cult/mobs/cogscarab.dm b/code/modules/antagonists/clock_cult/mobs/cogscarab.dm index b4115d7a98fa3..8d233a2d20db5 100644 --- a/code/modules/antagonists/clock_cult/mobs/cogscarab.dm +++ b/code/modules/antagonists/clock_cult/mobs/cogscarab.dm @@ -74,7 +74,7 @@ GLOBAL_LIST_INIT(cogscarabs, list()) to_chat(user, "Can't become a cogscarab before the game has started.") return var/be_drone = alert("Become a cogscarab? (Warning, You can no longer be cloned!)",,"Yes","No") - if(be_drone == "No" || QDELETED(src) || !isobserver(user)) + if(be_drone != "Yes" || QDELETED(src) || !isobserver(user)) return var/mob/living/simple_animal/drone/D = new mob_type(get_turf(loc)) if(!D.default_hatmask && seasonal_hats && possible_seasonal_hats.len) diff --git a/code/modules/antagonists/clock_cult/structure/eminence_beacon.dm b/code/modules/antagonists/clock_cult/structure/eminence_beacon.dm index 6b9ae6d5d1b6e..45950cc4c9743 100644 --- a/code/modules/antagonists/clock_cult/structure/eminence_beacon.dm +++ b/code/modules/antagonists/clock_cult/structure/eminence_beacon.dm @@ -21,7 +21,7 @@ to_chat(user, "The Eminence has already been released.") return var/option = alert(user,"Who shall control the Eminence?",,"Yourself","A ghost", "Cancel") - if(option == "Cancel") + if(option != "A ghost") return else if(option == "Yourself") hierophant_message("[user] has elected themselves to become the Eminence. Interact with [src] to object.", span="") diff --git a/code/modules/antagonists/nukeop/equipment/nuclear_challenge.dm b/code/modules/antagonists/nukeop/equipment/nuclear_challenge.dm index 1738534592f93..526ea0f510175 100644 --- a/code/modules/antagonists/nukeop/equipment/nuclear_challenge.dm +++ b/code/modules/antagonists/nukeop/equipment/nuclear_challenge.dm @@ -27,7 +27,7 @@ if(!check_allowed(user)) return - if(are_you_sure == "No") + if(are_you_sure != "Yes") to_chat(user, "On second thought, the element of surprise isn't so bad after all.") return diff --git a/code/modules/antagonists/traitor/equipment/Malf_Modules.dm b/code/modules/antagonists/traitor/equipment/Malf_Modules.dm index 415c6e270cc2b..6e04609ec07ca 100644 --- a/code/modules/antagonists/traitor/equipment/Malf_Modules.dm +++ b/code/modules/antagonists/traitor/equipment/Malf_Modules.dm @@ -666,7 +666,7 @@ GLOBAL_LIST_INIT(blacklisted_malf_machines, typecacheof(list( if(!owner_AI.can_place_transformer(src)) return active = TRUE - if(alert(owner, "Are you sure you want to place the machine here?", "Are you sure?", "Yes", "No") == "No") + if(alert(owner, "Are you sure you want to place the machine here?", "Are you sure?", "Yes", "No") != "Yes") active = FALSE return if(!owner_AI.can_place_transformer(src)) diff --git a/code/modules/awaymissions/corpse.dm b/code/modules/awaymissions/corpse.dm index 9216004a441ef..9e120c9403c56 100644 --- a/code/modules/awaymissions/corpse.dm +++ b/code/modules/awaymissions/corpse.dm @@ -51,7 +51,7 @@ to_chat(user, "You have died recently, you must wait [(user.client.next_ghost_role_tick - world.time)/10] seconds until you can use a ghost spawner.") return var/ghost_role = alert("Become [mob_name]? (Warning, You can no longer be cloned!)",,"Yes","No") - if(ghost_role == "No" || !loc) + if(ghost_role != "Yes" || !loc) return log_game("[key_name(user)] became [mob_name]") create(ckey = user.ckey) @@ -598,7 +598,7 @@ //ATTACK HAND IGNORING PARENT RETURN VALUE /obj/effect/mob_spawn/human/alive/space_bar_patron/attack_hand(mob/user) var/despawn = alert("Return to cryosleep? (Warning, Your mob will be deleted!)",,"Yes","No") - if(despawn == "No" || !loc || !Adjacent(user)) + if(despawn != "Yes" || !loc || !Adjacent(user)) return user.visible_message("[user.name] climbs back into cryosleep...") qdel(user) diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm index 1782c012af447..db56eab834537 100644 --- a/code/modules/client/client_procs.dm +++ b/code/modules/client/client_procs.dm @@ -1045,23 +1045,22 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( addtimer(CALLBACK(src, .proc/restore_account_identifier), 20) //Don't DoS DB queries, asshole var/confirm = alert("Do NOT share the verification ID in the following popup. Understand?", "Important Warning", "Yes", "Cancel") - if(confirm == "Cancel") + if(confirm != "Yes") return - if(confirm == "Yes") - var/uuid = fetch_uuid() - if(!uuid) - alert("Failed to fetch your verification ID. Try again later. If problems persist, tell an admin.", "Account Verification", "Okay") - log_sql("Failed to fetch UUID for [key_name(src)]") - else - var/dat - dat += "

Account Identifier

" - dat += "
" - dat += "

Do NOT share this id:

" - dat += "
" - dat += "[uuid]" - - src << browse(dat, "window=accountidentifier;size=600x320") - onclose(src, "accountidentifier") + var/uuid = fetch_uuid() + if(!uuid) + alert("Failed to fetch your verification ID. Try again later. If problems persist, tell an admin.", "Account Verification", "Okay") + log_sql("Failed to fetch UUID for [key_name(src)]") + else + var/dat + dat += "

Account Identifier

" + dat += "
" + dat += "

Do NOT share this id:

" + dat += "
" + dat += "[uuid]" + + src << browse(dat, "window=accountidentifier;size=600x320") + onclose(src, "accountidentifier") /client/proc/restore_account_identifier() add_verb(/client/proc/show_account_identifier) diff --git a/code/modules/events/pirates.dm b/code/modules/events/pirates.dm index 71fd946ae5100..b00f8a3dea977 100644 --- a/code/modules/events/pirates.dm +++ b/code/modules/events/pirates.dm @@ -128,7 +128,7 @@ /obj/machinery/shuttle_scrambler/interact(mob/user) if(!active) - if(alert(user, "Turning the scrambler on will make the shuttle trackable by GPS. Are you sure you want to do it?", "Scrambler", "Yes", "Cancel") == "Cancel") + if(alert(user, "Turning the scrambler on will make the shuttle trackable by GPS. Are you sure you want to do it?", "Scrambler", "Yes", "Cancel") != "Yes") return if(active || !user.canUseTopic(src, BE_CLOSE)) return diff --git a/code/modules/hydroponics/grown/replicapod.dm b/code/modules/hydroponics/grown/replicapod.dm index dac6c141ede9b..69f38af8fb401 100644 --- a/code/modules/hydroponics/grown/replicapod.dm +++ b/code/modules/hydroponics/grown/replicapod.dm @@ -109,7 +109,7 @@ if(!make_podman) // Prevent accidental harvesting. Make sure the user REALLY wants to do this if there's a chance of this coming from a living creature. if(mind || ckey) - if(alert("The pod is currently devoid of soul. There is a possibility that a soul could claim this creature, or you could harvest it for seeds.", "Harvest Seeds?", "Harvest Seeds", "Cancel") == "Cancel") + if(alert("The pod is currently devoid of soul. There is a possibility that a soul could claim this creature, or you could harvest it for seeds.", "Harvest Seeds?", "Harvest Seeds", "Cancel") != "Harvest Seeds") return result // If this plant has already been harvested, return early. diff --git a/code/modules/mob/dead/observer/observer.dm b/code/modules/mob/dead/observer/observer.dm index 29ddac10a7598..999cd61a9be59 100644 --- a/code/modules/mob/dead/observer/observer.dm +++ b/code/modules/mob/dead/observer/observer.dm @@ -644,7 +644,7 @@ This is the proc mobs get to turn into a ghost. Forked from ghostize due to comp return 0 if(can_reenter_corpse && mind && mind.current) - if(alert(src, "Your soul is still tied to your former life as [mind.current.name], if you go forward there is no going back to that life. Are you sure you wish to continue?", "Move On", "Yes", "No") == "No") + if(alert(src, "Your soul is still tied to your former life as [mind.current.name], if you go forward there is no going back to that life. Are you sure you wish to continue?", "Move On", "Yes", "No") != "Yes") return 0 if(target.key) to_chat(src, "Someone has taken this body while you were choosing!") diff --git a/code/modules/mob/living/brain/posibrain.dm b/code/modules/mob/living/brain/posibrain.dm index 8109f1c9ff279..6a292d9634c36 100644 --- a/code/modules/mob/living/brain/posibrain.dm +++ b/code/modules/mob/living/brain/posibrain.dm @@ -100,7 +100,7 @@ GLOBAL_VAR(posibrain_notify_cooldown) to_chat(user, "[src] fizzles slightly. Sadly it doesn't take those who suicided!") return var/posi_ask = alert("Become a [name]? (Warning, You can no longer be cloned, and all past lives will be forgotten!)","Are you positive?","Yes","No") - if(posi_ask == "No" || QDELETED(src)) + if(posi_ask != "Yes" || QDELETED(src)) return if(brainmob.suiciding) //clear suicide status if the old occupant suicided. brainmob.set_suicide(FALSE) diff --git a/code/modules/mob/living/living_sentience.dm b/code/modules/mob/living/living_sentience.dm index 293c8e920ae7a..2e349cbcd38c9 100644 --- a/code/modules/mob/living/living_sentience.dm +++ b/code/modules/mob/living/living_sentience.dm @@ -29,7 +29,7 @@ if(key || !playable || stat) return 0 var/question = alert("Do you want to become [name]?", "[name]", "Yes", "No") - if(question == "No" || !src || QDELETED(src)) + if(question != "Yes" || !src || QDELETED(src)) return TRUE if(key) to_chat(user, "Someone else already took [name].") diff --git a/code/modules/mob/living/simple_animal/friendly/drone/drones_as_items.dm b/code/modules/mob/living/simple_animal/friendly/drone/drones_as_items.dm index e09b6fc4f21dd..b5dd3f01af466 100644 --- a/code/modules/mob/living/simple_animal/friendly/drone/drones_as_items.dm +++ b/code/modules/mob/living/simple_animal/friendly/drone/drones_as_items.dm @@ -57,7 +57,7 @@ to_chat(user, "Can't become a drone before the game has started.") return var/be_drone = alert("Become a drone? (Warning, You can no longer be cloned!)",,"Yes","No") - if(be_drone == "No" || QDELETED(src) || !isobserver(user)) + if(be_drone != "Yes" || QDELETED(src) || !isobserver(user)) return var/mob/living/simple_animal/drone/D = new mob_type(get_turf(loc)) if(!D.default_hatmask && seasonal_hats && possible_seasonal_hats.len) diff --git a/code/modules/mob/living/simple_animal/hostile/mining_mobs/elites/elite.dm b/code/modules/mob/living/simple_animal/hostile/mining_mobs/elites/elite.dm index 66f3ce4c77a49..f5bbe1cf3477f 100644 --- a/code/modules/mob/living/simple_animal/hostile/mining_mobs/elites/elite.dm +++ b/code/modules/mob/living/simple_animal/hostile/mining_mobs/elites/elite.dm @@ -42,7 +42,7 @@ var/obj/structure/elite_tumor/T = target if(T.mychild == src && T.activity == TUMOR_PASSIVE) var/elite_remove = alert("Re-enter the tumor?", "Despawn yourself?", "Yes", "No") - if(elite_remove == "No" || !src || QDELETED(src)) + if(elite_remove != "Yes" || !src || QDELETED(src)) return T.mychild = null T.activity = TUMOR_INACTIVE diff --git a/code/modules/reagents/chemistry/machinery/chem_dispenser.dm b/code/modules/reagents/chemistry/machinery/chem_dispenser.dm index 5a512ec3385d7..7749795b7ff5d 100644 --- a/code/modules/reagents/chemistry/machinery/chem_dispenser.dm +++ b/code/modules/reagents/chemistry/machinery/chem_dispenser.dm @@ -309,7 +309,7 @@ var/name = stripped_input(usr,"Name","What do you want to name this recipe?", "Recipe", MAX_NAME_LEN) if(!usr.canUseTopic(src, !issilicon(usr))) return - if(saved_recipes[name] && alert("\"[name]\" already exists, do you want to overwrite it?",, "Yes", "No") == "No") + if(saved_recipes[name] && alert("\"[name]\" already exists, do you want to overwrite it?",, "Yes", "No") != "Yes") return if(name && recording_recipe) for(var/reagent in recording_recipe) diff --git a/code/modules/research/destructive_analyzer.dm b/code/modules/research/destructive_analyzer.dm index 42700bfa9e92a..7acaeda553913 100644 --- a/code/modules/research/destructive_analyzer.dm +++ b/code/modules/research/destructive_analyzer.dm @@ -120,7 +120,7 @@ Note: Must be placed within 3 tiles of the R&D Console if(length(worths) && !length(differences)) return FALSE var/choice = input("Are you sure you want to destroy [loaded_item] to [!length(worths) ? "reveal [TN.display_name]" : "boost [TN.display_name] by [json_encode(differences)] point\s"]?") in list("Proceed", "Cancel") - if(choice == "Cancel") + if(choice == "Cancel" || !choice) return FALSE if(QDELETED(loaded_item) || QDELETED(linked_console) || !user.Adjacent(linked_console) || QDELETED(src)) return FALSE @@ -138,7 +138,7 @@ Note: Must be placed within 3 tiles of the R&D Console else if(loaded_item.materials.len) user_mode_string = " for material reclamation" var/choice = input("Are you sure you want to destroy [loaded_item][user_mode_string]?") in list("Proceed", "Cancel") - if(choice == "Cancel") + if(choice != "Proceed") return FALSE if(QDELETED(loaded_item) || QDELETED(linked_console) || !user.Adjacent(linked_console) || QDELETED(src)) return FALSE diff --git a/code/modules/tgui/tgui_alert.dm b/code/modules/tgui/tgui_alert.dm new file mode 100644 index 0000000000000..13006c4e5b6ca --- /dev/null +++ b/code/modules/tgui/tgui_alert.dm @@ -0,0 +1,181 @@ +/** + * 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 + // Client does NOT have tgui_input on: Returns regular input + /*if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input)) + 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_modal/alert = new(user, message, title, buttons, timeout, autofocus) + alert.ui_interact(user) + alert.wait() + if (alert) + . = alert.choice + qdel(alert) + +/** + * Creates an asynchronous TGUI alert window with an associated callback. + * + * This proc should be used to create alerts that invoke a callback with the user's chosen option. + * 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. + * * callback - The callback to be invoked when a choice is made. + * * timeout - The timeout of the alert, after which the modal will close and qdel itself. Disabled by default, can be set to seconds otherwise. + * * autofocus - The bool that controls if this alert should grab window focus. + */ +/proc/tgui_alert_async(mob/user, message = "", title, list/buttons = list("Ok"), datum/callback/callback, timeout = 0, autofocus = 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.read_preference(/datum/preference/toggle/tgui_input)) + 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_modal/async/alert = new(user, message, title, buttons, callback, timeout, autofocus) + alert.ui_interact(user) + +/** + * # tgui_modal + * + * Datum used for instantiating and using a TGUI-controlled modal that prompts the user with + * a message and has buttons for responses. + */ +/datum/tgui_modal + /// 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_modal was created, for displaying timeout progress. + var/start_time + /// The lifespan of the tgui_modal, 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_modal was closed by the user. + var/closed + +/datum/tgui_modal/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_modal/Destroy(force, ...) + SStgui.close_uis(src) + QDEL_NULL(buttons) + . = ..() + +/** + * Waits for a user's response to the tgui_modal's prompt before returning. Returns early if + * the window was closed by the user. + */ +/datum/tgui_modal/proc/wait() + while (!choice && !closed && !QDELETED(src)) + stoplag(1) + +/datum/tgui_modal/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_modal/ui_close(mob/user) + . = ..() + closed = TRUE + +/datum/tgui_modal/ui_state(mob/user) + return GLOB.always_state + +/datum/tgui_modal/ui_data(mob/user) + . = list() + .["autofocus"] = autofocus + .["buttons"] = buttons + .["message"] = message + .["preferences"] = list() + .["preferences"]["large_buttons"] = TRUE//user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) + .["preferences"]["swapped_buttons"] = TRUE//user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) + .["title"] = title + if(timeout) + .["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + +/datum/tgui_modal/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_modal/proc/set_choice(choice) + src.choice = choice + +/** + * # async tgui_modal + * + * An asynchronous version of tgui_modal to be used with callbacks instead of waiting on user responses. + */ +/datum/tgui_modal/async + /// The callback to be invoked by the tgui_modal upon having a choice made. + var/datum/callback/callback + +/datum/tgui_modal/async/New(mob/user, message, title, list/buttons, callback, timeout, autofocus) + ..(user, message, title, buttons, timeout, autofocus) + src.callback = callback + +/datum/tgui_modal/async/Destroy(force, ...) + QDEL_NULL(callback) + . = ..() + +/datum/tgui_modal/async/set_choice(choice) + . = ..() + if(!isnull(src.choice)) + callback?.InvokeAsync(src.choice) + +/datum/tgui_modal/async/wait() + return diff --git a/code/modules/tgui/tgui_input_list.dm b/code/modules/tgui/tgui_input_list.dm new file mode 100644 index 0000000000000..2eff761716b3b --- /dev/null +++ b/code/modules/tgui/tgui_input_list.dm @@ -0,0 +1,202 @@ +/** + * 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.read_preference(/datum/preference/toggle/tgui_input)) + // return input(user, message, title) 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) + +/** + * Creates an asynchronous TGUI input list window with an associated callback. + * + * This proc should be used to create inputs that invoke a callback with the user's chosen option. + * 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. + * * callback - The callback to be invoked when a choice is made. + * * 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_async(mob/user, message, title = "Select", list/items, default, datum/callback/callback, timeout = 60 SECONDS) + 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.read_preference(/datum/preference/toggle/tgui_input)) + // return input(user, message, title) as null|anything in items + var/datum/tgui_list_input/async/input = new(user, message, title, items, default, callback, timeout) + input.ui_interact(user) + +/** + * # 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) + . = ..() + +/** + * 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 GLOB.always_state + +/datum/tgui_list_input/ui_static_data(mob/user) + . = list() + .["items"] = items + +/datum/tgui_list_input/ui_data(mob/user) + . = list() + .["init_value"] = default || items[1] + .["message"] = message + .["preferences"] = list() + .["preferences"]["large_buttons"] = TRUE//user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) + .["preferences"]["swapped_buttons"] = TRUE//user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) + .["title"] = title + if(timeout) + .["timeout"] = clamp((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS), 0, 1) + +/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 + +/** + * # async tgui_list_input + * + * An asynchronous version of tgui_list_input to be used with callbacks instead of waiting on user responses. + */ +/datum/tgui_list_input/async + /// The callback to be invoked by the tgui_list_input upon having a choice made. + var/datum/callback/callback + +/datum/tgui_list_input/async/New(mob/user, message, title, list/items, default, callback, timeout) + ..(user, message, title, items, default, timeout) + src.callback = callback + +/datum/tgui_list_input/async/Destroy(force, ...) + QDEL_NULL(callback) + . = ..() + +/datum/tgui_list_input/async/set_choice(choice) + . = ..() + if(!isnull(src.choice)) + callback?.InvokeAsync(src.choice) + +/datum/tgui_list_input/async/wait() + return diff --git a/code/modules/tgui/tgui_input_number.dm b/code/modules/tgui/tgui_input_number.dm new file mode 100644 index 0000000000000..19fed34c3eaca --- /dev/null +++ b/code/modules/tgui/tgui_input_number.dm @@ -0,0 +1,207 @@ +/** + * 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.read_preference(/datum/preference/toggle/tgui_input)) + // 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) + +/** + * Creates an asynchronous TGUI number input window with an associated callback. + * + * This proc should be used to create number inputs that invoke a callback with the user's entry. + * + * 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. + * * callback - The callback to be invoked when a choice is made. + * * 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_async(mob/user, message, title = "Number Input", default = 0, max_value = 10000, min_value = 0, datum/callback/callback, timeout = 60 SECONDS, 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.read_preference(/datum/preference/toggle/tgui_input)) + // 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/async/number_input = new(user, message, title, default, max_value, min_value, callback, timeout, round_value) + number_input.ui_interact(user) + +/** + * # 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 + /// 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 + /// Whether the submitted number is rounded down into an integer. + var/round_value + + +/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) + . = ..() + +/** + * 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 GLOB.always_state + +/datum/tgui_input_number/ui_data(mob/user) + . = list() + .["init_value"] = default // Default is a reserved keyword + .["max_value"] = max_value + .["message"] = message + .["min_value"] = min_value + .["preferences"] = list() + .["preferences"]["large_buttons"] = TRUE//user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) + .["preferences"]["swapped_buttons"] = TRUE//user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) + .["title"] = title + if(timeout) + .["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + +/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 + +/** + * # async tgui_input_number + * + * An asynchronous version of tgui_input_number to be used with callbacks instead of waiting on user responses. + */ +/datum/tgui_input_number/async + /// The callback to be invoked by the tgui_input_number upon having a choice made. + var/datum/callback/callback + +/datum/tgui_input_number/async/New(mob/user, message, title, default, max_value, min_value, callback, timeout) + ..(user, message, title, default, max_value, min_value, timeout) + src.callback = callback + +/datum/tgui_input_number/async/Destroy(force, ...) + QDEL_NULL(callback) + . = ..() + +/datum/tgui_input_number/async/set_entry(entry) + . = ..() + if(!isnull(src.entry)) + callback?.InvokeAsync(src.entry) + +/datum/tgui_input_number/async/wait() + return diff --git a/code/modules/tgui/tgui_input_text.dm b/code/modules/tgui/tgui_input_text.dm new file mode 100644 index 0000000000000..3326f78a2cb83 --- /dev/null +++ b/code/modules/tgui/tgui_input_text.dm @@ -0,0 +1,211 @@ +/** + * 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.read_preference(/datum/preference/toggle/tgui_input)) + 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) + +/** + * Creates an asynchronous TGUI text input window with an associated callback. + * + * This proc should be used to create text inputs that invoke a callback with the user's entry. + * 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. + * * multiline - Bool that determines if the input box is much larger. Good for large messages, laws, etc. + * * encode - If toggled, input is filtered via html_encode. Setting this to FALSE gives raw input. + * * callback - The callback to be invoked when a choice is made. + */ +/proc/tgui_input_text_async(mob/user, message = "", title = "Text Input", default, max_length = MAX_MESSAGE_LEN, multiline = FALSE, encode = TRUE, datum/callback/callback, timeout = 60 SECONDS) + 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.read_preference(/datum/preference/toggle/tgui_input)) + 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/async/text_input = new(user, message, title, default, max_length, multiline, encode, callback, timeout) + text_input.ui_interact(user) + +/** + * # 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) + . = ..() + +/** + * 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 GLOB.always_state + +/datum/tgui_input_text/ui_data(mob/user) + . = list() + .["max_length"] = max_length + .["message"] = message + .["multiline"] = multiline + .["placeholder"] = default // Default is a reserved keyword + .["preferences"] = list() + .["preferences"]["large_buttons"] = TRUE//user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) + .["preferences"]["swapped_buttons"] = TRUE//user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) + .["title"] = title + if(timeout) + .["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + +/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, "Input uses special characters, thus reducing the maximum length.") + set_entry(params["entry"]) + closed = TRUE + SStgui.close_uis(src) + return TRUE + if("cancel") + closed = TRUE + SStgui.close_uis(src) + return TRUE + +/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) + +/** + * # async tgui_input_text + * + * An asynchronous version of tgui_input_text to be used with callbacks instead of waiting on user responses. + */ +/datum/tgui_input_text/async + // The callback to be invoked by the tgui_input_text upon having a choice made. + var/datum/callback/callback + +/datum/tgui_input_text/async/New(mob/user, message, title, default, max_length, multiline, encode, callback, timeout) + ..(user, message, title, default, max_length, multiline, encode, timeout) + src.callback = callback + +/datum/tgui_input_text/async/Destroy(force, ...) + QDEL_NULL(callback) + . = ..() + +/datum/tgui_input_text/async/set_entry(entry) + . = ..() + if(!isnull(src.entry)) + callback?.InvokeAsync(src.entry) + +/datum/tgui_input_text/async/wait() + return diff --git a/interface/interface.dm b/interface/interface.dm index 58010814f4f0a..8ad6c9dbce6c5 100644 --- a/interface/interface.dm +++ b/interface/interface.dm @@ -63,6 +63,8 @@ if(GLOB.revdata.testmerge.len) message += "
The following experimental changes are active and are probably the cause of any new or sudden issues you may experience. If possible, please try to find a specific thread for your issue instead of posting to the general issue tracker:
" message += GLOB.revdata.GetTestMergeInfo(FALSE) + // We still use tgalert here because some people were concerned that if someone wanted to report that tgui wasn't working + // then the report issue button being tgui-based would be problematic. if(tgalert(src, message, "Report Issue","Yes","No")!="Yes") return var/static/issue_template = rustg_file_read(".github/ISSUE_TEMPLATE.md") diff --git a/tgui/packages/tgui/components/Autofocus.tsx b/tgui/packages/tgui/components/Autofocus.tsx new file mode 100644 index 0000000000000..28945dd7aa481 --- /dev/null +++ b/tgui/packages/tgui/components/Autofocus.tsx @@ -0,0 +1,19 @@ +import { Component, createRef } from 'inferno'; + +export class Autofocus extends Component { + ref = createRef(); + + componentDidMount() { + setTimeout(() => { + this.ref.current?.focus(); + }, 1); + } + + render() { + return ( +
+ {this.props.children} +
+ ); + } +} diff --git a/tgui/packages/tgui/components/RestrictedInput.js b/tgui/packages/tgui/components/RestrictedInput.js new file mode 100644 index 0000000000000..0a6e2cb440c38 --- /dev/null +++ b/tgui/packages/tgui/components/RestrictedInput.js @@ -0,0 +1,154 @@ +import { classes } from 'common/react'; +import { clamp } from 'common/math'; +import { Component, createRef } from 'inferno'; +import { Box } from './Box'; +import { KEY_ESCAPE, KEY_ENTER } from 'common/keycodes'; + +const DEFAULT_MIN = 0; +const DEFAULT_MAX = 10000; + +/** + * Takes a string input and parses integers from it. + * If none: Minimum is set. + * Else: Clamps it to the given range. + */ +const getClampedNumber = (value, minValue, maxValue) => { + const minimum = minValue || DEFAULT_MIN; + const maximum = maxValue || maxValue === 0 ? maxValue : DEFAULT_MAX; + if (!value || !value.length) { + return String(minimum); + } + let parsedValue = parseInt(value.replace(/\D/g, ''), 10); + if (isNaN(parsedValue)) { + return String(minimum); + } else { + return String(clamp(parsedValue, minimum, maximum)); + } +}; + +export class RestrictedInput extends Component { + constructor() { + super(); + this.inputRef = createRef(); + this.state = { + editing: false, + }; + this.handleBlur = (e) => { + const { editing } = this.state; + if (editing) { + this.setEditing(false); + } + }; + this.handleChange = (e) => { + const { maxValue, minValue, onChange } = this.props; + e.target.value = getClampedNumber(e.target.value, minValue, maxValue); + if (onChange) { + onChange(e, +e.target.value); + } + }; + this.handleFocus = (e) => { + const { editing } = this.state; + if (!editing) { + this.setEditing(true); + } + }; + this.handleInput = (e) => { + const { editing } = this.state; + const { onInput } = this.props; + if (!editing) { + this.setEditing(true); + } + if (onInput) { + onInput(e, +e.target.value); + } + }; + this.handleKeyDown = (e) => { + const { maxValue, minValue, onChange, onEnter } = this.props; + if (e.keyCode === KEY_ENTER) { + const safeNum = getClampedNumber(e.target.value, minValue, maxValue); + this.setEditing(false); + if (onChange) { + onChange(e, +safeNum); + } + if (onEnter) { + onEnter(e, +safeNum); + } + e.target.blur(); + return; + } + if (e.keyCode === KEY_ESCAPE) { + if (this.props.onEscape) { + this.props.onEscape(e); + return; + } + this.setEditing(false); + e.target.value = this.props.value; + e.target.blur(); + return; + } + }; + } + + componentDidMount() { + const { maxValue, minValue } = this.props; + const nextValue = this.props.value?.toString(); + const input = this.inputRef.current; + if (input) { + input.value = getClampedNumber(nextValue, minValue, maxValue); + } + if (this.props.autoFocus || this.props.autoSelect) { + setTimeout(() => { + input.focus(); + + if (this.props.autoSelect) { + input.select(); + } + }, 1); + } + } + + componentDidUpdate(prevProps, _) { + const { maxValue, minValue } = this.props; + const { editing } = this.state; + const prevValue = prevProps.value?.toString(); + const nextValue = this.props.value?.toString(); + const input = this.inputRef.current; + if (input && !editing) { + if (nextValue !== prevValue && nextValue !== input.value) { + input.value = getClampedNumber(nextValue, minValue, maxValue); + } + } + } + + setEditing(editing) { + this.setState({ editing }); + } + + render() { + const { props } = this; + const { onChange, onEnter, onInput, value, ...boxProps } = props; + const { className, fluid, monospace, ...rest } = boxProps; + return ( + +
.
+ +
+ ); + } +} diff --git a/tgui/packages/tgui/components/index.js b/tgui/packages/tgui/components/index.js index 5ed972519eefa..9797751480aa0 100644 --- a/tgui/packages/tgui/components/index.js +++ b/tgui/packages/tgui/components/index.js @@ -5,6 +5,7 @@ */ export { AnimatedNumber } from './AnimatedNumber'; +export { Autofocus } from './Autofocus'; export { Blink } from './Blink'; export { BlockQuote } from './BlockQuote'; export { Box } from './Box'; @@ -34,6 +35,7 @@ export { OrbitalMapSvg } from './OrbitalMapSvg'; export { ProgressBar } from './ProgressBar'; export { ScrollableBox } from './ScrollableBox'; export { Popper } from './Popper'; +export { RestrictedInput } from './RestrictedInput'; export { Section } from './Section'; export { Slider } from './Slider'; export { Stack } from './Stack'; diff --git a/tgui/packages/tgui/interfaces/AlertModal.tsx b/tgui/packages/tgui/interfaces/AlertModal.tsx new file mode 100644 index 0000000000000..693abe346949c --- /dev/null +++ b/tgui/packages/tgui/interfaces/AlertModal.tsx @@ -0,0 +1,162 @@ +import { Loader } from './common/Loader'; +import { Preferences } from './common/InputButtons'; +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[]; + message: string; + preferences: Preferences; + timeout: number; + title: string; +}; + +const KEY_DECREMENT = -1; +const KEY_INCREMENT = 1; + +export const AlertModal = (_, context) => { + const { act, data } = useBackend(context); + const { + autofocus, + buttons = [], + message, + preferences, + timeout, + title, + } = data; + const { large_buttons } = preferences; + const [selected, setSelected] = useLocalState(context, 'selected', 0); + // Dynamically sets window height + const windowHeight + = 115 + + (message.length > 30 ? Math.ceil(message.length / 3) : 0) + + (message.length && large_buttons ? 5 : 0) + + (buttons.length > 2 ? buttons.length * 25 : 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.shiftKey && keyCode === KEY_TAB) + ) { + onKey(KEY_DECREMENT); + } else if (keyCode === KEY_RIGHT || keyCode === KEY_TAB) { + 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 = [], preferences } = data; + const { selected } = props; + const { large_buttons, swapped_buttons } = preferences; + const buttonDirection + = (buttons.length > 2 ? 'column' : 'row') + + (!swapped_buttons ? '-reverse' : ''); + + 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 { preferences } = data; + const { large_buttons } = preferences; + const { button, selected } = props; + + return ( + + ); +}; diff --git a/tgui/packages/tgui/interfaces/ListInputModal.tsx b/tgui/packages/tgui/interfaces/ListInputModal.tsx new file mode 100644 index 0000000000000..bed0fd862717d --- /dev/null +++ b/tgui/packages/tgui/interfaces/ListInputModal.tsx @@ -0,0 +1,248 @@ +import { Loader } from './common/Loader'; +import { InputButtons, Preferences } from './common/InputButtons'; +import { Button, Input, Section, Stack } from '../components'; +import { + KEY_A, + KEY_DOWN, + KEY_ESCAPE, + KEY_ENTER, + KEY_UP, + KEY_Z, +} from '../../common/keycodes'; +import { Window } from '../layouts'; +import { useBackend, useLocalState } from '../backend'; + +type ListInputData = { + items: string[]; + message: string; + init_value: string; + preferences: Preferences; + timeout: number; + title: string; +}; + +export const ListInputModal = (_, context) => { + const { act, data } = useBackend(context); + const { items = [], message, init_value, preferences, timeout, title } = data; + const { large_buttons } = preferences; + 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 0000000000000..65b30f7b5f83b --- /dev/null +++ b/tgui/packages/tgui/interfaces/NumberInputModal.tsx @@ -0,0 +1,118 @@ +import { Loader } from './common/Loader'; +import { InputButtons, Preferences } 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 = { + max_value: number | null; + message: string; + min_value: number | null; + init_value: number; + preferences: Preferences; + timeout: number; + title: string; +}; + +export const NumberInputModal = (_, context) => { + const { act, data } = useBackend(context); + const { message, init_value, preferences, timeout, title } = data; + const { large_buttons } = preferences; + 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 + = 130 + + Math.ceil(message.length / 3) + + (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 } = data; + const { input, onClick, onChange } = props; + + return ( + + +