chromium/ash/webui/shortcut_customization_ui/resources/js/accelerator_lookup_manager.ts

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

import {assert} from 'chrome://resources/js/assert.js';
import {mojoString16ToString} from 'chrome://resources/js/mojo_type_util.js';

import {Accelerator, AcceleratorCategory, AcceleratorId, AcceleratorSource, AcceleratorSubcategory, LayoutInfo, LayoutStyle, MetaKey, MojoAcceleratorConfig, MojoAcceleratorInfo, MojoLayoutInfo, StandardAcceleratorInfo, TextAcceleratorInfo} from './shortcut_types.js';
import {getAcceleratorId, getSourceAndActionFromAcceleratorId, isStandardAcceleratorInfo, isTextAcceleratorInfo} from './shortcut_utils.js';

// Convert from Mojo types to the app types.
function createSanitizedAccelInfo(info: MojoAcceleratorInfo):
    StandardAcceleratorInfo {
  assert(isStandardAcceleratorInfo(info));
  const {acceleratorLocked, locked, state, type, layoutProperties} = info;
  const sanitizedAccelerator: Accelerator = {
    keyCode: layoutProperties.standardAccelerator.accelerator.keyCode,
    modifiers: layoutProperties.standardAccelerator.accelerator.modifiers,
    keyState: layoutProperties.standardAccelerator.accelerator.keyState,
  };
  const originalAccelerator =
      layoutProperties.standardAccelerator?.originalAccelerator;
  let sanitizedOriginalAccelerator: Accelerator|undefined = undefined;
  if (originalAccelerator) {
    sanitizedOriginalAccelerator = {
      keyCode: originalAccelerator.keyCode,
      modifiers: originalAccelerator.modifiers,
      keyState: layoutProperties.standardAccelerator.accelerator.keyState,
    };
  }

  return {
    acceleratorLocked,
    locked,
    state,
    type,
    layoutProperties: {
      standardAccelerator: {
        accelerator: sanitizedAccelerator,
        keyDisplay: mojoString16ToString(
            layoutProperties.standardAccelerator.keyDisplay),
        originalAccelerator: sanitizedOriginalAccelerator,
      },
    },
  };
}

/** The name of an {@link Accelerator}, e.g. "Snap Window Left". */
type AcceleratorName = string;
/**
 * The key used to lookup {@link AcceleratorId}s from a
 * {@link ReverseAcceleratorLookupMap}.
 * See getKeyForLookup() in this file for the implementation details.
 */
type AcceleratorLookupKey = string;
type StandardAcceleratorLookupMap =
    Map<AcceleratorId, StandardAcceleratorInfo[]>;
type TextAcceleratorLookupMap = Map<AcceleratorId, TextAcceleratorInfo[]>;

type ReverseAcceleratorLookupMap = Map<AcceleratorLookupKey, AcceleratorId>;

/**
 * A singleton class that manages the fetched accelerators and layout
 * information from the backend service. All accelerator-related manipulation is
 * handled in this class.
 */
export class AcceleratorLookupManager {
  private layoutInfoProvider = new LayoutInfoProvider();
  /**
   * A map with the key set to a concatenated string of the accelerator's
   * '{source}-{action_id}', this concatenation uniquely identifies one
   * accelerator. The value is an array of StandardAcceleratorInfo's
   * associated to one accelerator. This map serves as a way to quickly look up
   * all StandardAcceleratorInfos for one accelerator.
   */
  private standardAcceleratorLookup: StandardAcceleratorLookupMap = new Map();

  /**
   * A map with the key set to a concatenated string of the accelerator's
   * '{source}-{action_id}', this concatenation uniquely identifies one
   * accelerator. The value is a TextAcceleratorInfo associated to one
   * accelerator.
   */
  private textAcceleratorLookup: TextAcceleratorLookupMap = new Map();


  /**
   * A map with the key as a stringified version of AcceleratorKey and the
   * value as the unique string identifier `${source_id}-${action_id}`. Note
   * that Javascript Maps uses the SameValueZero algorithm to compare keys,
   * meaning objects are compared by their references instead of their
   * intrinsic values, therefore this uses a stringified version of
   * AcceleratorKey as the key instead of the object itself. This is used to
   * perform a reverse lookup to detect if a given shortcut is already
   * bound to an accelerator.
   */
  private reverseAcceleratorLookup: ReverseAcceleratorLookupMap = new Map();

  // An enum including Search, Launcher and LauncherRefresh to display the
  // keyboard 'meta' key with correct icon.
  private metaKey: MetaKey = MetaKey.kSearch;

