Skip to content
Merged
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
9 changes: 5 additions & 4 deletions source/appModules/soffice.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) 2006-2025 NV Access Limited, Bill Dengler, Leonard de Ruijter, Cyrille Bougot
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt

from typing import (
Optional,
Expand All @@ -18,7 +18,7 @@
from controlTypes import TextPosition
import textInfos
import colors
from compoundDocuments import CompoundDocument, TreeCompoundTextInfo
from compoundDocuments import CompoundDocument, TreeCompoundTextInfo, CompoundTextLeafTextInfo
from NVDAObjects import NVDAObject
from NVDAObjects.IAccessible import IAccessible, IA2TextTextInfo
from NVDAObjects.behaviors import EditableText
Expand Down Expand Up @@ -55,7 +55,7 @@ def get_id(obj: NVDAObject) -> str | None:
return obj.IA2Attributes.get("id")


class SymphonyTextInfo(IA2TextTextInfo):
class SymphonyTextInfo(IA2TextTextInfo, CompoundTextLeafTextInfo):
# C901 '_getFormatFieldFromLegacyAttributesString' is too complex
# Note: when working on _getFormatFieldFromLegacyAttributesString, look for opportunities to simplify
# and move logic out into smaller helper functions.
Expand Down Expand Up @@ -237,6 +237,7 @@ def _getLineOffsets(self, offset):
if offset == 0 and start == 0 and end == 0:
# HACK: Symphony doesn't expose any characters at all on empty lines, but this means we don't ever fetch the list item prefix in this case.
# Fake a character so that the list item prefix will be spoken on empty lines.
# Note: Observations in LibreOffice revealed that this might no longer be necessary.
return (0, 1)
return start, end

Expand Down
16 changes: 13 additions & 3 deletions source/compoundDocuments.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) 2010-2024 NV Access Limited, Bram Duvigneau
# Copyright (C) 2010-2025 NV Access Limited, Bram Duvigneau, Leonard de Ruijter
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt

