chromium/chrome/browser/resources/chromeos/accessibility/accessibility_common/facegaze/gesture_handler.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.

import {CustomCallbackMacro} from '/common/action_fulfillment/macros/custom_callback_macro.js';
import {KeyCombination, KeyPressMacro} from '/common/action_fulfillment/macros/key_press_macro.js';
import {Macro} from '/common/action_fulfillment/macros/macro.js';
import {MacroName} from '/common/action_fulfillment/macros/macro_names.js';
import {MouseClickLeftDoubleMacro, MouseClickMacro} from '/common/action_fulfillment/macros/mouse_click_macro.js';
import {ToggleDictationMacro} from '/common/action_fulfillment/macros/toggle_dictation_macro.js';
import {AsyncUtil} from '/common/async_util.js';
import {KeyCode} from '/common/key_code.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';
import type {FaceLandmarkerResult} from '/third_party/mediapipe/vision.js';

import {FacialGesture} from './facial_gestures.js';
import {GestureDetector} from './gesture_detector.js';
import {MouseScrollMacro} from './macros/mouse_scroll_macro.js';
import {ResetCursorMacro} from './macros/reset_cursor_macro.js';
import {MouseController} from './mouse_controller.js';

import RoleType = chrome.automation.RoleType;
import StateType = chrome.automation.StateType;

type AutomationNode = chrome.automation.AutomationNode;
type PrefObject = chrome.settingsPrivate.PrefObject;

/** Handles converting facial gestures to Macros. */
export class GestureHandler {
  private gesturesToKeyCombos_: Map<FacialGesture, KeyCombination> = new Map();
  private gestureToMacroName_: Map<FacialGesture, MacroName> = new Map();
  private gestureToConfidence_: Map<FacialGesture, number> = new Map();
  private gestureLastRecognized_: Map<FacialGesture, number> = new Map();
  private mouseController_: MouseController;
  private repeatDelayMs_ = GestureHandler.DEFAULT_REPEAT_DELAY_MS;
  private prefsListener_: (prefs: any) => void;
  private toggleInfoListener_: (enabled: boolean) => void;
  // The most recently detected gestures. We track this to know when a gesture
  // has ended.
  private previousGestures_: FacialGesture[] = [];
  private macrosToCompleteLater_: Map<FacialGesture, Macro> = new Map();
  private paused_ = false;

  constructor(mouseController: MouseController) {
    this.mouseController_ = mouseController;
    this.prefsListener_ = prefs => this.updateFromPrefs_(prefs);
    this.toggleInfoListener_ = enabled =>
        GestureDetector.toggleSendGestureDetectionInfo(enabled);
  }

  start(): void {
    this.paused_ = false;
    chrome.settingsPrivate.getAllPrefs(prefs => this.updateFromPrefs_(prefs));
    chrome.settingsPrivate.onPrefsChanged.addListener(this.prefsListener_);

    chrome.accessibilityPrivate.onToggleGestureInfoForSettings.addListener(
        this.toggleInfoListener_);
  }

  stop(): void {
    this.paused_ = false;
    chrome.settingsPrivate.onPrefsChanged.removeListener(this.prefsListener_);
    chrome.accessibilityPrivate.onToggleGestureInfoForSettings.removeListener(
        this.toggleInfoListener_);
    this.previousGestures_ = [];
    this.gestureLastRecognized_.clear();
    // Executing these macros clears their state, so that we aren't left in a
    // mouse down or key down state.
    this.macrosToCompleteLater_.forEach((macro) => {
      macro.run();
    });
    this.macrosToCompleteLater_.clear();
  }

  private updateFromPrefs_(prefs: PrefObject[]): void {
    prefs.forEach(pref => {
      switch (pref.key) {
        case GestureHandler.GESTURE_TO_MACRO_PREF:
          if (pref.value) {
            // Update the whole map from this preference.
            this.gestureToMacroName_.clear();
            if (Object.entries(pref.value).length === 0) {
              // TODO(b:361389043): Update this to default behavior.
              pref.value[FacialGesture.JAW_OPEN] = MacroName.MOUSE_CLICK_LEFT;
              pref.value[FacialGesture.BROW_INNER_UP] =
                  MacroName.MOUSE_CLICK_RIGHT;
              pref.value[FacialGesture.BROWS_DOWN] = MacroName.RESET_CURSOR;
            }
            for (const [gesture, assignedMacro] of Object.entries(pref.value)) {
              if (assignedMacro === MacroName.UNSPECIFIED) {
                continue;
              }
              this.gestureToMacroName_.set(
                  gesture as FacialGesture, Number(assignedMacro));
              // Ensure the confidence for this gesture is set to the default,
              // if it wasn't set yet. This might happen if the user hasn't
              // opened the settings subpage yet.
              if (!this.gestureToConfidence_.has(gesture as FacialGesture)) {
                this.gestureToConfidence_.set(
                    gesture as FacialGesture,
                    GestureHandler.DEFAULT_CONFIDENCE_THRESHOLD);
              }
            }
          }
          break;
        case GestureHandler.GESTURE_TO_CONFIDENCE_PREF:
          if (pref.value) {
            for (const [gesture, confidence] of Object.entries(pref.value)) {
              this.gestureToConfidence_.set(
                  gesture as FacialGesture, Number(confidence) / 100.);
            }
          }
          break;
        case GestureHandler.GESTURE_TO_KEY_COMBO_PREF:
          if (pref.value) {
            for (const [gesture, keyCombinationAsString] of Object.entries(
                     pref.value)) {
              const keyCombination =
                  JSON.parse(keyCombinationAsString as string);
              this.gesturesToKeyCombos_.set(
                  gesture as FacialGesture, keyCombination);
            }
          }
          break;
        default:
          return;
      }
    });
  }

