Coverage Report - orca.flat_review

ModuleCoverage %
orca.flat_review
60%
1
# Orca
2
#
3
# Copyright 2005-2006 Sun Microsystems Inc.
4
#
5
# This library is free software; you can redistribute it and/or
6
# modify it under the terms of the GNU Library General Public
7
# License as published by the Free Software Foundation; either
8
# version 2 of the License, or (at your option) any later version.
9
#
10
# This library is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13
# Library General Public License for more details.
14
#
15
# You should have received a copy of the GNU Library General Public
16
# License along with this library; if not, write to the
17
# Free Software Foundation, Inc., 59 Temple Place - Suite 330,
18
# Boston, MA 02111-1307, USA.
19
20 1
"""Provides the default implementation for flat review for Orca."""
21
22 1
__id__        = "$Id: flat_review.py 2126 2007-03-06 21:35:17Z richb $"
23 1
__version__   = "$Revision: 2126 $"
24 1
__date__      = "$Date: 2007-03-06 13:35:17 -0800 (Tue, 06 Mar 2007) $"
25 1
__copyright__ = "Copyright (c) 2005-2006 Sun Microsystems Inc."
26 1
__license__   = "LGPL"
27
28 1
import re
29 1
import sys
30
31 1
import atspi
32 1
import braille
33 1
import debug
34 1
import eventsynthesizer
35 1
import orca_state
36 1
import rolenames
37
38
# [[[WDW - HACK Regular expression to split strings on whitespace
39
# boundaries, which is what we'll use for word dividers instead of
40
# living at the whim of whomever decided to implement the AT-SPI
41
# interfaces for their toolkit or app.]]]
42
#
43 1
whitespace_re = re.compile(r'(\s+)', re.DOTALL | re.IGNORECASE | re.M)
44
45 2
class Char:
46
    """Represents a single char of an Accessibility_Text object."""
47
48 1
    def __init__(self,
49
                 word,
50
                 index,
51
                 string,
52
                 x, y, width, height):
53
        """Creates a new char.
54
55
        Arguments:
56
        - word: the Word instance this belongs to
57
        - index: the index of this char in the word
58
        - string: the actual char
59
        - x, y, width, height: the extents of this Char on the screen
60
        """
61
62 33
        self.word = word
63 33
        self.string = string
64 33
        self.index = index
65 33
        self.x = x
66 33
        self.y = y
67 33
        self.width = width
68 33
        self.height = height
69
70 2
class Word:
71
    """Represents a single word of an Accessibility_Text object, or
72
    the entire name of an Image or Component if the associated object
73
    does not implement the Accessibility_Text interface.  As a rule of
74
    thumb, all words derived from an Accessibility_Text interface will
75
    start with the word and will end with all chars up to the
76
    beginning of the next word.  That is, whitespace and punctuation
77
    will usually be tacked on to the end of words."""
78
79 1
    def __init__(self,
80
                 zone,
81
                 index,
82
                 startOffset,
83
                 string,
84
                 x, y, width, height):
85
        """Creates a new Word.
86
87
        Arguments:
88
        - zone: the Zone instance this belongs to
89
        - index: the index of this word in the Zone
90
        - string: the actual string
91
        - x, y, width, height: the extents of this Char on the screen"""
92
93 19
        self.zone = zone
94 19
        self.index = index
95 19
        self.startOffset = startOffset
96 19
        self.string = string
97 19
        self.length = len(string.decode("UTF-8"))
98 19
        self.x = x
99 19
        self.y = y
100 19
        self.width = width
101 19
        self.height = height
102
103 1
    def __getattr__(self, attr):
104
        """Used for lazily determining the chars of a word.  We do
105
        this to reduce the total number of round trip calls to the app,
106
        and to also spread the round trip calls out over the lifetime
107
        of a flat review context.
108
109
        Arguments:
110
        - attr: a string indicating the attribute name to retrieve
111
112
        Returns the value of the given attribute.
113
        """
114
115 7
        if attr == "chars":
116 7
            if isinstance(self.zone, TextZone):
117 7
                text = self.zone.accessible.text
118 7
                self.chars = []
119 7
                i = 0
120 40
                while i < self.length:
121 33
                    [char, startOffset, endOffset] = text.getTextAtOffset(
122
                        self.startOffset + i,
123 33
                        atspi.Accessibility.TEXT_BOUNDARY_CHAR)
124 33
                    [x, y, width, height] = text.getRangeExtents(
125
                        startOffset,
126 33
                        endOffset,
127 33
                        0)
128 33
                    self.chars.append(Char(self,
129 33
                                           i,
130 33
                                           char,
131 33
                                           x, y, width, height))
132 33
                    i += 1
133
            else:
134 0
                self.chars = None
135 7
            return self.chars
136 0
        elif attr.startswith('__') and attr.endswith('__'):
137 0
            raise AttributeError, attr
138
        else:
139 0
            return self.__dict__[attr]
140
141 2
class Zone:
142
    """Represents text that is a portion of a single horizontal line."""
143
144 1
    def __init__(self,
145
                 accessible,
146
                 string,
147
                 x, y,
148
                 width, height):
149
        """Creates a new Zone, which is a horizontal region of text.
150
151
        Arguments:
152
        - accessible: the Accessible associated with this Zone
153
        - string: the string being displayed for this Zone
154
        - extents: x, y, width, height in screen coordinates
155
        """
156
157 355
        self.accessible = accessible
158 355
        self.string = string
159 355
        self.length = len(string.decode("UTF-8"))
160 355
        self.x = x
161 355
        self.y = y
162 355
        self.width = width
163 355
        self.height = height
164
165 1
    def __getattr__(self, attr):
166
        """Used for lazily determining the words in a Zone.
167
168
        Arguments:
169
        - attr: a string indicating the attribute name to retrieve
170
171
        Returns the value of the given attribute.
172
        """
173
174 0
        if attr == "words":
175 0
            self.words = []
176 0
            return self.words
177 0
        elif attr.startswith('__') and attr.endswith('__'):
178 0
            raise AttributeError, attr
179
        else:
180 0
            return self.__dict__[attr]
181
182 1
    def onSameLine(self, zone):
183
        """Returns True if this Zone is on the same horiztonal line as
184
        the given zone."""
185
186 4129
        highestBottom = min(self.y + self.height, zone.y + zone.height)
187 4129
        lowestTop     = max(self.y,               zone.y)
188
189
        # If we do overlap, lets see how much.  We'll require a 25% overlap
190
        # for now...
191
        #
192 4129
        if lowestTop < highestBottom:
193 2924
            overlapAmount = highestBottom - lowestTop
194 2924
            shortestHeight = min(self.height, zone.height)
195 2924
            return ((1.0 * overlapAmount) / shortestHeight) > 0.25
196
        else:
197 1205
            return False
198
199 1
    def getWordAtOffset(self, charOffset):
200 0
        word = None
201 0
        offset = 0
202 0
        for word in self.words:
203 0
            nextOffset = offset + len(word.string.decode("UTF-8"))
204 0
            if nextOffset > charOffset:
205 0
                return [word, charOffset - offset]
206
            else:
207 0
                offset = nextOffset
208
209 0
        return [word, offset]
210
211 2
class TextZone(Zone):
212
    """Represents Accessibility_Text that is a portion of a single
213
    horizontal line."""
214
215 1
    def __init__(self,
216
                 accessible,
217
                 startOffset,
218
                 string,
219
                 x, y,
220
                 width, height):
221
        """Creates a new Zone, which is a horizontal region of text.
222
223
        Arguments:
224
        - accessible: the Accessible associated with this Zone
225
        - startOffset: the index of the char in the Accessibility_Text
226
                       interface where this Zone starts
227
        - string: the string being displayed for this Zone
228
        - extents: x, y, width, height in screen coordinates
229
        """
230
231 90
        Zone.__init__(self, accessible, string, x, y, width, height)
232 90
        self.startOffset = startOffset
233
234 1
    def __getattr__(self, attr):
235
        """Used for lazily determining the words in a Zone.  The words
236
        will either be all whitespace (interword boundaries) or actual
237
        words.  To determine if a Word is whitespace, use
238
        word.string.isspace()
239
240
        Arguments:
241
        - attr: a string indicating the attribute name to retrieve
242
243
        Returns the value of the given attribute.
244
        """
245
246 15
        if attr == "words":
247 15
            text = self.accessible.text
248 15
            self.words = []
249 15
            wordIndex = 0
250 15
            offset = self.startOffset
251 50
            for string in whitespace_re.split(self.string):
