Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 84 additions & 33 deletions source/braille.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@
OutputMode,
ReportSpellingErrors,
)
from config.featureFlagEnums import ReviewRoutingMovesSystemCaretFlag, FontFormattingBrailleModeFlag
from config.featureFlagEnums import (
BrailleTextWrapFlag,
FontFormattingBrailleModeFlag,
ReviewRoutingMovesSystemCaretFlag,
)
from logHandler import log
import controlTypes
import api
Expand Down Expand Up @@ -326,6 +330,7 @@
(0xFF, _("All dots")),
)
SELECTION_SHAPE = 0xC0 #: Dots 7 and 8
CONTINUATION_SHAPE = 0xC0 #: Dots 7 and 8
Comment thread
LeonarddeR marked this conversation as resolved.
Comment thread
LeonarddeR marked this conversation as resolved.

END_OF_BRAILLE_OUTPUT_SHAPE = 0xFF # All dots
"""
Expand Down Expand Up @@ -1385,7 +1390,12 @@ def _getTypeformFromFormatField(self, field, formatConfig):
typeform |= louis.underline
return typeform

def _addFieldText(self, text, contentPos, separate=True):
def _addFieldText(
self,
text: str,
contentPos: int,
separate: bool = True,
):
if separate and self.rawText:
# Separate this field text from the rest of the text.
text = TEXT_SEPARATOR + text
Expand Down Expand Up @@ -1813,10 +1823,12 @@ def rindex(seq, item, start, end):


class BrailleBuffer(baseObject.AutoPropertyObject):
handler: "BrailleHandler"
regions: list[Region]
"""The regions in this buffer."""

def __init__(self, handler):
self.handler = handler
#: The regions in this buffer.
#: @type: [L{Region}, ...]
self.regions = []
#: The raw text of the entire buffer.
self.rawText = ""
Expand All @@ -1826,10 +1838,10 @@ def __init__(self, handler):
#: The translated braille representation of the entire buffer.
#: @type: [int, ...]
self.brailleCells = []
self._windowRowBufferOffsets: list[tuple[int, int]] = [(0, 0)]
self._windowRowBufferOffsets: list[tuple[int, int, bool]] = [(0, 0, False)]
Comment thread
LeonarddeR marked this conversation as resolved.
Outdated
"""
A list representing the rows in the braille window,
each item being a tuple of start and end braille buffer offsets.
each item being a tuple of start and end braille buffer offsets and a bool indicating whether a continuation mark should appear.
Splitting the window into independent rows allows for optional avoidance of splitting words across rows.
"""

Expand Down Expand Up @@ -1860,35 +1872,45 @@ 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:
brailleToRawPos.extend(p + start for p in region.brailleToRawPos)
start += len(region.rawText)
return brailleToRawPos

def bufferPosToRegionPos(self, bufferPos):
def bufferPosToRegionPos(self, bufferPos: int) -> tuple[Region, int]:
"""Converts a position relative to the braille buffer to a position relative to the region it is in.
:param bufferPos: The position relative to the braille buffer.
:return: A tuple of the region and the position relative to that region.
"""
for region, start, end in self.regionsWithPositions:
if end > bufferPos:
return region, bufferPos - start
raise LookupError("No such position")

def regionPosToBufferPos(self, region, pos, allowNearest=False):
def regionPosToBufferPos(self, region: Region, pos: int, allowNearest: bool = False) -> int:
"""Converts a position relative to a region to a position relative to the braille buffer.
:param region: The region the position is relative to.
:param pos: The position relative to the region.
:param allowNearest: If True, if the position is outside the region, return the nearest position within the region. If False, raise LookupError if the position is outside the region.
:return: The position relative to the braille buffer.
"""
start: int = 0
for testRegion, start, end in self.regionsWithPositions:
if region == testRegion:
Expand All @@ -1905,7 +1927,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.
Expand All @@ -1927,7 +1955,12 @@ def bufferPositionsToRawText(self, startPos, endPos):
return ""

def bufferPosToWindowPos(self, bufferPos: int) -> int:
for row, (start, end) in enumerate(self._windowRowBufferOffsets):
"""
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)
raise LookupError("buffer pos not in window")
Expand All @@ -1941,7 +1974,7 @@ def windowPosToBufferPos(self, windowPos: int) -> int:
windowPos = max(min(windowPos, self.handler.displaySize), 0)
row, col = divmod(windowPos, self.handler.displayDimensions.numCols)
if row < len(self._windowRowBufferOffsets):
start, end = self._windowRowBufferOffsets[row]
start, end, _ = self._windowRowBufferOffsets[row]
return max(min(start + col, end - 1), 0)
raise ValueError("Position outside window")

