chromium/chrome/browser/resources/ash/settings/device_page/per_device_mouse_subsection.ts

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

/**
 * @fileoverview
 * 'per-device-mouse-subsection' allow users to configure their per-device-mouse
 * subsection settings in system settings.
 */

import '../icons.html.js';
import '../settings_shared.css.js';
import 'chrome://resources/ash/common/bluetooth/bluetooth_battery_icon_percentage.js';
import 'chrome://resources/ash/common/cr_elements/localized_link/localized_link.js';
import 'chrome://resources/ash/common/cr_elements/cr_radio_button/cr_radio_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import '../controls/settings_radio_group.js';
import '../controls/settings_slider.js';
import '../controls/settings_toggle_button.js';
import './input_device_settings_shared.css.js';
import './per_device_app_installed_row.js';
import './per_device_install_row.js';
import './per_device_subsection_header.js';
import 'chrome://resources/ash/common/cr_elements/cr_slider/cr_slider.js';

import {CrLinkRowElement} from 'chrome://resources/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.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 {DeepLinkingMixin} from '../common/deep_linking_mixin.js';
import {isRevampWayfindingEnabled} from '../common/load_time_booleans.js';
import {RouteObserverMixin} from '../common/route_observer_mixin.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Route, Router, routes} from '../router.js';

import {getInputDeviceSettingsProvider} from './input_device_mojo_interface_provider.js';
import {CompanionAppState, CustomizationRestriction, InputDeviceSettingsProviderInterface, Mouse, MousePolicies, MouseSettings} from './input_device_settings_types.js';
import {getPrefPolicyFields, settingsAreEqual} from './input_device_settings_utils.js';
import {getTemplate} from './per_device_mouse_subsection.html.js';

const SettingsPerDeviceMouseSubsectionElementBase =
    DeepLinkingMixin(RouteObserverMixin(I18nMixin(PolymerElement)));
