Bug 1120623 - Make the flamegraph keyboard accessible, r=jsantell
authorVictor Porof <vporof@mozilla.com>
Tue, 09 Feb 2016 13:45:37 +0200
changeset 329841 7a0ebe3fccacc655c38e7f32948626af4d3d3269
parent 329840 e417e32d9cb698fe0b2e376d9cdeac0c2cffb43f
child 329842 683c1c3ca8321e064f7bcd2f690557ef6562bffb
push id10617
push userdtownsend@mozilla.com
push dateTue, 09 Feb 2016 16:30:19 +0000
reviewersjsantell
bugs1120623
milestone47.0a1
Bug 1120623 - Make the flamegraph keyboard accessible, r=jsantell
devtools/client/performance/views/details-js-flamegraph.js
devtools/client/shared/test/browser.ini
devtools/client/shared/test/browser_flame-graph-05.js
devtools/client/shared/widgets/FlameGraph.js
--- a/devtools/client/performance/views/details-js-flamegraph.js
+++ b/devtools/client/performance/views/details-js-flamegraph.js
@@ -75,16 +75,18 @@ var JsFlameGraphView = Heritage.extend(D
         endTime: duration
       },
       visible: {
         startTime: interval.startTime || 0,
         endTime: interval.endTime || duration
       }
     });
 
