你的按键还够用么?资源监控器新增分层手势交互
资源监控器(Resource Monitor)是 NVDA 屏幕阅读器的一款插件,用于方便地读出 CPU、内存、显存、无线网络等资源信息。
我曾不止一次地吐槽这个插件占用了过多手势,缺乏设计感,尤其在快捷键组合稀缺的当下,这个问题尤为严重。
前段时间看到有同学在 GitHub 上提出为其添加分层命令的 Issue,维护者之一的 Joseph 表示愿意有条件地接受。
去年在开发 Polyglot 的时候,我对分层命令(layered commands)有过一些研究。简单讲,就是通过两步手势执行功能:先按下一个手势进入命令接收模式,我习惯称之为命令层,将后续的特定按键绑定到对应的功能。
在 NVDA 插件生态中,这种交互并不少见。对于资源监控器这个有着众多用户的插件来说,在实现这个功能的时候,我认为应当坚持一些基本原则:需要支持双击复制结果,保持原有交互行为;需要在按下无关按键后把按键传递回原来的应用,尽可能降低操作负担;避免使用 NVDA 私有 API,降低被上游变更意外破坏的风险。
当你看到这篇文章的时候,我的 PR 已经被合并了,该插件的 26.09.2 版已包含此特性。这篇文章希望从用户交互的使用角度和开发者的实现角度分享这一变化,希望对社区有所启发。
如何使用
按下:
NVDA+Shift+E
进入 Resource Monitor 命令层。进入时会播放短提示音。
进入后可以使用以下按键:
| 按键 | 功能 |
|---|---|
Space |
播报总体资源使用情况 |
C |
CPU 使用情况 |
M |
内存使用情况 |
D |
磁盘使用情况 |
W |
无线网络状态 |
O |
Windows 系统版本 |
U |
系统运行时间 |
G |
GPU 使用情况 |
Shift+G |
GPU 显存使用情况 |
Escape |
退出命令层 |
命令执行后会继续保留在命令层中,因此可以连续按同一个按键查看变化,也支持原有的双击复制功能。
例如:
NVDA+Shift+E → O → O
第一次按 O 播报 Windows 版本,第二次在 NVDA 设置的“连按超时”范围内按下 O,会将信息复制到剪贴板。
原有的 NVDA+Shift+1 至 NVDA+Shift+7 快捷键暂时保留以供过渡。
如果在命令层中按下没有对应命令的按键,命令层会自动退出,该按键随后继续按照 NVDA 和当前应用原本的规则处理。例如,在命令层中按 NVDA+F12,退出命令层后 NVDA+F12 仍会正常执行。
实现思路
说实话,在“口喷代码”的当下,我不知道写这个章节是否还会有人看,但无论正在读这篇文章的是一个活生生的人,还是一个一目千行的 AI Agent,我都希望下面的内容能对你有所帮助。
在开发 NVDA 插件的过程中,如果平时只给普通脚本分配手势,我们通常只需要这样写:
@scriptHandler.script(gesture="kb:NVDA+shift+e")
def script_announceSomething(self, gesture):
...
命令层多出来的部分主要有两个:一组临时手势,以及这组手势的生命周期管理。
首先定义命令层中的手势映射:
__layerGestures = {
"kb:space": "announceResourceSummary",
"kb:c": "announceProcessorInfo",
"kb:m": "announceRamInfo",
"kb:d": "announceDriveInfo",
"kb:w": "wlanStatusReport",
"kb:o": "announceWinVer",
"kb:u": "announceUptime",
"kb:g": "announceGpuInfo",
"kb:shift+g": "announceGpuMemoryInfo",
"kb:escape": "layerExit",
}
这里的值对应脚本名称,不包括 script_ 前缀。例如,"announceProcessorInfo" 对应:
def script_announceProcessorInfo(self, gesture):
...
进入命令层时,通过 bindGestures 临时绑定这些手势:
@scriptHandler.script(
description=_("Enter the Resource Monitor command layer."),
gesture="kb:NVDA+shift+e",
)
def script_layerEntry(self, gesture):
if self._isLayerActive:
return
self.bindGestures(self.__layerGestures)
self._isLayerActive = True
tones.beep(100, 10)
bindGestures 是 NVDA ScriptableObject 提供的公开 API。它只给当前插件实例增加临时手势,不会修改 NVDA 的全局手势配置。
退出命令层时,再移除这些临时绑定:
def _finishLayer(self):
self._isLayerActive = False
for gestureIdentifier in self.__layerGestures:
self.removeGestureBinding(gestureIdentifier)
Escape 也是命令层中的一个普通脚本,因此可以正常使用 scriptHandler.script 装饰器:
@scriptHandler.script(allowInSleepMode=True)
def script_layerExit(self, gesture):
self._finishLayer()
tones.beep(120, 100)
这里有一个比较容易忽略的问题:如果只在插件的 getScript 中处理未知按键,然后调用 gesture.send(),按键通常只能被发送到操作系统,不一定会再次经过 NVDA 的完整手势解析流程。这样可能影响 NVDA 自己的命令,或者用户在其他地方配置的手势。
所以这次使用了 NVDA 的一个有用的扩展点 inputCore.decide_executeGesture。它会在 NVDA 准备执行手势、但还没有正式解析脚本之前被调用。
插件初始化时注册处理函数:
inputCore.decide_executeGesture.register(self._decideExecuteGesture)
终止时注销:
inputCore.decide_executeGesture.unregister(self._decideExecuteGesture)
处理逻辑大致如下:
def _decideExecuteGesture(self, gesture):
if (
self._isLayerActive
and not gesture.isModifier
and self._layerGestureIdentifiers.isdisjoint(gesture.normalizedIdentifiers)
):
self._finishLayer()
return True
也就是说:
- 命令层没有激活时,不做任何处理。
- 单独按下 Shift、NVDA 等 modifier keys 时,不退出命令层。
- 命令层中定义的手势继续由命令层处理。
- 其他手势先退出命令层,然后返回
True,让 NVDA 按照正常流程继续解析。
因此,在命令层中按下 Tab、NVDA 命令或用户自己配置的手势时,命令层会退出,但这些按键不会被吞掉。
查看资源的命令本身不会在执行一次后自动退出,这样可以连续查看资源变化,也能保留原来的连续按两次复制功能。双击判断我们不用额外实现计时器,而是直接使用 NVDA 提供的:
scriptHandler.getLastScriptRepeatCount()
这个函数使用 NVDA 键盘设置中的“连按超时”选项,所以命令层中的双击行为会自动遵循用户当前的 NVDA 设置。
回顾整个实现,我们使用的是 NVDA 的公开 scripting、gesture binding 和 Extension Point APIs,没有使用 monkey patching。
结语
这次为 Resource Monitor 加入分层命令,对我来说并不只是为一个社区广泛使用的插件增加一种交互方式。更重要的是,它让我们再次思考了一个看似简单的问题:屏幕阅读器究竟应该为用户默认分配多少手势?
快捷键是一种非常宝贵的交互资源。开发者当然可以为每一个功能都分配一个默认手势,让功能触手可及;但另一方面,过多的默认手势也会不断挤占用户本就有限的快捷键空间,甚至与其他插件、应用或用户自己的配置产生冲突。
所以,我认为合理的设计不应该只是“有功能就给它一个手势”,而应该有所取舍:哪些功能值得提供默认手势,哪些功能更适合放进命令层,哪些功能应该默认不分配手势,交给用户自行配置,以及哪些功能其实根本没有必要加入屏幕阅读器。
这次的实现,意义或许不在于它本身改变了什么,而在于它再次引发了我们对这个问题的思考。于用户而言,算是又一次增加了命令层的可发现性;于社区和开发者而言,也算是再次提醒我们去思考这个问题。通过命令层,我们可以把一些不适合占用独立快捷键的功能集中起来,在“分配”与“默认不分配”之间取得平衡;而通过设计合理的命令层退出机制,可以进一步减少这种交互对用户原有操作方式的影响,降低上下文切换成本。
归根结底,我更希望我们讨论的不是“还能塞进多少功能”,而是怎样的功能取舍和交互设计才真正合理。
毕竟,按键就这么多,还是得省着点用。