HOME BLOG

Win32 编程基础之键盘输入

应用应该能够接收来自用户的键盘输入。键盘输入以投递的方式让应用进行接收。

1. 键盘的输入模式

通过为当前键盘安装合适的键盘设备驱动,系统提供了设备独立的键盘支持。通过使用语言特定的键盘布局,系统提供了语言独立的键盘布局(keyboard layout)支持,这个可以由用户或应用设置。键盘设备驱动从键盘接收扫描码,它被发送到键盘布局,在那里它被翻译为消息并投递到应用合适的窗口中。

分配给键盘上每个键的是一个个唯一的扫描码(scan code),它是键盘按键的设备独立标识符。键盘在用户按下一个键时生成两个扫描码 —— 用户按下和释放按键。

键盘设备驱动解释扫描码并将它翻译(映射)为一个虚拟键码(virtual-key code),这是系统定义的设备独立值,它标明了键盘的按键。在翻译扫描码后,键盘布局会创建一个包含扫描码、虚拟键码和其他关于击键信息的消息,随后将消息放入系统队列中。系统将它从系统队列中移除并投递到合适线程的消息队列。最终,线程的消息循环移除该消息并将他传递到合适的窗口过程进行处理。

2. 键盘焦点与活跃窗口

系统将键盘消息投递到创建了窗口且窗口带有键盘焦点的线程的消息队列中。 键盘焦点 是窗口的一个暂时属性。系统通过移动键盘焦点来让所有显示的窗口共享键盘,它根据用户的指令,从一个窗口移动到另一个。拥有键盘焦点的窗口接收所有的键盘消息(从创建它的线程的消息队列中)直到焦点移动到了另一个窗口。

线程可以调用 GetFocus 函数来得知它的哪一个窗口(如果有的话)现在拥有键盘焦点。线程可以通过调用 SetFocus 函数来将键盘焦点交给它的一个窗口。当键盘焦点从一个窗口变化到另一个时,系统会向丢失焦点的窗口发送 WM_KILLFOCUS 消息,并向获得焦点的窗口发送 WM_SETFOCUS 消息。

键盘焦点的概念与活跃窗口是相关的。 活跃窗口 是用户正在工作的顶级窗口。拥有键盘焦点的窗口要么是活跃窗口,要么是活跃窗口的子窗口。为了帮助用户识别活跃窗口,系统将活跃窗口放在 Z order 的最前面并将它的标题栏和边框高亮。

用户可以通过点击、使用 ALT + TABALT + ESC 组合键来激活一个顶级窗口。线程可以通过使用 SetActiveWindow 函数来激活一个顶级窗口。它也可以通过 GetActiveWindow 函数来判断他创建的哪个顶级窗口处于激活状态。

当一个窗口被停用而另一个窗口被激活时,系统会发送 WM_ACTIVATE 消息。 wParam 的低位字如果是 0 则说明窗口被停用,是非零则说明窗口被激活。当默认窗口过程接收到 WM_ACTIVATE 消息时,它会将键盘焦点设置到活跃窗口上。

要想将键盘和鼠标输入事件从它们将要到达的窗口屏蔽掉,可以使用 BlockInput 函数。注意, BlockInput 函数不会一影响异步键盘输入状态表(input-state table)。这就意味着调用 SendInput 函数会改变异步键盘输入状态表。

3. 击键消息

按下一个键会导致 WM_KEYDOWNWM_SYSKEYDOWN 消息被放在拥有键盘焦点的窗口所在线程的消息队列中。释放是个键会导致 WM_KEYUPWM_SYSKEYUP 消息放在队列中。

按下键和放开键一般是成对出现的,但是如果用户长时间按住一个键,来使用键盘的自动重复特性,系统会连续生成大量的 WM_KEYDOWNWM_SYSKEYDOWN 消息。它会在用户释放键时生成一个 WM_KEYUPWM_SYSKEYUP 消息。

3.1. 系统和非系统击键

系统区分系统和非系统击键。系统击键会产生系统击键消息,WM_SYSKEYDOWNWM_SYSKEYUP。非系统击键会产生非系统击键消息, WM_KEYDOWNWM_KEYUP

如果你的窗口过程必须处理一个系统击键消息,确保在处理消息后将它传递给 DefWindowProc 函数。否则,所有包括 ALT 键的系统操作会在这个窗口拥有键盘焦点时失效。也就是说,用户不能够使用窗口的菜单或系统菜单,或使用 ALT + ESCALT + TAB 之类的组合键来激活其他窗口。

