chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/output/braille_output.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 Provides braille output for ChromeVox.
 * Currently a stub; logic is being moved incrementally from Output to
 * BrailleOutput over a series of small changes.
 */

import {CursorRange} from '/common/cursors/range.js';

import {LogType} from '../../common/log_types.js';
import {Spannable} from '../../common/spannable.js';

import {OutputFormatLogger} from './output_logger.js';
import {AppendOptions, OutputNodeSpan, OutputSelectionSpan, SPACE} from './output_types.js';

type AutomationNode = chrome.automation.AutomationNode;
import RoleType = chrome.automation.RoleType;

export class BrailleOutput {
  readonly buffer: Spannable[] = [];
  readonly formatLog =
      new OutputFormatLogger('enableBrailleLogging', LogType.BRAILLE_RULE);

  equals(rhs: BrailleOutput): boolean {
    if (this.buffer.length !== rhs.buffer.length) {
      return false;
    }

    for (let i = 0; i < this.buffer.length; i++) {
      if (this.buffer[i].toString() !== rhs.buffer[i].toString()) {
        return false;
      }
    }
    return true;
  }

  subNode(range: CursorRange, options: AppendOptions): AppendOptions {
    const node = range.start.node;
    const rangeStart = range.start.index;
    const rangeEnd = range.end.index;

    options.annotation.push(new OutputNodeSpan(node));
    // TODO(b/314203187): Not null asserted, check that this is correct.
    const selStart = node.textSelStart!;
    const selEnd = node.textSelEnd!;

    if (selStart !== undefined && selEnd >= rangeStart &&
        selStart <= rangeEnd) {
      // Editable text selection.

      // |rangeStart| and |rangeEnd| are indices set by the caller and are
      // assumed to be inside of the range. In braille, we only ever expect
      // to get ranges surrounding a line as anything smaller doesn't make
      // sense.

      // |selStart| and |selEnd| reflect the editable selection. The
      // relative selStart and relative selEnd for the current line are then
      // just the difference between |selStart|, |selEnd| with |rangeStart|.
      // See editing_test.js for examples.
      options.annotation.push(
          new OutputSelectionSpan(selStart - rangeStart, selEnd - rangeStart));
    } else if (rangeStart !== 0 || rangeEnd !== range.start.getText().length) {
      // Non-editable text selection over less than the full contents
      // covered by the range. We exclude full content underlines because it
      // is distracting to read braille with all cells underlined with a
      // cursor.
      options.annotation.push(new OutputSelectionSpan(rangeStart, rangeEnd));
    }

    return options;
  }


  /** Converts the braille |spans| buffer to a single spannable. */
  static mergeSpans(spans: Spannable[]): Spannable {
    let separator = '';  // Changes to space as appropriate.
    let prevHasInlineNode = false;
    let prevIsName = false;
    return spans.reduce((result, cur) => {
      // Ignore empty spans except when they contain a selection.
      const hasSelection = cur.getSpanInstanceOf(OutputSelectionSpan);
      if (cur.length === 0 && !hasSelection) {
        return result;
      }

      // For empty selections, we just add the space separator to account for
      // showing the braille cursor.
      if (cur.length === 0 && hasSelection) {
        result.append(cur);
        result.append(SPACE);
        separator = '';
        return result;
      }

      // Keep track of if there's an inline node associated with
      // |cur|.
      const hasInlineNode =
          cur.getSpansInstanceOf(OutputNodeSpan)
              .some((spannableObj: Object) => {
                const spannable: {node: AutomationNode} =
                    spannableObj as {node: AutomationNode};
                if (!spannable.node) {
                  return false;
                }
                return spannable.node.display === 'inline' ||
                    spannable.node.role === RoleType.INLINE_TEXT_BOX;
              });

      const isName = cur.hasSpan('name');

      // Now, decide whether we should include separators between the previous
      // span and |cur|.
      // Never separate chunks without something already there at this point.

      // The only case where we know for certain that a separator is not
      // needed is when the previous and current values are in-lined and part
      // of the node's name. In all other cases, use the surrounding
      // whitespace to ensure we only have one separator between the node
      // text.
      if (result.length === 0 ||
          (hasInlineNode && prevHasInlineNode && isName && prevIsName)) {
        separator = '';
      } else if (
          result.toString()[result.length - 1] === SPACE ||
          cur.toString()[0] === SPACE) {
        separator = '';
      } else {
        separator = SPACE;
      }

      prevHasInlineNode = hasInlineNode;
      prevIsName = isName;
      result.append(separator);
      result.append(cur);
      return result;
    }, new Spannable());
  }
}