export class SettingsPerDeviceMouseSubsectionElement extends
    SettingsPerDeviceMouseSubsectionElementBase {
  static get is() {
    return 'settings-per-device-mouse-subsection';
  }

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

  static get properties(): PolymerElementProperties {
    return {
      isPeripheralCustomizationEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('enablePeripheralCustomization');
        },
        readOnly: true,
      },

      primaryRightPref: {
        type: Object,
        value() {
          return {
            key: 'fakePrimaryRightPref',
            type: chrome.settingsPrivate.PrefType.BOOLEAN,
            value: false,
          };
        },
      },

      accelerationPref: {
        type: Object,
        value() {
          return {
            key: 'fakeAccelerationPref',
            type: chrome.settingsPrivate.PrefType.BOOLEAN,
            value: true,
          };
        },
      },

      sensitivityPref: {
        type: Object,
        value() {
          return {
            key: 'fakeSensitivityPref',
            type: chrome.settingsPrivate.PrefType.NUMBER,
            value: 3,
          };
        },
      },

      scrollSensitivityPref: {
        type: Object,
        value() {
          return {
            key: 'fakeScrollSensitivityPref',
            type: chrome.settingsPrivate.PrefType.NUMBER,
            value: 3,
          };
        },
      },

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

      scrollAccelerationValue: {
        type: Boolean,
        value: true,
      },

      swapPrimaryOptions: {
        readOnly: true,
        type: Array,
        value() {
          return [
            {
              value: false,
              name: loadTimeData.getString('primaryMouseButtonLeft'),
            },
            {
              value: true,
              name: loadTimeData.getString('primaryMouseButtonRight'),
            },
          ];
        },
      },

      /**
       * TODO(khorimoto): Remove this conditional once the feature is launched.
       */
      allowScrollSettings_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('allowScrollSettings');
        },
        reflectToAttribute: true,
      },

      /**
       * TODO(michaelpg): settings-slider should optionally take a min and max
       * so we don't have to generate a simple range of natural numbers
       * ourselves. These values match the TouchpadSensitivity enum in
       * enums.xml.
       */
      sensitivityValues_: {
        type: Array,
        value: [1, 2, 3, 4, 5],
        readOnly: true,
      },

      isRevampWayfindingEnabled_: {
        type: Boolean,
        value: () => {
          return isRevampWayfindingEnabled();
        },
      },

      mouse: {
        type: Object,
      },

      mousePolicies: {
        type: Object,
      },

      /**
       * Used by DeepLinkingMixin to focus this page's deep links.
       */
      supportedSettingIds: {
        type: Object,
        value: () => new Set<Setting>([
          Setting.kMouseSwapPrimaryButtons,
          Setting.kMouseReverseScrolling,
          Setting.kMouseAcceleration,
          Setting.kMouseScrollAcceleration,
          Setting.kMouseSpeed,
        ]),
      },

      mouseIndex: {
        type: Number,
      },

      isLastDevice: {
        type: Boolean,
        reflectToAttribute: true,
      },

      customizationRestriction: {
        type: Object,
      },

      /**
         Used to track if the customize button row is clicked.
       */
      currentMouseChanged: {
        type: Boolean,
      },

      isWelcomeExperienceEnabled: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('enableWelcomeExperience');
        },
        readOnly: true,
      },

      deviceImageDataUrl: {
        type: String,
      },

      bluetoothDevice: {
        type: Object,
      },
    };
  }

  static get observers(): string[] {
    return [
      'onSettingsChanged(primaryRightPref.value,' +
          'accelerationPref.value,' +
          'sensitivityPref.value,' +
          'scrollSensitivityPref.value,' +
          'reverseScrollValue,' +
          'scrollAccelerationValue)',
      'onPoliciesChanged(mousePolicies)',
      'updateSettingsToCurrentPrefs(mouse)',
    ];
  }

  override async currentRouteChanged(route: Route): Promise<void> {
    // Avoid override currentMouseChanged when on the customization subpage.
    if (route === routes.CUSTOMIZE_MOUSE_BUTTONS) {
      return;
    }

    // Does not apply to this page.
    if (route !== routes.PER_DEVICE_MOUSE) {
      // Reset the boolean when on other pages.
      this.currentMouseChanged = false;
      return;
    }

    // If multiple mice are available, focus on the first one.
    if (this.mouseIndex === 0) {
      this.attemptDeepLink();
    }

    // Don't attempt to focus any item unless the last navigation was a
    // 'pop' (backwards) navigation.
    if (!Router.getInstance().lastRouteChangeWasPopstate()) {
      return;
    } else if (this.currentMouseChanged) {
      this.shadowRoot!
          .querySelector<CrLinkRowElement>('#customizeMouseButtons')!.focus();
    }

    this.currentMouseChanged = false;
  }

  isWelcomeExperienceEnabled: boolean;
  private mouse: Mouse;
  protected mousePolicies: MousePolicies;
  private primaryRightPref: chrome.settingsPrivate.PrefObject;
  private accelerationPref: chrome.settingsPrivate.PrefObject;
  private sensitivityPref: chrome.settingsPrivate.PrefObject;
  private scrollSensitivityPref: chrome.settingsPrivate.PrefObject;
  private reverseScrollValue: boolean;
  private scrollAccelerationValue: boolean;
  private isInitialized: boolean = false;
  private isPeripheralCustomizationEnabled_: boolean;
  private inputDeviceSettingsProvider: InputDeviceSettingsProviderInterface =
      getInputDeviceSettingsProvider();
  private mouseIndex: number;
  private isLastDevice: boolean;
  private isRevampWayfindingEnabled_: boolean;
  private customizationRestriction: CustomizationRestriction;
  private currentMouseChanged: boolean;

  private showCustomizeButtonRow(): boolean {
    return (this.customizationRestriction !==
            CustomizationRestriction.kDisallowCustomizations) &&
        this.isPeripheralCustomizationEnabled_;
  }

  private showSwapToggleButton(): boolean {
    return this.customizationRestriction ===
        CustomizationRestriction.kDisallowCustomizations &&
        this.isPeripheralCustomizationEnabled_;
  }

  private showInstallAppRow(): boolean {
    return this.mouse.appInfo?.state === CompanionAppState.kAvailable;
  }

  private onInstallCompanionAppButtonClicked(): void {
    window.open(this.mouse.appInfo?.actionLink);
  }

  private updateSettingsToCurrentPrefs(): void {
    // `updateSettingsToCurrentPrefs` gets called when the `keyboard` object
    // gets updated. This subsection element can be reused multiple times so we
    // need to reset `isInitialized` so we do not make unneeded API calls.
    this.isInitialized = false;
    this.set('primaryRightPref.value', this.mouse.settings.swapRight);
    this.set('accelerationPref.value', this.mouse.settings.accelerationEnabled);
    this.set('sensitivityPref.value', this.mouse.settings.sensitivity);
    this.set(
        'scrollSensitivityPref.value', this.mouse.settings.scrollSensitivity);
    this.reverseScrollValue = this.mouse.settings.reverseScrolling;
    this.scrollAccelerationValue = this.mouse.settings.scrollAcceleration;
    this.customizationRestriction = this.mouse.customizationRestriction;
    this.isInitialized = true;
  }

  private onPoliciesChanged(): void {
    this.primaryRightPref = {
      ...this.primaryRightPref,
      ...getPrefPolicyFields(this.mousePolicies.swapRightPolicy),
    };
  }

  private onLearnMoreLinkClicked_(event: Event): void {
    const path = event.composedPath();
    if (!Array.isArray(path) || !path.length) {
      return;
    }

    if ((path[0] as HTMLElement).tagName === 'A') {
      // Do not toggle reverse scrolling if the contained link is clicked.
      event.stopPropagation();
    }
  }

  private onMouseReverseScrollRowClicked_(): void {
    this.reverseScrollValue = !this.reverseScrollValue;
  }

  private onMouseControlledScrollingRowClicked_(): void {
    this.scrollAccelerationValue = !this.scrollAccelerationValue;
  }

  private onSettingsChanged(): void {
    if (!this.isInitialized) {
      return;
    }

    const newSettings: MouseSettings = {
      ...this.mouse.settings,
      swapRight: this.primaryRightPref.value,
      accelerationEnabled: this.accelerationPref.value,
      sensitivity: this.sensitivityPref.value,
      scrollSensitivity: this.scrollSensitivityPref.value,
      reverseScrolling: this.reverseScrollValue,
      scrollAcceleration: this.scrollAccelerationValue,
    };

    if (settingsAreEqual(newSettings, this.mouse.settings)) {
      return;
    }

    this.mouse.settings = newSettings;
    this.inputDeviceSettingsProvider.setMouseSettings(
        this.mouse.id, this.mouse.settings);
  }

  private getLabelWithoutLearnMore(stringName: string): string|TrustedHTML {
    const tempEl = document.createElement('div');
    const localizedString = this.i18nAdvanced(stringName);
    tempEl.innerHTML = localizedString;

    const nodesToDelete: Node[] = [];
    tempEl.childNodes.forEach((node) => {
      // Remove elements with the <a> tag
      if (node.nodeType === Node.ELEMENT_NODE && node.nodeName === 'A') {
        nodesToDelete.push(node);
        return;
      }
    });

    nodesToDelete.forEach((node) => {
      tempEl.removeChild(node);
    });

    return tempEl.innerHTML;
  }

  private getCursorSpeedString(): TrustedHTML {
    return this.i18nAdvanced(
        loadTimeData.getBoolean('allowScrollSettings') ? 'cursorSpeed' :
                                                         'mouseSpeed');
  }

  private getCursorAccelerationString(): TrustedHTML {
    return this.i18nAdvanced(
        loadTimeData.getBoolean('allowScrollSettings') ?
            'cursorAccelerationLabel' :
            'mouseAccelerationLabel');
  }

  private onCustomizeButtonsClick(): void {
    const url =
        new URLSearchParams(`mouseId=${encodeURIComponent(this.mouse.id)}`);

    Router.getInstance().navigateTo(
        routes.CUSTOMIZE_MOUSE_BUTTONS,
        /* dynamicParams= */ url, /* removeSearch= */ true);
    this.currentMouseChanged = true;
  }

  private getMouseAccelerationDescription(): string {
    if (this.isRevampWayfindingEnabled_) {
      return this.i18n('mouseAccelerationDescription');
    }
    return '';
  }

  private isCompanionAppInstalled(): boolean {
    return this.mouse.appInfo?.state === CompanionAppState.kInstalled;
  }

  private onCompanionAppRowClick(): void {
    assert(this.mouse.appInfo);
    this.inputDeviceSettingsProvider.launchCompanionApp(
        this.mouse.appInfo.packageId || '');
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-per-device-mouse-subsection':
        SettingsPerDeviceMouseSubsectionElement;
  }
}

customElements.define(
    SettingsPerDeviceMouseSubsectionElement.is,
    SettingsPerDeviceMouseSubsectionElement);