chromium/chrome/browser/resources/ash/settings/os_a11y_page/switch_access_action_assignment_pane.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.

/**
 * @fileoverview This component walks a user through the flow of assigning a
 * switch key to a command/action. Note that command and action are used
 * interchangeably with command used internally and action used for user-facing
 * UI.
 */

import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_style.css.js';
import '../os_settings_icons.html.js';

import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {WebUiListenerMixin} from 'chrome://resources/ash/common/cr_elements/web_ui_listener_mixin.js';
import {assertNotReached} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './switch_access_action_assignment_pane.html.js';
import {actionToPref, AssignmentContext, SwitchAccessCommand, SwitchAccessDeviceType} from './switch_access_constants.js';
import {SwitchAccessSubpageBrowserProxy, SwitchAccessSubpageBrowserProxyImpl} from './switch_access_subpage_browser_proxy.js';
import {KeyAssignment, SwitchAccessAssignmentsChangedValue} from './switch_access_types.js';

/**
 * Different states of the assignment flow dictating which overall view should
 * be shown.
 */
enum AssignmentState {
  WAIT_FOR_CONFIRMATION_REMOVAL = 0,
  WAIT_FOR_CONFIRMATION = 1,
  WAIT_FOR_KEY = 2,
  WARN_ALREADY_ASSIGNED_ACTION = 3,
  WARN_CANNOT_REMOVE_LAST_SELECT_SWITCH = 4,
  WARN_NOT_CONFIRMED_REMOVAL = 5,
  WARN_NOT_CONFIRMED = 6,
  WARN_UNRECOGNIZED_KEY = 7,
}

/**
 * Various icons representing the state of a given key assignment.
 */
enum AssignmentIcon {
  ASSIGNED = 'assigned',
  ADD_ASSIGNMENT = 'add-assignment',
  REMOVE_ASSIGNMENT = 'remove-assignment',
}

enum AssignmentResponse {
  EXIT = 'switchAccessActionAssignmentExitResponse',
  CONTINUE = 'switchAccessActionAssignmentContinueResponse',
  TRY_AGAIN = 'switchAccessActionAssignmentTryAgainResponse',
}

interface OnKeyWebUiEvent {
  key: string;
  keyCode: number;
  device: SwitchAccessDeviceType;
}

/**
 * Mapping of a stringified key code to a list of Switch Access device types
 * for that key code.
 */
interface SwitchAccessKeyAssignmentInfoMapping {
  [key: string]: SwitchAccessDeviceType[];
}

const SettingsSwitchAccessActionAssignmentPaneElementBase =
    WebUiListenerMixin(I18nMixin(PolymerElement));

