chromium/ash/webui/shortcut_customization_ui/resources/js/accelerator_view.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 'chrome://resources/ash/common/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/ash/common/shortcut_input_ui/shortcut_input_key.js';
import 'chrome://resources/ash/common/shortcut_input_ui/shortcut_input.js';

import {getInstance as getAnnouncerInstance} from 'chrome://resources/ash/common/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {KeyEvent} from 'chrome://resources/ash/common/shortcut_input_ui/input_device_settings.mojom-webui.js';
import {ShortcutInputElement} from 'chrome://resources/ash/common/shortcut_input_ui/shortcut_input.js';
import {strictQuery} from 'chrome://resources/ash/common/typescript_utils/strict_query.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {mojoString16ToString} from 'chrome://resources/js/mojo_type_util.js';
import {String16} from 'chrome://resources/mojo/mojo/public/mojom/base/string16.mojom-webui.js';
import {PolymerElementProperties} from 'chrome://resources/polymer/v3_0/polymer/interfaces.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {AcceleratorResultData, Subactions, UserAction} from '../mojom-webui/shortcut_customization.mojom-webui.js';
import {ShortcutInputProviderInterface} from '../mojom-webui/shortcut_input_provider.mojom-webui.js';

import {AcceleratorLookupManager} from './accelerator_lookup_manager.js';
import {getTemplate} from './accelerator_view.html.js';
import {getShortcutProvider} from './mojo_interface_provider.js';
import {getShortcutInputProvider} from './shortcut_input_mojo_interface_provider.js';
import {Accelerator, AcceleratorConfigResult, AcceleratorSource, AcceleratorState, EditAction, MetaKey, Modifier, ShortcutProviderInterface, StandardAcceleratorInfo} from './shortcut_types.js';
import {areAcceleratorsEqual, canBypassErrorWithRetry, containsAccelerator, getAccelerator, getKeyDisplay, getModifiersForAcceleratorInfo, isCustomizationAllowed, isStandardAcceleratorInfo, isValidAccelerator, keyEventToAccelerator, LWIN_KEY, META_KEY, resetKeyEvent} from './shortcut_utils.js';

export interface AcceleratorViewElement {
  $: {
    container: HTMLDivElement,
    shortcutInput: ShortcutInputElement,
  };
}

export enum ViewState {
  VIEW,
  ADD,
  EDIT,
}

// This delay should match the animation timing in `shortcut_input_key.html`.
// Matching the delay allows the user to see the full animation before
// requesting a change to the backend.
const kAnimationTimeoutMs: number = 300;

const kEscapeKey: number = 27;  // Keycode for VKEY_ESCAPE

/**
 * @fileoverview
 * 'accelerator-view' is wrapper component for an accelerator. It maintains both
 * the read-only and editable state of an accelerator.
 */
const AcceleratorViewElementBase = I18nMixin(PolymerElement);

export class AcceleratorViewElement extends AcceleratorViewElementBase {
  static get is(): string {
    return 'accelerator-view';
  }

  static get properties(): PolymerElementProperties {
    return {
      acceleratorInfo: {
        type: Object,
      },

      pendingKeyEvent: {
        type: Object,
      },

      viewState: {
        type: Number,
        value: ViewState.VIEW,
        notify: true,
        observer: AcceleratorViewElement.prototype.onViewStateChanged,
      },

      modifiers: {
        type: Array,
        computed: 'getModifiers(acceleratorInfo.accelerator.*)',
      },

      isCapturing: {
        type: Boolean,
        value: false,
      },

      statusMessage: {
        type: Object,
        notify: true,
      },

      /** Informs parent components that an error has occurred. */
      hasError: {
        type: Boolean,
        value: false,
        notify: true,
        observer: AcceleratorViewElement.prototype.onErrorUpdated,
      },

      // Keeps track if there was ever an error when interacting with this
      // accelerator.
      recordedError: {
        type: Boolean,
        value: false,
        notify: true,
      },

      description: {
        type: String,
        value: '',
      },

      action: {
        type: Number,
        value: 0,
      },

      source: {
        type: Number,
        value: 0,
      },

      sourceIsLocked: {
        type: Boolean,
        value: false,
      },

      /**
       * Conditionally show the edit-icon-container in `accelerator-view`, true
       * for `accelerator-row`, false for `accelerator-edit-view`.
       */
      showEditIcon: {
        type: Boolean,
        value: false,
      },

      /** Only show the edit button in the first row. */
      isFirstAccelerator: {
        type: Boolean,
      },

      isDisabled: {
        type: Boolean,
        computed: 'computeIsDisabled(acceleratorInfo.*)',
        reflectToAttribute: true,
      },

      /** The meta key on the keyboard to display to the user. */
      metaKey: Object,

      hasFunctionKey: {
        type: Boolean,
        value: loadTimeData.getBoolean('hasFunctionKey'),
      },
    };
  }

