From c1900f2d4f68b88a1012d495fbaa8b19ac49fbaf Mon Sep 17 00:00:00 2001 From: Aylong <69762909+Aylong220@users.noreply.github.com> Date: Fri, 24 Nov 2023 17:36:04 +0200 Subject: [PATCH] [TGUI] Input List (#23281) * TGUI Input List * Formatting * TGUI re-build * Holopad & Drop Bomb * Update tgui.bundle.js * if * linter * Mistake * TGUI input - Barsign * Review changes --- code/game/machinery/hologram.dm | 2 +- code/game/objects/structures/barsign.dm | 2 +- code/modules/admin/admin_verbs.dm | 2 +- code/modules/tgui/modules/tgui_input_list.dm | 180 ++++++++++++++ paradise.dme | 1 + tgui/packages/tgui/components/Autofocus.js | 19 ++ tgui/packages/tgui/components/Input.js | 13 +- tgui/packages/tgui/components/index.js | 1 + tgui/packages/tgui/hotkeys.js | 2 + tgui/packages/tgui/interfaces/ListInput.js | 232 ++++++++++++++++++ tgui/packages/tgui/public/tgui.bundle.css | 2 +- tgui/packages/tgui/public/tgui.bundle.js | 4 +- .../tgui/styles/interfaces/ListInput.scss | 29 +++ tgui/packages/tgui/styles/main.scss | 1 + 14 files changed, 480 insertions(+), 10 deletions(-) create mode 100644 code/modules/tgui/modules/tgui_input_list.dm create mode 100644 tgui/packages/tgui/components/Autofocus.js create mode 100644 tgui/packages/tgui/interfaces/ListInput.js create mode 100644 tgui/packages/tgui/styles/interfaces/ListInput.scss diff --git a/code/game/machinery/hologram.dm b/code/game/machinery/hologram.dm index d138477758e..cb6bba8b720 100644 --- a/code/game/machinery/hologram.dm +++ b/code/game/machinery/hologram.dm @@ -233,7 +233,7 @@ GLOBAL_LIST_EMPTY(holopads) callnames -= get_area(src) var/list/sorted_callnames = sortAtom(callnames) dialling_input = TRUE - var/result = input(usr, "Choose an area to call", "Holocall") as null|anything in sorted_callnames + var/result = tgui_input_list(usr, "Choose an area to call", "Holocall", sorted_callnames) dialling_input = FALSE if(QDELETED(usr) || !result || outgoing_call) return diff --git a/code/game/objects/structures/barsign.dm b/code/game/objects/structures/barsign.dm index fdab17f2d50..039698d630a 100644 --- a/code/game/objects/structures/barsign.dm +++ b/code/game/objects/structures/barsign.dm @@ -142,7 +142,7 @@ req_access = list(ACCESS_SYNDICATE) /obj/structure/sign/barsign/proc/pick_sign() - var/picked_name = input("Available Signage", "Bar Sign") as null|anything in barsigns + var/picked_name = tgui_input_list(usr, "Available Signage", "Bar Sign", barsigns) if(!picked_name) return set_sign(picked_name) diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 3e00f46a043..9bb9212f81d 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -537,7 +537,7 @@ GLOBAL_LIST_INIT(view_runtimes_verbs, list( var/turf/epicenter = mob.loc var/list/choices = list("Small Bomb", "Medium Bomb", "Big Bomb", "Custom Bomb") - var/choice = input("What size explosion would you like to produce?") as null|anything in choices + var/choice = tgui_input_list(src, "What size explosion would you like to produce?", "Drop Bomb", choices) switch(choice) if(null) return 0 diff --git a/code/modules/tgui/modules/tgui_input_list.dm b/code/modules/tgui/modules/tgui_input_list.dm new file mode 100644 index 00000000000..c642ef6bc9d --- /dev/null +++ b/code/modules/tgui/modules/tgui_input_list.dm @@ -0,0 +1,180 @@ +/** + * 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. + * * buttons - The options that can be chosen by the user, each string is assigned a button on the UI. + * * timeout - The timeout of the input box, after which the input box will close and qdel itself. Set to zero for no timeout. + */ +/proc/tgui_input_list(mob/user, message, title, list/buttons, timeout = 0) + if(!user) + user = usr + if(!length(buttons)) + return + if(!istype(user)) + if(!isclient(user)) + return + var/client/client = user + user = client.mob + var/datum/tgui_list_input/input = new(user, message, title, buttons, 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. + * * 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 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, list/buttons, datum/callback/callback, timeout = 60 SECONDS) + if(!user) + user = usr + if(!length(buttons)) + return + if(!istype(user)) + if(!isclient(user)) + return + var/client/client = user + user = client.mob + var/datum/tgui_list_input/async/input = new(user, message, title, buttons, timeout, callback) + 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 buttons (responses) provided on the TGUI window + var/list/buttons + /// Buttons (strings specifically) mapped to the actual value (e.g. a mob or a verb) + var/list/buttons_map + /// The button that the user has pressed, null if no selection has been made + var/choice + /// 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/buttons, timeout) + src.title = title + src.message = message + src.buttons = list() + src.buttons_map = list() + + // Gets rid of illegal characters + var/static/regex/whitelistedWords = regex(@{"([^\u0020-\u8000]+)"}) + + for(var/i in buttons) + var/string_key = whitelistedWords.Replace("[i]", "") + + src.buttons += string_key + src.buttons_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(buttons) + . = ..() + +/** + * 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, ui_key = "main", datum/tgui/ui = null, force_open = FALSE, datum/tgui/master_ui = null, datum/ui_state/state = GLOB.always_state) + ui = SStgui.try_update_ui(user, src, ui_key, ui, force_open) + if(!ui) + ui = new(user, src, ui_key, "ListInput", title, 325, 330, master_ui, state) + ui.set_autoupdate(FALSE) + ui.open() + +/datum/tgui_list_input/ui_close(mob/user) + . = ..() + closed = TRUE + +/datum/tgui_list_input/ui_static_data(mob/user) + var/list/data = list() + data["title"] = title + data["message"] = message + data["buttons"] = buttons + 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) + +/datum/tgui_list_input/ui_act(action, list/params) + if(..()) + return + + . = TRUE + + switch(action) + if("choose") + if(!(params["choice"] in buttons)) + return + choice = buttons_map[params["choice"]] + SStgui.close_uis(src) + if("cancel") + SStgui.close_uis(src) + closed = TRUE + +/** + * # 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/buttons, timeout, callback) + ..() + src.callback = callback + +/datum/tgui_list_input/async/Destroy(force, ...) + QDEL_NULL(callback) + . = ..() + +/datum/tgui_list_input/async/ui_close(mob/user) + . = ..() + qdel(src) + +/datum/tgui_list_input/async/ui_act(action, list/params) + . = ..() + if(!. || choice == null) + return + callback.InvokeAsync(choice) + qdel(src) + +/datum/tgui_list_input/async/wait() + return diff --git a/paradise.dme b/paradise.dme index b931d86bc2c..868eb0e33ea 100644 --- a/paradise.dme +++ b/paradise.dme @@ -2725,6 +2725,7 @@ #include "code\modules\tgui\modules\module_base.dm" #include "code\modules\tgui\modules\power_monitor.dm" #include "code\modules\tgui\modules\robot_self_diagnosis.dm" +#include "code\modules\tgui\modules\tgui_input_list.dm" #include "code\modules\tgui\modules\volume_mixer.dm" #include "code\modules\tgui\plugins\modal.dm" #include "code\modules\tgui\plugins\tgui_login.dm" diff --git a/tgui/packages/tgui/components/Autofocus.js b/tgui/packages/tgui/components/Autofocus.js new file mode 100644 index 00000000000..726d96717e1 --- /dev/null +++ b/tgui/packages/tgui/components/Autofocus.js @@ -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/Input.js b/tgui/packages/tgui/components/Input.js index 1c4c94e152d..19b57936097 100644 --- a/tgui/packages/tgui/components/Input.js +++ b/tgui/packages/tgui/components/Input.js @@ -76,11 +76,16 @@ export class Input extends Component { const input = this.inputRef.current; if (input) { input.value = toInputValue(nextValue); - if (this.props.autofocus) { + } + + if (this.props.autoFocus || this.props.autoSelect) { + setTimeout(() => { input.focus(); - input.selectionStart = 0; - input.selectionEnd = input.value.length; - } + + if (this.props.autoSelect) { + input.select(); + } + }, 1); } } diff --git a/tgui/packages/tgui/components/index.js b/tgui/packages/tgui/components/index.js index d51773c1943..29bb649f7c3 100644 --- a/tgui/packages/tgui/components/index.js +++ b/tgui/packages/tgui/components/index.js @@ -1,4 +1,5 @@ export { AnimatedNumber } from './AnimatedNumber'; +export { Autofocus } from './Autofocus'; export { BlockQuote } from './BlockQuote'; export { Box } from './Box'; export { Button } from './Button'; diff --git a/tgui/packages/tgui/hotkeys.js b/tgui/packages/tgui/hotkeys.js index 186905f41fb..ac2fc6d9ff6 100644 --- a/tgui/packages/tgui/hotkeys.js +++ b/tgui/packages/tgui/hotkeys.js @@ -12,6 +12,8 @@ export const KEY_CTRL = 17; export const KEY_ALT = 18; export const KEY_ESCAPE = 27; export const KEY_SPACE = 32; +export const ARROW_KEY_UP = 38; +export const ARROW_KEY_DOWN = 40; export const KEY_0 = 48; export const KEY_1 = 49; export const KEY_2 = 50; diff --git a/tgui/packages/tgui/interfaces/ListInput.js b/tgui/packages/tgui/interfaces/ListInput.js new file mode 100644 index 00000000000..6256442fe63 --- /dev/null +++ b/tgui/packages/tgui/interfaces/ListInput.js @@ -0,0 +1,232 @@ +/** + * @file + * @copyright 2020 watermelon914 (https://github.com/watermelon914) + * @license MIT + */ + +import { clamp01 } from 'common/math'; +import { useBackend, useLocalState } from '../backend'; +import { Box, Button, Flex, Section, Input } from '../components'; +import { Window } from '../layouts'; +import { ARROW_KEY_UP, ARROW_KEY_DOWN } from '../hotkeys'; + +let lastScrollTime = 0; + +export const ListInput = (props, context) => { + const { act, data } = useBackend(context); + const { title, message, buttons, timeout } = data; + + // Search + const [showSearchBar, setShowSearchBar] = useLocalState( + context, + 'search_bar', + false + ); + const [displayedArray, setDisplayedArray] = useLocalState( + context, + 'displayed_array', + buttons + ); + + // KeyPress + const [searchArray, setSearchArray] = useLocalState( + context, + 'search_array', + [] + ); + const [searchIndex, setSearchIndex] = useLocalState( + context, + 'search_index', + 0 + ); + const [lastCharCode, setLastCharCode] = useLocalState( + context, + 'last_char_code', + null + ); + + // Selected Button + const [selectedButton, setSelectedButton] = useLocalState( + context, + 'selected_button', + buttons[0] + ); + return ( + + {timeout !== undefined && } + + + +
{ + e.preventDefault(); + if (lastScrollTime > performance.now()) { + return; + } + lastScrollTime = performance.now() + 125; + + if ( + e.keyCode === ARROW_KEY_UP || + e.keyCode === ARROW_KEY_DOWN + ) { + let direction = 1; + if (e.keyCode === ARROW_KEY_UP) { + direction = -1; + } + + let index = 0; + for (index; index < buttons.length; index++) { + if (buttons[index] === selectedButton) { + break; + } + } + index += direction; + if (index < 0) { + index = buttons.length - 1; + } else if (index >= buttons.length) { + index = 0; + } + setSelectedButton(buttons[index]); + setLastCharCode(null); + document.getElementById(buttons[index]).focus(); + return; + } + + const charCode = String.fromCharCode(e.keyCode).toLowerCase(); + if (!charCode) { + return; + } + + let foundValue; + if (charCode === lastCharCode && searchArray.length > 0) { + const nextIndex = searchIndex + 1; + + if (nextIndex < searchArray.length) { + foundValue = searchArray[nextIndex]; + setSearchIndex(nextIndex); + } else { + foundValue = searchArray[0]; + setSearchIndex(0); + } + } else { + const resultArray = displayedArray.filter( + (value) => value.substring(0, 1).toLowerCase() === charCode + ); + + if (resultArray.length > 0) { + setSearchArray(resultArray); + setSearchIndex(0); + foundValue = resultArray[0]; + } + } + + if (foundValue) { + setLastCharCode(charCode); + setSelectedButton(foundValue); + document.getElementById(foundValue).focus(); + } + }} + buttons={ +
+
+ {showSearchBar && ( + + + setDisplayedArray( + buttons.filter( + (val) => + val.toLowerCase().search(value.toLowerCase()) !== -1 + ) + ) + } + /> + + )} + + + +