From fd063113615ac8a2c2429f46600b602c29a2607c Mon Sep 17 00:00:00 2001 From: SmArtKar <44720187+SmArtKar@users.noreply.github.com> Date: Fri, 28 Nov 2025 23:52:28 +0100 Subject: [PATCH] TGUI-fies the Spawn verb (#94094) ## About The Pull Request This PR converts the native text/list input that pops up when the Spawn verb is used into a TGUI panel with a dynamic search function. It now displays atom names, as well if they are abstract (and probably shouldn't be spawned). You can still input a typepath after the spawn verb in the tab bar which will open the menu with said path already in the input box. If only one typepath matches the input, it will be spawned immediately without opening the panel. Qs0t8xkGj7 It features RegEx support which activates when the input starts with ``re:``, as well as toggles for searching in atom names and fancy typepath display. They can be toggled via buttons, or Alt+R/N/F for regex/names/fancy paths respectively. Invalid regex-es will highlight the search bar red. The list itself can be navigated using arrow keys, enter/double click will spawn the atom, and escape will close the menu. xgK2vCd1ck 2DeOPBRqoC Old functionality of spawning multiple objects by leaving a colon + number after the typepath has been preserved. Using ``*`` and``!`` to signify final path/end of a path respectively also still works when regex mode isn't active. ## Why It's Good For The Game Spawn isn't a critical tool, and using TGUI allows it to have dynamic search and shifts searching from serverside to clientside, making it significantly more responsive. Because we don't need to build a fancy list of atom types, using it the first time won't cause a lag spike on weaker machines anymore, which should make debugging locally a bit less annoying. ## Changelog :cl: admin: Converted the Spawn verb into a TGUI input, featuring for dynamic search and RegEx support /:cl: --- code/__HELPERS/type_processing.dm | 210 +++++----- code/modules/admin/admin.dm | 51 ++- code/modules/admin/holder2.dm | 2 + code/modules/admin/spawn_menu.dm | 92 +++++ code/modules/admin/spawn_panel/spawn_panel.dm | 43 +- code/modules/admin/verbs/admingame.dm | 8 +- tgstation.dme | 1 + tgui/packages/tgui/interfaces/SpawnSearch.tsx | 380 ++++++++++++++++++ 8 files changed, 627 insertions(+), 160 deletions(-) create mode 100644 code/modules/admin/spawn_menu.dm create mode 100644 tgui/packages/tgui/interfaces/SpawnSearch.tsx diff --git a/code/__HELPERS/type_processing.dm b/code/__HELPERS/type_processing.dm index 180079cf65a..dd07d1fc9aa 100644 --- a/code/__HELPERS/type_processing.dm +++ b/code/__HELPERS/type_processing.dm @@ -1,112 +1,118 @@ +// Longer paths should come after shorter ones +GLOBAL_LIST_INIT(fancy_type_replacements, list( + /datum = "DATUM", + /area = "AREA", + /atom/movable = "MOVABLE", + /obj = "OBJ", + /turf = "TURF", + /turf/closed = "CLOSED", + /turf/open = "OPEN", + + /mob = "MOB", + /mob/living = "LIVING", + /mob/living/carbon = "CARBON", + /mob/living/carbon/human = "HUMANOID", + /mob/living/simple_animal = "SIMPLE", + /mob/living/basic = "BASIC", + /mob/living/silicon = "SILICON", + /mob/living/silicon/robot = "CYBORG", + + /obj/item = "ITEM", + /obj/item/mecha_parts/mecha_equipment = "MECHA_EQUIP", + /obj/item/mecha_parts/mecha_equipment/weapon = "MECHA_WEAPON", + /obj/item/organ = "ORGAN", + /obj/item/mod/control = "MODSUIT", + /obj/item/mod/module = "MODSUIT_MOD", + /obj/item/ammo_box = "AMMOBOX", + /obj/item/ammo_box/magazine = "AMMOBOX_MAGAZINE", + /obj/item/ammo_box/speedloader = "AMMOBOX_SPEEDLOADER", + /obj/item/gun = "GUN", + /obj/item/gun/magic = "GUN_MAGIC", + /obj/item/gun/energy = "GUN_ENERGY", + /obj/item/gun/energy/laser = "GUN_LASER", + /obj/item/gun/ballistic = "GUN_BALLISTIC", + /obj/item/gun/ballistic/automatic = "GUN_AUTOMATIC", + /obj/item/gun/ballistic/revolver = "GUN_REVOLVER", + /obj/item/gun/ballistic/rifle = "GUN_RIFLE", + /obj/item/gun/ballistic/shotgun = "GUN_SHOTGUN", + /obj/item/stack/sheet = "SHEET", + /obj/item/stack/sheet/mineral = "MINERAL_SHEET", + /obj/item/stack/ore = "ORE", + /obj/item/ai_module = "AI_LAW_MODULE", + /obj/item/circuitboard = "CIRCUITBOARD", + /obj/item/circuitboard/machine = "MACHINE_BOARD", + /obj/item/circuitboard/computer = "COMPUTER_BOARD", + /obj/item/reagent_containers = "REAGENT_CONTAINERS", + /obj/item/reagent_containers/applicator/pill = "PILL", + /obj/item/reagent_containers/applicator/patch = "MEDPATCH", + /obj/item/reagent_containers/hypospray/medipen = "MEDIPEN", + /obj/item/reagent_containers/cup/glass = "DRINK", + /obj/item/food = "FOOD", + /obj/item/bodypart = "BODYPART", + /obj/effect/decal/cleanable = "CLEANABLE", + /obj/item/radio/headset = "HEADSET", + /obj/item/clothing = "CLOTHING", + /obj/item/clothing/accessory = "ACCESSORY", + /obj/item/clothing/mask/gas = "GASMASK", + /obj/item/clothing/mask = "MASK", + /obj/item/clothing/gloves = "GLOVES", + /obj/item/clothing/shoes = "SHOES", + /obj/item/clothing/under/plasmaman = "PLASMAMAN_SUIT", + /obj/item/clothing/under = "JUMPSUIT", + /obj/item/clothing/suit/armor = "ARMOR", + /obj/item/clothing/suit = "SUIT", + /obj/item/clothing/head/helmet = "HELMET", + /obj/item/clothing/head = "HEAD", + /obj/item/clothing/neck = "NECK", + /obj/item/storage/backpack = "BACKPACK", + /obj/item/storage/belt = "BELT", + /obj/item/storage/pill_bottle = "PILL_BOTTLE", + /obj/item/book/manual = "MANUAL", + + /obj/structure = "STRUCTURE", + /obj/structure/closet = "CLOSET", + /obj/structure/closet/crate = "CRATE", + /obj/structure/closet/crate/secure = "LOCKED_CRATE", + /obj/structure/closet/secure_closet = "LOCKED_CLOSET", + + /obj/machinery = "MACHINERY", + /obj/machinery/atmospherics = "ATMOS_MECH", + /obj/machinery/portable_atmospherics = "PORT_ATMOS", + /obj/machinery/door = "DOOR", + /obj/machinery/door/airlock = "AIRLOCK", + /obj/machinery/rnd/production = "RND_FABRICATOR", + /obj/machinery/computer = "COMPUTER", + /obj/machinery/computer/camera_advanced/shuttle_docker = "DOCKING_COMPUTER", + /obj/machinery/vending = "VENDING", + /obj/machinery/vending/wardrobe = "JOBDROBE", + /obj/effect = "EFFECT", + /obj/projectile = "PROJECTILE", +)) + /proc/make_types_fancy(list/types) if (ispath(types)) types = list(types) var/static/list/types_to_replacement var/static/list/replacement_to_text if(!types_to_replacement) - // Longer paths come after shorter ones, try and keep the structure - var/list/work_from = list( - /datum = "DATUM", - /area = "AREA", - /atom/movable = "MOVABLE", - /obj = "OBJ", - /turf = "TURF", - /turf/closed = "CLOSED", - /turf/open = "OPEN", - - /mob = "MOB", - /mob/living = "LIVING", - /mob/living/carbon = "CARBON", - /mob/living/carbon/human = "HUMANOID", - /mob/living/simple_animal = "SIMPLE", - /mob/living/basic = "BASIC", - /mob/living/silicon = "SILICON", - /mob/living/silicon/robot = "CYBORG", - - /obj/item = "ITEM", - /obj/item/mecha_parts/mecha_equipment = "MECHA_EQUIP", - /obj/item/mecha_parts/mecha_equipment/weapon = "MECHA_WEAPON", - /obj/item/organ = "ORGAN", - /obj/item/mod/control = "MODSUIT", - /obj/item/mod/module = "MODSUIT_MOD", - /obj/item/ammo_box = "AMMOBOX", - /obj/item/ammo_box/magazine = "AMMOBOX_MAGAZINE", - /obj/item/ammo_box/speedloader = "AMMOBOX_SPEEDLOADER", - /obj/item/gun = "GUN", - /obj/item/gun/magic = "GUN_MAGIC", - /obj/item/gun/energy = "GUN_ENERGY", - /obj/item/gun/energy/laser = "GUN_LASER", - /obj/item/gun/ballistic = "GUN_BALLISTIC", - /obj/item/gun/ballistic/automatic = "GUN_AUTOMATIC", - /obj/item/gun/ballistic/revolver = "GUN_REVOLVER", - /obj/item/gun/ballistic/rifle = "GUN_RIFLE", - /obj/item/gun/ballistic/shotgun = "GUN_SHOTGUN", - /obj/item/stack/sheet = "SHEET", - /obj/item/stack/sheet/mineral = "MINERAL_SHEET", - /obj/item/stack/ore = "ORE", - /obj/item/ai_module = "AI_LAW_MODULE", - /obj/item/circuitboard = "CIRCUITBOARD", - /obj/item/circuitboard/machine = "MACHINE_BOARD", - /obj/item/circuitboard/computer = "COMPUTER_BOARD", - /obj/item/reagent_containers = "REAGENT_CONTAINERS", - /obj/item/reagent_containers/applicator/pill = "PILL", - /obj/item/reagent_containers/applicator/patch = "MEDPATCH", - /obj/item/reagent_containers/hypospray/medipen = "MEDIPEN", - /obj/item/reagent_containers/cup/glass = "DRINK", - /obj/item/food = "FOOD", - /obj/item/bodypart = "BODYPART", - /obj/effect/decal/cleanable = "CLEANABLE", - /obj/item/radio/headset = "HEADSET", - /obj/item/clothing = "CLOTHING", - /obj/item/clothing/accessory = "ACCESSORY", - /obj/item/clothing/mask/gas = "GASMASK", - /obj/item/clothing/mask = "MASK", - /obj/item/clothing/gloves = "GLOVES", - /obj/item/clothing/shoes = "SHOES", - /obj/item/clothing/under/plasmaman = "PLASMAMAN_SUIT", - /obj/item/clothing/under = "JUMPSUIT", - /obj/item/clothing/suit/armor = "ARMOR", - /obj/item/clothing/suit = "SUIT", - /obj/item/clothing/head/helmet = "HELMET", - /obj/item/clothing/head = "HEAD", - /obj/item/clothing/neck = "NECK", - /obj/item/storage/backpack = "BACKPACK", - /obj/item/storage/belt = "BELT", - /obj/item/storage/pill_bottle = "PILL_BOTTLE", - /obj/item/book/manual = "MANUAL", - - /obj/structure = "STRUCTURE", - /obj/structure/closet = "CLOSET", - /obj/structure/closet/crate = "CRATE", - /obj/structure/closet/crate/secure = "LOCKED_CRATE", - /obj/structure/closet/secure_closet = "LOCKED_CLOSET", - - /obj/machinery = "MACHINERY", - /obj/machinery/atmospherics = "ATMOS_MECH", - /obj/machinery/portable_atmospherics = "PORT_ATMOS", - /obj/machinery/door = "DOOR", - /obj/machinery/door/airlock = "AIRLOCK", - /obj/machinery/rnd/production = "RND_FABRICATOR", - /obj/machinery/computer = "COMPUTER", - /obj/machinery/computer/camera_advanced/shuttle_docker = "DOCKING_COMPUTER", - /obj/machinery/vending = "VENDING", - /obj/machinery/vending/wardrobe = "JOBDROBE", - /obj/effect = "EFFECT", - /obj/projectile = "PROJECTILE", - ) // ignore_root_path so we can draw the root normally - types_to_replacement = zebra_typecacheof(work_from, ignore_root_path = TRUE) - replacement_to_text = list() - for(var/key in work_from) - replacement_to_text[work_from[key]] = "[key]" + var/list/fancy_type_cache = GLOB.fancy_type_replacements + var/list/local_replacements = zebra_typecacheof(fancy_type_cache, ignore_root_path = TRUE) + var/list/local_texts = list() + for(var/key in fancy_type_cache) + local_texts[local_replacements[key]] = "[key]" + types_to_replacement = local_replacements + replacement_to_text = local_texts . = list() + var/list/local_replacements = types_to_replacement + var/list/local_texts = replacement_to_text for(var/type in types) - var/replace_with = types_to_replacement[type] + var/replace_with = local_replacements[type] if(!replace_with) .["[type]"] = type continue - var/cut_out = replacement_to_text[replace_with] + var/cut_out = local_texts[replace_with] // + 1 to account for / .[replace_with + copytext("[type]", length(cut_out) + 1)] = type @@ -141,13 +147,19 @@ if (endtype) var/list/split_filter = splittext("[key]", filter) if (!findtext(split_filter[length(split_filter)], "/")) - matches[key] = value + if (value) + matches[key] = value + else + matches += key continue else - matches[key] = value + if (value) + matches[key] = value + else + matches += key continue - if (findtext("[value]", filter, -end_len)) + if (value && findtext("[value]", filter, -end_len)) if (endtype) var/list/split_filter = splittext("[value]", filter) if (findtext(split_filter[length(split_filter)], "/")) diff --git a/code/modules/admin/admin.dm b/code/modules/admin/admin.dm index e1159d40503..5a40e578de1 100644 --- a/code/modules/admin/admin.dm +++ b/code/modules/admin/admin.dm @@ -35,29 +35,44 @@ ////////////////////////////////////////////////////////////////////////////////////////////////ADMIN HELPER PROCS -ADMIN_VERB(spawn_atom, R_SPAWN, "Spawn", "Spawn an atom.", ADMIN_CATEGORY_DEBUG, object as text) - if(!object) - return - var/list/preparsed = splittext(object,":") - var/path = preparsed[1] +ADMIN_VERB(spawn_atom, R_SPAWN, "Spawn", "Spawn an atom.", ADMIN_CATEGORY_DEBUG, object as text|null) + var/static/list/atom_types + if (isnull(atom_types)) + atom_types = subtypesof(/atom) + + var/chosen_path = null + var/list/preparsed = null + if (object) + preparsed = splittext(object, ":") + var/list/matches = filter_fancy_list(atom_types, preparsed[1]) + if (length(matches) == 1) + chosen_path = matches[1] + + if(!chosen_path) + var/datum/spawn_menu/menu = user.holder.spawn_menu + if (!menu) + menu = new() + user.holder.spawn_menu = menu + menu.init_value = object + menu.ui_interact(user.mob) + BLACKBOX_LOG_ADMIN_VERB("Spawn Atom") + return TRUE + var/amount = 1 - if(preparsed.len > 1) - amount = clamp(text2num(preparsed[2]),1,ADMIN_SPAWN_CAP) + if (length(preparsed) > 1) + amount = clamp(text2num(preparsed[2]), 1, ADMIN_SPAWN_CAP) - var/chosen = pick_closest_path(path) - if(!chosen) - return - var/turf/T = get_turf(user.mob) - - if(ispath(chosen, /turf)) - T.ChangeTurf(chosen) + var/turf/target_turf = get_turf(user.mob) + if (ispath(chosen_path, /turf)) + target_turf.ChangeTurf(chosen_path) else - for(var/i in 1 to amount) - var/atom/A = new chosen(T) - A.flags_1 |= ADMIN_SPAWNED_1 + for (var/i in 1 to amount) + var/atom/spawned = new chosen_path(target_turf) + spawned.flags_1 |= ADMIN_SPAWNED_1 - log_admin("[key_name(user)] spawned [amount] x [chosen] at [AREACOORD(user.mob)]") + log_admin("[key_name(user.mob)] spawned [amount] x [chosen_path] at [AREACOORD(user.mob)]") BLACKBOX_LOG_ADMIN_VERB("Spawn Atom") + return TRUE ADMIN_VERB(spawn_atom_pod, R_SPAWN, "PodSpawn", "Spawn an atom via supply drop.", ADMIN_CATEGORY_DEBUG, object as text) var/chosen = pick_closest_path(object) diff --git a/code/modules/admin/holder2.dm b/code/modules/admin/holder2.dm index b87878e7453..18b96318f06 100644 --- a/code/modules/admin/holder2.dm +++ b/code/modules/admin/holder2.dm @@ -38,6 +38,8 @@ GLOBAL_PROTECT(href_token) var/datum/plane_master_debug/plane_debug var/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/library_manager var/datum/pathfind_debug/path_debug + var/datum/spawn_menu/spawn_menu + var/datum/spawnpanel/spawn_panel /// Whether or not the user tried to connect, but was blocked by 2FA var/blocked_by_2fa = FALSE diff --git a/code/modules/admin/spawn_menu.dm b/code/modules/admin/spawn_menu.dm new file mode 100644 index 00000000000..07e461e3957 --- /dev/null +++ b/code/modules/admin/spawn_menu.dm @@ -0,0 +1,92 @@ +/datum/spawn_menu + /// Does the menu default to a regex prefix? + var/regex_search = FALSE + /// Does the search include atom names? + var/name_search = TRUE + /// Should we display full typepaths or the condensed versions? + var/fancy_types = TRUE + /// Should abstract types be included in the search? + var/include_abstracts = FALSE + /// Initial search value from the latest command + var/init_value = null + +/datum/spawn_menu/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if (!ui) + ui = new(user, src, "SpawnSearch") + ui.open() + +/datum/spawn_menu/ui_state(mob/user) + return ADMIN_STATE(R_SPAWN) + +/datum/spawn_menu/ui_act(action, params, datum/tgui/ui) + if (..() || !check_rights_for(ui.user.client, R_SPAWN)) + return FALSE + + switch (action) + if ("setRegexSearch") + regex_search = params["regexSearch"] + return TRUE + + if ("setNameSearch") + name_search = params["searchNames"] + return TRUE + + if ("setFancyTypes") + fancy_types = params["fancyTypes"] + return TRUE + + if ("setIncludeAbstracts") + include_abstracts = params["includeAbstracts"] + return TRUE + + if ("spawn") + var/path = text2path(params["type"]) + if (!path) + return TRUE + var/amount = clamp(text2num(params["amount"]) || 1, 1, ADMIN_SPAWN_CAP) + var/turf/target_turf = get_turf(ui.user) + if(ispath(path, /turf)) + target_turf.ChangeTurf(path) + else + for(var/i in 1 to amount) + var/atom/spawned = new path(target_turf) + spawned.flags_1 |= ADMIN_SPAWNED_1 + + log_admin("[key_name(ui.user)] spawned [amount] x [path] at [AREACOORD(ui.user)]") + SStgui.close_uis(src) + return TRUE + + if ("cancel") + SStgui.close_uis(src) + return TRUE + +/datum/spawn_menu/ui_data(mob/user) + var/list/data = list() + data["initValue"] = init_value + data["searchNames"] = name_search + data["regexSearch"] = regex_search + data["fancyTypes"] = fancy_types + data["includeAbstracts"] = include_abstracts + return data + +/datum/spawn_menu/ui_assets(mob/user) + return list( + get_asset_datum(/datum/asset/json/spawn_menu), + ) + +/datum/asset/json/spawn_menu + name = "spawn_menu_atom_data" + +/datum/asset/json/spawn_menu/generate() + var/list/data = list() + var/static/list/types_list + if (isnull(types_list)) + var/list/local_types = list() + for (var/atom/atom_type as anything in subtypesof(/atom)) + local_types[atom_type] = atom_type::name || "" + types_list = local_types + data["types"] = types_list + data["abstractTypes"] = get_abstract_types() + data["fancyTypes"] = GLOB.fancy_type_replacements + return data diff --git a/code/modules/admin/spawn_panel/spawn_panel.dm b/code/modules/admin/spawn_panel/spawn_panel.dm index 95097d6bade..a5cc42a4391 100644 --- a/code/modules/admin/spawn_panel/spawn_panel.dm +++ b/code/modules/admin/spawn_panel/spawn_panel.dm @@ -15,14 +15,10 @@ #define OFFSET_ABSOLUTE "Absolute offset" #define OFFSET_RELATIVE "Relative offset" -GLOBAL_LIST_INIT(spawnpanels_by_ckey, list()) - /* An instance of a /tg/UI™ Spawn Panel. Stores preferences, spawns things, controls the UI. Unique for each user (their ckey). */ /datum/spawnpanel - /// Who this instance of spawn panel belongs to. The instances are unique to correctly keep modified values between multiple admins. - var/owner_ckey /// Where and how the atom should be spawned. var/where_target_type = WHERE_FLOOR_BELOW_MOB /// The atom selected from the panel. @@ -57,45 +53,21 @@ GLOBAL_LIST_INIT(spawnpanels_by_ckey, list()) offset = list("X" = 0, "Y" = 0, "Z" = 0) /datum/spawnpanel/ui_interact(mob/user, datum/tgui/ui) - if(user.client.ckey != owner_ckey) - return - ui = SStgui.try_update_ui(user, src, ui) if(!ui) ui = new(user, src, "SpawnPanel") ui.open() -/// Returns a `spawnpanel` instance belonging to this `user`, or creates and registers a new one -/proc/get_spawnpanel_for_admin(mob/user) - if(!user?.client?.ckey) - return null - - var/ckey = user.client.ckey - - if(GLOB.spawnpanels_by_ckey[ckey]) - return GLOB.spawnpanels_by_ckey[ckey] - - var/datum/spawnpanel/new_panel = new() - new_panel.owner_ckey = ckey - GLOB.spawnpanels_by_ckey[ckey] = new_panel - - return new_panel - /datum/spawnpanel/ui_close(mob/user) . = ..() if (precise_mode && precise_mode != PRECISE_MODE_OFF) toggle_precise_mode(PRECISE_MODE_OFF) /datum/spawnpanel/ui_state(mob/user) - if(user.client.ckey != owner_ckey) - return GLOB.never_state - return ADMIN_STATE(R_ADMIN) + return ADMIN_STATE(R_SPAWN) -/datum/spawnpanel/ui_act(action, params) - if(..()) - return FALSE - - if(usr.client.ckey != owner_ckey) +/datum/spawnpanel/ui_act(action, params, datum/tgui/ui) + if(..() || !check_rights_for(ui.user.client, R_SPAWN)) return FALSE switch(action) @@ -106,12 +78,10 @@ GLOBAL_LIST_INIT(spawnpanels_by_ckey, list()) available_icon_states = icon_states(selected_atom_icon) if(!(selected_atom_icon_state in available_icon_states)) selected_atom_icon_state = available_icon_states[1] - SStgui.update_uis(src) return TRUE if("set-apply-icon-override") apply_icon_override = !!params["value"] - SStgui.update_uis(src) return TRUE if("reset-DMI-icon") @@ -123,35 +93,28 @@ GLOBAL_LIST_INIT(spawnpanels_by_ckey, list()) available_icon_states = icon_states(selected_atom_icon) else available_icon_states = list() - - SStgui.update_uis(src) return TRUE if("select-new-icon-state") selected_atom_icon_state = params["new_state"] - SStgui.update_uis(src) return TRUE if("reset-icon-state") selected_atom_icon_state = null if(selected_atom) selected_atom_icon_state = initial(selected_atom.icon_state) - SStgui.update_uis(src) return TRUE if("set-icon-size") atom_icon_size = params["size"] - SStgui.update_uis(src) return TRUE if("reset-icon-size") atom_icon_size = 100 - SStgui.update_uis(src) return TRUE if("get-icon-states") available_icon_states = icon_states(selected_atom_icon) - SStgui.update_uis(src) return TRUE if("selected-atom-changed") diff --git a/code/modules/admin/verbs/admingame.dm b/code/modules/admin/verbs/admingame.dm index d54952d8f9f..b444ad57337 100644 --- a/code/modules/admin/verbs/admingame.dm +++ b/code/modules/admin/verbs/admingame.dm @@ -421,7 +421,9 @@ ADMIN_VERB(lag_switch_panel, R_ADMIN, "Show Lag Switches", "Display the controls user << browse(dat.Join(), "window=lag_switch_panel;size=420x480") ADMIN_VERB(spawn_panel, R_SPAWN, "Spawn Panel", "Spawn Panel (TGUI).", ADMIN_CATEGORY_GAME) - var/datum/spawnpanel/panel = get_spawnpanel_for_admin(user.mob) - if(panel) - panel.ui_interact(user.mob) + var/datum/spawnpanel/panel = user.holder.spawn_panel + if(!panel) + panel = new() + user.holder.spawn_panel = panel + panel.ui_interact(user.mob) BLACKBOX_LOG_ADMIN_VERB("Spawn Panel") diff --git a/tgstation.dme b/tgstation.dme index 0a094ec8504..9b881baed82 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -3095,6 +3095,7 @@ #include "code\modules\admin\poll_management.dm" #include "code\modules\admin\skill_panel.dm" #include "code\modules\admin\sound_emitter.dm" +#include "code\modules\admin\spawn_menu.dm" #include "code\modules\admin\sql_ban_system.dm" #include "code\modules\admin\sql_message_system.dm" #include "code\modules\admin\stickyban.dm" diff --git a/tgui/packages/tgui/interfaces/SpawnSearch.tsx b/tgui/packages/tgui/interfaces/SpawnSearch.tsx new file mode 100644 index 00000000000..0f33d11448f --- /dev/null +++ b/tgui/packages/tgui/interfaces/SpawnSearch.tsx @@ -0,0 +1,380 @@ +import { useEffect, useState } from 'react'; +import { + Autofocus, + Button, + Input, + Section, + Stack, + VirtualList, +} from 'tgui-core/components'; +import { fetchRetry } from 'tgui-core/http'; +import { + KEY_A, + KEY_DOWN, + KEY_ENTER, + KEY_ESCAPE, + KEY_F, + KEY_N, + KEY_R, + KEY_UP, + KEY_Z, +} from 'tgui-core/keycodes'; +import { resolveAsset } from '../assets'; +import { useBackend } from './../backend'; +import { Window } from './../layouts'; +import { logger } from '../logging'; + +type SpawnSearchData = { + initValue: string | undefined; + searchNames: boolean; + regexSearch: boolean; + fancyTypes: boolean; + includeAbstracts: boolean; +}; + +type SpawnAtomData = { + // Type -> Name + types: Record; + abstractTypes: Array; + fancyTypes: Record; +}; + +type AtomPathData = { + types: Array; + abstractTypes: Array; + fancyTypes: Record; +}; + +type AtomTypeData = { + typepath: string; + name: string; +}; + +export const SpawnSearch = () => { + const { act, data } = useBackend(); + const { initValue, searchNames, regexSearch, fancyTypes, includeAbstracts } = + data; + const [atomData, setAtomData] = useState({ + types: [], + abstractTypes: [], + fancyTypes: {}, + }); + const [selected, setSelected] = useState(0); + const [query, setQuery] = useState( + (regexSearch ? 're:' : '') + (initValue || ''), + ); + const [spawnAmount, setSpawnAmount] = useState(1); + const [invalidInput, setInvalidInput] = useState(false); + const [searchBarVisible, setSearchBarVisible] = useState(true); + + const filterItems = () => { + let filterQuery = query; + setInvalidInput(false); + const isRegex = filterQuery.indexOf('re:') === 0; + // Remove regex command + if (isRegex) filterQuery = filterQuery.slice(3).trimStart(); + // We wiped the whole query in one keypress (Ctrl+A -> Delete) + // Default to regex if we have it enabled + else if (regexSearch && filterQuery.length === 0) filterQuery = 're:'; + const possibleAmountData = filterQuery.split(':'); + const amountElement = possibleAmountData[possibleAmountData.length - 1]; + // This language is cursed, check if last : contains a number afterwards + if (possibleAmountData.length > 1 && !Number.isNaN(+amountElement)) { + if (+amountElement <= 0) { + setInvalidInput(true); + return []; + } + + filterQuery = filterQuery + .slice(0, filterQuery.length - amountElement.length - 1) + .trimEnd(); + setSpawnAmount(+amountElement); + } else if (spawnAmount !== 1) setSpawnAmount(1); + + if (isRegex !== regexSearch) + act('setRegexSearch', { regexSearch: regexSearch }); + + if (filterQuery.length === 0) return []; + + if (isRegex) { + try { + const queryRegex = new RegExp(filterQuery); + return atomData.types.filter( + (type: AtomTypeData) => + queryRegex.test(type.typepath) || + (searchNames && queryRegex.test(type.name)), + ); + } catch (error) { + // We'll get plenty of invalid regexes as we type it out, just highlight the input red and abort search + setInvalidInput(true); + return []; + } + } + + const finalizer = filterQuery.slice(filterQuery.length - 1); + if (finalizer === '*' || finalizer === '!') + filterQuery = filterQuery.slice(0, filterQuery.length - 1); + filterQuery = filterQuery.toLowerCase(); + let searchLambda = (x: string) => x.toLowerCase().includes(filterQuery); + if (finalizer === '!') + searchLambda = (x: string) => + x.toLowerCase().includes(filterQuery) && + x.toLowerCase().lastIndexOf(filterQuery) === + x.length - filterQuery.length; + else if (finalizer === '*') + searchLambda = (x: string) => + x.toLowerCase().includes(filterQuery) && + !x.slice(x.toLowerCase().lastIndexOf(filterQuery)).includes('/'); + return atomData.types.filter( + (type: AtomTypeData) => + (searchLambda(type.typepath) || + (searchNames && searchLambda(type.name))) && + (includeAbstracts || !atomData.abstractTypes.includes(type.typepath)), + ); + }; + + const [filteredItems, setFilteredItems] = useState>([]); + + useEffect(() => { + fetchRetry(resolveAsset('spawn_menu_atom_data.json')) + .then((response) => response.json()) + .then((data: SpawnAtomData) => { + setAtomData({ + types: Object.keys(data.types).map((x: string) => ({ + typepath: x, + name: data.types[x], + })), + abstractTypes: data.abstractTypes, + fancyTypes: data.fancyTypes, + }); + }) + .catch((error) => { + logger.log( + 'Failed to fetch spawn_menu_atom_data.json', + JSON.stringify(error), + ); + }); + }, []); + + useEffect( + () => setFilteredItems(filterItems()), + [query, atomData, includeAbstracts], + ); + + // User presses up or down on keyboard + // Simulates clicking an item + const onArrowKey = (key: number) => { + const len = Object.keys(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(); + } + } + }; + + const onSelected = (selection: AtomTypeData) => + act('spawn', { type: selection.typepath, amount: spawnAmount }); + + const onSearch = (newQuery: string) => { + if (newQuery === query) { + return; + } + setQuery(newQuery); + setSelected(0); + document!.getElementById('0')?.scrollIntoView(); + }; + + // Grabs the cursor when no search bar is visible. + if (!searchBarVisible) { + setTimeout(() => document!.getElementById(selected.toString())?.focus(), 1); + } + + return ( + + + ))} + + + + {!!searchBarVisible && ( + onSelected(filteredItems[selected])} + onChange={onSearch} + placeholder="Search..." + value={query} + style={invalidInput ? { borderColor: 'red' } : {}} + /> + )} + + + + + ); +};