chromium/chrome/browser/resources/ash/settings/os_a11y_page/facegaze_actions_card.ts

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

/**
 * @fileoverview
 * 'facegaze-actions-card' is the card element containing facegaze
 *  action settings.
 */

import '../controls/settings_dropdown_menu.js';
import '../os_settings_page/settings_card.js';
import '../settings_shared.css.js';
import '../os_settings_page/os_settings_animated_pages.js';
import 'chrome://resources/cros_components/chip/chip.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {FacialGesture} from 'chrome://resources/ash/common/accessibility/facial_gestures.js';
import {MacroName} from 'chrome://resources/ash/common/accessibility/macro_names.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 {DomRepeatEvent, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {DeepLinkingMixin} from '../common/deep_linking_mixin.js';
import {RouteObserverMixin} from '../common/route_observer_mixin.js';
import {Route, routes} from '../router.js';

import {AddDialogPage} from './facegaze_actions_add_dialog.js';
import {getTemplate} from './facegaze_actions_card.html.js';
import {FACE_GAZE_GESTURE_TO_MACROS_PREF, FACEGAZE_ACTION_ASSIGN_GESTURE_EVENT_NAME, FACEGAZE_COMMAND_PAIR_ADDED_EVENT_NAME, FaceGazeCommandPair, FaceGazeUtils} from './facegaze_constants.js';

const FaceGazeActionsCardElementBase = DeepLinkingMixin(RouteObserverMixin(
    WebUiListenerMixin(PrefsMixin(I18nMixin(PolymerElement)))));

export interface FaceGazeActionsCardElement {
  $: {};
}

export class FaceGazeActionsCardElement extends FaceGazeActionsCardElementBase {
  static readonly FACEGAZE_COMMAND_PAIRS_PROPERTY_NAME =
      'commandPairs_' as const;
  disabled: boolean;

  private showAddActionDialog_: boolean;
  private leftClickGestures_: FacialGesture[] = [];
  private dialogPageToShow_: AddDialogPage;
  private actionToAssignGesture_: MacroName|null = null;
  private gestureToConfigure_: FacialGesture|null = null;

  // This should be kept in sync with the pref with all interactions.
  private commandPairs_: FaceGazeCommandPair[] = [];

  static get is() {
    return 'facegaze-actions-card' as const;
  }

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

  static get properties() {
    return {
      disabled: {
        type: Boolean,
      },

      disableConfigureControls_: {
        type: Boolean,
        computed:
            'shouldDisableConfigureControls_(disabled, prefs.settings.a11y.face_gaze.actions_enabled.value)',
      },

      commandPairs_: {
        type: Array,
        value: () => [],
      },

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

      leftClickGestures_: {
        type: Array,
        value: () => [],
      },

      dialogPageToShow_: {
        type: Number,
      },

      actionToAssignGesture_: {
        type: Object,
      },

      gestureToConfigure_: {
        type: Object,
      },
    };
  }

  override ready(): void {
    super.ready();
    this.updateConfiguredCommandPairsFromPrefs_();
  }

  override currentRouteChanged(route: Route): void {
    // Does not apply to this page.
    if (route !== routes.MANAGE_FACEGAZE_SETTINGS) {
      return;
    }

    this.attemptDeepLink();
  }

  private getCurrentAssignedGestures_(): Record<FacialGesture, MacroName> {
    return {...this.get(FACE_GAZE_GESTURE_TO_MACROS_PREF)} as
        Record<FacialGesture, MacroName>;
  }

  private shouldDisableConfigureControls_(): boolean {
    return this.disabled ||
        !this.getPref('settings.a11y.face_gaze.actions_enabled').value;
  }

  private onAddActionButtonClick_(): void {
    this.dialogPageToShow_ = AddDialogPage.SELECT_ACTION;
    this.leftClickGestures_ = this.computeLeftClickGestures_();
    this.showAddActionDialog_ = true;
  }

  private onAddActionDialogClose_(): void {
    this.showAddActionDialog_ = false;
    this.actionToAssignGesture_ = null;
    this.gestureToConfigure_ = null;
  }

  private onConfigureGestureButtonClick_(
      e: DomRepeatEvent<FaceGazeCommandPair>): void {
    this.dialogPageToShow_ = AddDialogPage.GESTURE_THRESHOLD;
    this.gestureToConfigure_ = e.model.item.gesture;
    this.showAddActionDialog_ = true;
  }

  private onAssignGestureButtonClick_(e: DomRepeatEvent<FaceGazeCommandPair>):
      void {
    this.dialogPageToShow_ = AddDialogPage.SELECT_GESTURE;
    this.leftClickGestures_ = this.computeLeftClickGestures_();
    this.actionToAssignGesture_ = e.model.item.action;
    this.showAddActionDialog_ = true;
  }

  private getActionDisplayText_(action: MacroName): string {
    return FaceGazeUtils.getMacroDisplayText(action);
  }

  private getSubLabel_(action: MacroName): string|null {
    return FaceGazeUtils.getMacroDisplaySubLabel(action);
  }

  private getGestureDisplayText_(gesture: FacialGesture|null): string {
    return FaceGazeUtils.getGestureDisplayText(gesture);
  }