252 35
                if len(string):
253 19
                    endOffset = offset + len(string.decode("UTF-8"))
254 19
                    [x, y, width, height] = text.getRangeExtents(
255
                        offset,
256 19
                        endOffset,
257 19
                        0)
258 19
                    word = Word(self,
259 19
                                wordIndex,
260 19
                                offset,
261 19
                                string,
262 19
                                x, y, width, height)
263 19
                    self.words.append(word)
264 19
                    wordIndex += 1
265 19
                    offset = endOffset
266
267 15
            return self.words
268
269 0
        elif attr.startswith('__') and attr.endswith('__'):
270 0
            raise AttributeError, attr
271
        else:
272 0
            return self.__dict__[attr]
273
274 2
class Line:
275
    """A Line is a single line across a window and is composed of Zones."""
276
277 1
    def __init__(self,
278
                 index,
279
                 zones):
280
        """Creates a new Line, which is a horizontal region of text.
281
282
        Arguments:
283
        - index: the index of this Line in the window
284
        - zones: the Zones that make up this line
285
        """
286
287 69
        self.index = index
288 69
        self.zones = zones
289
290 69
        bounds = None
291 69
        self.string = ""
292 424
        for zone in self.zones:
293 355
            if not bounds:
294 69
                bounds = [zone.x, zone.y,
295
                          zone.x + zone.width, zone.y + zone.height]
296
            else:
297 286
                bounds[0] = min(bounds[0], zone.x)
298 286
                bounds[1] = min(bounds[1], zone.y)
299 286
                bounds[2] = max(bounds[2], zone.x + zone.width)
300 286
                bounds[3] = max(bounds[3], zone.y + zone.height)
301 355
            if len(zone.string):
302 355
                if len(self.string):
303 286
                    self.string += " "
304 355
                self.string += zone.string
305
306 69
        if not bounds:
307 0
            bounds = [-1, -1, -1, -1]
308
309 69
        self.x = bounds[0]
310 69
        self.y = bounds[1]
311 69
        self.width = bounds[2] - bounds[0]
312 69
        self.height = bounds[3] - bounds[1]
313
314 69
        self.brailleRegions = None
315
316 1
    def getBrailleRegions(self):
317 23
        if not self.brailleRegions:
318 10
            self.brailleRegions = []
319 10
            brailleOffset = 0
320 26
            for zone in self.zones:
321 16
                if (zone.accessible.role == rolenames.ROLE_TEXT) \
322
                   or (zone.accessible.role == rolenames.ROLE_PASSWORD_TEXT) \
323
                   or (zone.accessible.role == rolenames.ROLE_TERMINAL):
324 9
                    region = braille.ReviewText(zone.accessible,
325 9
                                                zone.string,
326 9
                                                zone.startOffset,
327 9
                                                zone)
328
                else:
329 7
                    region = braille.ReviewComponent(zone.accessible,
330 7
                                                     zone.string,
331 7
                                                     0, # cursor offset
332 7
                                                     zone)
333 16
                if len(self.brailleRegions):
334 6
                    pad = braille.Region(" ")
335 6
                    pad.brailleOffset = brailleOffset
336 6
                    self.brailleRegions.append(pad)
337 6
                    brailleOffset += 1
338
339 16
                zone.brailleRegion = region
340 16
                region.brailleOffset = brailleOffset
341 16
                self.brailleRegions.append(region)
342
343 16
                brailleOffset += len(region.string.decode("UTF-8"))
344
345 10
            if len(self.brailleRegions):
346 10
                pad = braille.Region(" ")
347 10
                pad.brailleOffset = brailleOffset
348 10
                self.brailleRegions.append(pad)
349 10
                brailleOffset += 1
350 10
            eol = braille.Region("$l")
351 10
            eol.brailleOffset = brailleOffset
352 10
            self.brailleRegions.append(eol)
353
354 23
        return self.brailleRegions
355
356 2
class Context:
357
    """Information regarding where a user happens to be exploring
358
    right now.
359
    """
360
361 1
    ZONE   = 0
362 1
    CHAR   = 1
363 1
    WORD   = 2
364 1
    LINE   = 3 # includes all zones on same line
365 1
    WINDOW = 4
366
367 1
    WRAP_NONE       = 0
368 1
    WRAP_LINE       = 1 << 0
369 1
    WRAP_TOP_BOTTOM = 1 << 1
370 1
    WRAP_ALL        = (WRAP_LINE | WRAP_TOP_BOTTOM)
371
372 1
    def __init__(self, script):
373
        """Create a new Context that will be used for handling flat
374
        review mode.
375
        """
376
377 5
        self.script    = script
378
379 5
        if (not orca_state.locusOfFocus) \
380
            or (orca_state.locusOfFocus.app != self.script.app):
381 0
            self.lines = []
382
        else:
383
            # We want to stop at the window or frame or equivalent level.
384
            #
385 5
            obj = orca_state.locusOfFocus
386 11
            while obj \
387
                      and obj.parent \
388
                      and (obj.parent.role != rolenames.ROLE_APPLICATION) \
389
                      and (obj != obj.parent):
390 6
                obj = obj.parent
391 5
            if obj:
392 5
                self.lines = self.clusterZonesByLine(self.getShowingZones(obj))
393
            else:
394 0
                self.lines = []
395
396 5
        currentLineIndex = 0
397 5
        currentZoneIndex = 0
398 5
        currentWordIndex = 0
399 5
        currentCharIndex = 0
400
401 5
        if orca_state.locusOfFocus and \
402
           orca_state.locusOfFocus.role == rolenames.ROLE_TABLE_CELL:
403 0
            searchZone = orca_state.activeScript.getRealActiveDescendant(\
404
                                                   orca_state.locusOfFocus)
405
        else:
406 5
            searchZone = orca_state.locusOfFocus
407
408 5
        foundZoneWithFocus = False
409 50
        while currentLineIndex < len(self.lines):
410 46
            line = self.lines[currentLineIndex]
411 46
            currentZoneIndex = 0
412 376
            while currentZoneIndex < len(line.zones):
413 331
                zone = line.zones[currentZoneIndex]
414 331
                if zone.accessible == searchZone:
415 1
                    foundZoneWithFocus = True
416 1
                    break
417
                else:
418 330
                    currentZoneIndex += 1
419 46
            if foundZoneWithFocus:
420 1
                break
421
            else:
422 45
                currentLineIndex += 1
423
424
        # Fallback to the first Zone if we didn't find anything.
425
        #
426 5
        if not foundZoneWithFocus:
427 4
            currentLineIndex = 0
428 4
            currentZoneIndex = 0
429 1
        elif isinstance(zone, TextZone):
430
            # If we're on an accessible text object, try to set the
431
            # review cursor to the caret position of that object.
432
            #
433 1
            accessible  = zone.accessible
434 1
            lineIndex   = currentLineIndex
435 1
            zoneIndex   = currentZoneIndex
436 1
            caretOffset = zone.accessible.text.caretOffset
437 1
            foundZoneWithCaret = False
438 1
            checkForEOF = False
439 2
            while lineIndex < len(self.lines):
440 2
                line = self.lines[lineIndex]
441 4
                while zoneIndex < len(line.zones):
442 3
                    zone = line.zones[zoneIndex]
443 3
                    if zone.accessible == accessible:
444 2
                        if (caretOffset >= zone.startOffset):
445 2
                            if (caretOffset \
446
                                    < (zone.startOffset + zone.length)):
447 1
                                foundZoneWithCaret = True
448 1
                                break
449 1
                            elif (caretOffset \
450
                                    == (zone.startOffset + zone.length)):
451 0
                                checkForEOF = True
452 0
                                lineToCheck = lineIndex
453 0
                                zoneToCheck = zoneIndex
454 2
                    zoneIndex += 1
455 2
                if foundZoneWithCaret:
456 1
                    currentLineIndex = lineIndex
457 1
                    currentZoneIndex = zoneIndex
458 1
                    currentWordIndex = 0
459 1
                    currentCharIndex = 0
460 1
                    offset = zone.startOffset
461 2
                    while currentWordIndex < len(zone.words):
462 2
                        word = zone.words[currentWordIndex]
463 2
                        if (word.length + offset) > caretOffset:
464 1
                            currentCharIndex = caretOffset - offset
465 1
                            break
466
                        else:
467 1
                            currentWordIndex += 1
468 1
                            offset += word.length
469 1
                    break
470
                else:
471 1
                    zoneIndex = 0
472 1
                    lineIndex += 1
473 1
            atEOF = not foundZoneWithCaret and checkForEOF
