Bug 1250835 - Display swatch for angles in the rules panel. r=miker
authorNicolas Chevobbe <chevobbe.nicolas@gmail.com>
Tue, 08 Mar 2016 23:04:54 +0100
changeset 290341 7356a69124c90d98a417926455ca8d8d7d27691c
parent 290340 c96ec660af96a187b62c5dd5f9bce62c0f2ffeb6
child 290342 a296e3d1019d2953f4595b7d4aec24c19c4c1ce6
push id30118
push userryanvm@gmail.com
push dateFri, 25 Mar 2016 15:37:11 +0000
treeherdermozilla-central@b2dbee5ca727 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmiker
bugs1250835
milestone48.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1250835 - Display swatch for angles in the rules panel. r=miker Add a swatch before angle values in the rules panel and allow cycling through angle units with shift+click (like we already do for color units). MozReview-Commit-ID: CWhoUQTkP1G
devtools/client/inspector/rules/views/text-property-editor.js
devtools/client/inspector/shared/test/browser_styleinspector_output-parser.js
devtools/client/jar.mn
devtools/client/shared/output-parser.js
devtools/client/shared/test/browser.ini
devtools/client/shared/test/browser_css_angle.js
devtools/client/shared/test/browser_outputparser.js
devtools/client/themes/images/angle-swatch.svg
devtools/client/themes/rules.css
devtools/shared/css-angle.js
devtools/shared/moz.build
devtools/shared/tests/unit/test_cssAngle.js
devtools/shared/tests/unit/xpcshell.ini
--- a/devtools/client/inspector/rules/views/text-property-editor.js
+++ b/devtools/client/inspector/rules/views/text-property-editor.js
@@ -332,25 +332,28 @@ TextPropertyEditor.prototype = {
     } else {
       this.element.removeAttribute("dirty");
     }
 
     const sharedSwatchClass = "ruleview-swatch ";
     const colorSwatchClass = "ruleview-colorswatch";
     const bezierSwatchClass = "ruleview-bezierswatch";
     const filterSwatchClass = "ruleview-filterswatch";
+    const angleSwatchClass = "ruleview-angleswatch";
 
     let outputParser = this.ruleView._outputParser;
     let parserOptions = {
       colorSwatchClass: sharedSwatchClass + colorSwatchClass,
       colorClass: "ruleview-color",
       bezierSwatchClass: sharedSwatchClass + bezierSwatchClass,
       bezierClass: "ruleview-bezier",
       filterSwatchClass: sharedSwatchClass + filterSwatchClass,
       filterClass: "ruleview-filter",
+      angleSwatchClass: sharedSwatchClass + angleSwatchClass,
+      angleClass: "ruleview-angle",
       defaultColorType: !propDirty,
       urlClass: "theme-link",
       baseURI: this.sheetURI
     };
     let frag = outputParser.parseCssProperty(name, val, parserOptions);
     this.valueSpan.innerHTML = "";
     this.valueSpan.appendChild(frag);
 
--- a/devtools/client/inspector/shared/test/browser_styleinspector_output-parser.js
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_output-parser.js
@@ -9,16 +9,17 @@
 // This is more of a unit test than a mochitest-browser test, but can't be
 // tested with an xpcshell test as the output-parser requires the DOM to work.
 
 var {OutputParser} = require("devtools/client/shared/output-parser");
 
 const COLOR_CLASS = "color-class";
 const URL_CLASS = "url-class";
 const CUBIC_BEZIER_CLASS = "bezier-class";
