chromium/content/browser/resources/gpu/info_view.ts

// Copyright 2012 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 {CustomElement} from 'chrome://resources/js/custom_element.js';

import type {AngleFeature, BrowserBridge, ClientInfo, FeatureStatus, Problem} from './browser_bridge.js';
import {getTemplate} from './info_view.html.js';
import {VulkanInfo} from './vulkan_info.js';

/**
 * Given a blob and a filename, prompts user to
 * save as a file.
 */
const saveData = (function() {
  const a = document.createElement('a');
  a.style.display = 'none';
  document.body.appendChild(a);
  return function saveData(blob: Blob, fileName: string) {
    const url = window.URL.createObjectURL(blob);
    a.href = url;
    a.download = fileName;
    a.click();
  };
}());

function getProblemTextAndUrl(problem: Problem) {
  let text = problem.description;
  let url = '';
  const pattern = ' Please update your graphics driver via this link: ';
  const pos = text.search(pattern);
  if (pos > 0) {
    url = text.substring(pos + pattern.length);
    text = text.substring(0, pos);
  }
  return {text, url};
}

function formatANGLEBug(bug: string) {
  if (bug.includes('crbug.com/')) {
    return bug.match(/\d+/)!.toString();
  } else if (bug.includes('anglebug.com/')) {
    return `anglebug:${bug.match(/\d+/)}`;
  } else {
    return bug;
  }
}

/**
 * Calls a function to insert an element between every element
 * of an existing array
 */
function insertBetweenElements<Type>(
    array: Type[], fn: (i: number) => Type): Type[] {
  const newArray = array.slice(0, 1);
  for (let i = 1; i < array.length; ++i) {
    newArray.push(fn(i), array[i] as Type);
  }
  return newArray;
}

/** Inserts <span>, </span> between every element in array */
function separateByCommas(array: HTMLElement[], comma = ', ') {
  return insertBetweenElements(array, () => createElem('span', comma));
}

/**
 * Conditionally add elements to an array
 *
 * ```js
 * const array = [
 *   "carrots",
 *   "potatoes",
 *   ...addIf(haveFruit, () => ["apple", "cherry"]),
 * ]
 * ```
 *
 * The function is not called if `cond` is false.
 */
function addIf<T>(cond: boolean, fn: () => T[]) {
  return cond ? fn() : [];
}

/**
 * Word wraps a string, prefixing each line.
 */
function wordWrap(s: string, prefix = '    ', maxLength?: number) {
  maxLength = maxLength || (80 - prefix.length);
  const lines: string[] = [];
  const words = s.split(' ');
  const line: string[] = [];
  let length = 0;
  for (const word of words) {
    if (length + word.length + 1 >= maxLength) {
      lines.push(line.join(' '));
      line.length = 0;
      length = 0;
    }
    line.push(word);
    length += word.length + 1;
  }
  if (line.length) {
    lines.push(line.join(' '));
  }
  return lines.map(s => `${prefix}${s}`).join('\n');
}

interface Attributes {
  [key: string]: string|Attributes;
}

/**
 * Creates an HTMLElement with optional attributes and children
 *
 * Examples:
 *
 * ```js
 *   br = createElem('br');
 *   p = createElem('p', 'hello world');
 *   a = createElem('a', {href: 'https://google.com', textContent: 'Google'});
 *   ul = createElement('ul', {}, [
 *     createElem('li', 'apple'),
 *     createElem('li', 'banana'),
 *   ]);
 *   h1 = createElem('h1', { style: { color: 'red' }, textContent: 'Title'})
 * ```
 */
function createElem(
    tag: string, attrs: Attributes|string = {}, children: HTMLElement[] = []) {
  const elem = document.createElement(tag) as HTMLElement;
  if (typeof attrs === 'string') {
    elem.textContent = attrs;
  } else {
    const elemAsAttribs = elem as unknown as Attributes;
    for (const [key, value] of Object.entries(attrs)) {
      if (typeof value === 'function' && key.startsWith('on')) {
        const eventName = key.substring(2).toLowerCase();
        elem.addEventListener(eventName, value, {passive: false});
      } else if (typeof value === 'object') {
        for (const [k, v] of Object.entries(value)) {
          (elemAsAttribs[key] as Attributes)[k] = v;
        }
      } else if (elemAsAttribs[key] === undefined) {
        elem.setAttribute(key, value);
      } else {
        elemAsAttribs[key] = value;
      }
    }
  }
  for (const child of children) {
    elem.appendChild(child);
  }
  return elem;
}

