Bug 1250835 - Display swatch for angles in the rules panel. r=miker draft
authorNicolas Chevobbe <chevobbe.nicolas@gmail.com>
Tue, 08 Mar 2016 23:04:54 +0100
changeset 344020 5be5ad22d220756b07eea9e4915953b352c3df21
parent 343540 fb255ce8318d62a75ee0c671809cd0cf8baaab0a
child 516876 6cd9bf07a1e5cc7ef94d2b4c33f922e14796d015
push id13741
push userchevobbe.nicolas@gmail.com
push dateWed, 23 Mar 2016 18:57:57 +0000
reviewersmiker
bugs1250835
milestone48.0a1
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]