chromium/ash/webui/diagnostics_ui/resources/keyboard_tester.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_dialog/cr_dialog.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/ash/common/cr_elements/cr_toast/cr_toast.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import './strings.m.js';

import {KeyboardDiagramElement, MechanicalLayout as DiagramMechanicalLayout, PhysicalLayout as DiagramPhysicalLayout, TopRightKey as DiagramTopRightKey, TopRowKey as DiagramTopRowKey} from 'chrome://resources/ash/common/keyboard_diagram.js';
import {KeyboardKeyState} from 'chrome://resources/ash/common/keyboard_key.js';
import {loadTimeData} from 'chrome://resources/ash/common/load_time_data.m.js';
import {getInstance} from 'chrome://resources/ash/common/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {CrDialogElement} from 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import {CrToastElement} from 'chrome://resources/ash/common/cr_elements/cr_toast/cr_toast.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.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 {KeyboardInfo, MechanicalLayout, NumberPadPresence, PhysicalLayout, TopRightKey, TopRowKey} from './input.mojom-webui.js';
import {InputDataProviderInterface, KeyboardObserverReceiver, KeyEvent, KeyEventType} from './input_data_provider.mojom-webui.js';
import {getTemplate} from './keyboard_tester.html.js';
import {getInputDataProvider} from './mojo_interface_provider.js';

export interface KeyboardTesterElement {
  $: {
    dialog: CrDialogElement,
    lostFocusToast: CrToastElement,
  };
}

export type AnnounceTextEvent = CustomEvent<{text: string}>;

declare global {
  interface HTMLElementEventMap {
    'announce-text': AnnounceTextEvent;
  }
}

export interface KeyboardDiagramTopRowKey {
  icon?: string;
  ariaNameI18n?: string;
  text?: string;
}

/**
 * @fileoverview
 * 'keyboard-tester' displays a tester UI for a keyboard.
 */

/**
 * Map from Mojo TopRowKey constants to keyboard diagram top row key
 * definitions.
 */
const topRowKeyMap: {[index: number]: KeyboardDiagramTopRowKey} = {
  [TopRowKey.kNone]: DiagramTopRowKey['kNone'],
  [TopRowKey.kBack]: DiagramTopRowKey['kBack'],
  [TopRowKey.kForward]: DiagramTopRowKey['kForward'],
  [TopRowKey.kRefresh]: DiagramTopRowKey['kRefresh'],
  [TopRowKey.kFullscreen]: DiagramTopRowKey['kFullscreen'],
  [TopRowKey.kOverview]: DiagramTopRowKey['kOverview'],
  [TopRowKey.kScreenshot]: DiagramTopRowKey['kScreenshot'],
  [TopRowKey.kScreenBrightnessDown]: DiagramTopRowKey['kScreenBrightnessDown'],
  [TopRowKey.kScreenBrightnessUp]: DiagramTopRowKey['kScreenBrightnessUp'],
  [TopRowKey.kPrivacyScreenToggle]: DiagramTopRowKey['kPrivacyScreenToggle'],
  [TopRowKey.kMicrophoneMute]: DiagramTopRowKey['kMicrophoneMute'],
  [TopRowKey.kVolumeMute]: DiagramTopRowKey['kVolumeMute'],
  [TopRowKey.kVolumeDown]: DiagramTopRowKey['kVolumeDown'],
  [TopRowKey.kVolumeUp]: DiagramTopRowKey['kVolumeUp'],
  [TopRowKey.kKeyboardBacklightToggle]:
      DiagramTopRowKey['kKeyboardBacklightToggle'],
  [TopRowKey.kKeyboardBacklightDown]:
      DiagramTopRowKey['kKeyboardBacklightDown'],
  [TopRowKey.kKeyboardBacklightUp]: DiagramTopRowKey['kKeyboardBacklightUp'],
  [TopRowKey.kNextTrack]: DiagramTopRowKey['kNextTrack'],
  [TopRowKey.kPreviousTrack]: DiagramTopRowKey['kPreviousTrack'],
  [TopRowKey.kPlayPause]: DiagramTopRowKey['kPlayPause'],
  [TopRowKey.kScreenMirror]: DiagramTopRowKey['kScreenMirror'],
  [TopRowKey.kDelete]: DiagramTopRowKey['kDelete'],
  [TopRowKey.kUnknown]: DiagramTopRowKey['kUnknown'],
};

