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

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

/**
 * @fileoverview
 * 'settings-cursor-and-touchpad-page' is the accessibility settings subpage
 * for cursor and touchpad accessibility settings.
 */

import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import '//resources/polymer/v3_0/iron-icon/iron-icon.js';
import '//resources/ash/common/cr_elements/cr_toggle/cr_toggle.js';
import '../controls/settings_slider.js';
import '../controls/settings_toggle_button.js';
import '../settings_shared.css.js';
import 'chrome://resources/ash/common/cr_elements/localized_link/localized_link.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {CrLinkRowElement} from 'chrome://resources/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import {SliderTick} from 'chrome://resources/ash/common/cr_elements/cr_slider/cr_slider.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 {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {DeepLinkingMixin} from '../common/deep_linking_mixin.js';
import {RouteOriginMixin} from '../common/route_origin_mixin.js';
import {SettingsToggleButtonElement} from '../controls/settings_toggle_button.js';
import {DevicePageBrowserProxy, DevicePageBrowserProxyImpl} from '../device_page/device_page_browser_proxy.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Route, Router, routes} from '../router.js';

import {getTemplate} from './cursor_and_touchpad_page.html.js';
import {CursorAndTouchpadPageBrowserProxy, CursorAndTouchpadPageBrowserProxyImpl} from './cursor_and_touchpad_page_browser_proxy.js';

const DEFAULT_BLACK_CURSOR_COLOR = 0;

interface Option {
  name: string;
  value: number;
}

interface SliderData {
  min: number;
  max: number;
  step: number;
  defaultValue: number;
}

interface TickData {
  tick: number;
  percent: number;
  defaultValue: number;
}

export interface SettingsCursorAndTouchpadPageElement {
  $: {
    pointerSubpageButton: CrLinkRowElement,
  };
}

const SettingsCursorAndTouchpadPageElementBase =
    DeepLinkingMixin(RouteOriginMixin(
        PrefsMixin(WebUiListenerMixin(I18nMixin(PolymerElement)))));

