diff --git a/code/__defines/appearance.dm b/code/__defines/appearance.dm
index faa97e8f06..87a05419a8 100644
--- a/code/__defines/appearance.dm
+++ b/code/__defines/appearance.dm
@@ -1,3 +1,12 @@
// Consider these images/atoms as part of the UI/HUD
#define APPEARANCE_UI_IGNORE_ALPHA RESET_COLOR|RESET_TRANSFORM|NO_CLIENT_COLOR|RESET_ALPHA
#define APPEARANCE_UI RESET_COLOR|RESET_TRANSFORM|NO_CLIENT_COLOR
+
+/// The atom is not clickable
+#define XMOUSE_OPACITY_NEVER 0
+
+/// The atom is clickable normally
+#define XMOUSE_OPACITY_DEFAULT 1
+
+/// The atom steals clicks from other clickables
+#define XMOUSE_OPACITY_ALWAYS 2
diff --git a/code/controllers/subsystems/typing.dm b/code/controllers/subsystems/typing.dm
new file mode 100644
index 0000000000..081a220963
--- /dev/null
+++ b/code/controllers/subsystems/typing.dm
@@ -0,0 +1,234 @@
+SUBSYSTEM_DEF(typing)
+ name = "Typing"
+ flags = SS_BACKGROUND | SS_NO_INIT
+ runlevels = RUNLEVEL_GAME | RUNLEVEL_POSTGAME
+ wait = 0.5 SECONDS
+
+ /// The skin control to poll for TYPING_STATE_INPUT status.
+ var/const/INPUT_HANDLE = "mainwindow.input"
+
+ var/const/INFLIGHT_TIMEOUT = 5 SECONDS
+
+ /// The status entry index of the related client's typing indicator visibility preference.
+ var/const/INDEX_PREFERENCE = 1
+
+ /// The status entry index of the inflight state.
+ var/const/INDEX_INFLIGHT = 2
+
+ /// The status entry index of the timeout threshold.
+ var/const/INDEX_TIMEOUT = 3
+
+ /// The status entry index of the input bar typing state.
+ var/const/INDEX_INPUT_STATE = 4
+
+ /// The status entry index of the verb input typing state.
+ var/const/INDEX_VERB_STATE = 5
+
+ /// The highest index in a status entry.
+ var/const/MAX_INDEX = 5
+
+ /*
+ * A list of (ckey = list(
+ preference = 0|1,
+ inflight = 0|1,
+ timeout = num,
+ istyping_input = 0|1,
+ istyping_hotkey = 0|1
+ ), ...)
+ See .proc/GetEntry for details.
+ */
+ var/static/list/status = list()
+
+ /// Matches input bar verbs that should set TYPING_STATE_INPUT.
+ var/static/regex/match_verbs = regex("^(Me|Say) +\"?\\w+")
+
+ /// A list of clients waiting to be polled for input state.
+ var/static/list/client/queue = list()
+
+
+/datum/controller/subsystem/typing/Recover()
+ status = list()
+ queue = list()
+
+
+/datum/controller/subsystem/typing/fire(resumed, no_mc_tick)
+ if (!resumed)
+ queue = list()
+ for (var/client/client as anything in GLOB.clients)
+ queue += client
+ if (!length(queue))
+ return
+ var/cut_until = 1
+ var/list/entry
+ for (var/client/client as anything in queue)
+ ++cut_until
+ if (QDELETED(client))
+ continue
+ entry = GetEntry(client)
+ if (!entry[INDEX_PREFERENCE])
+ continue
+ if (!entry[INDEX_INFLIGHT])
+ UpdateInputState(client, entry)
+ else if (world.time < entry[INDEX_TIMEOUT])
+ entry[INDEX_INFLIGHT] = FALSE
+ if (no_mc_tick)
+ CHECK_TICK
+ else if (MC_TICK_CHECK)
+ queue.Cut(1, cut_until)
+ return
+ queue.Cut()
+
+
+/// Return, generating if necessary, a ckey-indexed list holding typing status.
+/datum/controller/subsystem/typing/proc/GetEntry(client/client)
+ PRIVATE_PROC(TRUE)
+ var/ckey
+ if (istext(client))
+ ckey = client
+ else if (istype(client))
+ ckey = client.ckey
+ else
+ return
+ var/list/entry = status[ckey]
+ if (!entry)
+ entry = new (MAX_INDEX)
+ entry[INDEX_PREFERENCE] = client.is_preference_enabled(/datum/client_preference/show_typing_indicator)
+ entry[INDEX_INFLIGHT] = FALSE
+ entry[INDEX_TIMEOUT] = world.time
+ entry[INDEX_INPUT_STATE] = FALSE
+ entry[INDEX_VERB_STATE] = FALSE
+ status[ckey] = entry
+ return entry
+
+
+/// Updates client's preference bool for whether typing indicators should be shown.
+/datum/controller/subsystem/typing/proc/UpdatePreference(client/client, preference)
+ var/list/entry = GetEntry(client)
+ entry[INDEX_PREFERENCE] = preference
+ UpdateIndicator(client, entry)
+
+
+/// Updates client|ckey's verb typing state to new_state.
+/datum/controller/subsystem/typing/proc/UpdateVerbState(client/client, state)
+ var/list/entry = GetEntry(client)
+ entry[INDEX_VERB_STATE] = state
+ UpdateIndicator(client, entry)
+
+
+/// Request client's input bar state using winget and updating entry accordingly.
+/datum/controller/subsystem/typing/proc/UpdateInputState(client/client, list/entry)
+ PRIVATE_PROC(TRUE)
+ set waitfor = FALSE
+ var/timeout = world.time + INFLIGHT_TIMEOUT
+ entry[INDEX_INFLIGHT] = TRUE
+ entry[INDEX_TIMEOUT] = timeout
+ var/content = winget(client, INPUT_HANDLE, "text")
+ if (timeout != entry[INDEX_TIMEOUT]) // We're stale. Touch nothing.
+ return
+ entry[INDEX_INFLIGHT] = FALSE
+ if (QDELETED(client) || !isliving(client.mob))
+ return
+ entry[INDEX_INPUT_STATE] = match_verbs.Find(content) != 0
+ UpdateIndicator(client, entry)
+
+
+/// Attempt to update the mob's typing state and indicator according to new state.
+/datum/controller/subsystem/typing/proc/UpdateIndicator(client/client, list/entry)
+ PRIVATE_PROC(TRUE)
+ var/mob/target = client.mob
+ var/display = target.stat == CONSCIOUS && entry[INDEX_PREFERENCE] && (entry[INDEX_INPUT_STATE] || entry[INDEX_VERB_STATE])
+ if (display == target.is_typing)
+ return
+ if (display)
+ if (!target.typing_indicator)
+ target.typing_indicator = new (null, target)
+ target.typing_indicator.icon_state = "[target.speech_bubble_appearance()]_typing"
+ target.typing_indicator.pixel_y = target.icon_expected_height - 32
+ target.vis_contents += target.typing_indicator
+ target.is_typing = TRUE
+ if (target.shadow)
+ target.vis_contents += target.typing_indicator
+ else
+ if (target.typing_indicator)
+ target.vis_contents -= target.typing_indicator
+ target.is_typing = FALSE
+ if (target.shadow)
+ target.vis_contents -= target.typing_indicator
+
+
+/atom/movable/typing_indicator
+ icon = 'icons/mob/talk.dmi'
+ icon_state = "typing"
+ plane = PLANE_LIGHTING_ABOVE
+ mouse_opacity = XMOUSE_OPACITY_NEVER
+ simulated = FALSE
+ anchored = TRUE
+
+ var/mob/owner
+
+
+/atom/movable/typing_indicator/Destroy()
+ if (owner)
+ owner.vis_contents -= src
+ owner.typing_indicator = null
+ owner = null
+ return ..()
+
+
+/atom/movable/typing_indicator/Initialize(mapload, mob/_owner)
+ . = ..()
+ if (!istype(_owner))
+ return INITIALIZE_HINT_QDEL
+ owner = _owner
+
+
+/// If this mob is or was piloted by a player with typing indicators enabled, an instance of one.
+/mob/var/atom/movable/typing_indicator/typing_indicator
+
+
+/// Whether this mob is currently typing, if piloted by a player.
+/mob/var/is_typing
+
+
+/mob/Destroy()
+ QDEL_NULL(typing_indicator)
+ return ..()
+
+
+/mob/Logout()
+ if (typing_indicator)
+ vis_contents -= typing_indicator
+ is_typing = FALSE
+ ..()
+
+
+/mob/verb/say_wrapper()
+ set name = ".Say"
+ set hidden = TRUE
+ SStyping.UpdateVerbState(client, TRUE)
+ var/message = input("","say (text)") as null | text
+ SStyping.UpdateVerbState(client, FALSE)
+ if (message)
+ say_verb(message)
+
+
+/mob/verb/me_wrapper()
+ set name = ".Me"
+ set hidden = TRUE
+ SStyping.UpdateVerbState(client, TRUE)
+ var/message = input("","me (text)") as null | text
+ SStyping.UpdateVerbState(client, FALSE)
+ if (message)
+ me_verb(message)
+
+
+// Retained for uses by non-player assets
+/proc/generate_speech_bubble(atom/movable/loc, icon_state, layer = FLOAT_LAYER)
+ var/image/image = image('icons/mob/talk.dmi', loc, icon_state, layer)
+ image.appearance_flags |= KEEP_APART | RESET_COLOR | PIXEL_SCALE
+ if (ismovable(loc))
+ var/x_scale = loc.get_icon_scale_x()
+ if (abs(x_scale) < 2) // reset transform on bubbles, except for the Very Large
+ image.pixel_z = (loc.icon_expected_height * (x_scale - 1))
+ image.appearance_flags |= RESET_TRANSFORM
+ return image
diff --git a/code/modules/client/preference_setup/global/setting_datums.dm b/code/modules/client/preference_setup/global/setting_datums.dm
index 1e6f4ed11c..51893ccb83 100644
--- a/code/modules/client/preference_setup/global/setting_datums.dm
+++ b/code/modules/client/preference_setup/global/setting_datums.dm
@@ -178,8 +178,7 @@ var/global/list/_client_preferences_by_type
disabled_description = "Hide"
/datum/client_preference/show_typing_indicator/toggled(var/mob/preference_mob, var/enabled)
- if(!enabled)
- preference_mob.set_typing_indicator(FALSE)
+ SStyping.UpdatePreference(preference_mob.client, enabled)
/datum/client_preference/show_ooc
description ="OOC chat"
diff --git a/code/modules/mob/living/carbon/brain/brain.dm b/code/modules/mob/living/carbon/brain/brain.dm
index 78acfe4703..1482ab006c 100644
--- a/code/modules/mob/living/carbon/brain/brain.dm
+++ b/code/modules/mob/living/carbon/brain/brain.dm
@@ -43,23 +43,3 @@
/mob/living/carbon/brain/isSynthetic()
return istype(loc, /obj/item/mmi)
-
-/mob/living/carbon/brain/set_typing_indicator(var/state)
- if(isturf(loc))
- return ..()
-
- if(!is_preference_enabled(/datum/client_preference/show_typing_indicator))
- loc.cut_overlay(typing_indicator, TRUE)
- return
-
- if(!typing_indicator)
- init_typing_indicator("[speech_bubble_appearance()]_typing")
-
- if(state && !typing)
- loc.add_overlay(typing_indicator, TRUE)
- typing = TRUE
- else if(typing)
- loc.cut_overlay(typing_indicator, TRUE)
- typing = FALSE
-
- return state
diff --git a/code/modules/mob/mob_defines.dm b/code/modules/mob/mob_defines.dm
index cf6aa54bc4..72f2c818c4 100644
--- a/code/modules/mob/mob_defines.dm
+++ b/code/modules/mob/mob_defines.dm
@@ -212,9 +212,6 @@
var/get_rig_stats = 0 //Moved from computer.dm
- var/typing
- var/obj/effect/decal/typing_indicator
-
var/low_priority = FALSE //Skip processing life() if there's just no players on this Z-level
var/default_pixel_x = 0 //For offsetting mobs
@@ -225,4 +222,4 @@
var/registered_z
- var/in_enclosed_vehicle = 0 //For mechs and fighters ambiance. Can be used in other cases.
\ No newline at end of file
+ var/in_enclosed_vehicle = 0 //For mechs and fighters ambiance. Can be used in other cases.
diff --git a/code/modules/mob/say.dm b/code/modules/mob/say.dm
index 20bf193bb4..fa7330cfc1 100644
--- a/code/modules/mob/say.dm
+++ b/code/modules/mob/say.dm
@@ -1,35 +1,32 @@
/mob/proc/say(var/message, var/datum/language/speaking = null, var/whispering = 0)
return
+
/mob/verb/whisper(message as text)
set name = "Whisper"
set category = "IC"
+ usr.say(message, whispering = TRUE)
- usr.say(message,whispering=1)
/mob/verb/say_verb(message as text)
set name = "Say"
set category = "IC"
-
- set_typing_indicator(FALSE)
usr.say(message)
+
/mob/verb/me_verb(message as message)
set name = "Me"
set category = "IC"
-
if(say_disabled) //This is here to try to identify lag problems
- to_chat(usr, "Speech is currently admin-disabled.")
+ to_chat(usr, SPAN_WARNING("Speech is currently admin-disabled."))
return
-
message = sanitize(message)
-
- set_typing_indicator(FALSE)
if(use_me)
custom_emote(usr.emote_type, message)
else
usr.emote(message)
+
/mob/proc/say_dead(var/message)
if(say_disabled) //This is here to try to identify lag problems
to_chat(usr, "Speech is currently admin-disabled.")
diff --git a/code/modules/mob/typing_indicator.dm b/code/modules/mob/typing_indicator.dm
deleted file mode 100644
index 36a99a265b..0000000000
--- a/code/modules/mob/typing_indicator.dm
+++ /dev/null
@@ -1,59 +0,0 @@
-/proc/generate_speech_bubble(var/bubble_loc, var/speech_state, var/set_layer = FLOAT_LAYER)
- var/image/I = image('icons/mob/talk.dmi', bubble_loc, speech_state, set_layer)
- I.appearance_flags |= (KEEP_APART|RESET_COLOR|PIXEL_SCALE)
- if(istype(bubble_loc, /atom/movable))
- var/atom/movable/AM = bubble_loc
- var/x_scale = AM.get_icon_scale_x()
- if(abs(x_scale) < 2) // reset transform on bubbles, except for the Very Large
- I.pixel_z = (AM.icon_expected_height * (x_scale-1))
- I.appearance_flags |= RESET_TRANSFORM
- return I
-
-/mob/proc/init_typing_indicator(var/set_state = "typing")
- typing_indicator = new
- typing_indicator.appearance = generate_speech_bubble(null, set_state)
- typing_indicator.appearance_flags |= (KEEP_APART|RESET_COLOR|RESET_TRANSFORM|PIXEL_SCALE)
-
-/mob/proc/set_typing_indicator(var/state) //Leaving this here for mobs.
-
- if(!is_preference_enabled(/datum/client_preference/show_typing_indicator))
- if(typing_indicator)
- cut_overlay(typing_indicator, TRUE)
- return
-
- if(!typing_indicator)
- init_typing_indicator("[speech_bubble_appearance()]_typing")
-
- if(state && !typing)
- add_overlay(typing_indicator, TRUE)
- typing = TRUE
- else if(typing)
- cut_overlay(typing_indicator, TRUE)
- typing = FALSE
-
- if(shadow) //Multi-Z above-me shadows
- shadow.set_typing_indicator(state)
-
- return state
-
-/mob/verb/say_wrapper()
- set name = ".Say"
- set hidden = 1
-
- set_typing_indicator(TRUE)
- var/message = input("","say (text)") as text
- set_typing_indicator(FALSE)
-
- if(message)
- say_verb(message)
-
-/mob/verb/me_wrapper()
- set name = ".Me"
- set hidden = 1
-
- set_typing_indicator(TRUE)
- var/message = input("","me (text)") as text
- set_typing_indicator(FALSE)
-
- if(message)
- me_verb(message)
diff --git a/code/modules/multiz/zshadow.dm b/code/modules/multiz/zshadow.dm
index ad86c06425..3bd9cae99a 100644
--- a/code/modules/multiz/zshadow.dm
+++ b/code/modules/multiz/zshadow.dm
@@ -113,15 +113,3 @@ INITIALIZE_IMMEDIATE(/mob/zshadow)
. = ..()
if(shadow)
shadow.set_dir(new_dir)
-
-/mob/zshadow/set_typing_indicator(var/state)
- if(!typing_indicator)
- init_typing_indicator("typing")
- if(state && !typing)
- add_overlay(typing_indicator)
- typing = 1
- else if(!state && typing)
- cut_overlay(typing_indicator)
- typing = 0
- if(shadow)
- shadow.set_typing_indicator(state)
diff --git a/polaris.dme b/polaris.dme
index 5f4be4cc24..542e55c807 100644
--- a/polaris.dme
+++ b/polaris.dme
@@ -248,6 +248,7 @@
#include "code\controllers\subsystems\ticker.dm"
#include "code\controllers\subsystems\time_track.dm"
#include "code\controllers\subsystems\timer.dm"
+#include "code\controllers\subsystems\typing.dm"
#include "code\controllers\subsystems\vote.dm"
#include "code\controllers\subsystems\webhooks.dm"
#include "code\controllers\subsystems\xenoarch.dm"
@@ -2205,7 +2206,6 @@
#include "code\modules\mob\say.dm"
#include "code\modules\mob\skillset.dm"
#include "code\modules\mob\transform_procs.dm"
-#include "code\modules\mob\typing_indicator.dm"
#include "code\modules\mob\update_icons.dm"
#include "code\modules\mob\_modifiers\aura.dm"
#include "code\modules\mob\_modifiers\changeling.dm"