chromium/tools/binary_size/libsupersize/viewer/static/state.js

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

'use strict';

/**
 * @fileoverview
 * Methods for manipulating the state and the DOM of the page
 */

/**
 * @typedef {string|number|boolean} StateValue
 */

/**
 * @typedef {Object} HasValue
 * @property {StateValue} value - Readable and writable value.
 */

/**
 * Encapsulation of an UI state, supporting observers on setting values.
 */
class UiState {
  /** @param {!StateValue} defaultValue */
  constructor(defaultValue) {
    /** @protected {!StateValue} */
    this.defaultValue = defaultValue;

    /** @protected {!StateValue} */
    this.value = this.defaultValue;

    /** @private {!Array<!function(): *>} */
    this.observers = [];
  }

  /**
   * @param {!function(): *} observer
   * @public
   */
  addObserver(observer) {
    this.observers.push(observer);
  }

  /** @protected */
  notifyObservers() {
    for (const observer of this.observers) {
      observer();
    }
  }

  /**
   * @return {!StateValue}
   * @public
   */
  get() {
    return this.value;
  }

  /**
   * @param {!StateValue} v
   * @public
   */
  set(v) {
    this.value = v;
    this.notifyObservers();
  }
}

/**
 * UiState supporting reads from / writes to a provided query param.
 */
class QueryParamUiState extends UiState {
  /**
   * @param {string} name
   * @param {!StateValue} defaultValue
   * @param {?function(string): StateValue} parser
   * @param {boolean} isHash
   */
  constructor(name, defaultValue, parser, isHash) {
    super(defaultValue);

    /** @public @const {string} */
    this.name = name;

    /** @private @const {?function(string): StateValue} null = identity. */
    this.parser = parser;

    /** @public @const {boolean} */
    this.isHash = isHash;

    /** @public {string} */
    this.hidden = false;
  }

  /**
   * @param {!URLSearchParams} params
   * @public
   */
  readFromSearchParams(params) {
    if (params.has(this.name)) {
      const s = params.get(this.name);
      this.value = this.parser ? this.parser(s) : s;
    } else {
      this.value = this.defaultValue;
    }
    this.notifyObservers();
  }

  /**
   * @param {!URLSearchParams} params
   * @public
   */
  writeToSearchParams(params) {
    if (this.hidden || this.value === this.defaultValue) {
      params.delete(this.name);
    } else {
      const s = (this.value === true) ? 'on' : this.value.toString();
      params.set(this.name, s);
    }
  }
}

/**
 * QueryParamUiState that syncs with UI elements.
 */
class ElementUiState extends QueryParamUiState {
  /**
   * @param {string} name
   * @param {!HasValue} elt
   * @param {boolean} isHash
   */
  constructor(name, elt, isHash) {
    let parser = null;
    let readElt = () => elt.value;
    let writeElt = (v) => {
      elt.value = v;
    };

    // Define Element specific adapters for data access to concentrate the mess,
    // and reduce boilerplate from defining stateful adapter classes.
    if (elt instanceof HTMLInputElement) {
      const input = /** @type {HTMLInputElement} */ (elt);
      if (input.type === 'number') {
        parser = (s) => {
          const ret = parseInt(s, 10);
          return isNaN(ret) ? this.defaultValue : ret;
        };
      } else if (input.type === 'checkbox') {
        parser = (s) => s === 'on' || s === 'true' || s === '1';
        readElt = () => elt.checked;
        writeElt = (v) => {
          elt.checked = v;
        };
      }
    } else if (elt instanceof HTMLSelectElement) {
      const sel = /** @type {!HTMLSelectElement} */ (elt);
      const values =
          new Set(Array.from(sel.querySelectorAll('option'), e => e.value));
      parser = (s) => values.has(s) ? s : this.defaultValue;
    } else if (elt instanceof RadioNodeList) {
      const inputs = Array.from(
          /** @type {!RadioNodeList} */ (elt),
          e => /** @type {HTMLInputElement} */ (e));
      assert(inputs.length > 0);
      if (inputs[0].type === 'radio') {
        const values = new Set(Array.from(inputs, e => e.value));
        parser = (s) => values.has(s) ? s : this.defaultValue;
      } else if (inputs[0].type === 'checkbox') {
        readElt = () => {
          return Array.from(inputs, e => e.checked ? e.value : '').join('');
        };
        writeElt = (s) => {
          const values = new Set(Array.from(s));
          for (const e of inputs) {
            e.checked = values.has(e.value);
          }
        };
      } else {
        throw new Error(`Unknown RadioNodeList type: ${inputs[0].type}.`);
      }
    } else {
      throw new Error('Unknown element type.');
    }

    super(name, readElt(), parser, isHash);

    /** @private @const {function(): StateValue} */
    this.readElt = readElt;

    /** @private @const {function(): StateValue} */
    this.writeElt = writeElt;
  }