Expand All @@ -1954,36 +1987,48 @@ def _get_windowStartPos(self) -> int:
def _set_windowStartPos(self, pos: int) -> None:
self._calculateWindowRowBufferOffsets(pos)

def _isMidWordCut(self, end: int, bufferEnd: int) -> bool:
"""Return True when the cut at `end` falls in the middle of a word (both adjacent cells are non-space)."""
return end < bufferEnd and all(self.brailleCells[end - 1 : end + 1])
Comment thread
LeonarddeR marked this conversation as resolved.

def _calculateWindowRowBufferOffsets(self, pos: int) -> None:
"""
Calculates the start and end positions of each row in the braille window.
Ensures that words are not split across rows when word wrap is enabled.
Ensures that words are not split across rows when text wrap is enabled.
Ensures that the window does not extend past the end of the braille buffer.
:param pos: The start position of the braille window.
"""
self._windowRowBufferOffsets.clear()
if len(self.brailleCells) == 0:
# Initialising with no actual braille content.
self._windowRowBufferOffsets = [(0, 0)]
self._windowRowBufferOffsets = [(0, 0, False)]
return
doWordWrap = config.conf["braille"]["wordWrap"]
textWrap: BrailleTextWrapFlag = config.conf["braille"]["textWrap"].calculated()
bufferEnd = len(self.brailleCells)
start = pos
clippedEnd = False
for row in range(self.handler.displayDimensions.numRows):
showContinuationMark = False
end = start + self.handler.displayDimensions.numCols
if end > bufferEnd:
end = bufferEnd
clippedEnd = True
elif doWordWrap:
elif textWrap == BrailleTextWrapFlag.MARK_WORD_CUTS and self._isMidWordCut(end, bufferEnd):
end -= 1
showContinuationMark = True
elif textWrap == BrailleTextWrapFlag.AT_WORD_BOUNDARIES:
try:
lastSpaceIndex = rindex(self.brailleCells, 0, start, end + 1)
if lastSpaceIndex < end:
# The next braille window doesn't start with space.
end = rindex(self.brailleCells, 0, start, end) + 1
# lastSpaceIndex < end proves brailleCells[end] is non-zero,
# so searching [start, end) yields the same lastSpaceIndex.
end = lastSpaceIndex + 1
except (ValueError, IndexError):
pass # No space on line
self._windowRowBufferOffsets.append((start, end))
# No space on line - fall back to display-edge cut.
if self._isMidWordCut(end, bufferEnd):
end -= 1
showContinuationMark = True
Comment thread
seanbudd marked this conversation as resolved.
self._windowRowBufferOffsets.append((start, end, showContinuationMark))
if clippedEnd:
break
start = end
Expand All @@ -1992,7 +2037,7 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None:
"""The end position of the braille window in the braille buffer."""

def _get_windowEndPos(self) -> int:
start, end = self._windowRowBufferOffsets[-1]
start, end, _ = self._windowRowBufferOffsets[-1]
return end

