张赐荣,视障者,信息无障碍专家
深耕Web/PC/移动端可访问性研究与实践工作多年,对跨平台无障碍解决方案拥有深刻的独特理论和丰富的实战经验。
精通视障用户软件交互设计,致力于用专业的能力改善、提升产品可及性体验。

張賜榮

张赐荣的技术博客

博客园 首页 新随笔 联系 订阅 管理

NVDA 插件开发入门详解

很多朋友都想自己开发 NVDA 插件,扩充想要的功能,但又担心开发 NVDA 插件非常难,需要精通底层原理、熟悉复杂的工具链。实际上不必担心,它并不难。NVDA 插件的开发语言是 Python,插件本身的结构也很简单,只需要一个清单文件、若干 Python 源代码文件,按照约定的目录结构组织好,压缩成特定格式的文件就可以分发了。整个过程不需要编译,不需要配置复杂的开发环境,有一台安装了 NVDA 的 Windows 电脑和一个文本编辑器就够了。

本文会通过一个具体的示例插件,完整演示从编写代码、本地测试到打包安装的完整步骤。读完本文,你应当能学会完成一个完整的插件的开发及打包。

本文主要面向没有 插件开发经验的朋友。如果你已经有一定的NVDA插件开发经验,本文大部分内容可能对你来说比较基础,可以跳过。


一、NVDA 插件简介

NVDA(NonVisual Desktop Access)是一款免费、开源的 Windows 读屏软件,由 NV Access 及全球贡献者共同开发维护。NVDA 使用 Python 语言编写,这一点对于插件开发来说非常重要,也就是说,插件同样使用 Python 编写,并且直接运行在 NVDA 自带的 Python 环境中,运行时不需要单独安装 Python。

附加组件(Add-on)是为 NVDA 扩展功能的程序包。它可以做很多事情,比如:

  • 增强对某些应用程序的支持。例如让 NVDA 更好地朗读某个特定软件中的内容。
  • 扩充对第三方盲文点显器或语音合成器的驱动支持。
  • 添加、修改 NVDA 自身的功能。比如增加全局功能、修改键盘行为、调整语音输出等。

在 NVDA 的插件商店中,已经有大量由社区开发者贡献的插件,全部可以免费下载。你也可以开发自己的插件,解决自己遇到的具体问题,然后分享给其他人。

事实上,从文件结构上看,一个 NVDA 插件本质上就是一个 ZIP 压缩包,只是文件扩展名改成了 .nvda-addon。压缩包内部包含一个清单文件(manifest.ini)和若干 Python 代码文件,按照特定的目录结构组织。NVDA 在安装插件时,会读取清单文件获取插件的名称、版本、兼容性等信息,然后将插件代码加载到运行环境中。

二、插件的两种类型

NVDA 插件按照作用范围,分为两种类型:全局插件(Global Plugin)和应用模块(App Module)。理解这两者的区别,是选择插件类型的第一步。

全局插件(Global Plugin)

全局插件在 NVDA 运行期间始终处于活动状态,不论当前焦点位于哪个应用程序。它适合实现全局性的功能,例如:

  • 添加全局快捷键
  • 修改键盘行为
  • 监控系统状态
  • 调整语音输出等

全局插件的代码文件放在插件包的 globalPlugins 目录下。

应用模块(App Module)

应用模块只在特定应用程序处于活动状态时才生效。它适合针对某个具体应用做适配增强,例如:

  • 修复某个软件的无障碍支持问题
  • 为某个软件添加专属命令
  • 调整某个软件中特定控件的朗读方式

应用模块的代码文件放在插件包的 appModules 目录下,文件名需要与目标应用程序的可执行文件名一致(不含 .exe 后缀)。例如,为记事本(notepad.exe)编写的应用模块,文件名应为 notepad.py

怎样选择

判断方式很简单:如果你要做的功能需要在所有应用中都生效,就用全局插件;如果只针对某一个特定程序,就用应用程序模块。

三、开发前的准备

在正式开始编写代码前,需要完成以下几项准备。

3.1 掌握 Python 基础

需要了解 Python 的基本语法,包括类(class)、方法(method)、导入模块(import)、条件判断(if/else)等概念。如果你之前没有接触过 Python,建议先花一到两个小时阅读 Python 官方教程 的前几章,了解基本语法即可。本文不会讲解 Python 语法本身。

3.2 准备文本编辑器

使用任意文本编辑器编写 Python 代码都可以。Windows 自带的记事本、VS Code、Notepad++ 都没问题。保存文件时,确保文件编码为 UTF-8。如果你使用 Windows 自带的记事本,在"另存为"对话框底部的"编码"下拉框中选择"UTF-8"。