  /**
   * @param {!StateValue} v
   * @public @override
   */
  set(v) {
    super.set(v);
    this.writeElt(/** @type {StateValue} */ (this.value));
  }

  /**
   * @param {!URLSearchParams} params
   * @public @override
   */
  readFromSearchParams(params) {
    super.readFromSearchParams(params);
    this.writeElt(/** @type {StateValue} */ (this.value));
  }

  /** @public */
  syncFromElt() {
    // Calling super.set() to avoid redundant writeElt() call in this.set().
    super.set(/** @type {StateValue} */ (this.readElt()));
  }
}

/** Build utilities for working with the state. */
class MainState {
  constructor() {
    /** @private @const {!Array<!QueryParamUiState>} */
    this.uiStates = [];

    /**
     * Instantiation helper that also pushes object to |uiStates|.
     * @param {string} name
     * @param {?HasValue} elt
     * @param {boolean=} isHash
     */
    const newUiState = (name, elt, isHash = false) => {
      if (!elt) {
        // Assume string value with defaultValue == ''.
        this.uiStates.push(new QueryParamUiState(name, '', null, isHash));
      } else {
        this.uiStates.push(new ElementUiState(name, elt, isHash));
      }
      return this.uiStates[this.uiStates.length - 1];
    };

    /**
     * @public @const {!QueryParamUiState} Active "load" URL that gets updated
     *   on "Upload data".
     */
    this.stLoadUrl = newUiState(STATE_KEY.LOAD_URL, null);

    /**
     * @public @const {!QueryParamUiState} "Before" URL that gets cleared on
     *   "Upload data".
     */
    this.stBeforeUrl = newUiState(STATE_KEY.BEFORE_URL, null);

    /** @public @const {!ElementUiState} */
    this.stMethodCount = newUiState(STATE_KEY.METHOD_COUNT, g_el.cbMethodCount);

    /** @public @const {!ElementUiState} */
    this.stByteUnit = newUiState(STATE_KEY.BYTE_UNIT, g_el.selByteUnit);

    /** @public @const {!ElementUiState} */
    this.stGroupBy = newUiState(STATE_KEY.GROUP_BY, g_el.rnlGroupBy);

    /** @public @const {!ElementUiState} */
    this.stMinSize = newUiState(STATE_KEY.MIN_SIZE, g_el.nbMinSize);

    /** @public @const {!ElementUiState} */
    this.stInclude = newUiState(STATE_KEY.INCLUDE, g_el.tbIncludeRegex);

    /** @public @const {!ElementUiState} */
    this.stExclude = newUiState(STATE_KEY.EXCLUDE, g_el.tbExcludeRegex);

    /** @public @const {!ElementUiState} */
    this.stType = newUiState(STATE_KEY.TYPE, g_el.rnlType);

    /** @public @const {!ElementUiState} */
    this.stFlagFilter = newUiState(STATE_KEY.FLAG_FILTER, g_el.rnlFlagFilter);

    /** @public @const {!QueryParamUiState} */
    this.stFocus = newUiState(STATE_KEY.FOCUS, null, true);

    /** @private {boolean} */
    this.diffMode = false;
  }