  // Determine if a search result row is currently focused. This ensures the
  // focused row stays highlighted as the search result, despite mouse hover
  // actions.
  private searchResultRowFocused: boolean = false;

  /**
   * Used to generate the keys for the ReverseAcceleratorLookupMap.
   */
  private getKeyForLookup(accelerator: Accelerator): AcceleratorLookupKey {
    return JSON.stringify(
        {keyCode: accelerator.keyCode, modifiers: accelerator.modifiers});
  }

  getStandardAcceleratorInfos(source: number|string, action: number|string):
      StandardAcceleratorInfo[] {
    const uuid: AcceleratorId = getAcceleratorId(source, action);
    const acceleratorInfos = this.standardAcceleratorLookup.get(uuid);
    assert(acceleratorInfos);
    return acceleratorInfos;
  }

  getTextAcceleratorInfos(source: number|string, action: number|string):
      TextAcceleratorInfo[] {
    const uuid: AcceleratorId = getAcceleratorId(source, action);
    const acceleratorInfos = this.textAcceleratorLookup.get(uuid);
    assert(acceleratorInfos);
    return acceleratorInfos;
  }

  isStandardAccelerator(style: number|string): boolean {
    return style === LayoutStyle.kDefault;
  }

  isStandardAcceleratorById(id: AcceleratorId): boolean {
    return this.standardAcceleratorLookup.has(id);
  }

  getAcceleratorLayout(
      category: AcceleratorCategory,
      subCategory: AcceleratorSubcategory): LayoutInfo[] {
    return this.layoutInfoProvider.getAcceleratorLayout(category, subCategory);
  }

  getSubcategories(category: AcceleratorCategory):
      Map<AcceleratorSubcategory, LayoutInfo[]>|undefined {
    return this.layoutInfoProvider.getSubcategories(category);
  }

  getAcceleratorName(source: number|string, action: number|string):
      AcceleratorName {
    return this.layoutInfoProvider.getAcceleratorName(source, action);
  }

  getAcceleratorSubcategory(source: number|string, action: number|string):
      AcceleratorSubcategory {
    return this.layoutInfoProvider.getAcceleratorSubcategory(source, action);
  }

  initializeLookupIdForStandardAccelerator(source: string, actionId: string):
      void {
    const id = getAcceleratorId(source, actionId);
    if (!this.standardAcceleratorLookup.has(id)) {
      this.standardAcceleratorLookup.set(id, []);
    }
  }

  initializeLookupIdForTextAccelerator(source: string, actionId: string): void {
    const id = getAcceleratorId(source, actionId);
    if (!this.textAcceleratorLookup.has(id)) {
      this.textAcceleratorLookup.set(id, []);
    }
  }

  setAcceleratorLookup(acceleratorConfig: MojoAcceleratorConfig): void {
    // Reset the lookup maps every time we update the accelerator mappings.
    this.reverseAcceleratorLookup.clear();
    this.standardAcceleratorLookup.clear();
    this.textAcceleratorLookup.clear();

    for (const [source, accelInfoMap] of Object.entries(acceleratorConfig)) {
      // When calling Object.entries on an object with optional enum keys,
      // TypeScript considers the values to be possibly undefined.
      // This guard lets us use this value later as if it were not undefined.
      if (!accelInfoMap) {
        continue;
      }
      for (const [actionId, accelInfos] of Object.entries(accelInfoMap)) {
        accelInfos.forEach((info: MojoAcceleratorInfo) => {
          if (isTextAcceleratorInfo(info)) {
            this.initializeLookupIdForTextAccelerator(source, actionId);
            this.getTextAcceleratorInfos(source, actionId).push({...info});
          } else {
            assert(isStandardAcceleratorInfo(info));
            this.initializeLookupIdForStandardAccelerator(source, actionId);
            const sanitizedAccelInfo = createSanitizedAccelInfo(info);
            this.reverseAcceleratorLookup.set(
                this.getKeyForLookup(sanitizedAccelInfo.layoutProperties
                                         .standardAccelerator.accelerator),
                getAcceleratorId(source, actionId));
            this.getStandardAcceleratorInfos(source, actionId)
                .push({...sanitizedAccelInfo});
          }
        });
      }
    }
  }

  setAcceleratorLayoutLookup(layoutInfoList: MojoLayoutInfo[]): void {
    this.layoutInfoProvider.initializeLayoutInfo(layoutInfoList);
  }

