chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/braille/braille_input_handler.js

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

/**
 * @fileoverview Handles braille input keys when the user is typing or editing
 * text in an input field.  This class cooperates with the Braille IME
 * that is built into Chrome OS to do the actual text editing.
 */

import {EventGenerator} from '/common/event_generator.js';
import {StringUtil} from '/common/string_util.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {BrailleKeyCommand, BrailleKeyEvent} from '../../common/braille/braille_key_types.js';
import {Spannable} from '../../common/spannable.js';

import {BrailleTranslatorManager} from './braille_translator_manager.js';
import {ExpandingBrailleTranslator} from './expanding_braille_translator.js';
import {LibLouis} from './liblouis.js';
import {ExtraCellsSpan, ValueSelectionSpan, ValueSpan} from './spans.js';

export class BrailleInputHandler {
  constructor() {
    /**
     * Port of the connected IME if any.
     * @private {Port}
     */
    this.imePort_ = null;
    /**
     * {code true} when the Braille IME is connected and has signaled that it is
     * active.
     * @private {boolean}
     */
    this.imeActive_ = false;
    /**
     * The input context of the current input field, as reported by the IME.
     * {@code null} if no input field has focus.
     * @private {?{contextID: number, type: string}}
     */
    this.inputContext_ = null;
    /**
     * Text that currently precedes the first selection end-point.
     * @private {string}
     */
    this.currentTextBefore_ = '';
    /**
     * Text that currently follows the last selection end-point.
     * @private {string}
     */
    this.currentTextAfter_ = '';
    /**
     * Cells that were entered while the IME wasn't active.  These will be
     * submitted once the IME becomes active and reports the current input
     * field. This is necessary because the IME is activated on the first
     * braille dots command, but we'll receive the command in parallel.  To work
     * around the race, we store the cell entered until we can submit it to the
     * IME.
     * @private {!Array<number>}
     */
    this.pendingCells_ = [];
    /** @private {BrailleInputHandler.EntryState_} */
    this.entryState_ = null;
    /** @private {ExtraCellsSpan} */
    this.uncommittedCellsSpan_ = null;
    /** @private {function()?} */
    this.uncommittedCellsChangedListener_ = null;

    this.initListeners_();
  }

  /**
   * Starts to listen for connections from the Chrome OS braille IME.
   * @private
   */
  initListeners_() {
    BrailleTranslatorManager.instance.addChangeListener(
        () => this.commitAndClearEntryState_());

    chrome.runtime.onConnectExternal.addListener(
        port => this.onImeConnect_(port));
  }

  static init() {
    if (BrailleInputHandler.instance) {
      throw new Error('Cannot create two BrailleInputHandler instances');
    }
    BrailleInputHandler.instance = new BrailleInputHandler();
  }

  /**
   * Called when the content on the braille display is updated.  Modifies the
   * input state according to the new content.
   * @param {Spannable} text Text, optionally with value and selection spans.
   * @param {function()} listener Called when the uncommitted cells
   *     have changed.
   */
  onDisplayContentChanged(text, listener) {
    const valueSpan = text.getSpanInstanceOf(ValueSpan);
    const selectionSpan = text.getSpanInstanceOf(ValueSelectionSpan);
    if (!(valueSpan && selectionSpan)) {
      return;
    }
    // Don't call the old listener any further, since new content is being
    // set.  If the old listener is not cleared here, it could be called
    // spuriously if the entry state is cleared below.
    this.uncommittedCellsChangedListener_ = null;
    const valueStart = text.getSpanStart(valueSpan);
    const valueEnd = text.getSpanEnd(valueSpan);
    const selectionStart = text.getSpanStart(selectionSpan);
    const selectionEnd = text.getSpanEnd(selectionSpan);
    if (selectionStart < valueStart || selectionEnd > valueEnd) {
      console.error('Selection outside of value in braille content');
      this.clearEntryState_();
      return;
    }
    const newTextBefore = text.toString().substring(valueStart, selectionStart);
    if (this.currentTextBefore_ !== newTextBefore && this.entryState_) {
      this.entryState_.onTextBeforeChanged(newTextBefore);
    }
    this.currentTextBefore_ = newTextBefore;
    this.currentTextAfter_ = text.toString().substring(selectionEnd, valueEnd);
    this.uncommittedCellsSpan_ = new ExtraCellsSpan();
    text.setSpan(this.uncommittedCellsSpan_, selectionStart, selectionStart);
    if (this.entryState_ && this.entryState_.usesUncommittedCells) {
      this.updateUncommittedCells_(
          new Uint8Array(this.entryState_.cells_).buffer);
    }
    this.uncommittedCellsChangedListener_ = listener;
  }