def _set_windowEndPos(self, endPos: int) -> None:
Expand All @@ -2001,7 +2046,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
Expand All @@ -2022,7 +2067,10 @@ def _set_windowEndPos(self, endPos: int) -> None:
if startPos <= restrictPos:
self.windowStartPos = restrictPos
return
if not config.conf["braille"]["wordWrap"]:
if config.conf["braille"]["textWrap"].calculated() in (
BrailleTextWrapFlag.NONE,
BrailleTextWrapFlag.MARK_WORD_CUTS,
):
self.windowStartPos = startPos
return
try:
Expand All @@ -2037,7 +2085,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(
Expand Down Expand Up @@ -2144,9 +2192,12 @@ def _get_windowRawText(self):

def _get_windowBrailleCells(self) -> list[int]:
windowCells = []
for start, end in self._windowRowBufferOffsets:
for row, (start, end, hasCont) in enumerate(self._windowRowBufferOffsets):
rowCells = self.brailleCells[start:end]
remaining = self.handler.displayDimensions.numCols - len(rowCells)
if remaining > 0 and hasCont:
rowCells.append(CONTINUATION_SHAPE)
remaining -= 1
if remaining > 0:
rowCells.extend([0] * remaining)
windowCells.extend(rowCells)
Expand Down
40 changes: 35 additions & 5 deletions source/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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:

Expand All @@ -1409,15 +1411,43 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat
>>> return
>>> ...
"""
# cacheVal defaults to val; overridden when profile and cache need different types.
cacheVal = val
match self.path:
case "braille":
match key:
case "wordWrap":
# The "wordWrap" setting was renamed to "textWrap" and became a feature flag.
log.warning(
"braille.wordWrap is deprecated. Use braille.textWrap instead.",
stack_info=True,
)
key = "textWrap"
flagEnum = BrailleTextWrapFlag.AT_WORD_BOUNDARIES if val else BrailleTextWrapFlag.NONE
# Profile stores strings; cache must hold a validated FeatureFlag object
# (matching what __setitem__ normally stores) so .calculated() works on next read.
# Validate through the spec to avoid hardcoding behaviorOfDefault here.
val = flagEnum.name
cacheVal = self.manager.validator.check(self._spec[key], val)
case "textWrap":
# The "textWrap" setting was added in place of "wordWrap" and became a feature flag.
key = "wordWrap"
calculated: BrailleTextWrapFlag = val.calculated()
val = calculated == BrailleTextWrapFlag.AT_WORD_BOUNDARIES
cacheVal = val

case _:
# We don't care about other keys in this section.
return

case _:
# We don't care about other sections.
return

# 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]
Expand Down
4 changes: 3 additions & 1 deletion source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -91,7 +91,9 @@
optionsEnum="ReviewRoutingMovesSystemCaretFlag", behaviorOfDefault="NEVER")
readByParagraph = boolean(default=false)
paragraphStartMarker = option("", " ", "¶", default="")
# Deprecated in 2026.3
wordWrap = boolean(default=true)
textWrap = featureFlag(optionsEnum="BrailleTextWrapFlag", behaviorOfDefault="AT_WORD_BOUNDARIES")
unicodeNormalization = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="disabled")
focusContextPresentation = option("changedContext", "fill", "scroll", default="changedContext")
interruptSpeechWhileScrolling = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled")
Expand Down
24 changes: 24 additions & 0 deletions source/config/featureFlagEnums.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,30 @@ def _displayStringLabels(self) -> dict["FontFormattingBrailleModeFlag", str]:
}


class BrailleTextWrapFlag(DisplayStringEnum):
"""Enumeration containing the possible ways to wrap text in braille when a row would exceed the display.

The continuation mark (dots 7-8) is shown on rows where a word was cut,
regardless of mode (except for NONE, which never shows the mark).
"""

@property
def _displayStringLabels(self):
return {
# Translators: A choice in a combo box in the braille settings panel to configure text wrapping.
self.NONE: pgettext("braille text wrap", "Off"),
# Translators: A choice in a combo box in the braille settings panel to configure text wrapping.
self.MARK_WORD_CUTS: pgettext("braille text wrap", "Show mark when words are cut"),
# Translators: A choice in a combo box in the braille settings panel to configure text wrapping.
self.AT_WORD_BOUNDARIES: pgettext("braille text wrap", "At word boundaries"),
}

DEFAULT = enum.auto()
NONE = enum.auto()
MARK_WORD_CUTS = enum.auto()
AT_WORD_BOUNDARIES = enum.auto()
Comment thread
LeonarddeR marked this conversation as resolved.
Outdated


def getAvailableEnums() -> typing.Generator[typing.Tuple[str, FlagValueEnum], None, None]:
for name, value in globals().items():
if (
Expand Down
Loading
Loading