// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {ArrayUtil} from '/common/array_util.js';
import {EventHandler} from '/common/event_handler.js';
import {ActionManager} from './action_manager.js';
import {Navigator} from './navigator.js';
import {SwitchAccess} from './switch_access.js';
import AutomationEvent = chrome.automation.AutomationEvent;
import AutomationNode = chrome.automation.AutomationNode;
import EventType = chrome.automation.EventType;
import MenuAction = chrome.accessibilityPrivate.SwitchAccessMenuAction;
import RoleType = chrome.automation.RoleType;
import ScreenRect = chrome.accessibilityPrivate.ScreenRect;
import StateType = chrome.automation.StateType;
import SwitchAccessBubble = chrome.accessibilityPrivate.SwitchAccessBubble;
interface EventHandlerOptions {
capture: boolean | undefined;
exactMatch: boolean | undefined;
listenOnce: boolean | undefined;
predicate: ((arg: any) => boolean) | undefined;
}
/**
* Class to handle interactions with the Switch Access action menu, including
* opening and closing the menu and setting its location / the actions to be
* displayed.
*/
export class MenuManager {
private displayedActions_: MenuAction[] | null = null;
private displayedLocation_?: ScreenRect;
private isMenuOpen_ = false;
private menuAutomationNode_?: AutomationNode | null;
private clickHandler_: EventHandler;
static instance?: MenuManager;
private constructor() {
this.clickHandler_ = new EventHandler(
[], EventType.CLICKED,
(event: AutomationEvent) => this.onButtonClicked_(event));
}
static create(): MenuManager {
if (MenuManager.instance) {
throw new Error('Cannot instantiate more than one MenuManager');
}
MenuManager.instance = new MenuManager();
return MenuManager.instance;
}
// ================= Static Methods ==================
static isMenuOpen(): boolean {
// TODO(b/314203187): Not nulls asserted, check that this is correct.
return Boolean(MenuManager.instance) && MenuManager.instance!.isMenuOpen_;
}
static get menuAutomationNode(): AutomationNode | null | undefined {
if (MenuManager.instance) {
return MenuManager.instance.menuAutomationNode_;
}
return null;
}
// ================ Instance Methods =================
/**
* If multiple actions are available for the currently highlighted node,
* opens the menu. Otherwise performs the node's default action.
*/
open(actions: MenuAction[], location?: ScreenRect): void {
if (!this.isMenuOpen_) {
if (!location) {
return;
}
this.displayedLocation_ = location;
}
if (ArrayUtil.contentsAreEqual(
actions, this.displayedActions_ ?? undefined)) {
return;
}
this.displayMenuWithActions_(actions);
}
/** Exits the menu. */
close(): void {
this.isMenuOpen_ = false;
this.displayedActions_ = null;
// To match the accessibilityPrivate function signature, displayedLocation_
// has to be undefined rather than null.
this.displayedLocation_ = undefined;
Navigator.byItem.exitIfInGroup(this.menuAutomationNode_ ?? null);
this.menuAutomationNode_ = null;
chrome.accessibilityPrivate.updateSwitchAccessBubble(
SwitchAccessBubble.MENU, false /* show */);
}
// ================= Private Methods ==================
private asAction_(actionString: string | undefined): MenuAction | null {
if (Object.values(MenuAction).includes(actionString as MenuAction)) {
return actionString as MenuAction;
}
return null;
}
/**
* Opens or reloads the menu for the current action node with the specified
* actions.
*/
private displayMenuWithActions_(actions: MenuAction[]): void {
chrome.accessibilityPrivate.updateSwitchAccessBubble(
SwitchAccessBubble.MENU, true /* show */, this.displayedLocation_,
actions);
this.isMenuOpen_ = true;
this.findAndJumpToMenu_();
this.displayedActions_ = actions;
}
/**
* Searches the automation tree to find the node for the Switch Access menu.
* If we've already found a node, and it's still valid, then jump to that
* node.
*/
private findAndJumpToMenu_(): void {
if (this.hasMenuNode_() && this.menuAutomationNode_) {
this.jumpToMenu_(this.menuAutomationNode_);
return;
}
SwitchAccess.findNodeMatching(
{
role: RoleType.MENU,
attributes: {className: 'SwitchAccessMenuView'},
},
node => this.jumpToMenu_(node));
}
private hasMenuNode_(): boolean {
// TODO(b/314203187): Not null asserted, check that this is correct.
return Boolean(this.menuAutomationNode_ &&
this.menuAutomationNode_.role &&
!this.menuAutomationNode_.state![StateType.OFFSCREEN]);
}
/**
* Saves the automation node representing the menu, adds all listeners, and
* jumps to the node.
*/
private jumpToMenu_(node: AutomationNode): void {
if (!this.isMenuOpen_) {
return;
}
// If the menu hasn't fully loaded, wait for that before jumping.
// TODO(b/314203187): Not null asserted, check that this is correct.
if (node.children.length < 1 ||
node.firstChild!.state![StateType.OFFSCREEN]) {
new EventHandler(
node, [EventType.CHILDREN_CHANGED, EventType.LOCATION_CHANGED],
() => this.jumpToMenu_(node),
{listenOnce: true} as EventHandlerOptions)
.start();
return;
}
this.menuAutomationNode_ = node;
this.clickHandler_.setNodes(this.menuAutomationNode_);
this.clickHandler_.start();
Navigator.byItem.jumpTo(this.menuAutomationNode_);
}
/**
* Listener for when buttons are clicked. Identifies the action to perform
* and forwards the request to the action manager.
*/
private onButtonClicked_(event: AutomationEvent): void {
const selectedAction = this.asAction_(event.target.value);
if (!this.isMenuOpen_ || !selectedAction) {
return;
}
ActionManager.performAction(selectedAction as MenuAction);
}
}