3.3 开启 NVDA 的开发者实验目录

NVDA 提供了一个名为"开发者实验目录"(Scratchpad)的功能,能让开发者在不打包插件的情况下直接测试代码。开启方法如下:

  1. 在NVDA启动后,按 NVDA+N 打开 NVDA 菜单。
  2. 弹出菜单后,按 P,进入"选项"子菜单。
  3. S,打开"设置"对话框。
  4. 在设置对话框类别列表中,使用下光标键找到"高级"类别,然后按 Tab 找到 “我清楚更改这些设置可能导致 NVDA 无法正常运行。” 复选框,空格选中。
  5. 此时会出现一个确认提示,告知更改高级设置可能导致 NVDA 无法正常运行。
  6. 按 Tab 键移动到"允许从开发者实验目录加载代码"复选框,按空格键勾选。
  7. 继续按 Tab 键找到"确定"按钮,按回车键保存并关闭对话框。

开启之后,你可以在 NVDA 菜单 → 选项→ 设置 → 高级中找到“打开开发者实验目录”,点击后会打开该目录。

3.4 重新加载插件的快捷键

在开发过程中,修改代码后不需要重启 NVDA。按下默认快捷键 NVDA+Ctrl+F3 即可重新加载全局插件和应用模块。

四、示例插件

本文就以一个更改NVDA键盘处理行为的插件为例,手把手带你玩转NVDA插件开发。

在开始之前,先讲清楚这个示例插件要做什么。

NVDA 可以让用户将 Caps Lock 键设置为 NVDA 键。一旦这样设置,Caps Lock 键原本的大小写切换功能就不再直接可用了。为了保留按键的原有功能,NVDA 统一设计了一个规则:快速双击该键,即可触发按键的原功能。也就是说,如果你把 Caps Lock 设为 NVDA 键,想要切换大小写,需要快速按两下 Caps Lock。

这个设计有它的合理之处,用一套统一的操作处理了所有被设为 NVDA 键的按键。但有些朋友并不习惯这种方式,更喜欢类似于争渡读屏软件的处理模式:单击 Caps Lock 后,如果抬起前没有组合其他按键,就切换大小写;如果组合了其他按键,则作为 NVDA 修饰键使用,抬起时不切换大小写。

本文就来开发个小插件实现这个功能。通过这个例子,可以了解NVDA全局插件的完整开发流程,包括如何注册扩展点、如何拦截键盘事件、如何读取配置、如何模拟按键、如何操控语音模块,以及最终的打包和安装。

完整代码在 github.com/CingZeoi/NVDACapsLockEnhancer

五、插件的文件结构

一个完整的 NVDA 插件包,内部结构如下:

插件包根目录/
├── manifest.ini              ← 插件清单文件(必须)
├── globalPlugins/            ← 全局插件代码目录
│   └── capsLockModifier.py   ← 插件代码文件
└── doc/                      ← 插件文档目录(可选)
    └── en/
        └── readme.html       ← 英文说明文档

对于开发阶段使用实验目录测试,你只需要在实验目录的 globalPlugins 子目录下创建 capsLockModifier.py 文件即可,不需要 manifest.inimanifest.ini 是打包分发时才需要的。

六、清单文件 manifest.ini

manifest.ini 是插件的元数据文件,NVDA 通过它识别插件的基本信息。以下是本插件的清单文件内容:


manifest.ini:

name = "capsLockModifier"
summary = "CapsLock Modifier Enhancer"
version = "1.0.0"
description = "Replaces NVDA's default double-press toggle for CapsLock with a single-press toggle when CapsLock is set as the NVDA key."
author = "Arno <arno@prc.cx>"
url = "https://github.com/cingzeoi"
docFileName = "readme.html"
minimumNVDAVersion = "2025.1.0"
lastTestedNVDAVersion = "2027.4.4"

各字段的含义如下:

  • name:插件的内部标识符,只能包含字母、数字、下划线和连字符。这个名称会作为插件在用户配置目录中的文件夹名。
  • summary:插件的显示名称,会出现在插件商店和插件管理界面中。
  • version:插件版本号,格式为 主版本.次版本.修订号
  • description:插件的功能描述,一两句话即可。
  • author:作者姓名和邮箱,格式为 姓名 <邮箱>
  • url:插件的主页地址,可以填 GitHub 仓库地址。
  • docFileName:插件说明文档的文件名。NVDA 会在 doc 目录下按语言查找这个文件。
  • minimumNVDAVersion:插件兼容的最低 NVDA 版本。格式为 年份.主版本.次版本,例如 2024.1.0
  • lastTestedNVDAVersion:插件经过测试的最高 NVDA 版本。