474 1
            if atEOF:
475 0
                line = self.lines[lineToCheck]
476 0
                zone = line.zones[zoneToCheck]
477 0
                currentLineIndex = lineToCheck
478 0
                currentZoneIndex = zoneToCheck
479 0
                if caretOffset:
480 0
                    currentWordIndex = len(zone.words) - 1
481 0
                    currentCharIndex = \
482
                          zone.words[currentWordIndex].length - 1
483
484 5
        self.lineIndex = currentLineIndex
485 5
        self.zoneIndex = currentZoneIndex
486 5
        self.wordIndex = currentWordIndex
487 5
        self.charIndex = currentCharIndex
488
489
        # This is used to tell us where we should strive to move to
490
        # when going up and down lines to the closest character.
491
        # The targetChar is the character where we initially started
492
        # moving from, and does not change when one moves up or down
493
        # by line.
494
        #
495 5
        self.targetCharInfo = None
496
497 1
    def visible(self,
498
                ax, ay, awidth, aheight,
499
                bx, by, bwidth, bheight):
500
        """Returns true if any portion of region 'a' is in region 'b'
501
        """
502 464
        highestBottom = min(ay + aheight, by + bheight)
503 464
        lowestTop = max(ay, by)
504
505 464
        leftMostRightEdge = min(ax + awidth, bx + bwidth)
506 464
        rightMostLeftEdge = max(ax, bx)
507
508 464
        visible = False
509
510 464
        if (lowestTop <= highestBottom) \
511
           and (rightMostLeftEdge <= leftMostRightEdge):
512 464
            visible = True
513 0
        elif (aheight == 0):
514 0
            if (awidth == 0):
515 0
                visible = (lowestTop == highestBottom) \
516
                          and (leftMostRightEdge == rightMostLeftEdge)
517
            else:
518 0
                visible = leftMostRightEdge <= rightMostLeftEdge
519 0
        elif (awidth == 0):
520 0
            visible = (lowestTop <= highestBottom)
521
522 464
        return visible
523
524 1
    def clip(self,
525
             ax, ay, awidth, aheight,
526
             bx, by, bwidth, bheight):
527
        """Clips region 'a' by region 'b' and returns the new region as
528
        a list: [x, y, width, height].
529
        """
530
531 395
        x = max(ax, bx)
532 395
        x2 = min(ax + awidth, bx + bwidth)
533 395
        width = x2 - x
534
535 395
        y = max(ay, by)
536 395
        y2 = min(ay + aheight, by + bheight)
537 395
        height = y2 - y
538
539 395
        return [x, y, width, height]
540
541 1
    def splitTextIntoZones(self, accessible, string, startOffset, cliprect):
542
        """Traverses the string, splitting it up into separate zones if the
543
        string contains the EMBEDDED_OBJECT_CHARACTER, which is used by apps
544
        such as Firefox to handle containment of things such as links in
545
        paragraphs.
546
547
        Arguments:
548
        - accessible: the accessible
549
        - string: a substring from the accessible's text specialization
550
        - startOffset: the starting character offset of the string
551
        - cliprect: the extents that the Zones must fit inside.
552
553
        Returns a list of Zones for the visible text or None if nothing is
554
        visible.
555
        """
556
557
        # We convert the string to unicode and walk through it.  While doing
558
        # this, we keep two sets of offsets:
559
        #
560
        # substring{Start,End}Offset: where in the accessible text implementation
561
        #                             we are
562
        #
563
        # unicodeStartOffset: where we are in the unicodeString
564
        #
565 90
        anyVisible = False
566 90
        zones = []
567 90
        text = accessible.text
568 90
        substringStartOffset = startOffset
569 90
        substringEndOffset   = startOffset
570 90
        unicodeStartOffset   = 0
571 90
        unicodeString = string.decode("UTF-8")
572
        #print "LOOKING AT '%s'" % unicodeString
573 552
        for i in range(0, len(unicodeString) + 1):
574 462
            if (i != len(unicodeString)) \
575
               and (unicodeString[i] != orca_state.activeScript.EMBEDDED_OBJECT_CHARACTER):
576 372
                substringEndOffset += 1
577 90
            elif (substringEndOffset == substringStartOffset):
578 0
                substringStartOffset += 1
579 0
                substringEndOffset   = substringStartOffset
580 0
                unicodeStartOffset   = i + 1
581
            else:
582 90
                [x, y, width, height] = text.getRangeExtents(substringStartOffset,
583 90
                                                             substringEndOffset,
584 90
                                                             0)
585 90
                if self.visible(x, y, width, height,
586 90
                                cliprect.x, cliprect.y,
587 90
                                cliprect.width, cliprect.height):
588
589 90
                    anyVisible = True
590
591 90
                    clipping = self.clip(x, y, width, height,
592 90
                                         cliprect.x, cliprect.y,
593 90
                                         cliprect.width, cliprect.height)
594
595
                    # [[[TODO: WDW - HACK it would be nice to clip the
596
                    # the text by what is really showing on the screen,
597
                    # but this seems to hang Orca and the client. Logged
598
                    # as bugzilla bug 319770.]]]
599
                    #
600
                    #ranges = text.getBoundedRanges(\
601
                    #    clipping[0],
602
                    #    clipping[1],
603
                    #    clipping[2],
604
                    #    clipping[3],
605
                    #    0,
606
                    #    atspi.Accessibility.TEXT_CLIP_BOTH,
607
                    #    atspi.Accessibility.TEXT_CLIP_BOTH)
608
                    #
609
                    #print
610
                    #print "HERE!"
611
                    #for range in ranges:
612
                    #    print range.startOffset
613
                    #    print range.endOffset
614
                    #    print range.content
615
616 90
                    substring = unicodeString[unicodeStartOffset:i]
617
                    #print " SUBSTRING '%s'" % substring
618 90
                    zones.append(TextZone(accessible,
619 90
                                          substringStartOffset,
620 90
                                          substring.encode("UTF-8"),
621 90
                                          clipping[0],
622 90
                                          clipping[1],
623 90
                                          clipping[2],
624 90
                                          clipping[3]))
625 90
                    substringStartOffset = substringEndOffset + 1
626 90
                    substringEndOffset   = substringStartOffset
627 90
                    unicodeStartOffset   = i + 1
628
629 90
        if anyVisible:
630 90
            return zones
631
        else:
632 0
            return None
633
634 1
    def getZonesFromText(self, accessible, cliprect):
635
        """Gets a list of Zones from an object that implements the
636
        AccessibleText specialization.
637
638
        Arguments:
639
        - accessible: the accessible
640
        - cliprect: the extents that the Zones must fit inside.
641
642
        Returns a list of Zones.
643
        """
644
645 97
        debug.println(debug.LEVEL_FINEST, "  looking at text:")
646
647 97
        if accessible.text:
648 97
            zones = []
649
        else:
650 0
            return []
651
652 97
        text = accessible.text
653 97
        length = text.characterCount
654
655 97
        offset = 0
656 97
        lastEndOffset = -1
657 187
        while offset < length:
658
659 90
            [string, startOffset, endOffset] = text.getTextAtOffset(
660
                offset,
661 90
                atspi.Accessibility.TEXT_BOUNDARY_LINE_START)
662
663
            #NEED TO SKIP OVER EMBEDDED_OBJECT_CHARACTERS
664
665
            #print "STRING at %d is (start=%d end=%d): '%s'" \
666
            #      % (offset, startOffset, endOffset, string)
667
            #if startOffset > offset:
668
            #    embedded = text.getText(offset, offset + 1).decode("UTF-8")
669
            #    if embedded[0] == orca_state.activeScript.EMBEDDED_OBJECT_CHARACTER:
670
            #        offset = startOffset
671
672 90
            debug.println(debug.LEVEL_FINEST,
673 90
                          "    line at %d is (start=%d end=%d): '%s'" \
674
                          % (offset, startOffset, endOffset, string))
675
676
            # [[[WDW - HACK: well...gnome-terminal sometimes wants to
677
            # give us outrageous values back from getTextAtOffset
678
            # (see http://bugzilla.gnome.org/show_bug.cgi?id=343133),
679
            # so we try to handle it.  Evolution does similar things.]]]
680
            #
681 90
            if (startOffset < 0) \
682
               or (endOffset < 0) \
683
               or (startOffset > offset) \
684
               or (endOffset < offset) \
685
               or (startOffset > endOffset) \
686
               or (abs(endOffset - startOffset) > 666e3):