export interface Data {
  description: string;
  id?: string;
  value: string;
}

export interface ArrayData {
  description: string;
  value: Data[];
}

/** Creates the td elements for a table row */
function createInfoElements(
    data: Data|ArrayData, padSize: number): HTMLElement[] {
  const desc = createElem('td', {}, [
    createElem('span', data.description.padEnd(padSize)),
    createHidden(':'),
  ]);

  if (Array.isArray(data.value)) {
    return [
      desc,
      createElem('td', {}, [createInfoTable((data as ArrayData).value)]),
    ];
  } else {
    return [
      desc,
      createElem('td', {
        textContent: data.value.toString().trim(),
        id: (data as Data).id!,
      }),
    ];
  }
}

/** Creates a table from the given data */
function createInfoTable(data: Data[]|ArrayData[]) {
  const longestDesc = Math.min(
      32,
      (data as Data[])
          .reduce(
              (longest, {description}) => Math.max(longest, description.length),
              0));
  return createElem('table', {className: 'info-table'}, [
    createElem(
        'tbody', {},
        data.map(
            data => createElem('tr', {}, createInfoElements(data, longestDesc)),
            )),
  ]);
}

/**
 * Creates a hidden span that will only be used when the when
 * the user copies or downloads text.
 */
function createHidden(textContent: string) {
  return createElem('span', {className: 'copy', textContent});
}

/**
 * Given a string or Attributes returns the `textContent`
 * and the attributes with `textContent` removed
 */
function separateTextContentFromAttributes(attrs: Attributes|string = {}) {
  return typeof attrs === 'string' ? {textContent: attrs, attribs: {}} : {
    textContent: attrs['textContent'] as string || '',
    attribs: {...attrs, textContent: ''},
  };
}

/**
 * Creates a list item with a hidden `*   ` span prepended for copy
 */
function createLi(attrs: Attributes|string = {}, children: HTMLElement[] = []) {
  const {textContent, attribs} = separateTextContentFromAttributes(attrs);
  return createElem('li', attribs, [
    createHidden('*   '),
    createElem('span', textContent),
    ...children,
  ]);
}

/**
 * Creates a heading tag with hidden text for copying
 * so the copy will be like markdown.
 */
function createHeading(
    tag: string, padChar: string, attrs: Attributes|string = {},
    children: HTMLElement[] = []) {
  const {textContent, attribs} = separateTextContentFromAttributes(attrs);
  return createElem(tag, attribs, [
    createHidden('\n\n'),
    createElem('span', textContent),
    createHidden(`\n${''.padEnd(textContent.length, padChar)}`),
    ...children,
  ]);
}

/**
 * Creates a link pair with an anchor tag that is visible
 * in the page and hidden text for copying so the copy
 * will appear as (href)
 */
function createLinkPair(textContent: string, href: string) {
  return [
    createElem('a', {
      className: 'hide-on-copy',
      textContent,
      href,
    }),
    createHidden(`(${href})`),
  ];
}

/**
 * Get a string data value
 */
function getDataValue(data: Data|ArrayData): string {
  return Array.isArray(data.value) ?
      data.value.map(data => getDataValue(data)).join(',') :
      data.value;
}

/**
 * Go through Datas and find ones that start with 'GPUx'
 * return the first with who's value ends itn '*ACTIVE*'
 * or else the first one.
 * @param data
 * @returns
 */
function getActiveGPU(data: Data[]|ArrayData[]) {
  // get list of GPUs
  const gpus =
      [...data].filter(({description}) => /^GPU\d+$/.test(description));
  // get list of active GPUs
  const active = gpus.filter(data => getDataValue(data).endsWith('*ACTIVE*'));
  const all = [...active, ...gpus];
  // get the first one
  if (all.length > 0) {
    const gpu = getDataValue(all[0]!);
    const parts = gpu.split(', ')[0]!.split('=');
    return parts.length === 2 && parts[0]! === 'VENDOR' ? parseInt(parts[1]!) :
                                                          0;
  }
  return 0;
}

