Gestures

typedef PointerID Integer;

dictionary GestureState {
  Boolean cancel = true; // if true, then cancel the gesture at this point
  Boolean capture = false; // (for pointer-down) if true, then this pointer is relevant
  Boolean choose = false; // if true, the gesture thinks that other gestures should give up
  Boolean finished = true; // if true, we're ready for the next gesture to start

  // choose and cancel are mutually exclusive
}

dictionary SendEventOptions {
  Integer? coallesceGroup = null; // when queuing events, only the last event with each group is kept
  Boolean prechoose = false; // if true, event should just be sent right away, not queued
}

abstract class Gesture : EventTarget {
  constructor (EventTarget target);
  readonly attribute EventTarget target;

  virtual GestureState processEvent(Event event);
  // return {}
  virtual void choose(); // called by GestureManager // make sure to call superclass choose() before
  // - assert: this.active == true
  // - assert: this.chosen == false
  // - set this.chosen = true
  // - if there are any buffered events, dispatch them on this
  virtual void cancel(); // called by GestureManager // make sure to call superclass cancel() after
  // - set active and chosen to false, clear the event buffer

  readonly attribute Boolean ready; // last event, we were finished
  readonly attribute Boolean active; // we have not yet been canceled since we last captured a pointer
  readonly attribute Boolean chosen; // we're the only possible gesture at this point

  // !ready && !active => we're discarding events until the user gets to a state where a new gesture can begin
  // active && !chosen => we're collecting events until no other gesture is valid, or until we take command

  void sendEvent(Event event, SendEventOptions options);
  // used internally to queue up or send events
  //  - assert: this.active == true
  //  - assert: options.prechoose is false or options.coallesceGroup
  //    is null
  //  - set event.gesture = this
  //  - if this.chosen is true or if options.prechoose is true, then
  //    send the event straight to the callback
  //  - otherwise:
  //     - if the event buffer has an entry with the same
  //       coallesceGroup identifier, drop it
  //     - add the event to the event buffer
}

Gesture objects have an Event buffer, initially empty. Each Event in this buffer can be associated with a coallesceGroup, which is identified by integer.

When created, Gesture objects register themselves as pointer-down, pointer-move, and pointer-up event handlers on their target, with the same event handler. That event handler runs the following steps:

  • let wasActive = this.active
  • if this.ready == true, then:
    • // reset the state to start a new gesture
    • if this.active == true, then:
      • call application.document.cancelGesture(this)
    • set this.active = true
    • set this.ready = false
  • let returnValue be the result of calling processEvent() with the Event object
  • if returnValue.capture == true:
    • assert: the event is a pointer-down event
    • if the event is a pointer-down event:
      • push this onto the event's return value
  • if returnValue.cancel == true:
    • assert: returnValue.choose == false
    • if wasActive == true:
      • call application.document.cancelGesture(this)
      • // if wasActive == false, then no need to cancel, since we never added ourselves
  • if returnValue.cancel == false and this.active == true:
    • if wasActive == false or if event is a pointer-down event:
      • call application.document.addGesture(event, this)
    • if returnValue.choose == true:
      • call application.document.chooseGesture(this)
  • set this.ready = returnValue.finished
  • set this.active = returnValue.valid

