Buffered hotstrings

Post your working scripts, libraries and tools.
Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Buffered hotstrings

Post by Descolada » 05 Nov 2023, 03:32

Introduction

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:
  1. 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.
  2. 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.
  3. 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.
Examples & code

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
}
Updates:
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).
Last edited by Descolada on 29 Mar 2024, 23:35, edited 6 times in total.

User avatar
kunkel321
Posts: 1061
Joined: 30 Nov 2015, 21:19

Re: Buffered hotstrings

Post by kunkel321 » 05 Nov 2023, 13:19

Kudos and thanks for sharing!

I've been dealing with this same frustration, but didn't know how to fix it. You can see my post about it here: viewtopic.php?f=82&t=122332

I did the same experiment with your tool, and it does seem to work. Using this hotstring.

Code: Select all

:X0B*:zx::llllllllllllllllllllllllllllllllllllllllllllllllllllllllll
and then "strumming" zxcv with my left hand, I get this:

Code: Select all

lllllllllllcllllllllllllllllllllllvlllllllllllllllllllllllll
llllllllllllllclllllllllllllllllllllllllllllllllvlllllllllll
lllllllllllllllllllllllllllcllllllllllllllllllllllllllvlllll
lllllllllllllllllllclllllllllllllllllllllllllllllllllllllllv
But using this one:

Code: Select all

:*:zx::_HS("llllllllllllllllllllllllllllllllllllllllllllllllllllllllll")
I get this:

Code: Select all

llllllllllllllllllllllllllllllllllllllllllllllllllllllllllcv
llllllllllllllllllllllllllllllllllllllllllllllllllllllllllcv
llllllllllllllllllllllllllllllllllllllllllllllllllllllllllcv
llllllllllllllllllllllllllllllllllllllllllllllllllllllllllcfv
Very nice! :thumbup:

Next day edit: @Descolada
I don't know if you are interested in "feature ideas," but I have a couple for you. Check out this post viewtopic.php?f=83&t=122531
The topic is about changing a hotstring so that only the right-most needed characters are actually changed. For example this

Code: Select all

:*:because of it's::because of its
gets changed to this

Code: Select all

:B0*:because of it's::{BS 2}s 
Maybe this could be added as an option (i.e. the second parameter options) (?)

Setting up hotstrings like this also has the benefit of "case-conformation." If the auto-corrected word is at the beginning of a sentence, then you (usually) don't have to worry about making the replacement start with a capital, because the left-most characters don't get removed.

EDIT a couple of days later: Upon studying your code, I realized the "modularity" of it. The sweet buffering goodness happens in the bottom part. I was able to use your function as a model, and integrate the InputBuffer class in my own function: viewtopic.php?f=83&t=120220&p=543210#p542691 I'll update it soon.
ste(phen|ve) kunkel

Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Re: Buffered hotstrings

Post by Descolada » 01 Dec 2023, 13:52

@kunkel321, I only saw your first reply without the edits, which is why I'm answering just now :)
If I understood correctly then you propose that instead of :B0*:because of it's::{BS 2}s something like :B2*:because of it's::s would be possible. Unfortunately that can't be implemented because AHK throws an error with that syntax, thus the _HS function also can't interpret the option. Additionally, the native B option shouldn't be changed from B0 because otherwise the backspacing can't be buffered by the InputBuffer.

The closest I could come is :*:because of it's::_HS("s", "B2"), though it's not much shorter than :*:because of it's::_HS("{BS 2}s", "B0"). I've updated the main post with that feature.

User avatar
kunkel321
Posts: 1061
Joined: 30 Nov 2015, 21:19

Re: Buffered hotstrings

Post by kunkel321 » 01 Dec 2023, 14:34

Hey very cool! Thanks for adding the feature!
EDIT: @Descolada Hey have you experimented with InputBuffer and the issue in AutoHokey (and probably other languages) about needing a "post-paste delay?" Each time SendInput '^v' is used in a loop, you have to put a sleep, to give the clipboard time to catch up. Or maybe it's not the clipboard per se, but whatever happens inside Windows when the clipboard is actually entered into the receiving edit field.

I'm referring to scenarios like the below code. With the below example, I have sleep 10, which works fine in Notepad. That is, it generates "one two three four five." Without the sleep, the entire 5 loops happen in just a couple of milliseconds, before Windows can do anything, so it generates "five."

Pasting into MS Outlook is the worst. With a 10ms delay, you are likely to get "five five five five five." For Outlook I find that about 350ms are needed for any reliability.

I did try integrating InputBuffer in the loop, but no luck. My guess is that it didn't work because InputHook() Only monitors what the user physically types, and can't monitor when back-to-back ^vs, or anything else, is sent programmatically. There might be other factors that are relevant too, IDK.

Code: Select all

#SingleInstance
#Requires AutoHotkey v2.0

list := ["one", "two", "three", "four", "five"]

