chromium/chrome/browser/resources/chromeos/accessibility/switch_access/nodes/switch_access_node.ts

// 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 {RectUtil} from '/common/rect_util.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {FocusRingManager} from '../focus_ring_manager.js';
import {SwitchAccess} from '../switch_access.js';
import {ActionResponse, ErrorType} from '../switch_access_constants.js';

type AutomationNode = chrome.automation.AutomationNode;
import MenuAction = chrome.accessibilityPrivate.SwitchAccessMenuAction;
type ScreenRect = chrome.accessibilityPrivate.ScreenRect;
type RoleType = chrome.automation.RoleType;

/**
 * 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).
 */
export abstract class SAChildNode {
  private isFocused_ = false;
  private next_: SAChildNode | null = null;
  private previous_: SAChildNode | null = null;
  private valid_ = true;

  // Abstract methods.

  /** Returns a list of all the actions available for this node. */
  abstract get actions(): MenuAction[];
  /** If this node is a group, returns the analogous SARootNode. */
  abstract asRootNode(): SARootNode | undefined;
  /** The automation node that most closely contains this node. */
  abstract get automationNode(): AutomationNode;
  abstract equals(other: SAChildNode | null | undefined): boolean;
  abstract isEquivalentTo(
      node: AutomationNode | SAChildNode | SARootNode | null): boolean;
  /** Returns whether this node should be displayed as a group. */
  abstract isGroup(): boolean;
  abstract get location(): ScreenRect | undefined;
  /** Performs the specified action on the node, if it is available. */
  abstract performAction(action: MenuAction): ActionResponse;
  abstract get role(): RoleType | undefined;


  // ================= Getters and setters =================

  get group(): SARootNode | null {
    return null;
  }

  set next(newVal: SAChildNode) {
    this.next_ = newVal;
  }

  /** Returns the next node in pre-order traversal. */
  get next(): SAChildNode {
    let next: SAChildNode | null = this;
    while (true) {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      next = next!.next_;
      if (!next) {
        this.onInvalidNavigation_(
            ErrorType.NEXT_UNDEFINED,
            'Next node must be set on all SAChildNodes before navigating');
      }
      if (this === next) {
        this.onInvalidNavigation_(ErrorType.NEXT_INVALID, 'No valid next node');
      }
      if (next!.isValidAndVisible()) {
        return next!;
      }
    }
  }

  set previous(newVal: SAChildNode) {
    this.previous_ = newVal;
  }

  /** Returns the previous node in pre-order traversal. */
  get previous(): SAChildNode {
    let previous: SAChildNode | null = this;
    while (true) {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      previous = previous!.previous_;
      if (!previous) {
        this.onInvalidNavigation_(
            ErrorType.PREVIOUS_UNDEFINED,
            'Previous node must be set on all SAChildNodes before navigating');
      }
      if (this === previous) {
        this.onInvalidNavigation_(
            ErrorType.PREVIOUS_INVALID, 'No valid previous node');
      }
      if (previous!.isValidAndVisible()) {
        return previous!;
      }
    }
  }

  // ================= General methods =================

  /** Performs the node's default action. */
  doDefaultAction(): void {
    if (!this.isFocused_) {
      return;
    }
    if (this.isGroup()) {
      this.performAction(MenuAction.DRILL_DOWN);
    } else {
      this.performAction(MenuAction.SELECT);
    }
  }

  /** Given a menu action, returns whether it can be performed on this node. */
  hasAction(action: MenuAction): boolean {
    return this.actions.includes(action);
  }

  /** Returns whether the node is currently focused by Switch Access. */
  isFocused(): boolean {
    return this.isFocused_;
  }

  /**
   * 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).
   */
  isValidAndVisible(): boolean {
    return this.valid_ && Boolean(this.location);
  }

  /** Called when this node becomes the primary highlighted node. */
  onFocus(): void {
    this.isFocused_ = true;
    FocusRingManager.setFocusedNode(this);
  }

  /** Called when this node stops being the primary highlighted node. */
  onUnfocus(): void {
    this.isFocused_ = false;
  }

  // ================= Debug methods =================

