66from typing import (
77 Iterable ,
88 Optional ,
9+ Tuple ,
910)
1011import typing
1112import weakref
13+ from ctypes import byref
1214from . import VirtualBuffer , VirtualBufferTextInfo , VBufStorage_findMatch_word , VBufStorage_findMatch_notEmpty
1315import treeInterceptorHandler
1416import controlTypes
1517import NVDAObjects .IAccessible .mozilla
1618import NVDAObjects .behaviors
1719import winUser
1820import IAccessibleHandler
19-
21+ from scriptHandler import script
22+ import ui
2023import oleacc
2124from logHandler import log
2225import textInfos
2326from comtypes .gen .IAccessible2Lib import IAccessible2
2427from comInterfaces import IAccessible2Lib as IA2
28+ from comInterfaces .IAccessible2Lib import IAccessibleTextSelectionContainer , IA2TextSelection , IAccessibleText
2529from comtypes import COMError
2630import aria
2731import config
@@ -45,6 +49,14 @@ def _getNormalizedCurrentAttrs(attrs: textInfos.ControlField) -> typing.Dict[str
4549
4650class Gecko_ia2_TextInfo (VirtualBufferTextInfo ):
4751
52+ def _setSelectionOffsets (self , start , end ):
53+ super ()._setSelectionOffsets (start , end )
54+ if self .obj ._nativeAppSelectionMode :
55+ if start != end :
56+ self .obj .updateAppSelection ()
57+ else :
58+ self .obj .clearAppSelection ()
59+
4860 def _getBoundingRectFromOffset (self ,offset ):
4961 formatFieldStart , formatFieldEnd = self ._getUnitOffsets (textInfos .UNIT_FORMATFIELD , offset )
5062 # The format field starts at the first character.
@@ -89,6 +101,15 @@ def _calculateDescriptionFrom(self, attrs: textInfos.ControlField) -> controlTyp
89101 # Note: when working on _normalizeControlField, look for opportunities to simplify
90102 # and move logic out into smaller helper functions.
91103 def _normalizeControlField (self , attrs ): # noqa: C901
104+ # convert some IAccessible2 text values to integers
105+ for name in (
106+ "ia2TextWindowHandle" ,
107+ "ia2TextUniqueID" ,
108+ "ia2TextStartOffset" ,
109+ ):
110+ val = attrs .get (name , None )
111+ if val is not None :
112+ attrs [name ] = int (val )
92113 for attr in (
93114 "table-rownumber-presentational" ,
94115 "table-columnnumber-presentational" ,
@@ -223,12 +244,18 @@ def _normalizeDetailsRole(self, detailsRoles: str) -> Iterable[Optional[controlT
223244
224245 def _normalizeFormatField (self , attrs ):
225246 normalizeIA2TextFormatField (attrs )
226- ia2TextStartOffset = attrs .get ("ia2TextStartOffset" )
227- if ia2TextStartOffset is not None :
228- assert ia2TextStartOffset .isdigit (), "ia2TextStartOffset isn't a digit, %r" % ia2TextStartOffset
229- attrs ["ia2TextStartOffset" ] = int (ia2TextStartOffset )
247+ # convert some IAccessible2 values to integers
248+ for name in (
249+ "ia2TextWindowHandle" ,
250+ "ia2TextUniqueID" ,
251+ "ia2TextStartOffset" ,
252+ ):
253+ val = attrs .get (name , None )
254+ if val is not None :
255+ attrs [name ] = int (val )
230256 return super (Gecko_ia2_TextInfo ,self )._normalizeFormatField (attrs )
231257
258+
232259class Gecko_ia2 (VirtualBuffer ):
233260
234261 TextInfo = Gecko_ia2_TextInfo
@@ -238,6 +265,35 @@ class Gecko_ia2(VirtualBuffer):
238265 #: frame/iframe in the lists is a tuple of (IAccessible2_2, uniqueId). This
239266 #: cache is used across instances.
240267 _framesCache = weakref .WeakKeyDictionary ()
268+ _nativeAppSelectionMode = False
269+
270+ @script (
271+ gesture = "kb:NVDA+shift+f10"
272+ )
273+ def script_toggleNativeAppSelectionMode (self , gesture ):
274+ self ._nativeAppSelectionMode = not self ._nativeAppSelectionMode
275+ if self ._nativeAppSelectionMode :
276+ ui .message (_ ("Native app selection mode enabled." ))
277+ try :
278+ self .updateAppSelection ()
279+ except NotImplementedError :
280+ pass
281+ else :
282+ ui .message (_ ("Native app selection mode disabled." ))
283+ try :
284+ self .clearAppSelection ()
285+ except NotImplementedError :
286+ pass
287+
288+ @script (
289+ gesture = "kb:control+c"
290+ )
291+ def script_copyToClipboard (self , gesture ):
292+ if self ._nativeAppSelectionMode :
293+ ui .message (_ ("native copy" ))
294+ gesture .send ()
295+ else :
296+ super ().script_copyToClipboard (gesture )
241297
242298 def __init__ (self ,rootNVDAObject ):
243299 super (Gecko_ia2 ,self ).__init__ (rootNVDAObject ,backendName = "gecko_ia2" )
@@ -576,3 +632,106 @@ def _getInitialCaretPos(self):
576632 if initialPos :
577633 return initialPos
578634 return self ._initialScrollObj
635+
636+ # C901 'updateAppSelection' is too complex
637+ # Note: when working on updateAppSelection, look for opportunities to simplify
638+ # and move logic out into smaller helper functions.
639+ def updateAppSelection (self ): # noqa: C901
640+ """Update the native selection in the application to match the browse mode selection in NVDA."""
641+ try :
642+ paccTextSelectionContainer = self .rootNVDAObject .IAccessibleObject .QueryInterface (
643+ IAccessibleTextSelectionContainer
644+ )
645+ except COMError as e :
646+ raise NotImplementedError from e
647+ selInfo = self .makeTextInfo (textInfos .POSITION_SELECTION )
648+ selFields = selInfo .getTextWithFields ()
649+ ia2StartWindow = None
650+ ia2StartID = None
651+ ia2StartOffset = None
652+ ia2EndWindow = None
653+ ia2EndID = None
654+ ia2EndOffset = None
655+ log .debug ("checking fields..." )
656+ # Locate the start of the selection by walking through the fields.
657+ # Until we find the deepest field with IAccessibleText information.
658+ # It may be on a formatChange which represents a text attribute run,
659+ # or on a controlStart which represents an embeded object within text,
660+ # Where we have not included its inner text attribute run
661+ # as the content was overridden by an ARIA label or similar.
662+ for field in selFields :
663+ if isinstance (field , textInfos .FieldCommand ):
664+ if field .command in ("controlStart" , "formatChange" ):
665+ hwnd = field .field .get ('ia2TextWindowHandle' )
666+ if hwnd is not None :
667+ ia2StartWindow = hwnd
668+ ia2StartID = field .field ['ia2TextUniqueID' ]
669+ ia2StartOffset = field .field ['ia2TextStartOffset' ]
670+ if field .command == "formatChange" :
671+ ia2StartOffset += field .field .get ('strippedCharsFromStart' , 0 )
672+ ia2StartOffset += field .field ['_offsetFromStartOfNode' ]
673+ if field .command == "controlStart" :
674+ continue
675+ break
676+ if ia2StartOffset is None :
677+ raise NotImplementedError
678+ log .debug (f"ia2StartWindow: { ia2StartWindow } " )
679+ log .debug (f"ia2StartID: { ia2StartID } " )
680+ log .debug (f"ia2StartOffset: { ia2StartOffset } " )
681+ ia2StartObj , childID = IAccessibleHandler .accessibleObjectFromEvent (
682+ ia2StartWindow , winUser .OBJID_CLIENT , ia2StartID
683+ )
684+ assert (childID == 0 ), f"childID should be 0"
685+ ia2StartObj = ia2StartObj .QueryInterface (IAccessibleText )
686+ log .debug (f"ia2StartObj { ia2StartObj } " )
687+ textLen = 0
688+ # Locate the end of the selection by walking through the fields in reverse,
689+ # similar to how we located the start of the selection.
690+ for field in reversed (selFields ):
691+ if isinstance (field , str ):
692+ textLen = len (field )
693+ continue
694+ elif isinstance (field , textInfos .FieldCommand ):
695+ if field .command in ("controlEnd" , "formatChange" ):
696+ hwnd = field .field .get ('ia2TextWindowHandle' )
697+ if hwnd is not None :
698+ ia2EndWindow = hwnd
699+ ia2EndID = field .field ['ia2TextUniqueID' ]
700+ ia2EndOffset = field .field ['ia2TextStartOffset' ]
701+ if field .command == "controlEnd" :
702+ ia2EndOffset += 1
703+ elif field .command == "formatChange" :
704+ ia2EndOffset += field .field .get ('strippedCharsFromStart' , 0 )
705+ ia2EndOffset += field .field ['_offsetFromStartOfNode' ]
706+ ia2EndOffset += textLen
707+ if field .command == "controlEnd" :
708+ continue
709+ break
710+ if ia2EndOffset is None :
711+ raise NotImplementedError
712+ log .debug (f"ia2EndWindow: { repr (ia2EndWindow )} " )
713+ log .debug (f"ia2EndID: { repr (ia2EndID )} " )
714+ log .debug (f"ia2EndOffset: { ia2EndOffset } " )
715+ if ia2EndID == ia2StartID :
716+ ia2EndObj = ia2StartObj
717+ log .debug ("Reusing ia2StartObj for ia2EndObj" )
718+ else :
719+ ia2EndObj , childID = IAccessibleHandler .accessibleObjectFromEvent (
720+ ia2EndWindow , winUser .OBJID_CLIENT , ia2EndID
721+ )
722+ assert (childID == 0 ), f"childID should be 0"
723+ ia2EndObj = ia2EndObj .QueryInterface (IAccessibleText )
724+ log .debug (f"ia2EndObj { ia2EndObj } " )
725+ r = IA2TextSelection (ia2StartObj , ia2StartOffset , ia2EndObj , ia2EndOffset , False )
726+ paccTextSelectionContainer .SetSelections (1 , byref (r ))
727+
728+ def clearAppSelection (self ):
729+ """Clear the native selection in the application."""
730+ try :
731+ paccTextSelectionContainer = self .rootNVDAObject .IAccessibleObject .QueryInterface (
732+ IAccessibleTextSelectionContainer
733+ )
734+ except COMError as e :
735+ raise NotImplementedError from e
736+ r = IA2TextSelection (None , 0 , None , 0 , False )
737+ paccTextSelectionContainer .SetSelections (0 , byref (r ))
0 commit comments