Skip to content

Commit

Permalink
Add ability to recognize links to the same page (#16994)
Browse files Browse the repository at this point in the history
Fixes #141

Summary of the issue:
NVDA cannot recognize when link destination points to the same document, and reporting this information is desired by many users.

Description of user facing changes
NVDA can report if a link destination points to the same document.

Description of development approach
A new INTERNAL_LINK state has been added to controlTypes.State.
NVDA object has a new linkType property, used to report internal (same page) links.
If the object is an internal link, the INTERNAL_LINKstate is added in _get_states, in IAccessible/IA2web and in UIA/Chromium.
In the gecko_ia2 virtual buffer backend, accValue is exposed for links.
In virtualBuffers.gecko_ia2.Gecko_ia2_TextInfo._normalizeControlField, the accValue is used to add the linkType state.
A configuration option has been added to enable or disable this feature. This can be changed through gui or with an unassigned gesture.
BrowseModeInterceptor object has a new documentUrl property and a new getLinkTypeInDocument method. This method uses a helper function of a new urlUtils submodule to check the provided URL.
  • Loading branch information
nvdaes authored Sep 6, 2024
1 parent 8cdd43a commit 21fbfa7
Show file tree
Hide file tree
Showing 17 changed files with 251 additions and 9 deletions.
14 changes: 10 additions & 4 deletions nvdaHelper/vbufBackends/gecko_ia2/gecko_ia2.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1000,10 +1000,16 @@ VBufStorage_fieldNode_t* GeckoVBufBackend_t::fillVBuf(
}

BSTR value=NULL;
if(pacc->get_accValue(varChild,&value)==S_OK) {
if(value&&SysStringLen(value)==0) {
SysFreeString(value);
value=NULL;
if (pacc->get_accValue(varChild, &value) == S_OK) {
if (value) {
if (role == ROLE_SYSTEM_LINK) {
// For links, store the IAccessible value to handle same page link detection.
parentNode->addAttribute(L"IAccessible::value", value);
}
if (SysStringLen(value) == 0) {
SysFreeString(value);
value = NULL;
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions source/NVDAObjects/IAccessible/ia2Web.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ def _get_states(self):
if popupState:
states.discard(controlTypes.State.HASPOPUP)
states.add(popupState)
if self.role == controlTypes.Role.LINK and controlTypes.State.LINKED in states and self.linkType:
states.add(self.linkType)
return states

def _get_landmark(self):
Expand Down
10 changes: 10 additions & 0 deletions source/NVDAObjects/UIA/chromium.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# See the file COPYING for more details.
# Copyright (C) 2020-2021 NV Access limited, Leonard de Ruijter


import UIAHandler
from . import web
import controlTypes
Expand Down Expand Up @@ -66,11 +67,20 @@ def _getControlFieldForUIAObject(self, obj, isEmbedded=False, startOfNode=False,
class ChromiumUIA(web.UIAWeb):
_TextInfo = ChromiumUIATextInfo

def _get_states(self) -> set[controlTypes.State]:
states = super().states
if self.role == controlTypes.Role.LINK and self.linkType:
states.add(self.linkType)
return states


class ChromiumUIATreeInterceptor(web.UIAWebTreeInterceptor):
def _get_documentConstantIdentifier(self):
return self.rootNVDAObject.parent._getUIACacheablePropertyValue(UIAHandler.UIA_AutomationIdPropertyId)

def _get_documentURL(self) -> str | None:
return self.rootNVDAObject.value


class ChromiumUIADocument(ChromiumUIA):
treeInterceptorClass = ChromiumUIATreeInterceptor
Expand Down
15 changes: 15 additions & 0 deletions source/NVDAObjects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1627,3 +1627,18 @@ def _get_isBelowLockScreen(self) -> bool:
if not isLockScreenModeActive():
return False
return _isObjectBelowLockScreen(self)

linkType: controlTypes.State | None
"""Typing information for auto property _get_linkType
Determines the link type based on the link and document URLs.
"""

def _get_linkType(self) -> controlTypes.State | None:
if self.role != controlTypes.Role.LINK:
return None
from browseMode import BrowseModeDocumentTreeInterceptor

ti = getattr(self, "treeInterceptor", None)
if not isinstance(ti, BrowseModeDocumentTreeInterceptor):
return None
return ti.getLinkTypeInDocument(self.value)
2 changes: 2 additions & 0 deletions source/braille.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@
controlTypes.State.HASCOMMENT: _("cmnt"),
# Translators: Displayed in braille when a control is switched on
controlTypes.State.ON: "⣏⣿⣹",
# Translators: Displayed in braille when a link destination points to the same page
controlTypes.State.INTERNAL_LINK: _("smp"),
}
negativeStateLabels = {
# Translators: Displayed in braille when an object is not selected.
Expand Down
10 changes: 10 additions & 0 deletions source/browseMode.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import gui.contextHelp
from abc import ABCMeta, abstractmethod
import globalVars
from utils import urlUtils
from typing import Optional


Expand Down Expand Up @@ -304,6 +305,15 @@ class BrowseModeTreeInterceptor(treeInterceptorHandler.TreeInterceptor):
scriptCategory = inputCore.SCRCAT_BROWSEMODE
_disableAutoPassThrough = False
APPLICATION_ROLES = (controlTypes.Role.APPLICATION, controlTypes.Role.DIALOG)
documentURL: str | None = None
"""The URL of the current browse mode document.
C{None} when there is no URL or it is unknown.
Used to determine the type of a link in the document.
"""

def getLinkTypeInDocument(self, url: str) -> controlTypes.State | None:
"""Returns the type of a link in the document, or C{None} if the link type cannot be determined."""
return urlUtils.getLinkType(url, self.documentURL)

def _get_currentNVDAObject(self):
raise NotImplementedError
Expand Down
1 change: 1 addition & 0 deletions source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@
# 0: Off, 1: style, 2: color and style
reportCellBorders = integer(0, 2, default=0)
reportLinks = boolean(default=true)
reportLinkType = boolean(default=true)
reportGraphics = boolean(default=True)
reportComments = boolean(default=true)
reportBookmarks = boolean(default=true)
Expand Down
6 changes: 5 additions & 1 deletion source/controlTypes/processAndLabelStates.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from typing import Dict, List, Optional, Set

from .role import Role, clickableRoles
from .state import State, STATES_SORTED
from .state import State, STATES_SORTED, STATES_LINK_TYPE
from .outputReason import OutputReason


Expand All @@ -31,6 +31,7 @@ def _processPositiveStates(
positiveStates.discard(State.EDITABLE)
if role != Role.LINK:
positiveStates.discard(State.VISITED)
positiveStates.discard(State.INTERNAL_LINK)
positiveStates.discard(State.SELECTABLE)
positiveStates.discard(State.FOCUSABLE)
positiveStates.discard(State.CHECKABLE)
Expand All @@ -47,6 +48,9 @@ def _processPositiveStates(
# or reporting clickable just isn't useful,
# or the user has explicitly requested no reporting clickable
positiveStates.discard(State.CLICKABLE)
if not config.conf["documentFormatting"]["reportLinkType"]:
for state in STATES_LINK_TYPE:
positiveStates.discard(state)
if reason == OutputReason.QUERY:
return positiveStates
positiveStates.discard(State.DEFUNCT)
Expand Down
6 changes: 6 additions & 0 deletions source/controlTypes/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,13 @@ def negativeDisplayString(self) -> str:
HASPOPUP_GRID = setBit(48)
HASPOPUP_LIST = setBit(49)
HASPOPUP_TREE = setBit(50)
INTERNAL_LINK = setBit(51)


STATES_SORTED = frozenset([State.SORTED, State.SORTED_ASCENDING, State.SORTED_DESCENDING])

STATES_LINK_TYPE = frozenset([State.INTERNAL_LINK])


_stateLabels: Dict[State, str] = {
# Translators: This is presented when a control or document is unavailable.
Expand Down Expand Up @@ -204,6 +207,9 @@ def negativeDisplayString(self) -> str:
State.HASPOPUP_LIST: _("opens list"),
# Translators: Presented when a control has a pop-up tree.
State.HASPOPUP_TREE: _("opens tree"),
# Translators: Presented when a link destination points to the page containing the link.
# For example, links of a table of contents of a document with different sections.
State.INTERNAL_LINK: _("same page"),
}


Expand Down
43 changes: 43 additions & 0 deletions source/globalCommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,29 @@
NO_SETTINGS_MSG = _("No settings")


def toggleBooleanValue(
configSection: str,
configKey: str,
enabledMsg: str,
disabledMsg: str,
) -> None:
"""
Toggles a boolean value in the configuration and returns the appropriate message.
:param configSection: The configuration section containing the boolean key.
:param configKey: The configuration key associated with the boolean value.
:param enabledMsg: The message for the enabled state.
:param disabledMsg: The message for the disabled state.
:return: None.
"""
currentValue = config.conf[configSection][configKey]
newValue = not currentValue
config.conf[configSection][configKey] = newValue

msg = enabledMsg if newValue else disabledMsg
ui.message(msg)


class GlobalCommands(ScriptableObject):
"""Commands that are available at all times, regardless of the current focus."""

Expand Down Expand Up @@ -902,6 +925,26 @@ def script_toggleReportLinks(self, gesture):
config.conf["documentFormatting"]["reportLinks"] = True
ui.message(state)

@script(
# Translators: Input help mode message for toggle report link type command.
description=_("Toggles on and off the reporting of link type"),
category=SCRCAT_DOCUMENTFORMATTING,
)
def script_toggleReportLinkType(self, gesture: inputCore.InputGesture):
if config.conf["documentFormatting"]["reportLinks"]:
toggleBooleanValue(
configSection="documentFormatting",
configKey="reportLinkType",
# Translators: The message announced when toggling the report link type document formatting setting.
enabledMsg=_("Report link type on"),
# Translators: The message announced when toggling the report link type document formatting setting.
disabledMsg=_("Report link type off"),
)
else:
# Translators: The message announced when reporting links is disabled,
# and the user tries to toggle the report link type document formatting setting.
ui.message(_("The report links setting must be enabled to toggle report link type"))

@script(
# Translators: Input help mode message for toggle report graphics command.
description=_("Toggles on and off the reporting of graphics"),
Expand Down
11 changes: 11 additions & 0 deletions source/gui/settingsDialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2800,8 +2800,15 @@ def makeSettings(self, settingsSizer):
# Translators: This is the label for a checkbox in the
# document formatting settings panel.
self.linksCheckBox = elementsGroup.addItem(wx.CheckBox(elementsGroupBox, label=_("Lin&ks")))
self.linksCheckBox.Bind(wx.EVT_CHECKBOX, self._onLinksChange)
self.linksCheckBox.SetValue(config.conf["documentFormatting"]["reportLinks"])

# Translators: This is the label for a checkbox in the
# document formatting settings panel.
self.linkTypeCheckBox = elementsGroup.addItem(wx.CheckBox(elementsGroupBox, label=_("Link type")))
self.linkTypeCheckBox.SetValue(config.conf["documentFormatting"]["reportLinkType"])
self.linkTypeCheckBox.Enable(self.linksCheckBox.IsChecked())

# Translators: This is the label for a checkbox in the
# document formatting settings panel.
self.graphicsCheckBox = elementsGroup.addItem(wx.CheckBox(elementsGroupBox, label=_("&Graphics")))
Expand Down Expand Up @@ -2868,6 +2875,9 @@ def makeSettings(self, settingsSizer):
def _onLineIndentationChange(self, evt: wx.CommandEvent) -> None:
self.ignoreBlankLinesRLICheckbox.Enable(evt.GetSelection() != 0)

def _onLinksChange(self, evt: wx.CommandEvent):
self.linkTypeCheckBox.Enable(evt.IsChecked())

def onSave(self):
config.conf["documentFormatting"]["detectFormatAfterCursor"] = (
self.detectFormatAfterCursorCheckBox.IsChecked()
Expand Down Expand Up @@ -2902,6 +2912,7 @@ def onSave(self):
config.conf["documentFormatting"]["reportTableCellCoords"] = self.tableCellCoordsCheckBox.IsChecked()
config.conf["documentFormatting"]["reportCellBorders"] = self.borderComboBox.GetSelection()
config.conf["documentFormatting"]["reportLinks"] = self.linksCheckBox.IsChecked()
config.conf["documentFormatting"]["reportLinkType"] = self.linkTypeCheckBox.IsChecked()
config.conf["documentFormatting"]["reportGraphics"] = self.graphicsCheckBox.IsChecked()
config.conf["documentFormatting"]["reportHeadings"] = self.headingsCheckBox.IsChecked()
config.conf["documentFormatting"]["reportLists"] = self.listsCheckBox.IsChecked()
Expand Down
54 changes: 54 additions & 0 deletions source/utils/urlUtils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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) 2024 NV Access Limited, Noelia Ruiz Martínez, Leonard de Ruijter

import controlTypes
from urllib.parse import ParseResult, urlparse, urlunparse
from logHandler import log


def getLinkType(targetURL: str, rootURL: str) -> controlTypes.State | None:
"""Returns the link type corresponding to a given URL.
:param targetURL: The URL of the link destination
:param rootURL: The root URL of the page
:return: A controlTypes.State corresponding to the link type, or C{None} if the state cannot be determined
"""
if not targetURL or not rootURL:
log.debug(f"getLinkType: Either targetUrl {targetURL} or rootUrl {rootURL} is empty.")
return None
if isSamePageURL(targetURL, rootURL):
log.debug(f"getLinkType: {targetURL} is an internal link.")
return controlTypes.State.INTERNAL_LINK
log.debug(f"getLinkType: {targetURL} type is unknown.")
return None


def isSamePageURL(targetURLOnPage: str, rootURL: str) -> bool:
"""Returns whether a given URL belongs to the same page as another URL.
:param targetURLOnPage: The URL that should be on the same page as `rootURL`
:param rootURL: The root URL of the page
:return: Whether `targetURLOnPage` belongs to the same page as `rootURL`
"""
if not targetURLOnPage or not rootURL:
return False

validSchemes = ("http", "https")
# Parse the URLs
parsedTargetURLOnPage: ParseResult = urlparse(targetURLOnPage)
if parsedTargetURLOnPage.scheme not in validSchemes:
return False
parsedRootURL: ParseResult = urlparse(rootURL)
if parsedRootURL.scheme not in validSchemes:
return False

# Reconstruct URLs without schemes and without fragments for comparison
targetURLOnPageWithoutFragments = urlunparse(parsedTargetURLOnPage._replace(scheme="", fragment=""))
rootURLWithoutFragments = urlunparse(parsedRootURL._replace(scheme="", fragment=""))

fragmentInvalidChars: str = "/" # Characters not considered valid in fragments
return targetURLOnPageWithoutFragments == rootURLWithoutFragments and not any(
char in parsedTargetURLOnPage.fragment for char in fragmentInvalidChars
)
17 changes: 13 additions & 4 deletions source/virtualBuffers/gecko_ia2.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# 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) 2008-2023 NV Access Limited, Babbage B.V., Mozilla Corporation, Accessolutions, Julien Cochuyt
# Copyright (C) 2008-2024 NV Access Limited, Babbage B.V., Mozilla Corporation, Accessolutions,
# Julien Cochuyt, Noelia Ruiz Martínez, Leonard de Ruijter

from dataclasses import dataclass
from typing import (
Expand Down Expand Up @@ -167,9 +168,14 @@ def _normalizeControlField(self, attrs): # noqa: C901
attrs["roleTextBraille"] = roleTextBraille
if attrs.get("IAccessible2::attribute_dropeffect", "none") != "none":
states.add(controlTypes.State.DROPTARGET)
if role == controlTypes.Role.LINK and controlTypes.State.LINKED not in states:
# This is a named link destination, not a link which can be activated. The user doesn't care about these.
role = controlTypes.Role.TEXTFRAME
if role == controlTypes.Role.LINK:
if controlTypes.State.LINKED not in states:
# This is a named link destination, not a link which can be activated. The user doesn't care about these.
role = controlTypes.Role.TEXTFRAME
elif (value := attrs.get("IAccessible::value")) is not None and (
linkType := self.obj.getLinkTypeInDocument(value)
) is not None:
states.add(linkType)
level = attrs.get("IAccessible2::attribute_level", "")
xmlRoles = attrs.get("IAccessible2::attribute_xml-roles", "").split(" ")
landmark = next((xr for xr in xmlRoles if xr in aria.landmarkRoles), None)
Expand Down Expand Up @@ -322,6 +328,9 @@ def _get_isAlive(self):
isDefunct = True
return not isDefunct

def _get_documentURL(self) -> str:
return self.documentConstantIdentifier

def getNVDAObjectFromIdentifier(
self,
docHandle: int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ schemaVersion = 2
highlightFocus = True
highlightNavigator = True
highlightBrowseMode = True
[documentFormatting]
reportLinkType = False
Loading

0 comments on commit 21fbfa7

Please sign in to comment.