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' } : {}} + /> + )} + + + + + ); +};