687 0
                debug.println(debug.LEVEL_WARNING,
688 0
                              "flat_review:getZonesFromText detected "\
689
                              "garbage from getTextAtOffset for accessible "\
690
                              "name='%s' role'='%s': offset used=%d, "\
691
                              "start/end offset returned=(%d,%d), string='%s'"\
692
                              % (accessible.name, accessible.role,
693
                                 offset, startOffset, endOffset, string))
694 0
                break
695
696
            # [[[WDW - HACK: this is here because getTextAtOffset
697
            # tends not to be implemented consistently across toolkits.
698
            # Sometimes it behaves properly (i.e., giving us an endOffset
699
            # that is the beginning of the next line), sometimes it
700
            # doesn't (e.g., giving us an endOffset that is the end of
701
            # the current line).  So...we hack.  The whole 'max' deal
702
            # is to account for lines that might be a brazillion lines
703
            # long.]]]
704
            #
705 90
            if endOffset == lastEndOffset:
706 0
                offset = max(offset + 1, lastEndOffset + 1)
707 0
                lastEndOffset = endOffset
708 0
                continue
709
            else:
710 90
                offset = endOffset
711 90
                lastEndOffset = endOffset
712
713 90
            textZones = self.splitTextIntoZones(
714
                accessible, string, startOffset, cliprect)
715
716 90
            if textZones:
717 90
                zones.extend(textZones)
718 0
            elif len(zones):
719
                # We'll break out of searching all the text - the idea
720
                # here is that we'll at least try to optimize for when
721
                # we gone below the visible clipping area.
722
                #
723
                # [[[TODO: WDW - would be nice to optimize this better.
724
                # for example, perhaps we can assume the caret will always
725
                # be visible, and we can start our text search from there.
726
                # Logged as bugzilla bug 319771.]]]
727
                #
728 0
                break
729
730
        # We might have a zero length text area.  In that case, well,
731
        # lets hack if this is something whose sole purpose is to
732
        # act as a text entry area.
733
        #
734 97
        if len(zones) == 0:
735 30
            if (accessible.role == rolenames.ROLE_TEXT) \
736
               or ((accessible.role == rolenames.ROLE_ENTRY)) \
737
               or ((accessible.role == rolenames.ROLE_PASSWORD_TEXT)):
738 0
                extents = accessible.component.getExtents(0)
739 0
                zones.append(TextZone(accessible,
740 0
                                      0,
741 0
                                      "",
742 0
                                      extents.x, extents.y,
743 0
                                      extents.width, extents.height))
744
745 97
        return zones
746
747 1
    def getZonesFromAccessible(self, accessible, cliprect):
748
        """Returns a list of Zones for the given accessible.
749
750
        Arguments:
751
        - accessible: the accessible
752
        - cliprect: the extents that the Zones must fit inside.
753
        """
754
755 374
        if not accessible.component:
756 0
            return []
757
758
        # Get the component extents in screen coordinates.
759
        #
760 374
        extents = accessible.component.getExtents(0)
761
762 374
        if not self.visible(extents.x, extents.y,
763 374
                            extents.width, extents.height,
764 374
                            cliprect.x, cliprect.y,
765 374
                            cliprect.width, cliprect.height):
766 0
            return []
767
768 374
        debug.println(
769
            debug.LEVEL_FINEST,
770 374
            "flat_review.getZonesFromAccessible (name=%s role=%s)" \
771
            % (accessible.name, accessible.role))
772
773
        # Now see if there is any accessible text.  If so, find new zones,
774
        # where each zone represents a line of this text object.  When
775
        # creating the zone, only keep track of the text that is actually
776
        # showing on the screen.
777
        #
778 374
        if accessible.text:
779 97
            zones = self.getZonesFromText(accessible, cliprect)
780
        else:
781 277
            zones = []
782
783
        # We really want the accessible text information.  But, if we have
784
        # an image, and it has a description, we can fall back on it.
785
        #
786 374
        if (len(zones) == 0) \
787
               and accessible.image \
788
               and accessible.image.imageDescription \
789
               and len(accessible.image.imageDescription):
790
791 0
            [x, y] = accessible.image.getImagePosition(0)
792 0
            [width, height] = accessible.image.getImageSize()
793
794 0
            if (width != 0) and (height != 0) \
795
                   and self.visible(x, y, width, height,
796 0
                                    cliprect.x, cliprect.y,
797 0
                                    cliprect.width, cliprect.height):
798
799 0
                clipping = self.clip(x, y, width, height,
800 0
                                     cliprect.x, cliprect.y,
801 0
                                     cliprect.width, cliprect.height)
802
803 0
                if (clipping[2] != 0) or (clipping[3] != 0):
804 0
                    zones.append(Zone(accessible,
805 0
                                      accessible.image.imageDescription,
806 0
                                      clipping[0],
807 0
                                      clipping[1],
808 0
                                      clipping[2],
809 0
                                      clipping[3]))
810
811
        # If the accessible is a parent, we really only looked at it for
812
        # its accessible text.  So...we'll skip the hacking here if that's
813
        # the case.  [[[TODO: WDW - HACK That is, except in the case of
814
        # combo boxes, which don't implement the accesible text
815
        # interface.  We also hack with MENU items for similar reasons.]]]
816
        #
817
        # Otherwise, even if we didn't get anything of use, we certainly
818
        # know there's something there.  If that's the case, we'll just
819
        # use the component extents and the name or description of the
820
        # accessible.
821
        #
822 374
        if (accessible.role != rolenames.ROLE_COMBO_BOX) \
823
            and (accessible.role != rolenames.ROLE_EMBEDDED) \
824
            and (accessible.role != rolenames.ROLE_MENU) \
825
            and accessible.childCount > 0:
826 2
            pass
827 372
        elif len(zones) == 0:
828 305
            clipping = self.clip(extents.x, extents.y,
829 305
                                 extents.width, extents.height,
830 305
                                 cliprect.x, cliprect.y,
831 305
                                 cliprect.width, cliprect.height)
832 305
            if accessible.name and len(accessible.name):
833 196
                string = accessible.name
834 109
            elif accessible.description and len(accessible.description):
835 0
                string = accessible.description
836
            else:
837 109
                string = ""
838
839 305
            if string == "":
840
                # [[[TODO: WDW - ooohhhh....this is going to be a headache.
841
                # We want to synthesize a string for objects that are there,
842
                # but have no text.  For example, scroll bars.  The rub is
843
                # that we will sometimes want to do different strings for
844
                # speech and braille (e.g., checkbox cells in tables - the
845
                # speech will say "checkbox checked" and the braille will
846
                # display "checkbox <x>".  Yikes.  That may be a challenge.
847
                # So...for now we punt on table cells.  Logged as bugzilla
848
                # bug 319772.]]]
849
                #
850 109
                if accessible.role != rolenames.ROLE_TABLE_CELL:
851 109
                    string = accessible.role
852
853 305
            if len(string) and ((clipping[2] != 0) or (clipping[3] != 0)):
854 265
                zones.append(Zone(accessible,
855 265
                                  string,
856 265
                                  clipping[0],
857 265
                                  clipping[1],
858 265
                                  clipping[2],
859 265
                                  clipping[3]))
860
861 374
        return zones
862
863 1
    def getShowingDescendants(self, parent):
864
        """Given a parent that manages its descendants, return a list of
865
        Accessible children that are actually showing.  This algorithm
866
        was inspired a little by the srw_elements_from_accessible logic
867
        in Gnopernicus, and makes the assumption that the children of
868
        an object that manages its descendants are arranged in a row
869
        and column format.
870
        """
871
872 0
        if (not parent) or (not parent.component):
873 0
            return []
874
875
        # A minimal chunk to jump around should we not really know where we
876
        # are going.
877
        #
878 0
        GRID_SIZE = 7
879
880 0
        descendants = []
881
882 0
        parentExtents = parent.component.getExtents(0)
883
884
        # [[[TODO: WDW - HACK related to GAIL bug where table column headers
885
        # seem to be ignored: http://bugzilla.gnome.org/show_bug.cgi?id=325809.
886
        # The problem is that this causes getAccessibleAtPoint to return the
887
        # cell effectively below the real cell at a given point, making a mess
888
        # of everything.  So...we just manually add in showing headers for now.
889
        # The remainder of the logic below accidentally accounts for this offset,
890
        # yet it should also work when bug 325809 is fixed.]]]
891
        #
892 0
        table = parent.table
893 0
        if table:
894 0
            for i in range(0, table.nColumns):
895 0
                obj = table.getColumnHeader(i)
896 0
                if obj:
897 0
                    header = atspi.Accessible.makeAccessible(obj)
898 0
                    extents = header.extents
899 0
                    if header.state.count(atspi.Accessibility.STATE_SHOWING) \
900
                       and (extents.x >= 0) and (extents.y >= 0) \
901
                       and (extents.width > 0) and (extents.height > 0) \
902
                       and self.visible(extents.x, extents.y,
903 0
                                        extents.width, extents.height,
904 0
                                        parentExtents.x, parentExtents.y,
905 0
                                        parentExtents.width,
906 0
                                        parentExtents.height):
907 0
                        descendants.append(header)
908
909
        # This algorithm goes left to right, top to bottom while attempting
910
        # to do *some* optimization over queries.  It could definitely be
911
        # improved.
912
        #
913 0
        currentY = parentExtents.y
914 0
        while currentY < (parentExtents.y + parentExtents.height):
915 0
            currentX = parentExtents.x
916 0
            minHeight = sys.maxint
917 0
            while currentX < (parentExtents.x + parentExtents.width):
918 0
                obj = parent.component.getAccessibleAtPoint(currentX,
919 0
                                                            currentY,
920 0
                                                            0)
921 0
                if obj:
922 0
                    child = atspi.Accessible.makeAccessible(obj)
923 0
                    extents = child.extents
924 0
                    if extents.x >= 0 and extents.y >= 0:
925 0
                        newX = extents.x + extents.width
926 0
                        minHeight = min(minHeight, extents.height)
927 0
                        if not descendants.count(child):
928 0
                            descendants.append(child)
929
                    else:
930 0
                        newX = currentX + GRID_SIZE
931
                else:
932 0
                    newX = currentX + GRID_SIZE
933 0
                if newX <= currentX:
934 0
                    currentX += GRID_SIZE
935
                else:
936 0
                    currentX = newX
937 0
            if minHeight == sys.maxint:
938 0
                minHeight = GRID_SIZE
939 0
            currentY += minHeight
940
941 0
        return descendants
942
943 1
    def getShowingZones(self, root):
944
        """Returns a list of all interesting, non-intersecting, regions
945
        that are drawn on the screen.  Each element of the list is the
946
        Accessible object associated with a given region.  The term
947
        'zone' here is inherited from OCR algorithms and techniques.
948
949
        The Zones are returned in no particular order.
950
951
        Arguments:
952
        - root: the Accessible object to traverse
953
954
        Returns: a list of Zones under the specified object
955
        """
956
957 435
        if not root:
958 0
            return []
959
960
        # If we're at a leaf node, then we've got a good one on our hands.
961
        #
962 435
        if root.childCount <= 0:
963 318
            return self.getZonesFromAccessible(root, root.extents)
964
965
        # We'll stop at various objects because, while they do have
966
        # children, we logically think of them as one region on the
967
        # screen.  [[[TODO: WDW - HACK stopping at menu bars for now
968
        # because their menu items tell us they are showing even though
969
        # they are not showing.  Until I can figure out a reliable way to
970
        # get past these lies, I'm going to ignore them.]]]
971
        #
972 117
        if (root.parent and (root.parent.role == rolenames.ROLE_MENU_BAR)) \
973
           or (root.role == rolenames.ROLE_COMBO_BOX) \
974
           or (root.role == rolenames.ROLE_EMBEDDED) \
975
           or (root.role == rolenames.ROLE_TEXT):
976 54
            return self.getZonesFromAccessible(root, root.extents)
977
978
        # Otherwise, dig deeper.
979
        #
980
        # We'll include page tabs: while they are parents, their extents do
981
        # not contain their children. [[[TODO: WDW - need to consider all
982
        # parents, especially those that implement accessible text.  Logged
983
        # as bugzilla bug 319773.]]]
984
        #
985 63
        zones = []
986 63
        if root.role == rolenames.ROLE_PAGE_TAB:
987 1
            zones.extend(self.getZonesFromAccessible(root, root.extents))
988
989 63
        if (len(zones) == 0) and root.text:
990 1
            zones = self.getZonesFromAccessible(root, root.extents)
991
992 63
        if root.state.count(atspi.Accessibility.STATE_MANAGES_DESCENDANTS) \
993
           and (root.childCount > 50):
994 0
            for child in self.getShowingDescendants(root):
995 0
                zones.extend(self.getShowingZones(child))
996
        else:
997 561
            for i in range(0, root.childCount):
998 498
                child = root.child(i)
999 498
                if child == root:
1000 0
                    debug.println(debug.LEVEL_WARNING,
1001 0
                                  "flat_review.getShowingZones: " +
1002
                                  "WARNING CHILD == PARENT!!!")
1003 498
                elif not child:
1004 0
                    debug.println(debug.LEVEL_WARNING,
1005 0
                                  "flat_review.getShowingZones: " +
1006
                                  "WARNING CHILD IS NONE!!!")
1007 498
                elif child.parent != root:
1008 0
                    debug.println(debug.LEVEL_WARNING,
1009 0
                                  "flat_review.getShowingZones: " +
1010
                                  "WARNING CHILD.PARENT != PARENT!!!")
1011 498
                elif self.script.pursueForFlatReview(child):
1012 430
                    zones.extend(self.getShowingZones(child))
1013
1014 63
        return zones
1015
1016 1
    def clusterZonesByLine(self, zones):
1017
        """Given a list of interesting accessible objects (the Zones),
1018
        returns a list of lines in order from the top to bottom, where
1019
        each line is a list of accessible objects in order from left
1020
        to right.
1021
        """
1022
1023 5
        if len(zones) == 0:
1024 0
            return []
1025
1026
        # Sort the zones and also find the top most zone - we'll bias
1027
        # the clustering to the top of the window.  That is, if an
1028
        # object can be part of multiple clusters, for now it will
1029
        # become a part of the top most cluster.
1030
        #
1031 5
        numZones = len(zones)
1032 360
        for i in range(0, numZones):
1033 13780
            for j in range(0, numZones - 1 - i):
1034 13425
                a = zones[j]
1035 13425
                b = zones[j + 1]
1036 13425
                if b.y < a.y:
1037 423
                    zones[j] = b
1038 423
                    zones[j + 1] = a
1039
1040
        # Now we cluster the zones.  We create the clusters on the
1041
        # fly, adding a zone to an existing cluster only if it's
1042
        # rectangle horizontally overlaps all other zones in the
1043
        # cluster.
1044
        #
1045 5
        lineClusters = []
1046 360
        for clusterCandidate in zones:
1047 355
            addedToCluster = False
1048 1560
            for lineCluster in lineClusters:
1049 1491
                inCluster = True
1050 4415
                for zone in lineCluster:
1051 4129
                    if not zone.onSameLine(clusterCandidate):
1052 1205
                        inCluster = False
1053 1205
                        break
1054 1491
                if inCluster:
1055
                    # Add to cluster based on the x position.
1056
                    #
1057 286
                    i = 0
1058 2874
                    while i < len(lineCluster):
1059 2640
                        zone = lineCluster[i]
1060 2640
                        if clusterCandidate.x < zone.x:
1061 52
                            break
1062
                        else:
1063 2588
                            i += 1
1064 286
                    lineCluster.insert(i, clusterCandidate)
1065 286
                    addedToCluster = True
1066 286
                    break
1067 355
            if not addedToCluster:
1068 69
                lineClusters.append([clusterCandidate])
1069
1070
        # Now, adjust all the indeces.
1071
        #
1072 5
        lines = []
1073 5
        lineIndex = 0
1074 74
        for lineCluster in lineClusters:
1075 69
            lines.append(Line(lineIndex, lineCluster))
1076 69
            zoneIndex = 0
1077 424
            for zone in lineCluster:
1078 355
                zone.line = lines[lineIndex]
1079 355
                zone.index = zoneIndex
1080 355
                zoneIndex += 1
1081 69
            lineIndex += 1
1082
1083 5
        return lines
1084
1085 1
    def _dumpCurrentState(self):
1086 0
        print "line=%d, zone=%d, word=%d, char=%d" \
1087
              % (self.lineIndex,
1088
                 self.zoneIndex,
1089
                 self.wordIndex,
1090
                 self.zoneIndex)
1091
1092 0
        zone = self.lines[self.lineIndex].zones[self.zoneIndex]
1093 0
        text = zone.accessible.text
1094
1095 0
        if not text:
1096 0
            print "  Not Accessibility_Text"
1097 0
            return
1098
1099 0
        print "  getTextBeforeOffset: %d" % text.caretOffset