  // When an action is removed from the list, update the pref and then update
  // the UI accordingly.
  private onRemoveCommandPairButtonClick_(
      e: DomRepeatEvent<FaceGazeCommandPair>): void {
    const removedCommandPair: FaceGazeCommandPair = e.model.item;
    this.updatePrefWithRemovedCommandPair_(removedCommandPair);

    const removeIndex = this.commandPairs_.findIndex(
        (item: FaceGazeCommandPair) => item.equals(removedCommandPair));
    this.splice(
        FaceGazeActionsCardElement.FACEGAZE_COMMAND_PAIRS_PROPERTY_NAME,
        removeIndex, 1);
  }

  private updatePrefWithRemovedCommandPair_(removedCommandPair:
                                                FaceGazeCommandPair): void {
    // Get current assigned gestures to macros.
    const assignedGestures = this.getCurrentAssignedGestures_();

    for (const [currentGesture, assignedMacro] of Object.entries(
             assignedGestures)) {
      if (removedCommandPair.equals(new FaceGazeCommandPair(
              assignedMacro, currentGesture as FacialGesture))) {
        assignedGestures[currentGesture as FacialGesture] =
            MacroName.UNSPECIFIED;
        break;
      }
    }

    this.set(FACE_GAZE_GESTURE_TO_MACROS_PREF, assignedGestures);
  }

  // When an action is added from the dialog, update the pref and then update
  // the UI accordingly.
  private onCommandPairAdded_(
      e: HTMLElementEventMap[typeof FACEGAZE_COMMAND_PAIR_ADDED_EVENT_NAME]):
      void {
    const newCommandPair = e.detail;
    this.updatePrefWithAddedCommandPair_(newCommandPair);

    // If gesture is already mapped to another action, remove that pairing as
    // gesture can only be mapped to one action.
    const unassignIndex = this.commandPairs_.findIndex(
        (item) => item.gesture === newCommandPair.gesture &&
            item.action !== newCommandPair.action);

    if (unassignIndex >= 0) {
      // Update configuration and notify Polymer.
      this.commandPairs_[unassignIndex].gesture = null;
      this.notifyPath(
          `${FaceGazeActionsCardElement.FACEGAZE_COMMAND_PAIRS_PROPERTY_NAME}.${
              unassignIndex}.gesture`);
    }

    if (this.dialogPageToShow_ === AddDialogPage.SELECT_GESTURE) {
      // Update an existing row for the action if coming from the Assign a
      // Gesture page.
      const updateIndex = this.commandPairs_.findIndex(
          (item: FaceGazeCommandPair) =>
              item.action === newCommandPair.action && item.gesture === null);
      if (updateIndex > -1) {
        // Update configuration and notify Polymer.
        this.commandPairs_[updateIndex].gesture = newCommandPair.gesture;
        this.notifyPath(`${
            FaceGazeActionsCardElement.FACEGAZE_COMMAND_PAIRS_PROPERTY_NAME}.${
            updateIndex}.gesture`);
      }
    } else {
      const updateIndex = this.commandPairs_.findIndex(
          (item: FaceGazeCommandPair) => item.equals(newCommandPair));
      if (updateIndex < 0) {
        // Add new gesture/action pairing if it does not already exist.
        this.push(
            FaceGazeActionsCardElement.FACEGAZE_COMMAND_PAIRS_PROPERTY_NAME,
            newCommandPair);
      }
    }
  }

  private updatePrefWithAddedCommandPair_(newCommandPair: FaceGazeCommandPair):
      void {
    if (!newCommandPair.gesture) {
      console.error(
          'FaceGaze added action with no valid gesture value: ' +
          this.getActionDisplayText_(newCommandPair.action));
      return;
    }

    // Get current assigned gestures to macros.
    const assignedGestures = this.getCurrentAssignedGestures_();

    // If the current gesture is already set to a macro, clear out the macro for
    // the gesture.
    let alreadySet = false;
    for (const [currentGesture, assignedMacro] of Object.entries(
             assignedGestures)) {
      if (newCommandPair.gesture === currentGesture) {
        if (newCommandPair.action === assignedMacro) {
          alreadySet = true;
          break;
        }

        assignedGestures[currentGesture] = MacroName.UNSPECIFIED;
        break;
      }
    }

    if (!alreadySet) {
      assignedGestures[newCommandPair.gesture] = newCommandPair.action;
    }

    this.set(FACE_GAZE_GESTURE_TO_MACROS_PREF, assignedGestures);
  }

  private updateConfiguredCommandPairsFromPrefs_(): void {
    // Get current assigned gestures to macros.
    const assignedGestures = this.getCurrentAssignedGestures_();

    for (const [currentGesture, assignedMacro] of Object.entries(
             assignedGestures)) {
      if (assignedMacro !== MacroName.UNSPECIFIED) {
        this.push(
            FaceGazeActionsCardElement.FACEGAZE_COMMAND_PAIRS_PROPERTY_NAME,
            new FaceGazeCommandPair(
                assignedMacro, currentGesture as FacialGesture));
      }
    }
  }

  private computeLeftClickGestures_(): FacialGesture[] {
    const gestures: FacialGesture[] = [];
    this.commandPairs_.forEach((commandPair: FaceGazeCommandPair) => {
      if (commandPair.action === MacroName.MOUSE_CLICK_LEFT &&
          commandPair.gesture !== null) {
        gestures.push(commandPair.gesture);
      }
    });

    return gestures;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [FaceGazeActionsCardElement.is]: FaceGazeActionsCardElement;
  }

  interface HTMLElementEventMap {
    [FACEGAZE_ACTION_ASSIGN_GESTURE_EVENT_NAME]: CustomEvent<MacroName>;
  }
}

customElements.define(
    FaceGazeActionsCardElement.is, FaceGazeActionsCardElement);