// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {AutomationPredicate} from '/common/automation_predicate.js';
import {constants} from '/common/constants.js';
import {RepeatedEventHandler} from '/common/repeated_event_handler.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';
import {AutomationTreeWalker} from '/common/tree_walker.js';
import {SACache} from '../cache.js';
import {FocusRingManager} from '../focus_ring_manager.js';
import {Navigator} from '../navigator.js';
import {SwitchAccess} from '../switch_access.js';
import {ActionResponse, ErrorType} from '../switch_access_constants.js';
import {SwitchAccessPredicate} from '../switch_access_predicate.js';
import {BackButtonNode} from './back_button_node.js';
import {SAChildNode, SARootNode} from './switch_access_node.js';
import AutomationActionType = chrome.automation.ActionType;
type AutomationNode = chrome.automation.AutomationNode;
import EventType = chrome.automation.EventType;
import MenuAction = chrome.accessibilityPrivate.SwitchAccessMenuAction;
type Rect = chrome.automation.Rect;
type RoleType = chrome.automation.RoleType;
interface Creator {
predicate: AutomationPredicate.Unary;
creator: (node: AutomationNode, parent: SARootNode | null) => BasicNode;
}
interface RootBuilder {
predicate: AutomationPredicate.Unary;
builder: (node: AutomationNode) => BasicRootNode;
}
/**
* This class handles interactions with an onscreen element based on a single
* AutomationNode.
*/
export class BasicNode extends SAChildNode {
private baseNode_: AutomationNode;
private parent_: SARootNode | null;
private locationChangedHandler_?: RepeatedEventHandler;
private isActionable_: boolean;
private static creators_: Creator[] = [];
protected constructor(baseNode: AutomationNode, parent: SARootNode | null) {
super();
this.baseNode_ = baseNode;
this.parent_ = parent;
this.isActionable_ = !this.isGroup() ||
SwitchAccessPredicate.isActionable(baseNode, new SACache());
}
// ================= Getters and setters =================
override get actions(): MenuAction[] {
const actions: MenuAction[] = [];
if (this.isActionable_) {
actions.push(MenuAction.SELECT);
}
if (this.isGroup()) {
actions.push(MenuAction.DRILL_DOWN);
}
const ancestor = this.getScrollableAncestor_();
// TODO(b/314203187): Not null asserted, check that this is correct.
if (ancestor.scrollable) {
if (ancestor.scrollX! > ancestor.scrollXMin!) {
actions.push(MenuAction.SCROLL_LEFT);
}
if (ancestor.scrollX! < ancestor.scrollXMax!) {
actions.push(MenuAction.SCROLL_RIGHT);
}
if (ancestor.scrollY! > ancestor.scrollYMin!) {
actions.push(MenuAction.SCROLL_UP);
}
if (ancestor.scrollY! < ancestor.scrollYMax!) {
actions.push(MenuAction.SCROLL_DOWN);
}
}
// Coerce enums to string arrays for comparison.
const menuActions: string[] = Object.values(MenuAction);
const standardActions: string[] = this.baseNode_.standardActions!
.filter((action: string) => menuActions.includes(action));
return actions.concat(standardActions as MenuAction[]);
}
override get automationNode(): AutomationNode {
return this.baseNode_;
}
override get location(): Rect | undefined {
return this.baseNode_.location;
}
override get role(): RoleType {
// TODO(b/314203187): Not null asserted, check that this is correct.
return this.baseNode_.role!;
}
// ================= General methods =================
override asRootNode(): SARootNode | undefined {
if (!this.isGroup()) {
return undefined;
}
return BasicRootNode.buildTree(this.baseNode_);
}
override equals(rhs: SAChildNode | null | undefined): boolean {
if (!rhs || !(rhs instanceof BasicNode)) {
return false;
}
const other = rhs as BasicNode;
return other.baseNode_ === this.baseNode_;
}
override isEquivalentTo(
node: AutomationNode | SAChildNode | SARootNode | null): boolean {
if (node instanceof BasicNode) {
return this.baseNode_ === node.baseNode_;
}
if (node instanceof BasicRootNode) {
return this.baseNode_ === node.automationNode;
}
if (node instanceof SAChildNode) {
return node.isEquivalentTo(this);
}
return this.baseNode_ === node;
}
override isGroup(): boolean {
const cache = new SACache();
return SwitchAccessPredicate.isGroup(this.baseNode_, this.parent_, cache);
}
override isValidAndVisible(): boolean {
// Nodes may have been deleted or orphaned.
if (!this.baseNode_ || !this.baseNode_.role) {
return false;
}
return SwitchAccessPredicate.isVisible(this.baseNode_) &&
super.isValidAndVisible();
}
override onFocus(): void {
super.onFocus();
this.locationChangedHandler_ = new RepeatedEventHandler(
this.baseNode_, EventType.LOCATION_CHANGED, () => {
if (this.isValidAndVisible()) {
FocusRingManager.setFocusedNode(this);
} else {
Navigator.byItem.moveToValidNode();
}
}, {exactMatch: true, allAncestors: true});
}
override onUnfocus(): void {
super.onUnfocus();
if (this.locationChangedHandler_) {
this.locationChangedHandler_.stop();
}
}
override performAction(action: MenuAction): ActionResponse {
let ancestor;
switch (action) {
case MenuAction.DRILL_DOWN:
if (this.isGroup()) {
Navigator.byItem.enterGroup();
return ActionResponse.CLOSE_MENU;
}
// Should not happen.
console.error('Action DRILL_DOWN received on non-group node.');
return ActionResponse.NO_ACTION_TAKEN;
case MenuAction.SELECT:
this.baseNode_.doDefault();
return ActionResponse.CLOSE_MENU;
case MenuAction.SCROLL_DOWN:
ancestor = this.getScrollableAncestor_();
if (ancestor.scrollable) {
ancestor.scrollDown(() => {});
}
return ActionResponse.RELOAD_MENU;
case MenuAction.SCROLL_UP:
ancestor = this.getScrollableAncestor_();
if (ancestor.scrollable) {
ancestor.scrollUp(() => {});
}
return ActionResponse.RELOAD_MENU;
case MenuAction.SCROLL_RIGHT:
ancestor = this.getScrollableAncestor_();
if (ancestor.scrollable) {
ancestor.scrollRight(() => {});
}
return ActionResponse.RELOAD_MENU;
case MenuAction.SCROLL_LEFT:
ancestor = this.getScrollableAncestor_();
if (ancestor.scrollable) {
ancestor.scrollLeft(() => {});
}
return ActionResponse.RELOAD_MENU;
default:
const actions = Object.values(AutomationActionType);
const automationAction = actions.find((a: string) => a === action);
if (automationAction) {
this.baseNode_.performStandardAction(automationAction);
}
return ActionResponse.CLOSE_MENU;
}
}
// ================= Private methods =================
protected getScrollableAncestor_(): AutomationNode {
let ancestor = this.baseNode_;
while (!ancestor.scrollable && ancestor.parent) {
ancestor = ancestor.parent;
}
return ancestor;
}
// ================= Static methods =================
static create(
baseNode: AutomationNode, parent: SARootNode | null): BasicNode {
const item =
BasicNode.creators.find(
(creator: Creator) => creator.predicate(baseNode));
if (item) {
return item.creator(baseNode, parent);
}
return new BasicNode(baseNode, parent);
}
static get creators(): Creator[] {
return BasicNode.creators_;
}
}
/**
* This class handles constructing and traversing a group of onscreen elements
* based on all the interesting descendants of a single AutomationNode.
*/
export class BasicRootNode extends SARootNode {
private static builders_: RootBuilder[] = [];
private childrenChangedHandler_?: RepeatedEventHandler;
private invalidated_ = false;
/**
* WARNING: If you call this constructor, you must *explicitly* set children.
* Use the static function BasicRootNode.buildTree for most use cases.
*/
constructor(baseNode: AutomationNode) {
super(baseNode);
}
// ================= Getters and setters =================
override get location(): Rect {
return this.automationNode.location || super.location;
}
// ================= General methods =================
override equals(other: SARootNode | null | undefined): boolean {
if (!(other instanceof BasicRootNode)) {
return false;
}
return super.equals(other) && this.automationNode === other.automationNode;
}
override isEquivalentTo(
node: AutomationNode | SAChildNode | SARootNode | null): boolean {
if (node instanceof BasicRootNode || node instanceof BasicNode) {
return this.automationNode === node.automationNode;
}
if (node instanceof SAChildNode) {
return node.isEquivalentTo(this);
}
return this.automationNode === node;
}
override isValidGroup(): boolean {
if (!this.automationNode.role) {
// If the underlying automation node has been invalidated, return false.
return false;
}
return !this.invalidated_ &&
SwitchAccessPredicate.isVisible(this.automationNode) &&
super.isValidGroup();
}
override onFocus(): void {
super.onFocus();
this.childrenChangedHandler_ = new RepeatedEventHandler(
this.automationNode, EventType.CHILDREN_CHANGED,
event => {
const cache = new SACache();
if (SwitchAccessPredicate.isInterestingSubtree(event.target, cache)) {
this.refresh();
}
});
}
override onUnfocus(): void {
super.onUnfocus();
if (this.childrenChangedHandler_) {
this.childrenChangedHandler_.stop();
}
}
override refreshChildren(): void {
const childConstructor =
(node: AutomationNode): BasicNode => BasicNode.create(node, this);
try {
BasicRootNode.findAndSetChildren(this, childConstructor);
} catch (e) {
this.invalidated_ = true;
}
}
override refresh(): void {
// Find the currently focused child.
let focusedChild: SAChildNode | null = null;
for (const child of this.children) {
if (child.isFocused()) {
focusedChild = child;
break;
}
}
// Update this BasicRootNode's children.
this.refreshChildren();
if (this.invalidated_) {
this.onUnfocus();
Navigator.byItem.moveToValidNode();
return;
}
// Set the new instance of that child to be the focused node.
if (focusedChild) {
for (const child of this.children) {
if (child.isEquivalentTo(focusedChild)) {
Navigator.byItem.forceFocusedNode(child);
return;
}
}
}
// If we didn't find a match, fall back and reset.
Navigator.byItem.moveToValidNode();
}
// ================= Static methods =================
static buildTree(rootNode: AutomationNode): BasicRootNode {
const item = BasicRootNode.builders.find(
(builder: RootBuilder) => builder.predicate(rootNode));
if (item) {
return item.builder(rootNode);
}
const root = new BasicRootNode(rootNode);
const childConstructor =
(node: AutomationNode): BasicNode => BasicNode.create(node, root);
BasicRootNode.findAndSetChildren(root, childConstructor);
return root;
}
/**
* Helper function to connect tree elements, given the root node and a
* constructor for the child type.
* @param childConstructor Constructs a child node from an automation node.
*/
static findAndSetChildren(
root: BasicRootNode,
childConstructor: (node: AutomationNode) => SAChildNode): void {
const interestingChildren = BasicRootNode.getInterestingChildren(root);
const children = interestingChildren.map(childConstructor)
.filter(child => child.isValidAndVisible());
if (children.length < 1) {
throw SwitchAccess.error(
ErrorType.NO_CHILDREN,
'Root node must have at least 1 interesting child.',
true /* shouldRecover */);
}
children.push(new BackButtonNode(root));
root.children = children;
}
static getInterestingChildren(
root: BasicRootNode | AutomationNode): AutomationNode[] {
if (root instanceof BasicRootNode) {
root = root.automationNode;
}
if (root.children.length === 0) {
return [];
}
const interestingChildren: AutomationNode[] = [];
const treeWalker = new AutomationTreeWalker(
root, constants.Dir.FORWARD, SwitchAccessPredicate.restrictions(root));
let node = treeWalker.next().node;
while (node) {
interestingChildren.push(node);
node = treeWalker.next().node;
}
return interestingChildren;
}
static get builders(): RootBuilder[] {
return BasicRootNode.builders_;
}
}
TestImportManager.exportForTesting(BasicNode, BasicRootNode);