chromium/chrome/browser/resources/chromeos/accessibility/select_to_speak/ui_manager.ts

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {AutomationUtil} from '/common/automation_util.js';
import {ParagraphUtils} from '/common/paragraph_utils.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {PrefsManager} from './prefs_manager.js';

type AutomationNode = chrome.automation.AutomationNode;
const EventType = chrome.automation.EventType;
const FocusRingStackingOrder =
    chrome.accessibilityPrivate.FocusRingStackingOrder;

// This must match the name of view class that implements the SelectToSpeakTray:
// ash/system/accessibility/select_to_speak/select_to_speak_tray.h
export const SELECT_TO_SPEAK_TRAY_CLASS_NAME = 'SelectToSpeakTray';

// This must match the name of view class that implements the menu view:
// ash/system/accessibility/select_to_speak/select_to_speak_menu_view.h
const SELECT_TO_SPEAK_MENU_CLASS_NAME = 'SelectToSpeakMenuView';

// This must match the name of view class that implements the speed view:
// ash/system/accessibility/select_to_speak/select_to_speak_speed_view.h
const SELECT_TO_SPEAK_SPEED_CLASS_NAME = 'SelectToSpeakSpeedView';

// This must match the name of view class that implements the bubble views:
// ash/system/tray/tray_bubble_view.h
const TRAY_BUBBLE_VIEW_CLASS_NAME = 'TrayBubbleView';

// This must match the name of view class that implements the buttons used in
// the floating panel:
// ash/system/accessibility/floating_menu_button.h
const FLOATING_MENU_BUTTON_CLASS_NAME = 'FloatingMenuButton';

// A RGBA hex string for the default background shading color, which is black at
// 40% opacity (hex 66). This should be equivalent to using
// AshColorProvider::ShieldLayerType kShield40.
const DEFAULT_BACKGROUND_SHADING_COLOR = '#0006';

/**
 * Callbacks invoked when users perform actions in the UI.
 */
export interface SelectToSpeakUiListener {
  /** User requests navigation to next paragraph. */
  onNextParagraphRequested: () => void;

  /** User requests navigation to previous paragraph. */
  onPreviousParagraphRequested: () => void;

  /** User requests navigation to next sentence. */
  onNextSentenceRequested: () => void;

  /** User requests navigation to previous sentence. */
  onPreviousSentenceRequested: () => void;

  /** User requests pausing TTS. */
  onPauseRequested: () => void;

  /** User requests resuming TTS. */
  onResumeRequested: () => void;

  /**
   * User requests reading speed adjustment.
   * @param speed rate multiplier.
   */
  onChangeSpeedRequested: (speed: number) => void;

  /** User requests exiting STS. */
  onExitRequested: () => void;

  /** User requests state change via tray button. */
  onStateChangeRequested: () => void;
}

/**
 * Manages user interface elements controlled by Select-to-speak, such the
 * focus ring, floating control panel, tray button, and word highlight.
 */
export class UiManager {
  // TODO(b/314204374): Convert from null to undefined.
  private desktop_: AutomationNode|null;
  private listener_: SelectToSpeakUiListener;
  // TODO(b/314204374): Convert from null to undefined.
  private panelButton_: AutomationNode|null;
  private prefsManager_: PrefsManager;
  /**
   * Please keep fields in alphabetical order.
   */
  constructor(prefsManager: PrefsManager, listener: SelectToSpeakUiListener) {
    this.desktop_ = null;
    this.listener_ = listener;

    /**
     * Button in the floating panel, useful for restoring focus to the panel.
     */
    this.panelButton_ = null;
    this.prefsManager_ = prefsManager;

    this.init_();
  }

  private init_(): void {
    // Cache desktop and listen to focus changes.
    chrome.automation.getDesktop(desktop => {
      this.desktop_ = desktop;

      // Listen to focus changes so we can grab the floating panel when it
      // goes into focus, so it can be used later without having to search
      // through the entire tree.
      desktop.addEventListener(EventType.FOCUS, evt => {
        this.onFocusChange_(evt);
      }, true);
    });

    // Listen to panel events.
    chrome.accessibilityPrivate.onSelectToSpeakPanelAction.addListener(
        (panelAction, value) => {
          this.onPanelAction_(panelAction, value);
        });

    // Listen to event from activating tray button.
    chrome.accessibilityPrivate.onSelectToSpeakStateChangeRequested.addListener(
        () => {
          this.listener_.onStateChangeRequested();
        });
  }