  detectMacros(result: FaceLandmarkerResult): Macro[] {
    const gestures = GestureDetector.detect(result, this.gestureToConfidence_);
    const macros = this.gesturesToMacros_(gestures);
    macros.push(
        ...this.popMacrosOnGestureEnd(gestures, this.previousGestures_));
    this.previousGestures_ = gestures;
    return macros;
  }

  togglePaused(): void {
    const newPaused = !this.paused_;
    // Run start/stop before assigning the new pause value, since start/stop
    // will modify the pause value.
    newPaused ? this.stop() : this.start();
    this.paused_ = newPaused;
  }

  private gesturesToMacros_(gestures: FacialGesture[]): Macro[] {
    const macroNames: Map<MacroName, FacialGesture> = new Map();
    for (const gesture of gestures) {
      const currentTime = new Date().getTime();
      if (this.gestureLastRecognized_.has(gesture) &&
              currentTime - this.gestureLastRecognized_.get(gesture)! <
                  this.repeatDelayMs_ ||
          this.macrosToCompleteLater_.has(gesture)) {
        // Avoid responding to the same macro repeatedly in too short a time
        // or if we are still waiting to complete them later (they shouldn't be
        // repeated until completed).
        continue;
      }
      this.gestureLastRecognized_.set(gesture, currentTime);
      const name = this.gestureToMacroName_.get(gesture);
      if (name) {
        macroNames.set(name, gesture);
      }
    }

    // Construct macros from all the macro names.
    const result: Macro[] = [];
    for (const [macroName, gesture] of macroNames) {
      const macro = this.macroFromName_(macroName, gesture);
      if (macro) {
        if (macro instanceof MouseClickMacro) {
          // Don't add mouse click macros if we are in the middle of long click.
          if ([...this.macrosToCompleteLater_.values()].some(
                  (savedMacro: Macro) => savedMacro.getName() ===
                      MacroName.MOUSE_LONG_CLICK_LEFT)) {
            continue;
          }
        }
        result.push(macro);
        if (macro.triggersAtActionStartAndEnd()) {
          // Cache this macro to be run a second time later,
          // e.g. for the mouse or key release.
          this.macrosToCompleteLater_.set(gesture, macro);
        }
      }
    }

    return result;
  }

  /**
   * Gets the cached macros that are run again when a gesture ends. For example,
   * for a left click macro, the left click starts when the gesture is first
   * detected and the macro is run a second time when the gesture is no longer
   * detected, thus the click will be held as long as the gesture is still
   * detected.
   */
  private popMacrosOnGestureEnd(
      gestures: FacialGesture[], previousGestures: FacialGesture[]): Macro[] {
    const macrosForLater: Macro[] = [];
    previousGestures.forEach(previousGesture => {
      if (!gestures.includes(previousGesture)) {
        // The gesture has stopped being recognized. Run the second half of this
        // macro, and stop saving it.
        const macro = this.macrosToCompleteLater_.get(previousGesture);
        if (!macro) {
          return;
        }
        if (macro instanceof MouseClickMacro) {
          macro.updateLocation(this.mouseController_.mouseLocation());
        }
        macrosForLater.push(macro);
        this.macrosToCompleteLater_.delete(previousGesture);
      }
    });
    return macrosForLater;
  }