在开发测试阶段,你暂时不需要这个文件。等到打包时再创建即可。

七、插件代码

以下是完整的插件代码。先看完整的,之后逐步解释。


globalPlugins/capsLockModifier.py:

# -*- coding: utf-8 -*-

# 导入 NVDA 插件所需的基础模块
import globalPluginHandler          # 全局插件基类
import inputCore                    # 输入核心,用于拦截按键事件
import keyboardHandler              # 键盘事件处理,管理修饰键状态
import winUser                      # Windows API 封装,含虚拟键码和键盘操作
import config                       # NVDA 配置
import core                         # 核心功能,如定时器
import ui                           # 用户界面,用于显示消息
import speech                       # 语音输出控制
from config.configFlags import NVDAKey      # NVDA 修饰键标志(如 Caps Lock)
from keyLabels import localizedKeyLabels     # 本地化键名

class GlobalPlugin(globalPluginHandler.GlobalPlugin):
    """
    全局插件:自定义 Caps Lock 键行为。
    当 Caps Lock 被设置为 NVDA 修饰键时,插件拦截其按键事件。
    - 若按下 Caps Lock 后与其他键组合(同时按下其他键),则作为修饰键使用,不改变 Caps Lock 实际状态。
    - 若单独按下并释放 Caps Lock(无组合),则模拟一次 Caps Lock 切换,并播报当前状态(开/关)。
    """

    def __init__(self):
        """
        初始化插件实例。
        - 调用父类初始化。
        - 初始化内部状态变量:
            _isCapsDown    : 标记 Caps Lock 键是否正处于按下状态。
            _hasCombined   : 标记在按下 Caps Lock 期间是否与其他键组合。
            _isSendingCaps : 标记是否模拟发送 Caps Lock 按键,防止无限递归。
        - 注册原始按键处理回调函数到 inputCore,以便在按键被 NVDA 处理前进行拦截。
        """
        super().__init__()
        self._isCapsDown = False          # Caps Lock 是否按下
        self._hasCombined = False         # 是否在按住期间组合了其他键
        self._isSendingCaps = False       # 是否正在模拟 Caps Lock 键(防止重入)
        # 注册 _onRawKey 到 inputCore 的 decide_handleRawKey 钩子,该钩子在每个原始按键事件时调用
        inputCore.decide_handleRawKey.register(self._onRawKey)

    def terminate(self):
        """
        插件终止时的清理工作。
        - 从 inputCore 注销按键处理回调。
        - 如果 Caps Lock 仍然处于按下状态(例如插件被禁用时键未释放),则从当前修饰键集合中移除,
          避免 NVDA 认为修饰键一直按住。
        - 调用父类 terminate。
        """
        # 注销回调,防止后续事件调用
        inputCore.decide_handleRawKey.unregister(self._onRawKey)
        # 如果 Caps Lock 还在按下状态,手动从 currentModifiers 中清除
        if self._isCapsDown:
            # 从修饰键集合中移除 (VK_CAPITAL, False) 和 (VK_CAPITAL, True) 两种扩展状态
            keyboardHandler.currentModifiers.discard((winUser.VK_CAPITAL, False))
            keyboardHandler.currentModifiers.discard((winUser.VK_CAPITAL, True))
        super().terminate()

    def _onRawKey(self, vkCode, scanCode, extended, pressed):
        """
        原始按键事件处理回调(由 inputCore 调用)。
        参数:
            vkCode   : 虚拟键码
            scanCode : 扫描码
            extended : 是否为扩展键(如右 Alt 等)
            pressed  : True 表示按下,False 表示释放
        返回值:
            True  : 允许该按键继续由 NVDA 正常处理(传递给后续步骤)
            False : 阻止该按键进一步处理(已由本插件消费)
        逻辑:
            1. 若 NVDA 当前正在处理注入的按键(如模拟按键),则直接放行,避免干扰。
            2. 若配置中未将 Caps Lock 设为 NVDA 修饰键,则放行。
            3. 若当前正有按键需要“穿透”(passKeyThroughCount >= 0),则放行。
            4. 如果当前按键是 Caps Lock:
                a. 按下时:标记 _isCapsDown = True,清除 _hasCombined,将该键加入修饰键集合,
                   返回 False 阻止系统默认行为(让 NVDA 接管)。
                b. 释放时:清除标记,从修饰键集合中移除。如果期间没有组合其他键,
                   则模拟一次 Caps Lock 的按下和释放(切换状态),并延迟播报状态。
                   返回 False。
            5. 如果当前按键不是 Caps Lock,且 Caps Lock 正处于按下状态并且本次是按下事件,
               则标记 _hasCombined = True(表示有组合),然后返回 True 让 NVDA 正常处理组合键。
        """
        # 如果 NVDA 设置了忽略注入按键的标记,则直接放行(例如模拟按键时避免再次触发)
        if keyboardHandler.ignoreInjected:
            return True

        # 检查配置:是否将 Caps Lock 作为 NVDA 修饰键之一
        if not (config.conf["keyboard"]["NVDAModifierKeys"] & NVDAKey.CAPS_LOCK):
            return True

        # 如果 passKeyThroughCount >= 0,表示某些按键需要直接穿透(不拦截),放行
        if keyboardHandler.passKeyThroughCount >= 0:
            return True

        # 当前按键是否为 Caps Lock
        if vkCode == winUser.VK_CAPITAL:
            # 构造该键的标识元组 (虚拟键码, 扩展标志)
            keyCode = (winUser.VK_CAPITAL, extended)

            if pressed:
                # Caps Lock 按下
                self._isCapsDown = True          # 记录按下状态
                self._hasCombined = False        # 重置组合标志
                # 将该键加入到当前修饰键集合,以便 NVDA 识别为活动修饰键
                keyboardHandler.currentModifiers.add(keyCode)
                # 记录最后一个 NVDA 修饰键,用于后续组合键识别
                keyboardHandler.lastNVDAModifier = keyCode
                # 返回 False 阻止系统进一步处理该按下事件(防止系统切换 Caps Lock 状态)
                return False

            else:
                # Caps Lock 释放
                self._isCapsDown = False          # 清除按下状态
                keyboardHandler.currentModifiers.discard(keyCode)  # 从修饰键集合移除

                # 如果在按住 Caps Lock 期间没有组合任何其他键,则执行单独的切换操作
                if not self._hasCombined:
                    # 设置标志,表示正在模拟发送 Caps Lock,防止重入
                    self._isSendingCaps = True
                    try:
                        # 使用 ignoreInjection 上下文,避免模拟的按键被 NVDA 再次拦截
                        with keyboardHandler.ignoreInjection():
                            # 模拟一次 Caps Lock 键按下和释放(系统会切换状态)
                            winUser.keybd_event(winUser.VK_CAPITAL, 0, 0, 0)   # 按下
                            winUser.keybd_event(winUser.VK_CAPITAL, 0, 2, 0)   # 释放(KEYEVENTF_KEYUP)
                    finally:
                        # 确保清除模拟标志,即使发生异常
                        self._isSendingCaps = False

                    # 延迟 30ms 后播报 Caps Lock 当前状态(等待系统状态更新)
                    core.callLater(30, self._reportCapsLockState)

                # 返回 False 阻止系统默认处理该释放事件(避免系统在释放时切换)
                return False

        else:
            # 当前按键不是 Caps Lock
            # 如果 Caps Lock 正处于按下状态,并且本次是按下事件,说明用户按下了其他键进行组合
            if self._isCapsDown and pressed:
                self._hasCombined = True
            # 放行该按键,让 NVDA 继续处理(例如执行组合键命令)
            return True

    def _reportCapsLockState(self):
        """
        播报 Caps Lock 的当前状态(开/关)。
        由 core.callLater 延迟调用,确保系统 Caps Lock 状态已更新。
        - 取消当前正在进行的语音,避免干扰。
        - 通过 winUser.getKeyState 获取实际切换状态(最低位为 1 表示开启)。
        - 使用本地化键名和状态文本,通过 ui.message 显示在屏幕上并语音读出。
        """
        # 取消任何正在播放的语音,使播报清晰
        speech.cancelSpeech()
        # 获取 Caps Lock 切换状态:最低有效位为 1 表示打开,0 表示关闭
        toggleState = winUser.getKeyState(winUser.VK_CAPITAL) & 1
        # 获取本地化的键名(如 "caps lock" 或对应翻译)
        key = localizedKeyLabels.get("capslock", "caps lock")
        # 构建并显示消息,状态使用下划线 _ 函数进行翻译(NVDA 支持多语言)
        ui.message(
            "{key} {state}".format(
                key=key,
                state=_("on") if toggleState else _("off"),
            ),
        )

