Skip to content

Correctly follow selection in braille when extending beyond display width#6775

Merged
jcsteh merged 20 commits into
nvaccess:masterfrom
dkager:i5770
Aug 1, 2017
Merged

Correctly follow selection in braille when extending beyond display width#6775
jcsteh merged 20 commits into
nvaccess:masterfrom
dkager:i5770

Conversation

@dkager

@dkager dkager commented Jan 21, 2017

Copy link
Copy Markdown
Contributor

This PR fixes #5770.

What

When extending a selection beyond the braille display width, braille keeps showing the unmoving end of the selection. It thus makes it difficult to track what is actually being selected without using speech. This is also true for multi-line selections, where braille will continue to show the top line when you press Shift+downArrow.

How

This patch adds an attribute to NVDAObject to indicate whether the selection is anchored at the start. That is, when the right/bottom end of the selection is moving, the selection is anchored at the start. Otherwise it is not. This attribute is set by CursorManager and EditableText (with and without auto selection detection).

Discussion

There was some discussion about how multi-line selection should be handled. In particular, the question was what should happen when you select entire lines with Shift+downArrow.
The question can be generalized: do you want to see the next character to be selected, or should braille follow the very last character that is still inside the selection?

To avoid having to deal with corner cases, I have chosen the latter approach in this patch. This is easiest to implement, but is it intuitive? Especially when selecting entire lines it may be useful to see the next line you are about to select.

Example

Say you have the text:

Hello
World
How are you?

When you are on the top line and press Shift+downArrow, braille would show "Hello" with dots 7&8. Another Shift+downArrow shows "World" with dots 7&8, etc.

Testing

Tested so far in Notepad, WordPad, Word 2016, Notepad++, Firefox (browse mode).

CC @LeonarddeR, @tuukkao

…TreeInterceptor. The property defaults to False.

Also use this to have braille scroll the selection's end into view if the anchor is at the start.
re nvaccess#5770
- The copy of the braille cursor TextInfo in braille.TextInfoRegion.update().
- Duplicate call to detectPossibleSelectionChange(). This is now handled in EditableTextWithAutoSelectDetection.
…ensate for that.

As a consequence, braille will always show the last selected character, both for single and multi-line selections.
if eventHandler.isPendingEvents('valueChange',self):
self.hasContentChangedSinceLastSelection=True
super(Edit,self).event_caret()
self.detectPossibleSelectionChange()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For justification of this removal, see #5770 (comment)

Comment thread source/cursorManager.py Outdated
newInfo.setEndPoint(oldInfo, "startToStart")
# _isSelectionAnchoredAtStart is not the same as _lastSelectionMovedStart,
# i.e. their default values are opposite and _isSelectionAnchoredAtStart is only updated if the selection has changed.
self._isSelectionAnchoredAtStart = not self._lastSelectionMovedStart

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jcsteh I think we need to make sure this is compatible with 62f42c6

Did some testing and that looked good.

Comment thread source/NVDAObjects/__init__.py Outdated
speech.speakObject(self,reason=controlTypes.REASON_FOCUS)

def _get_isSelectionAnchoredAtStart(self):
"""Determine if the selection is anchored at the start.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It might help here if you give a brief explanation of what you mean with a selection being anchored at the start.

if eventHandler.isPendingEvents('valueChange',self):
self.hasContentChangedSinceLastSelection=True
super(Edit,self).event_caret()
self.detectPossibleSelectionChange()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is explained in #5770 (comment)

Comment thread source/braille.py Outdated
def scrollToCursorOrSelection(self, region):
if region.brailleCursorPos is not None:
self.mainBuffer.scrollTo(region, region.brailleCursorPos)
elif not hasattr(region, "obj") or not region.obj.isSelectionAnchoredAtStart:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Probably a matter of style, but instead of the hasatr, I'd do a isinstance(region,(NVDAObjectRegion,TextInfoRegion)) here

Comment thread source/editableText.py
self.reportSelectionChange(oldInfo)
except:
return
return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't get how this one got into the diff, they look identical

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Likewise.

@dkager

dkager commented Jun 22, 2017

Copy link
Copy Markdown
Contributor Author

@LeonarddeR I tweaked the pydoc. Also, I believe hasattr is more future-proof than hard-coding subclasses.

@jcsteh jcsteh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for your work. This looks pretty good overall.

Comment thread source/NVDAObjects/__init__.py Outdated
"""
speech.speakObject(self,reason=controlTypes.REASON_FOCUS)

