diff --git a/source/browseMode.py b/source/browseMode.py index 0e59273f698..a8cb791068b 100644 --- a/source/browseMode.py +++ b/source/browseMode.py @@ -1,6 +1,6 @@ # A part of NonVisual Desktop Access (NVDA) # Copyright (C) 2007-2026 NV Access Limited, Babbage B.V., James Teh, Leonard de Ruijter, -# Thomas Stivers, Accessolutions, Julien Cochuyt, Cyrille Bougot +# Thomas Stivers, Accessolutions, Julien Cochuyt, Cyrille Bougot, Kefas Lungu # This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. # For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt @@ -583,19 +583,23 @@ def addQuickNav( prevDoc: str, prevError: str, readUnit: str | None = None, + touchLabel: str | None = None, ): """Adds a script for the given quick nav item. - @param itemType: The type of item, I.E. "heading" "Link" ... - @param key: The quick navigation key to bind to the script. - Shift is automatically added for the previous item gesture. E.G. h for heading. - If C{None} is provided, the script is unbound by default. - @param nextDoc: The command description to bind to the script that yields the next quick nav item. - @param nextError: The error message if there are no more quick nav items of type itemType in this direction. - @param prevDoc: The command description to bind to the script that yields the previous quick nav item. - @param prevError: The error message if there are no more quick nav items of type itemType in this direction. - @param readUnit: The unit (one of the textInfos.UNIT_* constants) to announce when moving to this type of item. + + :param itemType: The type of item, e.g. ``"heading"``, ``"link"``. + :param key: The quick navigation key to bind to the script. + Shift is automatically added for the previous item gesture, e.g. ``h`` for heading. + If ``None``, the script is unbound by default. + :param nextDoc: The command description for the script that moves to the next quick nav item. + :param nextError: The error message if there are no more quick nav items of this type in the forward direction. + :param prevDoc: The command description for the script that moves to the previous quick nav item. + :param prevError: The error message if there are no more quick nav items of this type in the backward direction. + :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. + If ``None``, the entire item will be announced. + :param touchLabel: A short, translated, plural label for this element type used in browse mode touch navigation + cycling (e.g. ``_("links")``). If ``None``, the element type is not registered for browse mode touch navigation. """ scriptSuffix = itemType[0].upper() + itemType[1:] scriptName = "next%s" % scriptSuffix @@ -622,6 +626,8 @@ def addQuickNav( setattr(cls, funcName, script) if key is not None: cls.__gestures["kb:shift+%s" % key] = scriptName + if touchLabel is not None: + cls._browseTouchNavRegistry.append((itemType, touchLabel)) @classmethod def _addQuickNavHeading( @@ -649,6 +655,9 @@ 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), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + # {i} will be replaced with the heading level number. + touchLabel=_("headings level {i}").format(i=i), ) def script_elementsList(self, gesture): @@ -776,6 +785,103 @@ def _set_disableAutoPassThrough(self, state: bool): def _get_disableAutoPassThrough(self): return self._disableAutoPassThrough + #: Registry of (itemType, label) pairs populated dynamically by :meth:`addQuickNav`. + #: Do not modify directly; pass a touchLabel to :meth:`addQuickNav`. + _browseTouchNavRegistry: list[tuple[str, str]] = [] + + #: The itemType currently selected for browse mode touch navigation. None means "default" (all content). + #: Stored as an instance attribute so each document remembers its own preference. + _browseModeCurrentType: str | None = None + + def _enabledBrowseElements(self) -> list[tuple[str | None, str]]: + """Returns the list of (itemType, label) pairs available for browse mode touch navigation cycling. + + The ``None`` entry (navigate all content) is always first. + Remaining entries are those whose itemType appears in the + :confval:`virtualBuffers.browseModeTouchNavigationElements` config list. + + :return: List of (itemType, label) pairs, with ``None`` meaning "all content". + """ + enabledTypes = set(config.conf["virtualBuffers"]["browseModeTouchNavigationElements"]) + # Translators: The default element type in browse mode touch navigation (navigates all content). + result: list[tuple[str | None, str]] = [(None, _("default"))] + for itemType, label in type(self)._browseTouchNavRegistry: + if itemType in enabledTypes: + result.append((itemType, label)) + return result + + @script( + description=_( + # Translators: Input help message for a browse mode touch navigation command in browse mode. + "Selects the next element type for browse mode touch navigation", + ), + category=inputCore.SCRCAT_BROWSEMODE, + gesture="ts(browse):flickDown", + ) + def script_nextBrowseElement(self, gesture: inputCore.InputGesture) -> None: + enabled = self._enabledBrowseElements() + types = [itemType for itemType, _label in enabled] + try: + idx = types.index(self._browseModeCurrentType) + except ValueError: + idx = -1 + idx = (idx + 1) % len(enabled) + self._browseModeCurrentType = enabled[idx][0] + ui.message(enabled[idx][1]) + + @script( + description=_( + # Translators: Input help message for a browse mode touch navigation command in browse mode. + "Selects the previous element type for browse mode touch navigation", + ), + category=inputCore.SCRCAT_BROWSEMODE, + gesture="ts(browse):flickUp", + ) + def script_prevBrowseElement(self, gesture: inputCore.InputGesture) -> None: + enabled = self._enabledBrowseElements() + types = [itemType for itemType, _label in enabled] + try: + idx = types.index(self._browseModeCurrentType) + except ValueError: + idx = 0 + idx = (idx - 1) % len(enabled) + self._browseModeCurrentType = enabled[idx][0] + ui.message(enabled[idx][1]) + + @script( + description=_( + # Translators: Input help message for a browse mode touch navigation command in browse mode. + "Moves to the next element of the selected type in browse mode touch navigation", + ), + category=inputCore.SCRCAT_BROWSEMODE, + gesture="ts(browse):flickRight", + ) + def script_nextSelectedElement(self, gesture: inputCore.InputGesture) -> None: + itemType = self._browseModeCurrentType + if itemType is None: + import globalCommands + + globalCommands.commands.script_navigatorObject_nextInFlow(gesture) + else: + getattr(self, f"script_next{itemType[0].upper()}{itemType[1:]}")(gesture) + + @script( + description=_( + # Translators: Input help message for a browse mode touch navigation command in browse mode. + "Moves to the previous element of the selected type in browse mode touch navigation", + ), + category=inputCore.SCRCAT_BROWSEMODE, + gesture="ts(browse):flickLeft", + ) + def script_prevSelectedElement(self, gesture: inputCore.InputGesture) -> None: + itemType = self._browseModeCurrentType + if itemType is None: + import globalCommands + + globalCommands.commands.script_navigatorObject_previousInFlow(gesture) + else: + getattr(self, f"script_previous{itemType[0].upper()}{itemType[1:]}")(gesture) + __gestures = { "kb:NVDA+f7": "elementsList", "kb:enter": "activatePosition", @@ -810,6 +916,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous heading"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous heading"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("headings"), ) BrowseModeTreeInterceptor._addQuickNavHeading(range(1, 10)) qn( @@ -824,6 +932,8 @@ def _get_disableAutoPassThrough(self): # Translators: Message presented when the browse mode element is not found. prevError=_("no previous table"), readUnit=textInfos.UNIT_LINE, + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("tables"), ) qn( "link", @@ -836,6 +946,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous link"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous link"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("links"), ) qn( "visitedLink", @@ -848,6 +960,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous visited link"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous visited link"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("visited links"), ) qn( "unvisitedLink", @@ -860,6 +974,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous unvisited link"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous unvisited link"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("unvisited links"), ) qn( "formField", @@ -872,6 +988,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous form field"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous form field"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("form fields"), ) qn( "list", @@ -885,6 +1003,8 @@ def _get_disableAutoPassThrough(self): # Translators: Message presented when the browse mode element is not found. prevError=_("no previous list"), readUnit=textInfos.UNIT_LINE, + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("lists"), ) qn( "listItem", @@ -897,6 +1017,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous list item"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous list item"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("list items"), ) qn( "button", @@ -909,6 +1031,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous button"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous button"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("buttons"), ) qn( "edit", @@ -922,6 +1046,8 @@ def _get_disableAutoPassThrough(self): # Translators: Message presented when the browse mode element is not found. prevError=_("no previous edit field"), readUnit=textInfos.UNIT_LINE, + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("edit fields"), ) qn( "frame", @@ -935,6 +1061,8 @@ def _get_disableAutoPassThrough(self): # Translators: Message presented when the browse mode element is not found. prevError=_("no previous frame"), readUnit=textInfos.UNIT_LINE, + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("frames"), ) qn( "separator", @@ -947,6 +1075,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous separator"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous separator"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("separators"), ) qn( "radioButton", @@ -959,6 +1089,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous radio button"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous radio button"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("radio buttons"), ) qn( "comboBox", @@ -971,6 +1103,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous combo box"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous combo box"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("combo boxes"), ) qn( "checkBox", @@ -983,6 +1117,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous check box"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous check box"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("check boxes"), ) qn( "graphic", @@ -995,6 +1131,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous graphic"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous graphic"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("graphics"), ) qn( "blockQuote", @@ -1007,6 +1145,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous block quote"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous block quote"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("block quotes"), ) qn( "notLinkBlock", @@ -1020,6 +1160,8 @@ def _get_disableAutoPassThrough(self): # Translators: Message presented when the browse mode element is not found. prevError=_("no more text before a block of links"), readUnit=textInfos.UNIT_LINE, + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("non-link blocks"), ) qn( "landmark", @@ -1033,6 +1175,8 @@ def _get_disableAutoPassThrough(self): # Translators: Message presented when the browse mode element is not found. prevError=_("no previous landmark"), readUnit=textInfos.UNIT_LINE, + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("landmarks"), ) qn( "embeddedObject", @@ -1045,6 +1189,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous embedded object"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous embedded object"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("embedded objects"), ) qn( "annotation", @@ -1057,6 +1203,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous annotation"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous annotation"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("annotations"), ) qn( "error", @@ -1069,6 +1217,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous error"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous error"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("errors"), ) qn( "slider", @@ -1081,6 +1231,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous slider"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous slider"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("sliders"), ) qn( "article", @@ -1093,6 +1245,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous article"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous article"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("articles"), ) qn( "grouping", @@ -1105,6 +1259,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous grouping"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous grouping"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("groupings"), ) qn( "tab", @@ -1117,6 +1273,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous tab"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous tab"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("tabs"), ) qn( "figure", @@ -1129,6 +1287,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous figure"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous figure"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("figures"), ) qn( "menuItem", @@ -1141,6 +1301,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous menu item"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous menu item"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("menu items"), ) qn( "toggleButton", @@ -1153,6 +1315,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous toggle button"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous toggle button"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("toggle buttons"), ) qn( "progressBar", @@ -1165,6 +1329,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous progress bar"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous progress bar"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("progress bars"), ) qn( "math", @@ -1177,6 +1343,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous math formula"), # Translators: Message presented when the browse mode element is not found. prevError=_("no previous math formula"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("math formulas"), ) qn( "textParagraph", @@ -1190,6 +1358,8 @@ def _get_disableAutoPassThrough(self): # Translators: Message presented when the browse mode element is not found. prevError=_("no previous text paragraph"), readUnit=textInfos.UNIT_PARAGRAPH, + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("text paragraphs"), ) qn( "verticalParagraph", @@ -1203,6 +1373,8 @@ def _get_disableAutoPassThrough(self): # Translators: Message presented when the browse mode element is not found. prevError=_("no previous vertically aligned paragraph"), readUnit=textInfos.UNIT_PARAGRAPH, + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("vertical paragraphs"), ) qn( "sameStyle", @@ -1215,6 +1387,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous same style text"), # Translators: Message presented when the browse mode element is not found. prevError=_("No previous same style text"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("same style"), ) qn( "differentStyle", @@ -1227,6 +1401,8 @@ def _get_disableAutoPassThrough(self): prevDoc=_("moves to the previous different style text"), # Translators: Message presented when the browse mode element is not found. prevError=_("No previous different style text"), + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("different style"), ) qn( "reference", @@ -1240,9 +1416,16 @@ def _get_disableAutoPassThrough(self): # Translators: Message presented when the browse mode element is not found. prevError=_("no previous reference"), readUnit=textInfos.UNIT_WORD, + # Translators: Label announced when cycling browse mode touch navigation element types in browse mode. + touchLabel=_("references"), ) del qn +# Build _browseModeElements dynamically from the registry populated by addQuickNav calls above. +BrowseModeTreeInterceptor._browseModeElements: tuple[tuple[str, str], ...] = ( + *BrowseModeTreeInterceptor._browseTouchNavRegistry, +) + class ElementsListDialog( DpiScalingHelperMixinWithoutInit, diff --git a/source/config/configSpec.py b/source/config/configSpec.py index a425c3a566b..d5202ee7d14 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -1,7 +1,7 @@ # A part of NonVisual Desktop Access (NVDA) # Copyright (C) 2006-2026 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt, # Joseph Lee, Dawid Pieper, mltony, Bram Duvigneau, Cyrille Bougot, Rob Meredith, -# Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka, Cary-rowen +# Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka, Cary-rowen, Kefas Lungu # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -215,6 +215,8 @@ enableOnPageLoad = boolean(default=true) loadChromiumVBufOnBusyState = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled") textParagraphRegex = string(default="{configDefaults.DEFAULT_TEXT_PARAGRAPH_REGEX}") + # Element types available for cycling in browse touch mode. + browseModeTouchNavigationElements = string_list(default=list("heading", "link", "formField", "list", "table")) [touch] enabled = boolean(default=true) diff --git a/source/globalCommands.py b/source/globalCommands.py index d09e4c57349..ca2982707af 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4582,12 +4582,7 @@ def script_touch_changeMode(self, gesture): index = (index + 1) % len(touchHandler.availableTouchModes) newMode = touchHandler.availableTouchModes[index] touchHandler.handler._curTouchMode = newMode - try: - newModeLabel = touchHandler.touchModeLabels[newMode] - except KeyError: - # 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). - newModeLabel = _("%s mode") % newMode - ui.message(newModeLabel) + ui.message(newMode.displayString) @script( # Translators: Input help mode message for a touchscreen gesture. diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 18d01757c26..5dfd2ba525f 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -6,7 +6,7 @@ # Łukasz Golonka, Aaron Cannon, Adriani90, André-Abush Clause, Dawid Pieper, # Takuya Nishimoto, jakubl7545, Tony Malykh, Rob Meredith, # Burman's Computer and Education Ltd, hwf1324, Cary-rowen, Christopher Proß, Tianze -# Neil Soiffer, Ryan McCleary. +# Neil Soiffer, Ryan McCleary, Kefas Lungu. # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -2664,6 +2664,23 @@ def makeSettings(self, settingsSizer): ) self.trapNonCommandGesturesCheckBox.SetValue(config.conf["virtualBuffers"]["trapNonCommandGestures"]) + # browseMode imports gui, which imports from settingsDialogs, so a top-level import + # would create a circular dependency. Keep this import lazy. + import browseMode + + # Store element types for use in onSave (excludes the always-active "default" entry). + self._browseModeElements = list(browseMode.BrowseModeTreeInterceptor._browseTouchNavRegistry) + self._browseModeCheckListBox: nvdaControls.CustomCheckListBox = sHelper.addLabeledControl( + # Translators: Label for the list of browse mode touch navigation element types in browse mode settings. + _("T&ouch navigation elements:"), + nvdaControls.CustomCheckListBox, + choices=[label for _itemType, label in self._browseModeElements], + ) + self._browseModeCheckListBox.Enable(touchHandler.touchSupported()) + enabledTypes = set(config.conf["virtualBuffers"]["browseModeTouchNavigationElements"]) + for i, (itemType, _label) in enumerate(self._browseModeElements): + self._browseModeCheckListBox.Check(i, itemType in enabledTypes) + def onSave(self): config.conf["virtualBuffers"]["maxLineLength"] = self.maxLengthEdit.GetValue() config.conf["virtualBuffers"]["linesPerPage"] = self.pageLinesEdit.GetValue() @@ -2683,6 +2700,11 @@ def onSave(self): config.conf["virtualBuffers"]["trapNonCommandGestures"] = ( self.trapNonCommandGesturesCheckBox.IsChecked() ) + config.conf["virtualBuffers"]["browseModeTouchNavigationElements"] = [ + itemType + for i, (itemType, _label) in enumerate(self._browseModeElements) + if self._browseModeCheckListBox.IsChecked(i) + ] class MathSettingsPanel(SettingsPanel): diff --git a/source/touchHandler.py b/source/touchHandler.py index df15267d9c9..b0b177a3410 100644 --- a/source/touchHandler.py +++ b/source/touchHandler.py @@ -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-2026 NV Access Limited, Joseph Lee, Babbage B.V., Kefas Lungu """handles touchscreen interaction. Used to provide input gestures for touchscreens, touch modes and other support facilities. @@ -9,6 +9,15 @@ """ import threading +from functools import cached_property +from typing import ( + TYPE_CHECKING, + Self, +) + +if TYPE_CHECKING: + import browseMode + from ctypes import ( byref, Structure, @@ -43,6 +52,8 @@ import core import systemUtils from utils import _deprecate +from utils.displayString import DisplayStringStrEnum +from treeInterceptorHandler import post_browseModeStateChange __getattr__ = _deprecate.handleDeprecations( _deprecate.MovedSymbol( @@ -51,15 +62,38 @@ "SystemMetrics", "MAXIMUM_TOUCHES", ), + _deprecate.RemovedSymbol( + "touchModeLabels", + { + "text": _("text mode"), + "object": _("object mode"), + "browse": _("browse mode"), + }, + message="Use touchHandler.TouchMode enum instead.", + ), ) -availableTouchModes = ["text", "object"] +class TouchMode(DisplayStringStrEnum): + """Available touch screen navigation modes.""" + + TEXT = "text" + OBJECT = "object" + BROWSE = "browse" + + @cached_property + def _displayStringLabels(self) -> dict[Self, str]: + return { + # Translators: The name of a touch mode. + TouchMode.TEXT: _("text mode"), + # Translators: The name of a touch mode. + TouchMode.OBJECT: _("object mode"), + # Translators: The name of a touch mode used when in browse mode. + TouchMode.BROWSE: _("browse mode"), + } + -touchModeLabels = { - "text": _("text mode"), - "object": _("object mode"), -} +availableTouchModes: list[TouchMode] = [TouchMode.TEXT, TouchMode.OBJECT] HWND_MESSAGE = -3 @@ -95,6 +129,30 @@ POINTER_MESSAGE_FLAG_CANCELED = 0x400 +def _browseModeStateChange( + browseMode: bool = False, + interceptor: "browseMode.BrowseModeTreeInterceptor | None" = None, + **kwargs, +) -> None: + if not handler: + return + + if browseMode: + # Entering browse mode + if TouchMode.BROWSE not in availableTouchModes: + availableTouchModes.append(TouchMode.BROWSE) + + handler._curTouchMode = TouchMode.BROWSE + + else: + # Leaving browse mode + if TouchMode.BROWSE in availableTouchModes: + availableTouchModes.remove(TouchMode.BROWSE) + + if handler._curTouchMode == TouchMode.BROWSE: + handler._curTouchMode = TouchMode.OBJECT + + class POINTER_INFO(Structure): _fields_ = [ ("pointerType", DWORD), @@ -232,7 +290,7 @@ def getDisplayTextForIdentifier(cls, identifier): # Translators: a touch screen gesture source = _("Touch screen") if mode: - source = "{source}, {mode}".format(source=source, mode=touchModeLabels[mode]) + source = "{source}, {mode}".format(source=source, mode=TouchMode(mode).displayString) return source, " + ".join(actions) def _get__immediate(self): @@ -249,7 +307,7 @@ class TouchHandler(threading.Thread): def __init__(self): self.pendingEmitsTimer = gui.NonReEntrantTimer(core.requestPump) super().__init__(name=f"{self.__class__.__module__}.{self.__class__.__qualname__}") - self._curTouchMode = "object" + self._curTouchMode = TouchMode.OBJECT self.initializedEvent = threading.Event() self.threadExc = None self.start() @@ -333,7 +391,7 @@ def setMode(self, mode): def pump(self): for preheldTracker, tracker in self.trackerManager.emitTrackers(): - gesture = TouchInputGesture(preheldTracker, tracker, self._curTouchMode) + gesture = TouchInputGesture(preheldTracker, tracker, self._curTouchMode.value) try: inputCore.manager.executeGesture(gesture) except inputCore.NoInputGestureAction: @@ -408,12 +466,14 @@ def initialize(): % user32.GetSystemMetrics(SystemMetrics.MAXIMUM_TOUCHES), ) config.post_configProfileSwitch.register(handlePostConfigProfileSwitch) + post_browseModeStateChange.register(_browseModeStateChange) setTouchSupport(config.conf["touch"]["enabled"]) def terminate(): global handler config.post_configProfileSwitch.unregister(handlePostConfigProfileSwitch) + post_browseModeStateChange.unregister(_browseModeStateChange) if handler: handler.terminate() handler = None diff --git a/tests/checkPot.py b/tests/checkPot.py index 2e75c05da35..4344fcc9fe2 100644 --- a/tests/checkPot.py +++ b/tests/checkPot.py @@ -47,8 +47,6 @@ "column break", "background pattern {pattern}", "NVDA Speech Viewer", - "text mode", - "object mode", "NonVisual Desktop Access", "A free and open source screen reader for Microsoft Windows", "Copyright (C) {years} NVDA Contributors", diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index c16ab4fbee2..ceaacbf8d7f 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -15,6 +15,9 @@ * 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 browse mode elements, allowing touch screen users to move between links, headings, form fields, lists, tables and other quick navigation elements. (#3424, @kefaslungu) + * Flick down or up to cycle through element types; flick right or left to navigate between elements of the selected type. + * The element types shown when cycling can be configured in the Browse Mode settings panel. * Added support for custom speech dictionaries. (#19558, #17468, @LeonarddeR) * Dictionaries can be provided in the `speechDicts` folder in an add-on package. * Dictionary metadata can be added to an optional `speechDictionaries` section in the add-on manifest. diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index b4ed87ee2ef..7b4a9830ea9 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -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 browse 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. -To toggle touch modes, perform a 3-finger tap. +To switch between touch modes, perform a 3-finger tap. #### Touch keyboard {#TouchKeyboard} @@ -1050,6 +1050,38 @@ 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. +#### Touch Navigation in Browse Mode {#BrowseModeTouch} + +When using a touch enabled device, NVDA provides an additional touch navigation mode for browsing content in browse mode. + +When browse mode is active in supported documents such as web pages or Word documents, NVDA can expose a browse 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 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. + +This feature is intended to provide touch users with efficient, structured navigation that mirrors existing browse mode functionality. + +##### Touch gestures in browse mode + + + +| Name | Touch | Description | +|---|---|---| +| Select next element type | flick down | Switches to the next browse mode navigation element type | +| Select previous element type | flick up | Switches to the previous browse mode navigation element type | +| Move to next element | flick right | Moves to the next browse mode element of the selected type | +| Move to previous element | flick left | Moves to the previous browse mode element of the selected type | + + + +When the "default" element type is selected, flicking left or right moves through all elements in the document. +When any other element type is selected, flicking left or right moves to the previous or next element of that type. +Flicking up or down cycles through the available element types. + +The selected element type is remembered separately for each document while it remains open. +Note that browse touch mode gestures only take effect when browse mode is active in the document. +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. + #### Text paragraph navigation command {#TextNavigationCommand} You can jump to the next or previous text paragraph by pressing `p` or `shift+p`. @@ -3292,6 +3324,15 @@ Enabled by default, this option allows you to choose if gestures (such as key pr 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. In this case NVDA will tell Windows to play a default sound whenever a key which gets trapped is pressed. +##### Browse mode touch navigation elements {#BrowseModeSettingsBrowseModeNavigationElements} + +This list allows you to choose which element types are available when cycling through elements in browse touch mode. +Use the checkboxes to enable or disable individual element types. +Only the checked element types will appear when flicking up or down to cycle through browse mode navigation elements. +This setting only affects touch navigation and has no effect on keyboard browse mode navigation. + +Available element types are those available from [single letter navigation](#SingleLetterNavigation). + #### Document Formatting {#DocumentFormattingSettings}