系统击键消息一般给系统而不是应用使用。系统使用它们来提供系统的内建的菜单键盘接口,并允许用户控制窗口的活跃。系统击键消息会在用户按下带有 ALT 的组合键时生成,或是在没有窗口拥有键盘焦点时用户进行击键(例如,在活跃窗口被最小化时)。这种情况下,消息会被投递到活跃窗口的消息队列中。

非系统击键消息是给应用窗口使用的; DefWindowProc 函数不会对它们做出任何响应。窗口过程可以丢弃它不需要的任何非系统击键消息。

3.2. 虚拟键码

击键消息的 wParam 参数包含着一个被按压或释放的按键的虚拟键码。窗口过程可以根据虚拟键码的值来处理或忽略击键消息。

对于字母键和数字键,它们的虚拟键码就是 ASCII 码。

一般窗口过程只处理少部分的击键消息,它会忽略掉其他剩余的。例如,窗口过程可能只处理 WM_KEYDOWN 击键消息,并且只处理那些光标移动键,shift 键和功能键。一般的窗口过程不会处理来自字符键的击键消息。取而代之的是,它使用 TranslateMessage 函数将击键消息转化为字符消息。

3.3. 击键消息标志

击键消息的 lParam 参数包含生成消息的击键的额外信息。这个信息包括重复计数(repeat count),扫描码(scan code),拓展键标志(extended-key flag),内容码(context code),之前的键状态标志(previous key-state flag),和过渡状态标志(transition-state flag)。下面的插图展示了这些标志的在 lParam 中的位置。

1.png

应用可以使用下面的值来操纵击键标志

  • KF_ALTDOWN ,操纵 ALT 键标志,它指明 ALT 键是否按下
  • KF_DLGMODE ,操纵对话框模式标志,它指明对话框是否是活跃的
  • KF_EXTENDED ,操纵拓展键标志
  • KF_MENUMODE ,操纵菜单模式标志,它指明菜单是否是活跃的
  • KF_REPEAT ,操纵重复计数
  • KF_UP ,操纵过渡状态标志

3.3.1. 重复计数

你可以检查重复计数来判断击键消息是否表示多于一个击键。系统在键盘生成 WM_KEYDOWNWM_SYSKEYDOWN 消息快于应用处理速度时会增加这个计数。这通常发生在用户按住一个键足够长的事件开启键盘自动重复特性时。系统不会将生成的按键消息填满系统消息队列,而是将消息组合成一个按键消息并增加重复计数。对按键释放不会启动自动重复特性,因此 WM_KEYUPWM_SYSKEYUP 消息的重复计数值总是 1。

3.3.2. 扫描码

扫描码是键盘硬件在用户按下键时生成的值。它是与设备相关的值,用于标识按下的键,而不是键所代表的字符。应用一般忽略扫描码,使用设备独立的虚拟键代码来解释击键消息。

3.3.3. 拓展键标志

拓展键标志指明了击键消息是否来自增强键盘上额外的键。拓展键包括:键盘右手边的 ALT 和 CTRL 键;INS,DEL,HOME,END,PAGE UP,PAGE DOWN,数字小键盘中的箭头键;NUM LOCK;BREAK 键;PRINT SCAN 键;以及在数字小键盘上的除号键(/)和 ENTER 键。如果按键是一个拓展键的话,拓展键标志会被设置。

3.3.4. 内容码

内容码指明在击键消息生成时是否按下了 ALT 键。如果按下了则内容码为 1,否则为 0。

3.3.5. 先前键状态标志

先前键状态标志指明生成击键消息的按钮之前是按下还是放起的状态。如果它的值是 1 则说明之前是按下状态,是 0 则说明是放起状态。你可以使用这个标志来判断消息是否是由键盘自动重复特性生成的击键消息。对由自动重复特性生成的 WM_KEYDOWNWM_SYSKEYDOWN,这个标志被设为 1。对于 WM_KEYUPWM_SYSKEYUP 消息,它总是被设为 0。

3.3.6. 过渡状态标志

过渡状态标志指明是按下或释放一个键生成了击键消息。对于 WM_KEYDOWNWM_SYSKEYDOWN 消息它总是 0;对于 WM_KEYUPWM_SYSKEYUP 消息,它总是 1。

4. 字符消息

击键消息提供了许多关于击键的消息,但是它们没有提供字符击键的字符代码。要想得到字符码,应用必须使用 TranslateMessage 函数。 TranslateMessageWM_KEYDOWNWM_SYSKEYDOWN 消息传递给键盘布局。键盘布局会测试消息的虚拟键代码,如果它对应与一个字符键的话,则提供等效的字符码(它会考虑按下 SHIFT 和 CAPS LOCK 按键的情况)。它随后生成一个包括字符码的字符消息,并放在消息队列的最前面。下一次的消息循环迭代会从队列移除字符消息并将消息分派到合适的窗口过程。