def _get_isSelectionAnchoredAtStart(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you please rename this to isTextSelectionAnchoredAtStart? It occurred to me that selection might be a bit ambiguous here; i.e. selection of items in a container vs text selection.

Comment thread source/NVDAObjects/__init__.py Outdated

def _get_isSelectionAnchoredAtStart(self):
"""Indicates if the selection is anchored at the start.
The anchored position is the end that doesn't move when extending or shrinking the selection.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Perhaps you could add an example here:

For example, if you have no selection and you press shift+rightArrow to select the next character, this will return True. In contrast, if you have no selection and you press shift+leftArrow to select the previous character, this will return False.

Comment thread source/NVDAObjects/__init__.py Outdated
If the selection is anchored at the end or there is no information this is C{False}.
@return: C{True} if the selection is anchored at the start else C{False}
@rtype: bool
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It strikes me as odd that we create this private variable here which gets set in a completely different class. That is, when you're reading this code, it's not clear where this gets set. I'm thinking the base implementation should just be a class variable with a default value and then the classes which implement this can either implement their own property or override with a simple instance variable.

Comment thread source/braille.py
# Collapse the selection to the point that is moving.
readingInfo.collapse(end=self.obj.isSelectionAnchoredAtStart)
# Get the reading unit at the end of the selection.
readingInfo.expand(unit)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As noted in the comment that was previously here:

		# HACK: Some TextInfos only support UNIT_LINE properly if they are based on POSITION_CARET,
		# so use the original cursor TextInfo for line and copy for cursor.

I think this is going to break for certain TextInfos. Affected implementations will only let you get the line if you're working with the caret or selection range. Once you copy the range, that ability is lost. This used to be true for Word, though I think we've since rectified that. However, I'm pretty certain this is still true for MSHTML. Long story short: let readingInfo be the TextInfo you create from POSITION_SELECTION and then copy it for sel.

Comment thread source/cursorManager.py Outdated
newInfo.setEndPoint(oldInfo, "startToStart")
# _isSelectionAnchoredAtStart is not the same as _lastSelectionMovedStart,
# i.e. their default values are opposite and _isSelectionAnchoredAtStart is only updated if the selection has changed.
self._isSelectionAnchoredAtStart = not self._lastSelectionMovedStart

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I get that they're reversed, but I don't follow the bit about it only being updated if the selection changes. Would it really matter if isSelectionAnchoredAtStart just always returned the reverse of _lastSelectionMovedStart? I'm thinking we should get rid of _lastSelectionMovedStart and just flip everything to use the new attribute.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It looks like this was valid before the refactor a while ago, but not anymore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On second thought there is a difference. But maybe it is actually the desired behavior.
Flipping around _lastSelectionMovedStart to _isTextSelectionAnchoredAtStart assumes that the end of the seletion is moving unless proven otherwise. This means that if the anchor position is unknown, braille will default to showing the end of the selection.
I'll give it a try and push an update when I'm satisfied.

Comment thread source/treeInterceptorHandler.py Outdated
review.setCurrentMode('document',True)
braille.handler.handleGainFocus(self)

def _get_isSelectionAnchoredAtStart(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  1. This should be in DocumentTreeInterceptor, since text is not valid for non-document TreeInterceptors. Only DocumentTreeInterceptors have makeTextInfo.
  2. Same suggested changes as for this property in NVDAObject.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  1. This should be in DocumentTreeInterceptor, since text is not valid for non-document TreeInterceptors.

I believe I put it in the superclass to avoid having to check for that specific subclass when using this attribute. See my previous comment. This would of course not be necessary if TextInfoRegion is always paired with a DocumentTreeInterceptor, but the documentation doesn't make that clear.

@jcsteh

jcsteh commented Jun 23, 2017

Copy link
Copy Markdown
Contributor

@dkager commented on 22 Jun 2017, 19:19 GMT+10:

Also, I believe hasattr is more future-proof than hard-coding subclasses.

More future proof, but potentially less clear and more error prone. I think i agree here; I'd prefer to see this as an isinstance. Note that you'll need to test for DocumentTreeInterceptor, not just TreeInterceptor.

@dkager

dkager commented Jun 23, 2017

Copy link
Copy Markdown
Contributor Author

With regard to hasattr versus isinstance, if I understand correctly we would end up with code like:

		elif not (isinstance(region, NVDAObjectRegion) or (isinstance(region, TextInfoRegion) and isinstance(region.obj, DocumentTreeInterceptor))) or not region.obj.isTextSelectionAnchoredAtStart:

I'm not sure I find that clearer. :)

@LeonarddeR

Copy link
Copy Markdown
Collaborator
  elif not (isinstance(region, NVDAObjectRegion) or (isinstance(region, TextInfoRegion) and isinstance(region.obj, DocumentTreeInterceptor))) or not region.obj.isTextSelectionAnchoredAtStart:

This is identical to

  elif not (isinstance(region, NVDAObjectRegion) or (isinstance(region, TextInfoRegion) and isinstance(region.obj, DocumentTreeInterceptor)) or region.obj.isTextSelectionAnchoredAtStart):

@dkager

dkager commented Jun 23, 2017

Copy link
Copy Markdown
Contributor Author

@LeonarddeR You mean equivalent.
And yes, it is. I find both mind-boggling, haha.

Comment thread source/braille.py
self._readingInfo = readingInfo = sel.copy()
if self.obj.isSelectionAnchoredAtStart:
# The end of the range is exclusive, so make it inclusive first.
readingInfo.move(textInfos.UNIT_CHARACTER, -1, "end")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm still not happy about this. For a downward multi-line selection it is more practical to see the next line to be selected rather than the very right edge of the last selected line. But there is no good way to detect the difference between the result of pressing shift+downArrow and shift+end. The only solution I know of is to rely on cursor position even when there is a selection, but that doesn't work in all applications.

So there are two options:

  1. Rely on the cursor where possible. Provides great usability in some programs (Notepad++, Eclipse) and average usability in others (Notepad, Word, browse mode).
  2. Don't try to be clever and always show the very edge of a selection. This is consistent and predictable, but usability is so-so.

@dkager dkager left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed comments from James and Leonard and made some small improvements.

Comment thread source/cursorManager.py Outdated
newInfo=self.makeTextInfo(toPosition)
if oldInfo.isCollapsed:
self._lastSelectionMovedStart = newInfo.compareEndPoints(oldInfo, "startToStart") < 0
self.isTextSelectionAnchoredAtStart = newInfo.compareEndPoints(oldInfo, "startToStart") == 0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jcsteh Could you please double check this line?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

newInfo is usually a collapsed destination position. Its start isn't yet set to the cursor. For example, if you press control+end, newInfo will be collapsed at the last character in the document at this point in the code. So, I think this should be an exact reversal; i.e. this should be >= 0. Having said that, I can't seem to figure out right now how this would actually break things. It seems to work fine, I guess because it gets set later. Still, I think I'd be more comfortable with an exact reversal, but do run the unit tests to make sure I'm not missing something. :)

Comment thread source/cursorManager.py
# Starting a new selection, so set the selection direction
# based on the direction of this movement.
self._lastSelectionMovedStart = direction < 0
self.isTextSelectionAnchoredAtStart = direction > 0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jcsteh Could you double check this line too?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This one looks fine, since direction can't be 0 if unit is not specified.

Comment thread source/braille.py Outdated
def scrollToCursorOrSelection(self, region):
if region.brailleCursorPos is not None:
self.mainBuffer.scrollTo(region, region.brailleCursorPos)
elif not isinstance(region, (NVDAObjectRegion, TextInfoRegion)) or not region.obj.isTextSelectionAnchoredAtStart:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Motivation for this test:

  • If a region is an NVDAObjectRegion it will have an NVDAObject, which has the isTextSelectionAnchoredAtStart attribute.
  • If a region is a TextInfoRegion then it has a TextInfo and it will be based on a TreeInterceptor. DocumentTreeInterceptor is the only TreeInterceptor with a TextInfo, so the region must have one of those as its object. This also means the selection anchor attribute is present.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unless I'm missing something, I think selection anchoring is only relevant for TextInfoRegion. Even if we're dealing with an NVDAObject (not a DocumentTreeInterceptor), if it has text, it'll also have a TextInfoRegion... and it is this region that we'll be dealing with. So, I think you only need to test for TextInfoRegion.

Comment thread source/braille.py Outdated
def scrollToCursorOrSelection(self, region):
if region.brailleCursorPos is not None:
self.mainBuffer.scrollTo(region, region.brailleCursorPos)
elif not isinstance(region, (NVDAObjectRegion, TextInfoRegion)) or not region.obj.isTextSelectionAnchoredAtStart:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unless I'm missing something, I think selection anchoring is only relevant for TextInfoRegion. Even if we're dealing with an NVDAObject (not a DocumentTreeInterceptor), if it has text, it'll also have a TextInfoRegion... and it is this region that we'll be dealing with. So, I think you only need to test for TextInfoRegion.

Comment thread source/cursorManager.py Outdated
newInfo=self.makeTextInfo(toPosition)
if oldInfo.isCollapsed:
self._lastSelectionMovedStart = newInfo.compareEndPoints(oldInfo, "startToStart") < 0
self.isTextSelectionAnchoredAtStart = newInfo.compareEndPoints(oldInfo, "startToStart") == 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

newInfo is usually a collapsed destination position. Its start isn't yet set to the cursor. For example, if you press control+end, newInfo will be collapsed at the last character in the document at this point in the code. So, I think this should be an exact reversal; i.e. this should be >= 0. Having said that, I can't seem to figure out right now how this would actually break things. It seems to work fine, I guess because it gets set later. Still, I think I'd be more comfortable with an exact reversal, but do run the unit tests to make sure I'm not missing something. :)

Comment thread source/cursorManager.py
# Starting a new selection, so set the selection direction
# based on the direction of this movement.
self._lastSelectionMovedStart = direction < 0
self.isTextSelectionAnchoredAtStart = direction > 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This one looks fine, since direction can't be 0 if unit is not specified.

Comment thread source/editableText.py Outdated
self.hasContentChangedSinceLastSelection=False
speech.speakSelectionChange(oldInfo,newInfo,generalize=hasContentChanged)

def _findSelectionAnchor(self,oldInfo,newInfo):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There's a slight chance someone might see this method name and expect it to return something, rather than taking action. I think renaming this to _updateSelectionAnchor or similar might make this clearer.

Comment thread source/braille.py Outdated
sel.setEndPoint(readingInfo, "endToEnd")
else:
# If there is no selection, use the cursor.
self._readingInfo = readingInfo = self._getCursor()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry; I missed this in my first round of review. If there's no selection, POSITION_SELECTION will be collapsed at the cursor, so this is redundant. That also suggests we should get rid of _getCursor altogether. ReviewTextInfoRegion will need to be updated to just return the review position for _getSelection.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Does that makes sense to you, semantically speaking? If it does then I'm happy to change it.

Would you discard POSITION_CARET? Intuitively, the following order looks good to me:

  1. POSITION_SELECTION
  2. POSITION_CARET
  3. POSITION_FIRST

Where POSITION_FIRST seems a poor fallback, but better than nothing I guess.

@dkager

dkager commented Jul 1, 2017

Copy link
Copy Markdown
Contributor Author

I think all review comments have been resolved.
My only comment now is that it seems slightly wasteful to pass a collapsed TextInfo to _addTextWithFields. All it does is establish where the cursor is located. Still, it looks like splitting the reading unit into chunks can't be avoided. So the extra overhead should be small enough.

@jcsteh

jcsteh commented Jul 3, 2017

Copy link
Copy Markdown
Contributor

@dkager commented on 1 Jul 2017, 22:05 GMT+10:

My only comment now is that it seems slightly wasteful to pass a collapsed TextInfo to _addTextWithFields.

I assume you're referring to the case where there's no selection, only a cursor. It's true that this will call getTextWithFields unnecessarily. I guess we could pass in an additional hint specifying that the TextInfo is collapsed (since we already checked that) and thus eliminate this call, but this code is already complex enough, so I'd prefer to avoid that unless we can prove there's a noticeable perf improvement. In any case, if we're going to do that, let's deal with that later. :)

@jcsteh jcsteh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Last tiny change. Thanks for your work and your patience. :)

Comment thread source/braille.py Outdated
except:
# try:
# return self.obj.makeTextInfo(textInfos.POSITION_CARET)
# except:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please remove these commented lines. To answer your question, you can discard POSITION_CARET because POSITION_SELECTION should return the caret if there's no selection. Speech relies on this assumption as well.

@jcsteh

jcsteh commented Jul 10, 2017

Copy link
Copy Markdown
Contributor

This is now conflicting due to a recent merge to master. Would you mind resolving? Thanks.

@dkager

dkager commented Jul 10, 2017

Copy link
Copy Markdown
Contributor Author

Should be fixed.

@jcsteh jcsteh dismissed LeonarddeR’s stale review August 1, 2017 02:32

Issues have been addressed and superseded by my subsequent review.

@jcsteh jcsteh merged commit a65d498 into nvaccess:master Aug 1, 2017
@nvaccessAuto nvaccessAuto added this to the 2017.3 milestone Aug 1, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Braille doesn't follow when extending a selection to the left or right

4 participants