diff --git a/source/braille.py b/source/braille.py index 1610df749c5..681d8e35cef 100644 --- a/source/braille.py +++ b/source/braille.py @@ -1,9 +1,10 @@ # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. -# Copyright (C) 2008-2025 NV Access Limited, Joseph Lee, Babbage B.V., Davy Kager, Bram Duvigneau, +# Copyright (C) 2008-2026 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 dataclasses import itertools @@ -35,10 +36,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 @@ -549,49 +553,50 @@ class Region(object): """A region of braille to be displayed. Each portion of braille to be displayed is represented by a region. The region is responsible for retrieving its text and the cursor and selection positions, translating it into braille cells and handling cursor routing requests relative to its braille cells. - The L{BrailleBuffer} containing this region will call L{update} and expect that L{brailleCells}, L{brailleCursorPos}, L{brailleSelectionStart} and L{brailleSelectionEnd} will be set appropriately. - L{routeTo} will be called to handle a cursor routing request. + The :class:`BrailleBuffer` containing this region will call :meth:`update` and expect that :attr:`brailleCells`, :attr:`brailleCursorPos`, :attr:`brailleSelectionStart` and :attr:`brailleSelectionEnd` will be set appropriately. + :meth:`routeTo` will be called to handle a cursor routing request. """ + rawText: str = "" + """The original, raw text of this region.""" + cursorPos: int | None = None + """The position of the cursor in :attr:`rawText`, ``None`` if the cursor is not in this region.""" + selectionStart: int | None = None + """The start of the selection in :attr:`rawText` (inclusive), ``None`` if there is no selection in this region.""" + selectionEnd: int | None = None + """The end of the selection in :attr:`rawText` (exclusive), ``None`` if there is no selection in this region.""" + rawTextTypeforms: list[int] | None = None + """liblouis typeform flags for each character in :attr:`rawText`, ``None`` if no typeform info.""" + brailleCursorPos: int | None = None + """The position of the cursor in :attr:`brailleCells`, ``None`` if the cursor is not in this region.""" + brailleSelectionStart: int | None = None + """The position of the selection start in :attr:`brailleCells`, ``None`` if there is no selection in this region.""" + brailleSelectionEnd: int | None = None + """The position of the selection end in :attr:`brailleCells`, ``None`` if there is no selection in this region.""" + hidePreviousRegions: bool = False + """Whether to hide all previous regions.""" + focusToHardLeft: bool = False + """Whether this region should be positioned at the absolute left of the display when focused.""" + def __init__(self): - #: The original, raw text of this region. - self.rawText = "" - #: The position of the cursor in L{rawText}, C{None} if the cursor is not in this region. - #: @type: int - self.cursorPos = None - #: The start of the selection in L{rawText} (inclusive), C{None} if there is no selection in this region. - #: @type: int - self.selectionStart = None - #: The end of the selection in L{rawText} (exclusive), C{None} if there is no selection in this region. - #: @type: int - self.selectionEnd = None - #: The translated braille representation of this region. - #: @type: [int, ...] - self.brailleCells = [] - #: liblouis typeform flags for each character in L{rawText}, - #: C{None} if no typeform info. - #: @type: [int, ...] - self.rawTextTypeforms = None - #: A list mapping positions in L{rawText} to positions in L{brailleCells}. - #: @type: [int, ...] - self.rawToBraillePos = [] - #: A list mapping positions in L{brailleCells} to positions in L{rawText}. - #: @type: [int, ...] - self.brailleToRawPos = [] - #: The position of the cursor in L{brailleCells}, C{None} if the cursor is not in this region. - self.brailleCursorPos: Optional[int] = None - #: The position of the selection start in L{brailleCells}, C{None} if there is no selection in this region. - #: @type: int - self.brailleSelectionStart = None - #: The position of the selection end in L{brailleCells}, C{None} if there is no selection in this region. - #: @type: int - self.brailleSelectionEnd = None - #: Whether to hide all previous regions. - #: @type: bool - self.hidePreviousRegions = False - #: Whether this region should be positioned at the absolute left of the display when focused. - #: @type: bool - self.focusToHardLeft = False + self._languageIndexes: dict[int, str] = {0: self._getDefaultRegionLanguage()} + """Language indexes in :attr:`rawText`. The last language is assumed to be the final language in the region.""" + self.brailleCells: list[int] = [] + """The translated braille representation of this region.""" + self.rawToBraillePos: list[int] = [] + """A list mapping positions in :attr:`rawText` to positions in :attr:`brailleCells`.""" + self.brailleToRawPos: list[int] = [] + """A list mapping positions in :attr:`brailleCells` to positions in :attr:`rawText`.""" + + 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 :attr:`rawText` based on :attr:`_languageIndexes`.""" + keys = sorted(self._languageIndexes) + i = bisect.bisect_right(keys, pos) - 1 + return self._languageIndexes[keys[i]] def update(self): """Update this region. @@ -1400,8 +1405,15 @@ def _addFieldText( 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 + self._languageIndexes[rawTextLen + textLen] = lastLanguage + self.rawText += text self.rawTextTypeforms.extend((louis.plain_text,) * textLen) self._rawToContentPos.extend((contentPos,) * textLen) @@ -1454,16 +1466,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 @@ -1531,6 +1548,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 = [] @@ -1917,6 +1935,11 @@ def bufferPosToRegionPos(self, bufferPos: int) -> tuple[Region, int]: return region, bufferPos - start raise LookupError("No such position") + def _getLanguageAtBufferPos(self, pos: int) -> str: + """Gets the language at the given braille buffer 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. @@ -2029,13 +2052,32 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None: elif textWrap == BrailleTextWrapFlag.MARK_WORD_CUTS and self._isMidWordCut(end, bufferEnd): end -= 1 showContinuationMark = True - elif textWrap == BrailleTextWrapFlag.AT_WORD_BOUNDARIES: + 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: # lastSpaceIndex < end proves brailleCells[end] is non-zero, # so searching [start, end) yields the same lastSpaceIndex. + oldEnd = end end = lastSpaceIndex + 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: + end = newEnd + showContinuationMark = True + break except (ValueError, IndexError): # No space on line - fall back to display-edge cut. if self._isMidWordCut(end, bufferEnd): diff --git a/source/config/featureFlagEnums.py b/source/config/featureFlagEnums.py index d9fa68b7ae1..92ce157e818 100644 --- a/source/config/featureFlagEnums.py +++ b/source/config/featureFlagEnums.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022 NV Access Limited, Bill Dengler, Rob Meredith +# Copyright (C) 2022-2026 NV Access Limited, Bill Dengler, Rob Meredith, Leonard de Ruijter # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -150,6 +150,7 @@ class BrailleTextWrapFlag(DisplayStringEnum): NONE = enum.auto() MARK_WORD_CUTS = enum.auto() AT_WORD_BOUNDARIES = enum.auto() + AT_WORD_OR_SYLLABLE_BOUNDARIES = enum.auto() @property def _displayStringLabels(self): @@ -160,6 +161,11 @@ def _displayStringLabels(self): 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", + ), } diff --git a/source/louisHelper.py b/source/louisHelper.py index d59b6730b60..2cc064260b3 100644 --- a/source/louisHelper.py +++ b/source/louisHelper.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) 2018-2025 NV Access Limited, Babbage B.V., Julien Cochuyt, Leonard de Ruijter +# Copyright (C) 2018-2026 NV Access Limited, Babbage B.V., Julien Cochuyt, Leonard de Ruijter """Helper module to ease communication to and from liblouis.""" @@ -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 None diff --git a/source/setup.py b/source/setup.py index d2eab6149d1..7d28bf70f0b 100755 --- a/source/setup.py +++ b/source/setup.py @@ -383,7 +383,6 @@ def _genManifestTemplate(shouldHaveUIAccess: bool) -> tuple[int, int, bytes]: "brailleDisplayDrivers.eurobraille", "brailleDisplayDrivers.dotPad", "synthDrivers", - "textUtils", "visionEnhancementProviders", # Required for markdown, markdown implicitly imports this so it isn't picked up "html.parser", diff --git a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py index 46e762ce8b2..b3f0910f0cb 100644 --- a/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py +++ b/tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py @@ -6,6 +6,7 @@ """Unit tests for the _calculateWindowRowBufferOffsets function in the braille module.""" import unittest +from unittest.mock import patch import braille import config @@ -42,6 +43,9 @@ def setUp(self): def tearDown(self): braille.filter_displayDimensions.unregister(_getDisplayDimensions) _setTextWrap(BrailleTextWrapFlag.NONE) + # Remove instance-level overrides of auto-properties set by syllable-boundary tests. + for attr in ("rawToBraillePos", "brailleToRawPos"): + braille.handler.buffer.__dict__.pop(attr, None) def test_noCells(self): """Check that, if list of braille cells is empty, offsets will be (0, 0, False).""" @@ -144,3 +148,112 @@ def test_atWordBoundaries_noSpaceInWindowMarksCut(self): braille._WindowRowPositions(0, 19, True), ) self.assertTrue(braille.handler.buffer._windowRowBufferOffsets[0].showContinuationMark) + + 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], + braille._WindowRowPositions(0, 14, True), + ) + self.assertTrue(braille.handler.buffer._windowRowBufferOffsets[0].showContinuationMark) + + 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], + braille._WindowRowPositions(0, 16, False), + ) + self.assertFalse(braille.handler.buffer._windowRowBufferOffsets[0].showContinuationMark) + + 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], + braille._WindowRowPositions(0, 16, False), + ) + self.assertFalse(braille.handler.buffer._windowRowBufferOffsets[0].showContinuationMark) + + 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], + braille._WindowRowPositions(0, 16, False), + ) + self.assertFalse(braille.handler.buffer._windowRowBufferOffsets[0].showContinuationMark) diff --git a/tests/unit/test_braille/test_regionLanguageIndexes.py b/tests/unit/test_braille/test_regionLanguageIndexes.py new file mode 100644 index 00000000000..ca4a7077e2b --- /dev/null +++ b/tests/unit/test_braille/test_regionLanguageIndexes.py @@ -0,0 +1,131 @@ +# 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 + + +class _FakeObj: + _brailleFormatFieldAttributesCache: dict = {} + + +class _FakeInfo: + isCollapsed = False + obj = _FakeObj() + + def __init__(self, commands): + self._commands = commands + + def getTextWithFields(self, formatConfig=None): + return self._commands + + +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", + ] + + 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(commands), 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/user_docs/en/changes.md b/user_docs/en/changes.md index 4b17f666a29..acfe99cd5a4 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -6,8 +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) +* 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) * 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. + * 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 diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index ab94f5e62c9..f9e5a2ec24c 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2600,6 +2600,9 @@ Instead, there will be some blank space at the end of the display. When you scroll the display, you will be able to read the entire word. Note that if the word is too large to fit on the display even by itself, the word must still be split; 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}