4.1. 非系统字符消息

窗口过程可以接收这些字符消息:WM_CHARWM_DEADCHARWM_SYSCHAR, WM_SYSDEADCHAR,和 WM_UNICHARTranslateMessage 函数在处理 WM_KEYDOWN 消息时会生成 WM_CHARWM_DEADCHAR 消息。类似地,他会在处理 WM_SYSKEYDOWN 消息时生成 WM_SYSCHARWM_SYSDEADCHAR 消息。

处理键盘输入的应用一般会忽略除了 WM_CHARWM_UNICHAR 消息之外的其他键盘消息,将其他的消息传递给 DefWindowProc 函数。注意到 WM_CHAR 使用的是 16 位 Unicode 传输格式(UTF)而 WM_UNICHAR 使用的是 UTF-32。系统使用 WM_SYSCHARWM_SYSDEADCHAR 来实现菜单助记符(menu mnemonics)。

所有字符消息的 wParam 参数包含这按下的字符键的字符码。字符码的值取决于收到消息窗口的窗口类。如果使用了 Unicode 版本的 RegisterClass 函数来注册窗口类,系统会为该类的所有窗口提供 Unicode 字符。否则,系统会提供 ASCII 字符码。更多信息可见于 Unicode and Character Sets

字符消息的 lParam 参数与击键消息的 lParam 是相同的。

4.2. 死字符消息

一些非英语键盘包含一些不被期望产生字符的键,它们被用于为后续击键的字符添加变音符号。这些键被叫做 *死键*。德语键盘上的抑扬琴键(circumflex)是一个例子。要想输入一个由 "o" 和抑扬符组成的字符,德国用户会按一次抑扬键,并随后按一次 "o" 键。拥有键盘焦点的窗口会收到以下字符序列:

  1. WM_KEYDOWN
  2. WM_DEADCHAR
  3. WM_KEYUP
  4. WM_KEYDOWN
  5. WM_CHAR
  6. WM_KEYUP

TranslateMessage 会在它处理来自死键的 WM_KEYDOWN 消息时生成 WM_DEADCHAR 消息。即使 WM_DEADCHAR 消息的 wParam 参数中包含死键的变音符字符码,应用一般会忽略这个消息。取而代之的是,它会处理随后击键生成的 WM_CHAR 消息。 WM_CHARwParam 参数包含着含有变音符的字符的字符码。如果随后的击键生成了不能与变音符组合的一个字符,系统会生成两个字符消息。前者的 wParam 参数是变音符的字符码;后者的 wParam 是随后输入的字符码。

在处理来自系统死键(与 ALT 键组合的死键)WM_SYSKEYDOWN 消息时, TranslateMessage 会生成 WM_SYSDEADCHAR 消息。应用一般会忽略掉这个消息。

5. 按键状态

当处理键盘消息时,除了当前生成消息的键外,应用可能需要判断其他键的状态。例如,文字处理应用允许用户使用 SHIFT+END 来选取一块文本,只要它从 END 键接收到击键消息,就必须检查 SHIFT 键的状态。应用可以使用 GetKeyState 函数来判断一个虚拟键在当前消息生成时的状态;它也可以使用GetAsyncKeyState 函数来判断虚拟键的当前状态。(两者的区别在于键盘消息生成时的状态与当前调用函数时的按键状态)

键盘布局维护了一张名字表。产生单个字符的键的名字和它所产生的字符是相同的。像是 TAB 和 ENTER 的非字符键以字符串的形式储存。应用可以调用 GetKeyNameTextA 函数来从设备驱动检索任何键的名字。

6. 击键与字符翻译

系统包括了几个翻译由各种击键消息生成的扫描码、字符码和虚拟键码的特殊目的函数。这些函数包括 MapVirtualKeyAToAsciiToUnicodeVkKeyScanA

另外,Microsoft 富文本编辑器 3.0 支持 HexToUnicode IME,它允许用户使用热键在十六进制和 Unicode 字符间切换。这意味着当富文本编辑器 3.0 整合到应用中时,应用会继承 HexToUnicode IME 的特性。

7. 热键支持

热键 是一个生成 WM_HOTKEY 消息的键组合,系统将它放在线程消息队列的最前面。使用使用热键来从用户处获得高优先级的键盘输入。例如,通过定义 CTRL + C 的组合键为热键,应用允许用户取消一个冗长的操作。