八、代码详解

8.1 导入模块

import globalPluginHandler
import inputCore
import keyboardHandler
import winUser
import config
import core
import ui
import speech
from config.configFlags import NVDAKey
from keyLabels import localizedKeyLabels

这些模块都是 NVDA 内部提供的,不需要额外安装。各模块的用途如下:

模块 用途
globalPluginHandler 提供全局插件的基类
inputCore 提供输入事件处理框架和扩展点
keyboardHandler 提供键盘处理的内部状态和工具函数
winUser 提供 Windows 用户界面 API 的封装
config 提供 NVDA 配置读取接口
core 提供核心功能,如延迟执行函数
ui 提供用户界面消息播报接口
speech 提供语音合成控制接口
NVDAKey 定义 NVDA 键的位掩码常量
localizedKeyLabels 提供按键名称的本地化翻译

8.2 插件类的定义与初始化

class GlobalPlugin(globalPluginHandler.GlobalPlugin):

    def __init__(self):
        super().__init__()
        self._isCapsDown = False
        self._hasCombined = False
        self._isSendingCaps = False
        inputCore.decide_handleRawKey.register(self._onRawKey)

全局插件必须定义一个名为 GlobalPlugin 的类,继承自 globalPluginHandler.GlobalPlugin。NVDA 加载插件时会自动实例化这个类。类名必须叫 GlobalPlugin,这是 NVDA 的约定。