/** Maps top-right key evdev codes to the corresponding DiagramTopRightKey. */
const topRightKeyByCode: Map<number, DiagramTopRightKey> = new Map([
  [116, DiagramTopRightKey.POWER],
  [142, DiagramTopRightKey.LOCK],
  [579, DiagramTopRightKey.CONTROL_PANEL],
]);

/** Evdev codes for keys that always appear in the number pad area. */
const numberPadCodes: Set<number> = new Set([
  55,   // KEY_KPASTERISK
  71,   // KEY_KP7
  72,   // KEY_KP8
  73,   // KEY_KP9
  74,   // KEY_KPMINUS
  75,   // KEY_KP4
  76,   // KEY_KP5
  77,   // KEY_KP6
  78,   // KEY_KPPLUS
  79,   // KEY_KP1
  80,   // KEY_KP2
  81,   // KEY_KP3
  82,   // KEY_KP0
  83,   // KEY_KPDOT
  96,   // KEY_KPENTER
  98,   // KEY_KPSLASH
  102,  // KEY_HOME
  107,  // KEY_END
]);

/**
 * Evdev codes for keys that appear in the number pad area on standard ChromeOS
 * keyboards, but not on Dell Enterprise ones.
 */
const standardNumberPadCodes: Set<number> = new Set([
  104,  // KEY_PAGEUP
  109,  // KEY_PAGEDOWN
  111,  // KEY_DELETE
]);

const DISPLAY_TOAST_INDEFINITELY_MS = 0;
const TOAST_LINGER_MS = 1000;

const KeyboardTesterElementBase = I18nMixin(PolymerElement);

export class KeyboardTesterElement extends KeyboardTesterElementBase {
  static get is(): string {
    return 'keyboard-tester';
  }

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

  static get properties(): PolymerElementProperties {
    return {
      /**
       * The keyboard being tested, or null if none is being tested at the
       * moment.
       */
      keyboard: KeyboardInfo,

      shouldDisplayDiagram: {
        type: Boolean,
        computed: 'computeShouldDisplayDiagram(keyboard)',
      },

      diagramMechanicalLayout: {
        type: String,
        computed: 'computeDiagramMechanicalLayout(keyboard)',
      },

      diagramPhysicalLayout: {
        type: String,
        computed: 'computeDiagramPhysicalLayout(keyboard)',
      },

      diagramTopRightKey: {
        type: String,
        computed: 'computeDiagramTopRightKey(keyboard)',
      },

      showNumberPad: {
        type: Boolean,
        computed: 'computeShowNumberPad(keyboard)',
      },

      topRowKeys: {
        type: Array,
        computed: 'computeTopRowKeys(keyboard)',
      },

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

      lostFocusToastLingerMs: {
        type: Number,
        value: DISPLAY_TOAST_INDEFINITELY_MS,
      },

    };
  }

  keyboard: KeyboardInfo;
  isLoggedIn: boolean;
  protected diagramTopRightKey: DiagramTopRightKey|null;
  private lostFocusToastLingerMs: number;
  private shouldDisplayDiagram: boolean;
  private diagramMechanicalLayout: DiagramMechanicalLayout|null;
  private diagramPhysicalLayout: DiagramPhysicalLayout|null;
  private showNumberPad: boolean;
  private topRowKeys: KeyboardDiagramTopRowKey[];
  private receiver: KeyboardObserverReceiver|null = null;
  private inputDataProvider: InputDataProviderInterface =
      getInputDataProvider();
  private eventTracker: EventTracker = new EventTracker();

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

  /**
   * Event callback for 'announce-text' which is triggered from keyboard-key.
   * Event will contain text to announce to screen readers.
   */
  private announceTextHandler = (e: AnnounceTextEvent): void => {
    assert(e.detail.text);
    e.stopPropagation();
    getInstance(this.$.dialog.getNative()).announce(e.detail.text);
  };

