chromium/chrome/browser/resources/ash/settings/os_a11y_page/facegaze_actions_add_dialog.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-add-dialog' is a dialog for
 * adding an action to FaceGaze.
 */
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_slider/cr_slider.js';
import 'chrome://resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import 'chrome://resources/polymer/v3_0/paper-ripple/paper-ripple.js';
import '../settings_shared.css.js';

import {CrSliderElement} from '//resources/ash/common/cr_elements/cr_slider/cr_slider.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 {CrDialogElement} from 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import {CrScrollableMixin} from 'chrome://resources/ash/common/cr_elements/cr_scrollable_mixin.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 {assert} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './facegaze_actions_add_dialog.html.js';
import {FACE_GAZE_GESTURE_TO_CONFIDENCE_PREF, FACE_GAZE_GESTURE_TO_CONFIDENCE_PREF_DICT, FACEGAZE_COMMAND_PAIR_ADDED_EVENT_NAME, FaceGazeActions, FaceGazeCommandPair, FaceGazeGestures, FaceGazeUtils} from './facegaze_constants.js';
import {FaceGazeSubpageBrowserProxy, FaceGazeSubpageBrowserProxyImpl} from './facegaze_subpage_browser_proxy.js';

export interface FaceGazeAddActionDialogElement {
  $: {
    dialog: CrDialogElement,
  };
}

export enum AddDialogPage {
  SELECT_ACTION = 0,
  SELECT_GESTURE = 1,
  GESTURE_THRESHOLD = 2,
}

export enum Navigation {
  PREVIOUS = 0,
  NEXT = 1,
}

export class PageNavigation {
  previous?: AddDialogPage;
  next?: AddDialogPage;
}

export class FaceGazeGestureConfidence {
  gesture: FacialGesture;
  confidence: number;
}

export const FACEGAZE_CONFIDENCE_DEFAULT = 60;
export const FACEGAZE_CONFIDENCE_MIN = 1;
export const FACEGAZE_CONFIDENCE_MAX = 100;
export const FACEGAZE_CONFIDENCE_BUTTON_STEP = 5;

const FaceGazeAddActionDialogElementBase = PrefsMixin(
    I18nMixin(CrScrollableMixin(WebUiListenerMixin(PolymerElement))));

