Bug 795957 - [PATCH 1/2][AccessFu] Adding support for live regions. r=eejay

---
 accessible/src/jsat/AccessFu.jsm                   |    6 +
 accessible/src/jsat/EventManager.jsm               |  192 ++++++++++++++++++--
 accessible/src/jsat/OutputGenerator.jsm            |   10 +-
 accessible/src/jsat/Presentation.jsm               |   47 +++++-
 accessible/src/jsat/Utils.jsm                      |   39 +++--
 .../en-US/chrome/accessibility/AccessFu.properties |    4 +
 6 files changed, 263 insertions(+), 35 deletions(-)
This commit is contained in:
Yura Zenevich 2013-08-21 12:40:06 -04:00
parent d57e8764ad
commit 53d2a48acc
6 changed files with 263 additions and 35 deletions

View File

@ -163,6 +163,9 @@ this.AccessFu = {
Services.obs.removeObserver(this, 'Accessibility:LongPress');
Services.obs.removeObserver(this, 'Accessibility:MoveByGranularity');
delete this._quicknavModesPref;
delete this._notifyOutputPref;
if (this.doneCallback) {
this.doneCallback();
delete this.doneCallback;
@ -171,6 +174,9 @@ this.AccessFu = {
_enableOrDisable: function _enableOrDisable() {
try {
if (!this._activatePref) {
return;
}
let activatePref = this._activatePref.value;
if (activatePref == ACCESSFU_ENABLE ||
this._systemPref && activatePref == ACCESSFU_AUTO)

View File

@ -14,10 +14,15 @@ const EVENT_TEXT_CARET_MOVED = Ci.nsIAccessibleEvent.EVENT_TEXT_CARET_MOVED;
const EVENT_TEXT_INSERTED = Ci.nsIAccessibleEvent.EVENT_TEXT_INSERTED;
const EVENT_TEXT_REMOVED = Ci.nsIAccessibleEvent.EVENT_TEXT_REMOVED;
const EVENT_FOCUS = Ci.nsIAccessibleEvent.EVENT_FOCUS;
const EVENT_SHOW = Ci.nsIAccessibleEvent.EVENT_SHOW;
const EVENT_HIDE = Ci.nsIAccessibleEvent.EVENT_HIDE;
const ROLE_INTERNAL_FRAME = Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME;
const ROLE_DOCUMENT = Ci.nsIAccessibleRole.ROLE_DOCUMENT;
const ROLE_CHROME_WINDOW = Ci.nsIAccessibleRole.ROLE_CHROME_WINDOW;
const ROLE_TEXT_LEAF = Ci.nsIAccessibleRole.ROLE_TEXT_LEAF;
const TEXT_NODE = 3;
Cu.import('resource://gre/modules/XPCOMUtils.jsm');
XPCOMUtils.defineLazyModuleGetter(this, 'Services',
@ -140,7 +145,11 @@ this.EventManager.prototype = {
// Don't bother with non-content events in firefox.
if (Utils.MozBuildApp == 'browser' &&
aEvent.eventType != EVENT_VIRTUALCURSOR_CHANGED &&
aEvent.accessibleDocument.docType == 'window') {
// XXX Bug 442005 results in DocAccessible::getDocType returning
// NS_ERROR_FAILURE. Checking for aEvent.accessibleDocument.docType ==
// 'window' does not currently work.
(aEvent.accessibleDocument.DOMDocument.doctype &&
aEvent.accessibleDocument.DOMDocument.doctype.name === 'window')) {
return;
}
@ -219,28 +228,47 @@ this.EventManager.prototype = {
this.editState = editState;
break;
}
case EVENT_SHOW:
{
let {liveRegion, isPolite} = this._handleLiveRegion(aEvent,
['additions', 'all']);
// Only handle show if it is a relevant live region.
if (!liveRegion) {
break;
}
// Show for text is handled by the EVENT_TEXT_INSERTED handler.
if (aEvent.accessible.role === ROLE_TEXT_LEAF) {
break;
}
this._dequeueLiveEvent(EVENT_HIDE, liveRegion);
this.present(Presentation.liveRegion(liveRegion, isPolite, false));
break;
}
case EVENT_HIDE:
{
let {liveRegion, isPolite} = this._handleLiveRegion(
aEvent.QueryInterface(Ci.nsIAccessibleHideEvent),
['removals', 'all']);
// Only handle hide if it is a relevant live region.
if (!liveRegion) {
break;
}
// Hide for text is handled by the EVENT_TEXT_REMOVED handler.
if (aEvent.accessible.role === ROLE_TEXT_LEAF) {
break;
}
this._queueLiveEvent(EVENT_HIDE, liveRegion, isPolite);
break;
}
case EVENT_TEXT_INSERTED:
case EVENT_TEXT_REMOVED:
{
if (aEvent.isFromUserInput) {
// XXX support live regions as well.
let event = aEvent.QueryInterface(Ci.nsIAccessibleTextChangeEvent);
let isInserted = event.isInserted;
let txtIface = aEvent.accessible.QueryInterface(Ci.nsIAccessibleText);
let text = '';
try {
text = txtIface.
getText(0, Ci.nsIAccessibleText.TEXT_OFFSET_END_OF_TEXT);
} catch (x) {
// XXX we might have gotten an exception with of a
// zero-length text. If we did, ignore it (bug #749810).
if (txtIface.characterCount)
throw x;
}
this.present(Presentation.textChanged(
isInserted, event.start, event.length,
text, event.modifiedText));
let {liveRegion, isPolite} = this._handleLiveRegion(aEvent,
['text', 'all']);
if (aEvent.isFromUserInput || liveRegion) {
// Handle all text mutations coming from the user or if they happen
// on a live region.
this._handleText(aEvent, liveRegion, isPolite);
}
break;
}
@ -258,6 +286,130 @@ this.EventManager.prototype = {
}
},
_handleText: function _handleText(aEvent, aLiveRegion, aIsPolite) {
let event = aEvent.QueryInterface(Ci.nsIAccessibleTextChangeEvent);
let isInserted = event.isInserted;
let txtIface = aEvent.accessible.QueryInterface(Ci.nsIAccessibleText);
let text = '';
try {
text = txtIface.getText(0, Ci.nsIAccessibleText.TEXT_OFFSET_END_OF_TEXT);
} catch (x) {
// XXX we might have gotten an exception with of a
// zero-length text. If we did, ignore it (bug #749810).
if (txtIface.characterCount) {
throw x;
}
}
// If there are embedded objects in the text, ignore them.
// Assuming changes to the descendants would already be handled by the
// show/hide event.
let modifiedText = event.modifiedText.replace(/\uFFFC/g, '').trim();
if (!modifiedText) {
return;
}
if (aLiveRegion) {
if (aEvent.eventType === EVENT_TEXT_REMOVED) {
this._queueLiveEvent(EVENT_TEXT_REMOVED, aLiveRegion, aIsPolite,
modifiedText);
} else {
this._dequeueLiveEvent(EVENT_TEXT_REMOVED, aLiveRegion);
this.present(Presentation.liveRegion(aLiveRegion, aIsPolite, false,
modifiedText));
}
} else {
this.present(Presentation.textChanged(isInserted, event.start,
event.length, text, modifiedText));
}
},
_handleLiveRegion: function _handleLiveRegion(aEvent, aRelevant) {
if (aEvent.isFromUserInput) {
return {};
}
let parseLiveAttrs = function parseLiveAttrs(aAccessible) {
let attrs = Utils.getAttributes(aAccessible);
if (attrs['container-live']) {
return {
live: attrs['container-live'],
relevant: attrs['container-relevant'] || 'additions text',
busy: attrs['container-busy'],
atomic: attrs['container-atomic'],
memberOf: attrs['member-of']
};
}
return null;
};
// XXX live attributes are not set for hidden accessibles yet. Need to
// climb up the tree to check for them.
let getLiveAttributes = function getLiveAttributes(aEvent) {
let liveAttrs = parseLiveAttrs(aEvent.accessible);
if (liveAttrs) {
return liveAttrs;
}
let parent = aEvent.targetParent;
while (parent) {
liveAttrs = parseLiveAttrs(parent);
if (liveAttrs) {
return liveAttrs;
}
parent = parent.parent
}
return {};
};
let {live, relevant, busy, atomic, memberOf} = getLiveAttributes(aEvent);
// If container-live is not present or is set to |off| ignore the event.
if (!live || live === 'off') {
return {};
}
// XXX: support busy and atomic.
// Determine if the type of the mutation is relevant. Default is additions
// and text.
let isRelevant = Utils.matchAttributeValue(relevant, aRelevant);
if (!isRelevant) {
return {};
}
return {
liveRegion: aEvent.accessible,
isPolite: live === 'polite'
};
},
_dequeueLiveEvent: function _dequeueLiveEvent(aEventType, aLiveRegion) {
let domNode = aLiveRegion.DOMNode;
if (this._liveEventQueue && this._liveEventQueue.has(domNode)) {
let queue = this._liveEventQueue.get(domNode);
let nextEvent = queue[0];
if (nextEvent.eventType === aEventType) {
Utils.win.clearTimeout(nextEvent.timeoutID);
queue.shift();
if (queue.length === 0) {
this._liveEventQueue.delete(domNode)
}
}
}
},
_queueLiveEvent: function _queueLiveEvent(aEventType, aLiveRegion, aIsPolite, aModifiedText) {
if (!this._liveEventQueue) {
this._liveEventQueue = new WeakMap();
}
let eventHandler = {
eventType: aEventType,
timeoutID: Utils.win.setTimeout(this.present.bind(this),
20, // Wait for a possible EVENT_SHOW or EVENT_TEXT_INSERTED event.
Presentation.liveRegion(aLiveRegion, aIsPolite, true, aModifiedText))
};
let domNode = aLiveRegion.DOMNode;
if (this._liveEventQueue.has(domNode)) {
this._liveEventQueue.get(domNode).push(eventHandler);
} else {
this._liveEventQueue.set(domNode, [eventHandler]);
}
},
present: function present(aPresentationData) {
this.sendMsgFunc("AccessFu:Present", aPresentationData);
},

View File

@ -116,7 +116,6 @@ this.OutputGenerator = {
let extState = {};
aAccessible.getState(state, extState);
let states = {base: state.value, ext: extState.value};
return func.apply(this, [aAccessible, roleString, states, flags, aContext]);
},
@ -418,6 +417,15 @@ this.UtteranceGenerator = {
return [gStringBundle.GetStringFromName(this.gActionMap[aActionName])];
},
genForLiveRegion: function genForLiveRegion(aContext, aIsHide, aModifiedText) {
let utterance = [];
if (aIsHide) {
utterance.push(gStringBundle.GetStringFromName('hidden'));
}
return utterance.concat(
aModifiedText || this.genForContext(aContext).output);
},
genForAnnouncement: function genForAnnouncement(aAnnouncement) {
try {
return [gStringBundle.GetStringFromName(aAnnouncement)];

View File

@ -102,7 +102,19 @@ Presenter.prototype = {
/**
* Announce something. Typically an app state change.
*/
announce: function announce(aAnnouncement) {}
announce: function announce(aAnnouncement) {},
/**
* Announce a live region.
* @param {PivotContext} aContext context object for an accessible.
* @param {boolean} aIsPolite A politeness level for a live region.
* @param {boolean} aIsHide An indicator of hide/remove event.
* @param {string} aModifiedText Optional modified text.
*/
liveRegion: function liveRegionShown(aContext, aIsPolite, aIsHide,
aModifiedText) {}
};
/**
@ -409,6 +421,13 @@ AndroidPresenter.prototype = {
fromIndex: 0
}]
};
},
liveRegion: function AndroidPresenter_liveRegion(aContext, aIsPolite,
aIsHide, aModifiedText) {
return this.announce(
UtteranceGenerator.genForLiveRegion(aContext, aIsHide,
aModifiedText).join(' '));
}
};
@ -451,6 +470,21 @@ SpeechPresenter.prototype = {
]
}
};
},
liveRegion: function SpeechPresenter_liveRegion(aContext, aIsPolite, aIsHide,
aModifiedText) {
return {
type: this.type,
details: {
actions: [{
method: 'speak',
data: UtteranceGenerator.genForLiveRegion(aContext, aIsHide,
aModifiedText).join(' '),
options: {enqueue: aIsPolite}
}]
}
};
}
};
@ -570,5 +604,16 @@ this.Presentation = {
// but there really isn't a point here.
return [p.announce(UtteranceGenerator.genForAnnouncement(aAnnouncement)[0])
for each (p in this.presenters)];
},
liveRegion: function Presentation_liveRegion(aAccessible, aIsPolite, aIsHide,
aModifiedText) {
let context;
if (!aModifiedText) {
context = new PivotContext(aAccessible, null, -1, -1, true,
aIsHide ? true : false);
}
return [p.liveRegion(context, aIsPolite, aIsHide, aModifiedText) for (
p of this.presenters)];
}
};

View File

@ -266,6 +266,15 @@ this.Utils = {
return true;
},
matchAttributeValue: function matchAttributeValue(aAttributeValue, values) {
let attrSet = new Set(aAttributeValue.split(' '));
for (let value of values) {
if (attrSet.has(value)) {
return value;
}
}
},
getLandmarkName: function getLandmarkName(aAccessible) {
const landmarks = [
'banner',
@ -281,11 +290,7 @@ this.Utils = {
}
// Looking up a role that would match a landmark.
for (let landmark of landmarks) {
if (roles.indexOf(landmark) > -1) {
return landmark;
}
}
return this.matchAttributeValue(roles, landmarks);
}
};
@ -422,12 +427,15 @@ this.Logger = {
* for a given accessible and its relationship with another accessible.
*/
this.PivotContext = function PivotContext(aAccessible, aOldAccessible,
aStartOffset, aEndOffset) {
aStartOffset, aEndOffset, aIgnoreAncestry = false,
aIncludeInvisible = false) {
this._accessible = aAccessible;
this._oldAccessible =
this._isDefunct(aOldAccessible) ? null : aOldAccessible;
this.startOffset = aStartOffset;
this.endOffset = aEndOffset;
this._ignoreAncestry = aIgnoreAncestry;
this._includeInvisible = aIncludeInvisible;
}
PivotContext.prototype = {
@ -497,7 +505,7 @@ PivotContext.prototype = {
*/
get oldAncestry() {
if (!this._oldAncestry) {
if (!this._oldAccessible) {
if (!this._oldAccessible || this._ignoreAncestry) {
this._oldAncestry = [];
} else {
this._oldAncestry = this._getAncestry(this._oldAccessible);
@ -512,7 +520,8 @@ PivotContext.prototype = {
*/
get currentAncestry() {
if (!this._currentAncestry) {
this._currentAncestry = this._getAncestry(this._accessible);
this._currentAncestry = this._ignoreAncestry ? [] :
this._getAncestry(this._accessible);
}
return this._currentAncestry;
},
@ -524,7 +533,7 @@ PivotContext.prototype = {
*/
get newAncestry() {
if (!this._newAncestry) {
this._newAncestry = [currentAncestor for (
this._newAncestry = this._ignoreAncestry ? [] : [currentAncestor for (
[index, currentAncestor] of Iterator(this.currentAncestry)) if (
currentAncestor !== this.oldAncestry[index])];
}
@ -543,9 +552,14 @@ PivotContext.prototype = {
}
let child = aAccessible.firstChild;
while (child) {
let state = {};
child.getState(state, {});
if (!(state.value & Ci.nsIAccessibleStates.STATE_INVISIBLE)) {
let include;
if (this._includeInvisible) {
include = true;
} else {
let [state,] = Utils.getStates(child);
include = !(state.value & Ci.nsIAccessibleStates.STATE_INVISIBLE);
}
if (include) {
if (aPreorder) {
yield child;
[yield node for (node of this._traverse(child, aPreorder, aStop))];
@ -703,7 +717,6 @@ PrefCache.prototype = {
if (!this.type) {
this.type = aBranch.getPrefType(this.name);
}
switch (this.type) {
case Ci.nsIPrefBranch.PREF_STRING:
return aBranch.getCharPref(this.name);

View File

@ -129,6 +129,10 @@ expandAction = expanded
activateAction = activated
cycleAction = cycled
# Live regions
# 'hidden' will be spoken when something disappears in a live region.
hidden = hidden
# Tab states
tabLoading = loading
tabLoaded = loaded