  /**
   * Formats the filter state as a string.
   * @return {string}
   * @private
   */
  toString() {
    const queryParams = new URLSearchParams();
    const hashParams = new URLSearchParams();
    for (const st of this.uiStates) {
      st.writeToSearchParams(st.isHash ? hashParams : queryParams);
    }
    const queryString = queryParams.toString();
    const hashString = hashParams.toString();
    return (queryString.length > 0 ? `?${queryString}` : '') +
        (hashString.length > 0 ? `#${hashString}` : '');
  }

  /** @private */
  updateUrlParams() {
    // Passing empty `state` leads to no change, so use `location.pathname`.
    history.replaceState(null, null, this.toString() || location.pathname);
  }

  /**
   * @return {?function(string): boolean}
   * @public
   */
  getFilter() {
    const getRegExpOrNull = (s) => {
      if (s) {
        try {
          return new RegExp(s);
        } catch (err) {
        }
      }
      return null;
    };

    const includeRE = getRegExpOrNull(this.stInclude.get());
    const excludeRE = getRegExpOrNull(this.stExclude.get());
    if (includeRE) {
      return excludeRE ? (s) => includeRE.test(s) && !excludeRE.test(s) :
                         (s) => includeRE.test(s);
    }
    return excludeRE ? (s) => !excludeRE.test(s) : null;
  }

  /**
   * @return {boolean}
   * @public
   */
  getDiffMode() {
    return this.diffMode;
  }

  /**
   * @param {boolean} diffMode
   * @public
   */
  setDiffMode(diffMode) {
    this.diffMode = diffMode;
  }

  /**
   * @return {!BuildOptions}
   * @public
   */
  exportToBuildOptions() {
    const ret = /** @type {BuildOptions} */ ({});
    ret.loadUrl = /** @type {string} */ (this.stLoadUrl.get());
    ret.beforeUrl = /** @type {string} */ (this.stBeforeUrl.get());
    ret.methodCountMode = /** @type {boolean} */ (this.stMethodCount.get());
    // Skipping |this.stByteUnit|.
    ret.minSymbolSize = /** @type {number} */ (this.stMinSize.value);
    ret.groupBy = /** @type {string} */ (this.stGroupBy.get());
    ret.includeRegex = /** @type {string} */ (this.stInclude.get());
    ret.excludeRegex = /** @type {string} */ (this.stExclude.get());
    if (ret.methodCountMode) {
      ret.includeSections = _DEX_METHOD_SYMBOL_TYPE;
    } else {
      ret.includeSections = /** @type {string} */ (this.stType.get());
    }
    const flagToFilterStr = /** @type {string} */ (this.stFlagFilter.get());
    ret.flagToFilter = _NAMES_TO_FLAGS[flagToFilterStr] ?? 0;
    ret.nonOverhead = flagToFilterStr === 'nonoverhead';
    ret.disassemblyMode = flagToFilterStr === 'disassembly';
    return ret;
  }

  /** @public */
  init() {
    const queryParams = new URLSearchParams(location.search.slice(1));
    const hashParams = new URLSearchParams(location.hash.slice(1));
    for (const st of this.uiStates) {
      st.readFromSearchParams(st.isHash ? hashParams : queryParams);
    }
    // At this point it's possible to update the URL to fix mistakes and
    // canonicalize (e.g., param ordering). However, we choose to NOT do this
    // since it's disconcerting for the user, as they might want to continue
    // editing or tweaking the URL.

    const loadUrl = /** @type {string} */ (this.stLoadUrl.get());
    const beforeUrl = /** @type {string} */ (this.stBeforeUrl.get());
    this.setDiffMode(
        Boolean(loadUrl && (loadUrl.endsWith('.sizediff') || beforeUrl)));

    // If load_url changes beyond initial load, clear before_url and hide both
    // in query params.
    this.stLoadUrl.addObserver(() => {
      this.stBeforeUrl.set('');
      if (!this.stLoadUrl.hidden) {
        this.stLoadUrl.hidden = true;
        this.stBeforeUrl.hidden = true;
        this.updateUrlParams();
      }
    });

    // Update states on form change.
    g_el.frmOptions.addEventListener('change', (e) => {
      for (const st of this.uiStates) {
        if (st instanceof ElementUiState)
          st.syncFromElt();
      }
      this.updateUrlParams();
    });

    this.stFocus.addObserver(() => this.updateUrlParams());
  }
}