  /**
   * Handles braille key events used for input by editing the current input
   * field appropriately.
   * @param {!BrailleKeyEvent} event The key event.
   * @return {boolean} {@code true} if the event was handled, {@code false}
   *     if it should propagate further.
   */
  onBrailleKeyEvent(event) {
    if (event.command === BrailleKeyCommand.DOTS) {
      return this.onBrailleDots_(/** @type {number} */ (event.brailleDots));
    }
    // Any other braille command cancels the pending cells.
    this.pendingCells_.length = 0;
    if (event.command === BrailleKeyCommand.STANDARD_KEY) {
      if (event.standardKeyCode === 'Backspace' && !event.altKey &&
          !event.ctrlKey && !event.shiftKey && this.onBackspace_()) {
        return true;
      } else {
        this.commitAndClearEntryState_();
        this.sendKeyEventPair_(event);
        return true;
      }
    }
    return false;
  }

  /**
   * Returns how the value of the currently displayed content should be
   * expanded given the current input state.
   * @return {ExpandingBrailleTranslator.ExpansionType}
   *     The current expansion type.
   */
  getExpansionType() {
    if (this.inAlwaysUncontractedContext_()) {
      return ExpandingBrailleTranslator.ExpansionType.ALL;
    }
    if (this.entryState_ &&
        this.entryState_.translator ===
            BrailleTranslatorManager.instance.getDefaultTranslator()) {
      return ExpandingBrailleTranslator.ExpansionType.NONE;
    }
    return ExpandingBrailleTranslator.ExpansionType.SELECTION;
  }

  /**
   * @return {boolean} {@code true} if we have an input context and
   *     uncontracted braille should always be used for that context.
   * @private
   */
  inAlwaysUncontractedContext_() {
    const inputType = this.inputContext_ ? this.inputContext_.type : '';
    return inputType === 'url' || inputType === 'email';
  }

  /**
   * Called when a user typed a braille cell.
   * @param {number} dots The dot pattern of the cell.
   * @return {boolean} Whether the event was handled or should be allowed to
   *    propagate further.
   * @private
   */
  onBrailleDots_(dots) {
    if (!this.imeActive_) {
      this.pendingCells_.push(dots);
      return true;
    }
    if (!this.inputContext_) {
      return false;
    }
    if (!this.entryState_) {
      if (!(this.entryState_ = this.createEntryState_())) {
        return false;
      }
    }
    this.entryState_.appendCell(dots);
    return true;
  }

  /**
   * Handles the backspace key by deleting the last typed cell if possible.
   * @return {boolean} {@code true} if the event was handled, {@code false}
   *     if it wasn't and should propagate further.
   * @private
   */
  onBackspace_() {
    if (this.imeActive_ && this.entryState_) {
      this.entryState_.deleteLastCell();
      return true;
    }
    return false;
  }

  /**
   * Creates a new empty {@code EntryState_} based on the current input
   * context and surrounding text.
   * @return {BrailleInputHandler.EntryState_} The newly created state
   *     object, or null if it couldn't be created (e.g. if there's no braille
   *     translator available yet).
   * @private
   */
  createEntryState_() {
    let translator = BrailleTranslatorManager.instance.getDefaultTranslator();
    if (!translator) {
      return null;
    }
    const uncontractedTranslator =
        BrailleTranslatorManager.instance.getUncontractedTranslator();
    let constructor = BrailleInputHandler.EditsEntryState_;
    if (uncontractedTranslator) {
      const textBefore = this.currentTextBefore_;
      const textAfter = this.currentTextAfter_;
      if (this.inAlwaysUncontractedContext_() ||
          (BrailleInputHandler.ENDS_WITH_NON_WHITESPACE_RE_.test(textBefore)) ||
          (BrailleInputHandler.STARTS_WITH_NON_WHITESPACE_RE_.test(
              textAfter))) {
        translator = uncontractedTranslator;
      } else {
        constructor = BrailleInputHandler.LateCommitEntryState_;
      }
    }

    return new constructor(this, translator);
  }

