Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b6fd89a
Added Web navigation to core
kefaslungu Jan 3, 2026
b4e9bd4
Pre-commit auto-fix
pre-commit-ci[bot] Jan 3, 2026
62eb77f
Added relevant user documentation and changes
kefaslungu Jan 3, 2026
daf47b4
Added name
kefaslungu Jan 3, 2026
8fe2f73
Merge branch 'webmode' of https://github.com/kefaslungu/nvda into web…
kefaslungu Jan 3, 2026
82043fa
Move web touch navigation into BrowseModeTreeInterceptor with dynamic…
kefaslungu Feb 19, 2026
6030aba
Merge branch 'master' into webmode
kefaslungu Feb 19, 2026
de05928
Pre-commit auto-fix
pre-commit-ci[bot] Feb 19, 2026
266c625
Addressed reviewer's comments
kefaslungu Feb 23, 2026
6a93eed
Merge branch 'webmode' of https://github.com/kefaslungu/nvda into web…
kefaslungu Feb 23, 2026
a68cdf3
Pre-commit auto-fix
pre-commit-ci[bot] Feb 23, 2026
d670765
Added documentation and minor fixes
kefaslungu Feb 27, 2026
3707430
Merge branch 'webmode' of https://github.com/kefaslungu/nvda into web…
kefaslungu Feb 27, 2026
4052b06
Fix type hint where necessary
kefaslungu Feb 27, 2026
152190a
Changed from webmode to browsemode
kefaslungu Mar 2, 2026
1fffa9e
Made edits to user docs and changes.md
kefaslungu Mar 2, 2026
c5ce667
rename web→browse, type hints, deprecation
kefaslungu Mar 2, 2026
5703f06
sentence split onto two lines in user docs and remove unnecessary imp…
kefaslungu Mar 4, 2026
7649ef3
Added documentation explaining browse mode in touch is only active wh…
kefaslungu Mar 5, 2026
3028100
updated docs and changes
kefaslungu Mar 9, 2026
53830c4
- Type annotate _enabledBrowseElements; Sphinx docstring
kefaslungu Mar 9, 2026
8af69b1
Merge upstream/master into webmode
kefaslungu Mar 9, 2026
8a9b3f2
changing the enum values to shorter strings, making _curTouchMode sto…
kefaslungu Mar 10, 2026
1c2a459
Fixed reviewers comments
kefaslungu Mar 10, 2026
3745f52
renamed AVAILABLE_TOUCH_MODES to availableTouchModes
kefaslungu Mar 11, 2026
f11f808
docs: Explicitly states the element type is remembered per document
kefaslungu Mar 24, 2026
f612318
translator comment now says browse mode touch navigation element types
kefaslungu Mar 24, 2026
6a7ba8e
clarifying comment added above the setting
kefaslungu Mar 24, 2026
9e237ef
addQuickNav docstring converted fully to Sphinx style
kefaslungu Mar 24, 2026
74614a2
Apply suggestions from code review
SaschaCowley Mar 30, 2026
ac048ae
Fixed reviewers suggestions
kefaslungu Mar 31, 2026
5690479
Merge branch 'webmode' of https://github.com/kefaslungu/nvda into web…
kefaslungu Mar 31, 2026
f0067e6
Update user_docs/en/userGuide.md
SaschaCowley Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions source/browseMode.py
Comment thread
kefaslungu marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ def addQuickNav(
prevDoc: str,
prevError: str,
readUnit: Optional[str] = None,
includeInWebTouch: bool = True,
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
):
"""Adds a script for the given quick nav item.
@param itemType: The type of item, I.E. "heading" "Link" ...
Expand All @@ -601,6 +602,8 @@ def addQuickNav(
@param readUnit: The unit (one of the textInfos.UNIT_* constants) to announce when moving to this type of item.
For example, only the line is read when moving to tables to avoid reading a potentially massive table.
If None, the entire item will be announced.
@param includeInWebTouch: If True, register this element type for web touch navigation cycling.
Set to False for sub-types (e.g. heading levels) that are already covered by a parent type.
"""
scriptSuffix = itemType[0].upper() + itemType[1:]
scriptName = "next%s" % scriptSuffix
Expand All @@ -627,6 +630,20 @@ def addQuickNav(
setattr(cls, funcName, script)
if key is not None:
cls.__gestures["kb:shift+%s" % key] = scriptName
if includeInWebTouch:
# Derive a short human-readable label from the camelCase itemType (e.g. "formField" -> "form field").
# Some itemTypes produce inadequate labels via camelCase splitting alone; override them.
_webTouchLabelOverrides = {
"edit": "edit field",
}
touchLabel = re.sub(r"([A-Z])", r" \1", itemType).lower().strip()
touchLabel = _webTouchLabelOverrides.get(touchLabel, touchLabel)
# Pluralise: words ending in "x" (e.g. "combo box") take "es"; all others take "s".
if touchLabel.endswith("x"):
touchLabel += "es"
else:
touchLabel += "s"
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
cls._webTouchNavRegistry.append((itemType, touchLabel))

@classmethod
def _addQuickNavHeading(
Expand Down Expand Up @@ -654,6 +671,8 @@ def _addQuickNavHeading(
# Translators: Message presented when the browse mode element is not found.
# {i} will be replaced with the level number.
prevError=_("No previous heading at level {i}").format(i=i),
# Heading levels are sub-types of "heading"; exclude from web touch navigation cycling.
includeInWebTouch=False,
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
)

def script_elementsList(self, gesture):
Expand Down Expand Up @@ -781,6 +800,98 @@ def _set_disableAutoPassThrough(self, state: bool):
def _get_disableAutoPassThrough(self):
return self._disableAutoPassThrough

#: Registry of (itemType, label) pairs populated dynamically by addQuickNav.
#: Do not modify directly; use addQuickNav with includeInWebTouch=True.
#: _webBrowseElements is built from this after all addQuickNav calls complete.
_webTouchNavRegistry: list = []
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

#: The itemType currently selected for web touch navigation. None means "default" (all content).
_webBrowseCurrentType = None
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

def _enabledWebElements(self):
"""Returns the subset of L{_webBrowseElements} that the user has enabled in settings."""
enabled = [
(itemType, label)
for itemType, label in self._webBrowseElements
if config.conf["virtualBuffers"].get(_webElementConfigKey(itemType), True)
]
# Always keep at least the first entry ("default") to prevent an empty list.
return enabled if enabled else [self._webBrowseElements[0]]

@script(
description=_(
# Translators: Input help message for a web touch navigation command in browse mode.
"Selects the next element type for web touch navigation",
),
category=inputCore.SCRCAT_BROWSEMODE,
gesture="ts(Web):flickDown",
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
)
def script_nextWebElement(self, gesture):
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
enabled = self._enabledWebElements()
types = [itemType for itemType, _label in enabled]
try:
idx = types.index(type(self)._webBrowseCurrentType)
except ValueError:
idx = -1
idx = (idx + 1) % len(enabled)
type(self)._webBrowseCurrentType = enabled[idx][0]
ui.message(enabled[idx][1])

@script(
description=_(
# Translators: Input help message for a web touch navigation command in browse mode.
"Selects the previous element type for web touch navigation",
),
category=inputCore.SCRCAT_BROWSEMODE,
gesture="ts(Web):flickUp",
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
Comment thread
seanbudd marked this conversation as resolved.
Outdated
)
def script_prevWebElement(self, gesture):
enabled = self._enabledWebElements()
types = [itemType for itemType, _label in enabled]
try:
idx = types.index(type(self)._webBrowseCurrentType)
except ValueError:
idx = 0
idx = (idx - 1) % len(enabled)
type(self)._webBrowseCurrentType = enabled[idx][0]
ui.message(enabled[idx][1])

@script(
description=_(
# Translators: Input help message for a web touch navigation command in browse mode.
"Moves to the next element of the selected type in web touch navigation",
),
category=inputCore.SCRCAT_BROWSEMODE,
gesture="ts(Web):flickRight",
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
)
def script_nextSelectedElement(self, gesture):
itemType = type(self)._webBrowseCurrentType
if itemType is None:
import globalCommands

globalCommands.commands.script_navigatorObject_nextInFlow(gesture)
else:
scriptSuffix = itemType[0].upper() + itemType[1:]
getattr(self, "script_next%s" % scriptSuffix)(gesture)
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

@script(
description=_(
# Translators: Input help message for a web touch navigation command in browse mode.
"Moves to the previous element of the selected type in web touch navigation",
),
category=inputCore.SCRCAT_BROWSEMODE,
gesture="ts(Web):flickLeft",
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
)
def script_prevSelectedElement(self, gesture):
itemType = type(self)._webBrowseCurrentType
if itemType is None:
import globalCommands

globalCommands.commands.script_navigatorObject_previousInFlow(gesture)
else:
scriptSuffix = itemType[0].upper() + itemType[1:]
getattr(self, "script_previous%s" % scriptSuffix)(gesture)
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

__gestures = {
"kb:NVDA+f7": "elementsList",
"kb:enter": "activatePosition",
Expand Down Expand Up @@ -1249,6 +1360,22 @@ def _get_disableAutoPassThrough(self):
del qn


def _webElementConfigKey(itemType: Optional[str]) -> str:
"""Returns the virtualBuffers config key for a web touch navigation element type.
@param itemType: The element type string (e.g. "link", "heading"), or None for the default mode.
"""
suffix = "Default" if itemType is None else itemType[0].upper() + itemType[1:]
return "webTouchNavigate%s" % suffix


# Build _webBrowseElements dynamically from the registry populated by addQuickNav calls above.
# The "default" entry (navigate all content) is always prepended.
BrowseModeTreeInterceptor._webBrowseElements = (
# Translators: The default element type in browse mode web touch navigation (navigates all content).
(None, _("default")),
) + tuple(BrowseModeTreeInterceptor._webTouchNavRegistry)


class ElementsListDialog(
DpiScalingHelperMixinWithoutInit,
gui.contextHelp.ContextHelpMixin,
Expand Down
37 changes: 37 additions & 0 deletions source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,43 @@
enableOnPageLoad = boolean(default=true)
loadChromiumVBufOnBusyState = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled")
textParagraphRegex = string(default="{configDefaults.DEFAULT_TEXT_PARAGRAPH_REGEX}")
webTouchNavigateDefault = boolean(default=true)
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
webTouchNavigateHeading = boolean(default=true)
webTouchNavigateTable = boolean(default=true)
webTouchNavigateLink = boolean(default=true)
webTouchNavigateVisitedLink = boolean(default=true)
webTouchNavigateUnvisitedLink = boolean(default=true)
webTouchNavigateFormField = boolean(default=true)
webTouchNavigateList = boolean(default=true)
webTouchNavigateListItem = boolean(default=true)
webTouchNavigateButton = boolean(default=true)
webTouchNavigateEdit = boolean(default=true)
webTouchNavigateFrame = boolean(default=true)
webTouchNavigateSeparator = boolean(default=true)
webTouchNavigateRadioButton = boolean(default=true)
webTouchNavigateComboBox = boolean(default=true)
webTouchNavigateCheckBox = boolean(default=true)
webTouchNavigateGraphic = boolean(default=true)
webTouchNavigateBlockQuote = boolean(default=true)
webTouchNavigateNotLinkBlock = boolean(default=true)
webTouchNavigateLandmark = boolean(default=true)
webTouchNavigateEmbeddedObject = boolean(default=true)
webTouchNavigateAnnotation = boolean(default=true)
webTouchNavigateError = boolean(default=true)
webTouchNavigateSlider = boolean(default=true)
webTouchNavigateArticle = boolean(default=true)
webTouchNavigateGrouping = boolean(default=true)
webTouchNavigateTab = boolean(default=true)
webTouchNavigateFigure = boolean(default=true)
webTouchNavigateMenuItem = boolean(default=true)
webTouchNavigateToggleButton = boolean(default=true)
webTouchNavigateProgressBar = boolean(default=true)
webTouchNavigateMath = boolean(default=true)
webTouchNavigateTextParagraph = boolean(default=true)
webTouchNavigateVerticalParagraph = boolean(default=true)
webTouchNavigateSameStyle = boolean(default=true)
webTouchNavigateDifferentStyle = boolean(default=true)
webTouchNavigateReference = boolean(default=true)
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

[touch]
enabled = boolean(default=true)
Expand Down
2 changes: 1 addition & 1 deletion source/globalCommands.py
Comment thread
kefaslungu marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# Copyright (C) 2006-2026 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Rui Batista, Joseph Lee,
# Leonard de Ruijter, Derek Riemer, Babbage B.V., Davy Kager, Ethan Holliger, Łukasz Golonka, Accessolutions,
# Julien Cochuyt, Jakub Lukowicz, Bill Dengler, Cyrille Bougot, Rob Meredith, Luke Davis,
# Burman's Computer and Education Ltd, Cary-rowen.
# Burman's Computer and Education Ltd, Cary-rowen, Kefas Lungu.
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

import itertools
from typing import (
Expand Down
21 changes: 21 additions & 0 deletions source/gui/settingsDialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2640,6 +2640,21 @@ def makeSettings(self, settingsSizer):
)
self.trapNonCommandGesturesCheckBox.SetValue(config.conf["virtualBuffers"]["trapNonCommandGestures"])

import browseMode
Comment thread
kefaslungu marked this conversation as resolved.

# Translators: Label for the web touch navigation sub-section in browse mode settings.
webTouchGroupText = _("Web touch navigation elements (touchscreens only)")
webTouchGroupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=webTouchGroupText)
webTouchGroupBox = webTouchGroupSizer.GetStaticBox()
webTouchGroup = guiHelper.BoxSizerHelper(self, sizer=webTouchGroupSizer)
sHelper.addItem(webTouchGroup)
self._webElementCheckboxes = {}
for itemType, label in browseMode.BrowseModeTreeInterceptor._webBrowseElements:
cb = webTouchGroup.addItem(wx.CheckBox(webTouchGroupBox, label=label))
configKey = browseMode._webElementConfigKey(itemType)
cb.SetValue(config.conf["virtualBuffers"].get(configKey, True))
self._webElementCheckboxes[itemType] = cb
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

def onSave(self):
config.conf["virtualBuffers"]["maxLineLength"] = self.maxLengthEdit.GetValue()
config.conf["virtualBuffers"]["linesPerPage"] = self.pageLinesEdit.GetValue()
Expand All @@ -2659,6 +2674,12 @@ def onSave(self):
config.conf["virtualBuffers"]["trapNonCommandGestures"] = (
self.trapNonCommandGesturesCheckBox.IsChecked()
)
import browseMode

for itemType, _label in browseMode.BrowseModeTreeInterceptor._webBrowseElements:
configKey = browseMode._webElementConfigKey(itemType)
if configKey in config.conf["virtualBuffers"]:
config.conf["virtualBuffers"][configKey] = self._webElementCheckboxes[itemType].IsChecked()


class MathSettingsPanel(SettingsPanel):
Expand Down
30 changes: 29 additions & 1 deletion source/touchHandler.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# A part of NonVisual Desktop Access (NVDA)
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# Copyright (C) 2012-2025 NV Access Limited, Joseph Lee, Babbage B.V.
# Copyright (C) 2012-2025 NV Access Limited, Joseph Lee, Babbage B.V., Kefas Lungu
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

"""handles touchscreen interaction.
Used to provide input gestures for touchscreens, touch modes and other support facilities.
Expand Down Expand Up @@ -43,6 +43,7 @@
import core
import systemUtils
from utils import _deprecate
from treeInterceptorHandler import post_browseModeStateChange

__getattr__ = _deprecate.handleDeprecations(
_deprecate.MovedSymbol(
Expand All @@ -59,6 +60,8 @@
touchModeLabels = {
"text": _("text mode"),
"object": _("object mode"),
# Translators: The name of a touch mode used when navigating web content in browse mode.
"web": _("web mode"),
}
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

HWND_MESSAGE = -3
Expand Down Expand Up @@ -95,6 +98,31 @@
POINTER_MESSAGE_FLAG_CANCELED = 0x400


def _browseModeStateChange(browseMode=False, interceptor=None, **kwargs):
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
if not handler:
return

webModeName = "web"
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

if browseMode:
# Entering browse mode
if webModeName not in availableTouchModes:
availableTouchModes.append(webModeName)
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

handler._curTouchMode = webModeName

Comment thread
seanbudd marked this conversation as resolved.
else:
# Leaving browse mode
if webModeName in availableTouchModes:
availableTouchModes.remove(webModeName)

if handler._curTouchMode == webModeName:
handler._curTouchMode = "object"


post_browseModeStateChange.register(_browseModeStateChange)
Comment thread
kefaslungu marked this conversation as resolved.
Outdated


class POINTER_INFO(Structure):
_fields_ = [
("pointerType", DWORD),
Expand Down
2 changes: 2 additions & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* 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)
* Added an unassigned command to toggle keyboard layout. (#19211, @CyrilleB79)
* Added an unassigned Quick Navigation Command for jumping to next/previous slider in browse mode. (#17005, @hdzrvcc0X74)
* Added touch based navigation of web elements in browse mode, allowing touch screen users to move between links, headings, form fields, landmarks and other structural elements. (#3424, #19414, @kefaslungu)
* Web elements can be added or removed from browse mode settings.
* New types have been added for Speech Dictionary entries, such as part of word and start of word.
Consult the speech dictionaries section in the User Guide for more details. (#19506, @LeonarddeR)
* When resetting the configuration to factory defaults from the NVDA menu, a dialog is now shown afterwards with an Undo button to restore the previous configuration.
Expand Down
31 changes: 29 additions & 2 deletions user_docs/en/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,13 +599,13 @@ Therefore, gestures such as 2-finger flick up and 4-finger flick left are all po
#### Touch Modes {#TouchModes}

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.
The two modes are text mode and object mode.
The three modes are text mode, object mode and web mode.
Certain NVDA commands listed in this document may have a touch mode listed in brackets after the touch gesture.
For example, flick up (text mode) means that the command will be performed if you flick up, but only while in text mode.
If the command does not have a mode listed, it will work in any mode.

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

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

#### Touch Navigation in Browse Mode {#BrowseModeTouch}

When using a touch enabled device, NVDA provides an additional touch navigation mode for browsing web content.

When browse mode is active in supported documents such as web pages, NVDA can expose a Web touch mode. This mode allows users to navigate structural elements of a document using touch gestures, similar to browse mode navigation with the keyboard.

In Web touch mode, flick gestures are used to move between common web elements such as links, buttons, headings, form fields, landmarks, and other document structures.

This feature is intended to provide touch users with efficient, structured navigation that mirrors existing browse mode functionality.

##### Touch gestures in Web mode
Comment thread
kefaslungu marked this conversation as resolved.
Outdated
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

<!-- KC:beginInclude -->

| Gesture | Description |
| ----------- | ---------------------------------------------------- |
| Flick down | Switches to the next web navigation element type |
| Flick up | Switches to the previous web navigation element type |
| Flick right | Moves to the next element of the selected type |
| Flick left | Moves to the previous element of the selected type |
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

<!-- KC:endInclude -->

When any element type is selected, flicking left or right moves through the document in reading order, while flicking up and down selects different element.
Comment thread
kefaslungu marked this conversation as resolved.
Outdated

Web touch mode is only available when browse mode is active and does not affect touch navigation outside of browse mode documents.

#### Text paragraph navigation command {#TextNavigationCommand}

You can jump to the next or previous text paragraph by pressing `p` or `shift+p`.
Expand Down