diff --git a/nvdaHelper/vbufBackends/gecko_ia2/gecko_ia2.cpp b/nvdaHelper/vbufBackends/gecko_ia2/gecko_ia2.cpp index f8ca0120eea..64db5eedd54 100755 --- a/nvdaHelper/vbufBackends/gecko_ia2/gecko_ia2.cpp +++ b/nvdaHelper/vbufBackends/gecko_ia2/gecko_ia2.cpp @@ -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; + } } } diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 50a7103a819..fd35f492a8b 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -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): diff --git a/source/NVDAObjects/UIA/chromium.py b/source/NVDAObjects/UIA/chromium.py index 73291b01e4e..d931fdd2adb 100644 --- a/source/NVDAObjects/UIA/chromium.py +++ b/source/NVDAObjects/UIA/chromium.py @@ -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 @@ -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 diff --git a/source/NVDAObjects/__init__.py b/source/NVDAObjects/__init__.py index 1929a3995de..a4a22e992d2 100644 --- a/source/NVDAObjects/__init__.py +++ b/source/NVDAObjects/__init__.py @@ -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) diff --git a/source/braille.py b/source/braille.py index 753d51f78f6..9d3c5128949 100644 --- a/source/braille.py +++ b/source/braille.py @@ -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. diff --git a/source/browseMode.py b/source/browseMode.py index 711cc521e30..d80428534b8 100644 --- a/source/browseMode.py +++ b/source/browseMode.py @@ -54,6 +54,7 @@ import gui.contextHelp from abc import ABCMeta, abstractmethod import globalVars +from utils import urlUtils from typing import Optional @@ -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 diff --git a/source/config/configSpec.py b/source/config/configSpec.py index e1d95608173..651ca1aecd3 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -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) diff --git a/source/controlTypes/processAndLabelStates.py b/source/controlTypes/processAndLabelStates.py index 86ec35e15b2..45755c5320f 100644 --- a/source/controlTypes/processAndLabelStates.py +++ b/source/controlTypes/processAndLabelStates.py @@ -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 @@ -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) @@ -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) diff --git a/source/controlTypes/state.py b/source/controlTypes/state.py index affc16b3550..9a2a02ddce7 100644 --- a/source/controlTypes/state.py +++ b/source/controlTypes/state.py @@ -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. @@ -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"), } diff --git a/source/globalCommands.py b/source/globalCommands.py index 1369281ca33..5939e909a17 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -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.""" @@ -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"), diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index aff0f15f66e..553ada554ef 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -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"))) @@ -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() @@ -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() diff --git a/source/utils/urlUtils.py b/source/utils/urlUtils.py new file mode 100644 index 00000000000..7eedb746d9f --- /dev/null +++ b/source/utils/urlUtils.py @@ -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 + ) diff --git a/source/virtualBuffers/gecko_ia2.py b/source/virtualBuffers/gecko_ia2.py index ec9a6d8b4df..06cb93d5223 100755 --- a/source/virtualBuffers/gecko_ia2.py +++ b/source/virtualBuffers/gecko_ia2.py @@ -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 ( @@ -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) @@ -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, diff --git a/tests/system/nvdaSettingsFiles/standard-dontShowWelcomeDialog.ini b/tests/system/nvdaSettingsFiles/standard-dontShowWelcomeDialog.ini index a11d7a331de..d8efefe2885 100644 --- a/tests/system/nvdaSettingsFiles/standard-dontShowWelcomeDialog.ini +++ b/tests/system/nvdaSettingsFiles/standard-dontShowWelcomeDialog.ini @@ -22,3 +22,5 @@ schemaVersion = 2 highlightFocus = True highlightNavigator = True highlightBrowseMode = True +[documentFormatting] + reportLinkType = False diff --git a/tests/unit/test_util/test_urlUtils.py b/tests/unit/test_util/test_urlUtils.py new file mode 100644 index 00000000000..1f642168c9d --- /dev/null +++ b/tests/unit/test_util/test_urlUtils.py @@ -0,0 +1,56 @@ +# 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 + +"""Unit tests for the urlUtils submodule.""" + +import unittest +from source.utils.urlUtils import isSamePageURL + + +class TestIsSamePageURL(unittest.TestCase): + def test_samePage_basic(self): + self.assertTrue(isSamePageURL("http://example.com/page#section", "http://example.com/page")) + + def test_samePageBothHaveFragments(self): + self.assertTrue(isSamePageURL("http://example.com/page#section", "http://example.com/page#main")) + + def test_differentPage(self): + self.assertFalse(isSamePageURL("http://example.com/otherpage#section", "http://example.com/page")) + + def test_noFragment(self): + self.assertTrue(isSamePageURL("http://example.com/page", "http://example.com/page")) + + def test_differentDomain(self): + self.assertFalse(isSamePageURL("http://other.com/page#section", "http://example.com/page")) + + def test_emptyURLOnPage(self): + self.assertFalse(isSamePageURL("", "http://example.com/page")) + + def test_emptyPageURL(self): + self.assertFalse(isSamePageURL("http://example.com/page#section", "")) + + def test_differentScheme(self): + self.assertTrue(isSamePageURL("http://example.com/page#section", "https://example.com/page")) + + def test_differentQuery(self): + self.assertFalse( + isSamePageURL("http://example.com/page?q=3#section", "http://example.com/page?q4#section"), + ) + + def test_fragmentHasPath(self): + """URLs whose fragments contain paths are not considered the same page.""" + self.assertFalse(isSamePageURL("http://example.com/page#fragment/path", "http://example.com/page")) + + def test_unusualCharacters(self): + """Test URLs with unusual characters.""" + self.assertTrue(isSamePageURL("http://example.com/page#%E2%9C%93", "http://example.com/page")) + + def test_ftpScheme(self): + """Test URLs with different schemes like FTP.""" + self.assertFalse(isSamePageURL("ftp://example.com/page#section", "http://example.com/page")) + + def test_mailtoScheme(self): + """Test URLs with different schemes like mailto.""" + self.assertFalse(isSamePageURL("mailto://example.com/page#section", "http://example.com/page")) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 58a792032be..2d3f8cc8145 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -10,6 +10,7 @@ In order to use this feature, the application volume adjuster needs to be enabled in the Audio category of NVDA settings. (#16052, @mltony) * When editing in Microsoft PowerPoint text boxes, you can now move per sentence with `alt+upArrow`/`alt+downArrow`. (#17015, @LeonarddeR) * In Mozilla Firefox, NVDA will report the highlighted text when a URL containing a text fragment is visited. (#16910, @jcsteh) +* NVDA can now report when a link destination points to the current page. (#141, @LeonarddeR, @nvdaes) * Added an action in the Add-on Store to cancel the install of add-ons. (#15578, @hwf1324) * It is now possible to specify a mirror URL to use for the Add-on Store. (#14974) @@ -35,6 +36,15 @@ Add-ons will need to be re-tested and have their manifest updated. * Updated Ruff to 0.6.3. (#17102) * Updated Comtypes to 1.4.6. (#17061, @LeonarddeR) * `ui.browseableMessage` may now be called with options to present a button for copying to clipboard, and/or a button for closing the window. (#17018, @XLTechie) +* Several additions to identify link types (#16994, @LeonarddeR, @nvdaes) + * A new `utils.urlUtils` module with different functions to determine link types + * A new `INTERNAL_LINK` state has been added to `controlTypes.states.State` + * A new `linkType` property has been added on `NVDAObject`. + It queries the `treeInterceptor` by default, if any. + * `BrowseModeTreeInterceptor` object has a new `documentUrl` property + * `BrowseModeTreeInterceptor` object has a new `getLinkTypeInDocument` method which accepts an URL to check the link type of the object + * A `toggleBooleanValue` helper function has been added to `globalCommands`. + It can be used in scripts to report the result when a boolean is toggled in `config.conf` #### API Breaking Changes diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 993c67639ce..eca0d0a3c80 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2983,6 +2983,7 @@ You can configure reporting of: * Elements * Headings * Links + * Link type (destination to same page) * Graphics * Lists * Block quotes