  private computeShouldDisplayDiagram(keyboard?: KeyboardInfo): boolean {
    if (!keyboard) {
      return false;
    }
    return keyboard.physicalLayout !== PhysicalLayout.kUnknown &&
        keyboard.mechanicalLayout !== MechanicalLayout.kUnknown;
    // Number pad presence can be unknown, as we can adapt on the fly if we get
    // a number pad event we weren't expecting.
  }

  private computeDiagramMechanicalLayout(keyboardInfo?: KeyboardInfo):
      DiagramMechanicalLayout|null {
    if (!keyboardInfo) {
      return null;
    }
    return {
      [MechanicalLayout.kUnknown]: null,
      [MechanicalLayout.kAnsi]: DiagramMechanicalLayout.ANSI,
      [MechanicalLayout.kIso]: DiagramMechanicalLayout.ISO,
      [MechanicalLayout.kJis]: DiagramMechanicalLayout.JIS,
    }[keyboardInfo.mechanicalLayout];
  }

  private computeDiagramPhysicalLayout(keyboardInfo?: KeyboardInfo):
      DiagramPhysicalLayout|null {
    if (!keyboardInfo) {
      return null;
    }
    return {
      [PhysicalLayout.kUnknown]: null,
      [PhysicalLayout.kChromeOS]: DiagramPhysicalLayout.CHROME_OS,
      [PhysicalLayout.kChromeOSDellEnterpriseWilco]:
          DiagramPhysicalLayout.CHROME_OS_DELL_ENTERPRISE_WILCO,
      [PhysicalLayout.kChromeOSDellEnterpriseDrallion]:
          DiagramPhysicalLayout.CHROME_OS_DELL_ENTERPRISE_DRALLION,
    }[keyboardInfo.physicalLayout];
  }

  private computeDiagramTopRightKey(keyboardInfo?: KeyboardInfo):
      DiagramTopRightKey|null {
    if (!keyboardInfo) {
      return null;
    }
    return {
      [TopRightKey.kUnknown]: null,
      [TopRightKey.kPower]: DiagramTopRightKey.POWER,
      [TopRightKey.kLock]: DiagramTopRightKey.LOCK,
      [TopRightKey.kControlPanel]: DiagramTopRightKey.CONTROL_PANEL,
    }[keyboardInfo.topRightKey];
  }

  private computeShowNumberPad(keyboard?: KeyboardInfo): boolean {
    return !!keyboard &&
        keyboard.numberPadPresent === NumberPadPresence.kPresent;
  }


  private computeTopRowKeys(keyboard?: KeyboardInfo):
      KeyboardDiagramTopRowKey[] {
    if (!keyboard) {
      return [];
    }
    return keyboard.topRowKeys.map((keyId: TopRowKey) => topRowKeyMap[keyId]);
  }

  protected getDescriptionLabel(): string {
    return this.i18n('keyboardTesterInstruction');
  }

  protected getShortcutInstructionLabel(): TrustedHTML {
    return this.i18nAdvanced(
        'keyboardTesterShortcutInstruction', {attrs: ['id']});
  }

  private addEventListeners(): void {
    this.eventTracker.add(
        document, 'keydown', (e: KeyboardEvent) => this.onKeyPress(e));
    this.eventTracker.add(
        document, 'keyup', (e: KeyboardEvent) => this.onKeyPress(e));
    this.eventTracker.add(
        document, 'announce-text',
        (e: AnnounceTextEvent) => this.announceTextHandler(e));
  }

  /** Shows the tester's dialog. */
  show(): void {
    assert(this.inputDataProvider);
    this.receiver = new KeyboardObserverReceiver(this);
    this.inputDataProvider.observeKeyEvents(
        this.keyboard.id, this.receiver.$.bindNewPipeAndPassRemote());
    this.addEventListeners();
    const title: HTMLElement|null =
        this.shadowRoot!.querySelector('div[slot="title"]');
    this.$.dialog.getNative().removeAttribute('aria-describedby');
    this.$.dialog.showModal();
    title?.focus();
  }

  // Prevent the default behavior for keydown/keyup only when the keyboard
  // tester dialog is opened.
  onKeyPress(e: KeyboardEvent): void {
    if (!this.isOpen()) {
      return;
    }
    e.preventDefault();
    e.stopPropagation();

    // If we receive alt + esc we should close the tester.
    if (e.type === 'keydown' && e.altKey && e.key === 'Escape') {
      this.close();
    }
  }