__init__ 方法在插件加载时执行。这里做了两件事:

第一,初始化三个内部状态变量:

  • _isCapsDown:记录 Caps Lock 当前是否处于按下状态。
  • _hasCombined:记录 Caps Lock 按下期间是否组合了其他按键。
  • _isSendingCaps:记录当前是否正在发送模拟按键,用于防止循环。

第二,将 _onRawKey 方法注册到 inputCore.decide_handleRawKey 扩展点。注册之后,每一个键盘按键事件(按下和抬起)到达 NVDA 时,都会先调用 _onRawKey 方法。

NVDA 在内部的关键流程中预留了一些"挂载点",允许插件把自己的函数注册上去。当流程执行到这些挂载点时,NVDA 会依次调用所有已注册的函数。decide_handleRawKey 就是其中一个扩展点,它位于键盘事件处理的最前端。插件可以在这里决定一个按键是被 NVDA 继续处理,还是被拦截下来。

8.3 插件终止时的清理

def terminate(self):
    inputCore.decide_handleRawKey.unregister(self._onRawKey)
    if self._isCapsDown:
        keyboardHandler.currentModifiers.discard((winUser.VK_CAPITAL, False))
        keyboardHandler.currentModifiers.discard((winUser.VK_CAPITAL, True))
    super().terminate()

terminate 方法在插件终止时(例如 NVDA 退出,插件被重载等)都会执行。这里做了两件事:

第一,从扩展点中注销 _onRawKey 方法。

第二,如果插件禁用时 Caps Lock 恰好处于按下状态,需要从 currentModifiers 集合中移除 Caps Lock 的记录,避免残留状态影响后续操作。这里同时移除了 extendedFalseTrue 两种情况,确保清理干净。

8.4 _onRawKey 方法

这个方法是插件的核心。每当有键盘事件发生时,NVDA 会调用该方法,传入四个参数:

  • vkCode:虚拟键码,标识是哪个按键。
  • scanCode:扫描码,硬件层面的按键编码。
  • extended:是否为扩展键。
  • pressedTrue 表示按下,False 表示抬起。

方法返回 True 表示放行,NVDA 继续正常处理该按键。返回 False 表示拦截,NVDA 不再处理该按键。

下面逐步说明。

第一步:放行模拟按键

if keyboardHandler.ignoreInjected:
    return True

keyboardHandler.ignoreInjected 是 NVDA 内部的一个标志位。当我们在 ignoreInjection() 上下文中发送模拟按键时,这个标志位为 True。此时直接放行,避免插件处理自己发出的按键,造成死循环。

第二步:检查 Caps Lock 是否被设为 NVDA 键

if not (config.conf["keyboard"]["NVDAModifierKeys"] & NVDAKey.CAPS_LOCK):
    return True

NVDA 使用一个整数的位掩码来记录哪些按键被设为 NVDA 键。NVDAKey.CAPS_LOCK 的值为 1(二进制 001)。通过按位与运算,可以判断 Caps Lock 是否在 NVDA 键列表中。如果用户没有将 Caps Lock 设为 NVDA 键,插件不做任何干预,直接放行。

第三步:尊重"跳过下一个按键"功能

if keyboardHandler.passKeyThroughCount >= 0:
    return True

NVDA 有一个"跳过下一个按键"的功能(快捷键 NVDA+F2),按下后下一个按键会直接传递给当前应用程序,NVDA 不做处理。如果该功能处于激活状态,插件同样不做干预。

第四步:处理 Caps Lock 按下

