diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm index 6b63c1aaa9f..cd1c636c283 100644 --- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm +++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm @@ -240,3 +240,9 @@ #define COMPONENT_BLOCK_MOB_CHANGE (1<<0) /// from /mob/proc/change_mob_type_unchecked() : () #define COMSIG_MOB_CHANGED_TYPE "mob_changed_type" + +/// from /mob/proc/slip(): (knockdown_amonut, obj/slipped_on, lube_flags [mobs.dm], paralyze, force_drop) +#define COMSIG_MOB_SLIPPED "mob_slipped" + +/// from /mob/proc/key_down(): (key, client/client, full_key) +#define COMSIG_MOB_KEYDOWN "mob_key_down" diff --git a/code/_onclick/hud/action_button.dm b/code/_onclick/hud/action_button.dm index 533e8f1e8dd..92defc66101 100644 --- a/code/_onclick/hud/action_button.dm +++ b/code/_onclick/hud/action_button.dm @@ -18,6 +18,8 @@ /// A weakref of the last thing we hovered over /// God I hate how dragging works var/datum/weakref/last_hovored_ref + /// overlay for keybind maptext + var/mutable_appearance/keybind_maptext /atom/movable/screen/movable/action_button/Destroy() if(our_hud) @@ -48,6 +50,9 @@ return FALSE var/list/modifiers = params2list(params) + if(LAZYACCESS(modifiers, ALT_CLICK)) + begin_creating_bind(usr) + return TRUE if(LAZYACCESS(modifiers, SHIFT_CLICK)) var/datum/hud/our_hud = usr.hud_used our_hud.position_action(src, SCRN_OBJ_DEFAULT) @@ -61,6 +66,14 @@ linked_action.Trigger(trigger_flags = trigger_flags) return TRUE +/atom/movable/screen/movable/action_button/proc/begin_creating_bind(mob/user) + if(!isnull(linked_action.full_key)) + linked_action.full_key = null + linked_action.update_button_status(src) + return + linked_action.full_key = tgui_input_keycombo(user, "Please bind a key for this action.") + linked_action.update_button_status(src) + // Entered and Exited won't fire while you're dragging something, because you're still "holding" it // Very much byond logic, but I want nice behavior, so we fake it with drag /atom/movable/screen/movable/action_button/MouseDrag(atom/over_object, src_location, over_location, src_control, over_control, params) @@ -149,6 +162,15 @@ return user.client.prefs.action_buttons_screen_locs -= "[name]_[id]" +/atom/movable/screen/movable/action_button/proc/update_keybind_maptext(key) + cut_overlay(keybind_maptext) + if(!key) + return + keybind_maptext = new + keybind_maptext.maptext = MAPTEXT("[key]") + keybind_maptext.transform = keybind_maptext.transform.Translate(-4, length(key) > 1 ? -6 : 2) //with modifiers, its placed lower so cooldown is visible + add_overlay(keybind_maptext) + /** * This is a silly proc used in hud code code to determine what icon and icon state we should be using * for hud elements (such as action buttons) that don't have their own icon and icon state set. @@ -241,7 +263,7 @@ action.HideFrom(src) /atom/movable/screen/button_palette - desc = "Drag buttons to move them
Shift-click any button to reset it
Alt-click this to reset all buttons" + desc = "Drag buttons to move them
Shift-click any button to reset it
Alt-click any button to begin binding it to a key
Alt-click this to reset all buttons" icon = 'icons/hud/64x16_actions.dmi' icon_state = "screen_gen_palette" screen_loc = ui_action_palette diff --git a/code/datums/actions/action.dm b/code/datums/actions/action.dm index 75c9cf59aeb..39e69ba9fa8 100644 --- a/code/datums/actions/action.dm +++ b/code/datums/actions/action.dm @@ -46,6 +46,10 @@ var/overlay_icon = 'icons/mob/actions/backgrounds.dmi' /// This is the icon state for any FOREGROUND overlay icons on the button (such as borders) var/overlay_icon_state + + /// full key we are bound to + var/full_key + /// Toggles whether this action is usable or not var/action_disabled = FALSE @@ -110,6 +114,7 @@ RegisterSignals(owner, list(SIGNAL_ADDTRAIT(TRAIT_MAGICALLY_PHASED), SIGNAL_REMOVETRAIT(TRAIT_MAGICALLY_PHASED)), PROC_REF(update_status_on_signal)) if(owner_has_control) + RegisterSignal(grant_to, COMSIG_MOB_KEYDOWN, PROC_REF(keydown), override = TRUE) GiveAction(grant_to) /// Remove the passed mob from being owner of our action @@ -122,6 +127,7 @@ HideFrom(hud.mymob) LAZYREMOVE(remove_from?.actions, src) // We aren't always properly inserted into the viewers list, gotta make sure that action's cleared viewers = list() + UnregisterSignal(remove_from, COMSIG_MOB_KEYDOWN) if(isnull(owner)) return @@ -312,6 +318,7 @@ * force - whether an update is forced regardless of existing status */ /datum/action/proc/update_button_status(atom/movable/screen/movable/action_button/current_button, force = FALSE) + current_button.update_keybind_maptext(full_key) if(IsAvailable()) current_button.color = rgb(255,255,255,255) else @@ -411,3 +418,14 @@ /// Checks if our action is actively selected. Used for selecting icons primarily. /datum/action/proc/is_action_active(atom/movable/screen/movable/action_button/current_button) return FALSE + +/datum/action/proc/keydown(mob/source, key, client/client, full_key) + SIGNAL_HANDLER + if(isnull(full_key) || full_key != src.full_key) + return + if(istype(source)) + if(source.next_click > world.time) + return + else + source.next_click = world.time + CLICK_CD_RANGE + INVOKE_ASYNC(src, PROC_REF(Trigger)) diff --git a/code/modules/keybindings/bindings_client.dm b/code/modules/keybindings/bindings_client.dm index 0aa0fd6952e..7c06b1b874b 100644 --- a/code/modules/keybindings/bindings_client.dm +++ b/code/modules/keybindings/bindings_client.dm @@ -74,11 +74,10 @@ if(kb.can_use(src) && kb.down(src) && keycount >= MAX_COMMANDS_PER_KEY) break - holder?.key_down(_key, src) - mob.focus?.key_down(_key, src) + holder?.key_down(_key, src, full_key) + mob.focus?.key_down(_key, src, full_key) mob.update_mouse_pointer() - /client/verb/keyUp(_key as text) set instant = TRUE set hidden = TRUE diff --git a/code/modules/keybindings/setup.dm b/code/modules/keybindings/setup.dm index ef87e12d901..d239c48d9ce 100644 --- a/code/modules/keybindings/setup.dm +++ b/code/modules/keybindings/setup.dm @@ -1,6 +1,7 @@ // Set a client's focus to an object and override these procs on that object to let it handle keypresses -/datum/proc/key_down(key, client/user) // Called when a key is pressed down initially +/datum/proc/key_down(key, client/user, full_key) // Called when a key is pressed down initially + SHOULD_CALL_PARENT(TRUE) return /datum/proc/key_up(key, client/user) // Called when a key is released return diff --git a/code/modules/mob/mob.dm b/code/modules/mob/mob.dm index 43ea9e9a6cf..cf42a08a93b 100644 --- a/code/modules/mob/mob.dm +++ b/code/modules/mob/mob.dm @@ -1708,3 +1708,7 @@ set name = "View Skills" mind?.print_levels(src) + +/mob/key_down(key, client/client, full_key) + ..() + SEND_SIGNAL(src, COMSIG_MOB_KEYDOWN, key, client, full_key) diff --git a/code/modules/mob/mob_defines.dm b/code/modules/mob/mob_defines.dm index 245afb86932..aaf654e6dba 100644 --- a/code/modules/mob/mob_defines.dm +++ b/code/modules/mob/mob_defines.dm @@ -200,4 +200,3 @@ var/active_typing_indicator ///the icon currently used for the thinking indicator's bubble var/active_thinking_indicator - diff --git a/code/modules/tgui_input/keycombo.dm b/code/modules/tgui_input/keycombo.dm new file mode 100644 index 00000000000..948dbaea234 --- /dev/null +++ b/code/modules/tgui_input/keycombo.dm @@ -0,0 +1,126 @@ +/** + * Creates a TGUI window with a key input. Returns the user's response as a full key with modifiers, eg ShiftK. + * + * This proc should be used to create windows for key entry that the caller will wait for a response from. + * If tgui fancy chat is turned off: Will return a normal input. + * + * Arguments: + * * user - The user to show the number input to. + * * message - The content of the number input, shown in the body of the TGUI window. + * * title - The title of the number input modal, shown on the top of the TGUI window. + * * default - The default (or current) key, shown as a placeholder. + */ +/proc/tgui_input_keycombo(mob/user = usr, message, title = "Key Input", default = 0, timeout = 0, ui_state = GLOB.always_state) + if (!istype(user)) + if (istype(user, /client)) + var/client/client = user + user = client.mob + else + return null + + if (isnull(user.client)) + return null + + // Client does NOT have tgui_input on: Returns regular input + if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input)) + var/input_key = input(user, message, title + "(Modifiers are TGUI only, sorry!)", default) as null|text + return input_key[1] + var/datum/tgui_input_keycombo/key_input = new(user, message, title, default, timeout, ui_state) + key_input.ui_interact(user) + key_input.wait() + if (key_input) + . = key_input.entry + qdel(key_input) + +/** + * # tgui_input_keycombo + * + * Datum used for instantiating and using a TGUI-controlled key input that prompts the user with + * a message and listens for key presses. + */ +/datum/tgui_input_keycombo + /// Boolean field describing if the tgui_input_number was closed by the user. + var/closed + /// The default (or current) value, shown as a default. Users can press reset with this. + var/default + /// The entry that the user has return_typed in. + var/entry + /// The prompt's body, if any, of the TGUI window. + var/message + /// The time at which the number input was created, for displaying timeout progress. + var/start_time + /// The lifespan of the number input, after which the window will close and delete itself. + var/timeout + /// The title of the TGUI window + var/title + /// The TGUI UI state that will be returned in ui_state(). Default: always_state + var/datum/ui_state/state + +/datum/tgui_input_keycombo/New(mob/user, message, title, default, timeout, ui_state) + src.default = default + src.message = message + src.title = title + src.state = ui_state + if (timeout) + src.timeout = timeout + start_time = world.time + QDEL_IN(src, timeout) + +/datum/tgui_input_keycombo/Destroy(force) + SStgui.close_uis(src) + state = null + return ..() + +/** + * Waits for a user's response to the tgui_input_keycombo's prompt before returning. Returns early if + * the window was closed by the user. + */ +/datum/tgui_input_keycombo/proc/wait() + while (!entry && !closed && !QDELETED(src)) + stoplag(1) + +/datum/tgui_input_keycombo/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "KeyComboModal") + ui.open() + +/datum/tgui_input_keycombo/ui_close(mob/user) + . = ..() + closed = TRUE + +/datum/tgui_input_keycombo/ui_state(mob/user) + return state + +/datum/tgui_input_keycombo/ui_static_data(mob/user) + var/list/data = list() + data["init_value"] = default // Default is a reserved keyword + data["large_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) + data["message"] = message + data["swapped_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) + data["title"] = title + return data + +/datum/tgui_input_keycombo/ui_data(mob/user) + var/list/data = list() + if(timeout) + data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + return data + +/datum/tgui_input_keycombo/ui_act(action, list/params) + . = ..() + if (.) + return + switch(action) + if("submit") + set_entry(params["entry"]) + closed = TRUE + SStgui.close_uis(src) + return TRUE + if("cancel") + closed = TRUE + SStgui.close_uis(src) + return TRUE + +/datum/tgui_input_keycombo/proc/set_entry(entry) + src.entry = entry diff --git a/tgstation.dme b/tgstation.dme index 73d44d9d840..1c5860bd270 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -5898,6 +5898,7 @@ #include "code\modules\tgui\states\zlevel.dm" #include "code\modules\tgui_input\alert.dm" #include "code\modules\tgui_input\checkboxes.dm" +#include "code\modules\tgui_input\keycombo.dm" #include "code\modules\tgui_input\list.dm" #include "code\modules\tgui_input\number.dm" #include "code\modules\tgui_input\text.dm" diff --git a/tgui/packages/tgui/interfaces/KeyComboModal.tsx b/tgui/packages/tgui/interfaces/KeyComboModal.tsx new file mode 100644 index 00000000000..e0b598764f1 --- /dev/null +++ b/tgui/packages/tgui/interfaces/KeyComboModal.tsx @@ -0,0 +1,147 @@ +import { KEY } from 'common/keys'; +import { useState } from 'react'; + +import { useBackend, useLocalState } from '../backend'; +import { Autofocus, Box, Button, Section, Stack } from '../components'; +import { Window } from '../layouts'; +import { InputButtons } from './common/InputButtons'; +import { Loader } from './common/Loader'; + +type KeyInputData = { + init_value: string; + large_buttons: boolean; + message: string; + timeout: number; + title: string; +}; + +const isStandardKey = (event: React.KeyboardEvent): boolean => { + return ( + event.key !== KEY.Alt && + event.key !== KEY.Control && + event.key !== KEY.Shift && + event.key !== KEY.Escape + ); +}; + +const KEY_CODE_TO_BYOND: Record = { + DEL: 'Delete', + DOWN: 'South', + END: 'Southwest', + HOME: 'Northwest', + INSERT: 'Insert', + LEFT: 'West', + PAGEDOWN: 'Southeast', + PAGEUP: 'Northeast', + RIGHT: 'East', + SPACEBAR: 'Space', + UP: 'North', +}; + +const DOM_KEY_LOCATION_NUMPAD = 3; + +const formatKeyboardEvent = ( + event: React.KeyboardEvent, +): string => { + let text = ''; + + if (event.altKey) { + text += 'Alt'; + } + + if (event.ctrlKey) { + text += 'Ctrl'; + } + + if (event.shiftKey) { + text += 'Shift'; + } + + if (event.location === DOM_KEY_LOCATION_NUMPAD) { + text += 'Numpad'; + } + + if (isStandardKey(event)) { + const key = event.key.toUpperCase(); + text += KEY_CODE_TO_BYOND[key] || key; + } + + return text; +}; + +export const KeyComboModal = (props) => { + const { act, data } = useBackend(); + const { init_value, large_buttons, message = '', title, timeout } = data; + const [input, setInput] = useState(init_value); + const [binding, setBinding] = useLocalState('binding', true); + + const setValue = (value: string) => { + if (value === input) { + return; + } + setInput(value); + }; + + // Dynamically changes the window height based on the message. + const windowHeight = + 130 + + (message.length > 30 ? Math.ceil(message.length / 3) : 0) + + (message.length && large_buttons ? 5 : 0); + + return ( + + {timeout && } + { + if (!binding) { + if (event.key === KEY.Enter) { + act('submit', { entry: input }); + } + if (event.key === KEY.Escape) { + act('cancel'); + } + return; + } + + event.preventDefault(); + + if (isStandardKey(event)) { + setValue(formatKeyboardEvent(event)); + setBinding(false); + return; + } else if (event.key === KEY.Escape) { + setValue(init_value); + setBinding(false); + return; + } + }} + > +
+ + + + {message} + + +
+
+
+ ); +};