  setMetaKeyToDisplay(metaKey: MetaKey): void {
    this.metaKey = metaKey;
  }

  getMetaKeyToDisplay(): MetaKey {
    return this.metaKey;
  }

  setSearchResultRowFocused(searchResultRowFocused: boolean): void {
    this.searchResultRowFocused = searchResultRowFocused;
  }

  getSearchResultRowFocused(): boolean {
    return this.searchResultRowFocused;
  }

  isSubcategoryLocked(subcategory: AcceleratorSubcategory): boolean {
    const acceleratorIds =
        this.layoutInfoProvider.getAcceleratorIdsBySubcategory(subcategory);

    for (const acceleratorId of acceleratorIds) {
      // Skip TextAccelerators as they are always locked.
      if (!this.isStandardAcceleratorById(acceleratorId)) {
        continue;
      }
      const {source, action} =
          getSourceAndActionFromAcceleratorId(acceleratorId);
      const acceleratorInfos = this.getStandardAcceleratorInfos(source, action);

      for (const acceleratorInfo of acceleratorInfos) {
        // Return false early when accelerator is editable, which is when
        // acceleratorInfo is not locked and source is kAsh(Only ash
        // accelerator is editable).
        if (!acceleratorInfo.locked && source === AcceleratorSource.kAsh) {
          return false;
        }
      }
    }
    return true;
  }

  reset(): void {
    this.standardAcceleratorLookup.clear();
    this.textAcceleratorLookup.clear();
    this.layoutInfoProvider.resetLookupMaps();
    this.reverseAcceleratorLookup.clear();
  }


  static getInstance(): AcceleratorLookupManager {
    return managerInstance ||
        (managerInstance = new AcceleratorLookupManager());
  }

  static setInstance(obj: AcceleratorLookupManager): void {
    managerInstance = obj;
  }
}

let managerInstance: AcceleratorLookupManager|null = null;


function createSanitizedLayoutInfo(entry: MojoLayoutInfo): LayoutInfo {
  return {...entry, description: mojoString16ToString(entry.description)};
}

type AcceleratorLayoutLookupMap =
    Map<AcceleratorCategory, Map<AcceleratorSubcategory, LayoutInfo[]>>;
type AcceleratorNameLookupMap = Map<AcceleratorId, AcceleratorName>;
type AcceleratorSubcategoryLookupMap =
    Map<AcceleratorId, AcceleratorSubcategory>;
type AcceleratorIdsBySubcategoryLookupMap =
    Map<AcceleratorSubcategory, AcceleratorId[]>;

interface LayoutProviderInterface {
  getAcceleratorLayout(
      category: AcceleratorCategory,
      subCategory: AcceleratorSubcategory): LayoutInfo[];
  getSubcategories(category: AcceleratorCategory):
      Map<AcceleratorSubcategory, LayoutInfo[]>|undefined;
  getAcceleratorName(source: number|string, action: number|string):
      AcceleratorName;
  getAcceleratorSubcategory(source: number|string, action: number|string):
      AcceleratorSubcategory;
  getAcceleratorIdsBySubcategory(subcategory: AcceleratorSubcategory):
      AcceleratorId[];
  initializeLayoutInfo(layoutInfoList: MojoLayoutInfo[]): void;
  resetLookupMaps(): void;
}

// Responsible for initializing/maintaining layout information for
// accelerators.
class LayoutInfoProvider implements LayoutProviderInterface {
  /**
   * A multi-layered map container. The top-most layer is a map with the key
   * as the accelerator's category (e.g. Tabs & Windows, Page & Web Browser).
   * The value of the top-most map is another map in which the key is the
   * accelerator's subcategory (e.g. System Controls, System Apps) and the value
   * is an Array of LayoutInfo. This map serves as a way to find all
   * LayoutInfo's of a given subsection of accelerators, where each LayoutInfo
   * corresponds to one AcceleratorRow.
   */
  private acceleratorLayoutLookup: AcceleratorLayoutLookupMap = new Map();
  /**
   * A map with the string key formatted as `${source_id}-${action_id}` and
   * the value as the string corresponding to the accelerator's name.
   */
  private acceleratorNameLookup: AcceleratorNameLookupMap = new Map();
  /**
   * A map with the string key formatted as `${source_id}-${action_id}` and
   * the value corresponding to the accelerator's subcategory.
   */
  private acceleratorSubcategoryLookup: AcceleratorSubcategoryLookupMap =
      new Map();
  /**
   * A map with the key "subcategory" and the value corresponding to the
   * accelerators under the subcategory.
   */
  private acceleratorIdsBySubcategoryLookup:
      AcceleratorIdsBySubcategoryLookupMap = new Map();