if vkCode == winUser.VK_CAPITAL:
    keyCode = (winUser.VK_CAPITAL, extended)

    if pressed:
        self._isCapsDown = True
        self._hasCombined = False
        keyboardHandler.currentModifiers.add(keyCode)
        keyboardHandler.lastNVDAModifier = keyCode
        return False

当 Caps Lock 被按下时:

  1. _isCapsDown 设为 True,记录 Caps Lock 已按下。
  2. _hasCombined 重置为 False,开始新一轮的组合检测。
  3. 将 Caps Lock 加入 keyboardHandler.currentModifiers 集合。这个集合记录了当前所有处于按下状态的修饰键。后续按下其他键时,NVDA 会检查这个集合来判断组合键。
  4. 设置 keyboardHandler.lastNVDAModifier,告诉 NVDA 最后按下的 NVDA 修饰键是 Caps Lock。
  5. 返回 False,拦截该按键。NVDA 不会继续处理这个按键,也不会触发默认的双击检测逻辑。

第五步:处理 Caps Lock 抬起

else:
    self._isCapsDown = False
    keyboardHandler.currentModifiers.discard(keyCode)

    if not self._hasCombined:
        self._isSendingCaps = True
        try:
            with keyboardHandler.ignoreInjection():
                winUser.keybd_event(winUser.VK_CAPITAL, 0, 0, 0)
                winUser.keybd_event(winUser.VK_CAPITAL, 0, 2, 0)
        finally:
            self._isSendingCaps = False

        core.callLater(30, self._reportCapsLockState)

    return False

当 Caps Lock 被抬起时:

  1. _isCapsDown 设为 False
  2. currentModifiers 集合中移除 Caps Lock。
  3. 检查 _hasCombined:如果为 False,说明 Caps Lock 按下期间没有组合其他按键,属于"单按"。此时执行大小写切换。

切换大小写的方式是:调用 winUser.keybd_event 向操作系统发送一个 Caps Lock 的按下和抬起事件。操作系统收到后会切换大小写状态。

这里有一个关键点:发送模拟按键时,必须包括在 keyboardHandler.ignoreInjection() 上下文中。如果不这样做,模拟发出的按键会再次触发 _onRawKey 方法,导致插件处理自己发出的按键,形成死循环。ignoreInjection() 的作用是告诉 NVDA 忽略这两个模拟按键。

切换之后,使用 core.callLater(30, self._reportCapsLockState) 延迟 30 毫秒后调用状态播报方法。延迟的原因是:keybd_event 发送的按键事件需要一小段时间才能被操作系统处理完毕,立即读取状态可能得到旧值。

最后返回 False,拦截抬起事件。

第六步:处理其他按键

else:
    if self._isCapsDown and pressed:
        self._hasCombined = True
    return True

如果按下的不是 Caps Lock,且 Caps Lock 当前处于按下状态,则将 _hasCombined 设为 True,标记已发生组合。然后返回 True 放行,让 NVDA 正常处理该按键(例如执行对应的 NVDA 命令)。

8.5 状态播报方法

def _reportCapsLockState(self):
    speech.cancelSpeech()
    toggleState = winUser.getKeyState(winUser.VK_CAPITAL) & 1
    key = localizedKeyLabels.get("capslock", "caps lock")
    ui.message(
        "{key} {state}".format(
            key=key,
            state=_("on") if toggleState else _("off"),
        ),
    )
  1. speech.cancelSpeech():取消当前正在朗读的语音。这样做的目的是让状态播报能够立即播出,而不必等待前面的语音朗读完毕。
  2. winUser.getKeyState(winUser.VK_CAPITAL) & 1:读取 Caps Lock 的当前开关状态。返回值的最低位为 1 表示开启(大写),为 0 表示关闭(小写)。
  3. localizedKeyLabels.get("capslock", "caps lock"):获取"Caps Lock"在当前语言下的翻译名称。中文环境下会返回"大写锁定"。
  4. ui.message(...):播报一条消息。_("on")_("off") 是 NVDA 的国际化函数,会根据当前语言返回对应的翻译。

九、本地测试

代码编写完成后,按以下步骤测试。

9.1 将代码文件放入实验目录

  1. 打开 NVDA 的实验目录,通常位于C:\Users\yourusername\AppData\Roaming\nvda\scratchpad
  2. 在实验目录中找到 globalPlugins 子目录。如果该目录不存在,手动创建一个。
  3. capsLockModifier.py 文件复制到 globalPlugins 目录中,随后重启 NVDA。

