accessible/src/jsat/VirtualCursorController.jsm
author Myk Melez <myk@mozilla.org>
Tue, 14 Aug 2012 15:27:26 -0700
changeset 102403 07b53bdc212ac3876cea6c2c7906e3106985043e
parent 99923 0ec90d4991ca25287d2c742bdd574840efcc9f51
child 102471 4e6595190bb132eeb593288192ec53bd4da959cb
permissions -rw-r--r--
bug 770770: refactor webapp runtime test harness to reduce complexity/special-casing; r=adw

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

'use strict';

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;

var EXPORTED_SYMBOLS = ['VirtualCursorController'];

Cu.import('resource://gre/modules/accessibility/Utils.jsm');
Cu.import('resource://gre/modules/XPCOMUtils.jsm');

var gAccRetrieval = Cc['@mozilla.org/accessibleRetrieval;1'].
  getService(Ci.nsIAccessibleRetrieval);

function BaseTraversalRule(aRoles, aMatchFunc) {
  this._matchRoles = aRoles;
  this._matchFunc = aMatchFunc;
}

BaseTraversalRule.prototype = {
    getMatchRoles: function BaseTraversalRule_getmatchRoles(aRules) {
      aRules.value = this._matchRoles;
      return aRules.value.length;
    },

    preFilter: Ci.nsIAccessibleTraversalRule.PREFILTER_DEFUNCT |
    Ci.nsIAccessibleTraversalRule.PREFILTER_INVISIBLE,

    match: function BaseTraversalRule_match(aAccessible)
    {
      if (this._matchFunc)
        return this._matchFunc(aAccessible);

      return Ci.nsIAccessibleTraversalRule.FILTER_MATCH;
    },

    QueryInterface: XPCOMUtils.generateQI([Ci.nsIAccessibleTraversalRule])
};

var TraversalRules = {
  Simple: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_MENUITEM,
     Ci.nsIAccessibleRole.ROLE_LINK,
     Ci.nsIAccessibleRole.ROLE_PAGETAB,
     Ci.nsIAccessibleRole.ROLE_GRAPHIC,
     // XXX: Find a better solution for ROLE_STATICTEXT.
     // It allows to filter list bullets but at the same time it
     // filters CSS generated content too as an unwanted side effect.
     // Ci.nsIAccessibleRole.ROLE_STATICTEXT,
     Ci.nsIAccessibleRole.ROLE_TEXT_LEAF,
     Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
     Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
     Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
     Ci.nsIAccessibleRole.ROLE_COMBOBOX,
     Ci.nsIAccessibleRole.ROLE_PROGRESSBAR,
     Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWN,
     Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
     Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
     Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT,
     Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
     Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
     Ci.nsIAccessibleRole.ROLE_ENTRY],
    function Simple_match(aAccessible) {
      switch (aAccessible.role) {
      case Ci.nsIAccessibleRole.ROLE_COMBOBOX:
        // We don't want to ignore the subtree because this is often
        // where the list box hangs out.
        return Ci.nsIAccessibleTraversalRule.FILTER_MATCH;
      case Ci.nsIAccessibleRole.ROLE_TEXT_LEAF:
        {
          // Nameless text leaves are boring, skip them.
          let name = aAccessible.name;
          if (name && name.trim())
            return Ci.nsIAccessibleTraversalRule.FILTER_MATCH;
          else
            return Ci.nsIAccessibleTraversalRule.FILTER_IGNORE;
        }
      case Ci.nsIAccessibleRole.ROLE_LINK:
        // If the link has children we should land on them instead.
        // Image map links don't have children so we need to match those.
        if (aAccessible.childCount == 0)
          return Ci.nsIAccessibleTraversalRule.FILTER_MATCH;
        else
          return Ci.nsIAccessibleTraversalRule.FILTER_IGNORE;
      default:
        // Ignore the subtree, if there is one. So that we don't land on
        // the same content that was already presented by its parent.
        return Ci.nsIAccessibleTraversalRule.FILTER_MATCH |
          Ci.nsIAccessibleTraversalRule.FILTER_IGNORE_SUBTREE;
      }
    }
  ),

  Anchor: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_LINK],
    function Anchor_match(aAccessible)
    {
      // We want to ignore links, only focus named anchors.
      let state = {};
      let extraState = {};
      aAccessible.getState(state, extraState);
      if (state.value & Ci.nsIAccessibleStates.STATE_LINKED) {
        return Ci.nsIAccessibleTraversalRule.FILTER_IGNORE;
      } else {
        return Ci.nsIAccessibleTraversalRule.FILTER_MATCH;
      }
    }),

  Button: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
     Ci.nsIAccessibleRole.ROLE_SPINBUTTON,
     Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
     Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWN,
     Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWNGRID]),

  Combobox: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_COMBOBOX,
     Ci.nsIAccessibleRole.ROLE_LISTBOX]),

  Entry: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_ENTRY,
     Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT]),

  FormElement: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
     Ci.nsIAccessibleRole.ROLE_SPINBUTTON,
     Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
     Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWN,
     Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWNGRID,
     Ci.nsIAccessibleRole.ROLE_COMBOBOX,
     Ci.nsIAccessibleRole.ROLE_LISTBOX,
     Ci.nsIAccessibleRole.ROLE_ENTRY,
     Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT,
     Ci.nsIAccessibleRole.ROLE_PAGETAB,
     Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
     Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
     Ci.nsIAccessibleRole.ROLE_SLIDER,
     Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
     Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM]),

  Graphic: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_GRAPHIC]),

  Heading: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_HEADING]),

  ListItem: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_LISTITEM,
     Ci.nsIAccessibleRole.ROLE_TERM]),

  Link: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_LINK],
    function Link_match(aAccessible)
    {
      // We want to ignore anchors, only focus real links.
      let state = {};
      let extraState = {};
      aAccessible.getState(state, extraState);
      if (state.value & Ci.nsIAccessibleStates.STATE_LINKED) {
        return Ci.nsIAccessibleTraversalRule.FILTER_MATCH;
      } else {
        return Ci.nsIAccessibleTraversalRule.FILTER_IGNORE;
      }
    }),

  List: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_LIST,
     Ci.nsIAccessibleRole.ROLE_DEFINITION_LIST]),

  PageTab: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_PAGETAB]),

  RadioButton: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
     Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM]),

  Separator: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_SEPARATOR]),

  Table: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_TABLE]),

  Checkbox: new BaseTraversalRule(
    [Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
     Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM])
};