  private macroFromName_(name: MacroName, gesture: FacialGesture): Macro
      |undefined {
    if (this.paused_ && name !== MacroName.TOGGLE_FACEGAZE) {
      return;
    }

    switch (name) {
      case MacroName.TOGGLE_DICTATION:
        return new ToggleDictationMacro();
      case MacroName.MOUSE_CLICK_LEFT:
        return new MouseClickMacro(this.mouseController_.mouseLocation());
      case MacroName.MOUSE_CLICK_RIGHT:
        return new MouseClickMacro(
            this.mouseController_.mouseLocation(), /*leftClick=*/ false);
      case MacroName.MOUSE_LONG_CLICK_LEFT:
        return new MouseClickMacro(
            this.mouseController_.mouseLocation(), /*leftClick=*/ true,
            /*clickImmediately=*/ false);
      case MacroName.MOUSE_CLICK_LEFT_DOUBLE:
        return new MouseClickLeftDoubleMacro(
            this.mouseController_.mouseLocation());
      case MacroName.RESET_CURSOR:
        return new ResetCursorMacro(this.mouseController_);
      case MacroName.KEY_PRESS_SPACE:
        return new KeyPressMacro(name, {key: KeyCode.SPACE});
      case MacroName.KEY_PRESS_DOWN:
        return new KeyPressMacro(name, {key: KeyCode.DOWN});
      case MacroName.KEY_PRESS_LEFT:
        return new KeyPressMacro(name, {key: KeyCode.LEFT});
      case MacroName.KEY_PRESS_RIGHT:
        return new KeyPressMacro(name, {key: KeyCode.RIGHT});
      case MacroName.KEY_PRESS_UP:
        return new KeyPressMacro(name, {key: KeyCode.UP});
      case MacroName.KEY_PRESS_TOGGLE_OVERVIEW:
        // The MEDIA_LAUNCH_APP1 key is bound to the kToggleOverview accelerator
        // action in accelerators.cc.
        return new KeyPressMacro(name, {key: KeyCode.MEDIA_LAUNCH_APP1});
      case MacroName.KEY_PRESS_MEDIA_PLAY_PAUSE:
        return new KeyPressMacro(name, {key: KeyCode.MEDIA_PLAY_PAUSE});
      case MacroName.OPEN_FACEGAZE_SETTINGS:
        return new CustomCallbackMacro(MacroName.OPEN_FACEGAZE_SETTINGS, () => {
          chrome.accessibilityPrivate.openSettingsSubpage(
              GestureHandler.SETTINGS_PATH);
        });
      case MacroName.TOGGLE_FACEGAZE:
        return new CustomCallbackMacro(MacroName.TOGGLE_FACEGAZE, () => {
          this.mouseController_.togglePaused();
          this.togglePaused();
        });
      case MacroName.TOGGLE_SCROLL_MODE:
        return new MouseScrollMacro(this.mouseController_);
      case MacroName.TOGGLE_VIRTUAL_KEYBOARD:
        return new CustomCallbackMacro(
            MacroName.TOGGLE_VIRTUAL_KEYBOARD, async () => {
              // TODO(b/355662617): Unify with SwitchAccessPredicate.
              const isVisible = (node: AutomationNode): boolean => {
                return Boolean(
                    !node.state![StateType.OFFSCREEN] && node.location &&
                    node.location.top >= 0 && node.location.left >= 0 &&
                    !node.state![StateType.INVISIBLE]);
              };

              const desktop = await AsyncUtil.getDesktop();
              const keyboard = desktop.find({role: RoleType.KEYBOARD});
              const currentlyVisible = Boolean(
                  keyboard && isVisible(keyboard) &&
                  keyboard.find({role: RoleType.ROOT_WEB_AREA}));
              // Toggle the visibility of the virtual keyboard.
              chrome.accessibilityPrivate.setVirtualKeyboardVisible(
                  !currentlyVisible);
            });
      case MacroName.CUSTOM_KEY_COMBINATION:
        const keyCombination = this.gesturesToKeyCombos_.get(gesture);
        if (!keyCombination) {
          throw new Error(
              `Expected a custom key combination for gesture: ${gesture}`);
        }

        return new KeyPressMacro(name, keyCombination);
      default:
        return;
    }
  }
}

export namespace GestureHandler {
  /** The default confidence threshold for facial gestures. */
  export const DEFAULT_CONFIDENCE_THRESHOLD = 0.5;

  /** Minimum repeat rate of a gesture. */
  // TODO(b:322511275): Move to a pref in settings.
  export const DEFAULT_REPEAT_DELAY_MS = 500;

  export const GESTURE_TO_KEY_COMBO_PREF =
      'settings.a11y.face_gaze.gestures_to_key_combos';

  /**
   * Pref name of preference mapping facegaze gestures to macro action names.
   */
  export const GESTURE_TO_MACRO_PREF =
      'settings.a11y.face_gaze.gestures_to_macros';

  export const GESTURE_TO_CONFIDENCE_PREF =
      'settings.a11y.face_gaze.gestures_to_confidence';

  export const SETTINGS_PATH = 'manageAccessibility/faceGaze';
}

TestImportManager.exportForTesting(GestureHandler);