export class FaceGazeAddActionDialogElement extends
    FaceGazeAddActionDialogElementBase {
  static get is() {
    return 'facegaze-actions-add-dialog' as const;
  }

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

  static get properties() {
    return {
      currentPage_: {
        type: Number,
        observer: 'currentPageChanged_',
      },

      initialPage: {
        type: Object,
        observer: 'initialPageChanged_',
      },

      actionToAssignGesture: {
        type: Object,
        observer: 'actionToAssignGestureChanged_',
      },

      gestureToConfigure: {
        type: Object,
        observer: 'gestureToConfigureChanged_',
      },

      leftClickGestures: {
        type: Array,
        value: () => [],
        observer: 'leftClickGesturesChanged_',
      },

      showSelectAction_: {
        type: Boolean,
        computed: 'shouldShowSelectAction_(currentPage_)',
      },

      showSelectGesture_: {
        type: Boolean,
        computed: 'shouldShowSelectGesture_(currentPage_)',
      },

      showGestureThreshold_: {
        type: Boolean,
        computed: 'shouldShowGestureThreshold_(currentPage_)',
      },

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

      selectedAction_: {
        type: Object,
        value: null,
      },

      localizedSelectGestureTitle_: {
        type: String,
        computed: 'getLocalizedSelectGestureTitle_(selectedAction_)',
      },

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

      selectedGesture_: {
        type: Object,
        value: null,
        observer: 'onSelectedGestureChanged_',
      },

      localizedGestureThresholdTitle_: {
        type: String,
        computed: 'getLocalizedGestureThresholdTitle_(selectedGesture_)',
      },

      localizedGestureCountLabel_: {
        type: String,
        computed: 'getLocalizedGestureCountLabel_(detectedGestureCount_)',
      },

      gestureThresholdValue_: {
        type: Number,
        observer: 'onGestureThresholdChanged_',
      },

      detectedGestureCount_: {
        type: Number,
      },

      disableActionNextButton_: {
        type: Boolean,
        computed: 'shouldDisableActionNextButton_(selectedAction_)',
      },

      disableGestureNextButton_: {
        type: Boolean,
        computed: 'shouldDisableGestureNextButton_(selectedGesture_)',
      },

      displayGesturePreviousButton_: {
        type: Boolean,
        computed: 'shouldDisplayGesturePreviousButton_(initialPage)',
      },

      displayThresholdPreviousButton_: {
        type: Boolean,
        computed: 'shouldDisplayThresholdPreviousButton_(initialPage)',
      },
    };
  }

  actionToAssignGesture: MacroName|null = null;
  initialPage: AddDialogPage = AddDialogPage.SELECT_ACTION;
  gestureToConfigure: FacialGesture|null = null;
  leftClickGestures: FacialGesture[] = [];

  // Internal state.
  private selectedAction_: MacroName|null = null;
  private selectedGesture_: FacialGesture|null = null;
  private gestureThresholdValue_: number;
  private currentPage_: AddDialogPage = AddDialogPage.SELECT_ACTION;
  private pageNavigation_: Record<AddDialogPage, PageNavigation> = {
    [AddDialogPage.SELECT_ACTION]: {
      next: AddDialogPage.SELECT_GESTURE,
    },
    [AddDialogPage.SELECT_GESTURE]: {
      previous: AddDialogPage.SELECT_ACTION,
      next: AddDialogPage.GESTURE_THRESHOLD,
    },
    [AddDialogPage.GESTURE_THRESHOLD]: {
      previous: AddDialogPage.SELECT_GESTURE,
    },
  };
  private detectedGestureCount_ = 0;

  // Computed properties.
  private displayedActions_: MacroName[] = FaceGazeActions;
  private displayedGestures_: FacialGesture[] = FaceGazeGestures;

  private faceGazeSubpageBrowserProxy_: FaceGazeSubpageBrowserProxy;

  constructor() {
    super();
    this.faceGazeSubpageBrowserProxy_ =
        FaceGazeSubpageBrowserProxyImpl.getInstance();
    this.addWebUiListener(
        'settings.sendGestureInfoToSettings',
        (gestureConfidences: FaceGazeGestureConfidence[]) =>
            this.onGestureConfidencesReceived_(gestureConfidences));
  }

  private getItemClass_(selected: boolean): 'selected'|'' {
    return selected ? 'selected' : '';
  }

  private getLocalizedSelectGestureTitle_(): string {
    return this.i18n(
        'faceGazeActionsDialogSelectGestureTitle',
        this.selectedAction_ ?
            FaceGazeUtils.getMacroDisplayText(this.selectedAction_) :
            '');
  }

  private getLocalizedGestureThresholdTitle_(): string {
    return this.i18n(
        'faceGazeActionsDialogGestureThresholdTitle',
        this.selectedGesture_ ?
            FaceGazeUtils.getGestureDisplayText(this.selectedGesture_) :
            '');
  }

  private getLocalizedGestureCountLabel_(): string {
    if (this.detectedGestureCount_ === 0) {
      return this.i18n('faceGazeActionsDialogGestureNotDetectedLabel');
    } else if (this.detectedGestureCount_ === 1) {
      return this.i18n('faceGazeActionsDialogGestureDetectedCountOneLabel');
    } else {
      return this.i18n(
          'faceGazeActionsDialogGestureDetectedCountLabel',
          this.detectedGestureCount_);
    }
  }

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

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

  // Dialog page navigation.
  private shouldShowSelectAction_(): boolean {
    return this.currentPage_ === AddDialogPage.SELECT_ACTION;
  }

  private shouldShowSelectGesture_(): boolean {
    return this.currentPage_ === AddDialogPage.SELECT_GESTURE;
  }

  private shouldShowGestureThreshold_(): boolean {
    return this.currentPage_ === AddDialogPage.GESTURE_THRESHOLD;
  }

  // Disable navigation buttons.
  private shouldDisableActionNextButton_(): boolean {
    return this.selectedAction_ === null;
  }

  private shouldDisableGestureNextButton_(): boolean {
    return this.selectedGesture_ === null;
  }

  private shouldDisplayGesturePreviousButton_(): boolean {
    // Only show the previous button on the gesture page if we are starting from
    // the beginning of the dialog flow.
    return this.initialPage === AddDialogPage.SELECT_ACTION;
  }

  private shouldDisplayThresholdPreviousButton_(): boolean {
    // Only show the previous button on the threshold page if we are starting
    // from an earlier dialog flow.
    return this.initialPage !== AddDialogPage.GESTURE_THRESHOLD;
  }

  // Dialog navigation button event handlers.
  private onPreviousButtonClick_(): void {
    this.onNavigateButtonClick_(Navigation.PREVIOUS);
  }

  private onNextButtonClick_(): void {
    this.onNavigateButtonClick_(Navigation.NEXT);
  }

  private onNavigateButtonClick_(direction: Navigation): void {
    const newPage = direction === Navigation.PREVIOUS ?
        this.pageNavigation_[this.currentPage_].previous :
        this.pageNavigation_[this.currentPage_].next;

    if (newPage === undefined) {
      // This should never happen.
      throw new Error(`Tried to navigate in ${
          direction.toString()} direction from page index ${
          this.currentPage_.toString()}`);
    }

    this.currentPage_ = newPage;
  }

  private onCancelButtonClick_(): void {
    this.close_();
  }

  private onSaveButtonClick_(): void {
    if (!this.selectedAction_ || !this.selectedGesture_) {
      console.error(
          'FaceGaze Add Dialog clicked save button but no action/gesture pair selected. Closing dialog.');
      this.close_();
      return;
    }

    const commandPair =
        new FaceGazeCommandPair(this.selectedAction_, this.selectedGesture_);
    const event = new CustomEvent(FACEGAZE_COMMAND_PAIR_ADDED_EVENT_NAME, {
      bubbles: true,
      composed: true,
      detail: commandPair,
    });
    this.dispatchEvent(event);

    this.setPrefDictEntry(
        FACE_GAZE_GESTURE_TO_CONFIDENCE_PREF_DICT, this.selectedGesture_,
        Math.round(this.getThresholdSlider().value));

    this.close_();
  }

  private currentPageChanged_(newPage: AddDialogPage, oldPage: AddDialogPage):
      void {
    // Only toggle request for gesture information on if we are on the gesture
    // threshold page, which requires information about detected gestures,
    // or toggle request off if we are switching away from the gesture
    // threshold page.
    if (newPage === AddDialogPage.GESTURE_THRESHOLD ||
        oldPage === AddDialogPage.GESTURE_THRESHOLD) {
      this.faceGazeSubpageBrowserProxy_.toggleGestureInfoForSettings(
          newPage === AddDialogPage.GESTURE_THRESHOLD);
    }
  }

  // Handlers for initial state.
  private initialPageChanged_(page: AddDialogPage): void {
    this.currentPage_ = page;
  }

  private actionToAssignGestureChanged_(newValue: MacroName|null): void {
    if (!newValue) {
      return;
    }

    this.selectedAction_ = newValue;
  }

  private gestureToConfigureChanged_(newValue: FacialGesture|null): void {
    if (!newValue) {
      return;
    }

    this.selectedGesture_ = newValue;
  }

  // If left-click action is assigned to a singular gesture then remove it
  // from the list of available gestures to avoid losing left click
  // functionality.
  private leftClickGesturesChanged_(leftClickGestures: FacialGesture[]): void {
    if (leftClickGestures.length === 1) {
      this.displayedGestures_ =
          this.displayedGestures_.filter((gesture: FacialGesture) => {
            return leftClickGestures[0] !== gesture;
          });
    }
  }

  private onDecreaseThresholdButtonClick_(): void {
    this.gestureThresholdValue_ = Math.max(
        FACEGAZE_CONFIDENCE_MIN,
        this.gestureThresholdValue_ - FACEGAZE_CONFIDENCE_BUTTON_STEP);
  }

  private onIncreaseThresholdButtonClick_(): void {
    this.gestureThresholdValue_ = Math.min(
        FACEGAZE_CONFIDENCE_MAX,
        this.gestureThresholdValue_ + FACEGAZE_CONFIDENCE_BUTTON_STEP);
  }

  private getThresholdSlider(): CrSliderElement {
    const slider = this.shadowRoot?.querySelector<CrSliderElement>(
        '#faceGazeGestureThresholdSlider');
    assert(slider);
    return slider;
  }

  private onThresholdSliderChanged_(): void {
    this.gestureThresholdValue_ = this.getThresholdSlider().value;
  }

  private onGestureConfidencesReceived_(info: FaceGazeGestureConfidence[]):
      void {
    if (!this.selectedGesture_ ||
        this.currentPage_ !== AddDialogPage.GESTURE_THRESHOLD) {
      return;
    }

    info.forEach((entry) => {
      if (entry.gesture === this.selectedGesture_) {
        if (entry.confidence >= this.gestureThresholdValue_) {
          this.detectedGestureCount_++;
        }

        // Show confidence values for all gestures in dynamic bar.
        const slider = this.getThresholdSlider();
        const sliderBar = slider.shadowRoot!.querySelector<HTMLElement>('#bar');
        assert(sliderBar);
        sliderBar.style.width = `${entry.confidence}%`;
      }
    });
  }

  private onSelectedGestureChanged_(): void {
    this.detectedGestureCount_ = 0;
    this.gestureThresholdValue_ = FACEGAZE_CONFIDENCE_DEFAULT;
    const gesturesToConfidence = this.get(FACE_GAZE_GESTURE_TO_CONFIDENCE_PREF);

    if (this.selectedGesture_ &&
        this.selectedGesture_ in gesturesToConfidence) {
      this.gestureThresholdValue_ = gesturesToConfidence[this.selectedGesture_];
    }
  }

  private onGestureThresholdChanged_(): void {
    this.detectedGestureCount_ = 0;
  }

  private close_(): void {
    this.faceGazeSubpageBrowserProxy_.toggleGestureInfoForSettings(false);
    this.$.dialog.close();
  }

  private onKeydown_(e: KeyboardEvent): void {
    // Close dialog if 'esc' is pressed.
    if (e.key === 'Escape') {
      this.close_();
    }
  }

  getCurrentPageForTest(): AddDialogPage {
    return this.currentPage_;
  }
}

customElements.define(
    FaceGazeAddActionDialogElement.is, FaceGazeAddActionDialogElement);

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

  interface HTMLElementEventMap {
    [FACEGAZE_COMMAND_PAIR_ADDED_EVENT_NAME]: CustomEvent<FaceGazeCommandPair>;
  }
}