[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.ViewModelContracts;
using Common.UI;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library.Enumerations;
using static ColorPicker.Helpers.NativeMethodsHelper;
namespace ColorPicker.Helpers
{
@ -43,6 +43,12 @@ namespace ColorPicker.Helpers
public event EventHandler AppClosed;
public event EventHandler EnterPressed;
public event EventHandler UserSessionStarted;
public event EventHandler UserSessionEnded;
public void StartUserSession()
{
EndUserSession(); // Ends current user session if there's an active one.
@ -62,10 +68,9 @@ namespace ColorPicker.Helpers
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())
{
SetupEscapeGlobalKeyShortcut();
UserSessionStarted?.Invoke(this, EventArgs.Empty);
}
}
}
@ -85,10 +90,9 @@ namespace ColorPicker.Helpers
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())
{
ClearEscapeGlobalKeyShortcut();
UserSessionEnded?.Invoke(this, EventArgs.Empty);
}
SessionEventHelper.End();
@ -213,62 +217,36 @@ namespace ColorPicker.Helpers
_hwndSource = hwndSource;
}
public IntPtr ProcessWindowMessages(IntPtr hwnd, int msg, IntPtr wparam, IntPtr lparam, ref bool handled)
public bool HandleEnterPressed()
{
switch (msg)
if (!IsColorPickerVisible())
{
return false;
}
EnterPressed?.Invoke(this, EventArgs.Empty);
return true;
}
public bool HandleEscPressed()
{
case NativeMethods.WM_HOTKEY:
if (!BlockEscapeKeyClosingColorPickerEditor)
{
handled = EndUserSession();
return 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));
return false;
}
}
break;
}
return IntPtr.Zero;
}
public void SetupEscapeGlobalKeyShortcut()
internal void MoveCursor(int xOffset, int yOffset)
{
if (_hwndSource == null)
{
return;
}
_hwndSource.AddHook(ProcessWindowMessages);
if (!NativeMethods.RegisterHotKey(_hwndSource.Handle, _globalHotKeyId, NativeMethods.MOD_NOREPEAT, NativeMethods.VK_ESCAPE))
{
Logger.LogWarning("Couldn't register the hotkey for Esc.");
}
}
public void ClearEscapeGlobalKeyShortcut()
{
if (_hwndSource == null)
{
return;
}
if (!NativeMethods.UnregisterHotKey(_hwndSource.Handle, _globalHotKeyId))
{
Logger.LogWarning("Couldn't unregister the hotkey for Esc.");
}
_hwndSource.RemoveHook(ProcessWindowMessages);
POINT lpPoint;
GetCursorPos(out lpPoint);
lpPoint.X += xOffset;
lpPoint.Y += yOffset;
SetCursorPos(lpPoint.X, lpPoint.Y);
}
}
}

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 GlobalKeyboardHook _keyboardHook;
private bool disposedValue;
private bool _activationShortcutPressed;
private int keyboardMoveSpeed;
private Key lastArrowKeyPressed = Key.None;
[ImportingConstructor]
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);
// 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)
{
if (first.Count != second.Count || (first.Count == 0 && second.Count == 0))
@ -157,18 +220,10 @@ namespace ColorPicker.Keyboard
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
_keyboardHook?.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method

View File

@ -25,6 +25,7 @@ namespace ColorPicker.ViewModels
private readonly ZoomWindowHelper _zoomWindowHelper;
private readonly AppStateHandler _appStateHandler;
private readonly IUserSettings _userSettings;
private KeyboardMonitor _keyboardMonitor;
/// <summary>
/// Backing field for <see cref="OtherColor"/>
@ -53,6 +54,7 @@ namespace ColorPicker.ViewModels
_zoomWindowHelper = zoomWindowHelper;
_appStateHandler = appStateHandler;
_userSettings = userSettings;
_keyboardMonitor = keyboardMonitor;
NativeEventWaiter.WaitForEventLoop(
Constants.ShowColorPickerSharedEvent(),
@ -77,16 +79,36 @@ namespace ColorPicker.ViewModels
_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.
// 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.
// This is much lighter than using a local low level keyboard hook.
// The appStateHandler starts and disposes a low level hook when ColorPicker is being used.
// 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())
{
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>
/// Gets the current selected color as a <see cref="Brush"/>
/// </summary>