Skip to content

Commit 58e5802

Browse files
authored
Add touch based web element navigation in browse mode (#19414)
Closes #3424 ### Summary of the issue: As noted in the above issue, touch users do not have a dedicated way to perform browse mode style navigation of web content using touch gestures. ### Description of user facing changes: A new Web touch navigation mode has been introduced. When active, touch gestures allow users to navigate common web elements such as links, buttons, headings, form fields, landmarks, and other structural elements in browse mode documents. This enables touch based navigation that mirrors existing browse mode navigation commands. When browse mode is exited, the Web touch mode is automatically removed and touch navigation returns to its previous behavior. ### Description of developer facing changes: New touch gesture scripts have been added to invoke existing browse mode navigation commands. These scripts reuse the existing BrowseModeTreeInterceptor logic rather than introducing new navigation implementations. Supporting logic has been added to track the active browse mode context and route touch gestures to the appropriate browse mode commands when available. ### Description of development approach: This change was implemented by subscribing to browse mode state change notifications and using them as the authoritative signal for enabling and disabling web specific touch behavior. The active browse mode tree interceptor is cached on activation and cleared on deactivation. ### Testing strategy: Manual testing was performed using touch navigation in multiple browse mode documents across supported web browsers. Testing verified: * Automatic activation of Web touch mode when entering browse mode * Correct removal of Web touch mode when leaving browse mode * Correct navigation between web elements using touch gestures * No appearance of Web touch mode in non browse mode contexts ### Known issues with pull request: None
1 parent ffe0a0a commit 58e5802

File tree

8 files changed

+336
-32
lines changed

8 files changed

+336
-32
lines changed

source/browseMode.py

Lines changed: 194 additions & 11 deletions
Large diffs are not rendered by default.

source/config/configSpec.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# A part of NonVisual Desktop Access (NVDA)
22
# Copyright (C) 2006-2026 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt,
33
# Joseph Lee, Dawid Pieper, mltony, Bram Duvigneau, Cyrille Bougot, Rob Meredith,
4-
# Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka, Cary-rowen
4+
# Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka, Cary-rowen, Kefas Lungu
55
# This file is covered by the GNU General Public License.
66
# See the file COPYING for more details.
77

@@ -215,6 +215,8 @@
215215
enableOnPageLoad = boolean(default=true)
216216
loadChromiumVBufOnBusyState = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled")
217217
textParagraphRegex = string(default="{configDefaults.DEFAULT_TEXT_PARAGRAPH_REGEX}")
218+
# Element types available for cycling in browse touch mode.
219+
browseModeTouchNavigationElements = string_list(default=list("heading", "link", "formField", "list", "table"))
218220
219221
[touch]
220222
enabled = boolean(default=true)

source/globalCommands.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4581,12 +4581,7 @@ def script_touch_changeMode(self, gesture):
45814581
index = (index + 1) % len(touchHandler.availableTouchModes)
45824582
newMode = touchHandler.availableTouchModes[index]
45834583
touchHandler.handler._curTouchMode = newMode
4584-
try:
4585-
newModeLabel = touchHandler.touchModeLabels[newMode]
4586-
except KeyError:
4587-
# Translators: Cycles through available touch modes (a group of related touch gestures; example output: "object mode"; see the user guide for more information on touch modes).
4588-
newModeLabel = _("%s mode") % newMode
4589-
ui.message(newModeLabel)
4584+
ui.message(newMode.displayString)
45904585

45914586
@script(
45924587
# Translators: Input help mode message for a touchscreen gesture.

source/gui/settingsDialogs.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# Łukasz Golonka, Aaron Cannon, Adriani90, André-Abush Clause, Dawid Pieper,
77
# Takuya Nishimoto, jakubl7545, Tony Malykh, Rob Meredith,
88
# Burman's Computer and Education Ltd, hwf1324, Cary-rowen, Christopher Proß, Tianze
9-
# Neil Soiffer, Ryan McCleary.
9+
# Neil Soiffer, Ryan McCleary, Kefas Lungu.
1010
# This file is covered by the GNU General Public License.
1111
# See the file COPYING for more details.
1212

@@ -2662,6 +2662,23 @@ def makeSettings(self, settingsSizer):
26622662
)
26632663
self.trapNonCommandGesturesCheckBox.SetValue(config.conf["virtualBuffers"]["trapNonCommandGestures"])
26642664

2665+
# browseMode imports gui, which imports from settingsDialogs, so a top-level import
2666+
# would create a circular dependency. Keep this import lazy.
2667+
import browseMode
2668+
2669+
# Store element types for use in onSave (excludes the always-active "default" entry).
2670+
self._browseModeElements = list(browseMode.BrowseModeTreeInterceptor._browseTouchNavRegistry)
2671+
self._browseModeCheckListBox: nvdaControls.CustomCheckListBox = sHelper.addLabeledControl(
2672+
# Translators: Label for the list of browse mode touch navigation element types in browse mode settings.
2673+
_("T&ouch navigation elements:"),
2674+
nvdaControls.CustomCheckListBox,
2675+
choices=[label for _itemType, label in self._browseModeElements],
2676+
)
2677+
self._browseModeCheckListBox.Enable(touchHandler.touchSupported())
2678+
enabledTypes = set(config.conf["virtualBuffers"]["browseModeTouchNavigationElements"])
2679+
for i, (itemType, _label) in enumerate(self._browseModeElements):
2680+
self._browseModeCheckListBox.Check(i, itemType in enabledTypes)
2681+
26652682
def onSave(self):
26662683
config.conf["virtualBuffers"]["maxLineLength"] = self.maxLengthEdit.GetValue()
26672684
config.conf["virtualBuffers"]["linesPerPage"] = self.pageLinesEdit.GetValue()
@@ -2681,6 +2698,11 @@ def onSave(self):
26812698
config.conf["virtualBuffers"]["trapNonCommandGestures"] = (
26822699
self.trapNonCommandGesturesCheckBox.IsChecked()
26832700
)
2701+
config.conf["virtualBuffers"]["browseModeTouchNavigationElements"] = [
2702+
itemType
2703+
for i, (itemType, _label) in enumerate(self._browseModeElements)
2704+
if self._browseModeCheckListBox.IsChecked(i)
2705+
]
26842706

26852707

26862708
class MathSettingsPanel(SettingsPanel):

source/touchHandler.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
# A part of NonVisual Desktop Access (NVDA)
22
# This file is covered by the GNU General Public License.
33
# See the file COPYING for more details.
4-
# Copyright (C) 2012-2025 NV Access Limited, Joseph Lee, Babbage B.V.
4+
# Copyright (C) 2012-2026 NV Access Limited, Joseph Lee, Babbage B.V., Kefas Lungu
55

66
"""handles touchscreen interaction.
77
Used to provide input gestures for touchscreens, touch modes and other support facilities.
88
In order to use touch features, NVDA must be installed on a touchscreen computer.
99
"""
1010

1111
import threading
12+
from functools import cached_property
13+
from typing import (
14+
TYPE_CHECKING,
15+
Self,
16+
)
17+
18+
if TYPE_CHECKING:
19+
import browseMode
20+
1221
from ctypes import (
1322
byref,
1423
Structure,
@@ -43,6 +52,8 @@
4352
import core
4453
import systemUtils
4554
from utils import _deprecate
55+
from utils.displayString import DisplayStringStrEnum
56+
from treeInterceptorHandler import post_browseModeStateChange
4657

4758
__getattr__ = _deprecate.handleDeprecations(
4859
_deprecate.MovedSymbol(
@@ -51,15 +62,38 @@
5162
"SystemMetrics",
5263
"MAXIMUM_TOUCHES",
5364
),
65+
_deprecate.RemovedSymbol(
66+
"touchModeLabels",
67+
{
68+
"text": _("text mode"),
69+
"object": _("object mode"),
70+
"browse": _("browse mode"),
71+
},
72+
message="Use touchHandler.TouchMode enum instead.",
73+
),
5474
)
5575

5676

57-
availableTouchModes = ["text", "object"]
77+
class TouchMode(DisplayStringStrEnum):
78+
"""Available touch screen navigation modes."""
79+
80+
TEXT = "text"
81+
OBJECT = "object"
82+
BROWSE = "browse"
83+
84+
@cached_property
85+
def _displayStringLabels(self) -> dict[Self, str]:
86+
return {
87+
# Translators: The name of a touch mode.
88+
TouchMode.TEXT: _("text mode"),
89+
# Translators: The name of a touch mode.
90+
TouchMode.OBJECT: _("object mode"),
91+
# Translators: The name of a touch mode used when in browse mode.
92+
TouchMode.BROWSE: _("browse mode"),
93+
}
94+
5895

59-
touchModeLabels = {
60-
"text": _("text mode"),
61-
"object": _("object mode"),
62-
}
96+
availableTouchModes: list[TouchMode] = [TouchMode.TEXT, TouchMode.OBJECT]
6397

6498
HWND_MESSAGE = -3
6599

@@ -95,6 +129,30 @@
95129
POINTER_MESSAGE_FLAG_CANCELED = 0x400
96130

97131

132+
def _browseModeStateChange(
133+
browseMode: bool = False,
134+
interceptor: "browseMode.BrowseModeTreeInterceptor | None" = None,
135+
**kwargs,
136+
) -> None:
137+
if not handler:
138+
return
139+
140+
if browseMode:
141+
# Entering browse mode
142+
if TouchMode.BROWSE not in availableTouchModes:
143+
availableTouchModes.append(TouchMode.BROWSE)
144+
145+
handler._curTouchMode = TouchMode.BROWSE
146+
147+
else:
148+
# Leaving browse mode
149+
if TouchMode.BROWSE in availableTouchModes:
150+
availableTouchModes.remove(TouchMode.BROWSE)
151+
152+
if handler._curTouchMode == TouchMode.BROWSE:
153+
handler._curTouchMode = TouchMode.OBJECT
154+
155+
98156
class POINTER_INFO(Structure):
99157
_fields_ = [
100158
("pointerType", DWORD),
@@ -232,7 +290,7 @@ def getDisplayTextForIdentifier(cls, identifier):
232290
# Translators: a touch screen gesture
233291
source = _("Touch screen")
234292
if mode:
235-
source = "{source}, {mode}".format(source=source, mode=touchModeLabels[mode])
293+
source = "{source}, {mode}".format(source=source, mode=TouchMode(mode).displayString)
236294
return source, " + ".join(actions)
237295

238296
def _get__immediate(self):
@@ -249,7 +307,7 @@ class TouchHandler(threading.Thread):
249307
def __init__(self):
250308
self.pendingEmitsTimer = gui.NonReEntrantTimer(core.requestPump)
251309
super().__init__(name=f"{self.__class__.__module__}.{self.__class__.__qualname__}")
252-
self._curTouchMode = "object"
310+
self._curTouchMode = TouchMode.OBJECT
253311
self.initializedEvent = threading.Event()
254312
self.threadExc = None
255313
self.start()
@@ -333,7 +391,7 @@ def setMode(self, mode):
333391

334392
def pump(self):
335393
for preheldTracker, tracker in self.trackerManager.emitTrackers():
336-
gesture = TouchInputGesture(preheldTracker, tracker, self._curTouchMode)
394+
gesture = TouchInputGesture(preheldTracker, tracker, self._curTouchMode.value)
337395
try:
338396
inputCore.manager.executeGesture(gesture)
339397
except inputCore.NoInputGestureAction:
@@ -408,12 +466,14 @@ def initialize():
408466
% user32.GetSystemMetrics(SystemMetrics.MAXIMUM_TOUCHES),
409467
)
410468
config.post_configProfileSwitch.register(handlePostConfigProfileSwitch)
469+
post_browseModeStateChange.register(_browseModeStateChange)
411470
setTouchSupport(config.conf["touch"]["enabled"])
412471

413472

414473
def terminate():
415474
global handler
416475
config.post_configProfileSwitch.unregister(handlePostConfigProfileSwitch)
476+
post_browseModeStateChange.unregister(_browseModeStateChange)
417477
if handler:
418478
handler.terminate()
419479
handler = None

tests/checkPot.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@
4747
"column break",
4848
"background pattern {pattern}",
4949
"NVDA Speech Viewer",
50-
"text mode",
51-
"object mode",
5250
"NonVisual Desktop Access",
5351
"A free and open source screen reader for Microsoft Windows",
5452
"Copyright (C) {years} NVDA Contributors",

user_docs/en/changes.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
* A new command, assigned to `NVDA+x`, has been introduced to repeat the last information spoken by NVDA; pressing it twice shows it in a browseable message. (#625, @CyrilleB79)
1717
* Added an unassigned command to toggle keyboard layout. (#19211, @CyrilleB79)
1818
* Added an unassigned Quick Navigation Command for jumping to next/previous slider in browse mode. (#17005, @hdzrvcc0X74)
19+
* Added touch based navigation of browse mode elements, allowing touch screen users to move between links, headings, form fields, lists, tables and other quick navigation elements. (#3424, @kefaslungu)
20+
* Flick down or up to cycle through element types; flick right or left to navigate between elements of the selected type.
21+
* The element types shown when cycling can be configured in the Browse Mode settings panel.
1922
* Added support for custom speech dictionaries. (#19558, #17468, @LeonarddeR)
2023
* Dictionaries can be provided in the `speechDicts` folder in an add-on package.
2124
* Dictionary metadata can be added to an optional `speechDictionaries` section in the add-on manifest.

user_docs/en/userGuide.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -613,13 +613,13 @@ Therefore, gestures such as 2-finger flick up and 4-finger flick left are all po
613613
#### Touch Modes {#TouchModes}
614614

615615
As there are many more NVDA commands than possible touch gestures, NVDA has several touch modes you can switch between which make certain subsets of commands available.
616-
The two modes are text mode and object mode.
616+
The three modes are text mode, object mode and browse mode.
617617
Certain NVDA commands listed in this document may have a touch mode listed in brackets after the touch gesture.
618618
For example, flick up (text mode) means that the command will be performed if you flick up, but only while in text mode.
619619
If the command does not have a mode listed, it will work in any mode.
620620

621621
<!-- KC:beginInclude -->
622-
To toggle touch modes, perform a 3-finger tap.
622+
To switch between touch modes, perform a 3-finger tap.
623623
<!-- KC:endInclude -->
624624

625625
#### Touch keyboard {#TouchKeyboard}
@@ -1064,6 +1064,38 @@ If you want to use these while still being able to use your cursor keys to read
10641064
To toggle single letter navigation on and off for the current document, press NVDA+shift+space.
10651065
<!-- KC:endInclude -->
10661066

1067+
#### Touch Navigation in Browse Mode {#BrowseModeTouch}
1068+
1069+
When using a touch enabled device, NVDA provides an additional touch navigation mode for browsing content in browse mode.
1070+
1071+
When browse mode is active in supported documents such as web pages or Word documents, NVDA can expose a browse touch mode.
1072+
This mode allows users to navigate structural elements of a document using touch gestures, similar to browse mode navigation with the keyboard.
1073+
1074+
In browse touch mode, flick gestures are used to move between common document elements such as links, buttons, headings, form fields, landmarks, and other document structures.
1075+
1076+
This feature is intended to provide touch users with efficient, structured navigation that mirrors existing browse mode functionality.
1077+
1078+
##### Touch gestures in browse mode
1079+
1080+
<!-- KC:beginInclude -->
1081+
1082+
| Name | Touch | Description |
1083+
|---|---|---|
1084+
| Select next element type | flick down | Switches to the next browse mode navigation element type |
1085+
| Select previous element type | flick up | Switches to the previous browse mode navigation element type |
1086+
| Move to next element | flick right | Moves to the next browse mode element of the selected type |
1087+
| Move to previous element | flick left | Moves to the previous browse mode element of the selected type |
1088+
1089+
<!-- KC:endInclude -->
1090+
1091+
When the "default" element type is selected, flicking left or right moves through all elements in the document.
1092+
When any other element type is selected, flicking left or right moves to the previous or next element of that type.
1093+
Flicking up or down cycles through the available element types.
1094+
1095+
The selected element type is remembered separately for each document while it remains open.
1096+
Note that browse touch mode gestures only take effect when browse mode is active in the document.
1097+
If focus moves outside the document (for example, to the browser address bar or the taskbar), browse touch mode gestures will not navigate the document until focus returns to it in browse mode.
1098+
10671099
#### Text paragraph navigation command {#TextNavigationCommand}
10681100

10691101
You can jump to the next or previous text paragraph by pressing `p` or `shift+p`.
@@ -3317,6 +3349,15 @@ Enabled by default, this option allows you to choose if gestures (such as key pr
33173349
As an example, if enabled and the letter j was pressed, it would be trapped from reaching the document, even though it is not a quick navigation command nor is it likely to be a command in the application itself.
33183350
In this case NVDA will tell Windows to play a default sound whenever a key which gets trapped is pressed.
33193351

3352+
##### Browse mode touch navigation elements {#BrowseModeSettingsBrowseModeNavigationElements}
3353+
3354+
This list allows you to choose which element types are available when cycling through elements in browse touch mode.
3355+
Use the checkboxes to enable or disable individual element types.
3356+
Only the checked element types will appear when flicking up or down to cycle through browse mode navigation elements.
3357+
This setting only affects touch navigation and has no effect on keyboard browse mode navigation.
3358+
3359+
Available element types are those available from [single letter navigation](#SingleLetterNavigation).
3360+
33203361
#### Document Formatting {#DocumentFormattingSettings}
33213362

33223363
<!-- KC:setting -->

0 commit comments

Comments
 (0)