[ColorPicker][Accessibility] adding feature control ColorPicker with keyboard (#32371)

* [ColorPicker] Accessibility: adding feature move ColorPicker with arrow keys

* Removing shortcut for Esc, catching Esc, Enter and Space with low level keyboard hook.
Starting and disposing the hook only when ColorPicker is active.

* Remove code which allows disposing the keyboard hook only once
This commit is contained in:
Laszlo Nemeth 2024-05-08 12:39:03 +02:00 committed by GitHub
parent b9da1e6abf
commit f9d16fdde2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 148 additions and 59 deletions

View File

@ -9,8 +9,8 @@ using System.Windows.Interop;
using ColorPicker.Settings; using ColorPicker.Settings;
using ColorPicker.ViewModelContracts; using ColorPicker.ViewModelContracts;
using Common.UI; using Common.UI;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library.Enumerations; using Microsoft.PowerToys.Settings.UI.Library.Enumerations;
using static ColorPicker.Helpers.NativeMethodsHelper;
namespace ColorPicker.Helpers namespace ColorPicker.Helpers
{ {
@ -43,6 +43,12 @@ namespace ColorPicker.Helpers
public event EventHandler AppClosed; public event EventHandler AppClosed;
public event EventHandler EnterPressed;
public event EventHandler UserSessionStarted;
public event EventHandler UserSessionEnded;
public void StartUserSession() public void StartUserSession()
{ {
EndUserSession(); // Ends current user session if there's an active one. EndUserSession(); // Ends current user session if there's an active one.
@ -62,10 +68,9 @@ namespace ColorPicker.Helpers
ShowColorPicker(); ShowColorPicker();
} }
// Handle the escape key to close Color Picker locally when being spawn from PowerToys, since Keyboard Hooks from the KeyboardMonitor are heavy.
if (!(System.Windows.Application.Current as ColorPickerUI.App).IsRunningDetachedFromPowerToys()) if (!(System.Windows.Application.Current as ColorPickerUI.App).IsRunningDetachedFromPowerToys())
{ {
SetupEscapeGlobalKeyShortcut(); UserSessionStarted?.Invoke(this, EventArgs.Empty);
} }
} }
} }
@ -85,10 +90,9 @@ namespace ColorPicker.Helpers
HideColorPicker(); HideColorPicker();
} }
// Handle the escape key to close Color Picker locally when being spawn from PowerToys, since Keyboard Hooks from the KeyboardMonitor are heavy.
if (!(System.Windows.Application.Current as ColorPickerUI.App).IsRunningDetachedFromPowerToys()) if (!(System.Windows.Application.Current as ColorPickerUI.App).IsRunningDetachedFromPowerToys())
{ {
ClearEscapeGlobalKeyShortcut(); UserSessionEnded?.Invoke(this, EventArgs.Empty);
} }
SessionEventHelper.End(); SessionEventHelper.End();
@ -213,62 +217,36 @@ namespace ColorPicker.Helpers
_hwndSource = hwndSource; _hwndSource = hwndSource;
} }
public IntPtr ProcessWindowMessages(IntPtr hwnd, int msg, IntPtr wparam, IntPtr lparam, ref bool handled) public bool HandleEnterPressed()
{ {
switch (msg) if (!IsColorPickerVisible())
{ {
case NativeMethods.WM_HOTKEY: return false;
if (!BlockEscapeKeyClosingColorPickerEditor)
{
handled = EndUserSession();
}
else
{
// If escape key is blocked it means a submenu is open.
// Send the escape key to the Window to close that submenu.
// Description for LPARAM in https://learn.microsoft.com/windows/win32/inputdev/wm-keyup#parameters
// It's basically some modifiers + scancode for escape (1) + number of repetitions (1)
handled = true;
handled &= NativeMethods.PostMessage(_hwndSource.Handle, NativeMethods.WM_KEYDOWN, (IntPtr)NativeMethods.VK_ESCAPE, (IntPtr)0x00010001);
// Need to make the value unchecked as a workaround for changes introduced in .NET 7
// https://github.com/dotnet/roslyn/blob/main/docs/compilers/CSharp/Compiler%20Breaking%20Changes%20-%20DotNet%207.md#checked-operators-on-systemintptr-and-systemuintptr
handled &= NativeMethods.PostMessage(_hwndSource.Handle, NativeMethods.WM_KEYUP, (IntPtr)NativeMethods.VK_ESCAPE, unchecked((IntPtr)0xC0010001));
}
break;
} }
return IntPtr.Zero; EnterPressed?.Invoke(this, EventArgs.Empty);
return true;
} }
public void SetupEscapeGlobalKeyShortcut() public bool HandleEscPressed()
{ {
if (_hwndSource == null) if (!BlockEscapeKeyClosingColorPickerEditor)
{ {
return; return EndUserSession();
} }
else
_hwndSource.AddHook(ProcessWindowMessages);
if (!NativeMethods.RegisterHotKey(_hwndSource.Handle, _globalHotKeyId, NativeMethods.MOD_NOREPEAT, NativeMethods.VK_ESCAPE))
{ {
Logger.LogWarning("Couldn't register the hotkey for Esc."); return false;
} }
} }
public void ClearEscapeGlobalKeyShortcut() internal void MoveCursor(int xOffset, int yOffset)
{ {
if (_hwndSource == null) POINT lpPoint;
{ GetCursorPos(out lpPoint);
return; lpPoint.X += xOffset;
} lpPoint.Y += yOffset;
SetCursorPos(lpPoint.X, lpPoint.Y);
if (!NativeMethods.UnregisterHotKey(_hwndSource.Handle, _globalHotKeyId))
{
Logger.LogWarning("Couldn't unregister the hotkey for Esc.");
}
_hwndSource.RemoveHook(ProcessWindowMessages);
} }
} }
} }