from typing import (
Optional,
Expand All @@ -12,6 +12,7 @@
import textUtils
import winUser
import textInfos
import textInfos.offsets
import controlTypes
import eventHandler
from NVDAObjects import NVDAObject
Expand Down Expand Up @@ -503,6 +504,15 @@ def _get_boundingRects(self):
return rects


class CompoundTextLeafTextInfo(textInfos.offsets.OffsetsTextInfo):
"""A mixin class for leafs within a CompoundTextInfo that utilize offsets.
It ensures that moving past the end of the object is only allowed for certain units.
"""

def allowMoveToUnitOffsetPastEnd(self, unit: str) -> bool:
return unit in (textInfos.UNIT_CHARACTER, textInfos.UNIT_WORD) or not self.obj.flowsTo


class CompoundDocument(EditableText, DocumentTreeInterceptor):
TextInfo = TreeCompoundTextInfo

Expand Down
38 changes: 26 additions & 12 deletions source/textInfos/offsets.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# textInfos/offsets.py
# 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) 2006-2024 NV Access Limited, Babbage B.V., Leonard de Ruijter
# Copyright (C) 2006-2025 NV Access Limited, Babbage B.V., Leonard de Ruijter
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt

from abc import abstractmethod
import re
import ctypes
import unicodedata
import NVDAHelper
import NVDAState
import config
import textInfos
import locationHelper
Expand Down Expand Up @@ -643,11 +643,27 @@ def unitCount(self, unit):
else:
raise NotImplementedError

allowMoveToOffsetPastEnd = True
"""
We can move 1 past story length to allow braille routing to end insertion point. (#2096)
Furthermore, review cursor is able to reach the last, empty line in some controls, like Scintilla. (#18348)
"""
def allowMoveToUnitOffsetPastEnd(self, unit: str) -> bool:
"""
This method indicates whether the `move` method is allowed to move one unit past the end of the text info.
For example, normally we should be able to move 1 past story length
to allow braille routing to move to an insertion point at the end. (#2096)
Furthermore, review cursor should be able to reach the last, empty line in some controls,
like Scintilla. (#18348)
:param unit: the TextInfo unit (e.g. character or word)
:return: Whether or not to allow movement past end for the specific unit.
"""
return True

if NVDAState._allowDeprecatedAPI():

def _get_allowMoveToOffsetPastEnd(self) -> bool:
log.warning(
"OffsetsTextInfo.allowMoveToOffsetPastEnd is deprecated. "
"Use the OffsetsTextInfo.allowMoveToUnitOffsetPastEnd method instead.",
stack_info=True,
)
return self.allowMoveToUnitOffsetPastEnd(textInfos.UNIT_CHARACTER)

def move(self, unit, direction, endPoint=None):
if direction == 0:
Expand All @@ -663,9 +679,7 @@ def move(self, unit, direction, endPoint=None):
count = 0
lowLimit = 0
highLimit = self._getStoryLength()
if self.allowMoveToOffsetPastEnd:
# #2096: There is often an uncounted character at the end of the text
# where the caret is placed to append text.
if self.allowMoveToUnitOffsetPastEnd(unit):
highLimit += 1
while (
count != direction
Expand Down
10 changes: 6 additions & 4 deletions source/virtualBuffers/__init__.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) 2007-2025 NV Access Limited, Peter Vágner, Cyrille Bougot
# Copyright (C) 2007-2025 NV Access Limited, Peter Vágner, Cyrille Bougot, Leonard de Ruijter
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt

import time
import threading
Expand Down Expand Up @@ -149,7 +149,9 @@ def isChild(self, parent):


class VirtualBufferTextInfo(browseMode.BrowseModeDocumentTextInfo, textInfos.offsets.OffsetsTextInfo):
allowMoveToOffsetPastEnd = False #: no need for end insertion point as vbuf is not editable.
def allowMoveToUnitOffsetPastEnd(self, unit: str) -> bool:
"""Virtual buffers have no insertion point, so no need to move past the end of text."""
return False

def _getControlFieldAttribs(self, docHandle, id):
info = self.copy()
Expand Down
11 changes: 7 additions & 4 deletions tests/unit/objectProvider.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
# tests/unit/objectProvider.py
# 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) 2017 NV Access Limited, Babbage B.V.
# Copyright (C) 2017-2025 NV Access Limited, Babbage B.V.
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt

"""Fake object provider implementation for testing of code which uses NVDAObjects."""

from NVDAObjects import NVDAObject
import controlTypes
from typing import Any


class PlaceholderNVDAObject(NVDAObject):
processID = None # Must be implemented to instantiate.
windowThreadID = 0 # Must be implemented for inputCore tests

def _isEqual(self, other: Any) -> bool:
Comment thread
seanbudd marked this conversation as resolved.
return False


class NVDAObjectWithRole(PlaceholderNVDAObject):
"""An object that accepts a role as one of its construction parameters.
Expand Down
121 changes: 121 additions & 0 deletions tests/unit/test_compoundDocuments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# A part of NonVisual Desktop Access (NVDA)
Comment thread
LeonarddeR marked this conversation as resolved.
# Copyright (C) 2025 NV Access Limited, Leonard de Ruijter
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt

"""Unit tests for the compoundDocuments module."""

import unittest

import compoundDocuments
import controlTypes
import textInfos
from .objectProvider import PlaceholderNVDAObject
from .textProvider import BasicTextInfo, BasicTextProvider


class BasicCompoundTextLeafTextInfo(BasicTextInfo, compoundDocuments.CompoundTextLeafTextInfo): ...


class BasicCompoundTextLeaf(BasicTextProvider):
TextInfo = BasicCompoundTextLeafTextInfo
windowHandle = 0
states = {controlTypes.State.FOCUSABLE}
flowsFrom = None
flowsTo = None


class CompoundTreePlaceholderObject(PlaceholderNVDAObject):
"""A placeholder NVDAObject for testing CompoundTextInfo implementations.
This class represents a tree structure of text providers.
Note that it also mutates the text providers to link them in a flow."""

def __init__(self, objs: list[BasicCompoundTextLeaf], **kwargs):
super().__init__(**kwargs)
assert len(objs) > 0, "At least one text provider must be provided"
self.children = objs
self.firstChild = objs[0]
self.lastChild = objs[-1]
lastProcessedObj = None
for obj in objs:
if lastProcessedObj is not None:
lastProcessedObj.flowsTo = obj
obj.flowsFrom = lastProcessedObj
lastProcessedObj = obj


class TestTreeCompoundTextInfo(unittest.TestCase):
"""Tests for the TreeCompoundTextInfo class."""

def setUp(self) -> None:
objs = [
BasicCompoundTextLeaf(text="one\r\n"),
BasicCompoundTextLeaf(text="two\r\n"),
BasicCompoundTextLeaf(text="three"),
]
self.objs = objs
self.rootObj = CompoundTreePlaceholderObject(objs=objs)
self.document = compoundDocuments.CompoundDocument(self.rootObj)
self.fullText = "one\r\ntwo\r\nthree"

def test_innerInfos(self):
"""Test that the text infos are created correctly."""
info: compoundDocuments.TreeCompoundTextInfo = self.document.makeTextInfo(textInfos.POSITION_ALL)
innerInfos = list(info._getTextInfos())
self.assertEqual(len(innerInfos), 3)
for obj, info in zip(self.objs, innerInfos):
self.assertEqual(obj, info.obj)

def test_text(self):
"""Test that the combined text is correct."""
info: compoundDocuments.TreeCompoundTextInfo = self.document.makeTextInfo(textInfos.POSITION_ALL)
self.assertEqual(info.text, self.fullText)

def test_characterMovement(self):
"""Test character movement across the compound text info."""
info: compoundDocuments.TreeCompoundTextInfo = self.document.makeTextInfo(textInfos.POSITION_FIRST)
expected = [*"one\r\n", "", *"two\r\n", "", *"three"]
for i in range(len(expected) + 1):
c = expected[i] if i < len(expected) else ""
with self.subTest(i=i, c=c):
info.collapse()
movement = min(i, 1)
self.assertEqual(info.move(textInfos.UNIT_CHARACTER, movement), movement)
info.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(info.text, c)
last: compoundDocuments.TreeCompoundTextInfo = self.document.makeTextInfo(textInfos.POSITION_LAST)
# Allow moving past end
self.assertGreater(info.compareEndPoints(last, "endToEnd"), 0)

def test_wordMovement(self):
"""Test word movement across the compound text info."""
info: compoundDocuments.TreeCompoundTextInfo = self.document.makeTextInfo(textInfos.POSITION_FIRST)
expected = ["one\r\n", "", "two\r\n", "", "three"]
for i in range(len(expected) + 1):
w = expected[i] if i < len(expected) else ""
with self.subTest(i=i, w=w):
info.collapse()
movement = min(i, 1)
self.assertEqual(info.move(textInfos.UNIT_WORD, movement), movement)
info.expand(textInfos.UNIT_WORD)
self.assertEqual(info.text, w)
last: compoundDocuments.TreeCompoundTextInfo = self.document.makeTextInfo(textInfos.POSITION_LAST)
# Allow moving past end
self.assertGreater(info.compareEndPoints(last, "endToEnd"), 0)

def test_lineMovement(self):
"""Test line movement across the compound text info."""
info: compoundDocuments.TreeCompoundTextInfo = self.document.makeTextInfo(textInfos.POSITION_FIRST)
expected = ["one\r\n", "two\r\n", "three"]
for i in range(len(expected) + 1):
if i < len(expected):
line = expected[i]
Comment thread
seanbudd marked this conversation as resolved.
with self.subTest(i=i, line=line):
info.collapse()
movement = min(i, 1)
self.assertEqual(info.move(textInfos.UNIT_LINE, movement), movement)
info.expand(textInfos.UNIT_LINE)
self.assertEqual(info.text, line)
last: compoundDocuments.TreeCompoundTextInfo = self.document.makeTextInfo(textInfos.POSITION_LAST)
# Allow moving past end
self.assertGreater(info.compareEndPoints(last, "endToEnd"), 0)
3 changes: 3 additions & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ We recommend using Windows 11, or if that is not possible, the latest Windows 10
* Certain settings will no-longer erroneously be saved to disk when running NVDA from the launcher. (#18171)
* Incorrect information is no longer displayed in braille when navigating the list of messages in Outlook Classic. (#18993, @nvdaes)
* NVDA now detects and stops repeated crash loops to prevent system lockups when startup failures occur. (#19133, @derekriemer)
* When moving Braille to the next line in LibreOffice Writer when the caret is at the start of the last line, it will now consistently move to the end of the document. (#19152, @LeonarddeR, @nvdaes)
* The browse mode cursor highlighter now appears on content recognition results, such as when using Windows OCR. (#19168, @hwf1324)

### Changes for Developers
Expand Down Expand Up @@ -236,6 +237,8 @@ Use `INPUT_TYPE.MOUSE`, `INPUT_TYPE.KEYBOARD`, `KEYEVENTF.KEYUP` and `KEYEVENTF.
Use `winBindings.magnification.MAGCOLOREFFECT` instead. (#18958)
* `visionEnhancementProviders.screenCurtain.isScreenFullyBlack` is deprecated.
Use `NVDAHelper.localLib.isScreenFullyBlack` instead. (#18958)
* `textInfos.OffsetsTextInfo.allowMoveToOffsetPastEnd` is deprecated.
Use the `OffsetsTextInfo.allowMoveToUnitOffsetPastEnd` method instead. (#19152, @LeonarddeR)

<!-- Beyond this point, Markdown should not be linted, as we don't modify old change log sections. -->
<!-- markdownlint-disable -->
Expand Down
Loading