9.2 确认 Caps Lock 已设为 NVDA 键

  1. NVDA+N 打开 NVDA 菜单。
  2. 点击 "选项" → "设置"。
  3. 在设置类别中选择"键盘"。
  4. 找到"选择 NVDA 键"复选列表,确认"大小写锁定键(capslock)"复选框已勾选。
  5. 如果没有勾选,按空格键勾选,然后按 Tab 键找到"确定"按钮,按回车保存。

9.3 重新加载插件

NVDA+Ctrl+F3。NVDA 会提示"插件已重新加载"。

9.4 测试

  1. 测试单按切换:按下 Caps Lock 然后松开。应当听到"大写锁定 开"或"大写锁定 关",并且实际的大小写状态已切换。可以打开记事本输入几个字母验证。
  2. 测试组合键:按住 Caps Lock,再按 T(即 NVDA+T)。应当正常朗读当前窗口标题,大小写状态不变。
  3. 测试双击:快速按两下 Caps Lock。由于两次单按各自切换一次,最终大小写状态与初始状态相同。

如果测试出现问题,可以打开日志查看器(NVDA 菜单 → 工具 → 日志查看器,或直接按 NVDA+F1)查看是否有错误信息。

十、打包为 .nvda-addon 文件

测试通过后,可以将插件打包为可分发的 .nvda-addon 文件。

10.1 创建插件目录

在任意位置(例如桌面)新建一个文件夹,命名为 CapsLockModifier

10.2 创建内部结构

CapsLockModifier 文件夹内,创建以下结构:

CapsLockModifier/
├── manifest.ini
├── globalPlugins/
│   └── capsLockModifier.py
└── doc/
    └── en/
        └── readme.html

具体操作:

  1. CapsLockModifier 文件夹内新建一个文本文档,将内容替换为上文所述的清单文件内容,保存为 manifest.ini。注意文件编码选择 UTF-8,扩展名为 .ini 而非 .txt
  2. 新建一个名为 globalPlugins 的文件夹,将 capsLockModifier.py 复制进去。
  3. 新建 doc 文件夹,在 doc 内新建 en 文件夹,在 en 内放入插件的说明文档 readme.html。文档内容可以简单写几句插件的功能说明,用html格式。

10.3 确认文件扩展名可见

在压缩之前,需要确认 Windows 资源管理器中能够看到文件的扩展名。如果看不到扩展名,可能会把 manifest.ini.txt 误认为 manifest.ini,或者把 capsLockModifier.py.txt 误认为 capsLockModifier.py

确认方法:

  1. 打开 Windows 资源管理器(按 Windows+E)。
  2. Alt+V 打开"查看"选项卡(Windows 10)。
  3. 找到"文件扩展名"复选框,确认已勾选。如果没有勾选,按空格键勾选。

勾选之后,所有文件都会显示完整的扩展名。请检查你创建的文件扩展名是否正确:清单文件应该是 manifest.ini,代码文件应该是 capsLockModifier.py,文档文件应该是 readme.html

10.4 压缩为 ZIP

  1. 打开 CapsLockModifier 文件夹。
  2. 全选文件夹内的所有项目(manifest.iniglobalPluginsdoc)。注意:是选中文件夹内部的内容,不是选中 CapsLockModifier 文件夹本身。可以按 Ctrl+A 全选。
  3. 右键点击选中的内容,选择"发送到" → "压缩(zipped)文件夹"。
  4. 系统会在当前目录生成一个 ZIP 文件。

这里需要特别注意:如果你直接右键点击 CapsLockModifier 文件夹选择"压缩",生成的 ZIP 文件内部会多一层 CapsLockModifier 目录。NVDA 安装时会在 ZIP 的根目录寻找 manifest.ini,多一层目录会导致安装失败,提示"文件丢失或者格式错误"。

正确的 ZIP 内部结构应该是这样的,打开 ZIP 文件,第一眼看到的就是 manifest.iniglobalPlugins 文件夹和 doc 文件夹:

CapsLockModifier.nvda-addon (ZIP)
├── manifest.ini         ← 必须在根目录
├── globalPlugins/       ← 必须在根目录
└── doc/                 ← 必须在根目录

10.5 修改扩展名

将生成的 ZIP 文件的扩展名从 .zip 改为 .nvda-addon。例如将 CapsLockModifier.zip 改为 CapsLockModifier.nvda-addon

如果系统提示"更改扩展名可能导致文件不可用",点击"是"确认。

如果在上面的步骤中没有开启文件扩展名显示,这里可能看不到 .zip 后缀,也就无法修改。所以请务必先确认扩展名可见。

十一、安装插件

打包完成后,可以通过以下方式安装。

方法一:双击安装