  /**
   * Returns whether the tester is currently open.
   */
  isOpen(): boolean {
    return this.$.dialog.open;
  }

  close(): void {
    if (this.shouldDisplayDiagram) {
      const diagram: KeyboardDiagramElement|null =
          this.shadowRoot!.querySelector('#diagram');
      assert(diagram);
      diagram.resetAllKeys();
    }
    this.$.dialog.close();

    const url = new URL(window.location.href);
    url.searchParams.delete('showDefaultKeyboardTester');
    history.pushState(null, '', url);
  }

  handleClose(): void {
    this.eventTracker.removeAll();
    if (this.receiver) {
      this.receiver.$.close();
    }
  }

  /**
   * Returns whether a key is part of the number pad on this keyboard layout.
   */
  private isNumberPadKey(evdevCode: number): boolean {
    // Some keys that are on the number pad on standard ChromeOS keyboards are
    // elsewhere on Dell Enterprise keyboards, so we should only check them if
    // we know this is a standard layout.
    if (this.keyboard.physicalLayout === PhysicalLayout.kChromeOS &&
        standardNumberPadCodes.has(evdevCode)) {
      return true;
    }

    return numberPadCodes.has(evdevCode);
  }

  /**
   * Implements KeyboardObserver.OnKeyEvent.
   * @param {!KeyEvent} keyEvent
   */
  onKeyEvent(keyEvent: KeyEvent): void {
    const diagram: KeyboardDiagramElement|null =
        this.shadowRoot!.querySelector('#diagram');
    assert(diagram);
    const state = keyEvent.type === KeyEventType.kPress ?
        KeyboardKeyState.PRESSED :
        KeyboardKeyState.TESTED;
    if (keyEvent.topRowPosition !== -1 &&
        keyEvent.topRowPosition < this.keyboard.topRowKeys.length) {
      diagram.setTopRowKeyState(keyEvent.topRowPosition, state);
    } else {
      // We can't be sure that the top right key reported over Mojo is correct,
      // so we need to fix it if we see a key event that suggests it's wrong.
      if (topRightKeyByCode.has(keyEvent.keyCode) &&
          diagram.topRightKey !== topRightKeyByCode.get(keyEvent.keyCode)) {
        const newValue =
            topRightKeyByCode.get(keyEvent.keyCode) as DiagramTopRightKey;
        diagram.topRightKey = newValue;
      }

      // Some Chromebooks (at least the Lenovo ThinkPad C13 Yoga a.k.a.
      // Morphius) report F13 instead of SLEEP when Lock is pressed.
      if (keyEvent.keyCode === 183 /* KEY_F13 */) {
        keyEvent.keyCode = 142 /* KEY_SLEEP */;
      }

      // There may be Chromebooks where hasNumberPad is incorrect, so if we see
      // any number pad key codes we need to adapt on-the-fly.
      if (!diagram.showNumberPad && this.isNumberPadKey(keyEvent.keyCode)) {
        diagram.showNumberPad = true;
      }

      diagram.setKeyState(keyEvent.keyCode, state);
    }
  }

  /**
   * Implements KeyboardObserver.OnKeyEventsPaused.
   */
  onKeyEventsPaused(): void {
    const diagram: KeyboardDiagramElement|null =
        this.shadowRoot!.querySelector('#diagram');
    assert(diagram);
    diagram.clearPressedKeys();
    this.lostFocusToastLingerMs = DISPLAY_TOAST_INDEFINITELY_MS;
    this.$.lostFocusToast.show();
  }

  /**
   * Implements KeyboardObserver.OnKeyEventsResumed.
   */
  onKeyEventsResumed(): void {
    if (this.isOpen()) {
      this.$.dialog.focus();
    }

    // Show focus lost toast for 1 second after regaining focus.
    this.lostFocusToastLingerMs = TOAST_LINGER_MS;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'keyboard-tester': KeyboardTesterElement;
  }
}

customElements.define(KeyboardTesterElement.is, KeyboardTesterElement);