function _startListeners() {
  const _SHOW_OPTIONS_STORAGE_KEY = 'show-options';

  /** @type {RadioNodeList} */
  const typeCheckboxes = /** @type {RadioNodeList} */ (
      g_el.frmOptions.elements.namedItem(STATE_KEY.TYPE));

  /**
   * The settings dialog on the side can be toggled on and off by elements with
   * a 'toggle-options' class.
   */
  function _toggleOptions() {
    const openedOptions = document.body.classList.toggle('show-options');
    localStorage.setItem(_SHOW_OPTIONS_STORAGE_KEY, openedOptions.toString());
  }
  for (const node of g_el.nlShowOptions) {
    node.addEventListener('click', _toggleOptions);
  }
  // Default to open if getItem returns null
  if (localStorage.getItem(_SHOW_OPTIONS_STORAGE_KEY) !== 'false') {
    document.body.classList.add('show-options');
  }

  /** Disables some fields when method_count is set. */
  function setMethodCountModeUI() {
    if (state.stMethodCount.get()) {
      g_el.selByteUnit.setAttribute('disabled', '');
      g_el.fsTypesFilter.setAttribute('disabled', '');
      g_el.spanSizeHeader.textContent = 'Methods';
    } else {
      g_el.selByteUnit.removeAttribute('disabled');
      g_el.fsTypesFilter.removeAttribute('disabled');
      g_el.spanSizeHeader.textContent = 'Size';
    }
  }
  setMethodCountModeUI();
  state.stMethodCount.addObserver(setMethodCountModeUI);

  /**
   * Displays error text on blur for regex inputs, if the input is invalid.
   * @param {Event} event
   */
  function checkForRegExError(event) {
    const input = /** @type {HTMLInputElement} */ (event.currentTarget);
    const errorBox = g_el.getAriaDescribedBy(input);
    try {
      new RegExp(input.value);
      errorBox.textContent = '';
      input.setAttribute('aria-invalid', 'false');
    } catch (err) {
      errorBox.textContent = err.message;
      input.setAttribute('aria-invalid', 'true');
    }
  }
  for (const input of [g_el.tbIncludeRegex, g_el.tbExcludeRegex]) {
    input.addEventListener('blur', checkForRegExError);
    input.dispatchEvent(new Event('blur'));
  }

  g_el.btnTypeAll.addEventListener('click', () => {
    for (const checkbox of typeCheckboxes) {
      /** @type {HTMLInputElement} */ (checkbox).checked = true;
    }
    g_el.frmOptions.dispatchEvent(new Event('change'));
  });
  g_el.btnTypeNone.addEventListener('click', () => {
    for (const checkbox of typeCheckboxes) {
      /** @type {HTMLInputElement} */ (checkbox).checked = false;
    }
    g_el.frmOptions.dispatchEvent(new Event('change'));
  });

  // Outside of input, make pressing "?" open FAQ page.
  window.addEventListener('keydown', event => {
    if (event.key === '?' &&
        /** @type {HTMLElement} */ (event.target).tagName !== 'INPUT') {
      // Open help when "?" is pressed.
      g_el.linkFaq.click();
    }
  });
}

