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.
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.
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 (
+
+
+ );
+};