1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
6 * @fileoverview Provides output services for ChromeVox.
9 goog.provide('Output');
10 goog.provide('Output.EventType');
12 goog.require('AutomationUtil.Dir');
13 goog.require('cursors.Cursor');
14 goog.require('cursors.Range');
15 goog.require('cursors.Unit');
16 goog.require('cvox.AbstractEarcons');
17 goog.require('cvox.NavBraille');
18 goog.require('cvox.Spannable');
19 goog.require('cvox.ValueSelectionSpan');
20 goog.require('cvox.ValueSpan');
21 goog.require('goog.i18n.MessageFormat');
23 goog.scope(function() {
24 var Dir = AutomationUtil.Dir;
27 * An Output object formats a cursors.Range into speech, braille, or both
28 * representations. This is typically a cvox.Spannable.
30 * The translation from Range to these output representations rely upon format
31 * rules which specify how to convert AutomationNode objects into annotated
33 * The format of these rules is as follows.
35 * $ prefix: used to substitute either an attribute or a specialized value from
36 * an AutomationNode. Specialized values include role and state.
37 * For example, $value $role $enabled
38 * @ prefix: used to substitute a message. Note the ability to specify params to
39 * the message. For example, '@tag_html' '@selected_index($text_sel_start,
41 * @@ prefix: similar to @, used to substitute a message, but also pulls the
42 * localized string through goog.i18n.MessageFormat to support locale
43 * aware plural handling. The first argument should be a number which will
44 * be passed as a COUNT named parameter to MessageFormat.
45 * TODO(plundblad): Make subsequent arguments normal placeholder arguments
47 * = suffix: used to specify substitution only if not previously appended.
48 * For example, $name= would insert the name attribute only if no name
49 * attribute had been inserted previously.
53 // TODO(dtseng): Include braille specific rules.
54 /** @type {!Array<cvox.Spannable>} */
55 this.speechBuffer_ = [];
56 /** @type {!Array<cvox.Spannable>} */
57 this.brailleBuffer_ = [];
58 /** @type {!Array<Object>} */
60 /** @type {function(?)} */
61 this.speechEndCallback_;
64 * Current global options.
65 * @type {{speech: boolean, braille: boolean, location: boolean}}
67 this.formatOptions_ = {speech: true, braille: false, location: true};
70 * Speech properties to apply to the entire output.
73 this.speechProperties_ = {};
77 * Delimiter to use between output values.
83 * Metadata about supported automation roles.
84 * @const {Object<{msgId: string,
85 * earconId: (string|undefined),
86 * inherits: (string|undefined)}>}
87 * msgId: the message id of the role.
88 * earconId: an optional earcon to play when encountering the role.
89 * inherits: inherits rules from this role.
94 msgId: 'aria_role_alert',
95 earconId: 'ALERT_NONMODAL',
98 msgId: 'aria_role_alertdialog'
101 msgId: 'aria_role_article',
102 inherits: 'abstractContainer'
105 msgId: 'aria_role_application',
106 inherits: 'abstractContainer'
109 msgId: 'aria_role_banner',
110 inherits: 'abstractContainer'
121 msgId: 'aria_role_gridcell'
124 msgId: 'input_type_checkbox'
127 msgId: 'aria_role_columnheader',
128 inherits: 'abstractContainer'
131 msgId: 'aria_role_combobox'
134 msgId: 'aria_role_complementary',
135 inherits: 'abstractContainer'
138 msgId: 'aria_role_contentinfo',
139 inherits: 'abstractContainer'
142 msgId: 'input_type_date',
143 inherits: 'abstractContainer'
146 msgId: 'aria_role_definition',
147 inherits: 'abstractContainer'
153 msgId: 'aria_role_directory',
154 inherits: 'abstractContainer'
157 msgId: 'aria_role_document',
158 inherits: 'abstractContainer'
161 msgId: 'aria_role_form',
162 inherits: 'abstractContainer'
165 msgId: 'aria_role_grid'
168 msgId: 'aria_role_group'
171 msgId: 'aria_role_heading',
174 msgId: 'aria_role_img',
177 msgId: 'input_type_time',
178 inherits: 'abstractContainer'
185 msgId: 'aria_role_listbox',
189 msgId: 'aria_role_listitem',
190 earconId: 'LIST_ITEM'
193 msgId: 'aria_role_listitem',
194 earconId: 'LIST_ITEM'
197 msgId: 'aria_role_log',
200 msgId: 'aria_role_main',
201 inherits: 'abstractContainer'
204 msgId: 'aria_role_marquee',
207 msgId: 'aria_role_math',
208 inherits: 'abstractContainer'
211 msgId: 'aria_role_menu'
214 msgId: 'aria_role_menubar',
217 msgId: 'aria_role_menuitem'
220 msgId: 'aria_role_menuitemcheckbox'
223 msgId: 'aria_role_menuitemradio'
226 msgId: 'aria_role_menuitem'
229 msgId: 'aria_role_menu'
232 msgId: 'aria_role_navigation',
233 inherits: 'abstractContainer'
236 msgId: 'aria_role_note',
237 inherits: 'abstractContainer'
244 msgId: 'input_type_radio'
247 msgId: 'aria_role_radiogroup',
250 msgId: 'aria_role_region',
251 inherits: 'abstractContainer'
254 msgId: 'aria_role_rowheader',
255 inherits: 'abstractContainer'
258 msgId: 'aria_role_scrollbar',
261 msgId: 'aria_role_search',
262 inherits: 'abstractContainer'
265 msgId: 'aria_role_separator',
266 inherits: 'abstractContainer'
269 msgId: 'aria_role_spinbutton',
273 msgId: 'aria_role_status'
276 msgId: 'aria_role_tab'
279 msgId: 'aria_role_tablist'
282 msgId: 'aria_role_tabpanel'
285 msgId: 'input_type_text',
286 earconId: 'EDITABLE_TEXT'
289 msgId: 'input_type_text',
290 earconId: 'EDITABLE_TEXT'
294 inherits: 'abstractContainer'
297 msgId: 'aria_role_timer'
300 msgId: 'aria_role_toolbar'
303 msgId: 'aria_role_tree'
306 msgId: 'aria_role_treeitem'
311 * Metadata about supported automation states.
312 * @const {!Object<{on: {msgId: string, earconId: string},
313 * off: {msgId: string, earconId: string},
314 * omitted: {msgId: string, earconId: string}}>}
315 * on: info used to describe a state that is set to true.
316 * off: info used to describe a state that is set to false.
317 * omitted: info used to describe a state that is undefined.
320 Output.STATE_INFO_ = {
323 earconId: 'CHECK_ON',
324 msgId: 'checkbox_checked_state'
327 earconId: 'CHECK_OFF',
328 msgId: 'checkbox_unchecked_state'
331 earconId: 'CHECK_OFF',
332 msgId: 'checkbox_unchecked_state'
337 msgId: 'aria_expanded_false'
340 msgId: 'aria_expanded_true'
345 msgId: 'aria_expanded_true'
348 msgId: 'aria_expanded_false'
353 msgId: 'visited_state'
359 * Rules specifying format of AutomationNodes for output.
360 * @type {!Object<Object<Object<string>>>}
365 speak: '$name $value $description $help $role',
369 enter: '$name $role',
370 leave: '@exited_container($role)'
373 speak: '!doNotInterrupt $role $descendants'
376 enter: '$name $role $descendants'
379 enter: '@column_granularity $tableCellColumnIndex'
382 speak: '$name $role $checked'
391 enter: '@tag_h+$hierarchicalLevel',
392 speak: '@tag_h+$hierarchicalLevel $nameOrDescendants='
398 enter: '$name $if($visited, @visited_link, $role)',
399 stay: '$name= $if($visited, @visited_link, $role)',
400 speak: '$name= $if($visited, @visited_link, $role)'
403 enter: '$role @@list_with_items($countChildren(listItem))'
406 enter: '$name $role @@list_with_items($countChildren(listBoxOption))'
409 speak: '$name $role @describe_index($indexInParent, $parentChildCount)'
415 enter: '$name $role @@list_with_items($countChildren(menuItem))'
418 speak: '$name $role $if($haspopup, @has_submenu) ' +
419 '@describe_index($indexInParent, $parentChildCount)'
422 speak: '$name $value @aria_role_menuitem ' +
423 '@describe_index($indexInParent, $parentChildCount)'
429 speak: '$value $name $role @aria_has_popup ' +
430 '$if($collapsed, @aria_expanded_false, @aria_expanded_true)'
433 speak: '$if($checked, @describe_radio_selected($name), ' +
434 '@describe_radio_unselected($name))'
440 enter: '@row_granularity $tableRowIndex'
443 speak: '@describe_slider($value, $name) $help'
446 speak: '$value $name'
449 speak: '@describe_tab($name)'
452 speak: '$name $value $if(' +
453 '$inputType, @input_type_+$inputType, @input_type_text)',
460 enter: '$name $role @@list_with_items($countChildren(treeItem))'
463 enter: '$role $expanded $collapsed ' +
464 '@describe_index($indexInParent, $parentChildCount) ' +
465 '@describe_depth($hierarchicalLevel)'
469 speak: '@describe_window($name) $earcon(OBJECT_OPEN)'
474 speak: '@chrome_menu_opened($name) $earcon(OBJECT_OPEN)'
479 speak: '@chrome_menu_closed $earcon(OBJECT_CLOSE)'
482 menuListValueChanged: {
484 speak: '$value $name ' +
485 '$find({"state": {"selected": true, "invisible": false}}, ' +
486 '@describe_index($indexInParent, $parentChildCount)) '
491 speak: '!doNotInterrupt ' +
492 '@aria_role_alert $name $earcon(ALERT_NONMODAL) $descendants'
498 * Custom actions performed while rendering an output string.
501 Output.Action = function() {
504 Output.Action.prototype = {
510 * Action to play an earcon.
511 * @param {string} earconId
513 * @extends {Output.Action}
515 Output.EarconAction = function(earconId) {
516 Output.Action.call(this);
517 /** @type {string} */
518 this.earconId = earconId;
521 Output.EarconAction.prototype = {
522 __proto__: Output.Action.prototype,
526 cvox.ChromeVox.earcons.playEarcon(cvox.AbstractEarcons[this.earconId]);
531 * Annotation for selection.
532 * @param {number} startIndex
533 * @param {number} endIndex
536 Output.SelectionSpan = function(startIndex, endIndex) {
537 // TODO(dtseng): Direction lost below; should preserve for braille panning.
538 this.startIndex = startIndex < endIndex ? startIndex : endIndex;
539 this.endIndex = endIndex > startIndex ? endIndex : startIndex;
543 * Possible events handled by ChromeVox internally.
552 * Gets the spoken output with separator '|'.
553 * @return {!cvox.Spannable}
555 get speechOutputForTest() {
556 return this.speechBuffer_.reduce(function(prev, cur) {
566 * Gets the output buffer for braille.
567 * @return {!cvox.Spannable}
569 get brailleOutputForTest() {
570 return this.createBrailleOutput_();
574 * Specify ranges for speech.
575 * @param {!cursors.Range} range
576 * @param {cursors.Range} prevRange
577 * @param {chrome.automation.EventType|Output.EventType} type
580 withSpeech: function(range, prevRange, type) {
581 this.formatOptions_ = {speech: true, braille: false, location: true};
582 this.render_(range, prevRange, type, this.speechBuffer_);
587 * Specify ranges for braille.
588 * @param {!cursors.Range} range
589 * @param {cursors.Range} prevRange
590 * @param {chrome.automation.EventType|Output.EventType} type
593 withBraille: function(range, prevRange, type) {
594 this.formatOptions_ = {speech: false, braille: true, location: false};
595 this.render_(range, prevRange, type, this.brailleBuffer_);
600 * Specify the same ranges for speech and braille.
601 * @param {!cursors.Range} range
602 * @param {cursors.Range} prevRange
603 * @param {chrome.automation.EventType|Output.EventType} type
606 withSpeechAndBraille: function(range, prevRange, type) {
607 this.withSpeech(range, prevRange, type);
608 this.withBraille(range, prevRange, type);
613 * Apply a format string directly to the output buffer. This lets you
614 * output a message directly to the buffer using the format syntax.
615 * @param {string} formatStr
618 format: function(formatStr) {
619 this.formatOptions_ = {speech: true, braille: false, location: true};
620 this.format_(null, formatStr, this.speechBuffer_);
622 this.formatOptions_ = {speech: false, braille: true, location: false};
623 this.format_(null, formatStr, this.brailleBuffer_);
629 * Triggers callback for a speech event.
630 * @param {function()} callback
632 onSpeechEnd: function(callback) {
633 this.speechEndCallback_ = function(opt_cleanupOnly) {
634 if (!opt_cleanupOnly)
641 * Executes all specified output.
645 var queueMode = cvox.QueueMode.FLUSH;
646 this.speechBuffer_.forEach(function(buff, i, a) {
647 if (buff.toString()) {
649 var scopedBuff = buff;
650 this.speechProperties_['startCallback'] = function() {
651 var actions = scopedBuff.getSpansInstanceOf(Output.Action);
653 actions.forEach(function(a) {
660 if (this.speechEndCallback_ && i == a.length - 1)
661 this.speechProperties_['endCallback'] = this.speechEndCallback_;
663 this.speechProperties_['endCallback'] = null;
664 cvox.ChromeVox.tts.speak(
665 buff.toString(), queueMode, this.speechProperties_);
666 queueMode = cvox.QueueMode.QUEUE;
671 if (this.brailleBuffer_.length) {
672 var buff = this.createBrailleOutput_();
674 buff.getSpanInstanceOf(Output.SelectionSpan);
675 var startIndex = -1, endIndex = -1;
677 // Casts ok, since the span is known to be in the spannable.
679 /** @type {number} */ (buff.getSpanStart(selSpan));
681 /** @type {number} */ (buff.getSpanEnd(selSpan));
682 startIndex = valueStart + selSpan.startIndex;
683 endIndex = valueStart + selSpan.endIndex;
684 buff.setSpan(new cvox.ValueSpan(0), valueStart, valueEnd);
685 buff.setSpan(new cvox.ValueSelectionSpan(), startIndex, endIndex);
688 var output = new cvox.NavBraille({
690 startIndex: startIndex,
694 cvox.ChromeVox.braille.write(output);
698 chrome.accessibilityPrivate.setFocusRing(this.locations_);
702 * Renders the given range using optional context previous range and event
704 * @param {!cursors.Range} range
705 * @param {cursors.Range} prevRange
706 * @param {chrome.automation.EventType|string} type
707 * @param {!Array<cvox.Spannable>} buff Buffer to receive rendered output.
710 render_: function(range, prevRange, type, buff) {
711 if (range.isSubNode())
712 this.subNode_(range, prevRange, type, buff);
714 this.range_(range, prevRange, type, buff);
718 * Format the node given the format specifier.
719 * @param {chrome.automation.AutomationNode} node
720 * @param {string|!Object} format The output format either specified as an
721 * output template string or a parsed output format tree.
722 * @param {!Array<cvox.Spannable>} buff Buffer to receive rendered output.
723 * @param {!Object=} opt_exclude A set of attributes to exclude.
726 format_: function(node, format, buff, opt_exclude) {
727 opt_exclude = opt_exclude || {};
731 // Hacky way to support args.
732 if (typeof(format) == 'string') {
733 format = format.replace(/([,:])\W/g, '$1');
734 tokens = format.split(' ');
739 tokens.forEach(function(token) {
740 // Ignore empty tokens.
746 if (typeof(token) == 'string')
747 tree = this.createParseTree_(token);
751 // Obtain the operator token.
754 // Set suffix options.
756 options.annotation = [];
757 options.isUnique = token[token.length - 1] == '=';
758 if (options.isUnique)
759 token = token.substring(0, token.length - 1);
761 // Process token based on prefix.
762 var prefix = token[0];
763 token = token.slice(1);
765 if (opt_exclude[token])
768 // All possible tokens based on prefix.
770 if (token == 'value') {
771 var text = node.value;
772 if (text !== undefined) {
773 if (node.textSelStart !== undefined) {
774 options.annotation.push(new Output.SelectionSpan(
779 // Annotate this as a name so we don't duplicate names from ancestors.
780 if (node.role == chrome.automation.RoleType.inlineTextBox)
782 options.annotation.push(token);
783 this.append_(buff, text, options);
784 } else if (token == 'name') {
785 options.annotation.push(token);
786 if (this.formatOptions_.speech) {
787 var earconFinder = node;
788 while (earconFinder) {
789 var info = Output.ROLE_INFO_[earconFinder.role];
790 if (info && info.earconId) {
791 options.annotation.push(
792 new Output.EarconAction(info.earconId));
795 earconFinder = earconFinder.parent;
799 // Pending finalization of name calculation; we must use the
800 // attributes property to access aria-label. See crbug.com/473220.
801 node.attributes = node.attributes || {};
802 var resolvedName = node.name || node.attributes['aria-label'];
803 this.append_(buff, resolvedName, options);
804 } else if (token == 'nameOrDescendants') {
805 options.annotation.push(token);
807 this.append_(buff, node.name, options);
809 this.format_(node, '$descendants', buff);
810 } else if (token == 'indexInParent') {
811 options.annotation.push(token);
812 this.append_(buff, String(node.indexInParent + 1));
813 } else if (token == 'parentChildCount') {
814 options.annotation.push(token);
816 this.append_(buff, String(node.parent.children.length));
817 } else if (token == 'state') {
818 options.annotation.push(token);
819 Object.getOwnPropertyNames(node.state).forEach(function(s) {
820 this.append_(buff, s, options);
822 } else if (token == 'find') {
823 // Find takes two arguments: JSON query string and format string.
824 if (tree.firstChild) {
825 var jsonQuery = tree.firstChild.value;
827 /** @type {Object}*/(JSON.parse(jsonQuery)));
828 var formatString = tree.firstChild.nextSibling;
830 this.format_(node, formatString, buff);
832 } else if (token == 'descendants') {
833 if (AutomationPredicate.leaf(node))
836 // Construct a range to the leftmost and rightmost leaves.
837 var leftmost = AutomationUtil.findNodePre(
838 node, Dir.FORWARD, AutomationPredicate.leaf);
839 var rightmost = AutomationUtil.findNodePre(
840 node, Dir.BACKWARD, AutomationPredicate.leaf);
841 if (!leftmost || !rightmost)
844 var subrange = new cursors.Range(
845 new cursors.Cursor(leftmost, 0),
846 new cursors.Cursor(rightmost, 0));
849 prev = cursors.Range.fromNode(node);
850 this.range_(subrange, prev, 'navigate', buff);
851 } else if (token == 'role') {
852 options.annotation.push(token);
854 var info = Output.ROLE_INFO_[node.role];
856 if (this.formatOptions_.braille)
857 msg = cvox.ChromeVox.msgs.getMsg(info.msgId + '_brl');
859 msg = cvox.ChromeVox.msgs.getMsg(info.msgId);
861 console.error('Missing role info for ' + node.role);
863 this.append_(buff, msg, options);
864 } else if (token == 'tableRowIndex' ||
865 token == 'tableCellColumnIndex') {
866 var value = node[token];
869 value = String(value + 1);
870 options.annotation.push(token);
871 this.append_(buff, value, options);
872 } else if (node[token] !== undefined) {
873 options.annotation.push(token);
874 var value = node[token];
875 if (typeof value == 'number')
876 value = String(value);
877 this.append_(buff, value, options);
878 } else if (Output.STATE_INFO_[token]) {
879 options.annotation.push('state');
880 var stateInfo = Output.STATE_INFO_[token];
881 var resolvedInfo = {};
882 if (node.state[token] === undefined)
883 resolvedInfo = stateInfo.omitted;
885 resolvedInfo = node.state[token] ? stateInfo.on : stateInfo.off;
888 if (this.formatOptions_.speech && resolvedInfo.earconId) {
889 options.annotation.push(
890 new Output.EarconAction(resolvedInfo.earconId));
893 this.formatOptions_.braille ? resolvedInfo.msgId + '_brl' :
895 var msg = cvox.ChromeVox.msgs.getMsg(msgId);
896 this.append_(buff, msg, options);
897 } else if (tree.firstChild) {
900 var cond = tree.firstChild;
901 var attrib = cond.value.slice(1);
902 if (node[attrib] || node.state[attrib])
903 this.format_(node, cond.nextSibling, buff);
905 this.format_(node, cond.nextSibling.nextSibling, buff);
906 } else if (token == 'earcon') {
907 // Ignore unless we're generating speech output.
908 if (!this.formatOptions_.speech)
910 // Assumes there's existing output in our buffer.
911 var lastBuff = buff[buff.length - 1];
916 new Output.EarconAction(tree.firstChild.value), 0, 0);
917 } else if (token == 'countChildren') {
918 var role = tree.firstChild.value;
919 var count = node.children.filter(function(e) {
920 return e.role == role;
922 this.append_(buff, String(count));
925 } else if (prefix == '@') {
926 var isPluralized = (token[0] == '@');
928 token = token.slice(1);
929 // Tokens can have substitutions.
930 var pieces = token.split('+');
931 token = pieces.reduce(function(prev, cur) {
934 lookup = node[cur.slice(1)];
935 return prev + lookup;
940 var curArg = tree.firstChild;
942 if (curArg.value[0] != '$') {
943 console.error('Unexpected value: ' + curArg.value);
947 this.format_(node, curArg, msgBuff);
948 msgArgs = msgArgs.concat(msgBuff);
949 curArg = curArg.nextSibling;
952 var msg = cvox.ChromeVox.msgs.getMsg(msgId, msgArgs);
954 if (this.formatOptions_.braille)
955 msg = cvox.ChromeVox.msgs.getMsg(msgId + '_brl', msgArgs) || msg;
959 console.log('Could not get message ' + msgId);
964 var arg = tree.firstChild;
965 if (!arg || arg.nextSibling) {
966 console.error('Pluralized messages take exactly one argument');
969 if (arg.value[0] != '$') {
970 console.error('Unexpected value: ' + arg.value);
974 this.format_(node, arg, argBuff);
975 var namedArgs = {COUNT: Number(argBuff[0])};
976 msg = new goog.i18n.MessageFormat(msg).format(namedArgs);
979 this.append_(buff, msg, options);
980 } else if (prefix == '!') {
981 this.speechProperties_[token] = true;
987 * @param {!cursors.Range} range
988 * @param {cursors.Range} prevRange
989 * @param {chrome.automation.EventType|string} type
990 * @param {!Array<cvox.Spannable>} rangeBuff
993 range_: function(range, prevRange, type, rangeBuff) {
995 prevRange = cursors.Range.fromNode(range.start.node.root);
997 var cursor = range.start;
998 var prevNode = prevRange.start.node;
1000 var formatNodeAndAncestors = function(node, prevNode) {
1002 this.ancestry_(node, prevNode, type, buff);
1003 this.node_(node, prevNode, type, buff);
1004 if (this.formatOptions_.location)
1005 this.locations_.push(node.location);
1009 while (cursor.node != range.end.node) {
1010 var node = cursor.node;
1011 rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(node, prevNode));
1013 cursor = cursor.move(cursors.Unit.NODE,
1014 cursors.Movement.DIRECTIONAL,
1017 // Reached a boundary.
1018 if (cursor.node == prevNode)
1021 var lastNode = range.end.node;
1022 rangeBuff.push.apply(rangeBuff, formatNodeAndAncestors(lastNode, prevNode));
1026 * @param {!chrome.automation.AutomationNode} node
1027 * @param {!chrome.automation.AutomationNode} prevNode
1028 * @param {chrome.automation.EventType|string} type
1029 * @param {!Array<cvox.Spannable>} buff
1030 * @param {!Object=} opt_exclude A list of attributes to exclude from
1034 ancestry_: function(node, prevNode, type, buff, opt_exclude) {
1035 opt_exclude = opt_exclude || {};
1036 var prevUniqueAncestors =
1037 AutomationUtil.getUniqueAncestors(node, prevNode);
1038 var uniqueAncestors = AutomationUtil.getUniqueAncestors(prevNode, node);
1040 // First, look up the event type's format block.
1041 // Navigate is the default event.
1042 var eventBlock = Output.RULES[type] || Output.RULES['navigate'];
1044 var getMergedRoleBlock = function(role) {
1045 var parentRole = (Output.ROLE_INFO_[role] || {}).inherits;
1046 var roleBlock = eventBlock[role] || eventBlock['default'];
1047 var parentRoleBlock = parentRole ? eventBlock[parentRole] : {};
1048 var mergedRoleBlock = {};
1049 for (var key in parentRoleBlock)
1050 mergedRoleBlock[key] = parentRoleBlock[key];
1051 for (var key in roleBlock)
1052 mergedRoleBlock[key] = roleBlock[key];
1053 return mergedRoleBlock;
1056 for (var i = 0, formatPrevNode;
1057 (formatPrevNode = prevUniqueAncestors[i]);
1059 var roleBlock = getMergedRoleBlock(formatPrevNode.role);
1060 if (roleBlock.leave)
1061 this.format_(formatPrevNode, roleBlock.leave, buff, opt_exclude);
1064 var enterOutputs = [];
1066 for (var j = uniqueAncestors.length - 2, formatNode;
1067 (formatNode = uniqueAncestors[j]);
1069 var roleBlock = getMergedRoleBlock(formatNode.role);
1070 if (roleBlock.enter) {
1071 if (enterRole[formatNode.role])
1073 enterRole[formatNode.role] = true;
1075 this.format_(formatNode, roleBlock.enter, tempBuff, opt_exclude);
1076 enterOutputs.unshift(tempBuff);
1078 if (formatNode.role == 'window')
1081 enterOutputs.forEach(function(b) {
1082 buff.push.apply(buff, b);
1085 if (!opt_exclude.stay) {
1086 var commonFormatNode = uniqueAncestors[0];
1087 while (commonFormatNode && commonFormatNode.parent) {
1088 commonFormatNode = commonFormatNode.parent;
1090 eventBlock[commonFormatNode.role] || eventBlock['default'];
1092 this.format_(commonFormatNode, roleBlock.stay, buff, opt_exclude);
1098 * @param {!chrome.automation.AutomationNode} node
1099 * @param {!chrome.automation.AutomationNode} prevNode
1100 * @param {chrome.automation.EventType|string} type
1101 * @param {!Array<cvox.Spannable>} buff
1104 node_: function(node, prevNode, type, buff) {
1105 // Navigate is the default event.
1106 var eventBlock = Output.RULES[type] || Output.RULES['navigate'];
1107 var roleBlock = eventBlock[node.role] || eventBlock['default'];
1108 var speakFormat = roleBlock.speak || eventBlock['default'].speak;
1109 this.format_(node, speakFormat, buff);
1113 * @param {!cursors.Range} range
1114 * @param {cursors.Range} prevRange
1115 * @param {chrome.automation.EventType|string} type
1116 * @param {!Array<cvox.Spannable>} buff
1119 subNode_: function(range, prevRange, type, buff) {
1122 var dir = cursors.Range.getDirection(prevRange, range);
1123 var prevNode = prevRange.getBound(dir).node;
1125 range.start.node, prevNode, type, buff,
1126 {stay: true, name: true, value: true});
1127 var startIndex = range.start.getIndex();
1128 var endIndex = range.end.getIndex();
1129 if (startIndex === endIndex)
1132 buff, range.start.getText().substring(startIndex, endIndex));
1136 * Appends output to the |buff|.
1137 * @param {!Array<cvox.Spannable>} buff
1138 * @param {string|!cvox.Spannable} value
1139 * @param {{isUnique: (boolean|undefined),
1140 * annotation: !Array<*>}=} opt_options
1142 append_: function(buff, value, opt_options) {
1143 opt_options = opt_options || {isUnique: false, annotation: []};
1145 // Reject empty values without annotations.
1146 if ((!value || value.length == 0) && opt_options.annotation.length == 0)
1149 var spannableToAdd = new cvox.Spannable(value);
1150 opt_options.annotation.forEach(function(a) {
1151 spannableToAdd.setSpan(a, 0, spannableToAdd.getLength());
1154 // Early return if the buffer is empty.
1155 if (buff.length == 0) {
1156 buff.push(spannableToAdd);
1160 // |isUnique| specifies an annotation that cannot be duplicated.
1161 if (opt_options.isUnique) {
1162 var alreadyAnnotated = buff.some(function(s) {
1163 return opt_options.annotation.some(function(annotation) {
1164 return s.getSpanStart(annotation) != undefined;
1167 if (alreadyAnnotated)
1171 buff.push(spannableToAdd);
1175 * Parses the token containing a custom function and returns a tree.
1176 * @param {string} inputStr
1180 createParseTree_: function(inputStr) {
1181 var root = {value: ''};
1182 var currentNode = root;
1184 var braceNesting = 0;
1185 while (index < inputStr.length) {
1186 if (inputStr[index] == '(') {
1187 currentNode.firstChild = {value: ''};
1188 currentNode.firstChild.parent = currentNode;
1189 currentNode = currentNode.firstChild;
1190 } else if (inputStr[index] == ')') {
1191 currentNode = currentNode.parent;
1192 } else if (inputStr[index] == '{') {
1194 currentNode.value += inputStr[index];
1195 } else if (inputStr[index] == '}') {
1197 currentNode.value += inputStr[index];
1198 } else if (inputStr[index] == ',' && braceNesting === 0) {
1199 currentNode.nextSibling = {value: ''};
1200 currentNode.nextSibling.parent = currentNode.parent;
1201 currentNode = currentNode.nextSibling;
1203 currentNode.value += inputStr[index];
1208 if (currentNode != root)
1209 throw 'Unbalanced parenthesis.';
1215 * Converts the currently rendered braille buffers to a single spannable.
1216 * @return {!cvox.Spannable}
1219 createBrailleOutput_: function() {
1220 return this.brailleBuffer_.reduce(function(prev, cur) {
1221 if (prev.getLength() > 0 && cur.getLength() > 0)
1222 prev.append(Output.SPACE);
1225 }, new cvox.Spannable());