diff --git a/C#/AutoHotInterception.sln b/C#/AutoHotInterception.sln index 351e265..5bbe268 100644 --- a/C#/AutoHotInterception.sln +++ b/C#/AutoHotInterception.sln @@ -12,6 +12,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Dependencies", "Dependencie Dependencies\Readme.md = Dependencies\Readme.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "UnitTests\UnitTests.csproj", "{8EDF4429-251A-416D-BB68-93F227191BCF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,6 +28,10 @@ Global {02CBCBB9-C17F-4C6A-8F93-D7EAF038CAED}.Debug|Any CPU.Build.0 = Debug|Any CPU {02CBCBB9-C17F-4C6A-8F93-D7EAF038CAED}.Release|Any CPU.ActiveCfg = Release|Any CPU {02CBCBB9-C17F-4C6A-8F93-D7EAF038CAED}.Release|Any CPU.Build.0 = Release|Any CPU + {8EDF4429-251A-416D-BB68-93F227191BCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EDF4429-251A-416D-BB68-93F227191BCF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EDF4429-251A-416D-BB68-93F227191BCF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EDF4429-251A-416D-BB68-93F227191BCF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/C#/AutoHotInterception/AutoHotInterception.csproj b/C#/AutoHotInterception/AutoHotInterception.csproj index 4c2b71c..8a2a4b5 100644 --- a/C#/AutoHotInterception/AutoHotInterception.csproj +++ b/C#/AutoHotInterception/AutoHotInterception.csproj @@ -46,6 +46,7 @@ + diff --git a/C#/AutoHotInterception/DeviceHandlers/DeviceHandler.cs b/C#/AutoHotInterception/DeviceHandlers/DeviceHandler.cs index f9b2833..c48f97f 100644 --- a/C#/AutoHotInterception/DeviceHandlers/DeviceHandler.cs +++ b/C#/AutoHotInterception/DeviceHandlers/DeviceHandler.cs @@ -1,6 +1,7 @@ using AutoHotInterception.Helpers; using System; using System.Collections.Concurrent; +using System.Collections.Generic; namespace AutoHotInterception.DeviceHandlers { @@ -37,7 +38,6 @@ namespace AutoHotInterception.DeviceHandlers if (!mappingOptions.Concurrent && !WorkerThreads.ContainsKey(code)) { WorkerThreads.TryAdd(code, new WorkerThread()); - WorkerThreads[code].Start(); } _isFiltered = true; } @@ -68,7 +68,6 @@ namespace AutoHotInterception.DeviceHandlers if (!mappingOptions.Concurrent && DeviceWorkerThread == null) { DeviceWorkerThread = new WorkerThread(); - DeviceWorkerThread.Start(); } _isFiltered = true; } @@ -83,6 +82,7 @@ namespace AutoHotInterception.DeviceHandlers if (!AllButtonsMapping.Concurrent && DeviceWorkerThread != null) { DeviceWorkerThread.Dispose(); + DeviceWorkerThread = null; } AllButtonsMapping = null; DisableFilterIfNeeded(); @@ -120,9 +120,9 @@ namespace AutoHotInterception.DeviceHandlers public abstract void DisableFilterIfNeeded(); /// - /// Process an incoming stroke + /// Process an incoming stroke, or a pair of extended keycode strokes /// - /// The stroke to process - public abstract void ProcessStroke(ManagedWrapper.Stroke stroke); + /// The stroke(s) to process + public abstract void ProcessStroke(List strokes); } } diff --git a/C#/AutoHotInterception/DeviceHandlers/IDeviceHandler.cs b/C#/AutoHotInterception/DeviceHandlers/IDeviceHandler.cs index 6b5944b..b3caa60 100644 --- a/C#/AutoHotInterception/DeviceHandlers/IDeviceHandler.cs +++ b/C#/AutoHotInterception/DeviceHandlers/IDeviceHandler.cs @@ -1,4 +1,5 @@ using AutoHotInterception.Helpers; +using System.Collections.Generic; namespace AutoHotInterception.DeviceHandlers { @@ -53,9 +54,9 @@ namespace AutoHotInterception.DeviceHandlers void DisableFilterIfNeeded(); /// - /// Process an incoming stroke + /// Process an incoming stroke, or a pair of extended keycode strokes /// - /// The stroke to process - void ProcessStroke(ManagedWrapper.Stroke stroke); + /// The stroke(s) to process + void ProcessStroke(List strokes); } } diff --git a/C#/AutoHotInterception/DeviceHandlers/KeyboardHandler.cs b/C#/AutoHotInterception/DeviceHandlers/KeyboardHandler.cs index 75c60b8..106610c 100644 --- a/C#/AutoHotInterception/DeviceHandlers/KeyboardHandler.cs +++ b/C#/AutoHotInterception/DeviceHandlers/KeyboardHandler.cs @@ -1,6 +1,7 @@ using AutoHotInterception.Helpers; using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Threading; namespace AutoHotInterception.DeviceHandlers @@ -34,25 +35,18 @@ namespace AutoHotInterception.DeviceHandlers /// The State to send (1 = pressed, 0 = released) public void SendKeyEvent(ushort code, int state) { - var st = 1 - state; - var stroke = new ManagedWrapper.Stroke(); - if (code > 255) + var strokes = ScanCodeHelper.TranslateAhkCode(code, state); + for (int i = 0; i < strokes.Count; i++) { - code -= 256; - if (code != 54) // RShift has > 256 code, but state is 0/1 - st += 2; + var stroke = strokes[i]; + ManagedWrapper.Send(DeviceContext, DeviceId, ref stroke, 1); } - - stroke.key.code = code; - stroke.key.state = (ushort)st; - ManagedWrapper.Send(DeviceContext, DeviceId, ref stroke, 1); } #endregion // ScanCode notes: https://www.win.tue.nl/~aeb/linux/kbd/scancodes-1.html - public override void ProcessStroke(ManagedWrapper.Stroke stroke) + public override void ProcessStroke(List strokes) { - //ManagedWrapper.Send(DeviceContext, _deviceId, ref stroke, 1); var hasSubscription = false; var hasContext = ContextCallback != null; @@ -62,7 +56,7 @@ namespace AutoHotInterception.DeviceHandlers if (_isFiltered) { var isKeyMapping = false; // True if this is a mapping to a single key, else it would be a mapping to a whole device - var processedState = HelperFunctions.KeyboardStrokeToKeyboardState(stroke); + var processedState = ScanCodeHelper.TranslateScanCodes(strokes); var code = processedState.Code; var state = processedState.State; MappingOptions mapping = null; @@ -81,41 +75,30 @@ namespace AutoHotInterception.DeviceHandlers if (mapping != null) { - // Begin translation of incoming key code, state, extended flag etc... - var processMappings = true; - if (processedState.Ignore) + hasSubscription = true; + + if (mapping.Block) block = true; + if (mapping.Concurrent) { - // Set flag to stop Context Mode from firing - hasSubscription = true; - // Set flag to indicate disable mapping processing - processMappings = false; + if (isKeyMapping) + { + ThreadPool.QueueUserWorkItem(threadProc => mapping.Callback(state)); + } + else + { + ThreadPool.QueueUserWorkItem(threadProc => mapping.Callback(code, state)); + } } - if (processMappings) + else { - hasSubscription = true; - - if (mapping.Block) block = true; - if (mapping.Concurrent) + //mapping.Callback(code, state); + if (isKeyMapping) { - if (isKeyMapping) - { - ThreadPool.QueueUserWorkItem(threadProc => mapping.Callback(state)); - } - else - { - ThreadPool.QueueUserWorkItem(threadProc => mapping.Callback(code, state)); - } + WorkerThreads[code]?.Actions.Add(() => mapping.Callback(state)); } else { - if (isKeyMapping) - { - WorkerThreads[code]?.Actions.Add(() => mapping.Callback(state)); - } - else - { - DeviceWorkerThread?.Actions.Add(() => mapping.Callback(code, state)); - } + DeviceWorkerThread?.Actions.Add(() => mapping.Callback(code, state)); } } @@ -127,8 +110,12 @@ namespace AutoHotInterception.DeviceHandlers // ... then set the Context before sending the key if (!hasSubscription && hasContext) ContextCallback(1); - // Pass the key through to the OS. - ManagedWrapper.Send(DeviceContext, DeviceId, ref stroke, 1); + // Pass the key(s) through to the OS. + for (int i = 0; i < strokes.Count; i++) + { + var stroke = strokes[i]; + ManagedWrapper.Send(DeviceContext, DeviceId, ref stroke, 1); + } // If we are processing Context Mode, then Unset the context variable after sending the key if (!hasSubscription && hasContext) ContextCallback(0); diff --git a/C#/AutoHotInterception/DeviceHandlers/MouseHandler.cs b/C#/AutoHotInterception/DeviceHandlers/MouseHandler.cs index 3393a02..63a3120 100644 --- a/C#/AutoHotInterception/DeviceHandlers/MouseHandler.cs +++ b/C#/AutoHotInterception/DeviceHandlers/MouseHandler.cs @@ -1,5 +1,5 @@ using System; -using System.Collections.Concurrent; +using System.Collections.Generic; using System.Threading; using AutoHotInterception.Helpers; @@ -45,7 +45,6 @@ namespace AutoHotInterception.DeviceHandlers if (!mappingOptions.Concurrent && !WorkerThreads.ContainsKey(7)) { WorkerThreads.TryAdd(7, new WorkerThread()); - WorkerThreads[7].Start(); } _isFiltered = true; } @@ -75,7 +74,6 @@ namespace AutoHotInterception.DeviceHandlers if (!mappingOptions.Concurrent && !WorkerThreads.ContainsKey(8)) { WorkerThreads.TryAdd(8, new WorkerThread()); - WorkerThreads[8].Start(); } _isFiltered = true; } @@ -153,215 +151,220 @@ namespace AutoHotInterception.DeviceHandlers } #endregion - public override void ProcessStroke(ManagedWrapper.Stroke stroke) + public override void ProcessStroke(List strokes) { - var hasSubscription = false; - var hasContext = ContextCallback != null; - - var moveRemoved = false; - var hasMove = false; - - var x = stroke.mouse.x; - var y = stroke.mouse.y; - - // Process mouse movement - var isAbsolute = (stroke.mouse.flags & (ushort)ManagedWrapper.MouseFlag.MouseMoveAbsolute) == - (ushort)ManagedWrapper.MouseFlag.MouseMoveAbsolute; - //Determine whether or not to report mouse movement. - // For Relative mode, this is fairly simple - if x and y are both 0, no movement was reported (Since a real mouse never reports x=0/y=0) - // For Absolute mode, x=0/y=0 is reported, but we should limit this to only reporting once... - // ... so when x=0/y=0 is seen in absolute mode, set the flag _absoluteMode00Reported to true and allow it to be reported... - // then on subsequent reports of x=0/y=0 for absolute mode, if _absoluteMode00Reported is already true, then do not report movement... - // ... In absolute mode, when x!=0/y!=0 is received, clear the _absoluteMode00Reported flag - if (isAbsolute) + for (int i = 0; i < strokes.Count; i++) { - if (x == 0 && y == 0) + var stroke = strokes[i]; + var hasSubscription = false; + var hasContext = ContextCallback != null; + + var moveRemoved = false; + var hasMove = false; + + var x = stroke.mouse.x; + var y = stroke.mouse.y; + + // Process mouse movement + var isAbsolute = (stroke.mouse.flags & (ushort)ManagedWrapper.MouseFlag.MouseMoveAbsolute) == + (ushort)ManagedWrapper.MouseFlag.MouseMoveAbsolute; + //Determine whether or not to report mouse movement. + // For Relative mode, this is fairly simple - if x and y are both 0, no movement was reported (Since a real mouse never reports x=0/y=0) + // For Absolute mode, x=0/y=0 is reported, but we should limit this to only reporting once... + // ... so when x=0/y=0 is seen in absolute mode, set the flag _absoluteMode00Reported to true and allow it to be reported... + // then on subsequent reports of x=0/y=0 for absolute mode, if _absoluteMode00Reported is already true, then do not report movement... + // ... In absolute mode, when x!=0/y!=0 is received, clear the _absoluteMode00Reported flag + if (isAbsolute) { - if (!_absoluteMode00Reported) + if (x == 0 && y == 0) { - hasMove = true; - _absoluteMode00Reported = true; + if (!_absoluteMode00Reported) + { + hasMove = true; + _absoluteMode00Reported = true; + } + else + { + hasMove = false; + } } else { - hasMove = false; + hasMove = true; + _absoluteMode00Reported = false; } } else { - hasMove = true; - _absoluteMode00Reported = false; + hasMove = (x != 0 || y != 0); } - } - else - { - hasMove = (x != 0 || y != 0); - } - if (hasMove) - { - // Process Absolute Mouse Move - if (isAbsolute) + if (hasMove) { - if (_mouseMoveAbsoluteMapping != null) + // Process Absolute Mouse Move + if (isAbsolute) { - var mapping = _mouseMoveAbsoluteMapping; - hasSubscription = true; - //var debugStr = $"AHK| Mouse stroke has absolute move of {x}, {y}..."; - - if (mapping.Concurrent) - ThreadPool.QueueUserWorkItem(threadProc => mapping.Callback(x, y)); - else if (WorkerThreads.ContainsKey(7)) - WorkerThreads[7]?.Actions.Add(() => mapping.Callback(x, y)); - if (mapping.Block) + if (_mouseMoveAbsoluteMapping != null) { - moveRemoved = true; - stroke.mouse.x = 0; - stroke.mouse.y = 0; - //debugStr += "Blocking"; - } - else - { - //debugStr += "Not Blocking"; - } + var mapping = _mouseMoveAbsoluteMapping; + hasSubscription = true; + //var debugStr = $"AHK| Mouse stroke has absolute move of {x}, {y}..."; + + if (mapping.Concurrent) + ThreadPool.QueueUserWorkItem(threadProc => mapping.Callback(x, y)); + else if (WorkerThreads.ContainsKey(7)) + WorkerThreads[7]?.Actions.Add(() => mapping.Callback(x, y)); + if (mapping.Block) + { + moveRemoved = true; + stroke.mouse.x = 0; + stroke.mouse.y = 0; + //debugStr += "Blocking"; + } + else + { + //debugStr += "Not Blocking"; + } - //Debug.WriteLine(debugStr); + //Debug.WriteLine(debugStr); + } } - } - else - { - if (_mouseMoveRelativeMapping != null) + else { - var mapping = _mouseMoveRelativeMapping; - hasSubscription = true; - //var debugStr = $"AHK| Mouse stroke has relative move of {x}, {y}..."; - - if (mapping.Concurrent) - ThreadPool.QueueUserWorkItem(threadProc => mapping.Callback(x, y)); - else if (WorkerThreads.ContainsKey(8)) - WorkerThreads[8]?.Actions.Add(() => mapping.Callback(x, y)); - if (mapping.Block) + if (_mouseMoveRelativeMapping != null) { - moveRemoved = true; - stroke.mouse.x = 0; - stroke.mouse.y = 0; - //debugStr += "Blocking"; - } - else - { - //debugStr += "Not Blocking"; - } + var mapping = _mouseMoveRelativeMapping; + hasSubscription = true; + //var debugStr = $"AHK| Mouse stroke has relative move of {x}, {y}..."; + + if (mapping.Concurrent) + ThreadPool.QueueUserWorkItem(threadProc => mapping.Callback(x, y)); + else if (WorkerThreads.ContainsKey(8)) + WorkerThreads[8]?.Actions.Add(() => mapping.Callback(x, y)); + if (mapping.Block) + { + moveRemoved = true; + stroke.mouse.x = 0; + stroke.mouse.y = 0; + //debugStr += "Blocking"; + } + else + { + //debugStr += "Not Blocking"; + } - //Debug.WriteLine(debugStr); + //Debug.WriteLine(debugStr); + } } - } - } + } - var isMouseButtonsMapping = AllButtonsMapping != null; + var isMouseButtonsMapping = AllButtonsMapping != null; - // Process Mouse Buttons - do this AFTER mouse movement, so that absolute mode has coordinates available at the point that the button callback is fired - if (stroke.mouse.state != 0 && SingleButtonMappings.Count > 0 || isMouseButtonsMapping) - { - var btnStates = HelperFunctions.MouseStrokeToButtonStates(stroke); - foreach (var btnState in btnStates) + // Process Mouse Buttons - do this AFTER mouse movement, so that absolute mode has coordinates available at the point that the button callback is fired + if (stroke.mouse.state != 0 && SingleButtonMappings.Count > 0 || isMouseButtonsMapping) { - if (!isMouseButtonsMapping && !SingleButtonMappings.ContainsKey(btnState.Button)) - continue; - - hasSubscription = true; - MappingOptions mapping = null; - if (isMouseButtonsMapping) - { - mapping = AllButtonsMapping; - } - else + var btnStates = HelperFunctions.MouseStrokeToButtonStates(stroke); + foreach (var btnState in btnStates) { - mapping = SingleButtonMappings[btnState.Button]; - } + if (!isMouseButtonsMapping && !SingleButtonMappings.ContainsKey(btnState.Button)) + continue; - var state = btnState; - - if (mapping.Concurrent) - { + hasSubscription = true; + MappingOptions mapping = null; if (isMouseButtonsMapping) { - ThreadPool.QueueUserWorkItem(threadProc => - mapping.Callback(btnState.Button, state.State)); + mapping = AllButtonsMapping; } else { - ThreadPool.QueueUserWorkItem(threadProc => mapping.Callback(state.State)); + mapping = SingleButtonMappings[btnState.Button]; } - } - else - { - if (isMouseButtonsMapping) + + var state = btnState; + + if (mapping.Concurrent) { - DeviceWorkerThread?.Actions - .Add(() => mapping.Callback(btnState.Button, state.State)); + if (isMouseButtonsMapping) + { + ThreadPool.QueueUserWorkItem(threadProc => + mapping.Callback(btnState.Button, state.State)); + } + else + { + ThreadPool.QueueUserWorkItem(threadProc => mapping.Callback(state.State)); + } } else { - WorkerThreads[btnState.Button]?.Actions - .Add(() => mapping.Callback(state.State)); + if (isMouseButtonsMapping) + { + DeviceWorkerThread?.Actions + .Add(() => mapping.Callback(btnState.Button, state.State)); + } + else + { + WorkerThreads[btnState.Button]?.Actions + .Add(() => mapping.Callback(state.State)); + } } - } - if (mapping.Block) - { - // Remove the event for this button from the stroke, leaving other button events intact - stroke.mouse.state -= btnState.Flag; - // If we are removing a mouse wheel event, then set rolling to 0 if no mouse wheel event left - if (btnState.Flag == 0x400 || btnState.Flag == 0x800) + if (mapping.Block) { - if ((stroke.mouse.state & 0x400) != 0x400 && - (stroke.mouse.state & 0x800) != 0x800) + // Remove the event for this button from the stroke, leaving other button events intact + stroke.mouse.state -= btnState.Flag; + // If we are removing a mouse wheel event, then set rolling to 0 if no mouse wheel event left + if (btnState.Flag == 0x400 || btnState.Flag == 0x800) { - //Debug.WriteLine("AHK| Removing rolling flag from stroke"); - stroke.mouse.rolling = 0; + if ((stroke.mouse.state & 0x400) != 0x400 && + (stroke.mouse.state & 0x800) != 0x800) + { + //Debug.WriteLine("AHK| Removing rolling flag from stroke"); + stroke.mouse.rolling = 0; + } } + + //Debug.WriteLine($"AHK| Removing flag {btnState.Flag} from stoke, leaving state {stroke.mouse.state}"); + } + else + { + //Debug.WriteLine($"AHK| Leaving flag {btnState.Flag} in stroke"); } + } + } - //Debug.WriteLine($"AHK| Removing flag {btnState.Flag} from stoke, leaving state {stroke.mouse.state}"); + // Forward on the stroke if required + if (hasSubscription) + { + // Subscription mode + // If the stroke has a move that was not removed, OR it has remaining button events, then forward on the stroke + if ((hasMove && !moveRemoved) || stroke.mouse.state != 0) + { + //Debug.WriteLine($"AHK| Sending stroke. State = {stroke.mouse.state}. hasMove={hasMove}, moveRemoved={moveRemoved}"); + ManagedWrapper.Send(DeviceContext, DeviceId, ref stroke, 1); } else { - //Debug.WriteLine($"AHK| Leaving flag {btnState.Flag} in stroke"); + // Everything removed from stroke, do not forward + //Debug.WriteLine("AHK| Mouse stroke now empty, not forwarding"); } } - } - - // Forward on the stroke if required - if (hasSubscription) - { - // Subscription mode - // If the stroke has a move that was not removed, OR it has remaining button events, then forward on the stroke - if ((hasMove && !moveRemoved) || stroke.mouse.state != 0) + else if (hasContext) { - //Debug.WriteLine($"AHK| Sending stroke. State = {stroke.mouse.state}. hasMove={hasMove}, moveRemoved={moveRemoved}"); + // Context Mode - forward stroke with context wrapping + ContextCallback(1); ManagedWrapper.Send(DeviceContext, DeviceId, ref stroke, 1); + ContextCallback(0); } else { - // Everything removed from stroke, do not forward - //Debug.WriteLine("AHK| Mouse stroke now empty, not forwarding"); + // No subscription or context mode - forward on + //Debug.WriteLine($"AHK| Sending stroke. State = {stroke.mouse.state}. hasMove={hasMove}, moveRemoved={moveRemoved}"); + ManagedWrapper.Send(DeviceContext, DeviceId, ref stroke, 1); } } - else if (hasContext) - { - // Context Mode - forward stroke with context wrapping - ContextCallback(1); - ManagedWrapper.Send(DeviceContext, DeviceId, ref stroke, 1); - ContextCallback(0); - } - else - { - // No subscription or context mode - forward on - //Debug.WriteLine($"AHK| Sending stroke. State = {stroke.mouse.state}. hasMove={hasMove}, moveRemoved={moveRemoved}"); - ManagedWrapper.Send(DeviceContext, DeviceId, ref stroke, 1); - } + } } } diff --git a/C#/AutoHotInterception/Helpers/ScanCodeHelper.cs b/C#/AutoHotInterception/Helpers/ScanCodeHelper.cs new file mode 100644 index 0000000..45803e4 --- /dev/null +++ b/C#/AutoHotInterception/Helpers/ScanCodeHelper.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using static AutoHotInterception.Helpers.ManagedWrapper; + +namespace AutoHotInterception.Helpers +{ + // Order of the strokes received + public enum Order + { + Normal, // Stroke order is Key press, Key release (No Extended Modifier) + Wrapped, // Stroke order is Ext Modifier press, Key press, Key release, Ext Modifier Release + Prefixed // Stroke order is Ext Modifier press, Key press, Ext Modifier release, Key release + }; + + public static class ScanCodeHelper + { + // Converts Interception state to AHK state + private static List _stateConverter = new List() { 1, 0, 1, 0, 1, 0 }; + // Converts state to extended mode + private static List _stateToExtendedMode = new List() { 0, 0, 1, 1, 2, 2 }; + + // Keys which have an E0 state, but AHK uses High (+256) code + private static HashSet _highCodeE0Keys = new HashSet() + { + 28, // Numpad Enter + 54, // Right Shift + 69, // Numlock + }; + + // Keys which have an E0 state, but have no extended modifier + private static HashSet _e1Keys = new HashSet() + { + 29, // Right Control + 53, // Numpad Div + 56, // Right Alt + 91, // Left Windows + 92, // Right Windows + 93, // Apps + }; + + // List of two-stroke keys, used to build _twoStrokeKeyConverter + // Also used by SendKeyEvent to work out what extended keys to send + public static readonly Dictionary _twoStrokeKeys = new Dictionary() + { + { 55, Order.Wrapped }, // PrtScr + { 69, Order.Prefixed }, // Pause + { 71, Order.Wrapped }, // Home + { 72, Order.Wrapped }, // Up + { 73, Order.Wrapped }, // PgUp + { 75, Order.Wrapped }, // Left + { 77, Order.Wrapped }, // Right + { 79, Order.Wrapped }, // End + { 80, Order.Wrapped }, // Down + { 81, Order.Wrapped }, // PgDn + { 82, Order.Wrapped }, // Insert + { 83, Order.Wrapped }, // Delete + }; + + // Lookup table to convert two-stroke keys to code and state + private static Dictionary, TranslatedKey> + _twoStrokeKeyConverter = new Dictionary, TranslatedKey>(); + + static ScanCodeHelper() + { + foreach (var item in _twoStrokeKeys) + { + var twoStrokeKey = new TwoStrokeKey(item.Key, item.Value); + _twoStrokeKeyConverter.Add(twoStrokeKey.PressTuple, twoStrokeKey.PressKey); + _twoStrokeKeyConverter.Add(twoStrokeKey.ReleaseTuple, twoStrokeKey.ReleaseKey); + } + } + + public static bool IsDoubleScanCode(List strokes) + { + return _twoStrokeKeyConverter.ContainsKey(new Tuple(strokes[0].key.code, strokes[0].key.state, strokes[1].key.code, strokes[1].key.state)); + } + + /// + /// Used by ProcessStrokes() KeyboardHandler to translate incoming key(s) from Interception to AHK format + /// + /// A list of one or two Strokes that describe a single key + /// An AHK ScanCode and State + public static TranslatedKey TranslateScanCodes(List strokes) + { + if (strokes.Count == 2) + { + return _twoStrokeKeyConverter[ + new Tuple(strokes[0].key.code, strokes[0].key.state, strokes[1].key.code, strokes[1].key.state)]; + } + else if (strokes.Count == 1) + { + var stroke = strokes[0]; + var code = stroke.key.code; + var state = _stateConverter[stroke.key.state]; + if (_highCodeE0Keys.Contains(stroke.key.code)) + { + // Stroke is E0, but AHK code is High (+256) + code += 256; + } + else + { + if (_stateToExtendedMode[stroke.key.state] > 0) + { + // Stroke is E1 or E2 + code += 256; + } + } + return new TranslatedKey(code, state); + } + else + { + throw new Exception($"Expected 1 or 2 strokes, but got {strokes.Count}"); + } + } + + /// + /// Used by SendKeyEvent() in KeyboardHandler to translate from AHK code / state into Interception Stroke(s) + /// + /// The AHK code of the key + /// The AH< state of the key/param> + /// A list of Strokes to send to simulate this key being pressed + public static List TranslateAhkCode(ushort code, int ahkState) + { + var strokes = new List(); + Order order; + ushort state = (ushort)(1 - ahkState); + var wasHighCode = false; + if (code > 256) + { + wasHighCode = true; + code -= 256; + if (_highCodeE0Keys.Contains(code) || _e1Keys.Contains(code)) + { + order = Order.Normal; + } + else if (_twoStrokeKeys.ContainsKey(code)) + { + order = _twoStrokeKeys[code]; + } + else + { + throw new Exception($"Do not know how to handle ScanCode of {code}"); + } + } + else if (code == 69) + { + order = Order.Prefixed; + } + else + { + order = Order.Normal; + } + + if (wasHighCode && _e1Keys.Contains(code)) + { + state += 2; + } + else + { + state += (ushort)((ushort)order * 2); + } + + if (order == Order.Normal) + { + strokes.Add(new Stroke() { key = { code = code, state = state } }); + } + else if (order == Order.Wrapped) + { + // Wrapped (E1) + if (ahkState == 1) + { + // Press + strokes.Add(new Stroke() { key = { code = 42, state = state } }); + strokes.Add(new Stroke() { key = { code = code, state = state } }); + } + else + { + // Release + strokes.Add(new Stroke() { key = { code = code, state = state } }); + strokes.Add(new Stroke() { key = { code = 42, state = state } }); + } + } + else + { + // Prefixed (E2) + if (ahkState == 1) + { + // Press + strokes.Add(new Stroke() { key = { code = 29, state = state } }); + strokes.Add(new Stroke() { key = { code = code, state = state } }); + } + else + { + // Release + strokes.Add(new Stroke() { key = { code = 29, state = state } }); + strokes.Add(new Stroke() { key = { code = code, state = state } }); + } + } + + return strokes; + } + + } + + // Holds the AHK code and state equivalent of a one or two-stroke set + public class TranslatedKey + { + public ushort Code { get; } + public int State { get; } + + public TranslatedKey(ushort code, int state) + { + Code = code; + State = state; + } + } + + // Builds entries for _twoStrokeKeyConverter + public class TwoStrokeKey + { + public Tuple PressTuple { get; } + public Tuple ReleaseTuple { get; } + public TranslatedKey PressKey { get; } + public TranslatedKey ReleaseKey { get; } + + public TwoStrokeKey(ushort code, Order order) + { + if (order == Order.Prefixed) + { + PressTuple = new Tuple(29, 4, code, 0); + ReleaseTuple = new Tuple(29, 5, code, 1); + PressKey = new TranslatedKey(code, 1); + ReleaseKey = new TranslatedKey(code, 0); + } + else if (order == Order.Wrapped) + { + PressTuple = new Tuple(42, 2, code, 2); + ReleaseTuple = new Tuple(code, 3, 42, 3); + code += 256; + PressKey = new TranslatedKey(code, 1); + ReleaseKey = new TranslatedKey(code, 0); + } + else + { + throw new Exception("Is not a two-stroke key"); + } + } + } +} diff --git a/C#/AutoHotInterception/KeyCode notes.xlsx b/C#/AutoHotInterception/KeyCode notes.xlsx index 749caa0..6cc9c33 100644 Binary files a/C#/AutoHotInterception/KeyCode notes.xlsx and b/C#/AutoHotInterception/KeyCode notes.xlsx differ diff --git a/C#/AutoHotInterception/Manager.cs b/C#/AutoHotInterception/Manager.cs index 48940dd..3a6a500 100644 --- a/C#/AutoHotInterception/Manager.cs +++ b/C#/AutoHotInterception/Manager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics; using System.Threading; using AutoHotInterception.DeviceHandlers; @@ -518,21 +519,63 @@ namespace AutoHotInterception state ? ManagedWrapper.Filter.All : ManagedWrapper.Filter.None); } + private static string RenderStroke(ManagedWrapper.Stroke stroke) + { + return $"key code: {stroke.key.code}, key state: {stroke.key.state}, mouse x/y: {stroke.mouse.x}, {stroke.mouse.y}"; + } + private static void PollThread(object obj) { var token = (CancellationToken)obj; //Debug.WriteLine($"AHK| Poll Thread Started"); _pollThreadRunning = true; - var stroke = new ManagedWrapper.Stroke(); - int i; + int stroke1DeviceId; + int stroke2DeviceId; while (!token.IsCancellationRequested) { - // While no input happens, this loop will exit every 1ms to allow us to check if cancellation has been requested + var stroke = new ManagedWrapper.Stroke(); + // While no input happens, this loop will exit every 10ms to allow us to check if cancellation has been requested // WaitWithTimeout is used with a timeout of 10ms instead of Wait, so that when we eg use SetState to turn the thread off... // ... any input which was filtered and is waiting to be processed can be processed (eg lots of mouse moves buffered) - while (ManagedWrapper.Receive(DeviceContext, i = ManagedWrapper.WaitWithTimeout(DeviceContext, 10), ref stroke, 1) > 0) + if (ManagedWrapper.Receive(DeviceContext, stroke1DeviceId = ManagedWrapper.WaitWithTimeout(DeviceContext, 10), ref stroke, 1) > 0) { - DeviceHandlers[i].ProcessStroke(stroke); + var strokes = new List(); + strokes.Add(stroke); + if (stroke1DeviceId < 11) + { + //Debug.WriteLine($"Stroke 1: {RenderStroke(stroke)}"); + // If this is a keyboard stroke, then keep performing more Receives immediately with a timeout of 0... + // ... this is to check whether an extended stroke is waiting. + // Unfortunately, at this point, it's entirely possible that two single-stroke keys end up in strokes... + // ... or even 3 strokes or more (eg one single-stroke key followed by a two-stroke key) + //while ((stroke2DeviceId = ManagedWrapper.WaitWithTimeout(DeviceContext, 0)) == stroke1DeviceId) + while ((stroke2DeviceId = ManagedWrapper.WaitWithTimeout(DeviceContext, 0)) != 0) + { + ManagedWrapper.Receive(DeviceContext, stroke2DeviceId, ref stroke, 1); + strokes.Add(stroke); + //Debug.WriteLine($"Stroke {strokes.Count}: {RenderStroke(stroke)}"); + } + + // Loop through the list checking the first 2 indexes for valid "two-code" key combinations. + // If no combo is found, send index 0 on its way, remove it off the top of the list, repeat + while (strokes.Count > 0) + { + if (strokes.Count >= 2 && ScanCodeHelper.IsDoubleScanCode(new List { strokes[0], strokes[1] })) + { + DeviceHandlers[stroke1DeviceId].ProcessStroke(new List { strokes[0], strokes[1] }); + strokes.RemoveRange(0, 2); + } + else + { + DeviceHandlers[stroke1DeviceId].ProcessStroke(new List { strokes[0] }); + strokes.RemoveAt(0); + } + } + } + else + { + DeviceHandlers[stroke1DeviceId].ProcessStroke(strokes); + } } } _pollThreadRunning = false; diff --git a/C#/AutoHotInterception/ScanCodeChecker.cs b/C#/AutoHotInterception/ScanCodeChecker.cs index c33052d..48f0a02 100644 --- a/C#/AutoHotInterception/ScanCodeChecker.cs +++ b/C#/AutoHotInterception/ScanCodeChecker.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; using AutoHotInterception.Helpers; namespace AutoHotInterception @@ -8,12 +10,13 @@ namespace AutoHotInterception Tool to check Scan Codes and Press / Release states Note that these are raw scancodes and states as they come from Interception. Some keys (eg extended code keys) will not match AHK key codes! */ - public class ScanCodeChecker + public class ScanCodeChecker : IDisposable { private readonly IntPtr _deviceContext; private dynamic _callback; private int _deviceId; private bool _block; + private Thread _pollThread; public ScanCodeChecker() { @@ -26,13 +29,47 @@ namespace AutoHotInterception _deviceId = deviceId; _block = block; + _pollThread = new Thread(PollThread); + _pollThread.Start(); + } + + private void PollThread() + { ManagedWrapper.SetFilter(_deviceContext, IsMonitoredDevice, ManagedWrapper.Filter.All); - int i; - var stroke = new ManagedWrapper.Stroke(); - while (ManagedWrapper.Receive(_deviceContext, i = ManagedWrapper.Wait(_deviceContext), ref stroke, 1) > 0) + int deviceId1; + int deviceId2; + var stroke1 = new ManagedWrapper.Stroke(); + var stroke2 = new ManagedWrapper.Stroke(); + while (true) { - if (!_block) ManagedWrapper.Send(_deviceContext, _deviceId, ref stroke, 1); - _callback(new KeyEvent { Code = stroke.key.code, State = stroke.key.state }); + var strokes = new List(); + if (ManagedWrapper.Receive(_deviceContext, deviceId1 = ManagedWrapper.WaitWithTimeout(_deviceContext, 10), ref stroke1, 1) > 0) + { + strokes.Add(stroke1); + if (deviceId1 < 11) + { + if (ManagedWrapper.Receive(_deviceContext, deviceId2 = ManagedWrapper.WaitWithTimeout(_deviceContext, 0), ref stroke2, 1) > 0) + { + strokes.Add(stroke2); + } + } + if (!_block) + { + for (int i = 0; i < strokes.Count; i++) + { + var stroke = strokes[i]; + ManagedWrapper.Send(_deviceContext, _deviceId, ref stroke, 1); + } + } + // Use array for callback, as the callback may be AHK code, and dealing with arrays in AHK is way simpler that Lists + var keyEvents = new KeyEvent[strokes.Count]; + for (int i = 0; i < strokes.Count; i++) + { + var s = strokes[i]; + keyEvents[i] = new KeyEvent { Code = s.key.code, State = s.key.state }; + } + _callback(keyEvents); + } } } @@ -43,7 +80,13 @@ namespace AutoHotInterception private int IsMonitoredDevice(int device) { - return Convert.ToInt32(_deviceId == device); + return (Convert.ToInt32(_deviceId == device) ); + } + + public void Dispose() + { + _pollThread.Abort(); + _pollThread.Join(); } } diff --git a/C#/AutoHotInterception/ToDo.md b/C#/AutoHotInterception/ToDo.md index b44dd05..5c20510 100644 --- a/C#/AutoHotInterception/ToDo.md +++ b/C#/AutoHotInterception/ToDo.md @@ -1,23 +1,15 @@ # Fix issues with Numlock/Pause BlueChipps in AHI Discord has a proposed fix [here](https://github.com/bluechipps/AutoHotInterception/tree/pause_numlock_support) -# Poor coding of extended keys -Currently, there is an `.ignore` flag on `KeyboardState` -When an extended code comes in (eg extended Shift or Ctrl), then the key may be ignored and `block` could never be set -so the extended modifier will always be passed through. -Implement better system: -* When an extended modifier is seen, add it to a buffer -* When the next key comes in, check if it is subscribed and blocked -* If it is, then purge the buffer -* If it is not, play back the buffered extended key code and then play back the unblocked key - -Del is a good example: -If Del (Above arrow keys, not on Numpad) is pressed while NumLock is off, it sends an Extended LCtrl, then the Pause Key - # Send before firing callback in Subscription Mode? If trying to write a system like AHK's hotstrings, but using AHI, if you had subscriptions with block set to false, you would want the key to be sent before firing the callback. That way, if you intended to replace `btw` with `By the way`, when the callback fired for `w`, the `w` would already be in the text editor. As it stands, when the callback for `w` fired, the `w` would not bein the text editor, and after AHI sent the `w` -(After firing the callback), you would have no chance to delete the `w` (So you would end up with `by the wayw` in the text editor) \ No newline at end of file +(After firing the callback), you would have no chance to delete the `w` (So you would end up with `by the wayw` in the text editor) + +# Disablling filter whilst sub or unsub is happening +When a Subscribe or Unsubscribe call is made, the filter is turned off before the sub/unsub, and then turned on again afterwards. +This seems un-necesarry, and could result in some blocked input "leaking" through. +Consider removing this \ No newline at end of file diff --git a/C#/AutoHotInterception/WorkerThread.cs b/C#/AutoHotInterception/WorkerThread.cs index d073aed..473a318 100644 --- a/C#/AutoHotInterception/WorkerThread.cs +++ b/C#/AutoHotInterception/WorkerThread.cs @@ -1,8 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; @@ -10,40 +7,32 @@ namespace AutoHotInterception { class WorkerThread : IDisposable { - private readonly Thread _worker; - private volatile bool _running; + private Task _worker; + private CancellationTokenSource _cancellationToken; + public BlockingCollection Actions { get; } public WorkerThread() { Actions = new BlockingCollection(); - _worker = new Thread(Run); - _running = false; - } - - public BlockingCollection Actions { get; } - - public void Dispose() - { - if (!_running) return; - _running = false; - _worker.Abort(); - _worker.Join(); - } - - public void Start() - { - if (_running) return; - _running = true; + _cancellationToken = new CancellationTokenSource(); + _worker = new Task(Run, _cancellationToken.Token); _worker.Start(); } - private void Run() + private void Run(Object obj) { - while (_running) + var token = (CancellationToken)obj; + while (!token.IsCancellationRequested) { var action = Actions.Take(); action.Invoke(); } } + + public void Dispose() + { + _cancellationToken.Cancel(); + _cancellationToken.Dispose(); + } } } diff --git a/C#/TestApp/Helpers/AhkKeys.cs b/C#/TestApp/Helpers/AhkKeys.cs index b0239ed..c63e67c 100644 --- a/C#/TestApp/Helpers/AhkKeys.cs +++ b/C#/TestApp/Helpers/AhkKeys.cs @@ -150,7 +150,7 @@ namespace TestApp.Helpers {81 /*(0x51)*/, "NumpadPgDn"}, {82 /*(0x52)*/, "NumpadIns"}, {83 /*(0x53)*/, "NumpadDel"}, - {84 /*(0x54)*/, "PrintScreen"}, + //{84 /*(0x54)*/, "PrintScreen"}, Use 311 instead {86 /*(0x56)*/, "\\"}, {87 /*(0x57)*/, "F11"}, {88 /*(0x58)*/, "F12"}, @@ -180,6 +180,7 @@ namespace TestApp.Helpers {306 /*(0x132)*/, "Browser_Home"}, {309 /*(0x135)*/, "NumpadDiv"}, {310 /*(0x136)*/, "RShift"}, + {311 /*(0x136)*/, "PrintScreen"}, // Note: GetKeySC("PrintScreen") yields 311, but GetKeyName("sc54") (Dec of 84) yields "PrintScreen" {312 /*(0x138)*/, "RAlt"}, {325 /*(0x145)*/, "Numlock"}, {326 /*(0x146)*/, "CtrlBreak"}, diff --git a/C#/TestApp/TestDevices.cs b/C#/TestApp/Helpers/TestDevices.cs similarity index 92% rename from C#/TestApp/TestDevices.cs rename to C#/TestApp/Helpers/TestDevices.cs index 7ace67a..44c051e 100644 --- a/C#/TestApp/TestDevices.cs +++ b/C#/TestApp/Helpers/TestDevices.cs @@ -12,6 +12,7 @@ namespace TestApp public static TestDevice WyseKeyboard { get; } = new TestDevice { IsMouse = false, Vid = 0x04F2, Pid = 0x0112 }; public static TestDevice LogitechWheelMouse { get; } = new TestDevice { IsMouse = true, Vid = 0x046D, Pid = 0xC00C }; public static TestDevice ParbloIslandA609 { get; } = new TestDevice { IsMouse = true, Handle = "HID\\VID_0B57&PID_9091&REV_0101&Col01" }; + public static TestDevice LogitechG604Mouse { get; } = new TestDevice { IsMouse = true, Vid = 0x046D, Pid = 0xC539 }; } public class TestDevice diff --git a/C#/TestApp/KeyboardAndMouseTester.cs b/C#/TestApp/KeyboardAndMouseTester.cs new file mode 100644 index 0000000..3280e34 --- /dev/null +++ b/C#/TestApp/KeyboardAndMouseTester.cs @@ -0,0 +1,82 @@ +using AutoHotInterception; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using TestApp.Helpers; + +namespace TestApp +{ + /// + /// Allows testing of Keyboard all keys and mouse movement at the same time + /// + public class KeyboardAndMouseTester + { + private Manager im = new Manager(); + private Dictionary _deviceStates = new Dictionary(); + private Dictionary _blockingEnabled = new Dictionary(); + + public KeyboardAndMouseTester(TestDevice testDevice, bool block = false) + { + AddDevice(testDevice, block); + } + + public KeyboardAndMouseTester AddDevice(TestDevice testDevice, bool block = false) + { + var devId = testDevice.GetDeviceId(); + + if (devId == 0) return this; + _blockingEnabled[devId] = block; + SetDeviceState(devId, true); + return this; + } + + private void SetDeviceState(int devId, bool state) + { + if (devId < 11) + { + if (state) + { + im.SubscribeKeyboard(devId, _blockingEnabled[devId], new Action((code, value) => + { + var keyObj = AhkKeys.Obj(code); + + Console.WriteLine($"Name: {keyObj.Name}, Code: {keyObj.LogCode()}, State: {value}"); + })); + } + else + { + im.UnsubscribeKeyboard(devId); + } + } + else + { + if (state) + { + im.SubscribeMouseMove(devId, _blockingEnabled[devId], new Action((x, y) => + { + Console.WriteLine($"Mouse Move: x: {x}, y: {y}"); + })); + } + else + { + im.UnsubscribeMouseMove(devId); + } + } + _deviceStates[devId] = state; + } + + // Allows toggling on and off of keyboard subscription whilst mouse sub active + public void Toggle(TestDevice testDevice) + { + var devId = testDevice.GetDeviceId(); + while (true) + { + Console.WriteLine($"Subscribe: {_deviceStates[devId]} (Enter to toggle)"); + Console.ReadLine(); + SetDeviceState(devId, !_deviceStates[devId]); + } + } + } +} diff --git a/C#/TestApp/Program.cs b/C#/TestApp/Program.cs index ccbe7ee..ac4a1ef 100644 --- a/C#/TestApp/Program.cs +++ b/C#/TestApp/Program.cs @@ -1,5 +1,5 @@ using System; -using AutoHotInterception; +using TestApp.Helpers; namespace TestApp { @@ -7,13 +7,15 @@ namespace TestApp { private static void Main() { - var mmt = new MouseMoveTester(TestDevices.LogitechWheelMouse); + //var mmt = new MouseMoveTester(TestDevices.LogitechWheelMouse); //var mbt = new MouseButtonTester(TestDevices.LogitechWheelMouse, MouseButtons.Left, true); //var ambt = new MouseButtonsTester(TestDevices.LogitechWheelMouse, true); //var kt = new KeyboardTester(TestDevices.WyseKeyboard, true); + var kmt = new KeyboardAndMouseTester(TestDevices.WyseKeyboard, true).AddDevice(TestDevices.LogitechWheelMouse, true); + kmt.Toggle(TestDevices.WyseKeyboard); //var kkt = new KeyboardKeyTester(TestDevices.WyseKeyboard, AhkKeys.Obj("1"), true); //var tt = new TabletTester(TestDevices.ParbloIslandA609); - //var sct = new ScanCodeTester(TestDevices.WyseKeyboard, true); + //var scc = new ScanCodeTester(TestDevices.WyseKeyboard, true); //var sst = new SetStateTester(TestDevices.WyseKeyboard, AhkKeys.Obj("1")); Console.ReadLine(); } diff --git a/C#/TestApp/ScanCodeTester.cs b/C#/TestApp/ScanCodeTester.cs index cbac41d..a2af3af 100644 --- a/C#/TestApp/ScanCodeTester.cs +++ b/C#/TestApp/ScanCodeTester.cs @@ -1,22 +1,34 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using AutoHotInterception; namespace TestApp { - public class ScanCodeTester + public class ScanCodeTester : IDisposable { + private ScanCodeChecker scc; public ScanCodeTester(TestDevice device, bool block = false) { - var scc = new ScanCodeChecker(); + scc = new ScanCodeChecker(); var devId = device.GetDeviceId(); if (devId == 0) return; - scc.Subscribe(devId, new Action(OnKeyEvent), block); + scc.Subscribe(devId, new Action(OnKeyEvent), block); } - public void OnKeyEvent(KeyEvent keyEvent) + public void Dispose() { - Debug.WriteLine($"Code: {keyEvent.Code} (0x{keyEvent.Code.ToString("X")}) - {keyEvent.Code + 256}, State: {keyEvent.State}"); + scc.Dispose(); + } + + public void OnKeyEvent(KeyEvent[] keyEvents) + { + var str = ""; + foreach (var keyEvent in keyEvents) + { + str += $"Code: {keyEvent.Code} (0x{keyEvent.Code.ToString("X")}) - {keyEvent.Code + 256}, State: {keyEvent.State} | "; + } + Debug.WriteLine(str); } } } diff --git a/C#/TestApp/TestApp.csproj b/C#/TestApp/TestApp.csproj index 5af8b14..3698f00 100644 --- a/C#/TestApp/TestApp.csproj +++ b/C#/TestApp/TestApp.csproj @@ -44,8 +44,9 @@ + - + diff --git a/C#/UnitTests/TranslateAhkCodeTests.cs b/C#/UnitTests/TranslateAhkCodeTests.cs new file mode 100644 index 0000000..5ad7604 --- /dev/null +++ b/C#/UnitTests/TranslateAhkCodeTests.cs @@ -0,0 +1,74 @@ +using AutoHotInterception.Helpers; +using NUnit.Framework; +using System.Collections.Generic; + +namespace UnitTests +{ + [TestFixture] + class TranslateAhkCodeTests + { + [Test, TestCaseSource("TestKeyProvider")] + public void PressRelease(string name, int code, List pressResult, List releaseResult) + { + var actualResult = ScanCodeHelper.TranslateAhkCode((ushort)code, 1); + AssertResults(pressResult, actualResult); + + actualResult = ScanCodeHelper.TranslateAhkCode((ushort)code, 0); + AssertResults(releaseResult, actualResult); + } + + private void AssertResults(List expectedResult, List actualResult) + { + Assert.That(actualResult.Count == expectedResult.Count, $"Expecting {expectedResult.Count} strokes, but got {actualResult.Count}"); + for (int i = 0; i < expectedResult.Count; i++) + { + + Assert.That(actualResult[i].key.code, Is.EqualTo(expectedResult[i].key.code), + $"Code should be {expectedResult[i].key.code}, got {actualResult[i].key.code}"); + Assert.That(actualResult[i].key.state, Is.EqualTo(expectedResult[i].key.state), + $"Code should be {expectedResult[i].key.state}, got {actualResult[i].key.state}"); + } + } + + private static List Result(ushort code1, ushort state1, ushort? code2 = null, ushort? state2 = null) + { + var strokes = new List(); + strokes.Add(new ManagedWrapper.Stroke() { key = { code = code1, state = state1 } }); + if (code2 != null) + { + strokes.Add(new ManagedWrapper.Stroke() { key = { code = (ushort)code2, state = (ushort)state2 } }); + } + return strokes; + } + + private static IEnumerable TestKeyProvider() + { + yield return new TestCaseData("One", 2, Result(2, 0), Result(2, 1)); + yield return new TestCaseData("Scroll Lock", 70, Result(70, 0), Result(70, 1)); + yield return new TestCaseData("Div", 53, Result(53, 0), Result(53, 1)); + + yield return new TestCaseData("Numpad Enter", 284, Result(28, 0), Result(28, 1)); + yield return new TestCaseData("Right Control", 285, Result(29, 2), Result(29, 3)); + yield return new TestCaseData("Numpad Div", 309, Result(53, 2), Result(53, 3)); + yield return new TestCaseData("Right Shift", 310, Result(54, 0), Result(54, 1)); + yield return new TestCaseData("Print Screen", 311, Result(42, 2, 55, 2), Result(55, 3, 42, 3)); + yield return new TestCaseData("Right Alt", 312, Result(56, 2), Result(56, 3)); + yield return new TestCaseData("Numlock", 325, Result(69, 0), Result(69, 1)); + yield return new TestCaseData("Pause", 69, Result(29, 4, 69, 4), Result(29, 5, 69, 5)); + yield return new TestCaseData("Home", 327, Result(42, 2, 71, 2), Result(71, 3, 42, 3)); + yield return new TestCaseData("Up", 328, Result(42, 2, 72, 2), Result(72, 3, 42, 3)); + yield return new TestCaseData("PgUp", 329, Result(42, 2, 73, 2), Result(73, 3, 42, 3)); + yield return new TestCaseData("Left", 331, Result(42, 2, 75, 2), Result(75, 3, 42, 3)); + yield return new TestCaseData("Right", 333, Result(42, 2, 77, 2), Result(77, 3, 42, 3)); + yield return new TestCaseData("End", 335, Result(42, 2, 79, 2), Result(79, 3, 42, 3)); + yield return new TestCaseData("Down", 336, Result(42, 2, 80, 2), Result(80, 3, 42, 3)); + yield return new TestCaseData("PgDn", 337, Result(42, 2, 81, 2), Result(81, 3, 42, 3)); + yield return new TestCaseData("PgDn", 338, Result(42, 2, 82, 2), Result(82, 3, 42, 3)); + yield return new TestCaseData("Delete", 339, Result(42, 2, 83, 2), Result(83, 3, 42, 3)); + yield return new TestCaseData("Left Win", 347, Result(91, 2), Result(91, 3)); + yield return new TestCaseData("Right Win", 348, Result(92, 2), Result(92, 3)); + yield return new TestCaseData("Apps", 349, Result(93, 2), Result(93, 3)); + } + } + +} diff --git a/C#/UnitTests/TranslateScanCodesTests.cs b/C#/UnitTests/TranslateScanCodesTests.cs new file mode 100644 index 0000000..0d715ca --- /dev/null +++ b/C#/UnitTests/TranslateScanCodesTests.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.Diagnostics; +using AutoHotInterception.Helpers; +using NUnit.Framework; +using static AutoHotInterception.Helpers.ManagedWrapper; + +namespace UnitTests +{ + public class TestKey + { + public string Name { get; } + public List PressStrokes { get; } + public List ReleaseStrokes { get; } + public ExpectedResult PressResult { get; } + public ExpectedResult ReleaseResult { get; } + + public TestKey(string name, List pressStrokes, List releaseStrokes, + ExpectedResult pressResult, ExpectedResult releaseResult) + { + Name = name; + PressStrokes = pressStrokes; + ReleaseStrokes = releaseStrokes; + PressResult = pressResult; + ReleaseResult = releaseResult; + } + } + + public class ExpectedResult + { + public ushort Code { get; } + public ushort State { get; } + + public ExpectedResult(ushort code, ushort state) + { + Code = code; + State = state; + } + } + + [TestFixture] + class TranslateScanCodesTests + { + private static List Stroke(ushort code1, ushort state1, ushort code2 = 0, ushort state2 = 0) + { + var strokes = new List(); + strokes.Add(new Stroke() { key = { code = code1, state = state1 } }); + if (code2 != 0) + { + strokes.Add(new Stroke() { key = { code = code2, state = state2 } }); + } + return strokes; + } + + private static ExpectedResult Result(ushort code, ushort state) + { + var results = new ExpectedResult(code, state); + return results; + } + + [Test, TestCaseSource("TestKeyProvider")] + public void PressRelease(string name, List pressStrokes, List releaseStrokes, ExpectedResult pressResult, ExpectedResult releaseResult ) + { + Debug.WriteLine($"\nTesting key {name}..."); + Debug.WriteLine("Testing Press"); + var expectedResult = pressResult; + var actualResult = ScanCodeHelper.TranslateScanCodes(pressStrokes); + AssertResult(actualResult, expectedResult); + + Debug.WriteLine("Testing Release"); + expectedResult = releaseResult; + actualResult = ScanCodeHelper.TranslateScanCodes(releaseStrokes); + AssertResult(actualResult, expectedResult); + + Debug.WriteLine("OK!"); + } + + private static IEnumerable TestKeyProvider() + { + yield return new TestCaseData("One", Stroke(2, 0), Stroke(2, 1), Result(2, 1), Result(2, 0)); + yield return new TestCaseData("Scroll Lock", Stroke(70, 0), Stroke(70, 1), Result(70, 1), Result(70, 0)); + + yield return new TestCaseData("Numpad Enter", Stroke(28, 0), Stroke(28, 1), Result(284, 1), Result(284, 0)); + yield return new TestCaseData("Right Control", Stroke(29, 2), Stroke(29, 3), Result(285, 1), Result(285, 0)); + yield return new TestCaseData("Numpad Div", Stroke(53, 2), Stroke(53, 3), Result(309, 1), Result(309, 0)); + yield return new TestCaseData("Right Shift", Stroke(54, 0), Stroke(54, 1), Result(310, 1), Result(310, 0)); + yield return new TestCaseData("Print Screen", Stroke(42, 2, 55, 2), Stroke(55, 3, 42, 3), Result(311, 1), Result(311, 0)); + yield return new TestCaseData("Right Alt", Stroke(56, 2), Stroke(56, 3), Result(312, 1), Result(312, 0)); + yield return new TestCaseData("Numlock", Stroke(69, 0), Stroke(69, 1), Result(325, 1), Result(325, 0)); + yield return new TestCaseData("Pause", Stroke(29, 4, 69, 0), Stroke(29, 5, 69, 1), Result(69, 1), Result(69, 0)); + yield return new TestCaseData("Home", Stroke(42, 2, 71, 2), Stroke(71, 3, 42, 3), Result(327, 1), Result(327, 0)); + yield return new TestCaseData("Up", Stroke(42, 2, 72, 2), Stroke(72, 3, 42, 3), Result(328, 1), Result(328, 0)); + yield return new TestCaseData("PgUp", Stroke(42, 2, 73, 2), Stroke(73, 3, 42, 3), Result(329, 1), Result(329, 0)); + yield return new TestCaseData("Left", Stroke(42, 2, 75, 2), Stroke(75, 3, 42, 3), Result(331, 1), Result(331, 0)); + yield return new TestCaseData("Right", Stroke(42, 2, 77, 2), Stroke(77, 3, 42, 3), Result(333, 1), Result(333, 0)); + yield return new TestCaseData("End", Stroke(42, 2, 79, 2), Stroke(79, 3, 42, 3), Result(335, 1), Result(335, 0)); + yield return new TestCaseData("Down", Stroke(42, 2, 80, 2), Stroke(80, 3, 42, 3), Result(336, 1), Result(336, 0)); + yield return new TestCaseData("PgDn", Stroke(42, 2, 81, 2), Stroke(81, 3, 42, 3), Result(337, 1), Result(337, 0)); + yield return new TestCaseData("Insert", Stroke(42, 2, 82, 2), Stroke(82, 3, 42, 3), Result(338, 1), Result(338, 0)); + yield return new TestCaseData("Delete", Stroke(42, 2, 83, 2), Stroke(83, 3, 42, 3), Result(339, 1), Result(339, 0)); + yield return new TestCaseData("Left Windows", Stroke(91, 2), Stroke(91, 3), Result(347, 1), Result(347, 0)); + yield return new TestCaseData("Right Windows", Stroke(92, 2), Stroke(92, 3), Result(348, 1), Result(348, 0)); + yield return new TestCaseData("Apps", Stroke(93, 2), Stroke(93, 3), Result(349, 1), Result(349, 0)); + + // Test Home block in E0 mode (Numlock on) + yield return new TestCaseData("HomeE0", Stroke(71, 2), Stroke(71, 3), Result(327, 1), Result(327, 0)); + yield return new TestCaseData("UpE0", Stroke(72, 2), Stroke(72, 3), Result(328, 1), Result(328, 0)); + yield return new TestCaseData("PgUpE0", Stroke(73, 2), Stroke(73, 3), Result(329, 1), Result(329, 0)); + yield return new TestCaseData("LeftE0", Stroke(75, 2), Stroke(75, 3), Result(331, 1), Result(331, 0)); + yield return new TestCaseData("RightE0", Stroke(77, 2), Stroke(77, 3), Result(333, 1), Result(333, 0)); + yield return new TestCaseData("EndE0", Stroke(79, 2), Stroke(79, 3), Result(335, 1), Result(335, 0)); + yield return new TestCaseData("DownE0", Stroke(80, 2), Stroke(80, 3), Result(336, 1), Result(336, 0)); + yield return new TestCaseData("PgDnE0", Stroke(81, 2), Stroke(81, 3), Result(337, 1), Result(337, 0)); + yield return new TestCaseData("InsertE0", Stroke(82, 2), Stroke(82, 3), Result(338, 1), Result(338, 0)); + yield return new TestCaseData("DeleteE0", Stroke(83, 2), Stroke(83, 3), Result(339, 1), Result(339, 0)); + + } + + void AssertResult(TranslatedKey actualResult, ExpectedResult expectedResult) + { + Debug.WriteLine($"Expecting code of {expectedResult.Code}, state of {expectedResult.State}"); + Assert.That(actualResult.Code, Is.EqualTo(expectedResult.Code), $"Code should be {expectedResult.Code}, got {actualResult.Code}"); + Assert.That(actualResult.State, Is.EqualTo(expectedResult.State), $"State should be {expectedResult.State}, got {actualResult.State}"); + } + } +} diff --git a/C#/UnitTests/UnitTests.csproj b/C#/UnitTests/UnitTests.csproj new file mode 100644 index 0000000..6c21cb1 --- /dev/null +++ b/C#/UnitTests/UnitTests.csproj @@ -0,0 +1,70 @@ + + + + + + + Debug + AnyCPU + {8EDF4429-251A-416D-BB68-93F227191BCF} + Library + Properties + UnitTests + UnitTests + v4.8 + 512 + + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\NUnit.3.13.2\lib\net45\nunit.framework.dll + + + + + + + + + + + + + {68fa4bc3-c277-44d0-8333-18d51dc3ca19} + AutoHotInterception + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + \ No newline at end of file diff --git a/C#/UnitTests/packages.config b/C#/UnitTests/packages.config new file mode 100644 index 0000000..66ce9e9 --- /dev/null +++ b/C#/UnitTests/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fe29b3a..ea17ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added +- Add two SubscribeAbsolute example scripts which show how to process movement data coming from a tablet ### Changed +- Input is now processed even faster +Since 0.6.0, there could have been a 10ms delay between processing one piece of input and the next +Now it should be effectively instant ### Deprecated ### Removed ### Fixed +- Subscription / Context mode Extended keycodes fixed +Previously, some keys were not properly subscribe-able (Pause / NumLock) +Also, if any key was subscribed to with block enabled, and that key generated Extended Modifier keys when pressed +(eg when you press Home with NumLock off, keyboards send LShift with a state of 2, then Home with a state of 2) +then the Extended Modifier key (LShift with a state of 2 in the above example) was NOT blocked. +- SendKeyEvent() now sends exactly the same ScanCodes that would be sent if you really pressed it +Similar to the above example, if you sent Home, previously, only Home would be sent with a state of 2 +LShift would not have been sent with a state of 2 as it should +Also, Pause should send a state of 4, whereas before it sent a state of 2 +- AhiScanCodeTester.ahk in Development Tools now works again +- Bug introduced in 0.7.0 whereby unsubscribing whilst a callback is still running would lock up the script is now fixed +(WorkerThreads now use Tasks and CancellationTokens) ## [0.7.0] - 2022-01 -17 ### Added diff --git a/Development Tools/AhiScanCodeTester.ahk b/Development Tools/AhiScanCodeTester.ahk index c3c8448..2b81cff 100644 --- a/Development Tools/AhiScanCodeTester.ahk +++ b/Development Tools/AhiScanCodeTester.ahk @@ -15,15 +15,20 @@ Note that for some keys (eg Pause), AHI will see TWO key events for that key, he All ScanCodes are in Decimal */ +#include ..\Lib\AutoHotInterception.ahk + +AHI := new AutoHotInterception() vid := 0x04F2, pid := 0x0112 ; Wyse Keyboard +keyboardId := AHI.GetKeyboardId(vid, pid) OutputDebug, DBGVIEWCLEAR AhkKeyBuffer := [] asm := CLR_LoadLibrary("..\Lib\AutoHotInterception.dll") sct := asm.CreateInstance("AutoHotInterception.ScanCodeChecker") -sct.Subscribe(vid, pid, Func("AhiKeyEvent")) +sct.Subscribe(keyboardId, Func("AhiKeyEvent"), false) + ih := InputHook() ih.KeyOpt("{All}", "SN") @@ -66,7 +71,7 @@ AhiKeyEvent(keyEvents){ } ; Note that keyEvents is a ZERO-BASED array! - + ahiSc1 := keyEvents[0].Code ahiState1 := keyEvents[0].state diff --git a/README.md b/README.md index eb7b394..d897a19 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,9 @@ All forms of input are supported in Subscription Mode. Subscription Mode overrides Context Mode - that is, if a key on a keyboard has been subscribed to with Subscription Mode, then Context Mode will not fire for that key on that keyboard. SubscribeKey overrides SubscribeKeyboard - that is, if you have subscribed to all keys and a specific key on the same keyboard, then if you press the specific key, it's callback will fire and the callback for SubscribeKeyboard will not. Each Subscribe endpoint also has a corresponding Unsubscribe endpoint, which removes the subscription and any block associated with it. +Both keyboard and mouse subscription functions have an optional `concurrent` parameter. This controls whether callbacks are fired sequentially or not. +False (Default) means that a new callback will not be fired until the last one has completed. This is especially useful for subscriptions involving mouse movement. +True means that a new thread will be used for each callback. If your callback has a long-running loop in it, this could mean that a callback could interrupt the previous callback, resulting in a steady buildup of callbacks (Read memory leak). Use at own risk! #### Subscribing to Keyboard keys ##### Subscribe to a specific key on a specific keyboard @@ -216,7 +219,6 @@ KeyEvent(state){ ToolTip % "State: " state } ``` -Parameter `` is optional and is false by default meaning that all the events raised for that key will be handled sequentially (i.e. callback function will be called on a single thread). If set to true, a new thread will be created for each event and the callback function will be called on it. ##### Subscribe to all keys on a specific keyboard `SubscribeKeyboard(, , , )` diff --git a/SubscribeAbsolute dragging example.ahk b/SubscribeAbsolute dragging example.ahk new file mode 100644 index 0000000..44a7230 --- /dev/null +++ b/SubscribeAbsolute dragging example.ahk @@ -0,0 +1,56 @@ +/* +This code illustrates how to process drags (Press down pen and move) with a tablet +*/ +#SingleInstance force +#Persistent +#include Lib\AutoHotInterception.ahk +Coordmode, Tooltip, Screen + +; Initialize AHI +AHI := new AutoHotInterception() +mouseId := AHI.GetMouseId(0x0B57, 0x9091) ; Get VID/PID of your device from the Monitor app and paste it in here +; Subscribe to the "Left Mouse" (AKA pressing the pen on the tablet) event from the tablet +AHI.SubscribeMouseButton(mouseId, 0, true, Func("ProcessInput")) +; Subscribe to movement coming from the tablet +; We use .Bind("") here to pass "" to the newState parameter of the function +AHI.SubscribeMouseMoveAbsolute(mouseId, true, Func("ProcessInput").Bind("")) +return + +ProcessInput(newState, nx := "", ny := ""){ + static state := 0 + static x := 0, y := 0 + if (newState != "" && newState != state){ + stateChanged := 1 + state := newState + } else { + stateChanged := 0 + } + if (nx != ""){ + x := nx + y := ny + } + ; At this point, regardless of whether the input was a move or click, the following variables tell us what has happened: + ; x and y hold current x and current y + ; state is the current state of the button + ; stateChanged indicates whether state just changed or not + + ; ---- Start of your code ---- + static dragStartX, dragStartY + tooltip % "Current Coords: " x ", " y, 0, 0, 1 + if (stateChanged){ + if (state){ + ; Process start of drag here + dragStartX := x + dragStartY := y + } else { + ; Process end of drag here + tooltip % "NOT DRAGGING. Last drag was: x " x - dragStartX ", y " y - dragStartY, 0, 30, 2 + } + } else if (state){ + ; If you want to do something during a drag, you would do it here + tooltip % "DRAGGING: dragged x " x - dragStartX ", y " y - dragStartY, 0, 30, 2 + } +} + +^Esc:: + ExitApp diff --git a/SubscribeAbsolute example.ahk b/SubscribeAbsolute example.ahk new file mode 100644 index 0000000..47e99a3 --- /dev/null +++ b/SubscribeAbsolute example.ahk @@ -0,0 +1,36 @@ +/* +This code illustrates how to detect movement and mouse event information coming from a Tablet +Bear in mind that when AHI receives input from a tablet... +... the "packet" of information always includes movement AND button state information +When AHI fires the callbacks, it always fires movement callbacks before button callbacks... +... so you can be sure that when ButtonEvent() fires, it always has the latest coordinates +*/ +#SingleInstance force +#Persistent ; If you have no hotkeys or GUI in the script, you need this else the script will instantly exit +#include Lib\AutoHotInterception.ahk +Coordmode, Tooltip, Screen + +; Initialize AHI +AHI := new AutoHotInterception() +mouseId := AHI.GetMouseId(0x0B57, 0x9091) ; Get VID/PID of your device from the Monitor app and paste it in here +; Subscribe to the "Left Mouse" (AKA pressing the pen on the tablet) event from the tablet +AHI.SubscribeMouseButton(mouseId, 0, true, Func("ButtonEvent")) +; Subscribe to movement coming from the tablet +AHI.SubscribeMouseMoveAbsolute(mouseId, true, Func("OnMouseMove")) +return + +ButtonEvent(state){ + global lastX, lastY + tooltip % "LMB State: " state " ( Event was at " lastX ", " lastY " )", 0, 30, 2 +} + +; Store coordinates on move, so they can be used in ButtonEvent +OnMouseMove(x, y){ + global lastX, lastY + lastX := x + lastY := y + tooltip % "Coords: " x ", " y, 0, 0, 1 +} + +^Esc:: + ExitApp diff --git a/Test.ahk b/Test.ahk new file mode 100644 index 0000000..7db4d27 --- /dev/null +++ b/Test.ahk @@ -0,0 +1,58 @@ +#SingleInstance force +;~ #Persistent +#include Lib\AutoHotInterception.ahk + +;~ pk := GetKeySC("Pause") +;~ dk := GetKeySC("Delete") +;~ RCtrl := GetKeySC("RCtrl") +msgbox % GetKeySC("ScrollLock") +;~ return +;~ msgbox % Format("{:d}", GetKeySC("Pause")) +return + +clipboard := "" +log := "`n// ============ DUPES ==================`n" +keys := {} + +Loop 512 { + hex := Format("{:x}", A_Index) + name := GetKeyName("sc" hex) + if (name == "") + continue + str := "{" A_Index " /*(0x" hex ")*/, " """" name """" "}" + ;~ if (A_Index == 86 || A_Index = 342) + ;~ break = true + if (keys.HasKey(name)){ + log .= "// " str " (Also " keys[name] ")`n" + } else { + clipboard .= str ",`n" + keys[name] := A_Index + } +} +clipboard .= log +return +state := true +AHI := new AutoHotInterception() + +;~ keyboardId := AHI.GetKeyboardId(0x04F2, 0x0112) +;~ AHI.SubscribeKey(keyboardId, GetKeySC("1"), true, Func("KeyEvent")) +mouseId := AHI.GetMouseId(0x046D, 0xC00C) +AHI.SubscribeMouseMove(mouseId, true, Func("OnMouseMove")) +return + +KeyEvent(state){ + ToolTip % "State: " state +} + +OnMouseMove(x, y){ + Tooltip % x ", " y, 0, 0 +} + +F1:: + state := !state + AHI.SetState(state) + Tooltip + return + +^Esc:: + ExitApp \ No newline at end of file