  acceleratorInfo: StandardAcceleratorInfo;
  viewState: ViewState;
  statusMessage: string|TrustedHTML;
  hasError: boolean;
  recordedError: boolean;
  description: string;
  action: number;
  source: AcceleratorSource;
  sourceIsLocked: boolean;
  showEditIcon: boolean;
  subcategoryIsLocked: boolean;
  isFirstAccelerator: boolean;
  isDisabled: boolean;
  metaKey: MetaKey = MetaKey.kSearch;
  pendingKeyEvent: KeyEvent|null = null;
  shortcutInput: ShortcutInputElement|null;
  defaultAccelerators: Accelerator[];
  hasFunctionKey: boolean;
  protected isCapturing: boolean;
  protected lastAccelerator: Accelerator;
  protected lastResult: AcceleratorConfigResult;
  protected lastPendingKeyEvent: KeyEvent|null = null;
  private shortcutProvider: ShortcutProviderInterface = getShortcutProvider();
  private lookupManager: AcceleratorLookupManager =
      AcceleratorLookupManager.getInstance();
  private eventTracker: EventTracker = new EventTracker();
  private editAction: EditAction = EditAction.NONE;

  override async connectedCallback(): Promise<void> {
    super.connectedCallback();

    this.subcategoryIsLocked = this.lookupManager.isSubcategoryLocked(
        this.lookupManager.getAcceleratorSubcategory(this.source, this.action));

    this.metaKey = this.lookupManager.getMetaKeyToDisplay();
    this.defaultAccelerators =
        (await this.shortcutProvider.getDefaultAcceleratorsForId(this.action))
            .accelerators;
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();
    this.eventTracker.removeAll();
  }

  getShortcutInputProvider(): ShortcutInputProviderInterface {
    return getShortcutInputProvider();
  }

  private getModifiers(): string[] {
    return getModifiersForAcceleratorInfo(this.acceleratorInfo);
  }

  protected onViewStateChanged(): void {
    if (this.viewState !== ViewState.VIEW) {
      this.registerKeyEventListeners();
      return;
    }
    this.unregisterKeyEventListeners();
  }

  protected onShortcutInputDomChange(): void {
    // `shortcutInput` will always be restamped when `viewState` is Edit.
    // Start observing for input events the moment `shortcutInput` is available.
    this.shortcutInput =
        this.shadowRoot!.querySelector<ShortcutInputElement>('#shortcutInput');
    if (this.shortcutInput) {
      this.shortcutInput.startObserving();
    }
  }

  private registerKeyEventListeners(): void {
    this.eventTracker.add(
        this, 'shortcut-input-capture-state',
        (e: CustomEvent) => this.onShortcutInputCaptureStateUpdate(e));
    this.eventTracker.add(
        this, 'shortcut-input-event',
        (e: CustomEvent) => this.handleKeyDown(e));
  }

  private unregisterKeyEventListeners(): void {
    this.eventTracker.removeAll();
  }

  private startCapture(): void {
    if (this.isCapturing) {
      return;
    }

    this.pendingKeyEvent = resetKeyEvent();
  }