/** convert a value to a string or empty string if null or undefined */
function safeString(value: any) {
  return typeof value === 'undefined' || value === null ? '' : value.toString();
}

const kSections = {
  featureStatus: ['Graphics Feature Status', 'ul'],
  clientInfo: ['Version Information', 'div'],
  basicInfo: ['Driver Information', 'div'],
  workarounds: ['Driver Bug Workarounds', 'ul'],
  problems: ['Problems Detected', 'ul'],
  angleFeatures: ['ANGLE Features', 'ul'],
  dawnInfo: ['Dawn Info', 'ul'],
  compositorInfo: ['Compositor Information', 'div'],
  gpuMemoryBufferInfo: ['GpuMemoryBuffers Status', 'div'],
  displayInfo: ['Display(s) Information', 'div'],
  videoAccelerationInfo: ['Video Acceleration Information', 'div'],
  vulkanInfo: ['Vulkan Information', 'div'],
  devicePerfInfo: ['Device Performance Information', 'div'],
  diagnostics: ['Diagnostics', 'div'],
  basicInfoForHardwareGpu: ['Driver Information for Hardware GPU', 'div'],
  featureStatusForHardwareGpu:
      ['Graphics Feature Status for Hardware GPU', 'ul'],
  workaroundsForHardwareGpu: ['Driver Bug Workarounds for Hardware GPU', 'ul'],
  problemsForHardwareGpu: ['Problems Detected for Hardware GPU', 'ul'],
  logMessages: ['Log Messages', 'ul'],
} as const;

interface Section {
  div: HTMLElement;
  list: HTMLElement;
  wrap: HTMLElement;
}

type Sections = {
  [key in keyof typeof kSections]: Section
};

/**
 * @fileoverview This view displays information on the current GPU
 * hardware.  Its primary usefulness is to allow users to copy-paste
 * their data in an easy to read format for bug reports.
 */
export class InfoViewElement extends CustomElement {
  browserBridge?: BrowserBridge;
  sections?: Sections;

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

  addBrowserBridgeListeners(browserBridge: BrowserBridge) {
    browserBridge.addEventListener(
        'gpuInfoUpdate', this.refresh.bind(this, browserBridge));
    browserBridge.addEventListener(
        'logMessagesChange', this.refresh.bind(this, browserBridge));
    browserBridge.addEventListener(
        'clientInfoChange', this.refresh.bind(this, browserBridge));
    this.refresh(browserBridge);
  }

  /**
   * public interface for testing
   */
  getInfo(category: string, feature: string = ''): string|string[] {
    const gpuInfo = this.browserBridge?.gpuInfo;
    if (!gpuInfo) {
      throw new Error('no gpuInfo');
    }

    switch (category) {
      case 'feature-status-for-hardware-gpu-list':
        return safeString(
            gpuInfo.featureStatusForHardwareGpu?.featureStatus[feature]);
      case 'feature-status-list':
        return safeString(gpuInfo.featureStatus?.featureStatus[feature]);
      case 'active-gpu-for-hardware':
        return safeString(getActiveGPU(gpuInfo.basicInfoForHardwareGpu));
      case 'active-gpu':
        return safeString(getActiveGPU(gpuInfo.basicInfo));
      case 'workarounds':
        return (gpuInfo.featureStatus || gpuInfo.featureStatusForHardwareGpu)
                   ?.workarounds ||
            [];
      default:
        throw new Error(`unknown category: ${category}`);
    }
  }