export class SettingsSwitchAccessActionAssignmentPaneElement extends
    SettingsSwitchAccessActionAssignmentPaneElementBase {
  static get is() {
    return 'settings-switch-access-action-assignment-pane';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      /**
       * Specify which switch action this pane handles.
       */
      action: {
        type: String,
      },

      /**
       * Specify the context this pane is located in.
       */
      context: String,

      /**
       * Enable the html template to use AssignmentState.
       */
      assignmentState_: {
        type: Number,
        value: AssignmentState.WAIT_FOR_KEY,
      },

      /**
       * Assignments for the current action.
       */
      assignments_: {
        type: Array,
        value: [],
      },

      /**
       * A dictionary containing all Switch Access key codes (mapped from
       * actions).
       */
      keyCodes_: {
        type: Object,
        value: {select: {}, next: {}, previous: {}},
      },

      /**
       * User prompt text shown at the top of the pane.
       */
      promptText_: {
        type: String,
        computed: 'computePromptText_(assignmentState_, assignments_)',
      },

      /**
       * Error text shown on the pane with error symbol. Hidden if  blank.
       */
      errorText_: {
        type: String,
        computed: 'computeErrorText_(assignmentState_)',
      },

      /**
       * The label indicating there are no switches.
       */
      noSwitchesLabel_: String,

      alreadyAssignedAction_: String,

      currentKey_: String,

      currentKeyCode_: {
        type: Number,
        value: null,
      },

      currentDeviceType_: String,
    };
  }

  action: SwitchAccessCommand;
  context: AssignmentContext;
  private alreadyAssignedAction_: SwitchAccessCommand;
  private assignmentState_: AssignmentState;
  private assignments_: KeyAssignment[];
  private currentDeviceType_: SwitchAccessDeviceType;
  private currentKey_: string;
  private currentKeyCode_: number|null;
  private errorText_: string;
  private keyCodes_:
      {[key in SwitchAccessCommand]: SwitchAccessKeyAssignmentInfoMapping};
  private noSwitchesLabel_: string;
  private promptText_: string;
  private switchAccessBrowserProxy_: SwitchAccessSubpageBrowserProxy;

  constructor() {
    super();

    this.switchAccessBrowserProxy_ =
        SwitchAccessSubpageBrowserProxyImpl.getInstance();
  }

  override connectedCallback(): void {
    super.connectedCallback();

    // Save all existing prefs.
    for (const action in actionToPref) {
      chrome.settingsPrivate
          .getPref(actionToPref[action as SwitchAccessCommand])
          .then((pref: {value: SwitchAccessKeyAssignmentInfoMapping}) => {
            this.keyCodes_[action as SwitchAccessCommand] = pref.value;
          });
    }

    this.addWebUiListener(
        'switch-access-got-key-press-for-assignment',
        (event: OnKeyWebUiEvent) => this.onKeyDown_(event));
    this.addWebUiListener(
        'switch-access-assignments-changed',
        (value: SwitchAccessAssignmentsChangedValue) =>
            this.onAssignmentsChanged_(value));
    this.switchAccessBrowserProxy_
        .notifySwitchAccessActionAssignmentPaneActive();

    if (this.context === AssignmentContext.SETUP_GUIDE) {
      this.noSwitchesLabel_ = this.i18n('noSwitchesAssignedSetupGuide');
    } else {
      this.noSwitchesLabel_ = this.i18n('noSwitchesAssigned');
    }
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();

    this.switchAccessBrowserProxy_
        .notifySwitchAccessActionAssignmentPaneInactive();

    // Restore everything before we close.
    for (const action in actionToPref) {
      chrome.settingsPrivate.setPref(
          actionToPref[action as SwitchAccessCommand],
          this.keyCodes_[action as SwitchAccessCommand]);
    }
  }

  private onKeyDown_(event: OnKeyWebUiEvent): void {
    switch (this.assignmentState_) {
      case AssignmentState.WAIT_FOR_KEY:
        this.handleKeyEventInWaitForKey_(event);
        break;
      case AssignmentState.WAIT_FOR_CONFIRMATION:
        this.handleKeyEventInWaitForConfirmation_(event);
        break;
      case AssignmentState.WAIT_FOR_CONFIRMATION_REMOVAL:
        this.handleKeyEventInWaitForConfirmationRemoval_(event);
        break;
      case AssignmentState.WARN_NOT_CONFIRMED:
      case AssignmentState.WARN_NOT_CONFIRMED_REMOVAL:
      case AssignmentState.WARN_ALREADY_ASSIGNED_ACTION:
      case AssignmentState.WARN_UNRECOGNIZED_KEY:
      case AssignmentState.WARN_CANNOT_REMOVE_LAST_SELECT_SWITCH:
        this.fireExitPane_();
        break;
    }
  }

  /**
   * In this state, the pane waits for the user to press the initial switch key
   * to be assigned.
   */
  private handleKeyEventInWaitForKey_(event: OnKeyWebUiEvent): void {
    this.currentKeyCode_ = event.keyCode;
    this.currentKey_ = event.key;
    this.currentDeviceType_ = event.device;

    if (!this.currentKey_) {
      this.assignmentState_ = AssignmentState.WARN_UNRECOGNIZED_KEY;
      return;
    }

    // Check for pre-existing assignments in actions other than the current one.
    for (const action of Object.values(SwitchAccessCommand)) {
      if (!this.keyCodes_[action][event.keyCode] ||
          !this.keyCodes_[action][event.keyCode].includes(
              this.currentDeviceType_)) {
        continue;
      }

      if (action === this.action) {
        if (action === SwitchAccessCommand.SELECT &&
            this.assignments_.length === 1) {
          this.assignmentState_ =
              AssignmentState.WARN_CANNOT_REMOVE_LAST_SELECT_SWITCH;
          return;
        }
        this.assignmentState_ = AssignmentState.WAIT_FOR_CONFIRMATION_REMOVAL;
      } else {
        this.alreadyAssignedAction_ = action;
        this.assignmentState_ = AssignmentState.WARN_ALREADY_ASSIGNED_ACTION;
      }
      return;
    }
    this.assignmentState_ = AssignmentState.WAIT_FOR_CONFIRMATION;
    this.push(
        'assignments_',
        {key: this.currentKey_, device: this.currentDeviceType_});
  }

  /**
   * In this state, the user has pressed the initial switch key, which is not
   * already assigned to any action. The pane waits for the user to press the
   * switch key again to confirm assignment.
   */
  private handleKeyEventInWaitForConfirmation_(event: OnKeyWebUiEvent): void {
    if (this.currentKeyCode_ !== event.keyCode ||
        this.currentDeviceType_ !== event.device) {
      this.assignmentState_ = AssignmentState.WARN_NOT_CONFIRMED;
      return;
    }

    // Save the key to |this.keyCodes_| for inclusion into prefs later.
    const keyAssignmentInfoMapping = this.keyCodes_[this.action];
    if (!keyAssignmentInfoMapping) {
      throw new Error('Expected valid pref for action: ' + this.action);
    }
    let devices = keyAssignmentInfoMapping[this.currentKeyCode_];
    if (!devices) {
      // |this.currentKeyCode_| was not set as a switch key for |this.action|
      // before.
      devices = [];
      keyAssignmentInfoMapping[this.currentKeyCode_] = devices;
    }
    if (!devices.includes(event.device)) {
      // A new device for the current key code has been added.
      devices.push(event.device);
    }

    this.fireExitPane_();
  }

  /**
   * In this state, the user has pressed the initial switch key, which is
   * already assigned to the current action. The pane waits for the user to
   * press the switch key again to confirm removal.
   */
  private handleKeyEventInWaitForConfirmationRemoval_(event: OnKeyWebUiEvent):
      void {
    if (this.currentKeyCode_ !== event.keyCode ||
        this.currentDeviceType_ !== event.device) {
      this.assignmentState_ = AssignmentState.WARN_NOT_CONFIRMED_REMOVAL;
      return;
    }

    // Remove this device type for this key code.
    const devices = this.keyCodes_[this.action][this.currentKeyCode_];
    devices.splice(devices.indexOf(event.device), 1);
    if (!devices.length) {
      delete this.keyCodes_[this.action][this.currentKeyCode_];
    }

    this.fireExitPane_();
  }

  private onAssignmentsChanged_(value: SwitchAccessAssignmentsChangedValue):
      void {
    this.assignments_ = value[this.action];
  }

  /**
   * Fires an 'exit-pane' event.
   */
  private fireExitPane_(): void {
    const exitPaneEvent =
        new CustomEvent('exit-pane', {bubbles: true, composed: true});
    this.dispatchEvent(exitPaneEvent);
  }

  private getLabelForAction_(action: SwitchAccessCommand): string {
    switch (action) {
      case SwitchAccessCommand.SELECT:
        return this.i18n('assignSelectSwitchLabel');
      case SwitchAccessCommand.NEXT:
        return this.i18n('assignNextSwitchLabel');
      case SwitchAccessCommand.PREVIOUS:
        return this.i18n('assignPreviousSwitchLabel');
      default:
        assertNotReached();
    }
  }

  private getLabelForDeviceType_(deviceType: SwitchAccessDeviceType):
      TrustedHTML {
    switch (deviceType) {
      case SwitchAccessDeviceType.INTERNAL:
        return this.i18nAdvanced('switchAccessInternalDeviceTypeLabel', {});
      case SwitchAccessDeviceType.USB:
        return this.i18nAdvanced('switchAccessUsbDeviceTypeLabel', {});
      case SwitchAccessDeviceType.BLUETOOTH:
        return this.i18nAdvanced('switchAccessBluetoothDeviceTypeLabel', {});
      case SwitchAccessDeviceType.UNKNOWN:
        return this.i18nAdvanced('switchAccessUnknownDeviceTypeLabel', {});
      default:
        assertNotReached('Invalid device type.');
    }
  }

  /**
   * Converts assignment object to pretty-formatted label.
   * E.g. {key: 'Escape', device: 'usb'} -> 'Escape (USB)'
   */
  private getLabelForAssignment_(assignment: KeyAssignment): TrustedHTML {
    return this.i18nAdvanced('switchAndDeviceType', {
      substitutions: [
        assignment.key,
        this.getLabelForDeviceType_(assignment.device).toString(),
      ],
    });
  }

  /**
   * Returns the image to use for the assignment's icon. The value must match
   * one of iron-icon's os-settings:(*) icon names.
   */
  private computeIcon_(assignment: KeyAssignment): AssignmentIcon {
    if (assignment.key !== this.currentKey_ ||
        assignment.device !== this.currentDeviceType_) {
      return AssignmentIcon.ASSIGNED;
    }

    switch (this.assignmentState_) {
      case AssignmentState.WAIT_FOR_KEY:
      case AssignmentState.WARN_ALREADY_ASSIGNED_ACTION:
      case AssignmentState.WARN_UNRECOGNIZED_KEY:
      case AssignmentState.WARN_CANNOT_REMOVE_LAST_SELECT_SWITCH:
        return AssignmentIcon.ASSIGNED;
      case AssignmentState.WAIT_FOR_CONFIRMATION:
      case AssignmentState.WARN_NOT_CONFIRMED:
        return AssignmentIcon.ADD_ASSIGNMENT;
      case AssignmentState.WAIT_FOR_CONFIRMATION_REMOVAL:
      case AssignmentState.WARN_NOT_CONFIRMED_REMOVAL:
        return AssignmentIcon.REMOVE_ASSIGNMENT;
      default:
        assertNotReached('Invalid assignment state.');
    }
  }

  /**
   * Returns the icon label describing the icon for the specified assignment.
   */
  private computeIconLabel_(assignment: KeyAssignment): string {
    const icon = this.computeIcon_(assignment);
    switch (icon) {
      case AssignmentIcon.ASSIGNED:
        return this.i18n('switchAccessActionAssignmentAssignedIconLabel');
      case AssignmentIcon.ADD_ASSIGNMENT:
        return this.i18n('switchAccessActionAssignmentAddAssignmentIconLabel');
      case AssignmentIcon.REMOVE_ASSIGNMENT:
        return this.i18n(
            'switchAccessActionAssignmentRemoveAssignmentIconLabel');
      default:
        assertNotReached('Invalid assignment icon.');
    }
  }

  private computePromptText_(
      assignmentState: AssignmentState, assignments: KeyAssignment[]): string {
    let response: AssignmentResponse;
    switch (assignmentState) {
      case AssignmentState.WAIT_FOR_KEY:
      case AssignmentState.WARN_ALREADY_ASSIGNED_ACTION:
      case AssignmentState.WARN_UNRECOGNIZED_KEY:
      case AssignmentState.WARN_CANNOT_REMOVE_LAST_SELECT_SWITCH:
        if (!assignments.length) {
          if (this.context === AssignmentContext.SETUP_GUIDE) {
            return this.i18n(
                'switchAccessActionAssignmentWaitForKeyPromptNoSwitchesSetupGuide',
                this.getLabelForAction_(this.action));
          }
          return this.i18n(
              'switchAccessActionAssignmentWaitForKeyPromptNoSwitches',
              this.getLabelForAction_(this.action));
        }
        return this.i18n(
            'switchAccessActionAssignmentWaitForKeyPromptAtLeastOneSwitch');
      case AssignmentState.WAIT_FOR_CONFIRMATION:
      case AssignmentState.WARN_NOT_CONFIRMED:
        response = this.context === AssignmentContext.SETUP_GUIDE ?
            AssignmentResponse.CONTINUE :
            AssignmentResponse.EXIT;
        return this.i18n(
            'switchAccessActionAssignmentWaitForConfirmationPrompt',
            this.currentKey_, this.i18n(response));
      case AssignmentState.WAIT_FOR_CONFIRMATION_REMOVAL:
      case AssignmentState.WARN_NOT_CONFIRMED_REMOVAL:
        response = this.context === AssignmentContext.SETUP_GUIDE ?
            AssignmentResponse.TRY_AGAIN :
            AssignmentResponse.EXIT;
        return this.i18n(
            'switchAccessActionAssignmentWaitForConfirmationRemovalPrompt',
            this.currentKey_, this.i18n(response));
      default:
        assertNotReached('Invalid assignment state.');
    }
  }

  private computeErrorText_(assignmentState: AssignmentState): string {
    let response = this.context === AssignmentContext.SETUP_GUIDE ?
        AssignmentResponse.TRY_AGAIN :
        AssignmentResponse.EXIT;
    switch (assignmentState) {
      case AssignmentState.WARN_NOT_CONFIRMED:
      case AssignmentState.WARN_NOT_CONFIRMED_REMOVAL:
        return this.i18n(
            'switchAccessActionAssignmentWarnNotConfirmedPrompt',
            this.i18n(response));
      case AssignmentState.WARN_ALREADY_ASSIGNED_ACTION:
        return this.i18n(
            'switchAccessActionAssignmentWarnAlreadyAssignedActionPrompt',
            this.currentKey_,
            this.getLabelForAction_(this.alreadyAssignedAction_),
            this.i18n(response));
      case AssignmentState.WARN_UNRECOGNIZED_KEY:
        return this.i18n(
            'switchAccessActionAssignmentWarnUnrecognizedKeyPrompt',
            this.i18n(response));
      case AssignmentState.WARN_CANNOT_REMOVE_LAST_SELECT_SWITCH:
        if (this.context === AssignmentContext.SETUP_GUIDE) {
          response = AssignmentResponse.CONTINUE;
        }
        return this.i18n(
            'switchAccessActionAssignmentWarnCannotRemoveLastSelectSwitch',
            this.i18n(response));
      case AssignmentState.WAIT_FOR_KEY:
      case AssignmentState.WAIT_FOR_CONFIRMATION:
      case AssignmentState.WAIT_FOR_CONFIRMATION_REMOVAL:
        return '';
      default:
        assertNotReached('Invalid assignment state.');
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-switch-access-action-assignment-pane':
        SettingsSwitchAccessActionAssignmentPaneElement;
  }
}

customElements.define(
    SettingsSwitchAccessActionAssignmentPaneElement.is,
    SettingsSwitchAccessActionAssignmentPaneElement);