Bug 1686995 - Add AXTextSelectionDirection and AXTextSelectionGranularity to text selection events. r=morgan

.. and also add AXTextSelectionChangedFocus and AXTextStateSync when needed.

Differential Revision: https://phabricator.services.mozilla.com/D102509
This commit is contained in:
Eitan Isaacson 2021-01-22 21:12:56 +00:00
Родитель c738dc904e
Коммит 135c36619d
11 изменённых файлов: 357 добавлений и 85 удалений

Просмотреть файл

@ -216,11 +216,12 @@ nsresult AccessibleWrap::HandleAccEvent(AccEvent* aEvent) {
case nsIAccessibleEvent::EVENT_TEXT_CARET_MOVED: {
AccCaretMoveEvent* event = downcast_accEvent(aEvent);
int32_t caretOffset = event->GetCaretOffset();
MOXTextMarkerDelegate* delegate =
[MOXTextMarkerDelegate getOrCreateForDoc:aEvent->Document()];
[delegate setCaretOffset:eventTarget at:caretOffset];
if (event->IsSelectionCollapsed()) {
// If the selection is collapsed, invalidate our text selection cache.
MOXTextMarkerDelegate* delegate =
[MOXTextMarkerDelegate getOrCreateForDoc:aEvent->Document()];
int32_t caretOffset = event->GetCaretOffset();
[delegate setSelectionFrom:eventTarget
at:caretOffset
to:eventTarget

Просмотреть файл

@ -48,6 +48,10 @@ class GeckoTextMarker final {
bool operator<(const GeckoTextMarker& aPoint) const;
bool operator==(const GeckoTextMarker& aPoint) const {
return mContainer == aPoint.mContainer && mOffset == aPoint.mOffset;
}
AccessibleOrProxy mContainer;
int32_t mOffset;

Просмотреть файл

@ -81,7 +81,8 @@ GeckoTextMarker::GeckoTextMarker(AccessibleOrProxy aDoc,
AXTextMarkerRef aTextMarker) {
MOZ_ASSERT(!aDoc.IsNull());
OpaqueGeckoTextMarker opaqueMarker;
if (AXTextMarkerGetLength(aTextMarker) == sizeof(OpaqueGeckoTextMarker)) {
if (aTextMarker &&
AXTextMarkerGetLength(aTextMarker) == sizeof(OpaqueGeckoTextMarker)) {
memcpy(&opaqueMarker, AXTextMarkerGetBytePtr(aTextMarker),
sizeof(OpaqueGeckoTextMarker));
if (DocumentExists(aDoc, opaqueMarker.mDoc)) {

Просмотреть файл

@ -15,6 +15,8 @@
@interface MOXTextMarkerDelegate : NSObject <MOXTextMarkerSupport> {
mozilla::a11y::AccessibleOrProxy mGeckoDocAccessible;
id mSelection;
id mCaret;
id mPrevCaret;
}
+ (id)getOrCreateForDoc:(mozilla::a11y::AccessibleOrProxy)aDoc;
@ -30,12 +32,15 @@
to:(mozilla::a11y::AccessibleOrProxy)endContainer
at:(int32_t)endOffset;
- (void)setCaretOffset:(mozilla::a11y::AccessibleOrProxy)container
at:(int32_t)offset;
- (NSDictionary*)selectionChangeInfo;
- (void)invalidateSelection;
- (mozilla::a11y::GeckoTextMarkerRange)selection;
- (BOOL)selectionIsCollapsed;
// override
- (id)moxStartTextMarker;
@ -107,3 +112,47 @@
- (void)moxSetSelectedTextMarkerRange:(id)textMarkerRange;
@end
namespace mozilla {
namespace a11y {
enum AXTextEditType {
AXTextEditTypeUnknown,
AXTextEditTypeDelete,
AXTextEditTypeInsert,
AXTextEditTypeTyping,
AXTextEditTypeDictation,
AXTextEditTypeCut,
AXTextEditTypePaste,
AXTextEditTypeAttributesChange
};
enum AXTextStateChangeType {
AXTextStateChangeTypeUnknown,
AXTextStateChangeTypeEdit,
AXTextStateChangeTypeSelectionMove,
AXTextStateChangeTypeSelectionExtend
};
enum AXTextSelectionDirection {
AXTextSelectionDirectionUnknown,
AXTextSelectionDirectionBeginning,
AXTextSelectionDirectionEnd,
AXTextSelectionDirectionPrevious,
AXTextSelectionDirectionNext,
AXTextSelectionDirectionDiscontiguous
};
enum AXTextSelectionGranularity {
AXTextSelectionGranularityUnknown,
AXTextSelectionGranularityCharacter,
AXTextSelectionGranularityWord,
AXTextSelectionGranularityLine,
AXTextSelectionGranularitySentence,
AXTextSelectionGranularityParagraph,
AXTextSelectionGranularityPage,
AXTextSelectionGranularityDocument,
AXTextSelectionGranularityAll
};
}
}

Просмотреть файл

@ -70,8 +70,97 @@ static nsDataHashtable<nsUint64HashKey, MOXTextMarkerDelegate*> sDelegates;
mSelection = [selection.CreateAXTextMarkerRange() retain];
}
- (void)setCaretOffset:(mozilla::a11y::AccessibleOrProxy)container
at:(int32_t)offset {
GeckoTextMarker caretMarker(container, offset);
mPrevCaret = mCaret;
mCaret = [caretMarker.CreateAXTextMarker() retain];
}
// This returns an info object to pass with AX SelectedTextChanged events.
// It uses the current and previous caret position to make decisions
// regarding which attributes to add to the info object.
- (NSDictionary*)selectionChangeInfo {
GeckoTextMarkerRange selectedGeckoRange =
GeckoTextMarkerRange(mGeckoDocAccessible, mSelection);
// This is the base info object, includes the selected marker range and
// the change type depending on the collapsed state of the selection.
NSMutableDictionary* info = [@{
@"AXSelectedTextMarkerRange" : selectedGeckoRange.IsValid() ? mSelection
: [NSNull null],
@"AXTextStateChangeType" :
selectedGeckoRange.mStart == selectedGeckoRange.mEnd
? @(AXTextStateChangeTypeSelectionMove)
: @(AXTextStateChangeTypeSelectionExtend)
} mutableCopy];
GeckoTextMarker caretMarker(mGeckoDocAccessible, mCaret);
GeckoTextMarker prevCaretMarker(mGeckoDocAccessible, mPrevCaret);
if (!caretMarker.IsValid()) {
// If the current caret is invalid, stop here and return base info.
return info;
}
mozAccessible* caretEditable =
[GetNativeFromGeckoAccessible(caretMarker.mContainer)
moxEditableAncestor];
if (!caretEditable) {
// If we are not in an editable, VO expects AXTextStateSync to be present
// and true.
info[@"AXTextStateSync"] = @YES;
}
if (!prevCaretMarker.IsValid() || caretMarker == prevCaretMarker) {
// If we have no stored previous marker, stop here.
return info;
}
mozAccessible* prevCaretEditable =
[GetNativeFromGeckoAccessible(prevCaretMarker.mContainer)
moxEditableAncestor];
if (prevCaretEditable != caretEditable) {
// If the caret goes in or out of an editable, consider the
// move direction "discontiguous".
info[@"AXTextSelectionDirection"] =
@(AXTextSelectionDirectionDiscontiguous);
if ([[caretEditable moxFocused] boolValue]) {
// If the caret is in a new focused editable, VO expects this attribute to
// be present and to be true.
info[@"AXTextSelectionChangedFocus"] = @YES;
}
return info;
}
bool isForward = prevCaretMarker < caretMarker;
uint32_t deltaLength =
GeckoTextMarkerRange(isForward ? prevCaretMarker : caretMarker,
isForward ? caretMarker : prevCaretMarker)
.Length();
// Determine selection direction with marker comparison.
// If the delta between the two markers is more than one, consider it
// a word. Not accurate, but good enough for VO.
[info addEntriesFromDictionary:@{
@"AXTextSelectionDirection" : isForward
? @(AXTextSelectionDirectionNext)
: @(AXTextSelectionDirectionPrevious),
@"AXTextSelectionGranularity" : deltaLength == 1
? @(AXTextSelectionGranularityCharacter)
: @(AXTextSelectionGranularityWord)
}];
return info;
}
- (void)invalidateSelection {
[mSelection release];
[mCaret release];
[mPrevCaret release];
mSelection = nil;
}
@ -79,13 +168,6 @@ static nsDataHashtable<nsUint64HashKey, MOXTextMarkerDelegate*> sDelegates;
return mozilla::a11y::GeckoTextMarkerRange(mGeckoDocAccessible, mSelection);
}
- (BOOL)selectionIsCollapsed {
GeckoTextMarkerRange range(mGeckoDocAccessible, mSelection);
return range.mStart.mContainer == range.mEnd.mContainer &&
range.mStart.mOffset == range.mEnd.mOffset;
}
- (id)moxStartTextMarker {
GeckoTextMarker geckoTextPoint(mGeckoDocAccessible, 0);
return geckoTextPoint.CreateAXTextMarker();

Просмотреть файл

@ -110,10 +110,11 @@ void ProxyStateChangeEvent(ProxyAccessible* aProxy, uint64_t aState,
void ProxyCaretMoveEvent(ProxyAccessible* aTarget, int32_t aOffset,
bool aIsSelectionCollapsed) {
mozAccessible* wrapper = GetNativeFromGeckoAccessible(aTarget);
MOXTextMarkerDelegate* delegate =
[MOXTextMarkerDelegate getOrCreateForDoc:aTarget->Document()];
[delegate setCaretOffset:aTarget at:aOffset];
if (aIsSelectionCollapsed) {
// If selection is collapsed, invalidate selection.
MOXTextMarkerDelegate* delegate =
[MOXTextMarkerDelegate getOrCreateForDoc:aTarget->Document()];
[delegate setSelectionFrom:aTarget at:aOffset to:aTarget at:aOffset];
}

Просмотреть файл

@ -1021,18 +1021,11 @@ struct RoleDescrComparator {
// We consider any caret move event to be a selected text change event.
// So dispatching an event for EVENT_TEXT_SELECTION_CHANGED would be
// reduntant.
id<MOXTextMarkerSupport> delegate = [self moxTextMarkerDelegate];
id selectedRange = [delegate moxSelectedTextMarkerRange];
BOOL isCollapsed =
[static_cast<MOXTextMarkerDelegate*>(delegate) selectionIsCollapsed];
NSDictionary* userInfo = @{
@"AXTextChangeElement" : self,
@"AXSelectedTextMarkerRange" :
(selectedRange ? selectedRange : [NSNull null]),
@"AXTextStateChangeType" : isCollapsed
? @(AXTextStateChangeTypeSelectionMove)
: @(AXTextStateChangeTypeSelectionExtend)
};
MOXTextMarkerDelegate* delegate =
static_cast<MOXTextMarkerDelegate*>([self moxTextMarkerDelegate]);
NSMutableDictionary* userInfo =
[[delegate selectionChangeInfo] mutableCopy];
userInfo[@"AXTextChangeElement"] = self;
mozAccessible* webArea = [self topWebArea];
[webArea

Просмотреть файл

@ -111,27 +111,3 @@
- (NSValue*)moxBoundsForRange:(NSValue*)range;
@end
namespace mozilla {
namespace a11y {
enum AXTextEditType {
AXTextEditTypeUnknown,
AXTextEditTypeDelete,
AXTextEditTypeInsert,
AXTextEditTypeTyping,
AXTextEditTypeDictation,
AXTextEditTypeCut,
AXTextEditTypePaste,
AXTextEditTypeAttributesChange
};
enum AXTextStateChangeType {
AXTextStateChangeTypeUnknown,
AXTextStateChangeTypeEdit,
AXTextStateChangeTypeSelectionMove,
AXTextStateChangeTypeSelectionExtend
};
}
}

Просмотреть файл

@ -57,43 +57,67 @@ function testValueChangedEventData(
is(str, expectedWordAtLeft);
}
function matchWebArea(expectedId, expectedStateChangeType) {
// Return true if the first given object a subset of the second
function isSubset(subset, superset) {
if (typeof subset != "object" || typeof superset != "object") {
return superset == subset;
}
for (let [prop, val] of Object.entries(subset)) {
if (!isSubset(val, superset[prop])) {
return false;
}
}
return true;
}
function matchWebArea(expectedId, expectedInfo) {
return (iface, data) => {
if (!data) {
return false;
}
let textChangeElemID = data.AXTextChangeElement.getAttributeValue(
"AXDOMIdentifier"
);
return (
iface.getAttributeValue("AXRole") == "AXWebArea" &&
!!data &&
data.AXTextChangeElement.getAttributeValue("AXDOMIdentifier") ==
expectedId &&
data.AXTextStateChangeType == expectedStateChangeType
textChangeElemID == expectedId &&
isSubset(expectedInfo, data)
);
};
}
function matchInput(expectedId, expectedStateChangeType) {
return (iface, data) =>
iface.getAttributeValue("AXDOMIdentifier") == expectedId &&
!!data &&
data.AXTextStateChangeType == expectedStateChangeType;
function matchInput(expectedId, expectedInfo) {
return (iface, data) => {
if (!data) {
return false;
}
return (
iface.getAttributeValue("AXDOMIdentifier") == expectedId &&
isSubset(expectedInfo, data)
);
};
}
async function synthKeyAndTestSelectionChanged(
synthKey,
synthEvent,
expectedId,
expectedSelectionString
expectedSelectionString,
expectedSelectionInfo
) {
// If the expected string is empty, it is a caret move/collapse
let expectedStateChangeType = expectedSelectionString
? AXTextStateChangeTypeSelectionExtend
: AXTextStateChangeTypeSelectionMove;
let selectionChangedEvents = Promise.all([
waitForMacEventWithInfo(
"AXSelectedTextChanged",
matchWebArea(expectedId, expectedStateChangeType)
matchWebArea(expectedId, expectedSelectionInfo)
),
waitForMacEventWithInfo(
"AXSelectedTextChanged",
matchInput(expectedId, expectedStateChangeType)
matchInput(expectedId, expectedSelectionInfo)
),
]);
@ -141,16 +165,42 @@ async function synthKeyAndTestValueChanged(
expectedWordAtLeft
) {
let valueChangedEvents = Promise.all([
waitForMacEventWithInfo(
waitForMacEvent(
"AXSelectedTextChanged",
matchWebArea(expectedTextSelectionId, AXTextStateChangeTypeSelectionMove)
matchWebArea(expectedTextSelectionId, {
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
})
),
waitForMacEvent(
"AXSelectedTextChanged",
matchInput(expectedTextSelectionId, {
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
})
),
waitForMacEventWithInfo(
"AXSelectedTextChanged",
matchInput(expectedTextSelectionId, AXTextStateChangeTypeSelectionMove)
"AXValueChanged",
matchWebArea(expectedId, {
AXTextStateChangeType: AXTextStateChangeTypeEdit,
AXTextChangeValues: [
{
AXTextChangeValue: expectedChangeValue,
AXTextEditType: expectedEditType,
},
],
})
),
waitForMacEventWithInfo(
"AXValueChanged",
matchInput(expectedId, {
AXTextStateChangeType: AXTextStateChangeTypeEdit,
AXTextChangeValues: [
{
AXTextChangeValue: expectedChangeValue,
AXTextEditType: expectedEditType,
},
],
})
),
waitForMacEventWithInfo("AXValueChanged", matchWebArea(expectedId, 1)),
waitForMacEventWithInfo("AXValueChanged", matchInput(expectedId, 1)),
]);
EventUtils.synthesizeKey(synthKey, synthEvent);
@ -186,11 +236,15 @@ async function focusIntoInputAndType(accDoc, inputId, innerContainerId) {
),
waitForMacEventWithInfo(
"AXSelectedTextChanged",
matchWebArea(selectionId, AXTextStateChangeTypeSelectionMove)
matchWebArea(selectionId, {
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
})
),
waitForMacEventWithInfo(
"AXSelectedTextChanged",
matchInput(selectionId, AXTextStateChangeTypeSelectionMove)
matchInput(selectionId, {
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
})
),
]);
input.setAttributeValue("AXFocused", true);
@ -242,32 +296,74 @@ async function focusIntoInputAndType(accDoc, inputId, innerContainerId) {
await testTextDelete("d", "worl");
await testTextDelete("l", "wor");
await synthKeyAndTestSelectionChanged("KEY_ArrowLeft", null, selectionId, "");
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
{ shiftKey: true },
null,
selectionId,
"o"
"",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
AXTextSelectionDirection: AXTextSelectionDirectionPrevious,
AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
}
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
{ shiftKey: true },
selectionId,
"wo"
"o",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
AXTextSelectionDirection: AXTextSelectionDirectionPrevious,
AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
}
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
{ shiftKey: true },
selectionId,
"wo",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
AXTextSelectionDirection: AXTextSelectionDirectionPrevious,
AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
}
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
null,
selectionId,
"",
{ AXTextStateChangeType: AXTextStateChangeTypeSelectionMove }
);
await synthKeyAndTestSelectionChanged("KEY_ArrowLeft", null, selectionId, "");
await synthKeyAndTestSelectionChanged(
"KEY_Home",
{ shiftKey: true },
selectionId,
"hello "
"hello ",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
AXTextSelectionDirection: AXTextSelectionDirectionPrevious,
AXTextSelectionGranularity: AXTextSelectionGranularityWord,
}
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
null,
selectionId,
"",
{ AXTextStateChangeType: AXTextStateChangeTypeSelectionMove }
);
await synthKeyAndTestSelectionChanged("KEY_ArrowLeft", null, selectionId, "");
await synthKeyAndTestSelectionChanged(
"KEY_ArrowRight",
{ shiftKey: true, altKey: true },
selectionId,
"hello"
"hello",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
AXTextSelectionDirection: AXTextSelectionDirectionNext,
AXTextSelectionGranularity: AXTextSelectionGranularityWord,
}
);
}

Просмотреть файл

@ -22,6 +22,7 @@ addAccessibleTask(`<p id="p">Hello World</p>`, async (browser, accDoc) => {
let evt = waitForMacEventWithInfo("AXSelectedTextChanged", (elem, info) => {
return (
info.AXTextStateSync &&
info.AXTextStateChangeType == AXTextStateChangeTypeSelectionExtend &&
elem.getAttributeValue("AXRole") == "AXWebArea"
);
@ -48,6 +49,7 @@ addAccessibleTask(`<p id="p">Hello World</p>`, async (browser, accDoc) => {
evt = waitForMacEventWithInfo("AXSelectedTextChanged", (elem, info) => {
return (
info.AXTextStateSync &&
info.AXTextStateChangeType == AXTextStateChangeTypeSelectionExtend &&
elem.getAttributeValue("AXRole") == "AXWebArea"
);
@ -60,6 +62,7 @@ addAccessibleTask(`<p id="p">Hello World</p>`, async (browser, accDoc) => {
// Collapse selection
evt = waitForMacEventWithInfo("AXSelectedTextChanged", (elem, info) => {
return (
info.AXTextStateSync &&
info.AXTextStateChangeType == AXTextStateChangeTypeSelectionMove &&
elem.getAttributeValue("AXRole") == "AXWebArea"
);
@ -130,3 +133,55 @@ addAccessibleTask(
);
}
);
/**
* Test text selection with focus change
*/
addAccessibleTask(
`<p id="p">Hello <input id="input"></p>`,
async (browser, accDoc) => {
let macDoc = accDoc.nativeInterface.QueryInterface(
Ci.nsIAccessibleMacInterface
);
let evt = waitForMacEventWithInfo("AXSelectedTextChanged", (elem, info) => {
return (
info.AXTextStateSync &&
info.AXTextStateChangeType == AXTextStateChangeTypeSelectionExtend &&
elem.getAttributeValue("AXRole") == "AXWebArea"
);
});
await SpecialPowers.spawn(browser, [], () => {
let p = content.document.getElementById("p");
let r = new content.Range();
r.setStart(p.firstChild, 1);
r.setEnd(p.firstChild, 3);
let s = content.getSelection();
s.addRange(r);
});
await evt;
let range = macDoc.getAttributeValue("AXSelectedTextMarkerRange");
is(stringForRange(macDoc, range), "el");
let events = Promise.all([
waitForMacEvent("AXFocusedUIElementChanged"),
waitForMacEventWithInfo("AXSelectedTextChanged"),
]);
await SpecialPowers.spawn(browser, [], () => {
content.document.getElementById("input").focus();
});
let [, { data }] = await events;
ok(
data.AXTextSelectionChangedFocus,
"have AXTextSelectionChangedFocus in event info"
);
ok(!data.AXTextStateSync, "no AXTextStateSync in editables");
is(
data.AXTextSelectionDirection,
AXTextSelectionDirectionDiscontiguous,
"discontigous direction"
);
}
);

Просмотреть файл

@ -7,7 +7,10 @@
/* exported getNativeInterface, waitForMacEventWithInfo, waitForMacEvent,
NSRange, NSDictionary, stringForRange, AXTextStateChangeTypeEdit,
AXTextEditTypeDelete, AXTextEditTypeTyping, AXTextStateChangeTypeSelectionMove,
AXTextStateChangeTypeSelectionExtend */
AXTextStateChangeTypeSelectionExtend, AXTextSelectionDirectionUnknown,
AXTextSelectionDirectionPrevious, AXTextSelectionDirectionNext,
AXTextSelectionDirectionDiscontiguous, AXTextSelectionGranularityUnknown,
AXTextSelectionGranularityCharacter, AXTextSelectionGranularityWord */
// Load the shared-head file first.
/* import-globals-from ../shared-head.js */
@ -32,6 +35,17 @@ const AXTextStateChangeTypeSelectionExtend = 3;
const AXTextEditTypeDelete = 1;
const AXTextEditTypeTyping = 3;
// AXTextSelectionDirection enum values
const AXTextSelectionDirectionUnknown = 0;
const AXTextSelectionDirectionPrevious = 3;
const AXTextSelectionDirectionNext = 4;
const AXTextSelectionDirectionDiscontiguous = 5;
// AXTextSelectionGranularity enum values
const AXTextSelectionGranularityUnknown = 0;
const AXTextSelectionGranularityCharacter = 1;
const AXTextSelectionGranularityWord = 2;
function getNativeInterface(accDoc, id) {
return findAccessibleChildByID(accDoc, id).nativeInterface.QueryInterface(
Ci.nsIAccessibleMacInterface