diff --git a/citadel.dme b/citadel.dme index bac76141f93..c7f6be454fa 100644 --- a/citadel.dme +++ b/citadel.dme @@ -400,6 +400,7 @@ #include "code\__HELPERS\_global_objects.dm" #include "code\__HELPERS\_lists_tg.dm" #include "code\__HELPERS\_logging.dm" +#include "code\__HELPERS\abstract_types.dm" #include "code\__HELPERS\animations.dm" #include "code\__HELPERS\areas.dm" #include "code\__HELPERS\atom_movables.dm" @@ -2401,6 +2402,7 @@ #include "code\modules\admin\news.dm" #include "code\modules\admin\player_notes.dm" #include "code\modules\admin\player_panel.dm" +#include "code\modules\admin\spawn_menu.dm" #include "code\modules\admin\topic.dm" #include "code\modules\admin\ToRban.dm" #include "code\modules\admin\admin_modal\admin_modal.dm" diff --git a/code/__DEFINES/admin/admin.dm b/code/__DEFINES/admin/admin.dm index ff56430025a..238e7e87948 100644 --- a/code/__DEFINES/admin/admin.dm +++ b/code/__DEFINES/admin/admin.dm @@ -117,3 +117,6 @@ // LOG BROWSE TYPES #define BROWSE_ROOT_ALL_LOGS 1 #define BROWSE_ROOT_CURRENT_LOGS 2 + +//How many things you can spawn at once with spawn verb/create panel +#define ADMIN_SPAWN_CAP 100 diff --git a/code/__HELPERS/abstract_types.dm b/code/__HELPERS/abstract_types.dm new file mode 100644 index 00000000000..5a1f800b511 --- /dev/null +++ b/code/__HELPERS/abstract_types.dm @@ -0,0 +1,18 @@ +/// Returns a list of all abstract typepaths for all datums +/proc/get_abstract_types() + var/static/list/abstracts + if(abstracts) + return abstracts + abstracts = list() + for(var/datum/sometype as anything in subtypesof(/datum)) + if(sometype == sometype::abstract_type) + abstracts |= sometype::abstract_type + return abstracts + +/// Like subtypesof, but automatically excludes abstract typepaths +/proc/valid_subtypesof(datum/sometype) + return subtypesof(sometype) - get_abstract_types() + +/// Like typesof, but automatically excludes abstract typepaths +/proc/valid_typesof(datum/sometype) + return typesof(sometype) - get_abstract_types() diff --git a/code/__HELPERS/lists/types_typecaches.dm b/code/__HELPERS/lists/types_typecaches.dm index ac9340eaa6a..217c20a5e6f 100644 --- a/code/__HELPERS/lists/types_typecaches.dm +++ b/code/__HELPERS/lists/types_typecaches.dm @@ -82,6 +82,62 @@ L[T] = TRUE return L +/** + * Like typesof() or subtypesof(), but returns a typecache instead of a list. + * This time it also uses the associated values given by the input list for the values of the subtypes. + * + * Latter values from the input list override earlier values. + * Thus subtypes should come _after_ parent types in the input list. + * Notice that this is the opposite priority of [/proc/is_type_in_list] and [/proc/is_path_in_list]. + * + * Arguments: + * - path: A typepath or list of typepaths with associated values. + * - single_value: The assoc value used if only a single path is passed as the first variable. + * - only_root_path: Whether the typecache should be specifically of the passed types. + * - ignore_root_path: Whether to ignore the root path when caching subtypes. + * - clear_nulls: Whether to remove keys with null assoc values from the typecache after generating it. + */ +/proc/zebra_typecacheof(path, single_value = TRUE, only_root_path = FALSE, ignore_root_path = FALSE, clear_nulls = FALSE) + if(isnull(path)) + return + + if(ispath(path)) + if (isnull(single_value)) + return + + . = list() + if(only_root_path) + .[path] = single_value + return + + for(var/subtype in (ignore_root_path ? subtypesof(path) : typesof(path))) + .[subtype] = single_value + return + + if(!islist(path)) + CRASH("Tried to create a typecache of [path] which is neither a typepath nor a list.") + + . = list() + var/list/pathlist = path + if(only_root_path) + for(var/current_path in pathlist) + .[current_path] = pathlist[current_path] + else if(ignore_root_path) + for(var/current_path in pathlist) + for(var/subtype in subtypesof(current_path)) + .[subtype] = pathlist[current_path] + else + for(var/current_path in pathlist) + for(var/subpath in typesof(current_path)) + .[subpath] = pathlist[current_path] + + if(!clear_nulls) + return + + for(var/cached_path in .) + if (isnull(.[cached_path])) + . -= cached_path + /** * cached typecache of a given path list * diff --git a/code/__HELPERS/type_processing.dm b/code/__HELPERS/type_processing.dm index 60ede72eedc..b6628294379 100644 --- a/code/__HELPERS/type_processing.dm +++ b/code/__HELPERS/type_processing.dm @@ -1,73 +1,162 @@ -/** - * todo: rename this - * - * Reformat types to be more readable for admin interfaces. - * - * This is done because BYOND is hell and "input in list" is obnoxious. - */ +// 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/simulated = "SIMULATED", + /turf/simulated/floor = "FLOOR", + + /mob = "MOB", + /mob/living = "LIVING", + /mob/living/carbon = "CARBON", + /mob/living/carbon/human = "HUMANOID", + /mob/living/simple_mob = "SIMPLE", + /mob/living/silicon = "SILICON", + /mob/living/silicon/robot = "CYBORG", + + /obj/item = "ITEM", + /obj/item/organ = "ORGAN", + /obj/item/gun = "GUN", + /obj/item/gun/projectile/ballistic = "GUN_BALLISTIC", + /obj/item/gun/projectile/energy = "GUN_ENERGY", + /obj/item/gun/projectile/magnetic = "GUN_MAGNETIC", + /obj/item/ammo_casing = "AMMO", + /obj/item/ammo_magazine = "MAGAZINE", + /obj/item/gun_attachment = "GUN_ATTATCHMENT", + /obj/item/gun_component = "GUN_COMPONENT", + /obj/item/stack/material = "MATERIAL", + /obj/item/stack/ore = "ORE", + /obj/item/aiModule = "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/pill = "PILL", + /obj/item/reagent_containers/pill/patch = "MEDPATCH", + /obj/item/reagent_containers/food = "FOOD", + /obj/item/reagent_containers/food/drinks = "DRINK", + /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 = "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/storage/backpack = "BACKPACK", + /obj/item/storage/belt = "BELT", + /obj/item/storage/pill_bottle = "PILL_BOTTLE", + /obj/item/book/manual = "MANUAL", + + /obj/vehicle = "VEHICLE", + /obj/item/vehicle_chassis = "VEHICLE_CHASSIS", + /obj/item/vehicle_part = "VEHICLE_PART", + /obj/item/vehicle_component = "VEHICLE_COMPONENT", + /obj/item/vehicle_module = "VEHICLE_MODULE", + /obj/item/vehicle_module/weapon = "VEHICLE_WEAPON", + + + /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/computer = "COMPUTER", + /obj/machinery/vending = "VENDING", + + /obj/effect = "EFFECT", + /obj/effect/debris = "DEBRIS", + /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) + // ignore_root_path so we can draw the root normally + 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/static/list/shortcut_lookup = list( - /obj/effect/debris = "//debris", - /obj/item/radio/headset = "//headset", - /obj/item/reagent_containers/food/drinks = "//drink", - /obj/item/reagent_containers/food = "//food", - /obj/machinery/atmospherics = "//atmos", - /obj/machinery/portable_atmospherics = "//port_atmos", - /obj/vehicle = "//vehicle", - /obj/item/vehicle_chassis = "//vehicle_chassis", - /obj/item/vehicle_part = "//vehicle_part", - /obj/item/vehicle_component = "//vehicle_component", - /obj/item/vehicle_module = "//vehicle_module", - /obj/item/vehicle_module/weapon = "//vehicle_weapon", - /obj/item/organ = "//organ", - /obj/item/gun_attachment = "//gun-attachment", - /obj/item/gun_component = "//gun-component", - /obj/item/gun/projectile/ballistic = "//gun-ballistic", - /obj/item/gun/projectile/energy = "//gun-energy", - /obj/item/gun/projectile/magnetic = "//gun-magnetic", - /obj/item/gun = "//gun", - /obj/item/ammo_casing = "//ammo", - /obj/item/ammo_magazine = "//magazine", - /obj/item = "//item", - /obj/machinery = "//machine", - /obj/effect = "//effect", - /turf/simulated/floor = "//floor", - /turf/simulated = "//simulated", - /mob/living/silicon/robot/module_preset = "//robot-preset", - /mob/living/silicon/robot = "//robot", - /mob/living/carbon = "//carbon", - /mob/living/simple_mob = "//simple", - /mob/living = "//living", - // we must have normal A-T-O-M handled; otherwise weird stuff happens when it falls to /atom/movable - /obj = "/obj", - /turf = "/turf", - /area = "/area", - /mob = "/mob", - /atom/movable = "//movable", - ) + var/list/local_replacements = types_to_replacement + var/list/local_texts = replacement_to_text for(var/type in types) - var/shortcut - for(var/prefix in shortcut_lookup) - if(ispath(type, prefix)) - shortcut = "[shortcut_lookup[prefix]][copytext("[type]", length("[prefix]") + 1)]" - break - .[shortcut || "[type]"] = type + var/replace_with = local_replacements[type] + if(!replace_with) + .["[type]"] = type + continue + var/cut_out = local_texts[replace_with] + // + 1 to account for / + .[replace_with + copytext("[type]", length(cut_out) + 1)] = type /proc/get_fancy_list_of_atom_types() - return make_types_fancy(typesof(/atom)) + var/static/list/pre_generated_list + if (!pre_generated_list) //init + pre_generated_list = make_types_fancy(typesof(/atom)) + return pre_generated_list /proc/get_fancy_list_of_datum_types() - return make_types_fancy(typesof(/datum) - typesof(/atom)) + var/static/list/pre_generated_list + if (!pre_generated_list) //init + pre_generated_list = make_types_fancy(sortList(typesof(/datum) - typesof(/atom))) + return pre_generated_list /proc/filter_fancy_list(list/L, filter as text) var/list/matches = new + var/end_len = -1 + var/list/endcheck = splittext(filter, "!") + if(endcheck.len > 1) + filter = endcheck[1] + end_len = length_char(filter) + var/endtype = (filter[length(filter)] == "*") + if (endtype) + filter = splittext(filter, "*")[1] + for(var/key in L) var/value = L[key] - if(findtext("[key]", filter) || findtext("[value]", filter)) + if (findtext("[key]", filter, -end_len)) + if (endtype) + var/list/split_filter = splittext("[key]", filter) + if (!findtext(split_filter[length(split_filter)], "/")) + if (value) + matches[key] = value + else + matches += key + continue + else + if (value) + matches[key] = value + else + matches += key + continue + + if (value && findtext("[value]", filter, -end_len)) + if (endtype) + var/list/split_filter = splittext("[value]", filter) + if (findtext(split_filter[length(split_filter)], "/")) + continue matches[key] = value + return matches /proc/pick_closest_path(value, list/matches = get_fancy_list_of_atom_types()) diff --git a/code/modules/admin/admin_holder-legacy.dm b/code/modules/admin/admin_holder-legacy.dm index ec9513339d3..e75dbcfd045 100644 --- a/code/modules/admin/admin_holder-legacy.dm +++ b/code/modules/admin/admin_holder-legacy.dm @@ -22,6 +22,7 @@ GLOBAL_PROTECT(href_token) var/href_token var/datum/filter_editor/filteriffic + var/datum/spawn_menu/spawn_menu /datum/admins/New(initial_rank = "Temporary Admin", initial_rights = 0, ckey) if(!ckey) diff --git a/code/modules/admin/spawn_menu.dm b/code/modules/admin/spawn_menu.dm new file mode 100644 index 00000000000..be53617679c --- /dev/null +++ b/code/modules/admin/spawn_menu.dm @@ -0,0 +1,90 @@ +/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) + new path(target_turf) + + 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_asset_injection(datum/tgui/ui, list/immediate, list/deferred) + immediate += /datum/asset_pack/json/spawn_menu + return ..() + +/datum/asset_pack/json/spawn_menu + name = "spawn_menu_atom_data" + +/datum/asset_pack/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/verbs/debug/spawn.dm b/code/modules/admin/verbs/debug/spawn.dm index e51312de7d8..3eaf787d1b2 100644 --- a/code/modules/admin/verbs/debug/spawn.dm +++ b/code/modules/admin/verbs/debug/spawn.dm @@ -1,4 +1,4 @@ -/client/proc/spawn_atom(query as text) +/client/proc/spawn_atom(object as text|null) set category = "Debug" set desc = "(atom path) Spawn an atom" set name = "Spawn" @@ -6,45 +6,38 @@ if(!check_rights(R_SPAWN)) return - if(length(query) < 5) - var/confirm = alert( - src, - "You haven't specified a long enough filter; this will take a while. Are you sure?", - "Mass Query Confirmation", - "No", - "Yes" - ) - if(confirm != "Yes") - return + var/static/list/atom_types + if (isnull(atom_types)) + atom_types = subtypesof(/atom) - var/list/matches = list() - for(var/path in typesof(/atom)) - if(findtext("[path]", query)) - matches += path - CHECK_TICK + 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(!length(matches)) - to_chat(src, SPAN_WARNING("Type query for '[query]' returned nothing.")) - return + if(!chosen_path) + var/datum/spawn_menu/menu = holder.spawn_menu + if (!menu) + menu = new() + holder.spawn_menu = menu + menu.init_value = object + menu.ui_interact(mob) + // BLACKBOX_LOG_ADMIN_VERB("Spawn Atom") + return TRUE - // todo: special prefix handling like # / ! / similar for fast-pathing? - var/path_to_spawn - if(length(matches) == 1) - path_to_spawn = matches[1] + var/amount = 1 + if (length(preparsed) > 1) + amount = clamp(text2num(preparsed[2]), 1, ADMIN_SPAWN_CAP) + + var/turf/target_turf = get_turf(mob) + if (ispath(chosen_path, /turf)) + target_turf.ChangeTurf(chosen_path) else - var/list/processed_types = make_types_fancy(matches) - var/picked_name = input("Select an atom type", "Spawn Atom", matches[1]) as null|anything in processed_types - if(!picked_name) - return - path_to_spawn = processed_types[picked_name] + for (var/i in 1 to amount) + new chosen_path(target_turf) - if(!path_to_spawn) - return - - if(ispath(path_to_spawn, /turf)) - var/turf/T = get_turf(mob) - T.ChangeTurf(path_to_spawn) - else - new path_to_spawn(mob.loc) - - log_and_message_admins("spawned [path_to_spawn] at ([usr.x],[usr.y],[usr.z])") + log_admin("[key_name(mob)] spawned [amount] x [chosen_path] at [AREACOORD(mob)]") + return TRUE diff --git a/tgui/packages/tgui/interfaces/SpawnSearch.tsx b/tgui/packages/tgui/interfaces/SpawnSearch.tsx new file mode 100644 index 00000000000..bfb586fd9b3 --- /dev/null +++ b/tgui/packages/tgui/interfaces/SpawnSearch.tsx @@ -0,0 +1,379 @@ +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 { logger } from '../logging'; +import { useBackend } from './../backend'; +import { Window } from './../layouts'; + +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' } : {}} + /> + )} + + + + + ); +};