var VirtualCursorController = {
  exploreByTouch: false,
  editableState: 0,

  attach: function attach(aWindow) {
    this.chromeWin = aWindow;
    this.chromeWin.document.addEventListener('keypress', this, true);
    this.chromeWin.document.addEventListener('mousemove', this, true);
  },

  detach: function detach() {
    this.chromeWin.document.removeEventListener('keypress', this, true);
    this.chromeWin.document.removeEventListener('mousemove', this, true);
  },

  handleEvent: function VirtualCursorController_handleEvent(aEvent) {
    switch (aEvent.type) {
      case 'keypress':
        this._handleKeypress(aEvent);
        break;
      case 'mousemove':
        this._handleMousemove(aEvent);
        break;
    }
  },

  _handleMousemove: function _handleMousemove(aEvent) {
    // Explore by touch is disabled.
    if (!this.exploreByTouch)
      return;

    // On non-Android we use the shift key to simulate touch.
    if (Utils.OS != 'Android' && !aEvent.shiftKey)
      return;

    // We should not be calling moveToPoint more than 10 times a second.
    // It is granular enough to feel natural, and it does not hammer the CPU.
    if (!this._handleMousemove._lastEventTime ||
        aEvent.timeStamp - this._handleMousemove._lastEventTime >= 100) {
      this.moveToPoint(Utils.getCurrentContentDoc(this.chromeWin),
                       aEvent.screenX, aEvent.screenY);
      this._handleMousemove._lastEventTime = aEvent.timeStamp;
    }

    aEvent.preventDefault();
    aEvent.stopImmediatePropagation();
  },

  _handleKeypress: function _handleKeypress(aEvent) {
    let document = Utils.getCurrentContentDoc(this.chromeWin);
    let target = aEvent.target;

    switch (aEvent.keyCode) {
      case 0:
        // an alphanumeric key was pressed, handle it separately.
        // If it was pressed with either alt or ctrl, just pass through.
        // If it was pressed with meta, pass the key on without the meta.
        if (this.editableState ||
            aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey)
          return;

        let key = String.fromCharCode(aEvent.charCode);
        let methodName = '', rule = {};
        try {
          [methodName, rule] = this.keyMap[key];
        } catch (x) {
          return;
        }
        this[methodName](document, false, rule);
        break;
      case aEvent.DOM_VK_END:
        if (this.editableState) {
          if (target.selectionEnd != target.textLength)
            // Don't move forward if caret is not at end of entry.
            // XXX: Fix for rtl
            return;
          else
            target.blur();
        }
        this.moveForward(document, true);
        break;
      case aEvent.DOM_VK_HOME:
        if (this.editableState) {
          if (target.selectionEnd != 0)
            // Don't move backward if caret is not at start of entry.
            // XXX: Fix for rtl
            return;
          else
            target.blur();
        }
        this.moveBackward(document, true);
        break;
      case aEvent.DOM_VK_RIGHT:
        if (this.editableState) {
          if (target.selectionEnd != target.textLength)
            // Don't move forward if caret is not at end of entry.
            // XXX: Fix for rtl
            return;
          else
            target.blur();
        }
        this.moveForward(document, aEvent.shiftKey);
        break;
      case aEvent.DOM_VK_LEFT:
        if (this.editableState) {
          if (target.selectionEnd != 0)
            // Don't move backward if caret is not at start of entry.
            // XXX: Fix for rtl
            return;
          else
            target.blur();
        }
        this.moveBackward(document, aEvent.shiftKey);
        break;
      case aEvent.DOM_VK_UP:
        if (this.editableState & Ci.nsIAccessibleStates.EXT_STATE_MULTI_LINE) {
          if (target.selectionEnd != 0)
            // Don't blur content if caret is not at start of text area.
            return;
          else
            target.blur();
        }

        if (Utils.OS == 'Android')
          // Return focus to native Android browser chrome.
          Cc['@mozilla.org/android/bridge;1'].
            getService(Ci.nsIAndroidBridge).handleGeckoMessage(
              JSON.stringify({ gecko: { type: 'ToggleChrome:Focus' } }));
        break;
      case aEvent.DOM_VK_RETURN:
      case aEvent.DOM_VK_ENTER:
        if (this.editableState)
          return;
        this.activateCurrent(document);
        break;
      default:
        return;
    }

    aEvent.preventDefault();
    aEvent.stopPropagation();
  },

  moveToPoint: function moveToPoint(aDocument, aX, aY) {
    Utils.getVirtualCursor(aDocument).moveToPoint(TraversalRules.Simple,
                                                  aX, aY, true);
  },

  moveForward: function moveForward(aDocument, aLast, aRule) {
    let virtualCursor = Utils.getVirtualCursor(aDocument);
    if (aLast) {
      virtualCursor.moveLast(TraversalRules.Simple);
    } else {
      try {
        virtualCursor.moveNext(aRule || TraversalRules.Simple);
      } catch (x) {
        this.moveCursorToObject(
          virtualCursor,
          gAccRetrieval.getAccessibleFor(aDocument.activeElement), aRule);
      }
    }
  },

  moveBackward: function moveBackward(aDocument, aFirst, aRule) {
    let virtualCursor = Utils.getVirtualCursor(aDocument);
    if (aFirst) {
      virtualCursor.moveFirst(TraversalRules.Simple);
    } else {
      try {
        virtualCursor.movePrevious(aRule || TraversalRules.Simple);
      } catch (x) {
        this.moveCursorToObject(
          virtualCursor,
          gAccRetrieval.getAccessibleFor(aDocument.activeElement), aRule);
      }
    }
  },

  activateCurrent: function activateCurrent(document) {
    let virtualCursor = Utils.getVirtualCursor(document);
    let acc = virtualCursor.position;

    if (acc.actionCount > 0) {
      acc.doAction(0);
    } else {
      // XXX Some mobile widget sets do not expose actions properly
      // (via ARIA roles, etc.), so we need to generate a click.
      // Could possibly be made simpler in the future. Maybe core
      // engine could expose nsCoreUtiles::DispatchMouseEvent()?
      let docAcc = gAccRetrieval.getAccessibleFor(this.chromeWin.document);
      let docX = {}, docY = {}, docW = {}, docH = {};
      docAcc.getBounds(docX, docY, docW, docH);

      let objX = {}, objY = {}, objW = {}, objH = {};
      acc.getBounds(objX, objY, objW, objH);

      let x = Math.round((objX.value - docX.value) + objW.value / 2);
      let y = Math.round((objY.value - docY.value) + objH.value / 2);

      let cwu = this.chromeWin.QueryInterface(Ci.nsIInterfaceRequestor).
        getInterface(Ci.nsIDOMWindowUtils);
      cwu.sendMouseEventToWindow('mousedown', x, y, 0, 1, 0, false);
      cwu.sendMouseEventToWindow('mouseup', x, y, 0, 1, 0, false);
    }
  },

  moveCursorToObject: function moveCursorToObject(aVirtualCursor,
                                                  aAccessible, aRule) {
    aVirtualCursor.moveNext(aRule || TraversalRules.Simple, aAccessible, true);
  },

  keyMap: {
    a: ['moveForward', TraversalRules.Anchor],
    A: ['moveBackward', TraversalRules.Anchor],
    b: ['moveForward', TraversalRules.Button],
    B: ['moveBackward', TraversalRules.Button],
    c: ['moveForward', TraversalRules.Combobox],
    C: ['moveBackward', TraversalRules.Combobox],
    e: ['moveForward', TraversalRules.Entry],
    E: ['moveBackward', TraversalRules.Entry],
    f: ['moveForward', TraversalRules.FormElement],
    F: ['moveBackward', TraversalRules.FormElement],
    g: ['moveForward', TraversalRules.Graphic],
    G: ['moveBackward', TraversalRules.Graphic],
    h: ['moveForward', TraversalRules.Heading],
    H: ['moveBackward', TraversalRules.Heading],
    i: ['moveForward', TraversalRules.ListItem],
    I: ['moveBackward', TraversalRules.ListItem],
    k: ['moveForward', TraversalRules.Link],
    K: ['moveBackward', TraversalRules.Link],
    l: ['moveForward', TraversalRules.List],
    L: ['moveBackward', TraversalRules.List],
    p: ['moveForward', TraversalRules.PageTab],
    P: ['moveBackward', TraversalRules.PageTab],
    r: ['moveForward', TraversalRules.RadioButton],
    R: ['moveBackward', TraversalRules.RadioButton],
    s: ['moveForward', TraversalRules.Separator],
    S: ['moveBackward', TraversalRules.Separator],
    t: ['moveForward', TraversalRules.Table],
    T: ['moveBackward', TraversalRules.Table],
    x: ['moveForward', TraversalRules.Checkbox],
    X: ['moveBackward', TraversalRules.Checkbox]
  }
};