!q::
{	for idx, item in list {
		A_Clipboard := ""
		A_Clipboard := item " "
		ClipWait 1 ; Wait for clipboard to contain data
		SendInput '^v' ; Send paste command
		sleep 10
	} 
}
ste(phen|ve) kunkel

Jasonosaj
Posts: 51
Joined: 02 Feb 2022, 15:02
Location: California

Re: Buffered hotstrings

Post by Jasonosaj » 06 Dec 2023, 16:54

Nice script! Below is a quick and dirty script to convert standard hotkeys to _HS syntax

Code: Select all

lines := StrSplit(clipboard, "`n")

for index, line in lines {
    ; If the line is a comment or does not contain a hotstring, keep it as is
    if (SubStr(line, 1, 1) = ";" or !RegExMatch(line, "::")) {
        continue
    }

    ; Parse the line to extract the hotstring parameters
    RegExMatch(line, "i)^(:[^:]*:)([^:]*::)([^;]*)(;.*)?$", match)

    prefix := match1
    inputString := match2
    outputString := StrReplace(match3,"`r","")
    comment := match4

    ; Handle the cases for "*", "T", and "C" in the prefix
    prefix := StrReplace(prefix, "T", "")
    prefix := StrReplace(prefix, "B0", "*") ; address :B0*:
    prefix := StrReplace(prefix, "X", "*") ; address :B0*:
    prefix := StrReplace(prefix, "?", "?") ; address :?:
    Loop 5
        prefix := StrReplace(prefix, "**", "*") ; address :C*:
    ; if (InStr(prefix, "*") || InStr(prefix, "C")) {
    ;     prefix := StrReplace(prefix, ":B0:", ":*:")  ; Replace ":B0:" with ":*:"
    ; }

    ; If the hotstring contains "SendInput", extract the number of backspaces and the text to send
    if (RegExMatch(outputString, "i){BS (\d+)}(.*)", sendInputMatch)) {
        backspaces := sendInputMatch1
        text := sendInputMatch2
        outputString := "_HS(""" . Trim(text) . """, ""B" . backspaces . """)"
    } else {
        outputString := "_HS(""" . Trim(outputString) . """)"
    }

    ; Format the new hotstring according to your specifications
    newHotstring := prefix . inputString . outputString

    ; Replace the line with the new hotstring
    lines[index] := newHotstring . (comment ? " " . comment : "")
}

; Join the processed lines back into a single string and write it back to the clipboard
clipboard := ""
for index, line in lines {
    clipboard .= line . "`n"
}
clipboard := RTrim(clipboard, "`n")  ; Remove the trailing newline


User avatar
kunkel321
Posts: 1061
Joined: 30 Nov 2015, 21:19

Re: Buffered hotstrings

Post by kunkel321 » 28 Mar 2024, 12:12

Hi @Descolada (or other knowledgeable person),
Can you comment on this...

I have a tool that reads boilerplate text entries from the keys of an ini [section] then types and/or pastes the text. The setup includes what I've summarized in the below pseudo code...

Code: Select all

;PSEUDO CODE.
Array := [{key:"paste me",value:"I get get pasted."}
		,{key:"",value:"I get get typed out."}
		,{key:"",value:"I also get typed out."}] ; <--- Array of keyvalue pairs... just an example.. might be bad syntax. 

for Item in Array {
	if item.key has string "paste"
		paste item.value with Send '^v'
	else ; item key doesn't have 'paste' so start input hook and type it. 
	{
		static HSInputBuffer := InputBuffer() ; <--- potentially gets created many times !!!
		HSInputBuffer.Start()
		send item.value
	}
}
HSInputBuffer.Stop() ; loop (and ini section) is finished, so release any buffered text and end capture. 

InputBuffer Class code is down here... 
My Question: Is it okay to create the InputBuffer() object over and over like this, or is that bad practice?

I did it like this, because many of my boilerplate ini [section] contain only "paste me" items. I only what the buffer started when the relevant part of the If/Then code is followed.

I did a couple of test runs with my actual code, and it does work quite nicely... But is it bad to create the start the buffer multiple times like this?
ste(phen|ve) kunkel

Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Re: Buffered hotstrings

Post by Descolada » 28 Mar 2024, 12:55

@kunkel321, if your code uses the static keyword then the variable gets initialized just once (on the first call), so it shouldn't be an issue. And even if you created it multiple times then it shouldn't cause issues, just be a bit less efficient as all the internal mechanisms get created and destroyed unnecessarily.

User avatar
kunkel321
Posts: 1061
Joined: 30 Nov 2015, 21:19

Re: Buffered hotstrings

Post by kunkel321 » 28 Mar 2024, 14:14

Descolada wrote:
28 Mar 2024, 12:55
@kunkel321, if your code uses the static keyword then the variable gets initialized just once (on the first call), so it shouldn't be an issue. And even if you created it multiple times then it shouldn't cause issues, just be a bit less efficient as all the internal mechanisms get created and destroyed unnecessarily.
Awesome -- Thank you!
Edit: Wait... It already has 'static'. So you mean like it already is -- yes?
ste(phen|ve) kunkel

User avatar
kunkel321
Posts: 1061
Joined: 30 Nov 2015, 21:19

Re: Buffered hotstrings

Post by kunkel321 » 28 Mar 2024, 16:30

@Descolada (or somebody) I discovered an interesting quirk... Not sure if it's a problem with the IB class, with AHK, with Win 10, or just something I'm doing wrong...

EDIT: I'll leave the rest of this reply for archival purposes, but Descolada explained the problem with me setup in his below reply.

Please try the below code, (types into a text field like Notepad) and see if you get the same. When I run it, after it runs, the keyboard is unresponsive. Presumably the IB class is still "hooking" the kb input. However the MButton hotkey msbBox message suggests that it is not.

Thoughts?
EDIT: Put different sample text so it's easier to follow...

Code: Select all

#SingleInstance
#Requires AutoHotkey v2+
; buffer testing
; Right click systray icon ---> exit to exit.
!+b::
{
	myArr := [{key:"paste",value:"11111111111111111111111111111111111111111111111111 "}
			,{key:"",value:"a`n"}
			,{key:"paste",value:"22222222222222222222222222222222222222222222222222 "}
			,{key:"",value:"b`n"}
			,{key:"paste",value:"33333333333333333333333333333333333333333333333333 "}
			,{key:"",value:"c`n"}
			,{key:"paste",value:"44444444444444444444444444444444444444444444444444 "}
			,{key:"",value:"d`n"}] 
			
	for Item in myArr {
		if inStr(item.key, "paste") {
			A_Clipboard := item.value
			Send '^v'
			sleep 200
		}
		else ; item key doesn't have 'paste' so start input hook and type it. 
		{
			static HSInputBuffer := InputBuffer() ; <--- potentially gets created many times !!!
			HSInputBuffer.Start()
			send item.value
		}
	}
	HSInputBuffer.Stop() ; loop (and ini section) is finished, so release any buffered text and end capture. 
}

Mbutton::msgbox 'count ' InputBuffer.ActiveCount '`nlevel ' InputBuffer.SendLevel '`nisRel ' InputBuffer.IsReleasing 

/**
 * 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, MouseButtons := ["LButton", "RButton", "MButton", "XButton1", "XButton2", "WheelUp", "WheelDown"]
    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 *")
            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 := []

        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
            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
        while this.Buffer.Length {
            key := this.Buffer.RemoveAt(1)
            sent.Push(key)
            if InStr(key, "{Click ")
                clickSent := true
            Send(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
}
Edit again: I noticed your info:
* Notes:
* * Mouse input can't be buffered while AHK is doing something uninterruptible (eg busy with Send)
So it occurred to me to put this above the .Stop....

Code: Select all

	While InputBuffer.IsReleasing = 1
		sleep 50
	HSInputBuffer.Stop() ; loop (and ini section) is finished, so release any buffered text and end capture. 
That does not fix it though...

Edit again....
I guess maybe using a timeout fixes it...

Code: Select all

static HSInputBuffer := InputBuffer(,,500)
:D
Last edited by kunkel321 on 29 Mar 2024, 13:05, edited 1 time in total.
ste(phen|ve) kunkel

Descolada
Posts: 1141
Joined: 23 Dec 2021, 02:30

Re: Buffered hotstrings

Post by Descolada » 29 Mar 2024, 00:48

@kunkel321, InputBuffer.Start() increases the internal reference count of active capture sessions, and Stop() decreases it. Once the reference count is 0 (meaning no other part of the code is depending on it being active any longer) the buffer is released.

If you want to buffer the whole section of Ctrl+V / Send, then the following would make more sense:

Code: Select all

	static HSInputBuffer := InputBuffer()
	HSInputBuffer.Start()
	for Item in myArr {
		if inStr(item.key, "paste") {
			A_Clipboard := item.value
			Send '^v'
			sleep 200
		}
		else ; item key doesn't have 'paste' so start input hook and type it. 
		{
			send item.value
		}
	}
    HSInputBuffer.Stop()
Note that the default SendMode is Input, so if it doesn't revert to Event then there'll be small timegaps where user input can still leak through because the keyboard hook is temporarily removed.

If you only want to buffer while using Send, then use

Code: Select all

	static HSInputBuffer := InputBuffer()
	for Item in myArr {
		if inStr(item.key, "paste") {
			A_Clipboard := item.value
			Send '^v'
			sleep 200
		}
		else ; item key doesn't have 'paste' so start input hook and type it. 
		{
			HSInputBuffer.Start()
			send item.value
			HSInputBuffer.Stop()
		}
	}

User avatar
kunkel321
Posts: 1061
Joined: 30 Nov 2015, 21:19

Re: Buffered hotstrings

Post by kunkel321 » 29 Mar 2024, 13:03

Thanks for the explanation Descolada... I guess maybe that was a no-brainer. LOL.
ste(phen|ve) kunkel

Post Reply

Return to “Scripts and Functions (v2)”