chromium/chrome/browser/resources/ash/settings/device_page/per_device_keyboard_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-keyboard-subsection' allow users to configure their
 * per-device-keyboard subsection settings in system settings.
 */

import '../icons.html.js';
import '../settings_shared.css.js';
import 'chrome://resources/ash/common/cr_elements/localized_link/localized_link.js';
import 'chrome://resources/ash/common/cr_elements/cr_link_row/cr_link_row.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 '../os_settings_page/os_settings_animated_pages.js';
import '../os_settings_page/os_settings_subpage.js';
import './input_device_settings_shared.css.js';
import './per_device_app_installed_row.js';
import './per_device_install_row.js';
import './per_device_keyboard_remap_keys.js';
import './per_device_subsection_header.js';
import 'chrome://resources/ash/common/cr_elements/cr_slider/cr_slider.js';

import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.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 {RouteObserverMixin} from '../common/route_observer_mixin.js';
import {SettingsSliderElement} from '../controls/settings_slider.js';
import {SettingsToggleButtonElement} from '../controls/settings_toggle_button.js';
import {KeyboardAmbientLightSensorObserverReceiver, KeyboardBrightnessObserverReceiver, LidStateObserverReceiver} from '../mojom-webui/input_device_settings_provider.mojom-webui.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {PersonalizationHubBrowserProxy, PersonalizationHubBrowserProxyImpl} from '../personalization_page/personalization_hub_browser_proxy.js';
import {Route, Router, routes} from '../router.js';

import {getInputDeviceSettingsProvider} from './input_device_mojo_interface_provider.js';
import {CompanionAppState, InputDeviceSettingsProviderInterface, Keyboard, KeyboardPolicies, KeyboardSettings, MetaKey, ModifierKey, SixPackKeyInfo, SixPackShortcutModifier} from './input_device_settings_types.js';
import {getPrefPolicyFields, settingsAreEqual} from './input_device_settings_utils.js';
import {getTemplate} from './per_device_keyboard_subsection.html.js';

const SettingsPerDeviceKeyboardSubsectionElementBase =
    DeepLinkingMixin(I18nMixin(RouteObserverMixin(PolymerElement)));

const MIN_VISIBLE_PERCENT = 5;