Subclasses should override processEvent():

  • as the events are received, they get examined to see if they fit the pattern for the gesture; if they do, then return an object with valid=true; if more events for this gesture could still come in, return finished=false.
  • if you returned valid=false finished=false, then the next call to this must not return valid=true
  • doing anything with the event or target other than reading state is a contract violation
  • you are allowed to call sendEvent() at any time during a processEventInternal() call, or after a call to processEventInternal(), assuming that the last such call returned valid=true, until the next call to processEventInternal() or cancel().
  • set forceChoose=true on the return value if you are confident that this is the gesture the user meant, even if it‘s possible that another gesture is still claiming it’s valid (e.g. a long press might forceChoose to override a scroll, if the user hasn't moved for a while)
  • if you send events, you can set prechoose=true to send the event even before the gesture has been chosen
  • if you send prechoose events, make sure to send corresponding “cancel” events if cancel() is called
dictionary GestureList {
  Array<Gesture> gestures;
  Boolean chosen;
}

class GestureManager {
  constructor (EventTarget target);
  readonly attribute EventTarget target; // the ApplicationDocument, normally

  void addGesture(Event event, Gesture gesture);
  void cancelGesture(Gesture gesture);
  void chooseGesture(Gesture gesture);

  GestureList getActiveGestures(PointerID pointer);
}

GestureManager objects have a map of lists of Gesture objects, keyed on pointer IDs, and with each list associated with a “chosen” flag indicating if an entry in the list has already been chosen. Initially the map is empty. It is exposed by the getActiveGestures() method, which returns the list and flag.

When addGesture() is called with an event and a Gesture, it runs the following steps:

  • let pointer be the value of the event's pointer field
  • assert: pointer is an integer
  • if we already have an entry for pointer:
    • assert: this Gesture isn't already on the list for pointer
    • if the list's “chosen” flag is set, then call cancelGesture() with this Gesture
    • otherwise, add this Gesture to the list for pointer
  • otherwise, we don't have an entry for this pointer:
    • create a list for pointer
    • add this Gesture to the list for pointer

A GestureManager, when created, starts listening to pointer-down events on its target. The listener acts as follows:

  • assert: event is a pointer-down event
  • let pointer be the value of the event's pointer field
  • if we have an entry for this pointer, and the “chosen” flag isn‘t set, and there is just one Gesture in the list, then set the flag on the list and call the Gesture’s choose() method.

When cancelGesture() is called with a Gesture:

  • for each pointer list:
    • if the pointer list has this Gesture, remove it
  • call cancel() on the Gesture
  • for each pointer list:
    • if the pointer list has no entries, forget it
    • if the pointer list has one Gesture and the “chosen” flag isn‘t set, set it and call that Gesture’s choose() method.

When chooseGesture() is called with a Gesture:

  • if this Gesture is not active, then return silently // this could happen e.g. if two gestures simultaneously add themselves // and chose themselves for the same pointer-down
  • let losers be an empty list of Gestures
  • for each pointer list:
    • if the pointer list has this Gesture, add all the other Gestures in the list to losers, remove them from the list, and set the “chosen” flag on that list
  • remove duplicates from losers
  • call cancel() on each entry in losers
  • call choose() on the Gesture
class TapGesture : Gesture {

  // internal state:
  //   Integer numButtons = 0;
  //   Boolean primaryDown = false;

  virtual GestureState processEvent(Event event);
  // - let returnValue = { finished = false }
  // - if the event is a pointer-down:
  //    - increment this.numButtons
  //    - set returnValue.capture = true
  // - otherwise if it is a pointer-up:
  //    - assert: this.numButtons > 0
  //    - decrement this.numButtons
  //    - if numButtons == 0:
  //       - set returnValue.finished = true
  // - if this.ready == false and this.active == false:
  //    - return returnValue
  // - if EventTarget isn't an Element:
  //    - assert: event is a pointer-down
  //    - return returnValue
  // - if the event is pointer-down:
  //    - assert: this.numButtons > 0
  //    - if it's primary:
  //       - assert: this.ready==true // this is the first press
  //       - this.primaryDown = true
  //       - sendEvent() a tap-down event, with prechoose=true
  //       - set returnValue.cancel = false
  //       - return returnValue
  //    - otherwise:
  //       - if this.primaryDown == true and this.active == true:
  //          - // this is some bogus secondary press that we should have prevent
  //            // taps from starting until it's finished, but it doesn't invalidate
  //            // the existing primary press
  //          - set returnValue.cancel = false
  //          - return returnValue
  //       - otherwise:
  //          - // this is some secondary press but we don't have a first press
  //            // (maybe this is all in the context of a right-click or something)
  //            // we have to wait til it's done before we can start a tap gesture again
  //          - return returnValue
  // - if the event is pointer-move:
  //    - assert: this.numButtons > 0
  //    - if it's primary:
  //       - if it hit tests within target's bounding box:
  //          - sendEvent() a tap-move event, with prechoose=true
  //          - set returnValue.cancel = false
  //          - return returnValue
  //       - otherwise:
  //          - sendEvent() a tap-cancel event, with prechoose=true
  //          - return returnValue
  //    - otherwise:
  //       - // this is the move of some bogus secondary press
  //         // ignore it, but continue listening if we have a primary button down
  //       - if this.primaryDown == true and this.active == true:
  //          - set returnValue.cancel = false
  //       - return returnValue
  // - if the event is pointer-up:
  //    - if it's primary:
  //       - sendEvent() a tap event
  //       - set this.primaryDown = false
  //       - set returnValue.cancel = false
  //       - return returnValue
  //    - otherwise:
  //       - // this is the 'up' of some bogus secondary press
  //         // ignore it, but continue listening for our primary up if necessary
  //       - if this.primaryDown == true and this.active == true:
  //          - set returnValue.cancel = false
  //       - return returnValue
}

class LongPressGesture : Gesture {
  GestureState processEvent(EventTarget target, Event event);  
  // long-tap-start: sent when the primary pointer goes down
  // long-tap-cancel: sent when cancel()ed or finger goes out of bounding box
  // long-tap: sent when the primary pointer is released
}

class DoubleTapGesture : Gesture {
  GestureState processEvent(EventTarget target, Event event);  
  // double-tap-start: sent when the primary pointer goes down the first time
  // double-tap-cancel: sent when cancel()ed or finger goes out of bounding box, or it times out
  // double-tap: sent when the primary pointer is released the second time within the timeout
}


abstract class ScrollGesture : Gesture {
  GestureState processEvent(EventTarget target, Event event);  
  // this fires the following events (inertia is a boolean, delta is a float):
  //   scroll-start, with field inertia=false, delta=0; prechoose=true
  //   scroll, with fields inertia (is this a simulated scroll from inertia or a real scroll?), delta (number of pixels to scroll); prechoose=true
  //   scroll-end, with field inertia (same), delta=0; prechoose=true
  // scroll-start is fired right away
  // scroll is sent whenever the primary pointer moves while down
  // scroll is also sent after the pointer goes back up, based on inertia
  // scroll-end is sent after the pointer goes back up once the scroll reaches delta=0
  // scroll-end is also sent when the gesture is canceled or reset
  // processEvent() returns:
  //  - cancel=false pretty much always so long as there's a primary touch (e.g. not for a right-click)
  //  - chose=true when you travel a certain distance
  //  - finished=true when the primary pointer goes up
}

class HorizontalScrollGesture : ScrollGesture { }
  // a ScrollGesture giving x-axis scrolling

class VerticalScrollGesture : ScrollGesture { }
  // a ScrollGesture giving y-axis scrolling


class PanGesture : Gesture {
  // similar to ScrollGesture, but with two axes
  // pan-start, pan, pan-end
  // events have inertia (boolean), dx (float), dy (float)
}


abstract class ZoomGesture : Gesture {
  GestureState processEvent(EventTarget target, Event event);  
  // zoom-start: sent when we could start zooming (e.g. for pinch-zoom, when two fingers hit the glass) (prechoose)
  // zoom-end: sent when cancel()ed after zoom-start, or when the fingers are lifted (prechoose)
  // zoom, with a 'scale' attribute, whose value is a multiple of the scale factor at zoom-start
  // e.g. if the user zooms to 2x, you'd get a bunch of 'zoom' events like scale=1.0, scale=1.17, ... scale=1.91, scale=2.0
}

class PinchZoomGesture : ZoomGesture {
  // a ZoomGesture for two-finger-pinch gesture
  // zoom is prechoose
}

class DoubleTapZoomGesture : ZoomGesture {
  // a ZoomGesture for the double-tap-slide gesture
  // when the slide starts, forceChoose
}


class PanAndZoomGesture : Gesture {
  GestureState processEvent(EventTarget target, Event event);  
  // manipulate-start (prechoose)
  // manipulate: (prechoose)
  //    panX, panY: pixels
  //    scaleX, scaleY: a multiplier of the scale at manipulate-start
  //    rotation: turns
  // manipulate-end (prechoose)
}


abstract class FlingGesture : Gesture {
  GestureState processEvent(EventTarget target, Event event);  
  // fling-start: when the gesture begins (prechoose)
  // fling-move: while the user is directly dragging the element (has delta attribute with the distance from fling-start) (prechoose)
  // fling: the user has released the pointer and the decision is it was in fact flung
  // fling-cancel: cancel(), or the user has released the pointer and the decision is it was not flung (prechoose)
  // fling-end: cancel(), or after fling or fling-cancel (prechoose)
}

class FlingLeftGesture : FlingGesture { }
class FlingRightGesture : FlingGesture { }
class FlingUpGesture : FlingGesture { }
class FlingDownGesture : FlingGesture { }