+const ANGLE_CLASS = "angle-class";
 
 const TEST_DATA = [
   {
     name: "width",
     value: "100%",
     test: fragment => {
       is(countAll(fragment), 0);
       is(fragment.textContent, "100%");
@@ -155,21 +156,24 @@ const TEST_DATA = [
       is(allSwatches[3].textContent, "#F06");
       is(allSwatches[4].textContent, "red");
     }
   },
   {
     name: "background",
     value: "-moz-radial-gradient(center 45deg, circle closest-side, orange 0%, red 100%)",
     test: fragment => {
-      is(countAll(fragment), 4);
-      let allSwatches = fragment.querySelectorAll("." + COLOR_CLASS);
-      is(allSwatches.length, 2);
-      is(allSwatches[0].textContent, "orange");
-      is(allSwatches[1].textContent, "red");
+      is(countAll(fragment), 6);
+      let colorSwatches = fragment.querySelectorAll("." + COLOR_CLASS);
+      is(colorSwatches.length, 2);
+      is(colorSwatches[0].textContent, "orange");
+      is(colorSwatches[1].textContent, "red");
+      let angleSwatches = fragment.querySelectorAll("." + ANGLE_CLASS);
+      is(angleSwatches.length, 1);
+      is(angleSwatches[0].textContent, "45deg");
     }
   },
   {
     name: "background",
     value: "white  url(http://test.com/wow_such_image.png) no-repeat top left",
     test: fragment => {
       is(countAll(fragment), 3);
       is(countUrls(fragment), 1);
@@ -291,16 +295,17 @@ add_task(function*() {
   for (let i = 0; i < TEST_DATA.length; i++) {
     let data = TEST_DATA[i];
     info("Output-parser test data " + i + ". {" + data.name + " : " +
       data.value + ";}");
     data.test(parser.parseCssProperty(data.name, data.value, {
       colorClass: COLOR_CLASS,
       urlClass: URL_CLASS,
       bezierClass: CUBIC_BEZIER_CLASS,
+      angleClass: ANGLE_CLASS,
       defaultColorType: false
     }));
   }
 });
 
 function countAll(fragment) {
   return fragment.querySelectorAll("*").length;
 }
--- a/devtools/client/jar.mn
+++ b/devtools/client/jar.mn
@@ -149,16 +149,17 @@ devtools.jar:
     skin/dark-theme.css (themes/dark-theme.css)
     skin/light-theme.css (themes/light-theme.css)
     skin/firebug-theme.css (themes/firebug-theme.css)
     skin/toolbars.css (themes/toolbars.css)
     skin/variables.css (themes/variables.css)
     skin/images/add.svg (themes/images/add.svg)
     skin/images/filters.svg (themes/images/filters.svg)
     skin/images/filter-swatch.svg (themes/images/filter-swatch.svg)
+    skin/images/angle-swatch.svg (themes/images/angle-swatch.svg)
     skin/images/pseudo-class.svg (themes/images/pseudo-class.svg)
     skin/images/controls.png (themes/images/controls.png)
     skin/images/controls@2x.png (themes/images/controls@2x.png)
     skin/images/animation-fast-track.svg (themes/images/animation-fast-track.svg)
     skin/images/performance-icons.svg (themes/images/performance-icons.svg)
     skin/widgets.css (themes/widgets.css)
     skin/images/power.svg (themes/images/power.svg)
     skin/images/filetypes/dir-close.svg (themes/images/filetypes/dir-close.svg)
--- a/devtools/client/shared/output-parser.js
+++ b/devtools/client/shared/output-parser.js
@@ -1,15 +1,16 @@
 /* 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, Ci, Cu} = require("chrome");
+const {angleUtils} = require("devtools/shared/css-angle");
 const {colorUtils} = require("devtools/shared/css-color");
 const Services = require("Services");
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 
 const BEZIER_KEYWORDS = ["linear", "ease-in-out", "ease-in", "ease-out",
                          "ease"];
 
@@ -19,16 +20,31 @@ const COLOR_TAKING_FUNCTIONS = ["linear-
                                 "repeating-linear-gradient",
                                 "-moz-repeating-linear-gradient",
                                 "radial-gradient",
                                 "-moz-radial-gradient",
                                 "repeating-radial-gradient",
                                 "-moz-repeating-radial-gradient",
                                 "drop-shadow"];
 
+// Functions that accept an angle argument.
+const ANGLE_TAKING_FUNCTIONS = ["linear-gradient",
+                                "-moz-linear-gradient",
+                                "repeating-linear-gradient",
+                                "-moz-repeating-linear-gradient",
+                                "rotate",
+                                "rotateX",
+                                "rotateY",
+                                "rotateZ",
+                                "rotate3d",
+                                "skew",
+                                "skewX",
+                                "skewY",
+                                "hue-rotate"];
+
 loader.lazyGetter(this, "DOMUtils", function() {
   return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
 });
 
 /**
  * This module is used to process text for output by developer tools. This means
  * linking JS files with the debugger, CSS files with the style editor, JS
  * functions with the debugger, placing color swatches next to colors and
@@ -43,17 +59,19 @@ loader.lazyGetter(this, "DOMUtils", func
  *   let parser = new OutputParser(document);
  *
  *   parser.parseCssProperty("color", "red"); // Returns document fragment.
  */
 function OutputParser(document) {
   this.parsed = [];
   this.doc = document;
   this.colorSwatches = new WeakMap();
-  this._onSwatchMouseDown = this._onSwatchMouseDown.bind(this);
+  this.angleSwatches = new WeakMap();
+  this._onColorSwatchMouseDown = this._onColorSwatchMouseDown.bind(this);
+  this._onAngleSwatchMouseDown = this._onAngleSwatchMouseDown.bind(this);
 }
 
 exports.OutputParser = OutputParser;
 
 OutputParser.prototype = {
   /**
    * Parse a CSS property value given a property name.
    *
@@ -62,17 +80,17 @@ OutputParser.prototype = {
    * @param  {String} value
    *         CSS Property value
    * @param  {Object} [options]
    *         Options object. For valid options and default values see
    *         _mergeOptions().
    * @return {DocumentFragment}
    *         A document fragment containing color swatches etc.
    */
-  parseCssProperty: function(name, value, options={}) {
+  parseCssProperty: function(name, value, options = {}) {
     options = this._mergeOptions(options);
 
     options.expectCubicBezier =
       safeCssPropertySupportsType(name, DOMUtils.TYPE_TIMING_FUNCTION);
     options.expectFilter = name === "filter";
     options.supportsColor =
       safeCssPropertySupportsType(name, DOMUtils.TYPE_COLOR) ||
       safeCssPropertySupportsType(name, DOMUtils.TYPE_GRADIENT);
@@ -135,50 +153,56 @@ OutputParser.prototype = {
    * @param  {String} text
    *         Text to parse.
    * @param  {Object} [options]
    *         Options object. For valid options and default values see
    *         _mergeOptions().
    * @return {DocumentFragment}
    *         A document fragment.
    */
-  _parse: function(text, options={}) {
+  _parse: function(text, options = {}) {
     text = text.trim();
     this.parsed.length = 0;
 
     let tokenStream = DOMUtils.getCSSLexer(text);
     let parenDepth = 0;
     let outerMostFunctionTakesColor = false;
 
     let colorOK = function() {
       return options.supportsColor ||
         (options.expectFilter && parenDepth === 1 &&
          outerMostFunctionTakesColor);
     };
 
+    let angleOK = function(angle) {
+      return /^-?\d+\.?\d*(deg|rad|grad|turn)$/gi.test(angle);
+    };
+
     while (true) {
       let token = tokenStream.nextToken();
       if (!token) {
         break;
       }
       if (token.tokenType === "comment") {
         continue;
       }
 
       switch (token.tokenType) {
         case "function": {
-          if (COLOR_TAKING_FUNCTIONS.indexOf(token.text) >= 0) {
-            // The function can accept a color argument, and we know
-            // it isn't special in some other way.  So, we let it
-            // through to the ordinary parsing loop so that colors
+          if (COLOR_TAKING_FUNCTIONS.includes(token.text) ||
+              ANGLE_TAKING_FUNCTIONS.includes(token.text)) {
+            // The function can accept a color or an angle argument, and we know
+            // it isn't special in some other way. So, we let it
+            // through to the ordinary parsing loop so that the value
             // can be handled in a single place.
             this._appendTextNode(text.substring(token.startOffset,
                                                 token.endOffset));
             if (parenDepth === 0) {
-              outerMostFunctionTakesColor = true;
+              outerMostFunctionTakesColor = COLOR_TAKING_FUNCTIONS.includes(
+                token.text);
             }
             ++parenDepth;
           } else {
             let functionText = this._collectFunctionText(token, text,
                                                          tokenStream);
 
             if (options.expectCubicBezier && token.text === "cubic-bezier") {
               this._appendCubicBezier(functionText, options);
@@ -192,49 +216,61 @@ OutputParser.prototype = {
         }
 
         case "ident":
           if (options.expectCubicBezier &&
               BEZIER_KEYWORDS.indexOf(token.text) >= 0) {
             this._appendCubicBezier(token.text, options);
           } else if (colorOK() && DOMUtils.isValidCSSColor(token.text)) {
             this._appendColor(token.text, options);
+          } else if (angleOK(token.text)) {
+            this._appendAngle(token.text, options);
           } else {
             this._appendTextNode(text.substring(token.startOffset,
                                                 token.endOffset));
           }
           break;
 
         case "id":
         case "hash": {
           let original = text.substring(token.startOffset, token.endOffset);
           if (colorOK() && DOMUtils.isValidCSSColor(original)) {
             this._appendColor(original, options);
           } else {
             this._appendTextNode(original);
           }
           break;
         }
-
+        case "dimension":
+          let value = text.substring(token.startOffset, token.endOffset);
+          if (angleOK(value)) {
+            this._appendAngle(value, options);
+          } else {
+            this._appendTextNode(value);
+          }
+          break;
         case "url":
         case "bad_url":
           this._appendURL(text.substring(token.startOffset, token.endOffset),
                           token.text, options);
           break;
 
         case "symbol":
           if (token.text === "(") {
             ++parenDepth;
-          } else if (token.token === ")") {
+          } else if (token.text === ")") {
             --parenDepth;
+            if (parenDepth === 0) {
+              outerMostFunctionTakesColor = false;
+            }
           }
           // falls through
         default:
-          this._appendTextNode(text.substring(token.startOffset,
-                                              token.endOffset));
+          this._appendTextNode(
+            text.substring(token.startOffset, token.endOffset));
           break;
       }
     }
 
     let result = this._toDOM();
 
     if (options.expectFilter && !options.filterSwatch) {
       result = this._wrapFilter(text, options, result);
@@ -249,17 +285,17 @@ OutputParser.prototype = {
    * @param {String} bezier
    *        The cubic-bezier timing function
    * @param {Object} options
    *        Options object. For valid options and default values see
    *        _mergeOptions()
    */
   _appendCubicBezier: function(bezier, options) {
     let container = this._createNode("span", {
-       "data-bezier": bezier
+      "data-bezier": bezier
     });
 
     if (options.bezierSwatchClass) {
       let swatch = this._createNode("span", {
         class: options.bezierSwatchClass
       });
       container.appendChild(swatch);
     }
@@ -268,16 +304,58 @@ OutputParser.prototype = {
       class: options.bezierClass
     }, bezier);
 
     container.appendChild(value);
     this.parsed.push(container);
   },
 
   /**
+   * Append a angle value to the output
+   *
+   * @param {String} angle
+   *        angle to append
+   * @param {Object} options
+   *        Options object. For valid options and default values see
+   *        _mergeOptions()
+   */
+  _appendAngle: function(angle, options) {
+    let angleObj = new angleUtils.CssAngle(angle);
+    let container = this._createNode("span", {
+      "data-angle": angle
+    });
+
+    if (options.angleSwatchClass) {
+      let swatch = this._createNode("span", {
+        class: options.angleSwatchClass
+      });
+      this.angleSwatches.set(swatch, angleObj);
+      swatch.addEventListener("mousedown", this._onAngleSwatchMouseDown, false);
+
+      // Add click listener to stop event propagation when shift key is pressed
+      // in order to prevent the value input to be focused.
+      // Bug 711942 will add a tooltip to edit angle values and we should
+      // be able to move this listener to Tooltip.js when it'll be implemented.
+      swatch.addEventListener("click", function(event) {
+        if (event.shiftKey) {
+          event.stopPropagation();
+        }
+      }, false);
+      container.appendChild(swatch);
+    }
+
+    let value = this._createNode("span", {
+      class: options.angleClass
+    }, angle);
+
+    container.appendChild(value);
+    this.parsed.push(container);
+  },
+
+  /**
    * Check if a CSS property supports a specific value.
    *
    * @param  {String} name
    *         CSS Property name to check
    * @param  {String} value
    *         CSS Property value to check
    */
   _cssPropertySupportsValue: function(name, value) {
@@ -312,17 +390,17 @@ OutputParser.prototype = {
       });
 
       if (options.colorSwatchClass) {
         let swatch = this._createNode("span", {
           class: options.colorSwatchClass,
           style: "background-color:" + color
         });
         this.colorSwatches.set(swatch, colorObj);
-        swatch.addEventListener("mousedown", this._onSwatchMouseDown, false);
+        swatch.addEventListener("mousedown", this._onColorSwatchMouseDown, false);
         container.appendChild(swatch);
       }
 
       if (options.defaultColorType) {
         color = colorObj.toString();
         container.dataset.colorĀ = color;
       }
 
@@ -366,31 +444,46 @@ OutputParser.prototype = {
       class: options.filterClass
     });
     value.appendChild(nodes);
     container.appendChild(value);
 
     return container;
   },
 
-  _onSwatchMouseDown: function(event) {
+  _onColorSwatchMouseDown: function(event) {
     // Prevent text selection in the case of shift-click or double-click.
     event.preventDefault();
 
     if (!event.shiftKey) {
       return;
     }
 
     let swatch = event.target;
     let color = this.colorSwatches.get(swatch);
     let val = color.nextColorUnit();
 
     swatch.nextElementSibling.textContent = val;
   },
 
+  _onAngleSwatchMouseDown: function(event) {
+    // Prevent text selection in the case of shift-click or double-click.
+    event.preventDefault();
+
+    if (!event.shiftKey) {
+      return;
+    }
+
+    let swatch = event.target;
+    let angle = this.angleSwatches.get(swatch);
+    let val = angle.nextAngleUnit();
+
+    swatch.nextElementSibling.textContent = val;
+  },
+
   /**
    * A helper function that sanitizes a possibly-unterminated URL.
    */
   _sanitizeURL: function(url) {
     // Re-lex the URL and add any needed termination characters.
     let urlTokenizer = DOMUtils.getCSSLexer(url);
     // Just read until EOF; there will only be a single token.
     while (urlTokenizer.nextToken()) {
@@ -538,16 +631,19 @@ OutputParser.prototype = {
    *           - defaultColorType: true // Convert colors to the default type
    *                                    // selected in the options panel.
    *           - colorSwatchClass: ""   // The class to use for color swatches.
    *           - colorClass: ""         // The class to use for the color value
    *                                    // that follows the swatch.
    *           - bezierSwatchClass: ""  // The class to use for bezier swatches.
    *           - bezierClass: ""        // The class to use for the bezier value
    *                                    // that follows the swatch.
+   *           - angleSwatchClass: ""   // The class to use for angle swatches.
+   *           - angleClass: ""         // The class to use for the angle value
+   *                                    // that follows the swatch.
    *           - supportsColor: false   // Does the CSS property support colors?
    *           - urlClass: ""           // The class to be used for url() links.
    *           - baseURI: ""            // A string or nsIURI used to resolve
    *                                    // relative links.
    *           - filterSwatch: false    // A special case for parsing a
    *                                    // "filter" property, causing the
    *                                    // parser to skip the call to
    *                                    // _wrapFilter.  Used only for
@@ -557,16 +653,18 @@ OutputParser.prototype = {
    */
   _mergeOptions: function(overrides) {
     let defaults = {
       defaultColorType: true,
       colorSwatchClass: "",
       colorClass: "",
       bezierSwatchClass: "",
       bezierClass: "",
+      angleSwatchClass: "",
+      angleClass: "",
       supportsColor: false,
       urlClass: "",
       baseURI: "",
       filterSwatch: false
     };
 
     if (typeof overrides.baseURI === "string") {
       overrides.baseURI = Services.io.newURI(overrides.baseURI, null, null);
--- a/devtools/client/shared/test/browser.ini
+++ b/devtools/client/shared/test/browser.ini
@@ -14,16 +14,17 @@ support-files =
   html-mdn-css-no-summary.html
   html-mdn-css-no-summary-or-syntax.html
   html-mdn-css-no-syntax.html
   html-mdn-css-syntax-old-style.html
   leakhunt.js
   test-actor.js
   test-actor-registry.js
 
+[browser_css_angle.js]
 [browser_css_color.js]
 [browser_cubic-bezier-01.js]
 [browser_cubic-bezier-02.js]
 [browser_cubic-bezier-03.js]
 [browser_cubic-bezier-04.js]
 [browser_cubic-bezier-05.js]
 [browser_cubic-bezier-06.js]
 [browser_filter-editor-01.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_css_angle.js
@@ -0,0 +1,126 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from head.js */
+"use strict";
+
+const TEST_URI = "data:text/html;charset=utf-8,browser_css_angle.js";
+var {angleUtils} = require("devtools/shared/css-angle");
+
+add_task(function*() {
+  yield addTab("about:blank");
+  let [host] = yield createHost("bottom", TEST_URI);
+
+  info("Starting the test");
+  testAngleUtils();
+
+  host.destroy();
+  gBrowser.removeCurrentTab();
+});
+
+function testAngleUtils() {
+  let data = getTestData();
+
+  for (let {authored, deg, rad, grad, turn} of data) {
+    let angle = new angleUtils.CssAngle(authored);
+
+    // Check all values.
+    info("Checking values for " + authored);
+    is(angle.deg, deg, "color.deg === deg");
+    is(angle.rad, rad, "color.rad === rad");
+    is(angle.grad, grad, "color.grad === grad");
+    is(angle.turn, turn, "color.turn === turn");
+
+    testToString(angle, deg, rad, grad, turn);
+  }
+}
+
+function testToString(angle, deg, rad, grad, turn) {
+  angle.angleUnit = angleUtils.CssAngle.ANGLEUNIT.deg;
+  is(angle.toString(), deg, "toString() with deg type");
+
+  angle.angleUnit = angleUtils.CssAngle.ANGLEUNIT.rad;
+  is(angle.toString(), rad, "toString() with rad type");
+
+  angle.angleUnit = angleUtils.CssAngle.ANGLEUNIT.grad;
+  is(angle.toString(), grad, "toString() with grad type");
+
+  angle.angleUnit = angleUtils.CssAngle.ANGLEUNIT.turn;
+  is(angle.toString(), turn, "toString() with turn type");
+}
+
+function getTestData() {
+  return [{
+    authored: "0deg",
+    deg: "0deg",
+    rad: "0rad",
+    grad: "0grad",
+    turn: "0turn"
+  }, {
+    authored: "180deg",
+    deg: "180deg",
+    rad: "3.14rad",
+    grad: "200grad",
+    turn: "0.5turn"
+  }, {
+    authored: "180DEG",
+    deg: "180DEG",
+    rad: "3.14RAD",
+    grad: "200GRAD",
+    turn: "0.5TURN"
+  }, {
+    authored: `-${Math.PI}rad`,
+    deg: "-180deg",
+    rad: `-${Math.PI}rad`,
+    grad: "-200grad",
+    turn: "-0.5turn"
+  }, {
+    authored: `-${Math.PI}RAD`,
+    deg: "-180DEG",
+    rad: `-${Math.PI}RAD`,
+    grad: "-200GRAD",
+    turn: "-0.5TURN"
+  }, {
+    authored: "100grad",
+    deg: "90deg",
+    rad: "1.57rad",
+    grad: "100grad",
+    turn: "0.25turn"
+  }, {
+    authored: "100GRAD",
+    deg: "90DEG",
+    rad: "1.57RAD",
+    grad: "100GRAD",
+    turn: "0.25TURN"
+  }, {
+    authored: "-1turn",
+    deg: "-360deg",
+    rad: "-6.28rad",
+    grad: "-400grad",
+    turn: "-1turn"
+  }, {
+    authored: "-10TURN",
+    deg: "-3600DEG",
+    rad: "-62.83RAD",
+    grad: "-4000GRAD",
+    turn: "-10TURN"
+  }, {
+    authored: "inherit",
+    deg: "inherit",
+    rad: "inherit",
+    grad: "inherit",
+    turn: "inherit"
+  }, {
+    authored: "initial",
+    deg: "initial",
+    rad: "initial",
+    grad: "initial",
+    turn: "initial"
+  }, {
+    authored: "unset",
+    deg: "unset",
+    rad: "unset",
+    grad: "unset",
+    turn: "unset"
+  }];
+}
--- a/devtools/client/shared/test/browser_outputparser.js
+++ b/devtools/client/shared/test/browser_outputparser.js
@@ -17,16 +17,17 @@ function* performTest() {
   let [host, , doc] = yield createHost("bottom", "data:text/html," +
     "<h1>browser_outputParser.js</h1><div></div>");
 
   let parser = new OutputParser(doc);
   testParseCssProperty(doc, parser);
   testParseCssVar(doc, parser);
   testParseURL(doc, parser);
   testParseFilter(doc, parser);
+  testParseAngle(doc, parser);
 
   host.destroy();
 }
 
 // Class name used in color swatch.
 var COLOR_TEST_CLASS = "test-class";
 
 // Create a new CSS color-parsing test.  |name| is the name of the CSS
@@ -96,18 +97,18 @@ function testParseCssProperty(doc, parse
                    "blur(1px) drop-shadow(0 0 0 ",
                    {name: "blue"},
                    ") url(red.svg#blue)</span></span>"]),
 
     makeColorTest("color", "currentColor", ["currentColor"]),
 
     // Test a very long property.
     makeColorTest("background-image",
-                  "linear-gradient(0deg, transparent 0, transparent 5%,#F00 0, #F00 10%,#FF0 0, #FF0 15%,#0F0 0, #0F0 20%,#0FF 0, #0FF 25%,#00F 0, #00F 30%,#800 0, #800 35%,#880 0, #880 40%,#080 0, #080 45%,#088 0, #088 50%,#008 0, #008 55%,#FFF 0, #FFF 60%,#EEE 0, #EEE 65%,#CCC 0, #CCC 70%,#999 0, #999 75%,#666 0, #666 80%,#333 0, #333 85%,#111 0, #111 90%,#000 0, #000 95%,transparent 0, transparent 100%)",
-                  ["linear-gradient(0deg, ", {name: "transparent"},
+                  "linear-gradient(to left, transparent 0, transparent 5%,#F00 0, #F00 10%,#FF0 0, #FF0 15%,#0F0 0, #0F0 20%,#0FF 0, #0FF 25%,#00F 0, #00F 30%,#800 0, #800 35%,#880 0, #880 40%,#080 0, #080 45%,#088 0, #088 50%,#008 0, #008 55%,#FFF 0, #FFF 60%,#EEE 0, #EEE 65%,#CCC 0, #CCC 70%,#999 0, #999 75%,#666 0, #666 80%,#333 0, #333 85%,#111 0, #111 90%,#000 0, #000 95%,transparent 0, transparent 100%)",
+                  ["linear-gradient(to left, ", {name: "transparent"},
                    " 0, ", {name: "transparent"},
                    " 5%,", {name: "#F00"},
                    " 0, ", {name: "#F00"},
                    " 10%,", {name: "#FF0"},
                    " 0, ", {name: "#FF0"},
                    " 15%,", {name: "#0F0"},
                    " 0, ", {name: "#0F0"},
                    " 20%,", {name: "#0FF"},
@@ -254,8 +255,25 @@ function testParseFilter(doc, parser) {
   let frag = parser.parseCssProperty("filter", "something invalid", {
     filterSwatchClass: "test-filterswatch"
   });
 
   let swatchCount = frag.querySelectorAll(".test-filterswatch").length;
   is(swatchCount, 1, "filter swatch was created");
 }
 
+function testParseAngle(doc, parser) {
+  let frag = parser.parseCssProperty("image-orientation", "90deg", {
+    angleSwatchClass: "test-angleswatch"
+  });
+
+  let swatchCount = frag.querySelectorAll(".test-angleswatch").length;
+  is(swatchCount, 1, "angle swatch was created");
+
+  frag = parser.parseCssProperty("background-image",
+    "linear-gradient(90deg, red, blue", {
+      angleSwatchClass: "test-angleswatch"
+    });
+
+  swatchCount = frag.querySelectorAll(".test-angleswatch").length;
+  is(swatchCount, 1, "angle swatch was created");
+}
+
new file mode 100644
--- /dev/null
+++ b/devtools/client/themes/images/angle-swatch.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12px" height="12px">
+  <mask id="angle-mask">
+    <rect width="100%" height="100%" fill="#fff"/>
+    <polygon points="6 6, 12 12, 0 12, 0 0, 6 0, 6 6"/>
+  </mask>
+  <mask id="circle-mask">
+    <circle cx="6" cy="6" r="6" fill="#fff"/>
+  </mask>
+  <circle cx="6" cy="6" r="6" fill="#fff"/>
+  <circle cx="6" cy="6" r="6" mask="url(#angle-mask)" fill="#aeb0b1"/>
+  <line x1="6" y1="0" x2="6" y2="6" stroke-width="0.5" stroke="rgba(0,0,0,0.5)"></line>
+  <line x1="6" y1="6" x2="12" y2="12" stroke-width="0.5" stroke="rgba(0,0,0,0.5)" mask="url(#circle-mask)"></line>
+</svg>
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -315,16 +315,21 @@
   background-size: 1em;
 }
 
 .ruleview-filterswatch {
   background: url("chrome://devtools/skin/images/filter-swatch.svg");
   background-size: 1em;
 }
 
+.ruleview-angleswatch {
+  background: url("chrome://devtools/skin/images/angle-swatch.svg");
+  background-size: 1em;
+}
+
 @media (min-resolution: 1.1dppx) {
   .ruleview-bezierswatch {
     background: url("chrome://devtools/skin/images/cubic-bezier-swatch@2x.png");
     background-size: 1em;
   }
 }
 
 .ruleview-overridden {
new file mode 100644
--- /dev/null
+++ b/devtools/shared/css-angle.js
@@ -0,0 +1,348 @@
+/* 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, Ci} = require("chrome");
+
+const SPECIALVALUES = new Set([
+  "initial",
+  "inherit",
+  "unset"
+]);
+
+/**
+ * This module is used to convert between various angle units.
+ *
+ * Usage:
+ *   let {require} = Cu.import("resource://devtools/shared/Loader.jsm", {});
+ *   let {angleUtils} = require("devtools/shared/css-angle");
+ *   let angle = new angleUtils.CssAngle("180deg");
+ *
+ *   angle.authored === "180deg"
+ *   angle.valid === true
+ *   angle.rad === "3,14rad"
+ *   angle.grad === "200grad"
+ *   angle.turn === "0.5turn"
+ *
+ *   angle.toString() === "180deg"; // Outputs the angle value and its unit
+ *   // Angle objects can be reused
+ *   angle.newAngle("-1TURN") === "-1TURN"; // true
+ */
+
+function CssAngle(angleValue) {
+  this.newAngle(angleValue);
+}
+
+module.exports.angleUtils = {
+  CssAngle: CssAngle,
+  classifyAngle: classifyAngle
+};
+
+CssAngle.ANGLEUNIT = {
+  "deg": "deg",
+  "rad": "rad",
+  "grad": "grad",
+  "turn": "turn"
+};
+
+CssAngle.prototype = {
+  _angleUnit: null,
+  _angleUnitUppercase: false,
+
+  // The value as-authored.
+  authored: null,
+  // A lower-cased copy of |authored|.
+  lowerCased: null,
+
+  get angleUnit() {
+    if (this._angleUnit === null) {
+      this._angleUnit = classifyAngle(this.authored);
+    }
+    return this._angleUnit;
+  },
+
+  set angleUnit(unit) {
+    this._angleUnit = unit;
+  },
+
+  get valid() {
+    return /^-?\d+\.?\d*(deg|rad|grad|turn)$/gi.test(this.authored);
+  },
+
+  get specialValue() {
+    return SPECIALVALUES.has(this.lowerCased) ? this.authored : null;
+  },
+
+  get deg() {
+    let invalidOrSpecialValue = this._getInvalidOrSpecialValue();
+    if (invalidOrSpecialValue !== false) {
+      return invalidOrSpecialValue;
+    }
+
+    let angleUnit = classifyAngle(this.authored);
+    if (angleUnit === CssAngle.ANGLEUNIT.deg) {
+      // The angle is valid and is in degree.
+      return this.authored;
+    }
+
+    let degValue;
+    if (angleUnit === CssAngle.ANGLEUNIT.rad) {
+      // The angle is valid and is in radian.
+      degValue = this.authoredAngleValue / (Math.PI / 180);
+    }
+
+    if (angleUnit === CssAngle.ANGLEUNIT.grad) {
+      // The angle is valid and is in gradian.
+      degValue = this.authoredAngleValue * 0.9;
+    }
+
+    if (angleUnit === CssAngle.ANGLEUNIT.turn) {
+      // The angle is valid and is in turn.
+      degValue = this.authoredAngleValue * 360;
+    }
+
+    let unitStr = CssAngle.ANGLEUNIT.deg;
+    if (this._angleUnitUppercase === true) {
+      unitStr = unitStr.toUpperCase();
+    }
+    return `${Math.round(degValue * 100) / 100}${unitStr}`;
+  },
+
+  get rad() {
+    let invalidOrSpecialValue = this._getInvalidOrSpecialValue();
+    if (invalidOrSpecialValue !== false) {
+      return invalidOrSpecialValue;
+    }
+
+    let unit = classifyAngle(this.authored);
+    if (unit === CssAngle.ANGLEUNIT.rad) {
+      // The angle is valid and is in radian.
+      return this.authored;
+    }
+
+    let radValue;
+    if (unit === CssAngle.ANGLEUNIT.deg) {
+      // The angle is valid and is in degree.
+      radValue = this.authoredAngleValue * (Math.PI / 180);
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.grad) {
+      // The angle is valid and is in gradian.
+      radValue = this.authoredAngleValue * 0.9 * (Math.PI / 180);
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.turn) {
+      // The angle is valid and is in turn.
+      radValue = this.authoredAngleValue * 360 * (Math.PI / 180);
+    }
+
+    let unitStr = CssAngle.ANGLEUNIT.rad;
+    if (this._angleUnitUppercase === true) {
+      unitStr = unitStr.toUpperCase();
+    }
+    return `${Math.round(radValue * 100) / 100}${unitStr}`;
+  },
+
+  get grad() {
+    let invalidOrSpecialValue = this._getInvalidOrSpecialValue();
+    if (invalidOrSpecialValue !== false) {
+      return invalidOrSpecialValue;
+    }
+
+    let unit = classifyAngle(this.authored);
+    if (unit === CssAngle.ANGLEUNIT.grad) {
+      // The angle is valid and is in gradian
+      return this.authored;
+    }
+
+    let gradValue;
+    if (unit === CssAngle.ANGLEUNIT.deg) {
+      // The angle is valid and is in degree
+      gradValue = this.authoredAngleValue / 0.9;
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.rad) {
+      // The angle is valid and is in radian
+      gradValue = this.authoredAngleValue / 0.9 / (Math.PI / 180);
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.turn) {
+      // The angle is valid and is in turn
+      gradValue = this.authoredAngleValue * 400;
+    }
+
+    let unitStr = CssAngle.ANGLEUNIT.grad;
+    if (this._angleUnitUppercase === true) {
+      unitStr = unitStr.toUpperCase();
+    }
+    return `${Math.round(gradValue * 100) / 100}${unitStr}`;
+  },
+
+  get turn() {
+    let invalidOrSpecialValue = this._getInvalidOrSpecialValue();
+    if (invalidOrSpecialValue !== false) {
+      return invalidOrSpecialValue;
+    }
+
+    let unit = classifyAngle(this.authored);
+    if (unit === CssAngle.ANGLEUNIT.turn) {
+      // The angle is valid and is in turn
+      return this.authored;
+    }
+
+    let turnValue;
+    if (unit === CssAngle.ANGLEUNIT.deg) {
+      // The angle is valid and is in degree
+      turnValue = this.authoredAngleValue / 360;
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.rad) {
+      // The angle is valid and is in radian
+      turnValue = (this.authoredAngleValue / (Math.PI / 180)) / 360;
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.grad) {
+      // The angle is valid and is in gradian
+      turnValue = this.authoredAngleValue / 400;
+    }
+
+    let unitStr = CssAngle.ANGLEUNIT.turn;
+    if (this._angleUnitUppercase === true) {
+      unitStr = unitStr.toUpperCase();
+    }
+    return `${Math.round(turnValue * 100) / 100}${unitStr}`;
+  },
+
+  /**
+   * Check whether the angle value is in the special list e.g.
+   * inherit or invalid.
+   *
+   * @return {String|Boolean}
+   *         - If the current angle is a special value e.g. "inherit" then
+   *           return the angle.
+   *         - If the angle is invalid return an empty string.
+   *         - If the angle is a regular angle e.g. 90deg so we return false
+   *           to indicate that the angle is neither invalid nor special.
+   */
+  _getInvalidOrSpecialValue: function() {
+    if (this.specialValue) {
+      return this.specialValue;
+    }
+    if (!this.valid) {
+      return "";
+    }
+    return false;
+  },
+
+  /**
+   * Change angle
+   *
+   * @param  {String} angle
+   *         Any valid angle value + unit string
+   */
+  newAngle: function(angle) {
+    // Store a lower-cased version of the angle to help with format
+    // testing.  The original text is kept as well so it can be
+    // returned when needed.
+    this.lowerCased = angle.toLowerCase();
+    this._angleUnitUppercase = (angle === angle.toUpperCase());
+    this.authored = angle;
+
+    let reg = new RegExp(
+      `(${Object.keys(CssAngle.ANGLEUNIT).join("|")})$`, "i");
+    let unitStartIdx = angle.search(reg);
+    this.authoredAngleValue = angle.substring(0, unitStartIdx);
+    this.authoredAngleUnit = angle.substring(unitStartIdx, angle.length);
+
+    return this;
+  },
+
+  nextAngleUnit: function() {
+    // Get a reordered array from the formats object
+    // to have the current format at the front so we can cycle through.
+    let formats = Object.keys(CssAngle.ANGLEUNIT);
+    let putOnEnd = formats.splice(0, formats.indexOf(this.angleUnit));
+    formats = formats.concat(putOnEnd);
+    let currentDisplayedValue = this[formats[0]];
+
+    for (let format of formats) {
+      if (this[format].toLowerCase() !== currentDisplayedValue.toLowerCase()) {
+        this.angleUnit = CssAngle.ANGLEUNIT[format];
+        break;
+      }
+    }
+    return this.toString();
+  },
+
+  /**
+   * Return a string representing a angle
+   */
+  toString: function() {
+    let angle;
+
+    switch (this.angleUnit) {
+      case CssAngle.ANGLEUNIT.deg:
+        angle = this.deg;
+        break;
+      case CssAngle.ANGLEUNIT.rad:
+        angle = this.rad;
+        break;
+      case CssAngle.ANGLEUNIT.grad:
+        angle = this.grad;
+        break;
+      case CssAngle.ANGLEUNIT.turn:
+        angle = this.turn;
+        break;
+      default:
+        angle = this.deg;
+    }
+
+    if (this._angleUnitUppercase &&
+        this.angleUnit != CssAngle.ANGLEUNIT.authored) {
+      angle = angle.toUpperCase();
+    }
+    return angle;
+  },
+
+  /**
+   * This method allows comparison of CssAngle objects using ===.
+   */
+  valueOf: function() {
+    return this.deg;
+  },
+};
+
+/**
+ * Given a color, classify its type as one of the possible angle
+ * units, as known by |CssAngle.angleUnit|.
+ *
+ * @param  {String} value
+ *         The angle, in any form accepted by CSS.
+ * @return {String}
+ *         The angle classification, one of "deg", "rad", "grad", or "turn".
+ */
+function classifyAngle(value) {
+  value = value.toLowerCase();
+  if (value.endsWith("deg")) {
+    return CssAngle.ANGLEUNIT.deg;
+  }
+
+  if (value.endsWith("grad")) {
+    return CssAngle.ANGLEUNIT.grad;
+  }
+
+  if (value.endsWith("rad")) {
+    return CssAngle.ANGLEUNIT.rad;
+  }
+  if (value.endsWith("turn")) {
+    return CssAngle.ANGLEUNIT.turn;
+  }
+
+  return CssAngle.ANGLEUNIT.deg;
+}
+
+loader.lazyGetter(this, "DOMUtils", function() {
+  return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
+});
--- a/devtools/shared/moz.build
+++ b/devtools/shared/moz.build
@@ -34,16 +34,17 @@ MOCHITEST_CHROME_MANIFESTS += ['tests/mo
 XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']
 
 JAR_MANIFESTS += ['jar.mn']
 
 DevToolsModules(
     'async-storage.js',
     'async-utils.js',
     'content-observer.js',
+    'css-angle.js',
     'css-color.js',
     'deprecated-sync-thenables.js',
     'DevToolsUtils.js',
     'event-emitter.js',
     'event-parsers.js',
     'indentation.js',
     'Loader.jsm',
     'Parser.jsm',
new file mode 100644
--- /dev/null
+++ b/devtools/shared/tests/unit/test_cssAngle.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test classifyAngle.
+
+"use strict";
+
+const {angleUtils} = require("devtools/shared/css-angle");
+
+const CLASSIFY_TESTS = [
+  { input: "180deg", output: "deg" },
+  { input: "-180deg", output: "deg" },
+  { input: "180DEG", output: "deg" },
+  { input: "200rad", output: "rad" },
+  { input: "-200rad", output: "rad" },
+  { input: "200RAD", output: "rad" },
+  { input: "0.5grad", output: "grad" },
+  { input: "-0.5grad", output: "grad" },
+  { input: "0.5GRAD", output: "grad" },
+  { input: "0.33turn", output: "turn" },
+  { input: "0.33TURN", output: "turn" },
+  { input: "-0.33turn", output: "turn" }
+];
+
+function run_test() {
+  for (let test of CLASSIFY_TESTS) {
+    let result = angleUtils.classifyAngle(test.input);
+    equal(result, test.output, "test classifyAngle(" + test.input + ")");
+  }
+}
--- a/devtools/shared/tests/unit/xpcshell.ini
+++ b/devtools/shared/tests/unit/xpcshell.ini
@@ -14,14 +14,15 @@ support-files =
 [test_fetch-resource.js]
 [test_indentation.js]
 [test_independent_loaders.js]
 [test_invisible_loader.js]
 [test_safeErrorString.js]
 [test_defineLazyPrototypeGetter.js]
 [test_async-utils.js]
 [test_consoleID.js]
+[test_cssAngle.js]
 [test_cssColor.js]
 [test_prettifyCSS.js]
 [test_require_lazy.js]
 [test_require.js]
 [test_stack.js]
 [test_executeSoon.js]