View File

@ -0,0 +1,34 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using System.Windows;
namespace ColorPicker.Helpers
{
internal class NativeMethodsHelper
{
/// <summary>
/// Struct representing a point.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct POINT
{
public int X;
public int Y;
public static implicit operator Point(POINT point)
{
return new Point(point.X, point.Y);
}
}
[DllImport("user32")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
internal static extern bool GetCursorPos(out POINT lpPoint);
}
}

View File

@ -22,8 +22,9 @@ namespace ColorPicker.Keyboard
private List<string> _activationKeys = new List<string>(); private List<string> _activationKeys = new List<string>();
private GlobalKeyboardHook _keyboardHook; private GlobalKeyboardHook _keyboardHook;
private bool disposedValue;
private bool _activationShortcutPressed; private bool _activationShortcutPressed;
private int keyboardMoveSpeed;
private Key lastArrowKeyPressed = Key.None;
[ImportingConstructor] [ImportingConstructor]
public KeyboardMonitor(AppStateHandler appStateHandler, IUserSettings userSettings) public KeyboardMonitor(AppStateHandler appStateHandler, IUserSettings userSettings)
@ -80,6 +81,39 @@ namespace ColorPicker.Keyboard
} }
} }
if ((virtualCode == KeyInterop.VirtualKeyFromKey(Key.Space) || virtualCode == KeyInterop.VirtualKeyFromKey(Key.Enter)) && (e.KeyboardState == GlobalKeyboardHook.KeyboardState.KeyDown))
{
e.Handled = _appStateHandler.HandleEnterPressed();
return;
}
if (virtualCode == KeyInterop.VirtualKeyFromKey(Key.Back) && e.KeyboardState == GlobalKeyboardHook.KeyboardState.KeyDown)
{
e.Handled = _appStateHandler.HandleEscPressed();
return;
}
if (CheckMoveNeeded(virtualCode, Key.Up, e, 0, -1))
{
e.Handled = true;
return;
}
else if (CheckMoveNeeded(virtualCode, Key.Down, e, 0, 1))
{
e.Handled = true;
return;
}
else if (CheckMoveNeeded(virtualCode, Key.Left, e, -1, 0))
{
e.Handled = true;
return;
}
else if (CheckMoveNeeded(virtualCode, Key.Right, e, 1, 0))
{
e.Handled = true;
return;
}
var name = Helper.GetKeyName((uint)virtualCode); var name = Helper.GetKeyName((uint)virtualCode);
// If the last key pressed is a modifier key, then currentlyPressedKeys cannot possibly match with _activationKeys // If the last key pressed is a modifier key, then currentlyPressedKeys cannot possibly match with _activationKeys
@ -115,6 +149,35 @@ namespace ColorPicker.Keyboard
} }
} }
private bool CheckMoveNeeded(int virtualCode, Key key, GlobalKeyboardHookEventArgs e, int xMove, int yMove)
{
if (virtualCode == KeyInterop.VirtualKeyFromKey(key))
{
if (e.KeyboardState == GlobalKeyboardHook.KeyboardState.KeyDown && _appStateHandler.IsColorPickerVisible())
{
if (lastArrowKeyPressed == key)
{
keyboardMoveSpeed++;
}
else
{
keyboardMoveSpeed = 1;
}
lastArrowKeyPressed = key;
_appStateHandler.MoveCursor(keyboardMoveSpeed * xMove, keyboardMoveSpeed * yMove);
return true;
}
else if (e.KeyboardState == GlobalKeyboardHook.KeyboardState.KeyUp)
{
lastArrowKeyPressed = Key.None;
keyboardMoveSpeed = 0;
}
}
return false;
}
private static bool ArraysAreSame(List<string> first, List<string> second) private static bool ArraysAreSame(List<string> first, List<string> second)
{ {
if (first.Count != second.Count || (first.Count == 0 && second.Count == 0)) if (first.Count != second.Count || (first.Count == 0 && second.Count == 0))
@ -158,15 +221,7 @@ namespace ColorPicker.Keyboard
protected virtual void Dispose(bool disposing) protected virtual void Dispose(bool disposing)
{ {
if (!disposedValue) _keyboardHook?.Dispose();
{
if (disposing)
{
_keyboardHook?.Dispose();
}
disposedValue = true;
}
} }
public void Dispose() public void Dispose()

View File

@ -25,6 +25,7 @@ namespace ColorPicker.ViewModels
private readonly ZoomWindowHelper _zoomWindowHelper; private readonly ZoomWindowHelper _zoomWindowHelper;
private readonly AppStateHandler _appStateHandler; private readonly AppStateHandler _appStateHandler;
private readonly IUserSettings _userSettings; private readonly IUserSettings _userSettings;
private KeyboardMonitor _keyboardMonitor;
/// <summary> /// <summary>
/// Backing field for <see cref="OtherColor"/> /// Backing field for <see cref="OtherColor"/>
@ -53,6 +54,7 @@ namespace ColorPicker.ViewModels
_zoomWindowHelper = zoomWindowHelper; _zoomWindowHelper = zoomWindowHelper;
_appStateHandler = appStateHandler; _appStateHandler = appStateHandler;
_userSettings = userSettings; _userSettings = userSettings;
_keyboardMonitor = keyboardMonitor;
NativeEventWaiter.WaitForEventLoop( NativeEventWaiter.WaitForEventLoop(
Constants.ShowColorPickerSharedEvent(), Constants.ShowColorPickerSharedEvent(),
@ -77,16 +79,36 @@ namespace ColorPicker.ViewModels
_userSettings.ShowColorName.PropertyChanged += (s, e) => { OnPropertyChanged(nameof(ShowColorName)); }; _userSettings.ShowColorName.PropertyChanged += (s, e) => { OnPropertyChanged(nameof(ShowColorName)); };
_appStateHandler.EnterPressed += AppStateHandler_EnterPressed;
_appStateHandler.UserSessionStarted += AppStateHandler_UserSessionStarted;
_appStateHandler.UserSessionEnded += AppStateHandler_UserSessionEnded;
// Only start a local keyboard low level hook if running as a standalone. // Only start a local keyboard low level hook if running as a standalone.
// Otherwise, the global keyboard hook from runner will be used to activate Color Picker through ShowColorPickerSharedEvent // Otherwise, the global keyboard hook from runner will be used to activate Color Picker through ShowColorPickerSharedEvent
// and the Escape key will be registered as a shortcut by appStateHandler when ColorPicker is being used. // The appStateHandler starts and disposes a low level hook when ColorPicker is being used.
// This is much lighter than using a local low level keyboard hook. // The hook catches the Esc, Space, Enter and Arrow key presses.
// This is much lighter than using a permanent local low level keyboard hook.
if ((System.Windows.Application.Current as ColorPickerUI.App).IsRunningDetachedFromPowerToys()) if ((System.Windows.Application.Current as ColorPickerUI.App).IsRunningDetachedFromPowerToys())
{ {
keyboardMonitor?.Start(); keyboardMonitor?.Start();
} }
} }
private void AppStateHandler_UserSessionEnded(object sender, EventArgs e)
{
_keyboardMonitor.Dispose();
}
private void AppStateHandler_UserSessionStarted(object sender, EventArgs e)
{
_keyboardMonitor?.Start();
}
private void AppStateHandler_EnterPressed(object sender, EventArgs e)
{
MouseInfoProvider_OnMouseDown(null, default(System.Drawing.Point));
}
/// <summary> /// <summary>
/// Gets the current selected color as a <see cref="Brush"/> /// Gets the current selected color as a <see cref="Brush"/>
/// </summary> /// </summary>