  /**
   * Handles Select-to-speak panel action.
   * @param panelAction Action to perform.
   * @param value Optional value associated with action.
   */
  private onPanelAction_(
      panelAction: chrome.accessibilityPrivate.SelectToSpeakPanelAction,
      value?: number): void {
    switch (panelAction) {
      case chrome.accessibilityPrivate.SelectToSpeakPanelAction.NEXT_PARAGRAPH:
        this.listener_.onNextParagraphRequested();
        break;
      case chrome.accessibilityPrivate.SelectToSpeakPanelAction
          .PREVIOUS_PARAGRAPH:
        this.listener_.onPreviousParagraphRequested();
        break;
      case chrome.accessibilityPrivate.SelectToSpeakPanelAction.NEXT_SENTENCE:
        this.listener_.onNextSentenceRequested();
        break;
      case chrome.accessibilityPrivate.SelectToSpeakPanelAction
          .PREVIOUS_SENTENCE:
        this.listener_.onPreviousSentenceRequested();
        break;
      case chrome.accessibilityPrivate.SelectToSpeakPanelAction.EXIT:
        this.listener_.onExitRequested();
        break;
      case chrome.accessibilityPrivate.SelectToSpeakPanelAction.PAUSE:
        this.listener_.onPauseRequested();
        break;
      case chrome.accessibilityPrivate.SelectToSpeakPanelAction.RESUME:
        this.listener_.onResumeRequested();
        break;
      case chrome.accessibilityPrivate.SelectToSpeakPanelAction.CHANGE_SPEED:
        if (!value) {
          console.warn(
              'Change speed request receieved with invalid value', value);
          return;
        }
        this.listener_.onChangeSpeedRequested(value);
        break;
      default:
        console.warn('Unknown panel action received', panelAction);
    }
  }

  /**
   * Handles desktop-wide focus changes.
   */
  private onFocusChange_(evt: chrome.automation.AutomationEvent): void {
    const focusedNode = evt.target;

    // As an optimization, look for the STS floating panel and store in case
    // we need to access that node at a later point (such as focusing panel).
    if (focusedNode.className !== FLOATING_MENU_BUTTON_CLASS_NAME) {
      // When panel is focused, initial focus is always on one of the buttons.
      return;
    }
    const windowParent = AutomationUtil.getFirstAncestorWithRole(
        focusedNode, chrome.automation.RoleType.WINDOW);
    if (windowParent &&
        windowParent.className === TRAY_BUBBLE_VIEW_CLASS_NAME &&
        windowParent.children.length === 1 &&
        windowParent.children[0].className ===
            SELECT_TO_SPEAK_MENU_CLASS_NAME) {
      this.panelButton_ = focusedNode;
    }
  }

  /**
   * Sets focus to the floating control panel, if present.
   */
  setFocusToPanel(): void {
    // Used cached panel node if possible to avoid expensive desktop.find().
    // Note: Checking role attribute to see if node is still valid.
    if (this.panelButton_ && this.panelButton_.role) {
      // The panel itself isn't focusable, so set focus to most recently
      // focused panel button.
      this.panelButton_.focus();
      return;
    }
    this.panelButton_ = null;

    // Fallback to more expensive method of finding panel.
    if (!this.desktop_) {
      console.error('No cached desktop object, cannot focus panel');
      return;
    }
    const menuView = this.desktop_.find(
        {attributes: {className: SELECT_TO_SPEAK_MENU_CLASS_NAME}});
    if (menuView !== null && menuView.parent &&
        menuView.parent.className === TRAY_BUBBLE_VIEW_CLASS_NAME) {
      // The menu view's parent is the TrayBubbleView can can be assigned focus.
      this.panelButton_ =
          menuView.find({role: chrome.automation.RoleType.TOGGLE_BUTTON});
      this.panelButton_.focus();
    }
  }

  /**
   * Sets the focus ring to |rects|. If |drawBackground|, draws the grey focus
   * background with the alpha set in prefs. |panelVisible| determines
   * the stacking order, so focus rings do not appear on top of panel.
   */
  private setFocusRings_(
      rects: chrome.accessibilityPrivate.ScreenRect[], drawBackground: boolean,
      panelVisible: boolean): void {
    let color = '#0000';  // Fully transparent.
    if (drawBackground && this.prefsManager_.backgroundShadingEnabled()) {
      color = DEFAULT_BACKGROUND_SHADING_COLOR;
    }
    // If we're also showing a floating panel, ensure the focus ring appears
    // below the panel UI.
    const stackingOrder = panelVisible ?
        FocusRingStackingOrder.BELOW_ACCESSIBILITY_BUBBLES :
        FocusRingStackingOrder.ABOVE_ACCESSIBILITY_BUBBLES;
    chrome.accessibilityPrivate.setFocusRings(
        [{
          rects,
          type: chrome.accessibilityPrivate.FocusType.GLOW,
          stackingOrder,
          color: this.prefsManager_.focusRingColor(),
          backgroundColor: color,
        }],
        chrome.accessibilityPrivate.AssistiveTechnologyType.SELECT_TO_SPEAK);
  }

  /**
   * Updates the floating control panel.
   */
  private updatePanel_(
      showPanel: boolean, anchorRect?: chrome.accessibilityPrivate.ScreenRect,
      paused?: boolean, speechRateMultiplier?: number): void {
    if (showPanel) {
      if (anchorRect === undefined || paused === undefined ||
          speechRateMultiplier === undefined) {
        console.error('Cannot display panel: missing required parameters');
        return;
      }
      // If the feature is enabled and we have a valid focus ring, flip the
      // pause and resume button according to the current STS and TTS state.
      // Also, update the location of the panel according to the focus ring.
      chrome.accessibilityPrivate.updateSelectToSpeakPanel(
          /* show= */ true, /* anchor= */ anchorRect,
          /* isPaused= */ paused,
          /* speed= */ speechRateMultiplier);
    } else {
      // Dismiss the panel if either the feature is disabled or the focus ring
      // is not valid.
      chrome.accessibilityPrivate.updateSelectToSpeakPanel(/* show= */ false);
    }
  }

