From 90ac1caf37a50642001021e885531701a5936179 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Fri, 3 Apr 2026 16:45:55 +0200 Subject: [PATCH 01/27] Add pyphen --- pyproject.toml | 1 + uv.lock | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 783b8736141..b7620d1a09b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ "mdx-gh-links==0.4", "l2m4m==1.0.4", "pymdown-extensions==10.17.1", + "pyphen>=0.17.2", ] [project.urls] diff --git a/uv.lock b/uv.lock index 282a0efaf5a..104c791613a 100644 --- a/uv.lock +++ b/uv.lock @@ -537,6 +537,7 @@ dependencies = [ { name = "nh3", marker = "sys_platform == 'win32'" }, { name = "pycaw", marker = "sys_platform == 'win32'" }, { name = "pymdown-extensions", marker = "sys_platform == 'win32'" }, + { name = "pyphen", marker = "sys_platform == 'win32'" }, { name = "pyserial", marker = "sys_platform == 'win32'" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "requests", marker = "sys_platform == 'win32'" }, @@ -594,6 +595,7 @@ requires-dist = [ { name = "nh3", specifier = "==0.3.2" }, { name = "pycaw", specifier = "==20251023" }, { name = "pymdown-extensions", specifier = "==10.17.1" }, + { name = "pyphen", specifier = ">=0.17.2" }, { name = "pyserial", specifier = "==3.5" }, { name = "pywin32", specifier = "==311" }, { name = "requests", specifier = "==2.33.0" }, @@ -844,6 +846,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, ] +[[package]] +name = "pyphen" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" }, +] + [[package]] name = "pyrect" version = "0.2.0" From 792938bf32c10fa54ab79df6b1a2e218107d2083 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Fri, 3 Apr 2026 18:17:52 +0200 Subject: [PATCH 02/27] Add a hyphenation module --- source/textUtils/hyphenation.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 source/textUtils/hyphenation.py diff --git a/source/textUtils/hyphenation.py b/source/textUtils/hyphenation.py new file mode 100644 index 00000000000..3bb1b0266a2 --- /dev/null +++ b/source/textUtils/hyphenation.py @@ -0,0 +1,31 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter +# 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 + +"""Utilities for hyphenation.""" + +from characterProcessing import LocaleDataMap +from pyphen import Pyphen, language_fallback + + +def _pyphenFactory(lang: str) -> Pyphen: + """Factory for Pyphen instances.""" + pyphenLang = language_fallback(lang) + if not pyphenLang: + raise LookupError(f"No Pyphen language found for locale '{lang}'") + elif "_" in lang and "_" not in pyphenLang: + raise LookupError( + f"Pyphen resolved {lang!r} to {pyphenLang:r} but the original locale contains a region subtag. " + "Fallbacks should be handled by LocaleDataMap instead" + ) + return Pyphen(lang=pyphenLang) + + +_hypenationMap: LocaleDataMap[Pyphen] = LocaleDataMap(_pyphenFactory) + + +def getHyphenPositions(text: str, locale: str): + """Get the positions of hyphenation points in the given text for the given locale.""" + pyphen = _hypenationMap.fetchLocaleData(locale=locale) + return pyphen.positions(text) From e9df289dc4bd002d87394b43df483af33e610e9e Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Fri, 3 Apr 2026 18:59:22 +0200 Subject: [PATCH 03/27] Add text wrap --- source/config/__init__.py | 29 +++++++++++++++++++++++++---- source/config/configFlags.py | 24 ++++++++++++++++++++++++ source/config/configSpec.py | 2 ++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/source/config/__init__.py b/source/config/__init__.py index 16dcbbf98d6..5bae36885c5 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -12,6 +12,7 @@ from collections.abc import Collection from enum import Enum +from ..addonAPIVersion import BACK_COMPAT_TO import globalVars import winreg import os @@ -37,6 +38,7 @@ from . import profileUpgrader from . import aggregatedSection from .configSpec import confspec +from .configFlags import BrailleTextWrap from .featureFlag import ( _transformSpec_AddFeatureFlagDefault, _validateConfig_featureFlag, @@ -1285,14 +1287,14 @@ def __setitem__( # Alias old config items to their new counterparts for backwards compatibility. # Uncomment when there are new links that need to be made. - # if BACK_COMPAT_TO < (2027, 1, 0) and NVDAState._allowDeprecatedAPI(): - # self._linkDeprecatedValues(key, val) + if BACK_COMPAT_TO < (2027, 1, 0) and NVDAState._allowDeprecatedAPI(): + self._linkDeprecatedValues(key, val) def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregatedSection._cacheValueT): """Link deprecated config keys and values to their replacements. - :arg key: The configuration key to link to its new or old counterpart. - :arg val: The value associated with the configuration key. + :param key: The configuration key to link to its new or old counterpart. + :param val: The value associated with the configuration key. Example of how to link values: @@ -1314,6 +1316,25 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat >>> ... """ match self.path: + case "braille": + match key: + case "wordWrap": + # The "wordWrap" setting was renamed to "textWrap" and became an enum. + log.warning( + "braille.wordWrap is deprecated. Use braille.textWrap instead.", + stack_info=True, + ) + key = "textWrap" + val = BrailleTextWrap.WORD_BOUNDARIES if val else BrailleTextWrap.OFF + case "textWrap": + # The "textWrap" setting was added in place of "wordWrap" and became an enum. + key = "wordWrap" + val = val != BrailleTextWrap.OFF + + case _: + # We don't care about other keys in this section. + return + case _: # We don't care about other sections. return diff --git a/source/config/configFlags.py b/source/config/configFlags.py index 9b8fbc0ab0d..458fdca02b8 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -464,3 +464,27 @@ def _displayStringLabels(self): # Translators: Label for a value in the Play a sound for logged errors combobox, in the Advanced settings. PlayErrorSound.NO: pgettext("advanced.playErrorSound", "No"), } + + +@unique +class BrailleTextWrap(DisplayStringIntEnum): + """Enumeration containing the possible config values for braille text wrap. + + Use BrailleTextWrap.MEMBER.value to compare with the config; + use BrailleTextWrap.MEMBER.displayString in the UI for a translatable description of this member. + """ + + OFF = 0 + WORD_BOUNDARIES = 1 + HYPHENATE = 2 + + @property + def _displayStringLabels(self): + return { + # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. + BrailleTextWrap.OFF: pgettext("braille text wrap setting", "Off"), + # Translators: A choice in a combo box in the braille settings panel to configure word wrap. + BrailleTextWrap.WORD_BOUNDARIES: pgettext("braille text wrap setting", "At word boundaries"), + # Translators: A choice in a combo box in the braille settings panel to configure word wrap. + BrailleTextWrap.HYPHENATE: pgettext("braille text wrap setting", "Break words with hyphen"), + } diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 1cc61e0c6a1..7e4d21fdbb1 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -90,6 +90,8 @@ readByParagraph = boolean(default=false) paragraphStartMarker = option("", " ", "¶", default="") wordWrap = boolean(default=true) + # 0: Off, 1: Word Boundaries, 2: Hyphenate + textWrap = integer(0, 2, default=2) unicodeNormalization = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="disabled") focusContextPresentation = option("changedContext", "fill", "scroll", default="changedContext") interruptSpeechWhileScrolling = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled") From 0e79ac7d1c3151612a878f825a78e55036365f30 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Fri, 3 Apr 2026 20:43:14 +0200 Subject: [PATCH 04/27] Config update --- source/config/__init__.py | 4 ++-- source/config/configSpec.py | 3 ++- source/config/profileUpgradeSteps.py | 27 +++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/source/config/__init__.py b/source/config/__init__.py index 5bae36885c5..25ed5bb814e 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -1325,11 +1325,11 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat stack_info=True, ) key = "textWrap" - val = BrailleTextWrap.WORD_BOUNDARIES if val else BrailleTextWrap.OFF + val = (BrailleTextWrap.WORD_BOUNDARIES if val else BrailleTextWrap.OFF).value case "textWrap": # The "textWrap" setting was added in place of "wordWrap" and became an enum. key = "wordWrap" - val = val != BrailleTextWrap.OFF + val = val != BrailleTextWrap.OFF.value case _: # We don't care about other keys in this section. diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 7e4d21fdbb1..25916fc7649 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -13,7 +13,7 @@ #: provide an upgrade step (@see profileUpgradeSteps.py). An upgrade step does not need to be added when #: just adding a new element to (or removing from) the schema, only when old versions of the config #: (conforming to old schema versions) will not work correctly with the new schema. -latestSchemaVersion = 21 +latestSchemaVersion = 22 #: The configuration specification string #: @type: String @@ -89,6 +89,7 @@ optionsEnum="ReviewRoutingMovesSystemCaretFlag", behaviorOfDefault="NEVER") readByParagraph = boolean(default=false) paragraphStartMarker = option("", " ", "¶", default="") + # Deprecated in 2026.2 wordWrap = boolean(default=true) # 0: Off, 1: Word Boundaries, 2: Hyphenate textWrap = integer(0, 2, default=2) diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index fde1f77d8a1..d74f030d735 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -20,6 +20,7 @@ from logHandler import log from config.configFlags import ( + BrailleTextWrap, NVDAKey, OutputMode, ReportCellBorders, @@ -662,3 +663,29 @@ def upgradeConfigFrom_20_to_21(profile: ConfigObj): speechConf["sapi5_32"] = sapi5Conf del speechConf["sapi5"] log.debug("Moved old sapi5 configuration values to sapi5_32") + + +def upgradeConfigFrom_21_to_22(profile: ConfigObj) -> None: + """ + If the wordWrap braille config flag is explicitly set in a profile, + set the new text wrap option to word boundaries, + rather than the new hyphenate default. + """ + section = "braille" + key = "wordWrap" + newKey = "textWrap" + try: + oldValue: bool = profile[section].as_bool(key) + except KeyError: + log.debug(f"'{key}' not present in config, no action taken.") + return + except ValueError: + log.error(f"'{key}' is not a boolean, got {profile[section][key]!r}. No action taken.") + return + + newValue = BrailleTextWrap.WORD_BOUNDARIES.value if oldValue else BrailleTextWrap.OFF.value + profile[section][newKey] = newValue + log.debug( + f"Converted '{key}' with value {oldValue} to '{newKey}' with value {newValue}" + f" ({BrailleTextWrap(newValue).name}).", + ) From bd889fa289f62fa410ef3aea32886428f3f020af Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Fri, 3 Apr 2026 22:02:06 +0200 Subject: [PATCH 05/27] Add language annotations to regions --- source/braille.py | 30 ++++++++++++++++++++++++++---- source/config/__init__.py | 2 +- source/louisHelper.py | 7 +++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/source/braille.py b/source/braille.py index c1c0abd4d8c..b02492fdcd7 100644 --- a/source/braille.py +++ b/source/braille.py @@ -34,6 +34,7 @@ import threading import time import wx +import languageHandler import louisHelper import louis import gui @@ -559,6 +560,11 @@ def __init__(self): #: The end of the selection in L{rawText} (exclusive), C{None} if there is no selection in this region. #: @type: int self.selectionEnd = None + #: Language indexes in L{rawText}. + #: The last language is assumed to be the final language in the region. + self._languageIndexes: dict[int:str] = { + 0: louisHelper.getTableLanguage(handler.table.fileName) or languageHandler.getLanguage(), + } #: The translated braille representation of this region. #: @type: [int, ...] self.brailleCells = [] @@ -1385,12 +1391,24 @@ def _getTypeformFromFormatField(self, field, formatConfig): typeform |= louis.underline return typeform - def _addFieldText(self, text, contentPos, separate=True): + def _addFieldText( + self, + text: str, + contentPos: int, + separate: bool = True, + ): if separate and self.rawText: # Separate this field text from the rest of the text. text = TEXT_SEPARATOR + text - self.rawText += text textLen = len(text) + # Fields are reported in NVDA's language + fieldLanguage = languageHandler.getLanguage() + lastLanguage = self._languageIndexes[max(self._languageIndexes.keys())] + if fieldLanguage != lastLanguage: + self._languageIndexes[len(self.rawText)] = fieldLanguage + # Restore to the previous language + self._languageIndexes[len(self.rawText) + textLen] = lastLanguage + self.rawText += text self.rawTextTypeforms.extend((louis.plain_text,) * textLen) self._rawToContentPos.extend((contentPos,) * textLen) @@ -1443,16 +1461,20 @@ def _addTextWithFields(self, info, formatConfig, isSelection=False): field = command.field if cmd == "formatChange": typeform = self._getTypeformFromFormatField(field, formatConfig) + language = field.get("language") text = getFormatFieldBraille( field, formatFieldAttributesCache, self._isFormatFieldAtStart, formatConfig, ) + if text: + # Map this field text to the start of the field's content. + self._addFieldText(text, self._currentContentPos, language) + if language: + self._languageIndexes[len(self.rawText)] = language if not text: continue - # Map this field text to the start of the field's content. - self._addFieldText(text, self._currentContentPos) elif cmd == "controlStart": if self._skipFieldsNotAtStartOfNode and not field.get("_startOfNode"): text = None diff --git a/source/config/__init__.py b/source/config/__init__.py index 25ed5bb814e..f230bbabb2e 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -12,7 +12,7 @@ from collections.abc import Collection from enum import Enum -from ..addonAPIVersion import BACK_COMPAT_TO +from addonAPIVersion import BACK_COMPAT_TO import globalVars import winreg import os diff --git a/source/louisHelper.py b/source/louisHelper.py index d59b6730b60..82d71e1cd2b 100644 --- a/source/louisHelper.py +++ b/source/louisHelper.py @@ -17,6 +17,7 @@ import brailleTables import config import globalVars +import languageHandler from logHandler import log with os.add_dll_directory(globalVars.appDir): @@ -176,3 +177,9 @@ def translate( if cursorPos is None: brailleCursorPos = None return braille, brailleToRawPos, rawToBraillePos, brailleCursorPos + + +def getTableLanguage(table: str) -> str | None: + """Get the language of a braille table, if specified in the table file.""" + lang = louis.getTableInfo(table, "language") + return languageHandler.normalizeLanguage(lang) if lang else lang From 3cd234732c5e42e8028679cc5038d86a9d45c2bb Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Mon, 6 Apr 2026 21:09:10 +0200 Subject: [PATCH 06/27] Fix continuation stuff --- source/braille.py | 133 ++++++++++++++---- source/config/__init__.py | 2 +- source/config/configFlags.py | 10 +- source/config/configSpec.py | 4 +- source/gui/settingsDialogs.py | 19 ++- .../test_calculateWindowRowBufferOffsets.py | 19 +-- 6 files changed, 141 insertions(+), 46 deletions(-) diff --git a/source/braille.py b/source/braille.py index b02492fdcd7..36fde0b1627 100644 --- a/source/braille.py +++ b/source/braille.py @@ -4,6 +4,7 @@ # Copyright (C) 2008-2025 NV Access Limited, Joseph Lee, Babbage B.V., Davy Kager, Bram Duvigneau, # Leonard de Ruijter, Burman's Computer and Education Ltd., Julien Cochuyt +import bisect from enum import StrEnum import itertools import typing @@ -39,6 +40,8 @@ import louis import gui from controlTypes.state import State +import textUtils +import textUtils.hyphenation import winBindings.kernel32 import winKernel import keyboardHandler @@ -46,6 +49,7 @@ import config import easeOfAccess from config.configFlags import ( + BrailleTextWrap, ShowMessages, TetherTo, BrailleMode, @@ -327,6 +331,7 @@ (0xFF, _("All dots")), ) SELECTION_SHAPE = 0xC0 #: Dots 7 and 8 +CONTINUATION_SHAPE = 0xC0 #: Dots 7 and 8 END_OF_BRAILLE_OUTPUT_SHAPE = 0xFF # All dots """ @@ -562,9 +567,7 @@ def __init__(self): self.selectionEnd = None #: Language indexes in L{rawText}. #: The last language is assumed to be the final language in the region. - self._languageIndexes: dict[int:str] = { - 0: louisHelper.getTableLanguage(handler.table.fileName) or languageHandler.getLanguage(), - } + self._languageIndexes: dict[int:str] = {0: self._getDefaultRegionLanguage()} #: The translated braille representation of this region. #: @type: [int, ...] self.brailleCells = [] @@ -593,6 +596,16 @@ def __init__(self): #: @type: bool self.focusToHardLeft = False + def _getDefaultRegionLanguage(self) -> str: + """Get the default language for a region.""" + return louisHelper.getTableLanguage(handler.table.fileName) or languageHandler.getLanguage() + + def _getLanguageAtPos(self, pos: int) -> str: + """Get the language at a given position in L{rawText} based on L{_languageIndexes}.""" + keys = sorted(self._languageIndexes) + i = bisect.bisect_right(keys, pos) - 1 + return self._languageIndexes[keys[i]] + def update(self): """Update this region. Subclasses should extend this to update L{rawText}, L{cursorPos}, L{selectionStart} and L{selectionEnd} if necessary. @@ -1403,11 +1416,12 @@ def _addFieldText( textLen = len(text) # Fields are reported in NVDA's language fieldLanguage = languageHandler.getLanguage() - lastLanguage = self._languageIndexes[max(self._languageIndexes.keys())] + rawTextLen = len(self.rawText) + lastLanguage = self._getLanguageAtPos(rawTextLen) if fieldLanguage != lastLanguage: - self._languageIndexes[len(self.rawText)] = fieldLanguage + self._languageIndexes[rawTextLen] = fieldLanguage # Restore to the previous language - self._languageIndexes[len(self.rawText) + textLen] = lastLanguage + self._languageIndexes[rawTextLen + textLen] = lastLanguage self.rawText += text self.rawTextTypeforms.extend((louis.plain_text,) * textLen) self._rawToContentPos.extend((contentPos,) * textLen) @@ -1470,9 +1484,10 @@ def _addTextWithFields(self, info, formatConfig, isSelection=False): ) if text: # Map this field text to the start of the field's content. - self._addFieldText(text, self._currentContentPos, language) - if language: - self._languageIndexes[len(self.rawText)] = language + self._addFieldText(text, self._currentContentPos) + rawTextLen = len(self.rawText) + if language and self._getLanguageAtPos(rawTextLen) != language: + self._languageIndexes[rawTextLen] = language if not text: continue elif cmd == "controlStart": @@ -1542,6 +1557,7 @@ def update(self): self.rawText = "" self.rawTextTypeforms = [] self.cursorPos = None + self._languageIndexes: dict[int:str] = {0: self._getDefaultRegionLanguage()} # The output includes text representing fields which isn't part of the real content in the control. # Therefore, maintain a map of positions in the output to positions in the content. self._rawToContentPos = [] @@ -1834,10 +1850,12 @@ def rindex(seq, item, start, end): class BrailleBuffer(baseObject.AutoPropertyObject): + handler: "BrailleHandler" + regions: list[Region] + """The regions in this buffer.""" + def __init__(self, handler): self.handler = handler - #: The regions in this buffer. - #: @type: [L{Region}, ...] self.regions = [] #: The raw text of the entire buffer. self.rawText = "" @@ -1853,6 +1871,8 @@ def __init__(self, handler): each item being a tuple of start and end braille buffer offsets. Splitting the window into independent rows allows for optional avoidance of splitting words across rows. """ + self._continuationRows: list[int] = [] + """A list of row indexes which should contain a continuation indicator at the end.""" def clear(self): """Clear the entire buffer. @@ -1881,21 +1901,21 @@ def _get_regionsWithPositions(self): yield RegionWithPositions(region, start, end) start = end - def _get_rawToBraillePos(self): - """@return: a list mapping positions in L{rawText} to positions in L{brailleCells} for the entire buffer. - @rtype: [int, ...] - """ + rawToBraillePos: list[int] + """Type definition for auto prop '_get_rawToBraillePos'""" + + def _get_rawToBraillePos(self) -> list[int]: + """:return: a list mapping positions in L{rawText} to positions in L{brailleCells} for the entire buffer.""" rawToBraillePos = [] for region, regionStart, regionEnd in self.regionsWithPositions: rawToBraillePos.extend(p + regionStart for p in region.rawToBraillePos) return rawToBraillePos - brailleToRawPos: List[int] + brailleToRawPos: list[int] + """Type definition for auto prop '_get_brailleToRawPos'""" - def _get_brailleToRawPos(self): - """@return: a list mapping positions in L{brailleCells} to positions in L{rawText} for the entire buffer. - @rtype: [int, ...] - """ + def _get_brailleToRawPos(self) -> list[int]: + """:return: a list mapping positions in L{brailleCells} to positions in L{rawText} for the entire buffer.""" brailleToRawPos = [] start = 0 for region in self.visibleRegions: @@ -1903,13 +1923,31 @@ def _get_brailleToRawPos(self): start += len(region.rawText) return brailleToRawPos - def bufferPosToRegionPos(self, bufferPos): + def bufferPosToRegionPos(self, bufferPos: int) -> tuple[Region, int]: + """Converts a position relative to the braille buffer to a position relative to the region it is in. + :param bufferPos: The position relative to the braille buffer. + :return: A tuple of the region and the position relative to that region. + """ for region, start, end in self.regionsWithPositions: if end > bufferPos: return region, bufferPos - start raise LookupError("No such position") - def regionPosToBufferPos(self, region, pos, allowNearest=False): + def _getLanguageAtBufferPos(self, pos: int) -> str: + """Gets the language at the given position in the braille buffer. + :param pos: The position in the braille buffer. + :return: The language at the given position. + """ + region, regionPos = self.bufferPosToRegionPos(pos) + return region._getLanguageAtPos(regionPos) + + def regionPosToBufferPos(self, region: Region, pos: int, allowNearest: bool = False) -> int: + """Converts a position relative to a region to a position relative to the braille buffer. + :param region: The region the position is relative to. + :param pos: The position relative to the region. + :param allowNearest: If True, if the position is outside the region, return the nearest position within the region. If False, raise LookupError if the position is outside the region. + :return: The position relative to the braille buffer. + """ start: int = 0 for testRegion, start, end in self.regionsWithPositions: if region == testRegion: @@ -1926,7 +1964,13 @@ def regionPosToBufferPos(self, region, pos, allowNearest=False): return start raise LookupError("No such position") - def bufferPositionsToRawText(self, startPos, endPos): + def bufferPositionsToRawText(self, startPos: int, endPos: int) -> str: + """ + Converts a range of positions in the braille buffer to the corresponding raw text. + :param startPos: The start position in the braille buffer. + :param endPos: The end position in the braille buffer. + :return: The corresponding raw text. + """ brailleToRawPos = self.brailleToRawPos if not brailleToRawPos or not self.rawText: # if either are empty, just return an empty string. @@ -1948,6 +1992,11 @@ def bufferPositionsToRawText(self, startPos, endPos): return "" def bufferPosToWindowPos(self, bufferPos: int) -> int: + """ + Converts a position relative to the braille buffer to a position relative to the braille window. + :param bufferPos: The position relative to the braille buffer. + :return: The position relative to the braille window. + """ for row, (start, end) in enumerate(self._windowRowBufferOffsets): if start <= bufferPos < end: return row * self.handler.displayDimensions.numCols + (bufferPos - start) @@ -1983,27 +2032,51 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: :param pos: The start position of the braille window. """ self._windowRowBufferOffsets.clear() + self._continuationRows.clear() if len(self.brailleCells) == 0: # Initialising with no actual braille content. self._windowRowBufferOffsets = [(0, 0)] return - doWordWrap = config.conf["braille"]["wordWrap"] + textWrap = BrailleTextWrap(config.conf["braille"]["textWrap"]) bufferEnd = len(self.brailleCells) start = pos clippedEnd = False for row in range(self.handler.displayDimensions.numRows): + showContinuationMark = False end = start + self.handler.displayDimensions.numCols if end > bufferEnd: end = bufferEnd clippedEnd = True - elif doWordWrap: + elif textWrap == BrailleTextWrap.CONTINUATION_ONLY and all(self.brailleCells[end - 1 : end + 1]): + end -= 1 + showContinuationMark = True + elif textWrap in (BrailleTextWrap.WORD_BOUNDARIES, BrailleTextWrap.HYPHENATE): try: lastSpaceIndex = rindex(self.brailleCells, 0, start, end + 1) if lastSpaceIndex < end: # The next braille window doesn't start with space. + oldEnd = end end = rindex(self.brailleCells, 0, start, end) + 1 + if end < oldEnd and textWrap == BrailleTextWrap.HYPHENATE: + # When hyphenating, we want to split the word after the last space. + # Note that, when the below index call fails, it is appropriately handled by the except block, + # which means that we won't hyphenate in this case. + nextSpace = self.brailleCells.index(0, oldEnd, bufferEnd) + word = self.bufferPositionsToRawText(end, nextSpace - 1) + if word: + language = self._getLanguageAtBufferPos(end) + rawPos = self.brailleToRawPos[end] + positions = textUtils.hyphenation.getHyphenPositions(word, language) + for posInWord in reversed(positions): + if (newEnd := self.rawToBraillePos[posInWord + rawPos]) < oldEnd: + # We can split the word at this position. + end = newEnd + showContinuationMark = True + break except (ValueError, IndexError): pass # No space on line + if showContinuationMark: + self._continuationRows.append(len(self._windowRowBufferOffsets)) self._windowRowBufferOffsets.append((start, end)) if clippedEnd: break @@ -2043,7 +2116,10 @@ def _set_windowEndPos(self, endPos: int) -> None: if startPos <= restrictPos: self.windowStartPos = restrictPos return - if not config.conf["braille"]["wordWrap"]: + if config.conf["braille"]["textWrap"] in ( + BrailleTextWrap.OFF.value, + BrailleTextWrap.CONTINUATION_ONLY.value, + ): self.windowStartPos = startPos return try: @@ -2165,9 +2241,12 @@ def _get_windowRawText(self): def _get_windowBrailleCells(self) -> list[int]: windowCells = [] - for start, end in self._windowRowBufferOffsets: + for row, (start, end) in enumerate(self._windowRowBufferOffsets): rowCells = self.brailleCells[start:end] remaining = self.handler.displayDimensions.numCols - len(rowCells) + if remaining > 0 and row in self._continuationRows: + rowCells.append(CONTINUATION_SHAPE) + remaining -= 1 if remaining > 0: rowCells.extend([0] * remaining) windowCells.extend(rowCells) diff --git a/source/config/__init__.py b/source/config/__init__.py index f230bbabb2e..853650253ea 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -1329,7 +1329,7 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat case "textWrap": # The "textWrap" setting was added in place of "wordWrap" and became an enum. key = "wordWrap" - val = val != BrailleTextWrap.OFF.value + val = val in (BrailleTextWrap.WORD_BOUNDARIES.value, BrailleTextWrap.HYPHENATE.value) case _: # We don't care about other keys in this section. diff --git a/source/config/configFlags.py b/source/config/configFlags.py index 458fdca02b8..86711d369c5 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -475,14 +475,20 @@ class BrailleTextWrap(DisplayStringIntEnum): """ OFF = 0 - WORD_BOUNDARIES = 1 - HYPHENATE = 2 + CONTINUATION_ONLY = 1 + WORD_BOUNDARIES = 2 + HYPHENATE = 3 @property def _displayStringLabels(self): return { # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. BrailleTextWrap.OFF: pgettext("braille text wrap setting", "Off"), + BrailleTextWrap.CONTINUATION_ONLY: pgettext( + "braille text wrap setting", + # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. + "Show continuation mark only", + ), # Translators: A choice in a combo box in the braille settings panel to configure word wrap. BrailleTextWrap.WORD_BOUNDARIES: pgettext("braille text wrap setting", "At word boundaries"), # Translators: A choice in a combo box in the braille settings panel to configure word wrap. diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 25916fc7649..5c4128ffbc4 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -91,8 +91,8 @@ paragraphStartMarker = option("", " ", "¶", default="") # Deprecated in 2026.2 wordWrap = boolean(default=true) - # 0: Off, 1: Word Boundaries, 2: Hyphenate - textWrap = integer(0, 2, default=2) + # 0: Off, 1: Continuation Only, 2: Word Boundaries, 3: Hyphenate + textWrap = integer(0, 3, default=3) unicodeNormalization = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="disabled") focusContextPresentation = option("changedContext", "fill", "scroll", default="changedContext") interruptSpeechWhileScrolling = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled") diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index a78b64fa446..69dcb46fcb0 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -63,6 +63,7 @@ from addonStore.models.channel import UpdateChannel from config.configFlags import ( AddonsAutomaticUpdate, + BrailleTextWrap, NVDAKey, OutputMode, ParagraphStartMarker, @@ -5475,10 +5476,16 @@ def makeSettings(self, settingsSizer): # Translators: The label for a setting in braille settings to enable word wrap # (try to avoid splitting words at the end of the braille display). - wordWrapText = _("Avoid splitting &words when possible") - self.wordWrapCheckBox = sHelper.addItem(wx.CheckBox(self, label=wordWrapText)) - self.bindHelpEvent("BrailleSettingsWordWrap", self.wordWrapCheckBox) - self.wordWrapCheckBox.Value = config.conf["braille"]["wordWrap"] + textWrapText = _("Text &wrap") + self.textWrapComboBox = sHelper.addLabeledControl( + textWrapText, + wx.Choice, + choices=[option.displayString for option in BrailleTextWrap], + ) + self.bindHelpEvent("BrailleSettingsTextWrap", self.textWrapComboBox) + self.textWrapComboBox.SetSelection( + [option.value for option in BrailleTextWrap].index(config.conf["braille"]["textWrap"]), + ) self.unicodeNormalizationCombo: nvdaControls.FeatureFlagCombo = sHelper.addLabeledControl( labelText=_( @@ -5544,7 +5551,9 @@ def onSave(self): ] config.conf["braille"]["speakOnRouting"] = self.speakOnRoutingCheckBox.Value config.conf["braille"]["speakOnNavigatingByUnit"] = self.speakOnNavigatingCheckBox.Value - config.conf["braille"]["wordWrap"] = self.wordWrapCheckBox.Value + config.conf["braille"]["textWrap"] = [option.value for option in BrailleTextWrap][ + self.textWrapComboBox.GetSelection() + ] self.unicodeNormalizationCombo.saveCurrentValueToConf() config.conf["braille"]["focusContextPresentation"] = self.focusContextPresentationValues[ self.focusContextPresentationList.GetSelection() diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index dbfb1fad614..fc4dc7c3966 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.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) 2022-2025 NV Access Limited, Noelia Ruiz Martínez +# Copyright (C) 2022-2026 NV Access Limited, Noelia Ruiz Martínez, Leonard de Ruijter +# 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 """Unit tests for the _calculateWindowRowBufferOffsets function in the braille module.""" @@ -9,6 +9,7 @@ import braille import config +from config.configFlags import BrailleTextWrap def _getDisplayDimensions(dimensions: braille.DisplayDimensions) -> braille.DisplayDimensions: @@ -25,7 +26,7 @@ def setUp(self): def tearDown(self): braille.filter_displayDimensions.unregister(_getDisplayDimensions) - config.conf["braille"]["wordWrap"] = False + config.conf["braille"]["textWrap"] = BrailleTextWrap.OFF.value def test_noCells(self): """Check that, if list of braille cells is empty, offsets will be (0, 0).""" @@ -56,8 +57,8 @@ def test_end(self): self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_wordWrapFirstRowWithSpace(self): - """Check that the first row will be truncated if it contains a space, only if word wrap is True.""" - config.conf["braille"]["wordWrap"] = True + """Check that the first row will be truncated if it contains a space, only if text wrap is set to word boundaries.""" + config.conf["braille"]["textWrap"] = BrailleTextWrap.WORD_BOUNDARIES.value cells = [1] * (braille.handler.displayDimensions.numCols - 5) cells.append(0) cells.extend([1] * (braille.handler.displayDimensions.numCols + 4)) @@ -65,14 +66,14 @@ def test_wordWrapFirstRowWithSpace(self): braille.handler.buffer._calculateWindowRowBufferOffsets(0) expectedOffsets = [(0, 16), (16, 36)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) - config.conf["braille"]["wordWrap"] = False + config.conf["braille"]["textWrap"] = BrailleTextWrap.OFF.value braille.handler.buffer._calculateWindowRowBufferOffsets(0) expectedOffsets = [(0, 20), (20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_wordWrapSecondRowStartsWithSpace(self): """Check that the first row won't be truncated if the next row starts with a space, even if word wrap is True.""" - config.conf["braille"]["wordWrap"] = True + config.conf["braille"]["textWrap"] = BrailleTextWrap.WORD_BOUNDARIES.value cells = [1] * braille.handler.displayDimensions.numCols cells.append(0) cells.extend([1] * (braille.handler.displayDimensions.numCols - 1)) @@ -80,6 +81,6 @@ def test_wordWrapSecondRowStartsWithSpace(self): braille.handler.buffer._calculateWindowRowBufferOffsets(0) expectedOffsets = [(0, 20), (20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) - config.conf["braille"]["wordWrap"] = False + config.conf["braille"]["textWrap"] = BrailleTextWrap.OFF.value braille.handler.buffer._calculateWindowRowBufferOffsets(0) self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) From 098627de466b4924309942c6af69a8acdeadea3d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:17:15 +0000 Subject: [PATCH 07/27] userGuide: replace word wrap section with text wrap combo box documentation Agent-Logs-Url: https://github.com/LeonarddeR/nvda/sessions/3c0c92ab-c024-44d3-bd6a-c7d6c3a92364 Co-authored-by: LeonarddeR <3049216+LeonarddeR@users.noreply.github.com> --- user_docs/en/userGuide.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 9261ec36a76..b8b25359ad9 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2562,18 +2562,23 @@ Enabling this option will cause NVDA to speak lines or paragraphs reached using To toggle this option from anywhere, please assign a custom gesture to "speakOnNavigatingByUnit" in the "Braille" section of the [Input Gestures dialog](#InputGestures). -##### Avoid splitting words when possible {#BrailleSettingsWordWrap} +##### Text wrap {#BrailleSettingsTextWrap} -If this is enabled, a word which is too large to fit at the end of the braille display will not be split. +This combo box allows you to configure how NVDA handles text that is too long to fit on the braille display. +The following options are available: + +* Off: Text is not wrapped. +As much of the text as possible will be displayed, but the rest will be cut off. +When you scroll the display, you will be able to read the rest of the text. +* Show continuation mark only: A continuation mark (default `...`) will be shown at the end of the braille display to indicate that the text continues, but words will not be split. +When you scroll the display, you will be able to read the entire word. +* At word boundaries: A word which is too large to fit at the end of the braille display will not be split. Instead, there will be some blank space at the end of the display. When you scroll the display, you will be able to read the entire word. -This is sometimes called "word wrap". Note that if the word is too large to fit on the display even by itself, the word must still be split. - -If this is disabled, as much of the word as possible will be displayed, but the rest will be cut off. -When you scroll the display, you will then be able to read the rest of the word. - -Enabling this may allow for more fluent reading, but generally requires you to scroll the display more. +This option may allow for more fluent reading, but generally requires you to scroll the display more. +* Break words with hyphen: Words that do not fit at the end of the braille display will be broken with a hyphen. +When you scroll the display, you will be able to read the rest of the word. ##### Unicode normalization {#BrailleUnicodeNormalization} From ccd60a1af3f18259e1dcc040b9e3fe66769ca3c0 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 7 Apr 2026 18:28:59 +0200 Subject: [PATCH 08/27] Update user guide --- user_docs/en/userGuide.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index b8b25359ad9..70a69af7fa6 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2570,15 +2570,17 @@ The following options are available: * Off: Text is not wrapped. As much of the text as possible will be displayed, but the rest will be cut off. When you scroll the display, you will be able to read the rest of the text. -* Show continuation mark only: A continuation mark (default `...`) will be shown at the end of the braille display to indicate that the text continues, but words will not be split. -When you scroll the display, you will be able to read the entire word. +* Show continuation mark only: A continuation mark (dots 7 and 8) will be shown at the end of the braille display to indicate that a word continues. +When you scroll the display, you will be able to read the rest of the word. +* Break words with hyphen: Words that do not fit at the end of the braille display will be broken with a continuation mark (dots 7 and 8). +When you scroll the display, you will be able to read the rest of the word. +This behavior is equal to how common word processors behave and takes the language of the word into account, if known. +For example, the word `behave` will be broken between `be` and `have`. * At word boundaries: A word which is too large to fit at the end of the braille display will not be split. Instead, there will be some blank space at the end of the display. When you scroll the display, you will be able to read the entire word. Note that if the word is too large to fit on the display even by itself, the word must still be split. This option may allow for more fluent reading, but generally requires you to scroll the display more. -* Break words with hyphen: Words that do not fit at the end of the braille display will be broken with a hyphen. -When you scroll the display, you will be able to read the rest of the word. ##### Unicode normalization {#BrailleUnicodeNormalization} From 3f1029e323f7d60c8d691779117719fed4f4a6d8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:00:27 +0000 Subject: [PATCH 09/27] Pre-commit auto-fix --- source/textUtils/hyphenation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/textUtils/hyphenation.py b/source/textUtils/hyphenation.py index 3bb1b0266a2..730db1046a6 100644 --- a/source/textUtils/hyphenation.py +++ b/source/textUtils/hyphenation.py @@ -17,7 +17,7 @@ def _pyphenFactory(lang: str) -> Pyphen: elif "_" in lang and "_" not in pyphenLang: raise LookupError( f"Pyphen resolved {lang!r} to {pyphenLang:r} but the original locale contains a region subtag. " - "Fallbacks should be handled by LocaleDataMap instead" + "Fallbacks should be handled by LocaleDataMap instead", ) return Pyphen(lang=pyphenLang) From 903959caeae965b99265a29b7344f14d07b57cab Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 14 Apr 2026 08:05:31 +0200 Subject: [PATCH 10/27] No longer exclude bisect --- source/setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/source/setup.py b/source/setup.py index 6b3b164dfa2..dc44d2ef7ce 100755 --- a/source/setup.py +++ b/source/setup.py @@ -322,8 +322,6 @@ def _genManifestTemplate(shouldHaveUIAccess: bool) -> tuple[int, int, bytes]: ], "includes": [ "nvdaBuiltin", - # #3368: bisect was implicitly included with Python 2.7.3, but isn't with 2.7.5. - "bisect", # robotremoteserver (for system tests) depends on xmlrpc.server "xmlrpc.server", # Required for RPYC over std pipes From f818f6220dbde6961e39c8e35c792720ac37acf9 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Mon, 20 Apr 2026 18:28:22 +0200 Subject: [PATCH 11/27] Fixup config spec --- source/config/configSpec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index ab67f3641d8..62e2292fd4d 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -13,7 +13,7 @@ #: provide an upgrade step (@see profileUpgradeSteps.py). An upgrade step does not need to be added when #: just adding a new element to (or removing from) the schema, only when old versions of the config #: (conforming to old schema versions) will not work correctly with the new schema. -latestSchemaVersion = 22 +latestSchemaVersion = 23 #: The configuration specification string #: @type: String From c0b2b97930b4049d2a8a9361358afdb806f45100 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Mon, 20 Apr 2026 19:05:36 +0200 Subject: [PATCH 12/27] Add a hook to setup.py --- source/setup.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/source/setup.py b/source/setup.py index dc44d2ef7ce..404e4a138c4 100755 --- a/source/setup.py +++ b/source/setup.py @@ -1,12 +1,12 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2026 NV Access Limited, Peter Vágner, Joseph Lee +# Copyright (C) 2006-2026 NV Access Limited, Peter Vágner, Joseph Lee, Leonard de Ruijter # This file is covered by the GNU General Public License. # See the file COPYING for more details. from __future__ import annotations import argparse -from ast import NodeTransformer, fix_missing_locations, parse +from ast import Assign, NodeTransformer, fix_missing_locations, parse import os import sys import gettext @@ -121,6 +121,70 @@ def _hook_latex2mathml_symbols_parser(finder: Scanner, module: Module) -> None: py2exe.hooks.hook_latex2mathml_symbols_parser = _hook_latex2mathml_symbols_parser +class _PyphenTransformer(NodeTransformer): + """Rewrite pyphen's ``dictionaries`` assignment to resolve relative to the frozen executable.""" + + def __init__(self, relpath: str): + super().__init__() + self.rewritten: bool = False + self.relpath = relpath + + def visit_Try(self, node): + # Match the upstream try/except TypeError block whose first body statement + # is ``dictionaries = resources.files('pyphen.dictionaries')``. + firstStmt = node.body[0] if node.body else None + if ( + isinstance(firstStmt, Assign) + and len(firstStmt.targets) == 1 + and getattr(firstStmt.targets[0], "id", None) == "dictionaries" + ): + replacement = parse( + f"dictionaries = Path(os.path.dirname(sys.executable)) / {self.relpath!r}", + ).body[0] + self.rewritten = True + return replacement + return node + + +def _hook_pyphen(finder: Scanner, module: Module) -> None: + """py2exe hook for the pyphen package. + + pyphen locates its ``dictionaries/*.dic`` data files at runtime relative to + its own package directory (via ``importlib.resources`` or ``__file__``). + After freezing, pyphen lives inside ``library.zip`` and those paths no + longer resolve, leaving ``pyphen.LANGUAGES`` empty. This hook: + + 1. Copies every ``hyph_*.dic`` file into ``pyphenDictionaries/`` next to + the frozen executable. + 2. Rewrites the module's ``dictionaries`` assignment via an AST + transformation so it resolves to + ``Path(os.path.dirname(sys.executable)) / 'pyphenDictionaries'``. + """ + import pyphen + + DEST_DIR: Final[str] = "pyphenDictionaries" + sourceDir = os.path.join(os.path.dirname(pyphen.__file__), "dictionaries") + for sourceFile in glob(os.path.join(sourceDir, "hyph_*.dic")): + finder.add_datafile( + os.path.join(DEST_DIR, os.path.basename(sourceFile)), + sourceFile, + ) + tree = parse(module.__source__) + # Inject imports needed by the rewritten expression. + tree.body.insert(0, parse("import os").body[0]) + tree.body.insert(0, parse("import sys").body[0]) + transformer = _PyphenTransformer(DEST_DIR) + newTree = fix_missing_locations(transformer.visit(tree)) + if not transformer.rewritten: + raise RuntimeError( + "py2exe hook failed to rewrite the dictionaries assignment in pyphen. The upstream module may have changed its layout.", + ) + module.__code_object__ = compile(newTree, module.__file__, "exec", optimize=module.__optimize__) + + +py2exe.hooks.hook_pyphen = _hook_pyphen + + def _parsePartialArguments() -> argparse.Namespace: """ Adds a command line option --enable-uiAccess to enable uiAccess for the main executable and EOA proxy From acc793fb04feb345fea2fb30f59121472502978a Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Mon, 20 Apr 2026 20:14:50 +0200 Subject: [PATCH 13/27] braille: switch textWrap to feature flag with renamed modes Replace BrailleTextWrap IntEnum with BrailleTextWrapFlag feature flag stored via featureFlag config spec, mirroring reviewRoutingMovesSystemCaret. Rename members to NONE, MARK_WORD_CUTS, AT_WORD_BOUNDARIES, AT_WORD_OR_SYLLABLE_BOUNDARIES for clarity (braille uses word division, not print hyphenation). Unify continuation-marker semantics under rule A: the marker now fires on any mid-word row end regardless of mode, including the no-whitespace fallback in AT_WORD_BOUNDARIES/AT_WORD_OR_SYLLABLE_BOUNDARIES. Handle unknown languages gracefully in getHyphenPositions by returning an empty tuple and logging once per locale. Update profile upgrade, deprecation bridge for wordWrap, settings dialog (FeatureFlagCombo), and user guide. --- source/braille.py | 33 ++++++++++++++++++---------- source/config/__init__.py | 16 +++++++++----- source/config/configFlags.py | 30 ------------------------- source/config/configSpec.py | 3 +-- source/config/featureFlagEnums.py | 30 +++++++++++++++++++++++++ source/config/profileUpgradeSteps.py | 9 ++++---- source/gui/settingsDialogs.py | 22 +++++++------------ source/textUtils/hyphenation.py | 31 +++++++++++++++++++++----- user_docs/en/userGuide.md | 14 ++++++------ 9 files changed, 107 insertions(+), 81 deletions(-) diff --git a/source/braille.py b/source/braille.py index beb12aa9560..e2feb0e9c8c 100644 --- a/source/braille.py +++ b/source/braille.py @@ -49,7 +49,6 @@ import config import easeOfAccess from config.configFlags import ( - BrailleTextWrap, ShowMessages, TetherTo, BrailleMode, @@ -57,7 +56,11 @@ OutputMode, ReportSpellingErrors, ) -from config.featureFlagEnums import ReviewRoutingMovesSystemCaretFlag, FontFormattingBrailleModeFlag +from config.featureFlagEnums import ( + BrailleTextWrapFlag, + FontFormattingBrailleModeFlag, + ReviewRoutingMovesSystemCaretFlag, +) from logHandler import log import controlTypes import api @@ -2038,7 +2041,7 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: # Initialising with no actual braille content. self._windowRowBufferOffsets = [(0, 0)] return - textWrap = BrailleTextWrap(config.conf["braille"]["textWrap"]) + textWrap: BrailleTextWrapFlag = config.conf["braille"]["textWrap"].calculated() bufferEnd = len(self.brailleCells) start = pos clippedEnd = False @@ -2048,20 +2051,23 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: if end > bufferEnd: end = bufferEnd clippedEnd = True - elif textWrap == BrailleTextWrap.CONTINUATION_ONLY and all(self.brailleCells[end - 1 : end + 1]): + elif textWrap == BrailleTextWrapFlag.MARK_WORD_CUTS and all(self.brailleCells[end - 1 : end + 1]): end -= 1 showContinuationMark = True - elif textWrap in (BrailleTextWrap.WORD_BOUNDARIES, BrailleTextWrap.HYPHENATE): + elif textWrap in ( + BrailleTextWrapFlag.AT_WORD_BOUNDARIES, + BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES, + ): try: lastSpaceIndex = rindex(self.brailleCells, 0, start, end + 1) if lastSpaceIndex < end: # The next braille window doesn't start with space. oldEnd = end end = rindex(self.brailleCells, 0, start, end) + 1 - if end < oldEnd and textWrap == BrailleTextWrap.HYPHENATE: - # When hyphenating, we want to split the word after the last space. + if end < oldEnd and textWrap == BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES: + # Prefer splitting the word at a syllable boundary closer to the display edge. # Note that, when the below index call fails, it is appropriately handled by the except block, - # which means that we won't hyphenate in this case. + # which means that we won't split at a syllable boundary in this case. nextSpace = self.brailleCells.index(0, oldEnd, bufferEnd) word = self.bufferPositionsToRawText(end, nextSpace - 1) if word: @@ -2075,7 +2081,10 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: showContinuationMark = True break except (ValueError, IndexError): - pass # No space on line + # No space on line - fall back to display-edge cut. + # Under rule A, mark this as a word cut since a word was split mid-way. + if all(self.brailleCells[end - 1 : end + 1]): + showContinuationMark = True if showContinuationMark: self._continuationRows.append(len(self._windowRowBufferOffsets)) self._windowRowBufferOffsets.append((start, end)) @@ -2117,9 +2126,9 @@ def _set_windowEndPos(self, endPos: int) -> None: if startPos <= restrictPos: self.windowStartPos = restrictPos return - if config.conf["braille"]["textWrap"] in ( - BrailleTextWrap.OFF.value, - BrailleTextWrap.CONTINUATION_ONLY.value, + if config.conf["braille"]["textWrap"].calculated() in ( + BrailleTextWrapFlag.NONE, + BrailleTextWrapFlag.MARK_WORD_CUTS, ): self.windowStartPos = startPos return diff --git a/source/config/__init__.py b/source/config/__init__.py index 82fbf84f04c..98e76d4ca76 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -39,7 +39,7 @@ from . import profileUpgrader from . import aggregatedSection from .configSpec import confspec -from .configFlags import BrailleTextWrap +from .featureFlagEnums import BrailleTextWrapFlag from .featureFlag import ( _transformSpec_AddFeatureFlagDefault, _validateConfig_featureFlag, @@ -1401,17 +1401,23 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat case "braille": match key: case "wordWrap": - # The "wordWrap" setting was renamed to "textWrap" and became an enum. + # The "wordWrap" setting was renamed to "textWrap" and became a feature flag. log.warning( "braille.wordWrap is deprecated. Use braille.textWrap instead.", stack_info=True, ) key = "textWrap" - val = (BrailleTextWrap.WORD_BOUNDARIES if val else BrailleTextWrap.OFF).value + val = ( + BrailleTextWrapFlag.AT_WORD_BOUNDARIES if val else BrailleTextWrapFlag.NONE + ).name case "textWrap": - # The "textWrap" setting was added in place of "wordWrap" and became an enum. + # The "textWrap" setting was added in place of "wordWrap" and became a feature flag. key = "wordWrap" - val = val in (BrailleTextWrap.WORD_BOUNDARIES.value, BrailleTextWrap.HYPHENATE.value) + calculated: BrailleTextWrapFlag = val.calculated() + val = calculated in ( + BrailleTextWrapFlag.AT_WORD_BOUNDARIES, + BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES, + ) case _: # We don't care about other keys in this section. diff --git a/source/config/configFlags.py b/source/config/configFlags.py index 86711d369c5..9b8fbc0ab0d 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -464,33 +464,3 @@ def _displayStringLabels(self): # Translators: Label for a value in the Play a sound for logged errors combobox, in the Advanced settings. PlayErrorSound.NO: pgettext("advanced.playErrorSound", "No"), } - - -@unique -class BrailleTextWrap(DisplayStringIntEnum): - """Enumeration containing the possible config values for braille text wrap. - - Use BrailleTextWrap.MEMBER.value to compare with the config; - use BrailleTextWrap.MEMBER.displayString in the UI for a translatable description of this member. - """ - - OFF = 0 - CONTINUATION_ONLY = 1 - WORD_BOUNDARIES = 2 - HYPHENATE = 3 - - @property - def _displayStringLabels(self): - return { - # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. - BrailleTextWrap.OFF: pgettext("braille text wrap setting", "Off"), - BrailleTextWrap.CONTINUATION_ONLY: pgettext( - "braille text wrap setting", - # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. - "Show continuation mark only", - ), - # Translators: A choice in a combo box in the braille settings panel to configure word wrap. - BrailleTextWrap.WORD_BOUNDARIES: pgettext("braille text wrap setting", "At word boundaries"), - # Translators: A choice in a combo box in the braille settings panel to configure word wrap. - BrailleTextWrap.HYPHENATE: pgettext("braille text wrap setting", "Break words with hyphen"), - } diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 62e2292fd4d..e3b7c5c70d5 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -93,8 +93,7 @@ paragraphStartMarker = option("", " ", "¶", default="") # Deprecated in 2026.2 wordWrap = boolean(default=true) - # 0: Off, 1: Continuation Only, 2: Word Boundaries, 3: Hyphenate - textWrap = integer(0, 3, default=3) + textWrap = featureFlag(optionsEnum="BrailleTextWrapFlag", behaviorOfDefault="AT_WORD_OR_SYLLABLE_BOUNDARIES") unicodeNormalization = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="disabled") focusContextPresentation = option("changedContext", "fill", "scroll", default="changedContext") interruptSpeechWhileScrolling = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled") diff --git a/source/config/featureFlagEnums.py b/source/config/featureFlagEnums.py index 5bcb1db1fdb..a0919ae9a1e 100644 --- a/source/config/featureFlagEnums.py +++ b/source/config/featureFlagEnums.py @@ -139,6 +139,36 @@ def _displayStringLabels(self) -> dict["FontFormattingBrailleModeFlag", str]: } +class BrailleTextWrapFlag(DisplayStringEnum): + """Enumeration containing the possible ways to wrap text in braille when a row would exceed the display. + + The continuation mark (dots 7-8) is shown on rows where a word was cut, + regardless of mode (except for NONE, which never shows the mark). + """ + + @property + def _displayStringLabels(self): + return { + # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. + self.NONE: pgettext("braille text wrap", "Off"), + # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. + self.MARK_WORD_CUTS: pgettext("braille text wrap", "Show mark when words are cut"), + # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. + self.AT_WORD_BOUNDARIES: pgettext("braille text wrap", "At word boundaries"), + # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. + self.AT_WORD_OR_SYLLABLE_BOUNDARIES: pgettext( + "braille text wrap", + "At word or syllable boundaries", + ), + } + + DEFAULT = enum.auto() + NONE = enum.auto() + MARK_WORD_CUTS = enum.auto() + AT_WORD_BOUNDARIES = enum.auto() + AT_WORD_OR_SYLLABLE_BOUNDARIES = enum.auto() + + def getAvailableEnums() -> typing.Generator[typing.Tuple[str, FlagValueEnum], None, None]: for name, value in globals().items(): if ( diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index c9db54548ee..8d3011be233 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -20,7 +20,6 @@ from logHandler import log from config.configFlags import ( - BrailleTextWrap, NVDAKey, OutputMode, ReportCellBorders, @@ -31,6 +30,7 @@ TetherTo, TypingEcho, ) +from config.featureFlagEnums import BrailleTextWrapFlag def upgradeConfigFrom_0_to_1(profile: ConfigObj) -> None: @@ -694,7 +694,7 @@ def upgradeConfigFrom_22_to_23(profile: ConfigObj) -> None: """ If the wordWrap braille config flag is explicitly set in a profile, set the new text wrap option to word boundaries, - rather than the new hyphenate default. + rather than the new default of word or syllable boundaries. """ section = "braille" key = "wordWrap" @@ -708,9 +708,8 @@ def upgradeConfigFrom_22_to_23(profile: ConfigObj) -> None: log.error(f"'{key}' is not a boolean, got {profile[section][key]!r}. No action taken.") return - newValue = BrailleTextWrap.WORD_BOUNDARIES.value if oldValue else BrailleTextWrap.OFF.value + newValue = BrailleTextWrapFlag.AT_WORD_BOUNDARIES.name if oldValue else BrailleTextWrapFlag.NONE.name profile[section][newKey] = newValue log.debug( - f"Converted '{key}' with value {oldValue} to '{newKey}' with value {newValue}" - f" ({BrailleTextWrap(newValue).name}).", + f"Converted '{key}' with value {oldValue} to '{newKey}' with value {newValue}.", ) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index b1931ab839a..d23010575da 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -63,7 +63,6 @@ from addonStore.models.channel import UpdateChannel from config.configFlags import ( AddonsAutomaticUpdate, - BrailleTextWrap, NVDAKey, OutputMode, ParagraphStartMarker, @@ -5484,18 +5483,15 @@ def makeSettings(self, settingsSizer): list(braille.BrailleMode)[self.brailleModes.GetSelection()] is braille.BrailleMode.FOLLOW_CURSORS, ) - # Translators: The label for a setting in braille settings to enable word wrap - # (try to avoid splitting words at the end of the braille display). - textWrapText = _("Text &wrap") - self.textWrapComboBox = sHelper.addLabeledControl( - textWrapText, - wx.Choice, - choices=[option.displayString for option in BrailleTextWrap], + self.textWrapComboBox: nvdaControls.FeatureFlagCombo = sHelper.addLabeledControl( + # Translators: The label for a setting in braille settings to configure text wrap behaviour + # (how to break lines that don't fit on the braille display). + labelText=_("Text &wrap"), + wxCtrlClass=nvdaControls.FeatureFlagCombo, + keyPath=["braille", "textWrap"], + conf=config.conf, ) self.bindHelpEvent("BrailleSettingsTextWrap", self.textWrapComboBox) - self.textWrapComboBox.SetSelection( - [option.value for option in BrailleTextWrap].index(config.conf["braille"]["textWrap"]), - ) self.unicodeNormalizationCombo: nvdaControls.FeatureFlagCombo = sHelper.addLabeledControl( labelText=_( @@ -5585,9 +5581,7 @@ def onSave(self): ] config.conf["braille"]["speakOnRouting"] = self.speakOnRoutingCheckBox.Value config.conf["braille"]["speakOnNavigatingByUnit"] = self.speakOnNavigatingCheckBox.Value - config.conf["braille"]["textWrap"] = [option.value for option in BrailleTextWrap][ - self.textWrapComboBox.GetSelection() - ] + self.textWrapComboBox.saveCurrentValueToConf() self.unicodeNormalizationCombo.saveCurrentValueToConf() config.conf["braille"]["focusContextPresentation"] = self.focusContextPresentationValues[ self.focusContextPresentationList.GetSelection() diff --git a/source/textUtils/hyphenation.py b/source/textUtils/hyphenation.py index 730db1046a6..38f778df55c 100644 --- a/source/textUtils/hyphenation.py +++ b/source/textUtils/hyphenation.py @@ -6,6 +6,7 @@ """Utilities for hyphenation.""" from characterProcessing import LocaleDataMap +from logHandler import log from pyphen import Pyphen, language_fallback @@ -16,7 +17,7 @@ def _pyphenFactory(lang: str) -> Pyphen: raise LookupError(f"No Pyphen language found for locale '{lang}'") elif "_" in lang and "_" not in pyphenLang: raise LookupError( - f"Pyphen resolved {lang!r} to {pyphenLang:r} but the original locale contains a region subtag. " + f"Pyphen resolved {lang!r} to {pyphenLang!r} but the original locale contains a region subtag. " "Fallbacks should be handled by LocaleDataMap instead", ) return Pyphen(lang=pyphenLang) @@ -24,8 +25,26 @@ def _pyphenFactory(lang: str) -> Pyphen: _hypenationMap: LocaleDataMap[Pyphen] = LocaleDataMap(_pyphenFactory) - -def getHyphenPositions(text: str, locale: str): - """Get the positions of hyphenation points in the given text for the given locale.""" - pyphen = _hypenationMap.fetchLocaleData(locale=locale) - return pyphen.positions(text) +#: Set of locales for which we have already logged an unknown-language fallback. +#: Used to avoid spamming the log on every call for the same unsupported locale. +_loggedUnknownLocales: set[str] = set() + + +def getHyphenPositions(text: str, locale: str) -> tuple[int, ...]: + """Get the positions of hyphenation points in the given text for the given locale. + + If no hyphenation dictionary is available for the locale, an empty tuple is returned + and a debug message is logged (once per locale). + + :param text: The text to find hyphenation points in. + :param locale: The locale of the text. + :return: A tuple of positions in the text where hyphenation points occur. + """ + try: + pyphen = _hypenationMap.fetchLocaleData(locale=locale) + except LookupError: + if locale not in _loggedUnknownLocales: + _loggedUnknownLocales.add(locale) + log.debug(f"No Pyphen dictionary available for locale {locale!r}; hyphenation disabled.") + return () + return tuple(pyphen.positions(text)) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 7ea323fb0d1..71ae5cce90a 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2579,22 +2579,22 @@ Commands can be assigned to toggle the automatic scroll option, and to increase ##### Text wrap {#BrailleSettingsTextWrap} This combo box allows you to configure how NVDA handles text that is too long to fit on the braille display. +When a word is cut across rows, the continuation mark (dots 7 and 8) is shown in the last cell of the row, unless otherwise noted below. The following options are available: -* Off: Text is not wrapped. +* Off: Text is not wrapped and no continuation mark is shown. As much of the text as possible will be displayed, but the rest will be cut off. When you scroll the display, you will be able to read the rest of the text. -* Show continuation mark only: A continuation mark (dots 7 and 8) will be shown at the end of the braille display to indicate that a word continues. +* Show mark when words are cut: Text is not wrapped, but whenever a word is cut at the end of the display, a continuation mark is shown. When you scroll the display, you will be able to read the rest of the word. -* Break words with hyphen: Words that do not fit at the end of the braille display will be broken with a continuation mark (dots 7 and 8). -When you scroll the display, you will be able to read the rest of the word. -This behavior is equal to how common word processors behave and takes the language of the word into account, if known. -For example, the word `behave` will be broken between `be` and `have`. * At word boundaries: A word which is too large to fit at the end of the braille display will not be split. Instead, there will be some blank space at the end of the display. When you scroll the display, you will be able to read the entire word. -Note that if the word is too large to fit on the display even by itself, the word must still be split. +Note that if the word is too large to fit on the display even by itself, the word must still be split; the continuation mark is then shown. This option may allow for more fluent reading, but generally requires you to scroll the display more. +* At word or syllable boundaries: Like "At word boundaries", but long words that don't fit are split at a syllable boundary when possible, using the language of the word if known. +For example, the word `behave` may be split between `be` and `have`. +The continuation mark is shown whenever a word is split. ##### Unicode normalization {#BrailleUnicodeNormalization} From ee3a2a3dceb6ecab5529c0026c51f372ab27ea49 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Mon, 20 Apr 2026 20:37:29 +0200 Subject: [PATCH 14/27] tests: add coverage for braille text wrap modes, continuation marker, region language, and hyphenation Update test_calculateWindowRowBufferOffsets for the renamed BrailleTextWrapFlag feature flag and add tests #1-#8 covering NONE, MARK_WORD_CUTS, AT_WORD_BOUNDARIES (including the rule-A marker fix for the no-whitespace fallback), and AT_WORD_OR_SYLLABLE_BOUNDARIES (success, empty positions, past-edge position, unknown language). Add test_windowBrailleCells for CONTINUATION_SHAPE rendering (#9-#10). Add test_regionLanguageIndexes for Region._languageIndexes defaults, _addFieldText switch/restore entries, _addTextWithFields formatChange language handling, and TextInfoRegion.update reset (#11-#14). Add test_hyphenation for getHyphenPositions with known and unknown locales (#15-#16). --- .../test_calculateWindowRowBufferOffsets.py | 192 +++++++++++++++++- .../test_regionLanguageIndexes.py | 123 +++++++++++ .../test_braille/test_windowBrailleCells.py | 50 +++++ tests/unit/test_hyphenation.py | 33 +++ 4 files changed, 392 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_braille/test_regionLanguageIndexes.py create mode 100644 tests/unit/test_braille/test_windowBrailleCells.py create mode 100644 tests/unit/test_hyphenation.py diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index fc4dc7c3966..81980293438 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -6,10 +6,12 @@ """Unit tests for the _calculateWindowRowBufferOffsets function in the braille module.""" import unittest +from unittest.mock import patch import braille import config -from config.configFlags import BrailleTextWrap +from config.featureFlag import FeatureFlag +from config.featureFlagEnums import BrailleTextWrapFlag def _getDisplayDimensions(dimensions: braille.DisplayDimensions) -> braille.DisplayDimensions: @@ -20,13 +22,27 @@ def _getDisplayDimensions(dimensions: braille.DisplayDimensions) -> braille.Disp ) +def _setTextWrap(mode: BrailleTextWrapFlag) -> None: + """Write a `BrailleTextWrapFlag` value to the config as a `FeatureFlag`. + + `behaviorOfDefault` is only meaningful when `mode` is `DEFAULT`; for any explicit value, + we pick an arbitrary non-DEFAULT member to satisfy the `FeatureFlag` constructor assertions. + """ + behaviorOfDefault = ( + BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES + if mode != BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES + else BrailleTextWrapFlag.AT_WORD_BOUNDARIES + ) + config.conf["braille"]["textWrap"] = FeatureFlag(mode, behaviorOfDefault) + + class TestCalculate(unittest.TestCase): def setUp(self): braille.filter_displayDimensions.register(_getDisplayDimensions) def tearDown(self): braille.filter_displayDimensions.unregister(_getDisplayDimensions) - config.conf["braille"]["textWrap"] = BrailleTextWrap.OFF.value + _setTextWrap(BrailleTextWrapFlag.NONE) def test_noCells(self): """Check that, if list of braille cells is empty, offsets will be (0, 0).""" @@ -58,7 +74,7 @@ def test_end(self): def test_wordWrapFirstRowWithSpace(self): """Check that the first row will be truncated if it contains a space, only if text wrap is set to word boundaries.""" - config.conf["braille"]["textWrap"] = BrailleTextWrap.WORD_BOUNDARIES.value + _setTextWrap(BrailleTextWrapFlag.AT_WORD_BOUNDARIES) cells = [1] * (braille.handler.displayDimensions.numCols - 5) cells.append(0) cells.extend([1] * (braille.handler.displayDimensions.numCols + 4)) @@ -66,14 +82,14 @@ def test_wordWrapFirstRowWithSpace(self): braille.handler.buffer._calculateWindowRowBufferOffsets(0) expectedOffsets = [(0, 16), (16, 36)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) - config.conf["braille"]["textWrap"] = BrailleTextWrap.OFF.value + _setTextWrap(BrailleTextWrapFlag.NONE) braille.handler.buffer._calculateWindowRowBufferOffsets(0) expectedOffsets = [(0, 20), (20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_wordWrapSecondRowStartsWithSpace(self): """Check that the first row won't be truncated if the next row starts with a space, even if word wrap is True.""" - config.conf["braille"]["textWrap"] = BrailleTextWrap.WORD_BOUNDARIES.value + _setTextWrap(BrailleTextWrapFlag.AT_WORD_BOUNDARIES) cells = [1] * braille.handler.displayDimensions.numCols cells.append(0) cells.extend([1] * (braille.handler.displayDimensions.numCols - 1)) @@ -81,6 +97,170 @@ def test_wordWrapSecondRowStartsWithSpace(self): braille.handler.buffer._calculateWindowRowBufferOffsets(0) expectedOffsets = [(0, 20), (20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) - config.conf["braille"]["textWrap"] = BrailleTextWrap.OFF.value + _setTextWrap(BrailleTextWrapFlag.NONE) braille.handler.buffer._calculateWindowRowBufferOffsets(0) self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) + + # --- Tests #1-#8 from the plan ------------------------------------------------ + + def test_none_hardCutsAtDisplayEdge(self): + """#1: NONE wraps at the raw display edge with no continuation marker, even mid-word.""" + _setTextWrap(BrailleTextWrapFlag.NONE) + # 25 consecutive non-zero cells: no space anywhere in the first row. + braille.handler.buffer.brailleCells = [1] * 25 + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, [(0, 20), (20, 25)]) + self.assertEqual(braille.handler.buffer._continuationRows, []) + + def test_markWordCuts_oneCellEarlierAndMarksRow(self): + """#2: MARK_WORD_CUTS cuts one cell earlier than NONE and records the row in _continuationRows.""" + _setTextWrap(BrailleTextWrapFlag.MARK_WORD_CUTS) + braille.handler.buffer.brailleCells = [1] * 25 + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + # With MARK_WORD_CUTS, the end is pulled back by 1 to leave room for the marker. + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 19)) + self.assertIn(0, braille.handler.buffer._continuationRows) + + def test_markWordCuts_cleanRowHasNoMarker(self): + """#3: MARK_WORD_CUTS does not mark a row that ends naturally at a space.""" + _setTextWrap(BrailleTextWrapFlag.MARK_WORD_CUTS) + # Row of 20 cells where cell 19 is a space (0): no mid-word cut. + cells = [1] * 19 + [0] + [1] * 10 + braille.handler.buffer.brailleCells = cells + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + self.assertNotIn(0, braille.handler.buffer._continuationRows) + + def test_atWordBoundaries_noSpaceInWindowMarksCut(self): + """#4: AT_WORD_BOUNDARIES with no whitespace in the window hard-cuts AND marks the row (rule A).""" + _setTextWrap(BrailleTextWrapFlag.AT_WORD_BOUNDARIES) + # No zero anywhere in row 0; the `rindex` call raises and falls through. + braille.handler.buffer.brailleCells = [1] * 25 + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 20)) + self.assertIn(0, braille.handler.buffer._continuationRows) + + def test_atWordOrSyllableBoundaries_success(self): + """#5: AT_WORD_OR_SYLLABLE_BOUNDARIES splits at a syllable boundary and marks the row.""" + _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) + # Layout: + # cells 0..9 -> short word + # cell 10 -> space + # cells 11..29 -> 19-cell long word that crosses the 20-cell display edge + # cell 30 -> space + # cells 31..35 -> tail + cells = [1] * 10 + [0] + [1] * 19 + [0] + [1] * 5 + braille.handler.buffer.brailleCells = cells + with ( + patch.object( + braille.handler.buffer, + "bufferPositionsToRawText", + return_value="abcdefghijklmnopqrs", + ), + patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="en_US"), + patch( + "braille.textUtils.hyphenation.getHyphenPositions", + return_value=(3,), + ), + # Identity mapping between raw text and braille cells is sufficient for this synthetic buffer. + patch.object( + type(braille.handler.buffer), + "rawToBraillePos", + new=list(range(len(cells))), + ), + patch.object( + type(braille.handler.buffer), + "brailleToRawPos", + new=list(range(len(cells))), + ), + ): + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + # Syllable split at rawPos 3 + brailleStart 11 = 14. + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 14)) + self.assertIn(0, braille.handler.buffer._continuationRows) + + def test_atWordOrSyllableBoundaries_emptyPositions(self): + """#6: AT_WORD_OR_SYLLABLE_BOUNDARIES with no hyphen positions falls back to a word boundary with no marker.""" + _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) + # Provide a real space so `rindex(... end+1)` / `rindex(... end)` succeeds. + cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 + braille.handler.buffer.brailleCells = cells + with ( + patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), + patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="en_US"), + patch( + "braille.textUtils.hyphenation.getHyphenPositions", + return_value=(), + ), + patch.object( + type(braille.handler.buffer), + "rawToBraillePos", + new=list(range(len(cells))), + ), + patch.object( + type(braille.handler.buffer), + "brailleToRawPos", + new=list(range(len(cells))), + ), + ): + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + # End should be just after the last space in row 0 (index 15 -> end = 16). + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 16)) + self.assertNotIn(0, braille.handler.buffer._continuationRows) + + def test_atWordOrSyllableBoundaries_positionPastEdge(self): + """#7: AT_WORD_OR_SYLLABLE_BOUNDARIES with newEnd >= oldEnd falls back to word boundary.""" + _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) + cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 + braille.handler.buffer.brailleCells = cells + with ( + patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), + patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="en_US"), + # Position that maps past the display edge. + patch( + "braille.textUtils.hyphenation.getHyphenPositions", + return_value=(22,), + ), + patch.object( + type(braille.handler.buffer), + "rawToBraillePos", + new=list(range(len(cells))), + ), + patch.object( + type(braille.handler.buffer), + "brailleToRawPos", + new=list(range(len(cells))), + ), + ): + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + # Falls back to word boundary at position 16. + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 16)) + self.assertNotIn(0, braille.handler.buffer._continuationRows) + + def test_atWordOrSyllableBoundaries_unknownLanguage(self): + """#8: AT_WORD_OR_SYLLABLE_BOUNDARIES with an unknown language behaves like empty positions.""" + # getHyphenPositions swallows LookupError itself, so from braille.py's perspective + # it just returns an empty tuple. Behaviour must match #6. + _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) + cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 + braille.handler.buffer.brailleCells = cells + with ( + patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), + patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="zz_ZZ"), + patch( + "braille.textUtils.hyphenation.getHyphenPositions", + return_value=(), + ), + patch.object( + type(braille.handler.buffer), + "rawToBraillePos", + new=list(range(len(cells))), + ), + patch.object( + type(braille.handler.buffer), + "brailleToRawPos", + new=list(range(len(cells))), + ), + ): + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 16)) + self.assertNotIn(0, braille.handler.buffer._continuationRows) diff --git a/tests/unit/test_braille/test_regionLanguageIndexes.py b/tests/unit/test_braille/test_regionLanguageIndexes.py new file mode 100644 index 00000000000..aefd983b23d --- /dev/null +++ b/tests/unit/test_braille/test_regionLanguageIndexes.py @@ -0,0 +1,123 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter +# 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 + +"""Unit tests for Region language index tracking in the braille module.""" + +import unittest +from unittest.mock import patch + +import braille +import textInfos + + +def _makeTextInfoRegion() -> braille.TextInfoRegion: + """Build a TextInfoRegion without going through __init__ (which requires an NVDAObject).""" + region = braille.TextInfoRegion.__new__(braille.TextInfoRegion) + braille.Region.__init__(region) + # Force a deterministic default language so we don't depend on NVDA's configured locale. + region._languageIndexes = {0: "en"} + return region + + +class TestLanguageIndexes(unittest.TestCase): + def test_freshRegion_defaultLanguageAtAnyPos(self): + """#11: A region returns the default language for any non-negative pos.""" + with patch.object(braille.Region, "_getDefaultRegionLanguage", return_value="en"): + region = braille.Region() + self.assertEqual(region._getLanguageAtPos(0), "en") + self.assertEqual(region._getLanguageAtPos(5), "en") + self.assertEqual(region._getLanguageAtPos(100), "en") + + def test_addFieldText_insertsSwitchAndRestore(self): + """#12: _addFieldText inserts a switch entry at len(rawText) and a restore entry at len+textLen when the field language differs.""" + region = _makeTextInfoRegion() + # Pre-existing raw text to make `len(rawText)` non-zero and exercise the separator logic. + region.rawText = "hello" + region.rawTextTypeforms = [] + region._rawToContentPos = list(range(5)) + rawTextLenBefore = len(region.rawText) + text = "field" + with patch("braille.languageHandler.getLanguage", return_value="fr"): + region._addFieldText(text, contentPos=0) + # `_addFieldText` prepends TEXT_SEPARATOR when `separate=True` and there is pre-existing text. + addedLen = len(braille.TEXT_SEPARATOR) + len(text) + self.assertIn(rawTextLenBefore, region._languageIndexes) + self.assertEqual(region._languageIndexes[rawTextLenBefore], "fr") + self.assertIn(rawTextLenBefore + addedLen, region._languageIndexes) + self.assertEqual(region._languageIndexes[rawTextLenBefore + addedLen], "en") + + def test_addTextWithFields_formatChangeInsertsLanguageIndex(self): + """#13: Processing a formatChange command whose field has a `language` attribute inserts an index entry.""" + region = _makeTextInfoRegion() + region.rawText = "" + region.rawTextTypeforms = [] + region._rawToContentPos = [] + region._currentContentPos = 0 + region._endsWithField = False + region._isFormatFieldAtStart = True + region._skipFieldsNotAtStartOfNode = False + region.cursorPos = None + region.selectionStart = region.selectionEnd = None + + # Build a minimal list of commands: text, then a formatChange with language=de, then more text. + field = textInfos.FormatField() + field["language"] = "de" + commands = [ + "pre ", + textInfos.FieldCommand(command="formatChange", field=field), + "post", + ] + + class FakeObj: + _brailleFormatFieldAttributesCache: dict = {} + + class FakeInfo: + isCollapsed = False + obj = FakeObj() + + def getTextWithFields(self, formatConfig=None): + return commands + + formatConfig = { + "reportClickable": False, + } + # Stub helpers that would otherwise require a real NVDA environment. + with ( + patch("braille.getFormatFieldBraille", return_value=""), + patch.object( + braille.TextInfoRegion, + "_getTypeformFromFormatField", + return_value=0, + ), + ): + region._addTextWithFields(FakeInfo(), formatConfig) + # The language switch should have been recorded at len("pre ") == 4. + self.assertIn(4, region._languageIndexes) + self.assertEqual(region._languageIndexes[4], "de") + + def test_textInfoRegion_update_resetsLanguageIndexes(self): + """#14: TextInfoRegion.update resets _languageIndexes to {0: default} — no stale indexes carry across updates.""" + region = _makeTextInfoRegion() + # Pollute _languageIndexes with stale entries. + region._languageIndexes = {0: "en", 10: "de", 30: "en"} + + # Run only the resetting portion of `update` by making `_getSelection` raise immediately, + # then inspect state. The reset happens before `_getSelection` is called. + with ( + patch.object(braille.TextInfoRegion, "_getDefaultRegionLanguage", return_value="en"), + patch.object( + braille.TextInfoRegion, + "_getReadingUnit", + return_value=textInfos.UNIT_LINE, + ), + patch.object( + braille.TextInfoRegion, + "_getSelection", + side_effect=RuntimeError("stop-after-reset"), + ), + ): + with self.assertRaises(RuntimeError): + region.update() + self.assertEqual(region._languageIndexes, {0: "en"}) diff --git a/tests/unit/test_braille/test_windowBrailleCells.py b/tests/unit/test_braille/test_windowBrailleCells.py new file mode 100644 index 00000000000..9342c760637 --- /dev/null +++ b/tests/unit/test_braille/test_windowBrailleCells.py @@ -0,0 +1,50 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter +# 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 + +"""Unit tests for the _get_windowBrailleCells property in the braille module.""" + +import unittest + +import braille + + +def _getDisplayDimensions(dimensions: braille.DisplayDimensions) -> braille.DisplayDimensions: + """Used to build a braille handler with particular dimensions.""" + return braille.DisplayDimensions( + numRows=2, + numCols=20, + ) + + +class TestWindowBrailleCells(unittest.TestCase): + def setUp(self): + braille.filter_displayDimensions.register(_getDisplayDimensions) + + def tearDown(self): + braille.filter_displayDimensions.unregister(_getDisplayDimensions) + + def test_continuationRow_hasContinuationShape(self): + """#9: A row present in _continuationRows gets CONTINUATION_SHAPE as its last cell.""" + buffer = braille.handler.buffer + # 15 real cells in row 0, remainder will be padded; row index 0 is marked. + buffer.brailleCells = [1] * 15 + [1] * 5 + buffer._windowRowBufferOffsets = [(0, 15), (15, 20)] + buffer._continuationRows = [0] + cells = buffer.windowBrailleCells + # First row: 15 real cells, then CONTINUATION_SHAPE, then 4 padding zeroes. + self.assertEqual(len(cells), 40) + self.assertEqual(cells[15], braille.CONTINUATION_SHAPE) + self.assertEqual(cells[16:20], [0, 0, 0, 0]) + + def test_nonContinuationRow_lastCellIsZero(self): + """#10: A row absent from _continuationRows has padding zero, not CONTINUATION_SHAPE.""" + buffer = braille.handler.buffer + buffer.brailleCells = [1] * 15 + [1] * 5 + buffer._windowRowBufferOffsets = [(0, 15), (15, 20)] + buffer._continuationRows = [] + cells = buffer.windowBrailleCells + # No continuation marker anywhere; positions 15..19 of row 0 should all be 0. + self.assertEqual(cells[15:20], [0, 0, 0, 0, 0]) + self.assertNotIn(braille.CONTINUATION_SHAPE, cells) diff --git a/tests/unit/test_hyphenation.py b/tests/unit/test_hyphenation.py new file mode 100644 index 00000000000..215bfb66cfa --- /dev/null +++ b/tests/unit/test_hyphenation.py @@ -0,0 +1,33 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter +# 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 + +"""Unit tests for textUtils.hyphenation.""" + +import unittest + +from textUtils import hyphenation + + +class TestGetHyphenPositions(unittest.TestCase): + def test_knownLanguage(self): + """#15: Known language returns a non-empty tuple of ints within range(len(text)).""" + text = "hyphenation" + positions = hyphenation.getHyphenPositions(text, "en_US") + self.assertIsInstance(positions, tuple) + self.assertGreater(len(positions), 0) + for pos in positions: + self.assertIsInstance(pos, int) + self.assertGreaterEqual(pos, 0) + self.assertLess(pos, len(text)) + + def test_unknownLanguage_returnsEmptyTuple(self): + """#16: Unknown language returns an empty tuple and does not raise.""" + # Purge any cached "already logged" state so this test is order-independent. + hyphenation._loggedUnknownLocales.discard("zz_ZZ") + positions = hyphenation.getHyphenPositions("anything", "zz_ZZ") + self.assertEqual(positions, ()) + # Calling again should still return () and not raise. + positions = hyphenation.getHyphenPositions("anything", "zz_ZZ") + self.assertEqual(positions, ()) From e2c9e4cbbe6cd72a982ddd6b5b02c3db1cb70d68 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Mon, 20 Apr 2026 21:17:46 +0200 Subject: [PATCH 15/27] Update tests --- .../test_calculateWindowRowBufferOffsets.py | 20 ++++++++----------- .../test_regionLanguageIndexes.py | 8 ++++---- .../test_braille/test_windowBrailleCells.py | 4 ++-- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index 81980293438..c810534615c 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -101,10 +101,8 @@ def test_wordWrapSecondRowStartsWithSpace(self): braille.handler.buffer._calculateWindowRowBufferOffsets(0) self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) - # --- Tests #1-#8 from the plan ------------------------------------------------ - def test_none_hardCutsAtDisplayEdge(self): - """#1: NONE wraps at the raw display edge with no continuation marker, even mid-word.""" + """NONE wraps at the raw display edge with no continuation marker, even mid-word.""" _setTextWrap(BrailleTextWrapFlag.NONE) # 25 consecutive non-zero cells: no space anywhere in the first row. braille.handler.buffer.brailleCells = [1] * 25 @@ -113,7 +111,7 @@ def test_none_hardCutsAtDisplayEdge(self): self.assertEqual(braille.handler.buffer._continuationRows, []) def test_markWordCuts_oneCellEarlierAndMarksRow(self): - """#2: MARK_WORD_CUTS cuts one cell earlier than NONE and records the row in _continuationRows.""" + """MARK_WORD_CUTS cuts one cell earlier than NONE and records the row in _continuationRows.""" _setTextWrap(BrailleTextWrapFlag.MARK_WORD_CUTS) braille.handler.buffer.brailleCells = [1] * 25 braille.handler.buffer._calculateWindowRowBufferOffsets(0) @@ -122,7 +120,7 @@ def test_markWordCuts_oneCellEarlierAndMarksRow(self): self.assertIn(0, braille.handler.buffer._continuationRows) def test_markWordCuts_cleanRowHasNoMarker(self): - """#3: MARK_WORD_CUTS does not mark a row that ends naturally at a space.""" + """MARK_WORD_CUTS does not mark a row that ends naturally at a space.""" _setTextWrap(BrailleTextWrapFlag.MARK_WORD_CUTS) # Row of 20 cells where cell 19 is a space (0): no mid-word cut. cells = [1] * 19 + [0] + [1] * 10 @@ -131,7 +129,7 @@ def test_markWordCuts_cleanRowHasNoMarker(self): self.assertNotIn(0, braille.handler.buffer._continuationRows) def test_atWordBoundaries_noSpaceInWindowMarksCut(self): - """#4: AT_WORD_BOUNDARIES with no whitespace in the window hard-cuts AND marks the row (rule A).""" + """AT_WORD_BOUNDARIES with no whitespace in the window hard-cuts AND marks the row.""" _setTextWrap(BrailleTextWrapFlag.AT_WORD_BOUNDARIES) # No zero anywhere in row 0; the `rindex` call raises and falls through. braille.handler.buffer.brailleCells = [1] * 25 @@ -140,7 +138,7 @@ def test_atWordBoundaries_noSpaceInWindowMarksCut(self): self.assertIn(0, braille.handler.buffer._continuationRows) def test_atWordOrSyllableBoundaries_success(self): - """#5: AT_WORD_OR_SYLLABLE_BOUNDARIES splits at a syllable boundary and marks the row.""" + """AT_WORD_OR_SYLLABLE_BOUNDARIES splits at a syllable boundary and marks the row.""" _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) # Layout: # cells 0..9 -> short word @@ -179,7 +177,7 @@ def test_atWordOrSyllableBoundaries_success(self): self.assertIn(0, braille.handler.buffer._continuationRows) def test_atWordOrSyllableBoundaries_emptyPositions(self): - """#6: AT_WORD_OR_SYLLABLE_BOUNDARIES with no hyphen positions falls back to a word boundary with no marker.""" + """AT_WORD_OR_SYLLABLE_BOUNDARIES with no hyphen positions falls back to a word boundary with no marker.""" _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) # Provide a real space so `rindex(... end+1)` / `rindex(... end)` succeeds. cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 @@ -208,7 +206,7 @@ def test_atWordOrSyllableBoundaries_emptyPositions(self): self.assertNotIn(0, braille.handler.buffer._continuationRows) def test_atWordOrSyllableBoundaries_positionPastEdge(self): - """#7: AT_WORD_OR_SYLLABLE_BOUNDARIES with newEnd >= oldEnd falls back to word boundary.""" + """AT_WORD_OR_SYLLABLE_BOUNDARIES with newEnd >= oldEnd falls back to word boundary.""" _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 braille.handler.buffer.brailleCells = cells @@ -237,9 +235,7 @@ def test_atWordOrSyllableBoundaries_positionPastEdge(self): self.assertNotIn(0, braille.handler.buffer._continuationRows) def test_atWordOrSyllableBoundaries_unknownLanguage(self): - """#8: AT_WORD_OR_SYLLABLE_BOUNDARIES with an unknown language behaves like empty positions.""" - # getHyphenPositions swallows LookupError itself, so from braille.py's perspective - # it just returns an empty tuple. Behaviour must match #6. + """AT_WORD_OR_SYLLABLE_BOUNDARIES with an unknown language behaves like empty positions.""" _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 braille.handler.buffer.brailleCells = cells diff --git a/tests/unit/test_braille/test_regionLanguageIndexes.py b/tests/unit/test_braille/test_regionLanguageIndexes.py index aefd983b23d..f4a3a42cf9a 100644 --- a/tests/unit/test_braille/test_regionLanguageIndexes.py +++ b/tests/unit/test_braille/test_regionLanguageIndexes.py @@ -23,7 +23,7 @@ def _makeTextInfoRegion() -> braille.TextInfoRegion: class TestLanguageIndexes(unittest.TestCase): def test_freshRegion_defaultLanguageAtAnyPos(self): - """#11: A region returns the default language for any non-negative pos.""" + """A region returns the default language for any non-negative pos.""" with patch.object(braille.Region, "_getDefaultRegionLanguage", return_value="en"): region = braille.Region() self.assertEqual(region._getLanguageAtPos(0), "en") @@ -31,7 +31,7 @@ def test_freshRegion_defaultLanguageAtAnyPos(self): self.assertEqual(region._getLanguageAtPos(100), "en") def test_addFieldText_insertsSwitchAndRestore(self): - """#12: _addFieldText inserts a switch entry at len(rawText) and a restore entry at len+textLen when the field language differs.""" + """_addFieldText inserts a switch entry at len(rawText) and a restore entry at len+textLen when the field language differs.""" region = _makeTextInfoRegion() # Pre-existing raw text to make `len(rawText)` non-zero and exercise the separator logic. region.rawText = "hello" @@ -49,7 +49,7 @@ def test_addFieldText_insertsSwitchAndRestore(self): self.assertEqual(region._languageIndexes[rawTextLenBefore + addedLen], "en") def test_addTextWithFields_formatChangeInsertsLanguageIndex(self): - """#13: Processing a formatChange command whose field has a `language` attribute inserts an index entry.""" + """Processing a formatChange command whose field has a `language` attribute inserts an index entry.""" region = _makeTextInfoRegion() region.rawText = "" region.rawTextTypeforms = [] @@ -98,7 +98,7 @@ def getTextWithFields(self, formatConfig=None): self.assertEqual(region._languageIndexes[4], "de") def test_textInfoRegion_update_resetsLanguageIndexes(self): - """#14: TextInfoRegion.update resets _languageIndexes to {0: default} — no stale indexes carry across updates.""" + """TextInfoRegion.update resets _languageIndexes to {0: default} — no stale indexes carry across updates.""" region = _makeTextInfoRegion() # Pollute _languageIndexes with stale entries. region._languageIndexes = {0: "en", 10: "de", 30: "en"} diff --git a/tests/unit/test_braille/test_windowBrailleCells.py b/tests/unit/test_braille/test_windowBrailleCells.py index 9342c760637..4256fe3b530 100644 --- a/tests/unit/test_braille/test_windowBrailleCells.py +++ b/tests/unit/test_braille/test_windowBrailleCells.py @@ -26,7 +26,7 @@ def tearDown(self): braille.filter_displayDimensions.unregister(_getDisplayDimensions) def test_continuationRow_hasContinuationShape(self): - """#9: A row present in _continuationRows gets CONTINUATION_SHAPE as its last cell.""" + """A row present in _continuationRows gets CONTINUATION_SHAPE as its last cell.""" buffer = braille.handler.buffer # 15 real cells in row 0, remainder will be padded; row index 0 is marked. buffer.brailleCells = [1] * 15 + [1] * 5 @@ -39,7 +39,7 @@ def test_continuationRow_hasContinuationShape(self): self.assertEqual(cells[16:20], [0, 0, 0, 0]) def test_nonContinuationRow_lastCellIsZero(self): - """#10: A row absent from _continuationRows has padding zero, not CONTINUATION_SHAPE.""" + """A row absent from _continuationRows has padding zero, not CONTINUATION_SHAPE.""" buffer = braille.handler.buffer buffer.brailleCells = [1] * 15 + [1] * 5 buffer._windowRowBufferOffsets = [(0, 15), (15, 20)] From e0d5810ad2231a0bdb30f47aeb0d21ab91b63b94 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Mon, 20 Apr 2026 21:35:56 +0200 Subject: [PATCH 16/27] changes: document braille text wrap and hyphenation (#17010) --- user_docs/en/changes.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index ef3120f0831..086aa653651 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -32,6 +32,9 @@ The triple-press keyboard shortcut (`NVDA+ctrl+r`) is not affected, as it is int * DotPad braille displays now support multi-button combination gestures. (#19565, @bramd) * You can now press multiple buttons simultaneously to create custom gestures (e.g., `f1+panLeft`). * A new voice setting "Natural pause after punctuation" was added for OneCore voices, allowing users to turn punctuation pauses on or off. (#11876, @gexgd0419) +* The braille "word wrap" option has been replaced with a four-valued "Text wrap" option: Off, Show mark when words are cut, At word boundaries, and At word or syllable boundaries. (#17010, @LeonarddeR) + * When a word is cut across rows, the last cell of the row now shows a continuation mark (braille dots 7-8) so it is clear that the word continues on the next row. + * The "At word or syllable boundaries" option uses hyphenation dictionaries to split long words at syllable boundaries when they do not fit on the display. ### Changes @@ -93,6 +96,8 @@ Use the individual test commands instead: `runcheckpot.bat`, `rununittests.bat`, #### Deprecations +* The `config.configFlags.BrailleTextWrap` IntEnum has been replaced with `config.featureFlagEnums.BrailleTextWrapFlag`, stored via the `featureFlag` config spec. (#17010, @LeonarddeR) +* The `braille.wordWrap` configuration key is deprecated and bridged to `braille.textWrap`. (#17010, @LeonarddeR) * The `speechDictHandler.ENTRY_TYPE_*` constants are deprecated. Use the `speechDictHandler.types.EntryType` enumeration instead. (#19430, @LeonarddeR) * `speechDictHandler.SpeechDictEntry` and `speechDictHandler.SpeechDict` have been moved to `speechDictHandler.types`. (#19430, @LeonarddeR) From 7c79183fa08616f7dd503111a8b3c7bf4b1898f2 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Sat, 25 Apr 2026 14:53:51 +0200 Subject: [PATCH 17/27] Simplify patch.object usage in braille unit tests and add comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch auto-properties (rawToBraillePos, brailleToRawPos) on the buffer instance instead of the class — they are non-data Getter descriptors via AutoPropertyObject, so instance attributes shadow them directly. Add comments explaining the mocking strategy for syllable-boundary isolation and the side_effect=RuntimeError pattern for halting update() mid-method. Co-Authored-By: Claude Opus 4 --- .../test_calculateWindowRowBufferOffsets.py | 40 +++++++++++-------- .../test_regionLanguageIndexes.py | 3 ++ 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index c810534615c..e6ec31d1a10 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -148,6 +148,10 @@ def test_atWordOrSyllableBoundaries_success(self): # cells 31..35 -> tail cells = [1] * 10 + [0] + [1] * 19 + [0] + [1] * 5 braille.handler.buffer.brailleCells = cells + # Stub methods and auto-properties on the buffer instance to isolate + # syllable-boundary logic from the real braille translation pipeline. + # rawToBraillePos/brailleToRawPos are Getter (non-data) descriptors via + # AutoPropertyObject, so setting them on the instance shadows the descriptor. with ( patch.object( braille.handler.buffer, @@ -159,16 +163,15 @@ def test_atWordOrSyllableBoundaries_success(self): "braille.textUtils.hyphenation.getHyphenPositions", return_value=(3,), ), - # Identity mapping between raw text and braille cells is sufficient for this synthetic buffer. patch.object( - type(braille.handler.buffer), + braille.handler.buffer, "rawToBraillePos", - new=list(range(len(cells))), + list(range(len(cells))), ), patch.object( - type(braille.handler.buffer), + braille.handler.buffer, "brailleToRawPos", - new=list(range(len(cells))), + list(range(len(cells))), ), ): braille.handler.buffer._calculateWindowRowBufferOffsets(0) @@ -182,6 +185,7 @@ def test_atWordOrSyllableBoundaries_emptyPositions(self): # Provide a real space so `rindex(... end+1)` / `rindex(... end)` succeeds. cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 braille.handler.buffer.brailleCells = cells + # Stub methods and auto-properties; see test_atWordOrSyllableBoundaries_success for explanation. with ( patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="en_US"), @@ -190,14 +194,14 @@ def test_atWordOrSyllableBoundaries_emptyPositions(self): return_value=(), ), patch.object( - type(braille.handler.buffer), + braille.handler.buffer, "rawToBraillePos", - new=list(range(len(cells))), + list(range(len(cells))), ), patch.object( - type(braille.handler.buffer), + braille.handler.buffer, "brailleToRawPos", - new=list(range(len(cells))), + list(range(len(cells))), ), ): braille.handler.buffer._calculateWindowRowBufferOffsets(0) @@ -210,6 +214,7 @@ def test_atWordOrSyllableBoundaries_positionPastEdge(self): _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 braille.handler.buffer.brailleCells = cells + # Stub methods and auto-properties; see test_atWordOrSyllableBoundaries_success for explanation. with ( patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="en_US"), @@ -219,14 +224,14 @@ def test_atWordOrSyllableBoundaries_positionPastEdge(self): return_value=(22,), ), patch.object( - type(braille.handler.buffer), + braille.handler.buffer, "rawToBraillePos", - new=list(range(len(cells))), + list(range(len(cells))), ), patch.object( - type(braille.handler.buffer), + braille.handler.buffer, "brailleToRawPos", - new=list(range(len(cells))), + list(range(len(cells))), ), ): braille.handler.buffer._calculateWindowRowBufferOffsets(0) @@ -239,6 +244,7 @@ def test_atWordOrSyllableBoundaries_unknownLanguage(self): _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 braille.handler.buffer.brailleCells = cells + # Stub methods and auto-properties; see test_atWordOrSyllableBoundaries_success for explanation. with ( patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="zz_ZZ"), @@ -247,14 +253,14 @@ def test_atWordOrSyllableBoundaries_unknownLanguage(self): return_value=(), ), patch.object( - type(braille.handler.buffer), + braille.handler.buffer, "rawToBraillePos", - new=list(range(len(cells))), + list(range(len(cells))), ), patch.object( - type(braille.handler.buffer), + braille.handler.buffer, "brailleToRawPos", - new=list(range(len(cells))), + list(range(len(cells))), ), ): braille.handler.buffer._calculateWindowRowBufferOffsets(0) diff --git a/tests/unit/test_braille/test_regionLanguageIndexes.py b/tests/unit/test_braille/test_regionLanguageIndexes.py index f4a3a42cf9a..b0ec733e7d7 100644 --- a/tests/unit/test_braille/test_regionLanguageIndexes.py +++ b/tests/unit/test_braille/test_regionLanguageIndexes.py @@ -24,6 +24,7 @@ def _makeTextInfoRegion() -> braille.TextInfoRegion: class TestLanguageIndexes(unittest.TestCase): def test_freshRegion_defaultLanguageAtAnyPos(self): """A region returns the default language for any non-negative pos.""" + # Stub default language so Region.__init__ doesn't depend on NVDA's configured locale. with patch.object(braille.Region, "_getDefaultRegionLanguage", return_value="en"): region = braille.Region() self.assertEqual(region._getLanguageAtPos(0), "en") @@ -105,6 +106,8 @@ def test_textInfoRegion_update_resetsLanguageIndexes(self): # Run only the resetting portion of `update` by making `_getSelection` raise immediately, # then inspect state. The reset happens before `_getSelection` is called. + # Using side_effect to halt execution mid-method avoids needing the full NVDA environment + # that the rest of update() requires. with ( patch.object(braille.TextInfoRegion, "_getDefaultRegionLanguage", return_value="en"), patch.object( From d99919e38973992436c723bb065e69468d027f01 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Sat, 25 Apr 2026 15:02:35 +0200 Subject: [PATCH 18/27] Use direct assignment for auto-property overrides instead of patch.object rawToBraillePos/brailleToRawPos are non-data Getter descriptors, so instance assignment shadows them directly. Cleanup in tearDown. Co-Authored-By: Claude Opus 4 --- .../test_calculateWindowRowBufferOffsets.py | 60 +++++-------------- 1 file changed, 15 insertions(+), 45 deletions(-) diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index e6ec31d1a10..07cdbd7a1fe 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -43,6 +43,9 @@ def setUp(self): def tearDown(self): braille.filter_displayDimensions.unregister(_getDisplayDimensions) _setTextWrap(BrailleTextWrapFlag.NONE) + # Remove instance-level overrides of auto-properties set by syllable-boundary tests. + for attr in ("rawToBraillePos", "brailleToRawPos"): + braille.handler.buffer.__dict__.pop(attr, None) def test_noCells(self): """Check that, if list of braille cells is empty, offsets will be (0, 0).""" @@ -148,10 +151,11 @@ def test_atWordOrSyllableBoundaries_success(self): # cells 31..35 -> tail cells = [1] * 10 + [0] + [1] * 19 + [0] + [1] * 5 braille.handler.buffer.brailleCells = cells - # Stub methods and auto-properties on the buffer instance to isolate - # syllable-boundary logic from the real braille translation pipeline. # rawToBraillePos/brailleToRawPos are Getter (non-data) descriptors via # AutoPropertyObject, so setting them on the instance shadows the descriptor. + # Cleanup happens in tearDown. + braille.handler.buffer.rawToBraillePos = list(range(len(cells))) + braille.handler.buffer.brailleToRawPos = list(range(len(cells))) with ( patch.object( braille.handler.buffer, @@ -163,16 +167,6 @@ def test_atWordOrSyllableBoundaries_success(self): "braille.textUtils.hyphenation.getHyphenPositions", return_value=(3,), ), - patch.object( - braille.handler.buffer, - "rawToBraillePos", - list(range(len(cells))), - ), - patch.object( - braille.handler.buffer, - "brailleToRawPos", - list(range(len(cells))), - ), ): braille.handler.buffer._calculateWindowRowBufferOffsets(0) # Syllable split at rawPos 3 + brailleStart 11 = 14. @@ -185,7 +179,9 @@ def test_atWordOrSyllableBoundaries_emptyPositions(self): # Provide a real space so `rindex(... end+1)` / `rindex(... end)` succeeds. cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 braille.handler.buffer.brailleCells = cells - # Stub methods and auto-properties; see test_atWordOrSyllableBoundaries_success for explanation. + # See test_atWordOrSyllableBoundaries_success for why direct assignment works here. + braille.handler.buffer.rawToBraillePos = list(range(len(cells))) + braille.handler.buffer.brailleToRawPos = list(range(len(cells))) with ( patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="en_US"), @@ -193,16 +189,6 @@ def test_atWordOrSyllableBoundaries_emptyPositions(self): "braille.textUtils.hyphenation.getHyphenPositions", return_value=(), ), - patch.object( - braille.handler.buffer, - "rawToBraillePos", - list(range(len(cells))), - ), - patch.object( - braille.handler.buffer, - "brailleToRawPos", - list(range(len(cells))), - ), ): braille.handler.buffer._calculateWindowRowBufferOffsets(0) # End should be just after the last space in row 0 (index 15 -> end = 16). @@ -214,7 +200,9 @@ def test_atWordOrSyllableBoundaries_positionPastEdge(self): _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 braille.handler.buffer.brailleCells = cells - # Stub methods and auto-properties; see test_atWordOrSyllableBoundaries_success for explanation. + # See test_atWordOrSyllableBoundaries_success for why direct assignment works here. + braille.handler.buffer.rawToBraillePos = list(range(len(cells))) + braille.handler.buffer.brailleToRawPos = list(range(len(cells))) with ( patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="en_US"), @@ -223,16 +211,6 @@ def test_atWordOrSyllableBoundaries_positionPastEdge(self): "braille.textUtils.hyphenation.getHyphenPositions", return_value=(22,), ), - patch.object( - braille.handler.buffer, - "rawToBraillePos", - list(range(len(cells))), - ), - patch.object( - braille.handler.buffer, - "brailleToRawPos", - list(range(len(cells))), - ), ): braille.handler.buffer._calculateWindowRowBufferOffsets(0) # Falls back to word boundary at position 16. @@ -244,7 +222,9 @@ def test_atWordOrSyllableBoundaries_unknownLanguage(self): _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 braille.handler.buffer.brailleCells = cells - # Stub methods and auto-properties; see test_atWordOrSyllableBoundaries_success for explanation. + # See test_atWordOrSyllableBoundaries_success for why direct assignment works here. + braille.handler.buffer.rawToBraillePos = list(range(len(cells))) + braille.handler.buffer.brailleToRawPos = list(range(len(cells))) with ( patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="zz_ZZ"), @@ -252,16 +232,6 @@ def test_atWordOrSyllableBoundaries_unknownLanguage(self): "braille.textUtils.hyphenation.getHyphenPositions", return_value=(), ), - patch.object( - braille.handler.buffer, - "rawToBraillePos", - list(range(len(cells))), - ), - patch.object( - braille.handler.buffer, - "brailleToRawPos", - list(range(len(cells))), - ), ): braille.handler.buffer._calculateWindowRowBufferOffsets(0) self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 16)) From 82cdf542ace1a7c906e78d211df81cf647e7051f Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Sat, 2 May 2026 16:38:57 +0200 Subject: [PATCH 19/27] Fix window start pos --- source/braille.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/source/braille.py b/source/braille.py index e2feb0e9c8c..06be296c48a 100644 --- a/source/braille.py +++ b/source/braille.py @@ -2126,10 +2126,7 @@ def _set_windowEndPos(self, endPos: int) -> None: if startPos <= restrictPos: self.windowStartPos = restrictPos return - if config.conf["braille"]["textWrap"].calculated() in ( - BrailleTextWrapFlag.NONE, - BrailleTextWrapFlag.MARK_WORD_CUTS, - ): + if config.conf["braille"]["textWrap"].calculated() == BrailleTextWrapFlag.NONE: self.windowStartPos = startPos return try: From 745536f59830f5f69440ddf3e208752586c92874 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Sat, 2 May 2026 16:43:13 +0200 Subject: [PATCH 20/27] docs: replace "word wrap" with "text wrap" in comments and test names Co-Authored-By: Claude Sonnet 4.6 --- source/braille.py | 6 +++--- .../test_braille/test_calculateWindowRowBufferOffsets.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/source/braille.py b/source/braille.py index 06be296c48a..85451ee8b53 100644 --- a/source/braille.py +++ b/source/braille.py @@ -2031,7 +2031,7 @@ def _set_windowStartPos(self, pos: int) -> None: def _calculateWindowRowBufferOffsets(self, pos: int) -> None: """ Calculates the start and end positions of each row in the braille window. - Ensures that words are not split across rows when word wrap is enabled. + Ensures that words are not split across rows when text wrap is enabled. Ensures that the window does not extend past the end of the braille buffer. :param pos: The start position of the braille window. """ @@ -2105,7 +2105,7 @@ def _set_windowEndPos(self, endPos: int) -> None: 2. Whether one of the regions should be shown hard left on the braille display; i.e. because of The configuration setting for focus context representation or whether the braille region that corresponds with the focus represents a multi line edit box. - 3. Whether word wrap is enabled.""" + 3. Whether text wrap is enabled.""" startPos = endPos - self.handler.displaySize # Loop through the currently displayed regions in reverse order # If focusToHardLeft is set for one of the regions, the display shouldn't scroll further back than the start of that region @@ -2141,7 +2141,7 @@ def _set_windowEndPos(self, endPos: int) -> None: break except ValueError: pass - # When word wrap is enabled, the first block of spaces may be removed from the current window. + # When text wrap is enabled, the first block of spaces may be removed from the current window. # This may prevent displaying the start of paragraphs. paragraphStartMarker = getParagraphStartMarker() if paragraphStartMarker and self.regions[-1].rawText.startswith( diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index 07cdbd7a1fe..753a33c810e 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -75,7 +75,7 @@ def test_end(self): expectedOffsets = [(0, 20), (20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) - def test_wordWrapFirstRowWithSpace(self): + def test_textWrapFirstRowWithSpace(self): """Check that the first row will be truncated if it contains a space, only if text wrap is set to word boundaries.""" _setTextWrap(BrailleTextWrapFlag.AT_WORD_BOUNDARIES) cells = [1] * (braille.handler.displayDimensions.numCols - 5) @@ -90,8 +90,8 @@ def test_wordWrapFirstRowWithSpace(self): expectedOffsets = [(0, 20), (20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) - def test_wordWrapSecondRowStartsWithSpace(self): - """Check that the first row won't be truncated if the next row starts with a space, even if word wrap is True.""" + def test_textWrapSecondRowStartsWithSpace(self): + """Check that the first row won't be truncated if the next row starts with a space, even if text wrap is not NONE.""" _setTextWrap(BrailleTextWrapFlag.AT_WORD_BOUNDARIES) cells = [1] * braille.handler.displayDimensions.numCols cells.append(0) From 6432ea930a10f6ddb267d23d9b0b5ed0525c6e5c Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Sat, 2 May 2026 18:26:18 +0200 Subject: [PATCH 21/27] Fix typo: hyphenationMap --- source/textUtils/hyphenation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/textUtils/hyphenation.py b/source/textUtils/hyphenation.py index 38f778df55c..abc740e0653 100644 --- a/source/textUtils/hyphenation.py +++ b/source/textUtils/hyphenation.py @@ -23,7 +23,7 @@ def _pyphenFactory(lang: str) -> Pyphen: return Pyphen(lang=pyphenLang) -_hypenationMap: LocaleDataMap[Pyphen] = LocaleDataMap(_pyphenFactory) +_hyphenationMap: LocaleDataMap[Pyphen] = LocaleDataMap(_pyphenFactory) #: Set of locales for which we have already logged an unknown-language fallback. #: Used to avoid spamming the log on every call for the same unsupported locale. @@ -41,7 +41,7 @@ def getHyphenPositions(text: str, locale: str) -> tuple[int, ...]: :return: A tuple of positions in the text where hyphenation points occur. """ try: - pyphen = _hypenationMap.fetchLocaleData(locale=locale) + pyphen = _hyphenationMap.fetchLocaleData(locale=locale) except LookupError: if locale not in _loggedUnknownLocales: _loggedUnknownLocales.add(locale) From 929a436b9276a8f3fd69e5a6eaff4d040eab3ea7 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter <3049216+LeonarddeR@users.noreply.github.com> Date: Sat, 2 May 2026 16:31:33 +0000 Subject: [PATCH 22/27] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- source/braille.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/source/braille.py b/source/braille.py index 85451ee8b53..51110880309 100644 --- a/source/braille.py +++ b/source/braille.py @@ -2084,6 +2084,11 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: # No space on line - fall back to display-edge cut. # Under rule A, mark this as a word cut since a word was split mid-way. if all(self.brailleCells[end - 1 : end + 1]): + if ( + end - start == self.handler.displayDimensions.numCols + and end < bufferEnd + ): + end -= 1 showContinuationMark = True if showContinuationMark: self._continuationRows.append(len(self._windowRowBufferOffsets)) From 1079808842dced157c48d5cdd2f3f4b9ac8db460 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 16:32:30 +0000 Subject: [PATCH 23/27] Pre-commit auto-fix --- source/braille.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/source/braille.py b/source/braille.py index 51110880309..e9b43b64c0f 100644 --- a/source/braille.py +++ b/source/braille.py @@ -2084,10 +2084,7 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: # No space on line - fall back to display-edge cut. # Under rule A, mark this as a word cut since a word was split mid-way. if all(self.brailleCells[end - 1 : end + 1]): - if ( - end - start == self.handler.displayDimensions.numCols - and end < bufferEnd - ): + if end - start == self.handler.displayDimensions.numCols and end < bufferEnd: end -= 1 showContinuationMark = True if showContinuationMark: From 8331868b5a777db8185216ce5885ffc484d0efd9 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Sat, 2 May 2026 18:34:17 +0200 Subject: [PATCH 24/27] SMall fixups --- source/braille.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/source/braille.py b/source/braille.py index 85451ee8b53..3cbc71401ff 100644 --- a/source/braille.py +++ b/source/braille.py @@ -570,7 +570,7 @@ def __init__(self): self.selectionEnd = None #: Language indexes in L{rawText}. #: The last language is assumed to be the final language in the region. - self._languageIndexes: dict[int:str] = {0: self._getDefaultRegionLanguage()} + self._languageIndexes: dict[int, str] = {0: self._getDefaultRegionLanguage()} #: The translated braille representation of this region. #: @type: [int, ...] self.brailleCells = [] @@ -1560,7 +1560,7 @@ def update(self): self.rawText = "" self.rawTextTypeforms = [] self.cursorPos = None - self._languageIndexes: dict[int:str] = {0: self._getDefaultRegionLanguage()} + self._languageIndexes: dict[int, str] = {0: self._getDefaultRegionLanguage()} # The output includes text representing fields which isn't part of the real content in the control. # Therefore, maintain a map of positions in the output to positions in the content. self._rawToContentPos = [] @@ -2082,7 +2082,6 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: break except (ValueError, IndexError): # No space on line - fall back to display-edge cut. - # Under rule A, mark this as a word cut since a word was split mid-way. if all(self.brailleCells[end - 1 : end + 1]): showContinuationMark = True if showContinuationMark: From b64ab7d09c00f104001df67415ce80585ee3b116 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 5 May 2026 23:10:02 +0200 Subject: [PATCH 25/27] Address PR #19916 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - braille.py: guard MARK_WORD_CUTS continuation mark behind `end < bufferEnd` to prevent a phantom mark on the final row when the buffer ends exactly at the display edge - config/__init__.py: fix wordWrap→textWrap bridge writing a raw string into _cache; now validates through the spec so the cache holds a proper FeatureFlag object, matching what __setitem__ normally stores - userGuide.md: rephrase "Off" description — text is cut at the display edge (not "not wrapped"), just without a continuation mark - test_calculateWindowRowBufferOffsets.py: fix two tests that expected end positions without room for the continuation marker; _get_windowBrailleCells only appends the marker when remaining > 0, so end must be numCols - 1 to leave space Co-Authored-By: Claude Opus 4.7 (1M context) --- source/braille.py | 6 +++++- source/config/__init__.py | 15 +++++++++++---- .../test_calculateWindowRowBufferOffsets.py | 4 ++-- user_docs/en/userGuide.md | 4 ++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/source/braille.py b/source/braille.py index bb85fa6daa0..09a64837a5a 100644 --- a/source/braille.py +++ b/source/braille.py @@ -2051,7 +2051,11 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: if end > bufferEnd: end = bufferEnd clippedEnd = True - elif textWrap == BrailleTextWrapFlag.MARK_WORD_CUTS and all(self.brailleCells[end - 1 : end + 1]): + elif ( + textWrap == BrailleTextWrapFlag.MARK_WORD_CUTS + and end < bufferEnd + and all(self.brailleCells[end - 1 : end + 1]) + ): end -= 1 showContinuationMark = True elif textWrap in ( diff --git a/source/config/__init__.py b/source/config/__init__.py index cbe93fca377..db6f3aab96d 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -1420,10 +1420,17 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat "braille.wordWrap is deprecated. Use braille.textWrap instead.", stack_info=True, ) - key = "textWrap" - val = ( - BrailleTextWrapFlag.AT_WORD_BOUNDARIES if val else BrailleTextWrapFlag.NONE - ).name + textWrapKey = "textWrap" + flagEnum = BrailleTextWrapFlag.AT_WORD_BOUNDARIES if val else BrailleTextWrapFlag.NONE + # Write the enum name (string) to the profile — configobj stores everything as strings. + self._getUpdateSection()[textWrapKey] = flagEnum.name + # Validate through the spec so the cache holds a proper FeatureFlag object, + # matching what __setitem__ would normally store. + self._cache[textWrapKey] = self.manager.validator.check( + self._spec[textWrapKey], + flagEnum.name, + ) + return case "textWrap": # The "textWrap" setting was added in place of "wordWrap" and became a feature flag. key = "wordWrap" diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index 753a33c810e..538a83cd91e 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -83,7 +83,7 @@ def test_textWrapFirstRowWithSpace(self): cells.extend([1] * (braille.handler.displayDimensions.numCols + 4)) braille.handler.buffer.brailleCells = cells braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 16), (16, 36)] + expectedOffsets = [(0, 16), (16, 35)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) _setTextWrap(BrailleTextWrapFlag.NONE) braille.handler.buffer._calculateWindowRowBufferOffsets(0) @@ -137,7 +137,7 @@ def test_atWordBoundaries_noSpaceInWindowMarksCut(self): # No zero anywhere in row 0; the `rindex` call raises and falls through. braille.handler.buffer.brailleCells = [1] * 25 braille.handler.buffer._calculateWindowRowBufferOffsets(0) - self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 20)) + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 19)) self.assertIn(0, braille.handler.buffer._continuationRows) def test_atWordOrSyllableBoundaries_success(self): diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 4aae4b84250..9d00eb2bcb1 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2583,8 +2583,8 @@ This combo box allows you to configure how NVDA handles text that is too long to When a word is cut across rows, the continuation mark (dots 7 and 8) is shown in the last cell of the row, unless otherwise noted below. The following options are available: -* Off: Text is not wrapped and no continuation mark is shown. -As much of the text as possible will be displayed, but the rest will be cut off. +* Off: Text wraps at the display edge, cutting words mid-way if necessary, without showing any continuation mark. +As much of the text as possible will be displayed on each row. When you scroll the display, you will be able to read the rest of the text. * Show mark when words are cut: Text is not wrapped, but whenever a word is cut at the end of the display, a continuation mark is shown. When you scroll the display, you will be able to read the rest of the word. From fbfca6bfe5749cb917bcf16a316befac8a2cb95e Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 5 May 2026 23:17:40 +0200 Subject: [PATCH 26/27] Refactor _linkDeprecatedValues to use shared profile/cache writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce cacheVal alongside val so the wordWrap→textWrap bridge can store a string in the profile and a validated FeatureFlag in the cache without duplicating the _getUpdateSection/_cache calls or returning early. Co-Authored-By: Claude Opus 4.7 (1M context) --- source/config/__init__.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/source/config/__init__.py b/source/config/__init__.py index db6f3aab96d..b91a6ae98bc 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -1411,6 +1411,8 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat >>> return >>> ... """ + # cacheVal defaults to val; overridden when profile and cache need different types. + cacheVal = val match self.path: case "braille": match key: @@ -1420,17 +1422,13 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat "braille.wordWrap is deprecated. Use braille.textWrap instead.", stack_info=True, ) - textWrapKey = "textWrap" + key = "textWrap" flagEnum = BrailleTextWrapFlag.AT_WORD_BOUNDARIES if val else BrailleTextWrapFlag.NONE - # Write the enum name (string) to the profile — configobj stores everything as strings. - self._getUpdateSection()[textWrapKey] = flagEnum.name - # Validate through the spec so the cache holds a proper FeatureFlag object, - # matching what __setitem__ would normally store. - self._cache[textWrapKey] = self.manager.validator.check( - self._spec[textWrapKey], - flagEnum.name, - ) - return + # Profile stores strings; cache must hold a validated FeatureFlag object + # (matching what __setitem__ normally stores) so .calculated() works on next read. + # Validate through the spec to avoid hardcoding behaviorOfDefault here. + val = flagEnum.name + cacheVal = self.manager.validator.check(self._spec[key], val) case "textWrap": # The "textWrap" setting was added in place of "wordWrap" and became a feature flag. key = "wordWrap" @@ -1439,6 +1437,7 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat BrailleTextWrapFlag.AT_WORD_BOUNDARIES, BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES, ) + cacheVal = val case _: # We don't care about other keys in this section. @@ -1451,7 +1450,7 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat # Update the value in the most recently activated profile. # If we have reached this point, we must have a new key and value to set. self._getUpdateSection()[key] = val - self._cache[key] = val + self._cache[key] = cacheVal def _getUpdateSection(self): profile = self.profiles[-1] From 79ac204fd8666d331ceedbef6250ccf151050896 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Wed, 13 May 2026 20:04:04 +0200 Subject: [PATCH 27/27] Update changes --- user_docs/en/changes.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 41546708986..2d3a5b87545 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -6,6 +6,10 @@ ### New Features +* The braille "word wrap" option has been replaced with a four-valued "Text wrap" option: Off, Show mark when words are cut, At word boundaries, and At word or syllable boundaries. (#17010, @LeonarddeR) + * When a word is cut across rows, the last cell of the row now shows a continuation mark (braille dots 7-8) so it is clear that the word continues on the next row. + * The "At word or syllable boundaries" option uses hyphenation dictionaries to split long words at syllable boundaries when they do not fit on the display. + ### Changes ### Bug Fixes @@ -16,6 +20,9 @@ Please refer to [the developer guide](https://download.nvaccess.org/documentatio #### Deprecations +* The `config.configFlags.BrailleTextWrap` IntEnum has been replaced with `config.featureFlagEnums.BrailleTextWrapFlag`, stored via the `featureFlag` config spec. (#17010, @LeonarddeR) +* The `braille.wordWrap` configuration key is deprecated and bridged to `braille.textWrap`. (#17010, @LeonarddeR) + @@ -52,9 +59,6 @@ The triple-press keyboard shortcut (`NVDA+ctrl+r`) is not affected, as it is int * DotPad braille displays now support multi-button combination gestures. (#19565, @bramd) * You can now press multiple buttons simultaneously to create custom gestures (e.g., `f1+panLeft`). * A new voice setting "Natural pause after punctuation" was added for OneCore voices, allowing users to turn punctuation pauses on or off. (#11876, @gexgd0419) -* The braille "word wrap" option has been replaced with a four-valued "Text wrap" option: Off, Show mark when words are cut, At word boundaries, and At word or syllable boundaries. (#17010, @LeonarddeR) - * When a word is cut across rows, the last cell of the row now shows a continuation mark (braille dots 7-8) so it is clear that the word continues on the next row. - * The "At word or syllable boundaries" option uses hyphenation dictionaries to split long words at syllable boundaries when they do not fit on the display. ### Changes @@ -135,8 +139,6 @@ Use the individual test commands instead: `runcheckpot.bat`, `rununittests.bat`, #### Deprecations -* The `config.configFlags.BrailleTextWrap` IntEnum has been replaced with `config.featureFlagEnums.BrailleTextWrapFlag`, stored via the `featureFlag` config spec. (#17010, @LeonarddeR) -* The `braille.wordWrap` configuration key is deprecated and bridged to `braille.textWrap`. (#17010, @LeonarddeR) * The `speechDictHandler.ENTRY_TYPE_*` constants are deprecated. Use the `speechDictHandler.types.EntryType` enumeration instead. (#19430, @LeonarddeR) * `speechDictHandler.SpeechDictEntry` and `speechDictHandler.SpeechDict` have been moved to `speechDictHandler.types`. (#19430, @LeonarddeR)