Skip to content

Commit e080142

Browse files
committed
feat: add braille text wrap modes with continuation marks
Replaces boolean wordWrap config with BrailleTextWrapFlag feature flag (NONE, MARK_WORD_CUTS, AT_WORD_BOUNDARIES, AT_WORD_OR_SYLLABLE_BOUNDARIES). Adds profile upgrade step v22->v23, deprecated API bridge, GUI combo box, continuation mark (dots 7-8) when a word is cut mid-display, and _continuationRows tracking in BrailleBuffer. Syllable boundary mode is wired but its backend follows in a separate PR.
1 parent 797a881 commit e080142

10 files changed

Lines changed: 313 additions & 61 deletions

File tree

source/braille.py

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@
5252
OutputMode,
5353
ReportSpellingErrors,
5454
)
55-
from config.featureFlagEnums import ReviewRoutingMovesSystemCaretFlag, FontFormattingBrailleModeFlag
55+
from config.featureFlagEnums import (
56+
BrailleTextWrapFlag,
57+
FontFormattingBrailleModeFlag,
58+
ReviewRoutingMovesSystemCaretFlag,
59+
)
5660
from logHandler import log
5761
import controlTypes
5862
import api
@@ -326,6 +330,7 @@
326330
(0xFF, _("All dots")),
327331
)
328332
SELECTION_SHAPE = 0xC0 #: Dots 7 and 8
333+
CONTINUATION_SHAPE = 0xC0 #: Dots 7 and 8
329334

330335
END_OF_BRAILLE_OUTPUT_SHAPE = 0xFF # All dots
331336
"""
@@ -1385,7 +1390,12 @@ def _getTypeformFromFormatField(self, field, formatConfig):
13851390
typeform |= louis.underline
13861391
return typeform
13871392

1388-
def _addFieldText(self, text, contentPos, separate=True):
1393+
def _addFieldText(
1394+
self,
1395+
text: str,
1396+
contentPos: int,
1397+
separate: bool = True,
1398+
):
13891399
if separate and self.rawText:
13901400
# Separate this field text from the rest of the text.
13911401
text = TEXT_SEPARATOR + text
@@ -1813,10 +1823,12 @@ def rindex(seq, item, start, end):
18131823

18141824

18151825
class BrailleBuffer(baseObject.AutoPropertyObject):
1826+
handler: "BrailleHandler"
1827+
regions: list[Region]
1828+
"""The regions in this buffer."""
1829+
18161830
def __init__(self, handler):
18171831
self.handler = handler
1818-
#: The regions in this buffer.
1819-
#: @type: [L{Region}, ...]
18201832
self.regions = []
18211833
#: The raw text of the entire buffer.
18221834
self.rawText = ""
@@ -1832,6 +1844,8 @@ def __init__(self, handler):
18321844
each item being a tuple of start and end braille buffer offsets.
18331845
Splitting the window into independent rows allows for optional avoidance of splitting words across rows.
18341846
"""
1847+
self._continuationRows: list[int] = []
1848+
"""A list of row indexes which should contain a continuation indicator at the end."""
18351849