要使用热键,应用可以调用 RegisterHotKey 函数,并指定生成热键消息的组合键、接收热键的窗口的句柄,以及热键的标识符。当用户按下热键时, WM_HOTKEY 消息被放在创建了窗口的线程的消息队列中。消息的 wParam 包含了热键的标识符。应用可以为一个线程定义多个热键,但线程中的每个热键必须有唯一的标识符。在应用终止前,它应该使用 UnregisterHotKey 函数来销毁热键。

用户可以使用热键空间来更容易地选择热键。热键空间一般用于定义激活窗口的热键;它们不使用 RegisterHotKeyUnregisterHotKey 函数。用使用热键的应用一般会发送 WM_SETHOTKEY 消息来设置热键。当用户按下热键时,系统会发送指定了 SC_HOTKEY 的 WM_SYSCOMMAND 消息。关于更多热键控件的消息,可见于About Hot Key Controls

8. 模拟输入

想要模拟一系列不间断用户输入事件,可以使用 SendInput 函数。这个函数接受三个参数。第一个参数 cInputs 指明将要模拟的输入的数量。第二个参数 rgInputs 是一个 INPUT 结构数组,其中的元素描述了输入事件类型以及额外的事件信息。最后一个参数 cbSize ,接收 INPUT 结构的大小,以字节为单位。

SendInput 函数通过注入一系列的模拟输入事件到设备的输入流中来进行工作。它的效果和重复调用 keybd_eventmouse_event 函数很相似,除了系统确保没有其他输入事件与模拟事件混合在一起之外。调用完成时,返回值指明成功的输入事件个数。如果这个值为 0,则说明输入被阻塞了。

SendInput 函数不会重设键盘当前的状态。因此,如果用户在你调用这个函数时按下了任意键,它们可能会与函数生成的事件相互干扰。如果你担心可能的干扰,可是在必要的时候使用 GetAsyncKeyState 来检查按键状态。

9. 语言,地区和键盘布局

语言 指一种自然语言,比如英语,法语和日语。 子语言 是一种自然语言的一种变种,指特定地理区域使用的语言,比如在英国的英语和美国的英语。应用可以使用叫做语言标识符(language identifiers)的值来唯一地确定语言和子语言。

应用一般使用 地区 (locale) 来设置语言的输入输出处理。例如,为键盘设置区域会应用想键盘生成的字符值。为显示器或打印机设置区域会影响显示或打印的字形。应用通过载入和使用键盘布局来为键盘设置区域。它们通过选择支持指定区域的字体来为显示器或打印机设置区域。

键盘布局不仅指定了键盘键的物理位置,而且决定了按键的字符值。每个布局标明了当前输入语言并决定了键和键组合所生成的字符值。

每个键盘布局有着对应的句柄,它标识了布局和语言。句柄的低位字是语言标识符。高位字是设备句柄,指定了物理布局,它的值也可以是 0,表示默认物理布局。用户可以将任意输入语言与物理布局关联起来。例如,英语使用者有时要使用法语工作,他可以将键盘的输入语言改为法语,而不需要改变键盘的物理布局。这意味着用户可以使用熟悉的英语键盘布局来输入法语文本。

一般应用不被期望来直接操纵输入语言。由用户来设置语言和布局组合,之后在它们之间选择。当用户选择其他语言标记的文本时,应用调用 ActivateKeyboardLayout 函数来激活用户对该语言的默认键盘布局。如果用户使用不在激活表中的语言进行编辑的话,应用可以使用 LoadKeyboardLayoutA 函数来获得基于该语言的布局。

ActivateKeyboardLayout 函数为当前任务设置输入语言。 hkl 参数可以是键盘布局的句柄或一个 0 值。可以通过 LoadKeyboardLayoutAGetKeyboardLayoutList 函数获得键盘布局句柄。 HKL_NEXTHKL_PREV 可以用来选取下一个或上一个键盘。

GetKeyboardLayoutNameA 函数为调用线程检索活跃键盘布局的名字。如果应用使用 LoadKeyboardLayoutA 函数创建了活跃键盘布局, GetKeyboardLayoutName 会检索与用于创建布局的相同字符串。否则,该字符串是与活跃布局的语言对应的主要语言标识符。这意味着这个函数可能不一定分辨使用相同主要语言的不同布局,因此也不能返回输入语言的特定信息。然而,GetKeyboardLayout 可用来判断输入语言。

LoadKeyboardLayout 函数载入一个键盘布局并使其对用户可用。应用可以使用 KLF_ACTIVATE 来使键盘布局对当前线程立即可用。应用可以使用 KLF_REORDER 来改变布局的排序而不需要指定 KLF_ACTIVATE 。应用应该在载入键盘布局并确保用户偏好时使用 KLF_SUBSTITUTE_OK

10. 参考资料

【1】 About Keyboard Input: https://docs.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input

【2】 Programming Windows , Charles Petzold