  getSelectionText(all: boolean) {
    const dynamicStyle = this.getRequiredElement('#dynamic-style')!;
    dynamicStyle.textContent = `
      #content { white-space: pre !important; }
      .copy { display: initial; }
      .hide-on-copy { display: none; }
    `;
    const contentDiv = this.getRequiredElement('#content')!;

    // document.getSelection doesn't work through shadowDom
    // and shadowRoot getSelection is non-standard chromium
    const shadowDoc = this.shadowRoot! as unknown as Document;
    const selection = shadowDoc.getSelection()!;

    if (all) {
      selection.removeAllRanges();
      selection.selectAllChildren(contentDiv);
    } else {
      const position =
          selection.anchorNode?.compareDocumentPosition(selection.focusNode!);
      const [startNode, startOffset, endNode, endOffset] =
          ((position || 0) & Node.DOCUMENT_POSITION_FOLLOWING) ?
          [
            selection.anchorNode!,
            selection.anchorOffset,
            selection.focusNode!,
            selection.focusOffset,
          ] :
          [
            selection.focusNode!,
            selection.focusOffset,
            selection.anchorNode!,
            selection.anchorOffset,
          ];
      if (startOffset === 0) {
        // Given the selection between > and <
        //
        //   * >abc
        //   * def<
        //
        // We need to move the start of the selection back to the parent
        // otherwise the selection above will copied as
        //
        //   abc
        //   * def
        //
        // since the * (the list item's bullet) is not selectable directly.
        const li = startNode.parentElement?.closest('li');
        selection.setBaseAndExtent(
            li || startNode.parentNode!, 0, endNode, endOffset);
      }
    }

    // Get text and remove superfluous lines and whitespace.
    const text = selection.toString()
                     .replace(/\s*\n\s*\n\s*\n+/g, '\n\n')
                     .replace(/\t/g, ' ')
                     .trim();

    if (all) {
      shadowDoc.getSelection()?.removeAllRanges();
    }

    dynamicStyle.textContent = '';
    return text;
  }


  connectedCallback() {
    // Add handler to 'download report to clipboard' button
    const downloadButton = this.getRequiredElement('#download-to-file');
    assert(downloadButton);
    downloadButton.onclick = (() => {
      const text = this.getSelectionText(true);
      const blob = new Blob([text], {type: 'text/text'});
      const filename = `about-gpu-${
          new Date().toISOString().replace(/[^a-z0-9-]/ig, '-')}.txt`;
      saveData(blob, filename);
    });

    // Add a copy handler to massage the text for plain text.
    document.addEventListener('copy', (event) => {
      const text = this.getSelectionText(false);
      event!.clipboardData!.setData('text/plain', text);
      event.preventDefault();
    });

    const contentDiv = this.getRequiredElement('#content')!;
    this.sections = Object.fromEntries(Object.entries(kSections).map(
                        ([propName, [title, tag]]) => {
                          const div = createHeading('h3', '=', title);
                          const list = createElem(tag);
                          const wrap = createElem('div', {}, [
                            div,
                            list,
                          ]);
                          contentDiv.appendChild(wrap);
                          return [propName, {div, list, wrap}];
                        })) as Sections;
  }