export class SettingsCursorAndTouchpadPageElement extends
    SettingsCursorAndTouchpadPageElementBase {
  static get is() {
    return 'settings-cursor-and-touchpad-page';
  }

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

  static get properties() {
    return {
      /**
       * Drop down menu options for auto click delay.
       */
      autoClickDelayOptions_: {
        readOnly: true,
        type: Array,
        value() {
          // These values correspond to the i18n values in
          // settings_strings.grdp. If these values get changed then those
          // strings need to be changed as well.
          return [
            {
              value: 600,
              name: loadTimeData.getString('delayBeforeClickExtremelyShort'),
            },
            {
              value: 800,
              name: loadTimeData.getString('delayBeforeClickVeryShort'),
            },
            {
              value: 1000,
              name: loadTimeData.getString('delayBeforeClickShort'),
            },
            {value: 2000, name: loadTimeData.getString('delayBeforeClickLong')},
            {
              value: 4000,
              name: loadTimeData.getString('delayBeforeClickVeryLong'),
            },
          ];
        },
      },

      /**
       * Drop down menu options for auto click movement threshold.
       */
      autoClickMovementThresholdOptions_: {
        readOnly: true,
        type: Array,
        value() {
          return [
            {
              value: 5,
              name: loadTimeData.getString(
                  'autoclickMovementThresholdExtraSmall'),
            },
            {
              value: 10,
              name: loadTimeData.getString('autoclickMovementThresholdSmall'),
            },
            {
              value: 20,
              name: loadTimeData.getString('autoclickMovementThresholdDefault'),
            },
            {
              value: 30,
              name: loadTimeData.getString('autoclickMovementThresholdLarge'),
            },
            {
              value: 40,
              name: loadTimeData.getString(
                  'autoclickMovementThresholdExtraLarge'),
            },
          ];
        },
      },

      cursorColorOptions_: {
        readOnly: true,
        type: Array,
        value() {
          return [
            {
              value: DEFAULT_BLACK_CURSOR_COLOR,
              name: loadTimeData.getString('cursorColorBlack'),
            },
            {
              value: 0xd93025,  // Red 600
              name: loadTimeData.getString('cursorColorRed'),
            },
            {
              value: 0xf29900,  //  Yellow 700
              name: loadTimeData.getString('cursorColorYellow'),
            },
            {
              value: 0x1e8e3e,  // Green 600
              name: loadTimeData.getString('cursorColorGreen'),
            },
            {
              value: 0x03b6be,  // Cyan 600
              name: loadTimeData.getString('cursorColorCyan'),
            },
            {
              value: 0x1a73e8,  // Blue 600
              name: loadTimeData.getString('cursorColorBlue'),
            },
            {
              value: 0xc61ad9,  // Magenta 600
              name: loadTimeData.getString('cursorColorMagenta'),
            },
            {
              value: 0xf50057,  // Pink A400
              name: loadTimeData.getString('cursorColorPink'),
            },

          ];
        },
      },

      disableTrackpadOptions_: {
        readOnly: true,
        type: Array,
        value() {
          return [
            {value: 0, name: loadTimeData.getString('disableTrackpadNever')},
            {value: 1, name: loadTimeData.getString('disableTrackpadAlways')},
            {
              value: 2,
              name: loadTimeData.getString('disableTrackpadMouseConnected'),
            },
          ];
        },
      },

      mouseKeysDominantHandOptions_: {
        readOnly: true,
        type: Array,
        value() {
          // These values correspond to the values of MouseKeysDominantHand in
          // ash/public/cpp/accessibility_controller_enums.h
          // If these values get changed then this needs to be updated as well.
          return [
            {value: 1, name: loadTimeData.getString('mouseKeysLeftHand')},
            {value: 0, name: loadTimeData.getString('mouseKeysRightHand')},
          ];
        },
      },

      /**
       * Whether the user is in kiosk mode.
       */
      isKioskModeActive_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isKioskModeActive');
        },
      },

      /**
       * Whether a setting for enabling shelf navigation buttons in tablet mode
       * should be displayed in the accessibility settings.
       */
      showShelfNavigationButtonsSettings_: {
        type: Boolean,
        computed:
            'computeShowShelfNavigationButtonsSettings_(isKioskModeActive_)',
      },

      /**
       * Boolean indicating whether shelf navigation buttons should implicitly
       * be enabled in tablet mode - the navigation buttons are implicitly
       * enabled when spoken feedback, automatic clicks, or switch access are
       * enabled. The buttons can also be explicitly enabled by a designated
       * a11y setting.
       */
      shelfNavigationButtonsImplicitlyEnabled_: {
        type: Boolean,
        computed: 'computeShelfNavigationButtonsImplicitlyEnabled_(' +
            'prefs.settings.accessibility.value,' +
            'prefs.settings.a11y.autoclick.value,' +
            'prefs.settings.a11y.switch_access.enabled.value)',
      },

      /**
       * The effective pref value that indicates whether shelf navigation
       * buttons are enabled in tablet mode.
       */
      shelfNavigationButtonsPref_: {
        type: Object,
        computed: 'getShelfNavigationButtonsEnabledPref_(' +
            'shelfNavigationButtonsImplicitlyEnabled_,' +
            'prefs.settings.a11y.tablet_mode_shelf_nav_buttons_enabled)',
      },

      /**
       * Whether the controlling the mouse cursor with the keyboard feature is
       * enabled.
       */
      isAccessibilityDisableTrackpadEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean(
              'isAccessibilityDisableTrackpadEnabled');
        },
      },

      /**
       * Whether the face movements mouse cursor and keyboard control feature is
       * enabled.
       */
      isAccessibilityFaceGazeEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isAccessibilityFaceGazeEnabled');
        },
      },

      /**
       * Whether the controlling the mouse cursor with the keyboard feature is
       * enabled.
       */
      isAccessibilityMouseKeysEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isAccessibilityMouseKeysEnabled');
        },
      },

      /**
       * Whether to show the overscroll history navigation setting.
       */
      isAccessibilityOverscrollSettingFeatureEnabled_: {
        type: Boolean,
        value: () => {
          return loadTimeData.getBoolean(
              'isAccessibilityOverscrollSettingFeatureEnabled');
        },
      },

      /**
       * Used by DeepLinkingMixin to focus this page's deep links.
       */
      supportedSettingIds: {
        type: Object,
        value: () => new Set<Setting>([
          Setting.kAutoClickWhenCursorStops,
          Setting.kMouseKeysEnabled,
          Setting.kLargeCursor,
          Setting.kHighlightCursorWhileMoving,
          Setting.kTabletNavigationButtons,
          Setting.kEnableCursorColor,
          Setting.kOverscrollEnabled,
        ]),
      },

      /**
       * Check if at least one mouse is connected.
       */
      hasMouse_: {
        type: Boolean,
      },

      /**
       * Check if at least one touchpad is connected.
       */
      hasTouchpad_: {
        type: Boolean,
      },

      /**
       * Check if at least one pointing stick is connected.
       */
      hasPointingStick_: {
        type: Boolean,
      },
    };
  }

  static get observers() {
    return [
      'pointersChanged(hasMouse_, hasPointingStick_, hasTouchpad_, ' +
          'isKioskModeActive_)',
    ];
  }

  private autoClickDelayOptions_: Option[];
  private autoClickMovementThresholdOptions_: Option[];
  private cursorAndTouchpadBrowserProxy_: CursorAndTouchpadPageBrowserProxy;
  private cursorColorOptions_: Option[];
  private deviceBrowserProxy_: DevicePageBrowserProxy;
  private disableTrackpadOptions_: Option[];
  private readonly isKioskModeActive_: boolean;
  private shelfNavigationButtonsImplicitlyEnabled_: boolean;
  private shelfNavigationButtonsPref_:
      chrome.settingsPrivate.PrefObject<boolean>;
  private showShelfNavigationButtonsSettings_: boolean;
  private readonly isAccessibilityDisableTrackpadEnabled_: boolean;
  private readonly isAccessibilityFaceGazeEnabled_: boolean;
  private readonly isAccessibilityMouseKeysEnabled_: boolean;
  private readonly isAccessibilityOverscrollSettingFeatureEnabled_: boolean;
  private readonly largeCursorMaxSize_: number;
  private hasMouse_: boolean;
  private hasTouchpad_: boolean;
  private hasPointingStick_: boolean;

  constructor() {
    super();

    /** RouteOriginMixin override */
    this.route = routes.A11Y_CURSOR_AND_TOUCHPAD;

    this.cursorAndTouchpadBrowserProxy_ =
        CursorAndTouchpadPageBrowserProxyImpl.getInstance();

    this.deviceBrowserProxy_ = DevicePageBrowserProxyImpl.getInstance();
  }

  override connectedCallback(): void {
    super.connectedCallback();

    this.addWebUiListener(
        'has-mouse-changed',
        (exists: boolean) => this.set('hasMouse_', exists));
    this.addWebUiListener(
        'has-pointing-stick-changed',
        (exists: boolean) => this.set('hasPointingStick_', exists));
    this.addWebUiListener(
        'has-touchpad-changed',
        (exists: boolean) => this.set('hasTouchpad_', exists));
    this.deviceBrowserProxy_.initializePointers();
  }

  override ready(): void {
    super.ready();

    if (loadTimeData.getBoolean('enableInputDeviceSettingsSplit')) {
      this.addFocusConfig(routes.DEVICE, '#pointerSubpageButton');
      this.addFocusConfig(routes.PER_DEVICE_TOUCHPAD, '#pointerSubpageButton');
      this.addFocusConfig(routes.PER_DEVICE_MOUSE, '#pointerSubpageButton');
      this.addFocusConfig(
          routes.PER_DEVICE_POINTING_STICK, '#pointerSubpageButton');
    } else {
      this.addFocusConfig(routes.POINTERS, '#pointerSubpageButton');
    }
    this.addFocusConfig(
        routes.MANAGE_FACEGAZE_SETTINGS, '#faceGazeSubpageButton');
  }

  /**
   * Note: Overrides RouteOriginMixin implementation
   */
  override currentRouteChanged(newRoute: Route, prevRoute?: Route): void {
    super.currentRouteChanged(newRoute, prevRoute);

    // Does not apply to this page.
    if (newRoute !== this.route) {
      return;
    }

    this.attemptDeepLink();
  }

  /**
   * Ticks for the Mouse Keys accelerations slider. Valid rates are
   * between 0 and 1.
   */
  private mouseKeysAccelerationTicks_(): SliderTick[] {
    return this.buildLinearTicks_({
      min: 0,
      max: 1,
      step: 0.1,
      defaultValue: 0.2,
    });
  }

  /**
   * Ticks for the Mouse Keys max speed slider. Valid rates are
   * between 1 and 10.
   */
  private mouseKeysMaxSpeedTicks_(): SliderTick[] {
    return this.buildLinearTicks_({
      min: 1,
      max: 10,
      step: 1,
      defaultValue: 5,
    });
  }

  /**
   * A helper to build a set of ticks between |min| and |max| (inclusive) spaced
   * evenly by |step|.
   */
  private buildLinearTicks_(data: SliderData): SliderTick[] {
    const ticks: SliderTick[] = [];

    const count = (data.max - data.min) / data.step;
    for (let i = 0; i <= count; i++) {
      const tickValue = data.step * i + data.min;
      ticks.push(this.initTick_({
        tick: tickValue,
        percent: tickValue / data.max,
        defaultValue: data.defaultValue,
      }));
    }
    return ticks;
  }

  /**
   * Initializes i18n labels for ticks arrays.
   */
  private initTick_(data: TickData): SliderTick {
    const value = Math.round(100 * data.percent);
    const strValue = value.toFixed(0);
    const label = data.tick.toFixed(1) === data.defaultValue.toFixed(1) ?
        this.i18n('defaultPercentage', strValue) :
        this.i18n('percentage', strValue);
    return {label: label, value: data.tick, ariaValue: value};
  }

  private onFaceGazeSettingsClick_(): void {
    Router.getInstance().navigateTo(routes.MANAGE_FACEGAZE_SETTINGS);
  }

  pointersChanged(
      hasMouse: boolean, hasTouchpad: boolean, hasPointingStick: boolean,
      isKioskModeActive: boolean): void {
    this.$.pointerSubpageButton.hidden =
        (!hasMouse && !hasPointingStick && !hasTouchpad) || isKioskModeActive;
  }

  /**
   * If enableInputDeviceSettingsSplit feature flag is enabled:
   * If there is only touchpad connected, navigate to touchpad subpage.
   * If there is only mouse connected, navigate to mouse subpage.
   * If there is only pointing stick connected, navigate to pointing stick
   * subpage. If there are more than one types device connected, navigate to
   * device subpage. If there is no mouse or touchpad or pointing stick
   * connected, navigate to device subpage.
   *
   * If enableInputDeviceSettingsSplit feature flag is disabled:
   * Navigate to pointers page.
   */
  onNavigateToSubpageClick(): void {
    if (!loadTimeData.getBoolean('enableInputDeviceSettingsSplit')) {
      Router.getInstance().navigateTo(
          routes.POINTERS,
          /* dynamicParams= */ undefined, /* removeSearch= */ true);
      return;
    }

    if (this.hasMouse_ && !this.hasTouchpad_ && !this.hasPointingStick_) {
      Router.getInstance().navigateTo(
          routes.PER_DEVICE_MOUSE,
          /* dynamicParams= */ undefined, /* removeSearch= */ true);
    } else if (
        !this.hasMouse_ && this.hasTouchpad_ && !this.hasPointingStick_) {
      Router.getInstance().navigateTo(
          routes.PER_DEVICE_TOUCHPAD,
          /* dynamicParams= */ undefined, /* removeSearch= */ true);
    } else if (
        !this.hasMouse_ && !this.hasTouchpad_ && this.hasPointingStick_) {
      Router.getInstance().navigateTo(
          routes.PER_DEVICE_POINTING_STICK,
          /* dynamicParams= */ undefined, /* removeSearch= */ true);
    } else {
      Router.getInstance().navigateTo(
          routes.DEVICE,
          /* dynamicParams= */ undefined, /* removeSearch= */ true);
    }
  }

  private computeShowShelfNavigationButtonsSettings_(): boolean {
    return !this.isKioskModeActive_ &&
        loadTimeData.getBoolean('showTabletModeShelfNavigationButtonsSettings');
  }

  /**
   * @return Whether shelf navigation buttons should implicitly be
   *     enabled in tablet mode (due to accessibility settings different than
   *     shelf_navigation_buttons_enabled_in_tablet_mode).
   */
  private computeShelfNavigationButtonsImplicitlyEnabled_(): boolean {
    /**
     * Gets the bool pref value for the provided pref key.
     */
    const getBoolPrefValue = (key: string): boolean => {
      const pref = this.getPref(key);
      return pref && !!pref.value;
    };

    return getBoolPrefValue('settings.accessibility') ||
        getBoolPrefValue('settings.a11y.autoclick') ||
        getBoolPrefValue('settings.a11y.switch_access.enabled');
  }

  /**
   * Calculates the effective value for "shelf navigation buttons enabled in
   * tablet mode" setting - if the setting is implicitly enabled (by other a11y
   * settings), this will return a stub pref value.
   */
  private getShelfNavigationButtonsEnabledPref_():
      chrome.settingsPrivate.PrefObject<boolean> {
    if (this.shelfNavigationButtonsImplicitlyEnabled_) {
      return {
        value: true,
        type: chrome.settingsPrivate.PrefType.BOOLEAN,
        key: '',
      };
    }

    return this.getPref<boolean>(
        'settings.a11y.tablet_mode_shelf_nav_buttons_enabled');
  }

  private onShelfNavigationButtonsLearnMoreClicked_(): void {
    chrome.metricsPrivate.recordUserAction(
        'Settings_A11y_ShelfNavigationButtonsLearnMoreClicked');
  }

  /**
   * Handles the <code>tablet_mode_shelf_nav_buttons_enabled</code> setting's
   * toggle changes. It updates the backing pref value, unless the setting is
   * implicitly enabled.
   */
  private updateShelfNavigationButtonsEnabledPref_(): void {
    if (this.shelfNavigationButtonsImplicitlyEnabled_) {
      return;
    }

    const enabled = this.shadowRoot!
                        .querySelector<SettingsToggleButtonElement>(
                            '#shelfNavigationButtonsEnabledControl')!.checked;
    this.setPrefValue(
        'settings.a11y.tablet_mode_shelf_nav_buttons_enabled', enabled);
    this.cursorAndTouchpadBrowserProxy_
        .recordSelectedShowShelfNavigationButtonValue(enabled);
  }

  private onA11yCursorColorChange_(): void {
    // Custom cursor color is enabled when the color is not set to black.
    const a11yCursorColorOn =
        this.getPref<number>('settings.a11y.cursor_color').value !==
        DEFAULT_BLACK_CURSOR_COLOR;
    this.set(
        'prefs.settings.a11y.cursor_color_enabled.value', a11yCursorColorOn);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-cursor-and-touchpad-page': SettingsCursorAndTouchpadPageElement;
  }
}

customElements.define(
    SettingsCursorAndTouchpadPageElement.is,
    SettingsCursorAndTouchpadPageElement);