  getAcceleratorLayout(
      category: AcceleratorCategory,
      subCategory: AcceleratorSubcategory): LayoutInfo[] {
    const categoryMap = this.acceleratorLayoutLookup.get(category);
    assert(categoryMap);
    const subCategoryMap = categoryMap.get(subCategory);
    assert(subCategoryMap);
    return subCategoryMap;
  }

  getSubcategories(category: AcceleratorCategory):
      Map<AcceleratorSubcategory, LayoutInfo[]>|undefined {
    return this.acceleratorLayoutLookup.get(category);
  }

  getAcceleratorName(source: number|string, action: number|string):
      AcceleratorName {
    const uuid: AcceleratorId = getAcceleratorId(source, action);
    const acceleratorName = this.acceleratorNameLookup.get(uuid);
    assert(acceleratorName);
    return acceleratorName;
  }

  getAcceleratorSubcategory(source: number|string, action: number|string):
      AcceleratorSubcategory {
    const uuid: AcceleratorId = getAcceleratorId(source, action);
    const acceleratorSubcategory = this.acceleratorSubcategoryLookup.get(uuid);
    // The value of 'acceleratorCategory' could possibly be '0' (representing
    // 'kGeneralControls'). So we should only assert that it's not 'undefined'.
    assert(acceleratorSubcategory !== undefined);
    return acceleratorSubcategory;
  }

  getAcceleratorIdsBySubcategory(subcategory: AcceleratorSubcategory):
      AcceleratorId[] {
    const acceleratorIds =
        this.acceleratorIdsBySubcategoryLookup.get(subcategory);
    assert(acceleratorIds);
    return acceleratorIds;
  }

  initializeLayoutInfo(layoutInfoList: MojoLayoutInfo[]): void {
    this.initializeCategoryMaps(layoutInfoList);
    for (const entry of layoutInfoList) {
      // The Accelerator layout table doesn't currently contain any
      // developer/debug accelerators. Once they are added, we need to
      // check if they should be shown or not. This assert is to ensure that
      // this case is handled once developer/debug accelerators are added.
      assert(
          entry.category !== AcceleratorCategory.kDebug &&
          entry.category !== AcceleratorCategory.kDeveloper);
      const layoutInfo = createSanitizedLayoutInfo(entry);
      this.getAcceleratorLayout(entry.category, entry.subCategory)
          .push(layoutInfo);

      const acceleratorId = getAcceleratorId(entry.source, entry.action);
      this.addEntryToAcceleratorNameLookup(
          acceleratorId, layoutInfo.description);
      this.addEntryToAcceleratorSubcategoryLookup(
          acceleratorId, entry.subCategory);
      this.addEntryToAcceleratorsBySubcategoryLookup(
          acceleratorId, entry.subCategory);
    }
  }

  initializeCategoryMaps(layoutInfoList: MojoLayoutInfo[]): void {
    for (const entry of layoutInfoList) {
      if (!this.acceleratorLayoutLookup.has(entry.category)) {
        this.acceleratorLayoutLookup.set(entry.category, new Map());
      }

      const subcatMap = this.acceleratorLayoutLookup.get(entry.category);
      if (!subcatMap!.has(entry.subCategory)) {
        subcatMap!.set(entry.subCategory, []);
      }
    }
  }

  private addEntryToAcceleratorNameLookup(uuid: string, description: string):
      void {
    this.acceleratorNameLookup.set(uuid, description);
  }

  private addEntryToAcceleratorSubcategoryLookup(
      uuid: string, subcategory: AcceleratorSubcategory): void {
    this.acceleratorSubcategoryLookup.set(uuid, subcategory);
  }

  private addEntryToAcceleratorsBySubcategoryLookup(
      uuid: string, subcategory: AcceleratorSubcategory): void {
    const acceleratorIds =
        this.acceleratorIdsBySubcategoryLookup.get(subcategory) || [];
    acceleratorIds.push(uuid);
    this.acceleratorIdsBySubcategoryLookup.set(subcategory, acceleratorIds);
  }

  resetLookupMaps(): void {
    this.acceleratorLayoutLookup.clear();
    this.acceleratorNameLookup.clear();
    this.acceleratorSubcategoryLookup.clear();
    this.acceleratorIdsBySubcategoryLookup.clear();
  }
}