  /**
   * Updates the view based on its currently known data
   */
  refresh(browserBridge: BrowserBridge) {
    this.browserBridge = browserBridge;
    let clientInfo: ClientInfo;
    function createSourcePermalink(
        revisionIdentifier: string, filepath: string): string {
      if (revisionIdentifier.length !== 40) {
        // If the revision id isn't a hash, just use the 0.0.0.0 version
        // from the Chrome version string "Chrome/0.0.0.0".
        revisionIdentifier = clientInfo.version.split('/')[1]!;
      }
      return `https://chromium.googlesource.com/chromium/src/+/${
          revisionIdentifier}/${filepath}`;
    }

    const sections = this.sections!;

    // Client info
    if (browserBridge.clientInfo) {
      clientInfo = browserBridge.clientInfo;

      this.setTable_(sections.clientInfo, [
        {description: 'Data exported', value: (new Date()).toISOString()},
        {description: 'Chrome version', value: clientInfo.version},
        {description: 'Operating system', value: clientInfo.operating_system},
        {
          description: 'Software rendering list URL',
          value: createSourcePermalink(
              clientInfo.revision_identifier,
              'gpu/config/software_rendering_list.json'),
        },
        {
          description: 'Driver bug list URL',
          value: createSourcePermalink(
              clientInfo.revision_identifier,
              'gpu/config/gpu_driver_bug_list.json'),
        },
        {description: 'ANGLE commit id', value: clientInfo.angle_commit_id},
        {
          description: '2D graphics backend',
          value: clientInfo.graphics_backend,
        },
        {description: 'Command Line', value: clientInfo.command_line},
      ]);
    } else {
      sections.clientInfo.list.textContent = '... loading ...';
    }

    const gpuInfo = browserBridge.gpuInfo;
    if (gpuInfo) {
      // Not using jstemplate here for blocklist status because we construct
      // href from data, which jstemplate can't seem to do.
      if (gpuInfo.featureStatus) {
        this.appendFeatureInfo_(
            gpuInfo.featureStatus, sections.featureStatus.list,
            sections.problems, sections.workarounds);
      } else {
        sections.featureStatus.list.textContent = '';
        sections.problems.list.hidden = true;
        sections.workarounds.list.hidden = true;
      }

      const hideHardware = !gpuInfo.featureStatusForHardwareGpu;
      sections.basicInfoForHardwareGpu.div.hidden = hideHardware;
      sections.featureStatusForHardwareGpu.div.hidden = hideHardware;
      sections.problemsForHardwareGpu.div.hidden = hideHardware;
      sections.workaroundsForHardwareGpu.div.hidden = hideHardware;
      if (!hideHardware) {
        this.appendFeatureInfo_(
            gpuInfo.featureStatusForHardwareGpu,
            sections.featureStatusForHardwareGpu.list,
            sections.problemsForHardwareGpu,
            sections.workaroundsForHardwareGpu);
        this.setTable_(
            sections.basicInfoForHardwareGpu, gpuInfo.basicInfoForHardwareGpu);
      }

      this.setTable_(sections.basicInfo, gpuInfo.basicInfo);
      this.setTable_(sections.compositorInfo, gpuInfo.compositorInfo);
      this.setTable_(sections.gpuMemoryBufferInfo, gpuInfo.gpuMemoryBufferInfo);
      this.setTable_(sections.displayInfo, gpuInfo.displayInfo);
      this.setTable_(
          sections.videoAccelerationInfo, gpuInfo.videoAcceleratorsInfo);

      this.updateSectionList_(
          sections.angleFeatures, gpuInfo.ANGLEFeatures,
          angleFeature => this.createAngleFeatureEl_(angleFeature));

      this.updateSection_(sections.dawnInfo, () => {
        const show = !!gpuInfo.dawnInfo && gpuInfo.dawnInfo.length > 0;
        if (show) {
          this.createDawnInfoEl_(sections.dawnInfo.list, gpuInfo.dawnInfo!);
        }
        return show;
      });

      this.updateSectionTable_(sections.diagnostics, gpuInfo.diagnostics);

      this.setTable_(
          sections.vulkanInfo,
          gpuInfo.vulkanInfo ? [{
            'description': 'info',
            'value': new VulkanInfo(gpuInfo.vulkanInfo).toString(),
            'id': 'vulkan-info-value',
          }] :
                               []);

      this.setTable_(sections.devicePerfInfo, gpuInfo.devicePerfInfo);
    } else {
      sections.basicInfo.list.textContent = '... loading ...';
      sections.diagnostics.div.hidden = true;
      sections.featureStatus.list.textContent = '';
      sections.problems.div.hidden = true;
      sections.dawnInfo.div.hidden = true;
    }

    // Log messages
    sections.logMessages.list.textContent = '';
    browserBridge.logMessages.forEach(messageObj => {
      sections.logMessages.list.appendChild(
          createElem('li', `${messageObj.header}: ${messageObj.message}`));
    });
  }

  /**
   * Clears a section and then updates it by calling fn. If fn returns false
   * it hides the section.
   */
  private updateSection_(section: Section, fn: () => boolean) {
    section.list.textContent = '';
    const show = fn();
    section.div.hidden = !show;
  }

  /**
   * Clears and and updates a section from a list. If the list is empty it
   * hides the section
   */
  private updateSectionList_<T>(
      section: Section, list: T[]|undefined, fn: (item: T) => HTMLElement) {
    this.updateSection_(section, () => {
      if (list) {
        for (const item of list) {
          section.list.appendChild(fn(item));
        }
      }
      return !!list && list.length > 0;
    });
  }

  /** Update a table, hiding it of the table has no elements */
  private updateSectionTable_(
      section: Section, inputData: Data[]|ArrayData[]|undefined) {
    this.updateSection_(section, () => {
      this.setTable_(section, inputData);
      return !!inputData && inputData.length > 0;
    });
  }

