mirror of
https://github.com/SPLURT-Station/S.P.L.U.R.T-Station-13.git
synced 2025-12-09 07:48:55 +00:00
TGUI Input Framework
This commit is contained in:
@@ -65,6 +65,8 @@
|
||||
#define COMSIG_TOPIC "handle_topic"
|
||||
/// from datum ui_act (usr, action)
|
||||
#define COMSIG_UI_ACT "COMSIG_UI_ACT"
|
||||
/// from datum tgui_fallback (payload)
|
||||
#define COMSIG_UI_FALLBACK "COMSIG_UI_FALLBACK"
|
||||
|
||||
/// fires on the target datum when an element is attached to it (/datum/element)
|
||||
#define COMSIG_ELEMENT_ATTACH "element_attach"
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
/// Maximum ping timeout allowed to detect zombie windows
|
||||
#define TGUI_PING_TIMEOUT 4 SECONDS
|
||||
/// Used for rate-limiting to prevent DoS by excessively refreshing a TGUI window
|
||||
#define TGUI_REFRESH_FULL_UPDATE_COOLDOWN 5 SECONDS
|
||||
|
||||
/// Window does not exist
|
||||
#define TGUI_WINDOW_CLOSED 0
|
||||
|
||||
@@ -83,6 +83,9 @@ GLOBAL_LIST_EMPTY(preferences_datums)
|
||||
|
||||
var/tgui_fancy = TRUE
|
||||
var/tgui_lock = TRUE
|
||||
var/tgui_input_mode = TRUE // All the Input Boxes (Text,Number,List,Alert)
|
||||
var/tgui_large_buttons = TRUE
|
||||
var/tgui_swapped_buttons = FALSE
|
||||
var/windowflashing = TRUE
|
||||
var/toggles = TOGGLES_DEFAULT
|
||||
/// A separate variable for deadmin toggles, only deals with those.
|
||||
@@ -948,7 +951,7 @@ GLOBAL_LIST_EMPTY(preferences_datums)
|
||||
dat += "</td>"
|
||||
if(pref_species.id == SPECIES_DULLAHAN)
|
||||
dat += APPEARANCE_CATEGORY_COLUMN
|
||||
|
||||
|
||||
dat += "<h3>Neckfire</h3>"
|
||||
dat += "<a style='display:block;width:50px' href='?_src_=prefs;preference=has_neckfire;task=input'>[features["neckfire"] ? "Yes" : "No"]</a>"
|
||||
if(features["neckfire"])
|
||||
@@ -2260,7 +2263,7 @@ GLOBAL_LIST_EMPTY(preferences_datums)
|
||||
features["neckfire_color"] = sanitize_hexcolor(new_neckfire_color, 6)
|
||||
else
|
||||
to_chat(user,"<span class='danger'>Invalid color. Your color is not bright enough.</span>")
|
||||
|
||||
|
||||
if("ipc_screen")
|
||||
var/new_ipc_screen
|
||||
new_ipc_screen = input(user, "Choose your character's screen:", "Character Preference") as null|anything in GLOB.ipc_screens_list
|
||||
|
||||
@@ -405,6 +405,9 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
|
||||
S["see_chat_non_mob"] >> see_chat_non_mob
|
||||
S["tgui_fancy"] >> tgui_fancy
|
||||
S["tgui_lock"] >> tgui_lock
|
||||
S["tgui_input_mode"] >> tgui_input_mode
|
||||
S["tgui_large_buttons"] >> tgui_large_buttons
|
||||
S["tgui_swapped_buttons"] >> tgui_swapped_buttons
|
||||
S["buttons_locked"] >> buttons_locked
|
||||
S["windowflash"] >> windowflashing
|
||||
S["be_special"] >> be_special
|
||||
@@ -490,6 +493,9 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
|
||||
see_chat_non_mob = sanitize_integer(see_chat_non_mob, 0, 1, initial(see_chat_non_mob))
|
||||
tgui_fancy = sanitize_integer(tgui_fancy, 0, 1, initial(tgui_fancy))
|
||||
tgui_lock = sanitize_integer(tgui_lock, 0, 1, initial(tgui_lock))
|
||||
tgui_input_mode = sanitize_integer(tgui_input_mode, 0, 1, initial(tgui_input_mode))
|
||||
tgui_large_buttons = sanitize_integer(tgui_large_buttons, 0, 1, initial(tgui_large_buttons))
|
||||
tgui_swapped_buttons = sanitize_integer(tgui_swapped_buttons, 0, 1, initial(tgui_swapped_buttons))
|
||||
buttons_locked = sanitize_integer(buttons_locked, 0, 1, initial(buttons_locked))
|
||||
windowflashing = sanitize_integer(windowflashing, 0, 1, initial(windowflashing))
|
||||
default_slot = sanitize_integer(default_slot, 1, max_save_slots, initial(default_slot))
|
||||
@@ -607,6 +613,9 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
|
||||
WRITE_FILE(S["see_chat_non_mob"], see_chat_non_mob)
|
||||
WRITE_FILE(S["tgui_fancy"], tgui_fancy)
|
||||
WRITE_FILE(S["tgui_lock"], tgui_lock)
|
||||
WRITE_FILE(S["tgui_input_mode"], tgui_input_mode)
|
||||
WRITE_FILE(S["tgui_large_buttons"], tgui_large_buttons)
|
||||
WRITE_FILE(S["tgui_swapped_buttons"], tgui_swapped_buttons)
|
||||
WRITE_FILE(S["buttons_locked"], buttons_locked)
|
||||
WRITE_FILE(S["windowflash"], windowflashing)
|
||||
WRITE_FILE(S["be_special"], be_special)
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
if(!ui || ui.status != UI_INTERACTIVE)
|
||||
return TRUE
|
||||
|
||||
/**
|
||||
* public
|
||||
*
|
||||
* Called on a UI when the UI crashed.
|
||||
*
|
||||
* required payload list A list of the payload supposed to be set on the regular UI.
|
||||
*/
|
||||
/datum/proc/ui_fallback(list/payload)
|
||||
SHOULD_CALL_PARENT(TRUE)
|
||||
SEND_SIGNAL(src, COMSIG_UI_FALLBACK, usr)
|
||||
|
||||
/**
|
||||
* public
|
||||
*
|
||||
|
||||
@@ -31,8 +31,16 @@
|
||||
var/closing = FALSE
|
||||
/// The status/visibility of the UI.
|
||||
var/status = UI_INTERACTIVE
|
||||
/// Timed refreshing state
|
||||
var/refreshing = FALSE
|
||||
/// Topic state used to determine status/interactability.
|
||||
var/datum/ui_state/state = null
|
||||
/// Rate limit client refreshes to prevent DoS.
|
||||
COOLDOWN_DECLARE(refresh_cooldown)
|
||||
/// The Parent UI
|
||||
var/datum/tgui/parent_ui
|
||||
/// Children of this UI
|
||||
var/list/children = list()
|
||||
|
||||
/**
|
||||
* public
|
||||
@@ -48,10 +56,7 @@
|
||||
*
|
||||
* return datum/tgui The requested UI.
|
||||
*/
|
||||
/datum/tgui/New(mob/user, datum/src_object, interface, title, ui_x, ui_y)
|
||||
log_tgui(user,
|
||||
"new [interface] fancy [user?.client?.prefs.tgui_fancy]",
|
||||
src_object = src_object)
|
||||
/datum/tgui/New(mob/user, datum/src_object, interface, title, datum/tgui/parent_ui, ui_x, ui_y)
|
||||
src.user = user
|
||||
src.src_object = src_object
|
||||
src.window_key = "[REF(src_object)]-main"
|
||||
@@ -59,6 +64,9 @@
|
||||
if(title)
|
||||
src.title = title
|
||||
src.state = src_object.ui_state(user)
|
||||
src.parent_ui = parent_ui
|
||||
if(parent_ui)
|
||||
parent_ui.children += src
|
||||
// Deprecated
|
||||
if(ui_x && ui_y)
|
||||
src.window_size = list(ui_x, ui_y)
|
||||
@@ -76,8 +84,6 @@
|
||||
* return bool - TRUE if a new pooled window is opened, FALSE in all other situations including if a new pooled window didn't open because one already exists.
|
||||
*/
|
||||
/datum/tgui/proc/open()
|
||||
if(!user)
|
||||
return FALSE
|
||||
if(!user.client)
|
||||
return FALSE
|
||||
if(window)
|
||||
@@ -93,7 +99,7 @@
|
||||
if(!window.is_ready())
|
||||
window.initialize(
|
||||
fancy = user.client.prefs.tgui_fancy,
|
||||
inline_assets = list(
|
||||
assets = list(
|
||||
get_asset_datum(/datum/asset/simple/tgui),
|
||||
))
|
||||
else
|
||||
@@ -120,10 +126,12 @@
|
||||
*
|
||||
* optional can_be_suspended bool
|
||||
*/
|
||||
/datum/tgui/proc/close(can_be_suspended = TRUE)
|
||||
/datum/tgui/proc/close(can_be_suspended = TRUE, logout = FALSE)
|
||||
if(closing)
|
||||
return
|
||||
closing = TRUE
|
||||
for(var/datum/tgui/child in children)
|
||||
child.close(can_be_suspended, logout)
|
||||
// If we don't have window_id, open proc did not have the opportunity
|
||||
// to finish, therefore it's safe to skip this whole block.
|
||||
if(window)
|
||||
@@ -135,6 +143,9 @@
|
||||
src_object.ui_close(user)
|
||||
SStgui.on_close(src)
|
||||
state = null
|
||||
if(parent_ui)
|
||||
parent_ui.children -= src
|
||||
parent_ui = null
|
||||
qdel(src)
|
||||
|
||||
/**
|
||||
@@ -182,11 +193,17 @@
|
||||
/datum/tgui/proc/send_full_update(custom_data, force)
|
||||
if(!user.client || !initialized || closing)
|
||||
return
|
||||
if(!COOLDOWN_FINISHED(src, refresh_cooldown))
|
||||
refreshing = TRUE
|
||||
addtimer(CALLBACK(src, .proc/send_full_update), TGUI_REFRESH_FULL_UPDATE_COOLDOWN, TIMER_UNIQUE)
|
||||
return
|
||||
refreshing = FALSE
|
||||
var/should_update_data = force || status >= UI_UPDATE
|
||||
window.send_message("update", get_payload(
|
||||
custom_data,
|
||||
with_data = should_update_data,
|
||||
with_static_data = TRUE))
|
||||
COOLDOWN_START(src, refresh_cooldown, TGUI_REFRESH_FULL_UPDATE_COOLDOWN)
|
||||
|
||||
/**
|
||||
* public
|
||||
@@ -219,6 +236,8 @@
|
||||
"title" = title,
|
||||
"status" = status,
|
||||
"interface" = interface,
|
||||
//"refreshing" = refreshing,
|
||||
"refreshing" = FALSE,
|
||||
"window" = list(
|
||||
"key" = window_key,
|
||||
"size" = window_size,
|
||||
@@ -261,9 +280,11 @@
|
||||
return
|
||||
// Validate ping
|
||||
if(!initialized && world.time - opened_at > TGUI_PING_TIMEOUT)
|
||||
log_tgui(user, "Error: Zombie window detected, closing.",
|
||||
window = window,
|
||||
src_object = src_object)
|
||||
log_tgui(user, \
|
||||
"Error: Zombie window detected, killing it with fire.\n" \
|
||||
+ "window_id: [window.id]\n" \
|
||||
+ "opened_at: [opened_at]\n" \
|
||||
+ "world.time: [world.time]")
|
||||
close(can_be_suspended = FALSE)
|
||||
return
|
||||
// Update through a normal call to ui_interact
|
||||
@@ -286,6 +307,8 @@
|
||||
/datum/tgui/proc/process_status()
|
||||
var/prev_status = status
|
||||
status = src_object.ui_status(user, state)
|
||||
if(parent_ui)
|
||||
status = min(status, parent_ui.status)
|
||||
return prev_status != status
|
||||
|
||||
/**
|
||||
@@ -297,15 +320,18 @@
|
||||
// Pass act type messages to ui_act
|
||||
if(type && copytext(type, 1, 5) == "act/")
|
||||
var/act_type = copytext(type, 5)
|
||||
log_tgui(user, "Action: [act_type] [href_list["payload"]]",
|
||||
window = window,
|
||||
src_object = src_object)
|
||||
#ifdef TGUI_DEBUGGING
|
||||
log_tgui(user, "Action: [act_type] [href_list["payload"]], Window: [window.id], Source: [src_object]")
|
||||
#endif
|
||||
process_status()
|
||||
if(src_object.ui_act(act_type, payload, src, state))
|
||||
SStgui.update_uis(src_object)
|
||||
return FALSE
|
||||
switch(type)
|
||||
if("ready")
|
||||
// Send a full update when the user manually refreshes the UI
|
||||
if(initialized)
|
||||
send_full_update()
|
||||
initialized = TRUE
|
||||
if("pingReply")
|
||||
initialized = TRUE
|
||||
@@ -322,3 +348,8 @@
|
||||
LAZYINITLIST(src_object.tgui_shared_states)
|
||||
src_object.tgui_shared_states[href_list["key"]] = href_list["value"]
|
||||
SStgui.update_uis(src_object)
|
||||
if("fallback")
|
||||
#ifdef TGUI_DEBUGGING
|
||||
log_tgui(user, "Fallback Triggered: [href_list["payload"]], Window: [window.id], Source: [src_object]")
|
||||
#endif
|
||||
src_object.ui_fallback(payload)
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
var/message_queue
|
||||
var/sent_assets = list()
|
||||
// Vars passed to initialize proc (and saved for later)
|
||||
var/inline_assets
|
||||
var/fancy
|
||||
var/initial_fancy
|
||||
var/initial_assets
|
||||
var/initial_inline_html
|
||||
var/initial_inline_js
|
||||
var/initial_inline_css
|
||||
|
||||
/**
|
||||
* public
|
||||
@@ -49,16 +52,19 @@
|
||||
* optional fancy bool If TRUE, will hide the window titlebar.
|
||||
*/
|
||||
/datum/tgui_window/proc/initialize(
|
||||
inline_assets = list(),
|
||||
fancy = FALSE,
|
||||
assets = list(),
|
||||
inline_html = "",
|
||||
fancy = FALSE)
|
||||
log_tgui(client,
|
||||
context = "[id]/initialize",
|
||||
window = src)
|
||||
inline_js = "",
|
||||
inline_css = "")
|
||||
log_tgui(client, "[id]/initialize ([src])")
|
||||
if(!client)
|
||||
return
|
||||
src.inline_assets = inline_assets
|
||||
src.fancy = fancy
|
||||
src.initial_fancy = fancy
|
||||
src.initial_assets = assets
|
||||
src.initial_inline_html = inline_html
|
||||
src.initial_inline_js = inline_js
|
||||
src.initial_inline_css = inline_css
|
||||
status = TGUI_WINDOW_LOADING
|
||||
fatally_errored = FALSE
|
||||
// Build window options
|
||||
@@ -73,7 +79,7 @@
|
||||
html = replacetextEx(html, "\[tgui:windowId]", id)
|
||||
// Inject inline assets
|
||||
var/inline_assets_str = ""
|
||||
for(var/datum/asset/asset in inline_assets)
|
||||
for(var/datum/asset/asset in assets)
|
||||
var/mappings = asset.get_url_mappings()
|
||||
for(var/name in mappings)
|
||||
var/url = mappings[name]
|
||||
@@ -86,8 +92,17 @@
|
||||
if(length(inline_assets_str))
|
||||
inline_assets_str = "<script>\n" + inline_assets_str + "</script>\n"
|
||||
html = replacetextEx(html, "<!-- tgui:assets -->\n", inline_assets_str)
|
||||
// Inject custom HTML
|
||||
html = replacetextEx(html, "<!-- tgui:html -->\n", inline_html)
|
||||
// Inject inline HTML
|
||||
if (inline_html)
|
||||
html = replacetextEx(html, "<!-- tgui:inline-html -->", inline_html)
|
||||
// Inject inline JS
|
||||
if (inline_js)
|
||||
inline_js = "<script>\n[inline_js]\n</script>"
|
||||
html = replacetextEx(html, "<!-- tgui:inline-js -->", inline_js)
|
||||
// Inject inline CSS
|
||||
if (inline_css)
|
||||
inline_css = "<style>\n[inline_css]\n</style>"
|
||||
html = replacetextEx(html, "<!-- tgui:inline-css -->", inline_css)
|
||||
// Open the window
|
||||
client << browse(html, "window=[id];[options]")
|
||||
// Detect whether the control is a browser
|
||||
@@ -176,19 +191,23 @@
|
||||
*
|
||||
* optional can_be_suspended bool
|
||||
*/
|
||||
/datum/tgui_window/proc/close(can_be_suspended = TRUE)
|
||||
/datum/tgui_window/proc/close(can_be_suspended = TRUE, logout = FALSE)
|
||||
if(!client)
|
||||
return
|
||||
if(can_be_suspended && can_be_suspended())
|
||||
log_tgui(client,
|
||||
context = "[id]/close (suspending)",
|
||||
window = src)
|
||||
#ifdef TGUI_DEBUGGING
|
||||
log_tgui(client, "[id]/close: suspending")
|
||||
#endif
|
||||
status = TGUI_WINDOW_READY
|
||||
send_message("suspend")
|
||||
// You would think that BYOND would null out client or make it stop passing istypes or, y'know, ANYTHING during
|
||||
// logout, but nope! It appears to be perfectly valid to call winset by every means we can measure in Logout,
|
||||
// and yet it causes a bad client runtime. To avoid that happening, we just have to know if we're in Logout or
|
||||
// not.
|
||||
if(!logout && client)
|
||||
winset(client, null, "mapwindow.map.focus=true")
|
||||
return
|
||||
log_tgui(client,
|
||||
context = "[id]/close",
|
||||
window = src)
|
||||
log_tgui(client, "[id]/close")
|
||||
release_lock()
|
||||
status = TGUI_WINDOW_CLOSED
|
||||
message_queue = null
|
||||
@@ -196,6 +215,8 @@
|
||||
// to read the error message.
|
||||
if(!fatally_errored)
|
||||
client << browse(null, "window=[id]")
|
||||
if(!logout && client)
|
||||
winset(client, null, "mapwindow.map.focus=true")
|
||||
|
||||
/**
|
||||
* public
|
||||
@@ -317,7 +338,12 @@
|
||||
client << link(href_list["url"])
|
||||
if("cacheReloaded")
|
||||
// Reinitialize
|
||||
initialize(inline_assets = inline_assets, fancy = fancy)
|
||||
initialize(
|
||||
fancy = initial_fancy,
|
||||
assets = initial_assets,
|
||||
inline_html = initial_inline_html,
|
||||
inline_js = initial_inline_js,
|
||||
inline_css = initial_inline_css)
|
||||
// Resend the assets
|
||||
for(var/asset in sent_assets)
|
||||
send_asset(asset)
|
||||
|
||||
@@ -10,7 +10,13 @@
|
||||
* * 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 = null, title = null, list/buttons = list("Ok"), timeout = 0, autofocus = TRUE)
|
||||
/proc/tgui_alert(mob/user, message = "", title, list/buttons = list("Ok"), timeout = 0, autofocus = TRUE, strict_byond = FALSE)
|
||||
if (istext(buttons))
|
||||
stack_trace("tgui_alert() received text for buttons instead of list")
|
||||
return
|
||||
if (istext(user))
|
||||
stack_trace("tgui_alert() received text for user instead of list")
|
||||
return
|
||||
if (!user)
|
||||
user = usr
|
||||
if (!istype(user))
|
||||
@@ -19,7 +25,19 @@
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
var/datum/tgui_modal/alert = new(user, message, title, buttons, timeout, autofocus)
|
||||
// A gentle nudge - you should not be using TGUI alert for anything other than a simple message.
|
||||
//if(length(buttons) > 3)
|
||||
// log_tgui(user, "Error: TGUI Alert initiated with too many buttons. Use a list.", "TguiAlert")
|
||||
// return tgui_input_list(user, message, title, buttons, timeout, autofocus)
|
||||
|
||||
// Client does NOT have tgui_input on: Returns regular input
|
||||
if(!user.client.prefs.tgui_input_mode || strict_byond)
|
||||
if(length(buttons) == 2)
|
||||
return alert(user, message, title, buttons[1], buttons[2])
|
||||
if(length(buttons) == 3)
|
||||
return alert(user, message, title, buttons[1], buttons[2], buttons[3])
|
||||
|
||||
var/datum/tgui_alert/alert = new(user, message, title, buttons, timeout, autofocus)
|
||||
alert.ui_interact(user)
|
||||
alert.wait()
|
||||
if (alert)
|
||||
@@ -27,37 +45,12 @@
|
||||
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 = null, title = null, 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
|
||||
var/datum/tgui_modal/async/alert = new(user, message, title, buttons, callback, timeout, autofocus)
|
||||
alert.ui_interact(user)
|
||||
|
||||
/**
|
||||
* # tgui_modal
|
||||
* # tgui_alert
|
||||
*
|
||||
* Datum used for instantiating and using a TGUI-controlled modal that prompts the user with
|
||||
* a message and has buttons for responses.
|
||||
*/
|
||||
/datum/tgui_modal
|
||||
/datum/tgui_alert
|
||||
/// The title of the TGUI window
|
||||
var/title
|
||||
/// The textual body of the TGUI window
|
||||
@@ -75,17 +68,17 @@
|
||||
/// 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.title = title
|
||||
src.message = message
|
||||
src.buttons = buttons.Copy()
|
||||
/datum/tgui_alert/New(mob/user, message, title, list/buttons, timeout, autofocus)
|
||||
src.autofocus = autofocus
|
||||
src.buttons = buttons.Copy()
|
||||
src.message = message
|
||||
src.title = title
|
||||
if (timeout)
|
||||
src.timeout = timeout
|
||||
start_time = world.time
|
||||
QDEL_IN(src, timeout)
|
||||
|
||||
/datum/tgui_modal/Destroy(force, ...)
|
||||
/datum/tgui_alert/Destroy(force, ...)
|
||||
SStgui.close_uis(src)
|
||||
QDEL_NULL(buttons)
|
||||
. = ..()
|
||||
@@ -94,36 +87,41 @@
|
||||
* 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()
|
||||
/datum/tgui_alert/proc/wait()
|
||||
while (!choice && !closed && !QDELETED(src))
|
||||
stoplag(1)
|
||||
|
||||
/datum/tgui_modal/ui_interact(mob/user, datum/tgui/ui)
|
||||
/datum/tgui_alert/ui_interact(mob/user, datum/tgui/ui)
|
||||
ui = SStgui.try_update_ui(user, src, ui)
|
||||
if(!ui)
|
||||
ui = new(user, src, "AlertModal")
|
||||
ui.open()
|
||||
|
||||
/datum/tgui_modal/ui_close(mob/user)
|
||||
. = ..()
|
||||
closed = TRUE
|
||||
///datum/tgui_alert/tgui_close(mob/user)
|
||||
// . = ..()
|
||||
// closed = TRUE
|
||||
|
||||
/datum/tgui_modal/ui_state(mob/user)
|
||||
/datum/tgui_alert/ui_state(mob/user)
|
||||
return GLOB.always_state
|
||||
|
||||
/datum/tgui_modal/ui_data(mob/user)
|
||||
. = list(
|
||||
"title" = title,
|
||||
"message" = message,
|
||||
"buttons" = buttons,
|
||||
"autofocus" = autofocus
|
||||
)
|
||||
/datum/tgui_alert/ui_static_data(mob/user)
|
||||
var/list/data = list()
|
||||
data["autofocus"] = autofocus
|
||||
data["buttons"] = buttons
|
||||
data["message"] = message
|
||||
data["large_buttons"] = user.client.prefs.tgui_large_buttons
|
||||
data["swapped_buttons"] = !user.client.prefs.tgui_swapped_buttons
|
||||
data["title"] = title
|
||||
return data
|
||||
|
||||
/datum/tgui_alert/ui_data(mob/user)
|
||||
var/list/data = list()
|
||||
if(timeout)
|
||||
.["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS))
|
||||
data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS))
|
||||
return data
|
||||
|
||||
/datum/tgui_modal/ui_act(action, list/params)
|
||||
. = ..()
|
||||
/datum/tgui_alert/ui_act(action, list/params)
|
||||
//. = ..()
|
||||
if (.)
|
||||
return
|
||||
switch(action)
|
||||
@@ -133,31 +131,65 @@
|
||||
set_choice(params["choice"])
|
||||
SStgui.close_uis(src)
|
||||
return TRUE
|
||||
if("cancel")
|
||||
closed = TRUE
|
||||
SStgui.close_uis(src)
|
||||
return TRUE
|
||||
|
||||
/datum/tgui_modal/proc/set_choice(choice)
|
||||
/datum/tgui_alert/proc/set_choice(choice)
|
||||
src.choice = choice
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/proc/tgui_alert_async(mob/user, message = "", title, list/buttons = list("Ok"), datum/callback/callback, timeout = 0, autofocus = TRUE)
|
||||
if (istext(buttons))
|
||||
stack_trace("tgui_alert() received text for buttons instead of list")
|
||||
return
|
||||
if (istext(user))
|
||||
stack_trace("tgui_alert() received text for user instead of list")
|
||||
return
|
||||
if (!user)
|
||||
user = usr
|
||||
if (!istype(user))
|
||||
if (istype(user, /client))
|
||||
var/client/client = user
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
var/datum/tgui_alert/async/alert = new(user, message, title, buttons, callback, timeout, autofocus)
|
||||
alert.ui_interact(user)
|
||||
|
||||
/**
|
||||
* # async tgui_modal
|
||||
*
|
||||
* An asynchronous version of tgui_modal to be used with callbacks instead of waiting on user responses.
|
||||
*/
|
||||
/datum/tgui_modal/async
|
||||
/datum/tgui_alert/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)
|
||||
/datum/tgui_alert/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, ...)
|
||||
/datum/tgui_alert/async/Destroy(force, ...)
|
||||
QDEL_NULL(callback)
|
||||
. = ..()
|
||||
|
||||
/datum/tgui_modal/async/set_choice(choice)
|
||||
/datum/tgui_alert/async/set_choice(choice)
|
||||
. = ..()
|
||||
if(!isnull(src.choice))
|
||||
callback?.InvokeAsync(src.choice)
|
||||
|
||||
/datum/tgui_modal/async/wait()
|
||||
/datum/tgui_alert/async/wait()
|
||||
return
|
||||
@@ -6,13 +6,18 @@
|
||||
* * 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.
|
||||
* * items - The options that can be chosen by the user, each string is assigned a button on the UI.
|
||||
* * default - The option with this value will be selected on first paint of the TGUI window.
|
||||
* * timeout - The timeout of the input box, after which the input box will close and qdel itself. Set to zero for no timeout.
|
||||
* * strict_modern - Disabled the preference check of the input box, only allowing the TGUI window to show.
|
||||
*/
|
||||
/proc/tgui_input_list(mob/user, message, title, list/buttons, timeout = 0)
|
||||
/proc/tgui_input_list(mob/user, message, title = "Select", list/items, default, timeout = 0, strict_modern = FALSE)
|
||||
if (istext(user))
|
||||
stack_trace("tgui_alert() received text for user instead of mob")
|
||||
return
|
||||
if (!user)
|
||||
user = usr
|
||||
if(!length(buttons))
|
||||
if(!length(items))
|
||||
return
|
||||
if (!istype(user))
|
||||
if (istype(user, /client))
|
||||
@@ -20,39 +25,16 @@
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
var/datum/tgui_list_input/input = new(user, message, title, buttons, timeout)
|
||||
/// Client does NOT have tgui_input on: Returns regular input
|
||||
if(!user.client.prefs.tgui_input_mode && !strict_modern)
|
||||
return input(user, message, title, default) as null|anything in items
|
||||
var/datum/tgui_list_input/input = new(user, message, title, items, default, timeout)
|
||||
input.ui_interact(user)
|
||||
input.wait()
|
||||
if (input)
|
||||
. = input.choice
|
||||
qdel(input)
|
||||
|
||||
/**
|
||||
* 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 (istype(user, /client))
|
||||
var/client/client = user
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
var/datum/tgui_list_input/async/input = new(user, message, title, buttons, callback, timeout)
|
||||
input.ui_interact(user)
|
||||
|
||||
/**
|
||||
* # tgui_list_input
|
||||
*
|
||||
@@ -64,12 +46,14 @@
|
||||
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 list of items (responses) provided on the TGUI window
|
||||
var/list/items
|
||||
/// Items (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 item 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.
|
||||
@@ -77,24 +61,29 @@
|
||||
/// 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)
|
||||
/datum/tgui_list_input/New(mob/user, message, title, list/items, default, timeout)
|
||||
src.title = title
|
||||
src.message = message
|
||||
src.buttons = list()
|
||||
src.buttons_map = list()
|
||||
var/list/repeat_buttons = list()
|
||||
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 buttons)
|
||||
for(var/i in items)
|
||||
if(isnull(i))
|
||||
stack_trace("Null in a tgui_input_list() items")
|
||||
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_buttons)
|
||||
string_key = avoid_assoc_duplicate_keys(string_key, repeat_items)
|
||||
|
||||
src.buttons += string_key
|
||||
src.buttons_map[string_key] = i
|
||||
src.items += string_key
|
||||
src.items_map[string_key] = i
|
||||
|
||||
|
||||
if (timeout)
|
||||
@@ -104,7 +93,7 @@
|
||||
|
||||
/datum/tgui_list_input/Destroy(force, ...)
|
||||
SStgui.close_uis(src)
|
||||
QDEL_NULL(buttons)
|
||||
QDEL_NULL(items)
|
||||
. = ..()
|
||||
|
||||
/**
|
||||
@@ -118,37 +107,42 @@
|
||||
/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, "ListInput")
|
||||
ui = new(user, src, "ListInputModal")
|
||||
ui.open()
|
||||
|
||||
/datum/tgui_list_input/ui_close(mob/user)
|
||||
. = ..()
|
||||
closed = TRUE
|
||||
///datum/tgui_list_input/tgui_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(
|
||||
"title" = title,
|
||||
"message" = message,
|
||||
"buttons" = buttons
|
||||
)
|
||||
var/list/data = list()
|
||||
data["init_value"] = default || items[1]
|
||||
data["items"] = items
|
||||
data["large_buttons"] = user.client.prefs.tgui_large_buttons
|
||||
data["message"] = message
|
||||
data["swapped_buttons"] = !user.client.prefs.tgui_swapped_buttons
|
||||
data["title"] = title
|
||||
return data
|
||||
|
||||
/datum/tgui_list_input/ui_data(mob/user)
|
||||
. = list()
|
||||
var/list/data = list()
|
||||
if(timeout)
|
||||
.["timeout"] = clamp((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS), 0, 1)
|
||||
data["timeout"] = clamp((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS), 0, 1)
|
||||
return data
|
||||
|
||||
/datum/tgui_list_input/ui_act(action, list/params)
|
||||
. = ..()
|
||||
//. = ..()
|
||||
if (.)
|
||||
return
|
||||
switch(action)
|
||||
if("choose")
|
||||
if (!(params["choice"] in buttons))
|
||||
if("submit")
|
||||
if (!(params["entry"] in items))
|
||||
return
|
||||
set_choice(buttons_map[params["choice"]])
|
||||
set_choice(items_map[params["entry"]])
|
||||
closed = TRUE
|
||||
SStgui.close_uis(src)
|
||||
return TRUE
|
||||
if("cancel")
|
||||
@@ -159,6 +153,36 @@
|
||||
/datum/tgui_list_input/proc/set_choice(choice)
|
||||
src.choice = choice
|
||||
|
||||
/**
|
||||
* 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 - The option with this value will be selected on first paint of the TGUI window.
|
||||
* * 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/items, default, datum/callback/callback, timeout = 60 SECONDS)
|
||||
if (istext(user))
|
||||
stack_trace("tgui_alert() received text for user instead of mob")
|
||||
return
|
||||
if (!user)
|
||||
user = usr
|
||||
if(!length(items))
|
||||
return
|
||||
if (!istype(user))
|
||||
if (istype(user, /client))
|
||||
var/client/client = user
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
var/datum/tgui_list_input/async/input = new(user, message, title, items, default, callback, timeout)
|
||||
input.ui_interact(user)
|
||||
|
||||
/**
|
||||
* # async tgui_list_input
|
||||
*
|
||||
@@ -168,14 +192,18 @@
|
||||
/// 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, callback, timeout)
|
||||
..(user, message, title, buttons, timeout)
|
||||
/datum/tgui_list_input/async/New(mob/user, message, title, list/items, default, callback, timeout)
|
||||
..(user, title, message, items, default, timeout)
|
||||
src.callback = callback
|
||||
|
||||
/datum/tgui_list_input/async/Destroy(force, ...)
|
||||
QDEL_NULL(callback)
|
||||
. = ..()
|
||||
|
||||
///datum/tgui_list_input/async/tgui_close(mob/user)
|
||||
// . = ..()
|
||||
// qdel(src)
|
||||
|
||||
/datum/tgui_list_input/async/set_choice(choice)
|
||||
. = ..()
|
||||
if(!isnull(src.choice))
|
||||
211
code/modules/tgui_input/number.dm
Normal file
211
code/modules/tgui_input/number.dm
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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 = INFINITY, min_value = -INFINITY, timeout = 0, round_value = FALSE)
|
||||
if (!user)
|
||||
user = usr
|
||||
if (!istype(user))
|
||||
if (istype(user, /client))
|
||||
var/client/client = user
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
// Client does NOT have tgui_input on: Returns regular input
|
||||
if(!user.client.prefs.tgui_input_mode)
|
||||
var/input_number = input(user, message, title, default) as null|num
|
||||
return clamp(round_value ? round(input_number) : input_number, min_value, max_value)
|
||||
var/datum/tgui_input_number/number_input = new(user, message, title, default, max_value, min_value, timeout, round_value)
|
||||
number_input.ui_interact(user)
|
||||
number_input.wait()
|
||||
if (number_input)
|
||||
. = number_input.entry
|
||||
qdel(number_input)
|
||||
|
||||
/**
|
||||
* # tgui_input_number
|
||||
*
|
||||
* Datum used for instantiating and using a TGUI-controlled number input that prompts the user with
|
||||
* a message and has an input for number entry.
|
||||
*/
|
||||
/datum/tgui_input_number
|
||||
/// Boolean field describing if the tgui_input_number was closed by the user.
|
||||
var/closed
|
||||
/// The default (or current) value, shown as a default. Users can press reset with this.
|
||||
var/default
|
||||
/// The entry that the user has return_typed in.
|
||||
var/entry
|
||||
/// The maximum value that can be entered.
|
||||
var/max_value
|
||||
/// The prompt's body, if any, of the TGUI window.
|
||||
var/message
|
||||
/// The minimum value that can be entered.
|
||||
var/min_value
|
||||
/// Whether the submitted number is rounded down into an integer.
|
||||
var/round_value
|
||||
/// The time at which the number input was created, for displaying timeout progress.
|
||||
var/start_time
|
||||
/// The lifespan of the number input, after which the window will close and delete itself.
|
||||
var/timeout
|
||||
/// The title of the TGUI window
|
||||
var/title
|
||||
|
||||
/datum/tgui_input_number/New(mob/user, message, title, default, max_value, min_value, timeout, round_value)
|
||||
src.default = default
|
||||
src.max_value = max_value
|
||||
src.message = message
|
||||
src.min_value = min_value
|
||||
src.title = title
|
||||
src.round_value = round_value
|
||||
if (timeout)
|
||||
src.timeout = timeout
|
||||
start_time = world.time
|
||||
QDEL_IN(src, timeout)
|
||||
/// Checks for empty numbers - bank accounts, etc.
|
||||
if(max_value == 0)
|
||||
src.min_value = 0
|
||||
if(default)
|
||||
src.default = 0
|
||||
/// Sanity check
|
||||
if(default < min_value)
|
||||
src.default = min_value
|
||||
if(default > max_value)
|
||||
CRASH("Default value is greater than max value.")
|
||||
|
||||
/datum/tgui_input_number/Destroy(force, ...)
|
||||
SStgui.close_uis(src)
|
||||
return ..()
|
||||
|
||||
/**
|
||||
* Waits for a user's response to the tgui_input_number's prompt before returning. Returns early if
|
||||
* the window was closed by the user.
|
||||
*/
|
||||
/datum/tgui_input_number/proc/wait()
|
||||
while (!entry && !closed && !QDELETED(src))
|
||||
stoplag(1)
|
||||
|
||||
/datum/tgui_input_number/ui_interact(mob/user, datum/tgui/ui)
|
||||
ui = SStgui.try_update_ui(user, src, ui)
|
||||
if(!ui)
|
||||
ui = new(user, src, "NumberInputModal")
|
||||
ui.open()
|
||||
|
||||
///datum/tgui_input_number/tgui_close(mob/user)
|
||||
// . = ..()
|
||||
// closed = TRUE
|
||||
|
||||
/datum/tgui_input_number/ui_state(mob/user)
|
||||
return GLOB.always_state
|
||||
|
||||
/datum/tgui_input_number/ui_static_data(mob/user)
|
||||
var/list/data = list()
|
||||
data["init_value"] = default // Default is a reserved keyword
|
||||
data["large_buttons"] = user.client.prefs.tgui_large_buttons
|
||||
data["max_value"] = max_value
|
||||
data["message"] = message
|
||||
data["min_value"] = min_value
|
||||
data["swapped_buttons"] = !user.client.prefs.tgui_swapped_buttons
|
||||
data["title"] = title
|
||||
return data
|
||||
|
||||
/datum/tgui_input_number/ui_data(mob/user)
|
||||
var/list/data = list()
|
||||
if(timeout)
|
||||
data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS))
|
||||
return data
|
||||
|
||||
/datum/tgui_input_number/ui_act(action, list/params)
|
||||
//. = ..()
|
||||
if (.)
|
||||
return
|
||||
switch(action)
|
||||
if("submit")
|
||||
if(!isnum(params["entry"]))
|
||||
CRASH("A non number was input into tgui input number by [usr]")
|
||||
//var/choice = round_value ? round(params["entry"]) : params["entry"]
|
||||
var/choice = 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
|
||||
|
||||
/**
|
||||
* Creates an asynchronous TGUI input num 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.
|
||||
* * default - The default value pre-populated in the input box.
|
||||
* * 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.
|
||||
* * round_value - whether the inputted number is rounded down into an integer.
|
||||
*/
|
||||
/proc/tgui_input_number_async(mob/user, message, title, default, datum/callback/callback, timeout = 60 SECONDS, round_value = FALSE)
|
||||
if (istext(user))
|
||||
stack_trace("tgui_input_num_async() received text for user instead of mob")
|
||||
return
|
||||
if (!user)
|
||||
user = usr
|
||||
if (!istype(user))
|
||||
if (istype(user, /client))
|
||||
var/client/client = user
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
var/datum/tgui_input_number/async/input = new(user, message, title, default, callback, timeout, round_value)
|
||||
input.ui_interact(user)
|
||||
|
||||
/**
|
||||
* # async tgui_text_input
|
||||
*
|
||||
* An asynchronous version of tgui_text_input to be used with callbacks instead of waiting on user responses.
|
||||
*/
|
||||
/datum/tgui_input_number/async
|
||||
/// The callback to be invoked by the tgui_text_input upon having a choice made.
|
||||
var/datum/callback/callback
|
||||
|
||||
/datum/tgui_input_number/async/New(mob/user, message, title, default, callback, timeout, round_value)
|
||||
..(user, title, message, default, timeout, round_value)
|
||||
src.callback = callback
|
||||
|
||||
/datum/tgui_input_number/async/Destroy(force, ...)
|
||||
QDEL_NULL(callback)
|
||||
. = ..()
|
||||
|
||||
///datum/tgui_input_number/async/tgui_close(mob/user)
|
||||
// . = ..()
|
||||
// qdel(src)
|
||||
|
||||
/datum/tgui_input_number/async/set_entry(entry)
|
||||
. = ..()
|
||||
if(!isnull(src.entry))
|
||||
callback?.InvokeAsync(src.entry)
|
||||
|
||||
/datum/tgui_input_number/async/wait()
|
||||
return
|
||||
214
code/modules/tgui_input/text.dm
Normal file
214
code/modules/tgui_input/text.dm
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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 (4096)
|
||||
* * 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 = INFINITY, multiline = FALSE, encode = FALSE, timeout = 0, prevent_enter = FALSE)
|
||||
if (istext(user))
|
||||
stack_trace("tgui_input_text() received text for user instead of mob")
|
||||
return
|
||||
if (!user)
|
||||
user = usr
|
||||
if (!istype(user))
|
||||
if (istype(user, /client))
|
||||
var/client/client = user
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
// Client does NOT have tgui_input on: Returns regular input
|
||||
if(!user.client.prefs.tgui_input_mode)
|
||||
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, prevent_enter)
|
||||
text_input.ui_interact(user)
|
||||
text_input.wait()
|
||||
if (text_input)
|
||||
. = text_input.entry
|
||||
qdel(text_input)
|
||||
|
||||
/**
|
||||
* tgui_input_text
|
||||
*
|
||||
* Datum used for instantiating and using a TGUI-controlled text input that prompts the user with
|
||||
* a message and has an input for text entry.
|
||||
*/
|
||||
/datum/tgui_input_text
|
||||
/// Boolean field describing if the tgui_input_text was closed by the user.
|
||||
var/closed
|
||||
/// The default (or current) value, shown as a default.
|
||||
var/default
|
||||
/// Whether the input should be stripped using html_encode
|
||||
var/encode
|
||||
/// The entry that the user has return_typed in.
|
||||
var/entry
|
||||
/// The maximum length for text entry
|
||||
var/max_length
|
||||
/// The prompt's body, if any, of the TGUI window.
|
||||
var/message
|
||||
/// Multiline input for larger input boxes.
|
||||
var/multiline
|
||||
/// The time at which the text input was created, for displaying timeout progress.
|
||||
var/start_time
|
||||
/// The lifespan of the text input, after which the window will close and delete itself.
|
||||
var/timeout
|
||||
/// The title of the TGUI window
|
||||
var/title
|
||||
|
||||
var/prevent_enter
|
||||
|
||||
/datum/tgui_input_text/New(mob/user, message, title, default, max_length, multiline, encode, timeout, prevent_enter)
|
||||
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)
|
||||
src.prevent_enter = prevent_enter
|
||||
|
||||
/datum/tgui_input_text/Destroy(force, ...)
|
||||
SStgui.close_uis(src)
|
||||
. = ..()
|
||||
|
||||
/**
|
||||
* Waits for a user's response to the tgui_text_input's prompt before returning. Returns early if
|
||||
* the window was closed by the user.
|
||||
*/
|
||||
/datum/tgui_input_text/proc/wait()
|
||||
while (!entry && !closed)
|
||||
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()
|
||||
|
||||
//area/datum/tgui_input_text/tgui_close(mob/user)
|
||||
// . = ..()
|
||||
// closed = TRUE
|
||||
|
||||
/datum/tgui_input_text/ui_state(mob/user)
|
||||
return GLOB.always_state
|
||||
|
||||
/datum/tgui_input_text/ui_static_data(mob/user)
|
||||
var/list/data = list()
|
||||
data["large_buttons"] = user.client.prefs.tgui_large_buttons
|
||||
data["max_length"] = max_length
|
||||
data["message"] = message
|
||||
data["multiline"] = multiline
|
||||
data["placeholder"] = default // Default is a reserved keyword
|
||||
data["swapped_buttons"] = !user.client.prefs.tgui_swapped_buttons
|
||||
data["title"] = title
|
||||
data["prevent_enter"] = prevent_enter
|
||||
return data
|
||||
|
||||
/datum/tgui_input_text/ui_data(mob/user)
|
||||
var/list/data = list()
|
||||
if(timeout)
|
||||
data["timeout"] = clamp((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS), 0, 1)
|
||||
return data
|
||||
|
||||
/datum/tgui_input_text/ui_act(action, list/params)
|
||||
//. = ..()
|
||||
if (.)
|
||||
return
|
||||
switch(action)
|
||||
if("submit")
|
||||
if(length(params["entry"]) > max_length)
|
||||
return
|
||||
if(encode && (length(html_encode(params["entry"])) > max_length))
|
||||
to_chat(usr, span_notice("Your message was clipped due to special character usage."))
|
||||
set_entry(params["entry"])
|
||||
closed = TRUE
|
||||
SStgui.close_uis(src)
|
||||
return TRUE
|
||||
if("cancel")
|
||||
SStgui.close_uis(src)
|
||||
closed = TRUE
|
||||
return TRUE
|
||||
|
||||
/datum/tgui_input_text/proc/set_entry(entry)
|
||||
if(!isnull(entry))
|
||||
var/converted_entry = encode ? html_encode(entry) : entry
|
||||
//converted_entry = readd_quotes(converted_entry)
|
||||
src.entry = trim(converted_entry, max_length)
|
||||
|
||||
/**
|
||||
* Creates an asynchronous TGUI input text 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.
|
||||
* * default - The default value pre-populated in the input box.
|
||||
* * 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_text_async(mob/user, message, title, default, datum/callback/callback, max_length, multiline, encode, timeout = 60 SECONDS)
|
||||
if (istext(user))
|
||||
stack_trace("tgui_input_text_async() received text for user instead of mob")
|
||||
return
|
||||
if (!user)
|
||||
user = usr
|
||||
if (!istype(user))
|
||||
if (istype(user, /client))
|
||||
var/client/client = user
|
||||
user = client.mob
|
||||
else
|
||||
return
|
||||
var/datum/tgui_input_text/async/input = new(user, message, title, default, callback, max_length, multiline, encode, timeout)
|
||||
input.ui_interact(user)
|
||||
|
||||
/**
|
||||
* # async tgui_text_input
|
||||
*
|
||||
* An asynchronous version of tgui_text_input to be used with callbacks instead of waiting on user responses.
|
||||
*/
|
||||
/datum/tgui_input_text/async
|
||||
/// The callback to be invoked by the tgui_text_input upon having a choice made.
|
||||
var/datum/callback/callback
|
||||
|
||||
/datum/tgui_input_text/async/New(mob/user, message, title, default, callback, max_length, multiline, encode, timeout)
|
||||
..(user, title, message, default, max_length, multiline, encode, timeout)
|
||||
src.callback = callback
|
||||
|
||||
/datum/tgui_input_text/async/Destroy(force, ...)
|
||||
QDEL_NULL(callback)
|
||||
. = ..()
|
||||
|
||||
///datum/tgui_input_text/async/tgui_close(mob/user)
|
||||
// . = ..()
|
||||
// qdel(src)
|
||||
|
||||
/datum/tgui_input_text/async/set_entry(entry)
|
||||
. = ..()
|
||||
if(!isnull(src.entry))
|
||||
callback?.InvokeAsync(src.entry)
|
||||
|
||||
/datum/tgui_input_text/async/wait()
|
||||
return
|
||||
@@ -42,7 +42,7 @@
|
||||
sleep(1)
|
||||
initialized_at = world.time
|
||||
// Perform a clean initialization
|
||||
window.initialize(inline_assets = list(
|
||||
window.initialize(assets = list(
|
||||
get_asset_datum(/datum/asset/simple/tgui_panel),
|
||||
))
|
||||
window.send_asset(get_asset_datum(/datum/asset/simple/namespaced/fontawesome))
|
||||
|
||||
@@ -3683,8 +3683,6 @@
|
||||
#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_list.dm"
|
||||
#include "code\modules\tgui\tgui_window.dm"
|
||||
#include "code\modules\tgui\states\admin.dm"
|
||||
#include "code\modules\tgui\states\always.dm"
|
||||
@@ -3705,6 +3703,10 @@
|
||||
#include "code\modules\tgui\states\self.dm"
|
||||
#include "code\modules\tgui\states\vorepanel.dm"
|
||||
#include "code\modules\tgui\states\zlevel.dm"
|
||||
#include "code\modules\tgui_input\alert.dm"
|
||||
#include "code\modules\tgui_input\list.dm"
|
||||
#include "code\modules\tgui_input\number.dm"
|
||||
#include "code\modules\tgui_input\text.dm"
|
||||
#include "code\modules\tgui_panel\audio.dm"
|
||||
#include "code\modules\tgui_panel\external.dm"
|
||||
#include "code\modules\tgui_panel\telemetry.dm"
|
||||
|
||||
631
tgui/.yarn/releases/yarn-3.0.1.cjs
vendored
631
tgui/.yarn/releases/yarn-3.0.1.cjs
vendored
File diff suppressed because one or more lines are too long
783
tgui/.yarn/releases/yarn-3.2.3.cjs
vendored
Normal file
783
tgui/.yarn/releases/yarn-3.2.3.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -14,4 +14,4 @@ preferAggregateCacheInfo: true
|
||||
|
||||
preferInteractive: true
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.0.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-3.2.3.cjs
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- `code/__HELPERS/_logging.dm`
|
||||
|
||||
If you have a dual nano/tgui setup, then make sure to rename all ui procs
|
||||
on `/datum`, such as `ui_interact` to `tgui_interact`, to avoid namespace
|
||||
on `/datum`, such as `ui_interact` to `ui_interact`, to avoid namespace
|
||||
clashing. Usual stuff.
|
||||
|
||||
## Update `ui_interact` proc signatures
|
||||
|
||||
@@ -2,45 +2,61 @@
|
||||
"private": true,
|
||||
"name": "tgui-workspace",
|
||||
"version": "4.3.0",
|
||||
"packageManager": "yarn@3.2.3",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"tgui:analyze": "webpack --analyze",
|
||||
"tgui:bench": "webpack --env TGUI_BENCH=1 && node packages/tgui-bench/index.js",
|
||||
"tgui:build": "webpack",
|
||||
"tgui:dev": "node --experimental-modules packages/tgui-dev-server/index.js",
|
||||
"tgui:lint": "eslint packages --ext .js,.cjs,.ts,.tsx",
|
||||
"tgui:prettier": "prettierx --check .",
|
||||
"tgui:sonar": "eslint packages --ext .js,.cjs,.ts,.tsx -c .eslintrc-harder.yml",
|
||||
"tgui:test": "jest --watch",
|
||||
"tgui:test-simple": "CI=true jest --color",
|
||||
"tgui:test-ci": "CI=true jest --color --collect-coverage",
|
||||
"tgui:tsc": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.15.0",
|
||||
"@babel/eslint-parser": "^7.15.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.14.5",
|
||||
"@babel/plugin-transform-jscript": "^7.14.5",
|
||||
"@babel/preset-env": "^7.15.0",
|
||||
"@babel/preset-typescript": "^7.15.0",
|
||||
"@types/jest": "^27.0.1",
|
||||
"@types/jsdom": "^16.2.13",
|
||||
"@types/node": "^14.17.9",
|
||||
"@typescript-eslint/parser": "^4.29.1",
|
||||
"babel-jest": "^27.0.6",
|
||||
"babel-loader": "^8.2.2",
|
||||
"babel-plugin-inferno": "^6.3.0",
|
||||
"@babel/core": "^7.18.0",
|
||||
"@babel/eslint-parser": "^7.17.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.17.12",
|
||||
"@babel/plugin-transform-jscript": "^7.17.12",
|
||||
"@babel/preset-env": "^7.18.0",
|
||||
"@babel/preset-typescript": "^7.17.12",
|
||||
"@types/jest": "^27.5.1",
|
||||
"@types/jsdom": "^16.2.14",
|
||||
"@types/node": "^17.0.35",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"@types/webpack-env": "^1.17.0",
|
||||
"@typescript-eslint/parser": "^5.25.0",
|
||||
"babel-jest": "^28.1.0",
|
||||
"babel-loader": "^8.2.5",
|
||||
"babel-plugin-inferno": "^6.4.0",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"common": "workspace:*",
|
||||
"css-loader": "^5.2.7",
|
||||
"eslint": "^7.32.0",
|
||||
"css-loader": "^6.7.1",
|
||||
"eslint": "^8.16.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-radar": "^0.2.1",
|
||||
"eslint-plugin-react": "^7.24.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"inferno": "^7.4.8",
|
||||
"jest": "^27.0.6",
|
||||
"jest-circus": "^27.0.6",
|
||||
"jsdom": "^16.7.0",
|
||||
"eslint-plugin-react": "^7.30.0",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"inferno": "^7.4.11",
|
||||
"jest": "^28.1.0",
|
||||
"jest-circus": "^28.1.0",
|
||||
"jest-environment-jsdom": "^28.1.0",
|
||||
"jsdom": "^19.0.0",
|
||||
"katex": "^0.15.1",
|
||||
"mini-css-extract-plugin": "^1.6.2",
|
||||
"sass": "^1.37.5",
|
||||
"sass-loader": "^11.1.1",
|
||||
"style-loader": "^2.0.0",
|
||||
"terser-webpack-plugin": "^5.1.4",
|
||||
"typescript": "^4.3.5",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.50.0",
|
||||
"webpack-bundle-analyzer": "^4.4.2",
|
||||
"webpack-cli": "^4.7.2"
|
||||
},
|
||||
"packageManager": "yarn@3.0.1"
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"sass": "^1.52.1",
|
||||
"sass-loader": "^13.0.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"terser-webpack-plugin": "^5.3.1",
|
||||
"typescript": "^4.6.4",
|
||||
"webpack": "^5.72.1",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
"webpack-cli": "^4.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,7 @@ type BackendState<TData> = {
|
||||
title: string,
|
||||
status: number,
|
||||
interface: string,
|
||||
refreshing: boolean,
|
||||
window: {
|
||||
key: string,
|
||||
size: [number, number],
|
||||
|
||||
19
tgui/packages/tgui/components/Autofocus.tsx
Normal file
19
tgui/packages/tgui/components/Autofocus.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Component, createRef } from 'inferno';
|
||||
|
||||
export class Autofocus extends Component {
|
||||
ref = createRef<HTMLDivElement>();
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
this.ref.current?.focus();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div ref={this.ref} tabIndex={-1}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
154
tgui/packages/tgui/components/RestrictedInput.js
Normal file
154
tgui/packages/tgui/components/RestrictedInput.js
Normal file
@@ -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 (
|
||||
<Box
|
||||
className={classes([
|
||||
'Input',
|
||||
fluid && 'Input--fluid',
|
||||
monospace && 'Input--monospace',
|
||||
className,
|
||||
])}
|
||||
{...rest}>
|
||||
<div className="Input__baseline">.</div>
|
||||
<input
|
||||
className="Input__input"
|
||||
onChange={this.handleChange}
|
||||
onInput={this.handleInput}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
ref={this.inputRef}
|
||||
type="number"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
export { AnimatedNumber } from './AnimatedNumber';
|
||||
export { Autofocus } from './Autofocus';
|
||||
export { Blink } from './Blink';
|
||||
export { BlockQuote } from './BlockQuote';
|
||||
export { Box } from './Box';
|
||||
@@ -30,6 +31,7 @@ export { NoticeBox } from './NoticeBox';
|
||||
export { NumberInput } from './NumberInput';
|
||||
export { ProgressBar } from './ProgressBar';
|
||||
export { Popper } from './Popper';
|
||||
export { RestrictedInput } from './RestrictedInput';
|
||||
export { RoundGauge } from './RoundGauge';
|
||||
export { Section } from './Section';
|
||||
export { Slider } from './Slider';
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 bobbahbrown (https://github.com/bobbahbrown)
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp01 } from 'common/math';
|
||||
import { useBackend } from '../backend';
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { Box, Flex, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import {
|
||||
KEY_ENTER,
|
||||
KEY_LEFT,
|
||||
KEY_RIGHT,
|
||||
KEY_SPACE,
|
||||
KEY_TAB,
|
||||
} from 'common/keycodes';
|
||||
|
||||
export class AlertModal extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.buttonRefs = [createRef()];
|
||||
this.state = { current: 0 };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { data } = useBackend(this.context);
|
||||
const { buttons, autofocus } = data;
|
||||
const { current } = this.state;
|
||||
const button = this.buttonRefs[current].current;
|
||||
|
||||
// Fill ref array with refs for other buttons
|
||||
for (let i = 1; i < buttons.length; i++) {
|
||||
this.buttonRefs.push(createRef());
|
||||
}
|
||||
|
||||
if (autofocus) {
|
||||
setTimeout(() => button.focus(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
setCurrent(current, isArrowKey) {
|
||||
const { data } = useBackend(this.context);
|
||||
const { buttons } = data;
|
||||
|
||||
// Mimic alert() behavior for tabs and arrow keys
|
||||
if (current >= buttons.length) {
|
||||
current = isArrowKey ? current - 1 : 0;
|
||||
} else if (current < 0) {
|
||||
current = isArrowKey ? 0 : buttons.length - 1;
|
||||
}
|
||||
|
||||
const button = this.buttonRefs[current].current;
|
||||
|
||||
// Prevents an error from occurring on close
|
||||
if (button) {
|
||||
setTimeout(() => button.focus(), 1);
|
||||
}
|
||||
this.setState({ current });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { act, data } = useBackend(this.context);
|
||||
const { title, message, buttons, timeout } = data;
|
||||
const { current } = this.state;
|
||||
const focusCurrentButton = () => this.setCurrent(current, false);
|
||||
|
||||
return (
|
||||
<Window
|
||||
title={title}
|
||||
width={350}
|
||||
height={150}
|
||||
canClose={timeout > 0}>
|
||||
{timeout && <Loader value={timeout} />}
|
||||
<Window.Content
|
||||
onFocus={focusCurrentButton}
|
||||
onClick={focusCurrentButton}>
|
||||
<Section fill>
|
||||
<Flex direction="column" height="100%">
|
||||
<Flex.Item grow={1}>
|
||||
<Flex
|
||||
direction="column"
|
||||
className="AlertModal__Message"
|
||||
height="100%">
|
||||
<Flex.Item>
|
||||
<Box m={1}>
|
||||
{message}
|
||||
</Box>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Flex.Item>
|
||||
<Flex.Item my={2}>
|
||||
<Flex className="AlertModal__Buttons">
|
||||
{buttons.map((button, buttonIndex) => (
|
||||
<Flex.Item key={buttonIndex} mx={1}>
|
||||
<div
|
||||
ref={this.buttonRefs[buttonIndex]}
|
||||
className="Button Button--color--default"
|
||||
px={3}
|
||||
onClick={() => act("choose", { choice: button })}
|
||||
onKeyDown={e => {
|
||||
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: button });
|
||||
} else if (
|
||||
keyCode === KEY_LEFT
|
||||
|| (e.shiftKey && keyCode === KEY_TAB)
|
||||
) {
|
||||
this.setCurrent(current - 1, keyCode === KEY_LEFT);
|
||||
} else if (
|
||||
keyCode === KEY_RIGHT || keyCode === KEY_TAB
|
||||
) {
|
||||
this.setCurrent(current + 1, keyCode === KEY_RIGHT);
|
||||
}
|
||||
}}>
|
||||
{button}
|
||||
</div>
|
||||
</Flex.Item>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const Loader = props => {
|
||||
const { value } = props;
|
||||
|
||||
return (
|
||||
<div className="AlertModal__Loader">
|
||||
<Box
|
||||
className="AlertModal__LoaderProgress"
|
||||
style={{ width: clamp01(value) * 100 + '%' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
151
tgui/packages/tgui/interfaces/AlertModal.tsx
Normal file
151
tgui/packages/tgui/interfaces/AlertModal.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Loader } from './common/Loader';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { KEY_ENTER, KEY_ESCAPE, KEY_LEFT, KEY_RIGHT, KEY_SPACE, KEY_TAB } from '../../common/keycodes';
|
||||
import { Autofocus, Box, Button, Flex, Section, Stack } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
type AlertModalData = {
|
||||
autofocus: boolean;
|
||||
buttons: string[];
|
||||
large_buttons: boolean;
|
||||
message: string;
|
||||
swapped_buttons: boolean;
|
||||
timeout: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const KEY_DECREMENT = -1;
|
||||
const KEY_INCREMENT = 1;
|
||||
|
||||
export const AlertModal = (_, context) => {
|
||||
const { act, data } = useBackend<AlertModalData>(context);
|
||||
const {
|
||||
autofocus,
|
||||
buttons = [],
|
||||
large_buttons,
|
||||
message = '',
|
||||
timeout,
|
||||
title,
|
||||
} = data;
|
||||
const [selected, setSelected] = useLocalState<number>(context, 'selected', 0);
|
||||
// Dynamically sets window dimensions
|
||||
const windowHeight
|
||||
= 115
|
||||
+ (message.length > 30 ? Math.ceil(message.length / 4) : 0)
|
||||
+ (message.length && large_buttons ? 5 : 0);
|
||||
const windowWidth = 325 + (buttons.length > 2 ? 55 : 0);
|
||||
const onKey = (direction: number) => {
|
||||
if (selected === 0 && direction === KEY_DECREMENT) {
|
||||
setSelected(buttons.length - 1);
|
||||
} else if (selected === buttons.length - 1 && direction === KEY_INCREMENT) {
|
||||
setSelected(0);
|
||||
} else {
|
||||
setSelected(selected + direction);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window height={windowHeight} title={title} width={windowWidth}>
|
||||
{!!timeout && <Loader value={timeout} />}
|
||||
<Window.Content
|
||||
onKeyDown={(e) => {
|
||||
const keyCode = window.event ? e.which : e.keyCode;
|
||||
/**
|
||||
* Simulate a click when pressing space or enter,
|
||||
* allow keyboard navigation, override tab behavior
|
||||
*/
|
||||
if (keyCode === KEY_SPACE || keyCode === KEY_ENTER) {
|
||||
act('choose', { choice: buttons[selected] });
|
||||
} else if (keyCode === KEY_ESCAPE) {
|
||||
act('cancel');
|
||||
} else if (keyCode === KEY_LEFT) {
|
||||
e.preventDefault();
|
||||
onKey(KEY_DECREMENT);
|
||||
} else if (keyCode === KEY_TAB || keyCode === KEY_RIGHT) {
|
||||
e.preventDefault();
|
||||
onKey(KEY_INCREMENT);
|
||||
}
|
||||
}}>
|
||||
<Section fill>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item grow m={1}>
|
||||
<Box color="label" overflow="hidden">
|
||||
{message}
|
||||
</Box>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
{!!autofocus && <Autofocus />}
|
||||
<ButtonDisplay selected={selected} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<AlertModalData>(context);
|
||||
const { buttons = [], large_buttons, swapped_buttons } = data;
|
||||
const { selected } = props;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
direction={!swapped_buttons ? 'row-reverse' : 'row'}
|
||||
fill
|
||||
justify="space-around"
|
||||
wrap>
|
||||
{buttons?.map((button, index) =>
|
||||
!!large_buttons && buttons.length < 3 ? (
|
||||
<Flex.Item grow key={index}>
|
||||
<AlertButton
|
||||
button={button}
|
||||
id={index.toString()}
|
||||
selected={selected === index}
|
||||
/>
|
||||
</Flex.Item>
|
||||
) : (
|
||||
<Flex.Item key={index}>
|
||||
<AlertButton
|
||||
button={button}
|
||||
id={index.toString()}
|
||||
selected={selected === index}
|
||||
/>
|
||||
</Flex.Item>
|
||||
)
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a button with variable sizing.
|
||||
*/
|
||||
const AlertButton = (props, context) => {
|
||||
const { act, data } = useBackend<AlertModalData>(context);
|
||||
const { large_buttons } = data;
|
||||
const { button, selected } = props;
|
||||
const buttonWidth = button.length > 7 ? button.length : 7;
|
||||
|
||||
return (
|
||||
<Button
|
||||
fluid={!!large_buttons}
|
||||
height={!!large_buttons && 2}
|
||||
onClick={() => act('choose', { choice: button })}
|
||||
m={0.5}
|
||||
pl={2}
|
||||
pr={2}
|
||||
pt={large_buttons ? 0.33 : 0}
|
||||
selected={selected}
|
||||
textAlign="center"
|
||||
width={!large_buttons && buttonWidth}>
|
||||
{!large_buttons ? button : button.toUpperCase()}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 watermelon914 (https://github.com/watermelon914)
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp01 } from 'common/math';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, Section, Input, Stack } from '../components';
|
||||
import { KEY_DOWN, KEY_UP, KEY_ENTER, KEY_SPACE } from 'common/keycodes';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
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]);
|
||||
|
||||
const handleKeyDown = e => {
|
||||
e.preventDefault();
|
||||
if (lastScrollTime > performance.now()) {
|
||||
return;
|
||||
}
|
||||
lastScrollTime = performance.now() + 125;
|
||||
|
||||
if (e.keyCode === KEY_UP || e.keyCode === KEY_DOWN) {
|
||||
let direction = 1;
|
||||
if (e.keyCode === 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;
|
||||
}
|
||||
|
||||
if (e.keyCode === KEY_SPACE || e.keyCode === KEY_ENTER) {
|
||||
act("choose", { choice: selectedButton });
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window
|
||||
title={title}
|
||||
width={325}
|
||||
height={325}>
|
||||
{timeout !== undefined && <Loader value={timeout} />}
|
||||
<Window.Content>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item grow>
|
||||
<Section
|
||||
fill
|
||||
scrollable
|
||||
className="ListInput__Section"
|
||||
title={message}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
buttons={(
|
||||
<Button
|
||||
compact
|
||||
icon="search"
|
||||
color="transparent"
|
||||
selected={showSearchBar}
|
||||
tooltip="Search Bar"
|
||||
tooltipPosition="left"
|
||||
onClick={() => {
|
||||
setShowSearchBar(!showSearchBar);
|
||||
setDisplayedArray(buttons);
|
||||
}}
|
||||
/>
|
||||
)}>
|
||||
{displayedArray.map(button => (
|
||||
<Button
|
||||
key={button}
|
||||
fluid
|
||||
color="transparent"
|
||||
id={button}
|
||||
selected={selectedButton === button}
|
||||
onClick={() => {
|
||||
if (selectedButton === button) {
|
||||
act("choose", { choice: button });
|
||||
}
|
||||
else {
|
||||
setSelectedButton(button);
|
||||
}
|
||||
setLastCharCode(null);
|
||||
}}>
|
||||
{button}
|
||||
</Button>
|
||||
))}
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
{showSearchBar && (
|
||||
<Stack.Item>
|
||||
<Input
|
||||
fluid
|
||||
onInput={(e, value) => setDisplayedArray(
|
||||
buttons.filter(val => (
|
||||
val.toLowerCase().search(value.toLowerCase()) !== -1
|
||||
))
|
||||
)}
|
||||
/>
|
||||
</Stack.Item>
|
||||
)}
|
||||
<Stack.Item>
|
||||
<Stack textAlign="center">
|
||||
<Stack.Item grow basis={0}>
|
||||
<Button
|
||||
fluid
|
||||
color="good"
|
||||
lineHeight={2}
|
||||
content="Confirm"
|
||||
disabled={selectedButton === null}
|
||||
onClick={() => act("choose", { choice: selectedButton })}
|
||||
/>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow basis={0}>
|
||||
<Button
|
||||
fluid
|
||||
color="bad"
|
||||
lineHeight={2}
|
||||
content="Cancel"
|
||||
onClick={() => act("cancel")}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
export const Loader = props => {
|
||||
const { value } = props;
|
||||
return (
|
||||
<div className="ListInput__Loader">
|
||||
<Box
|
||||
className="ListInput__LoaderProgress"
|
||||
style={{
|
||||
width: clamp01(value) * 100 + '%',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
247
tgui/packages/tgui/interfaces/ListInputModal.tsx
Normal file
247
tgui/packages/tgui/interfaces/ListInputModal.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { Loader } from './common/Loader';
|
||||
import { InputButtons } from './common/InputButtons';
|
||||
import { Button, Input, Section, Stack } from '../components';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { KEY_A, KEY_DOWN, KEY_ESCAPE, KEY_ENTER, KEY_UP, KEY_Z } from '../../common/keycodes';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
type ListInputData = {
|
||||
init_value: string;
|
||||
items: string[];
|
||||
large_buttons: boolean;
|
||||
message: string;
|
||||
timeout: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const ListInputModal = (_, context) => {
|
||||
const { act, data } = useBackend<ListInputData>(context);
|
||||
const {
|
||||
items = [],
|
||||
message = "",
|
||||
init_value,
|
||||
large_buttons,
|
||||
timeout,
|
||||
title,
|
||||
} = data;
|
||||
const [selected, setSelected] = useLocalState<number>(
|
||||
context,
|
||||
'selected',
|
||||
items.indexOf(init_value)
|
||||
);
|
||||
const [searchBarVisible, setSearchBarVisible] = useLocalState<boolean>(
|
||||
context,
|
||||
'searchBarVisible',
|
||||
items.length > 9
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useLocalState<string>(
|
||||
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 (
|
||||
<Window title={title} width={325} height={windowHeight}>
|
||||
{timeout && <Loader value={timeout} />}
|
||||
<Window.Content
|
||||
onKeyDown={(event) => {
|
||||
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');
|
||||
}
|
||||
}}>
|
||||
<Section
|
||||
buttons={
|
||||
<Button
|
||||
compact
|
||||
icon={searchBarVisible ? 'search' : 'font'}
|
||||
selected
|
||||
tooltip={
|
||||
searchBarVisible
|
||||
? 'Search Mode. Type to search or use arrow keys to select manually.'
|
||||
: 'Hotkey Mode. Type a letter to jump to the first match. Enter to select.'
|
||||
}
|
||||
tooltipPosition="left"
|
||||
onClick={() => onSearchBarToggle()}
|
||||
/>
|
||||
}
|
||||
className="ListInput__Section"
|
||||
fill
|
||||
title={message}>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item grow>
|
||||
<ListDisplay
|
||||
filteredItems={filteredItems}
|
||||
onClick={onClick}
|
||||
onFocusSearch={onFocusSearch}
|
||||
searchBarVisible={searchBarVisible}
|
||||
selected={selected}
|
||||
/>
|
||||
</Stack.Item>
|
||||
{searchBarVisible && (
|
||||
<SearchBar
|
||||
filteredItems={filteredItems}
|
||||
onSearch={onSearch}
|
||||
searchQuery={searchQuery}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
<Stack.Item>
|
||||
<InputButtons input={filteredItems[selected]} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays the list of selectable items.
|
||||
* If a search query is provided, filters the items.
|
||||
*/
|
||||
const ListDisplay = (props, context) => {
|
||||
const { act } = useBackend<ListInputData>(context);
|
||||
const { filteredItems, onClick, onFocusSearch, searchBarVisible, selected }
|
||||
= props;
|
||||
|
||||
return (
|
||||
<Section fill scrollable tabIndex={0}>
|
||||
{filteredItems.map((item, index) => {
|
||||
return (
|
||||
<Button
|
||||
color="transparent"
|
||||
fluid
|
||||
id={index}
|
||||
key={index}
|
||||
onClick={() => onClick(index)}
|
||||
onDblClick={(event) => {
|
||||
event.preventDefault();
|
||||
act('submit', { entry: filteredItems[selected] });
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
const keyCode = window.event ? event.which : event.keyCode;
|
||||
if (searchBarVisible && keyCode >= KEY_A && keyCode <= KEY_Z) {
|
||||
event.preventDefault();
|
||||
onFocusSearch();
|
||||
}
|
||||
}}
|
||||
selected={index === selected}
|
||||
style={{
|
||||
'animation': 'none',
|
||||
'transition': 'none',
|
||||
}}>
|
||||
{item.replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a search bar input.
|
||||
* Closing the bar defaults input to an empty string.
|
||||
*/
|
||||
const SearchBar = (props, context) => {
|
||||
const { act } = useBackend<ListInputData>(context);
|
||||
const { filteredItems, onSearch, searchQuery, selected } = props;
|
||||
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
fluid
|
||||
onEnter={(event) => {
|
||||
event.preventDefault();
|
||||
act('submit', { entry: filteredItems[selected] });
|
||||
}}
|
||||
onInput={(_, value) => onSearch(value)}
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
);
|
||||
};
|
||||
118
tgui/packages/tgui/interfaces/NumberInputModal.tsx
Normal file
118
tgui/packages/tgui/interfaces/NumberInputModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Loader } from './common/Loader';
|
||||
import { InputButtons } from './common/InputButtons';
|
||||
import { KEY_ENTER, KEY_ESCAPE } from '../../common/keycodes';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, RestrictedInput, Section, Stack } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
type NumberInputData = {
|
||||
init_value: number;
|
||||
large_buttons: boolean;
|
||||
max_value: number | null;
|
||||
message: string;
|
||||
min_value: number | null;
|
||||
timeout: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const NumberInputModal = (_, context) => {
|
||||
const { act, data } = useBackend<NumberInputData>(context);
|
||||
const { init_value, large_buttons, message = "", timeout, title }
|
||||
= data;
|
||||
const [input, setInput] = useLocalState(context, 'input', init_value);
|
||||
const onChange = (value: number) => {
|
||||
if (value === input) {
|
||||
return;
|
||||
}
|
||||
setInput(value);
|
||||
};
|
||||
const onClick = (value: number) => {
|
||||
if (value === input) {
|
||||
return;
|
||||
}
|
||||
setInput(value);
|
||||
};
|
||||
// Dynamically changes the window height based on the message.
|
||||
const windowHeight
|
||||
= 140
|
||||
+ (message.length > 30 ? Math.ceil(message.length / 3) : 0)
|
||||
+ (message.length && large_buttons ? 5 : 0);
|
||||
|
||||
return (
|
||||
<Window title={title} width={270} height={windowHeight}>
|
||||
{timeout && <Loader value={timeout} />}
|
||||
<Window.Content
|
||||
onKeyDown={(event) => {
|
||||
const keyCode = window.event ? event.which : event.keyCode;
|
||||
if (keyCode === KEY_ENTER) {
|
||||
act('submit', { entry: input });
|
||||
}
|
||||
if (keyCode === KEY_ESCAPE) {
|
||||
act('cancel');
|
||||
}
|
||||
}}>
|
||||
<Section fill>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item grow>
|
||||
<Box color="label">{message}</Box>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<InputArea input={input} onClick={onClick} onChange={onChange} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<InputButtons input={input} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
/** Gets the user input and invalidates if there's a constraint. */
|
||||
const InputArea = (props, context) => {
|
||||
const { act, data } = useBackend<NumberInputData>(context);
|
||||
const { min_value, max_value, init_value } = data;
|
||||
const { input, onClick, onChange } = props;
|
||||
|
||||
return (
|
||||
<Stack fill>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
disabled={input === min_value}
|
||||
icon="angle-double-left"
|
||||
onClick={() => onClick(min_value)}
|
||||
tooltip={min_value ? `Min (${min_value})` : 'Min'}
|
||||
/>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<RestrictedInput
|
||||
autoFocus
|
||||
autoSelect
|
||||
fluid
|
||||
minValue={min_value}
|
||||
maxValue={max_value}
|
||||
onChange={(_, value) => onChange(value)}
|
||||
onEnter={(_, value) => act('submit', { entry: value })}
|
||||
value={input}
|
||||
/>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
disabled={input === max_value}
|
||||
icon="angle-double-right"
|
||||
onClick={() => onClick(max_value)}
|
||||
tooltip={max_value ? `Max (${max_value})` : 'Max'}
|
||||
/>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
disabled={input === init_value}
|
||||
icon="redo"
|
||||
onClick={() => onClick(init_value)}
|
||||
tooltip={init_value ? `Reset (${init_value})` : 'Reset'}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
103
tgui/packages/tgui/interfaces/TextInputModal.tsx
Normal file
103
tgui/packages/tgui/interfaces/TextInputModal.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Loader } from './common/Loader';
|
||||
import { InputButtons } from './common/InputButtons';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { KEY_ENTER, KEY_ESCAPE } from '../../common/keycodes';
|
||||
import { Box, Section, Stack, TextArea } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
type TextInputData = {
|
||||
large_buttons: boolean;
|
||||
max_length: number;
|
||||
message: string;
|
||||
multiline: boolean;
|
||||
placeholder: string;
|
||||
timeout: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TextInputModal = (_, context) => {
|
||||
const { act, data } = useBackend<TextInputData>(context);
|
||||
const {
|
||||
large_buttons,
|
||||
max_length,
|
||||
message = "",
|
||||
multiline,
|
||||
placeholder,
|
||||
timeout,
|
||||
title,
|
||||
} = data;
|
||||
const [input, setInput] = useLocalState<string>(
|
||||
context,
|
||||
'input',
|
||||
placeholder || ''
|
||||
);
|
||||
const onType = (value: string) => {
|
||||
if (value === input) {
|
||||
return;
|
||||
}
|
||||
setInput(value);
|
||||
};
|
||||
// Dynamically changes the window height based on the message.
|
||||
const windowHeight
|
||||
= 135
|
||||
+ (message.length > 30 ? Math.ceil(message.length / 4) : 0)
|
||||
+ (multiline || input.length >= 30 ? 75 : 0)
|
||||
+ (message.length && large_buttons ? 5 : 0);
|
||||
|
||||
return (
|
||||
<Window title={title} width={325} height={windowHeight}>
|
||||
{timeout && <Loader value={timeout} />}
|
||||
<Window.Content
|
||||
onKeyDown={(event) => {
|
||||
const keyCode = window.event ? event.which : event.keyCode;
|
||||
if (keyCode === KEY_ENTER) {
|
||||
act('submit', { entry: input });
|
||||
}
|
||||
if (keyCode === KEY_ESCAPE) {
|
||||
act('cancel');
|
||||
}
|
||||
}}>
|
||||
<Section fill>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item>
|
||||
<Box color="label">{message}</Box>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<InputArea input={input} onType={onType} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<InputButtons
|
||||
input={input}
|
||||
message={`${input.length}/${max_length}`}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
/** Gets the user input and invalidates if there's a constraint. */
|
||||
const InputArea = (props, context) => {
|
||||
const { act, data } = useBackend<TextInputData>(context);
|
||||
const { max_length, multiline } = data;
|
||||
const { input, onType } = props;
|
||||
|
||||
return (
|
||||
<TextArea
|
||||
autoFocus
|
||||
autoSelect
|
||||
height={multiline || input.length >= 30 ? '100%' : '1.8rem'}
|
||||
maxLength={max_length}
|
||||
onEscape={() => act('cancel')}
|
||||
onEnter={(event) => {
|
||||
act('submit', { entry: input });
|
||||
event.preventDefault();
|
||||
}}
|
||||
onInput={(_, value) => onType(value)}
|
||||
placeholder="Type something..."
|
||||
value={input}
|
||||
/>
|
||||
);
|
||||
};
|
||||
75
tgui/packages/tgui/interfaces/common/InputButtons.tsx
Normal file
75
tgui/packages/tgui/interfaces/common/InputButtons.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useBackend } from '../../backend';
|
||||
import { Box, Button, Flex } from '../../components';
|
||||
|
||||
type InputButtonsData = {
|
||||
large_buttons: boolean;
|
||||
swapped_buttons: boolean;
|
||||
};
|
||||
|
||||
type InputButtonsProps = {
|
||||
input: string | number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export const InputButtons = (props: InputButtonsProps, context) => {
|
||||
const { act, data } = useBackend<InputButtonsData>(context);
|
||||
const { large_buttons, swapped_buttons } = data;
|
||||
const { input, message } = props;
|
||||
const submitButton = (
|
||||
<Button
|
||||
color="good"
|
||||
fluid={!!large_buttons}
|
||||
height={!!large_buttons && 2}
|
||||
onClick={() => act('submit', { entry: input })}
|
||||
m={0.5}
|
||||
pl={2}
|
||||
pr={2}
|
||||
pt={large_buttons ? 0.33 : 0}
|
||||
textAlign="center"
|
||||
tooltip={large_buttons && message}
|
||||
width={!large_buttons && 6}>
|
||||
{large_buttons ? 'SUBMIT' : 'Submit'}
|
||||
</Button>
|
||||
);
|
||||
const cancelButton = (
|
||||
<Button
|
||||
color="bad"
|
||||
fluid={!!large_buttons}
|
||||
height={!!large_buttons && 2}
|
||||
onClick={() => act('cancel')}
|
||||
m={0.5}
|
||||
pl={2}
|
||||
pr={2}
|
||||
pt={large_buttons ? 0.33 : 0}
|
||||
textAlign="center"
|
||||
width={!large_buttons && 6}>
|
||||
{large_buttons ? 'CANCEL' : 'Cancel'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
direction={!swapped_buttons ? 'row' : 'row-reverse'}
|
||||
fill
|
||||
justify="space-around">
|
||||
{large_buttons ? (
|
||||
<Flex.Item grow>{cancelButton}</Flex.Item>
|
||||
) : (
|
||||
<Flex.Item>{cancelButton}</Flex.Item>
|
||||
)}
|
||||
{!large_buttons && message && (
|
||||
<Flex.Item>
|
||||
<Box color="label" textAlign="center">
|
||||
{message}
|
||||
</Box>
|
||||
</Flex.Item>
|
||||
)}
|
||||
{large_buttons ? (
|
||||
<Flex.Item grow>{submitButton}</Flex.Item>
|
||||
) : (
|
||||
<Flex.Item>{submitButton}</Flex.Item>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
12
tgui/packages/tgui/interfaces/common/Loader.tsx
Normal file
12
tgui/packages/tgui/interfaces/common/Loader.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Box } from '../../components';
|
||||
import { clamp01 } from 'common/math';
|
||||
|
||||
export const Loader = (props) => {
|
||||
const { value } = props;
|
||||
|
||||
return (
|
||||
<div className="AlertModal__Loader">
|
||||
<Box className="AlertModal__LoaderProgress" style={{ width: clamp01(value) * 100 + '%' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { selectBackend } from './backend';
|
||||
import { Icon, Stack } from './components';
|
||||
import { selectDebug } from './debug/selectors';
|
||||
import { Window } from './layouts';
|
||||
|
||||
@@ -33,12 +34,32 @@ const SuspendedWindow = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const RefreshingWindow = () => {
|
||||
return (
|
||||
<Window height={130} title="Loading" width={150}>
|
||||
<Window.Content>
|
||||
<Stack align="center" fill justify="center" vertical>
|
||||
<Stack.Item>
|
||||
<Icon color="blue" name="toolbox" spin size={4} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
Please wait...
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
export const getRoutedComponent = store => {
|
||||
const state = store.getState();
|
||||
const { suspended, config } = selectBackend(state);
|
||||
if (suspended) {
|
||||
return SuspendedWindow;
|
||||
}
|
||||
if (config.refreshing) {
|
||||
return RefreshingWindow;
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const debug = selectDebug(state);
|
||||
// Show a kitchen sink
|
||||
|
||||
@@ -85,14 +85,7 @@ module.exports = (env = {}, argv) => {
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|svg)$/,
|
||||
use: [
|
||||
{
|
||||
loader: require.resolve('url-loader'),
|
||||
options: {
|
||||
esModule: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'asset/inline',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
4391
tgui/yarn.lock
4391
tgui/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user