  async endCapture(shouldDelay: boolean): Promise<void> {
    this.editAction = EditAction.NONE;

    if (this.shortcutInput) {
      this.shortcutInput.stopObserving();
    }

    this.dispatchEvent(new CustomEvent('accelerator-capturing-ended', {
      bubbles: true,
      composed: true,
    }));

    // Delay if an update event is fired.
    if (shouldDelay) {
      await new Promise(resolve => setTimeout(resolve, kAnimationTimeoutMs));
      // Dispatch event to update subsections and dialog accelerators.
      this.dispatchEvent(new CustomEvent('request-update-accelerator', {
        bubbles: true,
        composed: true,
        detail: {source: this.source, action: this.action},
      }));
    }

    this.viewState = ViewState.VIEW;
    // Should always set `hasError` before `statusMessage` since `statusMessage`
    // is dependent on `hasError`'s state.
    this.hasError = false;
    this.statusMessage = '';
    this.pendingKeyEvent = resetKeyEvent();
  }

  private onShortcutInputCaptureStateUpdate(e: CustomEvent): void {
    if (this.isCapturing === e.detail.capturing) {
      // Ignore repeated events.
      return;
    }

    this.isCapturing = e.detail.capturing;
    if (this.isCapturing) {
      this.dispatchEvent(new CustomEvent('accelerator-capturing-started', {
        bubbles: true,
        composed: true,
      }));
      this.startCapture();
      // Announce the hint message.
      this.makeA11yAnnouncement(this.i18n('editViewStatusMessage'));
    }
  }

  private handleKeyDown(e: CustomEvent): void {
    // Announce the icon label or key pressed.
    const keyOrIcon = e.detail.keyEvent.keyDisplay;
    this.makeA11yAnnouncement(getKeyDisplay(keyOrIcon));
    const rewrittenKeyEvent = e.detail.keyEvent;
    const pendingAccelerator = keyEventToAccelerator(rewrittenKeyEvent);
    if (this.hasError) {
      // If an error occurred, check if the pending accelerator matches the
      // last. If they match and a retry on the same accelerator
      // cannot bypass the error, exit early to prevent flickering error
      // messages.
      if (areAcceleratorsEqual(pendingAccelerator, this.lastAccelerator) &&
          !canBypassErrorWithRetry(this.lastResult)) {
        return;
      }
      // Reset status state when pressing a new key.
      this.statusMessage = '';
      this.hasError = false;
    }

    this.lastAccelerator = {...pendingAccelerator};
    // Alt + Esc will exit input handling immediately.
    if (pendingAccelerator.modifiers === Modifier.ALT &&
        pendingAccelerator.keyCode === kEscapeKey) {
      this.endCapture(/*shouldDelay=*/ false);
      return;
    }

    // Only process valid accelerators.
    if (isValidAccelerator(pendingAccelerator) ||
        containsAccelerator(this.defaultAccelerators, pendingAccelerator)) {
      // Store the pending key event.
      this.lastPendingKeyEvent = rewrittenKeyEvent;
      this.processPendingAccelerator(pendingAccelerator);
    }
  }

  private async processPendingAccelerator(pendingAccelerator: Accelerator):
      Promise<void> {
    // Dispatch an event indicating that accelerator update is in progress.
    this.dispatchEvent(new CustomEvent('accelerator-update-in-progress', {
      bubbles: true,
      composed: true,
    }));
    // Reset status state when processing the new accelerator.
    this.statusMessage = '';
    this.hasError = false;

    let result: {result: AcceleratorResultData};
    assert(this.viewState !== ViewState.VIEW);

    // If the accelerator is disabled, we should only add the new accelerator.
    const isDisabledAccelerator =
        this.acceleratorInfo.state === AcceleratorState.kDisabledByUser;

    if (this.viewState === ViewState.ADD || isDisabledAccelerator) {
      this.editAction = EditAction.ADD;
      result = await this.shortcutProvider.addAccelerator(
          this.source, this.action, pendingAccelerator);
    }

    if (this.viewState === ViewState.EDIT && !isDisabledAccelerator) {
      this.editAction = EditAction.EDIT;
      const originalAccelerator: Accelerator|undefined =
          this.acceleratorInfo.layoutProperties.standardAccelerator
              ?.originalAccelerator;
      const acceleratorToEdit =
          originalAccelerator || getAccelerator(this.acceleratorInfo);
      result = await this.shortcutProvider.replaceAccelerator(
          this.source, this.action, acceleratorToEdit, pendingAccelerator);
    }
    this.handleAcceleratorResultData(result!.result);
  }