首先启动 NVDA,然后直接双击 CapsLockModifier.nvda-addon 文件。NVDA 会弹出一个确认对话框,显示插件的名称、版本、作者等信息。按 Tab 键找到"是"按钮,按回车键确认安装。安装完成后,NVDA 会提示需要重新启动才能生效。按回车键重新启动 NVDA。

方法二:通过插件商店安装

  1. NVDA+N 打开 NVDA 菜单。
  2. 选择"工具" → "插件商店"。
  3. 在插件商店窗口中,按 Tab 键找到"从外部源安装"按钮,按回车。
  4. 在弹出的文件浏览对话框中,找到 CapsLockModifier.nvda-addon 文件,按回车。
  5. 确认安装,然后重启 NVDA。

移除插件

如果需要从 NVDA 中卸载插件:

  1. 打开插件商店(NVDA 菜单 → 工具 → 插件商店)。
  2. Ctrl+Tab 切换到"已安装的插件"选项卡。
  3. 在列表中找到 capsLockModifier,按回车。
  4. 在操作菜单中选择"移除",按回车。
  5. 确认移除,然后重启 NVDA。

十二、注意事项

12.1 关于内部 API 的使用

本插件直接操作了 keyboardHandler 模块的内部变量(currentModifierslastNVDAModifier)等。这些变量不完全是 NVDA 的公开 API,NVDA 的开发团队没有承诺在后续版本中保持它们的名称和行为不变。如果 NVDA 未来版本对这些内部实现进行了重构,本插件可能无法正常工作。

对于学习目的而言,这种写法是可以接受的。如果你计划开发一个长期维护并分发给其他用户的插件,建议尽量使用 NVDA 提供的公开 API 和扩展点。

12.2 关于安全模式

NVDA 在安全界面(如 Windows 登录界面、用户账户控制对话框)中会以安全模式运行。安全模式下,所有第三方插件都不会加载。因此,本插件在这些界面中不会生效。这是 NVDA 的安全设计,不是插件的问题。

12.3 关于与其他插件的兼容性

如果有其他插件也注册了 decide_handleRawKey 扩展点,或者也操作了 Caps Lock 键的行为,可能会与本插件产生冲突。如果遇到异常行为,可以尝试禁用其他插件逐一排查。排查方法:在退出 NVDA 时选择"重启、禁用插件并启用调试日志记录"选项,或者使用命令行参数 --disable-addons 启动 NVDA。

12.4 关于"连按超时"设置

NVDA 键盘设置中有一个"连按超时"选项,用于控制双击检测的时间窗口。本插件完全绕过了 NVDA 的双击检测逻辑,因此该设置对本插件无影响。

12.5 关于打断语音

本插件在切换大小写时会调用 speech.cancelSpeech() 取消当前正在朗读的语音,然后立即播报状态。如果你正在进行全文朗读,按下 Caps Lock 会中断朗读。这是预期行为。如果你不希望打断当前语音,可以将 _reportCapsLockState 方法中的 speech.cancelSpeech() 这行注释掉。


本文以一个具体的功能需求为线索,演示了 NVDA 全局插件从编写、测试到打包安装的完整开发流程。文中涉及的代码量不多,核心逻辑不到百行。NVDA 的插件开发并不神奇,本质上就是在 NVDA 提供的框架中编写 Python 代码,通过扩展点和内部接口与 NVDA 的运行时环境交互。

本文主要是引导读者入门,内容上做了大量简化,没有涵盖 NVDA 插件开发的完整知识。

想进一步学习,可参考以下资源:

阅读源码是理解 NVDA 内部机制最直接的方式。本文中的许多实现细节,例如 ignoreInjection() 的用法、currentModifiers 的数据结构、decide_handleRawKey 的触发时机,都是通过阅读 keyboardHandler.pyinputCore.py 的源码获得的。

希望本文能帮助你迈出 NVDA 插件开发的第一步。


作者

张赐荣,视障者,资深NVDA软件开发工程师、信息无障碍解决方案研发专家,长期致力于数字化产品的可及性研究与用户体验优化工作,专注于为残障人士消除数字鸿沟。

曾主持过国内外多个互联网大型产品的无障碍改造项目及发布大量原创技术指南,系统性地推动了无障碍技术的标准化进程与落地部署工作,是一位在信息无障碍行业具有显著影响力的领军人物。

posted on 2026-08-09 16:22  张赐荣  阅读(11)  评论(0)    收藏  举报

感谢您访问张赐荣的技术分享博客!
博客地址:https://cnblogs.com/netlog/
知乎主页:https://www.zhihu.com/people/tzujung-chang
个人网站:https://prc.cx/