  /**
   * Commits the current entry state and clears it, if any.
   * @private
   */
  commitAndClearEntryState_() {
    if (this.entryState_) {
      this.entryState_.commit();
      this.clearEntryState_();
    }
  }

  /**
   * Clears the current entry state without committing it.
   * @private
   */
  clearEntryState_() {
    if (this.entryState_) {
      if (this.entryState_.usesUncommittedCells) {
        this.updateUncommittedCells_(new ArrayBuffer(0));
      }
      this.entryState_.inputHandler_ = null;
      this.entryState_ = null;
    }
  }

  /**
   * @param {ArrayBuffer} cells
   * @private
   */
  updateUncommittedCells_(cells) {
    if (this.uncommittedCellsSpan_) {
      this.uncommittedCellsSpan_.cells = cells;
    }
    if (this.uncommittedCellsChangedListener_) {
      this.uncommittedCellsChangedListener_();
    }
  }

  /**
   * Called when another extension connects to this extension.  Accepts
   * connections from the ChromeOS builtin Braille IME and ignores connections
   * from other extensions.
   * @param {Port} port The port used to communicate with the other extension.
   * @private
   */
  onImeConnect_(port) {
    if (port.name !== BrailleInputHandler.IME_PORT_NAME_ ||
        port.sender.id !== BrailleInputHandler.IME_EXTENSION_ID_) {
      return;
    }
    if (this.imePort_) {
      this.imePort_.disconnect();
    }
    port.onDisconnect.addListener(() => this.onImeDisconnect_(port));
    port.onMessage.addListener(message => this.onImeMessage_(message));
    this.imePort_ = port;
  }

  /**
   * Called when a message is received from the IME.
   * @param {*} message The message.
   * @private
   */
  onImeMessage_(message) {
    if (!goog.isObject(message)) {
      console.error(
          'Unexpected message from Braille IME: ', JSON.stringify(message));
    }
    switch (message.type) {
      case 'activeState':
        this.imeActive_ = message.active;
        break;
      case 'inputContext':
        this.inputContext_ = message.context;
        this.clearEntryState_();
        if (this.imeActive_ && this.inputContext_) {
          this.pendingCells_.forEach(this.onBrailleDots_, this);
        }
        this.pendingCells_.length = 0;
        break;
      case 'brailleDots':
        this.onBrailleDots_(message['dots']);
        break;
      case 'backspace':
        // Note that we can't send the backspace key through the
        // virtualKeyboardPrivate API in this case because it would then be
        // processed by the IME again, leading to an infinite loop.
        this.postImeMessage_({
          type: 'keyEventHandled',
          requestId: message['requestId'],
          result: this.onBackspace_(),
        });
        break;
      case 'reset':
        this.clearEntryState_();
        break;
      default:
        console.error(
            'Unexpected message from Braille IME: ', JSON.stringify(message));
        break;
    }
  }

  /**
   * Called when the IME port is disconnected.
   * @param {Port} port The port that was disconnected.
   * @private
   */
  onImeDisconnect_(port) {
    this.imePort_ = null;
    this.clearEntryState_();
    this.imeActive_ = false;
    this.inputContext_ = null;
  }

  /**
   * Posts a message to the IME.
   * @param {Object} message The message.
   * @return {boolean} {@code true} if the message was sent, {@code false} if
   *     there was no connection open to the IME.
   * @private
   */
  postImeMessage_(message) {
    if (this.imePort_) {
      this.imePort_.postMessage(message);
      return true;
    }
    return false;
  }

  /**
   * Sends a {@code keydown} key event followed by a {@code keyup} event
   * corresponding to an event generated by the braille display.
   * @param {!BrailleKeyEvent} event The braille key event to base the
   *     key events on.
   * @private
   */
  sendKeyEventPair_(event) {
    const keyName = /** @type {string} */ (event.standardKeyCode);
    const numericCode = BrailleKeyEvent.keyCodeToLegacyCode(keyName);
    if (!numericCode) {
      throw Error('Unknown key code in event: ' + JSON.stringify(event));
    }
    EventGenerator.sendKeyPress(numericCode, {
      shift: Boolean(event.shiftKey),
      ctrl: Boolean(event.ctrlKey),
      alt: Boolean(event.altKey),
    });
  }
}

