From 2ac4ae4c122f924f23a48081d3adca96b6fad69f Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Fri, 15 May 2026 09:01:12 +0200 Subject: [PATCH 1/7] feat: add braille text wrap modes with continuation marks Replaces boolean wordWrap config with BrailleTextWrapFlag feature flag (NONE, MARK_WORD_CUTS, AT_WORD_BOUNDARIES, AT_WORD_OR_SYLLABLE_BOUNDARIES). Adds profile upgrade step v22->v23, deprecated API bridge, GUI combo box, continuation mark (dots 7-8) when a word is cut mid-display, and _continuationRows tracking in BrailleBuffer. Syllable boundary mode is wired but its backend follows in a separate PR. --- source/braille.py | 104 ++++++++++++++---- source/config/__init__.py | 42 ++++++- source/config/configSpec.py | 4 +- source/config/featureFlagEnums.py | 24 ++++ source/config/profileUpgradeSteps.py | 26 +++++ source/gui/settingsDialogs.py | 17 +-- .../test_calculateWindowRowBufferOffsets.py | 78 ++++++++++--- .../test_braille/test_windowBrailleCells.py | 50 +++++++++ user_docs/en/changes.md | 5 + user_docs/en/userGuide.md | 24 ++-- 10 files changed, 313 insertions(+), 61 deletions(-) create mode 100644 tests/unit/test_braille/test_windowBrailleCells.py diff --git a/source/braille.py b/source/braille.py index 6e1d36be208..6bb02638731 100644 --- a/source/braille.py +++ b/source/braille.py @@ -52,7 +52,11 @@ OutputMode, ReportSpellingErrors, ) -from config.featureFlagEnums import ReviewRoutingMovesSystemCaretFlag, FontFormattingBrailleModeFlag +from config.featureFlagEnums import ( + BrailleTextWrapFlag, + FontFormattingBrailleModeFlag, + ReviewRoutingMovesSystemCaretFlag, +) from logHandler import log import controlTypes import api @@ -326,6 +330,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 """ @@ -1385,7 +1390,12 @@ 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 @@ -1813,10 +1823,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 = "" @@ -1832,6 +1844,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. @@ -1860,21 +1874,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: @@ -1882,13 +1896,23 @@ 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 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: @@ -1905,7 +1929,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. @@ -1927,6 +1957,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) @@ -1957,32 +1992,47 @@ 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. """ 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: BrailleTextWrapFlag = config.conf["braille"]["textWrap"].calculated() 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 == BrailleTextWrapFlag.MARK_WORD_CUTS + and end < bufferEnd + and all(self.brailleCells[end - 1 : end + 1]) + ): + end -= 1 + showContinuationMark = True + elif textWrap == BrailleTextWrapFlag.AT_WORD_BOUNDARIES: try: lastSpaceIndex = rindex(self.brailleCells, 0, start, end + 1) if lastSpaceIndex < end: # The next braille window doesn't start with space. end = rindex(self.brailleCells, 0, start, end) + 1 except (ValueError, IndexError): - pass # No space on line + # No space on line - fall back to display-edge cut. + 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)) self._windowRowBufferOffsets.append((start, end)) if clippedEnd: break @@ -2001,7 +2051,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 @@ -2022,7 +2072,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"].calculated() in ( + BrailleTextWrapFlag.NONE, + BrailleTextWrapFlag.MARK_WORD_CUTS, + ): self.windowStartPos = startPos return try: @@ -2037,7 +2090,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( @@ -2144,9 +2197,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 b3ae0cab8f2..0578b534a7a 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -13,6 +13,7 @@ from collections.abc import Collection from enum import Enum from typing import Any +from addonAPIVersion import BACK_COMPAT_TO import globalVars import winreg @@ -39,6 +40,7 @@ from . import profileUpgrader from . import aggregatedSection from .configSpec import confspec +from .featureFlagEnums import BrailleTextWrapFlag from .featureFlag import ( _transformSpec_AddFeatureFlagDefault, _validateConfig_featureFlag, @@ -373,7 +375,7 @@ def _setSystemConfig( else: relativePath = os.path.relpath(curSourceDir, fromPath) curDestDir = os.path.join(toPath, relativePath) - if not isMigration and relativePath.casefold() == "addons": + if not isMigration and relativePath == "addons": _prepareToCopyAddons(fromPath, toPath, subDirs, addonsToCopy) if not os.path.isdir(curDestDir): os.makedirs(curDestDir) @@ -1381,14 +1383,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: @@ -1409,7 +1411,35 @@ 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: + case "wordWrap": + # 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" + flagEnum = BrailleTextWrapFlag.AT_WORD_BOUNDARIES if val else BrailleTextWrapFlag.NONE + # 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" + calculated: BrailleTextWrapFlag = val.calculated() + val = calculated == BrailleTextWrapFlag.AT_WORD_BOUNDARIES + cacheVal = val + + case _: + # We don't care about other keys in this section. + return + case _: # We don't care about other sections. return @@ -1417,7 +1447,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] diff --git a/source/config/configSpec.py b/source/config/configSpec.py index b0451e2ab02..c620f6b8c37 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 @@ -91,7 +91,9 @@ optionsEnum="ReviewRoutingMovesSystemCaretFlag", behaviorOfDefault="NEVER") readByParagraph = boolean(default=false) paragraphStartMarker = option("", " ", "¶", default="") + # Deprecated in 2026.3 wordWrap = boolean(default=true) + textWrap = featureFlag(optionsEnum="BrailleTextWrapFlag", behaviorOfDefault="AT_WORD_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..8626fa9507b 100644 --- a/source/config/featureFlagEnums.py +++ b/source/config/featureFlagEnums.py @@ -139,6 +139,30 @@ 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"), + } + + DEFAULT = enum.auto() + NONE = enum.auto() + MARK_WORD_CUTS = enum.auto() + AT_WORD_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 c9cde845726..8d3011be233 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -30,6 +30,7 @@ TetherTo, TypingEcho, ) +from config.featureFlagEnums import BrailleTextWrapFlag def upgradeConfigFrom_0_to_1(profile: ConfigObj) -> None: @@ -687,3 +688,28 @@ def upgradeConfigFrom_21_to_22(profile: ConfigObj): if language.casefold() == "auto": speechConf["language"] = "en" log.debug("Changed math.speech.language from 'Auto' to 'en'.") + + +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 default of word or syllable boundaries. + """ + 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 = 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}.", + ) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 594155ca6f0..48ee9a86fc4 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -5499,12 +5499,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). - 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"] + 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.unicodeNormalizationCombo: nvdaControls.FeatureFlagCombo = sHelper.addLabeledControl( labelText=_( @@ -5594,7 +5597,7 @@ def onSave(self): ] config.conf["braille"]["speakOnRouting"] = self.speakOnRoutingCheckBox.Value config.conf["braille"]["speakOnNavigatingByUnit"] = self.speakOnNavigatingCheckBox.Value - config.conf["braille"]["wordWrap"] = self.wordWrapCheckBox.Value + self.textWrapComboBox.saveCurrentValueToConf() 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..4289c566ce4 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,8 @@ import braille import config +from config.featureFlag import FeatureFlag +from config.featureFlagEnums import BrailleTextWrapFlag def _getDisplayDimensions(dimensions: braille.DisplayDimensions) -> braille.DisplayDimensions: @@ -19,13 +21,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_BOUNDARIES + if mode != BrailleTextWrapFlag.AT_WORD_BOUNDARIES + else BrailleTextWrapFlag.MARK_WORD_CUTS + ) + 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"]["wordWrap"] = False + _setTextWrap(BrailleTextWrapFlag.NONE) def test_noCells(self): """Check that, if list of braille cells is empty, offsets will be (0, 0).""" @@ -55,24 +71,24 @@ def test_end(self): expectedOffsets = [(0, 20), (20, 40)] 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 + 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) cells.append(0) 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) - config.conf["braille"]["wordWrap"] = False + _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"]["wordWrap"] = 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) cells.extend([1] * (braille.handler.displayDimensions.numCols - 1)) @@ -80,6 +96,42 @@ 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 + _setTextWrap(BrailleTextWrapFlag.NONE) braille.handler.buffer._calculateWindowRowBufferOffsets(0) self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) + + def test_none_hardCutsAtDisplayEdge(self): + """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): + """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): + """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): + """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 + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 19)) + self.assertIn(0, braille.handler.buffer._continuationRows) diff --git a/tests/unit/test_braille/test_windowBrailleCells.py b/tests/unit/test_braille/test_windowBrailleCells.py new file mode 100644 index 00000000000..4256fe3b530 --- /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): + """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): + """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/user_docs/en/changes.md b/user_docs/en/changes.md index 9210c00574b..b28c47bac70 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -6,6 +6,9 @@ ### New Features +* The braille "word wrap" option has been replaced with a three-valued "Text wrap" option: Off, Show mark when words are cut, and At word 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. + ### Changes ### Bug Fixes @@ -18,6 +21,8 @@ Please refer to [the developer guide](https://download.nvaccess.org/documentatio #### Deprecations +* The `braille.wordWrap` configuration key is deprecated and bridged to `braille.textWrap`. (#17010, @LeonarddeR) + diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 7094bd9a665..e8e9b992114 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2584,18 +2584,22 @@ Automatic scrolling will be disabled if a routing key is pressed, if a message i Commands can be assigned to toggle the automatic scroll option, and to increase or decrease the scroll rate, from the "Braille" section of the [Input Gestures dialog](#InputGestures). -##### Avoid splitting words when possible {#BrailleSettingsWordWrap} - -If this is enabled, a word which is too large to fit at the end of the braille display will not be split. +##### 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 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. +* 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. +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. ##### Unicode normalization {#BrailleUnicodeNormalization} From 1eb323f937b07ca7254d5ef86f03590cfa673e14 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 19 May 2026 09:51:15 +0200 Subject: [PATCH 2/7] refactor(braille): fold continuation flag into row offset tuples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _continuationRows was a parallel list kept in sync with _windowRowBufferOffsets and joined by index at read time. Merge into list[tuple[int, int, bool]] to eliminate the sync burden and O(n) membership test. Also: extract _isMidWordCut helper (removes duplication), drop dead guard `end - start == numCols` (always true in that branch), fix redundant rindex call (second search [start, end) finds same index as first over [start, end+1) when lastSpaceIndex < end), delete stale wordWrap key in upgrade step 22→23 (matches pattern of all other renames). --- source/braille.py | 45 +++++++++---------- source/config/profileUpgradeSteps.py | 3 +- .../test_calculateWindowRowBufferOffsets.py | 32 ++++++------- .../test_braille/test_windowBrailleCells.py | 10 ++--- 4 files changed, 42 insertions(+), 48 deletions(-) diff --git a/source/braille.py b/source/braille.py index 6bb02638731..20a9b90b1c1 100644 --- a/source/braille.py +++ b/source/braille.py @@ -1838,14 +1838,12 @@ def __init__(self, handler): #: The translated braille representation of the entire buffer. #: @type: [int, ...] self.brailleCells = [] - self._windowRowBufferOffsets: list[tuple[int, int]] = [(0, 0)] + self._windowRowBufferOffsets: list[tuple[int, int, bool]] = [(0, 0, False)] """ A list representing the rows in the braille window, - each item being a tuple of start and end braille buffer offsets. + each item being a tuple of start and end braille buffer offsets and a bool indicating whether a continuation mark should appear. 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. @@ -1962,7 +1960,7 @@ def bufferPosToWindowPos(self, bufferPos: int) -> int: :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): + for row, (start, end, _) in enumerate(self._windowRowBufferOffsets): if start <= bufferPos < end: return row * self.handler.displayDimensions.numCols + (bufferPos - start) raise LookupError("buffer pos not in window") @@ -1976,7 +1974,7 @@ def windowPosToBufferPos(self, windowPos: int) -> int: windowPos = max(min(windowPos, self.handler.displaySize), 0) row, col = divmod(windowPos, self.handler.displayDimensions.numCols) if row < len(self._windowRowBufferOffsets): - start, end = self._windowRowBufferOffsets[row] + start, end, _ = self._windowRowBufferOffsets[row] return max(min(start + col, end - 1), 0) raise ValueError("Position outside window") @@ -1989,6 +1987,10 @@ def _get_windowStartPos(self) -> int: def _set_windowStartPos(self, pos: int) -> None: self._calculateWindowRowBufferOffsets(pos) + def _isMidWordCut(self, end: int, bufferEnd: int) -> bool: + """Return True when the cut at `end` falls in the middle of a word (both adjacent cells are non-space).""" + return end < bufferEnd and all(self.brailleCells[end - 1 : end + 1]) + def _calculateWindowRowBufferOffsets(self, pos: int) -> None: """ Calculates the start and end positions of each row in the braille window. @@ -1997,10 +1999,9 @@ 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)] + self._windowRowBufferOffsets = [(0, 0, False)] return textWrap: BrailleTextWrapFlag = config.conf["braille"]["textWrap"].calculated() bufferEnd = len(self.brailleCells) @@ -2012,28 +2013,22 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: if end > bufferEnd: end = bufferEnd clippedEnd = True - elif ( - textWrap == BrailleTextWrapFlag.MARK_WORD_CUTS - and end < bufferEnd - and all(self.brailleCells[end - 1 : end + 1]) - ): + elif textWrap == BrailleTextWrapFlag.MARK_WORD_CUTS and self._isMidWordCut(end, bufferEnd): end -= 1 showContinuationMark = True elif textWrap == BrailleTextWrapFlag.AT_WORD_BOUNDARIES: try: lastSpaceIndex = rindex(self.brailleCells, 0, start, end + 1) if lastSpaceIndex < end: - # The next braille window doesn't start with space. - end = rindex(self.brailleCells, 0, start, end) + 1 + # lastSpaceIndex < end proves brailleCells[end] is non-zero, + # so searching [start, end) yields the same lastSpaceIndex. + end = lastSpaceIndex + 1 except (ValueError, IndexError): # No space on line - fall back to display-edge cut. - 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)) - self._windowRowBufferOffsets.append((start, end)) + if self._isMidWordCut(end, bufferEnd): + end -= 1 + showContinuationMark = True + self._windowRowBufferOffsets.append((start, end, showContinuationMark)) if clippedEnd: break start = end @@ -2042,7 +2037,7 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: """The end position of the braille window in the braille buffer.""" def _get_windowEndPos(self) -> int: - start, end = self._windowRowBufferOffsets[-1] + start, end, _ = self._windowRowBufferOffsets[-1] return end def _set_windowEndPos(self, endPos: int) -> None: @@ -2197,10 +2192,10 @@ def _get_windowRawText(self): def _get_windowBrailleCells(self) -> list[int]: windowCells = [] - for row, (start, end) in enumerate(self._windowRowBufferOffsets): + for row, (start, end, hasCont) in enumerate(self._windowRowBufferOffsets): rowCells = self.brailleCells[start:end] remaining = self.handler.displayDimensions.numCols - len(rowCells) - if remaining > 0 and row in self._continuationRows: + if remaining > 0 and hasCont: rowCells.append(CONTINUATION_SHAPE) remaining -= 1 if remaining > 0: diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index 8d3011be233..996da66383a 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -710,6 +710,7 @@ def upgradeConfigFrom_22_to_23(profile: ConfigObj) -> None: newValue = BrailleTextWrapFlag.AT_WORD_BOUNDARIES.name if oldValue else BrailleTextWrapFlag.NONE.name profile[section][newKey] = newValue + del profile[section][key] log.debug( - f"Converted '{key}' with value {oldValue} to '{newKey}' with value {newValue}.", + f"Converted '{key}' with value {oldValue} to '{newKey}' with value {newValue}. Deleted '{key}'.", ) diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index 4289c566ce4..fdd48ab2573 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -44,31 +44,31 @@ def tearDown(self): _setTextWrap(BrailleTextWrapFlag.NONE) def test_noCells(self): - """Check that, if list of braille cells is empty, offsets will be (0, 0).""" + """Check that, if list of braille cells is empty, offsets will be (0, 0, False).""" braille.handler.buffer.brailleCells = [] braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 0)] + expectedOffsets = [(0, 0, False)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_firstPosition(self): """Checks that first offset is equal to start parameter.""" braille.handler.buffer.brailleCells = [1] * braille.handler.displaySize braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20), (20, 40)] + expectedOffsets = [(0, 20, False), (20, 40, False)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) braille.handler.buffer._calculateWindowRowBufferOffsets(1) - expectedOffsets = [(1, 21), (21, 40)] + expectedOffsets = [(1, 21, False), (21, 40, False)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_end(self): """Check that last row offset won't be greater than length of list of braille cells.""" braille.handler.buffer.brailleCells = [1] * (braille.handler.displaySize - 10) braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20), (20, 30)] + expectedOffsets = [(0, 20, False), (20, 30, False)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) braille.handler.buffer.brailleCells = [1] * braille.handler.displaySize braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20), (20, 40)] + expectedOffsets = [(0, 20, False), (20, 40, False)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_textWrapFirstRowWithSpace(self): @@ -79,11 +79,11 @@ 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, 35)] + expectedOffsets = [(0, 16, False), (16, 35, True)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) _setTextWrap(BrailleTextWrapFlag.NONE) braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20), (20, 40)] + expectedOffsets = [(0, 20, False), (20, 40, False)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_textWrapSecondRowStartsWithSpace(self): @@ -94,7 +94,7 @@ def test_textWrapSecondRowStartsWithSpace(self): cells.extend([1] * (braille.handler.displayDimensions.numCols - 1)) braille.handler.buffer.brailleCells = cells braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20), (20, 40)] + expectedOffsets = [(0, 20, False), (20, 40, False)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) _setTextWrap(BrailleTextWrapFlag.NONE) braille.handler.buffer._calculateWindowRowBufferOffsets(0) @@ -106,8 +106,8 @@ def test_none_hardCutsAtDisplayEdge(self): # 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, []) + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, [(0, 20, False), (20, 25, False)]) + self.assertFalse(any(cont for _, _, cont in braille.handler.buffer._windowRowBufferOffsets)) def test_markWordCuts_oneCellEarlierAndMarksRow(self): """MARK_WORD_CUTS cuts one cell earlier than NONE and records the row in _continuationRows.""" @@ -115,8 +115,8 @@ def test_markWordCuts_oneCellEarlierAndMarksRow(self): 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) + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 19, True)) + self.assertTrue(braille.handler.buffer._windowRowBufferOffsets[0][2]) def test_markWordCuts_cleanRowHasNoMarker(self): """MARK_WORD_CUTS does not mark a row that ends naturally at a space.""" @@ -125,7 +125,7 @@ def test_markWordCuts_cleanRowHasNoMarker(self): cells = [1] * 19 + [0] + [1] * 10 braille.handler.buffer.brailleCells = cells braille.handler.buffer._calculateWindowRowBufferOffsets(0) - self.assertNotIn(0, braille.handler.buffer._continuationRows) + self.assertFalse(braille.handler.buffer._windowRowBufferOffsets[0][2]) def test_atWordBoundaries_noSpaceInWindowMarksCut(self): """AT_WORD_BOUNDARIES with no whitespace in the window hard-cuts AND marks the row.""" @@ -133,5 +133,5 @@ 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, 19)) - self.assertIn(0, braille.handler.buffer._continuationRows) + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 19, True)) + self.assertTrue(braille.handler.buffer._windowRowBufferOffsets[0][2]) diff --git a/tests/unit/test_braille/test_windowBrailleCells.py b/tests/unit/test_braille/test_windowBrailleCells.py index 4256fe3b530..6827b340716 100644 --- a/tests/unit/test_braille/test_windowBrailleCells.py +++ b/tests/unit/test_braille/test_windowBrailleCells.py @@ -26,12 +26,11 @@ def tearDown(self): braille.filter_displayDimensions.unregister(_getDisplayDimensions) def test_continuationRow_hasContinuationShape(self): - """A row present in _continuationRows gets CONTINUATION_SHAPE as its last cell.""" + """A row with hasContinuation=True 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] + buffer._windowRowBufferOffsets = [(0, 15, True), (15, 20, False)] cells = buffer.windowBrailleCells # First row: 15 real cells, then CONTINUATION_SHAPE, then 4 padding zeroes. self.assertEqual(len(cells), 40) @@ -39,11 +38,10 @@ def test_continuationRow_hasContinuationShape(self): self.assertEqual(cells[16:20], [0, 0, 0, 0]) def test_nonContinuationRow_lastCellIsZero(self): - """A row absent from _continuationRows has padding zero, not CONTINUATION_SHAPE.""" + """A row with hasContinuation=False has padding zero, not CONTINUATION_SHAPE.""" buffer = braille.handler.buffer buffer.brailleCells = [1] * 15 + [1] * 5 - buffer._windowRowBufferOffsets = [(0, 15), (15, 20)] - buffer._continuationRows = [] + buffer._windowRowBufferOffsets = [(0, 15, False), (15, 20, False)] 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]) From 836a7a419198c42c7f9ea98f5fde6c7a9442f922 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 19 May 2026 10:24:28 +0200 Subject: [PATCH 3/7] Again undo deletion of wordWrap key, deprecation doesn't mean removal per se --- source/config/__init__.py | 2 +- source/config/profileUpgradeSteps.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/source/config/__init__.py b/source/config/__init__.py index 0578b534a7a..6427e87034e 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -375,7 +375,7 @@ def _setSystemConfig( else: relativePath = os.path.relpath(curSourceDir, fromPath) curDestDir = os.path.join(toPath, relativePath) - if not isMigration and relativePath == "addons": + if not isMigration and relativePath.casefold() == "addons": _prepareToCopyAddons(fromPath, toPath, subDirs, addonsToCopy) if not os.path.isdir(curDestDir): os.makedirs(curDestDir) diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index 996da66383a..5cde7be5107 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -710,7 +710,6 @@ def upgradeConfigFrom_22_to_23(profile: ConfigObj) -> None: newValue = BrailleTextWrapFlag.AT_WORD_BOUNDARIES.name if oldValue else BrailleTextWrapFlag.NONE.name profile[section][newKey] = newValue - del profile[section][key] log.debug( f"Converted '{key}' with value {oldValue} to '{newKey}' with value {newValue}. Deleted '{key}'.", ) From 76860281fbfe7724ad019c777609580933b1cef6 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 19 May 2026 12:13:40 +0200 Subject: [PATCH 4/7] fix(braille): correct upgrade step docstring 'word or syllable boundaries' was inaccurate; BrailleTextWrapFlag has no syllable option. Correct to 'at word boundaries'. Co-Authored-By: Claude Sonnet 4.6 --- source/config/profileUpgradeSteps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index 5cde7be5107..8bfe2bab301 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -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 default of word or syllable boundaries. + rather than the new default of at word boundaries. """ section = "braille" key = "wordWrap" From 6886221dc8557989faf34ef8f25923c077af5cac Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Wed, 20 May 2026 07:36:21 +0200 Subject: [PATCH 5/7] Address review comments --- source/braille.py | 40 ++++++++++++------- source/config/featureFlagEnums.py | 10 ++--- source/gui/settingsDialogs.py | 2 +- .../test_calculateWindowRowBufferOffsets.py | 39 +++++++++++------- .../test_braille/test_windowBrailleCells.py | 10 ++++- user_docs/en/userGuide.md | 2 +- 6 files changed, 65 insertions(+), 38 deletions(-) diff --git a/source/braille.py b/source/braille.py index 20a9b90b1c1..1610df749c5 100644 --- a/source/braille.py +++ b/source/braille.py @@ -5,6 +5,7 @@ # Leonard de Ruijter, Burman's Computer and Education Ltd., Julien Cochuyt from enum import StrEnum +import dataclasses import itertools import typing from typing import ( @@ -1822,6 +1823,18 @@ def rindex(seq, item, start, end): raise ValueError("%r is not in sequence" % item) +@dataclasses.dataclass(frozen=True) +class _WindowRowPositions: + """Braille buffer positions for a single row of the braille window.""" + + start: int + """Start position (inclusive) in the braille buffer.""" + end: int + """End position (exclusive) in the braille buffer.""" + showContinuationMark: bool = False + """Whether to append a continuation mark (`CONTINUATION_SHAPE`) after the row cells.""" + + class BrailleBuffer(baseObject.AutoPropertyObject): handler: "BrailleHandler" regions: list[Region] @@ -1838,10 +1851,10 @@ def __init__(self, handler): #: The translated braille representation of the entire buffer. #: @type: [int, ...] self.brailleCells = [] - self._windowRowBufferOffsets: list[tuple[int, int, bool]] = [(0, 0, False)] + self._windowRowBufferOffsets: list[_WindowRowPositions] = [_WindowRowPositions(0, 0)] """ A list representing the rows in the braille window, - each item being a tuple of start and end braille buffer offsets and a bool indicating whether a continuation mark should appear. + each item containing start and end braille buffer offsets and whether a continuation mark should appear. Splitting the window into independent rows allows for optional avoidance of splitting words across rows. """ @@ -1960,9 +1973,9 @@ def bufferPosToWindowPos(self, bufferPos: int) -> int: :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) + for row, rowPositions in enumerate(self._windowRowBufferOffsets): + if rowPositions.start <= bufferPos < rowPositions.end: + return row * self.handler.displayDimensions.numCols + (bufferPos - rowPositions.start) raise LookupError("buffer pos not in window") def windowPosToBufferPos(self, windowPos: int) -> int: @@ -1974,8 +1987,8 @@ def windowPosToBufferPos(self, windowPos: int) -> int: windowPos = max(min(windowPos, self.handler.displaySize), 0) row, col = divmod(windowPos, self.handler.displayDimensions.numCols) if row < len(self._windowRowBufferOffsets): - start, end, _ = self._windowRowBufferOffsets[row] - return max(min(start + col, end - 1), 0) + rowPositions = self._windowRowBufferOffsets[row] + return max(min(rowPositions.start + col, rowPositions.end - 1), 0) raise ValueError("Position outside window") windowStartPos: int @@ -2001,7 +2014,7 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: self._windowRowBufferOffsets.clear() if len(self.brailleCells) == 0: # Initialising with no actual braille content. - self._windowRowBufferOffsets = [(0, 0, False)] + self._windowRowBufferOffsets = [_WindowRowPositions(0, 0)] return textWrap: BrailleTextWrapFlag = config.conf["braille"]["textWrap"].calculated() bufferEnd = len(self.brailleCells) @@ -2028,7 +2041,7 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: if self._isMidWordCut(end, bufferEnd): end -= 1 showContinuationMark = True - self._windowRowBufferOffsets.append((start, end, showContinuationMark)) + self._windowRowBufferOffsets.append(_WindowRowPositions(start, end, showContinuationMark)) if clippedEnd: break start = end @@ -2037,8 +2050,7 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: """The end position of the braille window in the braille buffer.""" def _get_windowEndPos(self) -> int: - start, end, _ = self._windowRowBufferOffsets[-1] - return end + return self._windowRowBufferOffsets[-1].end def _set_windowEndPos(self, endPos: int) -> None: """Sets the end position for the braille window and recalculates the window start position based on several variables. @@ -2192,10 +2204,10 @@ def _get_windowRawText(self): def _get_windowBrailleCells(self) -> list[int]: windowCells = [] - for row, (start, end, hasCont) in enumerate(self._windowRowBufferOffsets): - rowCells = self.brailleCells[start:end] + for row, rowPositions in enumerate(self._windowRowBufferOffsets): + rowCells = self.brailleCells[rowPositions.start : rowPositions.end] remaining = self.handler.displayDimensions.numCols - len(rowCells) - if remaining > 0 and hasCont: + if remaining > 0 and rowPositions.showContinuationMark: rowCells.append(CONTINUATION_SHAPE) remaining -= 1 if remaining > 0: diff --git a/source/config/featureFlagEnums.py b/source/config/featureFlagEnums.py index 8626fa9507b..d9fa68b7ae1 100644 --- a/source/config/featureFlagEnums.py +++ b/source/config/featureFlagEnums.py @@ -146,6 +146,11 @@ class BrailleTextWrapFlag(DisplayStringEnum): regardless of mode (except for NONE, which never shows the mark). """ + DEFAULT = enum.auto() + NONE = enum.auto() + MARK_WORD_CUTS = enum.auto() + AT_WORD_BOUNDARIES = enum.auto() + @property def _displayStringLabels(self): return { @@ -157,11 +162,6 @@ def _displayStringLabels(self): self.AT_WORD_BOUNDARIES: pgettext("braille text wrap", "At word boundaries"), } - DEFAULT = enum.auto() - NONE = enum.auto() - MARK_WORD_CUTS = enum.auto() - AT_WORD_BOUNDARIES = enum.auto() - def getAvailableEnums() -> typing.Generator[typing.Tuple[str, FlagValueEnum], None, None]: for name, value in globals().items(): diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 48ee9a86fc4..fb5ce1210db 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -5507,7 +5507,7 @@ def makeSettings(self, settingsSizer): keyPath=["braille", "textWrap"], conf=config.conf, ) - self.bindHelpEvent("BrailleSettingsTextWrap", self.textWrapComboBox) + self.bindHelpEvent("BrailleSettingsWordWrap", self.textWrapComboBox) self.unicodeNormalizationCombo: nvdaControls.FeatureFlagCombo = sHelper.addLabeledControl( labelText=_( diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index fdd48ab2573..933dc793c07 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -47,28 +47,28 @@ def test_noCells(self): """Check that, if list of braille cells is empty, offsets will be (0, 0, False).""" braille.handler.buffer.brailleCells = [] braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 0, False)] + expectedOffsets = [braille._WindowRowPositions(0, 0)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_firstPosition(self): """Checks that first offset is equal to start parameter.""" braille.handler.buffer.brailleCells = [1] * braille.handler.displaySize braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20, False), (20, 40, False)] + expectedOffsets = [braille._WindowRowPositions(0, 20), braille._WindowRowPositions(20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) braille.handler.buffer._calculateWindowRowBufferOffsets(1) - expectedOffsets = [(1, 21, False), (21, 40, False)] + expectedOffsets = [braille._WindowRowPositions(1, 21), braille._WindowRowPositions(21, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_end(self): """Check that last row offset won't be greater than length of list of braille cells.""" braille.handler.buffer.brailleCells = [1] * (braille.handler.displaySize - 10) braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20, False), (20, 30, False)] + expectedOffsets = [braille._WindowRowPositions(0, 20), braille._WindowRowPositions(20, 30)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) braille.handler.buffer.brailleCells = [1] * braille.handler.displaySize braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20, False), (20, 40, False)] + expectedOffsets = [braille._WindowRowPositions(0, 20), braille._WindowRowPositions(20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_textWrapFirstRowWithSpace(self): @@ -79,11 +79,11 @@ 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, False), (16, 35, True)] + expectedOffsets = [braille._WindowRowPositions(0, 16), braille._WindowRowPositions(16, 35, True)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) _setTextWrap(BrailleTextWrapFlag.NONE) braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20, False), (20, 40, False)] + expectedOffsets = [braille._WindowRowPositions(0, 20), braille._WindowRowPositions(20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) def test_textWrapSecondRowStartsWithSpace(self): @@ -94,7 +94,7 @@ def test_textWrapSecondRowStartsWithSpace(self): cells.extend([1] * (braille.handler.displayDimensions.numCols - 1)) braille.handler.buffer.brailleCells = cells braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20, False), (20, 40, False)] + expectedOffsets = [braille._WindowRowPositions(0, 20), braille._WindowRowPositions(20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) _setTextWrap(BrailleTextWrapFlag.NONE) braille.handler.buffer._calculateWindowRowBufferOffsets(0) @@ -106,8 +106,11 @@ def test_none_hardCutsAtDisplayEdge(self): # 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, False), (20, 25, False)]) - self.assertFalse(any(cont for _, _, cont in braille.handler.buffer._windowRowBufferOffsets)) + self.assertEqual( + braille.handler.buffer._windowRowBufferOffsets, + [braille._WindowRowPositions(0, 20), braille._WindowRowPositions(20, 25)], + ) + self.assertFalse(any(r.showContinuationMark for r in braille.handler.buffer._windowRowBufferOffsets)) def test_markWordCuts_oneCellEarlierAndMarksRow(self): """MARK_WORD_CUTS cuts one cell earlier than NONE and records the row in _continuationRows.""" @@ -115,8 +118,11 @@ def test_markWordCuts_oneCellEarlierAndMarksRow(self): 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, True)) - self.assertTrue(braille.handler.buffer._windowRowBufferOffsets[0][2]) + self.assertEqual( + braille.handler.buffer._windowRowBufferOffsets[0], + braille._WindowRowPositions(0, 19, True), + ) + self.assertTrue(braille.handler.buffer._windowRowBufferOffsets[0].showContinuationMark) def test_markWordCuts_cleanRowHasNoMarker(self): """MARK_WORD_CUTS does not mark a row that ends naturally at a space.""" @@ -125,7 +131,7 @@ def test_markWordCuts_cleanRowHasNoMarker(self): cells = [1] * 19 + [0] + [1] * 10 braille.handler.buffer.brailleCells = cells braille.handler.buffer._calculateWindowRowBufferOffsets(0) - self.assertFalse(braille.handler.buffer._windowRowBufferOffsets[0][2]) + self.assertFalse(braille.handler.buffer._windowRowBufferOffsets[0].showContinuationMark) def test_atWordBoundaries_noSpaceInWindowMarksCut(self): """AT_WORD_BOUNDARIES with no whitespace in the window hard-cuts AND marks the row.""" @@ -133,5 +139,8 @@ 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, 19, True)) - self.assertTrue(braille.handler.buffer._windowRowBufferOffsets[0][2]) + self.assertEqual( + braille.handler.buffer._windowRowBufferOffsets[0], + braille._WindowRowPositions(0, 19, True), + ) + self.assertTrue(braille.handler.buffer._windowRowBufferOffsets[0].showContinuationMark) diff --git a/tests/unit/test_braille/test_windowBrailleCells.py b/tests/unit/test_braille/test_windowBrailleCells.py index 6827b340716..3d3a54a0fd6 100644 --- a/tests/unit/test_braille/test_windowBrailleCells.py +++ b/tests/unit/test_braille/test_windowBrailleCells.py @@ -30,7 +30,10 @@ def test_continuationRow_hasContinuationShape(self): 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, True), (15, 20, False)] + buffer._windowRowBufferOffsets = [ + braille._WindowRowPositions(0, 15, True), + braille._WindowRowPositions(15, 20), + ] cells = buffer.windowBrailleCells # First row: 15 real cells, then CONTINUATION_SHAPE, then 4 padding zeroes. self.assertEqual(len(cells), 40) @@ -41,7 +44,10 @@ def test_nonContinuationRow_lastCellIsZero(self): """A row with hasContinuation=False has padding zero, not CONTINUATION_SHAPE.""" buffer = braille.handler.buffer buffer.brailleCells = [1] * 15 + [1] * 5 - buffer._windowRowBufferOffsets = [(0, 15, False), (15, 20, False)] + buffer._windowRowBufferOffsets = [ + braille._WindowRowPositions(0, 15), + braille._WindowRowPositions(15, 20), + ] 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]) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index e8e9b992114..aebcfc25c44 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2584,7 +2584,7 @@ Automatic scrolling will be disabled if a routing key is pressed, if a message i Commands can be assigned to toggle the automatic scroll option, and to increase or decrease the scroll rate, from the "Braille" section of the [Input Gestures dialog](#InputGestures). -##### Text wrap {#BrailleSettingsTextWrap} +##### Text wrap {#BrailleSettingsWordWrap} 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. From 1ec554ba364c93409bd12dfb2a967bd295773c67 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter <3049216+LeonarddeR@users.noreply.github.com> Date: Wed, 20 May 2026 07:48:08 +0200 Subject: [PATCH 6/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- user_docs/en/changes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index b28c47bac70..569964073c9 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -7,7 +7,7 @@ ### New Features * The braille "word wrap" option has been replaced with a three-valued "Text wrap" option: Off, Show mark when words are cut, and At word 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. + * In modes that show a continuation mark, 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. ### Changes From 9cad76ef65e58f0dce5b8525cdbae841cf26cf1f Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Wed, 20 May 2026 07:53:11 +0200 Subject: [PATCH 7/7] Final review actions for copilot --- source/config/profileUpgradeSteps.py | 2 +- tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index 8bfe2bab301..306ab9276f0 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -711,5 +711,5 @@ def upgradeConfigFrom_22_to_23(profile: ConfigObj) -> None: 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}. Deleted '{key}'.", + f"Converted '{key}' with value {oldValue} to '{newKey}' with value {newValue}.", ) diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index 933dc793c07..46e762ce8b2 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -113,7 +113,7 @@ def test_none_hardCutsAtDisplayEdge(self): self.assertFalse(any(r.showContinuationMark for r in braille.handler.buffer._windowRowBufferOffsets)) def test_markWordCuts_oneCellEarlierAndMarksRow(self): - """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 marks the row via _WindowRowPositions.showContinuationMark.""" _setTextWrap(BrailleTextWrapFlag.MARK_WORD_CUTS) braille.handler.buffer.brailleCells = [1] * 25 braille.handler.buffer._calculateWindowRowBufferOffsets(0)