+    this.graph.focus();
+
     this.emit(EVENTS.JS_FLAMEGRAPH_RENDERED);
   },
 
   /**
    * Fired when a range is selected or cleared in the FlameGraph.
    */
   _onRangeChangeInGraph: function () {
     let interval = this.graph.getViewRange();
--- a/devtools/client/shared/test/browser.ini
+++ b/devtools/client/shared/test/browser.ini
@@ -40,16 +40,17 @@ support-files =
 [browser_filter-presets-02.js]
 [browser_filter-presets-03.js]
 [browser_flame-graph-01.js]
 [browser_flame-graph-02.js]
 [browser_flame-graph-03a.js]
 [browser_flame-graph-03b.js]
 [browser_flame-graph-03c.js]
 [browser_flame-graph-04.js]
+[browser_flame-graph-05.js]
 [browser_flame-graph-utils-01.js]
 [browser_flame-graph-utils-02.js]
 [browser_flame-graph-utils-03.js]
 [browser_flame-graph-utils-04.js]
 [browser_flame-graph-utils-05.js]
 [browser_flame-graph-utils-06.js]
 [browser_flame-graph-utils-hash.js]
 [browser_graphs-01.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_flame-graph-05.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that flame graph widget has proper keyboard support.
+
+var TEST_DATA = [{ color: "#f00", blocks: [{ x: 0, y: 0, width: 50, height: 20, text: "FOO" }, { x: 50, y: 0, width: 100, height: 20, text: "BAR" }] }, { color: "#00f", blocks: [{ x: 0, y: 30, width: 30, height: 20, text: "BAZ" }] }];
+var TEST_BOUNDS = { startTime: 0, endTime: 150 };
+var TEST_DPI_DENSITIY = 2;
+
+const KEY_CODE_UP = 38;
+const KEY_CODE_DOWN = 40;
+const KEY_CODE_LEFT = 37;
+const KEY_CODE_RIGHT = 39;
+
+var {FlameGraph} = require("devtools/client/shared/widgets/FlameGraph");
+
+add_task(function*() {
+  yield addTab("about:blank");
+  yield performTest();
+  gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+  let [host, win, doc] = yield createHost();
+  doc.body.setAttribute("style", "position: fixed; width: 100%; height: 100%; margin: 0;");
+
+  let graph = new FlameGraph(doc.body, TEST_DPI_DENSITIY);
+  yield graph.ready();
+
+  yield testGraph(host, graph);
+
+  yield graph.destroy();
+  host.destroy();
+}
+
+function* testGraph(host, graph) {
+  graph.setData({ data: TEST_DATA, bounds: TEST_BOUNDS });
+
+  is(graph._selection.start, 0,
+    "The graph's selection start value is initially correct.");
+  is(graph._selection.end, TEST_BOUNDS.endTime * TEST_DPI_DENSITIY,
+    "The graph's selection end value is initially correct.");
+
+  yield pressKeyForTime(graph, KEY_CODE_LEFT, 1000);
+
+  is(graph._selection.start, 0,
+    "The graph's selection start value is correct after pressing LEFT.");
+  ok(graph._selection.end < TEST_BOUNDS.endTime * TEST_DPI_DENSITIY,
+    "The graph's selection end value is correct after pressing LEFT.");
+
+  graph._selection.start = 0;
+  graph._selection.end = TEST_BOUNDS.endTime * TEST_DPI_DENSITIY;
+  info("Graph selection was reset (1).");
+
+  yield pressKeyForTime(graph, KEY_CODE_RIGHT, 1000);
+
+  ok(graph._selection.start > 0,
+    "The graph's selection start value is correct after pressing RIGHT.");
+  is(graph._selection.end, TEST_BOUNDS.endTime * TEST_DPI_DENSITIY,
+    "The graph's selection end value is correct after pressing RIGHT.");
+
+  graph._selection.start = 0;
+  graph._selection.end = TEST_BOUNDS.endTime * TEST_DPI_DENSITIY;
+  info("Graph selection was reset (2).");
+
+  yield pressKeyForTime(graph, KEY_CODE_UP, 1000);
+
+  ok(graph._selection.start > 0,
+    "The graph's selection start value is correct after pressing UP.");
+  ok(graph._selection.end < TEST_BOUNDS.endTime * TEST_DPI_DENSITIY,
+    "The graph's selection end value is correct after pressing UP.");
+
+  let distanceLeft = graph._selection.start;
+  let distanceRight = TEST_BOUNDS.endTime * TEST_DPI_DENSITIY - graph._selection.end;
+
+  ok(Math.abs(distanceRight - distanceLeft) < 0.1,
+    "The graph zoomed correctly towards the center point.");
+}
+
+function pressKeyForTime(graph, keyCode, ms) {
+  let deferred = promise.defer();
+
+  graph._onKeyDown({ keyCode });
+
+  setTimeout(() => {
+    graph._onKeyUp({ keyCode });
+    deferred.resolve();
+  }, ms);
+
+  return deferred.promise;
+}
--- a/devtools/client/shared/widgets/FlameGraph.js
+++ b/devtools/client/shared/widgets/FlameGraph.js
@@ -32,16 +32,20 @@ const HTML_NS = "http://www.w3.org/1999/
 const GRAPH_SRC = "chrome://devtools/content/shared/widgets/graphs-frame.xhtml";
 
 const L10N = new ViewHelpers.L10N();
 
 const GRAPH_RESIZE_EVENTS_DRAIN = 100; // ms
 
 const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00035;
 const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.5;
+const GRAPH_KEYBOARD_ZOOM_SENSITIVITY = 20;
+const GRAPH_KEYBOARD_PAN_SENSITIVITY = 20;
+const GRAPH_KEYBOARD_ACCELERATION = 1.05;
+const GRAPH_KEYBOARD_TRANSLATION_MAX = 150;
 const GRAPH_MIN_SELECTION_WIDTH = 0.001; // ms
 
 const GRAPH_HORIZONTAL_PAN_THRESHOLD = 10; // px
 const GRAPH_VERTICAL_PAN_THRESHOLD = 30; // px
 
 const FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS = 100;
 const TIMELINE_TICKS_MULTIPLE = 5; // ms
 const TIMELINE_TICKS_SPACING_MIN = 75; // px
@@ -153,36 +157,47 @@ function FlameGraph(parent, sharpness) {
     this._height = canvas.height = bounds.height * this._pixelRatio;
     this._ctx = canvas.getContext("2d");
 
     this._bounds = new GraphArea();
     this._selection = new GraphArea();
     this._selectionDragger = new GraphAreaDragger();
     this._verticalOffset = 0;
     this._verticalOffsetDragger = new GraphAreaDragger(0);
+    this._keyboardZoomAccelerationFactor = 1;
+    this._keyboardPanAccelerationFactor = 1;
+
+    this._userInputStack = 0;
+    this._keysPressed = [];
 
     // Calculating text widths is necessary to trim the text inside the blocks
     // while the scaling changes (e.g. via scrolling). This is very expensive,
     // so maintain a cache of string contents to text widths.
     this._textWidthsCache = {};
 
     let fontSize = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE * this._pixelRatio;
     let fontFamily = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY;
     this._ctx.font = fontSize + "px " + fontFamily;
     this._averageCharWidth = this._calcAverageCharWidth();
     this._overflowCharWidth = this._getTextWidth(this.overflowChar);
 
     this._onAnimationFrame = this._onAnimationFrame.bind(this);
+    this._onKeyDown = this._onKeyDown.bind(this);
+    this._onKeyUp = this._onKeyUp.bind(this);
+    this._onKeyPress = this._onKeyPress.bind(this);
     this._onMouseMove = this._onMouseMove.bind(this);
     this._onMouseDown = this._onMouseDown.bind(this);
     this._onMouseUp = this._onMouseUp.bind(this);
     this._onMouseWheel = this._onMouseWheel.bind(this);
     this._onResize = this._onResize.bind(this);
     this.refresh = this.refresh.bind(this);
 
+    this._window.addEventListener("keydown", this._onKeyDown);
+    this._window.addEventListener("keyup", this._onKeyUp);
+    this._window.addEventListener("keypress", this._onKeyPress);
     this._window.addEventListener("mousemove", this._onMouseMove);
     this._window.addEventListener("mousedown", this._onMouseDown);
     this._window.addEventListener("mouseup", this._onMouseUp);
     this._window.addEventListener("MozMousePixelScroll", this._onMouseWheel);
 
     let ownerWindow = this._parent.ownerDocument.defaultView;
     ownerWindow.addEventListener("resize", this._onResize);
 
@@ -213,16 +228,19 @@ FlameGraph.prototype = {
   },
 
   /**
    * Destroys this graph.
    */
   destroy: Task.async(function*() {
     yield this.ready();
 
+    this._window.removeEventListener("keydown", this._onKeyDown);
+    this._window.removeEventListener("keyup", this._onKeyUp);
+    this._window.removeEventListener("keypress", this._onKeyPress);
     this._window.removeEventListener("mousemove", this._onMouseMove);
     this._window.removeEventListener("mousedown", this._onMouseDown);
     this._window.removeEventListener("mouseup", this._onMouseUp);
     this._window.removeEventListener("MozMousePixelScroll", this._onMouseWheel);
 
     let ownerWindow = this._parent.ownerDocument.defaultView;
     if (ownerWindow) {
       ownerWindow.removeEventListener("resize", this._onResize);
@@ -231,16 +249,18 @@ FlameGraph.prototype = {
     this._window.cancelAnimationFrame(this._animationId);
     this._iframe.remove();
 
     this._bounds = null;
     this._selection = null;
     this._selectionDragger = null;
     this._verticalOffset = null;
     this._verticalOffsetDragger = null;
+    this._keyboardZoomAccelerationFactor = null;
+    this._keyboardPanAccelerationFactor = null;
     this._textWidthsCache = null;
 
     this._data = null;
 
     this.emit("destroyed");
   }),
 
   /**
@@ -344,16 +364,23 @@ FlameGraph.prototype = {
     return {
       startTime: this._selection.start / this._pixelRatio,
       endTime: this._selection.end / this._pixelRatio,
       verticalOffset: this._verticalOffset / this._pixelRatio
     };
   },
 
   /**
+   * Focuses this graph's iframe window.
+   */
+  focus: function() {
+    this._window.focus();
+  },
+
+  /**
    * Updates this graph to reflect the new dimensions of the parent node.
    *
    * @param boolean options.force
    *        Force redraw everything.
    */
   refresh: function(options={}) {
     let bounds = this._parent.getBoundingClientRect();
     let newWidth = this.fixedWidth || bounds.width;
@@ -410,29 +437,130 @@ FlameGraph.prototype = {
   /**
    * Redraws the widget when necessary. The actual graph is not refreshed
    * every time this function is called, only the cliphead, selection etc.
    */
   _drawWidget: function() {
     if (!this._shouldRedraw) {
       return;
     }
+
+    // Unlike mouse events which are updated as needed in their own respective
+    // handlers, keyboard events are granular and non-continuous (not even
+    // "keydown", which is fired with a low frequency). Therefore, to maintain
+    // animation smoothness, update anything that's controllable via the
+    // keyboard here, in the animation loop, before any actual drawing.
+    this._keyboardUpdateLoop();
+
     let ctx = this._ctx;
     let canvasWidth = this._width;
     let canvasHeight = this._height;
     ctx.clearRect(0, 0, canvasWidth, canvasHeight);
 
     let selection = this._selection;
     let selectionWidth = selection.end - selection.start;
     let selectionScale = canvasWidth / selectionWidth;
     this._drawTicks(selection.start, selectionScale);
     this._drawPyramid(this._data, this._verticalOffset, selection.start, selectionScale);
     this._drawHeader(selection.start, selectionScale);
 
-    this._shouldRedraw = false;
+    // If the user isn't doing anything anymore, it's safe to stop drawing.
+    // XXX: This doesn't handle cases where we should still be drawing even
+    // if any input stops (e.g. smooth panning transitions after the user
+    // finishes input). We don't care about that right now.
+    if (this._userInputStack == 0) {
+      return void (this._shouldRedraw = false);
+    }
+    if (this._userInputStack < 0) {
+      throw new Error("The user went back in time from a pyramid.");
+    }
+  },
+
+  /**
+   * Performs any necessary changes to the graph's state based on the
+   * user's input on a keyboard.
+   */
+  _keyboardUpdateLoop: function() {
+    const KEY_CODE_UP = 38;
+    const KEY_CODE_DOWN = 40;
+    const KEY_CODE_LEFT = 37;
+    const KEY_CODE_RIGHT = 39;
+    const KEY_CODE_W = 87;
+    const KEY_CODE_A = 65;
+    const KEY_CODE_S = 83;
+    const KEY_CODE_D = 68;
+
+    let canvasWidth = this._width;
+    let canvasHeight = this._height;
+    let pressed = this._keysPressed;
+
+    let selection = this._selection;
+    let selectionWidth = selection.end - selection.start;
+    let selectionScale = canvasWidth / selectionWidth;
+
+    let translation = [0, 0];
+    let isZooming = false;
+    let isPanning = false;
+
+    if (pressed[KEY_CODE_UP] || pressed[KEY_CODE_W]) {
+      translation[0] += GRAPH_KEYBOARD_ZOOM_SENSITIVITY / selectionScale;
+      translation[1] -= GRAPH_KEYBOARD_ZOOM_SENSITIVITY / selectionScale;
+      isZooming = true;
+    }
+    if (pressed[KEY_CODE_DOWN] || pressed[KEY_CODE_S]) {
+      translation[0] -= GRAPH_KEYBOARD_ZOOM_SENSITIVITY / selectionScale;
+      translation[1] += GRAPH_KEYBOARD_ZOOM_SENSITIVITY / selectionScale;
+      isZooming = true;
+    }
+    if (pressed[KEY_CODE_LEFT] || pressed[KEY_CODE_A]) {
+      translation[0] -= GRAPH_KEYBOARD_PAN_SENSITIVITY / selectionScale;
+      translation[1] -= GRAPH_KEYBOARD_PAN_SENSITIVITY / selectionScale;
+      isPanning = true;
+    }
+    if (pressed[KEY_CODE_RIGHT] || pressed[KEY_CODE_D]) {
+      translation[0] += GRAPH_KEYBOARD_PAN_SENSITIVITY / selectionScale;
+      translation[1] += GRAPH_KEYBOARD_PAN_SENSITIVITY / selectionScale;
+      isPanning = true;
+    }
+
+    if (isPanning) {
+      // Accelerate the left/right selection panning continuously
+      // while the pan keys are pressed.
+      this._keyboardPanAccelerationFactor *= GRAPH_KEYBOARD_ACCELERATION;
+      translation[0] *= this._keyboardPanAccelerationFactor;
+      translation[1] *= this._keyboardPanAccelerationFactor;
+    } else {
+      this._keyboardPanAccelerationFactor = 1;
+    }
+
+    if (isZooming) {
+      // Accelerate the in/out selection zooming continuously
+      // while the zoom keys are pressed.
+      this._keyboardZoomAccelerationFactor *= GRAPH_KEYBOARD_ACCELERATION;
+      translation[0] *= this._keyboardZoomAccelerationFactor;
+      translation[1] *= this._keyboardZoomAccelerationFactor;
+    } else {
+      this._keyboardZoomAccelerationFactor = 1;
+    }
+
+    if (translation[0] != 0 || translation[1] != 0) {
+      // Make sure the panning translation speed doesn't end up
+      // being too high.
+      let maxTranslation = GRAPH_KEYBOARD_TRANSLATION_MAX / selectionScale;
+      if (Math.abs(translation[0]) > maxTranslation) {
+        translation[0] = Math.sign(translation[0]) * maxTranslation;
+      }
+      if (Math.abs(translation[1]) > maxTranslation) {
+        translation[1] = Math.sign(translation[1]) * maxTranslation;
+      }
+      this._selection.start += translation[0];
+      this._selection.end += translation[1];
+      this._normalizeSelectionBounds();
+      this.emit("selecting");
+    }
   },
 
   /**
    * Draws the overhead header, with time markers and ticks in this graph.
    *
    * @param number dataOffset, dataScale
    *        Offsets and scales the data source by the specified amount.
    *        This is used for scrolling the visualization.
@@ -757,16 +885,49 @@ FlameGraph.prototype = {
       if (trimmedWidth < maxWidth) {
         return trimmedText + this.overflowChar;
       }
     }
     return "";
   },
 
   /**
+   * Listener for the "keydown" event on the graph's container.
+   */
+  _onKeyDown: function(e) {
+    ViewHelpers.preventScrolling(e);
+
+    if (!this._keysPressed[e.keyCode]) {
+      this._keysPressed[e.keyCode] = true;
+      this._userInputStack++;
+      this._shouldRedraw = true;
+    }
+  },
+
+  /**
+   * Listener for the "keyup" event on the graph's container.
+   */
+  _onKeyUp: function(e) {
+    ViewHelpers.preventScrolling(e);
+
+    if (this._keysPressed[e.keyCode]) {
+      this._keysPressed[e.keyCode] = false;
+      this._userInputStack--;
+      this._shouldRedraw = true;
+    }
+  },
+
+  /**
+   * Listener for the "keypress" event on the graph's container.
+   */
+  _onKeyPress: function(e) {
+    ViewHelpers.preventScrolling(e);
+  },
+
+  /**
    * Listener for the "mousemove" event on the graph's container.
    */
   _onMouseMove: function(e) {
     let {mouseX, mouseY} = this._getRelativeEventCoordinates(e);
 
     let canvasWidth = this._width;
     let canvasHeight = this._height;