1100 0
        [string, startOffset, endOffset] = text.getTextBeforeOffset(
1101
            text.caretOffset,
1102 0
            atspi.Accessibility.TEXT_BOUNDARY_WORD_START)
1103 0
        print "    WORD_START: start=%d end=%d string='%s'" \
1104
              % (startOffset, endOffset, string)
1105 0
        [string, startOffset, endOffset] = text.getTextBeforeOffset(
1106
            text.caretOffset,
1107 0
            atspi.Accessibility.TEXT_BOUNDARY_WORD_END)
1108 0
        print "    WORD_END:   start=%d end=%d string='%s'" \
1109
              % (startOffset, endOffset, string)
1110
1111 0
        print "  getTextAtOffset: %d" % text.caretOffset
1112 0
        [string, startOffset, endOffset] = text.getTextAtOffset(
1113
            text.caretOffset,
1114 0
            atspi.Accessibility.TEXT_BOUNDARY_WORD_START)
1115 0
        print "    WORD_START: start=%d end=%d string='%s'" \
1116
              % (startOffset, endOffset, string)
1117 0
        [string, startOffset, endOffset] = text.getTextAtOffset(
1118
            text.caretOffset,
1119 0
            atspi.Accessibility.TEXT_BOUNDARY_WORD_END)
1120 0
        print "    WORD_END:   start=%d end=%d string='%s'" \
1121
              % (startOffset, endOffset, string)
1122
1123 0
        print "  getTextAfterOffset: %d" % text.caretOffset
1124 0
        [string, startOffset, endOffset] = text.getTextAfterOffset(
1125
            text.caretOffset,
1126 0
            atspi.Accessibility.TEXT_BOUNDARY_WORD_START)
1127 0
        print "    WORD_START: start=%d end=%d string='%s'" \
1128
              % (startOffset, endOffset, string)
1129 0
        [string, startOffset, endOffset] = text.getTextAfterOffset(
1130
            text.caretOffset,
1131 0
            atspi.Accessibility.TEXT_BOUNDARY_WORD_END)
1132 0
        print "    WORD_END:   start=%d end=%d string='%s'" \
1133
              % (startOffset, endOffset, string)
1134
1135 1
    def setCurrent(self, lineIndex, zoneIndex, wordIndex, charIndex):
1136
        """Sets the current character of interest.
1137
1138
        Arguments:
1139
        - lineIndex: index into lines
1140
        - zoneIndex: index into lines[lineIndex].zones
1141
        - wordIndex: index into lines[lineIndex].zones[zoneIndex].words
1142
        - charIndex: index lines[lineIndex].zones[zoneIndex].words[wordIndex].chars
1143
        """
1144
1145 0
        self.lineIndex = lineIndex
1146 0
        self.zoneIndex = zoneIndex
1147 0
        self.wordIndex = wordIndex
1148 0
        self.charIndex = charIndex
1149 0
        self.targetCharInfo = self.getCurrent(Context.CHAR)
1150
1151
        #print "Current line=%d zone=%d word=%d char=%d" \
1152
        #      % (lineIndex, zoneIndex, wordIndex, charIndex)
1153
1154 1
    def clickCurrent(self, button=1):
1155
        """Performs a mouse click on the current accessible."""
1156
1157 0
        if (not self.lines) \
1158
           or (not self.lines[self.lineIndex].zones):
1159 0
            return
1160
1161 0
        [string, x, y, width, height] = self.getCurrent(Context.CHAR)
1162 0
        try:
1163
1164
            # We try to click to the left of center.  This is to
1165
            # handle toolkits that will offset the caret position to
1166
            # the right if you click dead on center of a character.
1167
            #
1168 0
            eventsynthesizer.clickPoint(x,
1169 0
                                        y + height/ 2,
1170 0
                                        button)
1171 0
        except:
1172 0
            debug.printException(debug.LEVEL_SEVERE)
1173
1174 1
    def getCurrentAccessible(self):
1175
        """Returns the accessible associated with the current locus of
1176
        interest.
1177
        """
1178
1179 0
        if (not self.lines) \
1180
           or (not self.lines[self.lineIndex].zones):
1181 0
            return [None, -1, -1, -1, -1]
1182
1183 0
        zone = self.lines[self.lineIndex].zones[self.zoneIndex]
1184
1185 0
        return zone.accessible
1186
1187 1
    def getCurrent(self, type=ZONE):
1188
        """Gets the string, offset, and extent information for the
1189
        current locus of interest.
1190
1191
        Arguments:
1192
        - type: one of ZONE, CHAR, WORD, LINE
1193
1194
        Returns: [string, x, y, width, height]
1195
        """
1196
1197 59
        if (not self.lines) \
1198
           or (not self.lines[self.lineIndex].zones):
1199 0
            return [None, -1, -1, -1, -1]
1200
1201 59
        zone = self.lines[self.lineIndex].zones[self.zoneIndex]
1202
1203 59
        if type == Context.ZONE:
1204 0
            return [zone.string,
1205
                    zone.x,
1206
                    zone.y,
1207
                    zone.width,
1208
                    zone.height]
1209 59
        elif type == Context.CHAR:
1210 24
            if isinstance(zone, TextZone):
1211 24
                words = zone.words
1212 24
                if words:
1213 24
                    chars = zone.words[self.wordIndex].chars
1214 24
                    if chars:
1215 24
                        char = chars[self.charIndex]
1216 24
                        return [char.string,
1217
                                char.x,
1218
                                char.y,
1219
                                char.width,
1220
                                char.height]
1221
                    else:
1222 0
                        word = words[self.wordIndex]
1223 0
                        return [word.string,
1224
                                word.x,
1225
                                word.y,
1226
                                word.width,
1227
                                word.height]
1228 0
            return self.getCurrent(Context.ZONE)
1229 35
        elif type == Context.WORD:
1230 12
            if isinstance(zone, TextZone):
1231 12
                words = zone.words
1232 12
                if words:
1233 12
                    word = words[self.wordIndex]
1234 12
                    return [word.string,
1235
                            word.x,
1236
                            word.y,
1237
                            word.width,
1238
                            word.height]
1239 0
            return self.getCurrent(Context.ZONE)
1240 23
        elif type == Context.LINE:
1241 23
            line = self.lines[self.lineIndex]
1242 23
            return [line.string,
1243
                    line.x,
1244
                    line.y,
1245
                    line.width,
1246
                    line.height]
1247
        else:
1248 0
            raise Exception("Invalid type: %d" % type)
1249
1250 1
    def getCurrentBrailleRegions(self):
1251
        """Gets the braille for the entire current line.
1252
1253
        Returns [regions, regionWithFocus]
1254
        """
1255
1256 23
        if (not self.lines) \
1257
           or (not self.lines[self.lineIndex].zones):
1258 0
            return [None, None]
1259
1260 23
        regionWithFocus = None
1261 23
        line = self.lines[self.lineIndex]
1262 23
        regions = line.getBrailleRegions()
1263
1264
        # Now find the current region and the current character offset
1265
        # into that region.
1266
        #
1267 53
        for zone in line.zones:
1268 53
            if zone.index == self.zoneIndex:
1269 23
                regionWithFocus = zone.brailleRegion
1270 23
                regionWithFocus.cursorOffset = 0
1271 23
                if zone.words:
1272 23
                    for wordIndex in range(0, self.wordIndex):
1273 0
                        regionWithFocus.cursorOffset += \
1274
                            len(zone.words[wordIndex].string.decode("UTF-8"))
1275 23
                regionWithFocus.cursorOffset += self.charIndex
1276 23
                break
1277
1278 23
        return [regions, regionWithFocus]
1279
1280 1
    def goBegin(self, type=WINDOW):
1281
        """Moves this context's locus of interest to the first char
1282
        of the first relevant zone.
1283
1284
        Arguments:
1285
        - type: one of ZONE, LINE or WINDOW
1286
1287
        Returns True if the locus of interest actually changed.
1288
        """
1289
1290 0
        if (type == Context.LINE) or (type == Context.ZONE):
1291 0
            lineIndex = self.lineIndex
1292 0
        elif type == Context.WINDOW:
1293 0
            lineIndex = 0
1294
        else:
1295 0
            raise Exception("Invalid type: %d" % type)
1296
1297 0
        if type == Context.ZONE:
1298 0
            zoneIndex = self.zoneIndex
1299
        else:
1300 0
            zoneIndex = 0
1301
1302 0
        wordIndex = 0
1303 0
        charIndex = 0
1304
1305 0
        moved = (self.lineIndex != lineIndex) \
