blob: 0fde079855da1fe5622baee56e174bf3465bf870 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* This interface represents some object or group of objects on screen
* that Switch Access may be interested in interacting with.
*
* There is no guarantee of uniqueness; two distinct SAChildNodes may refer
* to the same object. However, it is expected that any pair of
* SAChildNodes referring to the same interesting object are equal
* (calling .equals() returns true).
* @abstract
*/
class SAChildNode {
constructor() {
/** @private {boolean} */
this.isFocused_ = false;
/** @private {?SAChildNode} */
this.next_ = null;
/** @private {?SAChildNode} */
this.previous_ = null;
/** @private {boolean} */
this.valid_ = true;
}
// ================= Getters and setters =================
/**
* Returns a list of all the actions available for this node.
* @return {!Array<SwitchAccessMenuAction>}
* @abstract
*/
get actions() {}
/**
* Returns the underlying automation node, if one exists.
* @return {AutomationNode}
* @abstract
*/
get automationNode() {}
/**
* @return {chrome.accessibilityPrivate.ScreenRect|undefined}
* @abstract
*/
get location() {}
/** @param {!SAChildNode} newVal */
set next(newVal) {
this.next_ = newVal;
}
/**
* Returns the next node in pre-order traversal.
* @return {!SAChildNode}
*/
get next() {
let next = this;
while (true) {
next = next.next_;
if (!next) {
this.onInvalidNavigation_(
SAConstants.ErrorType.NEXT_UNDEFINED,
'Next node must be set on all SAChildNodes before navigating');
}
if (this === next) {
this.onInvalidNavigation_(
SAConstants.ErrorType.NEXT_INVALID, 'No valid next node');
}
if (next.isValidAndVisible()) {
return next;
}
}
}
/** @param {!SAChildNode} newVal */
set previous(newVal) {
this.previous_ = newVal;
}
/**
* Returns the previous node in pre-order traversal.
* @return {!SAChildNode}
*/
get previous() {
let previous = this;
while (true) {
previous = previous.previous_;
if (!previous) {
this.onInvalidNavigation_(
SAConstants.ErrorType.PREVIOUS_UNDEFINED,
'Previous node must be set on all SAChildNodes before navigating');
}
if (this === previous) {
this.onInvalidNavigation_(
SAConstants.ErrorType.PREVIOUS_INVALID, 'No valid previous node');
}
if (previous.isValidAndVisible()) {
return previous;
}
}
}
/**
* @return {chrome.automation.RoleType|undefined}
* @abstract
*/
get role() {}
// ================= General methods =================
/**
* If this node is a group, returns the analogous SARootNode.
* @return {SARootNode}
* @abstract
*/
asRootNode() {}
/** Performs the node's default action. */
doDefaultAction() {
if (!this.isFocused_) {
return;
}
this.performAction(SwitchAccessMenuAction.SELECT);
}
/**
* @param {SAChildNode} other
* @return {boolean}
* @abstract
*/
equals(other) {}
/**
* Given a menu action, returns whether it can be performed on this node.
* @param {SwitchAccessMenuAction} action
* @return {boolean}
*/
hasAction(action) {
return this.actions.includes(action);
}
/**
* @param {?AutomationNode|!SAChildNode|!SARootNode} node
* @return {boolean}
* @abstract
*/
isEquivalentTo(node) {}
/**
* Returns whether the node is currently focused by Switch Access
* @return {boolean}
*/
isFocused() {
return this.isFocused_;
}
/**
* Returns whether this node should be displayed as a group.
* @return {boolean}
* @abstract
*/
isGroup() {}
/**
* Returns whether this node is still both valid and visible onscreen (e.g.
* has a location, and, if representing an AutomationNode, not hidden,
* not offscreen, not invisible).
* @return {boolean}
*/
isValidAndVisible() {
return this.valid_ && !!this.location;
}
/**
* Called when this node becomes the primary highlighted node.
*/
onFocus() {
this.isFocused_ = true;
FocusRingManager.setFocusedNode(this);
}
/**
* Called when this node stops being the primary highlighted node.
*/
onUnfocus() {
this.isFocused_ = false;
}
/**
* Performs the specified action on the node, if it is available.
* @param {SwitchAccessMenuAction} action
* @return {SAConstants.ActionResponse} What action the menu should perform in
* response.
* @abstract
*/
performAction(action) {}
// ================= Debug methods =================
/**
* String-ifies the node (for debugging purposes).
* @param {boolean} wholeTree Whether to recursively include descendants.
* @param {string=} prefix
* @param {SAChildNode=} currentNode the currentNode, to highlight.
* @return {string}
*/
debugString(wholeTree, prefix = '', currentNode = null) {
if (this.isGroup() && wholeTree) {
return this.asRootNode().debugString(
wholeTree, prefix + ' ', currentNode);
}
let str = this.role + ' ';
const autoNode = this.automationNode;
if (autoNode && autoNode.name) {
str += 'name(' + autoNode.name + ') ';
}
const loc = this.location;
if (loc) {
str += 'loc(' + RectUtil.toString(loc) + ') ';
}
if (this.isGroup()) {
str += '[isGroup]';
}
return str;
}
// ================= Private methods =================
/**
*
* @param {SAConstants.ErrorType} error
* @param {string} message
*/
onInvalidNavigation_(error, message) {
this.valid_ = false;
throw SwitchAccess.error(error, message, true /* shouldRecover */);
}
}
/**
* This class represents the root node of a Switch Access traversal group.
*/
class SARootNode {
constructor() {
/** @private {!Array<!SAChildNode>} */
this.children_ = [];
}
// ================= Getters and setters =================
/** @return {AutomationNode} */
get automationNode() {}
/** @param {!Array<!SAChildNode>} newVal */
set children(newVal) {
this.children_ = newVal;
this.connectChildren_();
}
/** @return {!Array<!SAChildNode>} */
get children() {
return this.children_;
}
/** @return {!SAChildNode} */
get firstChild() {
if (this.children_.length > 0) {
return this.children_[0];
} else {
throw SwitchAccess.error(
SAConstants.ErrorType.NO_CHILDREN,
'Root nodes must contain children.', true /* shouldRecover */);
}
}
/** @return {!SAChildNode} */
get lastChild() {
if (this.children_.length > 0) {
return this.children_[this.children_.length - 1];
} else {
throw SwitchAccess.error(
SAConstants.ErrorType.NO_CHILDREN,
'Root nodes must contain children.', true /* shouldRecover */);
}
}
/** @return {!chrome.accessibilityPrivate.ScreenRect} */
get location() {
const children =
this.children_.filter((c) => !(c instanceof BackButtonNode));
const childLocations = children.map((c) => c.location);
return RectUtil.unionAll(childLocations);
}
// ================= General methods =================
/**
* @param {SARootNode} other
* @return {boolean}
*/
equals(other) {
if (!other) {
return false;
}
if (this.children_.length !== other.children_.length) {
return false;
}
let result = true;
for (let i = 0; i < this.children_.length; i++) {
if (!this.children_[i]) {
console.error(SwitchAccess.error(
SAConstants.ErrorType.NULL_CHILD, 'Child cannot be null.'));
return false;
}
result = result && this.children_[i].equals(other.children_[i]);
}
return result;
}
/**
* Looks for and returns the specified node within this node's children.
* If no equivalent node is found, returns null.
* @param {?AutomationNode|!SAChildNode|!SARootNode} node
* @return {?SAChildNode}
*/
findChild(node) {
for (const child of this.children_) {
if (child.isEquivalentTo(node)) {
return child;
}
}
return null;
}
/**
* @param {?AutomationNode|!SARootNode|!SAChildNode} node
* @return {boolean}
*/
isEquivalentTo(node) {
if (node instanceof SARootNode) {
return this.equals(node);
}
if (node instanceof SAChildNode) {
return node.isEquivalentTo(this);
}
return false;
}
/** @return {boolean} */
isValidGroup() {
// Must have one interesting child that is not the back button.
return this.children_
.filter(
(child) => !(child instanceof BackButtonNode) &&
child.isValidAndVisible())
.length >= 1;
}
/** @return {SAChildNode} */
firstValidChild() {
const children =
this.children_.filter((child) => child.isValidAndVisible());
return children.length > 0 ? children[0] : null;
}
/** Called when a group is set as the current group. */
onFocus() {}
/** Called when a group is no longer the current group. */
onUnfocus() {}
/** Called when a group is explicitly exited. */
onExit() {}
/** Called when a group should recalculate its children. */
refreshChildren() {
this.children = this.children.filter((child) => child.isValidAndVisible());
}
/** Called when the group's children may have changed. */
refresh() {}
// ================= Debug methods =================
/**
* String-ifies the node (for debugging purposes).
* @param {boolean=} wholeTree Whether to recursively descend the tree
* @param {string=} prefix
* @param {SAChildNode} currentNode the currently focused node, to mark.
* @return {string}
*/
debugString(wholeTree = false, prefix = '', currentNode = null) {
const autoNode = this.automationNode;
let str = 'Root: ';
if (autoNode && autoNode.role) {
str += autoNode.role + ' ';
}
if (autoNode && autoNode.name) {
str += 'name(' + autoNode.name + ') ';
}
const loc = this.location;
if (loc) {
str += 'loc(' + RectUtil.toString(loc) + ') ';
}
for (const child of this.children) {
str += '\n' + prefix + ((child.equals(currentNode)) ? ' * ' : ' - ');
str += child.debugString(wholeTree, prefix, currentNode);
}
return str;
}
// ================= Private methods =================
/**
* Helper function to connect children.
* @private
*/
connectChildren_() {
if (this.children_.length < 1) {
console.error(SwitchAccess.error(
SAConstants.ErrorType.NO_CHILDREN,
'Root node must have at least 1 interesting child.'));
return;
}
let previous = this.children_[this.children_.length - 1];
for (let i = 0; i < this.children_.length; i++) {
const current = this.children_[i];
previous.next = current;
current.previous = previous;
previous = current;
}
}
}