18361850
def clear(self):
18371851
"""Clear the entire buffer.
@@ -1860,35 +1874,45 @@ def _get_regionsWithPositions(self):
18601874
yield RegionWithPositions(region, start, end)
18611875
start = end
18621876

1863-
def _get_rawToBraillePos(self):
1864-
"""@return: a list mapping positions in L{rawText} to positions in L{brailleCells} for the entire buffer.
1865-
@rtype: [int, ...]
1866-
"""
1877+
rawToBraillePos: list[int]
1878+
"""Type definition for auto prop '_get_rawToBraillePos'"""
1879+
1880+
def _get_rawToBraillePos(self) -> list[int]:
1881+
""":return: a list mapping positions in L{rawText} to positions in L{brailleCells} for the entire buffer."""
18671882
rawToBraillePos = []
18681883
for region, regionStart, regionEnd in self.regionsWithPositions:
18691884
rawToBraillePos.extend(p + regionStart for p in region.rawToBraillePos)
18701885
return rawToBraillePos
18711886

1872-
brailleToRawPos: List[int]
1887+
brailleToRawPos: list[int]
1888+
"""Type definition for auto prop '_get_brailleToRawPos'"""
18731889

1874-
def _get_brailleToRawPos(self):
1875-
"""@return: a list mapping positions in L{brailleCells} to positions in L{rawText} for the entire buffer.
1876-
@rtype: [int, ...]
1877-
"""
1890+
def _get_brailleToRawPos(self) -> list[int]:
1891+
""":return: a list mapping positions in L{brailleCells} to positions in L{rawText} for the entire buffer."""
18781892
brailleToRawPos = []
18791893
start = 0
18801894
for region in self.visibleRegions:
18811895
brailleToRawPos.extend(p + start for p in region.brailleToRawPos)
18821896
start += len(region.rawText)
18831897
return brailleToRawPos
18841898

1885-
def bufferPosToRegionPos(self, bufferPos):
1899+
def bufferPosToRegionPos(self, bufferPos: int) -> tuple[Region, int]:
1900+
"""Converts a position relative to the braille buffer to a position relative to the region it is in.
1901+
:param bufferPos: The position relative to the braille buffer.
1902+
:return: A tuple of the region and the position relative to that region.
1903+
"""
18861904
for region, start, end in self.regionsWithPositions:
18871905
if end > bufferPos:
18881906
return region, bufferPos - start
18891907
raise LookupError("No such position")
18901908

1891-
def regionPosToBufferPos(self, region, pos, allowNearest=False):
1909+
def regionPosToBufferPos(self, region: Region, pos: int, allowNearest: bool = False) -> int:
1910+
"""Converts a position relative to a region to a position relative to the braille buffer.
1911+
:param region: The region the position is relative to.
1912+
:param pos: The position relative to the region.
1913+
: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.
1914+
:return: The position relative to the braille buffer.
1915+
"""
18921916
start: int = 0
18931917
for testRegion, start, end in self.regionsWithPositions:
18941918
if region == testRegion:
@@ -1905,7 +1929,13 @@ def regionPosToBufferPos(self, region, pos, allowNearest=False):
19051929
return start
19061930
raise LookupError("No such position")
19071931

1908-
def bufferPositionsToRawText(self, startPos, endPos):
1932+
def bufferPositionsToRawText(self, startPos: int, endPos: int) -> str:
1933+
"""
1934+
Converts a range of positions in the braille buffer to the corresponding raw text.
1935+
:param startPos: The start position in the braille buffer.
1936+
:param endPos: The end position in the braille buffer.
1937+
:return: The corresponding raw text.
1938+
"""
19091939
brailleToRawPos = self.brailleToRawPos
19101940
if not brailleToRawPos or not self.rawText:
19111941
# if either are empty, just return an empty string.
@@ -1927,6 +1957,11 @@ def bufferPositionsToRawText(self, startPos, endPos):
19271957
return ""
19281958

19291959
def bufferPosToWindowPos(self, bufferPos: int) -> int:
1960+
"""
1961+
Converts a position relative to the braille buffer to a position relative to the braille window.
1962+
:param bufferPos: The position relative to the braille buffer.
1963+
:return: The position relative to the braille window.
1964+
"""
19301965
for row, (start, end) in enumerate(self._windowRowBufferOffsets):
19311966
if start <= bufferPos < end:
19321967
return row * self.handler.displayDimensions.numCols + (bufferPos - start)
@@ -1957,32 +1992,47 @@ def _set_windowStartPos(self, pos: int) -> None:
19571992
def _calculateWindowRowBufferOffsets(self, pos: int) -> None:
19581993
"""
19591994
Calculates the start and end positions of each row in the braille window.
1960-
Ensures that words are not split across rows when word wrap is enabled.
1995+
Ensures that words are not split across rows when text wrap is enabled.
19611996
Ensures that the window does not extend past the end of the braille buffer.
19621997
:param pos: The start position of the braille window.
19631998
"""
19641999
self._windowRowBufferOffsets.clear()
2000+
self._continuationRows.clear()
19652001
if len(self.brailleCells) == 0:
19662002
# Initialising with no actual braille content.
19672003
self._windowRowBufferOffsets = [(0, 0)]
19682004
return
1969-
doWordWrap = config.conf["braille"]["wordWrap"]
2005+
textWrap: BrailleTextWrapFlag = config.conf["braille"]["textWrap"].calculated()
19702006
bufferEnd = len(self.brailleCells)
19712007
start = pos
19722008
clippedEnd = False
19732009
for row in range(self.handler.displayDimensions.numRows):
2010+
showContinuationMark = False
19742011
end = start + self.handler.displayDimensions.numCols
19752012
if end > bufferEnd:
19762013
end = bufferEnd
19772014
clippedEnd = True
1978-
elif doWordWrap:
2015+
elif (
2016+
textWrap == BrailleTextWrapFlag.MARK_WORD_CUTS
2017+
and end < bufferEnd
2018+
and all(self.brailleCells[end - 1 : end + 1])
2019+
):
2020+
end -= 1
2021+
showContinuationMark = True
2022+
elif textWrap == BrailleTextWrapFlag.AT_WORD_BOUNDARIES:
19792023
try:
19802024
lastSpaceIndex = rindex(self.brailleCells, 0, start, end + 1)
19812025
if lastSpaceIndex < end:
19822026
# The next braille window doesn't start with space.
19832027
end = rindex(self.brailleCells, 0, start, end) + 1
19842028
except (ValueError, IndexError):
1985-
pass # No space on line
2029+
# No space on line - fall back to display-edge cut.
2030+
if all(self.brailleCells[end - 1 : end + 1]):
2031+
if end - start == self.handler.displayDimensions.numCols and end < bufferEnd:
2032+
end -= 1
2033+
showContinuationMark = True
2034+
if showContinuationMark:
2035+
self._continuationRows.append(len(self._windowRowBufferOffsets))
19862036
self._windowRowBufferOffsets.append((start, end))
19872037
if clippedEnd:
19882038
break
@@ -2001,7 +2051,7 @@ def _set_windowEndPos(self, endPos: int) -> None:
20012051
2. Whether one of the regions should be shown hard left on the braille display;
20022052
i.e. because of The configuration setting for focus context representation
20032053
or whether the braille region that corresponds with the focus represents a multi line edit box.
2004-
3. Whether word wrap is enabled."""
2054+
3. Whether text wrap is enabled."""
20052055
startPos = endPos - self.handler.displaySize
20062056
# Loop through the currently displayed regions in reverse order
20072057
# If focusToHardLeft is set for one of the regions, the display shouldn't scroll further back than the start of that region
@@ -2022,7 +2072,10 @@ def _set_windowEndPos(self, endPos: int) -> None:
20222072
if startPos <= restrictPos:
20232073
self.windowStartPos = restrictPos
20242074
return
2025-
if not config.conf["braille"]["wordWrap"]:
2075+
if config.conf["braille"]["textWrap"].calculated() in (
2076+
BrailleTextWrapFlag.NONE,
2077+
BrailleTextWrapFlag.MARK_WORD_CUTS,
2078+
):
20262079
self.windowStartPos = startPos
20272080
return
20282081
try:
@@ -2037,7 +2090,7 @@ def _set_windowEndPos(self, endPos: int) -> None:
20372090
break
20382091
except ValueError:
20392092
pass
2040-
# When word wrap is enabled, the first block of spaces may be removed from the current window.
2093+
# When text wrap is enabled, the first block of spaces may be removed from the current window.
20412094
# This may prevent displaying the start of paragraphs.
20422095
paragraphStartMarker = getParagraphStartMarker()
20432096
if paragraphStartMarker and self.regions[-1].rawText.startswith(
@@ -2144,9 +2197,12 @@ def _get_windowRawText(self):
21442197

21452198
def _get_windowBrailleCells(self) -> list[int]:
21462199
windowCells = []
2147-
for start, end in self._windowRowBufferOffsets:
2200+
for row, (start, end) in enumerate(self._windowRowBufferOffsets):
21482201
rowCells = self.brailleCells[start:end]
21492202
remaining = self.handler.displayDimensions.numCols - len(rowCells)
2203+
if remaining > 0 and row in self._continuationRows:
2204+
rowCells.append(CONTINUATION_SHAPE)
2205+
remaining -= 1
21502206
if remaining > 0:
21512207
rowCells.extend([0] * remaining)
21522208
windowCells.extend(rowCells)

source/config/__init__.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from collections.abc import Collection
1414
from enum import Enum
1515
from typing import Any
16+
from addonAPIVersion import BACK_COMPAT_TO
1617

1718
import globalVars
1819
import winreg
@@ -39,6 +40,7 @@
3940
from . import profileUpgrader
4041
from . import aggregatedSection
4142
from .configSpec import confspec
43+
from .featureFlagEnums import BrailleTextWrapFlag
4244
from .featureFlag import (
4345
_transformSpec_AddFeatureFlagDefault,
4446
_validateConfig_featureFlag,
@@ -373,7 +375,7 @@ def _setSystemConfig(
373375
else:
374376
relativePath = os.path.relpath(curSourceDir, fromPath)
375377
curDestDir = os.path.join(toPath, relativePath)
376-
if not isMigration and relativePath.casefold() == "addons":
378+
if not isMigration and relativePath == "addons":
377379
_prepareToCopyAddons(fromPath, toPath, subDirs, addonsToCopy)
378380
if not os.path.isdir(curDestDir):
379381
os.makedirs(curDestDir)
@@ -1381,14 +1383,14 @@ def __setitem__(
13811383

13821384
# Alias old config items to their new counterparts for backwards compatibility.
13831385
# Uncomment when there are new links that need to be made.
1384-
# if BACK_COMPAT_TO < (2027, 1, 0) and NVDAState._allowDeprecatedAPI():
1385-
# self._linkDeprecatedValues(key, val)
1386+
if BACK_COMPAT_TO < (2027, 1, 0) and NVDAState._allowDeprecatedAPI():
1387+
self._linkDeprecatedValues(key, val)
13861388

13871389
def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregatedSection._cacheValueT):
13881390
"""Link deprecated config keys and values to their replacements.
13891391
1390-
:arg key: The configuration key to link to its new or old counterpart.
1391-
:arg val: The value associated with the configuration key.
1392+
:param key: The configuration key to link to its new or old counterpart.
1393+
:param val: The value associated with the configuration key.
13921394
13931395
Example of how to link values:
13941396
@@ -1409,15 +1411,43 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat
14091411
>>> return
14101412
>>> ...
14111413
"""
1414+
# cacheVal defaults to val; overridden when profile and cache need different types.
1415+
cacheVal = val
14121416
match self.path:
1417+
case "braille":
1418+
match key:
1419+
case "wordWrap":
1420+
# The "wordWrap" setting was renamed to "textWrap" and became a feature flag.
1421+
log.warning(
1422+
"braille.wordWrap is deprecated. Use braille.textWrap instead.",
1423+
stack_info=True,
1424+
)
1425+
key = "textWrap"
1426+
flagEnum = BrailleTextWrapFlag.AT_WORD_BOUNDARIES if val else BrailleTextWrapFlag.NONE
1427+
# Profile stores strings; cache must hold a validated FeatureFlag object
1428+
# (matching what __setitem__ normally stores) so .calculated() works on next read.
1429+
# Validate through the spec to avoid hardcoding behaviorOfDefault here.
1430+
val = flagEnum.name
1431+
cacheVal = self.manager.validator.check(self._spec[key], val)
1432+
case "textWrap":
1433+
# The "textWrap" setting was added in place of "wordWrap" and became a feature flag.
1434+
key = "wordWrap"
1435+
calculated: BrailleTextWrapFlag = val.calculated()
1436+
val = calculated == BrailleTextWrapFlag.AT_WORD_BOUNDARIES
1437+
cacheVal = val
1438+
1439+
case _:
1440+
# We don't care about other keys in this section.
1441+
return
1442+
14131443
case _:
14141444
# We don't care about other sections.
14151445
return
14161446

14171447
# Update the value in the most recently activated profile.
14181448
# If we have reached this point, we must have a new key and value to set.
14191449
self._getUpdateSection()[key] = val
1420-
self._cache[key] = val
1450+
self._cache[key] = cacheVal
14211451

14221452
def _getUpdateSection(self):
14231453
profile = self.profiles[-1]

source/config/configSpec.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
#: provide an upgrade step (@see profileUpgradeSteps.py). An upgrade step does not need to be added when
1414
#: just adding a new element to (or removing from) the schema, only when old versions of the config
1515
#: (conforming to old schema versions) will not work correctly with the new schema.
16-
latestSchemaVersion = 22
16+
latestSchemaVersion = 23
1717

1818
#: The configuration specification string
1919
#: @type: String
@@ -91,7 +91,9 @@
9191
optionsEnum="ReviewRoutingMovesSystemCaretFlag", behaviorOfDefault="NEVER")
9292
readByParagraph = boolean(default=false)
9393
paragraphStartMarker = option("", " ", "¶", default="")
94+
# Deprecated in 2026.3
9495
wordWrap = boolean(default=true)
96+
textWrap = featureFlag(optionsEnum="BrailleTextWrapFlag", behaviorOfDefault="AT_WORD_BOUNDARIES")
9597
unicodeNormalization = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="disabled")
9698
focusContextPresentation = option("changedContext", "fill", "scroll", default="changedContext")
9799
interruptSpeechWhileScrolling = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled")

source/config/featureFlagEnums.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,30 @@ def _displayStringLabels(self) -> dict["FontFormattingBrailleModeFlag", str]:
139139
}
140140

141141

142+
class BrailleTextWrapFlag(DisplayStringEnum):
143+
"""Enumeration containing the possible ways to wrap text in braille when a row would exceed the display.
144+
145+
The continuation mark (dots 7-8) is shown on rows where a word was cut,
146+
regardless of mode (except for NONE, which never shows the mark).
147+
"""
148+
149+
@property
150+
def _displayStringLabels(self):
151+
return {
152+
# Translators: A choice in a combo box in the braille settings panel to configure text wrapping.
153+
self.NONE: pgettext("braille text wrap", "Off"),
154+
# Translators: A choice in a combo box in the braille settings panel to configure text wrapping.
155+
self.MARK_WORD_CUTS: pgettext("braille text wrap", "Show mark when words are cut"),
156+
# Translators: A choice in a combo box in the braille settings panel to configure text wrapping.
157+
self.AT_WORD_BOUNDARIES: pgettext("braille text wrap", "At word boundaries"),
158+
}
159+
160+
DEFAULT = enum.auto()
161+
NONE = enum.auto()
162+
MARK_WORD_CUTS = enum.auto()
163+
AT_WORD_BOUNDARIES = enum.auto()
164+
165+
142166
def getAvailableEnums() -> typing.Generator[typing.Tuple[str, FlagValueEnum], None, None]:
143167
for name, value in globals().items():
144168
if (

0 commit comments

Comments
 (0)