  private handleAcceleratorResultData(result: AcceleratorResultData): void {
    this.lastResult = result.result;
    switch (result.result) {
      // Shift is the only modifier.
      case AcceleratorConfigResult.kShiftOnlyNotAllowed: {
        this.statusMessage = this.i18n(
            'shiftOnlyNotAllowedStatusMessage', this.getMetaKeyDisplay());
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      // No modifiers is pressed before primary key.
      case AcceleratorConfigResult.kMissingModifier: {
        // This is a backup check, since only valid accelerators are processed
        // and a valid accelerator will have modifier(s) and a key or is
        // function key.
        this.statusMessage =
            this.i18n('missingModifierStatusMessage', this.getMetaKeyDisplay());
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      // Top row key used as activation keys(no search key pressed).
      case AcceleratorConfigResult.kKeyNotAllowed: {
        this.statusMessage =
            this.i18n('keyNotAllowedStatusMessage', this.getMetaKeyDisplay());
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      // Search with function keys are not allowed.
      case AcceleratorConfigResult.kSearchWithFunctionKeyNotAllowed: {
        this.statusMessage = this.i18n(
            'searchWithFunctionKeyNotAllowedStatusMessage',
            this.getMetaKeyDisplay());
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      // Conflict with a locked accelerator.
      case AcceleratorConfigResult.kConflict:
      case AcceleratorConfigResult.kActionLocked: {
        this.statusMessage = this.i18n(
            'lockedShortcutStatusMessage',
            mojoString16ToString(result.shortcutName as String16));
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      // Conflict with an editable shortcut.
      case AcceleratorConfigResult.kConflictCanOverride: {
        this.statusMessage = this.i18n(
            'shortcutWithConflictStatusMessage',
            mojoString16ToString(result.shortcutName as String16));
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      // Limit to only 5 accelerators allowed.
      case AcceleratorConfigResult.kMaximumAcceleratorsReached: {
        this.statusMessage = this.i18n('maxAcceleratorsReachedHint');
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      case AcceleratorConfigResult.kNonSearchAcceleratorWarning: {
        this.statusMessage = this.i18nAdvanced(
            'warningSearchNotIncluded',
            {substitutions: [this.getMetaKeyDisplay()]});
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      case AcceleratorConfigResult.kReservedKeyNotAllowed: {
        this.statusMessage = this.i18n(
            'reservedKeyNotAllowedStatusMessage',
            this.lastPendingKeyEvent!.keyDisplay);
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      case AcceleratorConfigResult.kNonStandardWithSearch: {
        this.statusMessage = this.i18n(
            'nonStandardNotAllowedWithSearchMessage',
            this.lastPendingKeyEvent!.keyDisplay, this.getMetaKeyDisplay());
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      case AcceleratorConfigResult.kBlockRightAlt: {
        this.statusMessage = this.i18n('blockRightAltKey');
        this.hasError = true;
        this.makeA11yAnnouncement(this.statusMessage);
        return;
      }
      case AcceleratorConfigResult.kSuccess: {
        this.fireEditCompletedActionEvent(this.editAction);
        getShortcutProvider().recordAddOrEditSubactions(
            this.viewState === ViewState.ADD,
            this.recordedError ? Subactions.kErrorSuccess :
                                 Subactions.kNoErrorSuccess);
        getShortcutProvider().recordUserAction(
            UserAction.kSuccessfulModification);
        this.fireUpdateEvent();
        return;
      }
    }
    assertNotReached();
  }


  private makeA11yAnnouncement(message: string|TrustedHTML): void {
    const announcer = getAnnouncerInstance(this.$.container);
    // Remove "role = alert" to avoid chromevox announcing "alert" before
    // message.
    strictQuery('#messages', announcer.shadowRoot, HTMLDivElement)
        .removeAttribute('role');
    // Announce the messages.
    announcer.announce(message as string);
  }

  private showEditView(): boolean {
    return this.viewState !== ViewState.VIEW;
  }

  private fireUpdateEvent(): void {
    if (this.acceleratorInfo.state === AcceleratorState.kDisabledByUser &&
        isStandardAcceleratorInfo(this.acceleratorInfo)) {
      this.dispatchEvent(new CustomEvent('default-conflict-resolved', {
        bubbles: true,
        composed: true,
        detail: {
          stringifiedAccelerator:
              JSON.stringify(getAccelerator(this.acceleratorInfo)),
        },
      }));
    }

    // Always end input capturing if an update event was fired.
    this.endCapture(/*should_delay=*/ true);
  }

  private fireEditCompletedActionEvent(editAction: EditAction): void {
    this.dispatchEvent(new CustomEvent('edit-action-completed', {
      bubbles: true,
      composed: true,
      detail: {
        editAction: editAction,
      },
    }));
  }

  private shouldShowLockIcon(): boolean {
    // Do not show lock icon in each row if customization is disabled or its
    // category is locked.
    if (!isCustomizationAllowed() || this.subcategoryIsLocked) {
      return false;
    }
    // Show lock icon if accelerator is locked.
    return (this.acceleratorInfo && this.acceleratorInfo.locked) ||
        this.sourceIsLocked;
  }

  private shouldShowEditIcon(): boolean {
    // Do not show edit icon in each row if customization is disabled, the row
    // is displayed in edit-dialog(!showEditIcon) or category is locked.
    if (!isCustomizationAllowed() || !this.showEditIcon ||
        this.subcategoryIsLocked) {
      return false;
    }
    // Show edit icon if accelerator is not locked.
    return !(this.acceleratorInfo && this.acceleratorInfo.locked) &&
        !this.sourceIsLocked && this.isFirstAccelerator;
  }

  private onEditIconClicked(): void {
    this.dispatchEvent(
        new CustomEvent('edit-icon-clicked', {bubbles: true, composed: true}));
  }

  private getAriaLabel(): string {
    // Clear aria-label during editing to avoid unnecessary chromevox
    // announcements.
    if (this.viewState !== ViewState.VIEW) {
      return '';
    }
    let keyOrIcon =
        this.acceleratorInfo.layoutProperties.standardAccelerator.keyDisplay;
    const metaKeyAriaLabel = this.getMetaKeyDisplay();
    // LWIN_KEY is not a modifier, but it is displayed as a meta icon.
    keyOrIcon = keyOrIcon === LWIN_KEY ? metaKeyAriaLabel : keyOrIcon;
    const modifiers =
        getModifiersForAcceleratorInfo(this.acceleratorInfo)
            .map(
                // Update modifiers if it includes META_KEY.
                modifier =>
                    modifier === META_KEY ? metaKeyAriaLabel : modifier);

    return [...modifiers, getKeyDisplay(keyOrIcon)].join(' ');
  }

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

  private computeIsDisabled(): boolean {
    return this.acceleratorInfo.state === AcceleratorState.kDisabledByUser ||
        this.acceleratorInfo.state === AcceleratorState.kDisabledByConflict;
  }

  private onErrorUpdated(): void {
    // `recordedError` will only update if it was previously false and
    // an error has been detected.
    if (!this.recordedError && this.hasError) {
      this.recordedError = true;
    }
  }

  private getMetaKeyDisplay(): string {
    const metaKey = this.lookupManager.getMetaKeyToDisplay();
    switch (metaKey) {
      case MetaKey.kLauncherRefresh:
        // TODO(b/338134189): Replace it with updated icon when finalized.
        return this.i18n('iconLabelOpenLauncher');
      case MetaKey.kSearch:
        return this.i18n('iconLabelOpenSearch');
      case MetaKey.kLauncher:
      default:
        return this.i18n('iconLabelOpenLauncher');
    }
  }

  private getEditButtonAriaLabel(): string {
    return this.i18n('editButtonForRow', this.description);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'accelerator-view': AcceleratorViewElement;
  }
}

customElements.define(AcceleratorViewElement.is, AcceleratorViewElement);