  private appendFeatureInfo_(
      featureInfo: FeatureStatus, featureStatusList: HTMLElement,
      problems: Section, workarounds: Section) {
    // Feature map
    const featureLabelMap: Record<string, string> = {
      '2d_canvas': 'Canvas',
      'gpu_compositing': 'Compositing',
      'webgl': 'WebGL',
      'multisampling': 'WebGL multisampling',
      'texture_sharing': 'Texture Sharing',
      'video_decode': 'Video Decode',
      'rasterization': 'Rasterization',
      'opengl': 'OpenGL',
      'metal': 'Metal',
      'vulkan': 'Vulkan',
      'multiple_raster_threads': 'Multiple Raster Threads',
      'native_gpu_memory_buffers': 'Native GpuMemoryBuffers',
      'protected_video_decode': 'Hardware Protected Video Decode',
      'surface_control': 'Surface Control',
      'vpx_decode': 'VPx Video Decode',
      'webgl2': 'WebGL2',
      'canvas_oop_rasterization': 'Canvas out-of-process rasterization',
      'raw_draw': 'Raw Draw',
      'video_encode': 'Video Encode',
      'direct_rendering_display_compositor':
          'Direct Rendering Display Compositor',
      'webgpu': 'WebGPU',
      'skia_graphite': 'Skia Graphite',
      'webnn': 'WebNN',
    };

    const statusMap: Record<string, {label: string, class: string}> = {
      'disabled_software': {
        'label': 'Software only. Hardware acceleration disabled',
        'class': 'feature-yellow',
      },
      'disabled_off': {'label': 'Disabled', 'class': 'feature-red'},
      'disabled_off_ok': {'label': 'Disabled', 'class': 'feature-yellow'},
      'unavailable_software': {
        'label': 'Software only, hardware acceleration unavailable',
        'class': 'feature-yellow',
      },
      'unavailable_off': {'label': 'Unavailable', 'class': 'feature-red'},
      'unavailable_off_ok': {
        'label': 'Unavailable',
        'class': 'feature-yellow',
      },
      'enabled_readback': {
        'label': 'Hardware accelerated but at reduced performance',
        'class': 'feature-yellow',
      },
      'enabled_force': {
        'label': 'Hardware accelerated on all pages',
        'class': 'feature-green',
      },
      'enabled': {'label': 'Hardware accelerated', 'class': 'feature-green'},
      'enabled_on': {'label': 'Enabled', 'class': 'feature-green'},
      'enabled_force_on': {'label': 'Force enabled', 'class': 'feature-green'},
    };

    // feature status list
    featureStatusList.textContent = '';
    for (const featureName in featureInfo.featureStatus) {
      const featureStatus = featureInfo.featureStatus[featureName]!;

      const label = featureLabelMap[featureName];
      if (!label) {
        console.info('Missing featureLabel for', featureName);
      }

      const statusInfo = statusMap[featureStatus];
      if (!statusInfo) {
        console.info('Missing status for ', featureStatus);
      }

      featureStatusList.appendChild(createLi({}, [
        createElem('span', `${label}: `),

        createElem(
            'span',
            statusInfo ? {
              textContent: statusInfo['label'],
              className: statusInfo['class'],
            } :
                         {
                           textContent: 'Unknown',
                           className: 'feature-red',
                         },
            ),
      ]));
    }

    // problems list
    this.updateSectionList_(
        problems, featureInfo.problems,
        problem => this.createProblemEl_(problem));

    // driver bug workarounds list
    this.updateSectionList_(
        workarounds, featureInfo.workarounds,
        workaround => createLi(workaround));
  }

