diff --git a/source/braille.py b/source/braille.py index 6e1d36be208..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 ( @@ -52,7 +53,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 +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 """ @@ -1385,7 +1391,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 @@ -1812,11 +1823,25 @@ 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] + """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 = "" @@ -1826,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]] = [(0, 0)] + 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. + 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. """ @@ -1860,21 +1885,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 +1907,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 +1940,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,9 +1968,14 @@ def bufferPositionsToRawText(self, startPos, endPos): return "" def bufferPosToWindowPos(self, bufferPos: int) -> int: - for row, (start, end) in enumerate(self._windowRowBufferOffsets): - if start <= bufferPos < end: - return row * self.handler.displayDimensions.numCols + (bufferPos - start) + """ + 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, 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: @@ -1941,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 @@ -1954,36 +2000,48 @@ 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. - 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() if len(self.brailleCells) == 0: # Initialising with no actual braille content. - self._windowRowBufferOffsets = [(0, 0)] + self._windowRowBufferOffsets = [_WindowRowPositions(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 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): - pass # No space on line - self._windowRowBufferOffsets.append((start, end)) + # No space on line - fall back to display-edge cut. + if self._isMidWordCut(end, bufferEnd): + end -= 1 + showContinuationMark = True + self._windowRowBufferOffsets.append(_WindowRowPositions(start, end, showContinuationMark)) if clippedEnd: break start = end @@ -1992,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. @@ -2001,7 +2058,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 +2079,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 +2097,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 +2204,12 @@ def _get_windowRawText(self): def _get_windowBrailleCells(self) -> list[int]: windowCells = [] - for start, end in 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 rowPositions.showContinuationMark: + 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..6427e87034e 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, @@ -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..d9fa68b7ae1 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). + """ + + DEFAULT = enum.auto() + NONE = enum.auto() + MARK_WORD_CUTS = enum.auto() + AT_WORD_BOUNDARIES = enum.auto() + + @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"), + } + + 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..306ab9276f0 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 at word 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..fb5ce1210db 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("BrailleSettingsWordWrap", 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..46e762ce8b2 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,67 +21,126 @@ 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).""" + """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 = [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), (20, 40)] + expectedOffsets = [braille._WindowRowPositions(0, 20), braille._WindowRowPositions(20, 40)] self.assertEqual(braille.handler.buffer._windowRowBufferOffsets, expectedOffsets) braille.handler.buffer._calculateWindowRowBufferOffsets(1) - expectedOffsets = [(1, 21), (21, 40)] + 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), (20, 30)] + 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), (20, 40)] + expectedOffsets = [braille._WindowRowPositions(0, 20), braille._WindowRowPositions(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 = [braille._WindowRowPositions(0, 16), braille._WindowRowPositions(16, 35, True)] 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)] + expectedOffsets = [braille._WindowRowPositions(0, 20), braille._WindowRowPositions(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)) braille.handler.buffer.brailleCells = cells braille.handler.buffer._calculateWindowRowBufferOffsets(0) - expectedOffsets = [(0, 20), (20, 40)] + expectedOffsets = [braille._WindowRowPositions(0, 20), braille._WindowRowPositions(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, + [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 marks the row via _WindowRowPositions.showContinuationMark.""" + _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], + 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.""" + _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.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.""" + _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], + 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 new file mode 100644 index 00000000000..3d3a54a0fd6 --- /dev/null +++ b/tests/unit/test_braille/test_windowBrailleCells.py @@ -0,0 +1,54 @@ +# 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 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 = [ + 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) + self.assertEqual(cells[15], braille.CONTINUATION_SHAPE) + self.assertEqual(cells[16:20], [0, 0, 0, 0]) + + 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 = [ + 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]) + self.assertNotIn(braille.CONTINUATION_SHAPE, cells) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 9210c00574b..569964073c9 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) + * 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 ### 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..aebcfc25c44 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 {#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. +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}