  /** String-ifies the node (for debugging purposes). */
  debugString(
      wholeTree: boolean, prefix: string = '',
      currentNode: SAChildNode | null = null): string {
    if (this.isGroup() && wholeTree) {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      return this.asRootNode()!.debugString(
          wholeTree, prefix + '  ', currentNode);
    }

    let str = this.constructor.name + ' role(' + this.role + ') ';

    if (this.automationNode.name) {
      str += 'name(' + this.automationNode.name + ') ';
    }

    const loc = this.location;
    if (loc) {
      str += 'loc(' + RectUtil.toString(loc) + ') ';
    }

    if (this.isGroup()) {
      str += '[isGroup]';
    }

    return str;
  }

  // ================= Private methods =================

  private onInvalidNavigation_(error: ErrorType, message: string): void {
    this.valid_ = false;
    throw SwitchAccess.error(error, message, true /* shouldRecover */);
  }

  /** @return Whether to ignore when computing the SARootNode's location. */
  ignoreWhenComputingUnionOfBoundingBoxes(): boolean {
    return false;
  }
}

/**
 * This class represents the root node of a Switch Access traversal group.
 */
export class SARootNode {
  private children_: SAChildNode[] = [];
  private automationNode_: AutomationNode;

  /**
   * @param autoNode The automation node that most closely contains all of
   * this node's children.
   */
  constructor(autoNode: AutomationNode) {
    this.automationNode_ = autoNode;
  }

  // ================= Getters and setters =================

  /**
   * @return The automation node that most closely contains all of this node's
   * children.
   */
  get automationNode(): AutomationNode {
    return this.automationNode_;
  }

  set children(newVal: SAChildNode[]) {
    this.children_ = newVal;
    this.connectChildren_();
  }

  get children(): SAChildNode[] {
    return this.children_;
  }

  get firstChild(): SAChildNode {
    if (this.children_.length > 0) {
      return this.children_[0];
    } else {
      throw SwitchAccess.error(
          ErrorType.NO_CHILDREN, 'Root nodes must contain children.',
          true /* shouldRecover */);
    }
  }

  get lastChild(): SAChildNode {
    if (this.children_.length > 0) {
      return this.children_[this.children_.length - 1];
    } else {
      throw SwitchAccess.error(
          ErrorType.NO_CHILDREN, 'Root nodes must contain children.',
          true /* shouldRecover */);
    }
  }

  get location(): ScreenRect {
    const children = this.children_.filter(
        c => !c.ignoreWhenComputingUnionOfBoundingBoxes());
    const childLocations = children.map(c => c.location).filter(l => l);
    return RectUtil.unionAll(childLocations as ScreenRect[]);
  }

  // ================= General methods =================

  equals(other: SARootNode): boolean {
    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(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.
   */
  findChild(
      node: AutomationNode | SAChildNode | SARootNode): SAChildNode | null {
    for (const child of this.children_) {
      if (child.isEquivalentTo(node)) {
        return child;
      }
    }
    return null;
  }

  isEquivalentTo(node: AutomationNode|SARootNode|SAChildNode|null): boolean {
    if (node instanceof SARootNode) {
      return this.equals(node);
    }
    if (node instanceof SAChildNode) {
      return node.isEquivalentTo(this);
    }
    return false;
  }

  isValidGroup(): boolean {
    // Must have one interesting child whose location is important.
    return this.children_
               .filter(
                   (child: SAChildNode) =>
                       !(child.ignoreWhenComputingUnionOfBoundingBoxes()) &&
                       child.isValidAndVisible())
               .length >= 1;
  }

  firstValidChild(): SAChildNode | null {
    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(): void {}

  /** Called when a group is no longer the current group. */
  onUnfocus(): void {}

  /** Called when a group is explicitly exited. */
  onExit(): void {}

  /** Called when a group should recalculate its children. */
  refreshChildren(): void {
    this.children =
        this.children.filter((child: SAChildNode) => child.isValidAndVisible());
  }

  /** Called when the group's children may have changed. */
  refresh(): void {}

  // ================= Debug methods =================

  /**
   * String-ifies the node (for debugging purposes).
   * @param wholeTree Whether to recursively descend the tree
   * @param currentNode the currently focused node, to mark.
   */
  debugString(
      wholeTree: boolean = false, prefix: string = '',
      currentNode: SAChildNode | null = null): string {
    let str =
        'Root: ' + this.constructor.name + ' ' + this.automationNode.role + ' ';
    if (this.automationNode.name) {
      str += 'name(' + this.automationNode.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_(): void {
    if (this.children_.length < 1) {
      console.error(SwitchAccess.error(
          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;
    }
  }
}

export type SANode = SAChildNode|SARootNode;

TestImportManager.exportForTesting(SARootNode);