/**
 * The ID of the Braille IME extension built into Chrome OS.
 * @const {string}
 * @private
 */
BrailleInputHandler.IME_EXTENSION_ID_ = 'jddehjeebkoimngcbdkaahpobgicbffp';

/**
 * Name of the port to use for communicating with the Braille IME.
 * @const {string}
 * @private
 */
BrailleInputHandler.IME_PORT_NAME_ = 'BrailleIme.Port';

/**
 * Regular expression that matches a string that starts with at least one
 * non-whitespace character.
 * @const {RegExp}
 * @private
 */
BrailleInputHandler.STARTS_WITH_NON_WHITESPACE_RE_ = /^\S/;

/**
 * Regular expression that matches a string that ends with at least one
 * non-whitespace character.
 * @const {RegExp}
 * @private
 */
BrailleInputHandler.ENDS_WITH_NON_WHITESPACE_RE_ = /\S$/;


/**
 * The entry state is the state related to entering a series of braille cells
 * without 'interruption', where interruption can be things like non braille
 * keyboard input or unexpected changes to the text surrounding the cursor.
 * @private
 */
BrailleInputHandler.EntryState_ = class {
  /**
   * @param {!BrailleInputHandler} inputHandler
   * @param {!LibLouis.Translator} translator
   */
  constructor(inputHandler, translator) {
    /** @private {BrailleInputHandler} */
    this.inputHandler_ = inputHandler;
    /**
     * The translator currently used for typing, if
     * {@code this.cells_.length > 0}.
     * @private {!LibLouis.Translator}
     */
    this.translator_ = translator;
    /**
     * Braille cells that have been typed by the user so far.
     * @private {!Array<number>}
     */
    this.cells_ = [];
    /**
     * Text resulting from translating {@code this.cells_}.
     * @private {string}
     */
    this.text_ = '';
    /**
     * List of strings that we expect to be set as preceding text of the
     * selection. This is populated when we send text changes to the IME so
     * that our own changes don't reset the pending cells.
     * @private {!Array<string>}
     */
    this.pendingTextsBefore_ = [];
  }

  /**
   * @return {!LibLouis.Translator} The translator used by this entry
   *     state. This doesn't change for a given object.
   */
  get translator() {
    return this.translator_;
  }

  /**
   * Appends a braille cell to the current input and updates the text if
   * necessary.
   * @param {number} cell The braille cell to append.
   */
  appendCell(cell) {
    this.cells_.push(cell);
    this.updateText_();
  }

  /**
   * Deletes the last cell of the input and updates the text if neccary.
   * If there's no more input in this object afterwards, clears the entry state
   * of the input handler.
   */
  deleteLastCell() {
    if (--this.cells_.length <= 0) {
      this.sendTextChange_('');
      this.inputHandler_.clearEntryState_();
      return;
    }
    this.updateText_();
  }

  /**
   * Called when the text before the cursor changes giving this object a
   * chance to clear the entry state of the input handler if the change
   * wasn't expected.
   * @param {string} newText New text before the cursor.
   */
  onTextBeforeChanged(newText) {
    // See if we are expecting this change as a result of one of our own
    // edits. Allow changes to be coalesced by the input system in an attempt
    // to not be too brittle.
    for (let i = 0; i < this.pendingTextsBefore_.length; ++i) {
      if (newText === this.pendingTextsBefore_[i]) {
        // Delete all previous expected changes and ignore this one.
        this.pendingTextsBefore_.splice(0, i + 1);
        return;
      }
    }
    // There was an actual text change (or cursor movement) that we hadn't
    // caused ourselves, reset any pending input.
    this.inputHandler_.clearEntryState_();
  }

  /**
   * Makes sure the current text is permanently added to the edit field.
   * After this call, this object should be abandoned.
   */
  commit() {}

  /**
   * @return {boolean} true if the entry state uses uncommitted cells.
   */
  get usesUncommittedCells() {
    return false;
  }

  /**
   * Updates the translated text based on the current cells and sends the
   * delta to the IME.
   * @private
   */
  updateText_() {
    const cellsBuffer = new Uint8Array(this.cells_).buffer;
    const commit = this.lastCellIsBlank_;
    if (!commit && this.usesUncommittedCells) {
      this.inputHandler_.updateUncommittedCells_(cellsBuffer);
    }
    this.translator_.backTranslate(cellsBuffer, result => {
      if (result === null) {
        console.error('Error when backtranslating braille cells');
        return;
      }
      if (!this.inputHandler_) {
        return;
      }
      this.sendTextChange_(result);
      this.text_ = result;
      if (commit) {
        this.inputHandler_.commitAndClearEntryState_();
      }
    });
  }

  /**
   * @return {boolean}
   * @private
   */
  get lastCellIsBlank_() {
    return this.cells_[this.cells_.length - 1] === 0;
  }

  /**
   * Sends new text to the IME.  This dhould be overriden by subclasses.
   * The old text is still available in the {@code text_} property.
   * @param {string} newText Text to send.
   * @private
   */
  sendTextChange_(newText) {}
};


