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