SendInput is the default SendMode for hotstrings and Send because of it's alleged reliability: it sends the keystrokes in a manner where user and computer-generated keystrokes can't be interspersed with it. This means that if SendInput is sending aaaaaaaaaaaa and I am holding down b, then the output will be aaaaaaaaaaaabb....
Unfortunately, if another script or program has a keyboard hook installed meaning it's listening for keystrokes, this reliability breaks down and the keys may become interspersed: aaabaaaabaaaaabb....
Additionally, some programs can't handle the rapid input of SendInput and some keys get lost. In that case we can use SendEvent or SendPlay instead, but during those Send actions the keystrokes can easily get interspersed either during the backspacing of the hotstring or during the send of the hotstring.
If I have a hotstring :SE K40:hs::hotstring, then after typing hs AHK sends 3 backspaces (to delete the trigger and the trigger key) and then sends hotstring using SendEvent (SE) with keydelay 40ms (K40). Now suppose I type hs is good without waiting for the hotstring to be expanded completely. The result might be hotstrings good if the first character i I typed gets inserted after AHK has sent 2 backspaces of the 3. Or it might be hotistrinsg
good if I type during the sending of the word "hotstring".
Buffered hotstrings
This library attempts to buffer all user keystrokes during the Send command using an InputHook and releases the keystrokes after the Send is complete. This means that no keystrokes should get interspersed similarly to SendInput.
Achieving this requires some syntax changes to all your hotstrings:
- Add the directives #MaxThreadsPerHotkey 10 (10 can be a higher number as well) and #Hotstring XB0 to make all hotstrings execute a function and to disable the automatic backspacing.
- Change all instances of ::hs::hotstring to ::hs::_HS("hotstring). The _HS function will do all the backspacing and replacement sending. See the code below and read the comments to get further information.
- Note that global changes via the #Hotstring options directive need to be done using _HS(, "options") instead, because current global hotstring options can't be read (as far as I know). Some options need to be changed from both the #Hotstring directive and the _HS function such as the Omit option.
Code: Select all
#requires AutoHotkey v2
; #MaxThreadsPerHotkey needs to be higher than 1, otherwise some hotstrings might get lost
; if their activation strings were buffered.
#MaxThreadsPerHotkey 10
; Enable X (execution) and B0 (no backspacing) for all hotstrings, which are necessary for this library to work.
; Z option resets the hotstring recognizer after each replacement, as AHK does with auto-replace hotstrings
#Hotstring ZXB0
; For demonstration purposes lets use SendEvent as the default hotstring mode.
; This can't be enabled with `#Hotstring SE` because that setting is not accessible from AHK.
; O (omit EndChar) argument needs to be changed with this AND with `#Hotstring O`.
_HS(, "SE")
; Regular hotstrings only need to be wrapped with _HS. This uses SendEvent with default delay 0.
::fwi::_HS("for your information")
; Regular hotstring arguments can be used; this sets the keydelay to 40ms (this works since we are using SendMode Event)
:K40:afaik::_HS("as far as I know")
; Other hotstring arguments can be used as well such as Text
:T:omg::_HS("oh my god{enter}")
; Backspacing can be limited to n backspaces with Bn
:*:because of it's::_HS("s", "B2")
; ... however that's usually not necessary, because unlike the default implementation, this one backspaces
; only the non-matching end of the trigger string
:*:thats::_HS("that's")
; To use regular hotstrings without _HS, reverse the global changes locally (X0 disables execute, B enables backspacing)
:X0B:btw::by the way
/**
* Sends a hotstring and buffers user keyboard input while sending, which means keystrokes won't
* become interspersed or get lost. This requires that the hotstring has the X (execute) and B0 (no
* backspacing) options enabled: these can be globally enabled with `#Hotstring XB0`
* Note that mouse clicks *will* interrupt sending keystrokes.
* @param replacement The hotstring to be sent. If no hotstring is provided then instead _HS options
* will be modified according to the provided opts.
* @param opts Optional: hotstring options that will either affect all the subsequent _HS calls (if
* no replacement string was provided), or can be used to disable backspacing (`:B0:hs::hotstring` should
* NOT be used, correct is `_HS("hotstring", "B0")`).
* Additionally, differing from the default AHK hotstring syntax, the default option of backspacing
* deletes only the non-matching end of the trigger string (compared to the replacement string).
* Use the `BF` option to delete the whole trigger string.
* Also, using `Bn` backspaces only n characters and `B-n` leaves n characters from the beginning
* of the trigger string.
*
* * Hotstring settings that modify the hotstring recognizer (eg Z, EndChars) must be changed with `#Hotstring`
* * Hotstring settings that modify SendMode or speed must be changed with `_HS(, "opts")` or with hotstring
* local options such as `:K40:hs::hotstring`. In this case `#Hotstring` has no effect.
* * O (omit EndChar) argument default option needs to be changed with `_HS(, "O")` AND with `#Hotstring O`.
*
* Note that if changing global settings then the SendMode will be reset to InputThenEvent if no SendMode is provided.
* SendMode can only be changed with this (`#Hotstring SE` has no effect).
* @param sendFunc Optional: this can be used to define a default custom send function (if replacement
* is left empty), or temporarily use a custom function. This could, for example, be used to send
* via the Clipboard. This only affects sending the replacement text: backspacing and sending the
* ending character is still done with the normal Send function.
* @returns {void}
*/
_HS(replacement?, opts?, sendFunc?) {
static HSInputBuffer := InputBuffer(), DefaultOmit := false, DefaultSendMode := A_SendMode, DefaultKeyDelay := 0
, DefaultTextMode := "", DefaultBS := 0xFFFFFFF0, DefaultCustomSendFunc := "", DefaultCaseConform := true
, __Init := HotstringRecognizer.Start()
; Save global variables ASAP to avoid these being modified if _HS is interrupted
local Omit, TextMode, PrevKeyDelay := A_KeyDelay, PrevKeyDurationPlay := A_KeyDurationPlay, PrevSendMode := A_SendMode
, ThisHotkey := A_ThisHotkey, EndChar := A_EndChar, Trigger := RegExReplace(ThisHotkey, "^:[^:]*:",,,1)
, ThisHotstring := SubStr(HotstringRecognizer.Content, -StrLen(Trigger)-StrLen(EndChar))
; Only options without replacement text changes the global/default options
if !IsSet(replacement) {
if IsSet(sendFunc)
DefaultCustomSendFunc := sendFunc
if IsSet(opts) {
i := 1, opts := StrReplace(opts, " "), len := StrLen(opts)
While i <= len {
o := SubStr(opts, i, 1), o_next := SubStr(opts, i+1, 1)
if o = "S" {
; SendMode is reset if no SendMode is specifically provided
DefaultSendMode := o_next = "E" ? "Event" : o_next = "I" ? "InputThenPlay" : o_next = "P" ? "Play" : (i--, "Input")
i += 2
continue
} else if o = "O"
DefaultOmit := o_next != "0"
else if o = "*"
DefaultOmit := o_next != "0"
else if o = "K" && RegExMatch(opts, "i)^[-0-9]+", &KeyDelay, i+1) {
i += StrLen(KeyDelay[0]) + 1, DefaultKeyDelay := Integer(KeyDelay[0])
continue
} else if o = "T"
DefaultTextMode := o_next = "0" ? "" : "{Text}"
else if o = "R"
DefaultTextMode := o_next = "0" ? "" : "{Raw}"
else if o = "B" {
++i, DefaultBS := RegExMatch(opts, "i)^[fF]|^[-0-9]+", &BSCount, i) ? (i += StrLen(BSCount[0]), BSCount[0] = "f" ? 0xFFFFFFFF : Integer(BSCount[0])) : 0xFFFFFFF0
continue
} else if o = "C"
DefaultCaseConform := o_next = "0" ? 1 : 0
i += IsNumber(o_next) ? 2 : 1
}
}
return
}
if !IsSet(replacement)
return
; Musn't use Critical here, otherwise InputBuffer callbacks won't work
; Start capturing input for the rare case where keys are sent during options parsing
HSInputBuffer.Start()
TextMode := DefaultTextMode, BS := DefaultBS, Omit := DefaultOmit, CustomSendFunc := sendFunc ?? DefaultCustomSendFunc, CaseConform := DefaultCaseConform
SendMode DefaultSendMode
if InStr(DefaultSendMode, "Play")
SetKeyDelay , DefaultKeyDelay, "Play"
else
SetKeyDelay DefaultKeyDelay
; The only opts currently accepted is "B" or "B0" to enable/disable backspacing, since this can't
; be changed with local hotstring options
if IsSet(opts) && InStr(opts, "B")
BS := RegExMatch(opts, "i)[fF]|[-0-9]+", &BSCount) ? (BSCount[0] = "f" ? 0xFFFFFFFF : Integer(BSCount[0])) : 0xFFFFFFF0
; Load local hotstring options, but don't check for backspacing
if RegExMatch(ThisHotkey, "^:([^:]+):", &opts) {
opts := StrReplace(opts[1], " "), i := 1, len := StrLen(opts)
While i <= len {
o := SubStr(opts, i, 1), o_next := SubStr(opts, i+1, 1)
if o = "S" {
SendMode(o_next = "E" ? "Event" : o_next = "I" ? "InputThenPlay" : o_next = "P" ? "Play" : "Input")
i += 2
continue
} else if o = "O"
Omit := o_next != "0"
else if o = "*"
Omit := o_next != "0"
else if o = "K" && RegExMatch(opts, "[-0-9]+", &KeyDelay, i+1) {
i += StrLen(KeyDelay[0]) + 1, KeyDelay := Integer(KeyDelay[0])
if InStr(A_SendMode, "Play")
SetKeyDelay , KeyDelay, "Play"
else
SetKeyDelay KeyDelay
continue
} else if o = "T"
TextMode := o_next = "0" ? "" : "{Text}"
else if o = "R"
TextMode := o_next = "0" ? "" : "{Raw}"
else if o = "C"
CaseConform := o_next = "0" ? 1 : 0
i += IsNumber(o_next) ? 2 : 1
}
}
if CaseConform && ThisHotstring && IsUpper(SubStr(ThisHotstringLetters := RegexReplace(ThisHotstring, "\P{L}"), 1, 1), 'Locale') {
if IsUpper(SubStr(ThisHotstringLetters, 2), 'Locale')
replacement := StrUpper(replacement), Trigger := StrUpper(Trigger)
else
replacement := (BS < 0xFFFFFFF0 ? replacement : StrUpper(SubStr(replacement, 1, 1))) SubStr(replacement, 2), Trigger := StrUpper(SubStr(Trigger, 1, 1)) SubStr(Trigger, 2)
}
; If backspacing is enabled, get the activation string length using Unicode character length
; since graphemes need one backspace to be deleted but regular StrLen would report more than one
if BS {
MaxBS := StrLen(RegExReplace(Trigger, "s)((?>\P{M}(\p{M}|\x{200D}))+\P{M})|\X", "_")) + !Omit
if BS = 0xFFFFFFF0 {
BoundGraphemeCallout := GraphemeCallout.Bind(info := {CompareString: replacement, GraphemeLength:0, Pos:1})
RegExMatch(Trigger, "s)((?:(?>\P{M}(\p{M}|\x{200D}))+\P{M})|\X)(?CBoundGraphemeCallout)")
BS := MaxBS - info.GraphemeLength, replacement := SubStr(replacement, info.Pos)
} else
BS := BS = 0xFFFFFFFF ? MaxBS : BS > 0 ? BS : MaxBS + BS
}
; Send backspacing + TextMode + replacement string + optionally EndChar. SendLevel isn't changed
; because AFAIK normal hotstrings don't add the replacements to the end of the hotstring recognizer
if TextMode || !CustomSendFunc
Send((BS ? "{BS " BS "}" : "") TextMode replacement (Omit ? "" : (TextMode ? EndChar : "{Raw}" EndChar)))
else {
Send((BS ? "{BS " BS "}" : ""))
CustomSendFunc(replacement)
if !Omit ; This could also be send with CustomSendFunc, but some programs (eg Chrome) sometimes trim spaces/tabs
Send("{Raw}" EndChar)
}
; Reset the recognizer, so the next step will be captured by it
HotstringRecognizer.Reset()
; Release the buffer, but restore Send settings *after* it (since it also uses Send)
HSInputBuffer.Stop()
if InStr(A_SendMode, "Play")
SetKeyDelay , PrevKeyDurationPlay, "Play"
else
SetKeyDelay PrevKeyDelay
SendMode PrevSendMode
GraphemeCallout(info, m, *) => SubStr(info.CompareString, info.Pos, len := StrLen(m[0])) == m[0] ? (info.Pos += len, info.GraphemeLength++, 1) : -1
}
/**
* Mimics the internal hotstring recognizer as close as possible. It is *not* automatically
* cleared if a hotstring is activated, as AutoHotkey doesn't provide a way to do that.
*
* Properties:
* HotstringRecognizer.Content => the current content of the recognizer
* HotstringRecognizer.Length => length of the content string
* HotstringRecognizer.IsActive => whether HotstringRecognizer is active or not
* HotstringRecognizer.MinSendLevel => minimum SendLevel that gets captured
* HotstringRecognizer.ResetKeys => gets or sets the keys that reset the recognizer (by default the arrow keys, Home, End, Next, Prior)
* HotstringRecognizer.OnChange => can be set to a callback function that is called when the recognizer content changes.
* The callback receives two arguments: Callback(OldContent, NewContent)
*
* Methods:
* HotstringRecognizer.Start() => starts capturing hotstring content
* HotstringRecognizer.Stop() => stops capturing
* HotstringRecognizer.Reset() => clears the content and resets the internal foreground window
*
*/
class HotstringRecognizer {
static Content := "", Length := 0, IsActive := 0, OnChange := 0, __ResetKeys := "{Left}{Right}{Up}{Down}{Next}{Prior}{Home}{End}"
, __hWnd := DllCall("GetForegroundWindow", "ptr"), __Hook := 0
static GetHotIfIsActive(*) => this.IsActive
static __New() {
this.__Hook := InputHook("V L0 I" A_SendLevel)
this.__Hook.KeyOpt(this.__ResetKeys "{Backspace}", "N")
this.__Hook.OnKeyDown := this.Reset.Bind(this)
this.__Hook.OnChar := this.__AddChar.Bind(this)
Hotstring.DefineProp("Call", {Call:this.__Hotstring.Bind(this)})
; These two throw critical recursion errors if defined with the normal syntax and AHK is ran in debugging mode
HotstringRecognizer.DefineProp("MinSendLevel", {
set:((hook, this, value, *) => hook.MinSendLevel := value).Bind(this.__Hook),
get:((hook, *) => hook.MinSendLevel).Bind(this.__Hook)})
HotstringRecognizer.DefineProp("ResetKeys",
{set:((this, dummy, value, *) => (this.__ResetKeys := value, this.__Hook.KeyOpt(this.__ResetKeys, "N"), Value)).Bind(this),
get:((this, *) => this.__ResetKeys).Bind(this)})
}
static Start() {
this.Reset()
if !this.HasProp("__HotIfIsActive") {
this.__HotIfIsActive := this.GetHotIfIsActive.Bind(this)
Hotstring("MouseReset", Hotstring("MouseReset")) ; activate or deactivate the relevant mouse hooks
}
this.__Hook.Start()
this.IsActive := 1
}
static Stop() => (this.__Hook.Stop(), this.IsActive := 0)
static Reset(ih:=0, vk:=0, *) => (vk = 8 ? this.__SetContent(SubStr(this.Content, 1, -1)) : this.__SetContent(""), this.Length := 0, this.__hWnd := DllCall("GetForegroundWindow", "ptr"))
static __AddChar(ih, char) {
hWnd := DllCall("GetForegroundWindow", "ptr")
if this.__hWnd != hWnd
this.__hWnd := hwnd, this.__SetContent("")
this.__SetContent(this.Content char), this.Length += 1
if this.Length > 100
this.Length := 50, this.Content := SubStr(this.Content, 52)
}
static __MouseReset(*) {
if Hotstring("MouseReset")
this.Reset()
}
static __Hotstring(BuiltInFunc, arg1, arg2?, arg3*) {
switch arg1, 0 {
case "MouseReset":
if IsSet(arg2) {
HotIf(this.__HotIfIsActive)
if arg2 {
Hotkey("~*LButton", this.__MouseReset.Bind(this))
Hotkey("~*RButton", this.__MouseReset.Bind(this))
} else {
Hotkey("~*LButton")
Hotkey("~*RButton")
}
HotIf()
}
case "Reset":
this.Reset()
}
return (Func.Prototype.Call)(BuiltInFunc, arg1, arg2?, arg3*)
}
static __SetContent(Value) {
if this.OnChange && HasMethod(this.OnChange) && this.Content !== Value
SetTimer(this.OnChange.Bind(this.Content, Value), -1)
this.Content := Value
}
}
/**
* InputBuffer can be used to buffer user input for keyboard, mouse, or both at once.
* The default InputBuffer (via the main class name) is keyboard only, but new instances
* can be created via InputBuffer().
*
* InputBuffer(keybd := true, mouse := false, timeout := 0)
* Creates a new InputBuffer instance. If keybd/mouse arguments are numeric then the default
* InputHook settings are used, and if they are a string then they are used as the Option
* arguments for InputHook and HotKey functions. Timeout can optionally be provided to call
* InputBuffer.Stop() automatically after the specified amount of milliseconds (as a failsafe).
*
* InputBuffer.Start() => initiates capturing input
* InputBuffer.Release() => releases buffered input and continues capturing input
* InputBuffer.Stop(release := true) => releases buffered input and then stops capturing input
* InputBuffer.ActiveCount => current number of Start() calls
* Capturing will stop only when this falls to 0 (Stop() decrements it by 1)
* InputBuffer.SendLevel => SendLevel of the InputHook
* InputBuffers default capturing SendLevel is A_SendLevel+2,
* and key release SendLevel is A_SendLevel+1.
* InputBuffer.IsReleasing => whether Release() is currently in action
* InputBuffer.Buffer => current buffered input in an array
*
* Notes:
* * Mouse input can't be buffered while AHK is doing something uninterruptible (eg busy with Send)
*/
class InputBuffer {
Buffer := [], SendLevel := A_SendLevel + 2, ActiveCount := 0, IsReleasing := 0, ModifierKeyStates := Map()
, MouseButtons := ["LButton", "RButton", "MButton", "XButton1", "XButton2", "WheelUp", "WheelDown"]
, ModifierKeys := ["LShift", "RShift", "LCtrl", "RCtrl", "LAlt", "RAlt", "LWin", "RWin"]
static __New() => this.DefineProp("Default", {value:InputBuffer()})
static __Get(Name, Params) => this.Default.%Name%
static __Set(Name, Params, Value) => this.Default.%Name% := Value
static __Call(Name, Params) => this.Default.%Name%(Params*)
__New(keybd := true, mouse := false, timeout := 0) {
if !keybd && !mouse
throw Error("At least one input type must be specified")
this.Timeout := timeout
this.Keybd := keybd, this.Mouse := mouse
if keybd {
if keybd is String {
if RegExMatch(keybd, "i)I *(\d+)", &lvl)
this.SendLevel := Integer(lvl[1])
}
this.InputHook := InputHook(keybd is String ? keybd : "I" (this.SendLevel) " L0 B0")
this.InputHook.NotifyNonText := true
this.InputHook.VisibleNonText := false
this.InputHook.OnKeyDown := this.BufferKey.Bind(this,,,, "Down")
this.InputHook.OnKeyUp := this.BufferKey.Bind(this,,,, "Up")
this.InputHook.KeyOpt("{All}", "N S")
}
this.HotIfIsActive := this.GetActiveCount.Bind(this)
}
BufferMouse(ThisHotkey, Opts := "") {
savedCoordMode := A_CoordModeMouse, CoordMode("Mouse", "Screen")
MouseGetPos(&X, &Y)
ThisHotkey := StrReplace(ThisHotkey, "Button")
this.Buffer.Push(Format("{Click {1} {2} {3} {4}}", X, Y, ThisHotkey, Opts))
CoordMode("Mouse", savedCoordMode)
}
BufferKey(ih, VK, SC, UD) => (this.Buffer.Push(Format("{{1} {2}}", GetKeyName(Format("vk{:x}sc{:x}", VK, SC)), UD)))
Start() {
this.ActiveCount += 1
SetTimer(this.Stop.Bind(this), -this.Timeout)
if this.ActiveCount > 1
return
this.Buffer := [], this.ModifierKeyStates := Map()
for modifier in this.ModifierKeys
this.ModifierKeyStates[modifier] := GetKeyState(modifier)
if this.Keybd
this.InputHook.Start()
if this.Mouse {
HotIf this.HotIfIsActive
if this.Mouse is String && RegExMatch(this.Mouse, "i)I *(\d+)", &lvl)
this.SendLevel := Integer(lvl[1])
opts := this.Mouse is String ? this.Mouse : ("I" this.SendLevel)
for key in this.MouseButtons {
if InStr(key, "Wheel")
HotKey key, this.BufferMouse.Bind(this), opts
else {
HotKey key, this.BufferMouse.Bind(this,, "Down"), opts
HotKey key " Up", this.BufferMouse.Bind(this), opts
}
}
HotIf ; Disable context sensitivity
}
}
Release() {
if this.IsReleasing || !this.Buffer.Length
return []
sent := [], clickSent := false, this.IsReleasing := 1
if this.Mouse
savedCoordMode := A_CoordModeMouse, CoordMode("Mouse", "Screen"), MouseGetPos(&X, &Y)
; Theoretically the user can still input keystrokes between ih.Stop() and Send, in which case
; they would get interspersed with Send. So try to send all keystrokes, then check if any more
; were added to the buffer and send those as well until the buffer is emptied.
PrevSendLevel := A_SendLevel
SendLevel this.SendLevel - 1
; Restore the state of any modifier keys before input buffering was started
modifierList := ""
for modifier, state in this.ModifierKeyStates
if GetKeyState(modifier) != state
modifierList .= "{" modifier (state ? " Down" : " Up") "}"
if modifierList
Send modifierList
while this.Buffer.Length {
key := this.Buffer.RemoveAt(1)
sent.Push(key)
if InStr(key, "{Click ")
clickSent := true
Send("{Blind}" key)
}
SendLevel PrevSendLevel
if this.Mouse && clickSent {
MouseMove(X, Y)
CoordMode("Mouse", savedCoordMode)
}
this.IsReleasing := 0
return sent
}
Stop(release := true) {
if !this.ActiveCount
return
sent := release ? this.Release() : []
if --this.ActiveCount
return
if this.Keybd
this.InputHook.Stop()
if this.Mouse {
HotIf this.HotIfIsActive
for key in this.MouseButtons
HotKey key, "Off"
HotIf ; Disable context sensitivity
}
return sent
}
GetActiveCount(HotkeyName) => this.ActiveCount
}
01.12.23: Added Bn option to _HS: B0 disables backspacing, Bn backspaces n characters, B-n leaves n characters of the trigger word, B deletes the whole trigger word. Fixed a bug where using Text/Raw mode didn't send the end character.
07.02.24: Added Z option to #Hotstring, to cause the hotstring recognizer to reset after triggering _HS.
12.02.24: Added a way to define a custom send function (eg Clip).
18.02.24: Fixed * option backspacing too much. Improved options parsing logic. Improved backspacing logic: now the entire trigger string won't be deleted if the beginning of the replacement matches the beginning of the trigger.
30.03.24. Implemented case conformity by introducing the HotstringRecognizer class, which tries to recreate AHKs internal hotstring recognizer mechanism (is not 100% the same).