Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
128 changes: 85 additions & 43 deletions source/braille.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -553,45 +557,46 @@ class Region(object):
L{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 L{rawText}, C{None} if the cursor is not in this region."""
selectionStart: int | None = None
"""The start of the selection in L{rawText} (inclusive), C{None} if there is no selection in this region."""
selectionEnd: int | None = None
"""The end of the selection in L{rawText} (exclusive), C{None} if there is no selection in this region."""
rawTextTypeforms: list[int] | None = None
"""liblouis typeform flags for each character in L{rawText}, C{None} if no typeform info."""
brailleCursorPos: int | None = None
"""The position of the cursor in L{brailleCells}, C{None} if the cursor is not in this region."""
brailleSelectionStart: int | None = None
"""The position of the selection start in L{brailleCells}, C{None} if there is no selection in this region."""
brailleSelectionEnd: int | None = None
"""The position of the selection end in L{brailleCells}, C{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 L{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 L{rawText} to positions in L{brailleCells}."""
self.brailleToRawPos: list[int] = []
"""A list mapping positions in L{brailleCells} to positions in L{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 L{rawText} based on L{_languageIndexes}."""
keys = list(self._languageIndexes)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive IMO. Dictionaries keep insertion order since Python 3.7, and keys are always inserted sorted.

i = bisect.bisect_right(keys, pos) - 1
return self._languageIndexes[keys[i]]

def update(self):
"""Update this region.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 7 additions & 1 deletion source/config/featureFlagEnums.py
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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):
Expand All @@ -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",
),
}


Expand Down
9 changes: 8 additions & 1 deletion source/louisHelper.py
Original file line number Diff line number Diff line change
@@ -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."""

Expand All @@ -17,6 +17,7 @@
import brailleTables
import config
import globalVars
import languageHandler
from logHandler import log

with os.add_dll_directory(globalVars.appDir):
Expand Down Expand Up @@ -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
1 change: 0 additions & 1 deletion source/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
113 changes: 113 additions & 0 deletions tests/unit/test_braille/test_calculateWindowRowBufferOffsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)."""
Expand Down Expand Up @@ -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)
Loading
Loading