  private createProblemEl_(problem: Problem): HTMLElement {
    const {text, url} = getProblemTextAndUrl(problem);
    return createLi({}, [
      createElem('span', text),

      // add bug separator
      ...addIf(
          problem.crBugs.length > 0, () => [createElem('span', ':\n    ')]),

      // add bugs
      ...separateByCommas(problem.crBugs.map((id) => {
        const bugId = parseInt(id);
        const href = `http://crbug.com/${bugId}`;
        return createElem('span', {}, createLinkPair(bugId.toString(), href));
      })),

      // add affectedGpuSettings
      ...addIf(
          problem.affectedGpuSettings.length > 0,
          () =>
              [createElem('br'),
               createHidden('    '),
               createElem(
                   'i', {},
                   [
                     createElem(
                         'span',
                         problem.tag === 'disabledFeatures' ?
                             'Disabled Features: ' :
                             'Applied Workarounds: '),
                     ...separateByCommas(
                         problem.affectedGpuSettings.map(
                             (textContent) => createElem('span', {
                               textContent,
                               className: problem.tag === 'disabledFeatures' ?
                                   'feature-red' :
                                   'feature-yellow',
                             }),
                             ),
                         ',\n        '),
                   ]),
    ]),

      // add driver update link
      ...addIf(
          !!url,
          () =>
              [createElem('br'),
               createHidden('    '),
               createElem(
                   'b', {className: 'bg-yellow'},
                   [
                     createElem(
                         'span', 'Please update your graphics drive via '),
                     createElem('a', {textContent: 'this link', href: url}),
                   ]),
    ]),

      // for copy spacing
      createElem('span', '\n\n'),

    ]);
  }

  private createAngleFeatureEl_(angleFeature: AngleFeature) {
    return createLi({}, [
      // Name comes first, bolded
      createElem('b', angleFeature.name),

      // If there's a category, it follows the name in parentheses
      ...addIf(
          !!angleFeature.category,
          () =>
              [createElem('span', ` (${angleFeature.category})`),
    ]),

      // If there's a bug link, try to parse the crbug/anglebug number
      ...addIf(
          !!angleFeature.bug,
          () =>
              [createElem('span', ' '),
               ...createLinkPair(
                   formatANGLEBug(angleFeature.bug), angleFeature.bug),
    ]),

      // Follow with a colon, and the status (colored)
      createElem('span', ': '),
      createElem(
          'span',
          angleFeature.status === 'enabled' ?
              {className: 'feature-green', textContent: 'Enabled'} :
              {className: 'feature-red', textContent: 'Disabled'}),

      ...addIf(
          !!angleFeature.condition,
          () =>
              [createHidden('\n    condition'),
               createElem('span', {
                 className: 'feature-gray',
                 textContent: `: ${angleFeature.condition}`,
               }),
    ]),

      ...addIf(
          !!angleFeature.description,
          () =>
              [createElem('br'),
               createElem('i', wordWrap(angleFeature.description!)),
    ]),

      // for copy spacing
      createElem('span', '\n\n'),
    ]);
  }

  private setTable_(section: Section, inputData: Data[]|ArrayData[]|undefined) {
    section.list.textContent = '';
    section.list.appendChild(createInfoTable(inputData || []));
  }

  private createDawnInfoEl_(dawnInfoList: HTMLElement, gpuDawnInfo: string[]) {
    dawnInfoList.textContent = '';
    let inProcessingToggles = false;

    for (let i = 0; i < gpuDawnInfo.length; ++i) {
      const infoString = gpuDawnInfo[i]!;
      let infoEl: HTMLElement;

      if (infoString.startsWith('<')) {
        // GPU type and backend type.
        // Add an empty line for the next adaptor.
        dawnInfoList.appendChild(createElem('br'));

        // e.g. <Discrete GPU> D3D12 backend
        infoEl = createHeading('h3', '-', infoString);
        inProcessingToggles = false;
      } else if (infoString.startsWith('[')) {
        // e.g. [Enabled Toggle Names]
        infoEl = createHeading('h4', '-', {
          className: 'dawn-info-header',
          textContent: infoString,
        });

        if (infoString === '[WebGPU Status]' ||
            infoString === '[Adapter Supported Features]') {
          inProcessingToggles = false;
        } else {
          inProcessingToggles = true;
        }
      } else if (inProcessingToggles) {
        // Each toggle takes 3 strings
        infoEl = createLi({}, [
          // The toggle name comes first, bolded.
          createElem('b', `${infoString}: \n    `),

          // URL
          ...createLinkPair(gpuDawnInfo[++i]!, gpuDawnInfo[i]!),

          // Description, italicized
          createElem('i', `:\n${wordWrap(gpuDawnInfo[++i]!)}`),

          // for copy spacing
          createElem('span', '\n\n'),
        ]);
      } else {
        // Display supported extensions
        infoEl = createLi(infoString);
      }

      dawnInfoList.appendChild(infoEl);
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'info-view': InfoViewElement;
  }
}

customElements.define('info-view', InfoViewElement);