1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5'use strict';
6
7/**
8 * @fileoverview Braille hardware keyboard input method.
9 *
10 * This method is automatically enabled when a braille display is connected
11 * and ChromeVox is turned on.  Most of the braille input and editing logic
12 * is located in ChromeVox where the braille translation library is available.
13 * This IME connects to ChromeVox and communicates using messages as follows:
14 *
15 * Sent from this IME to ChromeVox:
16 * {type: 'activeState', active: boolean}
17 * {type: 'inputContext', context: InputContext}
18 *   Sent on focus/blur to inform ChromeVox of the type of the current field.
19 *   In the latter case (blur), context is null.
20 * {type: 'reset'}
21 *   Sent when the {code onReset} IME event fires.
22 * {type: 'brailleDots', dots: number}
23 *   Sent when the user typed a braille cell using the standard keyboard.
24 *   ChromeVox treats this similarly to entering braille input using the
25 *   braille display.
26 * {type: 'backspace', requestId: string}
27 *   Sent when the user presses the backspace key.
28 *   ChromeVox must respond with a {@code keyEventHandled} message
29 *   with the same request id.
30 *
31 * Sent from ChromeVox to this IME:
32 * {type: 'replaceText', contextID: number, deleteBefore: number,
33 *  newText: string}
34 *   Deletes {@code deleteBefore} characters before the cursor (or selection)
35 *   and inserts {@code newText}.  {@code contextID} identifies the text field
36 *   to apply the update to (no change will happen if focus has moved to a
37 *   different field).
38 * {type: 'keyEventHandled', requestId: string, result: boolean}
39 *   Response to a {@code backspace} message indicating whether the
40 *   backspace was handled by ChromeVox or should be allowed to propagate
41 *   through the normal event handling pipeline.
42 */
43
44/**
45 * @constructor
46 */
47var BrailleIme = function() {};
48
49BrailleIme.prototype = {
50  /**
51   * Whether to enable extra debug logging for the IME.
52   * @const {boolean}
53   * @private
54   */
55  DEBUG: false,
56
57  /**
58   * ChromeVox extension ID.
59   * @const {string}
60   * @private
61   */
62  CHROMEVOX_EXTENSION_ID_: 'mndnfokpggljbaajbnioimlmbfngpief',
63
64  /**
65   * Name of the port used for communication with ChromeVox.
66   * @const {string}
67   * @private
68   */
69  PORT_NAME: 'cvox.BrailleIme.Port',
70
71  /**
72   * Identifier for the use standard keyboard option used in the menu and
73   * {@code localStorage}.  This can be switched on to type braille using the
74   * standard keyboard, or off (default) for the usual keyboard behaviour.
75   * @const {string}
76   */
77  USE_STANDARD_KEYBOARD_ID: 'useStandardKeyboard',
78
79  // State related to the support for typing braille using a standrad
80  // (qwerty) keyboard.
81
82  /** @private {boolean} */
83  useStandardKeyboard_: false,
84
85  /**
86   * Braille dots for keys that are currently pressed.
87   * @private {number}
88   */
89  pressed_: 0,
90
91  /**
92   * Dots that have been pressed at some point since {@code pressed_} was last
93   * {@code 0}.
94   * @private {number}
95   */
96  accumulated_: 0,
97
98  /**
99   * Bit in {@code pressed_} and {@code accumulated_} that represent
100   * the space key.
101   * @const {number}
102   */
103  SPACE: 0x100,
104
105  /**
106   * Maps key codes on a standard keyboard to the correspodning dots.
107   * Keys on the 'home row' correspond to the keys on a Perkins-style keyboard.
108   * Note that the mapping below is arranged like the dots in a braille cell.
109   * Only 6 dot input is supported.
110   * @private
111   * @const {Object.<string, number>}
112   */
113  CODE_TO_DOT_: {'KeyF': 0x01, 'KeyJ': 0x08,
114                 'KeyD': 0x02, 'KeyK': 0x10,
115                 'KeyS': 0x04, 'KeyL': 0x20,
116                 'Space': 0x100 },
117
118  /**
119   * The current engine ID as set by {@code onActivate}, or the empty string if
120   * the IME is not active.
121   * @type {string}
122   * @private
123   */
124  engineID_: '',
125
126  /**
127   * The port used to communicate with ChromeVox.
128   * @type {Port} port_
129   * @private
130   */
131  port_: null,
132
133  /**
134   * Registers event listeners in the chrome IME API.
135   */
136  init: function() {
137    chrome.input.ime.onActivate.addListener(this.onActivate_.bind(this));
138    chrome.input.ime.onDeactivated.addListener(this.onDeactivated_.bind(this));
139    chrome.input.ime.onFocus.addListener(this.onFocus_.bind(this));
140    chrome.input.ime.onBlur.addListener(this.onBlur_.bind(this));
141    chrome.input.ime.onInputContextUpdate.addListener(
142        this.onInputContextUpdate_.bind(this));
143    chrome.input.ime.onKeyEvent.addListener(this.onKeyEvent_.bind(this),
144                                            ['async']);
145    chrome.input.ime.onReset.addListener(this.onReset_.bind(this));
146    chrome.input.ime.onMenuItemActivated.addListener(
147        this.onMenuItemActivated_.bind(this));
148    this.connectChromeVox_();
149  },
150
151  /**
152   * Called by the IME framework when this IME is activated.
153   * @param {string} engineID Engine ID, should be 'braille'.
154   * @private
155   */
156  onActivate_: function(engineID) {
157    this.log_('onActivate', engineID);
158    this.engineID_ = engineID;
159    if (!this.port_) {
160      this.connectChromeVox_();
161    }
162    this.useStandardKeyboard_ =
163        localStorage[this.USE_STANDARD_KEYBOARD_ID] === String(true);
164    this.accumulated_ = 0;
165    this.pressed_ = 0;
166    this.updateMenuItems_();
167    this.sendActiveState_();
168  },
169
170  /**
171   * Called by the IME framework when this IME is deactivated.
172   * @param {string} engineID Engine ID, should be 'braille'.
173   * @private
174   */
175  onDeactivated_: function(engineID) {
176    this.log_('onDectivated', engineID);
177    this.engineID_ = '';
178    this.sendActiveState_();
179  },
180
181  /**
182   * Called by the IME framework when a text field receives focus.
183   * @param {InputContext} context Input field context.
184   * @private
185   */
186  onFocus_: function(context) {
187    this.log_('onFocus', JSON.stringify(context));
188    this.sendInputContext_(context);
189  },
190
191  /**
192   * Called by the IME framework when a text field looses focus.
193   * @param {number} contextID Input field context ID.
194   * @private
195   */
196  onBlur_: function(contextID) {
197    this.log_('onBlur', contextID + '');
198    this.sendInputContext_(null);
199  },
200
201  /**
202   * Called by the IME framework when the current input context is updated.
203   * @param {InputContext} context Input field context.
204   * @private
205   */
206  onInputContextUpdate_: function(context) {
207    this.log_('onInputContextUpdate', JSON.stringify(context));
208    this.sendInputContext_(context);
209  },
210
211  /**
212   * Called by the system when this IME is active and a key event is generated.
213   * @param {string} engineID Engine ID, should be 'braille'.
214   * @param {!ChromeKeyboardEvent} event The keyboard event.
215   * @private
216   */
217  onKeyEvent_: function(engineID, event) {
218    this.log_('onKeyEvent', engineID + ', ' + JSON.stringify(event));
219    var result = this.processKey_(event);
220    if (result !== undefined) {
221      chrome.input.ime.keyEventHandled(event.requestId, result);
222    }
223  },
224
225  /**
226   * Called when chrome ends the current text input session.
227   * @param {string} engineID Engine ID, should be 'braille'.
228   * @private
229   */
230  onReset_: function(engineID) {
231    this.log_('onReset', engineID);
232    this.engineID_ = engineID;
233    this.sendToChromeVox_({type: 'reset'});
234  },
235
236  /**
237   * Called by the IME framework when a menu item is activated.
238   * @param {string} engineID Engine ID, should be 'braille'.
239   * @param {string} itemID Identifies the menu item.
240   * @private
241   */
242  onMenuItemActivated_: function(engineID, itemID) {
243    if (engineID === this.engineID_ &&
244        itemID === this.USE_STANDARD_KEYBOARD_ID) {
245      this.useStandardKeyboard_ = !this.useStandardKeyboard_;
246      localStorage[this.USE_STANDARD_KEYBOARD_ID] =
247          String(this.useStandardKeyboard_);
248      if (!this.useStandardKeyboard_) {
249        this.accumulated_ = 0;
250        this.pressed_ = 0;
251      }
252      this.updateMenuItems_();
253    }
254  },
255
256  /**
257   * Outputs a log message to the console, only if {@link BrailleIme.DEBUG}
258   * is set to true.
259   * @param {string} func Name of the caller.
260   * @param {string} message Message to output.
261   * @private
262   */
263  log_: function(func, message) {
264    if (func === 'onKeyEvent') {
265      return;
266    }
267    if (this.DEBUG) {
268      console.log('BrailleIme.' + func + ': ' + message);
269    }
270  },
271
272  /**
273   * Handles a qwerty key on the home row as a braille key.
274   * @param {!ChromeKeyboardEvent} event Keyboard event.
275   * @return {boolean|undefined} Whether the event was handled, or
276   *     {@code undefined} if handling was delegated to ChromeVox.
277   * @private
278   */
279  processKey_: function(event) {
280    if (!this.useStandardKeyboard_) {
281      return false;
282    }
283    if (event.code === 'Backspace' && event.type === 'keydown') {
284      this.pressed_ = 0;
285      this.accumulated_ = 0;
286      this.sendToChromeVox_(
287          {type: 'backspace', requestId: event.requestId});
288      return undefined;
289    }
290    var dot = this.CODE_TO_DOT_[event.code];
291    if (!dot || event.altKey || event.ctrlKey || event.shiftKey ||
292        event.capsLock) {
293      this.pressed_ = 0;
294      this.accumulated_ = 0;
295      return false;
296    }
297    if (event.type === 'keydown') {
298      this.pressed_ |= dot;
299      this.accumulated_ |= this.pressed_;
300      return true;
301    } else if (event.type === 'keyup') {
302      this.pressed_ &= ~dot;
303      if (this.pressed_ === 0 && this.accumulated_ !== 0) {
304        var dotsToSend = this.accumulated_;
305        this.accumulated_ = 0;
306        if (dotsToSend & this.SPACE) {
307          if (dotsToSend != this.SPACE) {
308            // Can't combine space and actual dot keys.
309            return true;
310          }
311          // Space is sent as a blank cell.
312          dotsToSend = 0;
313        }
314        this.sendToChromeVox_({type: 'brailleDots', dots: dotsToSend});
315      }
316      return true;
317    }
318    return false;
319  },
320
321  /**
322   * Connects to the ChromeVox extension for message passing.
323   * @private
324   */
325  connectChromeVox_: function() {
326    if (this.port_) {
327      this.port_.disconnect();
328      this.port_ = null;
329    }
330    this.port_ = chrome.runtime.connect(
331        this.CHROMEVOX_EXTENSION_ID_, {name: this.PORT_NAME});
332    this.port_.onMessage.addListener(
333        this.onChromeVoxMessage_.bind(this));
334    this.port_.onDisconnect.addListener(
335        this.onChromeVoxDisconnect_.bind(this));
336  },
337
338  /**
339   * Handles a message from the ChromeVox extension.
340   * @param {*} message The message from the extension.
341   * @private
342   */
343  onChromeVoxMessage_: function(message) {
344    this.log_('onChromeVoxMessage', JSON.stringify(message));
345    message = /** @type {{type: string}} */ (message);
346    switch (message.type) {
347      case 'replaceText':
348        message =
349            /**
350             * @type {{contextID: number, deleteBefore: number,
351             *         newText: string}}
352             */
353            (message);
354        this.replaceText_(message.contextID, message.deleteBefore,
355                          message.newText);
356        break;
357      case 'keyEventHandled':
358        message =
359            /** @type {{requestId: string, result: boolean}} */ (message);
360        chrome.input.ime.keyEventHandled(message.requestId, message.result);
361        break;
362      default:
363        console.error('Unknown message from ChromeVox: ' +
364            JSON.stringify(message));
365        break;
366    }
367  },
368
369  /**
370   * Handles a disconnect event from the ChromeVox side.
371   * @private
372   */
373  onChromeVoxDisconnect_: function() {
374    this.port_ = null;
375    this.log_('onChromeVoxDisconnect',
376              JSON.stringify(chrome.runtime.lastError));
377  },
378
379  /**
380   * Sends a message to the ChromeVox extension.
381   * @param {Object} message The message to send.
382   * @private
383   */
384  sendToChromeVox_: function(message) {
385    if (this.port_) {
386      this.port_.postMessage(message);
387    }
388  },
389
390  /**
391   * Sends the given input context to ChromeVox.
392   * @param {InputContext} context Input context, or null when there's no input
393   *    context.
394   * @private
395   */
396  sendInputContext_: function(context) {
397    this.sendToChromeVox_({type: 'inputContext', context: context});
398  },
399
400  /**
401   * Sends the active state to ChromeVox.
402   * @private
403   */
404  sendActiveState_: function() {
405    this.sendToChromeVox_({type: 'activeState',
406                           active: this.engineID_.length > 0});
407  },
408
409  /**
410   * Replaces text in the current text field.
411   * @param {number} contextID Context for the input field to replace the
412   *     text in.
413   * @param {number} deleteBefore How many characters to delete before the
414   *     cursor.
415   * @param {string} toInsert Text to insert at the cursor.
416   */
417  replaceText_: function(contextID, deleteBefore, toInsert) {
418    var addText = function() {
419      chrome.input.ime.commitText(
420          {contextID: contextID, text: toInsert});
421    }.bind(this);
422    if (deleteBefore > 0) {
423      var deleteText = function() {
424        chrome.input.ime.deleteSurroundingText(
425            {engineID: this.engineID_, contextID: contextID,
426             offset: -deleteBefore, length: deleteBefore}, addText);
427      }.bind(this);
428      // Make sure there's no non-zero length selection so that
429      // deleteSurroundingText works correctly.
430      chrome.input.ime.deleteSurroundingText(
431          {engineID: this.engineID_, contextID: contextID,
432           offset: 0, length: 0}, deleteText);
433    } else {
434      addText();
435    }
436  },
437
438  /**
439   * Updates the menu items for this IME.
440   */
441  updateMenuItems_: function() {
442    // TODO(plundblad): Localize when translations available.
443    chrome.input.ime.setMenuItems(
444        {engineID: this.engineID_,
445         items: [
446           {
447             id: this.USE_STANDARD_KEYBOARD_ID,
448             label: 'Use standard keyboard for braille',
449             style: 'check',
450             visible: true,
451             checked: this.useStandardKeyboard_,
452             enabled: true
453             }
454         ]
455        });
456  }
457};
458