/**
 * Entry state that uses {@code deleteSurroundingText} and {@code commitText}
 * calls to the IME to update the currently enetered text.
 * @private
 */
BrailleInputHandler.EditsEntryState_ =
    class extends BrailleInputHandler.EntryState_ {
  /**
   * @param {!BrailleInputHandler} inputHandler
   * @param {!LibLouis.Translator} translator
   */
  constructor(inputHandler, translator) {
    super(inputHandler, translator);
  }

  /** @override */
  sendTextChange_(newText) {
    const oldText = this.text_;
    // Find the common prefix of the old and new text.
    const commonPrefixLength =
        StringUtil.longestCommonPrefixLength(oldText, newText);
    // How many characters we need to delete from the existing text to replace
    // them with characters from the new text.
    const deleteLength = oldText.length - commonPrefixLength;
    // New text, if any, to insert after deleting the deleteLength characters
    // before the cursor.
    const toInsert = newText.substring(commonPrefixLength);
    if (deleteLength > 0 || toInsert.length > 0) {
      // After deleting, we expect this text to be present before the cursor.
      const textBeforeAfterDelete =
          this.inputHandler_.currentTextBefore_.substring(
              0, this.inputHandler_.currentTextBefore_.length - deleteLength);
      if (deleteLength > 0) {
        // Queue this text up to be ignored when the change comes in.
        this.pendingTextsBefore_.push(textBeforeAfterDelete);
      }
      if (toInsert.length > 0) {
        // Likewise, queue up what we expect to be before the cursor after
        // the replacement text is inserted.
        this.pendingTextsBefore_.push(textBeforeAfterDelete + toInsert);
      }
      // Send the replace operation to be performed asynchronously by the IME.
      this.inputHandler_.postImeMessage_({
        type: 'replaceText',
        contextID: this.inputHandler_.inputContext_.contextID,
        deleteBefore: deleteLength,
        newText: toInsert,
      });
    }
  }
};


/**
 * Entry state that only updates the edit field when a blank cell is entered.
 * During the input of a single 'word', the uncommitted text is stored by the
 * IME.
 * @private
 */
BrailleInputHandler.LateCommitEntryState_ =
    class extends BrailleInputHandler.EntryState_ {
  /**
   * @param {!BrailleInputHandler} inputHandler
   * @param {!LibLouis.Translator} translator
   */
  constructor(inputHandler, translator) {
    super(inputHandler, translator);
  }

  /** @override */
  commit() {
    this.inputHandler_.postImeMessage_({
      type: 'commitUncommitted',
      contextID: this.inputHandler_.inputContext_.contextID,
    });
  }

  /** @override */
  get usesUncommittedCells() {
    return true;
  }

  /** @override */
  sendTextChange_(newText) {
    this.inputHandler_.postImeMessage_({
      type: 'setUncommitted',
      contextID: this.inputHandler_.inputContext_.contextID,
      text: newText,
    });
  }
};

/** @type {BrailleInputHandler} */
BrailleInputHandler.instance;

TestImportManager.exportForTesting(BrailleInputHandler);