function _makeIconTemplateGetter() {
  const getSymbolIcon = (q) => assertNotNull(g_el.divIcons.querySelector(q));

  /**
   * @type {{[type:string]: SVGSVGElement}} Icon elements
   * that correspond to each symbol type.
   */
  const symbolIcons = {
    D: getSymbolIcon('.foldericon'),
    G: getSymbolIcon('.groupicon'),
    J: getSymbolIcon('.javaclassicon'),
    F: getSymbolIcon('.fileicon'),
    b: getSymbolIcon('.bssicon'),
    d: getSymbolIcon('.dataicon'),
    r: getSymbolIcon('.readonlyicon'),
    t: getSymbolIcon('.codeicon'),
    R: getSymbolIcon('.relroicon'),
    x: getSymbolIcon('.dexothericon'),
    m: getSymbolIcon('.dexmethodicon'),
    p: getSymbolIcon('.localpakicon'),
    P: getSymbolIcon('.nonlocalpakicon'),
    a: getSymbolIcon('.arscicon'),
    o: getSymbolIcon('.othericon'),  // used as default icon
    '*': null,
  };

  const getDiffStatusIcon = (q) => {
    return assertNotNull(g_el.divDiffStatusIcons.querySelector(q));
  };
  const statusIcons = {
    added: getDiffStatusIcon('.addedicon'),
    removed: getDiffStatusIcon('.removedicon'),
    changed: getDiffStatusIcon('.changedicon'),
    unchanged: getDiffStatusIcon('.unchangedicon'),
  };

  const getMiscIcon = (q) => {
    return assertNotNull(g_el.divMiscIcons.querySelector(q))
  };
  const metricsIcons = {
    group: getSymbolIcon('.groupicon'),  // Reuse.
    elf: getMiscIcon('.elficon'),
    dex: getMiscIcon('.dexicon'),
    arsc: getSymbolIcon('.arscicon'),  // Reuse.
    metrics: getMiscIcon('.metricsicon'),
    other: getSymbolIcon('.othericon'),  // Reuse.
  };

  const metadataIcons = {
    root: getMiscIcon('.metadataicon'),
    group: getSymbolIcon('.groupicon'),  // Reuse.
  };

  /** @type {Map<string, {color:string, description:string}>} */
  const iconInfoCache = new Map();

  /**
   * Returns the SVG icon template element corresponding to the given type.
   * @param {string} type Symbol type character.
   * @param {boolean} readonly If true, the original template is returned.
   * If false, a copy is returned that can be modified.
   * @return {SVGSVGElement}
   */
  function getIconTemplate(type, readonly = false) {
    const iconTemplate = symbolIcons[type] || symbolIcons[_OTHER_SYMBOL_TYPE];
    return /** @type {SVGSVGElement} */ (
        readonly ? iconTemplate : iconTemplate.cloneNode(true));
  }

  /**
   * @param {string} type Symbol type character.
   * @param {?string} fill If non-null, fill color of icon.
   */
  function getIconTemplateWithFill(type, fill) {
    const icon = getIconTemplate(type);
    if (fill)
      icon.setAttribute('fill', fill);
    return icon;
  }

  /**
   * Returns style info about SVG icon template element corresponding to the
   * given type.
   * @param {string} type Symbol type character.
   */
  function getIconStyle(type) {
    let info = iconInfoCache.get(type);
    if (!info) {
      const icon = getIconTemplate(type, true);
      info = {
        color: icon.getAttribute('fill'),
        description: icon.querySelector('title').textContent,
      };
      iconInfoCache.set(type, info);
    }
    return info;
  }

  /**
   * Returns the SVG status icon template element corresponding to the diff
   * status of the node. Only valid for leaf nodes.
   * @param {TreeNode} node Leaf node whose diff status is used to select
   * template.
   * @return {SVGSVGElement}
   */
  function getDiffStatusTemplate(node) {
    const isLeaf = node.children && node.children.length === 0;
    const entries = Object.entries(node.childStats);
    let key = 'unchanged';
    if (isLeaf && entries.length !== 0) {
      const statsEntry = entries[0][1];
      if (statsEntry.added) {
        key = 'added';
      } else if (statsEntry.removed) {
        key = 'removed';
      } else if (statsEntry.changed) {
        key = 'changed';
      }
    } else if (node.diffStatus === _DIFF_STATUSES.ADDED) {
      key = 'added';
    } else if (node.diffStatus === _DIFF_STATUSES.REMOVED) {
      key = 'removed';
    }
    return statusIcons[key].cloneNode(true);
  }

  /**
   * @param {string} key
   * @return {SVGSVGElement}
   */
  function getMetricsIconTemplate(key) {
    return metricsIcons[key].cloneNode(true);
  }

  /**
   * @param {string} key
   * @return {SVGSVGElement}
   */
  function getMetadataIconTemplate(key) {
    return metadataIcons[key].cloneNode(true);
  }

  return {
    getIconTemplate,
    getIconTemplateWithFill,
    getIconStyle,
    getDiffStatusTemplate,
    getMetricsIconTemplate,
    getMetadataIconTemplate,
  };
}