1306
                or (self.zoneIndex != zoneIndex) \
1307
                or (self.wordIndex != wordIndex) \
1308
                or (self.charIndex != charIndex) \
1309
1310 0
        if moved:
1311 0
            self.lineIndex = lineIndex
1312 0
            self.zoneIndex = zoneIndex
1313 0
            self.wordIndex = wordIndex
1314 0
            self.charIndex = charIndex
1315 0
            self.targetCharInfo = self.getCurrent(Context.CHAR)
1316
1317 0
        return moved
1318
1319 1
    def goEnd(self, type=WINDOW):
1320
        """Moves this context's locus of interest to the last char
1321
        of the last relevant zone.
1322
1323
        Arguments:
1324
        - type: one of ZONE, LINE, or WINDOW
1325
1326
        Returns True if the locus of interest actually changed.
1327
        """
1328
1329 0
        if (type == Context.LINE) or (type == Context.ZONE):
1330 0
            lineIndex = self.lineIndex
1331 0
        elif type == Context.WINDOW:
1332 0
            lineIndex  = len(self.lines) - 1
1333
        else:
1334 0
            raise Exception("Invalid type: %d" % type)
1335
1336 0
        if type == Context.ZONE:
1337 0
            zoneIndex = self.zoneIndex
1338
        else:
1339 0
            zoneIndex = len(self.lines[lineIndex].zones) - 1
1340
1341 0
        zone = self.lines[lineIndex].zones[zoneIndex]
1342 0
        if zone.words:
1343 0
            wordIndex = len(zone.words) - 1
1344 0
            chars = zone.words[wordIndex].chars
1345 0
            if chars:
1346 0
                charIndex = len(chars) - 1
1347
            else:
1348 0
                charIndex = 0
1349
        else:
1350 0
            wordIndex = 0
1351 0
            charIndex = 0
1352
1353 0
        moved = (self.lineIndex != lineIndex) \
1354
                or (self.zoneIndex != zoneIndex) \
1355
                or (self.wordIndex != wordIndex) \
1356
                or (self.charIndex != charIndex) \
1357
1358 0
        if moved:
1359 0
            self.lineIndex = lineIndex
1360 0
            self.zoneIndex = zoneIndex
1361 0
            self.wordIndex = wordIndex
1362 0
            self.charIndex = charIndex
1363 0
            self.targetCharInfo = self.getCurrent(Context.CHAR)
1364
1365 0
        return moved
1366
1367 1
    def goPrevious(self, type=ZONE, wrap=WRAP_ALL, omitWhitespace=True):
1368
        """Moves this context's locus of interest to the first char
1369
        of the previous type.
1370
1371
        Arguments:
1372
        - type: one of ZONE, CHAR, WORD, LINE
1373
        - wrap: if True, will cross boundaries, including top and
1374
                bottom; if False, will stop on boundaries.
1375
1376
        Returns True if the locus of interest actually changed.
1377
        """
1378
1379 18
        moved = False
1380
1381 18
        if type == Context.ZONE:
1382 6
            if self.zoneIndex > 0:
1383 5
                self.zoneIndex -= 1
1384 5
                self.wordIndex = 0
1385 5
                self.charIndex = 0
1386 5
                moved = True
1387 1
            elif wrap & Context.WRAP_LINE:
1388 1
                if self.lineIndex > 0:
1389 1
                    self.lineIndex -= 1
1390 1
                    self.zoneIndex = len(self.lines[self.lineIndex].zones) - 1
1391 1
                    self.wordIndex = 0
1392 1
                    self.charIndex = 0
1393 1
                    moved = True
1394 0
                elif wrap & Context.WRAP_TOP_BOTTOM:
1395 0
                    self.lineIndex = len(self.lines) - 1
1396 0
                    self.zoneIndex = len(self.lines[self.lineIndex].zones) - 1
1397 0
                    self.wordIndex = 0
1398 0
                    self.charIndex = 0
1399 0
                    moved = True
1400 12
        elif type == Context.CHAR:
1401 0
            if self.charIndex > 0:
1402 0
                self.charIndex -= 1
1403 0
                moved = True
1404
            else:
1405 0
                moved = self.goPrevious(Context.WORD, wrap, False)
1406 0
                if moved:
1407 0
                    zone = self.lines[self.lineIndex].zones[self.zoneIndex]
1408 0
                    if zone.words:
1409 0
                        chars = zone.words[self.wordIndex].chars
1410 0
                        if chars:
1411 0
                            self.charIndex = len(chars) - 1
1412 12
        elif type == Context.WORD:
1413 6
            zone = self.lines[self.lineIndex].zones[self.zoneIndex]
1414 6
            accessible = zone.accessible
1415 6
            lineIndex = self.lineIndex
1416 6
            zoneIndex = self.zoneIndex
1417 6
            wordIndex = self.wordIndex
1418 6
            charIndex = self.charIndex
1419
1420 6
            if self.wordIndex > 0:
1421 0
                self.wordIndex -= 1
1422 0
                self.charIndex = 0
1423 0
                moved = True
1424
            else:
1425 6
                moved = self.goPrevious(Context.ZONE, wrap)
1426 6
                if moved:
1427 6
                    zone = self.lines[self.lineIndex].zones[self.zoneIndex]
1428 6
                    if zone.words:
1429 6
                        self.wordIndex = len(zone.words) - 1
1430
1431
            # If we landed on a whitespace word or something with no words,
1432
            # we might need to move some more.
1433
            #
1434 6
            zone = self.lines[self.lineIndex].zones[self.zoneIndex]
1435 6
            if omitWhitespace \
1436
               and moved \
1437
               and ((len(zone.words) == 0) \
1438
                    or zone.words[self.wordIndex].string.isspace()):
1439
1440
                # If we're on whitespace in the same zone, then let's
1441
                # try to move on.  If not, we've definitely moved
1442
                # across accessibles.  If that's the case, let's try
1443
                # to find the first 'real' word in the accessible.
1444
                # If we cannot, then we're just stuck on an accessible
1445
                # with no words and we should do our best to announce
1446
                # this to the user (e.g., "whitespace" or "blank").
1447
                #
1448 0
                if zone.accessible == accessible:
1449 0
                    moved = self.goPrevious(Context.WORD, wrap)
1450
                else:
1451 0
                    wordIndex = self.wordIndex - 1
1452 0
                    while wordIndex >= 0:
1453 0
                        if (not zone.words[wordIndex].string) \
1454
                            or not len(zone.words[wordIndex].string) \
1455
                            or zone.words[wordIndex].string.isspace():
1456 0
                            wordIndex -= 1
1457
                        else:
1458 0
                            break
1459 0
                    if wordIndex >= 0:
1460 0
                        self.wordIndex = wordIndex
1461
1462 6
            if not moved:
1463 0
                self.lineIndex = lineIndex
1464 0
                self.zoneIndex = zoneIndex
1465 0
                self.wordIndex = wordIndex
1466 0
                self.charIndex = charIndex
1467
1468 6
        elif type == Context.LINE:
1469 6
            if wrap & Context.WRAP_LINE:
1470 6
                if self.lineIndex > 0:
1471 2
                    self.lineIndex -= 1
1472 2
                    self.zoneIndex = 0
1473 2
                    self.wordIndex = 0
1474 2
                    self.charIndex = 0
1475 2
                    moved = True
1476 4
                elif (wrap & Context.WRAP_TOP_BOTTOM) \
1477
                     and (len(self.lines) != 1):
1478 0
                    self.lineIndex = len(self.lines) - 1
1479 0
                    self.zoneIndex = 0
1480 0
                    self.wordIndex = 0
1481 0
                    self.charIndex = 0
1482 0
                    moved = True
1483
        else:
1484 0
            raise Exception("Invalid type: %d" % type)
1485
1486 18
        if moved and (type != Context.LINE):
1487 12
            self.targetCharInfo = self.getCurrent(Context.CHAR)
1488
1489 18
        return moved
1490
1491 1
    def goNext(self, type=ZONE, wrap=WRAP_ALL, omitWhitespace=True):
1492
        """Moves this context's locus of interest to first char of
1493
        the next type.
1494
1495
        Arguments:
1496
        - type: one of ZONE, CHAR, WORD, LINE
1497
        - wrap: if True, will cross boundaries, including top and
1498
                bottom; if False, will stop on boundaries.
1499
        """
1500
1501 21
        moved = False
1502
1503 21
        if type == Context.ZONE:
1504 6
            if self.zoneIndex < (len(self.lines[self.lineIndex].zones) - 1):
1505 5
                self.zoneIndex += 1