  /**
   * Updates word highlight.
   * @param node Current node being spoken.
   * @param currentWord Character offsets of
   *    current word spoken within node if word highlighting is enabled.
   */
  private updateHighlight_(
      node: AutomationNode,
      // TODO(b/314204374): Convert null to undefined.
      currentWord: {start: number, end: number}|null, paused: boolean): void {
    if (!currentWord) {
      chrome.accessibilityPrivate.setHighlights(
          [], this.prefsManager_.highlightColor());
      return;
    }
    // getStartCharIndexInParent is only defined for nodes with role
    // INLINE_TEXT_BOX.
    const charIndexInParent =
        node.role === chrome.automation.RoleType.INLINE_TEXT_BOX ?
        ParagraphUtils.getStartCharIndexInParent(node) :
        0;
    node.boundsForRange(
        currentWord.start - charIndexInParent,
        currentWord.end - charIndexInParent, bounds => {
          const highlights = bounds ? [bounds] : [];
          chrome.accessibilityPrivate.setHighlights(
              highlights, this.prefsManager_.highlightColor());
          if (!paused) {
            // If speech is ongoing, update the bounds. (If it was paused,
            // reading focus hasn't actually changed, so there's no need for
            // this notification).
            chrome.accessibilityPrivate.setSelectToSpeakFocus(
                bounds ? bounds : node.location);
          }
        });
  }

  /**
   * Renders user selection rect, in the form of a focus ring.
   */
  setSelectionRect(rect: chrome.accessibilityPrivate.ScreenRect): void {
    // TODO(crbug.com/40753028): Support showing two focus rings at once, in case
    // a focus ring highlighting a node group is already present.
    this.setFocusRings_(
        [rect], false /* don't draw background */, false /* panelVisible */);
  }

  /**
   * Updates overlay UI based on current node and panel state.
   * @param nodeGroup Current node group.
   * @param node Current node being spoken.
   * @param currentWord Character offsets of
   *    current word spoken within node if word highlighting is enabled.
   */
  update(
      nodeGroup: ParagraphUtils.NodeGroup, node: AutomationNode,
      // TODO(b/314204374): Convert null to undefined.
      currentWord: {start: number, end: number}|null,
      panelState:
          {showPanel: boolean, paused: boolean, speechRateMultiplier: number}):
      void {
    const {showPanel, paused, speechRateMultiplier} = panelState;
    // Show the block parent of the currently verbalized node with the
    // focus ring. If the node has no siblings in the group, highlight just
    // the one node.
    let focusRingRect;
    const currentBlockParent = nodeGroup.blockParent;
    if (currentBlockParent !== null && nodeGroup.nodes.length > 1) {
      focusRingRect = currentBlockParent!.location;
    } else {
      focusRingRect = node.location;
    }
    this.updateHighlight_(node, currentWord, paused);
    if (focusRingRect) {
      this.setFocusRings_(
          [focusRingRect], true /* draw background */, showPanel);
      this.updatePanel_(showPanel, focusRingRect, paused, speechRateMultiplier);
    } else {
      console.warn('No node location; cannot render focus ring or panel');
    }
  }

  /**
   * Clears overlay UI, hiding focus rings, panel, and word highlight.
   */
  clear(): void {
    this.setFocusRings_(
        [], false /* do not draw background */, false /* panel not visible */);
    chrome.accessibilityPrivate.setHighlights(
        [], this.prefsManager_.highlightColor());
    this.updatePanel_(false /* hide panel */);
  }

  /**
   * @return Whether given node is the Select-to-speak floating panel.
   */
  static isPanel(node?: AutomationNode): boolean {
    if (!node) {
      return false;
    }

    // Determine if the node is part of the floating panel or the reading speed
    // selection bubble.
    return (
        node.className === TRAY_BUBBLE_VIEW_CLASS_NAME &&
        node.children.length === 1 &&
        (node.children[0].className === SELECT_TO_SPEAK_MENU_CLASS_NAME ||
         node.children[0].className === SELECT_TO_SPEAK_SPEED_CLASS_NAME));
  }

  /**
   * @return Whether given node is the Select-to-speak tray button.
   */
  static isTrayButton(node?: AutomationNode): boolean {
    if (!node) {
      return false;
    }
    return AutomationUtil.getAncestors(node).find(n => {
      return n.className === SELECT_TO_SPEAK_TRAY_CLASS_NAME;
    }) !== undefined;
  }
}

TestImportManager.exportForTesting(
    UiManager,
    ['SELECT_TO_SPEAK_TRAY_CLASS_NAME', SELECT_TO_SPEAK_TRAY_CLASS_NAME]);