function _makeSizeTextGetter() {
  /**
   * @param {number} bytes
   * @return {!DocumentFragment}
   */
  function makeBytesElement(bytes) {
    const unit = /** @type {string} */ (state.stByteUnit.get());
    const suffix = _BYTE_UNITS[unit];
    // Format |bytes| as a number with 2 digits after the decimal point
    const text = formatNumber(bytes / suffix, 2, 2);
    const textNode = document.createTextNode(`${text} `);
    // Display the suffix with a smaller font
    const suffixElement = dom.textElement('small', unit);

    return dom.createFragment([textNode, suffixElement]);
  }

  /**
   * Create the contents for the size element of a tree node.
   * The unit to use is selected from the current state.
   *
   * If in method count mode, size instead represents the amount of methods in
   * the node. Otherwise, the original number of bytes will be displayed.
   *
   * @param {TreeNode} node Node whose size is the number of bytes to use for
   * the size text
   * @return {GetSizeResult} Object with hover text title and size element
   * body.
   */
  function getSizeContents(node) {
    if (state.stMethodCount.get()) {
      const {count: methodCount = 0} =
        node.childStats[_DEX_METHOD_SYMBOL_TYPE] || {};
      const methodStr = formatNumber(methodCount);
      return {
        description: `${methodStr} method${methodCount === 1 ? '' : 's'}`,
        element: document.createTextNode(methodStr),
        value: methodCount,
      };

    } else {
      const isLeaf = node.children && node.children.length === 0;
      const bytes = node.size;
      const descriptionToks = [];
      // Show "before → after" only for leaf nodes, since group nodes'
      // |beforeSize| would miss contributions from unchanged symbols.
      if (isLeaf && ('beforeSize' in node)) {
        const before = formatNumber(node.beforeSize);
        const after = formatNumber(node.beforeSize + bytes);
        descriptionToks.push(`(${before} → ${after})`);  // '→' is '\u2192'.
      }
      descriptionToks.push(`${formatNumber(bytes)} bytes`);
      if (node.numAliases && node.numAliases > 1) {
        descriptionToks.push(`for 1 of ${node.numAliases} aliases`);
      }

      return {
        description: descriptionToks.join(' '),
        element: makeBytesElement(bytes),
        value: bytes,
      };
    }
  }

  /**
   * Set classes on an element based on the size it represents.
   * @param {HTMLElement} sizeElement
   * @param {number} value
   * @param {boolean} isCount Whether |value| is count (true) or byte (false).
   */
  function setSizeClasses(sizeElement, value, isCount) {
    const cutOff = isCount ? 10 : 50000;
    const shouldHaveStyle = state.getDiffMode() && Math.abs(value) > cutOff;

    if (shouldHaveStyle) {
      if (value < 0) {
        sizeElement.classList.add('shrunk');
        sizeElement.classList.remove('grew');
      } else {
        sizeElement.classList.remove('shrunk');
        sizeElement.classList.add('grew');
      }
    } else {
      sizeElement.classList.remove('shrunk', 'grew');
    }
  }

  return {makeBytesElement, getSizeContents, setSizeClasses};
}

/** Global UI State. */
const state = new MainState();
state.init();

/** Utilities for working with the state */
const {
  getIconTemplate,
  getIconTemplateWithFill,
  getIconStyle,
  getDiffStatusTemplate,
  getMetricsIconTemplate,
  getMetadataIconTemplate,
} = _makeIconTemplateGetter();
const {makeBytesElement, getSizeContents, setSizeClasses} =
    _makeSizeTextGetter();
_startListeners();