diff --git a/pyproject.toml b/pyproject.toml index ee2a0f39bfb..604b7382e2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ "mdx-gh-links==0.4", "l2m4m==1.0.4", "pymdown-extensions==10.17.1", + "pyphen>=0.17.2", # pinned due to incompatibility with py2exe # https://github.com/py2exe/py2exe/issues/241 "charset-normalizer==3.4.4", diff --git a/source/braille.py b/source/braille.py index 6e1d36be208..09a64837a5a 100644 --- a/source/braille.py +++ b/source/braille.py @@ -4,6 +4,7 @@ # Copyright (C) 2008-2025 NV Access Limited, Joseph Lee, Babbage B.V., Davy Kager, Bram Duvigneau, # Leonard de Ruijter, Burman's Computer and Education Ltd., Julien Cochuyt +import bisect from enum import StrEnum import itertools import typing @@ -34,10 +35,13 @@ import threading import time import wx +import languageHandler import louisHelper import louis import gui from controlTypes.state import State +import textUtils +import textUtils.hyphenation import winBindings.kernel32 import winKernel import keyboardHandler @@ -52,7 +56,11 @@ OutputMode, ReportSpellingErrors, ) -from config.featureFlagEnums import ReviewRoutingMovesSystemCaretFlag, FontFormattingBrailleModeFlag +from config.featureFlagEnums import ( + BrailleTextWrapFlag, + FontFormattingBrailleModeFlag, + ReviewRoutingMovesSystemCaretFlag, +) from logHandler import log import controlTypes import api @@ -326,6 +334,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 """ @@ -559,6 +568,9 @@ def __init__(self): #: The end of the selection in L{rawText} (exclusive), C{None} if there is no selection in this region. #: @type: int self.selectionEnd = None + #: Language indexes in L{rawText}. + #: The last language is assumed to be the final language in the region. + self._languageIndexes: dict[int, str] = {0: self._getDefaultRegionLanguage()} #: The translated braille representation of this region. #: @type: [int, ...] self.brailleCells = [] @@ -587,6 +599,16 @@ def __init__(self): #: @type: bool self.focusToHardLeft = False + def _getDefaultRegionLanguage(self) -> str: + """Get the default language for a region.""" + return louisHelper.getTableLanguage(handler.table.fileName) or languageHandler.getLanguage() + + def _getLanguageAtPos(self, pos: int) -> str: + """Get the language at a given position in L{rawText} based on L{_languageIndexes}.""" + keys = sorted(self._languageIndexes) + i = bisect.bisect_right(keys, pos) - 1 + return self._languageIndexes[keys[i]] + def update(self): """Update this region. Subclasses should extend this to update L{rawText}, L{cursorPos}, L{selectionStart} and L{selectionEnd} if necessary. @@ -1385,12 +1407,25 @@ def _getTypeformFromFormatField(self, field, formatConfig): typeform |= louis.underline return typeform - def _addFieldText(self, text, contentPos, separate=True): + def _addFieldText( + self, + text: str, + contentPos: int, + separate: bool = True, + ): if separate and self.rawText: # Separate this field text from the rest of the text. text = TEXT_SEPARATOR + text - self.rawText += text textLen = len(text) + # Fields are reported in NVDA's language + fieldLanguage = languageHandler.getLanguage() + rawTextLen = len(self.rawText) + lastLanguage = self._getLanguageAtPos(rawTextLen) + if fieldLanguage != lastLanguage: + self._languageIndexes[rawTextLen] = fieldLanguage + # Restore to the previous language + self._languageIndexes[rawTextLen + textLen] = lastLanguage + self.rawText += text self.rawTextTypeforms.extend((louis.plain_text,) * textLen) self._rawToContentPos.extend((contentPos,) * textLen) @@ -1443,16 +1478,21 @@ def _addTextWithFields(self, info, formatConfig, isSelection=False): field = command.field if cmd == "formatChange": typeform = self._getTypeformFromFormatField(field, formatConfig) + language = field.get("language") text = getFormatFieldBraille( field, formatFieldAttributesCache, self._isFormatFieldAtStart, formatConfig, ) + if text: + # Map this field text to the start of the field's content. + self._addFieldText(text, self._currentContentPos) + rawTextLen = len(self.rawText) + if language and self._getLanguageAtPos(rawTextLen) != language: + self._languageIndexes[rawTextLen] = language if not text: continue - # Map this field text to the start of the field's content. - self._addFieldText(text, self._currentContentPos) elif cmd == "controlStart": if self._skipFieldsNotAtStartOfNode and not field.get("_startOfNode"): text = None @@ -1520,6 +1560,7 @@ def update(self): self.rawText = "" self.rawTextTypeforms = [] self.cursorPos = None + self._languageIndexes: dict[int, str] = {0: self._getDefaultRegionLanguage()} # The output includes text representing fields which isn't part of the real content in the control. # Therefore, maintain a map of positions in the output to positions in the content. self._rawToContentPos = [] @@ -1813,10 +1854,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 +1875,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 +1905,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 +1927,31 @@ def _get_brailleToRawPos(self): start += len(region.rawText) return brailleToRawPos - def bufferPosToRegionPos(self, bufferPos): + def bufferPosToRegionPos(self, bufferPos: int) -> tuple[Region, int]: + """Converts a position relative to the braille buffer to a position relative to the region it is in. + :param bufferPos: The position relative to the braille buffer. + :return: A tuple of the region and the position relative to that region. + """ for region, start, end in self.regionsWithPositions: if end > bufferPos: return region, bufferPos - start raise LookupError("No such position") - def regionPosToBufferPos(self, region, pos, allowNearest=False): + def _getLanguageAtBufferPos(self, pos: int) -> str: + """Gets the language at the given position in the braille buffer. + :param pos: The position in the braille buffer. + :return: The language at the given position. + """ + region, regionPos = self.bufferPosToRegionPos(pos) + return region._getLanguageAtPos(regionPos) + + def regionPosToBufferPos(self, region: Region, pos: int, allowNearest: bool = False) -> int: + """Converts a position relative to a region to a position relative to the braille buffer. + :param region: The region the position is relative to. + :param pos: The position relative to the region. + :param allowNearest: If True, if the position is outside the region, return the nearest position within the region. If False, raise LookupError if the position is outside the region. + :return: The position relative to the braille buffer. + """ start: int = 0 for testRegion, start, end in self.regionsWithPositions: if region == testRegion: @@ -1905,7 +1968,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 +1996,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 +2031,67 @@ 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 in ( + BrailleTextWrapFlag.AT_WORD_BOUNDARIES, + BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES, + ): try: lastSpaceIndex = rindex(self.brailleCells, 0, start, end + 1) if lastSpaceIndex < end: # The next braille window doesn't start with space. + oldEnd = end end = rindex(self.brailleCells, 0, start, end) + 1 + if end < oldEnd and textWrap == BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES: + # Prefer splitting the word at a syllable boundary closer to the display edge. + # Note that, when the below index call fails, it is appropriately handled by the except block, + # which means that we won't split at a syllable boundary in this case. + nextSpace = self.brailleCells.index(0, oldEnd, bufferEnd) + word = self.bufferPositionsToRawText(end, nextSpace - 1) + if word: + language = self._getLanguageAtBufferPos(end) + rawPos = self.brailleToRawPos[end] + positions = textUtils.hyphenation.getHyphenPositions(word, language) + for posInWord in reversed(positions): + if (newEnd := self.rawToBraillePos[posInWord + rawPos]) < oldEnd: + # We can split the word at this position. + end = newEnd + showContinuationMark = True + break except (ValueError, IndexError): - pass # No space on line + # 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 +2110,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 +2131,7 @@ 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() == BrailleTextWrapFlag.NONE: self.windowStartPos = startPos return try: @@ -2037,7 +2146,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 +2253,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 754cb77c86d..b91a6ae98bc 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,38 @@ 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 in ( + BrailleTextWrapFlag.AT_WORD_BOUNDARIES, + BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_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 +1450,7 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat # Update the value in the most recently activated profile. # If we have reached this point, we must have a new key and value to set. self._getUpdateSection()[key] = val - self._cache[key] = val + self._cache[key] = cacheVal def _getUpdateSection(self): profile = self.profiles[-1] diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 065f7457686..11dd5898edb 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.2 wordWrap = boolean(default=true) + textWrap = featureFlag(optionsEnum="BrailleTextWrapFlag", behaviorOfDefault="AT_WORD_OR_SYLLABLE_BOUNDARIES") unicodeNormalization = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="disabled") focusContextPresentation = option("changedContext", "fill", "scroll", default="changedContext") interruptSpeechWhileScrolling = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled") diff --git a/source/config/featureFlagEnums.py b/source/config/featureFlagEnums.py index 5bcb1db1fdb..c088899a1d0 100644 --- a/source/config/featureFlagEnums.py +++ b/source/config/featureFlagEnums.py @@ -139,6 +139,36 @@ def _displayStringLabels(self) -> dict["FontFormattingBrailleModeFlag", str]: } +class BrailleTextWrapFlag(DisplayStringEnum): + """Enumeration containing the possible ways to wrap text in braille when a row would exceed the display. + + The continuation mark (dots 7-8) is shown on rows where a word was cut, + regardless of mode (except for NONE, which never shows the mark). + """ + + @property + def _displayStringLabels(self): + return { + # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. + self.NONE: pgettext("braille text wrap", "Off"), + # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. + self.MARK_WORD_CUTS: pgettext("braille text wrap", "Show mark when words are cut"), + # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. + self.AT_WORD_BOUNDARIES: pgettext("braille text wrap", "At word boundaries"), + self.AT_WORD_OR_SYLLABLE_BOUNDARIES: pgettext( + "braille text wrap", + # Translators: A choice in a combo box in the braille settings panel to configure text wrapping. + "At word or syllable boundaries", + ), + } + + DEFAULT = enum.auto() + NONE = enum.auto() + MARK_WORD_CUTS = enum.auto() + AT_WORD_BOUNDARIES = enum.auto() + AT_WORD_OR_SYLLABLE_BOUNDARIES = enum.auto() + + def getAvailableEnums() -> typing.Generator[typing.Tuple[str, FlagValueEnum], None, None]: for name, value in globals().items(): if ( diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index 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 eba08d3441c..3fce3a222e9 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -5498,12 +5498,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=_( @@ -5593,7 +5596,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/source/louisHelper.py b/source/louisHelper.py index d59b6730b60..82d71e1cd2b 100644 --- a/source/louisHelper.py +++ b/source/louisHelper.py @@ -17,6 +17,7 @@ import brailleTables import config import globalVars +import languageHandler from logHandler import log with os.add_dll_directory(globalVars.appDir): @@ -176,3 +177,9 @@ def translate( if cursorPos is None: brailleCursorPos = None return braille, brailleToRawPos, rawToBraillePos, brailleCursorPos + + +def getTableLanguage(table: str) -> str | None: + """Get the language of a braille table, if specified in the table file.""" + lang = louis.getTableInfo(table, "language") + return languageHandler.normalizeLanguage(lang) if lang else lang diff --git a/source/setup.py b/source/setup.py index 10e4ab18ff1..ad434da6c40 100755 --- a/source/setup.py +++ b/source/setup.py @@ -1,12 +1,12 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2026 NV Access Limited, Peter Vágner, Joseph Lee +# Copyright (C) 2006-2026 NV Access Limited, Peter Vágner, Joseph Lee, Leonard de Ruijter # This file is covered by the GNU General Public License. # See the file COPYING for more details. from __future__ import annotations import argparse -from ast import NodeTransformer, fix_missing_locations, parse +from ast import Assign, NodeTransformer, fix_missing_locations, parse import os import sys import gettext @@ -121,6 +121,70 @@ def _hook_latex2mathml_symbols_parser(finder: Scanner, module: Module) -> None: py2exe.hooks.hook_latex2mathml_symbols_parser = _hook_latex2mathml_symbols_parser +class _PyphenTransformer(NodeTransformer): + """Rewrite pyphen's ``dictionaries`` assignment to resolve relative to the frozen executable.""" + + def __init__(self, relpath: str): + super().__init__() + self.rewritten: bool = False + self.relpath = relpath + + def visit_Try(self, node): + # Match the upstream try/except TypeError block whose first body statement + # is ``dictionaries = resources.files('pyphen.dictionaries')``. + firstStmt = node.body[0] if node.body else None + if ( + isinstance(firstStmt, Assign) + and len(firstStmt.targets) == 1 + and getattr(firstStmt.targets[0], "id", None) == "dictionaries" + ): + replacement = parse( + f"dictionaries = Path(os.path.dirname(sys.executable)) / {self.relpath!r}", + ).body[0] + self.rewritten = True + return replacement + return node + + +def _hook_pyphen(finder: Scanner, module: Module) -> None: + """py2exe hook for the pyphen package. + + pyphen locates its ``dictionaries/*.dic`` data files at runtime relative to + its own package directory (via ``importlib.resources`` or ``__file__``). + After freezing, pyphen lives inside ``library.zip`` and those paths no + longer resolve, leaving ``pyphen.LANGUAGES`` empty. This hook: + + 1. Copies every ``hyph_*.dic`` file into ``pyphenDictionaries/`` next to + the frozen executable. + 2. Rewrites the module's ``dictionaries`` assignment via an AST + transformation so it resolves to + ``Path(os.path.dirname(sys.executable)) / 'pyphenDictionaries'``. + """ + import pyphen + + DEST_DIR: Final[str] = "pyphenDictionaries" + sourceDir = os.path.join(os.path.dirname(pyphen.__file__), "dictionaries") + for sourceFile in glob(os.path.join(sourceDir, "hyph_*.dic")): + finder.add_datafile( + os.path.join(DEST_DIR, os.path.basename(sourceFile)), + sourceFile, + ) + tree = parse(module.__source__) + # Inject imports needed by the rewritten expression. + tree.body.insert(0, parse("import os").body[0]) + tree.body.insert(0, parse("import sys").body[0]) + transformer = _PyphenTransformer(DEST_DIR) + newTree = fix_missing_locations(transformer.visit(tree)) + if not transformer.rewritten: + raise RuntimeError( + "py2exe hook failed to rewrite the dictionaries assignment in pyphen. The upstream module may have changed its layout.", + ) + module.__code_object__ = compile(newTree, module.__file__, "exec", optimize=module.__optimize__) + + +py2exe.hooks.hook_pyphen = _hook_pyphen + + def _parsePartialArguments() -> argparse.Namespace: """ Adds a command line option --enable-uiAccess to enable uiAccess for the main executable and EOA proxy @@ -330,8 +394,6 @@ def _genManifestTemplate(shouldHaveUIAccess: bool) -> tuple[int, int, bytes]: ], "includes": [ "nvdaBuiltin", - # #3368: bisect was implicitly included with Python 2.7.3, but isn't with 2.7.5. - "bisect", # robotremoteserver (for system tests) depends on xmlrpc.server "xmlrpc.server", # Required for RPYC over std pipes diff --git a/source/textUtils/hyphenation.py b/source/textUtils/hyphenation.py new file mode 100644 index 00000000000..abc740e0653 --- /dev/null +++ b/source/textUtils/hyphenation.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 + +"""Utilities for hyphenation.""" + +from characterProcessing import LocaleDataMap +from logHandler import log +from pyphen import Pyphen, language_fallback + + +def _pyphenFactory(lang: str) -> Pyphen: + """Factory for Pyphen instances.""" + pyphenLang = language_fallback(lang) + if not pyphenLang: + raise LookupError(f"No Pyphen language found for locale '{lang}'") + elif "_" in lang and "_" not in pyphenLang: + raise LookupError( + f"Pyphen resolved {lang!r} to {pyphenLang!r} but the original locale contains a region subtag. " + "Fallbacks should be handled by LocaleDataMap instead", + ) + return Pyphen(lang=pyphenLang) + + +_hyphenationMap: LocaleDataMap[Pyphen] = LocaleDataMap(_pyphenFactory) + +#: Set of locales for which we have already logged an unknown-language fallback. +#: Used to avoid spamming the log on every call for the same unsupported locale. +_loggedUnknownLocales: set[str] = set() + + +def getHyphenPositions(text: str, locale: str) -> tuple[int, ...]: + """Get the positions of hyphenation points in the given text for the given locale. + + If no hyphenation dictionary is available for the locale, an empty tuple is returned + and a debug message is logged (once per locale). + + :param text: The text to find hyphenation points in. + :param locale: The locale of the text. + :return: A tuple of positions in the text where hyphenation points occur. + """ + try: + pyphen = _hyphenationMap.fetchLocaleData(locale=locale) + except LookupError: + if locale not in _loggedUnknownLocales: + _loggedUnknownLocales.add(locale) + log.debug(f"No Pyphen dictionary available for locale {locale!r}; hyphenation disabled.") + return () + return tuple(pyphen.positions(text)) diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index dbfb1fad614..538a83cd91e 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -1,14 +1,17 @@ # 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.""" import unittest +from unittest.mock import patch import braille import config +from config.featureFlag import FeatureFlag +from config.featureFlagEnums import BrailleTextWrapFlag def _getDisplayDimensions(dimensions: braille.DisplayDimensions) -> braille.DisplayDimensions: @@ -19,13 +22,30 @@ def _getDisplayDimensions(dimensions: braille.DisplayDimensions) -> braille.Disp ) +def _setTextWrap(mode: BrailleTextWrapFlag) -> None: + """Write a `BrailleTextWrapFlag` value to the config as a `FeatureFlag`. + + `behaviorOfDefault` is only meaningful when `mode` is `DEFAULT`; for any explicit value, + we pick an arbitrary non-DEFAULT member to satisfy the `FeatureFlag` constructor assertions. + """ + behaviorOfDefault = ( + BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES + if mode != BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES + else BrailleTextWrapFlag.AT_WORD_BOUNDARIES + ) + config.conf["braille"]["textWrap"] = FeatureFlag(mode, behaviorOfDefault) + + class TestCalculate(unittest.TestCase): def setUp(self): braille.filter_displayDimensions.register(_getDisplayDimensions) def tearDown(self): braille.filter_displayDimensions.unregister(_getDisplayDimensions) - config.conf["braille"]["wordWrap"] = False + _setTextWrap(BrailleTextWrapFlag.NONE) + # Remove instance-level overrides of auto-properties set by syllable-boundary tests. + for attr in ("rawToBraillePos", "brailleToRawPos"): + braille.handler.buffer.__dict__.pop(attr, None) def test_noCells(self): """Check that, if list of braille cells is empty, offsets will be (0, 0).""" @@ -55,24 +75,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 +100,139 @@ 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) + + def test_atWordOrSyllableBoundaries_success(self): + """AT_WORD_OR_SYLLABLE_BOUNDARIES splits at a syllable boundary and marks the row.""" + _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) + # Layout: + # cells 0..9 -> short word + # cell 10 -> space + # cells 11..29 -> 19-cell long word that crosses the 20-cell display edge + # cell 30 -> space + # cells 31..35 -> tail + cells = [1] * 10 + [0] + [1] * 19 + [0] + [1] * 5 + braille.handler.buffer.brailleCells = cells + # rawToBraillePos/brailleToRawPos are Getter (non-data) descriptors via + # AutoPropertyObject, so setting them on the instance shadows the descriptor. + # Cleanup happens in tearDown. + braille.handler.buffer.rawToBraillePos = list(range(len(cells))) + braille.handler.buffer.brailleToRawPos = list(range(len(cells))) + with ( + patch.object( + braille.handler.buffer, + "bufferPositionsToRawText", + return_value="abcdefghijklmnopqrs", + ), + patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="en_US"), + patch( + "braille.textUtils.hyphenation.getHyphenPositions", + return_value=(3,), + ), + ): + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + # Syllable split at rawPos 3 + brailleStart 11 = 14. + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 14)) + self.assertIn(0, braille.handler.buffer._continuationRows) + + def test_atWordOrSyllableBoundaries_emptyPositions(self): + """AT_WORD_OR_SYLLABLE_BOUNDARIES with no hyphen positions falls back to a word boundary with no marker.""" + _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) + # Provide a real space so `rindex(... end+1)` / `rindex(... end)` succeeds. + cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 + braille.handler.buffer.brailleCells = cells + # See test_atWordOrSyllableBoundaries_success for why direct assignment works here. + braille.handler.buffer.rawToBraillePos = list(range(len(cells))) + braille.handler.buffer.brailleToRawPos = list(range(len(cells))) + with ( + patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), + patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="en_US"), + patch( + "braille.textUtils.hyphenation.getHyphenPositions", + return_value=(), + ), + ): + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + # End should be just after the last space in row 0 (index 15 -> end = 16). + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 16)) + self.assertNotIn(0, braille.handler.buffer._continuationRows) + + def test_atWordOrSyllableBoundaries_positionPastEdge(self): + """AT_WORD_OR_SYLLABLE_BOUNDARIES with newEnd >= oldEnd falls back to word boundary.""" + _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) + cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 + braille.handler.buffer.brailleCells = cells + # See test_atWordOrSyllableBoundaries_success for why direct assignment works here. + braille.handler.buffer.rawToBraillePos = list(range(len(cells))) + braille.handler.buffer.brailleToRawPos = list(range(len(cells))) + with ( + patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), + patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="en_US"), + # Position that maps past the display edge. + patch( + "braille.textUtils.hyphenation.getHyphenPositions", + return_value=(22,), + ), + ): + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + # Falls back to word boundary at position 16. + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 16)) + self.assertNotIn(0, braille.handler.buffer._continuationRows) + + def test_atWordOrSyllableBoundaries_unknownLanguage(self): + """AT_WORD_OR_SYLLABLE_BOUNDARIES with an unknown language behaves like empty positions.""" + _setTextWrap(BrailleTextWrapFlag.AT_WORD_OR_SYLLABLE_BOUNDARIES) + cells = [1] * 15 + [0] + [1] * 9 + [0] + [1] * 10 + braille.handler.buffer.brailleCells = cells + # See test_atWordOrSyllableBoundaries_success for why direct assignment works here. + braille.handler.buffer.rawToBraillePos = list(range(len(cells))) + braille.handler.buffer.brailleToRawPos = list(range(len(cells))) + with ( + patch.object(braille.handler.buffer, "bufferPositionsToRawText", return_value="word"), + patch.object(braille.handler.buffer, "_getLanguageAtBufferPos", return_value="zz_ZZ"), + patch( + "braille.textUtils.hyphenation.getHyphenPositions", + return_value=(), + ), + ): + braille.handler.buffer._calculateWindowRowBufferOffsets(0) + self.assertEqual(braille.handler.buffer._windowRowBufferOffsets[0], (0, 16)) + self.assertNotIn(0, braille.handler.buffer._continuationRows) diff --git a/tests/unit/test_braille/test_regionLanguageIndexes.py b/tests/unit/test_braille/test_regionLanguageIndexes.py new file mode 100644 index 00000000000..b0ec733e7d7 --- /dev/null +++ b/tests/unit/test_braille/test_regionLanguageIndexes.py @@ -0,0 +1,126 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +"""Unit tests for Region language index tracking in the braille module.""" + +import unittest +from unittest.mock import patch + +import braille +import textInfos + + +def _makeTextInfoRegion() -> braille.TextInfoRegion: + """Build a TextInfoRegion without going through __init__ (which requires an NVDAObject).""" + region = braille.TextInfoRegion.__new__(braille.TextInfoRegion) + braille.Region.__init__(region) + # Force a deterministic default language so we don't depend on NVDA's configured locale. + region._languageIndexes = {0: "en"} + return region + + +class TestLanguageIndexes(unittest.TestCase): + def test_freshRegion_defaultLanguageAtAnyPos(self): + """A region returns the default language for any non-negative pos.""" + # Stub default language so Region.__init__ doesn't depend on NVDA's configured locale. + with patch.object(braille.Region, "_getDefaultRegionLanguage", return_value="en"): + region = braille.Region() + self.assertEqual(region._getLanguageAtPos(0), "en") + self.assertEqual(region._getLanguageAtPos(5), "en") + self.assertEqual(region._getLanguageAtPos(100), "en") + + def test_addFieldText_insertsSwitchAndRestore(self): + """_addFieldText inserts a switch entry at len(rawText) and a restore entry at len+textLen when the field language differs.""" + region = _makeTextInfoRegion() + # Pre-existing raw text to make `len(rawText)` non-zero and exercise the separator logic. + region.rawText = "hello" + region.rawTextTypeforms = [] + region._rawToContentPos = list(range(5)) + rawTextLenBefore = len(region.rawText) + text = "field" + with patch("braille.languageHandler.getLanguage", return_value="fr"): + region._addFieldText(text, contentPos=0) + # `_addFieldText` prepends TEXT_SEPARATOR when `separate=True` and there is pre-existing text. + addedLen = len(braille.TEXT_SEPARATOR) + len(text) + self.assertIn(rawTextLenBefore, region._languageIndexes) + self.assertEqual(region._languageIndexes[rawTextLenBefore], "fr") + self.assertIn(rawTextLenBefore + addedLen, region._languageIndexes) + self.assertEqual(region._languageIndexes[rawTextLenBefore + addedLen], "en") + + def test_addTextWithFields_formatChangeInsertsLanguageIndex(self): + """Processing a formatChange command whose field has a `language` attribute inserts an index entry.""" + region = _makeTextInfoRegion() + region.rawText = "" + region.rawTextTypeforms = [] + region._rawToContentPos = [] + region._currentContentPos = 0 + region._endsWithField = False + region._isFormatFieldAtStart = True + region._skipFieldsNotAtStartOfNode = False + region.cursorPos = None + region.selectionStart = region.selectionEnd = None + + # Build a minimal list of commands: text, then a formatChange with language=de, then more text. + field = textInfos.FormatField() + field["language"] = "de" + commands = [ + "pre ", + textInfos.FieldCommand(command="formatChange", field=field), + "post", + ] + + class FakeObj: + _brailleFormatFieldAttributesCache: dict = {} + + class FakeInfo: + isCollapsed = False + obj = FakeObj() + + def getTextWithFields(self, formatConfig=None): + return commands + + formatConfig = { + "reportClickable": False, + } + # Stub helpers that would otherwise require a real NVDA environment. + with ( + patch("braille.getFormatFieldBraille", return_value=""), + patch.object( + braille.TextInfoRegion, + "_getTypeformFromFormatField", + return_value=0, + ), + ): + region._addTextWithFields(FakeInfo(), formatConfig) + # The language switch should have been recorded at len("pre ") == 4. + self.assertIn(4, region._languageIndexes) + self.assertEqual(region._languageIndexes[4], "de") + + def test_textInfoRegion_update_resetsLanguageIndexes(self): + """TextInfoRegion.update resets _languageIndexes to {0: default} — no stale indexes carry across updates.""" + region = _makeTextInfoRegion() + # Pollute _languageIndexes with stale entries. + region._languageIndexes = {0: "en", 10: "de", 30: "en"} + + # Run only the resetting portion of `update` by making `_getSelection` raise immediately, + # then inspect state. The reset happens before `_getSelection` is called. + # Using side_effect to halt execution mid-method avoids needing the full NVDA environment + # that the rest of update() requires. + with ( + patch.object(braille.TextInfoRegion, "_getDefaultRegionLanguage", return_value="en"), + patch.object( + braille.TextInfoRegion, + "_getReadingUnit", + return_value=textInfos.UNIT_LINE, + ), + patch.object( + braille.TextInfoRegion, + "_getSelection", + side_effect=RuntimeError("stop-after-reset"), + ), + ): + with self.assertRaises(RuntimeError): + region.update() + self.assertEqual(region._languageIndexes, {0: "en"}) diff --git a/tests/unit/test_braille/test_windowBrailleCells.py b/tests/unit/test_braille/test_windowBrailleCells.py new file mode 100644 index 00000000000..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/tests/unit/test_hyphenation.py b/tests/unit/test_hyphenation.py new file mode 100644 index 00000000000..215bfb66cfa --- /dev/null +++ b/tests/unit/test_hyphenation.py @@ -0,0 +1,33 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +"""Unit tests for textUtils.hyphenation.""" + +import unittest + +from textUtils import hyphenation + + +class TestGetHyphenPositions(unittest.TestCase): + def test_knownLanguage(self): + """#15: Known language returns a non-empty tuple of ints within range(len(text)).""" + text = "hyphenation" + positions = hyphenation.getHyphenPositions(text, "en_US") + self.assertIsInstance(positions, tuple) + self.assertGreater(len(positions), 0) + for pos in positions: + self.assertIsInstance(pos, int) + self.assertGreaterEqual(pos, 0) + self.assertLess(pos, len(text)) + + def test_unknownLanguage_returnsEmptyTuple(self): + """#16: Unknown language returns an empty tuple and does not raise.""" + # Purge any cached "already logged" state so this test is order-independent. + hyphenation._loggedUnknownLocales.discard("zz_ZZ") + positions = hyphenation.getHyphenPositions("anything", "zz_ZZ") + self.assertEqual(positions, ()) + # Calling again should still return () and not raise. + positions = hyphenation.getHyphenPositions("anything", "zz_ZZ") + self.assertEqual(positions, ()) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index b9b38b1655d..e9b88b261e6 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -32,6 +32,9 @@ The triple-press keyboard shortcut (`NVDA+ctrl+r`) is not affected, as it is int * DotPad braille displays now support multi-button combination gestures. (#19565, @bramd) * You can now press multiple buttons simultaneously to create custom gestures (e.g., `f1+panLeft`). * A new voice setting "Natural pause after punctuation" was added for OneCore voices, allowing users to turn punctuation pauses on or off. (#11876, @gexgd0419) +* The braille "word wrap" option has been replaced with a four-valued "Text wrap" option: Off, Show mark when words are cut, At word boundaries, and At word or syllable boundaries. (#17010, @LeonarddeR) + * When a word is cut across rows, the last cell of the row now shows a continuation mark (braille dots 7-8) so it is clear that the word continues on the next row. + * The "At word or syllable boundaries" option uses hyphenation dictionaries to split long words at syllable boundaries when they do not fit on the display. ### Changes @@ -109,6 +112,8 @@ Use the individual test commands instead: `runcheckpot.bat`, `rununittests.bat`, #### Deprecations +* The `config.configFlags.BrailleTextWrap` IntEnum has been replaced with `config.featureFlagEnums.BrailleTextWrapFlag`, stored via the `featureFlag` config spec. (#17010, @LeonarddeR) +* The `braille.wordWrap` configuration key is deprecated and bridged to `braille.textWrap`. (#17010, @LeonarddeR) * The `speechDictHandler.ENTRY_TYPE_*` constants are deprecated. Use the `speechDictHandler.types.EntryType` enumeration instead. (#19430, @LeonarddeR) * `speechDictHandler.SpeechDictEntry` and `speechDictHandler.SpeechDict` have been moved to `speechDictHandler.types`. (#19430, @LeonarddeR) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 98be0703093..9d00eb2bcb1 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2577,18 +2577,25 @@ 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. +* At word or syllable boundaries: Like "At word boundaries", but long words that don't fit are split at a syllable boundary when possible, using the language of the word if known. +For example, the word `behave` may be split between `be` and `have`. +The continuation mark is shown whenever a word is split. ##### Unicode normalization {#BrailleUnicodeNormalization} diff --git a/uv.lock b/uv.lock index 42151a8e9c2..b6ab324f8c0 100644 --- a/uv.lock +++ b/uv.lock @@ -552,6 +552,7 @@ dependencies = [ { name = "nh3", marker = "sys_platform == 'win32'" }, { name = "pycaw", marker = "sys_platform == 'win32'" }, { name = "pymdown-extensions", marker = "sys_platform == 'win32'" }, + { name = "pyphen", marker = "sys_platform == 'win32'" }, { name = "pyserial", marker = "sys_platform == 'win32'" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "regex", marker = "sys_platform == 'win32'" }, @@ -612,6 +613,7 @@ requires-dist = [ { name = "nh3", specifier = "==0.3.2" }, { name = "pycaw", specifier = "==20251023" }, { name = "pymdown-extensions", specifier = "==10.17.1" }, + { name = "pyphen", specifier = ">=0.17.2" }, { name = "pyserial", specifier = "==3.5" }, { name = "pywin32", specifier = "==311" }, { name = "regex", specifier = "==2026.4.4" }, @@ -863,6 +865,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, ] +[[package]] +name = "pyphen" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" }, +] + [[package]] name = "pyrect" version = "0.2.0"