1506 5
                self.wordIndex = 0
1507 5
                self.charIndex = 0
1508 5
                moved = True
1509 1
            elif wrap & Context.WRAP_LINE:
1510 1
                if self.lineIndex < (len(self.lines) - 1):
1511 1
                    self.lineIndex += 1
1512 1
                    self.zoneIndex  = 0
1513 1
                    self.wordIndex = 0
1514 1
                    self.charIndex = 0
1515 1
                    moved = True
1516 0
                elif wrap & Context.WRAP_TOP_BOTTOM:
1517 0
                    self.lineIndex  = 0
1518 0
                    self.zoneIndex  = 0
1519 0
                    self.wordIndex = 0
1520 0
                    self.charIndex = 0
1521 0
                    moved = True
1522 15
        elif type == Context.CHAR:
1523 0
            zone = self.lines[self.lineIndex].zones[self.zoneIndex]
1524 0
            if zone.words:
1525 0
                chars = zone.words[self.wordIndex].chars
1526 0
                if chars:
1527 0
                    if self.charIndex < (len(chars) - 1):
1528 0
                        self.charIndex += 1
1529 0
                        moved = True
1530
                    else:
1531 0
                        moved = self.goNext(Context.WORD, wrap, False)
1532
                else:
1533 0
                    moved = self.goNext(Context.WORD, wrap)
1534
            else:
1535 0
                moved = self.goNext(Context.ZONE, wrap)
1536 15
        elif type == Context.WORD:
1537 6
            zone = self.lines[self.lineIndex].zones[self.zoneIndex]
1538 6
            accessible = zone.accessible
1539 6
            lineIndex = self.lineIndex
1540 6
            zoneIndex = self.zoneIndex
1541 6
            wordIndex = self.wordIndex
1542 6
            charIndex = self.charIndex
1543
1544 6
            if zone.words:
1545 6
                if self.wordIndex < (len(zone.words) - 1):
1546 0
                    self.wordIndex += 1
1547 0
                    self.charIndex = 0
1548 0
                    moved = True
1549
                else:
1550 6
                    moved = self.goNext(Context.ZONE, wrap)
1551
            else:
1552 0
                moved = self.goNext(Context.ZONE, wrap)
1553
1554
            # If we landed on a whitespace word or something with no words,
1555
            # we might need to move some more.
1556
            #
1557 6
            zone = self.lines[self.lineIndex].zones[self.zoneIndex]
1558 6
            if omitWhitespace \
1559
               and moved \
1560
               and ((len(zone.words) == 0) \
1561
                    or zone.words[self.wordIndex].string.isspace()):
1562
1563
                # If we're on whitespace in the same zone, then let's
1564
                # try to move on.  If not, we've definitely moved
1565
                # across accessibles.  If that's the case, let's try
1566
                # to find the first 'real' word in the accessible.
1567
                # If we cannot, then we're just stuck on an accessible
1568
                # with no words and we should do our best to announce
1569
                # this to the user (e.g., "whitespace" or "blank").
1570
                #
1571 0
                if zone.accessible == accessible:
1572 0
                    moved = self.goNext(Context.WORD, wrap)
1573
                else:
1574 0
                    wordIndex = self.wordIndex + 1
1575 0
                    while wordIndex < len(zone.words):
1576 0
                        if (not zone.words[wordIndex].string) \
1577
                            or not len(zone.words[wordIndex].string) \
1578
                            or zone.words[wordIndex].string.isspace():
1579 0
                            wordIndex += 1
1580
                        else:
1581 0
                            break
1582 0
                    if wordIndex < len(zone.words):
1583 0
                        self.wordIndex = wordIndex
1584
1585 6
            if not moved:
1586 0
                self.lineIndex = lineIndex
1587 0
                self.zoneIndex = zoneIndex
1588 0
                self.wordIndex = wordIndex
1589 0
                self.charIndex = charIndex
1590
1591 9
        elif type == Context.LINE:
1592 9
            if wrap & Context.WRAP_LINE:
1593 9
                if self.lineIndex < (len(self.lines) - 1):
1594 9
                    self.lineIndex += 1
1595 9
                    self.zoneIndex = 0
1596 9
                    self.wordIndex = 0
1597 9
                    self.charIndex = 0
1598 9
                    moved = True
1599 0
                elif (wrap & Context.WRAP_TOP_BOTTOM) \
1600
                     and (self.lineIndex != 0):
1601 0
                        self.lineIndex = 0
1602 0
                        self.zoneIndex = 0
1603 0
                        self.wordIndex = 0
1604 0
                        self.charIndex = 0
1605 0
                        moved = True
1606
        else:
1607 0
            raise Exception("Invalid type: %d" % type)
1608
1609 21
        if moved and (type != Context.LINE):
1610 12
            self.targetCharInfo = self.getCurrent(Context.CHAR)
1611
1612 21
        return moved
1613
1614 1
    def goAbove(self, type=LINE, wrap=WRAP_ALL):
1615
        """Moves this context's locus of interest to first char
1616
        of the type that's closest to and above the current locus of
1617
        interest.
1618
1619
        Arguments:
1620
        - type: LINE
1621
        - wrap: if True, will cross top/bottom boundaries; if False, will
1622
                stop on top/bottom boundaries.
1623
1624
        Returns: [string, startOffset, endOffset, x, y, width, height]
1625
        """
1626
1627 0
        moved = False
1628 0
        if type == Context.CHAR:
1629
            # We want to shoot for the closest character, which we've
1630
            # saved away as self.targetCharInfo, which is the list
1631
            # [string, x, y, width, height].
1632
            #
1633 0
            if not self.targetCharInfo:
1634 0
                self.targetCharInfo = self.getCurrent(Context.CHAR)
1635 0
            target = self.targetCharInfo
1636
1637 0
            [string, x, y, width, height] = target
1638 0
            middleTargetX = x + (width / 2)
1639
1640 0
            moved = self.goPrevious(Context.LINE, wrap)
1641 0
            if moved:
1642 0
                while True:
1643 0
                    [string, bx, by, bwidth, bheight] = \
1644
                             self.getCurrent(Context.CHAR)
1645 0
                    if (bx + width) >= middleTargetX:
1646 0
                        break
1647 0
                    elif not self.goNext(Context.CHAR, Context.WRAP_NONE):
1648 0
                        break
1649
1650
            # Moving around might have reset the current targetCharInfo,
1651
            # so we reset it to our saved value.
1652
            #
1653 0
            self.targetCharInfo = target
1654 0
        elif type == Context.LINE:
1655 0
            return self.goPrevious(type, wrap)
1656
        else:
1657 0
            raise Exception("Invalid type: %d" % type)
1658
1659 0
        return moved
1660
1661 1
    def goBelow(self, type=LINE, wrap=WRAP_ALL):
1662
        """Moves this context's locus of interest to the first
1663
        char of the type that's closest to and below the current
1664
        locus of interest.
1665
1666
        Arguments:
1667
        - type: one of WORD, LINE
1668
        - wrap: if True, will cross top/bottom boundaries; if False, will
1669
                stop on top/bottom boundaries.
1670
1671
        Returns: [string, startOffset, endOffset, x, y, width, height]
1672
        """
1673
1674 0
        moved = False
1675 0
        if type == Context.CHAR:
1676
            # We want to shoot for the closest character, which we've
1677
            # saved away as self.targetCharInfo, which is the list
1678
            # [string, x, y, width, height].
1679
            #
1680 0
            if not self.targetCharInfo:
1681 0
                self.targetCharInfo = self.getCurrent(Context.CHAR)
1682 0
            target = self.targetCharInfo
1683
1684 0
            [string, x, y, width, height] = target
1685 0
            middleTargetX = x + (width / 2)
1686
1687 0
            moved = self.goNext(Context.LINE, wrap)
1688 0
            if moved:
1689 0
                while True:
1690 0
                    [string, bx, by, bwidth, bheight] = \
1691
                             self.getCurrent(Context.CHAR)
1692 0
                    if (bx + width) >= middleTargetX:
1693 0
                        break
1694 0
                    elif not self.goNext(Context.CHAR, Context.WRAP_NONE):
1695 0
                        break
1696
1697
            # Moving around might have reset the current targetCharInfo,
1698
            # so we reset it to our saved value.
1699
            #
1700 0
            self.targetCharInfo = target
1701 0
        elif type == Context.LINE:
1702 0
            moved = self.goNext(type, wrap)
1703
        else:
1704 0
            raise Exception("Invalid type: %d" % type)
1705
1706 0
        return moved