Skip to content

Commit 61428bc

Browse files
authored
Re-Introduce Table SayAll commands (#14070)
Fixes #13469. Fixes #13927. Summary of the issue: Previous PR #13956 broke sayAll functionality in BookWorm (#13927) and therefore was reverted. This PR undoes reverting, in other words it reintroduces table sayAll commands. It also contains a minor change that fixes sayAll in BookWorm. Description of user facing changes Reintroduces table sayAll commands. Description of development approach In my original PR #13956 I did a minor refactoring of SayAll classes, in order to avoid code reduplication and have a nice class inheritance. In order to achieve this I slightly rearranged calls to textInfo. This didn't affect functionality anywhere except for page turn detector in BookWorm. Reverting to the original order of TextInfo calls at the cost of slightly less elegant code.
1 parent 0b72324 commit 61428bc

8 files changed

Lines changed: 567 additions & 159 deletions

File tree

‎source/documentBase.py‎

Lines changed: 210 additions & 92 deletions
Large diffs are not rendered by default.

‎source/speech/sayAll.py‎

Lines changed: 130 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
# Copyright (C) 2006-2022 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler,
55
# Julien Cochuyt
66

7+
from abc import ABCMeta, abstractmethod
78
from enum import IntEnum
8-
from typing import Callable, TYPE_CHECKING
9+
from typing import Callable, TYPE_CHECKING, Optional
910
import weakref
1011
import garbageHandler
1112
from logHandler import log
@@ -36,6 +37,7 @@
3637
class CURSOR(IntEnum):
3738
CARET = 0
3839
REVIEW = 1
40+
TABLE = 2
3941

4042

4143
SayAllHandler = None
@@ -95,10 +97,23 @@ def readObjects(self, obj: 'NVDAObjects.NVDAObject'):
9597
self._getActiveSayAll = weakref.ref(reader)
9698
reader.next()
9799

98-
def readText(self, cursor: CURSOR):
100+
def readText(
101+
self,
102+
cursor: CURSOR,
103+
startPos: Optional[textInfos.TextInfo] = None,
104+
nextLineFunc: Optional[Callable[[textInfos.TextInfo], textInfos.TextInfo]] = None,
105+
shouldUpdateCaret: bool = True,
106+
) -> None:
99107
self.lastSayAllMode = cursor
100108
try:
101-
reader = _TextReader(self, cursor)
109+
if cursor == CURSOR.CARET:
110+
reader = _CaretTextReader(self)
111+
elif cursor == CURSOR.REVIEW:
112+
reader = _ReviewTextReader(self)
113+
elif cursor == CURSOR.TABLE:
114+
reader = _TableTextReader(self, startPos, nextLineFunc, shouldUpdateCaret)
115+
else:
116+
raise RuntimeError(f"Unknown cursor {cursor}")
102117
except NotImplementedError:
103118
log.debugWarning("Unable to make reader", exc_info=True)
104119
return
@@ -149,7 +164,7 @@ def stop(self):
149164
self.walker = None
150165

151166

152-
class _TextReader(garbageHandler.TrackedObject):
167+
class _TextReader(garbageHandler.TrackedObject, metaclass=ABCMeta):
153168
"""Manages continuous reading of text.
154169
This is intended for internal use only.
155170
@@ -171,35 +186,32 @@ class _TextReader(garbageHandler.TrackedObject):
171186
"""
172187
MAX_BUFFERED_LINES = 10
173188

174-
def __init__(self, handler: _SayAllHandler, cursor: CURSOR):
189+
def __init__(self, handler: _SayAllHandler):
175190
self.handler = handler
176-
self.cursor = cursor
177191
self.trigger = SayAllProfileTrigger()
178-
self.reader = None
179-
# Start at the cursor.
180-
if cursor == CURSOR.CARET:
181-
try:
182-
self.reader = api.getCaretObject().makeTextInfo(textInfos.POSITION_CARET)
183-
except (NotImplementedError, RuntimeError) as e:
184-
raise NotImplementedError("Unable to make TextInfo: " + str(e))
185-
else:
186-
self.reader = api.getReviewPosition()
192+
self.reader = self.getInitialTextInfo()
187193
# #10899: SayAll profile can't be activated earlier because they may not be anything to read
188194
self.trigger.enter()
189195
self.speakTextInfoState = SayAllHandler._makeSpeakTextInfoState(self.reader.obj)
190196
self.numBufferedLines = 0
197+
self.initialIteration = True
191198

192-
def nextLine(self):
193-
if not self.reader:
194-
log.debug("no self.reader")
195-
# We were stopped.
196-
return
197-
if not self.reader.obj:
198-
log.debug("no self.reader.obj")
199-
# The object died, so we should too.
200-
self.finish()
201-
return
202-
bookmark = self.reader.bookmark
199+
@abstractmethod
200+
def getInitialTextInfo(self) -> textInfos.TextInfo:
201+
...
202+
203+
@abstractmethod
204+
def updateCaret(self, updater: textInfos.TextInfo) -> None:
205+
...
206+
207+
def shouldReadInitialPosition(self) -> bool:
208+
return False
209+
210+
def nextLineImpl(self) -> bool:
211+
"""
212+
Advances cursor to the next reading chunk (e.g. paragraph).
213+
@return: C{True} if advanced successfully, C{False} otherwise.
214+
"""
203215
# Expand to the current line.
204216
# We use move end rather than expand
205217
# because the user might start in the middle of a line
@@ -215,8 +227,40 @@ def nextLine(self):
215227
self.handler.speechWithoutPausesInstance.speakWithoutPauses([cb, EndUtteranceCommand()])
216228
else:
217229
self.finish()
218-
return
230+
return False
231+
return True
232+
233+
def collapseLineImpl(self) -> bool:
234+
"""
235+
Collapses to the end of this line, ready to read the next.
236+
@return: C{True} if collapsed successfully, C{False} otherwise.
237+
"""
238+
try:
239+
self.reader.collapse(end=True)
240+
return True
241+
except RuntimeError:
242+
# This occurs in Microsoft Word when the range covers the end of the document.
243+
# without this exception to indicate that further collapsing is not possible,
244+
# say all could enter an infinite loop.
245+
self.finish()
246+
return False
219247

248+
def nextLine(self):
249+
if not self.reader:
250+
log.debug("no self.reader")
251+
# We were stopped.
252+
return
253+
if not self.reader.obj:
254+
log.debug("no self.reader.obj")
255+
# The object died, so we should too.
256+
self.finish()
257+
return
258+
if not self.initialIteration or not self.shouldReadInitialPosition():
259+
if not self.nextLineImpl():
260+
self.finish()
261+
return
262+
self.initialIteration = False
263+
bookmark = self.reader.bookmark
220264
# Copy the speakTextInfoState so that speak callbackCommand
221265
# and its associated callback are using a copy isolated to this specific line.
222266
state = self.speakTextInfoState.copy()
@@ -248,15 +292,9 @@ def _onLineReached(obj=self.reader.obj, state=state):
248292
# Update the textInfo state ready for when speaking the next line.
249293
self.speakTextInfoState = state.copy()
250294

251-
# Collapse to the end of this line, ready to read the next.
252-
try:
253-
self.reader.collapse(end=True)
254-
except RuntimeError:
255-
# This occurs in Microsoft Word when the range covers the end of the document.
256-
# without this exception to indicate that further collapsing is not possible,
257-
# say all could enter an infinite loop.
258-
self.finish()
295+
if not self.collapseLineImpl():
259296
return
297+
260298
if not spoke:
261299
# This line didn't include a natural pause, so nothing was spoken.
262300
self.numBufferedLines += 1
@@ -275,10 +313,7 @@ def lineReached(self, obj, bookmark, state):
275313
# We've just started speaking this line, so move the cursor there.
276314
state.updateObj()
277315
updater = obj.makeTextInfo(bookmark)
278-
if self.cursor == CURSOR.CARET:
279-
updater.updateCaret()
280-
if self.cursor != CURSOR.CARET or config.conf["reviewCursor"]["followCaret"]:
281-
api.setReviewPosition(updater, isCaret=self.cursor == CURSOR.CARET)
316+
self.updateCaret(updater)
282317
winKernel.SetThreadExecutionState(winKernel.ES_SYSTEM_REQUIRED)
283318
if self.numBufferedLines == 0:
284319
# This was the last line spoken, so move on.
@@ -320,6 +355,62 @@ def stop(self):
320355
def __del__(self):
321356
self.stop()
322357

358+
359+
class _CaretTextReader(_TextReader):
360+
def getInitialTextInfo(self) -> textInfos.TextInfo:
361+
try:
362+
return api.getCaretObject().makeTextInfo(textInfos.POSITION_CARET)
363+
except (NotImplementedError, RuntimeError) as e:
364+
raise NotImplementedError("Unable to make TextInfo: ", e)
365+
366+
def updateCaret(self, updater: textInfos.TextInfo) -> None:
367+
updater.updateCaret()
368+
if config.conf["reviewCursor"]["followCaret"]:
369+
api.setReviewPosition(updater, isCaret=True)
370+
371+
372+
class _ReviewTextReader(_TextReader):
373+
def getInitialTextInfo(self) -> textInfos.TextInfo:
374+
return api.getReviewPosition()
375+
376+
def updateCaret(self, updater: textInfos.TextInfo) -> None:
377+
api.setReviewPosition(updater, isCaret=False)
378+
379+
380+
class _TableTextReader(_CaretTextReader):
381+
def __init__(
382+
self,
383+
handler: _SayAllHandler,
384+
startPos: Optional[textInfos.TextInfo] = None,
385+
nextLineFunc: Optional[Callable[[textInfos.TextInfo], textInfos.TextInfo]] = None,
386+
shouldUpdateCaret: bool = True,
387+
):
388+
self.startPos = startPos
389+
self.nextLineFunc = nextLineFunc
390+
self.shouldUpdateCaret = shouldUpdateCaret
391+
super().__init__(handler)
392+
393+
def getInitialTextInfo(self) -> textInfos.TextInfo:
394+
return self.startPos or super().getInitialTextInfo()
395+
396+
def nextLineImpl(self) -> bool:
397+
try:
398+
self.reader = self.nextLineFunc(self.reader)
399+
return True
400+
except StopIteration:
401+
return False
402+
403+
def collapseLineImpl(self) -> bool:
404+
return True
405+
406+
def shouldReadInitialPosition(self) -> bool:
407+
return True
408+
409+
def updateCaret(self, updater: textInfos.TextInfo) -> None:
410+
if self.shouldUpdateCaret:
411+
return super().updateCaret(updater)
412+
413+
323414
class SayAllProfileTrigger(config.ProfileTrigger):
324415
"""A configuration profile trigger for when say all is in progress.
325416
"""

‎source/virtualBuffers/__init__.py‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import treeInterceptorHandler
3939
import watchdog
4040
from abc import abstractmethod
41+
import documentBase
42+
4143

4244
VBufStorage_findDirection_forward=0
4345
VBufStorage_findDirection_back=1
@@ -640,15 +642,14 @@ def _iterTableCells(self, tableID, startPos=None, direction="next", row=None, co
640642

641643
def _getNearestTableCell(
642644
self,
643-
tableID,
644-
startPos,
645-
origRow,
646-
origCol,
647-
origRowSpan,
648-
origColSpan,
649-
movement,
650-
axis
651-
):
645+
startPos: textInfos.TextInfo,
646+
cell: documentBase._TableCell,
647+
movement: documentBase._Movement,
648+
axis: documentBase._Axis,
649+
) -> textInfos.TextInfo:
650+
tableID, origRow, origCol, origRowSpan, origColSpan = (
651+
cell.tableID, cell.row, cell.col, cell.rowSpan, cell.colSpan
652+
)
652653
# Determine destination row and column.
653654
destRow = origRow
654655
destCol = origCol

‎source/virtualBuffers/gecko_ia2.py‎

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import aria
2424
import config
2525
from NVDAObjects.IAccessible import normalizeIA2TextFormatField, IA2TextTextInfo
26+
import documentBase
2627

2728

2829
def _getNormalizedCurrentAttrs(attrs: textInfos.ControlField) -> typing.Dict[str, typing.Any]:
@@ -548,26 +549,13 @@ def _getTableCellAt(self,tableID,startPos,destRow,destCol):
548549

549550
def _getNearestTableCell(
550551
self,
551-
tableID,
552-
startPos,
553-
origRow,
554-
origCol,
555-
origRowSpan,
556-
origColSpan,
557-
movement,
558-
axis,
559-
):
552+
startPos: textInfos.TextInfo,
553+
cell: documentBase._TableCell,
554+
movement: documentBase._Movement,
555+
axis: documentBase._Axis,
556+
) -> textInfos.TextInfo:
560557
# Skip the VirtualBuffer implementation as the base BrowseMode implementation is good enough for us here.
561-
return super(VirtualBuffer, self)._getNearestTableCell(
562-
tableID,
563-
startPos,
564-
origRow,
565-
origCol,
566-
origRowSpan,
567-
origColSpan,
568-
movement,
569-
axis
570-
)
558+
return super(VirtualBuffer, self)._getNearestTableCell(startPos, cell, movement, axis)
571559

572560
def _get_documentConstantIdentifier(self):
573561
try:

0 commit comments

Comments
 (0)