/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ /* global Components, XPCOMUtils, Utils, Logger, BraillePresenter, Presentation, UtteranceGenerator, BrailleGenerator, States, Roles, PivotContext */ /* exported Presentation */ 'use strict'; const {utils: Cu, interfaces: Ci} = Components; Cu.import('resource://gre/modules/XPCOMUtils.jsm'); Cu.import('resource://gre/modules/accessibility/Utils.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'Logger', // jshint ignore:line 'resource://gre/modules/accessibility/Utils.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'PivotContext', // jshint ignore:line 'resource://gre/modules/accessibility/Utils.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'UtteranceGenerator', // jshint ignore:line 'resource://gre/modules/accessibility/OutputGenerator.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'BrailleGenerator', // jshint ignore:line 'resource://gre/modules/accessibility/OutputGenerator.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'Roles', // jshint ignore:line 'resource://gre/modules/accessibility/Constants.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'States', // jshint ignore:line 'resource://gre/modules/accessibility/Constants.jsm'); this.EXPORTED_SYMBOLS = ['Presentation']; // jshint ignore:line /** * The interface for all presenter classes. A presenter could be, for example, * a speech output module, or a visual cursor indicator. */ function Presenter() {} Presenter.prototype = { /** * The type of presenter. Used for matching it with the appropriate output method. */ type: 'Base', /** * The virtual cursor's position changed. * @param {PivotContext} aContext the context object for the new pivot * position. * @param {int} aReason the reason for the pivot change. * See nsIAccessiblePivot. * @param {bool} aIsFromUserInput the pivot change was invoked by the user */ pivotChanged: function pivotChanged(aContext, aReason, aIsFromUserInput) {}, // jshint ignore:line /** * An object's action has been invoked. * @param {nsIAccessible} aObject the object that has been invoked. * @param {string} aActionName the name of the action. */ actionInvoked: function actionInvoked(aObject, aActionName) {}, // jshint ignore:line /** * Text has changed, either by the user or by the system. TODO. */ textChanged: function textChanged(aAccessible, aIsInserted, aStartOffset, // jshint ignore:line aLength, aText, aModifiedText) {}, // jshint ignore:line /** * Text selection has changed. TODO. */ textSelectionChanged: function textSelectionChanged( aText, aStart, aEnd, aOldStart, aOldEnd, aIsFromUserInput) {}, // jshint ignore:line /** * Selection has changed. TODO. * @param {nsIAccessible} aObject the object that has been selected. */ selectionChanged: function selectionChanged(aObject) {}, // jshint ignore:line /** * Name has changed. * @param {nsIAccessible} aAccessible the object whose value has changed. */ nameChanged: function nameChanged(aAccessible) {}, // jshint ignore: line /** * Value has changed. * @param {nsIAccessible} aAccessible the object whose value has changed. */ valueChanged: function valueChanged(aAccessible) {}, // jshint ignore:line /** * The tab, or the tab's document state has changed. * @param {nsIAccessible} aDocObj the tab document accessible that has had its * state changed, or null if the tab has no associated document yet. * @param {string} aPageState the state name for the tab, valid states are: * 'newtab', 'loading', 'newdoc', 'loaded', 'stopped', and 'reload'. */ tabStateChanged: function tabStateChanged(aDocObj, aPageState) {}, // jshint ignore:line /** * The current tab has changed. * @param {PivotContext} aDocContext context object for tab's * document. * @param {PivotContext} aVCContext context object for tab's current * virtual cursor position. */ tabSelected: function tabSelected(aDocContext, aVCContext) {}, // jshint ignore:line /** * The viewport has changed, either a scroll, pan, zoom, or * landscape/portrait toggle. * @param {Window} aWindow window of viewport that changed. * @param {PivotContext} aCurrentContext context of last pivot change. */ viewportChanged: function viewportChanged(aWindow, aCurrentContext) {}, // jshint ignore:line /** * We have entered or left text editing mode. */ editingModeChanged: function editingModeChanged(aIsEditing) {}, // jshint ignore:line /** * Announce something. Typically an app state change. */ announce: function announce(aAnnouncement) {}, // jshint ignore:line /** * User tried to move cursor forward or backward with no success. * @param {string} aMoveMethod move method that was used (eg. 'moveNext'). */ noMove: function noMove(aMoveMethod) {}, /** * 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, // jshint ignore:line aModifiedText) {} // jshint ignore:line }; /** * Visual presenter. Draws a box around the virtual cursor's position. */ function VisualPresenter() {} VisualPresenter.prototype = Object.create(Presenter.prototype); VisualPresenter.prototype.type = 'Visual'; /** * The padding in pixels between the object and the highlight border. */ VisualPresenter.prototype.BORDER_PADDING = 2; VisualPresenter.prototype.viewportChanged = function VisualPresenter_viewportChanged(aWindow, aCurrentContext) { if (!aCurrentContext) { return null; } let currentAcc = aCurrentContext.accessibleForBounds; let start = aCurrentContext.startOffset; let end = aCurrentContext.endOffset; if (Utils.isAliveAndVisible(currentAcc)) { let bounds = (start === -1 && end === -1) ? Utils.getBounds(currentAcc) : Utils.getTextBounds(currentAcc, start, end); return { type: this.type, details: { eventType: 'viewport-change', bounds: bounds, padding: this.BORDER_PADDING } }; } return null; }; VisualPresenter.prototype.pivotChanged = function VisualPresenter_pivotChanged(aContext) { if (!aContext.accessible) { // XXX: Don't hide because another vc may be using the highlight. return null; } try { aContext.accessibleForBounds.scrollTo( Ci.nsIAccessibleScrollType.SCROLL_TYPE_ANYWHERE); let bounds = (aContext.startOffset === -1 && aContext.endOffset === -1) ? aContext.bounds : Utils.getTextBounds(aContext.accessibleForBounds, aContext.startOffset, aContext.endOffset); return { type: this.type, details: { eventType: 'vc-change', bounds: bounds, padding: this.BORDER_PADDING } }; } catch (e) { Logger.logException(e, 'Failed to get bounds'); return null; } }; VisualPresenter.prototype.tabSelected = function VisualPresenter_tabSelected(aDocContext, aVCContext) { return this.pivotChanged(aVCContext, Ci.nsIAccessiblePivot.REASON_NONE); }; VisualPresenter.prototype.tabStateChanged = function VisualPresenter_tabStateChanged(aDocObj, aPageState) { if (aPageState == 'newdoc') { return {type: this.type, details: {eventType: 'tabstate-change'}}; } return null; }; /** * Android presenter. Fires Android a11y events. */ function AndroidPresenter() {} AndroidPresenter.prototype = Object.create(Presenter.prototype); AndroidPresenter.prototype.type = 'Android'; // Android AccessibilityEvent type constants. AndroidPresenter.prototype.ANDROID_VIEW_CLICKED = 0x01; AndroidPresenter.prototype.ANDROID_VIEW_LONG_CLICKED = 0x02; AndroidPresenter.prototype.ANDROID_VIEW_SELECTED = 0x04; AndroidPresenter.prototype.ANDROID_VIEW_FOCUSED = 0x08; AndroidPresenter.prototype.ANDROID_VIEW_TEXT_CHANGED = 0x10; AndroidPresenter.prototype.ANDROID_WINDOW_STATE_CHANGED = 0x20; AndroidPresenter.prototype.ANDROID_VIEW_HOVER_ENTER = 0x80; AndroidPresenter.prototype.ANDROID_VIEW_HOVER_EXIT = 0x100; AndroidPresenter.prototype.ANDROID_VIEW_SCROLLED = 0x1000; AndroidPresenter.prototype.ANDROID_VIEW_TEXT_SELECTION_CHANGED = 0x2000; AndroidPresenter.prototype.ANDROID_ANNOUNCEMENT = 0x4000; AndroidPresenter.prototype.ANDROID_VIEW_ACCESSIBILITY_FOCUSED = 0x8000; AndroidPresenter.prototype.ANDROID_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY = 0x20000; AndroidPresenter.prototype.pivotChanged = function AndroidPresenter_pivotChanged(aContext, aReason) { if (!aContext.accessible) { return null; } let androidEvents = []; let isExploreByTouch = (aReason == Ci.nsIAccessiblePivot.REASON_POINT && Utils.AndroidSdkVersion >= 14); let focusEventType = (Utils.AndroidSdkVersion >= 16) ? this.ANDROID_VIEW_ACCESSIBILITY_FOCUSED : this.ANDROID_VIEW_FOCUSED; if (isExploreByTouch) { // This isn't really used by TalkBack so this is a half-hearted attempt // for now. androidEvents.push({eventType: this.ANDROID_VIEW_HOVER_EXIT, text: []}); } let brailleOutput = {}; if (Utils.AndroidSdkVersion >= 16) { if (!this._braillePresenter) { this._braillePresenter = new BraillePresenter(); } brailleOutput = this._braillePresenter.pivotChanged(aContext, aReason). details; } if (aReason === Ci.nsIAccessiblePivot.REASON_TEXT) { if (Utils.AndroidSdkVersion >= 16) { let adjustedText = aContext.textAndAdjustedOffsets; androidEvents.push({ eventType: this.ANDROID_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY, text: [adjustedText.text], fromIndex: adjustedText.startOffset, toIndex: adjustedText.endOffset }); } } else { let state = Utils.getState(aContext.accessible); androidEvents.push({eventType: (isExploreByTouch) ? this.ANDROID_VIEW_HOVER_ENTER : focusEventType, text: Utils.localize(UtteranceGenerator.genForContext( aContext)), bounds: aContext.bounds, clickable: aContext.accessible.actionCount > 0, checkable: state.contains(States.CHECKABLE), checked: state.contains(States.CHECKED), brailleOutput: brailleOutput}); } return { type: this.type, details: androidEvents }; }; AndroidPresenter.prototype.actionInvoked = function AndroidPresenter_actionInvoked(aObject, aActionName) { let state = Utils.getState(aObject); // Checkable objects use TalkBack's text derived from the event state, // so we don't populate the text here. let text = ''; if (!state.contains(States.CHECKABLE)) { text = Utils.localize(UtteranceGenerator.genForAction(aObject, aActionName)); } return { type: this.type, details: [{ eventType: this.ANDROID_VIEW_CLICKED, text: text, checked: state.contains(States.CHECKED) }] }; }; AndroidPresenter.prototype.tabSelected = function AndroidPresenter_tabSelected(aDocContext, aVCContext) { // Send a pivot change message with the full context utterance for this doc. return this.pivotChanged(aVCContext, Ci.nsIAccessiblePivot.REASON_NONE); }; AndroidPresenter.prototype.tabStateChanged = function AndroidPresenter_tabStateChanged(aDocObj, aPageState) { return this.announce( UtteranceGenerator.genForTabStateChange(aDocObj, aPageState)); }; AndroidPresenter.prototype.textChanged = function AndroidPresenter_textChanged( aAccessible, aIsInserted, aStart, aLength, aText, aModifiedText) { let eventDetails = { eventType: this.ANDROID_VIEW_TEXT_CHANGED, text: [aText], fromIndex: aStart, removedCount: 0, addedCount: 0 }; if (aIsInserted) { eventDetails.addedCount = aLength; eventDetails.beforeText = aText.substring(0, aStart) + aText.substring(aStart + aLength); } else { eventDetails.removedCount = aLength; eventDetails.beforeText = aText.substring(0, aStart) + aModifiedText + aText.substring(aStart); } return {type: this.type, details: [eventDetails]}; }; AndroidPresenter.prototype.textSelectionChanged = function AndroidPresenter_textSelectionChanged(aText, aStart, aEnd, aOldStart, aOldEnd, aIsFromUserInput) { let androidEvents = []; if (Utils.AndroidSdkVersion >= 14 && !aIsFromUserInput) { if (!this._braillePresenter) { this._braillePresenter = new BraillePresenter(); } let brailleOutput = this._braillePresenter.textSelectionChanged( aText, aStart, aEnd, aOldStart, aOldEnd, aIsFromUserInput).details; androidEvents.push({ eventType: this.ANDROID_VIEW_TEXT_SELECTION_CHANGED, text: [aText], fromIndex: aStart, toIndex: aEnd, itemCount: aText.length, brailleOutput: brailleOutput }); } if (Utils.AndroidSdkVersion >= 16 && aIsFromUserInput) { let [from, to] = aOldStart < aStart ? [aOldStart, aStart] : [aStart, aOldStart]; androidEvents.push({ eventType: this.ANDROID_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY, text: [aText], fromIndex: from, toIndex: to }); } return { type: this.type, details: androidEvents }; }; AndroidPresenter.prototype.viewportChanged = function AndroidPresenter_viewportChanged(aWindow, aCurrentContext) { if (Utils.AndroidSdkVersion < 14) { return null; } let events = [{ eventType: this.ANDROID_VIEW_SCROLLED, text: [], scrollX: aWindow.scrollX, scrollY: aWindow.scrollY, maxScrollX: aWindow.scrollMaxX, maxScrollY: aWindow.scrollMaxY }]; if (Utils.AndroidSdkVersion >= 16 && aCurrentContext) { let currentAcc = aCurrentContext.accessibleForBounds; if (Utils.isAliveAndVisible(currentAcc)) { events.push({ eventType: this.ANDROID_VIEW_ACCESSIBILITY_FOCUSED, bounds: Utils.getBounds(currentAcc) }); } } return { type: this.type, details: events }; }; AndroidPresenter.prototype.editingModeChanged = function AndroidPresenter_editingModeChanged(aIsEditing) { return this.announce(UtteranceGenerator.genForEditingMode(aIsEditing)); }; AndroidPresenter.prototype.announce = function AndroidPresenter_announce(aAnnouncement) { let localizedAnnouncement = Utils.localize(aAnnouncement).join(' '); return { type: this.type, details: [{ eventType: (Utils.AndroidSdkVersion >= 16) ? this.ANDROID_ANNOUNCEMENT : this.ANDROID_VIEW_TEXT_CHANGED, text: [localizedAnnouncement], addedCount: localizedAnnouncement.length, removedCount: 0, fromIndex: 0 }] }; }; AndroidPresenter.prototype.liveRegion = function AndroidPresenter_liveRegion(aContext, aIsPolite, aIsHide, aModifiedText) { return this.announce( UtteranceGenerator.genForLiveRegion(aContext, aIsHide, aModifiedText)); }; AndroidPresenter.prototype.noMove = function AndroidPresenter_noMove(aMoveMethod) { return { type: this.type, details: [ { eventType: this.ANDROID_VIEW_ACCESSIBILITY_FOCUSED, exitView: aMoveMethod, text: [''] }] }; }; /** * A B2G presenter for Gaia. */ function B2GPresenter() {} B2GPresenter.prototype = Object.create(Presenter.prototype); B2GPresenter.prototype.type = 'B2G'; B2GPresenter.prototype.keyboardEchoSetting = new PrefCache('accessibility.accessfu.keyboard_echo'); B2GPresenter.prototype.NO_ECHO = 0; B2GPresenter.prototype.CHARACTER_ECHO = 1; B2GPresenter.prototype.WORD_ECHO = 2; B2GPresenter.prototype.CHARACTER_AND_WORD_ECHO = 3; /** * A pattern used for haptic feedback. * @type {Array} */ B2GPresenter.prototype.PIVOT_CHANGE_HAPTIC_PATTERN = [40]; /** * Pivot move reasons. * @type {Array} */ B2GPresenter.prototype.pivotChangedReasons = ['none', 'next', 'prev', 'first', 'last', 'text', 'point']; B2GPresenter.prototype.pivotChanged = function B2GPresenter_pivotChanged(aContext, aReason, aIsUserInput) { if (!aContext.accessible) { return null; } return { type: this.type, details: { eventType: 'vc-change', data: UtteranceGenerator.genForContext(aContext), options: { pattern: this.PIVOT_CHANGE_HAPTIC_PATTERN, isKey: Utils.isActivatableOnFingerUp(aContext.accessible), reason: this.pivotChangedReasons[aReason], isUserInput: aIsUserInput, hints: aContext.interactionHints } } }; }; B2GPresenter.prototype.nameChanged = function B2GPresenter_nameChanged(aAccessible, aIsPolite = true) { return { type: this.type, details: { eventType: 'name-change', data: aAccessible.name, options: {enqueue: aIsPolite} } }; }; B2GPresenter.prototype.valueChanged = function B2GPresenter_valueChanged(aAccessible, aIsPolite = true) { // the editable value changes are handled in the text changed presenter if (Utils.getState(aAccessible).contains(States.EDITABLE)) { return null; } return { type: this.type, details: { eventType: 'value-change', data: aAccessible.value, options: {enqueue: aIsPolite} } }; }; B2GPresenter.prototype.textChanged = function B2GPresenter_textChanged( aAccessible, aIsInserted, aStart, aLength, aText, aModifiedText) { let echoSetting = this.keyboardEchoSetting.value; let text = ''; if (echoSetting == this.CHARACTER_ECHO || echoSetting == this.CHARACTER_AND_WORD_ECHO) { text = aModifiedText; } // add word if word boundary is added if ((echoSetting == this.WORD_ECHO || echoSetting == this.CHARACTER_AND_WORD_ECHO) && aIsInserted && aLength === 1) { let accText = aAccessible.QueryInterface(Ci.nsIAccessibleText); let startBefore = {}, endBefore = {}; let startAfter = {}, endAfter = {}; accText.getTextBeforeOffset(aStart, Ci.nsIAccessibleText.BOUNDARY_WORD_END, startBefore, endBefore); let maybeWord = accText.getTextBeforeOffset(aStart + 1, Ci.nsIAccessibleText.BOUNDARY_WORD_END, startAfter, endAfter); if (endBefore.value !== endAfter.value) { text += maybeWord; } } return { type: this.type, details: { eventType: 'text-change', data: text } }; }; B2GPresenter.prototype.actionInvoked = function B2GPresenter_actionInvoked(aObject, aActionName) { return { type: this.type, details: { eventType: 'action', data: UtteranceGenerator.genForAction(aObject, aActionName) } }; }; B2GPresenter.prototype.liveRegion = function B2GPresenter_liveRegion(aContext, aIsPolite, aIsHide, aModifiedText) { return { type: this.type, details: { eventType: 'liveregion-change', data: UtteranceGenerator.genForLiveRegion(aContext, aIsHide, aModifiedText), options: {enqueue: aIsPolite} } }; }; B2GPresenter.prototype.announce = function B2GPresenter_announce(aAnnouncement) { return { type: this.type, details: { eventType: 'announcement', data: aAnnouncement } }; }; B2GPresenter.prototype.noMove = function B2GPresenter_noMove(aMoveMethod) { return { type: this.type, details: { eventType: 'no-move', data: aMoveMethod } }; }; /** * A braille presenter */ function BraillePresenter() {} BraillePresenter.prototype = Object.create(Presenter.prototype); BraillePresenter.prototype.type = 'Braille'; BraillePresenter.prototype.pivotChanged = function BraillePresenter_pivotChanged(aContext) { if (!aContext.accessible) { return null; } return { type: this.type, details: { output: Utils.localize(BrailleGenerator.genForContext(aContext)).join( ' '), selectionStart: 0, selectionEnd: 0 } }; }; BraillePresenter.prototype.textSelectionChanged = function BraillePresenter_textSelectionChanged(aText, aStart, aEnd) { return { type: this.type, details: { selectionStart: aStart, selectionEnd: aEnd } }; }; this.Presentation = { // jshint ignore:line get presenters() { delete this.presenters; let presenterMap = { 'mobile/android': [VisualPresenter, AndroidPresenter], 'b2g': [VisualPresenter, B2GPresenter], 'browser': [VisualPresenter, B2GPresenter, AndroidPresenter] }; this.presenters = presenterMap[Utils.MozBuildApp].map(P => new P()); return this.presenters; }, get displayedAccessibles() { delete this.displayedAccessibles; this.displayedAccessibles = new WeakMap(); return this.displayedAccessibles; }, pivotChanged: function Presentation_pivotChanged( aPosition, aOldPosition, aReason, aStartOffset, aEndOffset, aIsUserInput) { let context = new PivotContext( aPosition, aOldPosition, aStartOffset, aEndOffset); if (context.accessible) { this.displayedAccessibles.set(context.accessible.document.window, context); } return this.presenters.map(p => p.pivotChanged(context, aReason, aIsUserInput)); }, actionInvoked: function Presentation_actionInvoked(aObject, aActionName) { return this.presenters.map(p => p.actionInvoked(aObject, aActionName)); }, textChanged: function Presentation_textChanged(aAccessible, aIsInserted, aStartOffset, aLength, aText, aModifiedText) { return this.presenters.map(p => p.textChanged(aAccessible, aIsInserted, aStartOffset, aLength, aText, aModifiedText)); }, textSelectionChanged: function textSelectionChanged(aText, aStart, aEnd, aOldStart, aOldEnd, aIsFromUserInput) { return this.presenters.map(p => p.textSelectionChanged(aText, aStart, aEnd, aOldStart, aOldEnd, aIsFromUserInput)); }, nameChanged: function nameChanged(aAccessible) { return this.presenters.map(p => p.nameChanged(aAccessible)); }, valueChanged: function valueChanged(aAccessible) { return this.presenters.map(p => p.valueChanged(aAccessible)); }, tabStateChanged: function Presentation_tabStateChanged(aDocObj, aPageState) { return this.presenters.map(p => p.tabStateChanged(aDocObj, aPageState)); }, viewportChanged: function Presentation_viewportChanged(aWindow) { let context = this.displayedAccessibles.get(aWindow); return this.presenters.map(p => p.viewportChanged(aWindow, context)); }, editingModeChanged: function Presentation_editingModeChanged(aIsEditing) { return this.presenters.map(p => p.editingModeChanged(aIsEditing)); }, announce: function Presentation_announce(aAnnouncement) { // XXX: Typically each presenter uses the UtteranceGenerator, // but there really isn't a point here. return this.presenters.map(p => p.announce(UtteranceGenerator.genForAnnouncement(aAnnouncement))); }, noMove: function Presentation_noMove(aMoveMethod) { return this.presenters.map(p => p.noMove(aMoveMethod)); }, liveRegion: function Presentation_liveRegion(aAccessible, aIsPolite, aIsHide, aModifiedText) { let context; if (!aModifiedText) { context = new PivotContext(aAccessible, null, -1, -1, true, aIsHide ? true : false); } return this.presenters.map(p => p.liveRegion(context, aIsPolite, aIsHide, aModifiedText)); } };