Skip to content
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
90ac1ca
Add pyphen
LeonarddeR Apr 3, 2026
792938b
Add a hyphenation module
LeonarddeR Apr 3, 2026
e9df289
Add text wrap
LeonarddeR Apr 3, 2026
0e79ac7
Config update
LeonarddeR Apr 3, 2026
bd889fa
Add language annotations to regions
LeonarddeR Apr 3, 2026
3cd2347
Fix continuation stuff
LeonarddeR Apr 6, 2026
098627d
userGuide: replace word wrap section with text wrap combo box documen…
Copilot Apr 7, 2026
ccd60a1
Update user guide
LeonarddeR Apr 7, 2026
3f1029e
Pre-commit auto-fix
pre-commit-ci[bot] Apr 7, 2026
903959c
No longer exclude bisect
LeonarddeR Apr 14, 2026
44685d5
Merge branch 'pyphen' of https://github.com/leonardder/nvda into pyphen
LeonarddeR Apr 14, 2026
3cda5ba
Merge branch 'master' into pyphen
LeonarddeR Apr 20, 2026
f818f62
Fixup config spec
LeonarddeR Apr 20, 2026
c0b2b97
Add a hook to setup.py
LeonarddeR Apr 20, 2026
acc793f
braille: switch textWrap to feature flag with renamed modes
LeonarddeR Apr 20, 2026
ee3a2a3
tests: add coverage for braille text wrap modes, continuation marker,…
LeonarddeR Apr 20, 2026
e2c9e4c
Update tests
LeonarddeR Apr 20, 2026
e0d5810
changes: document braille text wrap and hyphenation (#17010)
LeonarddeR Apr 20, 2026
4020b2f
Merge remote-tracking branch 'origin/master' into pyphen
LeonarddeR Apr 25, 2026
7c79183
Simplify patch.object usage in braille unit tests and add comments
LeonarddeR Apr 25, 2026
d99919e
Use direct assignment for auto-property overrides instead of patch.ob…
LeonarddeR Apr 25, 2026
124ea0c
Merge remote-tracking branch 'origin/master' into pyphen
LeonarddeR May 2, 2026
82cdf54
Fix window start pos
LeonarddeR May 2, 2026
745536f
docs: replace "word wrap" with "text wrap" in comments and test names
LeonarddeR May 2, 2026
6432ea9
Fix typo: hyphenationMap
LeonarddeR May 2, 2026
929a436
Potential fix for pull request finding
LeonarddeR May 2, 2026
1079808
Pre-commit auto-fix
pre-commit-ci[bot] May 2, 2026
8331868
SMall fixups
LeonarddeR May 2, 2026
951d01a
Merge branch 'pyphen' of https://github.com/leonardder/nvda into pyphen
LeonarddeR May 2, 2026
312d0e2
Merge remote-tracking branch 'origin/master' into pyphen
LeonarddeR May 5, 2026
b64ab7d
Address PR #19916 review comments
LeonarddeR May 5, 2026
fbfca6b
Refactor _linkDeprecatedValues to use shared profile/cache writes
LeonarddeR May 5, 2026
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",
# pinned due to incompatibility with py2exe
# https://github.com/py2exe/py2exe/issues/241
"charset-normalizer==3.4.4",
Expand Down
161 changes: 134 additions & 27 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,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
Expand All @@ -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
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -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()}
Comment thread
LeonarddeR marked this conversation as resolved.
Outdated
#: The translated braille representation of this region.
#: @type: [int, ...]
self.brailleCells = []
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1520,6 +1560,7 @@ def update(self):
self.rawText = ""
self.rawTextTypeforms = []
self.cursorPos = None
self._languageIndexes: dict[int:str] = {0: self._getDefaultRegionLanguage()}
Comment thread
LeonarddeR marked this conversation as resolved.
Outdated
# 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 @@ -1813,10 +1854,12 @@ def rindex(seq, item, start, end):


class BrailleBuffer(baseObject.AutoPropertyObject):
handler: "BrailleHandler"
regions: list[Region]
"""The regions in this buffer."""
Comment thread
LeonarddeR marked this conversation as resolved.

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 @@ -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.
Expand Down Expand Up @@ -1860,35 +1905,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'"""
Comment thread
LeonarddeR marked this conversation as resolved.

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'"""
Comment thread
LeonarddeR marked this conversation as resolved.

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 @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -1957,32 +2031,62 @@ 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 all(self.brailleCells[end - 1 : end + 1]):
end -= 1
showContinuationMark = True
Comment thread
LeonarddeR marked this conversation as resolved.
Outdated
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.
# Under rule A, mark this as a word cut since a word was split mid-way.
if all(self.brailleCells[end - 1 : end + 1]):
Comment thread
LeonarddeR marked this conversation as resolved.
showContinuationMark = True
if showContinuationMark:
self._continuationRows.append(len(self._windowRowBufferOffsets))
self._windowRowBufferOffsets.append((start, end))
if clippedEnd:
break
Expand All @@ -2001,7 +2105,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 +2126,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:
Expand All @@ -2037,7 +2141,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 +2248,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
Loading
Loading