Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ dependencies = [
"mdx-gh-links==0.4",
"l2m4m==1.0.4",
"pymdown-extensions==10.17.1",
"pyphen>=0.17.2",
]

[project.urls]
Expand Down
145 changes: 123 additions & 22 deletions source/braille.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -34,17 +35,21 @@
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
import baseObject
import config
import easeOfAccess
from config.configFlags import (
BrailleTextWrap,
ShowMessages,
TetherTo,
BrailleMode,
Expand Down Expand Up @@ -326,6 +331,7 @@
(0xFF, _("All dots")),
)
SELECTION_SHAPE = 0xC0 #: Dots 7 and 8
CONTINUATION_SHAPE = 0xC0 #: Dots 7 and 8

END_OF_BRAILLE_OUTPUT_SHAPE = 0xFF # All dots
"""
Expand Down Expand Up @@ -559,6 +565,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 = []
Expand Down Expand Up @@ -587,6 +596,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.
Expand Down Expand Up @@ -1385,12 +1404,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)

Expand Down Expand Up @@ -1443,16 +1475,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
Expand Down Expand Up @@ -1520,6 +1557,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 = []
Expand Down Expand Up @@ -1812,10 +1850,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 @@ -1831,6 +1871,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.
Expand Down Expand Up @@ -1859,35 +1901,53 @@ 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 _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:
Expand All @@ -1904,7 +1964,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 @@ -1926,6 +1992,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)
Expand Down Expand Up @@ -1961,27 +2032,51 @@ def _calculateWindowRowBufferOffsets(self, pos: int) -> None:
: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 = BrailleTextWrap(config.conf["braille"]["textWrap"])
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 == BrailleTextWrap.CONTINUATION_ONLY and all(self.brailleCells[end - 1 : end + 1]):
end -= 1
showContinuationMark = True
elif textWrap in (BrailleTextWrap.WORD_BOUNDARIES, BrailleTextWrap.HYPHENATE):
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 == BrailleTextWrap.HYPHENATE:
# When hyphenating, we want to split the word after the last space.
# Note that, when the below index call fails, it is appropriately handled by the except block,
# which means that we won't hyphenate 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
if showContinuationMark:
self._continuationRows.append(len(self._windowRowBufferOffsets))
self._windowRowBufferOffsets.append((start, end))
if clippedEnd:
break
Expand Down Expand Up @@ -2021,7 +2116,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"] in (
BrailleTextWrap.OFF.value,
BrailleTextWrap.CONTINUATION_ONLY.value,
):
self.windowStartPos = startPos
return
try:
Expand Down Expand Up @@ -2143,9 +2241,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)
Expand Down
29 changes: 25 additions & 4 deletions source/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from collections.abc import Collection
from enum import Enum
from addonAPIVersion import BACK_COMPAT_TO
import globalVars
import winreg
import os
Expand All @@ -37,6 +38,7 @@
from . import profileUpgrader
from . import aggregatedSection
from .configSpec import confspec
from .configFlags import BrailleTextWrap
from .featureFlag import (
_transformSpec_AddFeatureFlagDefault,
_validateConfig_featureFlag,
Expand Down Expand Up @@ -1285,14 +1287,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 @@ -1314,6 +1316,25 @@ def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregat
>>> ...
"""
match self.path:
case "braille":
match key:
case "wordWrap":
# The "wordWrap" setting was renamed to "textWrap" and became an enum.
log.warning(
"braille.wordWrap is deprecated. Use braille.textWrap instead.",
stack_info=True,
)
key = "textWrap"
val = (BrailleTextWrap.WORD_BOUNDARIES if val else BrailleTextWrap.OFF).value
case "textWrap":
# The "textWrap" setting was added in place of "wordWrap" and became an enum.
key = "wordWrap"
val = val in (BrailleTextWrap.WORD_BOUNDARIES.value, BrailleTextWrap.HYPHENATE.value)

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

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