export class SettingsPerDeviceKeyboardSubsectionElement extends
    SettingsPerDeviceKeyboardSubsectionElementBase {
  static get is() {
    return 'settings-per-device-keyboard-subsection';
  }

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

  static get properties(): PolymerElementProperties {
    return {
      topRowAreFunctionKeysPref: {
        type: Object,
        value() {
          return {
            key: 'fakeTopRowAreFunctionKeysPref',
            type: chrome.settingsPrivate.PrefType.BOOLEAN,
            value: false,
          };
        },
      },

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

      keyboardBrightnessPercentPref: {
        type: Object,
        value() {
          return {
            key: 'fakekeyboardBrightnessPercentPref',
            type: chrome.settingsPrivate.PrefType.NUMBER,
            value: 40,
          };
        },
      },

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

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

      keyboard: {
        type: Object,
      },

      keyboardPolicies: {
        type: Object,
      },

      remapKeyboardKeysSublabel: {
        type: String,
        value: '',
      },

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

      keyboardIndex: {
        type: Number,
      },

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

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

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

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

      isLidOpen: {
        type: Boolean,
        value: true,
      },
    };
  }

  static get observers(): string[] {
    return [
      'onSettingsChanged(topRowAreFunctionKeysPref.value,' +
          'blockMetaFunctionKeyRewritesPref.value,' +
          'enableAutoRepeatPref.value,' +
          'autoRepeatDelaysPref.value,' +
          'autoRepeatIntervalsPref.value)',
      'onPoliciesChanged(keyboardPolicies)',
      'onKeyboardRemappingsChanged(keyboard.*)',
      'updateSettingsToCurrentPrefs(keyboard)',
    ];
  }

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

    if (this.keyboard.isExternal) {
      this.supportedSettingIds.add(Setting.kKeyboardBlockMetaFkeyRewrites);
    }

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

  protected keyboard: Keyboard;
  protected keyboardPolicies: KeyboardPolicies;
  private topRowAreFunctionKeysPref: chrome.settingsPrivate.PrefObject;
  private blockMetaFunctionKeyRewritesPref: chrome.settingsPrivate.PrefObject;
  private keyboardBrightnessPercentPref: chrome.settingsPrivate.PrefObject;
  private keyboardAutoBrightnessPref: chrome.settingsPrivate.PrefObject;
  private remapKeyboardKeysSublabel: string;
  private isInitialized: boolean = false;
  private inputDeviceSettingsProvider: InputDeviceSettingsProviderInterface =
      getInputDeviceSettingsProvider();
  private personalizationHubBrowserProxy: PersonalizationHubBrowserProxy =
      PersonalizationHubBrowserProxyImpl.getInstance();
  private keyboardBrightnessObserverReceiver:
      KeyboardBrightnessObserverReceiver;
  private keyboardAmbientLightSensorObserverReceiver:
      KeyboardAmbientLightSensorObserverReceiver;
  private lidStateObserverReceiver: LidStateObserverReceiver;
  private keyboardIndex: number;
  private isLastDevice: boolean;
  private isRgbKeyboardSupported: boolean;
  private hasKeyboardBacklight: boolean;
  private hasAmbientLightSensor: boolean;
  private isKeyboardBacklightControlInSettingsEnabled: boolean;
  private isLidOpen: boolean;

  override async connectedCallback(): Promise<void> {
    super.connectedCallback();

    if (this.isKeyboardBacklightControlInSettingsEnabled) {
      // Add keyboardBrightnessChange observer.
      this.keyboardBrightnessObserverReceiver =
          new KeyboardBrightnessObserverReceiver(this);
      this.inputDeviceSettingsProvider.observeKeyboardBrightness(
          this.keyboardBrightnessObserverReceiver.$.bindNewPipeAndPassRemote());

      // Add keyboardAmbientLightSensorChange observer.
      this.keyboardAmbientLightSensorObserverReceiver =
          new KeyboardAmbientLightSensorObserverReceiver(this);
      this.inputDeviceSettingsProvider.observeKeyboardAmbientLightSensor(
          this.keyboardAmbientLightSensorObserverReceiver.$
              .bindNewPipeAndPassRemote());

      // Add LidState Observer.
      this.lidStateObserverReceiver = new LidStateObserverReceiver(this);
      this.inputDeviceSettingsProvider
          .observeLidState(
              this.lidStateObserverReceiver.$.bindNewPipeAndPassRemote())
          .then(({isLidOpen}: {isLidOpen: boolean}) => {
            this.onLidStateChanged(isLidOpen);
          });

      this.isRgbKeyboardSupported =
        (await this.inputDeviceSettingsProvider.isRgbKeyboardSupported())
          ?.isRgbKeyboardSupported;
      this.hasKeyboardBacklight =
          (await this.inputDeviceSettingsProvider.hasKeyboardBacklight())
              ?.hasKeyboardBacklight;
      this.hasAmbientLightSensor =
          (await this.inputDeviceSettingsProvider.hasAmbientLightSensor())
              ?.hasAmbientLightSensor;

      if (this.hasKeyboardBacklight) {
        const crSlider = this.shadowRoot!
                             .querySelector<SettingsSliderElement>(
                                 '#keyboardBrightnessSlider')!.shadowRoot!
                             .querySelector('cr-slider');
        if (crSlider) {
          // Set key press increment value to be 10.
          crSlider.setAttribute('key-press-slider-increment', '10');
        }
      }
    }
  }

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

  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(
        'topRowAreFunctionKeysPref.value',
        this.keyboard.settings.topRowAreFkeys);
    this.set(
        'blockMetaFunctionKeyRewritesPref.value',
        this.keyboard.settings.suppressMetaFkeyRewrites);
    this.isInitialized = true;
  }

  private onPoliciesChanged(): void {
    this.topRowAreFunctionKeysPref = {
      ...this.topRowAreFunctionKeysPref,
      ...getPrefPolicyFields(this.keyboardPolicies.topRowAreFkeysPolicy),
    };
    this.blockMetaFunctionKeyRewritesPref = {
      ...this.blockMetaFunctionKeyRewritesPref,
      ...getPrefPolicyFields(
          this.keyboardPolicies.enableMetaFkeyRewritesPolicy),
    };
  }

  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 onKeyboardBrightnessSliderChanged(): void {
    this.inputDeviceSettingsProvider.setKeyboardBrightness(
        this.getKeyboardBrightnessFromSlider());
  }

  private onKeyup(event: KeyboardEvent): void {
    // Record updated brightness if adjusted via arrow keys.
    if (['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp'].includes(
            event.key)) {
      this.inputDeviceSettingsProvider.recordKeyboardBrightnessChangeFromSlider(
          this.getKeyboardBrightnessFromSlider());
    }
  }

  private onPointerup(): void {
    // Record brightness after slider adjustment is completed.
    this.inputDeviceSettingsProvider.recordKeyboardBrightnessChangeFromSlider(
        this.getKeyboardBrightnessFromSlider());
  }

  private onKeyboardAutoBrightnessToggleChanged(e: Event): void {
    const toggle = e.target as SettingsToggleButtonElement;
    this.inputDeviceSettingsProvider.setKeyboardAmbientLightSensorEnabled(
        toggle.checked);
  }

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

    const newSettings: KeyboardSettings = {
      ...this.keyboard.settings,
      topRowAreFkeys: this.topRowAreFunctionKeysPref.value,
      suppressMetaFkeyRewrites: this.blockMetaFunctionKeyRewritesPref.value,
    };

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

    this.keyboard.settings = newSettings;
    this.inputDeviceSettingsProvider.setKeyboardSettings(
        this.keyboard.id, this.keyboard.settings);
  }

  onKeyboardBrightnessChanged(keyboardBrightnessPercent: number): void {
    if (keyboardBrightnessPercent > 0 &&
        keyboardBrightnessPercent < MIN_VISIBLE_PERCENT) {
      // When auto-brightness is enabled, it's likely that the automated
      // brightness percentage will fall between 0% and 5%. To avoid confusion
      // where the user cannot distinguish between the keyboard being off (0%)
      // and low brightness levels, set the slider to a minimum visible
      // percentage (5%).
      this.set('keyboardBrightnessPercentPref.value', MIN_VISIBLE_PERCENT);
      return;
    }
    this.set('keyboardBrightnessPercentPref.value', keyboardBrightnessPercent);
  }

  onKeyboardAmbientLightSensorEnabledChanged(keyboardAmbientLightSensorEnabled:
                                                 boolean): void {
    this.set(
        'keyboardAutoBrightnessPref.value', keyboardAmbientLightSensorEnabled);
  }

  onLidStateChanged(isLidOpen: boolean): void {
    this.isLidOpen = isLidOpen;
  }

  private getNumRemappedSixPackKeys(): number {
    if (!this.keyboard.settings.sixPackKeyRemappings) {
      return 0;
    }

    return Object
        .values(this.keyboard.settings.sixPackKeyRemappings as SixPackKeyInfo)
        .filter(
            (modifier: SixPackShortcutModifier) =>
                modifier !== SixPackShortcutModifier.kSearch)
        .length;
  }

  private async onKeyboardRemappingsChanged(): Promise<void> {
    let numRemappedKeys =
        Object.keys(this.keyboard.settings.modifierRemappings).length;
    if (loadTimeData.getBoolean('enableAltClickAndSixPackCustomization')) {
      numRemappedKeys += this.getNumRemappedSixPackKeys();
    }
    this.remapKeyboardKeysSublabel =
        await PluralStringProxyImpl.getInstance().getPluralString(
            'remapKeyboardKeysRowSubLabel', numRemappedKeys);
  }

  private onRemapKeyboardKeysClick(): void {
    const url = new URLSearchParams(
        'keyboardId=' + encodeURIComponent(this.keyboard.id));

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

  private getKeyboardName(): string {
    return this.keyboard.isExternal ? this.keyboard.name :
                                      this.i18n('builtInKeyboardName');
  }

  private showKeyboardSettings(): boolean {
    if (!this.isKeyboardBacklightControlInSettingsEnabled) {
      return true;
    }
    return this.keyboard.isExternal ||
        (!this.keyboard.isExternal && this.isLidOpen);
  }

  private isChromeOsKeyboard(): boolean {
    return this.keyboard.metaKey === MetaKey.kLauncher ||
        this.keyboard.metaKey === MetaKey.kSearch ||
        this.keyboard.metaKey === MetaKey.kLauncherRefresh;
  }

  private openPersonalizationHub(): void {
    this.inputDeviceSettingsProvider.recordKeyboardColorLinkClicked();
    this.personalizationHubBrowserProxy.openPersonalizationHub();
  }

  private getKeyboardBrightnessFromSlider(): number {
    const slider = this.shadowRoot!.querySelector<SettingsSliderElement>(
        '#keyboardBrightnessSlider');
    return slider!.pref.value;
  }

  protected getRemapKeyboardKeysClass(): string {
    return `hr bottom-divider ${
        this.keyboard.isExternal ? '' : 'remap-keyboard-keys-row-internal'}`;
  }

  protected showSendFunctionKeyDescription(): string {
    const hasFunctionKey: boolean =
        this.keyboard.modifierKeys.includes(ModifierKey.kFunction);
    if (hasFunctionKey) {
      return this.i18n('splitModifierKeyboardSendFunctionKeysDescription');
    } else {
      return this.i18n('keyboardSendFunctionKeysDescription');
    }
  }

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

declare global {
  interface HTMLElementTagNameMap {
    'settings-per-device-keyboard-subsection':
        SettingsPerDeviceKeyboardSubsectionElement;
  }
}

customElements.define(
    SettingsPerDeviceKeyboardSubsectionElement.is,
    SettingsPerDeviceKeyboardSubsectionElement);