summaryrefslogtreecommitdiff
path: root/toolkit/devtools/shared
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/devtools/shared')
-rw-r--r--toolkit/devtools/shared/AppCacheUtils.jsm629
-rw-r--r--toolkit/devtools/shared/Curl.jsm400
-rw-r--r--toolkit/devtools/shared/DOMHelpers.jsm156
-rw-r--r--toolkit/devtools/shared/DeveloperToolbar.jsm1280
-rw-r--r--toolkit/devtools/shared/Jsbeautify.jsm16
-rw-r--r--toolkit/devtools/shared/Parser.jsm2337
-rw-r--r--toolkit/devtools/shared/SplitView.jsm299
-rw-r--r--toolkit/devtools/shared/autocomplete-popup.js595
-rw-r--r--toolkit/devtools/shared/d3.js9275
-rw-r--r--toolkit/devtools/shared/devices.js603
-rw-r--r--toolkit/devtools/shared/doorhanger.js160
-rw-r--r--toolkit/devtools/shared/frame-script-utils.js114
-rw-r--r--toolkit/devtools/shared/inplace-editor.js1226
-rw-r--r--toolkit/devtools/shared/moz.build67
-rw-r--r--toolkit/devtools/shared/observable-object.js129
-rw-r--r--toolkit/devtools/shared/options-view.js178
-rw-r--r--toolkit/devtools/shared/profiler/global.js108
-rw-r--r--toolkit/devtools/shared/profiler/tree-model.js281
-rw-r--r--toolkit/devtools/shared/profiler/tree-view.js345
-rw-r--r--toolkit/devtools/shared/splitview.css99
-rw-r--r--toolkit/devtools/shared/telemetry.js315
-rw-r--r--toolkit/devtools/shared/test/browser.ini98
-rw-r--r--toolkit/devtools/shared/test/browser_css_color.js316
-rw-r--r--toolkit/devtools/shared/test/browser_cubic-bezier-01.js37
-rw-r--r--toolkit/devtools/shared/test/browser_cubic-bezier-02.js149
-rw-r--r--toolkit/devtools/shared/test/browser_cubic-bezier-03.js68
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-01.js57
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-02.js42
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-03a.js120
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-03b.js76
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-04.js90
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-utils-01.js261
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-utils-02.js103
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-utils-03.js112
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-utils-04.js166
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-utils-05.js42
-rw-r--r--toolkit/devtools/shared/test/browser_flame-graph-utils-hash.js24
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-01.js66
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-02.js83
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-03.js110
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-04.js68
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-05.js132
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-06.js90
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-07a.js201
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-07b.js66
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-08.js66
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-09a.js82
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-09b.js60
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-09c.js37
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-09d.js38
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-09e.js62
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-09f.js52
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-10a.js139
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-10b.js48
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-11a.js59
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-11b.js129
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-12.js153
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-13.js42
-rw-r--r--toolkit/devtools/shared/test/browser_graphs-14.js89
-rw-r--r--toolkit/devtools/shared/test/browser_inplace-editor.js123
-rw-r--r--toolkit/devtools/shared/test/browser_layoutHelpers-getBoxQuads.html65
-rw-r--r--toolkit/devtools/shared/test/browser_layoutHelpers-getBoxQuads.js222
-rw-r--r--toolkit/devtools/shared/test/browser_layoutHelpers.html25
-rw-r--r--toolkit/devtools/shared/test/browser_layoutHelpers.js101
-rw-r--r--toolkit/devtools/shared/test/browser_layoutHelpers_iframe.html19
-rw-r--r--toolkit/devtools/shared/test/browser_num-l10n.js25
-rw-r--r--toolkit/devtools/shared/test/browser_observableobject.js86
-rw-r--r--toolkit/devtools/shared/test/browser_options-view-01.js101
-rw-r--r--toolkit/devtools/shared/test/browser_outputparser.js102
-rw-r--r--toolkit/devtools/shared/test/browser_prefs.js33
-rw-r--r--toolkit/devtools/shared/test/browser_require_basic.js140
-rw-r--r--toolkit/devtools/shared/test/browser_spectrum.js114
-rw-r--r--toolkit/devtools/shared/test/browser_tableWidget_basic.js382
-rw-r--r--toolkit/devtools/shared/test/browser_tableWidget_keyboard_interaction.js227
-rw-r--r--toolkit/devtools/shared/test/browser_tableWidget_mouse_interaction.js298
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_button_eyedropper.js57
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_button_paintflashing.js89
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_button_responsive.js89
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_button_scratchpad.js127
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_button_tilt.js89
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_sidebar.js85
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolbox.js20
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_canvasdebugger.js27
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_inspector.js20
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js20
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js19
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_netmonitor.js20
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_options.js19
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_shadereditor.js33
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_storage.js25
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_styleeditor.js20
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_webaudioeditor.js26
-rw-r--r--toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_webconsole.js19
-rw-r--r--toolkit/devtools/shared/test/browser_templater_basic.html13
-rw-r--r--toolkit/devtools/shared/test/browser_templater_basic.js285
-rw-r--r--toolkit/devtools/shared/test/browser_theme.js81
-rw-r--r--toolkit/devtools/shared/test/browser_toolbar_basic.html35
-rw-r--r--toolkit/devtools/shared/test/browser_toolbar_basic.js74
-rw-r--r--toolkit/devtools/shared/test/browser_toolbar_tooltip.js72
-rw-r--r--toolkit/devtools/shared/test/browser_toolbar_webconsole_errors_count.html32
-rw-r--r--toolkit/devtools/shared/test/browser_toolbar_webconsole_errors_count.js246
-rw-r--r--toolkit/devtools/shared/test/browser_treeWidget_basic.js254
-rw-r--r--toolkit/devtools/shared/test/browser_treeWidget_keyboard_interaction.js228
-rw-r--r--toolkit/devtools/shared/test/browser_treeWidget_mouse_interaction.js137
-rw-r--r--toolkit/devtools/shared/test/doc_options-view.xul27
-rw-r--r--toolkit/devtools/shared/test/head.js241
-rw-r--r--toolkit/devtools/shared/test/leakhunt.js170
-rw-r--r--toolkit/devtools/shared/test/unit/test_VariablesView_getString_promise.js75
-rw-r--r--toolkit/devtools/shared/test/unit/test_bezierCanvas.js113
-rw-r--r--toolkit/devtools/shared/test/unit/test_cubicBezier.js102
-rw-r--r--toolkit/devtools/shared/test/unit/test_undoStack.js98
-rw-r--r--toolkit/devtools/shared/test/unit/xpcshell.ini10
-rw-r--r--toolkit/devtools/shared/theme-switching.js120
-rw-r--r--toolkit/devtools/shared/theme.js90
-rw-r--r--toolkit/devtools/shared/timeline/global.js69
-rw-r--r--toolkit/devtools/shared/timeline/marker-details.js301
-rw-r--r--toolkit/devtools/shared/timeline/markers-overview.js230
-rw-r--r--toolkit/devtools/shared/timeline/memory-overview.js80
-rw-r--r--toolkit/devtools/shared/timeline/waterfall.js619
-rw-r--r--toolkit/devtools/shared/undo.js206
-rw-r--r--toolkit/devtools/shared/widgets/AbstractTreeItem.jsm481
-rw-r--r--toolkit/devtools/shared/widgets/BreadcrumbsWidget.jsm260
-rw-r--r--toolkit/devtools/shared/widgets/Chart.jsm450
-rw-r--r--toolkit/devtools/shared/widgets/CubicBezierWidget.js556
-rw-r--r--toolkit/devtools/shared/widgets/FastListWidget.js250
-rw-r--r--toolkit/devtools/shared/widgets/FlameGraph.jsm1023
-rw-r--r--toolkit/devtools/shared/widgets/Graphs.jsm2199
-rw-r--r--toolkit/devtools/shared/widgets/GraphsWorker.js107
-rw-r--r--toolkit/devtools/shared/widgets/SideMenuWidget.jsm676
-rw-r--r--toolkit/devtools/shared/widgets/SimpleListWidget.jsm253
-rw-r--r--toolkit/devtools/shared/widgets/Spectrum.js337
-rw-r--r--toolkit/devtools/shared/widgets/TableWidget.js983
-rw-r--r--toolkit/devtools/shared/widgets/Tooltip.js1489
-rw-r--r--toolkit/devtools/shared/widgets/TreeWidget.js597
-rw-r--r--toolkit/devtools/shared/widgets/VariablesView.jsm4131
-rw-r--r--toolkit/devtools/shared/widgets/VariablesView.xul19
-rw-r--r--toolkit/devtools/shared/widgets/VariablesViewController.jsm588
-rw-r--r--toolkit/devtools/shared/widgets/ViewHelpers.jsm1735
-rw-r--r--toolkit/devtools/shared/widgets/cubic-bezier-frame.xhtml25
-rw-r--r--toolkit/devtools/shared/widgets/cubic-bezier.css142
-rw-r--r--toolkit/devtools/shared/widgets/graphs-frame.xhtml26
-rw-r--r--toolkit/devtools/shared/widgets/spectrum-frame.xhtml24
-rw-r--r--toolkit/devtools/shared/widgets/spectrum.css176
-rw-r--r--toolkit/devtools/shared/widgets/widgets.css109
144 files changed, 45756 insertions, 0 deletions
diff --git a/toolkit/devtools/shared/AppCacheUtils.jsm b/toolkit/devtools/shared/AppCacheUtils.jsm
new file mode 100644
index 000000000..f830dd1c7
--- /dev/null
+++ b/toolkit/devtools/shared/AppCacheUtils.jsm
@@ -0,0 +1,629 @@
+/* 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/. */
+
+/**
+ * validateManifest() warns of the following errors:
+ * - No manifest specified in page
+ * - Manifest is not utf-8
+ * - Manifest mimetype not text/cache-manifest
+ * - Manifest does not begin with "CACHE MANIFEST"
+ * - Page modified since appcache last changed
+ * - Duplicate entries
+ * - Conflicting entries e.g. in both CACHE and NETWORK sections or in cache
+ * but blocked by FALLBACK namespace
+ * - Detect referenced files that are not available
+ * - Detect referenced files that have cache-control set to no-store
+ * - Wildcards used in a section other than NETWORK
+ * - Spaces in URI not replaced with %20
+ * - Completely invalid URIs
+ * - Too many dot dot slash operators
+ * - SETTINGS section is valid
+ * - Invalid section name
+ * - etc.
+ */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+let { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {});
+let { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+let { LoadContextInfo } = Cu.import("resource://gre/modules/LoadContextInfo.jsm", {});
+let { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {});
+
+this.EXPORTED_SYMBOLS = ["AppCacheUtils"];
+
+function AppCacheUtils(documentOrUri) {
+ this._parseManifest = this._parseManifest.bind(this);
+
+ if (documentOrUri) {
+ if (typeof documentOrUri == "string") {
+ this.uri = documentOrUri;
+ }
+ if (/HTMLDocument/.test(documentOrUri.toString())) {
+ this.doc = documentOrUri;
+ }
+ }
+}
+
+AppCacheUtils.prototype = {
+ get cachePath() {
+ return "";
+ },
+
+ validateManifest: function ACU_validateManifest() {
+ let deferred = promise.defer();
+ this.errors = [];
+ // Check for missing manifest.
+ this._getManifestURI().then(manifestURI => {
+ this.manifestURI = manifestURI;
+
+ if (!this.manifestURI) {
+ this._addError(0, "noManifest");
+ deferred.resolve(this.errors);
+ }
+
+ this._getURIInfo(this.manifestURI).then(uriInfo => {
+ this._parseManifest(uriInfo).then(() => {
+ // Sort errors by line number.
+ this.errors.sort(function(a, b) {
+ return a.line - b.line;
+ });
+ deferred.resolve(this.errors);
+ });
+ });
+ });
+
+ return deferred.promise;
+ },
+
+ _parseManifest: function ACU__parseManifest(uriInfo) {
+ let deferred = promise.defer();
+ let manifestName = uriInfo.name;
+ let manifestLastModified = new Date(uriInfo.responseHeaders["Last-Modified"]);
+
+ if (uriInfo.charset.toLowerCase() != "utf-8") {
+ this._addError(0, "notUTF8", uriInfo.charset);
+ }
+
+ if (uriInfo.mimeType != "text/cache-manifest") {
+ this._addError(0, "badMimeType", uriInfo.mimeType);
+ }
+
+ let parser = new ManifestParser(uriInfo.text, this.manifestURI);
+ let parsed = parser.parse();
+
+ if (parsed.errors.length > 0) {
+ this.errors.push.apply(this.errors, parsed.errors);
+ }
+
+ // Check for duplicate entries.
+ let dupes = {};
+ for (let parsedUri of parsed.uris) {
+ dupes[parsedUri.uri] = dupes[parsedUri.uri] || [];
+ dupes[parsedUri.uri].push({
+ line: parsedUri.line,
+ section: parsedUri.section,
+ original: parsedUri.original
+ });
+ }
+ for (let [uri, value] of Iterator(dupes)) {
+ if (value.length > 1) {
+ this._addError(0, "duplicateURI", uri, JSON.stringify(value));
+ }
+ }
+
+ // Loop through network entries making sure that fallback and cache don't
+ // contain uris starting with the network uri.
+ for (let neturi of parsed.uris) {
+ if (neturi.section == "NETWORK") {
+ for (let parsedUri of parsed.uris) {
+ if (parsedUri.section !== "NETWORK" &&
+ parsedUri.uri.startsWith(neturi.uri)) {
+ this._addError(neturi.line, "networkBlocksURI", neturi.line,
+ neturi.original, parsedUri.line, parsedUri.original,
+ parsedUri.section);
+ }
+ }
+ }
+ }
+
+ // Loop through fallback entries making sure that fallback and cache don't
+ // contain uris starting with the network uri.
+ for (let fb of parsed.fallbacks) {
+ for (let parsedUri of parsed.uris) {
+ if (parsedUri.uri.startsWith(fb.namespace)) {
+ this._addError(fb.line, "fallbackBlocksURI", fb.line,
+ fb.original, parsedUri.line, parsedUri.original,
+ parsedUri.section);
+ }
+ }
+ }
+
+ // Check that all resources exist and that their cach-control headers are
+ // not set to no-store.
+ let current = -1;
+ for (let i = 0, len = parsed.uris.length; i < len; i++) {
+ let parsedUri = parsed.uris[i];
+ this._getURIInfo(parsedUri.uri).then(uriInfo => {
+ current++;
+
+ if (uriInfo.success) {
+ // Check that the resource was not modified after the manifest was last
+ // modified. If it was then the manifest file should be refreshed.
+ let resourceLastModified =
+ new Date(uriInfo.responseHeaders["Last-Modified"]);
+
+ if (manifestLastModified < resourceLastModified) {
+ this._addError(parsedUri.line, "fileChangedButNotManifest",
+ uriInfo.name, manifestName, parsedUri.line);
+ }
+
+ // If cache-control: no-store the file will not be added to the
+ // appCache.
+ if (uriInfo.nocache) {
+ this._addError(parsedUri.line, "cacheControlNoStore",
+ parsedUri.original, parsedUri.line);
+ }
+ } else if (parsedUri.original !== "*") {
+ this._addError(parsedUri.line, "notAvailable",
+ parsedUri.original, parsedUri.line);
+ }
+
+ if (current == len - 1) {
+ deferred.resolve();
+ }
+ });
+ }
+
+ return deferred.promise;
+ },
+
+ _getURIInfo: function ACU__getURIInfo(uri) {
+ let inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
+ .createInstance(Ci.nsIScriptableInputStream);
+ let deferred = promise.defer();
+ let buffer = "";
+ let channel = Services.io.newChannel2(uri,
+ null,
+ null,
+ null, // aLoadingNode
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null, // aTriggeringPrincipal
+ Ci.nsILoadInfo.SEC_NORMAL,
+ Ci.nsIContentPolicy.TYPE_OTHER);
+
+ // Avoid the cache:
+ channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+
+ channel.asyncOpen({
+ onStartRequest: function (request, context) {
+ // This empty method is needed in order for onDataAvailable to be
+ // called.
+ },
+
+ onDataAvailable: function (request, context, stream, offset, count) {
+ request.QueryInterface(Ci.nsIHttpChannel);
+ inputStream.init(stream);
+ buffer = buffer.concat(inputStream.read(count));
+ },
+
+ onStopRequest: function onStartRequest(request, context, statusCode) {
+ if (statusCode === 0) {
+ request.QueryInterface(Ci.nsIHttpChannel);
+
+ let result = {
+ name: request.name,
+ success: request.requestSucceeded,
+ status: request.responseStatus + " - " + request.responseStatusText,
+ charset: request.contentCharset || "utf-8",
+ mimeType: request.contentType,
+ contentLength: request.contentLength,
+ nocache: request.isNoCacheResponse() || request.isNoStoreResponse(),
+ prePath: request.URI.prePath + "/",
+ text: buffer
+ };
+
+ result.requestHeaders = {};
+ request.visitRequestHeaders(function(header, value) {
+ result.requestHeaders[header] = value;
+ });
+
+ result.responseHeaders = {};
+ request.visitResponseHeaders(function(header, value) {
+ result.responseHeaders[header] = value;
+ });
+
+ deferred.resolve(result);
+ } else {
+ deferred.resolve({
+ name: request.name,
+ success: false
+ });
+ }
+ }
+ }, null);
+ return deferred.promise;
+ },
+
+ listEntries: function ACU_show(searchTerm) {
+ if (!Services.prefs.getBoolPref("browser.cache.disk.enable")) {
+ throw new Error(l10n.GetStringFromName("cacheDisabled"));
+ }
+
+ let entries = [];
+
+ let appCacheStorage = Services.cache2.appCacheStorage(LoadContextInfo.default, null);
+ appCacheStorage.asyncVisitStorage({
+ onCacheStorageInfo: function() {},
+
+ onCacheEntryInfo: function(aURI, aIdEnhance, aDataSize, aFetchCount, aLastModifiedTime, aExpirationTime) {
+ let lowerKey = aURI.asciiSpec.toLowerCase();
+
+ if (searchTerm && lowerKey.indexOf(searchTerm.toLowerCase()) == -1) {
+ return;
+ }
+
+ if (aIdEnhance) {
+ aIdEnhance += ":";
+ }
+
+ let entry = {
+ "deviceID": "offline",
+ "key": aIdEnhance + aURI.asciiSpec,
+ "fetchCount": aFetchCount,
+ "lastFetched": null,
+ "lastModified": new Date(aLastModifiedTime * 1000),
+ "expirationTime": new Date(aExpirationTime * 1000),
+ "dataSize": aDataSize
+ };
+
+ entries.push(entry);
+ return true;
+ }
+ }, true);
+
+ if (entries.length === 0) {
+ throw new Error(l10n.GetStringFromName("noResults"));
+ }
+ return entries;
+ },
+
+ viewEntry: function ACU_viewEntry(key) {
+ let wm = Cc["@mozilla.org/appshell/window-mediator;1"]
+ .getService(Ci.nsIWindowMediator);
+ let win = wm.getMostRecentWindow("navigator:browser");
+ win.gBrowser.selectedTab = win.gBrowser.addTab(
+ "about:cache-entry?storage=appcache&context=&eid=&uri=" + key);
+ },
+
+ clearAll: function ACU_clearAll() {
+ if (!Services.prefs.getBoolPref("browser.cache.disk.enable")) {
+ throw new Error(l10n.GetStringFromName("cacheDisabled"));
+ }
+
+ let appCacheStorage = Services.cache2.appCacheStorage(LoadContextInfo.default, null);
+ appCacheStorage.asyncEvictStorage({
+ onCacheEntryDoomed: function(result) {}
+ });
+ },
+
+ _getManifestURI: function ACU__getManifestURI() {
+ let deferred = promise.defer();
+
+ let getURI = () => {
+ let htmlNode = this.doc.querySelector("html[manifest]");
+ if (htmlNode) {
+ let pageUri = this.doc.location ? this.doc.location.href : this.uri;
+ let origin = pageUri.substr(0, pageUri.lastIndexOf("/") + 1);
+ let manifestURI = htmlNode.getAttribute("manifest");
+
+ if (manifestURI.startsWith("/")) {
+ manifestURI = manifestURI.substr(1);
+ }
+
+ return origin + manifestURI;
+ }
+ };
+
+ if (this.doc) {
+ let uri = getURI();
+ return promise.resolve(uri);
+ } else {
+ this._getURIInfo(this.uri).then(uriInfo => {
+ if (uriInfo.success) {
+ let html = uriInfo.text;
+ let parser = _DOMParser;
+ this.doc = parser.parseFromString(html, "text/html");
+ let uri = getURI();
+ deferred.resolve(uri);
+ } else {
+ this.errors.push({
+ line: 0,
+ msg: l10n.GetStringFromName("invalidURI")
+ });
+ }
+ });
+ }
+ return deferred.promise;
+ },
+
+ _addError: function ACU__addError(line, l10nString, ...params) {
+ let msg;
+
+ if (params) {
+ msg = l10n.formatStringFromName(l10nString, params, params.length);
+ } else {
+ msg = l10n.GetStringFromName(l10nString);
+ }
+
+ this.errors.push({
+ line: line,
+ msg: msg
+ });
+ },
+};
+
+/**
+ * We use our own custom parser because we need far more detailed information
+ * than the system manifest parser provides.
+ *
+ * @param {String} manifestText
+ * The text content of the manifest file.
+ * @param {String} manifestURI
+ * The URI of the manifest file. This is used in calculating the path of
+ * relative URIs.
+ */
+function ManifestParser(manifestText, manifestURI) {
+ this.manifestText = manifestText;
+ this.origin = manifestURI.substr(0, manifestURI.lastIndexOf("/") + 1)
+ .replace(" ", "%20");
+}
+
+ManifestParser.prototype = {
+ parse: function OCIMP_parse() {
+ let lines = this.manifestText.split(/\r?\n/);
+ let fallbacks = this.fallbacks = [];
+ let settings = this.settings = [];
+ let errors = this.errors = [];
+ let uris = this.uris = [];
+
+ this.currSection = "CACHE";
+
+ for (let i = 0; i < lines.length; i++) {
+ let text = this.text = lines[i].trim();
+ this.currentLine = i + 1;
+
+ if (i === 0 && text !== "CACHE MANIFEST") {
+ this._addError(1, "firstLineMustBeCacheManifest", 1);
+ }
+
+ // Ignore comments
+ if (/^#/.test(text) || !text.length) {
+ continue;
+ }
+
+ if (text == "CACHE MANIFEST") {
+ if (this.currentLine != 1) {
+ this._addError(this.currentLine, "cacheManifestOnlyFirstLine2",
+ this.currentLine);
+ }
+ continue;
+ }
+
+ if (this._maybeUpdateSectionName()) {
+ continue;
+ }
+
+ switch (this.currSection) {
+ case "CACHE":
+ case "NETWORK":
+ this.parseLine();
+ break;
+ case "FALLBACK":
+ this.parseFallbackLine();
+ break;
+ case "SETTINGS":
+ this.parseSettingsLine();
+ break;
+ }
+ }
+
+ return {
+ uris: uris,
+ fallbacks: fallbacks,
+ settings: settings,
+ errors: errors
+ };
+ },
+
+ parseLine: function OCIMP_parseLine() {
+ let text = this.text;
+
+ if (text.indexOf("*") != -1) {
+ if (this.currSection != "NETWORK" || text.length != 1) {
+ this._addError(this.currentLine, "asteriskInWrongSection2",
+ this.currSection, this.currentLine);
+ return;
+ }
+ }
+
+ if (/\s/.test(text)) {
+ this._addError(this.currentLine, "escapeSpaces", this.currentLine);
+ text = text.replace(/\s/g, "%20");
+ }
+
+ if (text[0] == "/") {
+ if (text.substr(0, 4) == "/../") {
+ this._addError(this.currentLine, "slashDotDotSlashBad", this.currentLine);
+ } else {
+ this.uris.push(this._wrapURI(this.origin + text.substring(1)));
+ }
+ } else if (text.substr(0, 2) == "./") {
+ this.uris.push(this._wrapURI(this.origin + text.substring(2)));
+ } else if (text.substr(0, 4) == "http") {
+ this.uris.push(this._wrapURI(text));
+ } else {
+ let origin = this.origin;
+ let path = text;
+
+ while (path.substr(0, 3) == "../" && /^https?:\/\/.*?\/.*?\//.test(origin)) {
+ let trimIdx = origin.substr(0, origin.length - 1).lastIndexOf("/") + 1;
+ origin = origin.substr(0, trimIdx);
+ path = path.substr(3);
+ }
+
+ if (path.substr(0, 3) == "../") {
+ this._addError(this.currentLine, "tooManyDotDotSlashes", this.currentLine);
+ return;
+ }
+
+ if (/^https?:\/\//.test(path)) {
+ this.uris.push(this._wrapURI(path));
+ return;
+ }
+ this.uris.push(this._wrapURI(origin + path));
+ }
+ },
+
+ parseFallbackLine: function OCIMP_parseFallbackLine() {
+ let split = this.text.split(/\s+/);
+ let origURI = this.text;
+
+ if (split.length != 2) {
+ this._addError(this.currentLine, "fallbackUseSpaces", this.currentLine);
+ return;
+ }
+
+ let [ namespace, fallback ] = split;
+
+ if (namespace.indexOf("*") != -1) {
+ this._addError(this.currentLine, "fallbackAsterisk2", this.currentLine);
+ }
+
+ if (/\s/.test(namespace)) {
+ this._addError(this.currentLine, "escapeSpaces", this.currentLine);
+ namespace = namespace.replace(/\s/g, "%20");
+ }
+
+ if (namespace.substr(0, 4) == "/../") {
+ this._addError(this.currentLine, "slashDotDotSlashBad", this.currentLine);
+ }
+
+ if (namespace.substr(0, 2) == "./") {
+ namespace = this.origin + namespace.substring(2);
+ }
+
+ if (namespace.substr(0, 4) != "http") {
+ let origin = this.origin;
+ let path = namespace;
+
+ while (path.substr(0, 3) == "../" && /^https?:\/\/.*?\/.*?\//.test(origin)) {
+ let trimIdx = origin.substr(0, origin.length - 1).lastIndexOf("/") + 1;
+ origin = origin.substr(0, trimIdx);
+ path = path.substr(3);
+ }
+
+ if (path.substr(0, 3) == "../") {
+ this._addError(this.currentLine, "tooManyDotDotSlashes", this.currentLine);
+ }
+
+ if (/^https?:\/\//.test(path)) {
+ namespace = path;
+ } else {
+ if (path[0] == "/") {
+ path = path.substring(1);
+ }
+ namespace = origin + path;
+ }
+ }
+
+ this.text = fallback;
+ this.parseLine();
+
+ this.fallbacks.push({
+ line: this.currentLine,
+ original: origURI,
+ namespace: namespace,
+ fallback: fallback
+ });
+ },
+
+ parseSettingsLine: function OCIMP_parseSettingsLine() {
+ let text = this.text;
+
+ if (this.settings.length == 1 || !/prefer-online|fast/.test(text)) {
+ this._addError(this.currentLine, "settingsBadValue", this.currentLine);
+ return;
+ }
+
+ switch (text) {
+ case "prefer-online":
+ this.settings.push(this._wrapURI(text));
+ break;
+ case "fast":
+ this.settings.push(this._wrapURI(text));
+ break;
+ }
+ },
+
+ _wrapURI: function OCIMP__wrapURI(uri) {
+ return {
+ section: this.currSection,
+ line: this.currentLine,
+ uri: uri,
+ original: this.text
+ };
+ },
+
+ _addError: function OCIMP__addError(line, l10nString, ...params) {
+ let msg;
+
+ if (params) {
+ msg = l10n.formatStringFromName(l10nString, params, params.length);
+ } else {
+ msg = l10n.GetStringFromName(l10nString);
+ }
+
+ this.errors.push({
+ line: line,
+ msg: msg
+ });
+ },
+
+ _maybeUpdateSectionName: function OCIMP__maybeUpdateSectionName() {
+ let text = this.text;
+
+ if (text == text.toUpperCase() && text.charAt(text.length - 1) == ":") {
+ text = text.substr(0, text.length - 1);
+
+ switch (text) {
+ case "CACHE":
+ case "NETWORK":
+ case "FALLBACK":
+ case "SETTINGS":
+ this.currSection = text;
+ return true;
+ default:
+ this._addError(this.currentLine,
+ "invalidSectionName", text, this.currentLine);
+ return false;
+ }
+ }
+ },
+};
+
+XPCOMUtils.defineLazyGetter(this, "l10n", function() Services.strings
+ .createBundle("chrome://browser/locale/devtools/appcacheutils.properties"));
+
+XPCOMUtils.defineLazyGetter(this, "appcacheservice", function() {
+ return Cc["@mozilla.org/network/application-cache-service;1"]
+ .getService(Ci.nsIApplicationCacheService);
+
+});
+
+XPCOMUtils.defineLazyGetter(this, "_DOMParser", function() {
+ return Cc["@mozilla.org/xmlextras/domparser;1"].createInstance(Ci.nsIDOMParser);
+});
diff --git a/toolkit/devtools/shared/Curl.jsm b/toolkit/devtools/shared/Curl.jsm
new file mode 100644
index 000000000..dc7fd37fc
--- /dev/null
+++ b/toolkit/devtools/shared/Curl.jsm
@@ -0,0 +1,400 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+
+/*
+ * Copyright (C) 2007, 2008 Apple Inc. All rights reserved.
+ * Copyright (C) 2008, 2009 Anthony Ricaud <rik@webkit.org>
+ * Copyright (C) 2011 Google Inc. All rights reserved.
+ * Copyright (C) 2009 Mozilla Foundation. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
+ * its contributors may be used to endorse or promote products derived
+ * from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["Curl", "CurlUtils"];
+
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+const DEFAULT_HTTP_VERSION = "HTTP/1.1";
+
+this.Curl = {
+ /**
+ * Generates a cURL command string which can be used from the command line etc.
+ *
+ * @param object aData
+ * Datasource to create the command from.
+ * The object must contain the following properties:
+ * - url:string, the URL of the request.
+ * - method:string, the request method upper cased. HEAD / GET / POST etc.
+ * - headers:array, an array of request headers {name:x, value:x} tuples.
+ * - httpVersion:string, http protocol version rfc2616 formatted. Eg. "HTTP/1.1"
+ * - postDataText:string, optional - the request payload.
+ *
+ * @return string
+ * A cURL command.
+ */
+ generateCommand: function(aData) {
+ let utils = CurlUtils;
+
+ let command = ["curl"];
+ let ignoredHeaders = new Set();
+
+ // The cURL command is expected to run on the same platform that Firefox runs
+ // (it may be different from the inspected page platform).
+ let escapeString = Services.appinfo.OS == "WINNT" ?
+ utils.escapeStringWin : utils.escapeStringPosix;
+
+ // Add URL.
+ command.push(escapeString(aData.url));
+
+ let postDataText = null;
+ let multipartRequest = utils.isMultipartRequest(aData);
+
+ // Create post data.
+ let data = [];
+ if (utils.isUrlEncodedRequest(aData) || aData.method == "PUT") {
+ postDataText = aData.postDataText;
+ data.push("--data");
+ data.push(escapeString(utils.writePostDataTextParams(postDataText)));
+ ignoredHeaders.add("Content-Length");
+ } else if (multipartRequest) {
+ postDataText = aData.postDataText;
+ data.push("--data-binary");
+ let boundary = utils.getMultipartBoundary(aData);
+ let text = utils.removeBinaryDataFromMultipartText(postDataText, boundary);
+ data.push(escapeString(text));
+ ignoredHeaders.add("Content-Length");
+ }
+
+ // Add method.
+ // For GET and POST requests this is not necessary as GET is the
+ // default. If --data or --binary is added POST is the default.
+ if (!(aData.method == "GET" || aData.method == "POST")) {
+ command.push("-X");
+ command.push(aData.method);
+ }
+
+ // Add -I (HEAD)
+ // For servers that supports HEAD.
+ // This will fetch the header of a document only.
+ if (aData.method == "HEAD") {
+ command.push("-I");
+ }
+
+ // Add http version.
+ if (aData.httpVersion && aData.httpVersion != DEFAULT_HTTP_VERSION) {
+ command.push("--" + aData.httpVersion.split("/")[1]);
+ }
+
+ // Add request headers.
+ let headers = aData.headers;
+ if (multipartRequest) {
+ let multipartHeaders = utils.getHeadersFromMultipartText(postDataText);
+ headers = headers.concat(multipartHeaders);
+ }
+ for (let i = 0; i < headers.length; i++) {
+ let header = headers[i];
+ if (header.name === "Accept-Encoding"){
+ command.push("--compressed");
+ continue;
+ }
+ if (ignoredHeaders.has(header.name)) {
+ continue;
+ }
+ command.push("-H");
+ command.push(escapeString(header.name + ": " + header.value));
+ }
+
+ // Add post data.
+ command = command.concat(data);
+
+ return command.join(" ");
+ }
+};
+
+/**
+ * Utility functions for the Curl command generator.
+ */
+this.CurlUtils = {
+ /**
+ * Check if the request is an URL encoded request.
+ *
+ * @param object aData
+ * The data source. See the description in the Curl object.
+ * @return boolean
+ * True if the request is URL encoded, false otherwise.
+ */
+ isUrlEncodedRequest: function(aData) {
+ let postDataText = aData.postDataText;
+ if (!postDataText) {
+ return false;
+ }
+
+ postDataText = postDataText.toLowerCase();
+ if (postDataText.contains("content-type: application/x-www-form-urlencoded")) {
+ return true;
+ }
+
+ let contentType = this.findHeader(aData.headers, "content-type");
+
+ return (contentType &&
+ contentType.toLowerCase().contains("application/x-www-form-urlencoded"));
+ },
+
+ /**
+ * Check if the request is a multipart request.
+ *
+ * @param object aData
+ * The data source.
+ * @return boolean
+ * True if the request is multipart reqeust, false otherwise.
+ */
+ isMultipartRequest: function(aData) {
+ let postDataText = aData.postDataText;
+ if (!postDataText) {
+ return false;
+ }
+
+ postDataText = postDataText.toLowerCase();
+ if (postDataText.contains("content-type: multipart/form-data")) {
+ return true;
+ }
+
+ let contentType = this.findHeader(aData.headers, "content-type");
+
+ return (contentType &&
+ contentType.toLowerCase().contains("multipart/form-data;"));
+ },
+
+ /**
+ * Write out paramters from post data text.
+ *
+ * @param object aPostDataText
+ * Post data text.
+ * @return string
+ * Post data parameters.
+ */
+ writePostDataTextParams: function(aPostDataText) {
+ let lines = aPostDataText.split("\r\n");
+ return lines[lines.length - 1];
+ },
+
+ /**
+ * Finds the header with the given name in the headers array.
+ *
+ * @param array aHeaders
+ * Array of headers info {name:x, value:x}.
+ * @param string aName
+ * The header name to find.
+ * @return string
+ * The found header value or null if not found.
+ */
+ findHeader: function(aHeaders, aName) {
+ if (!aHeaders) {
+ return null;
+ }
+
+ let name = aName.toLowerCase();
+ for (let header of aHeaders) {
+ if (name == header.name.toLowerCase()) {
+ return header.value;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Returns the boundary string for a multipart request.
+ *
+ * @param string aData
+ * The data source. See the description in the Curl object.
+ * @return string
+ * The boundary string for the request.
+ */
+ getMultipartBoundary: function(aData) {
+ let boundaryRe = /\bboundary=(-{3,}\w+)/i;
+
+ // Get the boundary string from the Content-Type request header.
+ let contentType = this.findHeader(aData.headers, "Content-Type");
+ if (boundaryRe.test(contentType)) {
+ return contentType.match(boundaryRe)[1];
+ }
+ // Temporary workaround. As of 2014-03-11 the requestHeaders array does not
+ // always contain the Content-Type header for mulitpart requests. See bug 978144.
+ // Find the header from the request payload.
+ let boundaryString = aData.postDataText.match(boundaryRe)[1];
+ if (boundaryString) {
+ return boundaryString;
+ }
+
+ return null;
+ },
+
+ /**
+ * Removes the binary data from mulitpart text.
+ *
+ * @param string aMultipartText
+ * Multipart form data text.
+ * @param string aBoundary
+ * The boundary string.
+ * @return string
+ * The mulitpart text without the binary data.
+ */
+ removeBinaryDataFromMultipartText: function(aMultipartText, aBoundary) {
+ let result = "";
+ let boundary = "--" + aBoundary;
+ let parts = aMultipartText.split(boundary);
+ for (let part of parts) {
+ // Each part is expected to have a content disposition line.
+ let contentDispositionLine = part.trimLeft().split("\r\n")[0];
+ if (!contentDispositionLine) {
+ continue;
+ }
+ contentDispositionLine = contentDispositionLine.toLowerCase();
+ if (contentDispositionLine.contains("content-disposition: form-data")) {
+ if (contentDispositionLine.contains("filename=")) {
+ // The header lines and the binary blob is separated by 2 CRLF's.
+ // Add only the headers to the result.
+ let headers = part.split("\r\n\r\n")[0];
+ result += boundary + "\r\n" + headers + "\r\n\r\n";
+ }
+ else {
+ result += boundary + "\r\n" + part;
+ }
+ }
+ }
+ result += aBoundary + "--\r\n";
+
+ return result;
+ },
+
+ /**
+ * Get the headers from a multipart post data text.
+ *
+ * @param string aMultipartText
+ * Multipart post text.
+ * @return array
+ * An array of header objects {name:x, value:x}
+ */
+ getHeadersFromMultipartText: function(aMultipartText) {
+ let headers = [];
+ if (!aMultipartText || aMultipartText.startsWith("---")) {
+ return headers;
+ }
+
+ // Get the header section.
+ let index = aMultipartText.indexOf("\r\n\r\n");
+ if (index == -1) {
+ return headers;
+ }
+
+ // Parse the header lines.
+ let headersText = aMultipartText.substring(0, index);
+ let headerLines = headersText.split("\r\n");
+ let lastHeaderName = null;
+
+ for (let line of headerLines) {
+ // Create a header for each line in fields that spans across multiple lines.
+ // Subsquent lines always begins with at least one space or tab character.
+ // (rfc2616)
+ if (lastHeaderName && /^\s+/.test(line)) {
+ headers.push({ name: lastHeaderName, value: line.trim() });
+ continue;
+ }
+
+ let indexOfColon = line.indexOf(":");
+ if (indexOfColon == -1) {
+ continue;
+ }
+
+ let header = [line.slice(0, indexOfColon), line.slice(indexOfColon + 1)];
+ if (header.length != 2) {
+ continue;
+ }
+ lastHeaderName = header[0].trim();
+ headers.push({ name: lastHeaderName, value: header[1].trim() });
+ }
+
+ return headers;
+ },
+
+ /**
+ * Escape util function for POSIX oriented operating systems.
+ * Credit: Google DevTools
+ */
+ escapeStringPosix: function(str) {
+ function escapeCharacter(x) {
+ let code = x.charCodeAt(0);
+ if (code < 256) {
+ // Add leading zero when needed to not care about the next character.
+ return code < 16 ? "\\x0" + code.toString(16) : "\\x" + code.toString(16);
+ }
+ code = code.toString(16);
+ return "\\u" + ("0000" + code).substr(code.length, 4);
+ }
+
+ if (/[^\x20-\x7E]|\'/.test(str)) {
+ // Use ANSI-C quoting syntax.
+ return "$\'" + str.replace(/\\/g, "\\\\")
+ .replace(/\'/g, "\\\'")
+ .replace(/\n/g, "\\n")
+ .replace(/\r/g, "\\r")
+ .replace(/[^\x20-\x7E]/g, escapeCharacter) + "'";
+ } else {
+ // Use single quote syntax.
+ return "'" + str + "'";
+ }
+ },
+
+ /**
+ * Escape util function for Windows systems.
+ * Credit: Google DevTools
+ */
+ escapeStringWin: function(str) {
+ /* Replace quote by double quote (but not by \") because it is
+ recognized by both cmd.exe and MS Crt arguments parser.
+
+ Replace % by "%" because it could be expanded to an environment
+ variable value. So %% becomes "%""%". Even if an env variable ""
+ (2 doublequotes) is declared, the cmd.exe will not
+ substitute it with its value.
+
+ Replace each backslash with double backslash to make sure
+ MS Crt arguments parser won't collapse them.
+
+ Replace new line outside of quotes since cmd.exe doesn't let
+ to do it inside.
+ */
+ return "\"" + str.replace(/"/g, "\"\"")
+ .replace(/%/g, "\"%\"")
+ .replace(/\\/g, "\\\\")
+ .replace(/[\r\n]+/g, "\"^$&\"") + "\"";
+ }
+};
diff --git a/toolkit/devtools/shared/DOMHelpers.jsm b/toolkit/devtools/shared/DOMHelpers.jsm
new file mode 100644
index 000000000..4c2bd522d
--- /dev/null
+++ b/toolkit/devtools/shared/DOMHelpers.jsm
@@ -0,0 +1,156 @@
+/* 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/. */
+
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+Cu.import("resource://gre/modules/Services.jsm");
+
+this.EXPORTED_SYMBOLS = ["DOMHelpers"];
+
+/**
+ * DOMHelpers
+ * Makes DOM traversal easier. Goes through iframes.
+ *
+ * @constructor
+ * @param nsIDOMWindow aWindow
+ * The content window, owning the document to traverse.
+ */
+this.DOMHelpers = function DOMHelpers(aWindow) {
+ if (!aWindow) {
+ throw new Error("window can't be null or undefined");
+ }
+ this.window = aWindow;
+};
+
+DOMHelpers.prototype = {
+ getParentObject: function Helpers_getParentObject(node)
+ {
+ let parentNode = node ? node.parentNode : null;
+
+ if (!parentNode) {
+ // Documents have no parentNode; Attr, Document, DocumentFragment, Entity,
+ // and Notation. top level windows have no parentNode
+ if (node && node == this.window.Node.DOCUMENT_NODE) {
+ // document type
+ if (node.defaultView) {
+ let embeddingFrame = node.defaultView.frameElement;
+ if (embeddingFrame)
+ return embeddingFrame.parentNode;
+ }
+ }
+ // a Document object without a parentNode or window
+ return null; // top level has no parent
+ }
+
+ if (parentNode.nodeType == this.window.Node.DOCUMENT_NODE) {
+ if (parentNode.defaultView) {
+ return parentNode.defaultView.frameElement;
+ }
+ // parent is document element, but no window at defaultView.
+ return null;
+ }
+
+ if (!parentNode.localName)
+ return null;
+
+ return parentNode;
+ },
+
+ getChildObject: function Helpers_getChildObject(node, index, previousSibling,
+ showTextNodesWithWhitespace)
+ {
+ if (!node)
+ return null;
+
+ if (node.contentDocument) {
+ // then the node is a frame
+ if (index == 0) {
+ return node.contentDocument.documentElement; // the node's HTMLElement
+ }
+ return null;
+ }
+
+ if (node.getSVGDocument) {
+ let svgDocument = node.getSVGDocument();
+ if (svgDocument) {
+ // then the node is a frame
+ if (index == 0) {
+ return svgDocument.documentElement; // the node's SVGElement
+ }
+ return null;
+ }
+ }
+
+ let child = null;
+ if (previousSibling) // then we are walking
+ child = this.getNextSibling(previousSibling);
+ else
+ child = this.getFirstChild(node);
+
+ if (showTextNodesWithWhitespace)
+ return child;
+
+ for (; child; child = this.getNextSibling(child)) {
+ if (!this.isWhitespaceText(child))
+ return child;
+ }
+
+ return null; // we have no children worth showing.
+ },
+
+ getFirstChild: function Helpers_getFirstChild(node)
+ {
+ let SHOW_ALL = Components.interfaces.nsIDOMNodeFilter.SHOW_ALL;
+ this.treeWalker = node.ownerDocument.createTreeWalker(node,
+ SHOW_ALL, null);
+ return this.treeWalker.firstChild();
+ },
+
+ getNextSibling: function Helpers_getNextSibling(node)
+ {
+ let next = this.treeWalker.nextSibling();
+
+ if (!next)
+ delete this.treeWalker;
+
+ return next;
+ },
+
+ isWhitespaceText: function Helpers_isWhitespaceText(node)
+ {
+ return node.nodeType == this.window.Node.TEXT_NODE &&
+ !/[^\s]/.exec(node.nodeValue);
+ },
+
+ destroy: function Helpers_destroy()
+ {
+ delete this.window;
+ delete this.treeWalker;
+ },
+
+ /**
+ * A simple way to be notified (once) when a window becomes
+ * interactive (DOMContentLoaded).
+ *
+ * It is based on the chromeEventHandler. This is useful when
+ * chrome iframes are loaded in content docshells (in Firefox
+ * tabs for example).
+ */
+ onceDOMReady: function Helpers_onLocationChange(callback) {
+ let window = this.window;
+ let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShell);
+ let onReady = function(event) {
+ if (event.target == window.document) {
+ docShell.chromeEventHandler.removeEventListener("DOMContentLoaded", onReady, false);
+ // If in `callback` the URL of the window is changed and a listener to DOMContentLoaded
+ // is attached, the event we just received will be also be caught by the new listener.
+ // We want to avoid that so we execute the callback in the next queue.
+ Services.tm.mainThread.dispatch(callback, 0);
+ }
+ }
+ docShell.chromeEventHandler.addEventListener("DOMContentLoaded", onReady, false);
+ }
+};
diff --git a/toolkit/devtools/shared/DeveloperToolbar.jsm b/toolkit/devtools/shared/DeveloperToolbar.jsm
new file mode 100644
index 000000000..9c2375e16
--- /dev/null
+++ b/toolkit/devtools/shared/DeveloperToolbar.jsm
@@ -0,0 +1,1280 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = [ "DeveloperToolbar", "CommandUtils" ];
+
+const NS_XHTML = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+const { require, TargetFactory } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools;
+
+const Node = Ci.nsIDOMNode;
+
+XPCOMUtils.defineLazyModuleGetter(this, "console",
+ "resource://gre/modules/devtools/Console.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
+ "resource://gre/modules/PluralForm.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
+ "resource://gre/modules/devtools/event-emitter.js");
+
+XPCOMUtils.defineLazyGetter(this, "prefBranch", function() {
+ let prefService = Cc["@mozilla.org/preferences-service;1"]
+ .getService(Ci.nsIPrefService);
+ return prefService.getBranch(null)
+ .QueryInterface(Ci.nsIPrefBranch2);
+});
+
+XPCOMUtils.defineLazyGetter(this, "toolboxStrings", function () {
+ return Services.strings.createBundle("chrome://browser/locale/devtools/toolbox.properties");
+});
+
+const Telemetry = require("devtools/shared/telemetry");
+
+// This lazy getter is needed to prevent a require loop
+XPCOMUtils.defineLazyGetter(this, "gcli", () => {
+ try {
+ require("devtools/commandline/commands-index");
+ return require("gcli/index");
+ }
+ catch (ex) {
+ console.error(ex);
+ }
+});
+
+XPCOMUtils.defineLazyGetter(this, "util", () => {
+ return require("gcli/util/util");
+});
+
+Object.defineProperty(this, "ConsoleServiceListener", {
+ get: function() {
+ return require("devtools/toolkit/webconsole/utils").ConsoleServiceListener;
+ },
+ configurable: true,
+ enumerable: true
+});
+
+const promise = Cu.import('resource://gre/modules/Promise.jsm', {}).Promise;
+
+/**
+ * A collection of utilities to help working with commands
+ */
+let CommandUtils = {
+ /**
+ * Utility to ensure that things are loaded in the correct order
+ */
+ createRequisition: function(environment) {
+ return gcli.load().then(() => {
+ return gcli.createRequisition({ environment: environment });
+ });
+ },
+
+ /**
+ * Read a toolbarSpec from preferences
+ * @param pref The name of the preference to read
+ */
+ getCommandbarSpec: function(pref) {
+ let value = prefBranch.getComplexValue(pref, Ci.nsISupportsString).data;
+ return JSON.parse(value);
+ },
+
+ /**
+ * A toolbarSpec is an array of strings each of which is a GCLI command.
+ *
+ * Warning: this method uses the unload event of the window that owns the
+ * buttons that are of type checkbox. this means that we don't properly
+ * unregister event handlers until the window is destroyed.
+ */
+ createButtons: function(toolbarSpec, target, document, requisition) {
+ return util.promiseEach(toolbarSpec, typed => {
+ // Ask GCLI to parse the typed string (doesn't execute it)
+ return requisition.update(typed).then(() => {
+ let button = document.createElement("toolbarbutton");
+
+ // Ignore invalid commands
+ let command = requisition.commandAssignment.value;
+ if (command == null) {
+ throw new Error("No command '" + typed + "'");
+ }
+
+ // Do not build a button for a non-remote safe command in a non-local target.
+ if (!target.isLocalTab && !command.isRemoteSafe) {
+ requisition.clear();
+ return;
+ }
+
+ if (command.buttonId != null) {
+ button.id = command.buttonId;
+ if (command.buttonClass != null) {
+ button.className = command.buttonClass;
+ }
+ }
+ else {
+ button.setAttribute("text-as-image", "true");
+ button.setAttribute("label", command.name);
+ button.className = "devtools-toolbarbutton";
+ }
+ if (command.tooltipText != null) {
+ button.setAttribute("tooltiptext", command.tooltipText);
+ }
+ else if (command.description != null) {
+ button.setAttribute("tooltiptext", command.description);
+ }
+
+ button.addEventListener("click", () => {
+ requisition.updateExec(typed);
+ }, false);
+
+ // Allow the command button to be toggleable
+ if (command.state) {
+ button.setAttribute("autocheck", false);
+
+ /**
+ * The onChange event should be called with an event object that
+ * contains a target property which specifies which target the event
+ * applies to. For legacy reasons the event object can also contain
+ * a tab property.
+ */
+ let onChange = (eventName, ev) => {
+ if (ev.target == target || ev.tab == target.tab) {
+
+ let updateChecked = (checked) => {
+ if (checked) {
+ button.setAttribute("checked", true);
+ }
+ else if (button.hasAttribute("checked")) {
+ button.removeAttribute("checked");
+ }
+ };
+
+ // isChecked would normally be synchronous. An annoying quirk
+ // of the 'csscoverage toggle' command forces us to accept a
+ // promise here, but doing Promise.resolve(reply).then(...) here
+ // makes this async for everyone, which breaks some tests so we
+ // treat non-promise replies separately to keep then synchronous.
+ let reply = command.state.isChecked(target);
+ if (typeof reply.then == "function") {
+ reply.then(updateChecked, console.error);
+ }
+ else {
+ updateChecked(reply);
+ }
+ }
+ };
+
+ command.state.onChange(target, onChange);
+ onChange("", { target: target });
+ document.defaultView.addEventListener("unload", () => {
+ if (command.state.offChange) {
+ command.state.offChange(target, onChange);
+ }
+ }, false);
+ }
+
+ requisition.clear();
+
+ return button;
+ });
+ });
+ },
+
+ /**
+ * A helper function to create the environment object that is passed to
+ * GCLI commands.
+ * @param targetContainer An object containing a 'target' property which
+ * reflects the current debug target
+ */
+ createEnvironment: function(container, targetProperty='target') {
+ if (!container[targetProperty].toString ||
+ !/TabTarget/.test(container[targetProperty].toString())) {
+ throw new Error('Missing target');
+ }
+
+ return {
+ get target() {
+ if (!container[targetProperty].toString ||
+ !/TabTarget/.test(container[targetProperty].toString())) {
+ throw new Error('Removed target');
+ }
+
+ return container[targetProperty];
+ },
+
+ get chromeWindow() {
+ return this.target.tab.ownerDocument.defaultView;
+ },
+
+ get chromeDocument() {
+ return this.chromeWindow.document;
+ },
+
+ get window() {
+ return this.chromeWindow.gBrowser.selectedBrowser.contentWindow;
+ },
+
+ get document() {
+ return this.window.document;
+ }
+ };
+ },
+};
+
+this.CommandUtils = CommandUtils;
+
+/**
+ * Due to a number of panel bugs we need a way to check if we are running on
+ * Linux. See the comments for TooltipPanel and OutputPanel for further details.
+ *
+ * When bug 780102 is fixed all isLinux checks can be removed and we can revert
+ * to using panels.
+ */
+XPCOMUtils.defineLazyGetter(this, "isLinux", function() {
+ return OS == "Linux";
+});
+
+XPCOMUtils.defineLazyGetter(this, "OS", function() {
+ let os = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime).OS;
+ return os;
+});
+
+/**
+ * A component to manage the global developer toolbar, which contains a GCLI
+ * and buttons for various developer tools.
+ * @param aChromeWindow The browser window to which this toolbar is attached
+ * @param aToolbarElement See browser.xul:<toolbar id="developer-toolbar">
+ */
+this.DeveloperToolbar = function DeveloperToolbar(aChromeWindow, aToolbarElement)
+{
+ this._chromeWindow = aChromeWindow;
+
+ this._element = aToolbarElement;
+ this._element.hidden = true;
+ this._doc = this._element.ownerDocument;
+
+ this._telemetry = new Telemetry();
+ this._errorsCount = {};
+ this._warningsCount = {};
+ this._errorListeners = {};
+ this._errorCounterButton = this._doc
+ .getElementById("developer-toolbar-toolbox-button");
+ this._errorCounterButton._defaultTooltipText =
+ this._errorCounterButton.getAttribute("tooltiptext");
+
+ EventEmitter.decorate(this);
+}
+
+/**
+ * Inspector notifications dispatched through the nsIObserverService
+ */
+const NOTIFICATIONS = {
+ /** DeveloperToolbar.show() has been called, and we're working on it */
+ LOAD: "developer-toolbar-load",
+
+ /** DeveloperToolbar.show() has completed */
+ SHOW: "developer-toolbar-show",
+
+ /** DeveloperToolbar.hide() has been called */
+ HIDE: "developer-toolbar-hide"
+};
+
+/**
+ * Attach notification constants to the object prototype so tests etc can
+ * use them without needing to import anything
+ */
+DeveloperToolbar.prototype.NOTIFICATIONS = NOTIFICATIONS;
+
+/**
+ * target is dynamic because the selectedTab changes
+ */
+Object.defineProperty(DeveloperToolbar.prototype, "target", {
+ get: function() {
+ return TargetFactory.forTab(this._chromeWindow.gBrowser.selectedTab);
+ },
+ enumerable: true
+});
+
+/**
+ * Is the toolbar open?
+ */
+Object.defineProperty(DeveloperToolbar.prototype, 'visible', {
+ get: function DT_visible() {
+ return !this._element.hidden;
+ },
+ enumerable: true
+});
+
+let _gSequenceId = 0;
+
+/**
+ * Getter for a unique ID.
+ */
+Object.defineProperty(DeveloperToolbar.prototype, 'sequenceId', {
+ get: function DT_visible() {
+ return _gSequenceId++;
+ },
+ enumerable: true
+});
+
+/**
+ * Called from browser.xul in response to menu-click or keyboard shortcut to
+ * toggle the toolbar
+ */
+DeveloperToolbar.prototype.toggle = function() {
+ if (this.visible) {
+ return this.hide().catch(console.error);
+ } else {
+ return this.show(true).catch(console.error);
+ }
+};
+
+/**
+ * Called from browser.xul in response to menu-click or keyboard shortcut to
+ * toggle the toolbar
+ */
+DeveloperToolbar.prototype.focus = function() {
+ if (this.visible) {
+ this._input.focus();
+ return promise.resolve();
+ } else {
+ return this.show(true);
+ }
+};
+
+/**
+ * Called from browser.xul in response to menu-click or keyboard shortcut to
+ * toggle the toolbar
+ */
+DeveloperToolbar.prototype.focusToggle = function() {
+ if (this.visible) {
+ // If we have focus then the active element is the HTML input contained
+ // inside the xul input element
+ let active = this._chromeWindow.document.activeElement;
+ let position = this._input.compareDocumentPosition(active);
+ if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
+ this.hide();
+ }
+ else {
+ this._input.focus();
+ }
+ } else {
+ this.show(true);
+ }
+};
+
+/**
+ * Even if the user has not clicked on 'Got it' in the intro, we only show it
+ * once per session.
+ * Warning this is slightly messed up because this.DeveloperToolbar is not the
+ * same as this.DeveloperToolbar when in browser.js context.
+ */
+DeveloperToolbar.introShownThisSession = false;
+
+/**
+ * Show the developer toolbar
+ */
+DeveloperToolbar.prototype.show = function(focus) {
+ if (this._showPromise != null) {
+ return this._showPromise;
+ }
+
+ // hide() is async, so ensure we don't need to wait for hide() to finish
+ var waitPromise = this._hidePromise || promise.resolve();
+
+ this._showPromise = waitPromise.then(() => {
+ Services.prefs.setBoolPref("devtools.toolbar.visible", true);
+
+ this._telemetry.toolOpened("developertoolbar");
+
+ this._notify(NOTIFICATIONS.LOAD);
+
+ this._input = this._doc.querySelector(".gclitoolbar-input-node");
+
+ // Initializing GCLI can only be done when we've got content windows to
+ // write to, so this needs to be done asynchronously.
+ let panelPromises = [
+ TooltipPanel.create(this),
+ OutputPanel.create(this)
+ ];
+ return promise.all(panelPromises).then(panels => {
+ [ this.tooltipPanel, this.outputPanel ] = panels;
+
+ this._doc.getElementById("Tools:DevToolbar").setAttribute("checked", "true");
+
+ return gcli.load().then(() => {
+ this.display = gcli.createDisplay({
+ contentDocument: this._chromeWindow.gBrowser.contentDocument,
+ chromeDocument: this._doc,
+ chromeWindow: this._chromeWindow,
+ hintElement: this.tooltipPanel.hintElement,
+ inputElement: this._input,
+ completeElement: this._doc.querySelector(".gclitoolbar-complete-node"),
+ backgroundElement: this._doc.querySelector(".gclitoolbar-stack-node"),
+ outputDocument: this.outputPanel.document,
+ environment: CommandUtils.createEnvironment(this, "target"),
+ tooltipClass: "gcliterm-tooltip",
+ eval: null,
+ scratchpad: null
+ });
+
+ this.display.focusManager.addMonitoredElement(this.outputPanel._frame);
+ this.display.focusManager.addMonitoredElement(this._element);
+
+ this.display.onVisibilityChange.add(this.outputPanel._visibilityChanged,
+ this.outputPanel);
+ this.display.onVisibilityChange.add(this.tooltipPanel._visibilityChanged,
+ this.tooltipPanel);
+ this.display.onOutput.add(this.outputPanel._outputChanged, this.outputPanel);
+
+ let tabbrowser = this._chromeWindow.gBrowser;
+ tabbrowser.tabContainer.addEventListener("TabSelect", this, false);
+ tabbrowser.tabContainer.addEventListener("TabClose", this, false);
+ tabbrowser.addEventListener("load", this, true);
+ tabbrowser.addEventListener("beforeunload", this, true);
+
+ this._initErrorsCount(tabbrowser.selectedTab);
+ this._devtoolsUnloaded = this._devtoolsUnloaded.bind(this);
+ this._devtoolsLoaded = this._devtoolsLoaded.bind(this);
+ Services.obs.addObserver(this._devtoolsUnloaded, "devtools-unloaded", false);
+ Services.obs.addObserver(this._devtoolsLoaded, "devtools-loaded", false);
+
+ this._element.hidden = false;
+
+ if (focus) {
+ this._input.focus();
+ }
+
+ this._notify(NOTIFICATIONS.SHOW);
+
+ if (!DeveloperToolbar.introShownThisSession) {
+ this.display.maybeShowIntro();
+ DeveloperToolbar.introShownThisSession = true;
+ }
+
+ this._showPromise = null;
+ });
+ });
+ });
+
+ return this._showPromise;
+};
+
+/**
+ * Hide the developer toolbar.
+ */
+DeveloperToolbar.prototype.hide = function() {
+ // If we're already in the process of hiding, just use the other promise
+ if (this._hidePromise != null) {
+ return this._hidePromise;
+ }
+
+ // show() is async, so ensure we don't need to wait for show() to finish
+ var waitPromise = this._showPromise || promise.resolve();
+
+ this._hidePromise = waitPromise.then(() => {
+ this._element.hidden = true;
+
+ Services.prefs.setBoolPref("devtools.toolbar.visible", false);
+
+ this._doc.getElementById("Tools:DevToolbar").setAttribute("checked", "false");
+ this.destroy();
+
+ this._telemetry.toolClosed("developertoolbar");
+ this._notify(NOTIFICATIONS.HIDE);
+
+ this._hidePromise = null;
+ });
+
+ return this._hidePromise;
+};
+
+/**
+ * The devtools-unloaded event handler.
+ * @private
+ */
+DeveloperToolbar.prototype._devtoolsUnloaded = function() {
+ let tabbrowser = this._chromeWindow.gBrowser;
+ Array.prototype.forEach.call(tabbrowser.tabs, this._stopErrorsCount, this);
+};
+
+/**
+ * The devtools-loaded event handler.
+ * @private
+ */
+DeveloperToolbar.prototype._devtoolsLoaded = function() {
+ let tabbrowser = this._chromeWindow.gBrowser;
+ this._initErrorsCount(tabbrowser.selectedTab);
+};
+
+/**
+ * Initialize the listeners needed for tracking the number of errors for a given
+ * tab.
+ *
+ * @private
+ * @param nsIDOMNode tab the xul:tab for which you want to track the number of
+ * errors.
+ */
+DeveloperToolbar.prototype._initErrorsCount = function(tab) {
+ let tabId = tab.linkedPanel;
+ if (tabId in this._errorsCount) {
+ this._updateErrorsCount();
+ return;
+ }
+
+ let window = tab.linkedBrowser.contentWindow;
+ let listener = new ConsoleServiceListener(window, {
+ onConsoleServiceMessage: this._onPageError.bind(this, tabId),
+ });
+ listener.init();
+
+ this._errorListeners[tabId] = listener;
+ this._errorsCount[tabId] = 0;
+ this._warningsCount[tabId] = 0;
+
+ let messages = listener.getCachedMessages();
+ messages.forEach(this._onPageError.bind(this, tabId));
+
+ this._updateErrorsCount();
+};
+
+/**
+ * Stop the listeners needed for tracking the number of errors for a given
+ * tab.
+ *
+ * @private
+ * @param nsIDOMNode tab the xul:tab for which you want to stop tracking the
+ * number of errors.
+ */
+DeveloperToolbar.prototype._stopErrorsCount = function(tab) {
+ let tabId = tab.linkedPanel;
+ if (!(tabId in this._errorsCount) || !(tabId in this._warningsCount)) {
+ this._updateErrorsCount();
+ return;
+ }
+
+ this._errorListeners[tabId].destroy();
+ delete this._errorListeners[tabId];
+ delete this._errorsCount[tabId];
+ delete this._warningsCount[tabId];
+
+ this._updateErrorsCount();
+};
+
+/**
+ * Hide the developer toolbar
+ */
+DeveloperToolbar.prototype.destroy = function() {
+ if (this._input == null) {
+ return; // Already destroyed
+ }
+
+ let tabbrowser = this._chromeWindow.gBrowser;
+ tabbrowser.tabContainer.removeEventListener("TabSelect", this, false);
+ tabbrowser.tabContainer.removeEventListener("TabClose", this, false);
+ tabbrowser.removeEventListener("load", this, true);
+ tabbrowser.removeEventListener("beforeunload", this, true);
+
+ Services.obs.removeObserver(this._devtoolsUnloaded, "devtools-unloaded");
+ Services.obs.removeObserver(this._devtoolsLoaded, "devtools-loaded");
+ Array.prototype.forEach.call(tabbrowser.tabs, this._stopErrorsCount, this);
+
+ this.display.focusManager.removeMonitoredElement(this.outputPanel._frame);
+ this.display.focusManager.removeMonitoredElement(this._element);
+
+ this.display.onVisibilityChange.remove(this.outputPanel._visibilityChanged, this.outputPanel);
+ this.display.onVisibilityChange.remove(this.tooltipPanel._visibilityChanged, this.tooltipPanel);
+ this.display.onOutput.remove(this.outputPanel._outputChanged, this.outputPanel);
+ this.display.destroy();
+ this.outputPanel.destroy();
+ this.tooltipPanel.destroy();
+ delete this._input;
+
+ // We could "delete this.display" etc if we have hard-to-track-down memory
+ // leaks as a belt-and-braces approach, however this prevents our DOM node
+ // hunter from looking in all the nooks and crannies, so it's better if we
+ // can be leak-free without
+ /*
+ delete this.display;
+ delete this.outputPanel;
+ delete this.tooltipPanel;
+ */
+};
+
+/**
+ * Utility for sending notifications
+ * @param topic a NOTIFICATION constant
+ */
+DeveloperToolbar.prototype._notify = function(topic) {
+ let data = { toolbar: this };
+ data.wrappedJSObject = data;
+ Services.obs.notifyObservers(data, topic, null);
+};
+
+/**
+ * Update various parts of the UI when the current tab changes
+ */
+DeveloperToolbar.prototype.handleEvent = function(ev) {
+ if (ev.type == "TabSelect" || ev.type == "load") {
+ if (this.visible) {
+ this.display.reattach({
+ contentDocument: this._chromeWindow.gBrowser.contentDocument
+ });
+
+ if (ev.type == "TabSelect") {
+ this._initErrorsCount(ev.target);
+ }
+ }
+ }
+ else if (ev.type == "TabClose") {
+ this._stopErrorsCount(ev.target);
+ }
+ else if (ev.type == "beforeunload") {
+ this._onPageBeforeUnload(ev);
+ }
+};
+
+/**
+ * Count a page error received for the currently selected tab. This
+ * method counts the JavaScript exceptions received and CSS errors/warnings.
+ *
+ * @private
+ * @param string tabId the ID of the tab from where the page error comes.
+ * @param object pageError the page error object received from the
+ * PageErrorListener.
+ */
+DeveloperToolbar.prototype._onPageError = function(tabId, pageError) {
+ if (pageError.category == "CSS Parser" ||
+ pageError.category == "CSS Loader") {
+ return;
+ }
+ if ((pageError.flags & pageError.warningFlag) ||
+ (pageError.flags & pageError.strictFlag)) {
+ this._warningsCount[tabId]++;
+ } else {
+ this._errorsCount[tabId]++;
+ }
+ this._updateErrorsCount(tabId);
+};
+
+/**
+ * The |beforeunload| event handler. This function resets the errors count when
+ * a different page starts loading.
+ *
+ * @private
+ * @param nsIDOMEvent ev the beforeunload DOM event.
+ */
+DeveloperToolbar.prototype._onPageBeforeUnload = function(ev) {
+ let window = ev.target.defaultView;
+ if (window.top !== window) {
+ return;
+ }
+
+ let tabs = this._chromeWindow.gBrowser.tabs;
+ Array.prototype.some.call(tabs, function(tab) {
+ if (tab.linkedBrowser.contentWindow === window) {
+ let tabId = tab.linkedPanel;
+ if (tabId in this._errorsCount || tabId in this._warningsCount) {
+ this._errorsCount[tabId] = 0;
+ this._warningsCount[tabId] = 0;
+ this._updateErrorsCount(tabId);
+ }
+ return true;
+ }
+ return false;
+ }, this);
+};
+
+/**
+ * Update the page errors count displayed in the Web Console button for the
+ * currently selected tab.
+ *
+ * @private
+ * @param string [changedTabId] Optional. The tab ID that had its page errors
+ * count changed. If this is provided and it doesn't match the currently
+ * selected tab, then the button is not updated.
+ */
+DeveloperToolbar.prototype._updateErrorsCount = function(changedTabId) {
+ let tabId = this._chromeWindow.gBrowser.selectedTab.linkedPanel;
+ if (changedTabId && tabId != changedTabId) {
+ return;
+ }
+
+ let errors = this._errorsCount[tabId];
+ let warnings = this._warningsCount[tabId];
+ let btn = this._errorCounterButton;
+ if (errors) {
+ let errorsText = toolboxStrings
+ .GetStringFromName("toolboxToggleButton.errors");
+ errorsText = PluralForm.get(errors, errorsText).replace("#1", errors);
+
+ let warningsText = toolboxStrings
+ .GetStringFromName("toolboxToggleButton.warnings");
+ warningsText = PluralForm.get(warnings, warningsText).replace("#1", warnings);
+
+ let tooltiptext = toolboxStrings
+ .formatStringFromName("toolboxToggleButton.tooltip",
+ [errorsText, warningsText], 2);
+
+ btn.setAttribute("error-count", errors);
+ btn.setAttribute("tooltiptext", tooltiptext);
+ } else {
+ btn.removeAttribute("error-count");
+ btn.setAttribute("tooltiptext", btn._defaultTooltipText);
+ }
+
+ this.emit("errors-counter-updated");
+};
+
+/**
+ * Reset the errors counter for the given tab.
+ *
+ * @param nsIDOMElement tab The xul:tab for which you want to reset the page
+ * errors counters.
+ */
+DeveloperToolbar.prototype.resetErrorsCount = function(tab) {
+ let tabId = tab.linkedPanel;
+ if (tabId in this._errorsCount || tabId in this._warningsCount) {
+ this._errorsCount[tabId] = 0;
+ this._warningsCount[tabId] = 0;
+ this._updateErrorsCount(tabId);
+ }
+};
+
+/**
+ * Creating a OutputPanel is asynchronous
+ */
+function OutputPanel() {
+ throw new Error('Use OutputPanel.create()');
+}
+
+/**
+ * Panel to handle command line output.
+ *
+ * There is a tooltip bug on Windows and OSX that prevents tooltips from being
+ * positioned properly (bug 786975). There is a Gnome panel bug on Linux that
+ * causes ugly focus issues (https://bugzilla.gnome.org/show_bug.cgi?id=621848).
+ * We now use a tooltip on Linux and a panel on OSX & Windows.
+ *
+ * If a panel has no content and no height it is not shown when openPopup is
+ * called on Windows and OSX (bug 692348) ... this prevents the panel from
+ * appearing the first time it is shown. Setting the panel's height to 1px
+ * before calling openPopup works around this issue as we resize it ourselves
+ * anyway.
+ *
+ * @param devtoolbar The parent DeveloperToolbar object
+ */
+OutputPanel.create = function(devtoolbar) {
+ var outputPanel = Object.create(OutputPanel.prototype);
+ return outputPanel._init(devtoolbar);
+};
+
+/**
+ * @private See OutputPanel.create
+ */
+OutputPanel.prototype._init = function(devtoolbar) {
+ this._devtoolbar = devtoolbar;
+ this._input = this._devtoolbar._input;
+ this._toolbar = this._devtoolbar._doc.getElementById("developer-toolbar");
+
+ /*
+ <tooltip|panel id="gcli-output"
+ noautofocus="true"
+ noautohide="true"
+ class="gcli-panel">
+ <html:iframe xmlns:html="http://www.w3.org/1999/xhtml"
+ id="gcli-output-frame"
+ src="chrome://browser/content/devtools/commandlineoutput.xhtml"
+ sandbox="allow-same-origin"/>
+ </tooltip|panel>
+ */
+
+ // TODO: Switch back from tooltip to panel when metacity focus issue is fixed:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=780102
+ this._panel = this._devtoolbar._doc.createElement(isLinux ? "tooltip" : "panel");
+
+ this._panel.id = "gcli-output";
+ this._panel.classList.add("gcli-panel");
+
+ if (isLinux) {
+ this.canHide = false;
+ this._onpopuphiding = this._onpopuphiding.bind(this);
+ this._panel.addEventListener("popuphiding", this._onpopuphiding, true);
+ } else {
+ this._panel.setAttribute("noautofocus", "true");
+ this._panel.setAttribute("noautohide", "true");
+
+ // Bug 692348: On Windows and OSX if a panel has no content and no height
+ // openPopup fails to display it. Setting the height to 1px alows the panel
+ // to be displayed before has content or a real height i.e. the first time
+ // it is displayed.
+ this._panel.setAttribute("height", "1px");
+ }
+
+ this._toolbar.parentElement.insertBefore(this._panel, this._toolbar);
+
+ this._frame = this._devtoolbar._doc.createElementNS(NS_XHTML, "iframe");
+ this._frame.id = "gcli-output-frame";
+ this._frame.setAttribute("src", "chrome://browser/content/devtools/commandlineoutput.xhtml");
+ this._frame.setAttribute("sandbox", "allow-same-origin");
+ this._panel.appendChild(this._frame);
+
+ this.displayedOutput = undefined;
+
+ this._update = this._update.bind(this);
+
+ // Wire up the element from the iframe, and resolve the promise
+ let deferred = promise.defer();
+ let onload = () => {
+ this._frame.removeEventListener("load", onload, true);
+
+ this.document = this._frame.contentDocument;
+
+ this._div = this.document.getElementById("gcli-output-root");
+ this._div.classList.add('gcli-row-out');
+ this._div.setAttribute('aria-live', 'assertive');
+
+ let styles = this._toolbar.ownerDocument.defaultView
+ .getComputedStyle(this._toolbar);
+ this._div.setAttribute("dir", styles.direction);
+
+ deferred.resolve(this);
+ };
+ this._frame.addEventListener("load", onload, true);
+
+ return deferred.promise;
+}
+
+/**
+ * Prevent the popup from hiding if it is not permitted via this.canHide.
+ */
+OutputPanel.prototype._onpopuphiding = function(ev) {
+ // TODO: When we switch back from tooltip to panel we can remove this hack:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=780102
+ if (isLinux && !this.canHide) {
+ ev.preventDefault();
+ }
+};
+
+/**
+ * Display the OutputPanel.
+ */
+OutputPanel.prototype.show = function() {
+ if (isLinux) {
+ this.canHide = false;
+ }
+
+ // We need to reset the iframe size in order for future size calculations to
+ // be correct
+ this._frame.style.minHeight = this._frame.style.maxHeight = 0;
+ this._frame.style.minWidth = 0;
+
+ this._panel.openPopup(this._input, "before_start", 0, 0, false, false, null);
+ this._resize();
+
+ this._input.focus();
+};
+
+/**
+ * Internal helper to set the height of the output panel to fit the available
+ * content;
+ */
+OutputPanel.prototype._resize = function() {
+ if (this._panel == null || this.document == null || !this._panel.state == "closed") {
+ return
+ }
+
+ // Set max panel width to match any content with a max of the width of the
+ // browser window.
+ let maxWidth = this._panel.ownerDocument.documentElement.clientWidth;
+
+ // Adjust max width according to OS.
+ // We'd like to put this in CSS but we can't:
+ // body { width: calc(min(-5px, max-content)); }
+ // #_panel { max-width: -5px; }
+ switch(OS) {
+ case "Linux":
+ maxWidth -= 5;
+ break;
+ case "Darwin":
+ maxWidth -= 25;
+ break;
+ case "WINNT":
+ maxWidth -= 5;
+ break;
+ }
+
+ this.document.body.style.width = "-moz-max-content";
+ let style = this._frame.contentWindow.getComputedStyle(this.document.body);
+ let frameWidth = parseInt(style.width, 10);
+ let width = Math.min(maxWidth, frameWidth);
+ this.document.body.style.width = width + "px";
+
+ // Set the width of the iframe.
+ this._frame.style.minWidth = width + "px";
+ this._panel.style.maxWidth = maxWidth + "px";
+
+ // browserAdjustment is used to correct the panel height according to the
+ // browsers borders etc.
+ const browserAdjustment = 15;
+
+ // Set max panel height to match any content with a max of the height of the
+ // browser window.
+ let maxHeight =
+ this._panel.ownerDocument.documentElement.clientHeight - browserAdjustment;
+ let height = Math.min(maxHeight, this.document.documentElement.scrollHeight);
+
+ // Set the height of the iframe. Setting iframe.height does not work.
+ this._frame.style.minHeight = this._frame.style.maxHeight = height + "px";
+
+ // Set the height and width of the panel to match the iframe.
+ this._panel.sizeTo(width, height);
+
+ // Move the panel to the correct position in the case that it has been
+ // positioned incorrectly.
+ let screenX = this._input.boxObject.screenX;
+ let screenY = this._toolbar.boxObject.screenY;
+ this._panel.moveTo(screenX, screenY - height);
+};
+
+/**
+ * Called by GCLI when a command is executed.
+ */
+OutputPanel.prototype._outputChanged = function(ev) {
+ if (ev.output.hidden) {
+ return;
+ }
+
+ this.remove();
+
+ this.displayedOutput = ev.output;
+
+ if (this.displayedOutput.completed) {
+ this._update();
+ }
+ else {
+ this.displayedOutput.promise.then(this._update, this._update)
+ .then(null, console.error);
+ }
+};
+
+/**
+ * Called when displayed Output says it's changed or from outputChanged, which
+ * happens when there is a new displayed Output.
+ */
+OutputPanel.prototype._update = function() {
+ // destroy has been called, bail out
+ if (this._div == null) {
+ return;
+ }
+
+ // Empty this._div
+ while (this._div.hasChildNodes()) {
+ this._div.removeChild(this._div.firstChild);
+ }
+
+ if (this.displayedOutput.data != null) {
+ let context = this._devtoolbar.display.requisition.conversionContext;
+ this.displayedOutput.convert('dom', context).then(node => {
+ if (node == null) {
+ return;
+ }
+
+ while (this._div.hasChildNodes()) {
+ this._div.removeChild(this._div.firstChild);
+ }
+
+ var links = node.querySelectorAll('*[href]');
+ for (var i = 0; i < links.length; i++) {
+ links[i].setAttribute('target', '_blank');
+ }
+
+ this._div.appendChild(node);
+ this.show();
+ });
+ }
+};
+
+/**
+ * Detach listeners from the currently displayed Output.
+ */
+OutputPanel.prototype.remove = function() {
+ if (isLinux) {
+ this.canHide = true;
+ }
+
+ if (this._panel && this._panel.hidePopup) {
+ this._panel.hidePopup();
+ }
+
+ if (this.displayedOutput) {
+ delete this.displayedOutput;
+ }
+};
+
+/**
+ * Detach listeners from the currently displayed Output.
+ */
+OutputPanel.prototype.destroy = function() {
+ this.remove();
+
+ this._panel.removeEventListener("popuphiding", this._onpopuphiding, true);
+
+ this._panel.removeChild(this._frame);
+ this._toolbar.parentElement.removeChild(this._panel);
+
+ delete this._devtoolbar;
+ delete this._input;
+ delete this._toolbar;
+ delete this._onpopuphiding;
+ delete this._panel;
+ delete this._frame;
+ delete this._content;
+ delete this._div;
+ delete this.document;
+};
+
+/**
+ * Called by GCLI to indicate that we should show or hide one either the
+ * tooltip panel or the output panel.
+ */
+OutputPanel.prototype._visibilityChanged = function(ev) {
+ if (ev.outputVisible === true) {
+ // this.show is called by _outputChanged
+ } else {
+ if (isLinux) {
+ this.canHide = true;
+ }
+ this._panel.hidePopup();
+ }
+};
+
+/**
+ * Creating a TooltipPanel is asynchronous
+ */
+function TooltipPanel() {
+ throw new Error('Use TooltipPanel.create()');
+}
+
+/**
+ * Panel to handle tooltips.
+ *
+ * There is a tooltip bug on Windows and OSX that prevents tooltips from being
+ * positioned properly (bug 786975). There is a Gnome panel bug on Linux that
+ * causes ugly focus issues (https://bugzilla.gnome.org/show_bug.cgi?id=621848).
+ * We now use a tooltip on Linux and a panel on OSX & Windows.
+ *
+ * If a panel has no content and no height it is not shown when openPopup is
+ * called on Windows and OSX (bug 692348) ... this prevents the panel from
+ * appearing the first time it is shown. Setting the panel's height to 1px
+ * before calling openPopup works around this issue as we resize it ourselves
+ * anyway.
+ *
+ * @param devtoolbar The parent DeveloperToolbar object
+ */
+TooltipPanel.create = function(devtoolbar) {
+ var tooltipPanel = Object.create(TooltipPanel.prototype);
+ return tooltipPanel._init(devtoolbar);
+};
+
+/**
+ * @private See TooltipPanel.create
+ */
+TooltipPanel.prototype._init = function(devtoolbar) {
+ let deferred = promise.defer();
+
+ let chromeDocument = devtoolbar._doc;
+ this._input = devtoolbar._doc.querySelector(".gclitoolbar-input-node");
+ this._toolbar = devtoolbar._doc.querySelector("#developer-toolbar");
+ this._dimensions = { start: 0, end: 0 };
+
+ /*
+ <tooltip|panel id="gcli-tooltip"
+ type="arrow"
+ noautofocus="true"
+ noautohide="true"
+ class="gcli-panel">
+ <html:iframe xmlns:html="http://www.w3.org/1999/xhtml"
+ id="gcli-tooltip-frame"
+ src="chrome://browser/content/devtools/commandlinetooltip.xhtml"
+ flex="1"
+ sandbox="allow-same-origin"/>
+ </tooltip|panel>
+ */
+
+ // TODO: Switch back from tooltip to panel when metacity focus issue is fixed:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=780102
+ this._panel = devtoolbar._doc.createElement(isLinux ? "tooltip" : "panel");
+
+ this._panel.id = "gcli-tooltip";
+ this._panel.classList.add("gcli-panel");
+
+ if (isLinux) {
+ this.canHide = false;
+ this._onpopuphiding = this._onpopuphiding.bind(this);
+ this._panel.addEventListener("popuphiding", this._onpopuphiding, true);
+ } else {
+ this._panel.setAttribute("noautofocus", "true");
+ this._panel.setAttribute("noautohide", "true");
+
+ // Bug 692348: On Windows and OSX if a panel has no content and no height
+ // openPopup fails to display it. Setting the height to 1px alows the panel
+ // to be displayed before has content or a real height i.e. the first time
+ // it is displayed.
+ this._panel.setAttribute("height", "1px");
+ }
+
+ this._toolbar.parentElement.insertBefore(this._panel, this._toolbar);
+
+ this._frame = devtoolbar._doc.createElementNS(NS_XHTML, "iframe");
+ this._frame.id = "gcli-tooltip-frame";
+ this._frame.setAttribute("src", "chrome://browser/content/devtools/commandlinetooltip.xhtml");
+ this._frame.setAttribute("flex", "1");
+ this._frame.setAttribute("sandbox", "allow-same-origin");
+ this._panel.appendChild(this._frame);
+
+ /**
+ * Wire up the element from the iframe, and resolve the promise.
+ */
+ let onload = () => {
+ this._frame.removeEventListener("load", onload, true);
+
+ this.document = this._frame.contentDocument;
+ this.hintElement = this.document.getElementById("gcli-tooltip-root");
+ this._connector = this.document.getElementById("gcli-tooltip-connector");
+
+ let styles = this._toolbar.ownerDocument.defaultView
+ .getComputedStyle(this._toolbar);
+ this.hintElement.setAttribute("dir", styles.direction);
+
+ deferred.resolve(this);
+ };
+ this._frame.addEventListener("load", onload, true);
+
+ return deferred.promise;
+}
+
+/**
+ * Prevent the popup from hiding if it is not permitted via this.canHide.
+ */
+TooltipPanel.prototype._onpopuphiding = function(ev) {
+ // TODO: When we switch back from tooltip to panel we can remove this hack:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=780102
+ if (isLinux && !this.canHide) {
+ ev.preventDefault();
+ }
+};
+
+/**
+ * Display the TooltipPanel.
+ */
+TooltipPanel.prototype.show = function(dimensions) {
+ if (!dimensions) {
+ dimensions = { start: 0, end: 0 };
+ }
+ this._dimensions = dimensions;
+
+ // This is nasty, but displaying the panel causes it to re-flow, which can
+ // change the size it should be, so we need to resize the iframe after the
+ // panel has displayed
+ this._panel.ownerDocument.defaultView.setTimeout(() => {
+ this._resize();
+ }, 0);
+
+ if (isLinux) {
+ this.canHide = false;
+ }
+
+ this._resize();
+ this._panel.openPopup(this._input, "before_start", dimensions.start * 10, 0,
+ false, false, null);
+ this._input.focus();
+};
+
+/**
+ * One option is to spend lots of time taking an average width of characters
+ * in the current font, dynamically, and weighting for the frequency of use of
+ * various characters, or even to render the given string off screen, and then
+ * measure the width.
+ * Or we could do this...
+ */
+const AVE_CHAR_WIDTH = 4.5;
+
+/**
+ * Display the TooltipPanel.
+ */
+TooltipPanel.prototype._resize = function() {
+ if (this._panel == null || this.document == null || !this._panel.state == "closed") {
+ return
+ }
+
+ let offset = 10 + Math.floor(this._dimensions.start * AVE_CHAR_WIDTH);
+ this._panel.style.marginLeft = offset + "px";
+
+ /*
+ // Bug 744906: UX review - Not sure if we want this code to fatten connector
+ // with param width
+ let width = Math.floor(this._dimensions.end * AVE_CHAR_WIDTH);
+ width = Math.min(width, 100);
+ width = Math.max(width, 10);
+ this._connector.style.width = width + "px";
+ */
+
+ this._frame.height = this.document.body.scrollHeight;
+};
+
+/**
+ * Hide the TooltipPanel.
+ */
+TooltipPanel.prototype.remove = function() {
+ if (isLinux) {
+ this.canHide = true;
+ }
+ if (this._panel && this._panel.hidePopup) {
+ this._panel.hidePopup();
+ }
+};
+
+/**
+ * Hide the TooltipPanel.
+ */
+TooltipPanel.prototype.destroy = function() {
+ this.remove();
+
+ this._panel.removeEventListener("popuphiding", this._onpopuphiding, true);
+
+ this._panel.removeChild(this._frame);
+ this._toolbar.parentElement.removeChild(this._panel);
+
+ delete this._connector;
+ delete this._dimensions;
+ delete this._input;
+ delete this._onpopuphiding;
+ delete this._panel;
+ delete this._frame;
+ delete this._toolbar;
+ delete this._content;
+ delete this.document;
+ delete this.hintElement;
+};
+
+/**
+ * Called by GCLI to indicate that we should show or hide one either the
+ * tooltip panel or the output panel.
+ */
+TooltipPanel.prototype._visibilityChanged = function(ev) {
+ if (ev.tooltipVisible === true) {
+ this.show(ev.dimensions);
+ } else {
+ if (isLinux) {
+ this.canHide = true;
+ }
+ this._panel.hidePopup();
+ }
+};
diff --git a/toolkit/devtools/shared/Jsbeautify.jsm b/toolkit/devtools/shared/Jsbeautify.jsm
new file mode 100644
index 000000000..438fa6e2e
--- /dev/null
+++ b/toolkit/devtools/shared/Jsbeautify.jsm
@@ -0,0 +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";
+
+/*
+ * JS Beautifier. Please use devtools.require("devtools/jsbeautify") instead of
+ * this JSM.
+ */
+
+this.EXPORTED_SYMBOLS = [ "jsBeautify" ];
+
+const { devtools } = Components.utils.import("resource://gre/modules/devtools/Loader.jsm", {});
+const { beautify } = devtools.require("devtools/jsbeautify");
+const jsBeautify = beautify.js;
diff --git a/toolkit/devtools/shared/Parser.jsm b/toolkit/devtools/shared/Parser.jsm
new file mode 100644
index 000000000..d7f2f7c02
--- /dev/null
+++ b/toolkit/devtools/shared/Parser.jsm
@@ -0,0 +1,2337 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+const { DevToolsUtils } = Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm", {});
+
+XPCOMUtils.defineLazyModuleGetter(this,
+ "Reflect", "resource://gre/modules/reflect.jsm");
+
+this.EXPORTED_SYMBOLS = ["Parser", "ParserHelpers", "SyntaxTreeVisitor"];
+
+/**
+ * A JS parser using the reflection API.
+ */
+this.Parser = function Parser() {
+ this._cache = new Map();
+ this.errors = [];
+};
+
+Parser.prototype = {
+ /**
+ * Gets a collection of parser methods for a specified source.
+ *
+ * @param string aSource
+ * The source text content.
+ * @param string aUrl [optional]
+ * The source url. The AST nodes will be cached, so you can use this
+ * identifier to avoid parsing the whole source again.
+ */
+ get: function(aSource, aUrl = "") {
+ // Try to use the cached AST nodes, to avoid useless parsing operations.
+ if (this._cache.has(aUrl)) {
+ return this._cache.get(aUrl);
+ }
+
+ // The source may not necessarily be JS, in which case we need to extract
+ // all the scripts. Fastest/easiest way is with a regular expression.
+ // Don't worry, the rules of using a <script> tag are really strict,
+ // this will work.
+ let regexp = /<script[^>]*>([^]*?)<\/script\s*>/gim;
+ let syntaxTrees = [];
+ let scriptMatches = [];
+ let scriptMatch;
+
+ if (aSource.match(/^\s*</)) {
+ // First non whitespace character is &lt, so most definitely HTML.
+ while (scriptMatch = regexp.exec(aSource)) {
+ scriptMatches.push(scriptMatch[1]); // Contents are captured at index 1.
+ }
+ }
+
+ // If there are no script matches, send the whole source directly to the
+ // reflection API to generate the AST nodes.
+ if (!scriptMatches.length) {
+ // Reflect.parse throws when encounters a syntax error.
+ try {
+ let nodes = Reflect.parse(aSource);
+ let length = aSource.length;
+ syntaxTrees.push(new SyntaxTree(nodes, aUrl, length));
+ } catch (e) {
+ this.errors.push(e);
+ DevToolsUtils.reportException(aUrl, e);
+ }
+ }
+ // Generate the AST nodes for each script.
+ else {
+ for (let script of scriptMatches) {
+ // Reflect.parse throws when encounters a syntax error.
+ try {
+ let nodes = Reflect.parse(script);
+ let offset = aSource.indexOf(script);
+ let length = script.length;
+ syntaxTrees.push(new SyntaxTree(nodes, aUrl, length, offset));
+ } catch (e) {
+ this.errors.push(e);
+ DevToolsUtils.reportException(aUrl, e);
+ }
+ }
+ }
+
+ let pool = new SyntaxTreesPool(syntaxTrees, aUrl);
+
+ // Cache the syntax trees pool by the specified url. This is entirely
+ // optional, but it's strongly encouraged to cache ASTs because
+ // generating them can be costly with big/complex sources.
+ if (aUrl) {
+ this._cache.set(aUrl, pool);
+ }
+
+ return pool;
+ },
+
+ /**
+ * Clears all the parsed sources from cache.
+ */
+ clearCache: function() {
+ this._cache.clear();
+ },
+
+ /**
+ * Clears the AST for a particular source.
+ *
+ * @param String aUrl
+ * The URL of the source that is being cleared.
+ */
+ clearSource: function(aUrl) {
+ this._cache.delete(aUrl);
+ },
+
+ _cache: null,
+ errors: null
+};
+
+/**
+ * A pool handling a collection of AST nodes generated by the reflection API.
+ *
+ * @param object aSyntaxTrees
+ * A collection of AST nodes generated for a source.
+ * @param string aUrl [optional]
+ * The source url.
+ */
+function SyntaxTreesPool(aSyntaxTrees, aUrl = "<unknown>") {
+ this._trees = aSyntaxTrees;
+ this._url = aUrl;
+ this._cache = new Map();
+}
+
+SyntaxTreesPool.prototype = {
+ /**
+ * @see SyntaxTree.prototype.getIdentifierAt
+ */
+ getIdentifierAt: function({ line, column, scriptIndex, ignoreLiterals }) {
+ return this._call("getIdentifierAt", scriptIndex, line, column, ignoreLiterals)[0];
+ },
+
+ /**
+ * @see SyntaxTree.prototype.getNamedFunctionDefinitions
+ */
+ getNamedFunctionDefinitions: function(aSubstring) {
+ return this._call("getNamedFunctionDefinitions", -1, aSubstring);
+ },
+
+ /**
+ * Gets the total number of scripts in the parent source.
+ * @return number
+ */
+ get scriptCount() {
+ return this._trees.length;
+ },
+
+ /**
+ * Finds the start and length of the script containing the specified offset
+ * relative to its parent source.
+ *
+ * @param number aOffset
+ * The offset relative to the parent source.
+ * @return object
+ * The offset and length relative to the enclosing script.
+ */
+ getScriptInfo: function(aOffset) {
+ let info = { start: -1, length: -1, index: -1 };
+
+ for (let { offset, length } of this._trees) {
+ info.index++;
+ if (offset <= aOffset && offset + length >= aOffset) {
+ info.start = offset;
+ info.length = length;
+ return info;
+ }
+ }
+
+ info.index = -1;
+ return info;
+ },
+
+ /**
+ * Handles a request for a specific or all known syntax trees.
+ *
+ * @param string aFunction
+ * The function name to call on the SyntaxTree instances.
+ * @param number aSyntaxTreeIndex
+ * The syntax tree for which to handle the request. If the tree at
+ * the specified index isn't found, the accumulated results for all
+ * syntax trees are returned.
+ * @param any aParams
+ * Any kind params to pass to the request function.
+ * @return array
+ * The results given by all known syntax trees.
+ */
+ _call: function(aFunction, aSyntaxTreeIndex, ...aParams) {
+ let results = [];
+ let requestId = [aFunction, aSyntaxTreeIndex, aParams].toSource();
+
+ if (this._cache.has(requestId)) {
+ return this._cache.get(requestId);
+ }
+
+ let requestedTree = this._trees[aSyntaxTreeIndex];
+ let targettedTrees = requestedTree ? [requestedTree] : this._trees;
+
+ for (let syntaxTree of targettedTrees) {
+ try {
+ let parseResults = syntaxTree[aFunction].apply(syntaxTree, aParams);
+ if (parseResults) {
+ parseResults.sourceUrl = syntaxTree.url;
+ parseResults.scriptLength = syntaxTree.length;
+ parseResults.scriptOffset = syntaxTree.offset;
+ results.push(parseResults);
+ }
+ } catch (e) {
+ // Can't guarantee that the tree traversal logic is forever perfect :)
+ // Language features may be added, in which case the recursive methods
+ // need to be updated. If an exception is thrown here, file a bug.
+ DevToolsUtils.reportException("Syntax tree visitor for " + this._url, e);
+ }
+ }
+ this._cache.set(requestId, results);
+ return results;
+ },
+
+ _trees: null,
+ _cache: null
+};
+
+/**
+ * A collection of AST nodes generated by the reflection API.
+ *
+ * @param object aNodes
+ * The AST nodes.
+ * @param string aUrl
+ * The source url.
+ * @param number aLength
+ * The total number of chars of the parsed script in the parent source.
+ * @param number aOffset [optional]
+ * The char offset of the parsed script in the parent source.
+ */
+function SyntaxTree(aNodes, aUrl, aLength, aOffset = 0) {
+ this.AST = aNodes;
+ this.url = aUrl;
+ this.length = aLength;
+ this.offset = aOffset;
+};
+
+SyntaxTree.prototype = {
+ /**
+ * Gets the identifier at the specified location.
+ *
+ * @param number aLine
+ * The line in the source.
+ * @param number aColumn
+ * The column in the source.
+ * @param boolean aIgnoreLiterals
+ * Specifies if alone literals should be ignored.
+ * @return object
+ * An object containing identifier information as { name, location,
+ * evalString } properties, or null if nothing is found.
+ */
+ getIdentifierAt: function(aLine, aColumn, aIgnoreLiterals) {
+ let info = null;
+
+ SyntaxTreeVisitor.walk(this.AST, {
+ /**
+ * Callback invoked for each identifier node.
+ * @param Node aNode
+ */
+ onIdentifier: function(aNode) {
+ if (ParserHelpers.nodeContainsPoint(aNode, aLine, aColumn)) {
+ info = {
+ name: aNode.name,
+ location: ParserHelpers.getNodeLocation(aNode),
+ evalString: ParserHelpers.getIdentifierEvalString(aNode)
+ };
+
+ // Abruptly halt walking the syntax tree.
+ SyntaxTreeVisitor.break = true;
+ }
+ },
+
+ /**
+ * Callback invoked for each literal node.
+ * @param Node aNode
+ */
+ onLiteral: function(aNode) {
+ if (!aIgnoreLiterals) {
+ this.onIdentifier(aNode);
+ }
+ },
+
+ /**
+ * Callback invoked for each 'this' node.
+ * @param Node aNode
+ */
+ onThisExpression: function(aNode) {
+ this.onIdentifier(aNode);
+ }
+ });
+
+ return info;
+ },
+
+ /**
+ * Searches for all function definitions (declarations and expressions)
+ * whose names (or inferred names) contain a string.
+ *
+ * @param string aSubstring
+ * The string to be contained in the function name (or inferred name).
+ * Can be an empty string to match all functions.
+ * @return array
+ * All the matching function declarations and expressions, as
+ * { functionName, functionLocation ... } object hashes.
+ */
+ getNamedFunctionDefinitions: function(aSubstring) {
+ let lowerCaseToken = aSubstring.toLowerCase();
+ let store = [];
+
+ SyntaxTreeVisitor.walk(this.AST, {
+ /**
+ * Callback invoked for each function declaration node.
+ * @param Node aNode
+ */
+ onFunctionDeclaration: function(aNode) {
+ let functionName = aNode.id.name;
+ if (functionName.toLowerCase().includes(lowerCaseToken)) {
+ store.push({
+ functionName: functionName,
+ functionLocation: ParserHelpers.getNodeLocation(aNode)
+ });
+ }
+ },
+
+ /**
+ * Callback invoked for each function expression node.
+ * @param Node aNode
+ */
+ onFunctionExpression: function(aNode) {
+ // Function expressions don't necessarily have a name.
+ let functionName = aNode.id ? aNode.id.name : "";
+ let functionLocation = ParserHelpers.getNodeLocation(aNode);
+
+ // Infer the function's name from an enclosing syntax tree node.
+ let inferredInfo = ParserHelpers.inferFunctionExpressionInfo(aNode);
+ let inferredName = inferredInfo.name;
+ let inferredChain = inferredInfo.chain;
+ let inferredLocation = inferredInfo.loc;
+
+ // Current node may be part of a larger assignment expression stack.
+ if (aNode._parent.type == "AssignmentExpression") {
+ this.onFunctionExpression(aNode._parent);
+ }
+
+ if ((functionName && functionName.toLowerCase().includes(lowerCaseToken)) ||
+ (inferredName && inferredName.toLowerCase().includes(lowerCaseToken))) {
+ store.push({
+ functionName: functionName,
+ functionLocation: functionLocation,
+ inferredName: inferredName,
+ inferredChain: inferredChain,
+ inferredLocation: inferredLocation
+ });
+ }
+ },
+
+ /**
+ * Callback invoked for each arrow expression node.
+ * @param Node aNode
+ */
+ onArrowExpression: function(aNode) {
+ // Infer the function's name from an enclosing syntax tree node.
+ let inferredInfo = ParserHelpers.inferFunctionExpressionInfo(aNode);
+ let inferredName = inferredInfo.name;
+ let inferredChain = inferredInfo.chain;
+ let inferredLocation = inferredInfo.loc;
+
+ // Current node may be part of a larger assignment expression stack.
+ if (aNode._parent.type == "AssignmentExpression") {
+ this.onFunctionExpression(aNode._parent);
+ }
+
+ if (inferredName && inferredName.toLowerCase().includes(lowerCaseToken)) {
+ store.push({
+ inferredName: inferredName,
+ inferredChain: inferredChain,
+ inferredLocation: inferredLocation
+ });
+ }
+ }
+ });
+
+ return store;
+ },
+
+ AST: null,
+ url: "",
+ length: 0,
+ offset: 0
+};
+
+/**
+ * Parser utility methods.
+ */
+let ParserHelpers = {
+ /**
+ * Gets the location information for a node. Not all nodes have a
+ * location property directly attached, or the location information
+ * is incorrect, in which cases it's accessible via the parent.
+ *
+ * @param Node aNode
+ * The node who's location needs to be retrieved.
+ * @return object
+ * An object containing { line, column } information.
+ */
+ getNodeLocation: function(aNode) {
+ if (aNode.type != "Identifier") {
+ return aNode.loc;
+ }
+ // Work around the fact that some identifier nodes don't have the
+ // correct location attached.
+ let { loc: parentLocation, type: parentType } = aNode._parent;
+ let { loc: nodeLocation } = aNode;
+ if (!nodeLocation) {
+ if (parentType == "FunctionDeclaration" ||
+ parentType == "FunctionExpression") {
+ // e.g. "function foo() {}" or "{ bar: function foo() {} }"
+ // The location is unavailable for the identifier node "foo".
+ let loc = Cu.cloneInto(parentLocation, {});
+ loc.end.line = loc.start.line;
+ loc.end.column = loc.start.column + aNode.name.length;
+ return loc;
+ }
+ if (parentType == "MemberExpression") {
+ // e.g. "foo.bar"
+ // The location is unavailable for the identifier node "bar".
+ let loc = Cu.cloneInto(parentLocation, {});
+ loc.start.line = loc.end.line;
+ loc.start.column = loc.end.column - aNode.name.length;
+ return loc;
+ }
+ if (parentType == "LabeledStatement") {
+ // e.g. label: ...
+ // The location is unavailable for the identifier node "label".
+ let loc = Cu.cloneInto(parentLocation, {});
+ loc.end.line = loc.start.line;
+ loc.end.column = loc.start.column + aNode.name.length;
+ return loc;
+ }
+ if (parentType == "ContinueStatement" || parentType == "BreakStatement") {
+ // e.g. continue label; or break label;
+ // The location is unavailable for the identifier node "label".
+ let loc = Cu.cloneInto(parentLocation, {});
+ loc.start.line = loc.end.line;
+ loc.start.column = loc.end.column - aNode.name.length;
+ return loc;
+ }
+ } else {
+ if (parentType == "VariableDeclarator") {
+ // e.g. "let foo = 42"
+ // The location incorrectly spans across the whole variable declaration,
+ // not just the identifier node "foo".
+ let loc = Cu.cloneInto(nodeLocation, {});
+ loc.end.line = loc.start.line;
+ loc.end.column = loc.start.column + aNode.name.length;
+ return loc;
+ }
+ }
+ return aNode.loc;
+ },
+
+ /**
+ * Checks if a node's bounds contains a specified line.
+ *
+ * @param Node aNode
+ * The node's bounds used as reference.
+ * @param number aLine
+ * The line number to check.
+ * @return boolean
+ * True if the line and column is contained in the node's bounds.
+ */
+ nodeContainsLine: function(aNode, aLine) {
+ let { start: s, end: e } = this.getNodeLocation(aNode);
+ return s.line <= aLine && e.line >= aLine;
+ },
+
+ /**
+ * Checks if a node's bounds contains a specified line and column.
+ *
+ * @param Node aNode
+ * The node's bounds used as reference.
+ * @param number aLine
+ * The line number to check.
+ * @param number aColumn
+ * The column number to check.
+ * @return boolean
+ * True if the line and column is contained in the node's bounds.
+ */
+ nodeContainsPoint: function(aNode, aLine, aColumn) {
+ let { start: s, end: e } = this.getNodeLocation(aNode);
+ return s.line == aLine && e.line == aLine &&
+ s.column <= aColumn && e.column >= aColumn;
+ },
+
+ /**
+ * Try to infer a function expression's name & other details based on the
+ * enclosing VariableDeclarator, AssignmentExpression or ObjectExpression.
+ *
+ * @param Node aNode
+ * The function expression node to get the name for.
+ * @return object
+ * The inferred function name, or empty string can't infer the name,
+ * along with the chain (a generic "context", like a prototype chain)
+ * and location if available.
+ */
+ inferFunctionExpressionInfo: function(aNode) {
+ let parent = aNode._parent;
+
+ // A function expression may be defined in a variable declarator,
+ // e.g. var foo = function(){}, in which case it is possible to infer
+ // the variable name.
+ if (parent.type == "VariableDeclarator") {
+ return {
+ name: parent.id.name,
+ chain: null,
+ loc: this.getNodeLocation(parent.id)
+ };
+ }
+
+ // Function expressions can also be defined in assignment expressions,
+ // e.g. foo = function(){} or foo.bar = function(){}, in which case it is
+ // possible to infer the assignee name ("foo" and "bar" respectively).
+ if (parent.type == "AssignmentExpression") {
+ let propertyChain = this._getMemberExpressionPropertyChain(parent.left);
+ let propertyLeaf = propertyChain.pop();
+ return {
+ name: propertyLeaf,
+ chain: propertyChain,
+ loc: this.getNodeLocation(parent.left)
+ };
+ }
+
+ // If a function expression is defined in an object expression,
+ // e.g. { foo: function(){} }, then it is possible to infer the name
+ // from the corresponding property.
+ if (parent.type == "ObjectExpression") {
+ let propertyKey = this._getObjectExpressionPropertyKeyForValue(aNode);
+ let propertyChain = this._getObjectExpressionPropertyChain(parent);
+ let propertyLeaf = propertyKey.name;
+ return {
+ name: propertyLeaf,
+ chain: propertyChain,
+ loc: this.getNodeLocation(propertyKey)
+ };
+ }
+
+ // Can't infer the function expression's name.
+ return {
+ name: "",
+ chain: null,
+ loc: null
+ };
+ },
+
+ /**
+ * Gets the name of an object expression's property to which a specified
+ * value is assigned.
+ *
+ * Used for inferring function expression information and retrieving
+ * an identifier evaluation string.
+ *
+ * For example, if aNode represents the "bar" identifier in a hypothetical
+ * "{ foo: bar }" object expression, the returned node is the "foo" identifier.
+ *
+ * @param Node aNode
+ * The value node in an object expression.
+ * @return object
+ * The key identifier node in the object expression.
+ */
+ _getObjectExpressionPropertyKeyForValue: function(aNode) {
+ let parent = aNode._parent;
+ if (parent.type != "ObjectExpression") {
+ return null;
+ }
+ for (let property of parent.properties) {
+ if (property.value == aNode) {
+ return property.key;
+ }
+ }
+ },
+
+ /**
+ * Gets an object expression's property chain to its parent
+ * variable declarator or assignment expression, if available.
+ *
+ * Used for inferring function expression information and retrieving
+ * an identifier evaluation string.
+ *
+ * For example, if aNode represents the "baz: {}" object expression in a
+ * hypothetical "foo = { bar: { baz: {} } }" assignment expression, the
+ * returned chain is ["foo", "bar", "baz"].
+ *
+ * @param Node aNode
+ * The object expression node to begin the scan from.
+ * @param array aStore [optional]
+ * The chain to store the nodes into.
+ * @return array
+ * The chain to the parent variable declarator, as strings.
+ */
+ _getObjectExpressionPropertyChain: function(aNode, aStore = []) {
+ switch (aNode.type) {
+ case "ObjectExpression":
+ this._getObjectExpressionPropertyChain(aNode._parent, aStore);
+ let propertyKey = this._getObjectExpressionPropertyKeyForValue(aNode);
+ if (propertyKey) {
+ aStore.push(propertyKey.name);
+ }
+ break;
+ // Handle "var foo = { ... }" variable declarators.
+ case "VariableDeclarator":
+ aStore.push(aNode.id.name);
+ break;
+ // Handle "foo.bar = { ... }" assignment expressions, since they're
+ // commonly used when defining an object's prototype methods; e.g:
+ // "Foo.prototype = { ... }".
+ case "AssignmentExpression":
+ this._getMemberExpressionPropertyChain(aNode.left, aStore);
+ break;
+ // Additionally handle stuff like "foo = bar.baz({ ... })", because it's
+ // commonly used in prototype-based inheritance in many libraries; e.g:
+ // "Foo = Bar.extend({ ... })".
+ case "NewExpression":
+ case "CallExpression":
+ this._getObjectExpressionPropertyChain(aNode._parent, aStore);
+ break;
+ }
+ return aStore;
+ },
+
+ /**
+ * Gets a member expression's property chain.
+ *
+ * Used for inferring function expression information and retrieving
+ * an identifier evaluation string.
+ *
+ * For example, if aNode represents a hypothetical "foo.bar.baz"
+ * member expression, the returned chain ["foo", "bar", "baz"].
+ *
+ * More complex expressions like foo.bar().baz are intentionally not handled.
+ *
+ * @param Node aNode
+ * The member expression node to begin the scan from.
+ * @param array aStore [optional]
+ * The chain to store the nodes into.
+ * @return array
+ * The full member chain, as strings.
+ */
+ _getMemberExpressionPropertyChain: function(aNode, aStore = []) {
+ switch (aNode.type) {
+ case "MemberExpression":
+ this._getMemberExpressionPropertyChain(aNode.object, aStore);
+ this._getMemberExpressionPropertyChain(aNode.property, aStore);
+ break;
+ case "ThisExpression":
+ aStore.push("this");
+ break;
+ case "Identifier":
+ aStore.push(aNode.name);
+ break;
+ }
+ return aStore;
+ },
+
+ /**
+ * Returns an evaluation string which can be used to obtain the
+ * current value for the respective identifier.
+ *
+ * @param Node aNode
+ * The leaf node (e.g. Identifier, Literal) to begin the scan from.
+ * @return string
+ * The corresponding evaluation string, or empty string if
+ * the specified leaf node can't be used.
+ */
+ getIdentifierEvalString: function(aNode) {
+ switch (aNode._parent.type) {
+ case "ObjectExpression":
+ // If the identifier is the actual property value, it can be used
+ // directly as an evaluation string. Otherwise, construct the property
+ // access chain, since the value might have changed.
+ if (!this._getObjectExpressionPropertyKeyForValue(aNode)) {
+ let propertyChain = this._getObjectExpressionPropertyChain(aNode._parent);
+ let propertyLeaf = aNode.name;
+ return [...propertyChain, propertyLeaf].join(".");
+ }
+ break;
+ case "MemberExpression":
+ // Make sure this is a property identifier, not the parent object.
+ if (aNode._parent.property == aNode) {
+ return this._getMemberExpressionPropertyChain(aNode._parent).join(".");
+ }
+ break;
+ }
+ switch (aNode.type) {
+ case "ThisExpression":
+ return "this";
+ case "Identifier":
+ return aNode.name;
+ case "Literal":
+ return uneval(aNode.value);
+ default:
+ return "";
+ }
+ }
+};
+
+/**
+ * A visitor for a syntax tree generated by the reflection API.
+ * See https://developer.mozilla.org/en-US/docs/SpiderMonkey/Parser_API.
+ *
+ * All node types implement the following interface:
+ * interface Node {
+ * type: string;
+ * loc: SourceLocation | null;
+ * }
+ */
+let SyntaxTreeVisitor = {
+ /**
+ * Walks a syntax tree.
+ *
+ * @param object aTree
+ * The AST nodes generated by the reflection API
+ * @param object aCallbacks
+ * A map of all the callbacks to invoke when passing through certain
+ * types of noes (e.g: onFunctionDeclaration, onBlockStatement etc.).
+ */
+ walk: function(aTree, aCallbacks) {
+ this.break = false;
+ this[aTree.type](aTree, aCallbacks);
+ },
+
+ /**
+ * Filters all the nodes in this syntax tree based on a predicate.
+ *
+ * @param object aTree
+ * The AST nodes generated by the reflection API
+ * @param function aPredicate
+ * The predicate ran on each node.
+ * @return array
+ * An array of nodes validating the predicate.
+ */
+ filter: function(aTree, aPredicate) {
+ let store = [];
+ this.walk(aTree, { onNode: e => { if (aPredicate(e)) store.push(e); } });
+ return store;
+ },
+
+ /**
+ * A flag checked on each node in the syntax tree. If true, walking is
+ * abruptly halted.
+ */
+ break: false,
+
+ /**
+ * A complete program source tree.
+ *
+ * interface Program <: Node {
+ * type: "Program";
+ * body: [ Statement ];
+ * }
+ */
+ Program: function(aNode, aCallbacks) {
+ if (aCallbacks.onProgram) {
+ aCallbacks.onProgram(aNode);
+ }
+ for (let statement of aNode.body) {
+ this[statement.type](statement, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * Any statement.
+ *
+ * interface Statement <: Node { }
+ */
+ Statement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onStatement) {
+ aCallbacks.onStatement(aNode);
+ }
+ },
+
+ /**
+ * An empty statement, i.e., a solitary semicolon.
+ *
+ * interface EmptyStatement <: Statement {
+ * type: "EmptyStatement";
+ * }
+ */
+ EmptyStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onEmptyStatement) {
+ aCallbacks.onEmptyStatement(aNode);
+ }
+ },
+
+ /**
+ * A block statement, i.e., a sequence of statements surrounded by braces.
+ *
+ * interface BlockStatement <: Statement {
+ * type: "BlockStatement";
+ * body: [ Statement ];
+ * }
+ */
+ BlockStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onBlockStatement) {
+ aCallbacks.onBlockStatement(aNode);
+ }
+ for (let statement of aNode.body) {
+ this[statement.type](statement, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * An expression statement, i.e., a statement consisting of a single expression.
+ *
+ * interface ExpressionStatement <: Statement {
+ * type: "ExpressionStatement";
+ * expression: Expression;
+ * }
+ */
+ ExpressionStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onExpressionStatement) {
+ aCallbacks.onExpressionStatement(aNode);
+ }
+ this[aNode.expression.type](aNode.expression, aNode, aCallbacks);
+ },
+
+ /**
+ * An if statement.
+ *
+ * interface IfStatement <: Statement {
+ * type: "IfStatement";
+ * test: Expression;
+ * consequent: Statement;
+ * alternate: Statement | null;
+ * }
+ */
+ IfStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onIfStatement) {
+ aCallbacks.onIfStatement(aNode);
+ }
+ this[aNode.test.type](aNode.test, aNode, aCallbacks);
+ this[aNode.consequent.type](aNode.consequent, aNode, aCallbacks);
+ if (aNode.alternate) {
+ this[aNode.alternate.type](aNode.alternate, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A labeled statement, i.e., a statement prefixed by a break/continue label.
+ *
+ * interface LabeledStatement <: Statement {
+ * type: "LabeledStatement";
+ * label: Identifier;
+ * body: Statement;
+ * }
+ */
+ LabeledStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onLabeledStatement) {
+ aCallbacks.onLabeledStatement(aNode);
+ }
+ this[aNode.label.type](aNode.label, aNode, aCallbacks);
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * A break statement.
+ *
+ * interface BreakStatement <: Statement {
+ * type: "BreakStatement";
+ * label: Identifier | null;
+ * }
+ */
+ BreakStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onBreakStatement) {
+ aCallbacks.onBreakStatement(aNode);
+ }
+ if (aNode.label) {
+ this[aNode.label.type](aNode.label, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A continue statement.
+ *
+ * interface ContinueStatement <: Statement {
+ * type: "ContinueStatement";
+ * label: Identifier | null;
+ * }
+ */
+ ContinueStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onContinueStatement) {
+ aCallbacks.onContinueStatement(aNode);
+ }
+ if (aNode.label) {
+ this[aNode.label.type](aNode.label, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A with statement.
+ *
+ * interface WithStatement <: Statement {
+ * type: "WithStatement";
+ * object: Expression;
+ * body: Statement;
+ * }
+ */
+ WithStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onWithStatement) {
+ aCallbacks.onWithStatement(aNode);
+ }
+ this[aNode.object.type](aNode.object, aNode, aCallbacks);
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * A switch statement. The lexical flag is metadata indicating whether the
+ * switch statement contains any unnested let declarations (and therefore
+ * introduces a new lexical scope).
+ *
+ * interface SwitchStatement <: Statement {
+ * type: "SwitchStatement";
+ * discriminant: Expression;
+ * cases: [ SwitchCase ];
+ * lexical: boolean;
+ * }
+ */
+ SwitchStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onSwitchStatement) {
+ aCallbacks.onSwitchStatement(aNode);
+ }
+ this[aNode.discriminant.type](aNode.discriminant, aNode, aCallbacks);
+ for (let _case of aNode.cases) {
+ this[_case.type](_case, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A return statement.
+ *
+ * interface ReturnStatement <: Statement {
+ * type: "ReturnStatement";
+ * argument: Expression | null;
+ * }
+ */
+ ReturnStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onReturnStatement) {
+ aCallbacks.onReturnStatement(aNode);
+ }
+ if (aNode.argument) {
+ this[aNode.argument.type](aNode.argument, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A throw statement.
+ *
+ * interface ThrowStatement <: Statement {
+ * type: "ThrowStatement";
+ * argument: Expression;
+ * }
+ */
+ ThrowStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onThrowStatement) {
+ aCallbacks.onThrowStatement(aNode);
+ }
+ this[aNode.argument.type](aNode.argument, aNode, aCallbacks);
+ },
+
+ /**
+ * A try statement.
+ *
+ * interface TryStatement <: Statement {
+ * type: "TryStatement";
+ * block: BlockStatement;
+ * handler: CatchClause | null;
+ * guardedHandlers: [ CatchClause ];
+ * finalizer: BlockStatement | null;
+ * }
+ */
+ TryStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onTryStatement) {
+ aCallbacks.onTryStatement(aNode);
+ }
+ this[aNode.block.type](aNode.block, aNode, aCallbacks);
+ if (aNode.handler) {
+ this[aNode.handler.type](aNode.handler, aNode, aCallbacks);
+ }
+ for (let guardedHandler of aNode.guardedHandlers) {
+ this[guardedHandler.type](guardedHandler, aNode, aCallbacks);
+ }
+ if (aNode.finalizer) {
+ this[aNode.finalizer.type](aNode.finalizer, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A while statement.
+ *
+ * interface WhileStatement <: Statement {
+ * type: "WhileStatement";
+ * test: Expression;
+ * body: Statement;
+ * }
+ */
+ WhileStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onWhileStatement) {
+ aCallbacks.onWhileStatement(aNode);
+ }
+ this[aNode.test.type](aNode.test, aNode, aCallbacks);
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * A do/while statement.
+ *
+ * interface DoWhileStatement <: Statement {
+ * type: "DoWhileStatement";
+ * body: Statement;
+ * test: Expression;
+ * }
+ */
+ DoWhileStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onDoWhileStatement) {
+ aCallbacks.onDoWhileStatement(aNode);
+ }
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ this[aNode.test.type](aNode.test, aNode, aCallbacks);
+ },
+
+ /**
+ * A for statement.
+ *
+ * interface ForStatement <: Statement {
+ * type: "ForStatement";
+ * init: VariableDeclaration | Expression | null;
+ * test: Expression | null;
+ * update: Expression | null;
+ * body: Statement;
+ * }
+ */
+ ForStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onForStatement) {
+ aCallbacks.onForStatement(aNode);
+ }
+ if (aNode.init) {
+ this[aNode.init.type](aNode.init, aNode, aCallbacks);
+ }
+ if (aNode.test) {
+ this[aNode.test.type](aNode.test, aNode, aCallbacks);
+ }
+ if (aNode.update) {
+ this[aNode.update.type](aNode.update, aNode, aCallbacks);
+ }
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * A for/in statement, or, if each is true, a for each/in statement.
+ *
+ * interface ForInStatement <: Statement {
+ * type: "ForInStatement";
+ * left: VariableDeclaration | Expression;
+ * right: Expression;
+ * body: Statement;
+ * each: boolean;
+ * }
+ */
+ ForInStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onForInStatement) {
+ aCallbacks.onForInStatement(aNode);
+ }
+ this[aNode.left.type](aNode.left, aNode, aCallbacks);
+ this[aNode.right.type](aNode.right, aNode, aCallbacks);
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * A for/of statement.
+ *
+ * interface ForOfStatement <: Statement {
+ * type: "ForOfStatement";
+ * left: VariableDeclaration | Expression;
+ * right: Expression;
+ * body: Statement;
+ * }
+ */
+ ForOfStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onForOfStatement) {
+ aCallbacks.onForOfStatement(aNode);
+ }
+ this[aNode.left.type](aNode.left, aNode, aCallbacks);
+ this[aNode.right.type](aNode.right, aNode, aCallbacks);
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * A let statement.
+ *
+ * interface LetStatement <: Statement {
+ * type: "LetStatement";
+ * head: [ { id: Pattern, init: Expression | null } ];
+ * body: Statement;
+ * }
+ */
+ LetStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onLetStatement) {
+ aCallbacks.onLetStatement(aNode);
+ }
+ for (let { id, init } of aNode.head) {
+ this[id.type](id, aNode, aCallbacks);
+ if (init) {
+ this[init.type](init, aNode, aCallbacks);
+ }
+ }
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * A debugger statement.
+ *
+ * interface DebuggerStatement <: Statement {
+ * type: "DebuggerStatement";
+ * }
+ */
+ DebuggerStatement: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onDebuggerStatement) {
+ aCallbacks.onDebuggerStatement(aNode);
+ }
+ },
+
+ /**
+ * Any declaration node. Note that declarations are considered statements;
+ * this is because declarations can appear in any statement context in the
+ * language recognized by the SpiderMonkey parser.
+ *
+ * interface Declaration <: Statement { }
+ */
+ Declaration: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onDeclaration) {
+ aCallbacks.onDeclaration(aNode);
+ }
+ },
+
+ /**
+ * A function declaration.
+ *
+ * interface FunctionDeclaration <: Function, Declaration {
+ * type: "FunctionDeclaration";
+ * id: Identifier;
+ * params: [ Pattern ];
+ * defaults: [ Expression ];
+ * rest: Identifier | null;
+ * body: BlockStatement | Expression;
+ * generator: boolean;
+ * expression: boolean;
+ * }
+ */
+ FunctionDeclaration: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onFunctionDeclaration) {
+ aCallbacks.onFunctionDeclaration(aNode);
+ }
+ this[aNode.id.type](aNode.id, aNode, aCallbacks);
+ for (let param of aNode.params) {
+ this[param.type](param, aNode, aCallbacks);
+ }
+ for (let _default of aNode.defaults) {
+ this[_default.type](_default, aNode, aCallbacks);
+ }
+ if (aNode.rest) {
+ this[aNode.rest.type](aNode.rest, aNode, aCallbacks);
+ }
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * A variable declaration, via one of var, let, or const.
+ *
+ * interface VariableDeclaration <: Declaration {
+ * type: "VariableDeclaration";
+ * declarations: [ VariableDeclarator ];
+ * kind: "var" | "let" | "const";
+ * }
+ */
+ VariableDeclaration: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onVariableDeclaration) {
+ aCallbacks.onVariableDeclaration(aNode);
+ }
+ for (let declaration of aNode.declarations) {
+ this[declaration.type](declaration, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A variable declarator.
+ *
+ * interface VariableDeclarator <: Node {
+ * type: "VariableDeclarator";
+ * id: Pattern;
+ * init: Expression | null;
+ * }
+ */
+ VariableDeclarator: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onVariableDeclarator) {
+ aCallbacks.onVariableDeclarator(aNode);
+ }
+ this[aNode.id.type](aNode.id, aNode, aCallbacks);
+ if (aNode.init) {
+ this[aNode.init.type](aNode.init, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * Any expression node. Since the left-hand side of an assignment may be any
+ * expression in general, an expression can also be a pattern.
+ *
+ * interface Expression <: Node, Pattern { }
+ */
+ Expression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onExpression) {
+ aCallbacks.onExpression(aNode);
+ }
+ },
+
+ /**
+ * A this expression.
+ *
+ * interface ThisExpression <: Expression {
+ * type: "ThisExpression";
+ * }
+ */
+ ThisExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onThisExpression) {
+ aCallbacks.onThisExpression(aNode);
+ }
+ },
+
+ /**
+ * An array expression.
+ *
+ * interface ArrayExpression <: Expression {
+ * type: "ArrayExpression";
+ * elements: [ Expression | null ];
+ * }
+ */
+ ArrayExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onArrayExpression) {
+ aCallbacks.onArrayExpression(aNode);
+ }
+ for (let element of aNode.elements) {
+ // TODO: remove the typeof check when support for SpreadExpression is
+ // added (bug 890913).
+ if (element && typeof this[element.type] == "function") {
+ this[element.type](element, aNode, aCallbacks);
+ }
+ }
+ },
+
+ /**
+ * An object expression. A literal property in an object expression can have
+ * either a string or number as its value. Ordinary property initializers
+ * have a kind value "init"; getters and setters have the kind values "get"
+ * and "set", respectively.
+ *
+ * interface ObjectExpression <: Expression {
+ * type: "ObjectExpression";
+ * properties: [ { key: Literal | Identifier,
+ * value: Expression,
+ * kind: "init" | "get" | "set" } ];
+ * }
+ */
+ ObjectExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onObjectExpression) {
+ aCallbacks.onObjectExpression(aNode);
+ }
+ for (let { key, value } of aNode.properties) {
+ this[key.type](key, aNode, aCallbacks);
+ this[value.type](value, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A function expression.
+ *
+ * interface FunctionExpression <: Function, Expression {
+ * type: "FunctionExpression";
+ * id: Identifier | null;
+ * params: [ Pattern ];
+ * defaults: [ Expression ];
+ * rest: Identifier | null;
+ * body: BlockStatement | Expression;
+ * generator: boolean;
+ * expression: boolean;
+ * }
+ */
+ FunctionExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onFunctionExpression) {
+ aCallbacks.onFunctionExpression(aNode);
+ }
+ if (aNode.id) {
+ this[aNode.id.type](aNode.id, aNode, aCallbacks);
+ }
+ for (let param of aNode.params) {
+ this[param.type](param, aNode, aCallbacks);
+ }
+ for (let _default of aNode.defaults) {
+ this[_default.type](_default, aNode, aCallbacks);
+ }
+ if (aNode.rest) {
+ this[aNode.rest.type](aNode.rest, aNode, aCallbacks);
+ }
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * An arrow expression.
+ *
+ * interface ArrowExpression <: Function, Expression {
+ * type: "ArrowExpression";
+ * params: [ Pattern ];
+ * defaults: [ Expression ];
+ * rest: Identifier | null;
+ * body: BlockStatement | Expression;
+ * generator: boolean;
+ * expression: boolean;
+ * }
+ */
+ ArrowExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onArrowExpression) {
+ aCallbacks.onArrowExpression(aNode);
+ }
+ for (let param of aNode.params) {
+ this[param.type](param, aNode, aCallbacks);
+ }
+ for (let _default of aNode.defaults) {
+ this[_default.type](_default, aNode, aCallbacks);
+ }
+ if (aNode.rest) {
+ this[aNode.rest.type](aNode.rest, aNode, aCallbacks);
+ }
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * A sequence expression, i.e., a comma-separated sequence of expressions.
+ *
+ * interface SequenceExpression <: Expression {
+ * type: "SequenceExpression";
+ * expressions: [ Expression ];
+ * }
+ */
+ SequenceExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onSequenceExpression) {
+ aCallbacks.onSequenceExpression(aNode);
+ }
+ for (let expression of aNode.expressions) {
+ this[expression.type](expression, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A unary operator expression.
+ *
+ * interface UnaryExpression <: Expression {
+ * type: "UnaryExpression";
+ * operator: UnaryOperator;
+ * prefix: boolean;
+ * argument: Expression;
+ * }
+ */
+ UnaryExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onUnaryExpression) {
+ aCallbacks.onUnaryExpression(aNode);
+ }
+ this[aNode.argument.type](aNode.argument, aNode, aCallbacks);
+ },
+
+ /**
+ * A binary operator expression.
+ *
+ * interface BinaryExpression <: Expression {
+ * type: "BinaryExpression";
+ * operator: BinaryOperator;
+ * left: Expression;
+ * right: Expression;
+ * }
+ */
+ BinaryExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onBinaryExpression) {
+ aCallbacks.onBinaryExpression(aNode);
+ }
+ this[aNode.left.type](aNode.left, aNode, aCallbacks);
+ this[aNode.right.type](aNode.right, aNode, aCallbacks);
+ },
+
+ /**
+ * An assignment operator expression.
+ *
+ * interface AssignmentExpression <: Expression {
+ * type: "AssignmentExpression";
+ * operator: AssignmentOperator;
+ * left: Expression;
+ * right: Expression;
+ * }
+ */
+ AssignmentExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onAssignmentExpression) {
+ aCallbacks.onAssignmentExpression(aNode);
+ }
+ this[aNode.left.type](aNode.left, aNode, aCallbacks);
+ this[aNode.right.type](aNode.right, aNode, aCallbacks);
+ },
+
+ /**
+ * An update (increment or decrement) operator expression.
+ *
+ * interface UpdateExpression <: Expression {
+ * type: "UpdateExpression";
+ * operator: UpdateOperator;
+ * argument: Expression;
+ * prefix: boolean;
+ * }
+ */
+ UpdateExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onUpdateExpression) {
+ aCallbacks.onUpdateExpression(aNode);
+ }
+ this[aNode.argument.type](aNode.argument, aNode, aCallbacks);
+ },
+
+ /**
+ * A logical operator expression.
+ *
+ * interface LogicalExpression <: Expression {
+ * type: "LogicalExpression";
+ * operator: LogicalOperator;
+ * left: Expression;
+ * right: Expression;
+ * }
+ */
+ LogicalExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onLogicalExpression) {
+ aCallbacks.onLogicalExpression(aNode);
+ }
+ this[aNode.left.type](aNode.left, aNode, aCallbacks);
+ this[aNode.right.type](aNode.right, aNode, aCallbacks);
+ },
+
+ /**
+ * A conditional expression, i.e., a ternary ?/: expression.
+ *
+ * interface ConditionalExpression <: Expression {
+ * type: "ConditionalExpression";
+ * test: Expression;
+ * alternate: Expression;
+ * consequent: Expression;
+ * }
+ */
+ ConditionalExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onConditionalExpression) {
+ aCallbacks.onConditionalExpression(aNode);
+ }
+ this[aNode.test.type](aNode.test, aNode, aCallbacks);
+ this[aNode.alternate.type](aNode.alternate, aNode, aCallbacks);
+ this[aNode.consequent.type](aNode.consequent, aNode, aCallbacks);
+ },
+
+ /**
+ * A new expression.
+ *
+ * interface NewExpression <: Expression {
+ * type: "NewExpression";
+ * callee: Expression;
+ * arguments: [ Expression | null ];
+ * }
+ */
+ NewExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onNewExpression) {
+ aCallbacks.onNewExpression(aNode);
+ }
+ this[aNode.callee.type](aNode.callee, aNode, aCallbacks);
+ for (let argument of aNode.arguments) {
+ if (argument) {
+ this[argument.type](argument, aNode, aCallbacks);
+ }
+ }
+ },
+
+ /**
+ * A function or method call expression.
+ *
+ * interface CallExpression <: Expression {
+ * type: "CallExpression";
+ * callee: Expression;
+ * arguments: [ Expression | null ];
+ * }
+ */
+ CallExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onCallExpression) {
+ aCallbacks.onCallExpression(aNode);
+ }
+ this[aNode.callee.type](aNode.callee, aNode, aCallbacks);
+ for (let argument of aNode.arguments) {
+ if (argument) {
+ this[argument.type](argument, aNode, aCallbacks);
+ }
+ }
+ },
+
+ /**
+ * A member expression. If computed is true, the node corresponds to a
+ * computed e1[e2] expression and property is an Expression. If computed is
+ * false, the node corresponds to a static e1.x expression and property is an
+ * Identifier.
+ *
+ * interface MemberExpression <: Expression {
+ * type: "MemberExpression";
+ * object: Expression;
+ * property: Identifier | Expression;
+ * computed: boolean;
+ * }
+ */
+ MemberExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onMemberExpression) {
+ aCallbacks.onMemberExpression(aNode);
+ }
+ this[aNode.object.type](aNode.object, aNode, aCallbacks);
+ this[aNode.property.type](aNode.property, aNode, aCallbacks);
+ },
+
+ /**
+ * A yield expression.
+ *
+ * interface YieldExpression <: Expression {
+ * argument: Expression | null;
+ * }
+ */
+ YieldExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onYieldExpression) {
+ aCallbacks.onYieldExpression(aNode);
+ }
+ if (aNode.argument) {
+ this[aNode.argument.type](aNode.argument, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * An array comprehension. The blocks array corresponds to the sequence of
+ * for and for each blocks. The optional filter expression corresponds to the
+ * final if clause, if present.
+ *
+ * interface ComprehensionExpression <: Expression {
+ * body: Expression;
+ * blocks: [ ComprehensionBlock ];
+ * filter: Expression | null;
+ * }
+ */
+ ComprehensionExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onComprehensionExpression) {
+ aCallbacks.onComprehensionExpression(aNode);
+ }
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ for (let block of aNode.blocks) {
+ this[block.type](block, aNode, aCallbacks);
+ }
+ if (aNode.filter) {
+ this[aNode.filter.type](aNode.filter, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A generator expression. As with array comprehensions, the blocks array
+ * corresponds to the sequence of for and for each blocks, and the optional
+ * filter expression corresponds to the final if clause, if present.
+ *
+ * interface GeneratorExpression <: Expression {
+ * body: Expression;
+ * blocks: [ ComprehensionBlock ];
+ * filter: Expression | null;
+ * }
+ */
+ GeneratorExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onGeneratorExpression) {
+ aCallbacks.onGeneratorExpression(aNode);
+ }
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ for (let block of aNode.blocks) {
+ this[block.type](block, aNode, aCallbacks);
+ }
+ if (aNode.filter) {
+ this[aNode.filter.type](aNode.filter, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A graph expression, aka "sharp literal," such as #1={ self: #1# }.
+ *
+ * interface GraphExpression <: Expression {
+ * index: uint32;
+ * expression: Literal;
+ * }
+ */
+ GraphExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onGraphExpression) {
+ aCallbacks.onGraphExpression(aNode);
+ }
+ this[aNode.expression.type](aNode.expression, aNode, aCallbacks);
+ },
+
+ /**
+ * A graph index expression, aka "sharp variable," such as #1#.
+ *
+ * interface GraphIndexExpression <: Expression {
+ * index: uint32;
+ * }
+ */
+ GraphIndexExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onGraphIndexExpression) {
+ aCallbacks.onGraphIndexExpression(aNode);
+ }
+ },
+
+ /**
+ * A let expression.
+ *
+ * interface LetExpression <: Expression {
+ * type: "LetExpression";
+ * head: [ { id: Pattern, init: Expression | null } ];
+ * body: Expression;
+ * }
+ */
+ LetExpression: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onLetExpression) {
+ aCallbacks.onLetExpression(aNode);
+ }
+ for (let { id, init } of aNode.head) {
+ this[id.type](id, aNode, aCallbacks);
+ if (init) {
+ this[init.type](init, aNode, aCallbacks);
+ }
+ }
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * Any pattern.
+ *
+ * interface Pattern <: Node { }
+ */
+ Pattern: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onPattern) {
+ aCallbacks.onPattern(aNode);
+ }
+ },
+
+ /**
+ * An object-destructuring pattern. A literal property in an object pattern
+ * can have either a string or number as its value.
+ *
+ * interface ObjectPattern <: Pattern {
+ * type: "ObjectPattern";
+ * properties: [ { key: Literal | Identifier, value: Pattern } ];
+ * }
+ */
+ ObjectPattern: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onObjectPattern) {
+ aCallbacks.onObjectPattern(aNode);
+ }
+ for (let { key, value } of aNode.properties) {
+ this[key.type](key, aNode, aCallbacks);
+ this[value.type](value, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * An array-destructuring pattern.
+ *
+ * interface ArrayPattern <: Pattern {
+ * type: "ArrayPattern";
+ * elements: [ Pattern | null ];
+ * }
+ */
+ ArrayPattern: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onArrayPattern) {
+ aCallbacks.onArrayPattern(aNode);
+ }
+ for (let element of aNode.elements) {
+ if (element) {
+ this[element.type](element, aNode, aCallbacks);
+ }
+ }
+ },
+
+ /**
+ * A case (if test is an Expression) or default (if test is null) clause in
+ * the body of a switch statement.
+ *
+ * interface SwitchCase <: Node {
+ * type: "SwitchCase";
+ * test: Expression | null;
+ * consequent: [ Statement ];
+ * }
+ */
+ SwitchCase: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onSwitchCase) {
+ aCallbacks.onSwitchCase(aNode);
+ }
+ if (aNode.test) {
+ this[aNode.test.type](aNode.test, aNode, aCallbacks);
+ }
+ for (let consequent of aNode.consequent) {
+ this[consequent.type](consequent, aNode, aCallbacks);
+ }
+ },
+
+ /**
+ * A catch clause following a try block. The optional guard property
+ * corresponds to the optional expression guard on the bound variable.
+ *
+ * interface CatchClause <: Node {
+ * type: "CatchClause";
+ * param: Pattern;
+ * guard: Expression | null;
+ * body: BlockStatement;
+ * }
+ */
+ CatchClause: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onCatchClause) {
+ aCallbacks.onCatchClause(aNode);
+ }
+ this[aNode.param.type](aNode.param, aNode, aCallbacks);
+ if (aNode.guard) {
+ this[aNode.guard.type](aNode.guard, aNode, aCallbacks);
+ }
+ this[aNode.body.type](aNode.body, aNode, aCallbacks);
+ },
+
+ /**
+ * A for or for each block in an array comprehension or generator expression.
+ *
+ * interface ComprehensionBlock <: Node {
+ * left: Pattern;
+ * right: Expression;
+ * each: boolean;
+ * }
+ */
+ ComprehensionBlock: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onComprehensionBlock) {
+ aCallbacks.onComprehensionBlock(aNode);
+ }
+ this[aNode.left.type](aNode.left, aNode, aCallbacks);
+ this[aNode.right.type](aNode.right, aNode, aCallbacks);
+ },
+
+ /**
+ * An identifier. Note that an identifier may be an expression or a
+ * destructuring pattern.
+ *
+ * interface Identifier <: Node, Expression, Pattern {
+ * type: "Identifier";
+ * name: string;
+ * }
+ */
+ Identifier: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onIdentifier) {
+ aCallbacks.onIdentifier(aNode);
+ }
+ },
+
+ /**
+ * A literal token. Note that a literal can be an expression.
+ *
+ * interface Literal <: Node, Expression {
+ * type: "Literal";
+ * value: string | boolean | null | number | RegExp;
+ * }
+ */
+ Literal: function(aNode, aParent, aCallbacks) {
+ aNode._parent = aParent;
+
+ if (this.break) {
+ return;
+ }
+ if (aCallbacks.onNode) {
+ if (aCallbacks.onNode(aNode, aParent) === false) {
+ return;
+ }
+ }
+ if (aCallbacks.onLiteral) {
+ aCallbacks.onLiteral(aNode);
+ }
+ }
+};
+
+XPCOMUtils.defineLazyGetter(Parser, "reflectionAPI", () => Reflect);
diff --git a/toolkit/devtools/shared/SplitView.jsm b/toolkit/devtools/shared/SplitView.jsm
new file mode 100644
index 000000000..5e6322ef2
--- /dev/null
+++ b/toolkit/devtools/shared/SplitView.jsm
@@ -0,0 +1,299 @@
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* 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";
+
+this.EXPORTED_SYMBOLS = ["SplitView"];
+
+/* this must be kept in sync with CSS (ie. splitview.css) */
+const LANDSCAPE_MEDIA_QUERY = "(min-width: 551px)";
+
+let bindings = new WeakMap();
+
+/**
+ * SplitView constructor
+ *
+ * Initialize the split view UI on an existing DOM element.
+ *
+ * A split view contains items, each of those having one summary and one details
+ * elements.
+ * It is adaptive as it behaves similarly to a richlistbox when there the aspect
+ * ratio is narrow or as a pair listbox-box otherwise.
+ *
+ * @param DOMElement aRoot
+ * @see appendItem
+ */
+this.SplitView = function SplitView(aRoot)
+{
+ this._root = aRoot;
+ this._controller = aRoot.querySelector(".splitview-controller");
+ this._nav = aRoot.querySelector(".splitview-nav");
+ this._side = aRoot.querySelector(".splitview-side-details");
+ this._activeSummary = null
+
+ this._mql = aRoot.ownerDocument.defaultView.matchMedia(LANDSCAPE_MEDIA_QUERY);
+
+ // items list focus and search-on-type handling
+ this._nav.addEventListener("keydown", (aEvent) => {
+ function getFocusedItemWithin(nav) {
+ let node = nav.ownerDocument.activeElement;
+ while (node && node.parentNode != nav) {
+ node = node.parentNode;
+ }
+ return node;
+ }
+
+ // do not steal focus from inside iframes or textboxes
+ if (aEvent.target.ownerDocument != this._nav.ownerDocument ||
+ aEvent.target.tagName == "input" ||
+ aEvent.target.tagName == "textbox" ||
+ aEvent.target.tagName == "textarea" ||
+ aEvent.target.classList.contains("textbox")) {
+ return false;
+ }
+
+ // handle keyboard navigation within the items list
+ let newFocusOrdinal;
+ if (aEvent.keyCode == aEvent.DOM_VK_PAGE_UP ||
+ aEvent.keyCode == aEvent.DOM_VK_HOME) {
+ newFocusOrdinal = 0;
+ } else if (aEvent.keyCode == aEvent.DOM_VK_PAGE_DOWN ||
+ aEvent.keyCode == aEvent.DOM_VK_END) {
+ newFocusOrdinal = this._nav.childNodes.length - 1;
+ } else if (aEvent.keyCode == aEvent.DOM_VK_UP) {
+ newFocusOrdinal = getFocusedItemWithin(this._nav).getAttribute("data-ordinal");
+ newFocusOrdinal--;
+ } else if (aEvent.keyCode == aEvent.DOM_VK_DOWN) {
+ newFocusOrdinal = getFocusedItemWithin(this._nav).getAttribute("data-ordinal");
+ newFocusOrdinal++;
+ }
+ if (newFocusOrdinal !== undefined) {
+ aEvent.stopPropagation();
+ let el = this.getSummaryElementByOrdinal(newFocusOrdinal);
+ if (el) {
+ el.focus();
+ }
+ return false;
+ }
+ }, false);
+}
+
+SplitView.prototype = {
+ /**
+ * Retrieve whether the UI currently has a landscape orientation.
+ *
+ * @return boolean
+ */
+ get isLandscape() this._mql.matches,
+
+ /**
+ * Retrieve the root element.
+ *
+ * @return DOMElement
+ */
+ get rootElement() this._root,
+
+ /**
+ * Retrieve the active item's summary element or null if there is none.
+ *
+ * @return DOMElement
+ */
+ get activeSummary() this._activeSummary,
+
+ /**
+ * Set the active item's summary element.
+ *
+ * @param DOMElement aSummary
+ */
+ set activeSummary(aSummary)
+ {
+ if (aSummary == this._activeSummary) {
+ return;
+ }
+
+ if (this._activeSummary) {
+ let binding = bindings.get(this._activeSummary);
+
+ if (binding.onHide) {
+ binding.onHide(this._activeSummary, binding._details, binding.data);
+ }
+
+ this._activeSummary.classList.remove("splitview-active");
+ binding._details.classList.remove("splitview-active");
+ }
+
+ if (!aSummary) {
+ return;
+ }
+
+ let binding = bindings.get(aSummary);
+ aSummary.classList.add("splitview-active");
+ binding._details.classList.add("splitview-active");
+
+ this._activeSummary = aSummary;
+
+ if (binding.onShow) {
+ binding.onShow(aSummary, binding._details, binding.data);
+ }
+ },
+
+ /**
+ * Retrieve the active item's details element or null if there is none.
+ * @return DOMElement
+ */
+ get activeDetails()
+ {
+ let summary = this.activeSummary;
+ return summary ? bindings.get(summary)._details : null;
+ },
+
+ /**
+ * Retrieve the summary element for a given ordinal.
+ *
+ * @param number aOrdinal
+ * @return DOMElement
+ * Summary element with given ordinal or null if not found.
+ * @see appendItem
+ */
+ getSummaryElementByOrdinal: function SEC_getSummaryElementByOrdinal(aOrdinal)
+ {
+ return this._nav.querySelector("* > li[data-ordinal='" + aOrdinal + "']");
+ },
+
+ /**
+ * Append an item to the split view.
+ *
+ * @param DOMElement aSummary
+ * The summary element for the item.
+ * @param DOMElement aDetails
+ * The details element for the item.
+ * @param object aOptions
+ * Optional object that defines custom behavior and data for the item.
+ * All properties are optional :
+ * - function(DOMElement summary, DOMElement details, object data) onCreate
+ * Called when the item has been added.
+ * - function(summary, details, data) onShow
+ * Called when the item is shown/active.
+ * - function(summary, details, data) onHide
+ * Called when the item is hidden/inactive.
+ * - function(summary, details, data) onDestroy
+ * Called when the item has been removed.
+ * - object data
+ * Object to pass to the callbacks above.
+ * - number ordinal
+ * Items with a lower ordinal are displayed before those with a
+ * higher ordinal.
+ */
+ appendItem: function ASV_appendItem(aSummary, aDetails, aOptions)
+ {
+ let binding = aOptions || {};
+
+ binding._summary = aSummary;
+ binding._details = aDetails;
+ bindings.set(aSummary, binding);
+
+ this._nav.appendChild(aSummary);
+
+ aSummary.addEventListener("click", (aEvent) => {
+ aEvent.stopPropagation();
+ this.activeSummary = aSummary;
+ }, false);
+
+ this._side.appendChild(aDetails);
+
+ if (binding.onCreate) {
+ binding.onCreate(aSummary, aDetails, binding.data);
+ }
+ },
+
+ /**
+ * Append an item to the split view according to two template elements
+ * (one for the item's summary and the other for the item's details).
+ *
+ * @param string aName
+ * Name of the template elements to instantiate.
+ * Requires two (hidden) DOM elements with id "splitview-tpl-summary-"
+ * and "splitview-tpl-details-" suffixed with aName.
+ * @param object aOptions
+ * Optional object that defines custom behavior and data for the item.
+ * See appendItem for full description.
+ * @return object{summary:,details:}
+ * Object with the new DOM elements created for summary and details.
+ * @see appendItem
+ */
+ appendTemplatedItem: function ASV_appendTemplatedItem(aName, aOptions)
+ {
+ aOptions = aOptions || {};
+ let summary = this._root.querySelector("#splitview-tpl-summary-" + aName);
+ let details = this._root.querySelector("#splitview-tpl-details-" + aName);
+
+ summary = summary.cloneNode(true);
+ summary.id = "";
+ if (aOptions.ordinal !== undefined) { // can be zero
+ summary.style.MozBoxOrdinalGroup = aOptions.ordinal;
+ summary.setAttribute("data-ordinal", aOptions.ordinal);
+ }
+ details = details.cloneNode(true);
+ details.id = "";
+
+ this.appendItem(summary, details, aOptions);
+ return {summary: summary, details: details};
+ },
+
+ /**
+ * Remove an item from the split view.
+ *
+ * @param DOMElement aSummary
+ * Summary element of the item to remove.
+ */
+ removeItem: function ASV_removeItem(aSummary)
+ {
+ if (aSummary == this._activeSummary) {
+ this.activeSummary = null;
+ }
+
+ let binding = bindings.get(aSummary);
+ aSummary.parentNode.removeChild(aSummary);
+ binding._details.parentNode.removeChild(binding._details);
+
+ if (binding.onDestroy) {
+ binding.onDestroy(aSummary, binding._details, binding.data);
+ }
+ },
+
+ /**
+ * Remove all items from the split view.
+ */
+ removeAll: function ASV_removeAll()
+ {
+ while (this._nav.hasChildNodes()) {
+ this.removeItem(this._nav.firstChild);
+ }
+ },
+
+ /**
+ * Set the item's CSS class name.
+ * This sets the class on both the summary and details elements, retaining
+ * any SplitView-specific classes.
+ *
+ * @param DOMElement aSummary
+ * Summary element of the item to set.
+ * @param string aClassName
+ * One or more space-separated CSS classes.
+ */
+ setItemClassName: function ASV_setItemClassName(aSummary, aClassName)
+ {
+ let binding = bindings.get(aSummary);
+ let viewSpecific;
+
+ viewSpecific = aSummary.className.match(/(splitview\-[\w-]+)/g);
+ viewSpecific = viewSpecific ? viewSpecific.join(" ") : "";
+ aSummary.className = viewSpecific + " " + aClassName;
+
+ viewSpecific = binding._details.className.match(/(splitview\-[\w-]+)/g);
+ viewSpecific = viewSpecific ? viewSpecific.join(" ") : "";
+ binding._details.className = viewSpecific + " " + aClassName;
+ },
+};
diff --git a/toolkit/devtools/shared/autocomplete-popup.js b/toolkit/devtools/shared/autocomplete-popup.js
new file mode 100644
index 000000000..b2828ac83
--- /dev/null
+++ b/toolkit/devtools/shared/autocomplete-popup.js
@@ -0,0 +1,595 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+loader.lazyImporter(this, "Services", "resource://gre/modules/Services.jsm");
+loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm");
+
+/**
+ * Autocomplete popup UI implementation.
+ *
+ * @constructor
+ * @param nsIDOMDocument aDocument
+ * The document you want the popup attached to.
+ * @param Object aOptions
+ * An object consiting any of the following options:
+ * - panelId {String} The id for the popup panel.
+ * - listBoxId {String} The id for the richlistbox inside the panel.
+ * - position {String} The position for the popup panel.
+ * - theme {String} String related to the theme of the popup.
+ * - autoSelect {Boolean} Boolean to allow the first entry of the popup
+ * panel to be automatically selected when the popup shows.
+ * - direction {String} The direction of the text in the panel. rtl or ltr
+ * - onSelect {String} The select event handler for the richlistbox
+ * - onClick {String} The click event handler for the richlistbox.
+ * - onKeypress {String} The keypress event handler for the richlistitems.
+ */
+function AutocompletePopup(aDocument, aOptions = {})
+{
+ this._document = aDocument;
+
+ this.autoSelect = aOptions.autoSelect || false;
+ this.position = aOptions.position || "after_start";
+ this.direction = aOptions.direction || "ltr";
+
+ this.onSelect = aOptions.onSelect;
+ this.onClick = aOptions.onClick;
+ this.onKeypress = aOptions.onKeypress;
+
+ let id = aOptions.panelId || "devtools_autoCompletePopup";
+ let theme = aOptions.theme || "dark";
+ // If theme is auto, use the devtools.theme pref
+ if (theme == "auto") {
+ theme = Services.prefs.getCharPref("devtools.theme");
+ this.autoThemeEnabled = true;
+ // Setup theme change listener.
+ this._handleThemeChange = this._handleThemeChange.bind(this);
+ gDevTools.on("pref-changed", this._handleThemeChange);
+ }
+ // Reuse the existing popup elements.
+ this._panel = this._document.getElementById(id);
+ if (!this._panel) {
+ this._panel = this._document.createElementNS(XUL_NS, "panel");
+ this._panel.setAttribute("id", id);
+ this._panel.className = "devtools-autocomplete-popup devtools-monospace "
+ + theme + "-theme";
+
+ this._panel.setAttribute("noautofocus", "true");
+ this._panel.setAttribute("level", "top");
+ if (!aOptions.onKeypress) {
+ this._panel.setAttribute("ignorekeys", "true");
+ }
+ // Stop this appearing as an alert to accessibility.
+ this._panel.setAttribute("role", "presentation");
+
+ let mainPopupSet = this._document.getElementById("mainPopupSet");
+ if (mainPopupSet) {
+ mainPopupSet.appendChild(this._panel);
+ }
+ else {
+ this._document.documentElement.appendChild(this._panel);
+ }
+ }
+ else {
+ this._list = this._panel.firstChild;
+ }
+
+ if (!this._list) {
+ this._list = this._document.createElementNS(XUL_NS, "richlistbox");
+ this._panel.appendChild(this._list);
+
+ // Open and hide the panel, so we initialize the API of the richlistbox.
+ this._panel.openPopup(null, this.position, 0, 0);
+ this._panel.hidePopup();
+ }
+
+ this._list.setAttribute("flex", "1");
+ this._list.setAttribute("seltype", "single");
+
+ if (aOptions.listBoxId) {
+ this._list.setAttribute("id", aOptions.listBoxId);
+ }
+ this._list.className = "devtools-autocomplete-listbox " + theme + "-theme";
+
+ if (this.onSelect) {
+ this._list.addEventListener("select", this.onSelect, false);
+ }
+
+ if (this.onClick) {
+ this._list.addEventListener("click", this.onClick, false);
+ }
+
+ if (this.onKeypress) {
+ this._list.addEventListener("keypress", this.onKeypress, false);
+ }
+ this._itemIdCounter = 0;
+}
+exports.AutocompletePopup = AutocompletePopup;
+
+AutocompletePopup.prototype = {
+ _document: null,
+ _panel: null,
+ _list: null,
+ __scrollbarWidth: null,
+
+ // Event handlers.
+ onSelect: null,
+ onClick: null,
+ onKeypress: null,
+
+ /**
+ * Open the autocomplete popup panel.
+ *
+ * @param nsIDOMNode aAnchor
+ * Optional node to anchor the panel to.
+ * @param Number aXOffset
+ * Horizontal offset in pixels from the left of the node to the left
+ * of the popup.
+ * @param Number aYOffset
+ * Vertical offset in pixels from the top of the node to the starting
+ * of the popup.
+ */
+ openPopup: function AP_openPopup(aAnchor, aXOffset = 0, aYOffset = 0)
+ {
+ this.__maxLabelLength = -1;
+ this._updateSize();
+ this._panel.openPopup(aAnchor, this.position, aXOffset, aYOffset);
+
+ if (this.autoSelect) {
+ this.selectFirstItem();
+ }
+ },
+
+ /**
+ * Hide the autocomplete popup panel.
+ */
+ hidePopup: function AP_hidePopup()
+ {
+ // Return accessibility focus to the input.
+ this._document.activeElement.removeAttribute("aria-activedescendant");
+ this._panel.hidePopup();
+ },
+
+ /**
+ * Check if the autocomplete popup is open.
+ */
+ get isOpen() {
+ return this._panel &&
+ (this._panel.state == "open" || this._panel.state == "showing");
+ },
+
+ /**
+ * Destroy the object instance. Please note that the panel DOM elements remain
+ * in the DOM, because they might still be in use by other instances of the
+ * same code. It is the responsability of the client code to perform DOM
+ * cleanup.
+ */
+ destroy: function AP_destroy()
+ {
+ if (this.isOpen) {
+ this.hidePopup();
+ }
+
+ if (this.onSelect) {
+ this._list.removeEventListener("select", this.onSelect, false);
+ }
+
+ if (this.onClick) {
+ this._list.removeEventListener("click", this.onClick, false);
+ }
+
+ if (this.onKeypress) {
+ this._list.removeEventListener("keypress", this.onKeypress, false);
+ }
+
+ if (this.autoThemeEnabled) {
+ gDevTools.off("pref-changed", this._handleThemeChange);
+ }
+
+ this._list.remove();
+ this._panel.remove();
+ this._document = null;
+ this._list = null;
+ this._panel = null;
+ },
+
+ /**
+ * Get the autocomplete items array.
+ *
+ * @param Number aIndex The index of the item what is wanted.
+ *
+ * @return The autocomplete item at index aIndex.
+ */
+ getItemAtIndex: function AP_getItemAtIndex(aIndex)
+ {
+ return this._list.getItemAtIndex(aIndex)._autocompleteItem;
+ },
+
+ /**
+ * Get the autocomplete items array.
+ *
+ * @return array
+ * The array of autocomplete items.
+ */
+ getItems: function AP_getItems()
+ {
+ let items = [];
+
+ Array.forEach(this._list.childNodes, function(aItem) {
+ items.push(aItem._autocompleteItem);
+ });
+
+ return items;
+ },
+
+ /**
+ * Set the autocomplete items list, in one go.
+ *
+ * @param array aItems
+ * The list of items you want displayed in the popup list.
+ */
+ setItems: function AP_setItems(aItems)
+ {
+ this.clearItems();
+ aItems.forEach(this.appendItem, this);
+
+ // Make sure that the new content is properly fitted by the XUL richlistbox.
+ if (this.isOpen) {
+ if (this.autoSelect) {
+ this.selectFirstItem();
+ }
+ this._updateSize();
+ }
+ },
+
+ /**
+ * Selects the first item of the richlistbox. Note that first item here is the
+ * item closes to the input element, which means that 0th index if position is
+ * below, and last index if position is above.
+ */
+ selectFirstItem: function AP_selectFirstItem()
+ {
+ if (this.position.contains("before")) {
+ this.selectedIndex = this.itemCount - 1;
+ }
+ else {
+ this.selectedIndex = 0;
+ }
+ this._list.ensureIndexIsVisible(this._list.selectedIndex);
+ },
+
+ __maxLabelLength: -1,
+
+ get _maxLabelLength() {
+ if (this.__maxLabelLength != -1) {
+ return this.__maxLabelLength;
+ }
+
+ let max = 0;
+ for (let i = 0; i < this._list.childNodes.length; i++) {
+ let item = this._list.childNodes[i]._autocompleteItem;
+ let str = item.label;
+ if (item.count) {
+ str += (item.count + "");
+ }
+ max = Math.max(str.length, max);
+ }
+
+ this.__maxLabelLength = max;
+ return this.__maxLabelLength;
+ },
+
+ /**
+ * Update the panel size to fit the content.
+ *
+ * @private
+ */
+ _updateSize: function AP__updateSize()
+ {
+ if (!this._panel) {
+ return;
+ }
+
+ this._list.style.width = (this._maxLabelLength + 3) +"ch";
+ this._list.ensureIndexIsVisible(this._list.selectedIndex);
+ },
+
+ /**
+ * Update accessibility appropriately when the selected item is changed.
+ *
+ * @private
+ */
+ _updateAriaActiveDescendant: function AP__updateAriaActiveDescendant()
+ {
+ if (!this._list.selectedItem) {
+ // Return accessibility focus to the input.
+ this._document.activeElement.removeAttribute("aria-activedescendant");
+ return;
+ }
+ // Focus this for accessibility so users know about the selected item.
+ this._document.activeElement.setAttribute("aria-activedescendant",
+ this._list.selectedItem.id);
+ },
+
+ /**
+ * Clear all the items from the autocomplete list.
+ */
+ clearItems: function AP_clearItems()
+ {
+ // Reset the selectedIndex to -1 before clearing the list
+ this.selectedIndex = -1;
+
+ while (this._list.hasChildNodes()) {
+ this._list.removeChild(this._list.firstChild);
+ }
+
+ this.__maxLabelLength = -1;
+
+ // Reset the panel and list dimensions. New dimensions are calculated when
+ // a new set of items is added to the autocomplete popup.
+ this._list.width = "";
+ this._list.style.width = "";
+ this._list.height = "";
+ this._panel.width = "";
+ this._panel.height = "";
+ this._panel.top = "";
+ this._panel.left = "";
+ },
+
+ /**
+ * Getter for the index of the selected item.
+ *
+ * @type number
+ */
+ get selectedIndex() {
+ return this._list.selectedIndex;
+ },
+
+ /**
+ * Setter for the selected index.
+ *
+ * @param number aIndex
+ * The number (index) of the item you want to select in the list.
+ */
+ set selectedIndex(aIndex) {
+ this._list.selectedIndex = aIndex;
+ if (this.isOpen && this._list.ensureIndexIsVisible) {
+ this._list.ensureIndexIsVisible(this._list.selectedIndex);
+ }
+ this._updateAriaActiveDescendant();
+ },
+
+ /**
+ * Getter for the selected item.
+ * @type object
+ */
+ get selectedItem() {
+ return this._list.selectedItem ?
+ this._list.selectedItem._autocompleteItem : null;
+ },
+
+ /**
+ * Setter for the selected item.
+ *
+ * @param object aItem
+ * The object you want selected in the list.
+ */
+ set selectedItem(aItem) {
+ this._list.selectedItem = this._findListItem(aItem);
+ if (this.isOpen) {
+ this._list.ensureIndexIsVisible(this._list.selectedIndex);
+ }
+ this._updateAriaActiveDescendant();
+ },
+
+ /**
+ * Append an item into the autocomplete list.
+ *
+ * @param object aItem
+ * The item you want appended to the list.
+ * The item object can have the following properties:
+ * - label {String} Property which is used as the displayed value.
+ * - preLabel {String} [Optional] The String that will be displayed
+ * before the label indicating that this is the already
+ * present text in the input box, and label is the text
+ * that will be auto completed. When this property is
+ * present, |preLabel.length| starting characters will be
+ * removed from label.
+ * - count {Number} [Optional] The number to represent the count of
+ * autocompleted label.
+ */
+ appendItem: function AP_appendItem(aItem)
+ {
+ let listItem = this._document.createElementNS(XUL_NS, "richlistitem");
+ // Items must have an id for accessibility.
+ listItem.id = this._panel.id + "_item_" + this._itemIdCounter++;
+ if (this.direction) {
+ listItem.setAttribute("dir", this.direction);
+ }
+ let label = this._document.createElementNS(XUL_NS, "label");
+ label.setAttribute("value", aItem.label);
+ label.setAttribute("class", "autocomplete-value");
+ if (aItem.preLabel) {
+ let preDesc = this._document.createElementNS(XUL_NS, "label");
+ preDesc.setAttribute("value", aItem.preLabel);
+ preDesc.setAttribute("class", "initial-value");
+ listItem.appendChild(preDesc);
+ label.setAttribute("value", aItem.label.slice(aItem.preLabel.length));
+ }
+ listItem.appendChild(label);
+ if (aItem.count && aItem.count > 1) {
+ let countDesc = this._document.createElementNS(XUL_NS, "label");
+ countDesc.setAttribute("value", aItem.count);
+ countDesc.setAttribute("flex", "1");
+ countDesc.setAttribute("class", "autocomplete-count");
+ listItem.appendChild(countDesc);
+ }
+ listItem._autocompleteItem = aItem;
+
+ this._list.appendChild(listItem);
+ },
+
+ /**
+ * Find the richlistitem element that belongs to an item.
+ *
+ * @private
+ *
+ * @param object aItem
+ * The object you want found in the list.
+ *
+ * @return nsIDOMNode|null
+ * The nsIDOMNode that belongs to the given item object. This node is
+ * the richlistitem element.
+ */
+ _findListItem: function AP__findListItem(aItem)
+ {
+ for (let i = 0; i < this._list.childNodes.length; i++) {
+ let child = this._list.childNodes[i];
+ if (child._autocompleteItem == aItem) {
+ return child;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Remove an item from the popup list.
+ *
+ * @param object aItem
+ * The item you want removed.
+ */
+ removeItem: function AP_removeItem(aItem)
+ {
+ let item = this._findListItem(aItem);
+ if (!item) {
+ throw new Error("Item not found!");
+ }
+ this._list.removeChild(item);
+ },
+
+ /**
+ * Getter for the number of items in the popup.
+ * @type number
+ */
+ get itemCount() {
+ return this._list.childNodes.length;
+ },
+
+ /**
+ * Getter for the height of each item in the list.
+ *
+ * @private
+ *
+ * @type number
+ */
+ get _itemHeight() {
+ return this._list.selectedItem.clientHeight;
+ },
+
+ /**
+ * Select the next item in the list.
+ *
+ * @return object
+ * The newly selected item object.
+ */
+ selectNextItem: function AP_selectNextItem()
+ {
+ if (this.selectedIndex < (this.itemCount - 1)) {
+ this.selectedIndex++;
+ }
+ else {
+ this.selectedIndex = 0;
+ }
+
+ return this.selectedItem;
+ },
+
+ /**
+ * Select the previous item in the list.
+ *
+ * @return object
+ * The newly-selected item object.
+ */
+ selectPreviousItem: function AP_selectPreviousItem()
+ {
+ if (this.selectedIndex > 0) {
+ this.selectedIndex--;
+ }
+ else {
+ this.selectedIndex = this.itemCount - 1;
+ }
+
+ return this.selectedItem;
+ },
+
+ /**
+ * Select the top-most item in the next page of items or
+ * the last item in the list.
+ *
+ * @return object
+ * The newly-selected item object.
+ */
+ selectNextPageItem: function AP_selectNextPageItem()
+ {
+ let itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight);
+ let nextPageIndex = this.selectedIndex + itemsPerPane + 1;
+ this.selectedIndex = nextPageIndex > this.itemCount - 1 ?
+ this.itemCount - 1 : nextPageIndex;
+
+ return this.selectedItem;
+ },
+
+ /**
+ * Select the bottom-most item in the previous page of items,
+ * or the first item in the list.
+ *
+ * @return object
+ * The newly-selected item object.
+ */
+ selectPreviousPageItem: function AP_selectPreviousPageItem()
+ {
+ let itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight);
+ let prevPageIndex = this.selectedIndex - itemsPerPane - 1;
+ this.selectedIndex = prevPageIndex < 0 ? 0 : prevPageIndex;
+
+ return this.selectedItem;
+ },
+
+ /**
+ * Focuses the richlistbox.
+ */
+ focus: function AP_focus()
+ {
+ this._list.focus();
+ },
+
+ /**
+ * Manages theme switching for the popup based on the devtools.theme pref.
+ *
+ * @private
+ *
+ * @param String aEvent
+ * The name of the event. In this case, "pref-changed".
+ * @param Object aData
+ * An object passed by the emitter of the event. In this case, the
+ * object consists of three properties:
+ * - pref {String} The name of the preference that was modified.
+ * - newValue {Object} The new value of the preference.
+ * - oldValue {Object} The old value of the preference.
+ */
+ _handleThemeChange: function AP__handleThemeChange(aEvent, aData)
+ {
+ if (aData.pref == "devtools.theme") {
+ this._panel.classList.toggle(aData.oldValue + "-theme", false);
+ this._panel.classList.toggle(aData.newValue + "-theme", true);
+ this._list.classList.toggle(aData.oldValue + "-theme", false);
+ this._list.classList.toggle(aData.newValue + "-theme", true);
+ }
+ },
+};
diff --git a/toolkit/devtools/shared/d3.js b/toolkit/devtools/shared/d3.js
new file mode 100644
index 000000000..2f645354c
--- /dev/null
+++ b/toolkit/devtools/shared/d3.js
@@ -0,0 +1,9275 @@
+!function() {
+ var d3 = {
+ version: "3.4.2"
+ };
+ if (!Date.now) Date.now = function() {
+ return +new Date();
+ };
+ var d3_arraySlice = [].slice, d3_array = function(list) {
+ return d3_arraySlice.call(list);
+ };
+ var d3_document = document, d3_documentElement = d3_document.documentElement, d3_window = window;
+ try {
+ d3_array(d3_documentElement.childNodes)[0].nodeType;
+ } catch (e) {
+ d3_array = function(list) {
+ var i = list.length, array = new Array(i);
+ while (i--) array[i] = list[i];
+ return array;
+ };
+ }
+ try {
+ d3_document.createElement("div").style.setProperty("opacity", 0, "");
+ } catch (error) {
+ var d3_element_prototype = d3_window.Element.prototype, d3_element_setAttribute = d3_element_prototype.setAttribute, d3_element_setAttributeNS = d3_element_prototype.setAttributeNS, d3_style_prototype = d3_window.CSSStyleDeclaration.prototype, d3_style_setProperty = d3_style_prototype.setProperty;
+ d3_element_prototype.setAttribute = function(name, value) {
+ d3_element_setAttribute.call(this, name, value + "");
+ };
+ d3_element_prototype.setAttributeNS = function(space, local, value) {
+ d3_element_setAttributeNS.call(this, space, local, value + "");
+ };
+ d3_style_prototype.setProperty = function(name, value, priority) {
+ d3_style_setProperty.call(this, name, value + "", priority);
+ };
+ }
+ d3.ascending = function(a, b) {
+ return a < b ? -1 : a > b ? 1 : a >= b ? 0 : NaN;
+ };
+ d3.descending = function(a, b) {
+ return b < a ? -1 : b > a ? 1 : b >= a ? 0 : NaN;
+ };
+ d3.min = function(array, f) {
+ var i = -1, n = array.length, a, b;
+ if (arguments.length === 1) {
+ while (++i < n && !((a = array[i]) != null && a <= a)) a = undefined;
+ while (++i < n) if ((b = array[i]) != null && a > b) a = b;
+ } else {
+ while (++i < n && !((a = f.call(array, array[i], i)) != null && a <= a)) a = undefined;
+ while (++i < n) if ((b = f.call(array, array[i], i)) != null && a > b) a = b;
+ }
+ return a;
+ };
+ d3.max = function(array, f) {
+ var i = -1, n = array.length, a, b;
+ if (arguments.length === 1) {
+ while (++i < n && !((a = array[i]) != null && a <= a)) a = undefined;
+ while (++i < n) if ((b = array[i]) != null && b > a) a = b;
+ } else {
+ while (++i < n && !((a = f.call(array, array[i], i)) != null && a <= a)) a = undefined;
+ while (++i < n) if ((b = f.call(array, array[i], i)) != null && b > a) a = b;
+ }
+ return a;
+ };
+ d3.extent = function(array, f) {
+ var i = -1, n = array.length, a, b, c;
+ if (arguments.length === 1) {
+ while (++i < n && !((a = c = array[i]) != null && a <= a)) a = c = undefined;
+ while (++i < n) if ((b = array[i]) != null) {
+ if (a > b) a = b;
+ if (c < b) c = b;
+ }
+ } else {
+ while (++i < n && !((a = c = f.call(array, array[i], i)) != null && a <= a)) a = undefined;
+ while (++i < n) if ((b = f.call(array, array[i], i)) != null) {
+ if (a > b) a = b;
+ if (c < b) c = b;
+ }
+ }
+ return [ a, c ];
+ };
+ d3.sum = function(array, f) {
+ var s = 0, n = array.length, a, i = -1;
+ if (arguments.length === 1) {
+ while (++i < n) if (!isNaN(a = +array[i])) s += a;
+ } else {
+ while (++i < n) if (!isNaN(a = +f.call(array, array[i], i))) s += a;
+ }
+ return s;
+ };
+ function d3_number(x) {
+ return x != null && !isNaN(x);
+ }
+ d3.mean = function(array, f) {
+ var n = array.length, a, m = 0, i = -1, j = 0;
+ if (arguments.length === 1) {
+ while (++i < n) if (d3_number(a = array[i])) m += (a - m) / ++j;
+ } else {
+ while (++i < n) if (d3_number(a = f.call(array, array[i], i))) m += (a - m) / ++j;
+ }
+ return j ? m : undefined;
+ };
+ d3.quantile = function(values, p) {
+ var H = (values.length - 1) * p + 1, h = Math.floor(H), v = +values[h - 1], e = H - h;
+ return e ? v + e * (values[h] - v) : v;
+ };
+ d3.median = function(array, f) {
+ if (arguments.length > 1) array = array.map(f);
+ array = array.filter(d3_number);
+ return array.length ? d3.quantile(array.sort(d3.ascending), .5) : undefined;
+ };
+ d3.bisector = function(f) {
+ return {
+ left: function(a, x, lo, hi) {
+ if (arguments.length < 3) lo = 0;
+ if (arguments.length < 4) hi = a.length;
+ while (lo < hi) {
+ var mid = lo + hi >>> 1;
+ if (f.call(a, a[mid], mid) < x) lo = mid + 1; else hi = mid;
+ }
+ return lo;
+ },
+ right: function(a, x, lo, hi) {
+ if (arguments.length < 3) lo = 0;
+ if (arguments.length < 4) hi = a.length;
+ while (lo < hi) {
+ var mid = lo + hi >>> 1;
+ if (x < f.call(a, a[mid], mid)) hi = mid; else lo = mid + 1;
+ }
+ return lo;
+ }
+ };
+ };
+ var d3_bisector = d3.bisector(function(d) {
+ return d;
+ });
+ d3.bisectLeft = d3_bisector.left;
+ d3.bisect = d3.bisectRight = d3_bisector.right;
+ d3.shuffle = function(array) {
+ var m = array.length, t, i;
+ while (m) {
+ i = Math.random() * m-- | 0;
+ t = array[m], array[m] = array[i], array[i] = t;
+ }
+ return array;
+ };
+ d3.permute = function(array, indexes) {
+ var i = indexes.length, permutes = new Array(i);
+ while (i--) permutes[i] = array[indexes[i]];
+ return permutes;
+ };
+ d3.pairs = function(array) {
+ var i = 0, n = array.length - 1, p0, p1 = array[0], pairs = new Array(n < 0 ? 0 : n);
+ while (i < n) pairs[i] = [ p0 = p1, p1 = array[++i] ];
+ return pairs;
+ };
+ d3.zip = function() {
+ if (!(n = arguments.length)) return [];
+ for (var i = -1, m = d3.min(arguments, d3_zipLength), zips = new Array(m); ++i < m; ) {
+ for (var j = -1, n, zip = zips[i] = new Array(n); ++j < n; ) {
+ zip[j] = arguments[j][i];
+ }
+ }
+ return zips;
+ };
+ function d3_zipLength(d) {
+ return d.length;
+ }
+ d3.transpose = function(matrix) {
+ return d3.zip.apply(d3, matrix);
+ };
+ d3.keys = function(map) {
+ var keys = [];
+ for (var key in map) keys.push(key);
+ return keys;
+ };
+ d3.values = function(map) {
+ var values = [];
+ for (var key in map) values.push(map[key]);
+ return values;
+ };
+ d3.entries = function(map) {
+ var entries = [];
+ for (var key in map) entries.push({
+ key: key,
+ value: map[key]
+ });
+ return entries;
+ };
+ d3.merge = function(arrays) {
+ var n = arrays.length, m, i = -1, j = 0, merged, array;
+ while (++i < n) j += arrays[i].length;
+ merged = new Array(j);
+ while (--n >= 0) {
+ array = arrays[n];
+ m = array.length;
+ while (--m >= 0) {
+ merged[--j] = array[m];
+ }
+ }
+ return merged;
+ };
+ var abs = Math.abs;
+ d3.range = function(start, stop, step) {
+ if (arguments.length < 3) {
+ step = 1;
+ if (arguments.length < 2) {
+ stop = start;
+ start = 0;
+ }
+ }
+ if ((stop - start) / step === Infinity) throw new Error("infinite range");
+ var range = [], k = d3_range_integerScale(abs(step)), i = -1, j;
+ start *= k, stop *= k, step *= k;
+ if (step < 0) while ((j = start + step * ++i) > stop) range.push(j / k); else while ((j = start + step * ++i) < stop) range.push(j / k);
+ return range;
+ };
+ function d3_range_integerScale(x) {
+ var k = 1;
+ while (x * k % 1) k *= 10;
+ return k;
+ }
+ function d3_class(ctor, properties) {
+ try {
+ for (var key in properties) {
+ Object.defineProperty(ctor.prototype, key, {
+ value: properties[key],
+ enumerable: false
+ });
+ }
+ } catch (e) {
+ ctor.prototype = properties;
+ }
+ }
+ d3.map = function(object) {
+ var map = new d3_Map();
+ if (object instanceof d3_Map) object.forEach(function(key, value) {
+ map.set(key, value);
+ }); else for (var key in object) map.set(key, object[key]);
+ return map;
+ };
+ function d3_Map() {}
+ d3_class(d3_Map, {
+ has: d3_map_has,
+ get: function(key) {
+ return this[d3_map_prefix + key];
+ },
+ set: function(key, value) {
+ return this[d3_map_prefix + key] = value;
+ },
+ remove: d3_map_remove,
+ keys: d3_map_keys,
+ values: function() {
+ var values = [];
+ this.forEach(function(key, value) {
+ values.push(value);
+ });
+ return values;
+ },
+ entries: function() {
+ var entries = [];
+ this.forEach(function(key, value) {
+ entries.push({
+ key: key,
+ value: value
+ });
+ });
+ return entries;
+ },
+ size: d3_map_size,
+ empty: d3_map_empty,
+ forEach: function(f) {
+ for (var key in this) if (key.charCodeAt(0) === d3_map_prefixCode) f.call(this, key.substring(1), this[key]);
+ }
+ });
+ var d3_map_prefix = "\x00", d3_map_prefixCode = d3_map_prefix.charCodeAt(0);
+ function d3_map_has(key) {
+ return d3_map_prefix + key in this;
+ }
+ function d3_map_remove(key) {
+ key = d3_map_prefix + key;
+ return key in this && delete this[key];
+ }
+ function d3_map_keys() {
+ var keys = [];
+ this.forEach(function(key) {
+ keys.push(key);
+ });
+ return keys;
+ }
+ function d3_map_size() {
+ var size = 0;
+ for (var key in this) if (key.charCodeAt(0) === d3_map_prefixCode) ++size;
+ return size;
+ }
+ function d3_map_empty() {
+ for (var key in this) if (key.charCodeAt(0) === d3_map_prefixCode) return false;
+ return true;
+ }
+ d3.nest = function() {
+ var nest = {}, keys = [], sortKeys = [], sortValues, rollup;
+ function map(mapType, array, depth) {
+ if (depth >= keys.length) return rollup ? rollup.call(nest, array) : sortValues ? array.sort(sortValues) : array;
+ var i = -1, n = array.length, key = keys[depth++], keyValue, object, setter, valuesByKey = new d3_Map(), values;
+ while (++i < n) {
+ if (values = valuesByKey.get(keyValue = key(object = array[i]))) {
+ values.push(object);
+ } else {
+ valuesByKey.set(keyValue, [ object ]);
+ }
+ }
+ if (mapType) {
+ object = mapType();
+ setter = function(keyValue, values) {
+ object.set(keyValue, map(mapType, values, depth));
+ };
+ } else {
+ object = {};
+ setter = function(keyValue, values) {
+ object[keyValue] = map(mapType, values, depth);
+ };
+ }
+ valuesByKey.forEach(setter);
+ return object;
+ }
+ function entries(map, depth) {
+ if (depth >= keys.length) return map;
+ var array = [], sortKey = sortKeys[depth++];
+ map.forEach(function(key, keyMap) {
+ array.push({
+ key: key,
+ values: entries(keyMap, depth)
+ });
+ });
+ return sortKey ? array.sort(function(a, b) {
+ return sortKey(a.key, b.key);
+ }) : array;
+ }
+ nest.map = function(array, mapType) {
+ return map(mapType, array, 0);
+ };
+ nest.entries = function(array) {
+ return entries(map(d3.map, array, 0), 0);
+ };
+ nest.key = function(d) {
+ keys.push(d);
+ return nest;
+ };
+ nest.sortKeys = function(order) {
+ sortKeys[keys.length - 1] = order;
+ return nest;
+ };
+ nest.sortValues = function(order) {
+ sortValues = order;
+ return nest;
+ };
+ nest.rollup = function(f) {
+ rollup = f;
+ return nest;
+ };
+ return nest;
+ };
+ d3.set = function(array) {
+ var set = new d3_Set();
+ if (array) for (var i = 0, n = array.length; i < n; ++i) set.add(array[i]);
+ return set;
+ };
+ function d3_Set() {}
+ d3_class(d3_Set, {
+ has: d3_map_has,
+ add: function(value) {
+ this[d3_map_prefix + value] = true;
+ return value;
+ },
+ remove: function(value) {
+ value = d3_map_prefix + value;
+ return value in this && delete this[value];
+ },
+ values: d3_map_keys,
+ size: d3_map_size,
+ empty: d3_map_empty,
+ forEach: function(f) {
+ for (var value in this) if (value.charCodeAt(0) === d3_map_prefixCode) f.call(this, value.substring(1));
+ }
+ });
+ d3.behavior = {};
+ d3.rebind = function(target, source) {
+ var i = 1, n = arguments.length, method;
+ while (++i < n) target[method = arguments[i]] = d3_rebind(target, source, source[method]);
+ return target;
+ };
+ function d3_rebind(target, source, method) {
+ return function() {
+ var value = method.apply(source, arguments);
+ return value === source ? target : value;
+ };
+ }
+ function d3_vendorSymbol(object, name) {
+ if (name in object) return name;
+ name = name.charAt(0).toUpperCase() + name.substring(1);
+ for (var i = 0, n = d3_vendorPrefixes.length; i < n; ++i) {
+ var prefixName = d3_vendorPrefixes[i] + name;
+ if (prefixName in object) return prefixName;
+ }
+ }
+ var d3_vendorPrefixes = [ "webkit", "ms", "moz", "Moz", "o", "O" ];
+ function d3_noop() {}
+ d3.dispatch = function() {
+ var dispatch = new d3_dispatch(), i = -1, n = arguments.length;
+ while (++i < n) dispatch[arguments[i]] = d3_dispatch_event(dispatch);
+ return dispatch;
+ };
+ function d3_dispatch() {}
+ d3_dispatch.prototype.on = function(type, listener) {
+ var i = type.indexOf("."), name = "";
+ if (i >= 0) {
+ name = type.substring(i + 1);
+ type = type.substring(0, i);
+ }
+ if (type) return arguments.length < 2 ? this[type].on(name) : this[type].on(name, listener);
+ if (arguments.length === 2) {
+ if (listener == null) for (type in this) {
+ if (this.hasOwnProperty(type)) this[type].on(name, null);
+ }
+ return this;
+ }
+ };
+ function d3_dispatch_event(dispatch) {
+ var listeners = [], listenerByName = new d3_Map();
+ function event() {
+ var z = listeners, i = -1, n = z.length, l;
+ while (++i < n) if (l = z[i].on) l.apply(this, arguments);
+ return dispatch;
+ }
+ event.on = function(name, listener) {
+ var l = listenerByName.get(name), i;
+ if (arguments.length < 2) return l && l.on;
+ if (l) {
+ l.on = null;
+ listeners = listeners.slice(0, i = listeners.indexOf(l)).concat(listeners.slice(i + 1));
+ listenerByName.remove(name);
+ }
+ if (listener) listeners.push(listenerByName.set(name, {
+ on: listener
+ }));
+ return dispatch;
+ };
+ return event;
+ }
+ d3.event = null;
+ function d3_eventPreventDefault() {
+ d3.event.preventDefault();
+ }
+ function d3_eventSource() {
+ var e = d3.event, s;
+ while (s = e.sourceEvent) e = s;
+ return e;
+ }
+ function d3_eventDispatch(target) {
+ var dispatch = new d3_dispatch(), i = 0, n = arguments.length;
+ while (++i < n) dispatch[arguments[i]] = d3_dispatch_event(dispatch);
+ dispatch.of = function(thiz, argumentz) {
+ return function(e1) {
+ try {
+ var e0 = e1.sourceEvent = d3.event;
+ e1.target = target;
+ d3.event = e1;
+ dispatch[e1.type].apply(thiz, argumentz);
+ } finally {
+ d3.event = e0;
+ }
+ };
+ };
+ return dispatch;
+ }
+ d3.requote = function(s) {
+ return s.replace(d3_requote_re, "\\$&");
+ };
+ var d3_requote_re = /[\\\^\$\*\+\?\|\[\]\(\)\.\{\}]/g;
+ var d3_subclass = {}.__proto__ ? function(object, prototype) {
+ object.__proto__ = prototype;
+ } : function(object, prototype) {
+ for (var property in prototype) object[property] = prototype[property];
+ };
+ function d3_selection(groups) {
+ d3_subclass(groups, d3_selectionPrototype);
+ return groups;
+ }
+ var d3_select = function(s, n) {
+ return n.querySelector(s);
+ }, d3_selectAll = function(s, n) {
+ return n.querySelectorAll(s);
+ }, d3_selectMatcher = d3_documentElement[d3_vendorSymbol(d3_documentElement, "matchesSelector")], d3_selectMatches = function(n, s) {
+ return d3_selectMatcher.call(n, s);
+ };
+ if (typeof Sizzle === "function") {
+ d3_select = function(s, n) {
+ return Sizzle(s, n)[0] || null;
+ };
+ d3_selectAll = function(s, n) {
+ return Sizzle.uniqueSort(Sizzle(s, n));
+ };
+ d3_selectMatches = Sizzle.matchesSelector;
+ }
+ d3.selection = function() {
+ return d3_selectionRoot;
+ };
+ var d3_selectionPrototype = d3.selection.prototype = [];
+ d3_selectionPrototype.select = function(selector) {
+ var subgroups = [], subgroup, subnode, group, node;
+ selector = d3_selection_selector(selector);
+ for (var j = -1, m = this.length; ++j < m; ) {
+ subgroups.push(subgroup = []);
+ subgroup.parentNode = (group = this[j]).parentNode;
+ for (var i = -1, n = group.length; ++i < n; ) {
+ if (node = group[i]) {
+ subgroup.push(subnode = selector.call(node, node.__data__, i, j));
+ if (subnode && "__data__" in node) subnode.__data__ = node.__data__;
+ } else {
+ subgroup.push(null);
+ }
+ }
+ }
+ return d3_selection(subgroups);
+ };
+ function d3_selection_selector(selector) {
+ return typeof selector === "function" ? selector : function() {
+ return d3_select(selector, this);
+ };
+ }
+ d3_selectionPrototype.selectAll = function(selector) {
+ var subgroups = [], subgroup, node;
+ selector = d3_selection_selectorAll(selector);
+ for (var j = -1, m = this.length; ++j < m; ) {
+ for (var group = this[j], i = -1, n = group.length; ++i < n; ) {
+ if (node = group[i]) {
+ subgroups.push(subgroup = d3_array(selector.call(node, node.__data__, i, j)));
+ subgroup.parentNode = node;
+ }
+ }
+ }
+ return d3_selection(subgroups);
+ };
+ function d3_selection_selectorAll(selector) {
+ return typeof selector === "function" ? selector : function() {
+ return d3_selectAll(selector, this);
+ };
+ }
+ var d3_nsPrefix = {
+ svg: "http://www.w3.org/2000/svg",
+ xhtml: "http://www.w3.org/1999/xhtml",
+ xlink: "http://www.w3.org/1999/xlink",
+ xml: "http://www.w3.org/XML/1998/namespace",
+ xmlns: "http://www.w3.org/2000/xmlns/"
+ };
+ d3.ns = {
+ prefix: d3_nsPrefix,
+ qualify: function(name) {
+ var i = name.indexOf(":"), prefix = name;
+ if (i >= 0) {
+ prefix = name.substring(0, i);
+ name = name.substring(i + 1);
+ }
+ return d3_nsPrefix.hasOwnProperty(prefix) ? {
+ space: d3_nsPrefix[prefix],
+ local: name
+ } : name;
+ }
+ };
+ d3_selectionPrototype.attr = function(name, value) {
+ if (arguments.length < 2) {
+ if (typeof name === "string") {
+ var node = this.node();
+ name = d3.ns.qualify(name);
+ return name.local ? node.getAttributeNS(name.space, name.local) : node.getAttribute(name);
+ }
+ for (value in name) this.each(d3_selection_attr(value, name[value]));
+ return this;
+ }
+ return this.each(d3_selection_attr(name, value));
+ };
+ function d3_selection_attr(name, value) {
+ name = d3.ns.qualify(name);
+ function attrNull() {
+ this.removeAttribute(name);
+ }
+ function attrNullNS() {
+ this.removeAttributeNS(name.space, name.local);
+ }
+ function attrConstant() {
+ this.setAttribute(name, value);
+ }
+ function attrConstantNS() {
+ this.setAttributeNS(name.space, name.local, value);
+ }
+ function attrFunction() {
+ var x = value.apply(this, arguments);
+ if (x == null) this.removeAttribute(name); else this.setAttribute(name, x);
+ }
+ function attrFunctionNS() {
+ var x = value.apply(this, arguments);
+ if (x == null) this.removeAttributeNS(name.space, name.local); else this.setAttributeNS(name.space, name.local, x);
+ }
+ return value == null ? name.local ? attrNullNS : attrNull : typeof value === "function" ? name.local ? attrFunctionNS : attrFunction : name.local ? attrConstantNS : attrConstant;
+ }
+ function d3_collapse(s) {
+ return s.trim().replace(/\s+/g, " ");
+ }
+ d3_selectionPrototype.classed = function(name, value) {
+ if (arguments.length < 2) {
+ if (typeof name === "string") {
+ var node = this.node(), n = (name = d3_selection_classes(name)).length, i = -1;
+ if (value = node.classList) {
+ while (++i < n) if (!value.contains(name[i])) return false;
+ } else {
+ value = node.getAttribute("class");
+ while (++i < n) if (!d3_selection_classedRe(name[i]).test(value)) return false;
+ }
+ return true;
+ }
+ for (value in name) this.each(d3_selection_classed(value, name[value]));
+ return this;
+ }
+ return this.each(d3_selection_classed(name, value));
+ };
+ function d3_selection_classedRe(name) {
+ return new RegExp("(?:^|\\s+)" + d3.requote(name) + "(?:\\s+|$)", "g");
+ }
+ function d3_selection_classes(name) {
+ return name.trim().split(/^|\s+/);
+ }
+ function d3_selection_classed(name, value) {
+ name = d3_selection_classes(name).map(d3_selection_classedName);
+ var n = name.length;
+ function classedConstant() {
+ var i = -1;
+ while (++i < n) name[i](this, value);
+ }
+ function classedFunction() {
+ var i = -1, x = value.apply(this, arguments);
+ while (++i < n) name[i](this, x);
+ }
+ return typeof value === "function" ? classedFunction : classedConstant;
+ }
+ function d3_selection_classedName(name) {
+ var re = d3_selection_classedRe(name);
+ return function(node, value) {
+ if (c = node.classList) return value ? c.add(name) : c.remove(name);
+ var c = node.getAttribute("class") || "";
+ if (value) {
+ re.lastIndex = 0;
+ if (!re.test(c)) node.setAttribute("class", d3_collapse(c + " " + name));
+ } else {
+ node.setAttribute("class", d3_collapse(c.replace(re, " ")));
+ }
+ };
+ }
+ d3_selectionPrototype.style = function(name, value, priority) {
+ var n = arguments.length;
+ if (n < 3) {
+ if (typeof name !== "string") {
+ if (n < 2) value = "";
+ for (priority in name) this.each(d3_selection_style(priority, name[priority], value));
+ return this;
+ }
+ if (n < 2) return d3_window.getComputedStyle(this.node(), null).getPropertyValue(name);
+ priority = "";
+ }
+ return this.each(d3_selection_style(name, value, priority));
+ };
+ function d3_selection_style(name, value, priority) {
+ function styleNull() {
+ this.style.removeProperty(name);
+ }
+ function styleConstant() {
+ this.style.setProperty(name, value, priority);
+ }
+ function styleFunction() {
+ var x = value.apply(this, arguments);
+ if (x == null) this.style.removeProperty(name); else this.style.setProperty(name, x, priority);
+ }
+ return value == null ? styleNull : typeof value === "function" ? styleFunction : styleConstant;
+ }
+ d3_selectionPrototype.property = function(name, value) {
+ if (arguments.length < 2) {
+ if (typeof name === "string") return this.node()[name];
+ for (value in name) this.each(d3_selection_property(value, name[value]));
+ return this;
+ }
+ return this.each(d3_selection_property(name, value));
+ };
+ function d3_selection_property(name, value) {
+ function propertyNull() {
+ delete this[name];
+ }
+ function propertyConstant() {
+ this[name] = value;
+ }
+ function propertyFunction() {
+ var x = value.apply(this, arguments);
+ if (x == null) delete this[name]; else this[name] = x;
+ }
+ return value == null ? propertyNull : typeof value === "function" ? propertyFunction : propertyConstant;
+ }
+ d3_selectionPrototype.text = function(value) {
+ return arguments.length ? this.each(typeof value === "function" ? function() {
+ var v = value.apply(this, arguments);
+ this.textContent = v == null ? "" : v;
+ } : value == null ? function() {
+ this.textContent = "";
+ } : function() {
+ this.textContent = value;
+ }) : this.node().textContent;
+ };
+ d3_selectionPrototype.html = function(value) {
+ return arguments.length ? this.each(typeof value === "function" ? function() {
+ var v = value.apply(this, arguments);
+ this.innerHTML = v == null ? "" : v;
+ } : value == null ? function() {
+ this.innerHTML = "";
+ } : function() {
+ this.innerHTML = value;
+ }) : this.node().innerHTML;
+ };
+ d3_selectionPrototype.append = function(name) {
+ name = d3_selection_creator(name);
+ return this.select(function() {
+ return this.appendChild(name.apply(this, arguments));
+ });
+ };
+ function d3_selection_creator(name) {
+ return typeof name === "function" ? name : (name = d3.ns.qualify(name)).local ? function() {
+ return this.ownerDocument.createElementNS(name.space, name.local);
+ } : function() {
+ return this.ownerDocument.createElementNS(this.namespaceURI, name);
+ };
+ }
+ d3_selectionPrototype.insert = function(name, before) {
+ name = d3_selection_creator(name);
+ before = d3_selection_selector(before);
+ return this.select(function() {
+ return this.insertBefore(name.apply(this, arguments), before.apply(this, arguments) || null);
+ });
+ };
+ d3_selectionPrototype.remove = function() {
+ return this.each(function() {
+ var parent = this.parentNode;
+ if (parent) parent.removeChild(this);
+ });
+ };
+ d3_selectionPrototype.data = function(value, key) {
+ var i = -1, n = this.length, group, node;
+ if (!arguments.length) {
+ value = new Array(n = (group = this[0]).length);
+ while (++i < n) {
+ if (node = group[i]) {
+ value[i] = node.__data__;
+ }
+ }
+ return value;
+ }
+ function bind(group, groupData) {
+ var i, n = group.length, m = groupData.length, n0 = Math.min(n, m), updateNodes = new Array(m), enterNodes = new Array(m), exitNodes = new Array(n), node, nodeData;
+ if (key) {
+ var nodeByKeyValue = new d3_Map(), dataByKeyValue = new d3_Map(), keyValues = [], keyValue;
+ for (i = -1; ++i < n; ) {
+ keyValue = key.call(node = group[i], node.__data__, i);
+ if (nodeByKeyValue.has(keyValue)) {
+ exitNodes[i] = node;
+ } else {
+ nodeByKeyValue.set(keyValue, node);
+ }
+ keyValues.push(keyValue);
+ }
+ for (i = -1; ++i < m; ) {
+ keyValue = key.call(groupData, nodeData = groupData[i], i);
+ if (node = nodeByKeyValue.get(keyValue)) {
+ updateNodes[i] = node;
+ node.__data__ = nodeData;
+ } else if (!dataByKeyValue.has(keyValue)) {
+ enterNodes[i] = d3_selection_dataNode(nodeData);
+ }
+ dataByKeyValue.set(keyValue, nodeData);
+ nodeByKeyValue.remove(keyValue);
+ }
+ for (i = -1; ++i < n; ) {
+ if (nodeByKeyValue.has(keyValues[i])) {
+ exitNodes[i] = group[i];
+ }
+ }
+ } else {
+ for (i = -1; ++i < n0; ) {
+ node = group[i];
+ nodeData = groupData[i];
+ if (node) {
+ node.__data__ = nodeData;
+ updateNodes[i] = node;
+ } else {
+ enterNodes[i] = d3_selection_dataNode(nodeData);
+ }
+ }
+ for (;i < m; ++i) {
+ enterNodes[i] = d3_selection_dataNode(groupData[i]);
+ }
+ for (;i < n; ++i) {
+ exitNodes[i] = group[i];
+ }
+ }
+ enterNodes.update = updateNodes;
+ enterNodes.parentNode = updateNodes.parentNode = exitNodes.parentNode = group.parentNode;
+ enter.push(enterNodes);
+ update.push(updateNodes);
+ exit.push(exitNodes);
+ }
+ var enter = d3_selection_enter([]), update = d3_selection([]), exit = d3_selection([]);
+ if (typeof value === "function") {
+ while (++i < n) {
+ bind(group = this[i], value.call(group, group.parentNode.__data__, i));
+ }
+ } else {
+ while (++i < n) {
+ bind(group = this[i], value);
+ }
+ }
+ update.enter = function() {
+ return enter;
+ };
+ update.exit = function() {
+ return exit;
+ };
+ return update;
+ };
+ function d3_selection_dataNode(data) {
+ return {
+ __data__: data
+ };
+ }
+ d3_selectionPrototype.datum = function(value) {
+ return arguments.length ? this.property("__data__", value) : this.property("__data__");
+ };
+ d3_selectionPrototype.filter = function(filter) {
+ var subgroups = [], subgroup, group, node;
+ if (typeof filter !== "function") filter = d3_selection_filter(filter);
+ for (var j = 0, m = this.length; j < m; j++) {
+ subgroups.push(subgroup = []);
+ subgroup.parentNode = (group = this[j]).parentNode;
+ for (var i = 0, n = group.length; i < n; i++) {
+ if ((node = group[i]) && filter.call(node, node.__data__, i, j)) {
+ subgroup.push(node);
+ }
+ }
+ }
+ return d3_selection(subgroups);
+ };
+ function d3_selection_filter(selector) {
+ return function() {
+ return d3_selectMatches(this, selector);
+ };
+ }
+ d3_selectionPrototype.order = function() {
+ for (var j = -1, m = this.length; ++j < m; ) {
+ for (var group = this[j], i = group.length - 1, next = group[i], node; --i >= 0; ) {
+ if (node = group[i]) {
+ if (next && next !== node.nextSibling) next.parentNode.insertBefore(node, next);
+ next = node;
+ }
+ }
+ }
+ return this;
+ };
+ d3_selectionPrototype.sort = function(comparator) {
+ comparator = d3_selection_sortComparator.apply(this, arguments);
+ for (var j = -1, m = this.length; ++j < m; ) this[j].sort(comparator);
+ return this.order();
+ };
+ function d3_selection_sortComparator(comparator) {
+ if (!arguments.length) comparator = d3.ascending;
+ return function(a, b) {
+ return a && b ? comparator(a.__data__, b.__data__) : !a - !b;
+ };
+ }
+ d3_selectionPrototype.each = function(callback) {
+ return d3_selection_each(this, function(node, i, j) {
+ callback.call(node, node.__data__, i, j);
+ });
+ };
+ function d3_selection_each(groups, callback) {
+ for (var j = 0, m = groups.length; j < m; j++) {
+ for (var group = groups[j], i = 0, n = group.length, node; i < n; i++) {
+ if (node = group[i]) callback(node, i, j);
+ }
+ }
+ return groups;
+ }
+ d3_selectionPrototype.call = function(callback) {
+ var args = d3_array(arguments);
+ callback.apply(args[0] = this, args);
+ return this;
+ };
+ d3_selectionPrototype.empty = function() {
+ return !this.node();
+ };
+ d3_selectionPrototype.node = function() {
+ for (var j = 0, m = this.length; j < m; j++) {
+ for (var group = this[j], i = 0, n = group.length; i < n; i++) {
+ var node = group[i];
+ if (node) return node;
+ }
+ }
+ return null;
+ };
+ d3_selectionPrototype.size = function() {
+ var n = 0;
+ this.each(function() {
+ ++n;
+ });
+ return n;
+ };
+ function d3_selection_enter(selection) {
+ d3_subclass(selection, d3_selection_enterPrototype);
+ return selection;
+ }
+ var d3_selection_enterPrototype = [];
+ d3.selection.enter = d3_selection_enter;
+ d3.selection.enter.prototype = d3_selection_enterPrototype;
+ d3_selection_enterPrototype.append = d3_selectionPrototype.append;
+ d3_selection_enterPrototype.empty = d3_selectionPrototype.empty;
+ d3_selection_enterPrototype.node = d3_selectionPrototype.node;
+ d3_selection_enterPrototype.call = d3_selectionPrototype.call;
+ d3_selection_enterPrototype.size = d3_selectionPrototype.size;
+ d3_selection_enterPrototype.select = function(selector) {
+ var subgroups = [], subgroup, subnode, upgroup, group, node;
+ for (var j = -1, m = this.length; ++j < m; ) {
+ upgroup = (group = this[j]).update;
+ subgroups.push(subgroup = []);
+ subgroup.parentNode = group.parentNode;
+ for (var i = -1, n = group.length; ++i < n; ) {
+ if (node = group[i]) {
+ subgroup.push(upgroup[i] = subnode = selector.call(group.parentNode, node.__data__, i, j));
+ subnode.__data__ = node.__data__;
+ } else {
+ subgroup.push(null);
+ }
+ }
+ }
+ return d3_selection(subgroups);
+ };
+ d3_selection_enterPrototype.insert = function(name, before) {
+ if (arguments.length < 2) before = d3_selection_enterInsertBefore(this);
+ return d3_selectionPrototype.insert.call(this, name, before);
+ };
+ function d3_selection_enterInsertBefore(enter) {
+ var i0, j0;
+ return function(d, i, j) {
+ var group = enter[j].update, n = group.length, node;
+ if (j != j0) j0 = j, i0 = 0;
+ if (i >= i0) i0 = i + 1;
+ while (!(node = group[i0]) && ++i0 < n) ;
+ return node;
+ };
+ }
+ d3_selectionPrototype.transition = function() {
+ var id = d3_transitionInheritId || ++d3_transitionId, subgroups = [], subgroup, node, transition = d3_transitionInherit || {
+ time: Date.now(),
+ ease: d3_ease_cubicInOut,
+ delay: 0,
+ duration: 250
+ };
+ for (var j = -1, m = this.length; ++j < m; ) {
+ subgroups.push(subgroup = []);
+ for (var group = this[j], i = -1, n = group.length; ++i < n; ) {
+ if (node = group[i]) d3_transitionNode(node, i, id, transition);
+ subgroup.push(node);
+ }
+ }
+ return d3_transition(subgroups, id);
+ };
+ d3_selectionPrototype.interrupt = function() {
+ return this.each(d3_selection_interrupt);
+ };
+ function d3_selection_interrupt() {
+ var lock = this.__transition__;
+ if (lock) ++lock.active;
+ }
+ d3.select = function(node) {
+ var group = [ typeof node === "string" ? d3_select(node, d3_document) : node ];
+ group.parentNode = d3_documentElement;
+ return d3_selection([ group ]);
+ };
+ d3.selectAll = function(nodes) {
+ var group = d3_array(typeof nodes === "string" ? d3_selectAll(nodes, d3_document) : nodes);
+ group.parentNode = d3_documentElement;
+ return d3_selection([ group ]);
+ };
+ var d3_selectionRoot = d3.select(d3_documentElement);
+ d3_selectionPrototype.on = function(type, listener, capture) {
+ var n = arguments.length;
+ if (n < 3) {
+ if (typeof type !== "string") {
+ if (n < 2) listener = false;
+ for (capture in type) this.each(d3_selection_on(capture, type[capture], listener));
+ return this;
+ }
+ if (n < 2) return (n = this.node()["__on" + type]) && n._;
+ capture = false;
+ }
+ return this.each(d3_selection_on(type, listener, capture));
+ };
+ function d3_selection_on(type, listener, capture) {
+ var name = "__on" + type, i = type.indexOf("."), wrap = d3_selection_onListener;
+ if (i > 0) type = type.substring(0, i);
+ var filter = d3_selection_onFilters.get(type);
+ if (filter) type = filter, wrap = d3_selection_onFilter;
+ function onRemove() {
+ var l = this[name];
+ if (l) {
+ this.removeEventListener(type, l, l.$);
+ delete this[name];
+ }
+ }
+ function onAdd() {
+ var l = wrap(listener, d3_array(arguments));
+ onRemove.call(this);
+ this.addEventListener(type, this[name] = l, l.$ = capture);
+ l._ = listener;
+ }
+ function removeAll() {
+ var re = new RegExp("^__on([^.]+)" + d3.requote(type) + "$"), match;
+ for (var name in this) {
+ if (match = name.match(re)) {
+ var l = this[name];
+ this.removeEventListener(match[1], l, l.$);
+ delete this[name];
+ }
+ }
+ }
+ return i ? listener ? onAdd : onRemove : listener ? d3_noop : removeAll;
+ }
+ var d3_selection_onFilters = d3.map({
+ mouseenter: "mouseover",
+ mouseleave: "mouseout"
+ });
+ d3_selection_onFilters.forEach(function(k) {
+ if ("on" + k in d3_document) d3_selection_onFilters.remove(k);
+ });
+ function d3_selection_onListener(listener, argumentz) {
+ return function(e) {
+ var o = d3.event;
+ d3.event = e;
+ argumentz[0] = this.__data__;
+ try {
+ listener.apply(this, argumentz);
+ } finally {
+ d3.event = o;
+ }
+ };
+ }
+ function d3_selection_onFilter(listener, argumentz) {
+ var l = d3_selection_onListener(listener, argumentz);
+ return function(e) {
+ var target = this, related = e.relatedTarget;
+ if (!related || related !== target && !(related.compareDocumentPosition(target) & 8)) {
+ l.call(target, e);
+ }
+ };
+ }
+ var d3_event_dragSelect = "onselectstart" in d3_document ? null : d3_vendorSymbol(d3_documentElement.style, "userSelect"), d3_event_dragId = 0;
+ function d3_event_dragSuppress() {
+ var name = ".dragsuppress-" + ++d3_event_dragId, click = "click" + name, w = d3.select(d3_window).on("touchmove" + name, d3_eventPreventDefault).on("dragstart" + name, d3_eventPreventDefault).on("selectstart" + name, d3_eventPreventDefault);
+ if (d3_event_dragSelect) {
+ var style = d3_documentElement.style, select = style[d3_event_dragSelect];
+ style[d3_event_dragSelect] = "none";
+ }
+ return function(suppressClick) {
+ w.on(name, null);
+ if (d3_event_dragSelect) style[d3_event_dragSelect] = select;
+ if (suppressClick) {
+ function off() {
+ w.on(click, null);
+ }
+ w.on(click, function() {
+ d3_eventPreventDefault();
+ off();
+ }, true);
+ setTimeout(off, 0);
+ }
+ };
+ }
+ d3.mouse = function(container) {
+ return d3_mousePoint(container, d3_eventSource());
+ };
+ var d3_mouse_bug44083 = /WebKit/.test(d3_window.navigator.userAgent) ? -1 : 0;
+ function d3_mousePoint(container, e) {
+ if (e.changedTouches) e = e.changedTouches[0];
+ var svg = container.ownerSVGElement || container;
+ if (svg.createSVGPoint) {
+ var point = svg.createSVGPoint();
+ if (d3_mouse_bug44083 < 0 && (d3_window.scrollX || d3_window.scrollY)) {
+ svg = d3.select("body").append("svg").style({
+ position: "absolute",
+ top: 0,
+ left: 0,
+ margin: 0,
+ padding: 0,
+ border: "none"
+ }, "important");
+ var ctm = svg[0][0].getScreenCTM();
+ d3_mouse_bug44083 = !(ctm.f || ctm.e);
+ svg.remove();
+ }
+ if (d3_mouse_bug44083) point.x = e.pageX, point.y = e.pageY; else point.x = e.clientX,
+ point.y = e.clientY;
+ point = point.matrixTransform(container.getScreenCTM().inverse());
+ return [ point.x, point.y ];
+ }
+ var rect = container.getBoundingClientRect();
+ return [ e.clientX - rect.left - container.clientLeft, e.clientY - rect.top - container.clientTop ];
+ }
+ d3.touches = function(container, touches) {
+ if (arguments.length < 2) touches = d3_eventSource().touches;
+ return touches ? d3_array(touches).map(function(touch) {
+ var point = d3_mousePoint(container, touch);
+ point.identifier = touch.identifier;
+ return point;
+ }) : [];
+ };
+ d3.behavior.drag = function() {
+ var event = d3_eventDispatch(drag, "drag", "dragstart", "dragend"), origin = null, mousedown = dragstart(d3_noop, d3.mouse, "mousemove", "mouseup"), touchstart = dragstart(touchid, touchposition, "touchmove", "touchend");
+ function drag() {
+ this.on("mousedown.drag", mousedown).on("touchstart.drag", touchstart);
+ }
+ function touchid() {
+ return d3.event.changedTouches[0].identifier;
+ }
+ function touchposition(parent, id) {
+ return d3.touches(parent).filter(function(p) {
+ return p.identifier === id;
+ })[0];
+ }
+ function dragstart(id, position, move, end) {
+ return function() {
+ var target = this, parent = target.parentNode, event_ = event.of(target, arguments), eventTarget = d3.event.target, eventId = id(), drag = eventId == null ? "drag" : "drag-" + eventId, origin_ = position(parent, eventId), dragged = 0, offset, w = d3.select(d3_window).on(move + "." + drag, moved).on(end + "." + drag, ended), dragRestore = d3_event_dragSuppress();
+ if (origin) {
+ offset = origin.apply(target, arguments);
+ offset = [ offset.x - origin_[0], offset.y - origin_[1] ];
+ } else {
+ offset = [ 0, 0 ];
+ }
+ event_({
+ type: "dragstart"
+ });
+ function moved() {
+ var p = position(parent, eventId), dx = p[0] - origin_[0], dy = p[1] - origin_[1];
+ dragged |= dx | dy;
+ origin_ = p;
+ event_({
+ type: "drag",
+ x: p[0] + offset[0],
+ y: p[1] + offset[1],
+ dx: dx,
+ dy: dy
+ });
+ }
+ function ended() {
+ w.on(move + "." + drag, null).on(end + "." + drag, null);
+ dragRestore(dragged && d3.event.target === eventTarget);
+ event_({
+ type: "dragend"
+ });
+ }
+ };
+ }
+ drag.origin = function(x) {
+ if (!arguments.length) return origin;
+ origin = x;
+ return drag;
+ };
+ return d3.rebind(drag, event, "on");
+ };
+ var π = Math.PI, τ = 2 * π, halfπ = π / 2, ε = 1e-6, ε2 = ε * ε, d3_radians = π / 180, d3_degrees = 180 / π;
+ function d3_sgn(x) {
+ return x > 0 ? 1 : x < 0 ? -1 : 0;
+ }
+ function d3_cross2d(a, b, c) {
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
+ }
+ function d3_acos(x) {
+ return x > 1 ? 0 : x < -1 ? π : Math.acos(x);
+ }
+ function d3_asin(x) {
+ return x > 1 ? halfπ : x < -1 ? -halfπ : Math.asin(x);
+ }
+ function d3_sinh(x) {
+ return ((x = Math.exp(x)) - 1 / x) / 2;
+ }
+ function d3_cosh(x) {
+ return ((x = Math.exp(x)) + 1 / x) / 2;
+ }
+ function d3_tanh(x) {
+ return ((x = Math.exp(2 * x)) - 1) / (x + 1);
+ }
+ function d3_haversin(x) {
+ return (x = Math.sin(x / 2)) * x;
+ }
+ var ρ = Math.SQRT2, ρ2 = 2, ρ4 = 4;
+ d3.interpolateZoom = function(p0, p1) {
+ var ux0 = p0[0], uy0 = p0[1], w0 = p0[2], ux1 = p1[0], uy1 = p1[1], w1 = p1[2];
+ var dx = ux1 - ux0, dy = uy1 - uy0, d2 = dx * dx + dy * dy, d1 = Math.sqrt(d2), b0 = (w1 * w1 - w0 * w0 + ρ4 * d2) / (2 * w0 * ρ2 * d1), b1 = (w1 * w1 - w0 * w0 - ρ4 * d2) / (2 * w1 * ρ2 * d1), r0 = Math.log(Math.sqrt(b0 * b0 + 1) - b0), r1 = Math.log(Math.sqrt(b1 * b1 + 1) - b1), dr = r1 - r0, S = (dr || Math.log(w1 / w0)) / ρ;
+ function interpolate(t) {
+ var s = t * S;
+ if (dr) {
+ var coshr0 = d3_cosh(r0), u = w0 / (ρ2 * d1) * (coshr0 * d3_tanh(ρ * s + r0) - d3_sinh(r0));
+ return [ ux0 + u * dx, uy0 + u * dy, w0 * coshr0 / d3_cosh(ρ * s + r0) ];
+ }
+ return [ ux0 + t * dx, uy0 + t * dy, w0 * Math.exp(ρ * s) ];
+ }
+ interpolate.duration = S * 1e3;
+ return interpolate;
+ };
+ d3.behavior.zoom = function() {
+ var view = {
+ x: 0,
+ y: 0,
+ k: 1
+ }, translate0, center, size = [ 960, 500 ], scaleExtent = d3_behavior_zoomInfinity, mousedown = "mousedown.zoom", mousemove = "mousemove.zoom", mouseup = "mouseup.zoom", mousewheelTimer, touchstart = "touchstart.zoom", touchtime, event = d3_eventDispatch(zoom, "zoomstart", "zoom", "zoomend"), x0, x1, y0, y1;
+ function zoom(g) {
+ g.on(mousedown, mousedowned).on(d3_behavior_zoomWheel + ".zoom", mousewheeled).on(mousemove, mousewheelreset).on("dblclick.zoom", dblclicked).on(touchstart, touchstarted);
+ }
+ zoom.event = function(g) {
+ g.each(function() {
+ var event_ = event.of(this, arguments), view1 = view;
+ if (d3_transitionInheritId) {
+ d3.select(this).transition().each("start.zoom", function() {
+ view = this.__chart__ || {
+ x: 0,
+ y: 0,
+ k: 1
+ };
+ zoomstarted(event_);
+ }).tween("zoom:zoom", function() {
+ var dx = size[0], dy = size[1], cx = dx / 2, cy = dy / 2, i = d3.interpolateZoom([ (cx - view.x) / view.k, (cy - view.y) / view.k, dx / view.k ], [ (cx - view1.x) / view1.k, (cy - view1.y) / view1.k, dx / view1.k ]);
+ return function(t) {
+ var l = i(t), k = dx / l[2];
+ this.__chart__ = view = {
+ x: cx - l[0] * k,
+ y: cy - l[1] * k,
+ k: k
+ };
+ zoomed(event_);
+ };
+ }).each("end.zoom", function() {
+ zoomended(event_);
+ });
+ } else {
+ this.__chart__ = view;
+ zoomstarted(event_);
+ zoomed(event_);
+ zoomended(event_);
+ }
+ });
+ };
+ zoom.translate = function(_) {
+ if (!arguments.length) return [ view.x, view.y ];
+ view = {
+ x: +_[0],
+ y: +_[1],
+ k: view.k
+ };
+ rescale();
+ return zoom;
+ };
+ zoom.scale = function(_) {
+ if (!arguments.length) return view.k;
+ view = {
+ x: view.x,
+ y: view.y,
+ k: +_
+ };
+ rescale();
+ return zoom;
+ };
+ zoom.scaleExtent = function(_) {
+ if (!arguments.length) return scaleExtent;
+ scaleExtent = _ == null ? d3_behavior_zoomInfinity : [ +_[0], +_[1] ];
+ return zoom;
+ };
+ zoom.center = function(_) {
+ if (!arguments.length) return center;
+ center = _ && [ +_[0], +_[1] ];
+ return zoom;
+ };
+ zoom.size = function(_) {
+ if (!arguments.length) return size;
+ size = _ && [ +_[0], +_[1] ];
+ return zoom;
+ };
+ zoom.x = function(z) {
+ if (!arguments.length) return x1;
+ x1 = z;
+ x0 = z.copy();
+ view = {
+ x: 0,
+ y: 0,
+ k: 1
+ };
+ return zoom;
+ };
+ zoom.y = function(z) {
+ if (!arguments.length) return y1;
+ y1 = z;
+ y0 = z.copy();
+ view = {
+ x: 0,
+ y: 0,
+ k: 1
+ };
+ return zoom;
+ };
+ function location(p) {
+ return [ (p[0] - view.x) / view.k, (p[1] - view.y) / view.k ];
+ }
+ function point(l) {
+ return [ l[0] * view.k + view.x, l[1] * view.k + view.y ];
+ }
+ function scaleTo(s) {
+ view.k = Math.max(scaleExtent[0], Math.min(scaleExtent[1], s));
+ }
+ function translateTo(p, l) {
+ l = point(l);
+ view.x += p[0] - l[0];
+ view.y += p[1] - l[1];
+ }
+ function rescale() {
+ if (x1) x1.domain(x0.range().map(function(x) {
+ return (x - view.x) / view.k;
+ }).map(x0.invert));
+ if (y1) y1.domain(y0.range().map(function(y) {
+ return (y - view.y) / view.k;
+ }).map(y0.invert));
+ }
+ function zoomstarted(event) {
+ event({
+ type: "zoomstart"
+ });
+ }
+ function zoomed(event) {
+ rescale();
+ event({
+ type: "zoom",
+ scale: view.k,
+ translate: [ view.x, view.y ]
+ });
+ }
+ function zoomended(event) {
+ event({
+ type: "zoomend"
+ });
+ }
+ function mousedowned() {
+ var target = this, event_ = event.of(target, arguments), eventTarget = d3.event.target, dragged = 0, w = d3.select(d3_window).on(mousemove, moved).on(mouseup, ended), l = location(d3.mouse(target)), dragRestore = d3_event_dragSuppress();
+ d3_selection_interrupt.call(target);
+ zoomstarted(event_);
+ function moved() {
+ dragged = 1;
+ translateTo(d3.mouse(target), l);
+ zoomed(event_);
+ }
+ function ended() {
+ w.on(mousemove, d3_window === target ? mousewheelreset : null).on(mouseup, null);
+ dragRestore(dragged && d3.event.target === eventTarget);
+ zoomended(event_);
+ }
+ }
+ function touchstarted() {
+ var target = this, event_ = event.of(target, arguments), locations0 = {}, distance0 = 0, scale0, eventId = d3.event.changedTouches[0].identifier, touchmove = "touchmove.zoom-" + eventId, touchend = "touchend.zoom-" + eventId, w = d3.select(d3_window).on(touchmove, moved).on(touchend, ended), t = d3.select(target).on(mousedown, null).on(touchstart, started), dragRestore = d3_event_dragSuppress();
+ d3_selection_interrupt.call(target);
+ started();
+ zoomstarted(event_);
+ function relocate() {
+ var touches = d3.touches(target);
+ scale0 = view.k;
+ touches.forEach(function(t) {
+ if (t.identifier in locations0) locations0[t.identifier] = location(t);
+ });
+ return touches;
+ }
+ function started() {
+ var changed = d3.event.changedTouches;
+ for (var i = 0, n = changed.length; i < n; ++i) {
+ locations0[changed[i].identifier] = null;
+ }
+ var touches = relocate(), now = Date.now();
+ if (touches.length === 1) {
+ if (now - touchtime < 500) {
+ var p = touches[0], l = locations0[p.identifier];
+ scaleTo(view.k * 2);
+ translateTo(p, l);
+ d3_eventPreventDefault();
+ zoomed(event_);
+ }
+ touchtime = now;
+ } else if (touches.length > 1) {
+ var p = touches[0], q = touches[1], dx = p[0] - q[0], dy = p[1] - q[1];
+ distance0 = dx * dx + dy * dy;
+ }
+ }
+ function moved() {
+ var touches = d3.touches(target), p0, l0, p1, l1;
+ for (var i = 0, n = touches.length; i < n; ++i, l1 = null) {
+ p1 = touches[i];
+ if (l1 = locations0[p1.identifier]) {
+ if (l0) break;
+ p0 = p1, l0 = l1;
+ }
+ }
+ if (l1) {
+ var distance1 = (distance1 = p1[0] - p0[0]) * distance1 + (distance1 = p1[1] - p0[1]) * distance1, scale1 = distance0 && Math.sqrt(distance1 / distance0);
+ p0 = [ (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2 ];
+ l0 = [ (l0[0] + l1[0]) / 2, (l0[1] + l1[1]) / 2 ];
+ scaleTo(scale1 * scale0);
+ }
+ touchtime = null;
+ translateTo(p0, l0);
+ zoomed(event_);
+ }
+ function ended() {
+ if (d3.event.touches.length) {
+ var changed = d3.event.changedTouches;
+ for (var i = 0, n = changed.length; i < n; ++i) {
+ delete locations0[changed[i].identifier];
+ }
+ for (var identifier in locations0) {
+ return void relocate();
+ }
+ }
+ w.on(touchmove, null).on(touchend, null);
+ t.on(mousedown, mousedowned).on(touchstart, touchstarted);
+ dragRestore();
+ zoomended(event_);
+ }
+ }
+ function mousewheeled() {
+ var event_ = event.of(this, arguments);
+ if (mousewheelTimer) clearTimeout(mousewheelTimer); else d3_selection_interrupt.call(this),
+ zoomstarted(event_);
+ mousewheelTimer = setTimeout(function() {
+ mousewheelTimer = null;
+ zoomended(event_);
+ }, 50);
+ d3_eventPreventDefault();
+ var point = center || d3.mouse(this);
+ if (!translate0) translate0 = location(point);
+ scaleTo(Math.pow(2, d3_behavior_zoomDelta() * .002) * view.k);
+ translateTo(point, translate0);
+ zoomed(event_);
+ }
+ function mousewheelreset() {
+ translate0 = null;
+ }
+ function dblclicked() {
+ var event_ = event.of(this, arguments), p = d3.mouse(this), l = location(p), k = Math.log(view.k) / Math.LN2;
+ zoomstarted(event_);
+ scaleTo(Math.pow(2, d3.event.shiftKey ? Math.ceil(k) - 1 : Math.floor(k) + 1));
+ translateTo(p, l);
+ zoomed(event_);
+ zoomended(event_);
+ }
+ return d3.rebind(zoom, event, "on");
+ };
+ var d3_behavior_zoomInfinity = [ 0, Infinity ];
+ var d3_behavior_zoomDelta, d3_behavior_zoomWheel = "onwheel" in d3_document ? (d3_behavior_zoomDelta = function() {
+ return -d3.event.deltaY * (d3.event.deltaMode ? 120 : 1);
+ }, "wheel") : "onmousewheel" in d3_document ? (d3_behavior_zoomDelta = function() {
+ return d3.event.wheelDelta;
+ }, "mousewheel") : (d3_behavior_zoomDelta = function() {
+ return -d3.event.detail;
+ }, "MozMousePixelScroll");
+ function d3_Color() {}
+ d3_Color.prototype.toString = function() {
+ return this.rgb() + "";
+ };
+ d3.hsl = function(h, s, l) {
+ return arguments.length === 1 ? h instanceof d3_Hsl ? d3_hsl(h.h, h.s, h.l) : d3_rgb_parse("" + h, d3_rgb_hsl, d3_hsl) : d3_hsl(+h, +s, +l);
+ };
+ function d3_hsl(h, s, l) {
+ return new d3_Hsl(h, s, l);
+ }
+ function d3_Hsl(h, s, l) {
+ this.h = h;
+ this.s = s;
+ this.l = l;
+ }
+ var d3_hslPrototype = d3_Hsl.prototype = new d3_Color();
+ d3_hslPrototype.brighter = function(k) {
+ k = Math.pow(.7, arguments.length ? k : 1);
+ return d3_hsl(this.h, this.s, this.l / k);
+ };
+ d3_hslPrototype.darker = function(k) {
+ k = Math.pow(.7, arguments.length ? k : 1);
+ return d3_hsl(this.h, this.s, k * this.l);
+ };
+ d3_hslPrototype.rgb = function() {
+ return d3_hsl_rgb(this.h, this.s, this.l);
+ };
+ function d3_hsl_rgb(h, s, l) {
+ var m1, m2;
+ h = isNaN(h) ? 0 : (h %= 360) < 0 ? h + 360 : h;
+ s = isNaN(s) ? 0 : s < 0 ? 0 : s > 1 ? 1 : s;
+ l = l < 0 ? 0 : l > 1 ? 1 : l;
+ m2 = l <= .5 ? l * (1 + s) : l + s - l * s;
+ m1 = 2 * l - m2;
+ function v(h) {
+ if (h > 360) h -= 360; else if (h < 0) h += 360;
+ if (h < 60) return m1 + (m2 - m1) * h / 60;
+ if (h < 180) return m2;
+ if (h < 240) return m1 + (m2 - m1) * (240 - h) / 60;
+ return m1;
+ }
+ function vv(h) {
+ return Math.round(v(h) * 255);
+ }
+ return d3_rgb(vv(h + 120), vv(h), vv(h - 120));
+ }
+ d3.hcl = function(h, c, l) {
+ return arguments.length === 1 ? h instanceof d3_Hcl ? d3_hcl(h.h, h.c, h.l) : h instanceof d3_Lab ? d3_lab_hcl(h.l, h.a, h.b) : d3_lab_hcl((h = d3_rgb_lab((h = d3.rgb(h)).r, h.g, h.b)).l, h.a, h.b) : d3_hcl(+h, +c, +l);
+ };
+ function d3_hcl(h, c, l) {
+ return new d3_Hcl(h, c, l);
+ }
+ function d3_Hcl(h, c, l) {
+ this.h = h;
+ this.c = c;
+ this.l = l;
+ }
+ var d3_hclPrototype = d3_Hcl.prototype = new d3_Color();
+ d3_hclPrototype.brighter = function(k) {
+ return d3_hcl(this.h, this.c, Math.min(100, this.l + d3_lab_K * (arguments.length ? k : 1)));
+ };
+ d3_hclPrototype.darker = function(k) {
+ return d3_hcl(this.h, this.c, Math.max(0, this.l - d3_lab_K * (arguments.length ? k : 1)));
+ };
+ d3_hclPrototype.rgb = function() {
+ return d3_hcl_lab(this.h, this.c, this.l).rgb();
+ };
+ function d3_hcl_lab(h, c, l) {
+ if (isNaN(h)) h = 0;
+ if (isNaN(c)) c = 0;
+ return d3_lab(l, Math.cos(h *= d3_radians) * c, Math.sin(h) * c);
+ }
+ d3.lab = function(l, a, b) {
+ return arguments.length === 1 ? l instanceof d3_Lab ? d3_lab(l.l, l.a, l.b) : l instanceof d3_Hcl ? d3_hcl_lab(l.l, l.c, l.h) : d3_rgb_lab((l = d3.rgb(l)).r, l.g, l.b) : d3_lab(+l, +a, +b);
+ };
+ function d3_lab(l, a, b) {
+ return new d3_Lab(l, a, b);
+ }
+ function d3_Lab(l, a, b) {
+ this.l = l;
+ this.a = a;
+ this.b = b;
+ }
+ var d3_lab_K = 18;
+ var d3_lab_X = .95047, d3_lab_Y = 1, d3_lab_Z = 1.08883;
+ var d3_labPrototype = d3_Lab.prototype = new d3_Color();
+ d3_labPrototype.brighter = function(k) {
+ return d3_lab(Math.min(100, this.l + d3_lab_K * (arguments.length ? k : 1)), this.a, this.b);
+ };
+ d3_labPrototype.darker = function(k) {
+ return d3_lab(Math.max(0, this.l - d3_lab_K * (arguments.length ? k : 1)), this.a, this.b);
+ };
+ d3_labPrototype.rgb = function() {
+ return d3_lab_rgb(this.l, this.a, this.b);
+ };
+ function d3_lab_rgb(l, a, b) {
+ var y = (l + 16) / 116, x = y + a / 500, z = y - b / 200;
+ x = d3_lab_xyz(x) * d3_lab_X;
+ y = d3_lab_xyz(y) * d3_lab_Y;
+ z = d3_lab_xyz(z) * d3_lab_Z;
+ return d3_rgb(d3_xyz_rgb(3.2404542 * x - 1.5371385 * y - .4985314 * z), d3_xyz_rgb(-.969266 * x + 1.8760108 * y + .041556 * z), d3_xyz_rgb(.0556434 * x - .2040259 * y + 1.0572252 * z));
+ }
+ function d3_lab_hcl(l, a, b) {
+ return l > 0 ? d3_hcl(Math.atan2(b, a) * d3_degrees, Math.sqrt(a * a + b * b), l) : d3_hcl(NaN, NaN, l);
+ }
+ function d3_lab_xyz(x) {
+ return x > .206893034 ? x * x * x : (x - 4 / 29) / 7.787037;
+ }
+ function d3_xyz_lab(x) {
+ return x > .008856 ? Math.pow(x, 1 / 3) : 7.787037 * x + 4 / 29;
+ }
+ function d3_xyz_rgb(r) {
+ return Math.round(255 * (r <= .00304 ? 12.92 * r : 1.055 * Math.pow(r, 1 / 2.4) - .055));
+ }
+ d3.rgb = function(r, g, b) {
+ return arguments.length === 1 ? r instanceof d3_Rgb ? d3_rgb(r.r, r.g, r.b) : d3_rgb_parse("" + r, d3_rgb, d3_hsl_rgb) : d3_rgb(~~r, ~~g, ~~b);
+ };
+ function d3_rgbNumber(value) {
+ return d3_rgb(value >> 16, value >> 8 & 255, value & 255);
+ }
+ function d3_rgbString(value) {
+ return d3_rgbNumber(value) + "";
+ }
+ function d3_rgb(r, g, b) {
+ return new d3_Rgb(r, g, b);
+ }
+ function d3_Rgb(r, g, b) {
+ this.r = r;
+ this.g = g;
+ this.b = b;
+ }
+ var d3_rgbPrototype = d3_Rgb.prototype = new d3_Color();
+ d3_rgbPrototype.brighter = function(k) {
+ k = Math.pow(.7, arguments.length ? k : 1);
+ var r = this.r, g = this.g, b = this.b, i = 30;
+ if (!r && !g && !b) return d3_rgb(i, i, i);
+ if (r && r < i) r = i;
+ if (g && g < i) g = i;
+ if (b && b < i) b = i;
+ return d3_rgb(Math.min(255, ~~(r / k)), Math.min(255, ~~(g / k)), Math.min(255, ~~(b / k)));
+ };
+ d3_rgbPrototype.darker = function(k) {
+ k = Math.pow(.7, arguments.length ? k : 1);
+ return d3_rgb(~~(k * this.r), ~~(k * this.g), ~~(k * this.b));
+ };
+ d3_rgbPrototype.hsl = function() {
+ return d3_rgb_hsl(this.r, this.g, this.b);
+ };
+ d3_rgbPrototype.toString = function() {
+ return "#" + d3_rgb_hex(this.r) + d3_rgb_hex(this.g) + d3_rgb_hex(this.b);
+ };
+ function d3_rgb_hex(v) {
+ return v < 16 ? "0" + Math.max(0, v).toString(16) : Math.min(255, v).toString(16);
+ }
+ function d3_rgb_parse(format, rgb, hsl) {
+ var r = 0, g = 0, b = 0, m1, m2, name;
+ m1 = /([a-z]+)\((.*)\)/i.exec(format);
+ if (m1) {
+ m2 = m1[2].split(",");
+ switch (m1[1]) {
+ case "hsl":
+ {
+ return hsl(parseFloat(m2[0]), parseFloat(m2[1]) / 100, parseFloat(m2[2]) / 100);
+ }
+
+ case "rgb":
+ {
+ return rgb(d3_rgb_parseNumber(m2[0]), d3_rgb_parseNumber(m2[1]), d3_rgb_parseNumber(m2[2]));
+ }
+ }
+ }
+ if (name = d3_rgb_names.get(format)) return rgb(name.r, name.g, name.b);
+ if (format != null && format.charAt(0) === "#") {
+ if (format.length === 4) {
+ r = format.charAt(1);
+ r += r;
+ g = format.charAt(2);
+ g += g;
+ b = format.charAt(3);
+ b += b;
+ } else if (format.length === 7) {
+ r = format.substring(1, 3);
+ g = format.substring(3, 5);
+ b = format.substring(5, 7);
+ }
+ r = parseInt(r, 16);
+ g = parseInt(g, 16);
+ b = parseInt(b, 16);
+ }
+ return rgb(r, g, b);
+ }
+ function d3_rgb_hsl(r, g, b) {
+ var min = Math.min(r /= 255, g /= 255, b /= 255), max = Math.max(r, g, b), d = max - min, h, s, l = (max + min) / 2;
+ if (d) {
+ s = l < .5 ? d / (max + min) : d / (2 - max - min);
+ if (r == max) h = (g - b) / d + (g < b ? 6 : 0); else if (g == max) h = (b - r) / d + 2; else h = (r - g) / d + 4;
+ h *= 60;
+ } else {
+ h = NaN;
+ s = l > 0 && l < 1 ? 0 : h;
+ }
+ return d3_hsl(h, s, l);
+ }
+ function d3_rgb_lab(r, g, b) {
+ r = d3_rgb_xyz(r);
+ g = d3_rgb_xyz(g);
+ b = d3_rgb_xyz(b);
+ var x = d3_xyz_lab((.4124564 * r + .3575761 * g + .1804375 * b) / d3_lab_X), y = d3_xyz_lab((.2126729 * r + .7151522 * g + .072175 * b) / d3_lab_Y), z = d3_xyz_lab((.0193339 * r + .119192 * g + .9503041 * b) / d3_lab_Z);
+ return d3_lab(116 * y - 16, 500 * (x - y), 200 * (y - z));
+ }
+ function d3_rgb_xyz(r) {
+ return (r /= 255) <= .04045 ? r / 12.92 : Math.pow((r + .055) / 1.055, 2.4);
+ }
+ function d3_rgb_parseNumber(c) {
+ var f = parseFloat(c);
+ return c.charAt(c.length - 1) === "%" ? Math.round(f * 2.55) : f;
+ }
+ var d3_rgb_names = d3.map({
+ aliceblue: 15792383,
+ antiquewhite: 16444375,
+ aqua: 65535,
+ aquamarine: 8388564,
+ azure: 15794175,
+ beige: 16119260,
+ bisque: 16770244,
+ black: 0,
+ blanchedalmond: 16772045,
+ blue: 255,
+ blueviolet: 9055202,
+ brown: 10824234,
+ burlywood: 14596231,
+ cadetblue: 6266528,
+ chartreuse: 8388352,
+ chocolate: 13789470,
+ coral: 16744272,
+ cornflowerblue: 6591981,
+ cornsilk: 16775388,
+ crimson: 14423100,
+ cyan: 65535,
+ darkblue: 139,
+ darkcyan: 35723,
+ darkgoldenrod: 12092939,
+ darkgray: 11119017,
+ darkgreen: 25600,
+ darkgrey: 11119017,
+ darkkhaki: 12433259,
+ darkmagenta: 9109643,
+ darkolivegreen: 5597999,
+ darkorange: 16747520,
+ darkorchid: 10040012,
+ darkred: 9109504,
+ darksalmon: 15308410,
+ darkseagreen: 9419919,
+ darkslateblue: 4734347,
+ darkslategray: 3100495,
+ darkslategrey: 3100495,
+ darkturquoise: 52945,
+ darkviolet: 9699539,
+ deeppink: 16716947,
+ deepskyblue: 49151,
+ dimgray: 6908265,
+ dimgrey: 6908265,
+ dodgerblue: 2003199,
+ firebrick: 11674146,
+ floralwhite: 16775920,
+ forestgreen: 2263842,
+ fuchsia: 16711935,
+ gainsboro: 14474460,
+ ghostwhite: 16316671,
+ gold: 16766720,
+ goldenrod: 14329120,
+ gray: 8421504,
+ green: 32768,
+ greenyellow: 11403055,
+ grey: 8421504,
+ honeydew: 15794160,
+ hotpink: 16738740,
+ indianred: 13458524,
+ indigo: 4915330,
+ ivory: 16777200,
+ khaki: 15787660,
+ lavender: 15132410,
+ lavenderblush: 16773365,
+ lawngreen: 8190976,
+ lemonchiffon: 16775885,
+ lightblue: 11393254,
+ lightcoral: 15761536,
+ lightcyan: 14745599,
+ lightgoldenrodyellow: 16448210,
+ lightgray: 13882323,
+ lightgreen: 9498256,
+ lightgrey: 13882323,
+ lightpink: 16758465,
+ lightsalmon: 16752762,
+ lightseagreen: 2142890,
+ lightskyblue: 8900346,
+ lightslategray: 7833753,
+ lightslategrey: 7833753,
+ lightsteelblue: 11584734,
+ lightyellow: 16777184,
+ lime: 65280,
+ limegreen: 3329330,
+ linen: 16445670,
+ magenta: 16711935,
+ maroon: 8388608,
+ mediumaquamarine: 6737322,
+ mediumblue: 205,
+ mediumorchid: 12211667,
+ mediumpurple: 9662683,
+ mediumseagreen: 3978097,
+ mediumslateblue: 8087790,
+ mediumspringgreen: 64154,
+ mediumturquoise: 4772300,
+ mediumvioletred: 13047173,
+ midnightblue: 1644912,
+ mintcream: 16121850,
+ mistyrose: 16770273,
+ moccasin: 16770229,
+ navajowhite: 16768685,
+ navy: 128,
+ oldlace: 16643558,
+ olive: 8421376,
+ olivedrab: 7048739,
+ orange: 16753920,
+ orangered: 16729344,
+ orchid: 14315734,
+ palegoldenrod: 15657130,
+ palegreen: 10025880,
+ paleturquoise: 11529966,
+ palevioletred: 14381203,
+ papayawhip: 16773077,
+ peachpuff: 16767673,
+ peru: 13468991,
+ pink: 16761035,
+ plum: 14524637,
+ powderblue: 11591910,
+ purple: 8388736,
+ red: 16711680,
+ rosybrown: 12357519,
+ royalblue: 4286945,
+ saddlebrown: 9127187,
+ salmon: 16416882,
+ sandybrown: 16032864,
+ seagreen: 3050327,
+ seashell: 16774638,
+ sienna: 10506797,
+ silver: 12632256,
+ skyblue: 8900331,
+ slateblue: 6970061,
+ slategray: 7372944,
+ slategrey: 7372944,
+ snow: 16775930,
+ springgreen: 65407,
+ steelblue: 4620980,
+ tan: 13808780,
+ teal: 32896,
+ thistle: 14204888,
+ tomato: 16737095,
+ turquoise: 4251856,
+ violet: 15631086,
+ wheat: 16113331,
+ white: 16777215,
+ whitesmoke: 16119285,
+ yellow: 16776960,
+ yellowgreen: 10145074
+ });
+ d3_rgb_names.forEach(function(key, value) {
+ d3_rgb_names.set(key, d3_rgbNumber(value));
+ });
+ function d3_functor(v) {
+ return typeof v === "function" ? v : function() {
+ return v;
+ };
+ }
+ d3.functor = d3_functor;
+ function d3_identity(d) {
+ return d;
+ }
+ d3.xhr = d3_xhrType(d3_identity);
+ function d3_xhrType(response) {
+ return function(url, mimeType, callback) {
+ if (arguments.length === 2 && typeof mimeType === "function") callback = mimeType,
+ mimeType = null;
+ return d3_xhr(url, mimeType, response, callback);
+ };
+ }
+ function d3_xhr(url, mimeType, response, callback) {
+ var xhr = {}, dispatch = d3.dispatch("beforesend", "progress", "load", "error"), headers = {}, request = new XMLHttpRequest(), responseType = null;
+ if (d3_window.XDomainRequest && !("withCredentials" in request) && /^(http(s)?:)?\/\//.test(url)) request = new XDomainRequest();
+ "onload" in request ? request.onload = request.onerror = respond : request.onreadystatechange = function() {
+ request.readyState > 3 && respond();
+ };
+ function respond() {
+ var status = request.status, result;
+ if (!status && request.responseText || status >= 200 && status < 300 || status === 304) {
+ try {
+ result = response.call(xhr, request);
+ } catch (e) {
+ dispatch.error.call(xhr, e);
+ return;
+ }
+ dispatch.load.call(xhr, result);
+ } else {
+ dispatch.error.call(xhr, request);
+ }
+ }
+ request.onprogress = function(event) {
+ var o = d3.event;
+ d3.event = event;
+ try {
+ dispatch.progress.call(xhr, request);
+ } finally {
+ d3.event = o;
+ }
+ };
+ xhr.header = function(name, value) {
+ name = (name + "").toLowerCase();
+ if (arguments.length < 2) return headers[name];
+ if (value == null) delete headers[name]; else headers[name] = value + "";
+ return xhr;
+ };
+ xhr.mimeType = function(value) {
+ if (!arguments.length) return mimeType;
+ mimeType = value == null ? null : value + "";
+ return xhr;
+ };
+ xhr.responseType = function(value) {
+ if (!arguments.length) return responseType;
+ responseType = value;
+ return xhr;
+ };
+ xhr.response = function(value) {
+ response = value;
+ return xhr;
+ };
+ [ "get", "post" ].forEach(function(method) {
+ xhr[method] = function() {
+ return xhr.send.apply(xhr, [ method ].concat(d3_array(arguments)));
+ };
+ });
+ xhr.send = function(method, data, callback) {
+ if (arguments.length === 2 && typeof data === "function") callback = data, data = null;
+ request.open(method, url, true);
+ if (mimeType != null && !("accept" in headers)) headers["accept"] = mimeType + ",*/*";
+ if (request.setRequestHeader) for (var name in headers) request.setRequestHeader(name, headers[name]);
+ if (mimeType != null && request.overrideMimeType) request.overrideMimeType(mimeType);
+ if (responseType != null) request.responseType = responseType;
+ if (callback != null) xhr.on("error", callback).on("load", function(request) {
+ callback(null, request);
+ });
+ dispatch.beforesend.call(xhr, request);
+ request.send(data == null ? null : data);
+ return xhr;
+ };
+ xhr.abort = function() {
+ request.abort();
+ return xhr;
+ };
+ d3.rebind(xhr, dispatch, "on");
+ return callback == null ? xhr : xhr.get(d3_xhr_fixCallback(callback));
+ }
+ function d3_xhr_fixCallback(callback) {
+ return callback.length === 1 ? function(error, request) {
+ callback(error == null ? request : null);
+ } : callback;
+ }
+ d3.dsv = function(delimiter, mimeType) {
+ var reFormat = new RegExp('["' + delimiter + "\n]"), delimiterCode = delimiter.charCodeAt(0);
+ function dsv(url, row, callback) {
+ if (arguments.length < 3) callback = row, row = null;
+ var xhr = d3_xhr(url, mimeType, row == null ? response : typedResponse(row), callback);
+ xhr.row = function(_) {
+ return arguments.length ? xhr.response((row = _) == null ? response : typedResponse(_)) : row;
+ };
+ return xhr;
+ }
+ function response(request) {
+ return dsv.parse(request.responseText);
+ }
+ function typedResponse(f) {
+ return function(request) {
+ return dsv.parse(request.responseText, f);
+ };
+ }
+ dsv.parse = function(text, f) {
+ var o;
+ return dsv.parseRows(text, function(row, i) {
+ if (o) return o(row, i - 1);
+ var a = new Function("d", "return {" + row.map(function(name, i) {
+ return JSON.stringify(name) + ": d[" + i + "]";
+ }).join(",") + "}");
+ o = f ? function(row, i) {
+ return f(a(row), i);
+ } : a;
+ });
+ };
+ dsv.parseRows = function(text, f) {
+ var EOL = {}, EOF = {}, rows = [], N = text.length, I = 0, n = 0, t, eol;
+ function token() {
+ if (I >= N) return EOF;
+ if (eol) return eol = false, EOL;
+ var j = I;
+ if (text.charCodeAt(j) === 34) {
+ var i = j;
+ while (i++ < N) {
+ if (text.charCodeAt(i) === 34) {
+ if (text.charCodeAt(i + 1) !== 34) break;
+ ++i;
+ }
+ }
+ I = i + 2;
+ var c = text.charCodeAt(i + 1);
+ if (c === 13) {
+ eol = true;
+ if (text.charCodeAt(i + 2) === 10) ++I;
+ } else if (c === 10) {
+ eol = true;
+ }
+ return text.substring(j + 1, i).replace(/""/g, '"');
+ }
+ while (I < N) {
+ var c = text.charCodeAt(I++), k = 1;
+ if (c === 10) eol = true; else if (c === 13) {
+ eol = true;
+ if (text.charCodeAt(I) === 10) ++I, ++k;
+ } else if (c !== delimiterCode) continue;
+ return text.substring(j, I - k);
+ }
+ return text.substring(j);
+ }
+ while ((t = token()) !== EOF) {
+ var a = [];
+ while (t !== EOL && t !== EOF) {
+ a.push(t);
+ t = token();
+ }
+ if (f && !(a = f(a, n++))) continue;
+ rows.push(a);
+ }
+ return rows;
+ };
+ dsv.format = function(rows) {
+ if (Array.isArray(rows[0])) return dsv.formatRows(rows);
+ var fieldSet = new d3_Set(), fields = [];
+ rows.forEach(function(row) {
+ for (var field in row) {
+ if (!fieldSet.has(field)) {
+ fields.push(fieldSet.add(field));
+ }
+ }
+ });
+ return [ fields.map(formatValue).join(delimiter) ].concat(rows.map(function(row) {
+ return fields.map(function(field) {
+ return formatValue(row[field]);
+ }).join(delimiter);
+ })).join("\n");
+ };
+ dsv.formatRows = function(rows) {
+ return rows.map(formatRow).join("\n");
+ };
+ function formatRow(row) {
+ return row.map(formatValue).join(delimiter);
+ }
+ function formatValue(text) {
+ return reFormat.test(text) ? '"' + text.replace(/\"/g, '""') + '"' : text;
+ }
+ return dsv;
+ };
+ d3.csv = d3.dsv(",", "text/csv");
+ d3.tsv = d3.dsv(" ", "text/tab-separated-values");
+ var d3_timer_queueHead, d3_timer_queueTail, d3_timer_interval, d3_timer_timeout, d3_timer_active, d3_timer_frame = d3_window[d3_vendorSymbol(d3_window, "requestAnimationFrame")] || function(callback) {
+ setTimeout(callback, 17);
+ };
+ d3.timer = function(callback, delay, then) {
+ var n = arguments.length;
+ if (n < 2) delay = 0;
+ if (n < 3) then = Date.now();
+ var time = then + delay, timer = {
+ c: callback,
+ t: time,
+ f: false,
+ n: null
+ };
+ if (d3_timer_queueTail) d3_timer_queueTail.n = timer; else d3_timer_queueHead = timer;
+ d3_timer_queueTail = timer;
+ if (!d3_timer_interval) {
+ d3_timer_timeout = clearTimeout(d3_timer_timeout);
+ d3_timer_interval = 1;
+ d3_timer_frame(d3_timer_step);
+ }
+ };
+ function d3_timer_step() {
+ var now = d3_timer_mark(), delay = d3_timer_sweep() - now;
+ if (delay > 24) {
+ if (isFinite(delay)) {
+ clearTimeout(d3_timer_timeout);
+ d3_timer_timeout = setTimeout(d3_timer_step, delay);
+ }
+ d3_timer_interval = 0;
+ } else {
+ d3_timer_interval = 1;
+ d3_timer_frame(d3_timer_step);
+ }
+ }
+ d3.timer.flush = function() {
+ d3_timer_mark();
+ d3_timer_sweep();
+ };
+ function d3_timer_mark() {
+ var now = Date.now();
+ d3_timer_active = d3_timer_queueHead;
+ while (d3_timer_active) {
+ if (now >= d3_timer_active.t) d3_timer_active.f = d3_timer_active.c(now - d3_timer_active.t);
+ d3_timer_active = d3_timer_active.n;
+ }
+ return now;
+ }
+ function d3_timer_sweep() {
+ var t0, t1 = d3_timer_queueHead, time = Infinity;
+ while (t1) {
+ if (t1.f) {
+ t1 = t0 ? t0.n = t1.n : d3_timer_queueHead = t1.n;
+ } else {
+ if (t1.t < time) time = t1.t;
+ t1 = (t0 = t1).n;
+ }
+ }
+ d3_timer_queueTail = t0;
+ return time;
+ }
+ function d3_format_precision(x, p) {
+ return p - (x ? Math.ceil(Math.log(x) / Math.LN10) : 1);
+ }
+ d3.round = function(x, n) {
+ return n ? Math.round(x * (n = Math.pow(10, n))) / n : Math.round(x);
+ };
+ var d3_formatPrefixes = [ "y", "z", "a", "f", "p", "n", "µ", "m", "", "k", "M", "G", "T", "P", "E", "Z", "Y" ].map(d3_formatPrefix);
+ d3.formatPrefix = function(value, precision) {
+ var i = 0;
+ if (value) {
+ if (value < 0) value *= -1;
+ if (precision) value = d3.round(value, d3_format_precision(value, precision));
+ i = 1 + Math.floor(1e-12 + Math.log(value) / Math.LN10);
+ i = Math.max(-24, Math.min(24, Math.floor((i <= 0 ? i + 1 : i - 1) / 3) * 3));
+ }
+ return d3_formatPrefixes[8 + i / 3];
+ };
+ function d3_formatPrefix(d, i) {
+ var k = Math.pow(10, abs(8 - i) * 3);
+ return {
+ scale: i > 8 ? function(d) {
+ return d / k;
+ } : function(d) {
+ return d * k;
+ },
+ symbol: d
+ };
+ }
+ function d3_locale_numberFormat(locale) {
+ var locale_decimal = locale.decimal, locale_thousands = locale.thousands, locale_grouping = locale.grouping, locale_currency = locale.currency, formatGroup = locale_grouping ? function(value) {
+ var i = value.length, t = [], j = 0, g = locale_grouping[0];
+ while (i > 0 && g > 0) {
+ t.push(value.substring(i -= g, i + g));
+ g = locale_grouping[j = (j + 1) % locale_grouping.length];
+ }
+ return t.reverse().join(locale_thousands);
+ } : d3_identity;
+ return function(specifier) {
+ var match = d3_format_re.exec(specifier), fill = match[1] || " ", align = match[2] || ">", sign = match[3] || "", symbol = match[4] || "", zfill = match[5], width = +match[6], comma = match[7], precision = match[8], type = match[9], scale = 1, prefix = "", suffix = "", integer = false;
+ if (precision) precision = +precision.substring(1);
+ if (zfill || fill === "0" && align === "=") {
+ zfill = fill = "0";
+ align = "=";
+ if (comma) width -= Math.floor((width - 1) / 4);
+ }
+ switch (type) {
+ case "n":
+ comma = true;
+ type = "g";
+ break;
+
+ case "%":
+ scale = 100;
+ suffix = "%";
+ type = "f";
+ break;
+
+ case "p":
+ scale = 100;
+ suffix = "%";
+ type = "r";
+ break;
+
+ case "b":
+ case "o":
+ case "x":
+ case "X":
+ if (symbol === "#") prefix = "0" + type.toLowerCase();
+
+ case "c":
+ case "d":
+ integer = true;
+ precision = 0;
+ break;
+
+ case "s":
+ scale = -1;
+ type = "r";
+ break;
+ }
+ if (symbol === "$") prefix = locale_currency[0], suffix = locale_currency[1];
+ if (type == "r" && !precision) type = "g";
+ if (precision != null) {
+ if (type == "g") precision = Math.max(1, Math.min(21, precision)); else if (type == "e" || type == "f") precision = Math.max(0, Math.min(20, precision));
+ }
+ type = d3_format_types.get(type) || d3_format_typeDefault;
+ var zcomma = zfill && comma;
+ return function(value) {
+ var fullSuffix = suffix;
+ if (integer && value % 1) return "";
+ var negative = value < 0 || value === 0 && 1 / value < 0 ? (value = -value, "-") : sign;
+ if (scale < 0) {
+ var unit = d3.formatPrefix(value, precision);
+ value = unit.scale(value);
+ fullSuffix = unit.symbol + suffix;
+ } else {
+ value *= scale;
+ }
+ value = type(value, precision);
+ var i = value.lastIndexOf("."), before = i < 0 ? value : value.substring(0, i), after = i < 0 ? "" : locale_decimal + value.substring(i + 1);
+ if (!zfill && comma) before = formatGroup(before);
+ var length = prefix.length + before.length + after.length + (zcomma ? 0 : negative.length), padding = length < width ? new Array(length = width - length + 1).join(fill) : "";
+ if (zcomma) before = formatGroup(padding + before);
+ negative += prefix;
+ value = before + after;
+ return (align === "<" ? negative + value + padding : align === ">" ? padding + negative + value : align === "^" ? padding.substring(0, length >>= 1) + negative + value + padding.substring(length) : negative + (zcomma ? value : padding + value)) + fullSuffix;
+ };
+ };
+ }
+ var d3_format_re = /(?:([^{])?([<>=^]))?([+\- ])?([$#])?(0)?(\d+)?(,)?(\.-?\d+)?([a-z%])?/i;
+ var d3_format_types = d3.map({
+ b: function(x) {
+ return x.toString(2);
+ },
+ c: function(x) {
+ return String.fromCharCode(x);
+ },
+ o: function(x) {
+ return x.toString(8);
+ },
+ x: function(x) {
+ return x.toString(16);
+ },
+ X: function(x) {
+ return x.toString(16).toUpperCase();
+ },
+ g: function(x, p) {
+ return x.toPrecision(p);
+ },
+ e: function(x, p) {
+ return x.toExponential(p);
+ },
+ f: function(x, p) {
+ return x.toFixed(p);
+ },
+ r: function(x, p) {
+ return (x = d3.round(x, d3_format_precision(x, p))).toFixed(Math.max(0, Math.min(20, d3_format_precision(x * (1 + 1e-15), p))));
+ }
+ });
+ function d3_format_typeDefault(x) {
+ return x + "";
+ }
+ var d3_time = d3.time = {}, d3_date = Date;
+ function d3_date_utc() {
+ this._ = new Date(arguments.length > 1 ? Date.UTC.apply(this, arguments) : arguments[0]);
+ }
+ d3_date_utc.prototype = {
+ getDate: function() {
+ return this._.getUTCDate();
+ },
+ getDay: function() {
+ return this._.getUTCDay();
+ },
+ getFullYear: function() {
+ return this._.getUTCFullYear();
+ },
+ getHours: function() {
+ return this._.getUTCHours();
+ },
+ getMilliseconds: function() {
+ return this._.getUTCMilliseconds();
+ },
+ getMinutes: function() {
+ return this._.getUTCMinutes();
+ },
+ getMonth: function() {
+ return this._.getUTCMonth();
+ },
+ getSeconds: function() {
+ return this._.getUTCSeconds();
+ },
+ getTime: function() {
+ return this._.getTime();
+ },
+ getTimezoneOffset: function() {
+ return 0;
+ },
+ valueOf: function() {
+ return this._.valueOf();
+ },
+ setDate: function() {
+ d3_time_prototype.setUTCDate.apply(this._, arguments);
+ },
+ setDay: function() {
+ d3_time_prototype.setUTCDay.apply(this._, arguments);
+ },
+ setFullYear: function() {
+ d3_time_prototype.setUTCFullYear.apply(this._, arguments);
+ },
+ setHours: function() {
+ d3_time_prototype.setUTCHours.apply(this._, arguments);
+ },
+ setMilliseconds: function() {
+ d3_time_prototype.setUTCMilliseconds.apply(this._, arguments);
+ },
+ setMinutes: function() {
+ d3_time_prototype.setUTCMinutes.apply(this._, arguments);
+ },
+ setMonth: function() {
+ d3_time_prototype.setUTCMonth.apply(this._, arguments);
+ },
+ setSeconds: function() {
+ d3_time_prototype.setUTCSeconds.apply(this._, arguments);
+ },
+ setTime: function() {
+ d3_time_prototype.setTime.apply(this._, arguments);
+ }
+ };
+ var d3_time_prototype = Date.prototype;
+ function d3_time_interval(local, step, number) {
+ function round(date) {
+ var d0 = local(date), d1 = offset(d0, 1);
+ return date - d0 < d1 - date ? d0 : d1;
+ }
+ function ceil(date) {
+ step(date = local(new d3_date(date - 1)), 1);
+ return date;
+ }
+ function offset(date, k) {
+ step(date = new d3_date(+date), k);
+ return date;
+ }
+ function range(t0, t1, dt) {
+ var time = ceil(t0), times = [];
+ if (dt > 1) {
+ while (time < t1) {
+ if (!(number(time) % dt)) times.push(new Date(+time));
+ step(time, 1);
+ }
+ } else {
+ while (time < t1) times.push(new Date(+time)), step(time, 1);
+ }
+ return times;
+ }
+ function range_utc(t0, t1, dt) {
+ try {
+ d3_date = d3_date_utc;
+ var utc = new d3_date_utc();
+ utc._ = t0;
+ return range(utc, t1, dt);
+ } finally {
+ d3_date = Date;
+ }
+ }
+ local.floor = local;
+ local.round = round;
+ local.ceil = ceil;
+ local.offset = offset;
+ local.range = range;
+ var utc = local.utc = d3_time_interval_utc(local);
+ utc.floor = utc;
+ utc.round = d3_time_interval_utc(round);
+ utc.ceil = d3_time_interval_utc(ceil);
+ utc.offset = d3_time_interval_utc(offset);
+ utc.range = range_utc;
+ return local;
+ }
+ function d3_time_interval_utc(method) {
+ return function(date, k) {
+ try {
+ d3_date = d3_date_utc;
+ var utc = new d3_date_utc();
+ utc._ = date;
+ return method(utc, k)._;
+ } finally {
+ d3_date = Date;
+ }
+ };
+ }
+ d3_time.year = d3_time_interval(function(date) {
+ date = d3_time.day(date);
+ date.setMonth(0, 1);
+ return date;
+ }, function(date, offset) {
+ date.setFullYear(date.getFullYear() + offset);
+ }, function(date) {
+ return date.getFullYear();
+ });
+ d3_time.years = d3_time.year.range;
+ d3_time.years.utc = d3_time.year.utc.range;
+ d3_time.day = d3_time_interval(function(date) {
+ var day = new d3_date(2e3, 0);
+ day.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
+ return day;
+ }, function(date, offset) {
+ date.setDate(date.getDate() + offset);
+ }, function(date) {
+ return date.getDate() - 1;
+ });
+ d3_time.days = d3_time.day.range;
+ d3_time.days.utc = d3_time.day.utc.range;
+ d3_time.dayOfYear = function(date) {
+ var year = d3_time.year(date);
+ return Math.floor((date - year - (date.getTimezoneOffset() - year.getTimezoneOffset()) * 6e4) / 864e5);
+ };
+ [ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" ].forEach(function(day, i) {
+ i = 7 - i;
+ var interval = d3_time[day] = d3_time_interval(function(date) {
+ (date = d3_time.day(date)).setDate(date.getDate() - (date.getDay() + i) % 7);
+ return date;
+ }, function(date, offset) {
+ date.setDate(date.getDate() + Math.floor(offset) * 7);
+ }, function(date) {
+ var day = d3_time.year(date).getDay();
+ return Math.floor((d3_time.dayOfYear(date) + (day + i) % 7) / 7) - (day !== i);
+ });
+ d3_time[day + "s"] = interval.range;
+ d3_time[day + "s"].utc = interval.utc.range;
+ d3_time[day + "OfYear"] = function(date) {
+ var day = d3_time.year(date).getDay();
+ return Math.floor((d3_time.dayOfYear(date) + (day + i) % 7) / 7);
+ };
+ });
+ d3_time.week = d3_time.sunday;
+ d3_time.weeks = d3_time.sunday.range;
+ d3_time.weeks.utc = d3_time.sunday.utc.range;
+ d3_time.weekOfYear = d3_time.sundayOfYear;
+ function d3_locale_timeFormat(locale) {
+ var locale_dateTime = locale.dateTime, locale_date = locale.date, locale_time = locale.time, locale_periods = locale.periods, locale_days = locale.days, locale_shortDays = locale.shortDays, locale_months = locale.months, locale_shortMonths = locale.shortMonths;
+ function d3_time_format(template) {
+ var n = template.length;
+ function format(date) {
+ var string = [], i = -1, j = 0, c, p, f;
+ while (++i < n) {
+ if (template.charCodeAt(i) === 37) {
+ string.push(template.substring(j, i));
+ if ((p = d3_time_formatPads[c = template.charAt(++i)]) != null) c = template.charAt(++i);
+ if (f = d3_time_formats[c]) c = f(date, p == null ? c === "e" ? " " : "0" : p);
+ string.push(c);
+ j = i + 1;
+ }
+ }
+ string.push(template.substring(j, i));
+ return string.join("");
+ }
+ format.parse = function(string) {
+ var d = {
+ y: 1900,
+ m: 0,
+ d: 1,
+ H: 0,
+ M: 0,
+ S: 0,
+ L: 0,
+ Z: null
+ }, i = d3_time_parse(d, template, string, 0);
+ if (i != string.length) return null;
+ if ("p" in d) d.H = d.H % 12 + d.p * 12;
+ var localZ = d.Z != null && d3_date !== d3_date_utc, date = new (localZ ? d3_date_utc : d3_date)();
+ if ("j" in d) date.setFullYear(d.y, 0, d.j); else if ("w" in d && ("W" in d || "U" in d)) {
+ date.setFullYear(d.y, 0, 1);
+ date.setFullYear(d.y, 0, "W" in d ? (d.w + 6) % 7 + d.W * 7 - (date.getDay() + 5) % 7 : d.w + d.U * 7 - (date.getDay() + 6) % 7);
+ } else date.setFullYear(d.y, d.m, d.d);
+ date.setHours(d.H + Math.floor(d.Z / 100), d.M + d.Z % 100, d.S, d.L);
+ return localZ ? date._ : date;
+ };
+ format.toString = function() {
+ return template;
+ };
+ return format;
+ }
+ function d3_time_parse(date, template, string, j) {
+ var c, p, t, i = 0, n = template.length, m = string.length;
+ while (i < n) {
+ if (j >= m) return -1;
+ c = template.charCodeAt(i++);
+ if (c === 37) {
+ t = template.charAt(i++);
+ p = d3_time_parsers[t in d3_time_formatPads ? template.charAt(i++) : t];
+ if (!p || (j = p(date, string, j)) < 0) return -1;
+ } else if (c != string.charCodeAt(j++)) {
+ return -1;
+ }
+ }
+ return j;
+ }
+ d3_time_format.utc = function(template) {
+ var local = d3_time_format(template);
+ function format(date) {
+ try {
+ d3_date = d3_date_utc;
+ var utc = new d3_date();
+ utc._ = date;
+ return local(utc);
+ } finally {
+ d3_date = Date;
+ }
+ }
+ format.parse = function(string) {
+ try {
+ d3_date = d3_date_utc;
+ var date = local.parse(string);
+ return date && date._;
+ } finally {
+ d3_date = Date;
+ }
+ };
+ format.toString = local.toString;
+ return format;
+ };
+ d3_time_format.multi = d3_time_format.utc.multi = d3_time_formatMulti;
+ var d3_time_periodLookup = d3.map(), d3_time_dayRe = d3_time_formatRe(locale_days), d3_time_dayLookup = d3_time_formatLookup(locale_days), d3_time_dayAbbrevRe = d3_time_formatRe(locale_shortDays), d3_time_dayAbbrevLookup = d3_time_formatLookup(locale_shortDays), d3_time_monthRe = d3_time_formatRe(locale_months), d3_time_monthLookup = d3_time_formatLookup(locale_months), d3_time_monthAbbrevRe = d3_time_formatRe(locale_shortMonths), d3_time_monthAbbrevLookup = d3_time_formatLookup(locale_shortMonths);
+ locale_periods.forEach(function(p, i) {
+ d3_time_periodLookup.set(p.toLowerCase(), i);
+ });
+ var d3_time_formats = {
+ a: function(d) {
+ return locale_shortDays[d.getDay()];
+ },
+ A: function(d) {
+ return locale_days[d.getDay()];
+ },
+ b: function(d) {
+ return locale_shortMonths[d.getMonth()];
+ },
+ B: function(d) {
+ return locale_months[d.getMonth()];
+ },
+ c: d3_time_format(locale_dateTime),
+ d: function(d, p) {
+ return d3_time_formatPad(d.getDate(), p, 2);
+ },
+ e: function(d, p) {
+ return d3_time_formatPad(d.getDate(), p, 2);
+ },
+ H: function(d, p) {
+ return d3_time_formatPad(d.getHours(), p, 2);
+ },
+ I: function(d, p) {
+ return d3_time_formatPad(d.getHours() % 12 || 12, p, 2);
+ },
+ j: function(d, p) {
+ return d3_time_formatPad(1 + d3_time.dayOfYear(d), p, 3);
+ },
+ L: function(d, p) {
+ return d3_time_formatPad(d.getMilliseconds(), p, 3);
+ },
+ m: function(d, p) {
+ return d3_time_formatPad(d.getMonth() + 1, p, 2);
+ },
+ M: function(d, p) {
+ return d3_time_formatPad(d.getMinutes(), p, 2);
+ },
+ p: function(d) {
+ return locale_periods[+(d.getHours() >= 12)];
+ },
+ S: function(d, p) {
+ return d3_time_formatPad(d.getSeconds(), p, 2);
+ },
+ U: function(d, p) {
+ return d3_time_formatPad(d3_time.sundayOfYear(d), p, 2);
+ },
+ w: function(d) {
+ return d.getDay();
+ },
+ W: function(d, p) {
+ return d3_time_formatPad(d3_time.mondayOfYear(d), p, 2);
+ },
+ x: d3_time_format(locale_date),
+ X: d3_time_format(locale_time),
+ y: function(d, p) {
+ return d3_time_formatPad(d.getFullYear() % 100, p, 2);
+ },
+ Y: function(d, p) {
+ return d3_time_formatPad(d.getFullYear() % 1e4, p, 4);
+ },
+ Z: d3_time_zone,
+ "%": function() {
+ return "%";
+ }
+ };
+ var d3_time_parsers = {
+ a: d3_time_parseWeekdayAbbrev,
+ A: d3_time_parseWeekday,
+ b: d3_time_parseMonthAbbrev,
+ B: d3_time_parseMonth,
+ c: d3_time_parseLocaleFull,
+ d: d3_time_parseDay,
+ e: d3_time_parseDay,
+ H: d3_time_parseHour24,
+ I: d3_time_parseHour24,
+ j: d3_time_parseDayOfYear,
+ L: d3_time_parseMilliseconds,
+ m: d3_time_parseMonthNumber,
+ M: d3_time_parseMinutes,
+ p: d3_time_parseAmPm,
+ S: d3_time_parseSeconds,
+ U: d3_time_parseWeekNumberSunday,
+ w: d3_time_parseWeekdayNumber,
+ W: d3_time_parseWeekNumberMonday,
+ x: d3_time_parseLocaleDate,
+ X: d3_time_parseLocaleTime,
+ y: d3_time_parseYear,
+ Y: d3_time_parseFullYear,
+ Z: d3_time_parseZone,
+ "%": d3_time_parseLiteralPercent
+ };
+ function d3_time_parseWeekdayAbbrev(date, string, i) {
+ d3_time_dayAbbrevRe.lastIndex = 0;
+ var n = d3_time_dayAbbrevRe.exec(string.substring(i));
+ return n ? (date.w = d3_time_dayAbbrevLookup.get(n[0].toLowerCase()), i + n[0].length) : -1;
+ }
+ function d3_time_parseWeekday(date, string, i) {
+ d3_time_dayRe.lastIndex = 0;
+ var n = d3_time_dayRe.exec(string.substring(i));
+ return n ? (date.w = d3_time_dayLookup.get(n[0].toLowerCase()), i + n[0].length) : -1;
+ }
+ function d3_time_parseMonthAbbrev(date, string, i) {
+ d3_time_monthAbbrevRe.lastIndex = 0;
+ var n = d3_time_monthAbbrevRe.exec(string.substring(i));
+ return n ? (date.m = d3_time_monthAbbrevLookup.get(n[0].toLowerCase()), i + n[0].length) : -1;
+ }
+ function d3_time_parseMonth(date, string, i) {
+ d3_time_monthRe.lastIndex = 0;
+ var n = d3_time_monthRe.exec(string.substring(i));
+ return n ? (date.m = d3_time_monthLookup.get(n[0].toLowerCase()), i + n[0].length) : -1;
+ }
+ function d3_time_parseLocaleFull(date, string, i) {
+ return d3_time_parse(date, d3_time_formats.c.toString(), string, i);
+ }
+ function d3_time_parseLocaleDate(date, string, i) {
+ return d3_time_parse(date, d3_time_formats.x.toString(), string, i);
+ }
+ function d3_time_parseLocaleTime(date, string, i) {
+ return d3_time_parse(date, d3_time_formats.X.toString(), string, i);
+ }
+ function d3_time_parseAmPm(date, string, i) {
+ var n = d3_time_periodLookup.get(string.substring(i, i += 2).toLowerCase());
+ return n == null ? -1 : (date.p = n, i);
+ }
+ return d3_time_format;
+ }
+ var d3_time_formatPads = {
+ "-": "",
+ _: " ",
+ "0": "0"
+ }, d3_time_numberRe = /^\s*\d+/, d3_time_percentRe = /^%/;
+ function d3_time_formatPad(value, fill, width) {
+ var sign = value < 0 ? "-" : "", string = (sign ? -value : value) + "", length = string.length;
+ return sign + (length < width ? new Array(width - length + 1).join(fill) + string : string);
+ }
+ function d3_time_formatRe(names) {
+ return new RegExp("^(?:" + names.map(d3.requote).join("|") + ")", "i");
+ }
+ function d3_time_formatLookup(names) {
+ var map = new d3_Map(), i = -1, n = names.length;
+ while (++i < n) map.set(names[i].toLowerCase(), i);
+ return map;
+ }
+ function d3_time_parseWeekdayNumber(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i, i + 1));
+ return n ? (date.w = +n[0], i + n[0].length) : -1;
+ }
+ function d3_time_parseWeekNumberSunday(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i));
+ return n ? (date.U = +n[0], i + n[0].length) : -1;
+ }
+ function d3_time_parseWeekNumberMonday(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i));
+ return n ? (date.W = +n[0], i + n[0].length) : -1;
+ }
+ function d3_time_parseFullYear(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i, i + 4));
+ return n ? (date.y = +n[0], i + n[0].length) : -1;
+ }
+ function d3_time_parseYear(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i, i + 2));
+ return n ? (date.y = d3_time_expandYear(+n[0]), i + n[0].length) : -1;
+ }
+ function d3_time_parseZone(date, string, i) {
+ return /^[+-]\d{4}$/.test(string = string.substring(i, i + 5)) ? (date.Z = +string,
+ i + 5) : -1;
+ }
+ function d3_time_expandYear(d) {
+ return d + (d > 68 ? 1900 : 2e3);
+ }
+ function d3_time_parseMonthNumber(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i, i + 2));
+ return n ? (date.m = n[0] - 1, i + n[0].length) : -1;
+ }
+ function d3_time_parseDay(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i, i + 2));
+ return n ? (date.d = +n[0], i + n[0].length) : -1;
+ }
+ function d3_time_parseDayOfYear(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i, i + 3));
+ return n ? (date.j = +n[0], i + n[0].length) : -1;
+ }
+ function d3_time_parseHour24(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i, i + 2));
+ return n ? (date.H = +n[0], i + n[0].length) : -1;
+ }
+ function d3_time_parseMinutes(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i, i + 2));
+ return n ? (date.M = +n[0], i + n[0].length) : -1;
+ }
+ function d3_time_parseSeconds(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i, i + 2));
+ return n ? (date.S = +n[0], i + n[0].length) : -1;
+ }
+ function d3_time_parseMilliseconds(date, string, i) {
+ d3_time_numberRe.lastIndex = 0;
+ var n = d3_time_numberRe.exec(string.substring(i, i + 3));
+ return n ? (date.L = +n[0], i + n[0].length) : -1;
+ }
+ function d3_time_zone(d) {
+ var z = d.getTimezoneOffset(), zs = z > 0 ? "-" : "+", zh = ~~(abs(z) / 60), zm = abs(z) % 60;
+ return zs + d3_time_formatPad(zh, "0", 2) + d3_time_formatPad(zm, "0", 2);
+ }
+ function d3_time_parseLiteralPercent(date, string, i) {
+ d3_time_percentRe.lastIndex = 0;
+ var n = d3_time_percentRe.exec(string.substring(i, i + 1));
+ return n ? i + n[0].length : -1;
+ }
+ function d3_time_formatMulti(formats) {
+ var n = formats.length, i = -1;
+ while (++i < n) formats[i][0] = this(formats[i][0]);
+ return function(date) {
+ var i = 0, f = formats[i];
+ while (!f[1](date)) f = formats[++i];
+ return f[0](date);
+ };
+ }
+ d3.locale = function(locale) {
+ return {
+ numberFormat: d3_locale_numberFormat(locale),
+ timeFormat: d3_locale_timeFormat(locale)
+ };
+ };
+ var d3_locale_enUS = d3.locale({
+ decimal: ".",
+ thousands: ",",
+ grouping: [ 3 ],
+ currency: [ "$", "" ],
+ dateTime: "%a %b %e %X %Y",
+ date: "%m/%d/%Y",
+ time: "%H:%M:%S",
+ periods: [ "AM", "PM" ],
+ days: [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ],
+ shortDays: [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ],
+ months: [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ],
+ shortMonths: [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ]
+ });
+ d3.format = d3_locale_enUS.numberFormat;
+ d3.geo = {};
+ function d3_adder() {}
+ d3_adder.prototype = {
+ s: 0,
+ t: 0,
+ add: function(y) {
+ d3_adderSum(y, this.t, d3_adderTemp);
+ d3_adderSum(d3_adderTemp.s, this.s, this);
+ if (this.s) this.t += d3_adderTemp.t; else this.s = d3_adderTemp.t;
+ },
+ reset: function() {
+ this.s = this.t = 0;
+ },
+ valueOf: function() {
+ return this.s;
+ }
+ };
+ var d3_adderTemp = new d3_adder();
+ function d3_adderSum(a, b, o) {
+ var x = o.s = a + b, bv = x - a, av = x - bv;
+ o.t = a - av + (b - bv);
+ }
+ d3.geo.stream = function(object, listener) {
+ if (object && d3_geo_streamObjectType.hasOwnProperty(object.type)) {
+ d3_geo_streamObjectType[object.type](object, listener);
+ } else {
+ d3_geo_streamGeometry(object, listener);
+ }
+ };
+ function d3_geo_streamGeometry(geometry, listener) {
+ if (geometry && d3_geo_streamGeometryType.hasOwnProperty(geometry.type)) {
+ d3_geo_streamGeometryType[geometry.type](geometry, listener);
+ }
+ }
+ var d3_geo_streamObjectType = {
+ Feature: function(feature, listener) {
+ d3_geo_streamGeometry(feature.geometry, listener);
+ },
+ FeatureCollection: function(object, listener) {
+ var features = object.features, i = -1, n = features.length;
+ while (++i < n) d3_geo_streamGeometry(features[i].geometry, listener);
+ }
+ };
+ var d3_geo_streamGeometryType = {
+ Sphere: function(object, listener) {
+ listener.sphere();
+ },
+ Point: function(object, listener) {
+ object = object.coordinates;
+ listener.point(object[0], object[1], object[2]);
+ },
+ MultiPoint: function(object, listener) {
+ var coordinates = object.coordinates, i = -1, n = coordinates.length;
+ while (++i < n) object = coordinates[i], listener.point(object[0], object[1], object[2]);
+ },
+ LineString: function(object, listener) {
+ d3_geo_streamLine(object.coordinates, listener, 0);
+ },
+ MultiLineString: function(object, listener) {
+ var coordinates = object.coordinates, i = -1, n = coordinates.length;
+ while (++i < n) d3_geo_streamLine(coordinates[i], listener, 0);
+ },
+ Polygon: function(object, listener) {
+ d3_geo_streamPolygon(object.coordinates, listener);
+ },
+ MultiPolygon: function(object, listener) {
+ var coordinates = object.coordinates, i = -1, n = coordinates.length;
+ while (++i < n) d3_geo_streamPolygon(coordinates[i], listener);
+ },
+ GeometryCollection: function(object, listener) {
+ var geometries = object.geometries, i = -1, n = geometries.length;
+ while (++i < n) d3_geo_streamGeometry(geometries[i], listener);
+ }
+ };
+ function d3_geo_streamLine(coordinates, listener, closed) {
+ var i = -1, n = coordinates.length - closed, coordinate;
+ listener.lineStart();
+ while (++i < n) coordinate = coordinates[i], listener.point(coordinate[0], coordinate[1], coordinate[2]);
+ listener.lineEnd();
+ }
+ function d3_geo_streamPolygon(coordinates, listener) {
+ var i = -1, n = coordinates.length;
+ listener.polygonStart();
+ while (++i < n) d3_geo_streamLine(coordinates[i], listener, 1);
+ listener.polygonEnd();
+ }
+ d3.geo.area = function(object) {
+ d3_geo_areaSum = 0;
+ d3.geo.stream(object, d3_geo_area);
+ return d3_geo_areaSum;
+ };
+ var d3_geo_areaSum, d3_geo_areaRingSum = new d3_adder();
+ var d3_geo_area = {
+ sphere: function() {
+ d3_geo_areaSum += 4 * π;
+ },
+ point: d3_noop,
+ lineStart: d3_noop,
+ lineEnd: d3_noop,
+ polygonStart: function() {
+ d3_geo_areaRingSum.reset();
+ d3_geo_area.lineStart = d3_geo_areaRingStart;
+ },
+ polygonEnd: function() {
+ var area = 2 * d3_geo_areaRingSum;
+ d3_geo_areaSum += area < 0 ? 4 * π + area : area;
+ d3_geo_area.lineStart = d3_geo_area.lineEnd = d3_geo_area.point = d3_noop;
+ }
+ };
+ function d3_geo_areaRingStart() {
+ var λ00, φ00, λ0, cosφ0, sinφ0;
+ d3_geo_area.point = function(λ, φ) {
+ d3_geo_area.point = nextPoint;
+ λ0 = (λ00 = λ) * d3_radians, cosφ0 = Math.cos(φ = (φ00 = φ) * d3_radians / 2 + π / 4),
+ sinφ0 = Math.sin(φ);
+ };
+ function nextPoint(λ, φ) {
+ λ *= d3_radians;
+ φ = φ * d3_radians / 2 + π / 4;
+ var dλ = λ - λ0, cosφ = Math.cos(φ), sinφ = Math.sin(φ), k = sinφ0 * sinφ, u = cosφ0 * cosφ + k * Math.cos(dλ), v = k * Math.sin(dλ);
+ d3_geo_areaRingSum.add(Math.atan2(v, u));
+ λ0 = λ, cosφ0 = cosφ, sinφ0 = sinφ;
+ }
+ d3_geo_area.lineEnd = function() {
+ nextPoint(λ00, φ00);
+ };
+ }
+ function d3_geo_cartesian(spherical) {
+ var λ = spherical[0], φ = spherical[1], cosφ = Math.cos(φ);
+ return [ cosφ * Math.cos(λ), cosφ * Math.sin(λ), Math.sin(φ) ];
+ }
+ function d3_geo_cartesianDot(a, b) {
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+ }
+ function d3_geo_cartesianCross(a, b) {
+ return [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0] ];
+ }
+ function d3_geo_cartesianAdd(a, b) {
+ a[0] += b[0];
+ a[1] += b[1];
+ a[2] += b[2];
+ }
+ function d3_geo_cartesianScale(vector, k) {
+ return [ vector[0] * k, vector[1] * k, vector[2] * k ];
+ }
+ function d3_geo_cartesianNormalize(d) {
+ var l = Math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
+ d[0] /= l;
+ d[1] /= l;
+ d[2] /= l;
+ }
+ function d3_geo_spherical(cartesian) {
+ return [ Math.atan2(cartesian[1], cartesian[0]), d3_asin(cartesian[2]) ];
+ }
+ function d3_geo_sphericalEqual(a, b) {
+ return abs(a[0] - b[0]) < ε && abs(a[1] - b[1]) < ε;
+ }
+ d3.geo.bounds = function() {
+ var λ0, φ0, λ1, φ1, λ_, λ__, φ__, p0, dλSum, ranges, range;
+ var bound = {
+ point: point,
+ lineStart: lineStart,
+ lineEnd: lineEnd,
+ polygonStart: function() {
+ bound.point = ringPoint;
+ bound.lineStart = ringStart;
+ bound.lineEnd = ringEnd;
+ dλSum = 0;
+ d3_geo_area.polygonStart();
+ },
+ polygonEnd: function() {
+ d3_geo_area.polygonEnd();
+ bound.point = point;
+ bound.lineStart = lineStart;
+ bound.lineEnd = lineEnd;
+ if (d3_geo_areaRingSum < 0) λ0 = -(λ1 = 180), φ0 = -(φ1 = 90); else if (dλSum > ε) φ1 = 90; else if (dλSum < -ε) φ0 = -90;
+ range[0] = λ0, range[1] = λ1;
+ }
+ };
+ function point(λ, φ) {
+ ranges.push(range = [ λ0 = λ, λ1 = λ ]);
+ if (φ < φ0) φ0 = φ;
+ if (φ > φ1) φ1 = φ;
+ }
+ function linePoint(λ, φ) {
+ var p = d3_geo_cartesian([ λ * d3_radians, φ * d3_radians ]);
+ if (p0) {
+ var normal = d3_geo_cartesianCross(p0, p), equatorial = [ normal[1], -normal[0], 0 ], inflection = d3_geo_cartesianCross(equatorial, normal);
+ d3_geo_cartesianNormalize(inflection);
+ inflection = d3_geo_spherical(inflection);
+ var dλ = λ - λ_, s = dλ > 0 ? 1 : -1, λi = inflection[0] * d3_degrees * s, antimeridian = abs(dλ) > 180;
+ if (antimeridian ^ (s * λ_ < λi && λi < s * λ)) {
+ var φi = inflection[1] * d3_degrees;
+ if (φi > φ1) φ1 = φi;
+ } else if (λi = (λi + 360) % 360 - 180, antimeridian ^ (s * λ_ < λi && λi < s * λ)) {
+ var φi = -inflection[1] * d3_degrees;
+ if (φi < φ0) φ0 = φi;
+ } else {
+ if (φ < φ0) φ0 = φ;
+ if (φ > φ1) φ1 = φ;
+ }
+ if (antimeridian) {
+ if (λ < λ_) {
+ if (angle(λ0, λ) > angle(λ0, λ1)) λ1 = λ;
+ } else {
+ if (angle(λ, λ1) > angle(λ0, λ1)) λ0 = λ;
+ }
+ } else {
+ if (λ1 >= λ0) {
+ if (λ < λ0) λ0 = λ;
+ if (λ > λ1) λ1 = λ;
+ } else {
+ if (λ > λ_) {
+ if (angle(λ0, λ) > angle(λ0, λ1)) λ1 = λ;
+ } else {
+ if (angle(λ, λ1) > angle(λ0, λ1)) λ0 = λ;
+ }
+ }
+ }
+ } else {
+ point(λ, φ);
+ }
+ p0 = p, λ_ = λ;
+ }
+ function lineStart() {
+ bound.point = linePoint;
+ }
+ function lineEnd() {
+ range[0] = λ0, range[1] = λ1;
+ bound.point = point;
+ p0 = null;
+ }
+ function ringPoint(λ, φ) {
+ if (p0) {
+ var dλ = λ - λ_;
+ dλSum += abs(dλ) > 180 ? dλ + (dλ > 0 ? 360 : -360) : dλ;
+ } else λ__ = λ, φ__ = φ;
+ d3_geo_area.point(λ, φ);
+ linePoint(λ, φ);
+ }
+ function ringStart() {
+ d3_geo_area.lineStart();
+ }
+ function ringEnd() {
+ ringPoint(λ__, φ__);
+ d3_geo_area.lineEnd();
+ if (abs(dλSum) > ε) λ0 = -(λ1 = 180);
+ range[0] = λ0, range[1] = λ1;
+ p0 = null;
+ }
+ function angle(λ0, λ1) {
+ return (λ1 -= λ0) < 0 ? λ1 + 360 : λ1;
+ }
+ function compareRanges(a, b) {
+ return a[0] - b[0];
+ }
+ function withinRange(x, range) {
+ return range[0] <= range[1] ? range[0] <= x && x <= range[1] : x < range[0] || range[1] < x;
+ }
+ return function(feature) {
+ φ1 = λ1 = -(λ0 = φ0 = Infinity);
+ ranges = [];
+ d3.geo.stream(feature, bound);
+ var n = ranges.length;
+ if (n) {
+ ranges.sort(compareRanges);
+ for (var i = 1, a = ranges[0], b, merged = [ a ]; i < n; ++i) {
+ b = ranges[i];
+ if (withinRange(b[0], a) || withinRange(b[1], a)) {
+ if (angle(a[0], b[1]) > angle(a[0], a[1])) a[1] = b[1];
+ if (angle(b[0], a[1]) > angle(a[0], a[1])) a[0] = b[0];
+ } else {
+ merged.push(a = b);
+ }
+ }
+ var best = -Infinity, dλ;
+ for (var n = merged.length - 1, i = 0, a = merged[n], b; i <= n; a = b, ++i) {
+ b = merged[i];
+ if ((dλ = angle(a[1], b[0])) > best) best = dλ, λ0 = b[0], λ1 = a[1];
+ }
+ }
+ ranges = range = null;
+ return λ0 === Infinity || φ0 === Infinity ? [ [ NaN, NaN ], [ NaN, NaN ] ] : [ [ λ0, φ0 ], [ λ1, φ1 ] ];
+ };
+ }();
+ d3.geo.centroid = function(object) {
+ d3_geo_centroidW0 = d3_geo_centroidW1 = d3_geo_centroidX0 = d3_geo_centroidY0 = d3_geo_centroidZ0 = d3_geo_centroidX1 = d3_geo_centroidY1 = d3_geo_centroidZ1 = d3_geo_centroidX2 = d3_geo_centroidY2 = d3_geo_centroidZ2 = 0;
+ d3.geo.stream(object, d3_geo_centroid);
+ var x = d3_geo_centroidX2, y = d3_geo_centroidY2, z = d3_geo_centroidZ2, m = x * x + y * y + z * z;
+ if (m < ε2) {
+ x = d3_geo_centroidX1, y = d3_geo_centroidY1, z = d3_geo_centroidZ1;
+ if (d3_geo_centroidW1 < ε) x = d3_geo_centroidX0, y = d3_geo_centroidY0, z = d3_geo_centroidZ0;
+ m = x * x + y * y + z * z;
+ if (m < ε2) return [ NaN, NaN ];
+ }
+ return [ Math.atan2(y, x) * d3_degrees, d3_asin(z / Math.sqrt(m)) * d3_degrees ];
+ };
+ var d3_geo_centroidW0, d3_geo_centroidW1, d3_geo_centroidX0, d3_geo_centroidY0, d3_geo_centroidZ0, d3_geo_centroidX1, d3_geo_centroidY1, d3_geo_centroidZ1, d3_geo_centroidX2, d3_geo_centroidY2, d3_geo_centroidZ2;
+ var d3_geo_centroid = {
+ sphere: d3_noop,
+ point: d3_geo_centroidPoint,
+ lineStart: d3_geo_centroidLineStart,
+ lineEnd: d3_geo_centroidLineEnd,
+ polygonStart: function() {
+ d3_geo_centroid.lineStart = d3_geo_centroidRingStart;
+ },
+ polygonEnd: function() {
+ d3_geo_centroid.lineStart = d3_geo_centroidLineStart;
+ }
+ };
+ function d3_geo_centroidPoint(λ, φ) {
+ λ *= d3_radians;
+ var cosφ = Math.cos(φ *= d3_radians);
+ d3_geo_centroidPointXYZ(cosφ * Math.cos(λ), cosφ * Math.sin(λ), Math.sin(φ));
+ }
+ function d3_geo_centroidPointXYZ(x, y, z) {
+ ++d3_geo_centroidW0;
+ d3_geo_centroidX0 += (x - d3_geo_centroidX0) / d3_geo_centroidW0;
+ d3_geo_centroidY0 += (y - d3_geo_centroidY0) / d3_geo_centroidW0;
+ d3_geo_centroidZ0 += (z - d3_geo_centroidZ0) / d3_geo_centroidW0;
+ }
+ function d3_geo_centroidLineStart() {
+ var x0, y0, z0;
+ d3_geo_centroid.point = function(λ, φ) {
+ λ *= d3_radians;
+ var cosφ = Math.cos(φ *= d3_radians);
+ x0 = cosφ * Math.cos(λ);
+ y0 = cosφ * Math.sin(λ);
+ z0 = Math.sin(φ);
+ d3_geo_centroid.point = nextPoint;
+ d3_geo_centroidPointXYZ(x0, y0, z0);
+ };
+ function nextPoint(λ, φ) {
+ λ *= d3_radians;
+ var cosφ = Math.cos(φ *= d3_radians), x = cosφ * Math.cos(λ), y = cosφ * Math.sin(λ), z = Math.sin(φ), w = Math.atan2(Math.sqrt((w = y0 * z - z0 * y) * w + (w = z0 * x - x0 * z) * w + (w = x0 * y - y0 * x) * w), x0 * x + y0 * y + z0 * z);
+ d3_geo_centroidW1 += w;
+ d3_geo_centroidX1 += w * (x0 + (x0 = x));
+ d3_geo_centroidY1 += w * (y0 + (y0 = y));
+ d3_geo_centroidZ1 += w * (z0 + (z0 = z));
+ d3_geo_centroidPointXYZ(x0, y0, z0);
+ }
+ }
+ function d3_geo_centroidLineEnd() {
+ d3_geo_centroid.point = d3_geo_centroidPoint;
+ }
+ function d3_geo_centroidRingStart() {
+ var λ00, φ00, x0, y0, z0;
+ d3_geo_centroid.point = function(λ, φ) {
+ λ00 = λ, φ00 = φ;
+ d3_geo_centroid.point = nextPoint;
+ λ *= d3_radians;
+ var cosφ = Math.cos(φ *= d3_radians);
+ x0 = cosφ * Math.cos(λ);
+ y0 = cosφ * Math.sin(λ);
+ z0 = Math.sin(φ);
+ d3_geo_centroidPointXYZ(x0, y0, z0);
+ };
+ d3_geo_centroid.lineEnd = function() {
+ nextPoint(λ00, φ00);
+ d3_geo_centroid.lineEnd = d3_geo_centroidLineEnd;
+ d3_geo_centroid.point = d3_geo_centroidPoint;
+ };
+ function nextPoint(λ, φ) {
+ λ *= d3_radians;
+ var cosφ = Math.cos(φ *= d3_radians), x = cosφ * Math.cos(λ), y = cosφ * Math.sin(λ), z = Math.sin(φ), cx = y0 * z - z0 * y, cy = z0 * x - x0 * z, cz = x0 * y - y0 * x, m = Math.sqrt(cx * cx + cy * cy + cz * cz), u = x0 * x + y0 * y + z0 * z, v = m && -d3_acos(u) / m, w = Math.atan2(m, u);
+ d3_geo_centroidX2 += v * cx;
+ d3_geo_centroidY2 += v * cy;
+ d3_geo_centroidZ2 += v * cz;
+ d3_geo_centroidW1 += w;
+ d3_geo_centroidX1 += w * (x0 + (x0 = x));
+ d3_geo_centroidY1 += w * (y0 + (y0 = y));
+ d3_geo_centroidZ1 += w * (z0 + (z0 = z));
+ d3_geo_centroidPointXYZ(x0, y0, z0);
+ }
+ }
+ function d3_true() {
+ return true;
+ }
+ function d3_geo_clipPolygon(segments, compare, clipStartInside, interpolate, listener) {
+ var subject = [], clip = [];
+ segments.forEach(function(segment) {
+ if ((n = segment.length - 1) <= 0) return;
+ var n, p0 = segment[0], p1 = segment[n];
+ if (d3_geo_sphericalEqual(p0, p1)) {
+ listener.lineStart();
+ for (var i = 0; i < n; ++i) listener.point((p0 = segment[i])[0], p0[1]);
+ listener.lineEnd();
+ return;
+ }
+ var a = new d3_geo_clipPolygonIntersection(p0, segment, null, true), b = new d3_geo_clipPolygonIntersection(p0, null, a, false);
+ a.o = b;
+ subject.push(a);
+ clip.push(b);
+ a = new d3_geo_clipPolygonIntersection(p1, segment, null, false);
+ b = new d3_geo_clipPolygonIntersection(p1, null, a, true);
+ a.o = b;
+ subject.push(a);
+ clip.push(b);
+ });
+ clip.sort(compare);
+ d3_geo_clipPolygonLinkCircular(subject);
+ d3_geo_clipPolygonLinkCircular(clip);
+ if (!subject.length) return;
+ for (var i = 0, entry = clipStartInside, n = clip.length; i < n; ++i) {
+ clip[i].e = entry = !entry;
+ }
+ var start = subject[0], points, point;
+ while (1) {
+ var current = start, isSubject = true;
+ while (current.v) if ((current = current.n) === start) return;
+ points = current.z;
+ listener.lineStart();
+ do {
+ current.v = current.o.v = true;
+ if (current.e) {
+ if (isSubject) {
+ for (var i = 0, n = points.length; i < n; ++i) listener.point((point = points[i])[0], point[1]);
+ } else {
+ interpolate(current.x, current.n.x, 1, listener);
+ }
+ current = current.n;
+ } else {
+ if (isSubject) {
+ points = current.p.z;
+ for (var i = points.length - 1; i >= 0; --i) listener.point((point = points[i])[0], point[1]);
+ } else {
+ interpolate(current.x, current.p.x, -1, listener);
+ }
+ current = current.p;
+ }
+ current = current.o;
+ points = current.z;
+ isSubject = !isSubject;
+ } while (!current.v);
+ listener.lineEnd();
+ }
+ }
+ function d3_geo_clipPolygonLinkCircular(array) {
+ if (!(n = array.length)) return;
+ var n, i = 0, a = array[0], b;
+ while (++i < n) {
+ a.n = b = array[i];
+ b.p = a;
+ a = b;
+ }
+ a.n = b = array[0];
+ b.p = a;
+ }
+ function d3_geo_clipPolygonIntersection(point, points, other, entry) {
+ this.x = point;
+ this.z = points;
+ this.o = other;
+ this.e = entry;
+ this.v = false;
+ this.n = this.p = null;
+ }
+ function d3_geo_clip(pointVisible, clipLine, interpolate, clipStart) {
+ return function(rotate, listener) {
+ var line = clipLine(listener), rotatedClipStart = rotate.invert(clipStart[0], clipStart[1]);
+ var clip = {
+ point: point,
+ lineStart: lineStart,
+ lineEnd: lineEnd,
+ polygonStart: function() {
+ clip.point = pointRing;
+ clip.lineStart = ringStart;
+ clip.lineEnd = ringEnd;
+ segments = [];
+ polygon = [];
+ listener.polygonStart();
+ },
+ polygonEnd: function() {
+ clip.point = point;
+ clip.lineStart = lineStart;
+ clip.lineEnd = lineEnd;
+ segments = d3.merge(segments);
+ var clipStartInside = d3_geo_pointInPolygon(rotatedClipStart, polygon);
+ if (segments.length) {
+ d3_geo_clipPolygon(segments, d3_geo_clipSort, clipStartInside, interpolate, listener);
+ } else if (clipStartInside) {
+ listener.lineStart();
+ interpolate(null, null, 1, listener);
+ listener.lineEnd();
+ }
+ listener.polygonEnd();
+ segments = polygon = null;
+ },
+ sphere: function() {
+ listener.polygonStart();
+ listener.lineStart();
+ interpolate(null, null, 1, listener);
+ listener.lineEnd();
+ listener.polygonEnd();
+ }
+ };
+ function point(λ, φ) {
+ var point = rotate(λ, φ);
+ if (pointVisible(λ = point[0], φ = point[1])) listener.point(λ, φ);
+ }
+ function pointLine(λ, φ) {
+ var point = rotate(λ, φ);
+ line.point(point[0], point[1]);
+ }
+ function lineStart() {
+ clip.point = pointLine;
+ line.lineStart();
+ }
+ function lineEnd() {
+ clip.point = point;
+ line.lineEnd();
+ }
+ var segments;
+ var buffer = d3_geo_clipBufferListener(), ringListener = clipLine(buffer), polygon, ring;
+ function pointRing(λ, φ) {
+ ring.push([ λ, φ ]);
+ var point = rotate(λ, φ);
+ ringListener.point(point[0], point[1]);
+ }
+ function ringStart() {
+ ringListener.lineStart();
+ ring = [];
+ }
+ function ringEnd() {
+ pointRing(ring[0][0], ring[0][1]);
+ ringListener.lineEnd();
+ var clean = ringListener.clean(), ringSegments = buffer.buffer(), segment, n = ringSegments.length;
+ ring.pop();
+ polygon.push(ring);
+ ring = null;
+ if (!n) return;
+ if (clean & 1) {
+ segment = ringSegments[0];
+ var n = segment.length - 1, i = -1, point;
+ listener.lineStart();
+ while (++i < n) listener.point((point = segment[i])[0], point[1]);
+ listener.lineEnd();
+ return;
+ }
+ if (n > 1 && clean & 2) ringSegments.push(ringSegments.pop().concat(ringSegments.shift()));
+ segments.push(ringSegments.filter(d3_geo_clipSegmentLength1));
+ }
+ return clip;
+ };
+ }
+ function d3_geo_clipSegmentLength1(segment) {
+ return segment.length > 1;
+ }
+ function d3_geo_clipBufferListener() {
+ var lines = [], line;
+ return {
+ lineStart: function() {
+ lines.push(line = []);
+ },
+ point: function(λ, φ) {
+ line.push([ λ, φ ]);
+ },
+ lineEnd: d3_noop,
+ buffer: function() {
+ var buffer = lines;
+ lines = [];
+ line = null;
+ return buffer;
+ },
+ rejoin: function() {
+ if (lines.length > 1) lines.push(lines.pop().concat(lines.shift()));
+ }
+ };
+ }
+ function d3_geo_clipSort(a, b) {
+ return ((a = a.x)[0] < 0 ? a[1] - halfπ - ε : halfπ - a[1]) - ((b = b.x)[0] < 0 ? b[1] - halfπ - ε : halfπ - b[1]);
+ }
+ function d3_geo_pointInPolygon(point, polygon) {
+ var meridian = point[0], parallel = point[1], meridianNormal = [ Math.sin(meridian), -Math.cos(meridian), 0 ], polarAngle = 0, winding = 0;
+ d3_geo_areaRingSum.reset();
+ for (var i = 0, n = polygon.length; i < n; ++i) {
+ var ring = polygon[i], m = ring.length;
+ if (!m) continue;
+ var point0 = ring[0], λ0 = point0[0], φ0 = point0[1] / 2 + π / 4, sinφ0 = Math.sin(φ0), cosφ0 = Math.cos(φ0), j = 1;
+ while (true) {
+ if (j === m) j = 0;
+ point = ring[j];
+ var λ = point[0], φ = point[1] / 2 + π / 4, sinφ = Math.sin(φ), cosφ = Math.cos(φ), dλ = λ - λ0, antimeridian = abs(dλ) > π, k = sinφ0 * sinφ;
+ d3_geo_areaRingSum.add(Math.atan2(k * Math.sin(dλ), cosφ0 * cosφ + k * Math.cos(dλ)));
+ polarAngle += antimeridian ? dλ + (dλ >= 0 ? τ : -τ) : dλ;
+ if (antimeridian ^ λ0 >= meridian ^ λ >= meridian) {
+ var arc = d3_geo_cartesianCross(d3_geo_cartesian(point0), d3_geo_cartesian(point));
+ d3_geo_cartesianNormalize(arc);
+ var intersection = d3_geo_cartesianCross(meridianNormal, arc);
+ d3_geo_cartesianNormalize(intersection);
+ var φarc = (antimeridian ^ dλ >= 0 ? -1 : 1) * d3_asin(intersection[2]);
+ if (parallel > φarc || parallel === φarc && (arc[0] || arc[1])) {
+ winding += antimeridian ^ dλ >= 0 ? 1 : -1;
+ }
+ }
+ if (!j++) break;
+ λ0 = λ, sinφ0 = sinφ, cosφ0 = cosφ, point0 = point;
+ }
+ }
+ return (polarAngle < -ε || polarAngle < ε && d3_geo_areaRingSum < 0) ^ winding & 1;
+ }
+ var d3_geo_clipAntimeridian = d3_geo_clip(d3_true, d3_geo_clipAntimeridianLine, d3_geo_clipAntimeridianInterpolate, [ -π, -π / 2 ]);
+ function d3_geo_clipAntimeridianLine(listener) {
+ var λ0 = NaN, φ0 = NaN, sλ0 = NaN, clean;
+ return {
+ lineStart: function() {
+ listener.lineStart();
+ clean = 1;
+ },
+ point: function(λ1, φ1) {
+ var sλ1 = λ1 > 0 ? π : -π, dλ = abs(λ1 - λ0);
+ if (abs(dλ - π) < ε) {
+ listener.point(λ0, φ0 = (φ0 + φ1) / 2 > 0 ? halfπ : -halfπ);
+ listener.point(sλ0, φ0);
+ listener.lineEnd();
+ listener.lineStart();
+ listener.point(sλ1, φ0);
+ listener.point(λ1, φ0);
+ clean = 0;
+ } else if (sλ0 !== sλ1 && dλ >= π) {
+ if (abs(λ0 - sλ0) < ε) λ0 -= sλ0 * ε;
+ if (abs(λ1 - sλ1) < ε) λ1 -= sλ1 * ε;
+ φ0 = d3_geo_clipAntimeridianIntersect(λ0, φ0, λ1, φ1);
+ listener.point(sλ0, φ0);
+ listener.lineEnd();
+ listener.lineStart();
+ listener.point(sλ1, φ0);
+ clean = 0;
+ }
+ listener.point(λ0 = λ1, φ0 = φ1);
+ sλ0 = sλ1;
+ },
+ lineEnd: function() {
+ listener.lineEnd();
+ λ0 = φ0 = NaN;
+ },
+ clean: function() {
+ return 2 - clean;
+ }
+ };
+ }
+ function d3_geo_clipAntimeridianIntersect(λ0, φ0, λ1, φ1) {
+ var cosφ0, cosφ1, sinλ0_λ1 = Math.sin(λ0 - λ1);
+ return abs(sinλ0_λ1) > ε ? Math.atan((Math.sin(φ0) * (cosφ1 = Math.cos(φ1)) * Math.sin(λ1) - Math.sin(φ1) * (cosφ0 = Math.cos(φ0)) * Math.sin(λ0)) / (cosφ0 * cosφ1 * sinλ0_λ1)) : (φ0 + φ1) / 2;
+ }
+ function d3_geo_clipAntimeridianInterpolate(from, to, direction, listener) {
+ var φ;
+ if (from == null) {
+ φ = direction * halfπ;
+ listener.point(-π, φ);
+ listener.point(0, φ);
+ listener.point(π, φ);
+ listener.point(π, 0);
+ listener.point(π, -φ);
+ listener.point(0, -φ);
+ listener.point(-π, -φ);
+ listener.point(-π, 0);
+ listener.point(-π, φ);
+ } else if (abs(from[0] - to[0]) > ε) {
+ var s = from[0] < to[0] ? π : -π;
+ φ = direction * s / 2;
+ listener.point(-s, φ);
+ listener.point(0, φ);
+ listener.point(s, φ);
+ } else {
+ listener.point(to[0], to[1]);
+ }
+ }
+ function d3_geo_clipCircle(radius) {
+ var cr = Math.cos(radius), smallRadius = cr > 0, notHemisphere = abs(cr) > ε, interpolate = d3_geo_circleInterpolate(radius, 6 * d3_radians);
+ return d3_geo_clip(visible, clipLine, interpolate, smallRadius ? [ 0, -radius ] : [ -π, radius - π ]);
+ function visible(λ, φ) {
+ return Math.cos(λ) * Math.cos(φ) > cr;
+ }
+ function clipLine(listener) {
+ var point0, c0, v0, v00, clean;
+ return {
+ lineStart: function() {
+ v00 = v0 = false;
+ clean = 1;
+ },
+ point: function(λ, φ) {
+ var point1 = [ λ, φ ], point2, v = visible(λ, φ), c = smallRadius ? v ? 0 : code(λ, φ) : v ? code(λ + (λ < 0 ? π : -π), φ) : 0;
+ if (!point0 && (v00 = v0 = v)) listener.lineStart();
+ if (v !== v0) {
+ point2 = intersect(point0, point1);
+ if (d3_geo_sphericalEqual(point0, point2) || d3_geo_sphericalEqual(point1, point2)) {
+ point1[0] += ε;
+ point1[1] += ε;
+ v = visible(point1[0], point1[1]);
+ }
+ }
+ if (v !== v0) {
+ clean = 0;
+ if (v) {
+ listener.lineStart();
+ point2 = intersect(point1, point0);
+ listener.point(point2[0], point2[1]);
+ } else {
+ point2 = intersect(point0, point1);
+ listener.point(point2[0], point2[1]);
+ listener.lineEnd();
+ }
+ point0 = point2;
+ } else if (notHemisphere && point0 && smallRadius ^ v) {
+ var t;
+ if (!(c & c0) && (t = intersect(point1, point0, true))) {
+ clean = 0;
+ if (smallRadius) {
+ listener.lineStart();
+ listener.point(t[0][0], t[0][1]);
+ listener.point(t[1][0], t[1][1]);
+ listener.lineEnd();
+ } else {
+ listener.point(t[1][0], t[1][1]);
+ listener.lineEnd();
+ listener.lineStart();
+ listener.point(t[0][0], t[0][1]);
+ }
+ }
+ }
+ if (v && (!point0 || !d3_geo_sphericalEqual(point0, point1))) {
+ listener.point(point1[0], point1[1]);
+ }
+ point0 = point1, v0 = v, c0 = c;
+ },
+ lineEnd: function() {
+ if (v0) listener.lineEnd();
+ point0 = null;
+ },
+ clean: function() {
+ return clean | (v00 && v0) << 1;
+ }
+ };
+ }
+ function intersect(a, b, two) {
+ var pa = d3_geo_cartesian(a), pb = d3_geo_cartesian(b);
+ var n1 = [ 1, 0, 0 ], n2 = d3_geo_cartesianCross(pa, pb), n2n2 = d3_geo_cartesianDot(n2, n2), n1n2 = n2[0], determinant = n2n2 - n1n2 * n1n2;
+ if (!determinant) return !two && a;
+ var c1 = cr * n2n2 / determinant, c2 = -cr * n1n2 / determinant, n1xn2 = d3_geo_cartesianCross(n1, n2), A = d3_geo_cartesianScale(n1, c1), B = d3_geo_cartesianScale(n2, c2);
+ d3_geo_cartesianAdd(A, B);
+ var u = n1xn2, w = d3_geo_cartesianDot(A, u), uu = d3_geo_cartesianDot(u, u), t2 = w * w - uu * (d3_geo_cartesianDot(A, A) - 1);
+ if (t2 < 0) return;
+ var t = Math.sqrt(t2), q = d3_geo_cartesianScale(u, (-w - t) / uu);
+ d3_geo_cartesianAdd(q, A);
+ q = d3_geo_spherical(q);
+ if (!two) return q;
+ var λ0 = a[0], λ1 = b[0], φ0 = a[1], φ1 = b[1], z;
+ if (λ1 < λ0) z = λ0, λ0 = λ1, λ1 = z;
+ var δλ = λ1 - λ0, polar = abs(δλ - π) < ε, meridian = polar || δλ < ε;
+ if (!polar && φ1 < φ0) z = φ0, φ0 = φ1, φ1 = z;
+ if (meridian ? polar ? φ0 + φ1 > 0 ^ q[1] < (abs(q[0] - λ0) < ε ? φ0 : φ1) : φ0 <= q[1] && q[1] <= φ1 : δλ > π ^ (λ0 <= q[0] && q[0] <= λ1)) {
+ var q1 = d3_geo_cartesianScale(u, (-w + t) / uu);
+ d3_geo_cartesianAdd(q1, A);
+ return [ q, d3_geo_spherical(q1) ];
+ }
+ }
+ function code(λ, φ) {
+ var r = smallRadius ? radius : π - radius, code = 0;
+ if (λ < -r) code |= 1; else if (λ > r) code |= 2;
+ if (φ < -r) code |= 4; else if (φ > r) code |= 8;
+ return code;
+ }
+ }
+ function d3_geom_clipLine(x0, y0, x1, y1) {
+ return function(line) {
+ var a = line.a, b = line.b, ax = a.x, ay = a.y, bx = b.x, by = b.y, t0 = 0, t1 = 1, dx = bx - ax, dy = by - ay, r;
+ r = x0 - ax;
+ if (!dx && r > 0) return;
+ r /= dx;
+ if (dx < 0) {
+ if (r < t0) return;
+ if (r < t1) t1 = r;
+ } else if (dx > 0) {
+ if (r > t1) return;
+ if (r > t0) t0 = r;
+ }
+ r = x1 - ax;
+ if (!dx && r < 0) return;
+ r /= dx;
+ if (dx < 0) {
+ if (r > t1) return;
+ if (r > t0) t0 = r;
+ } else if (dx > 0) {
+ if (r < t0) return;
+ if (r < t1) t1 = r;
+ }
+ r = y0 - ay;
+ if (!dy && r > 0) return;
+ r /= dy;
+ if (dy < 0) {
+ if (r < t0) return;
+ if (r < t1) t1 = r;
+ } else if (dy > 0) {
+ if (r > t1) return;
+ if (r > t0) t0 = r;
+ }
+ r = y1 - ay;
+ if (!dy && r < 0) return;
+ r /= dy;
+ if (dy < 0) {
+ if (r > t1) return;
+ if (r > t0) t0 = r;
+ } else if (dy > 0) {
+ if (r < t0) return;
+ if (r < t1) t1 = r;
+ }
+ if (t0 > 0) line.a = {
+ x: ax + t0 * dx,
+ y: ay + t0 * dy
+ };
+ if (t1 < 1) line.b = {
+ x: ax + t1 * dx,
+ y: ay + t1 * dy
+ };
+ return line;
+ };
+ }
+ var d3_geo_clipExtentMAX = 1e9;
+ d3.geo.clipExtent = function() {
+ var x0, y0, x1, y1, stream, clip, clipExtent = {
+ stream: function(output) {
+ if (stream) stream.valid = false;
+ stream = clip(output);
+ stream.valid = true;
+ return stream;
+ },
+ extent: function(_) {
+ if (!arguments.length) return [ [ x0, y0 ], [ x1, y1 ] ];
+ clip = d3_geo_clipExtent(x0 = +_[0][0], y0 = +_[0][1], x1 = +_[1][0], y1 = +_[1][1]);
+ if (stream) stream.valid = false, stream = null;
+ return clipExtent;
+ }
+ };
+ return clipExtent.extent([ [ 0, 0 ], [ 960, 500 ] ]);
+ };
+ function d3_geo_clipExtent(x0, y0, x1, y1) {
+ return function(listener) {
+ var listener_ = listener, bufferListener = d3_geo_clipBufferListener(), clipLine = d3_geom_clipLine(x0, y0, x1, y1), segments, polygon, ring;
+ var clip = {
+ point: point,
+ lineStart: lineStart,
+ lineEnd: lineEnd,
+ polygonStart: function() {
+ listener = bufferListener;
+ segments = [];
+ polygon = [];
+ clean = true;
+ },
+ polygonEnd: function() {
+ listener = listener_;
+ segments = d3.merge(segments);
+ var clipStartInside = insidePolygon([ x0, y1 ]), inside = clean && clipStartInside, visible = segments.length;
+ if (inside || visible) {
+ listener.polygonStart();
+ if (inside) {
+ listener.lineStart();
+ interpolate(null, null, 1, listener);
+ listener.lineEnd();
+ }
+ if (visible) {
+ d3_geo_clipPolygon(segments, compare, clipStartInside, interpolate, listener);
+ }
+ listener.polygonEnd();
+ }
+ segments = polygon = ring = null;
+ }
+ };
+ function insidePolygon(p) {
+ var wn = 0, n = polygon.length, y = p[1];
+ for (var i = 0; i < n; ++i) {
+ for (var j = 1, v = polygon[i], m = v.length, a = v[0], b; j < m; ++j) {
+ b = v[j];
+ if (a[1] <= y) {
+ if (b[1] > y && d3_cross2d(a, b, p) > 0) ++wn;
+ } else {
+ if (b[1] <= y && d3_cross2d(a, b, p) < 0) --wn;
+ }
+ a = b;
+ }
+ }
+ return wn !== 0;
+ }
+ function interpolate(from, to, direction, listener) {
+ var a = 0, a1 = 0;
+ if (from == null || (a = corner(from, direction)) !== (a1 = corner(to, direction)) || comparePoints(from, to) < 0 ^ direction > 0) {
+ do {
+ listener.point(a === 0 || a === 3 ? x0 : x1, a > 1 ? y1 : y0);
+ } while ((a = (a + direction + 4) % 4) !== a1);
+ } else {
+ listener.point(to[0], to[1]);
+ }
+ }
+ function pointVisible(x, y) {
+ return x0 <= x && x <= x1 && y0 <= y && y <= y1;
+ }
+ function point(x, y) {
+ if (pointVisible(x, y)) listener.point(x, y);
+ }
+ var x__, y__, v__, x_, y_, v_, first, clean;
+ function lineStart() {
+ clip.point = linePoint;
+ if (polygon) polygon.push(ring = []);
+ first = true;
+ v_ = false;
+ x_ = y_ = NaN;
+ }
+ function lineEnd() {
+ if (segments) {
+ linePoint(x__, y__);
+ if (v__ && v_) bufferListener.rejoin();
+ segments.push(bufferListener.buffer());
+ }
+ clip.point = point;
+ if (v_) listener.lineEnd();
+ }
+ function linePoint(x, y) {
+ x = Math.max(-d3_geo_clipExtentMAX, Math.min(d3_geo_clipExtentMAX, x));
+ y = Math.max(-d3_geo_clipExtentMAX, Math.min(d3_geo_clipExtentMAX, y));
+ var v = pointVisible(x, y);
+ if (polygon) ring.push([ x, y ]);
+ if (first) {
+ x__ = x, y__ = y, v__ = v;
+ first = false;
+ if (v) {
+ listener.lineStart();
+ listener.point(x, y);
+ }
+ } else {
+ if (v && v_) listener.point(x, y); else {
+ var l = {
+ a: {
+ x: x_,
+ y: y_
+ },
+ b: {
+ x: x,
+ y: y
+ }
+ };
+ if (clipLine(l)) {
+ if (!v_) {
+ listener.lineStart();
+ listener.point(l.a.x, l.a.y);
+ }
+ listener.point(l.b.x, l.b.y);
+ if (!v) listener.lineEnd();
+ clean = false;
+ } else if (v) {
+ listener.lineStart();
+ listener.point(x, y);
+ clean = false;
+ }
+ }
+ }
+ x_ = x, y_ = y, v_ = v;
+ }
+ return clip;
+ };
+ function corner(p, direction) {
+ return abs(p[0] - x0) < ε ? direction > 0 ? 0 : 3 : abs(p[0] - x1) < ε ? direction > 0 ? 2 : 1 : abs(p[1] - y0) < ε ? direction > 0 ? 1 : 0 : direction > 0 ? 3 : 2;
+ }
+ function compare(a, b) {
+ return comparePoints(a.x, b.x);
+ }
+ function comparePoints(a, b) {
+ var ca = corner(a, 1), cb = corner(b, 1);
+ return ca !== cb ? ca - cb : ca === 0 ? b[1] - a[1] : ca === 1 ? a[0] - b[0] : ca === 2 ? a[1] - b[1] : b[0] - a[0];
+ }
+ }
+ function d3_geo_compose(a, b) {
+ function compose(x, y) {
+ return x = a(x, y), b(x[0], x[1]);
+ }
+ if (a.invert && b.invert) compose.invert = function(x, y) {
+ return x = b.invert(x, y), x && a.invert(x[0], x[1]);
+ };
+ return compose;
+ }
+ function d3_geo_conic(projectAt) {
+ var φ0 = 0, φ1 = π / 3, m = d3_geo_projectionMutator(projectAt), p = m(φ0, φ1);
+ p.parallels = function(_) {
+ if (!arguments.length) return [ φ0 / π * 180, φ1 / π * 180 ];
+ return m(φ0 = _[0] * π / 180, φ1 = _[1] * π / 180);
+ };
+ return p;
+ }
+ function d3_geo_conicEqualArea(φ0, φ1) {
+ var sinφ0 = Math.sin(φ0), n = (sinφ0 + Math.sin(φ1)) / 2, C = 1 + sinφ0 * (2 * n - sinφ0), ρ0 = Math.sqrt(C) / n;
+ function forward(λ, φ) {
+ var ρ = Math.sqrt(C - 2 * n * Math.sin(φ)) / n;
+ return [ ρ * Math.sin(λ *= n), ρ0 - ρ * Math.cos(λ) ];
+ }
+ forward.invert = function(x, y) {
+ var ρ0_y = ρ0 - y;
+ return [ Math.atan2(x, ρ0_y) / n, d3_asin((C - (x * x + ρ0_y * ρ0_y) * n * n) / (2 * n)) ];
+ };
+ return forward;
+ }
+ (d3.geo.conicEqualArea = function() {
+ return d3_geo_conic(d3_geo_conicEqualArea);
+ }).raw = d3_geo_conicEqualArea;
+ d3.geo.albers = function() {
+ return d3.geo.conicEqualArea().rotate([ 96, 0 ]).center([ -.6, 38.7 ]).parallels([ 29.5, 45.5 ]).scale(1070);
+ };
+ d3.geo.albersUsa = function() {
+ var lower48 = d3.geo.albers();
+ var alaska = d3.geo.conicEqualArea().rotate([ 154, 0 ]).center([ -2, 58.5 ]).parallels([ 55, 65 ]);
+ var hawaii = d3.geo.conicEqualArea().rotate([ 157, 0 ]).center([ -3, 19.9 ]).parallels([ 8, 18 ]);
+ var point, pointStream = {
+ point: function(x, y) {
+ point = [ x, y ];
+ }
+ }, lower48Point, alaskaPoint, hawaiiPoint;
+ function albersUsa(coordinates) {
+ var x = coordinates[0], y = coordinates[1];
+ point = null;
+ (lower48Point(x, y), point) || (alaskaPoint(x, y), point) || hawaiiPoint(x, y);
+ return point;
+ }
+ albersUsa.invert = function(coordinates) {
+ var k = lower48.scale(), t = lower48.translate(), x = (coordinates[0] - t[0]) / k, y = (coordinates[1] - t[1]) / k;
+ return (y >= .12 && y < .234 && x >= -.425 && x < -.214 ? alaska : y >= .166 && y < .234 && x >= -.214 && x < -.115 ? hawaii : lower48).invert(coordinates);
+ };
+ albersUsa.stream = function(stream) {
+ var lower48Stream = lower48.stream(stream), alaskaStream = alaska.stream(stream), hawaiiStream = hawaii.stream(stream);
+ return {
+ point: function(x, y) {
+ lower48Stream.point(x, y);
+ alaskaStream.point(x, y);
+ hawaiiStream.point(x, y);
+ },
+ sphere: function() {
+ lower48Stream.sphere();
+ alaskaStream.sphere();
+ hawaiiStream.sphere();
+ },
+ lineStart: function() {
+ lower48Stream.lineStart();
+ alaskaStream.lineStart();
+ hawaiiStream.lineStart();
+ },
+ lineEnd: function() {
+ lower48Stream.lineEnd();
+ alaskaStream.lineEnd();
+ hawaiiStream.lineEnd();
+ },
+ polygonStart: function() {
+ lower48Stream.polygonStart();
+ alaskaStream.polygonStart();
+ hawaiiStream.polygonStart();
+ },
+ polygonEnd: function() {
+ lower48Stream.polygonEnd();
+ alaskaStream.polygonEnd();
+ hawaiiStream.polygonEnd();
+ }
+ };
+ };
+ albersUsa.precision = function(_) {
+ if (!arguments.length) return lower48.precision();
+ lower48.precision(_);
+ alaska.precision(_);
+ hawaii.precision(_);
+ return albersUsa;
+ };
+ albersUsa.scale = function(_) {
+ if (!arguments.length) return lower48.scale();
+ lower48.scale(_);
+ alaska.scale(_ * .35);
+ hawaii.scale(_);
+ return albersUsa.translate(lower48.translate());
+ };
+ albersUsa.translate = function(_) {
+ if (!arguments.length) return lower48.translate();
+ var k = lower48.scale(), x = +_[0], y = +_[1];
+ lower48Point = lower48.translate(_).clipExtent([ [ x - .455 * k, y - .238 * k ], [ x + .455 * k, y + .238 * k ] ]).stream(pointStream).point;
+ alaskaPoint = alaska.translate([ x - .307 * k, y + .201 * k ]).clipExtent([ [ x - .425 * k + ε, y + .12 * k + ε ], [ x - .214 * k - ε, y + .234 * k - ε ] ]).stream(pointStream).point;
+ hawaiiPoint = hawaii.translate([ x - .205 * k, y + .212 * k ]).clipExtent([ [ x - .214 * k + ε, y + .166 * k + ε ], [ x - .115 * k - ε, y + .234 * k - ε ] ]).stream(pointStream).point;
+ return albersUsa;
+ };
+ return albersUsa.scale(1070);
+ };
+ var d3_geo_pathAreaSum, d3_geo_pathAreaPolygon, d3_geo_pathArea = {
+ point: d3_noop,
+ lineStart: d3_noop,
+ lineEnd: d3_noop,
+ polygonStart: function() {
+ d3_geo_pathAreaPolygon = 0;
+ d3_geo_pathArea.lineStart = d3_geo_pathAreaRingStart;
+ },
+ polygonEnd: function() {
+ d3_geo_pathArea.lineStart = d3_geo_pathArea.lineEnd = d3_geo_pathArea.point = d3_noop;
+ d3_geo_pathAreaSum += abs(d3_geo_pathAreaPolygon / 2);
+ }
+ };
+ function d3_geo_pathAreaRingStart() {
+ var x00, y00, x0, y0;
+ d3_geo_pathArea.point = function(x, y) {
+ d3_geo_pathArea.point = nextPoint;
+ x00 = x0 = x, y00 = y0 = y;
+ };
+ function nextPoint(x, y) {
+ d3_geo_pathAreaPolygon += y0 * x - x0 * y;
+ x0 = x, y0 = y;
+ }
+ d3_geo_pathArea.lineEnd = function() {
+ nextPoint(x00, y00);
+ };
+ }
+ var d3_geo_pathBoundsX0, d3_geo_pathBoundsY0, d3_geo_pathBoundsX1, d3_geo_pathBoundsY1;
+ var d3_geo_pathBounds = {
+ point: d3_geo_pathBoundsPoint,
+ lineStart: d3_noop,
+ lineEnd: d3_noop,
+ polygonStart: d3_noop,
+ polygonEnd: d3_noop
+ };
+ function d3_geo_pathBoundsPoint(x, y) {
+ if (x < d3_geo_pathBoundsX0) d3_geo_pathBoundsX0 = x;
+ if (x > d3_geo_pathBoundsX1) d3_geo_pathBoundsX1 = x;
+ if (y < d3_geo_pathBoundsY0) d3_geo_pathBoundsY0 = y;
+ if (y > d3_geo_pathBoundsY1) d3_geo_pathBoundsY1 = y;
+ }
+ function d3_geo_pathBuffer() {
+ var pointCircle = d3_geo_pathBufferCircle(4.5), buffer = [];
+ var stream = {
+ point: point,
+ lineStart: function() {
+ stream.point = pointLineStart;
+ },
+ lineEnd: lineEnd,
+ polygonStart: function() {
+ stream.lineEnd = lineEndPolygon;
+ },
+ polygonEnd: function() {
+ stream.lineEnd = lineEnd;
+ stream.point = point;
+ },
+ pointRadius: function(_) {
+ pointCircle = d3_geo_pathBufferCircle(_);
+ return stream;
+ },
+ result: function() {
+ if (buffer.length) {
+ var result = buffer.join("");
+ buffer = [];
+ return result;
+ }
+ }
+ };
+ function point(x, y) {
+ buffer.push("M", x, ",", y, pointCircle);
+ }
+ function pointLineStart(x, y) {
+ buffer.push("M", x, ",", y);
+ stream.point = pointLine;
+ }
+ function pointLine(x, y) {
+ buffer.push("L", x, ",", y);
+ }
+ function lineEnd() {
+ stream.point = point;
+ }
+ function lineEndPolygon() {
+ buffer.push("Z");
+ }
+ return stream;
+ }
+ function d3_geo_pathBufferCircle(radius) {
+ return "m0," + radius + "a" + radius + "," + radius + " 0 1,1 0," + -2 * radius + "a" + radius + "," + radius + " 0 1,1 0," + 2 * radius + "z";
+ }
+ var d3_geo_pathCentroid = {
+ point: d3_geo_pathCentroidPoint,
+ lineStart: d3_geo_pathCentroidLineStart,
+ lineEnd: d3_geo_pathCentroidLineEnd,
+ polygonStart: function() {
+ d3_geo_pathCentroid.lineStart = d3_geo_pathCentroidRingStart;
+ },
+ polygonEnd: function() {
+ d3_geo_pathCentroid.point = d3_geo_pathCentroidPoint;
+ d3_geo_pathCentroid.lineStart = d3_geo_pathCentroidLineStart;
+ d3_geo_pathCentroid.lineEnd = d3_geo_pathCentroidLineEnd;
+ }
+ };
+ function d3_geo_pathCentroidPoint(x, y) {
+ d3_geo_centroidX0 += x;
+ d3_geo_centroidY0 += y;
+ ++d3_geo_centroidZ0;
+ }
+ function d3_geo_pathCentroidLineStart() {
+ var x0, y0;
+ d3_geo_pathCentroid.point = function(x, y) {
+ d3_geo_pathCentroid.point = nextPoint;
+ d3_geo_pathCentroidPoint(x0 = x, y0 = y);
+ };
+ function nextPoint(x, y) {
+ var dx = x - x0, dy = y - y0, z = Math.sqrt(dx * dx + dy * dy);
+ d3_geo_centroidX1 += z * (x0 + x) / 2;
+ d3_geo_centroidY1 += z * (y0 + y) / 2;
+ d3_geo_centroidZ1 += z;
+ d3_geo_pathCentroidPoint(x0 = x, y0 = y);
+ }
+ }
+ function d3_geo_pathCentroidLineEnd() {
+ d3_geo_pathCentroid.point = d3_geo_pathCentroidPoint;
+ }
+ function d3_geo_pathCentroidRingStart() {
+ var x00, y00, x0, y0;
+ d3_geo_pathCentroid.point = function(x, y) {
+ d3_geo_pathCentroid.point = nextPoint;
+ d3_geo_pathCentroidPoint(x00 = x0 = x, y00 = y0 = y);
+ };
+ function nextPoint(x, y) {
+ var dx = x - x0, dy = y - y0, z = Math.sqrt(dx * dx + dy * dy);
+ d3_geo_centroidX1 += z * (x0 + x) / 2;
+ d3_geo_centroidY1 += z * (y0 + y) / 2;
+ d3_geo_centroidZ1 += z;
+ z = y0 * x - x0 * y;
+ d3_geo_centroidX2 += z * (x0 + x);
+ d3_geo_centroidY2 += z * (y0 + y);
+ d3_geo_centroidZ2 += z * 3;
+ d3_geo_pathCentroidPoint(x0 = x, y0 = y);
+ }
+ d3_geo_pathCentroid.lineEnd = function() {
+ nextPoint(x00, y00);
+ };
+ }
+ function d3_geo_pathContext(context) {
+ var pointRadius = 4.5;
+ var stream = {
+ point: point,
+ lineStart: function() {
+ stream.point = pointLineStart;
+ },
+ lineEnd: lineEnd,
+ polygonStart: function() {
+ stream.lineEnd = lineEndPolygon;
+ },
+ polygonEnd: function() {
+ stream.lineEnd = lineEnd;
+ stream.point = point;
+ },
+ pointRadius: function(_) {
+ pointRadius = _;
+ return stream;
+ },
+ result: d3_noop
+ };
+ function point(x, y) {
+ context.moveTo(x, y);
+ context.arc(x, y, pointRadius, 0, τ);
+ }
+ function pointLineStart(x, y) {
+ context.moveTo(x, y);
+ stream.point = pointLine;
+ }
+ function pointLine(x, y) {
+ context.lineTo(x, y);
+ }
+ function lineEnd() {
+ stream.point = point;
+ }
+ function lineEndPolygon() {
+ context.closePath();
+ }
+ return stream;
+ }
+ function d3_geo_resample(project) {
+ var δ2 = .5, cosMinDistance = Math.cos(30 * d3_radians), maxDepth = 16;
+ function resample(stream) {
+ return (maxDepth ? resampleRecursive : resampleNone)(stream);
+ }
+ function resampleNone(stream) {
+ return d3_geo_transformPoint(stream, function(x, y) {
+ x = project(x, y);
+ stream.point(x[0], x[1]);
+ });
+ }
+ function resampleRecursive(stream) {
+ var λ00, φ00, x00, y00, a00, b00, c00, λ0, x0, y0, a0, b0, c0;
+ var resample = {
+ point: point,
+ lineStart: lineStart,
+ lineEnd: lineEnd,
+ polygonStart: function() {
+ stream.polygonStart();
+ resample.lineStart = ringStart;
+ },
+ polygonEnd: function() {
+ stream.polygonEnd();
+ resample.lineStart = lineStart;
+ }
+ };
+ function point(x, y) {
+ x = project(x, y);
+ stream.point(x[0], x[1]);
+ }
+ function lineStart() {
+ x0 = NaN;
+ resample.point = linePoint;
+ stream.lineStart();
+ }
+ function linePoint(λ, φ) {
+ var c = d3_geo_cartesian([ λ, φ ]), p = project(λ, φ);
+ resampleLineTo(x0, y0, λ0, a0, b0, c0, x0 = p[0], y0 = p[1], λ0 = λ, a0 = c[0], b0 = c[1], c0 = c[2], maxDepth, stream);
+ stream.point(x0, y0);
+ }
+ function lineEnd() {
+ resample.point = point;
+ stream.lineEnd();
+ }
+ function ringStart() {
+ lineStart();
+ resample.point = ringPoint;
+ resample.lineEnd = ringEnd;
+ }
+ function ringPoint(λ, φ) {
+ linePoint(λ00 = λ, φ00 = φ), x00 = x0, y00 = y0, a00 = a0, b00 = b0, c00 = c0;
+ resample.point = linePoint;
+ }
+ function ringEnd() {
+ resampleLineTo(x0, y0, λ0, a0, b0, c0, x00, y00, λ00, a00, b00, c00, maxDepth, stream);
+ resample.lineEnd = lineEnd;
+ lineEnd();
+ }
+ return resample;
+ }
+ function resampleLineTo(x0, y0, λ0, a0, b0, c0, x1, y1, λ1, a1, b1, c1, depth, stream) {
+ var dx = x1 - x0, dy = y1 - y0, d2 = dx * dx + dy * dy;
+ if (d2 > 4 * δ2 && depth--) {
+ var a = a0 + a1, b = b0 + b1, c = c0 + c1, m = Math.sqrt(a * a + b * b + c * c), φ2 = Math.asin(c /= m), λ2 = abs(abs(c) - 1) < ε || abs(λ0 - λ1) < ε ? (λ0 + λ1) / 2 : Math.atan2(b, a), p = project(λ2, φ2), x2 = p[0], y2 = p[1], dx2 = x2 - x0, dy2 = y2 - y0, dz = dy * dx2 - dx * dy2;
+ if (dz * dz / d2 > δ2 || abs((dx * dx2 + dy * dy2) / d2 - .5) > .3 || a0 * a1 + b0 * b1 + c0 * c1 < cosMinDistance) {
+ resampleLineTo(x0, y0, λ0, a0, b0, c0, x2, y2, λ2, a /= m, b /= m, c, depth, stream);
+ stream.point(x2, y2);
+ resampleLineTo(x2, y2, λ2, a, b, c, x1, y1, λ1, a1, b1, c1, depth, stream);
+ }
+ }
+ }
+ resample.precision = function(_) {
+ if (!arguments.length) return Math.sqrt(δ2);
+ maxDepth = (δ2 = _ * _) > 0 && 16;
+ return resample;
+ };
+ return resample;
+ }
+ d3.geo.path = function() {
+ var pointRadius = 4.5, projection, context, projectStream, contextStream, cacheStream;
+ function path(object) {
+ if (object) {
+ if (typeof pointRadius === "function") contextStream.pointRadius(+pointRadius.apply(this, arguments));
+ if (!cacheStream || !cacheStream.valid) cacheStream = projectStream(contextStream);
+ d3.geo.stream(object, cacheStream);
+ }
+ return contextStream.result();
+ }
+ path.area = function(object) {
+ d3_geo_pathAreaSum = 0;
+ d3.geo.stream(object, projectStream(d3_geo_pathArea));
+ return d3_geo_pathAreaSum;
+ };
+ path.centroid = function(object) {
+ d3_geo_centroidX0 = d3_geo_centroidY0 = d3_geo_centroidZ0 = d3_geo_centroidX1 = d3_geo_centroidY1 = d3_geo_centroidZ1 = d3_geo_centroidX2 = d3_geo_centroidY2 = d3_geo_centroidZ2 = 0;
+ d3.geo.stream(object, projectStream(d3_geo_pathCentroid));
+ return d3_geo_centroidZ2 ? [ d3_geo_centroidX2 / d3_geo_centroidZ2, d3_geo_centroidY2 / d3_geo_centroidZ2 ] : d3_geo_centroidZ1 ? [ d3_geo_centroidX1 / d3_geo_centroidZ1, d3_geo_centroidY1 / d3_geo_centroidZ1 ] : d3_geo_centroidZ0 ? [ d3_geo_centroidX0 / d3_geo_centroidZ0, d3_geo_centroidY0 / d3_geo_centroidZ0 ] : [ NaN, NaN ];
+ };
+ path.bounds = function(object) {
+ d3_geo_pathBoundsX1 = d3_geo_pathBoundsY1 = -(d3_geo_pathBoundsX0 = d3_geo_pathBoundsY0 = Infinity);
+ d3.geo.stream(object, projectStream(d3_geo_pathBounds));
+ return [ [ d3_geo_pathBoundsX0, d3_geo_pathBoundsY0 ], [ d3_geo_pathBoundsX1, d3_geo_pathBoundsY1 ] ];
+ };
+ path.projection = function(_) {
+ if (!arguments.length) return projection;
+ projectStream = (projection = _) ? _.stream || d3_geo_pathProjectStream(_) : d3_identity;
+ return reset();
+ };
+ path.context = function(_) {
+ if (!arguments.length) return context;
+ contextStream = (context = _) == null ? new d3_geo_pathBuffer() : new d3_geo_pathContext(_);
+ if (typeof pointRadius !== "function") contextStream.pointRadius(pointRadius);
+ return reset();
+ };
+ path.pointRadius = function(_) {
+ if (!arguments.length) return pointRadius;
+ pointRadius = typeof _ === "function" ? _ : (contextStream.pointRadius(+_), +_);
+ return path;
+ };
+ function reset() {
+ cacheStream = null;
+ return path;
+ }
+ return path.projection(d3.geo.albersUsa()).context(null);
+ };
+ function d3_geo_pathProjectStream(project) {
+ var resample = d3_geo_resample(function(x, y) {
+ return project([ x * d3_degrees, y * d3_degrees ]);
+ });
+ return function(stream) {
+ return d3_geo_projectionRadians(resample(stream));
+ };
+ }
+ d3.geo.transform = function(methods) {
+ return {
+ stream: function(stream) {
+ var transform = new d3_geo_transform(stream);
+ for (var k in methods) transform[k] = methods[k];
+ return transform;
+ }
+ };
+ };
+ function d3_geo_transform(stream) {
+ this.stream = stream;
+ }
+ d3_geo_transform.prototype = {
+ point: function(x, y) {
+ this.stream.point(x, y);
+ },
+ sphere: function() {
+ this.stream.sphere();
+ },
+ lineStart: function() {
+ this.stream.lineStart();
+ },
+ lineEnd: function() {
+ this.stream.lineEnd();
+ },
+ polygonStart: function() {
+ this.stream.polygonStart();
+ },
+ polygonEnd: function() {
+ this.stream.polygonEnd();
+ }
+ };
+ function d3_geo_transformPoint(stream, point) {
+ return {
+ point: point,
+ sphere: function() {
+ stream.sphere();
+ },
+ lineStart: function() {
+ stream.lineStart();
+ },
+ lineEnd: function() {
+ stream.lineEnd();
+ },
+ polygonStart: function() {
+ stream.polygonStart();
+ },
+ polygonEnd: function() {
+ stream.polygonEnd();
+ }
+ };
+ }
+ d3.geo.projection = d3_geo_projection;
+ d3.geo.projectionMutator = d3_geo_projectionMutator;
+ function d3_geo_projection(project) {
+ return d3_geo_projectionMutator(function() {
+ return project;
+ })();
+ }
+ function d3_geo_projectionMutator(projectAt) {
+ var project, rotate, projectRotate, projectResample = d3_geo_resample(function(x, y) {
+ x = project(x, y);
+ return [ x[0] * k + δx, δy - x[1] * k ];
+ }), k = 150, x = 480, y = 250, λ = 0, φ = 0, δλ = 0, δφ = 0, δγ = 0, δx, δy, preclip = d3_geo_clipAntimeridian, postclip = d3_identity, clipAngle = null, clipExtent = null, stream;
+ function projection(point) {
+ point = projectRotate(point[0] * d3_radians, point[1] * d3_radians);
+ return [ point[0] * k + δx, δy - point[1] * k ];
+ }
+ function invert(point) {
+ point = projectRotate.invert((point[0] - δx) / k, (δy - point[1]) / k);
+ return point && [ point[0] * d3_degrees, point[1] * d3_degrees ];
+ }
+ projection.stream = function(output) {
+ if (stream) stream.valid = false;
+ stream = d3_geo_projectionRadians(preclip(rotate, projectResample(postclip(output))));
+ stream.valid = true;
+ return stream;
+ };
+ projection.clipAngle = function(_) {
+ if (!arguments.length) return clipAngle;
+ preclip = _ == null ? (clipAngle = _, d3_geo_clipAntimeridian) : d3_geo_clipCircle((clipAngle = +_) * d3_radians);
+ return invalidate();
+ };
+ projection.clipExtent = function(_) {
+ if (!arguments.length) return clipExtent;
+ clipExtent = _;
+ postclip = _ ? d3_geo_clipExtent(_[0][0], _[0][1], _[1][0], _[1][1]) : d3_identity;
+ return invalidate();
+ };
+ projection.scale = function(_) {
+ if (!arguments.length) return k;
+ k = +_;
+ return reset();
+ };
+ projection.translate = function(_) {
+ if (!arguments.length) return [ x, y ];
+ x = +_[0];
+ y = +_[1];
+ return reset();
+ };
+ projection.center = function(_) {
+ if (!arguments.length) return [ λ * d3_degrees, φ * d3_degrees ];
+ λ = _[0] % 360 * d3_radians;
+ φ = _[1] % 360 * d3_radians;
+ return reset();
+ };
+ projection.rotate = function(_) {
+ if (!arguments.length) return [ δλ * d3_degrees, δφ * d3_degrees, δγ * d3_degrees ];
+ δλ = _[0] % 360 * d3_radians;
+ δφ = _[1] % 360 * d3_radians;
+ δγ = _.length > 2 ? _[2] % 360 * d3_radians : 0;
+ return reset();
+ };
+ d3.rebind(projection, projectResample, "precision");
+ function reset() {
+ projectRotate = d3_geo_compose(rotate = d3_geo_rotation(δλ, δφ, δγ), project);
+ var center = project(λ, φ);
+ δx = x - center[0] * k;
+ δy = y + center[1] * k;
+ return invalidate();
+ }
+ function invalidate() {
+ if (stream) stream.valid = false, stream = null;
+ return projection;
+ }
+ return function() {
+ project = projectAt.apply(this, arguments);
+ projection.invert = project.invert && invert;
+ return reset();
+ };
+ }
+ function d3_geo_projectionRadians(stream) {
+ return d3_geo_transformPoint(stream, function(x, y) {
+ stream.point(x * d3_radians, y * d3_radians);
+ });
+ }
+ function d3_geo_equirectangular(λ, φ) {
+ return [ λ, φ ];
+ }
+ (d3.geo.equirectangular = function() {
+ return d3_geo_projection(d3_geo_equirectangular);
+ }).raw = d3_geo_equirectangular.invert = d3_geo_equirectangular;
+ d3.geo.rotation = function(rotate) {
+ rotate = d3_geo_rotation(rotate[0] % 360 * d3_radians, rotate[1] * d3_radians, rotate.length > 2 ? rotate[2] * d3_radians : 0);
+ function forward(coordinates) {
+ coordinates = rotate(coordinates[0] * d3_radians, coordinates[1] * d3_radians);
+ return coordinates[0] *= d3_degrees, coordinates[1] *= d3_degrees, coordinates;
+ }
+ forward.invert = function(coordinates) {
+ coordinates = rotate.invert(coordinates[0] * d3_radians, coordinates[1] * d3_radians);
+ return coordinates[0] *= d3_degrees, coordinates[1] *= d3_degrees, coordinates;
+ };
+ return forward;
+ };
+ function d3_geo_identityRotation(λ, φ) {
+ return [ λ > π ? λ - τ : λ < -π ? λ + τ : λ, φ ];
+ }
+ d3_geo_identityRotation.invert = d3_geo_equirectangular;
+ function d3_geo_rotation(δλ, δφ, δγ) {
+ return δλ ? δφ || δγ ? d3_geo_compose(d3_geo_rotationλ(δλ), d3_geo_rotationφγ(δφ, δγ)) : d3_geo_rotationλ(δλ) : δφ || δγ ? d3_geo_rotationφγ(δφ, δγ) : d3_geo_identityRotation;
+ }
+ function d3_geo_forwardRotationλ(δλ) {
+ return function(λ, φ) {
+ return λ += δλ, [ λ > π ? λ - τ : λ < -π ? λ + τ : λ, φ ];
+ };
+ }
+ function d3_geo_rotationλ(δλ) {
+ var rotation = d3_geo_forwardRotationλ(δλ);
+ rotation.invert = d3_geo_forwardRotationλ(-δλ);
+ return rotation;
+ }
+ function d3_geo_rotationφγ(δφ, δγ) {
+ var cosδφ = Math.cos(δφ), sinδφ = Math.sin(δφ), cosδγ = Math.cos(δγ), sinδγ = Math.sin(δγ);
+ function rotation(λ, φ) {
+ var cosφ = Math.cos(φ), x = Math.cos(λ) * cosφ, y = Math.sin(λ) * cosφ, z = Math.sin(φ), k = z * cosδφ + x * sinδφ;
+ return [ Math.atan2(y * cosδγ - k * sinδγ, x * cosδφ - z * sinδφ), d3_asin(k * cosδγ + y * sinδγ) ];
+ }
+ rotation.invert = function(λ, φ) {
+ var cosφ = Math.cos(φ), x = Math.cos(λ) * cosφ, y = Math.sin(λ) * cosφ, z = Math.sin(φ), k = z * cosδγ - y * sinδγ;
+ return [ Math.atan2(y * cosδγ + z * sinδγ, x * cosδφ + k * sinδφ), d3_asin(k * cosδφ - x * sinδφ) ];
+ };
+ return rotation;
+ }
+ d3.geo.circle = function() {
+ var origin = [ 0, 0 ], angle, precision = 6, interpolate;
+ function circle() {
+ var center = typeof origin === "function" ? origin.apply(this, arguments) : origin, rotate = d3_geo_rotation(-center[0] * d3_radians, -center[1] * d3_radians, 0).invert, ring = [];
+ interpolate(null, null, 1, {
+ point: function(x, y) {
+ ring.push(x = rotate(x, y));
+ x[0] *= d3_degrees, x[1] *= d3_degrees;
+ }
+ });
+ return {
+ type: "Polygon",
+ coordinates: [ ring ]
+ };
+ }
+ circle.origin = function(x) {
+ if (!arguments.length) return origin;
+ origin = x;
+ return circle;
+ };
+ circle.angle = function(x) {
+ if (!arguments.length) return angle;
+ interpolate = d3_geo_circleInterpolate((angle = +x) * d3_radians, precision * d3_radians);
+ return circle;
+ };
+ circle.precision = function(_) {
+ if (!arguments.length) return precision;
+ interpolate = d3_geo_circleInterpolate(angle * d3_radians, (precision = +_) * d3_radians);
+ return circle;
+ };
+ return circle.angle(90);
+ };
+ function d3_geo_circleInterpolate(radius, precision) {
+ var cr = Math.cos(radius), sr = Math.sin(radius);
+ return function(from, to, direction, listener) {
+ var step = direction * precision;
+ if (from != null) {
+ from = d3_geo_circleAngle(cr, from);
+ to = d3_geo_circleAngle(cr, to);
+ if (direction > 0 ? from < to : from > to) from += direction * τ;
+ } else {
+ from = radius + direction * τ;
+ to = radius - .5 * step;
+ }
+ for (var point, t = from; direction > 0 ? t > to : t < to; t -= step) {
+ listener.point((point = d3_geo_spherical([ cr, -sr * Math.cos(t), -sr * Math.sin(t) ]))[0], point[1]);
+ }
+ };
+ }
+ function d3_geo_circleAngle(cr, point) {
+ var a = d3_geo_cartesian(point);
+ a[0] -= cr;
+ d3_geo_cartesianNormalize(a);
+ var angle = d3_acos(-a[1]);
+ return ((-a[2] < 0 ? -angle : angle) + 2 * Math.PI - ε) % (2 * Math.PI);
+ }
+ d3.geo.distance = function(a, b) {
+ var Δλ = (b[0] - a[0]) * d3_radians, φ0 = a[1] * d3_radians, φ1 = b[1] * d3_radians, sinΔλ = Math.sin(Δλ), cosΔλ = Math.cos(Δλ), sinφ0 = Math.sin(φ0), cosφ0 = Math.cos(φ0), sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1), t;
+ return Math.atan2(Math.sqrt((t = cosφ1 * sinΔλ) * t + (t = cosφ0 * sinφ1 - sinφ0 * cosφ1 * cosΔλ) * t), sinφ0 * sinφ1 + cosφ0 * cosφ1 * cosΔλ);
+ };
+ d3.geo.graticule = function() {
+ var x1, x0, X1, X0, y1, y0, Y1, Y0, dx = 10, dy = dx, DX = 90, DY = 360, x, y, X, Y, precision = 2.5;
+ function graticule() {
+ return {
+ type: "MultiLineString",
+ coordinates: lines()
+ };
+ }
+ function lines() {
+ return d3.range(Math.ceil(X0 / DX) * DX, X1, DX).map(X).concat(d3.range(Math.ceil(Y0 / DY) * DY, Y1, DY).map(Y)).concat(d3.range(Math.ceil(x0 / dx) * dx, x1, dx).filter(function(x) {
+ return abs(x % DX) > ε;
+ }).map(x)).concat(d3.range(Math.ceil(y0 / dy) * dy, y1, dy).filter(function(y) {
+ return abs(y % DY) > ε;
+ }).map(y));
+ }
+ graticule.lines = function() {
+ return lines().map(function(coordinates) {
+ return {
+ type: "LineString",
+ coordinates: coordinates
+ };
+ });
+ };
+ graticule.outline = function() {
+ return {
+ type: "Polygon",
+ coordinates: [ X(X0).concat(Y(Y1).slice(1), X(X1).reverse().slice(1), Y(Y0).reverse().slice(1)) ]
+ };
+ };
+ graticule.extent = function(_) {
+ if (!arguments.length) return graticule.minorExtent();
+ return graticule.majorExtent(_).minorExtent(_);
+ };
+ graticule.majorExtent = function(_) {
+ if (!arguments.length) return [ [ X0, Y0 ], [ X1, Y1 ] ];
+ X0 = +_[0][0], X1 = +_[1][0];
+ Y0 = +_[0][1], Y1 = +_[1][1];
+ if (X0 > X1) _ = X0, X0 = X1, X1 = _;
+ if (Y0 > Y1) _ = Y0, Y0 = Y1, Y1 = _;
+ return graticule.precision(precision);
+ };
+ graticule.minorExtent = function(_) {
+ if (!arguments.length) return [ [ x0, y0 ], [ x1, y1 ] ];
+ x0 = +_[0][0], x1 = +_[1][0];
+ y0 = +_[0][1], y1 = +_[1][1];
+ if (x0 > x1) _ = x0, x0 = x1, x1 = _;
+ if (y0 > y1) _ = y0, y0 = y1, y1 = _;
+ return graticule.precision(precision);
+ };
+ graticule.step = function(_) {
+ if (!arguments.length) return graticule.minorStep();
+ return graticule.majorStep(_).minorStep(_);
+ };
+ graticule.majorStep = function(_) {
+ if (!arguments.length) return [ DX, DY ];
+ DX = +_[0], DY = +_[1];
+ return graticule;
+ };
+ graticule.minorStep = function(_) {
+ if (!arguments.length) return [ dx, dy ];
+ dx = +_[0], dy = +_[1];
+ return graticule;
+ };
+ graticule.precision = function(_) {
+ if (!arguments.length) return precision;
+ precision = +_;
+ x = d3_geo_graticuleX(y0, y1, 90);
+ y = d3_geo_graticuleY(x0, x1, precision);
+ X = d3_geo_graticuleX(Y0, Y1, 90);
+ Y = d3_geo_graticuleY(X0, X1, precision);
+ return graticule;
+ };
+ return graticule.majorExtent([ [ -180, -90 + ε ], [ 180, 90 - ε ] ]).minorExtent([ [ -180, -80 - ε ], [ 180, 80 + ε ] ]);
+ };
+ function d3_geo_graticuleX(y0, y1, dy) {
+ var y = d3.range(y0, y1 - ε, dy).concat(y1);
+ return function(x) {
+ return y.map(function(y) {
+ return [ x, y ];
+ });
+ };
+ }
+ function d3_geo_graticuleY(x0, x1, dx) {
+ var x = d3.range(x0, x1 - ε, dx).concat(x1);
+ return function(y) {
+ return x.map(function(x) {
+ return [ x, y ];
+ });
+ };
+ }
+ function d3_source(d) {
+ return d.source;
+ }
+ function d3_target(d) {
+ return d.target;
+ }
+ d3.geo.greatArc = function() {
+ var source = d3_source, source_, target = d3_target, target_;
+ function greatArc() {
+ return {
+ type: "LineString",
+ coordinates: [ source_ || source.apply(this, arguments), target_ || target.apply(this, arguments) ]
+ };
+ }
+ greatArc.distance = function() {
+ return d3.geo.distance(source_ || source.apply(this, arguments), target_ || target.apply(this, arguments));
+ };
+ greatArc.source = function(_) {
+ if (!arguments.length) return source;
+ source = _, source_ = typeof _ === "function" ? null : _;
+ return greatArc;
+ };
+ greatArc.target = function(_) {
+ if (!arguments.length) return target;
+ target = _, target_ = typeof _ === "function" ? null : _;
+ return greatArc;
+ };
+ greatArc.precision = function() {
+ return arguments.length ? greatArc : 0;
+ };
+ return greatArc;
+ };
+ d3.geo.interpolate = function(source, target) {
+ return d3_geo_interpolate(source[0] * d3_radians, source[1] * d3_radians, target[0] * d3_radians, target[1] * d3_radians);
+ };
+ function d3_geo_interpolate(x0, y0, x1, y1) {
+ var cy0 = Math.cos(y0), sy0 = Math.sin(y0), cy1 = Math.cos(y1), sy1 = Math.sin(y1), kx0 = cy0 * Math.cos(x0), ky0 = cy0 * Math.sin(x0), kx1 = cy1 * Math.cos(x1), ky1 = cy1 * Math.sin(x1), d = 2 * Math.asin(Math.sqrt(d3_haversin(y1 - y0) + cy0 * cy1 * d3_haversin(x1 - x0))), k = 1 / Math.sin(d);
+ var interpolate = d ? function(t) {
+ var B = Math.sin(t *= d) * k, A = Math.sin(d - t) * k, x = A * kx0 + B * kx1, y = A * ky0 + B * ky1, z = A * sy0 + B * sy1;
+ return [ Math.atan2(y, x) * d3_degrees, Math.atan2(z, Math.sqrt(x * x + y * y)) * d3_degrees ];
+ } : function() {
+ return [ x0 * d3_degrees, y0 * d3_degrees ];
+ };
+ interpolate.distance = d;
+ return interpolate;
+ }
+ d3.geo.length = function(object) {
+ d3_geo_lengthSum = 0;
+ d3.geo.stream(object, d3_geo_length);
+ return d3_geo_lengthSum;
+ };
+ var d3_geo_lengthSum;
+ var d3_geo_length = {
+ sphere: d3_noop,
+ point: d3_noop,
+ lineStart: d3_geo_lengthLineStart,
+ lineEnd: d3_noop,
+ polygonStart: d3_noop,
+ polygonEnd: d3_noop
+ };
+ function d3_geo_lengthLineStart() {
+ var λ0, sinφ0, cosφ0;
+ d3_geo_length.point = function(λ, φ) {
+ λ0 = λ * d3_radians, sinφ0 = Math.sin(φ *= d3_radians), cosφ0 = Math.cos(φ);
+ d3_geo_length.point = nextPoint;
+ };
+ d3_geo_length.lineEnd = function() {
+ d3_geo_length.point = d3_geo_length.lineEnd = d3_noop;
+ };
+ function nextPoint(λ, φ) {
+ var sinφ = Math.sin(φ *= d3_radians), cosφ = Math.cos(φ), t = abs((λ *= d3_radians) - λ0), cosΔλ = Math.cos(t);
+ d3_geo_lengthSum += Math.atan2(Math.sqrt((t = cosφ * Math.sin(t)) * t + (t = cosφ0 * sinφ - sinφ0 * cosφ * cosΔλ) * t), sinφ0 * sinφ + cosφ0 * cosφ * cosΔλ);
+ λ0 = λ, sinφ0 = sinφ, cosφ0 = cosφ;
+ }
+ }
+ function d3_geo_azimuthal(scale, angle) {
+ function azimuthal(λ, φ) {
+ var cosλ = Math.cos(λ), cosφ = Math.cos(φ), k = scale(cosλ * cosφ);
+ return [ k * cosφ * Math.sin(λ), k * Math.sin(φ) ];
+ }
+ azimuthal.invert = function(x, y) {
+ var ρ = Math.sqrt(x * x + y * y), c = angle(ρ), sinc = Math.sin(c), cosc = Math.cos(c);
+ return [ Math.atan2(x * sinc, ρ * cosc), Math.asin(ρ && y * sinc / ρ) ];
+ };
+ return azimuthal;
+ }
+ var d3_geo_azimuthalEqualArea = d3_geo_azimuthal(function(cosλcosφ) {
+ return Math.sqrt(2 / (1 + cosλcosφ));
+ }, function(ρ) {
+ return 2 * Math.asin(ρ / 2);
+ });
+ (d3.geo.azimuthalEqualArea = function() {
+ return d3_geo_projection(d3_geo_azimuthalEqualArea);
+ }).raw = d3_geo_azimuthalEqualArea;
+ var d3_geo_azimuthalEquidistant = d3_geo_azimuthal(function(cosλcosφ) {
+ var c = Math.acos(cosλcosφ);
+ return c && c / Math.sin(c);
+ }, d3_identity);
+ (d3.geo.azimuthalEquidistant = function() {
+ return d3_geo_projection(d3_geo_azimuthalEquidistant);
+ }).raw = d3_geo_azimuthalEquidistant;
+ function d3_geo_conicConformal(φ0, φ1) {
+ var cosφ0 = Math.cos(φ0), t = function(φ) {
+ return Math.tan(π / 4 + φ / 2);
+ }, n = φ0 === φ1 ? Math.sin(φ0) : Math.log(cosφ0 / Math.cos(φ1)) / Math.log(t(φ1) / t(φ0)), F = cosφ0 * Math.pow(t(φ0), n) / n;
+ if (!n) return d3_geo_mercator;
+ function forward(λ, φ) {
+ var ρ = abs(abs(φ) - halfπ) < ε ? 0 : F / Math.pow(t(φ), n);
+ return [ ρ * Math.sin(n * λ), F - ρ * Math.cos(n * λ) ];
+ }
+ forward.invert = function(x, y) {
+ var ρ0_y = F - y, ρ = d3_sgn(n) * Math.sqrt(x * x + ρ0_y * ρ0_y);
+ return [ Math.atan2(x, ρ0_y) / n, 2 * Math.atan(Math.pow(F / ρ, 1 / n)) - halfπ ];
+ };
+ return forward;
+ }
+ (d3.geo.conicConformal = function() {
+ return d3_geo_conic(d3_geo_conicConformal);
+ }).raw = d3_geo_conicConformal;
+ function d3_geo_conicEquidistant(φ0, φ1) {
+ var cosφ0 = Math.cos(φ0), n = φ0 === φ1 ? Math.sin(φ0) : (cosφ0 - Math.cos(φ1)) / (φ1 - φ0), G = cosφ0 / n + φ0;
+ if (abs(n) < ε) return d3_geo_equirectangular;
+ function forward(λ, φ) {
+ var ρ = G - φ;
+ return [ ρ * Math.sin(n * λ), G - ρ * Math.cos(n * λ) ];
+ }
+ forward.invert = function(x, y) {
+ var ρ0_y = G - y;
+ return [ Math.atan2(x, ρ0_y) / n, G - d3_sgn(n) * Math.sqrt(x * x + ρ0_y * ρ0_y) ];
+ };
+ return forward;
+ }
+ (d3.geo.conicEquidistant = function() {
+ return d3_geo_conic(d3_geo_conicEquidistant);
+ }).raw = d3_geo_conicEquidistant;
+ var d3_geo_gnomonic = d3_geo_azimuthal(function(cosλcosφ) {
+ return 1 / cosλcosφ;
+ }, Math.atan);
+ (d3.geo.gnomonic = function() {
+ return d3_geo_projection(d3_geo_gnomonic);
+ }).raw = d3_geo_gnomonic;
+ function d3_geo_mercator(λ, φ) {
+ return [ λ, Math.log(Math.tan(π / 4 + φ / 2)) ];
+ }
+ d3_geo_mercator.invert = function(x, y) {
+ return [ x, 2 * Math.atan(Math.exp(y)) - halfπ ];
+ };
+ function d3_geo_mercatorProjection(project) {
+ var m = d3_geo_projection(project), scale = m.scale, translate = m.translate, clipExtent = m.clipExtent, clipAuto;
+ m.scale = function() {
+ var v = scale.apply(m, arguments);
+ return v === m ? clipAuto ? m.clipExtent(null) : m : v;
+ };
+ m.translate = function() {
+ var v = translate.apply(m, arguments);
+ return v === m ? clipAuto ? m.clipExtent(null) : m : v;
+ };
+ m.clipExtent = function(_) {
+ var v = clipExtent.apply(m, arguments);
+ if (v === m) {
+ if (clipAuto = _ == null) {
+ var k = π * scale(), t = translate();
+ clipExtent([ [ t[0] - k, t[1] - k ], [ t[0] + k, t[1] + k ] ]);
+ }
+ } else if (clipAuto) {
+ v = null;
+ }
+ return v;
+ };
+ return m.clipExtent(null);
+ }
+ (d3.geo.mercator = function() {
+ return d3_geo_mercatorProjection(d3_geo_mercator);
+ }).raw = d3_geo_mercator;
+ var d3_geo_orthographic = d3_geo_azimuthal(function() {
+ return 1;
+ }, Math.asin);
+ (d3.geo.orthographic = function() {
+ return d3_geo_projection(d3_geo_orthographic);
+ }).raw = d3_geo_orthographic;
+ var d3_geo_stereographic = d3_geo_azimuthal(function(cosλcosφ) {
+ return 1 / (1 + cosλcosφ);
+ }, function(ρ) {
+ return 2 * Math.atan(ρ);
+ });
+ (d3.geo.stereographic = function() {
+ return d3_geo_projection(d3_geo_stereographic);
+ }).raw = d3_geo_stereographic;
+ function d3_geo_transverseMercator(λ, φ) {
+ return [ Math.log(Math.tan(π / 4 + φ / 2)), -λ ];
+ }
+ d3_geo_transverseMercator.invert = function(x, y) {
+ return [ -y, 2 * Math.atan(Math.exp(x)) - halfπ ];
+ };
+ (d3.geo.transverseMercator = function() {
+ var projection = d3_geo_mercatorProjection(d3_geo_transverseMercator), center = projection.center, rotate = projection.rotate;
+ projection.center = function(_) {
+ return _ ? center([ -_[1], _[0] ]) : (_ = center(), [ -_[1], _[0] ]);
+ };
+ projection.rotate = function(_) {
+ return _ ? rotate([ _[0], _[1], _.length > 2 ? _[2] + 90 : 90 ]) : (_ = rotate(),
+ [ _[0], _[1], _[2] - 90 ]);
+ };
+ return projection.rotate([ 0, 0 ]);
+ }).raw = d3_geo_transverseMercator;
+ d3.geom = {};
+ function d3_geom_pointX(d) {
+ return d[0];
+ }
+ function d3_geom_pointY(d) {
+ return d[1];
+ }
+ d3.geom.hull = function(vertices) {
+ var x = d3_geom_pointX, y = d3_geom_pointY;
+ if (arguments.length) return hull(vertices);
+ function hull(data) {
+ if (data.length < 3) return [];
+ var fx = d3_functor(x), fy = d3_functor(y), i, n = data.length, points = [], flippedPoints = [];
+ for (i = 0; i < n; i++) {
+ points.push([ +fx.call(this, data[i], i), +fy.call(this, data[i], i), i ]);
+ }
+ points.sort(d3_geom_hullOrder);
+ for (i = 0; i < n; i++) flippedPoints.push([ points[i][0], -points[i][1] ]);
+ var upper = d3_geom_hullUpper(points), lower = d3_geom_hullUpper(flippedPoints);
+ var skipLeft = lower[0] === upper[0], skipRight = lower[lower.length - 1] === upper[upper.length - 1], polygon = [];
+ for (i = upper.length - 1; i >= 0; --i) polygon.push(data[points[upper[i]][2]]);
+ for (i = +skipLeft; i < lower.length - skipRight; ++i) polygon.push(data[points[lower[i]][2]]);
+ return polygon;
+ }
+ hull.x = function(_) {
+ return arguments.length ? (x = _, hull) : x;
+ };
+ hull.y = function(_) {
+ return arguments.length ? (y = _, hull) : y;
+ };
+ return hull;
+ };
+ function d3_geom_hullUpper(points) {
+ var n = points.length, hull = [ 0, 1 ], hs = 2;
+ for (var i = 2; i < n; i++) {
+ while (hs > 1 && d3_cross2d(points[hull[hs - 2]], points[hull[hs - 1]], points[i]) <= 0) --hs;
+ hull[hs++] = i;
+ }
+ return hull.slice(0, hs);
+ }
+ function d3_geom_hullOrder(a, b) {
+ return a[0] - b[0] || a[1] - b[1];
+ }
+ d3.geom.polygon = function(coordinates) {
+ d3_subclass(coordinates, d3_geom_polygonPrototype);
+ return coordinates;
+ };
+ var d3_geom_polygonPrototype = d3.geom.polygon.prototype = [];
+ d3_geom_polygonPrototype.area = function() {
+ var i = -1, n = this.length, a, b = this[n - 1], area = 0;
+ while (++i < n) {
+ a = b;
+ b = this[i];
+ area += a[1] * b[0] - a[0] * b[1];
+ }
+ return area * .5;
+ };
+ d3_geom_polygonPrototype.centroid = function(k) {
+ var i = -1, n = this.length, x = 0, y = 0, a, b = this[n - 1], c;
+ if (!arguments.length) k = -1 / (6 * this.area());
+ while (++i < n) {
+ a = b;
+ b = this[i];
+ c = a[0] * b[1] - b[0] * a[1];
+ x += (a[0] + b[0]) * c;
+ y += (a[1] + b[1]) * c;
+ }
+ return [ x * k, y * k ];
+ };
+ d3_geom_polygonPrototype.clip = function(subject) {
+ var input, closed = d3_geom_polygonClosed(subject), i = -1, n = this.length - d3_geom_polygonClosed(this), j, m, a = this[n - 1], b, c, d;
+ while (++i < n) {
+ input = subject.slice();
+ subject.length = 0;
+ b = this[i];
+ c = input[(m = input.length - closed) - 1];
+ j = -1;
+ while (++j < m) {
+ d = input[j];
+ if (d3_geom_polygonInside(d, a, b)) {
+ if (!d3_geom_polygonInside(c, a, b)) {
+ subject.push(d3_geom_polygonIntersect(c, d, a, b));
+ }
+ subject.push(d);
+ } else if (d3_geom_polygonInside(c, a, b)) {
+ subject.push(d3_geom_polygonIntersect(c, d, a, b));
+ }
+ c = d;
+ }
+ if (closed) subject.push(subject[0]);
+ a = b;
+ }
+ return subject;
+ };
+ function d3_geom_polygonInside(p, a, b) {
+ return (b[0] - a[0]) * (p[1] - a[1]) < (b[1] - a[1]) * (p[0] - a[0]);
+ }
+ function d3_geom_polygonIntersect(c, d, a, b) {
+ var x1 = c[0], x3 = a[0], x21 = d[0] - x1, x43 = b[0] - x3, y1 = c[1], y3 = a[1], y21 = d[1] - y1, y43 = b[1] - y3, ua = (x43 * (y1 - y3) - y43 * (x1 - x3)) / (y43 * x21 - x43 * y21);
+ return [ x1 + ua * x21, y1 + ua * y21 ];
+ }
+ function d3_geom_polygonClosed(coordinates) {
+ var a = coordinates[0], b = coordinates[coordinates.length - 1];
+ return !(a[0] - b[0] || a[1] - b[1]);
+ }
+ var d3_geom_voronoiEdges, d3_geom_voronoiCells, d3_geom_voronoiBeaches, d3_geom_voronoiBeachPool = [], d3_geom_voronoiFirstCircle, d3_geom_voronoiCircles, d3_geom_voronoiCirclePool = [];
+ function d3_geom_voronoiBeach() {
+ d3_geom_voronoiRedBlackNode(this);
+ this.edge = this.site = this.circle = null;
+ }
+ function d3_geom_voronoiCreateBeach(site) {
+ var beach = d3_geom_voronoiBeachPool.pop() || new d3_geom_voronoiBeach();
+ beach.site = site;
+ return beach;
+ }
+ function d3_geom_voronoiDetachBeach(beach) {
+ d3_geom_voronoiDetachCircle(beach);
+ d3_geom_voronoiBeaches.remove(beach);
+ d3_geom_voronoiBeachPool.push(beach);
+ d3_geom_voronoiRedBlackNode(beach);
+ }
+ function d3_geom_voronoiRemoveBeach(beach) {
+ var circle = beach.circle, x = circle.x, y = circle.cy, vertex = {
+ x: x,
+ y: y
+ }, previous = beach.P, next = beach.N, disappearing = [ beach ];
+ d3_geom_voronoiDetachBeach(beach);
+ var lArc = previous;
+ while (lArc.circle && abs(x - lArc.circle.x) < ε && abs(y - lArc.circle.cy) < ε) {
+ previous = lArc.P;
+ disappearing.unshift(lArc);
+ d3_geom_voronoiDetachBeach(lArc);
+ lArc = previous;
+ }
+ disappearing.unshift(lArc);
+ d3_geom_voronoiDetachCircle(lArc);
+ var rArc = next;
+ while (rArc.circle && abs(x - rArc.circle.x) < ε && abs(y - rArc.circle.cy) < ε) {
+ next = rArc.N;
+ disappearing.push(rArc);
+ d3_geom_voronoiDetachBeach(rArc);
+ rArc = next;
+ }
+ disappearing.push(rArc);
+ d3_geom_voronoiDetachCircle(rArc);
+ var nArcs = disappearing.length, iArc;
+ for (iArc = 1; iArc < nArcs; ++iArc) {
+ rArc = disappearing[iArc];
+ lArc = disappearing[iArc - 1];
+ d3_geom_voronoiSetEdgeEnd(rArc.edge, lArc.site, rArc.site, vertex);
+ }
+ lArc = disappearing[0];
+ rArc = disappearing[nArcs - 1];
+ rArc.edge = d3_geom_voronoiCreateEdge(lArc.site, rArc.site, null, vertex);
+ d3_geom_voronoiAttachCircle(lArc);
+ d3_geom_voronoiAttachCircle(rArc);
+ }
+ function d3_geom_voronoiAddBeach(site) {
+ var x = site.x, directrix = site.y, lArc, rArc, dxl, dxr, node = d3_geom_voronoiBeaches._;
+ while (node) {
+ dxl = d3_geom_voronoiLeftBreakPoint(node, directrix) - x;
+ if (dxl > ε) node = node.L; else {
+ dxr = x - d3_geom_voronoiRightBreakPoint(node, directrix);
+ if (dxr > ε) {
+ if (!node.R) {
+ lArc = node;
+ break;
+ }
+ node = node.R;
+ } else {
+ if (dxl > -ε) {
+ lArc = node.P;
+ rArc = node;
+ } else if (dxr > -ε) {
+ lArc = node;
+ rArc = node.N;
+ } else {
+ lArc = rArc = node;
+ }
+ break;
+ }
+ }
+ }
+ var newArc = d3_geom_voronoiCreateBeach(site);
+ d3_geom_voronoiBeaches.insert(lArc, newArc);
+ if (!lArc && !rArc) return;
+ if (lArc === rArc) {
+ d3_geom_voronoiDetachCircle(lArc);
+ rArc = d3_geom_voronoiCreateBeach(lArc.site);
+ d3_geom_voronoiBeaches.insert(newArc, rArc);
+ newArc.edge = rArc.edge = d3_geom_voronoiCreateEdge(lArc.site, newArc.site);
+ d3_geom_voronoiAttachCircle(lArc);
+ d3_geom_voronoiAttachCircle(rArc);
+ return;
+ }
+ if (!rArc) {
+ newArc.edge = d3_geom_voronoiCreateEdge(lArc.site, newArc.site);
+ return;
+ }
+ d3_geom_voronoiDetachCircle(lArc);
+ d3_geom_voronoiDetachCircle(rArc);
+ var lSite = lArc.site, ax = lSite.x, ay = lSite.y, bx = site.x - ax, by = site.y - ay, rSite = rArc.site, cx = rSite.x - ax, cy = rSite.y - ay, d = 2 * (bx * cy - by * cx), hb = bx * bx + by * by, hc = cx * cx + cy * cy, vertex = {
+ x: (cy * hb - by * hc) / d + ax,
+ y: (bx * hc - cx * hb) / d + ay
+ };
+ d3_geom_voronoiSetEdgeEnd(rArc.edge, lSite, rSite, vertex);
+ newArc.edge = d3_geom_voronoiCreateEdge(lSite, site, null, vertex);
+ rArc.edge = d3_geom_voronoiCreateEdge(site, rSite, null, vertex);
+ d3_geom_voronoiAttachCircle(lArc);
+ d3_geom_voronoiAttachCircle(rArc);
+ }
+ function d3_geom_voronoiLeftBreakPoint(arc, directrix) {
+ var site = arc.site, rfocx = site.x, rfocy = site.y, pby2 = rfocy - directrix;
+ if (!pby2) return rfocx;
+ var lArc = arc.P;
+ if (!lArc) return -Infinity;
+ site = lArc.site;
+ var lfocx = site.x, lfocy = site.y, plby2 = lfocy - directrix;
+ if (!plby2) return lfocx;
+ var hl = lfocx - rfocx, aby2 = 1 / pby2 - 1 / plby2, b = hl / plby2;
+ if (aby2) return (-b + Math.sqrt(b * b - 2 * aby2 * (hl * hl / (-2 * plby2) - lfocy + plby2 / 2 + rfocy - pby2 / 2))) / aby2 + rfocx;
+ return (rfocx + lfocx) / 2;
+ }
+ function d3_geom_voronoiRightBreakPoint(arc, directrix) {
+ var rArc = arc.N;
+ if (rArc) return d3_geom_voronoiLeftBreakPoint(rArc, directrix);
+ var site = arc.site;
+ return site.y === directrix ? site.x : Infinity;
+ }
+ function d3_geom_voronoiCell(site) {
+ this.site = site;
+ this.edges = [];
+ }
+ d3_geom_voronoiCell.prototype.prepare = function() {
+ var halfEdges = this.edges, iHalfEdge = halfEdges.length, edge;
+ while (iHalfEdge--) {
+ edge = halfEdges[iHalfEdge].edge;
+ if (!edge.b || !edge.a) halfEdges.splice(iHalfEdge, 1);
+ }
+ halfEdges.sort(d3_geom_voronoiHalfEdgeOrder);
+ return halfEdges.length;
+ };
+ function d3_geom_voronoiCloseCells(extent) {
+ var x0 = extent[0][0], x1 = extent[1][0], y0 = extent[0][1], y1 = extent[1][1], x2, y2, x3, y3, cells = d3_geom_voronoiCells, iCell = cells.length, cell, iHalfEdge, halfEdges, nHalfEdges, start, end;
+ while (iCell--) {
+ cell = cells[iCell];
+ if (!cell || !cell.prepare()) continue;
+ halfEdges = cell.edges;
+ nHalfEdges = halfEdges.length;
+ iHalfEdge = 0;
+ while (iHalfEdge < nHalfEdges) {
+ end = halfEdges[iHalfEdge].end(), x3 = end.x, y3 = end.y;
+ start = halfEdges[++iHalfEdge % nHalfEdges].start(), x2 = start.x, y2 = start.y;
+ if (abs(x3 - x2) > ε || abs(y3 - y2) > ε) {
+ halfEdges.splice(iHalfEdge, 0, new d3_geom_voronoiHalfEdge(d3_geom_voronoiCreateBorderEdge(cell.site, end, abs(x3 - x0) < ε && y1 - y3 > ε ? {
+ x: x0,
+ y: abs(x2 - x0) < ε ? y2 : y1
+ } : abs(y3 - y1) < ε && x1 - x3 > ε ? {
+ x: abs(y2 - y1) < ε ? x2 : x1,
+ y: y1
+ } : abs(x3 - x1) < ε && y3 - y0 > ε ? {
+ x: x1,
+ y: abs(x2 - x1) < ε ? y2 : y0
+ } : abs(y3 - y0) < ε && x3 - x0 > ε ? {
+ x: abs(y2 - y0) < ε ? x2 : x0,
+ y: y0
+ } : null), cell.site, null));
+ ++nHalfEdges;
+ }
+ }
+ }
+ }
+ function d3_geom_voronoiHalfEdgeOrder(a, b) {
+ return b.angle - a.angle;
+ }
+ function d3_geom_voronoiCircle() {
+ d3_geom_voronoiRedBlackNode(this);
+ this.x = this.y = this.arc = this.site = this.cy = null;
+ }
+ function d3_geom_voronoiAttachCircle(arc) {
+ var lArc = arc.P, rArc = arc.N;
+ if (!lArc || !rArc) return;
+ var lSite = lArc.site, cSite = arc.site, rSite = rArc.site;
+ if (lSite === rSite) return;
+ var bx = cSite.x, by = cSite.y, ax = lSite.x - bx, ay = lSite.y - by, cx = rSite.x - bx, cy = rSite.y - by;
+ var d = 2 * (ax * cy - ay * cx);
+ if (d >= -ε2) return;
+ var ha = ax * ax + ay * ay, hc = cx * cx + cy * cy, x = (cy * ha - ay * hc) / d, y = (ax * hc - cx * ha) / d, cy = y + by;
+ var circle = d3_geom_voronoiCirclePool.pop() || new d3_geom_voronoiCircle();
+ circle.arc = arc;
+ circle.site = cSite;
+ circle.x = x + bx;
+ circle.y = cy + Math.sqrt(x * x + y * y);
+ circle.cy = cy;
+ arc.circle = circle;
+ var before = null, node = d3_geom_voronoiCircles._;
+ while (node) {
+ if (circle.y < node.y || circle.y === node.y && circle.x <= node.x) {
+ if (node.L) node = node.L; else {
+ before = node.P;
+ break;
+ }
+ } else {
+ if (node.R) node = node.R; else {
+ before = node;
+ break;
+ }
+ }
+ }
+ d3_geom_voronoiCircles.insert(before, circle);
+ if (!before) d3_geom_voronoiFirstCircle = circle;
+ }
+ function d3_geom_voronoiDetachCircle(arc) {
+ var circle = arc.circle;
+ if (circle) {
+ if (!circle.P) d3_geom_voronoiFirstCircle = circle.N;
+ d3_geom_voronoiCircles.remove(circle);
+ d3_geom_voronoiCirclePool.push(circle);
+ d3_geom_voronoiRedBlackNode(circle);
+ arc.circle = null;
+ }
+ }
+ function d3_geom_voronoiClipEdges(extent) {
+ var edges = d3_geom_voronoiEdges, clip = d3_geom_clipLine(extent[0][0], extent[0][1], extent[1][0], extent[1][1]), i = edges.length, e;
+ while (i--) {
+ e = edges[i];
+ if (!d3_geom_voronoiConnectEdge(e, extent) || !clip(e) || abs(e.a.x - e.b.x) < ε && abs(e.a.y - e.b.y) < ε) {
+ e.a = e.b = null;
+ edges.splice(i, 1);
+ }
+ }
+ }
+ function d3_geom_voronoiConnectEdge(edge, extent) {
+ var vb = edge.b;
+ if (vb) return true;
+ var va = edge.a, x0 = extent[0][0], x1 = extent[1][0], y0 = extent[0][1], y1 = extent[1][1], lSite = edge.l, rSite = edge.r, lx = lSite.x, ly = lSite.y, rx = rSite.x, ry = rSite.y, fx = (lx + rx) / 2, fy = (ly + ry) / 2, fm, fb;
+ if (ry === ly) {
+ if (fx < x0 || fx >= x1) return;
+ if (lx > rx) {
+ if (!va) va = {
+ x: fx,
+ y: y0
+ }; else if (va.y >= y1) return;
+ vb = {
+ x: fx,
+ y: y1
+ };
+ } else {
+ if (!va) va = {
+ x: fx,
+ y: y1
+ }; else if (va.y < y0) return;
+ vb = {
+ x: fx,
+ y: y0
+ };
+ }
+ } else {
+ fm = (lx - rx) / (ry - ly);
+ fb = fy - fm * fx;
+ if (fm < -1 || fm > 1) {
+ if (lx > rx) {
+ if (!va) va = {
+ x: (y0 - fb) / fm,
+ y: y0
+ }; else if (va.y >= y1) return;
+ vb = {
+ x: (y1 - fb) / fm,
+ y: y1
+ };
+ } else {
+ if (!va) va = {
+ x: (y1 - fb) / fm,
+ y: y1
+ }; else if (va.y < y0) return;
+ vb = {
+ x: (y0 - fb) / fm,
+ y: y0
+ };
+ }
+ } else {
+ if (ly < ry) {
+ if (!va) va = {
+ x: x0,
+ y: fm * x0 + fb
+ }; else if (va.x >= x1) return;
+ vb = {
+ x: x1,
+ y: fm * x1 + fb
+ };
+ } else {
+ if (!va) va = {
+ x: x1,
+ y: fm * x1 + fb
+ }; else if (va.x < x0) return;
+ vb = {
+ x: x0,
+ y: fm * x0 + fb
+ };
+ }
+ }
+ }
+ edge.a = va;
+ edge.b = vb;
+ return true;
+ }
+ function d3_geom_voronoiEdge(lSite, rSite) {
+ this.l = lSite;
+ this.r = rSite;
+ this.a = this.b = null;
+ }
+ function d3_geom_voronoiCreateEdge(lSite, rSite, va, vb) {
+ var edge = new d3_geom_voronoiEdge(lSite, rSite);
+ d3_geom_voronoiEdges.push(edge);
+ if (va) d3_geom_voronoiSetEdgeEnd(edge, lSite, rSite, va);
+ if (vb) d3_geom_voronoiSetEdgeEnd(edge, rSite, lSite, vb);
+ d3_geom_voronoiCells[lSite.i].edges.push(new d3_geom_voronoiHalfEdge(edge, lSite, rSite));
+ d3_geom_voronoiCells[rSite.i].edges.push(new d3_geom_voronoiHalfEdge(edge, rSite, lSite));
+ return edge;
+ }
+ function d3_geom_voronoiCreateBorderEdge(lSite, va, vb) {
+ var edge = new d3_geom_voronoiEdge(lSite, null);
+ edge.a = va;
+ edge.b = vb;
+ d3_geom_voronoiEdges.push(edge);
+ return edge;
+ }
+ function d3_geom_voronoiSetEdgeEnd(edge, lSite, rSite, vertex) {
+ if (!edge.a && !edge.b) {
+ edge.a = vertex;
+ edge.l = lSite;
+ edge.r = rSite;
+ } else if (edge.l === rSite) {
+ edge.b = vertex;
+ } else {
+ edge.a = vertex;
+ }
+ }
+ function d3_geom_voronoiHalfEdge(edge, lSite, rSite) {
+ var va = edge.a, vb = edge.b;
+ this.edge = edge;
+ this.site = lSite;
+ this.angle = rSite ? Math.atan2(rSite.y - lSite.y, rSite.x - lSite.x) : edge.l === lSite ? Math.atan2(vb.x - va.x, va.y - vb.y) : Math.atan2(va.x - vb.x, vb.y - va.y);
+ }
+ d3_geom_voronoiHalfEdge.prototype = {
+ start: function() {
+ return this.edge.l === this.site ? this.edge.a : this.edge.b;
+ },
+ end: function() {
+ return this.edge.l === this.site ? this.edge.b : this.edge.a;
+ }
+ };
+ function d3_geom_voronoiRedBlackTree() {
+ this._ = null;
+ }
+ function d3_geom_voronoiRedBlackNode(node) {
+ node.U = node.C = node.L = node.R = node.P = node.N = null;
+ }
+ d3_geom_voronoiRedBlackTree.prototype = {
+ insert: function(after, node) {
+ var parent, grandpa, uncle;
+ if (after) {
+ node.P = after;
+ node.N = after.N;
+ if (after.N) after.N.P = node;
+ after.N = node;
+ if (after.R) {
+ after = after.R;
+ while (after.L) after = after.L;
+ after.L = node;
+ } else {
+ after.R = node;
+ }
+ parent = after;
+ } else if (this._) {
+ after = d3_geom_voronoiRedBlackFirst(this._);
+ node.P = null;
+ node.N = after;
+ after.P = after.L = node;
+ parent = after;
+ } else {
+ node.P = node.N = null;
+ this._ = node;
+ parent = null;
+ }
+ node.L = node.R = null;
+ node.U = parent;
+ node.C = true;
+ after = node;
+ while (parent && parent.C) {
+ grandpa = parent.U;
+ if (parent === grandpa.L) {
+ uncle = grandpa.R;
+ if (uncle && uncle.C) {
+ parent.C = uncle.C = false;
+ grandpa.C = true;
+ after = grandpa;
+ } else {
+ if (after === parent.R) {
+ d3_geom_voronoiRedBlackRotateLeft(this, parent);
+ after = parent;
+ parent = after.U;
+ }
+ parent.C = false;
+ grandpa.C = true;
+ d3_geom_voronoiRedBlackRotateRight(this, grandpa);
+ }
+ } else {
+ uncle = grandpa.L;
+ if (uncle && uncle.C) {
+ parent.C = uncle.C = false;
+ grandpa.C = true;
+ after = grandpa;
+ } else {
+ if (after === parent.L) {
+ d3_geom_voronoiRedBlackRotateRight(this, parent);
+ after = parent;
+ parent = after.U;
+ }
+ parent.C = false;
+ grandpa.C = true;
+ d3_geom_voronoiRedBlackRotateLeft(this, grandpa);
+ }
+ }
+ parent = after.U;
+ }
+ this._.C = false;
+ },
+ remove: function(node) {
+ if (node.N) node.N.P = node.P;
+ if (node.P) node.P.N = node.N;
+ node.N = node.P = null;
+ var parent = node.U, sibling, left = node.L, right = node.R, next, red;
+ if (!left) next = right; else if (!right) next = left; else next = d3_geom_voronoiRedBlackFirst(right);
+ if (parent) {
+ if (parent.L === node) parent.L = next; else parent.R = next;
+ } else {
+ this._ = next;
+ }
+ if (left && right) {
+ red = next.C;
+ next.C = node.C;
+ next.L = left;
+ left.U = next;
+ if (next !== right) {
+ parent = next.U;
+ next.U = node.U;
+ node = next.R;
+ parent.L = node;
+ next.R = right;
+ right.U = next;
+ } else {
+ next.U = parent;
+ parent = next;
+ node = next.R;
+ }
+ } else {
+ red = node.C;
+ node = next;
+ }
+ if (node) node.U = parent;
+ if (red) return;
+ if (node && node.C) {
+ node.C = false;
+ return;
+ }
+ do {
+ if (node === this._) break;
+ if (node === parent.L) {
+ sibling = parent.R;
+ if (sibling.C) {
+ sibling.C = false;
+ parent.C = true;
+ d3_geom_voronoiRedBlackRotateLeft(this, parent);
+ sibling = parent.R;
+ }
+ if (sibling.L && sibling.L.C || sibling.R && sibling.R.C) {
+ if (!sibling.R || !sibling.R.C) {
+ sibling.L.C = false;
+ sibling.C = true;
+ d3_geom_voronoiRedBlackRotateRight(this, sibling);
+ sibling = parent.R;
+ }
+ sibling.C = parent.C;
+ parent.C = sibling.R.C = false;
+ d3_geom_voronoiRedBlackRotateLeft(this, parent);
+ node = this._;
+ break;
+ }
+ } else {
+ sibling = parent.L;
+ if (sibling.C) {
+ sibling.C = false;
+ parent.C = true;
+ d3_geom_voronoiRedBlackRotateRight(this, parent);
+ sibling = parent.L;
+ }
+ if (sibling.L && sibling.L.C || sibling.R && sibling.R.C) {
+ if (!sibling.L || !sibling.L.C) {
+ sibling.R.C = false;
+ sibling.C = true;
+ d3_geom_voronoiRedBlackRotateLeft(this, sibling);
+ sibling = parent.L;
+ }
+ sibling.C = parent.C;
+ parent.C = sibling.L.C = false;
+ d3_geom_voronoiRedBlackRotateRight(this, parent);
+ node = this._;
+ break;
+ }
+ }
+ sibling.C = true;
+ node = parent;
+ parent = parent.U;
+ } while (!node.C);
+ if (node) node.C = false;
+ }
+ };
+ function d3_geom_voronoiRedBlackRotateLeft(tree, node) {
+ var p = node, q = node.R, parent = p.U;
+ if (parent) {
+ if (parent.L === p) parent.L = q; else parent.R = q;
+ } else {
+ tree._ = q;
+ }
+ q.U = parent;
+ p.U = q;
+ p.R = q.L;
+ if (p.R) p.R.U = p;
+ q.L = p;
+ }
+ function d3_geom_voronoiRedBlackRotateRight(tree, node) {
+ var p = node, q = node.L, parent = p.U;
+ if (parent) {
+ if (parent.L === p) parent.L = q; else parent.R = q;
+ } else {
+ tree._ = q;
+ }
+ q.U = parent;
+ p.U = q;
+ p.L = q.R;
+ if (p.L) p.L.U = p;
+ q.R = p;
+ }
+ function d3_geom_voronoiRedBlackFirst(node) {
+ while (node.L) node = node.L;
+ return node;
+ }
+ function d3_geom_voronoi(sites, bbox) {
+ var site = sites.sort(d3_geom_voronoiVertexOrder).pop(), x0, y0, circle;
+ d3_geom_voronoiEdges = [];
+ d3_geom_voronoiCells = new Array(sites.length);
+ d3_geom_voronoiBeaches = new d3_geom_voronoiRedBlackTree();
+ d3_geom_voronoiCircles = new d3_geom_voronoiRedBlackTree();
+ while (true) {
+ circle = d3_geom_voronoiFirstCircle;
+ if (site && (!circle || site.y < circle.y || site.y === circle.y && site.x < circle.x)) {
+ if (site.x !== x0 || site.y !== y0) {
+ d3_geom_voronoiCells[site.i] = new d3_geom_voronoiCell(site);
+ d3_geom_voronoiAddBeach(site);
+ x0 = site.x, y0 = site.y;
+ }
+ site = sites.pop();
+ } else if (circle) {
+ d3_geom_voronoiRemoveBeach(circle.arc);
+ } else {
+ break;
+ }
+ }
+ if (bbox) d3_geom_voronoiClipEdges(bbox), d3_geom_voronoiCloseCells(bbox);
+ var diagram = {
+ cells: d3_geom_voronoiCells,
+ edges: d3_geom_voronoiEdges
+ };
+ d3_geom_voronoiBeaches = d3_geom_voronoiCircles = d3_geom_voronoiEdges = d3_geom_voronoiCells = null;
+ return diagram;
+ }
+ function d3_geom_voronoiVertexOrder(a, b) {
+ return b.y - a.y || b.x - a.x;
+ }
+ d3.geom.voronoi = function(points) {
+ var x = d3_geom_pointX, y = d3_geom_pointY, fx = x, fy = y, clipExtent = d3_geom_voronoiClipExtent;
+ if (points) return voronoi(points);
+ function voronoi(data) {
+ var polygons = new Array(data.length), x0 = clipExtent[0][0], y0 = clipExtent[0][1], x1 = clipExtent[1][0], y1 = clipExtent[1][1];
+ d3_geom_voronoi(sites(data), clipExtent).cells.forEach(function(cell, i) {
+ var edges = cell.edges, site = cell.site, polygon = polygons[i] = edges.length ? edges.map(function(e) {
+ var s = e.start();
+ return [ s.x, s.y ];
+ }) : site.x >= x0 && site.x <= x1 && site.y >= y0 && site.y <= y1 ? [ [ x0, y1 ], [ x1, y1 ], [ x1, y0 ], [ x0, y0 ] ] : [];
+ polygon.point = data[i];
+ });
+ return polygons;
+ }
+ function sites(data) {
+ return data.map(function(d, i) {
+ return {
+ x: Math.round(fx(d, i) / ε) * ε,
+ y: Math.round(fy(d, i) / ε) * ε,
+ i: i
+ };
+ });
+ }
+ voronoi.links = function(data) {
+ return d3_geom_voronoi(sites(data)).edges.filter(function(edge) {
+ return edge.l && edge.r;
+ }).map(function(edge) {
+ return {
+ source: data[edge.l.i],
+ target: data[edge.r.i]
+ };
+ });
+ };
+ voronoi.triangles = function(data) {
+ var triangles = [];
+ d3_geom_voronoi(sites(data)).cells.forEach(function(cell, i) {
+ var site = cell.site, edges = cell.edges.sort(d3_geom_voronoiHalfEdgeOrder), j = -1, m = edges.length, e0, s0, e1 = edges[m - 1].edge, s1 = e1.l === site ? e1.r : e1.l;
+ while (++j < m) {
+ e0 = e1;
+ s0 = s1;
+ e1 = edges[j].edge;
+ s1 = e1.l === site ? e1.r : e1.l;
+ if (i < s0.i && i < s1.i && d3_geom_voronoiTriangleArea(site, s0, s1) < 0) {
+ triangles.push([ data[i], data[s0.i], data[s1.i] ]);
+ }
+ }
+ });
+ return triangles;
+ };
+ voronoi.x = function(_) {
+ return arguments.length ? (fx = d3_functor(x = _), voronoi) : x;
+ };
+ voronoi.y = function(_) {
+ return arguments.length ? (fy = d3_functor(y = _), voronoi) : y;
+ };
+ voronoi.clipExtent = function(_) {
+ if (!arguments.length) return clipExtent === d3_geom_voronoiClipExtent ? null : clipExtent;
+ clipExtent = _ == null ? d3_geom_voronoiClipExtent : _;
+ return voronoi;
+ };
+ voronoi.size = function(_) {
+ if (!arguments.length) return clipExtent === d3_geom_voronoiClipExtent ? null : clipExtent && clipExtent[1];
+ return voronoi.clipExtent(_ && [ [ 0, 0 ], _ ]);
+ };
+ return voronoi;
+ };
+ var d3_geom_voronoiClipExtent = [ [ -1e6, -1e6 ], [ 1e6, 1e6 ] ];
+ function d3_geom_voronoiTriangleArea(a, b, c) {
+ return (a.x - c.x) * (b.y - a.y) - (a.x - b.x) * (c.y - a.y);
+ }
+ d3.geom.delaunay = function(vertices) {
+ return d3.geom.voronoi().triangles(vertices);
+ };
+ d3.geom.quadtree = function(points, x1, y1, x2, y2) {
+ var x = d3_geom_pointX, y = d3_geom_pointY, compat;
+ if (compat = arguments.length) {
+ x = d3_geom_quadtreeCompatX;
+ y = d3_geom_quadtreeCompatY;
+ if (compat === 3) {
+ y2 = y1;
+ x2 = x1;
+ y1 = x1 = 0;
+ }
+ return quadtree(points);
+ }
+ function quadtree(data) {
+ var d, fx = d3_functor(x), fy = d3_functor(y), xs, ys, i, n, x1_, y1_, x2_, y2_;
+ if (x1 != null) {
+ x1_ = x1, y1_ = y1, x2_ = x2, y2_ = y2;
+ } else {
+ x2_ = y2_ = -(x1_ = y1_ = Infinity);
+ xs = [], ys = [];
+ n = data.length;
+ if (compat) for (i = 0; i < n; ++i) {
+ d = data[i];
+ if (d.x < x1_) x1_ = d.x;
+ if (d.y < y1_) y1_ = d.y;
+ if (d.x > x2_) x2_ = d.x;
+ if (d.y > y2_) y2_ = d.y;
+ xs.push(d.x);
+ ys.push(d.y);
+ } else for (i = 0; i < n; ++i) {
+ var x_ = +fx(d = data[i], i), y_ = +fy(d, i);
+ if (x_ < x1_) x1_ = x_;
+ if (y_ < y1_) y1_ = y_;
+ if (x_ > x2_) x2_ = x_;
+ if (y_ > y2_) y2_ = y_;
+ xs.push(x_);
+ ys.push(y_);
+ }
+ }
+ var dx = x2_ - x1_, dy = y2_ - y1_;
+ if (dx > dy) y2_ = y1_ + dx; else x2_ = x1_ + dy;
+ function insert(n, d, x, y, x1, y1, x2, y2) {
+ if (isNaN(x) || isNaN(y)) return;
+ if (n.leaf) {
+ var nx = n.x, ny = n.y;
+ if (nx != null) {
+ if (abs(nx - x) + abs(ny - y) < .01) {
+ insertChild(n, d, x, y, x1, y1, x2, y2);
+ } else {
+ var nPoint = n.point;
+ n.x = n.y = n.point = null;
+ insertChild(n, nPoint, nx, ny, x1, y1, x2, y2);
+ insertChild(n, d, x, y, x1, y1, x2, y2);
+ }
+ } else {
+ n.x = x, n.y = y, n.point = d;
+ }
+ } else {
+ insertChild(n, d, x, y, x1, y1, x2, y2);
+ }
+ }
+ function insertChild(n, d, x, y, x1, y1, x2, y2) {
+ var sx = (x1 + x2) * .5, sy = (y1 + y2) * .5, right = x >= sx, bottom = y >= sy, i = (bottom << 1) + right;
+ n.leaf = false;
+ n = n.nodes[i] || (n.nodes[i] = d3_geom_quadtreeNode());
+ if (right) x1 = sx; else x2 = sx;
+ if (bottom) y1 = sy; else y2 = sy;
+ insert(n, d, x, y, x1, y1, x2, y2);
+ }
+ var root = d3_geom_quadtreeNode();
+ root.add = function(d) {
+ insert(root, d, +fx(d, ++i), +fy(d, i), x1_, y1_, x2_, y2_);
+ };
+ root.visit = function(f) {
+ d3_geom_quadtreeVisit(f, root, x1_, y1_, x2_, y2_);
+ };
+ i = -1;
+ if (x1 == null) {
+ while (++i < n) {
+ insert(root, data[i], xs[i], ys[i], x1_, y1_, x2_, y2_);
+ }
+ --i;
+ } else data.forEach(root.add);
+ xs = ys = data = d = null;
+ return root;
+ }
+ quadtree.x = function(_) {
+ return arguments.length ? (x = _, quadtree) : x;
+ };
+ quadtree.y = function(_) {
+ return arguments.length ? (y = _, quadtree) : y;
+ };
+ quadtree.extent = function(_) {
+ if (!arguments.length) return x1 == null ? null : [ [ x1, y1 ], [ x2, y2 ] ];
+ if (_ == null) x1 = y1 = x2 = y2 = null; else x1 = +_[0][0], y1 = +_[0][1], x2 = +_[1][0],
+ y2 = +_[1][1];
+ return quadtree;
+ };
+ quadtree.size = function(_) {
+ if (!arguments.length) return x1 == null ? null : [ x2 - x1, y2 - y1 ];
+ if (_ == null) x1 = y1 = x2 = y2 = null; else x1 = y1 = 0, x2 = +_[0], y2 = +_[1];
+ return quadtree;
+ };
+ return quadtree;
+ };
+ function d3_geom_quadtreeCompatX(d) {
+ return d.x;
+ }
+ function d3_geom_quadtreeCompatY(d) {
+ return d.y;
+ }
+ function d3_geom_quadtreeNode() {
+ return {
+ leaf: true,
+ nodes: [],
+ point: null,
+ x: null,
+ y: null
+ };
+ }
+ function d3_geom_quadtreeVisit(f, node, x1, y1, x2, y2) {
+ if (!f(node, x1, y1, x2, y2)) {
+ var sx = (x1 + x2) * .5, sy = (y1 + y2) * .5, children = node.nodes;
+ if (children[0]) d3_geom_quadtreeVisit(f, children[0], x1, y1, sx, sy);
+ if (children[1]) d3_geom_quadtreeVisit(f, children[1], sx, y1, x2, sy);
+ if (children[2]) d3_geom_quadtreeVisit(f, children[2], x1, sy, sx, y2);
+ if (children[3]) d3_geom_quadtreeVisit(f, children[3], sx, sy, x2, y2);
+ }
+ }
+ d3.interpolateRgb = d3_interpolateRgb;
+ function d3_interpolateRgb(a, b) {
+ a = d3.rgb(a);
+ b = d3.rgb(b);
+ var ar = a.r, ag = a.g, ab = a.b, br = b.r - ar, bg = b.g - ag, bb = b.b - ab;
+ return function(t) {
+ return "#" + d3_rgb_hex(Math.round(ar + br * t)) + d3_rgb_hex(Math.round(ag + bg * t)) + d3_rgb_hex(Math.round(ab + bb * t));
+ };
+ }
+ d3.interpolateObject = d3_interpolateObject;
+ function d3_interpolateObject(a, b) {
+ var i = {}, c = {}, k;
+ for (k in a) {
+ if (k in b) {
+ i[k] = d3_interpolate(a[k], b[k]);
+ } else {
+ c[k] = a[k];
+ }
+ }
+ for (k in b) {
+ if (!(k in a)) {
+ c[k] = b[k];
+ }
+ }
+ return function(t) {
+ for (k in i) c[k] = i[k](t);
+ return c;
+ };
+ }
+ d3.interpolateNumber = d3_interpolateNumber;
+ function d3_interpolateNumber(a, b) {
+ b -= a = +a;
+ return function(t) {
+ return a + b * t;
+ };
+ }
+ d3.interpolateString = d3_interpolateString;
+ function d3_interpolateString(a, b) {
+ var m, i, j, s0 = 0, s1 = 0, s = [], q = [], n, o;
+ a = a + "", b = b + "";
+ d3_interpolate_number.lastIndex = 0;
+ for (i = 0; m = d3_interpolate_number.exec(b); ++i) {
+ if (m.index) s.push(b.substring(s0, s1 = m.index));
+ q.push({
+ i: s.length,
+ x: m[0]
+ });
+ s.push(null);
+ s0 = d3_interpolate_number.lastIndex;
+ }
+ if (s0 < b.length) s.push(b.substring(s0));
+ for (i = 0, n = q.length; (m = d3_interpolate_number.exec(a)) && i < n; ++i) {
+ o = q[i];
+ if (o.x == m[0]) {
+ if (o.i) {
+ if (s[o.i + 1] == null) {
+ s[o.i - 1] += o.x;
+ s.splice(o.i, 1);
+ for (j = i + 1; j < n; ++j) q[j].i--;
+ } else {
+ s[o.i - 1] += o.x + s[o.i + 1];
+ s.splice(o.i, 2);
+ for (j = i + 1; j < n; ++j) q[j].i -= 2;
+ }
+ } else {
+ if (s[o.i + 1] == null) {
+ s[o.i] = o.x;
+ } else {
+ s[o.i] = o.x + s[o.i + 1];
+ s.splice(o.i + 1, 1);
+ for (j = i + 1; j < n; ++j) q[j].i--;
+ }
+ }
+ q.splice(i, 1);
+ n--;
+ i--;
+ } else {
+ o.x = d3_interpolateNumber(parseFloat(m[0]), parseFloat(o.x));
+ }
+ }
+ while (i < n) {
+ o = q.pop();
+ if (s[o.i + 1] == null) {
+ s[o.i] = o.x;
+ } else {
+ s[o.i] = o.x + s[o.i + 1];
+ s.splice(o.i + 1, 1);
+ }
+ n--;
+ }
+ if (s.length === 1) {
+ return s[0] == null ? (o = q[0].x, function(t) {
+ return o(t) + "";
+ }) : function() {
+ return b;
+ };
+ }
+ return function(t) {
+ for (i = 0; i < n; ++i) s[(o = q[i]).i] = o.x(t);
+ return s.join("");
+ };
+ }
+ var d3_interpolate_number = /[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g;
+ d3.interpolate = d3_interpolate;
+ function d3_interpolate(a, b) {
+ var i = d3.interpolators.length, f;
+ while (--i >= 0 && !(f = d3.interpolators[i](a, b))) ;
+ return f;
+ }
+ d3.interpolators = [ function(a, b) {
+ var t = typeof b;
+ return (t === "string" ? d3_rgb_names.has(b) || /^(#|rgb\(|hsl\()/.test(b) ? d3_interpolateRgb : d3_interpolateString : b instanceof d3_Color ? d3_interpolateRgb : t === "object" ? Array.isArray(b) ? d3_interpolateArray : d3_interpolateObject : d3_interpolateNumber)(a, b);
+ } ];
+ d3.interpolateArray = d3_interpolateArray;
+ function d3_interpolateArray(a, b) {
+ var x = [], c = [], na = a.length, nb = b.length, n0 = Math.min(a.length, b.length), i;
+ for (i = 0; i < n0; ++i) x.push(d3_interpolate(a[i], b[i]));
+ for (;i < na; ++i) c[i] = a[i];
+ for (;i < nb; ++i) c[i] = b[i];
+ return function(t) {
+ for (i = 0; i < n0; ++i) c[i] = x[i](t);
+ return c;
+ };
+ }
+ var d3_ease_default = function() {
+ return d3_identity;
+ };
+ var d3_ease = d3.map({
+ linear: d3_ease_default,
+ poly: d3_ease_poly,
+ quad: function() {
+ return d3_ease_quad;
+ },
+ cubic: function() {
+ return d3_ease_cubic;
+ },
+ sin: function() {
+ return d3_ease_sin;
+ },
+ exp: function() {
+ return d3_ease_exp;
+ },
+ circle: function() {
+ return d3_ease_circle;
+ },
+ elastic: d3_ease_elastic,
+ back: d3_ease_back,
+ bounce: function() {
+ return d3_ease_bounce;
+ }
+ });
+ var d3_ease_mode = d3.map({
+ "in": d3_identity,
+ out: d3_ease_reverse,
+ "in-out": d3_ease_reflect,
+ "out-in": function(f) {
+ return d3_ease_reflect(d3_ease_reverse(f));
+ }
+ });
+ d3.ease = function(name) {
+ var i = name.indexOf("-"), t = i >= 0 ? name.substring(0, i) : name, m = i >= 0 ? name.substring(i + 1) : "in";
+ t = d3_ease.get(t) || d3_ease_default;
+ m = d3_ease_mode.get(m) || d3_identity;
+ return d3_ease_clamp(m(t.apply(null, d3_arraySlice.call(arguments, 1))));
+ };
+ function d3_ease_clamp(f) {
+ return function(t) {
+ return t <= 0 ? 0 : t >= 1 ? 1 : f(t);
+ };
+ }
+ function d3_ease_reverse(f) {
+ return function(t) {
+ return 1 - f(1 - t);
+ };
+ }
+ function d3_ease_reflect(f) {
+ return function(t) {
+ return .5 * (t < .5 ? f(2 * t) : 2 - f(2 - 2 * t));
+ };
+ }
+ function d3_ease_quad(t) {
+ return t * t;
+ }
+ function d3_ease_cubic(t) {
+ return t * t * t;
+ }
+ function d3_ease_cubicInOut(t) {
+ if (t <= 0) return 0;
+ if (t >= 1) return 1;
+ var t2 = t * t, t3 = t2 * t;
+ return 4 * (t < .5 ? t3 : 3 * (t - t2) + t3 - .75);
+ }
+ function d3_ease_poly(e) {
+ return function(t) {
+ return Math.pow(t, e);
+ };
+ }
+ function d3_ease_sin(t) {
+ return 1 - Math.cos(t * halfπ);
+ }
+ function d3_ease_exp(t) {
+ return Math.pow(2, 10 * (t - 1));
+ }
+ function d3_ease_circle(t) {
+ return 1 - Math.sqrt(1 - t * t);
+ }
+ function d3_ease_elastic(a, p) {
+ var s;
+ if (arguments.length < 2) p = .45;
+ if (arguments.length) s = p / τ * Math.asin(1 / a); else a = 1, s = p / 4;
+ return function(t) {
+ return 1 + a * Math.pow(2, -10 * t) * Math.sin((t - s) * τ / p);
+ };
+ }
+ function d3_ease_back(s) {
+ if (!s) s = 1.70158;
+ return function(t) {
+ return t * t * ((s + 1) * t - s);
+ };
+ }
+ function d3_ease_bounce(t) {
+ return t < 1 / 2.75 ? 7.5625 * t * t : t < 2 / 2.75 ? 7.5625 * (t -= 1.5 / 2.75) * t + .75 : t < 2.5 / 2.75 ? 7.5625 * (t -= 2.25 / 2.75) * t + .9375 : 7.5625 * (t -= 2.625 / 2.75) * t + .984375;
+ }
+ d3.interpolateHcl = d3_interpolateHcl;
+ function d3_interpolateHcl(a, b) {
+ a = d3.hcl(a);
+ b = d3.hcl(b);
+ var ah = a.h, ac = a.c, al = a.l, bh = b.h - ah, bc = b.c - ac, bl = b.l - al;
+ if (isNaN(bc)) bc = 0, ac = isNaN(ac) ? b.c : ac;
+ if (isNaN(bh)) bh = 0, ah = isNaN(ah) ? b.h : ah; else if (bh > 180) bh -= 360; else if (bh < -180) bh += 360;
+ return function(t) {
+ return d3_hcl_lab(ah + bh * t, ac + bc * t, al + bl * t) + "";
+ };
+ }
+ d3.interpolateHsl = d3_interpolateHsl;
+ function d3_interpolateHsl(a, b) {
+ a = d3.hsl(a);
+ b = d3.hsl(b);
+ var ah = a.h, as = a.s, al = a.l, bh = b.h - ah, bs = b.s - as, bl = b.l - al;
+ if (isNaN(bs)) bs = 0, as = isNaN(as) ? b.s : as;
+ if (isNaN(bh)) bh = 0, ah = isNaN(ah) ? b.h : ah; else if (bh > 180) bh -= 360; else if (bh < -180) bh += 360;
+ return function(t) {
+ return d3_hsl_rgb(ah + bh * t, as + bs * t, al + bl * t) + "";
+ };
+ }
+ d3.interpolateLab = d3_interpolateLab;
+ function d3_interpolateLab(a, b) {
+ a = d3.lab(a);
+ b = d3.lab(b);
+ var al = a.l, aa = a.a, ab = a.b, bl = b.l - al, ba = b.a - aa, bb = b.b - ab;
+ return function(t) {
+ return d3_lab_rgb(al + bl * t, aa + ba * t, ab + bb * t) + "";
+ };
+ }
+ d3.interpolateRound = d3_interpolateRound;
+ function d3_interpolateRound(a, b) {
+ b -= a;
+ return function(t) {
+ return Math.round(a + b * t);
+ };
+ }
+ d3.transform = function(string) {
+ var g = d3_document.createElementNS(d3.ns.prefix.svg, "g");
+ return (d3.transform = function(string) {
+ if (string != null) {
+ g.setAttribute("transform", string);
+ var t = g.transform.baseVal.consolidate();
+ }
+ return new d3_transform(t ? t.matrix : d3_transformIdentity);
+ })(string);
+ };
+ function d3_transform(m) {
+ var r0 = [ m.a, m.b ], r1 = [ m.c, m.d ], kx = d3_transformNormalize(r0), kz = d3_transformDot(r0, r1), ky = d3_transformNormalize(d3_transformCombine(r1, r0, -kz)) || 0;
+ if (r0[0] * r1[1] < r1[0] * r0[1]) {
+ r0[0] *= -1;
+ r0[1] *= -1;
+ kx *= -1;
+ kz *= -1;
+ }
+ this.rotate = (kx ? Math.atan2(r0[1], r0[0]) : Math.atan2(-r1[0], r1[1])) * d3_degrees;
+ this.translate = [ m.e, m.f ];
+ this.scale = [ kx, ky ];
+ this.skew = ky ? Math.atan2(kz, ky) * d3_degrees : 0;
+ }
+ d3_transform.prototype.toString = function() {
+ return "translate(" + this.translate + ")rotate(" + this.rotate + ")skewX(" + this.skew + ")scale(" + this.scale + ")";
+ };
+ function d3_transformDot(a, b) {
+ return a[0] * b[0] + a[1] * b[1];
+ }
+ function d3_transformNormalize(a) {
+ var k = Math.sqrt(d3_transformDot(a, a));
+ if (k) {
+ a[0] /= k;
+ a[1] /= k;
+ }
+ return k;
+ }
+ function d3_transformCombine(a, b, k) {
+ a[0] += k * b[0];
+ a[1] += k * b[1];
+ return a;
+ }
+ var d3_transformIdentity = {
+ a: 1,
+ b: 0,
+ c: 0,
+ d: 1,
+ e: 0,
+ f: 0
+ };
+ d3.interpolateTransform = d3_interpolateTransform;
+ function d3_interpolateTransform(a, b) {
+ var s = [], q = [], n, A = d3.transform(a), B = d3.transform(b), ta = A.translate, tb = B.translate, ra = A.rotate, rb = B.rotate, wa = A.skew, wb = B.skew, ka = A.scale, kb = B.scale;
+ if (ta[0] != tb[0] || ta[1] != tb[1]) {
+ s.push("translate(", null, ",", null, ")");
+ q.push({
+ i: 1,
+ x: d3_interpolateNumber(ta[0], tb[0])
+ }, {
+ i: 3,
+ x: d3_interpolateNumber(ta[1], tb[1])
+ });
+ } else if (tb[0] || tb[1]) {
+ s.push("translate(" + tb + ")");
+ } else {
+ s.push("");
+ }
+ if (ra != rb) {
+ if (ra - rb > 180) rb += 360; else if (rb - ra > 180) ra += 360;
+ q.push({
+ i: s.push(s.pop() + "rotate(", null, ")") - 2,
+ x: d3_interpolateNumber(ra, rb)
+ });
+ } else if (rb) {
+ s.push(s.pop() + "rotate(" + rb + ")");
+ }
+ if (wa != wb) {
+ q.push({
+ i: s.push(s.pop() + "skewX(", null, ")") - 2,
+ x: d3_interpolateNumber(wa, wb)
+ });
+ } else if (wb) {
+ s.push(s.pop() + "skewX(" + wb + ")");
+ }
+ if (ka[0] != kb[0] || ka[1] != kb[1]) {
+ n = s.push(s.pop() + "scale(", null, ",", null, ")");
+ q.push({
+ i: n - 4,
+ x: d3_interpolateNumber(ka[0], kb[0])
+ }, {
+ i: n - 2,
+ x: d3_interpolateNumber(ka[1], kb[1])
+ });
+ } else if (kb[0] != 1 || kb[1] != 1) {
+ s.push(s.pop() + "scale(" + kb + ")");
+ }
+ n = q.length;
+ return function(t) {
+ var i = -1, o;
+ while (++i < n) s[(o = q[i]).i] = o.x(t);
+ return s.join("");
+ };
+ }
+ function d3_uninterpolateNumber(a, b) {
+ b = b - (a = +a) ? 1 / (b - a) : 0;
+ return function(x) {
+ return (x - a) * b;
+ };
+ }
+ function d3_uninterpolateClamp(a, b) {
+ b = b - (a = +a) ? 1 / (b - a) : 0;
+ return function(x) {
+ return Math.max(0, Math.min(1, (x - a) * b));
+ };
+ }
+ d3.layout = {};
+ d3.layout.bundle = function() {
+ return function(links) {
+ var paths = [], i = -1, n = links.length;
+ while (++i < n) paths.push(d3_layout_bundlePath(links[i]));
+ return paths;
+ };
+ };
+ function d3_layout_bundlePath(link) {
+ var start = link.source, end = link.target, lca = d3_layout_bundleLeastCommonAncestor(start, end), points = [ start ];
+ while (start !== lca) {
+ start = start.parent;
+ points.push(start);
+ }
+ var k = points.length;
+ while (end !== lca) {
+ points.splice(k, 0, end);
+ end = end.parent;
+ }
+ return points;
+ }
+ function d3_layout_bundleAncestors(node) {
+ var ancestors = [], parent = node.parent;
+ while (parent != null) {
+ ancestors.push(node);
+ node = parent;
+ parent = parent.parent;
+ }
+ ancestors.push(node);
+ return ancestors;
+ }
+ function d3_layout_bundleLeastCommonAncestor(a, b) {
+ if (a === b) return a;
+ var aNodes = d3_layout_bundleAncestors(a), bNodes = d3_layout_bundleAncestors(b), aNode = aNodes.pop(), bNode = bNodes.pop(), sharedNode = null;
+ while (aNode === bNode) {
+ sharedNode = aNode;
+ aNode = aNodes.pop();
+ bNode = bNodes.pop();
+ }
+ return sharedNode;
+ }
+ d3.layout.chord = function() {
+ var chord = {}, chords, groups, matrix, n, padding = 0, sortGroups, sortSubgroups, sortChords;
+ function relayout() {
+ var subgroups = {}, groupSums = [], groupIndex = d3.range(n), subgroupIndex = [], k, x, x0, i, j;
+ chords = [];
+ groups = [];
+ k = 0, i = -1;
+ while (++i < n) {
+ x = 0, j = -1;
+ while (++j < n) {
+ x += matrix[i][j];
+ }
+ groupSums.push(x);
+ subgroupIndex.push(d3.range(n));
+ k += x;
+ }
+ if (sortGroups) {
+ groupIndex.sort(function(a, b) {
+ return sortGroups(groupSums[a], groupSums[b]);
+ });
+ }
+ if (sortSubgroups) {
+ subgroupIndex.forEach(function(d, i) {
+ d.sort(function(a, b) {
+ return sortSubgroups(matrix[i][a], matrix[i][b]);
+ });
+ });
+ }
+ k = (τ - padding * n) / k;
+ x = 0, i = -1;
+ while (++i < n) {
+ x0 = x, j = -1;
+ while (++j < n) {
+ var di = groupIndex[i], dj = subgroupIndex[di][j], v = matrix[di][dj], a0 = x, a1 = x += v * k;
+ subgroups[di + "-" + dj] = {
+ index: di,
+ subindex: dj,
+ startAngle: a0,
+ endAngle: a1,
+ value: v
+ };
+ }
+ groups[di] = {
+ index: di,
+ startAngle: x0,
+ endAngle: x,
+ value: (x - x0) / k
+ };
+ x += padding;
+ }
+ i = -1;
+ while (++i < n) {
+ j = i - 1;
+ while (++j < n) {
+ var source = subgroups[i + "-" + j], target = subgroups[j + "-" + i];
+ if (source.value || target.value) {
+ chords.push(source.value < target.value ? {
+ source: target,
+ target: source
+ } : {
+ source: source,
+ target: target
+ });
+ }
+ }
+ }
+ if (sortChords) resort();
+ }
+ function resort() {
+ chords.sort(function(a, b) {
+ return sortChords((a.source.value + a.target.value) / 2, (b.source.value + b.target.value) / 2);
+ });
+ }
+ chord.matrix = function(x) {
+ if (!arguments.length) return matrix;
+ n = (matrix = x) && matrix.length;
+ chords = groups = null;
+ return chord;
+ };
+ chord.padding = function(x) {
+ if (!arguments.length) return padding;
+ padding = x;
+ chords = groups = null;
+ return chord;
+ };
+ chord.sortGroups = function(x) {
+ if (!arguments.length) return sortGroups;
+ sortGroups = x;
+ chords = groups = null;
+ return chord;
+ };
+ chord.sortSubgroups = function(x) {
+ if (!arguments.length) return sortSubgroups;
+ sortSubgroups = x;
+ chords = null;
+ return chord;
+ };
+ chord.sortChords = function(x) {
+ if (!arguments.length) return sortChords;
+ sortChords = x;
+ if (chords) resort();
+ return chord;
+ };
+ chord.chords = function() {
+ if (!chords) relayout();
+ return chords;
+ };
+ chord.groups = function() {
+ if (!groups) relayout();
+ return groups;
+ };
+ return chord;
+ };
+ d3.layout.force = function() {
+ var force = {}, event = d3.dispatch("start", "tick", "end"), size = [ 1, 1 ], drag, alpha, friction = .9, linkDistance = d3_layout_forceLinkDistance, linkStrength = d3_layout_forceLinkStrength, charge = -30, chargeDistance2 = d3_layout_forceChargeDistance2, gravity = .1, theta2 = .64, nodes = [], links = [], distances, strengths, charges;
+ function repulse(node) {
+ return function(quad, x1, _, x2) {
+ if (quad.point !== node) {
+ var dx = quad.cx - node.x, dy = quad.cy - node.y, dw = x2 - x1, dn = dx * dx + dy * dy;
+ if (dw * dw / theta2 < dn) {
+ if (dn < chargeDistance2) {
+ var k = quad.charge / dn;
+ node.px -= dx * k;
+ node.py -= dy * k;
+ }
+ return true;
+ }
+ if (quad.point && dn && dn < chargeDistance2) {
+ var k = quad.pointCharge / dn;
+ node.px -= dx * k;
+ node.py -= dy * k;
+ }
+ }
+ return !quad.charge;
+ };
+ }
+ force.tick = function() {
+ if ((alpha *= .99) < .005) {
+ event.end({
+ type: "end",
+ alpha: alpha = 0
+ });
+ return true;
+ }
+ var n = nodes.length, m = links.length, q, i, o, s, t, l, k, x, y;
+ for (i = 0; i < m; ++i) {
+ o = links[i];
+ s = o.source;
+ t = o.target;
+ x = t.x - s.x;
+ y = t.y - s.y;
+ if (l = x * x + y * y) {
+ l = alpha * strengths[i] * ((l = Math.sqrt(l)) - distances[i]) / l;
+ x *= l;
+ y *= l;
+ t.x -= x * (k = s.weight / (t.weight + s.weight));
+ t.y -= y * k;
+ s.x += x * (k = 1 - k);
+ s.y += y * k;
+ }
+ }
+ if (k = alpha * gravity) {
+ x = size[0] / 2;
+ y = size[1] / 2;
+ i = -1;
+ if (k) while (++i < n) {
+ o = nodes[i];
+ o.x += (x - o.x) * k;
+ o.y += (y - o.y) * k;
+ }
+ }
+ if (charge) {
+ d3_layout_forceAccumulate(q = d3.geom.quadtree(nodes), alpha, charges);
+ i = -1;
+ while (++i < n) {
+ if (!(o = nodes[i]).fixed) {
+ q.visit(repulse(o));
+ }
+ }
+ }
+ i = -1;
+ while (++i < n) {
+ o = nodes[i];
+ if (o.fixed) {
+ o.x = o.px;
+ o.y = o.py;
+ } else {
+ o.x -= (o.px - (o.px = o.x)) * friction;
+ o.y -= (o.py - (o.py = o.y)) * friction;
+ }
+ }
+ event.tick({
+ type: "tick",
+ alpha: alpha
+ });
+ };
+ force.nodes = function(x) {
+ if (!arguments.length) return nodes;
+ nodes = x;
+ return force;
+ };
+ force.links = function(x) {
+ if (!arguments.length) return links;
+ links = x;
+ return force;
+ };
+ force.size = function(x) {
+ if (!arguments.length) return size;
+ size = x;
+ return force;
+ };
+ force.linkDistance = function(x) {
+ if (!arguments.length) return linkDistance;
+ linkDistance = typeof x === "function" ? x : +x;
+ return force;
+ };
+ force.distance = force.linkDistance;
+ force.linkStrength = function(x) {
+ if (!arguments.length) return linkStrength;
+ linkStrength = typeof x === "function" ? x : +x;
+ return force;
+ };
+ force.friction = function(x) {
+ if (!arguments.length) return friction;
+ friction = +x;
+ return force;
+ };
+ force.charge = function(x) {
+ if (!arguments.length) return charge;
+ charge = typeof x === "function" ? x : +x;
+ return force;
+ };
+ force.chargeDistance = function(x) {
+ if (!arguments.length) return Math.sqrt(chargeDistance2);
+ chargeDistance2 = x * x;
+ return force;
+ };
+ force.gravity = function(x) {
+ if (!arguments.length) return gravity;
+ gravity = +x;
+ return force;
+ };
+ force.theta = function(x) {
+ if (!arguments.length) return Math.sqrt(theta2);
+ theta2 = x * x;
+ return force;
+ };
+ force.alpha = function(x) {
+ if (!arguments.length) return alpha;
+ x = +x;
+ if (alpha) {
+ if (x > 0) alpha = x; else alpha = 0;
+ } else if (x > 0) {
+ event.start({
+ type: "start",
+ alpha: alpha = x
+ });
+ d3.timer(force.tick);
+ }
+ return force;
+ };
+ force.start = function() {
+ var i, n = nodes.length, m = links.length, w = size[0], h = size[1], neighbors, o;
+ for (i = 0; i < n; ++i) {
+ (o = nodes[i]).index = i;
+ o.weight = 0;
+ }
+ for (i = 0; i < m; ++i) {
+ o = links[i];
+ if (typeof o.source == "number") o.source = nodes[o.source];
+ if (typeof o.target == "number") o.target = nodes[o.target];
+ ++o.source.weight;
+ ++o.target.weight;
+ }
+ for (i = 0; i < n; ++i) {
+ o = nodes[i];
+ if (isNaN(o.x)) o.x = position("x", w);
+ if (isNaN(o.y)) o.y = position("y", h);
+ if (isNaN(o.px)) o.px = o.x;
+ if (isNaN(o.py)) o.py = o.y;
+ }
+ distances = [];
+ if (typeof linkDistance === "function") for (i = 0; i < m; ++i) distances[i] = +linkDistance.call(this, links[i], i); else for (i = 0; i < m; ++i) distances[i] = linkDistance;
+ strengths = [];
+ if (typeof linkStrength === "function") for (i = 0; i < m; ++i) strengths[i] = +linkStrength.call(this, links[i], i); else for (i = 0; i < m; ++i) strengths[i] = linkStrength;
+ charges = [];
+ if (typeof charge === "function") for (i = 0; i < n; ++i) charges[i] = +charge.call(this, nodes[i], i); else for (i = 0; i < n; ++i) charges[i] = charge;
+ function position(dimension, size) {
+ if (!neighbors) {
+ neighbors = new Array(n);
+ for (j = 0; j < n; ++j) {
+ neighbors[j] = [];
+ }
+ for (j = 0; j < m; ++j) {
+ var o = links[j];
+ neighbors[o.source.index].push(o.target);
+ neighbors[o.target.index].push(o.source);
+ }
+ }
+ var candidates = neighbors[i], j = -1, m = candidates.length, x;
+ while (++j < m) if (!isNaN(x = candidates[j][dimension])) return x;
+ return Math.random() * size;
+ }
+ return force.resume();
+ };
+ force.resume = function() {
+ return force.alpha(.1);
+ };
+ force.stop = function() {
+ return force.alpha(0);
+ };
+ force.drag = function() {
+ if (!drag) drag = d3.behavior.drag().origin(d3_identity).on("dragstart.force", d3_layout_forceDragstart).on("drag.force", dragmove).on("dragend.force", d3_layout_forceDragend);
+ if (!arguments.length) return drag;
+ this.on("mouseover.force", d3_layout_forceMouseover).on("mouseout.force", d3_layout_forceMouseout).call(drag);
+ };
+ function dragmove(d) {
+ d.px = d3.event.x, d.py = d3.event.y;
+ force.resume();
+ }
+ return d3.rebind(force, event, "on");
+ };
+ function d3_layout_forceDragstart(d) {
+ d.fixed |= 2;
+ }
+ function d3_layout_forceDragend(d) {
+ d.fixed &= ~6;
+ }
+ function d3_layout_forceMouseover(d) {
+ d.fixed |= 4;
+ d.px = d.x, d.py = d.y;
+ }
+ function d3_layout_forceMouseout(d) {
+ d.fixed &= ~4;
+ }
+ function d3_layout_forceAccumulate(quad, alpha, charges) {
+ var cx = 0, cy = 0;
+ quad.charge = 0;
+ if (!quad.leaf) {
+ var nodes = quad.nodes, n = nodes.length, i = -1, c;
+ while (++i < n) {
+ c = nodes[i];
+ if (c == null) continue;
+ d3_layout_forceAccumulate(c, alpha, charges);
+ quad.charge += c.charge;
+ cx += c.charge * c.cx;
+ cy += c.charge * c.cy;
+ }
+ }
+ if (quad.point) {
+ if (!quad.leaf) {
+ quad.point.x += Math.random() - .5;
+ quad.point.y += Math.random() - .5;
+ }
+ var k = alpha * charges[quad.point.index];
+ quad.charge += quad.pointCharge = k;
+ cx += k * quad.point.x;
+ cy += k * quad.point.y;
+ }
+ quad.cx = cx / quad.charge;
+ quad.cy = cy / quad.charge;
+ }
+ var d3_layout_forceLinkDistance = 20, d3_layout_forceLinkStrength = 1, d3_layout_forceChargeDistance2 = Infinity;
+ d3.layout.hierarchy = function() {
+ var sort = d3_layout_hierarchySort, children = d3_layout_hierarchyChildren, value = d3_layout_hierarchyValue;
+ function recurse(node, depth, nodes) {
+ var childs = children.call(hierarchy, node, depth);
+ node.depth = depth;
+ nodes.push(node);
+ if (childs && (n = childs.length)) {
+ var i = -1, n, c = node.children = new Array(n), v = 0, j = depth + 1, d;
+ while (++i < n) {
+ d = c[i] = recurse(childs[i], j, nodes);
+ d.parent = node;
+ v += d.value;
+ }
+ if (sort) c.sort(sort);
+ if (value) node.value = v;
+ } else {
+ delete node.children;
+ if (value) {
+ node.value = +value.call(hierarchy, node, depth) || 0;
+ }
+ }
+ return node;
+ }
+ function revalue(node, depth) {
+ var children = node.children, v = 0;
+ if (children && (n = children.length)) {
+ var i = -1, n, j = depth + 1;
+ while (++i < n) v += revalue(children[i], j);
+ } else if (value) {
+ v = +value.call(hierarchy, node, depth) || 0;
+ }
+ if (value) node.value = v;
+ return v;
+ }
+ function hierarchy(d) {
+ var nodes = [];
+ recurse(d, 0, nodes);
+ return nodes;
+ }
+ hierarchy.sort = function(x) {
+ if (!arguments.length) return sort;
+ sort = x;
+ return hierarchy;
+ };
+ hierarchy.children = function(x) {
+ if (!arguments.length) return children;
+ children = x;
+ return hierarchy;
+ };
+ hierarchy.value = function(x) {
+ if (!arguments.length) return value;
+ value = x;
+ return hierarchy;
+ };
+ hierarchy.revalue = function(root) {
+ revalue(root, 0);
+ return root;
+ };
+ return hierarchy;
+ };
+ function d3_layout_hierarchyRebind(object, hierarchy) {
+ d3.rebind(object, hierarchy, "sort", "children", "value");
+ object.nodes = object;
+ object.links = d3_layout_hierarchyLinks;
+ return object;
+ }
+ function d3_layout_hierarchyChildren(d) {
+ return d.children;
+ }
+ function d3_layout_hierarchyValue(d) {
+ return d.value;
+ }
+ function d3_layout_hierarchySort(a, b) {
+ return b.value - a.value;
+ }
+ function d3_layout_hierarchyLinks(nodes) {
+ return d3.merge(nodes.map(function(parent) {
+ return (parent.children || []).map(function(child) {
+ return {
+ source: parent,
+ target: child
+ };
+ });
+ }));
+ }
+ d3.layout.partition = function() {
+ var hierarchy = d3.layout.hierarchy(), size = [ 1, 1 ];
+ function position(node, x, dx, dy) {
+ var children = node.children;
+ node.x = x;
+ node.y = node.depth * dy;
+ node.dx = dx;
+ node.dy = dy;
+ if (children && (n = children.length)) {
+ var i = -1, n, c, d;
+ dx = node.value ? dx / node.value : 0;
+ while (++i < n) {
+ position(c = children[i], x, d = c.value * dx, dy);
+ x += d;
+ }
+ }
+ }
+ function depth(node) {
+ var children = node.children, d = 0;
+ if (children && (n = children.length)) {
+ var i = -1, n;
+ while (++i < n) d = Math.max(d, depth(children[i]));
+ }
+ return 1 + d;
+ }
+ function partition(d, i) {
+ var nodes = hierarchy.call(this, d, i);
+ position(nodes[0], 0, size[0], size[1] / depth(nodes[0]));
+ return nodes;
+ }
+ partition.size = function(x) {
+ if (!arguments.length) return size;
+ size = x;
+ return partition;
+ };
+ return d3_layout_hierarchyRebind(partition, hierarchy);
+ };
+ d3.layout.pie = function() {
+ var value = Number, sort = d3_layout_pieSortByValue, startAngle = 0, endAngle = τ;
+ function pie(data) {
+ var values = data.map(function(d, i) {
+ return +value.call(pie, d, i);
+ });
+ var a = +(typeof startAngle === "function" ? startAngle.apply(this, arguments) : startAngle);
+ var k = ((typeof endAngle === "function" ? endAngle.apply(this, arguments) : endAngle) - a) / d3.sum(values);
+ var index = d3.range(data.length);
+ if (sort != null) index.sort(sort === d3_layout_pieSortByValue ? function(i, j) {
+ return values[j] - values[i];
+ } : function(i, j) {
+ return sort(data[i], data[j]);
+ });
+ var arcs = [];
+ index.forEach(function(i) {
+ var d;
+ arcs[i] = {
+ data: data[i],
+ value: d = values[i],
+ startAngle: a,
+ endAngle: a += d * k
+ };
+ });
+ return arcs;
+ }
+ pie.value = function(x) {
+ if (!arguments.length) return value;
+ value = x;
+ return pie;
+ };
+ pie.sort = function(x) {
+ if (!arguments.length) return sort;
+ sort = x;
+ return pie;
+ };
+ pie.startAngle = function(x) {
+ if (!arguments.length) return startAngle;
+ startAngle = x;
+ return pie;
+ };
+ pie.endAngle = function(x) {
+ if (!arguments.length) return endAngle;
+ endAngle = x;
+ return pie;
+ };
+ return pie;
+ };
+ var d3_layout_pieSortByValue = {};
+ d3.layout.stack = function() {
+ var values = d3_identity, order = d3_layout_stackOrderDefault, offset = d3_layout_stackOffsetZero, out = d3_layout_stackOut, x = d3_layout_stackX, y = d3_layout_stackY;
+ function stack(data, index) {
+ var series = data.map(function(d, i) {
+ return values.call(stack, d, i);
+ });
+ var points = series.map(function(d) {
+ return d.map(function(v, i) {
+ return [ x.call(stack, v, i), y.call(stack, v, i) ];
+ });
+ });
+ var orders = order.call(stack, points, index);
+ series = d3.permute(series, orders);
+ points = d3.permute(points, orders);
+ var offsets = offset.call(stack, points, index);
+ var n = series.length, m = series[0].length, i, j, o;
+ for (j = 0; j < m; ++j) {
+ out.call(stack, series[0][j], o = offsets[j], points[0][j][1]);
+ for (i = 1; i < n; ++i) {
+ out.call(stack, series[i][j], o += points[i - 1][j][1], points[i][j][1]);
+ }
+ }
+ return data;
+ }
+ stack.values = function(x) {
+ if (!arguments.length) return values;
+ values = x;
+ return stack;
+ };
+ stack.order = function(x) {
+ if (!arguments.length) return order;
+ order = typeof x === "function" ? x : d3_layout_stackOrders.get(x) || d3_layout_stackOrderDefault;
+ return stack;
+ };
+ stack.offset = function(x) {
+ if (!arguments.length) return offset;
+ offset = typeof x === "function" ? x : d3_layout_stackOffsets.get(x) || d3_layout_stackOffsetZero;
+ return stack;
+ };
+ stack.x = function(z) {
+ if (!arguments.length) return x;
+ x = z;
+ return stack;
+ };
+ stack.y = function(z) {
+ if (!arguments.length) return y;
+ y = z;
+ return stack;
+ };
+ stack.out = function(z) {
+ if (!arguments.length) return out;
+ out = z;
+ return stack;
+ };
+ return stack;
+ };
+ function d3_layout_stackX(d) {
+ return d.x;
+ }
+ function d3_layout_stackY(d) {
+ return d.y;
+ }
+ function d3_layout_stackOut(d, y0, y) {
+ d.y0 = y0;
+ d.y = y;
+ }
+ var d3_layout_stackOrders = d3.map({
+ "inside-out": function(data) {
+ var n = data.length, i, j, max = data.map(d3_layout_stackMaxIndex), sums = data.map(d3_layout_stackReduceSum), index = d3.range(n).sort(function(a, b) {
+ return max[a] - max[b];
+ }), top = 0, bottom = 0, tops = [], bottoms = [];
+ for (i = 0; i < n; ++i) {
+ j = index[i];
+ if (top < bottom) {
+ top += sums[j];
+ tops.push(j);
+ } else {
+ bottom += sums[j];
+ bottoms.push(j);
+ }
+ }
+ return bottoms.reverse().concat(tops);
+ },
+ reverse: function(data) {
+ return d3.range(data.length).reverse();
+ },
+ "default": d3_layout_stackOrderDefault
+ });
+ var d3_layout_stackOffsets = d3.map({
+ silhouette: function(data) {
+ var n = data.length, m = data[0].length, sums = [], max = 0, i, j, o, y0 = [];
+ for (j = 0; j < m; ++j) {
+ for (i = 0, o = 0; i < n; i++) o += data[i][j][1];
+ if (o > max) max = o;
+ sums.push(o);
+ }
+ for (j = 0; j < m; ++j) {
+ y0[j] = (max - sums[j]) / 2;
+ }
+ return y0;
+ },
+ wiggle: function(data) {
+ var n = data.length, x = data[0], m = x.length, i, j, k, s1, s2, s3, dx, o, o0, y0 = [];
+ y0[0] = o = o0 = 0;
+ for (j = 1; j < m; ++j) {
+ for (i = 0, s1 = 0; i < n; ++i) s1 += data[i][j][1];
+ for (i = 0, s2 = 0, dx = x[j][0] - x[j - 1][0]; i < n; ++i) {
+ for (k = 0, s3 = (data[i][j][1] - data[i][j - 1][1]) / (2 * dx); k < i; ++k) {
+ s3 += (data[k][j][1] - data[k][j - 1][1]) / dx;
+ }
+ s2 += s3 * data[i][j][1];
+ }
+ y0[j] = o -= s1 ? s2 / s1 * dx : 0;
+ if (o < o0) o0 = o;
+ }
+ for (j = 0; j < m; ++j) y0[j] -= o0;
+ return y0;
+ },
+ expand: function(data) {
+ var n = data.length, m = data[0].length, k = 1 / n, i, j, o, y0 = [];
+ for (j = 0; j < m; ++j) {
+ for (i = 0, o = 0; i < n; i++) o += data[i][j][1];
+ if (o) for (i = 0; i < n; i++) data[i][j][1] /= o; else for (i = 0; i < n; i++) data[i][j][1] = k;
+ }
+ for (j = 0; j < m; ++j) y0[j] = 0;
+ return y0;
+ },
+ zero: d3_layout_stackOffsetZero
+ });
+ function d3_layout_stackOrderDefault(data) {
+ return d3.range(data.length);
+ }
+ function d3_layout_stackOffsetZero(data) {
+ var j = -1, m = data[0].length, y0 = [];
+ while (++j < m) y0[j] = 0;
+ return y0;
+ }
+ function d3_layout_stackMaxIndex(array) {
+ var i = 1, j = 0, v = array[0][1], k, n = array.length;
+ for (;i < n; ++i) {
+ if ((k = array[i][1]) > v) {
+ j = i;
+ v = k;
+ }
+ }
+ return j;
+ }
+ function d3_layout_stackReduceSum(d) {
+ return d.reduce(d3_layout_stackSum, 0);
+ }
+ function d3_layout_stackSum(p, d) {
+ return p + d[1];
+ }
+ d3.layout.histogram = function() {
+ var frequency = true, valuer = Number, ranger = d3_layout_histogramRange, binner = d3_layout_histogramBinSturges;
+ function histogram(data, i) {
+ var bins = [], values = data.map(valuer, this), range = ranger.call(this, values, i), thresholds = binner.call(this, range, values, i), bin, i = -1, n = values.length, m = thresholds.length - 1, k = frequency ? 1 : 1 / n, x;
+ while (++i < m) {
+ bin = bins[i] = [];
+ bin.dx = thresholds[i + 1] - (bin.x = thresholds[i]);
+ bin.y = 0;
+ }
+ if (m > 0) {
+ i = -1;
+ while (++i < n) {
+ x = values[i];
+ if (x >= range[0] && x <= range[1]) {
+ bin = bins[d3.bisect(thresholds, x, 1, m) - 1];
+ bin.y += k;
+ bin.push(data[i]);
+ }
+ }
+ }
+ return bins;
+ }
+ histogram.value = function(x) {
+ if (!arguments.length) return valuer;
+ valuer = x;
+ return histogram;
+ };
+ histogram.range = function(x) {
+ if (!arguments.length) return ranger;
+ ranger = d3_functor(x);
+ return histogram;
+ };
+ histogram.bins = function(x) {
+ if (!arguments.length) return binner;
+ binner = typeof x === "number" ? function(range) {
+ return d3_layout_histogramBinFixed(range, x);
+ } : d3_functor(x);
+ return histogram;
+ };
+ histogram.frequency = function(x) {
+ if (!arguments.length) return frequency;
+ frequency = !!x;
+ return histogram;
+ };
+ return histogram;
+ };
+ function d3_layout_histogramBinSturges(range, values) {
+ return d3_layout_histogramBinFixed(range, Math.ceil(Math.log(values.length) / Math.LN2 + 1));
+ }
+ function d3_layout_histogramBinFixed(range, n) {
+ var x = -1, b = +range[0], m = (range[1] - b) / n, f = [];
+ while (++x <= n) f[x] = m * x + b;
+ return f;
+ }
+ function d3_layout_histogramRange(values) {
+ return [ d3.min(values), d3.max(values) ];
+ }
+ d3.layout.tree = function() {
+ var hierarchy = d3.layout.hierarchy().sort(null).value(null), separation = d3_layout_treeSeparation, size = [ 1, 1 ], nodeSize = false;
+ function tree(d, i) {
+ var nodes = hierarchy.call(this, d, i), root = nodes[0];
+ function firstWalk(node, previousSibling) {
+ var children = node.children, layout = node._tree;
+ if (children && (n = children.length)) {
+ var n, firstChild = children[0], previousChild, ancestor = firstChild, child, i = -1;
+ while (++i < n) {
+ child = children[i];
+ firstWalk(child, previousChild);
+ ancestor = apportion(child, previousChild, ancestor);
+ previousChild = child;
+ }
+ d3_layout_treeShift(node);
+ var midpoint = .5 * (firstChild._tree.prelim + child._tree.prelim);
+ if (previousSibling) {
+ layout.prelim = previousSibling._tree.prelim + separation(node, previousSibling);
+ layout.mod = layout.prelim - midpoint;
+ } else {
+ layout.prelim = midpoint;
+ }
+ } else {
+ if (previousSibling) {
+ layout.prelim = previousSibling._tree.prelim + separation(node, previousSibling);
+ }
+ }
+ }
+ function secondWalk(node, x) {
+ node.x = node._tree.prelim + x;
+ var children = node.children;
+ if (children && (n = children.length)) {
+ var i = -1, n;
+ x += node._tree.mod;
+ while (++i < n) {
+ secondWalk(children[i], x);
+ }
+ }
+ }
+ function apportion(node, previousSibling, ancestor) {
+ if (previousSibling) {
+ var vip = node, vop = node, vim = previousSibling, vom = node.parent.children[0], sip = vip._tree.mod, sop = vop._tree.mod, sim = vim._tree.mod, som = vom._tree.mod, shift;
+ while (vim = d3_layout_treeRight(vim), vip = d3_layout_treeLeft(vip), vim && vip) {
+ vom = d3_layout_treeLeft(vom);
+ vop = d3_layout_treeRight(vop);
+ vop._tree.ancestor = node;
+ shift = vim._tree.prelim + sim - vip._tree.prelim - sip + separation(vim, vip);
+ if (shift > 0) {
+ d3_layout_treeMove(d3_layout_treeAncestor(vim, node, ancestor), node, shift);
+ sip += shift;
+ sop += shift;
+ }
+ sim += vim._tree.mod;
+ sip += vip._tree.mod;
+ som += vom._tree.mod;
+ sop += vop._tree.mod;
+ }
+ if (vim && !d3_layout_treeRight(vop)) {
+ vop._tree.thread = vim;
+ vop._tree.mod += sim - sop;
+ }
+ if (vip && !d3_layout_treeLeft(vom)) {
+ vom._tree.thread = vip;
+ vom._tree.mod += sip - som;
+ ancestor = node;
+ }
+ }
+ return ancestor;
+ }
+ d3_layout_treeVisitAfter(root, function(node, previousSibling) {
+ node._tree = {
+ ancestor: node,
+ prelim: 0,
+ mod: 0,
+ change: 0,
+ shift: 0,
+ number: previousSibling ? previousSibling._tree.number + 1 : 0
+ };
+ });
+ firstWalk(root);
+ secondWalk(root, -root._tree.prelim);
+ var left = d3_layout_treeSearch(root, d3_layout_treeLeftmost), right = d3_layout_treeSearch(root, d3_layout_treeRightmost), deep = d3_layout_treeSearch(root, d3_layout_treeDeepest), x0 = left.x - separation(left, right) / 2, x1 = right.x + separation(right, left) / 2, y1 = deep.depth || 1;
+ d3_layout_treeVisitAfter(root, nodeSize ? function(node) {
+ node.x *= size[0];
+ node.y = node.depth * size[1];
+ delete node._tree;
+ } : function(node) {
+ node.x = (node.x - x0) / (x1 - x0) * size[0];
+ node.y = node.depth / y1 * size[1];
+ delete node._tree;
+ });
+ return nodes;
+ }
+ tree.separation = function(x) {
+ if (!arguments.length) return separation;
+ separation = x;
+ return tree;
+ };
+ tree.size = function(x) {
+ if (!arguments.length) return nodeSize ? null : size;
+ nodeSize = (size = x) == null;
+ return tree;
+ };
+ tree.nodeSize = function(x) {
+ if (!arguments.length) return nodeSize ? size : null;
+ nodeSize = (size = x) != null;
+ return tree;
+ };
+ return d3_layout_hierarchyRebind(tree, hierarchy);
+ };
+ function d3_layout_treeSeparation(a, b) {
+ return a.parent == b.parent ? 1 : 2;
+ }
+ function d3_layout_treeLeft(node) {
+ var children = node.children;
+ return children && children.length ? children[0] : node._tree.thread;
+ }
+ function d3_layout_treeRight(node) {
+ var children = node.children, n;
+ return children && (n = children.length) ? children[n - 1] : node._tree.thread;
+ }
+ function d3_layout_treeSearch(node, compare) {
+ var children = node.children;
+ if (children && (n = children.length)) {
+ var child, n, i = -1;
+ while (++i < n) {
+ if (compare(child = d3_layout_treeSearch(children[i], compare), node) > 0) {
+ node = child;
+ }
+ }
+ }
+ return node;
+ }
+ function d3_layout_treeRightmost(a, b) {
+ return a.x - b.x;
+ }
+ function d3_layout_treeLeftmost(a, b) {
+ return b.x - a.x;
+ }
+ function d3_layout_treeDeepest(a, b) {
+ return a.depth - b.depth;
+ }
+ function d3_layout_treeVisitAfter(node, callback) {
+ function visit(node, previousSibling) {
+ var children = node.children;
+ if (children && (n = children.length)) {
+ var child, previousChild = null, i = -1, n;
+ while (++i < n) {
+ child = children[i];
+ visit(child, previousChild);
+ previousChild = child;
+ }
+ }
+ callback(node, previousSibling);
+ }
+ visit(node, null);
+ }
+ function d3_layout_treeShift(node) {
+ var shift = 0, change = 0, children = node.children, i = children.length, child;
+ while (--i >= 0) {
+ child = children[i]._tree;
+ child.prelim += shift;
+ child.mod += shift;
+ shift += child.shift + (change += child.change);
+ }
+ }
+ function d3_layout_treeMove(ancestor, node, shift) {
+ ancestor = ancestor._tree;
+ node = node._tree;
+ var change = shift / (node.number - ancestor.number);
+ ancestor.change += change;
+ node.change -= change;
+ node.shift += shift;
+ node.prelim += shift;
+ node.mod += shift;
+ }
+ function d3_layout_treeAncestor(vim, node, ancestor) {
+ return vim._tree.ancestor.parent == node.parent ? vim._tree.ancestor : ancestor;
+ }
+ d3.layout.pack = function() {
+ var hierarchy = d3.layout.hierarchy().sort(d3_layout_packSort), padding = 0, size = [ 1, 1 ], radius;
+ function pack(d, i) {
+ var nodes = hierarchy.call(this, d, i), root = nodes[0], w = size[0], h = size[1], r = radius == null ? Math.sqrt : typeof radius === "function" ? radius : function() {
+ return radius;
+ };
+ root.x = root.y = 0;
+ d3_layout_treeVisitAfter(root, function(d) {
+ d.r = +r(d.value);
+ });
+ d3_layout_treeVisitAfter(root, d3_layout_packSiblings);
+ if (padding) {
+ var dr = padding * (radius ? 1 : Math.max(2 * root.r / w, 2 * root.r / h)) / 2;
+ d3_layout_treeVisitAfter(root, function(d) {
+ d.r += dr;
+ });
+ d3_layout_treeVisitAfter(root, d3_layout_packSiblings);
+ d3_layout_treeVisitAfter(root, function(d) {
+ d.r -= dr;
+ });
+ }
+ d3_layout_packTransform(root, w / 2, h / 2, radius ? 1 : 1 / Math.max(2 * root.r / w, 2 * root.r / h));
+ return nodes;
+ }
+ pack.size = function(_) {
+ if (!arguments.length) return size;
+ size = _;
+ return pack;
+ };
+ pack.radius = function(_) {
+ if (!arguments.length) return radius;
+ radius = _ == null || typeof _ === "function" ? _ : +_;
+ return pack;
+ };
+ pack.padding = function(_) {
+ if (!arguments.length) return padding;
+ padding = +_;
+ return pack;
+ };
+ return d3_layout_hierarchyRebind(pack, hierarchy);
+ };
+ function d3_layout_packSort(a, b) {
+ return a.value - b.value;
+ }
+ function d3_layout_packInsert(a, b) {
+ var c = a._pack_next;
+ a._pack_next = b;
+ b._pack_prev = a;
+ b._pack_next = c;
+ c._pack_prev = b;
+ }
+ function d3_layout_packSplice(a, b) {
+ a._pack_next = b;
+ b._pack_prev = a;
+ }
+ function d3_layout_packIntersects(a, b) {
+ var dx = b.x - a.x, dy = b.y - a.y, dr = a.r + b.r;
+ return .999 * dr * dr > dx * dx + dy * dy;
+ }
+ function d3_layout_packSiblings(node) {
+ if (!(nodes = node.children) || !(n = nodes.length)) return;
+ var nodes, xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity, a, b, c, i, j, k, n;
+ function bound(node) {
+ xMin = Math.min(node.x - node.r, xMin);
+ xMax = Math.max(node.x + node.r, xMax);
+ yMin = Math.min(node.y - node.r, yMin);
+ yMax = Math.max(node.y + node.r, yMax);
+ }
+ nodes.forEach(d3_layout_packLink);
+ a = nodes[0];
+ a.x = -a.r;
+ a.y = 0;
+ bound(a);
+ if (n > 1) {
+ b = nodes[1];
+ b.x = b.r;
+ b.y = 0;
+ bound(b);
+ if (n > 2) {
+ c = nodes[2];
+ d3_layout_packPlace(a, b, c);
+ bound(c);
+ d3_layout_packInsert(a, c);
+ a._pack_prev = c;
+ d3_layout_packInsert(c, b);
+ b = a._pack_next;
+ for (i = 3; i < n; i++) {
+ d3_layout_packPlace(a, b, c = nodes[i]);
+ var isect = 0, s1 = 1, s2 = 1;
+ for (j = b._pack_next; j !== b; j = j._pack_next, s1++) {
+ if (d3_layout_packIntersects(j, c)) {
+ isect = 1;
+ break;
+ }
+ }
+ if (isect == 1) {
+ for (k = a._pack_prev; k !== j._pack_prev; k = k._pack_prev, s2++) {
+ if (d3_layout_packIntersects(k, c)) {
+ break;
+ }
+ }
+ }
+ if (isect) {
+ if (s1 < s2 || s1 == s2 && b.r < a.r) d3_layout_packSplice(a, b = j); else d3_layout_packSplice(a = k, b);
+ i--;
+ } else {
+ d3_layout_packInsert(a, c);
+ b = c;
+ bound(c);
+ }
+ }
+ }
+ }
+ var cx = (xMin + xMax) / 2, cy = (yMin + yMax) / 2, cr = 0;
+ for (i = 0; i < n; i++) {
+ c = nodes[i];
+ c.x -= cx;
+ c.y -= cy;
+ cr = Math.max(cr, c.r + Math.sqrt(c.x * c.x + c.y * c.y));
+ }
+ node.r = cr;
+ nodes.forEach(d3_layout_packUnlink);
+ }
+ function d3_layout_packLink(node) {
+ node._pack_next = node._pack_prev = node;
+ }
+ function d3_layout_packUnlink(node) {
+ delete node._pack_next;
+ delete node._pack_prev;
+ }
+ function d3_layout_packTransform(node, x, y, k) {
+ var children = node.children;
+ node.x = x += k * node.x;
+ node.y = y += k * node.y;
+ node.r *= k;
+ if (children) {
+ var i = -1, n = children.length;
+ while (++i < n) d3_layout_packTransform(children[i], x, y, k);
+ }
+ }
+ function d3_layout_packPlace(a, b, c) {
+ var db = a.r + c.r, dx = b.x - a.x, dy = b.y - a.y;
+ if (db && (dx || dy)) {
+ var da = b.r + c.r, dc = dx * dx + dy * dy;
+ da *= da;
+ db *= db;
+ var x = .5 + (db - da) / (2 * dc), y = Math.sqrt(Math.max(0, 2 * da * (db + dc) - (db -= dc) * db - da * da)) / (2 * dc);
+ c.x = a.x + x * dx + y * dy;
+ c.y = a.y + x * dy - y * dx;
+ } else {
+ c.x = a.x + db;
+ c.y = a.y;
+ }
+ }
+ d3.layout.cluster = function() {
+ var hierarchy = d3.layout.hierarchy().sort(null).value(null), separation = d3_layout_treeSeparation, size = [ 1, 1 ], nodeSize = false;
+ function cluster(d, i) {
+ var nodes = hierarchy.call(this, d, i), root = nodes[0], previousNode, x = 0;
+ d3_layout_treeVisitAfter(root, function(node) {
+ var children = node.children;
+ if (children && children.length) {
+ node.x = d3_layout_clusterX(children);
+ node.y = d3_layout_clusterY(children);
+ } else {
+ node.x = previousNode ? x += separation(node, previousNode) : 0;
+ node.y = 0;
+ previousNode = node;
+ }
+ });
+ var left = d3_layout_clusterLeft(root), right = d3_layout_clusterRight(root), x0 = left.x - separation(left, right) / 2, x1 = right.x + separation(right, left) / 2;
+ d3_layout_treeVisitAfter(root, nodeSize ? function(node) {
+ node.x = (node.x - root.x) * size[0];
+ node.y = (root.y - node.y) * size[1];
+ } : function(node) {
+ node.x = (node.x - x0) / (x1 - x0) * size[0];
+ node.y = (1 - (root.y ? node.y / root.y : 1)) * size[1];
+ });
+ return nodes;
+ }
+ cluster.separation = function(x) {
+ if (!arguments.length) return separation;
+ separation = x;
+ return cluster;
+ };
+ cluster.size = function(x) {
+ if (!arguments.length) return nodeSize ? null : size;
+ nodeSize = (size = x) == null;
+ return cluster;
+ };
+ cluster.nodeSize = function(x) {
+ if (!arguments.length) return nodeSize ? size : null;
+ nodeSize = (size = x) != null;
+ return cluster;
+ };
+ return d3_layout_hierarchyRebind(cluster, hierarchy);
+ };
+ function d3_layout_clusterY(children) {
+ return 1 + d3.max(children, function(child) {
+ return child.y;
+ });
+ }
+ function d3_layout_clusterX(children) {
+ return children.reduce(function(x, child) {
+ return x + child.x;
+ }, 0) / children.length;
+ }
+ function d3_layout_clusterLeft(node) {
+ var children = node.children;
+ return children && children.length ? d3_layout_clusterLeft(children[0]) : node;
+ }
+ function d3_layout_clusterRight(node) {
+ var children = node.children, n;
+ return children && (n = children.length) ? d3_layout_clusterRight(children[n - 1]) : node;
+ }
+ d3.layout.treemap = function() {
+ var hierarchy = d3.layout.hierarchy(), round = Math.round, size = [ 1, 1 ], padding = null, pad = d3_layout_treemapPadNull, sticky = false, stickies, mode = "squarify", ratio = .5 * (1 + Math.sqrt(5));
+ function scale(children, k) {
+ var i = -1, n = children.length, child, area;
+ while (++i < n) {
+ area = (child = children[i]).value * (k < 0 ? 0 : k);
+ child.area = isNaN(area) || area <= 0 ? 0 : area;
+ }
+ }
+ function squarify(node) {
+ var children = node.children;
+ if (children && children.length) {
+ var rect = pad(node), row = [], remaining = children.slice(), child, best = Infinity, score, u = mode === "slice" ? rect.dx : mode === "dice" ? rect.dy : mode === "slice-dice" ? node.depth & 1 ? rect.dy : rect.dx : Math.min(rect.dx, rect.dy), n;
+ scale(remaining, rect.dx * rect.dy / node.value);
+ row.area = 0;
+ while ((n = remaining.length) > 0) {
+ row.push(child = remaining[n - 1]);
+ row.area += child.area;
+ if (mode !== "squarify" || (score = worst(row, u)) <= best) {
+ remaining.pop();
+ best = score;
+ } else {
+ row.area -= row.pop().area;
+ position(row, u, rect, false);
+ u = Math.min(rect.dx, rect.dy);
+ row.length = row.area = 0;
+ best = Infinity;
+ }
+ }
+ if (row.length) {
+ position(row, u, rect, true);
+ row.length = row.area = 0;
+ }
+ children.forEach(squarify);
+ }
+ }
+ function stickify(node) {
+ var children = node.children;
+ if (children && children.length) {
+ var rect = pad(node), remaining = children.slice(), child, row = [];
+ scale(remaining, rect.dx * rect.dy / node.value);
+ row.area = 0;
+ while (child = remaining.pop()) {
+ row.push(child);
+ row.area += child.area;
+ if (child.z != null) {
+ position(row, child.z ? rect.dx : rect.dy, rect, !remaining.length);
+ row.length = row.area = 0;
+ }
+ }
+ children.forEach(stickify);
+ }
+ }
+ function worst(row, u) {
+ var s = row.area, r, rmax = 0, rmin = Infinity, i = -1, n = row.length;
+ while (++i < n) {
+ if (!(r = row[i].area)) continue;
+ if (r < rmin) rmin = r;
+ if (r > rmax) rmax = r;
+ }
+ s *= s;
+ u *= u;
+ return s ? Math.max(u * rmax * ratio / s, s / (u * rmin * ratio)) : Infinity;
+ }
+ function position(row, u, rect, flush) {
+ var i = -1, n = row.length, x = rect.x, y = rect.y, v = u ? round(row.area / u) : 0, o;
+ if (u == rect.dx) {
+ if (flush || v > rect.dy) v = rect.dy;
+ while (++i < n) {
+ o = row[i];
+ o.x = x;
+ o.y = y;
+ o.dy = v;
+ x += o.dx = Math.min(rect.x + rect.dx - x, v ? round(o.area / v) : 0);
+ }
+ o.z = true;
+ o.dx += rect.x + rect.dx - x;
+ rect.y += v;
+ rect.dy -= v;
+ } else {
+ if (flush || v > rect.dx) v = rect.dx;
+ while (++i < n) {
+ o = row[i];
+ o.x = x;
+ o.y = y;
+ o.dx = v;
+ y += o.dy = Math.min(rect.y + rect.dy - y, v ? round(o.area / v) : 0);
+ }
+ o.z = false;
+ o.dy += rect.y + rect.dy - y;
+ rect.x += v;
+ rect.dx -= v;
+ }
+ }
+ function treemap(d) {
+ var nodes = stickies || hierarchy(d), root = nodes[0];
+ root.x = 0;
+ root.y = 0;
+ root.dx = size[0];
+ root.dy = size[1];
+ if (stickies) hierarchy.revalue(root);
+ scale([ root ], root.dx * root.dy / root.value);
+ (stickies ? stickify : squarify)(root);
+ if (sticky) stickies = nodes;
+ return nodes;
+ }
+ treemap.size = function(x) {
+ if (!arguments.length) return size;
+ size = x;
+ return treemap;
+ };
+ treemap.padding = function(x) {
+ if (!arguments.length) return padding;
+ function padFunction(node) {
+ var p = x.call(treemap, node, node.depth);
+ return p == null ? d3_layout_treemapPadNull(node) : d3_layout_treemapPad(node, typeof p === "number" ? [ p, p, p, p ] : p);
+ }
+ function padConstant(node) {
+ return d3_layout_treemapPad(node, x);
+ }
+ var type;
+ pad = (padding = x) == null ? d3_layout_treemapPadNull : (type = typeof x) === "function" ? padFunction : type === "number" ? (x = [ x, x, x, x ],
+ padConstant) : padConstant;
+ return treemap;
+ };
+ treemap.round = function(x) {
+ if (!arguments.length) return round != Number;
+ round = x ? Math.round : Number;
+ return treemap;
+ };
+ treemap.sticky = function(x) {
+ if (!arguments.length) return sticky;
+ sticky = x;
+ stickies = null;
+ return treemap;
+ };
+ treemap.ratio = function(x) {
+ if (!arguments.length) return ratio;
+ ratio = x;
+ return treemap;
+ };
+ treemap.mode = function(x) {
+ if (!arguments.length) return mode;
+ mode = x + "";
+ return treemap;
+ };
+ return d3_layout_hierarchyRebind(treemap, hierarchy);
+ };
+ function d3_layout_treemapPadNull(node) {
+ return {
+ x: node.x,
+ y: node.y,
+ dx: node.dx,
+ dy: node.dy
+ };
+ }
+ function d3_layout_treemapPad(node, padding) {
+ var x = node.x + padding[3], y = node.y + padding[0], dx = node.dx - padding[1] - padding[3], dy = node.dy - padding[0] - padding[2];
+ if (dx < 0) {
+ x += dx / 2;
+ dx = 0;
+ }
+ if (dy < 0) {
+ y += dy / 2;
+ dy = 0;
+ }
+ return {
+ x: x,
+ y: y,
+ dx: dx,
+ dy: dy
+ };
+ }
+ d3.random = {
+ normal: function(µ, σ) {
+ var n = arguments.length;
+ if (n < 2) σ = 1;
+ if (n < 1) µ = 0;
+ return function() {
+ var x, y, r;
+ do {
+ x = Math.random() * 2 - 1;
+ y = Math.random() * 2 - 1;
+ r = x * x + y * y;
+ } while (!r || r > 1);
+ return µ + σ * x * Math.sqrt(-2 * Math.log(r) / r);
+ };
+ },
+ logNormal: function() {
+ var random = d3.random.normal.apply(d3, arguments);
+ return function() {
+ return Math.exp(random());
+ };
+ },
+ bates: function(m) {
+ var random = d3.random.irwinHall(m);
+ return function() {
+ return random() / m;
+ };
+ },
+ irwinHall: function(m) {
+ return function() {
+ for (var s = 0, j = 0; j < m; j++) s += Math.random();
+ return s;
+ };
+ }
+ };
+ d3.scale = {};
+ function d3_scaleExtent(domain) {
+ var start = domain[0], stop = domain[domain.length - 1];
+ return start < stop ? [ start, stop ] : [ stop, start ];
+ }
+ function d3_scaleRange(scale) {
+ return scale.rangeExtent ? scale.rangeExtent() : d3_scaleExtent(scale.range());
+ }
+ function d3_scale_bilinear(domain, range, uninterpolate, interpolate) {
+ var u = uninterpolate(domain[0], domain[1]), i = interpolate(range[0], range[1]);
+ return function(x) {
+ return i(u(x));
+ };
+ }
+ function d3_scale_nice(domain, nice) {
+ var i0 = 0, i1 = domain.length - 1, x0 = domain[i0], x1 = domain[i1], dx;
+ if (x1 < x0) {
+ dx = i0, i0 = i1, i1 = dx;
+ dx = x0, x0 = x1, x1 = dx;
+ }
+ domain[i0] = nice.floor(x0);
+ domain[i1] = nice.ceil(x1);
+ return domain;
+ }
+ function d3_scale_niceStep(step) {
+ return step ? {
+ floor: function(x) {
+ return Math.floor(x / step) * step;
+ },
+ ceil: function(x) {
+ return Math.ceil(x / step) * step;
+ }
+ } : d3_scale_niceIdentity;
+ }
+ var d3_scale_niceIdentity = {
+ floor: d3_identity,
+ ceil: d3_identity
+ };
+ function d3_scale_polylinear(domain, range, uninterpolate, interpolate) {
+ var u = [], i = [], j = 0, k = Math.min(domain.length, range.length) - 1;
+ if (domain[k] < domain[0]) {
+ domain = domain.slice().reverse();
+ range = range.slice().reverse();
+ }
+ while (++j <= k) {
+ u.push(uninterpolate(domain[j - 1], domain[j]));
+ i.push(interpolate(range[j - 1], range[j]));
+ }
+ return function(x) {
+ var j = d3.bisect(domain, x, 1, k) - 1;
+ return i[j](u[j](x));
+ };
+ }
+ d3.scale.linear = function() {
+ return d3_scale_linear([ 0, 1 ], [ 0, 1 ], d3_interpolate, false);
+ };
+ function d3_scale_linear(domain, range, interpolate, clamp) {
+ var output, input;
+ function rescale() {
+ var linear = Math.min(domain.length, range.length) > 2 ? d3_scale_polylinear : d3_scale_bilinear, uninterpolate = clamp ? d3_uninterpolateClamp : d3_uninterpolateNumber;
+ output = linear(domain, range, uninterpolate, interpolate);
+ input = linear(range, domain, uninterpolate, d3_interpolate);
+ return scale;
+ }
+ function scale(x) {
+ return output(x);
+ }
+ scale.invert = function(y) {
+ return input(y);
+ };
+ scale.domain = function(x) {
+ if (!arguments.length) return domain;
+ domain = x.map(Number);
+ return rescale();
+ };
+ scale.range = function(x) {
+ if (!arguments.length) return range;
+ range = x;
+ return rescale();
+ };
+ scale.rangeRound = function(x) {
+ return scale.range(x).interpolate(d3_interpolateRound);
+ };
+ scale.clamp = function(x) {
+ if (!arguments.length) return clamp;
+ clamp = x;
+ return rescale();
+ };
+ scale.interpolate = function(x) {
+ if (!arguments.length) return interpolate;
+ interpolate = x;
+ return rescale();
+ };
+ scale.ticks = function(m) {
+ return d3_scale_linearTicks(domain, m);
+ };
+ scale.tickFormat = function(m, format) {
+ return d3_scale_linearTickFormat(domain, m, format);
+ };
+ scale.nice = function(m) {
+ d3_scale_linearNice(domain, m);
+ return rescale();
+ };
+ scale.copy = function() {
+ return d3_scale_linear(domain, range, interpolate, clamp);
+ };
+ return rescale();
+ }
+ function d3_scale_linearRebind(scale, linear) {
+ return d3.rebind(scale, linear, "range", "rangeRound", "interpolate", "clamp");
+ }
+ function d3_scale_linearNice(domain, m) {
+ return d3_scale_nice(domain, d3_scale_niceStep(d3_scale_linearTickRange(domain, m)[2]));
+ }
+ function d3_scale_linearTickRange(domain, m) {
+ if (m == null) m = 10;
+ var extent = d3_scaleExtent(domain), span = extent[1] - extent[0], step = Math.pow(10, Math.floor(Math.log(span / m) / Math.LN10)), err = m / span * step;
+ if (err <= .15) step *= 10; else if (err <= .35) step *= 5; else if (err <= .75) step *= 2;
+ extent[0] = Math.ceil(extent[0] / step) * step;
+ extent[1] = Math.floor(extent[1] / step) * step + step * .5;
+ extent[2] = step;
+ return extent;
+ }
+ function d3_scale_linearTicks(domain, m) {
+ return d3.range.apply(d3, d3_scale_linearTickRange(domain, m));
+ }
+ function d3_scale_linearTickFormat(domain, m, format) {
+ var range = d3_scale_linearTickRange(domain, m);
+ return d3.format(format ? format.replace(d3_format_re, function(a, b, c, d, e, f, g, h, i, j) {
+ return [ b, c, d, e, f, g, h, i || "." + d3_scale_linearFormatPrecision(j, range), j ].join("");
+ }) : ",." + d3_scale_linearPrecision(range[2]) + "f");
+ }
+ var d3_scale_linearFormatSignificant = {
+ s: 1,
+ g: 1,
+ p: 1,
+ r: 1,
+ e: 1
+ };
+ function d3_scale_linearPrecision(value) {
+ return -Math.floor(Math.log(value) / Math.LN10 + .01);
+ }
+ function d3_scale_linearFormatPrecision(type, range) {
+ var p = d3_scale_linearPrecision(range[2]);
+ return type in d3_scale_linearFormatSignificant ? Math.abs(p - d3_scale_linearPrecision(Math.max(Math.abs(range[0]), Math.abs(range[1])))) + +(type !== "e") : p - (type === "%") * 2;
+ }
+ d3.scale.log = function() {
+ return d3_scale_log(d3.scale.linear().domain([ 0, 1 ]), 10, true, [ 1, 10 ]);
+ };
+ function d3_scale_log(linear, base, positive, domain) {
+ function log(x) {
+ return (positive ? Math.log(x < 0 ? 0 : x) : -Math.log(x > 0 ? 0 : -x)) / Math.log(base);
+ }
+ function pow(x) {
+ return positive ? Math.pow(base, x) : -Math.pow(base, -x);
+ }
+ function scale(x) {
+ return linear(log(x));
+ }
+ scale.invert = function(x) {
+ return pow(linear.invert(x));
+ };
+ scale.domain = function(x) {
+ if (!arguments.length) return domain;
+ positive = x[0] >= 0;
+ linear.domain((domain = x.map(Number)).map(log));
+ return scale;
+ };
+ scale.base = function(_) {
+ if (!arguments.length) return base;
+ base = +_;
+ linear.domain(domain.map(log));
+ return scale;
+ };
+ scale.nice = function() {
+ var niced = d3_scale_nice(domain.map(log), positive ? Math : d3_scale_logNiceNegative);
+ linear.domain(niced);
+ domain = niced.map(pow);
+ return scale;
+ };
+ scale.ticks = function() {
+ var extent = d3_scaleExtent(domain), ticks = [], u = extent[0], v = extent[1], i = Math.floor(log(u)), j = Math.ceil(log(v)), n = base % 1 ? 2 : base;
+ if (isFinite(j - i)) {
+ if (positive) {
+ for (;i < j; i++) for (var k = 1; k < n; k++) ticks.push(pow(i) * k);
+ ticks.push(pow(i));
+ } else {
+ ticks.push(pow(i));
+ for (;i++ < j; ) for (var k = n - 1; k > 0; k--) ticks.push(pow(i) * k);
+ }
+ for (i = 0; ticks[i] < u; i++) {}
+ for (j = ticks.length; ticks[j - 1] > v; j--) {}
+ ticks = ticks.slice(i, j);
+ }
+ return ticks;
+ };
+ scale.tickFormat = function(n, format) {
+ if (!arguments.length) return d3_scale_logFormat;
+ if (arguments.length < 2) format = d3_scale_logFormat; else if (typeof format !== "function") format = d3.format(format);
+ var k = Math.max(.1, n / scale.ticks().length), f = positive ? (e = 1e-12, Math.ceil) : (e = -1e-12,
+ Math.floor), e;
+ return function(d) {
+ return d / pow(f(log(d) + e)) <= k ? format(d) : "";
+ };
+ };
+ scale.copy = function() {
+ return d3_scale_log(linear.copy(), base, positive, domain);
+ };
+ return d3_scale_linearRebind(scale, linear);
+ }
+ var d3_scale_logFormat = d3.format(".0e"), d3_scale_logNiceNegative = {
+ floor: function(x) {
+ return -Math.ceil(-x);
+ },
+ ceil: function(x) {
+ return -Math.floor(-x);
+ }
+ };
+ d3.scale.pow = function() {
+ return d3_scale_pow(d3.scale.linear(), 1, [ 0, 1 ]);
+ };
+ function d3_scale_pow(linear, exponent, domain) {
+ var powp = d3_scale_powPow(exponent), powb = d3_scale_powPow(1 / exponent);
+ function scale(x) {
+ return linear(powp(x));
+ }
+ scale.invert = function(x) {
+ return powb(linear.invert(x));
+ };
+ scale.domain = function(x) {
+ if (!arguments.length) return domain;
+ linear.domain((domain = x.map(Number)).map(powp));
+ return scale;
+ };
+ scale.ticks = function(m) {
+ return d3_scale_linearTicks(domain, m);
+ };
+ scale.tickFormat = function(m, format) {
+ return d3_scale_linearTickFormat(domain, m, format);
+ };
+ scale.nice = function(m) {
+ return scale.domain(d3_scale_linearNice(domain, m));
+ };
+ scale.exponent = function(x) {
+ if (!arguments.length) return exponent;
+ powp = d3_scale_powPow(exponent = x);
+ powb = d3_scale_powPow(1 / exponent);
+ linear.domain(domain.map(powp));
+ return scale;
+ };
+ scale.copy = function() {
+ return d3_scale_pow(linear.copy(), exponent, domain);
+ };
+ return d3_scale_linearRebind(scale, linear);
+ }
+ function d3_scale_powPow(e) {
+ return function(x) {
+ return x < 0 ? -Math.pow(-x, e) : Math.pow(x, e);
+ };
+ }
+ d3.scale.sqrt = function() {
+ return d3.scale.pow().exponent(.5);
+ };
+ d3.scale.ordinal = function() {
+ return d3_scale_ordinal([], {
+ t: "range",
+ a: [ [] ]
+ });
+ };
+ function d3_scale_ordinal(domain, ranger) {
+ var index, range, rangeBand;
+ function scale(x) {
+ return range[((index.get(x) || ranger.t === "range" && index.set(x, domain.push(x))) - 1) % range.length];
+ }
+ function steps(start, step) {
+ return d3.range(domain.length).map(function(i) {
+ return start + step * i;
+ });
+ }
+ scale.domain = function(x) {
+ if (!arguments.length) return domain;
+ domain = [];
+ index = new d3_Map();
+ var i = -1, n = x.length, xi;
+ while (++i < n) if (!index.has(xi = x[i])) index.set(xi, domain.push(xi));
+ return scale[ranger.t].apply(scale, ranger.a);
+ };
+ scale.range = function(x) {
+ if (!arguments.length) return range;
+ range = x;
+ rangeBand = 0;
+ ranger = {
+ t: "range",
+ a: arguments
+ };
+ return scale;
+ };
+ scale.rangePoints = function(x, padding) {
+ if (arguments.length < 2) padding = 0;
+ var start = x[0], stop = x[1], step = (stop - start) / (Math.max(1, domain.length - 1) + padding);
+ range = steps(domain.length < 2 ? (start + stop) / 2 : start + step * padding / 2, step);
+ rangeBand = 0;
+ ranger = {
+ t: "rangePoints",
+ a: arguments
+ };
+ return scale;
+ };
+ scale.rangeBands = function(x, padding, outerPadding) {
+ if (arguments.length < 2) padding = 0;
+ if (arguments.length < 3) outerPadding = padding;
+ var reverse = x[1] < x[0], start = x[reverse - 0], stop = x[1 - reverse], step = (stop - start) / (domain.length - padding + 2 * outerPadding);
+ range = steps(start + step * outerPadding, step);
+ if (reverse) range.reverse();
+ rangeBand = step * (1 - padding);
+ ranger = {
+ t: "rangeBands",
+ a: arguments
+ };
+ return scale;
+ };
+ scale.rangeRoundBands = function(x, padding, outerPadding) {
+ if (arguments.length < 2) padding = 0;
+ if (arguments.length < 3) outerPadding = padding;
+ var reverse = x[1] < x[0], start = x[reverse - 0], stop = x[1 - reverse], step = Math.floor((stop - start) / (domain.length - padding + 2 * outerPadding)), error = stop - start - (domain.length - padding) * step;
+ range = steps(start + Math.round(error / 2), step);
+ if (reverse) range.reverse();
+ rangeBand = Math.round(step * (1 - padding));
+ ranger = {
+ t: "rangeRoundBands",
+ a: arguments
+ };
+ return scale;
+ };
+ scale.rangeBand = function() {
+ return rangeBand;
+ };
+ scale.rangeExtent = function() {
+ return d3_scaleExtent(ranger.a[0]);
+ };
+ scale.copy = function() {
+ return d3_scale_ordinal(domain, ranger);
+ };
+ return scale.domain(domain);
+ }
+ d3.scale.category10 = function() {
+ return d3.scale.ordinal().range(d3_category10);
+ };
+ d3.scale.category20 = function() {
+ return d3.scale.ordinal().range(d3_category20);
+ };
+ d3.scale.category20b = function() {
+ return d3.scale.ordinal().range(d3_category20b);
+ };
+ d3.scale.category20c = function() {
+ return d3.scale.ordinal().range(d3_category20c);
+ };
+ var d3_category10 = [ 2062260, 16744206, 2924588, 14034728, 9725885, 9197131, 14907330, 8355711, 12369186, 1556175 ].map(d3_rgbString);
+ var d3_category20 = [ 2062260, 11454440, 16744206, 16759672, 2924588, 10018698, 14034728, 16750742, 9725885, 12955861, 9197131, 12885140, 14907330, 16234194, 8355711, 13092807, 12369186, 14408589, 1556175, 10410725 ].map(d3_rgbString);
+ var d3_category20b = [ 3750777, 5395619, 7040719, 10264286, 6519097, 9216594, 11915115, 13556636, 9202993, 12426809, 15186514, 15190932, 8666169, 11356490, 14049643, 15177372, 8077683, 10834324, 13528509, 14589654 ].map(d3_rgbString);
+ var d3_category20c = [ 3244733, 7057110, 10406625, 13032431, 15095053, 16616764, 16625259, 16634018, 3253076, 7652470, 10607003, 13101504, 7695281, 10394312, 12369372, 14342891, 6513507, 9868950, 12434877, 14277081 ].map(d3_rgbString);
+ d3.scale.quantile = function() {
+ return d3_scale_quantile([], []);
+ };
+ function d3_scale_quantile(domain, range) {
+ var thresholds;
+ function rescale() {
+ var k = 0, q = range.length;
+ thresholds = [];
+ while (++k < q) thresholds[k - 1] = d3.quantile(domain, k / q);
+ return scale;
+ }
+ function scale(x) {
+ if (!isNaN(x = +x)) return range[d3.bisect(thresholds, x)];
+ }
+ scale.domain = function(x) {
+ if (!arguments.length) return domain;
+ domain = x.filter(function(d) {
+ return !isNaN(d);
+ }).sort(d3.ascending);
+ return rescale();
+ };
+ scale.range = function(x) {
+ if (!arguments.length) return range;
+ range = x;
+ return rescale();
+ };
+ scale.quantiles = function() {
+ return thresholds;
+ };
+ scale.invertExtent = function(y) {
+ y = range.indexOf(y);
+ return y < 0 ? [ NaN, NaN ] : [ y > 0 ? thresholds[y - 1] : domain[0], y < thresholds.length ? thresholds[y] : domain[domain.length - 1] ];
+ };
+ scale.copy = function() {
+ return d3_scale_quantile(domain, range);
+ };
+ return rescale();
+ }
+ d3.scale.quantize = function() {
+ return d3_scale_quantize(0, 1, [ 0, 1 ]);
+ };
+ function d3_scale_quantize(x0, x1, range) {
+ var kx, i;
+ function scale(x) {
+ return range[Math.max(0, Math.min(i, Math.floor(kx * (x - x0))))];
+ }
+ function rescale() {
+ kx = range.length / (x1 - x0);
+ i = range.length - 1;
+ return scale;
+ }
+ scale.domain = function(x) {
+ if (!arguments.length) return [ x0, x1 ];
+ x0 = +x[0];
+ x1 = +x[x.length - 1];
+ return rescale();
+ };
+ scale.range = function(x) {
+ if (!arguments.length) return range;
+ range = x;
+ return rescale();
+ };
+ scale.invertExtent = function(y) {
+ y = range.indexOf(y);
+ y = y < 0 ? NaN : y / kx + x0;
+ return [ y, y + 1 / kx ];
+ };
+ scale.copy = function() {
+ return d3_scale_quantize(x0, x1, range);
+ };
+ return rescale();
+ }
+ d3.scale.threshold = function() {
+ return d3_scale_threshold([ .5 ], [ 0, 1 ]);
+ };
+ function d3_scale_threshold(domain, range) {
+ function scale(x) {
+ if (x <= x) return range[d3.bisect(domain, x)];
+ }
+ scale.domain = function(_) {
+ if (!arguments.length) return domain;
+ domain = _;
+ return scale;
+ };
+ scale.range = function(_) {
+ if (!arguments.length) return range;
+ range = _;
+ return scale;
+ };
+ scale.invertExtent = function(y) {
+ y = range.indexOf(y);
+ return [ domain[y - 1], domain[y] ];
+ };
+ scale.copy = function() {
+ return d3_scale_threshold(domain, range);
+ };
+ return scale;
+ }
+ d3.scale.identity = function() {
+ return d3_scale_identity([ 0, 1 ]);
+ };
+ function d3_scale_identity(domain) {
+ function identity(x) {
+ return +x;
+ }
+ identity.invert = identity;
+ identity.domain = identity.range = function(x) {
+ if (!arguments.length) return domain;
+ domain = x.map(identity);
+ return identity;
+ };
+ identity.ticks = function(m) {
+ return d3_scale_linearTicks(domain, m);
+ };
+ identity.tickFormat = function(m, format) {
+ return d3_scale_linearTickFormat(domain, m, format);
+ };
+ identity.copy = function() {
+ return d3_scale_identity(domain);
+ };
+ return identity;
+ }
+ d3.svg = {};
+ d3.svg.arc = function() {
+ var innerRadius = d3_svg_arcInnerRadius, outerRadius = d3_svg_arcOuterRadius, startAngle = d3_svg_arcStartAngle, endAngle = d3_svg_arcEndAngle;
+ function arc() {
+ var r0 = innerRadius.apply(this, arguments), r1 = outerRadius.apply(this, arguments), a0 = startAngle.apply(this, arguments) + d3_svg_arcOffset, a1 = endAngle.apply(this, arguments) + d3_svg_arcOffset, da = (a1 < a0 && (da = a0,
+ a0 = a1, a1 = da), a1 - a0), df = da < π ? "0" : "1", c0 = Math.cos(a0), s0 = Math.sin(a0), c1 = Math.cos(a1), s1 = Math.sin(a1);
+ return da >= d3_svg_arcMax ? r0 ? "M0," + r1 + "A" + r1 + "," + r1 + " 0 1,1 0," + -r1 + "A" + r1 + "," + r1 + " 0 1,1 0," + r1 + "M0," + r0 + "A" + r0 + "," + r0 + " 0 1,0 0," + -r0 + "A" + r0 + "," + r0 + " 0 1,0 0," + r0 + "Z" : "M0," + r1 + "A" + r1 + "," + r1 + " 0 1,1 0," + -r1 + "A" + r1 + "," + r1 + " 0 1,1 0," + r1 + "Z" : r0 ? "M" + r1 * c0 + "," + r1 * s0 + "A" + r1 + "," + r1 + " 0 " + df + ",1 " + r1 * c1 + "," + r1 * s1 + "L" + r0 * c1 + "," + r0 * s1 + "A" + r0 + "," + r0 + " 0 " + df + ",0 " + r0 * c0 + "," + r0 * s0 + "Z" : "M" + r1 * c0 + "," + r1 * s0 + "A" + r1 + "," + r1 + " 0 " + df + ",1 " + r1 * c1 + "," + r1 * s1 + "L0,0" + "Z";
+ }
+ arc.innerRadius = function(v) {
+ if (!arguments.length) return innerRadius;
+ innerRadius = d3_functor(v);
+ return arc;
+ };
+ arc.outerRadius = function(v) {
+ if (!arguments.length) return outerRadius;
+ outerRadius = d3_functor(v);
+ return arc;
+ };
+ arc.startAngle = function(v) {
+ if (!arguments.length) return startAngle;
+ startAngle = d3_functor(v);
+ return arc;
+ };
+ arc.endAngle = function(v) {
+ if (!arguments.length) return endAngle;
+ endAngle = d3_functor(v);
+ return arc;
+ };
+ arc.centroid = function() {
+ var r = (innerRadius.apply(this, arguments) + outerRadius.apply(this, arguments)) / 2, a = (startAngle.apply(this, arguments) + endAngle.apply(this, arguments)) / 2 + d3_svg_arcOffset;
+ return [ Math.cos(a) * r, Math.sin(a) * r ];
+ };
+ return arc;
+ };
+ var d3_svg_arcOffset = -halfπ, d3_svg_arcMax = τ - ε;
+ function d3_svg_arcInnerRadius(d) {
+ return d.innerRadius;
+ }
+ function d3_svg_arcOuterRadius(d) {
+ return d.outerRadius;
+ }
+ function d3_svg_arcStartAngle(d) {
+ return d.startAngle;
+ }
+ function d3_svg_arcEndAngle(d) {
+ return d.endAngle;
+ }
+ function d3_svg_line(projection) {
+ var x = d3_geom_pointX, y = d3_geom_pointY, defined = d3_true, interpolate = d3_svg_lineLinear, interpolateKey = interpolate.key, tension = .7;
+ function line(data) {
+ var segments = [], points = [], i = -1, n = data.length, d, fx = d3_functor(x), fy = d3_functor(y);
+ function segment() {
+ segments.push("M", interpolate(projection(points), tension));
+ }
+ while (++i < n) {
+ if (defined.call(this, d = data[i], i)) {
+ points.push([ +fx.call(this, d, i), +fy.call(this, d, i) ]);
+ } else if (points.length) {
+ segment();
+ points = [];
+ }
+ }
+ if (points.length) segment();
+ return segments.length ? segments.join("") : null;
+ }
+ line.x = function(_) {
+ if (!arguments.length) return x;
+ x = _;
+ return line;
+ };
+ line.y = function(_) {
+ if (!arguments.length) return y;
+ y = _;
+ return line;
+ };
+ line.defined = function(_) {
+ if (!arguments.length) return defined;
+ defined = _;
+ return line;
+ };
+ line.interpolate = function(_) {
+ if (!arguments.length) return interpolateKey;
+ if (typeof _ === "function") interpolateKey = interpolate = _; else interpolateKey = (interpolate = d3_svg_lineInterpolators.get(_) || d3_svg_lineLinear).key;
+ return line;
+ };
+ line.tension = function(_) {
+ if (!arguments.length) return tension;
+ tension = _;
+ return line;
+ };
+ return line;
+ }
+ d3.svg.line = function() {
+ return d3_svg_line(d3_identity);
+ };
+ var d3_svg_lineInterpolators = d3.map({
+ linear: d3_svg_lineLinear,
+ "linear-closed": d3_svg_lineLinearClosed,
+ step: d3_svg_lineStep,
+ "step-before": d3_svg_lineStepBefore,
+ "step-after": d3_svg_lineStepAfter,
+ basis: d3_svg_lineBasis,
+ "basis-open": d3_svg_lineBasisOpen,
+ "basis-closed": d3_svg_lineBasisClosed,
+ bundle: d3_svg_lineBundle,
+ cardinal: d3_svg_lineCardinal,
+ "cardinal-open": d3_svg_lineCardinalOpen,
+ "cardinal-closed": d3_svg_lineCardinalClosed,
+ monotone: d3_svg_lineMonotone
+ });
+ d3_svg_lineInterpolators.forEach(function(key, value) {
+ value.key = key;
+ value.closed = /-closed$/.test(key);
+ });
+ function d3_svg_lineLinear(points) {
+ return points.join("L");
+ }
+ function d3_svg_lineLinearClosed(points) {
+ return d3_svg_lineLinear(points) + "Z";
+ }
+ function d3_svg_lineStep(points) {
+ var i = 0, n = points.length, p = points[0], path = [ p[0], ",", p[1] ];
+ while (++i < n) path.push("H", (p[0] + (p = points[i])[0]) / 2, "V", p[1]);
+ if (n > 1) path.push("H", p[0]);
+ return path.join("");
+ }
+ function d3_svg_lineStepBefore(points) {
+ var i = 0, n = points.length, p = points[0], path = [ p[0], ",", p[1] ];
+ while (++i < n) path.push("V", (p = points[i])[1], "H", p[0]);
+ return path.join("");
+ }
+ function d3_svg_lineStepAfter(points) {
+ var i = 0, n = points.length, p = points[0], path = [ p[0], ",", p[1] ];
+ while (++i < n) path.push("H", (p = points[i])[0], "V", p[1]);
+ return path.join("");
+ }
+ function d3_svg_lineCardinalOpen(points, tension) {
+ return points.length < 4 ? d3_svg_lineLinear(points) : points[1] + d3_svg_lineHermite(points.slice(1, points.length - 1), d3_svg_lineCardinalTangents(points, tension));
+ }
+ function d3_svg_lineCardinalClosed(points, tension) {
+ return points.length < 3 ? d3_svg_lineLinear(points) : points[0] + d3_svg_lineHermite((points.push(points[0]),
+ points), d3_svg_lineCardinalTangents([ points[points.length - 2] ].concat(points, [ points[1] ]), tension));
+ }
+ function d3_svg_lineCardinal(points, tension) {
+ return points.length < 3 ? d3_svg_lineLinear(points) : points[0] + d3_svg_lineHermite(points, d3_svg_lineCardinalTangents(points, tension));
+ }
+ function d3_svg_lineHermite(points, tangents) {
+ if (tangents.length < 1 || points.length != tangents.length && points.length != tangents.length + 2) {
+ return d3_svg_lineLinear(points);
+ }
+ var quad = points.length != tangents.length, path = "", p0 = points[0], p = points[1], t0 = tangents[0], t = t0, pi = 1;
+ if (quad) {
+ path += "Q" + (p[0] - t0[0] * 2 / 3) + "," + (p[1] - t0[1] * 2 / 3) + "," + p[0] + "," + p[1];
+ p0 = points[1];
+ pi = 2;
+ }
+ if (tangents.length > 1) {
+ t = tangents[1];
+ p = points[pi];
+ pi++;
+ path += "C" + (p0[0] + t0[0]) + "," + (p0[1] + t0[1]) + "," + (p[0] - t[0]) + "," + (p[1] - t[1]) + "," + p[0] + "," + p[1];
+ for (var i = 2; i < tangents.length; i++, pi++) {
+ p = points[pi];
+ t = tangents[i];
+ path += "S" + (p[0] - t[0]) + "," + (p[1] - t[1]) + "," + p[0] + "," + p[1];
+ }
+ }
+ if (quad) {
+ var lp = points[pi];
+ path += "Q" + (p[0] + t[0] * 2 / 3) + "," + (p[1] + t[1] * 2 / 3) + "," + lp[0] + "," + lp[1];
+ }
+ return path;
+ }
+ function d3_svg_lineCardinalTangents(points, tension) {
+ var tangents = [], a = (1 - tension) / 2, p0, p1 = points[0], p2 = points[1], i = 1, n = points.length;
+ while (++i < n) {
+ p0 = p1;
+ p1 = p2;
+ p2 = points[i];
+ tangents.push([ a * (p2[0] - p0[0]), a * (p2[1] - p0[1]) ]);
+ }
+ return tangents;
+ }
+ function d3_svg_lineBasis(points) {
+ if (points.length < 3) return d3_svg_lineLinear(points);
+ var i = 1, n = points.length, pi = points[0], x0 = pi[0], y0 = pi[1], px = [ x0, x0, x0, (pi = points[1])[0] ], py = [ y0, y0, y0, pi[1] ], path = [ x0, ",", y0, "L", d3_svg_lineDot4(d3_svg_lineBasisBezier3, px), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, py) ];
+ points.push(points[n - 1]);
+ while (++i <= n) {
+ pi = points[i];
+ px.shift();
+ px.push(pi[0]);
+ py.shift();
+ py.push(pi[1]);
+ d3_svg_lineBasisBezier(path, px, py);
+ }
+ points.pop();
+ path.push("L", pi);
+ return path.join("");
+ }
+ function d3_svg_lineBasisOpen(points) {
+ if (points.length < 4) return d3_svg_lineLinear(points);
+ var path = [], i = -1, n = points.length, pi, px = [ 0 ], py = [ 0 ];
+ while (++i < 3) {
+ pi = points[i];
+ px.push(pi[0]);
+ py.push(pi[1]);
+ }
+ path.push(d3_svg_lineDot4(d3_svg_lineBasisBezier3, px) + "," + d3_svg_lineDot4(d3_svg_lineBasisBezier3, py));
+ --i;
+ while (++i < n) {
+ pi = points[i];
+ px.shift();
+ px.push(pi[0]);
+ py.shift();
+ py.push(pi[1]);
+ d3_svg_lineBasisBezier(path, px, py);
+ }
+ return path.join("");
+ }
+ function d3_svg_lineBasisClosed(points) {
+ var path, i = -1, n = points.length, m = n + 4, pi, px = [], py = [];
+ while (++i < 4) {
+ pi = points[i % n];
+ px.push(pi[0]);
+ py.push(pi[1]);
+ }
+ path = [ d3_svg_lineDot4(d3_svg_lineBasisBezier3, px), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, py) ];
+ --i;
+ while (++i < m) {
+ pi = points[i % n];
+ px.shift();
+ px.push(pi[0]);
+ py.shift();
+ py.push(pi[1]);
+ d3_svg_lineBasisBezier(path, px, py);
+ }
+ return path.join("");
+ }
+ function d3_svg_lineBundle(points, tension) {
+ var n = points.length - 1;
+ if (n) {
+ var x0 = points[0][0], y0 = points[0][1], dx = points[n][0] - x0, dy = points[n][1] - y0, i = -1, p, t;
+ while (++i <= n) {
+ p = points[i];
+ t = i / n;
+ p[0] = tension * p[0] + (1 - tension) * (x0 + t * dx);
+ p[1] = tension * p[1] + (1 - tension) * (y0 + t * dy);
+ }
+ }
+ return d3_svg_lineBasis(points);
+ }
+ function d3_svg_lineDot4(a, b) {
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
+ }
+ var d3_svg_lineBasisBezier1 = [ 0, 2 / 3, 1 / 3, 0 ], d3_svg_lineBasisBezier2 = [ 0, 1 / 3, 2 / 3, 0 ], d3_svg_lineBasisBezier3 = [ 0, 1 / 6, 2 / 3, 1 / 6 ];
+ function d3_svg_lineBasisBezier(path, x, y) {
+ path.push("C", d3_svg_lineDot4(d3_svg_lineBasisBezier1, x), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier1, y), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier2, x), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier2, y), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, x), ",", d3_svg_lineDot4(d3_svg_lineBasisBezier3, y));
+ }
+ function d3_svg_lineSlope(p0, p1) {
+ return (p1[1] - p0[1]) / (p1[0] - p0[0]);
+ }
+ function d3_svg_lineFiniteDifferences(points) {
+ var i = 0, j = points.length - 1, m = [], p0 = points[0], p1 = points[1], d = m[0] = d3_svg_lineSlope(p0, p1);
+ while (++i < j) {
+ m[i] = (d + (d = d3_svg_lineSlope(p0 = p1, p1 = points[i + 1]))) / 2;
+ }
+ m[i] = d;
+ return m;
+ }
+ function d3_svg_lineMonotoneTangents(points) {
+ var tangents = [], d, a, b, s, m = d3_svg_lineFiniteDifferences(points), i = -1, j = points.length - 1;
+ while (++i < j) {
+ d = d3_svg_lineSlope(points[i], points[i + 1]);
+ if (abs(d) < ε) {
+ m[i] = m[i + 1] = 0;
+ } else {
+ a = m[i] / d;
+ b = m[i + 1] / d;
+ s = a * a + b * b;
+ if (s > 9) {
+ s = d * 3 / Math.sqrt(s);
+ m[i] = s * a;
+ m[i + 1] = s * b;
+ }
+ }
+ }
+ i = -1;
+ while (++i <= j) {
+ s = (points[Math.min(j, i + 1)][0] - points[Math.max(0, i - 1)][0]) / (6 * (1 + m[i] * m[i]));
+ tangents.push([ s || 0, m[i] * s || 0 ]);
+ }
+ return tangents;
+ }
+ function d3_svg_lineMonotone(points) {
+ return points.length < 3 ? d3_svg_lineLinear(points) : points[0] + d3_svg_lineHermite(points, d3_svg_lineMonotoneTangents(points));
+ }
+ d3.svg.line.radial = function() {
+ var line = d3_svg_line(d3_svg_lineRadial);
+ line.radius = line.x, delete line.x;
+ line.angle = line.y, delete line.y;
+ return line;
+ };
+ function d3_svg_lineRadial(points) {
+ var point, i = -1, n = points.length, r, a;
+ while (++i < n) {
+ point = points[i];
+ r = point[0];
+ a = point[1] + d3_svg_arcOffset;
+ point[0] = r * Math.cos(a);
+ point[1] = r * Math.sin(a);
+ }
+ return points;
+ }
+ function d3_svg_area(projection) {
+ var x0 = d3_geom_pointX, x1 = d3_geom_pointX, y0 = 0, y1 = d3_geom_pointY, defined = d3_true, interpolate = d3_svg_lineLinear, interpolateKey = interpolate.key, interpolateReverse = interpolate, L = "L", tension = .7;
+ function area(data) {
+ var segments = [], points0 = [], points1 = [], i = -1, n = data.length, d, fx0 = d3_functor(x0), fy0 = d3_functor(y0), fx1 = x0 === x1 ? function() {
+ return x;
+ } : d3_functor(x1), fy1 = y0 === y1 ? function() {
+ return y;
+ } : d3_functor(y1), x, y;
+ function segment() {
+ segments.push("M", interpolate(projection(points1), tension), L, interpolateReverse(projection(points0.reverse()), tension), "Z");
+ }
+ while (++i < n) {
+ if (defined.call(this, d = data[i], i)) {
+ points0.push([ x = +fx0.call(this, d, i), y = +fy0.call(this, d, i) ]);
+ points1.push([ +fx1.call(this, d, i), +fy1.call(this, d, i) ]);
+ } else if (points0.length) {
+ segment();
+ points0 = [];
+ points1 = [];
+ }
+ }
+ if (points0.length) segment();
+ return segments.length ? segments.join("") : null;
+ }
+ area.x = function(_) {
+ if (!arguments.length) return x1;
+ x0 = x1 = _;
+ return area;
+ };
+ area.x0 = function(_) {
+ if (!arguments.length) return x0;
+ x0 = _;
+ return area;
+ };
+ area.x1 = function(_) {
+ if (!arguments.length) return x1;
+ x1 = _;
+ return area;
+ };
+ area.y = function(_) {
+ if (!arguments.length) return y1;
+ y0 = y1 = _;
+ return area;
+ };
+ area.y0 = function(_) {
+ if (!arguments.length) return y0;
+ y0 = _;
+ return area;
+ };
+ area.y1 = function(_) {
+ if (!arguments.length) return y1;
+ y1 = _;
+ return area;
+ };
+ area.defined = function(_) {
+ if (!arguments.length) return defined;
+ defined = _;
+ return area;
+ };
+ area.interpolate = function(_) {
+ if (!arguments.length) return interpolateKey;
+ if (typeof _ === "function") interpolateKey = interpolate = _; else interpolateKey = (interpolate = d3_svg_lineInterpolators.get(_) || d3_svg_lineLinear).key;
+ interpolateReverse = interpolate.reverse || interpolate;
+ L = interpolate.closed ? "M" : "L";
+ return area;
+ };
+ area.tension = function(_) {
+ if (!arguments.length) return tension;
+ tension = _;
+ return area;
+ };
+ return area;
+ }
+ d3_svg_lineStepBefore.reverse = d3_svg_lineStepAfter;
+ d3_svg_lineStepAfter.reverse = d3_svg_lineStepBefore;
+ d3.svg.area = function() {
+ return d3_svg_area(d3_identity);
+ };
+ d3.svg.area.radial = function() {
+ var area = d3_svg_area(d3_svg_lineRadial);
+ area.radius = area.x, delete area.x;
+ area.innerRadius = area.x0, delete area.x0;
+ area.outerRadius = area.x1, delete area.x1;
+ area.angle = area.y, delete area.y;
+ area.startAngle = area.y0, delete area.y0;
+ area.endAngle = area.y1, delete area.y1;
+ return area;
+ };
+ d3.svg.chord = function() {
+ var source = d3_source, target = d3_target, radius = d3_svg_chordRadius, startAngle = d3_svg_arcStartAngle, endAngle = d3_svg_arcEndAngle;
+ function chord(d, i) {
+ var s = subgroup(this, source, d, i), t = subgroup(this, target, d, i);
+ return "M" + s.p0 + arc(s.r, s.p1, s.a1 - s.a0) + (equals(s, t) ? curve(s.r, s.p1, s.r, s.p0) : curve(s.r, s.p1, t.r, t.p0) + arc(t.r, t.p1, t.a1 - t.a0) + curve(t.r, t.p1, s.r, s.p0)) + "Z";
+ }
+ function subgroup(self, f, d, i) {
+ var subgroup = f.call(self, d, i), r = radius.call(self, subgroup, i), a0 = startAngle.call(self, subgroup, i) + d3_svg_arcOffset, a1 = endAngle.call(self, subgroup, i) + d3_svg_arcOffset;
+ return {
+ r: r,
+ a0: a0,
+ a1: a1,
+ p0: [ r * Math.cos(a0), r * Math.sin(a0) ],
+ p1: [ r * Math.cos(a1), r * Math.sin(a1) ]
+ };
+ }
+ function equals(a, b) {
+ return a.a0 == b.a0 && a.a1 == b.a1;
+ }
+ function arc(r, p, a) {
+ return "A" + r + "," + r + " 0 " + +(a > π) + ",1 " + p;
+ }
+ function curve(r0, p0, r1, p1) {
+ return "Q 0,0 " + p1;
+ }
+ chord.radius = function(v) {
+ if (!arguments.length) return radius;
+ radius = d3_functor(v);
+ return chord;
+ };
+ chord.source = function(v) {
+ if (!arguments.length) return source;
+ source = d3_functor(v);
+ return chord;
+ };
+ chord.target = function(v) {
+ if (!arguments.length) return target;
+ target = d3_functor(v);
+ return chord;
+ };
+ chord.startAngle = function(v) {
+ if (!arguments.length) return startAngle;
+ startAngle = d3_functor(v);
+ return chord;
+ };
+ chord.endAngle = function(v) {
+ if (!arguments.length) return endAngle;
+ endAngle = d3_functor(v);
+ return chord;
+ };
+ return chord;
+ };
+ function d3_svg_chordRadius(d) {
+ return d.radius;
+ }
+ d3.svg.diagonal = function() {
+ var source = d3_source, target = d3_target, projection = d3_svg_diagonalProjection;
+ function diagonal(d, i) {
+ var p0 = source.call(this, d, i), p3 = target.call(this, d, i), m = (p0.y + p3.y) / 2, p = [ p0, {
+ x: p0.x,
+ y: m
+ }, {
+ x: p3.x,
+ y: m
+ }, p3 ];
+ p = p.map(projection);
+ return "M" + p[0] + "C" + p[1] + " " + p[2] + " " + p[3];
+ }
+ diagonal.source = function(x) {
+ if (!arguments.length) return source;
+ source = d3_functor(x);
+ return diagonal;
+ };
+ diagonal.target = function(x) {
+ if (!arguments.length) return target;
+ target = d3_functor(x);
+ return diagonal;
+ };
+ diagonal.projection = function(x) {
+ if (!arguments.length) return projection;
+ projection = x;
+ return diagonal;
+ };
+ return diagonal;
+ };
+ function d3_svg_diagonalProjection(d) {
+ return [ d.x, d.y ];
+ }
+ d3.svg.diagonal.radial = function() {
+ var diagonal = d3.svg.diagonal(), projection = d3_svg_diagonalProjection, projection_ = diagonal.projection;
+ diagonal.projection = function(x) {
+ return arguments.length ? projection_(d3_svg_diagonalRadialProjection(projection = x)) : projection;
+ };
+ return diagonal;
+ };
+ function d3_svg_diagonalRadialProjection(projection) {
+ return function() {
+ var d = projection.apply(this, arguments), r = d[0], a = d[1] + d3_svg_arcOffset;
+ return [ r * Math.cos(a), r * Math.sin(a) ];
+ };
+ }
+ d3.svg.symbol = function() {
+ var type = d3_svg_symbolType, size = d3_svg_symbolSize;
+ function symbol(d, i) {
+ return (d3_svg_symbols.get(type.call(this, d, i)) || d3_svg_symbolCircle)(size.call(this, d, i));
+ }
+ symbol.type = function(x) {
+ if (!arguments.length) return type;
+ type = d3_functor(x);
+ return symbol;
+ };
+ symbol.size = function(x) {
+ if (!arguments.length) return size;
+ size = d3_functor(x);
+ return symbol;
+ };
+ return symbol;
+ };
+ function d3_svg_symbolSize() {
+ return 64;
+ }
+ function d3_svg_symbolType() {
+ return "circle";
+ }
+ function d3_svg_symbolCircle(size) {
+ var r = Math.sqrt(size / π);
+ return "M0," + r + "A" + r + "," + r + " 0 1,1 0," + -r + "A" + r + "," + r + " 0 1,1 0," + r + "Z";
+ }
+ var d3_svg_symbols = d3.map({
+ circle: d3_svg_symbolCircle,
+ cross: function(size) {
+ var r = Math.sqrt(size / 5) / 2;
+ return "M" + -3 * r + "," + -r + "H" + -r + "V" + -3 * r + "H" + r + "V" + -r + "H" + 3 * r + "V" + r + "H" + r + "V" + 3 * r + "H" + -r + "V" + r + "H" + -3 * r + "Z";
+ },
+ diamond: function(size) {
+ var ry = Math.sqrt(size / (2 * d3_svg_symbolTan30)), rx = ry * d3_svg_symbolTan30;
+ return "M0," + -ry + "L" + rx + ",0" + " 0," + ry + " " + -rx + ",0" + "Z";
+ },
+ square: function(size) {
+ var r = Math.sqrt(size) / 2;
+ return "M" + -r + "," + -r + "L" + r + "," + -r + " " + r + "," + r + " " + -r + "," + r + "Z";
+ },
+ "triangle-down": function(size) {
+ var rx = Math.sqrt(size / d3_svg_symbolSqrt3), ry = rx * d3_svg_symbolSqrt3 / 2;
+ return "M0," + ry + "L" + rx + "," + -ry + " " + -rx + "," + -ry + "Z";
+ },
+ "triangle-up": function(size) {
+ var rx = Math.sqrt(size / d3_svg_symbolSqrt3), ry = rx * d3_svg_symbolSqrt3 / 2;
+ return "M0," + -ry + "L" + rx + "," + ry + " " + -rx + "," + ry + "Z";
+ }
+ });
+ d3.svg.symbolTypes = d3_svg_symbols.keys();
+ var d3_svg_symbolSqrt3 = Math.sqrt(3), d3_svg_symbolTan30 = Math.tan(30 * d3_radians);
+ function d3_transition(groups, id) {
+ d3_subclass(groups, d3_transitionPrototype);
+ groups.id = id;
+ return groups;
+ }
+ var d3_transitionPrototype = [], d3_transitionId = 0, d3_transitionInheritId, d3_transitionInherit;
+ d3_transitionPrototype.call = d3_selectionPrototype.call;
+ d3_transitionPrototype.empty = d3_selectionPrototype.empty;
+ d3_transitionPrototype.node = d3_selectionPrototype.node;
+ d3_transitionPrototype.size = d3_selectionPrototype.size;
+ d3.transition = function(selection) {
+ return arguments.length ? d3_transitionInheritId ? selection.transition() : selection : d3_selectionRoot.transition();
+ };
+ d3.transition.prototype = d3_transitionPrototype;
+ d3_transitionPrototype.select = function(selector) {
+ var id = this.id, subgroups = [], subgroup, subnode, node;
+ selector = d3_selection_selector(selector);
+ for (var j = -1, m = this.length; ++j < m; ) {
+ subgroups.push(subgroup = []);
+ for (var group = this[j], i = -1, n = group.length; ++i < n; ) {
+ if ((node = group[i]) && (subnode = selector.call(node, node.__data__, i, j))) {
+ if ("__data__" in node) subnode.__data__ = node.__data__;
+ d3_transitionNode(subnode, i, id, node.__transition__[id]);
+ subgroup.push(subnode);
+ } else {
+ subgroup.push(null);
+ }
+ }
+ }
+ return d3_transition(subgroups, id);
+ };
+ d3_transitionPrototype.selectAll = function(selector) {
+ var id = this.id, subgroups = [], subgroup, subnodes, node, subnode, transition;
+ selector = d3_selection_selectorAll(selector);
+ for (var j = -1, m = this.length; ++j < m; ) {
+ for (var group = this[j], i = -1, n = group.length; ++i < n; ) {
+ if (node = group[i]) {
+ transition = node.__transition__[id];
+ subnodes = selector.call(node, node.__data__, i, j);
+ subgroups.push(subgroup = []);
+ for (var k = -1, o = subnodes.length; ++k < o; ) {
+ if (subnode = subnodes[k]) d3_transitionNode(subnode, k, id, transition);
+ subgroup.push(subnode);
+ }
+ }
+ }
+ }
+ return d3_transition(subgroups, id);
+ };
+ d3_transitionPrototype.filter = function(filter) {
+ var subgroups = [], subgroup, group, node;
+ if (typeof filter !== "function") filter = d3_selection_filter(filter);
+ for (var j = 0, m = this.length; j < m; j++) {
+ subgroups.push(subgroup = []);
+ for (var group = this[j], i = 0, n = group.length; i < n; i++) {
+ if ((node = group[i]) && filter.call(node, node.__data__, i, j)) {
+ subgroup.push(node);
+ }
+ }
+ }
+ return d3_transition(subgroups, this.id);
+ };
+ d3_transitionPrototype.tween = function(name, tween) {
+ var id = this.id;
+ if (arguments.length < 2) return this.node().__transition__[id].tween.get(name);
+ return d3_selection_each(this, tween == null ? function(node) {
+ node.__transition__[id].tween.remove(name);
+ } : function(node) {
+ node.__transition__[id].tween.set(name, tween);
+ });
+ };
+ function d3_transition_tween(groups, name, value, tween) {
+ var id = groups.id;
+ return d3_selection_each(groups, typeof value === "function" ? function(node, i, j) {
+ node.__transition__[id].tween.set(name, tween(value.call(node, node.__data__, i, j)));
+ } : (value = tween(value), function(node) {
+ node.__transition__[id].tween.set(name, value);
+ }));
+ }
+ d3_transitionPrototype.attr = function(nameNS, value) {
+ if (arguments.length < 2) {
+ for (value in nameNS) this.attr(value, nameNS[value]);
+ return this;
+ }
+ var interpolate = nameNS == "transform" ? d3_interpolateTransform : d3_interpolate, name = d3.ns.qualify(nameNS);
+ function attrNull() {
+ this.removeAttribute(name);
+ }
+ function attrNullNS() {
+ this.removeAttributeNS(name.space, name.local);
+ }
+ function attrTween(b) {
+ return b == null ? attrNull : (b += "", function() {
+ var a = this.getAttribute(name), i;
+ return a !== b && (i = interpolate(a, b), function(t) {
+ this.setAttribute(name, i(t));
+ });
+ });
+ }
+ function attrTweenNS(b) {
+ return b == null ? attrNullNS : (b += "", function() {
+ var a = this.getAttributeNS(name.space, name.local), i;
+ return a !== b && (i = interpolate(a, b), function(t) {
+ this.setAttributeNS(name.space, name.local, i(t));
+ });
+ });
+ }
+ return d3_transition_tween(this, "attr." + nameNS, value, name.local ? attrTweenNS : attrTween);
+ };
+ d3_transitionPrototype.attrTween = function(nameNS, tween) {
+ var name = d3.ns.qualify(nameNS);
+ function attrTween(d, i) {
+ var f = tween.call(this, d, i, this.getAttribute(name));
+ return f && function(t) {
+ this.setAttribute(name, f(t));
+ };
+ }
+ function attrTweenNS(d, i) {
+ var f = tween.call(this, d, i, this.getAttributeNS(name.space, name.local));
+ return f && function(t) {
+ this.setAttributeNS(name.space, name.local, f(t));
+ };
+ }
+ return this.tween("attr." + nameNS, name.local ? attrTweenNS : attrTween);
+ };
+ d3_transitionPrototype.style = function(name, value, priority) {
+ var n = arguments.length;
+ if (n < 3) {
+ if (typeof name !== "string") {
+ if (n < 2) value = "";
+ for (priority in name) this.style(priority, name[priority], value);
+ return this;
+ }
+ priority = "";
+ }
+ function styleNull() {
+ this.style.removeProperty(name);
+ }
+ function styleString(b) {
+ return b == null ? styleNull : (b += "", function() {
+ var a = d3_window.getComputedStyle(this, null).getPropertyValue(name), i;
+ return a !== b && (i = d3_interpolate(a, b), function(t) {
+ this.style.setProperty(name, i(t), priority);
+ });
+ });
+ }
+ return d3_transition_tween(this, "style." + name, value, styleString);
+ };
+ d3_transitionPrototype.styleTween = function(name, tween, priority) {
+ if (arguments.length < 3) priority = "";
+ function styleTween(d, i) {
+ var f = tween.call(this, d, i, d3_window.getComputedStyle(this, null).getPropertyValue(name));
+ return f && function(t) {
+ this.style.setProperty(name, f(t), priority);
+ };
+ }
+ return this.tween("style." + name, styleTween);
+ };
+ d3_transitionPrototype.text = function(value) {
+ return d3_transition_tween(this, "text", value, d3_transition_text);
+ };
+ function d3_transition_text(b) {
+ if (b == null) b = "";
+ return function() {
+ this.textContent = b;
+ };
+ }
+ d3_transitionPrototype.remove = function() {
+ return this.each("end.transition", function() {
+ var p;
+ if (this.__transition__.count < 2 && (p = this.parentNode)) p.removeChild(this);
+ });
+ };
+ d3_transitionPrototype.ease = function(value) {
+ var id = this.id;
+ if (arguments.length < 1) return this.node().__transition__[id].ease;
+ if (typeof value !== "function") value = d3.ease.apply(d3, arguments);
+ return d3_selection_each(this, function(node) {
+ node.__transition__[id].ease = value;
+ });
+ };
+ d3_transitionPrototype.delay = function(value) {
+ var id = this.id;
+ return d3_selection_each(this, typeof value === "function" ? function(node, i, j) {
+ node.__transition__[id].delay = +value.call(node, node.__data__, i, j);
+ } : (value = +value, function(node) {
+ node.__transition__[id].delay = value;
+ }));
+ };
+ d3_transitionPrototype.duration = function(value) {
+ var id = this.id;
+ return d3_selection_each(this, typeof value === "function" ? function(node, i, j) {
+ node.__transition__[id].duration = Math.max(1, value.call(node, node.__data__, i, j));
+ } : (value = Math.max(1, value), function(node) {
+ node.__transition__[id].duration = value;
+ }));
+ };
+ d3_transitionPrototype.each = function(type, listener) {
+ var id = this.id;
+ if (arguments.length < 2) {
+ var inherit = d3_transitionInherit, inheritId = d3_transitionInheritId;
+ d3_transitionInheritId = id;
+ d3_selection_each(this, function(node, i, j) {
+ d3_transitionInherit = node.__transition__[id];
+ type.call(node, node.__data__, i, j);
+ });
+ d3_transitionInherit = inherit;
+ d3_transitionInheritId = inheritId;
+ } else {
+ d3_selection_each(this, function(node) {
+ var transition = node.__transition__[id];
+ (transition.event || (transition.event = d3.dispatch("start", "end"))).on(type, listener);
+ });
+ }
+ return this;
+ };
+ d3_transitionPrototype.transition = function() {
+ var id0 = this.id, id1 = ++d3_transitionId, subgroups = [], subgroup, group, node, transition;
+ for (var j = 0, m = this.length; j < m; j++) {
+ subgroups.push(subgroup = []);
+ for (var group = this[j], i = 0, n = group.length; i < n; i++) {
+ if (node = group[i]) {
+ transition = Object.create(node.__transition__[id0]);
+ transition.delay += transition.duration;
+ d3_transitionNode(node, i, id1, transition);
+ }
+ subgroup.push(node);
+ }
+ }
+ return d3_transition(subgroups, id1);
+ };
+ function d3_transitionNode(node, i, id, inherit) {
+ var lock = node.__transition__ || (node.__transition__ = {
+ active: 0,
+ count: 0
+ }), transition = lock[id];
+ if (!transition) {
+ var time = inherit.time;
+ transition = lock[id] = {
+ tween: new d3_Map(),
+ time: time,
+ ease: inherit.ease,
+ delay: inherit.delay,
+ duration: inherit.duration
+ };
+ ++lock.count;
+ d3.timer(function(elapsed) {
+ var d = node.__data__, ease = transition.ease, delay = transition.delay, duration = transition.duration, timer = d3_timer_active, tweened = [];
+ timer.t = delay + time;
+ if (delay <= elapsed) return start(elapsed - delay);
+ timer.c = start;
+ function start(elapsed) {
+ if (lock.active > id) return stop();
+ lock.active = id;
+ transition.event && transition.event.start.call(node, d, i);
+ transition.tween.forEach(function(key, value) {
+ if (value = value.call(node, d, i)) {
+ tweened.push(value);
+ }
+ });
+ d3.timer(function() {
+ timer.c = tick(elapsed || 1) ? d3_true : tick;
+ return 1;
+ }, 0, time);
+ }
+ function tick(elapsed) {
+ if (lock.active !== id) return stop();
+ var t = elapsed / duration, e = ease(t), n = tweened.length;
+ while (n > 0) {
+ tweened[--n].call(node, e);
+ }
+ if (t >= 1) {
+ transition.event && transition.event.end.call(node, d, i);
+ return stop();
+ }
+ }
+ function stop() {
+ if (--lock.count) delete lock[id]; else delete node.__transition__;
+ return 1;
+ }
+ }, 0, time);
+ }
+ }
+ d3.svg.axis = function() {
+ var scale = d3.scale.linear(), orient = d3_svg_axisDefaultOrient, innerTickSize = 6, outerTickSize = 6, tickPadding = 3, tickArguments_ = [ 10 ], tickValues = null, tickFormat_;
+ function axis(g) {
+ g.each(function() {
+ var g = d3.select(this);
+ var scale0 = this.__chart__ || scale, scale1 = this.__chart__ = scale.copy();
+ var ticks = tickValues == null ? scale1.ticks ? scale1.ticks.apply(scale1, tickArguments_) : scale1.domain() : tickValues, tickFormat = tickFormat_ == null ? scale1.tickFormat ? scale1.tickFormat.apply(scale1, tickArguments_) : d3_identity : tickFormat_, tick = g.selectAll(".tick").data(ticks, scale1), tickEnter = tick.enter().insert("g", ".domain").attr("class", "tick").style("opacity", ε), tickExit = d3.transition(tick.exit()).style("opacity", ε).remove(), tickUpdate = d3.transition(tick).style("opacity", 1), tickTransform;
+ var range = d3_scaleRange(scale1), path = g.selectAll(".domain").data([ 0 ]), pathUpdate = (path.enter().append("path").attr("class", "domain"),
+ d3.transition(path));
+ tickEnter.append("line");
+ tickEnter.append("text");
+ var lineEnter = tickEnter.select("line"), lineUpdate = tickUpdate.select("line"), text = tick.select("text").text(tickFormat), textEnter = tickEnter.select("text"), textUpdate = tickUpdate.select("text");
+ switch (orient) {
+ case "bottom":
+ {
+ tickTransform = d3_svg_axisX;
+ lineEnter.attr("y2", innerTickSize);
+ textEnter.attr("y", Math.max(innerTickSize, 0) + tickPadding);
+ lineUpdate.attr("x2", 0).attr("y2", innerTickSize);
+ textUpdate.attr("x", 0).attr("y", Math.max(innerTickSize, 0) + tickPadding);
+ text.attr("dy", ".71em").style("text-anchor", "middle");
+ pathUpdate.attr("d", "M" + range[0] + "," + outerTickSize + "V0H" + range[1] + "V" + outerTickSize);
+ break;
+ }
+
+ case "top":
+ {
+ tickTransform = d3_svg_axisX;
+ lineEnter.attr("y2", -innerTickSize);
+ textEnter.attr("y", -(Math.max(innerTickSize, 0) + tickPadding));
+ lineUpdate.attr("x2", 0).attr("y2", -innerTickSize);
+ textUpdate.attr("x", 0).attr("y", -(Math.max(innerTickSize, 0) + tickPadding));
+ text.attr("dy", "0em").style("text-anchor", "middle");
+ pathUpdate.attr("d", "M" + range[0] + "," + -outerTickSize + "V0H" + range[1] + "V" + -outerTickSize);
+ break;
+ }
+
+ case "left":
+ {
+ tickTransform = d3_svg_axisY;
+ lineEnter.attr("x2", -innerTickSize);
+ textEnter.attr("x", -(Math.max(innerTickSize, 0) + tickPadding));
+ lineUpdate.attr("x2", -innerTickSize).attr("y2", 0);
+ textUpdate.attr("x", -(Math.max(innerTickSize, 0) + tickPadding)).attr("y", 0);
+ text.attr("dy", ".32em").style("text-anchor", "end");
+ pathUpdate.attr("d", "M" + -outerTickSize + "," + range[0] + "H0V" + range[1] + "H" + -outerTickSize);
+ break;
+ }
+
+ case "right":
+ {
+ tickTransform = d3_svg_axisY;
+ lineEnter.attr("x2", innerTickSize);
+ textEnter.attr("x", Math.max(innerTickSize, 0) + tickPadding);
+ lineUpdate.attr("x2", innerTickSize).attr("y2", 0);
+ textUpdate.attr("x", Math.max(innerTickSize, 0) + tickPadding).attr("y", 0);
+ text.attr("dy", ".32em").style("text-anchor", "start");
+ pathUpdate.attr("d", "M" + outerTickSize + "," + range[0] + "H0V" + range[1] + "H" + outerTickSize);
+ break;
+ }
+ }
+ if (scale1.rangeBand) {
+ var x = scale1, dx = x.rangeBand() / 2;
+ scale0 = scale1 = function(d) {
+ return x(d) + dx;
+ };
+ } else if (scale0.rangeBand) {
+ scale0 = scale1;
+ } else {
+ tickExit.call(tickTransform, scale1);
+ }
+ tickEnter.call(tickTransform, scale0);
+ tickUpdate.call(tickTransform, scale1);
+ });
+ }
+ axis.scale = function(x) {
+ if (!arguments.length) return scale;
+ scale = x;
+ return axis;
+ };
+ axis.orient = function(x) {
+ if (!arguments.length) return orient;
+ orient = x in d3_svg_axisOrients ? x + "" : d3_svg_axisDefaultOrient;
+ return axis;
+ };
+ axis.ticks = function() {
+ if (!arguments.length) return tickArguments_;
+ tickArguments_ = arguments;
+ return axis;
+ };
+ axis.tickValues = function(x) {
+ if (!arguments.length) return tickValues;
+ tickValues = x;
+ return axis;
+ };
+ axis.tickFormat = function(x) {
+ if (!arguments.length) return tickFormat_;
+ tickFormat_ = x;
+ return axis;
+ };
+ axis.tickSize = function(x) {
+ var n = arguments.length;
+ if (!n) return innerTickSize;
+ innerTickSize = +x;
+ outerTickSize = +arguments[n - 1];
+ return axis;
+ };
+ axis.innerTickSize = function(x) {
+ if (!arguments.length) return innerTickSize;
+ innerTickSize = +x;
+ return axis;
+ };
+ axis.outerTickSize = function(x) {
+ if (!arguments.length) return outerTickSize;
+ outerTickSize = +x;
+ return axis;
+ };
+ axis.tickPadding = function(x) {
+ if (!arguments.length) return tickPadding;
+ tickPadding = +x;
+ return axis;
+ };
+ axis.tickSubdivide = function() {
+ return arguments.length && axis;
+ };
+ return axis;
+ };
+ var d3_svg_axisDefaultOrient = "bottom", d3_svg_axisOrients = {
+ top: 1,
+ right: 1,
+ bottom: 1,
+ left: 1
+ };
+ function d3_svg_axisX(selection, x) {
+ selection.attr("transform", function(d) {
+ return "translate(" + x(d) + ",0)";
+ });
+ }
+ function d3_svg_axisY(selection, y) {
+ selection.attr("transform", function(d) {
+ return "translate(0," + y(d) + ")";
+ });
+ }
+ d3.svg.brush = function() {
+ var event = d3_eventDispatch(brush, "brushstart", "brush", "brushend"), x = null, y = null, xExtent = [ 0, 0 ], yExtent = [ 0, 0 ], xExtentDomain, yExtentDomain, xClamp = true, yClamp = true, resizes = d3_svg_brushResizes[0];
+ function brush(g) {
+ g.each(function() {
+ var g = d3.select(this).style("pointer-events", "all").style("-webkit-tap-highlight-color", "rgba(0,0,0,0)").on("mousedown.brush", brushstart).on("touchstart.brush", brushstart);
+ var background = g.selectAll(".background").data([ 0 ]);
+ background.enter().append("rect").attr("class", "background").style("visibility", "hidden").style("cursor", "crosshair");
+ g.selectAll(".extent").data([ 0 ]).enter().append("rect").attr("class", "extent").style("cursor", "move");
+ var resize = g.selectAll(".resize").data(resizes, d3_identity);
+ resize.exit().remove();
+ resize.enter().append("g").attr("class", function(d) {
+ return "resize " + d;
+ }).style("cursor", function(d) {
+ return d3_svg_brushCursor[d];
+ }).append("rect").attr("x", function(d) {
+ return /[ew]$/.test(d) ? -3 : null;
+ }).attr("y", function(d) {
+ return /^[ns]/.test(d) ? -3 : null;
+ }).attr("width", 6).attr("height", 6).style("visibility", "hidden");
+ resize.style("display", brush.empty() ? "none" : null);
+ var gUpdate = d3.transition(g), backgroundUpdate = d3.transition(background), range;
+ if (x) {
+ range = d3_scaleRange(x);
+ backgroundUpdate.attr("x", range[0]).attr("width", range[1] - range[0]);
+ redrawX(gUpdate);
+ }
+ if (y) {
+ range = d3_scaleRange(y);
+ backgroundUpdate.attr("y", range[0]).attr("height", range[1] - range[0]);
+ redrawY(gUpdate);
+ }
+ redraw(gUpdate);
+ });
+ }
+ brush.event = function(g) {
+ g.each(function() {
+ var event_ = event.of(this, arguments), extent1 = {
+ x: xExtent,
+ y: yExtent,
+ i: xExtentDomain,
+ j: yExtentDomain
+ }, extent0 = this.__chart__ || extent1;
+ this.__chart__ = extent1;
+ if (d3_transitionInheritId) {
+ d3.select(this).transition().each("start.brush", function() {
+ xExtentDomain = extent0.i;
+ yExtentDomain = extent0.j;
+ xExtent = extent0.x;
+ yExtent = extent0.y;
+ event_({
+ type: "brushstart"
+ });
+ }).tween("brush:brush", function() {
+ var xi = d3_interpolateArray(xExtent, extent1.x), yi = d3_interpolateArray(yExtent, extent1.y);
+ xExtentDomain = yExtentDomain = null;
+ return function(t) {
+ xExtent = extent1.x = xi(t);
+ yExtent = extent1.y = yi(t);
+ event_({
+ type: "brush",
+ mode: "resize"
+ });
+ };
+ }).each("end.brush", function() {
+ xExtentDomain = extent1.i;
+ yExtentDomain = extent1.j;
+ event_({
+ type: "brush",
+ mode: "resize"
+ });
+ event_({
+ type: "brushend"
+ });
+ });
+ } else {
+ event_({
+ type: "brushstart"
+ });
+ event_({
+ type: "brush",
+ mode: "resize"
+ });
+ event_({
+ type: "brushend"
+ });
+ }
+ });
+ };
+ function redraw(g) {
+ g.selectAll(".resize").attr("transform", function(d) {
+ return "translate(" + xExtent[+/e$/.test(d)] + "," + yExtent[+/^s/.test(d)] + ")";
+ });
+ }
+ function redrawX(g) {
+ g.select(".extent").attr("x", xExtent[0]);
+ g.selectAll(".extent,.n>rect,.s>rect").attr("width", xExtent[1] - xExtent[0]);
+ }
+ function redrawY(g) {
+ g.select(".extent").attr("y", yExtent[0]);
+ g.selectAll(".extent,.e>rect,.w>rect").attr("height", yExtent[1] - yExtent[0]);
+ }
+ function brushstart() {
+ var target = this, eventTarget = d3.select(d3.event.target), event_ = event.of(target, arguments), g = d3.select(target), resizing = eventTarget.datum(), resizingX = !/^(n|s)$/.test(resizing) && x, resizingY = !/^(e|w)$/.test(resizing) && y, dragging = eventTarget.classed("extent"), dragRestore = d3_event_dragSuppress(), center, origin = d3.mouse(target), offset;
+ var w = d3.select(d3_window).on("keydown.brush", keydown).on("keyup.brush", keyup);
+ if (d3.event.changedTouches) {
+ w.on("touchmove.brush", brushmove).on("touchend.brush", brushend);
+ } else {
+ w.on("mousemove.brush", brushmove).on("mouseup.brush", brushend);
+ }
+ g.interrupt().selectAll("*").interrupt();
+ if (dragging) {
+ origin[0] = xExtent[0] - origin[0];
+ origin[1] = yExtent[0] - origin[1];
+ } else if (resizing) {
+ var ex = +/w$/.test(resizing), ey = +/^n/.test(resizing);
+ offset = [ xExtent[1 - ex] - origin[0], yExtent[1 - ey] - origin[1] ];
+ origin[0] = xExtent[ex];
+ origin[1] = yExtent[ey];
+ } else if (d3.event.altKey) center = origin.slice();
+ g.style("pointer-events", "none").selectAll(".resize").style("display", null);
+ d3.select("body").style("cursor", eventTarget.style("cursor"));
+ event_({
+ type: "brushstart"
+ });
+ brushmove();
+ function keydown() {
+ if (d3.event.keyCode == 32) {
+ if (!dragging) {
+ center = null;
+ origin[0] -= xExtent[1];
+ origin[1] -= yExtent[1];
+ dragging = 2;
+ }
+ d3_eventPreventDefault();
+ }
+ }
+ function keyup() {
+ if (d3.event.keyCode == 32 && dragging == 2) {
+ origin[0] += xExtent[1];
+ origin[1] += yExtent[1];
+ dragging = 0;
+ d3_eventPreventDefault();
+ }
+ }
+ function brushmove() {
+ var point = d3.mouse(target), moved = false;
+ if (offset) {
+ point[0] += offset[0];
+ point[1] += offset[1];
+ }
+ if (!dragging) {
+ if (d3.event.altKey) {
+ if (!center) center = [ (xExtent[0] + xExtent[1]) / 2, (yExtent[0] + yExtent[1]) / 2 ];
+ origin[0] = xExtent[+(point[0] < center[0])];
+ origin[1] = yExtent[+(point[1] < center[1])];
+ } else center = null;
+ }
+ if (resizingX && move1(point, x, 0)) {
+ redrawX(g);
+ moved = true;
+ }
+ if (resizingY && move1(point, y, 1)) {
+ redrawY(g);
+ moved = true;
+ }
+ if (moved) {
+ redraw(g);
+ event_({
+ type: "brush",
+ mode: dragging ? "move" : "resize"
+ });
+ }
+ }
+ function move1(point, scale, i) {
+ var range = d3_scaleRange(scale), r0 = range[0], r1 = range[1], position = origin[i], extent = i ? yExtent : xExtent, size = extent[1] - extent[0], min, max;
+ if (dragging) {
+ r0 -= position;
+ r1 -= size + position;
+ }
+ min = (i ? yClamp : xClamp) ? Math.max(r0, Math.min(r1, point[i])) : point[i];
+ if (dragging) {
+ max = (min += position) + size;
+ } else {
+ if (center) position = Math.max(r0, Math.min(r1, 2 * center[i] - min));
+ if (position < min) {
+ max = min;
+ min = position;
+ } else {
+ max = position;
+ }
+ }
+ if (extent[0] != min || extent[1] != max) {
+ if (i) yExtentDomain = null; else xExtentDomain = null;
+ extent[0] = min;
+ extent[1] = max;
+ return true;
+ }
+ }
+ function brushend() {
+ brushmove();
+ g.style("pointer-events", "all").selectAll(".resize").style("display", brush.empty() ? "none" : null);
+ d3.select("body").style("cursor", null);
+ w.on("mousemove.brush", null).on("mouseup.brush", null).on("touchmove.brush", null).on("touchend.brush", null).on("keydown.brush", null).on("keyup.brush", null);
+ dragRestore();
+ event_({
+ type: "brushend"
+ });
+ }
+ }
+ brush.x = function(z) {
+ if (!arguments.length) return x;
+ x = z;
+ resizes = d3_svg_brushResizes[!x << 1 | !y];
+ return brush;
+ };
+ brush.y = function(z) {
+ if (!arguments.length) return y;
+ y = z;
+ resizes = d3_svg_brushResizes[!x << 1 | !y];
+ return brush;
+ };
+ brush.clamp = function(z) {
+ if (!arguments.length) return x && y ? [ xClamp, yClamp ] : x ? xClamp : y ? yClamp : null;
+ if (x && y) xClamp = !!z[0], yClamp = !!z[1]; else if (x) xClamp = !!z; else if (y) yClamp = !!z;
+ return brush;
+ };
+ brush.extent = function(z) {
+ var x0, x1, y0, y1, t;
+ if (!arguments.length) {
+ if (x) {
+ if (xExtentDomain) {
+ x0 = xExtentDomain[0], x1 = xExtentDomain[1];
+ } else {
+ x0 = xExtent[0], x1 = xExtent[1];
+ if (x.invert) x0 = x.invert(x0), x1 = x.invert(x1);
+ if (x1 < x0) t = x0, x0 = x1, x1 = t;
+ }
+ }
+ if (y) {
+ if (yExtentDomain) {
+ y0 = yExtentDomain[0], y1 = yExtentDomain[1];
+ } else {
+ y0 = yExtent[0], y1 = yExtent[1];
+ if (y.invert) y0 = y.invert(y0), y1 = y.invert(y1);
+ if (y1 < y0) t = y0, y0 = y1, y1 = t;
+ }
+ }
+ return x && y ? [ [ x0, y0 ], [ x1, y1 ] ] : x ? [ x0, x1 ] : y && [ y0, y1 ];
+ }
+ if (x) {
+ x0 = z[0], x1 = z[1];
+ if (y) x0 = x0[0], x1 = x1[0];
+ xExtentDomain = [ x0, x1 ];
+ if (x.invert) x0 = x(x0), x1 = x(x1);
+ if (x1 < x0) t = x0, x0 = x1, x1 = t;
+ if (x0 != xExtent[0] || x1 != xExtent[1]) xExtent = [ x0, x1 ];
+ }
+ if (y) {
+ y0 = z[0], y1 = z[1];
+ if (x) y0 = y0[1], y1 = y1[1];
+ yExtentDomain = [ y0, y1 ];
+ if (y.invert) y0 = y(y0), y1 = y(y1);
+ if (y1 < y0) t = y0, y0 = y1, y1 = t;
+ if (y0 != yExtent[0] || y1 != yExtent[1]) yExtent = [ y0, y1 ];
+ }
+ return brush;
+ };
+ brush.clear = function() {
+ if (!brush.empty()) {
+ xExtent = [ 0, 0 ], yExtent = [ 0, 0 ];
+ xExtentDomain = yExtentDomain = null;
+ }
+ return brush;
+ };
+ brush.empty = function() {
+ return !!x && xExtent[0] == xExtent[1] || !!y && yExtent[0] == yExtent[1];
+ };
+ return d3.rebind(brush, event, "on");
+ };
+ var d3_svg_brushCursor = {
+ n: "ns-resize",
+ e: "ew-resize",
+ s: "ns-resize",
+ w: "ew-resize",
+ nw: "nwse-resize",
+ ne: "nesw-resize",
+ se: "nwse-resize",
+ sw: "nesw-resize"
+ };
+ var d3_svg_brushResizes = [ [ "n", "e", "s", "w", "nw", "ne", "se", "sw" ], [ "e", "w" ], [ "n", "s" ], [] ];
+ var d3_time_format = d3_time.format = d3_locale_enUS.timeFormat;
+ var d3_time_formatUtc = d3_time_format.utc;
+ var d3_time_formatIso = d3_time_formatUtc("%Y-%m-%dT%H:%M:%S.%LZ");
+ d3_time_format.iso = Date.prototype.toISOString && +new Date("2000-01-01T00:00:00.000Z") ? d3_time_formatIsoNative : d3_time_formatIso;
+ function d3_time_formatIsoNative(date) {
+ return date.toISOString();
+ }
+ d3_time_formatIsoNative.parse = function(string) {
+ var date = new Date(string);
+ return isNaN(date) ? null : date;
+ };
+ d3_time_formatIsoNative.toString = d3_time_formatIso.toString;
+ d3_time.second = d3_time_interval(function(date) {
+ return new d3_date(Math.floor(date / 1e3) * 1e3);
+ }, function(date, offset) {
+ date.setTime(date.getTime() + Math.floor(offset) * 1e3);
+ }, function(date) {
+ return date.getSeconds();
+ });
+ d3_time.seconds = d3_time.second.range;
+ d3_time.seconds.utc = d3_time.second.utc.range;
+ d3_time.minute = d3_time_interval(function(date) {
+ return new d3_date(Math.floor(date / 6e4) * 6e4);
+ }, function(date, offset) {
+ date.setTime(date.getTime() + Math.floor(offset) * 6e4);
+ }, function(date) {
+ return date.getMinutes();
+ });
+ d3_time.minutes = d3_time.minute.range;
+ d3_time.minutes.utc = d3_time.minute.utc.range;
+ d3_time.hour = d3_time_interval(function(date) {
+ var timezone = date.getTimezoneOffset() / 60;
+ return new d3_date((Math.floor(date / 36e5 - timezone) + timezone) * 36e5);
+ }, function(date, offset) {
+ date.setTime(date.getTime() + Math.floor(offset) * 36e5);
+ }, function(date) {
+ return date.getHours();
+ });
+ d3_time.hours = d3_time.hour.range;
+ d3_time.hours.utc = d3_time.hour.utc.range;
+ d3_time.month = d3_time_interval(function(date) {
+ date = d3_time.day(date);
+ date.setDate(1);
+ return date;
+ }, function(date, offset) {
+ date.setMonth(date.getMonth() + offset);
+ }, function(date) {
+ return date.getMonth();
+ });
+ d3_time.months = d3_time.month.range;
+ d3_time.months.utc = d3_time.month.utc.range;
+ function d3_time_scale(linear, methods, format) {
+ function scale(x) {
+ return linear(x);
+ }
+ scale.invert = function(x) {
+ return d3_time_scaleDate(linear.invert(x));
+ };
+ scale.domain = function(x) {
+ if (!arguments.length) return linear.domain().map(d3_time_scaleDate);
+ linear.domain(x);
+ return scale;
+ };
+ function tickMethod(extent, count) {
+ var span = extent[1] - extent[0], target = span / count, i = d3.bisect(d3_time_scaleSteps, target);
+ return i == d3_time_scaleSteps.length ? [ methods.year, d3_scale_linearTickRange(extent.map(function(d) {
+ return d / 31536e6;
+ }), count)[2] ] : !i ? [ d3_time_scaleMilliseconds, d3_scale_linearTickRange(extent, count)[2] ] : methods[target / d3_time_scaleSteps[i - 1] < d3_time_scaleSteps[i] / target ? i - 1 : i];
+ }
+ scale.nice = function(interval, skip) {
+ var domain = scale.domain(), extent = d3_scaleExtent(domain), method = interval == null ? tickMethod(extent, 10) : typeof interval === "number" && tickMethod(extent, interval);
+ if (method) interval = method[0], skip = method[1];
+ function skipped(date) {
+ return !isNaN(date) && !interval.range(date, d3_time_scaleDate(+date + 1), skip).length;
+ }
+ return scale.domain(d3_scale_nice(domain, skip > 1 ? {
+ floor: function(date) {
+ while (skipped(date = interval.floor(date))) date = d3_time_scaleDate(date - 1);
+ return date;
+ },
+ ceil: function(date) {
+ while (skipped(date = interval.ceil(date))) date = d3_time_scaleDate(+date + 1);
+ return date;
+ }
+ } : interval));
+ };
+ scale.ticks = function(interval, skip) {
+ var extent = d3_scaleExtent(scale.domain()), method = interval == null ? tickMethod(extent, 10) : typeof interval === "number" ? tickMethod(extent, interval) : !interval.range && [ {
+ range: interval
+ }, skip ];
+ if (method) interval = method[0], skip = method[1];
+ return interval.range(extent[0], d3_time_scaleDate(+extent[1] + 1), skip < 1 ? 1 : skip);
+ };
+ scale.tickFormat = function() {
+ return format;
+ };
+ scale.copy = function() {
+ return d3_time_scale(linear.copy(), methods, format);
+ };
+ return d3_scale_linearRebind(scale, linear);
+ }
+ function d3_time_scaleDate(t) {
+ return new Date(t);
+ }
+ var d3_time_scaleSteps = [ 1e3, 5e3, 15e3, 3e4, 6e4, 3e5, 9e5, 18e5, 36e5, 108e5, 216e5, 432e5, 864e5, 1728e5, 6048e5, 2592e6, 7776e6, 31536e6 ];
+ var d3_time_scaleLocalMethods = [ [ d3_time.second, 1 ], [ d3_time.second, 5 ], [ d3_time.second, 15 ], [ d3_time.second, 30 ], [ d3_time.minute, 1 ], [ d3_time.minute, 5 ], [ d3_time.minute, 15 ], [ d3_time.minute, 30 ], [ d3_time.hour, 1 ], [ d3_time.hour, 3 ], [ d3_time.hour, 6 ], [ d3_time.hour, 12 ], [ d3_time.day, 1 ], [ d3_time.day, 2 ], [ d3_time.week, 1 ], [ d3_time.month, 1 ], [ d3_time.month, 3 ], [ d3_time.year, 1 ] ];
+ var d3_time_scaleLocalFormat = d3_time_format.multi([ [ ".%L", function(d) {
+ return d.getMilliseconds();
+ } ], [ ":%S", function(d) {
+ return d.getSeconds();
+ } ], [ "%I:%M", function(d) {
+ return d.getMinutes();
+ } ], [ "%I %p", function(d) {
+ return d.getHours();
+ } ], [ "%a %d", function(d) {
+ return d.getDay() && d.getDate() != 1;
+ } ], [ "%b %d", function(d) {
+ return d.getDate() != 1;
+ } ], [ "%B", function(d) {
+ return d.getMonth();
+ } ], [ "%Y", d3_true ] ]);
+ var d3_time_scaleMilliseconds = {
+ range: function(start, stop, step) {
+ return d3.range(+start, +stop, step).map(d3_time_scaleDate);
+ },
+ floor: d3_identity,
+ ceil: d3_identity
+ };
+ d3_time_scaleLocalMethods.year = d3_time.year;
+ d3_time.scale = function() {
+ return d3_time_scale(d3.scale.linear(), d3_time_scaleLocalMethods, d3_time_scaleLocalFormat);
+ };
+ var d3_time_scaleUtcMethods = d3_time_scaleLocalMethods.map(function(m) {
+ return [ m[0].utc, m[1] ];
+ });
+ var d3_time_scaleUtcFormat = d3_time_formatUtc.multi([ [ ".%L", function(d) {
+ return d.getUTCMilliseconds();
+ } ], [ ":%S", function(d) {
+ return d.getUTCSeconds();
+ } ], [ "%I:%M", function(d) {
+ return d.getUTCMinutes();
+ } ], [ "%I %p", function(d) {
+ return d.getUTCHours();
+ } ], [ "%a %d", function(d) {
+ return d.getUTCDay() && d.getUTCDate() != 1;
+ } ], [ "%b %d", function(d) {
+ return d.getUTCDate() != 1;
+ } ], [ "%B", function(d) {
+ return d.getUTCMonth();
+ } ], [ "%Y", d3_true ] ]);
+ d3_time_scaleUtcMethods.year = d3_time.year.utc;
+ d3_time.scale.utc = function() {
+ return d3_time_scale(d3.scale.linear(), d3_time_scaleUtcMethods, d3_time_scaleUtcFormat);
+ };
+ d3.text = d3_xhrType(function(request) {
+ return request.responseText;
+ });
+ d3.json = function(url, callback) {
+ return d3_xhr(url, "application/json", d3_json, callback);
+ };
+ function d3_json(request) {
+ return JSON.parse(request.responseText);
+ }
+ d3.html = function(url, callback) {
+ return d3_xhr(url, "text/html", d3_html, callback);
+ };
+ function d3_html(request) {
+ var range = d3_document.createRange();
+ range.selectNode(d3_document.body);
+ return range.createContextualFragment(request.responseText);
+ }
+ d3.xml = d3_xhrType(function(request) {
+ return request.responseXML;
+ });
+ if (typeof define === "function" && define.amd) {
+ define(d3);
+ } else if (typeof module === "object" && module.exports) {
+ module.exports = d3;
+ } else {
+ this.d3 = d3;
+ }
+}(); \ No newline at end of file
diff --git a/toolkit/devtools/shared/devices.js b/toolkit/devtools/shared/devices.js
new file mode 100644
index 000000000..c105ab98c
--- /dev/null
+++ b/toolkit/devtools/shared/devices.js
@@ -0,0 +1,603 @@
+/* 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 { Ci, Cc } = require("chrome");
+const { Services } = require("resource://gre/modules/Services.jsm");
+const Strings = Services.strings.createBundle("chrome://browser/locale/devtools/device.properties");
+
+/* `Devices` is a catalog of existing devices and their properties, intended
+ * for (mobile) device emulation tools and features.
+ *
+ * The properties of a device are:
+ * - name: Device brand and model(s).
+ * - width: Viewport width.
+ * - height: Viewport height.
+ * - pixelRatio: Screen pixel ratio to viewport.
+ * - userAgent: Device UserAgent string.
+ * - touch: Whether the screen is touch-enabled.
+ *
+ * To add more devices to this catalog, either patch this file, or push new
+ * device descriptions from your own code (e.g. an addon) like so:
+ *
+ * var myPhone = { name: "My Phone", ... };
+ * require("devtools/shared/devices").Devices.Others.phones.push(myPhone);
+ */
+
+let Devices = {
+ Types: ["phones", "tablets", "notebooks", "televisions", "watches"],
+
+ // Get the localized string of a device type.
+ GetString(deviceType) {
+ return Strings.GetStringFromName("device." + deviceType);
+ },
+};
+exports.Devices = Devices;
+
+
+// The `Devices.FirefoxOS` list was put together from various sources online.
+Devices.FirefoxOS = {
+ phones: [
+ {
+ name: "Firefox OS Flame",
+ width: 320,
+ height: 570,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "Alcatel One Touch Fire, Fire C",
+ width: 320,
+ height: 480,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Mobile; ALCATELOneTouch4012X; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "Alcatel Fire E",
+ width: 320,
+ height: 480,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Mobile; ALCATELOneTouch4012X; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "Geeksphone Keon",
+ width: 320,
+ height: 480,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "Geeksphone Peak, Revolution",
+ width: 360,
+ height: 640,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "Intex Cloud Fx",
+ width: 320,
+ height: 480,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "LG Fireweb",
+ width: 320,
+ height: 480,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Mobile; LG-D300; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "Spice Fire One Mi-FX1",
+ width: 320,
+ height: 480,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "Symphony GoFox F15",
+ width: 320,
+ height: 480,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "Zen Fire 105",
+ width: 320,
+ height: 480,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "ZTE Open",
+ width: 320,
+ height: 480,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Mobile; ZTEOPEN; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "ZTE Open C",
+ width: 320,
+ height: 450,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (Mobile; OPENC; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ ],
+ tablets: [
+ {
+ name: "Foxconn InFocus",
+ width: 1280,
+ height: 800,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ {
+ name: "VIA Vixen",
+ width: 1024,
+ height: 600,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ touch: true,
+ },
+ ],
+ notebooks: [
+ ],
+ televisions: [
+ {
+ name: "720p HD Television",
+ width: 1280,
+ height: 720,
+ pixelRatio: 1,
+ userAgent: "",
+ touch: false,
+ },
+ {
+ name: "1080p Full HD Television",
+ width: 1920,
+ height: 1080,
+ pixelRatio: 1,
+ userAgent: "",
+ touch: false,
+ },
+ {
+ name: "4K Ultra HD Television",
+ width: 3840,
+ height: 2160,
+ pixelRatio: 1,
+ userAgent: "",
+ touch: false,
+ },
+ ],
+ watches: [
+ {
+ name: "LG G Watch",
+ width: 280,
+ height: 280,
+ pixelRatio: 1,
+ userAgent: "",
+ touch: true,
+ },
+ {
+ name: "LG G Watch R",
+ width: 320,
+ height: 320,
+ pixelRatio: 1,
+ userAgent: "",
+ touch: true,
+ },
+ {
+ name: "Moto 360",
+ width: 320,
+ height: 290,
+ pixelRatio: 1,
+ userAgent: "",
+ touch: true,
+ },
+ {
+ name: "Samsung Gear Live",
+ width: 320,
+ height: 320,
+ pixelRatio: 1,
+ userAgent: "",
+ touch: true,
+ },
+ ],
+};
+
+// `Devices.Others` was derived from the Chromium source code:
+// - chromium/src/third_party/WebKit/Source/devtools/front_end/toolbox/OverridesUI.js
+Devices.Others = {
+ phones: [
+ {
+ name: "Apple iPhone 3GS",
+ width: 320,
+ height: 480,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_2_1 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8C148 Safari/6533.18.5",
+ touch: true,
+ },
+ {
+ name: "Apple iPhone 4",
+ width: 320,
+ height: 480,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_2_1 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8C148 Safari/6533.18.5",
+ touch: true,
+ },
+ {
+ name: "Apple iPhone 5",
+ width: 320,
+ height: 568,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X; en-us) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53",
+ touch: true,
+ },
+ {
+ name: "Apple iPhone 6",
+ width: 375,
+ height: 667,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4",
+ touch: true,
+ },
+ {
+ name: "Apple iPhone 6 Plus",
+ width: 414,
+ height: 736,
+ pixelRatio: 3,
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4",
+ touch: true,
+ },
+ {
+ name: "BlackBerry Z10",
+ width: 384,
+ height: 640,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+",
+ touch: true,
+ },
+ {
+ name: "BlackBerry Z30",
+ width: 360,
+ height: 640,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+",
+ touch: true,
+ },
+ {
+ name: "Google Nexus 4",
+ width: 384,
+ height: 640,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; Android 4.2.1; en-us; Nexus 4 Build/JOP40D) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19",
+ touch: true,
+ },
+ {
+ name: "Google Nexus 5",
+ width: 360,
+ height: 640,
+ pixelRatio: 3,
+ userAgent: "Mozilla/5.0 (Linux; Android 4.2.1; en-us; Nexus 5 Build/JOP40D) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19",
+ touch: true,
+ },
+ {
+ name: "Google Nexus S",
+ width: 320,
+ height: 533,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.3.4; en-us; Nexus S Build/GRJ22) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ touch: true,
+ },
+ {
+ name: "HTC Evo, Touch HD, Desire HD, Desire",
+ width: 320,
+ height: 533,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.2; en-us; Sprint APA9292KT Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ touch: true,
+ },
+ {
+ name: "HTC One X, EVO LTE",
+ width: 360,
+ height: 640,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; Android 4.0.3; HTC One X Build/IML74K) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19",
+ touch: true,
+ },
+ {
+ name: "HTC Sensation, Evo 3D",
+ width: 360,
+ height: 640,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 4.0.3; en-us; HTC Sensation Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30",
+ touch: true,
+ },
+ {
+ name: "LG Optimus 2X, Optimus 3D, Optimus Black",
+ width: 320,
+ height: 533,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.2; en-us; LG-P990/V08c Build/FRG83) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1 MMS/LG-Android-MMS-V1.0/1.2",
+ touch: true,
+ },
+ {
+ name: "LG Optimus G",
+ width: 384,
+ height: 640,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; Android 4.0; LG-E975 Build/IMM76L) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19",
+ touch: true,
+ },
+ {
+ name: "LG Optimus LTE, Optimus 4X HD",
+ width: 424,
+ height: 753,
+ pixelRatio: 1.7,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.3; en-us; LG-P930 Build/GRJ90) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ touch: true,
+ },
+ {
+ name: "LG Optimus One",
+ width: 213,
+ height: 320,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.2.1; en-us; LG-MS690 Build/FRG83) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ touch: true,
+ },
+ {
+ name: "Motorola Defy, Droid, Droid X, Milestone",
+ width: 320,
+ height: 569,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.0; en-us; Milestone Build/ SHOLS_U2_01.03.1) AppleWebKit/530.17 (KHTML, like Gecko) Version/4.0 Mobile Safari/530.17",
+ touch: true,
+ },
+ {
+ name: "Motorola Droid 3, Droid 4, Droid Razr, Atrix 4G, Atrix 2",
+ width: 540,
+ height: 960,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.2; en-us; Droid Build/FRG22D) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ touch: true,
+ },
+ {
+ name: "Motorola Droid Razr HD",
+ width: 720,
+ height: 1280,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.3; en-us; DROID RAZR 4G Build/6.5.1-73_DHD-11_M1-29) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ touch: true,
+ },
+ {
+ name: "Nokia C5, C6, C7, N97, N8, X7",
+ width: 360,
+ height: 640,
+ pixelRatio: 1,
+ userAgent: "NokiaN97/21.1.107 (SymbianOS/9.4; Series60/5.0 Mozilla/5.0; Profile/MIDP-2.1 Configuration/CLDC-1.1) AppleWebkit/525 (KHTML, like Gecko) BrowserNG/7.1.4",
+ touch: true,
+ },
+ {
+ name: "Nokia Lumia 7X0, Lumia 8XX, Lumia 900, N800, N810, N900",
+ width: 320,
+ height: 533,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 820)",
+ touch: true,
+ },
+ {
+ name: "Samsung Galaxy Note 3",
+ width: 360,
+ height: 640,
+ pixelRatio: 3,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30",
+ touch: true,
+ },
+ {
+ name: "Samsung Galaxy Note II",
+ width: 360,
+ height: 640,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30",
+ touch: true,
+ },
+ {
+ name: "Samsung Galaxy Note",
+ width: 400,
+ height: 640,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.3; en-us; SAMSUNG-SGH-I717 Build/GINGERBREAD) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ touch: true,
+ },
+ {
+ name: "Samsung Galaxy S III, Galaxy Nexus",
+ width: 360,
+ height: 640,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30",
+ touch: true,
+ },
+ {
+ name: "Samsung Galaxy S, S II, W",
+ width: 320,
+ height: 533,
+ pixelRatio: 1.5,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.1; en-us; GT-I9000 Build/ECLAIR) AppleWebKit/525.10+ (KHTML, like Gecko) Version/3.0.4 Mobile Safari/523.12.2",
+ touch: true,
+ },
+ {
+ name: "Samsung Galaxy S4",
+ width: 360,
+ height: 640,
+ pixelRatio: 3,
+ userAgent: "Mozilla/5.0 (Linux; Android 4.2.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.59 Mobile Safari/537.36",
+ touch: true,
+ },
+ {
+ name: "Sony Xperia S, Ion",
+ width: 360,
+ height: 640,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 4.0; en-us; LT28at Build/6.1.C.1.111) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30",
+ touch: true,
+ },
+ {
+ name: "Sony Xperia Sola, U",
+ width: 480,
+ height: 854,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.3; en-us; SonyEricssonST25i Build/6.0.B.1.564) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ touch: true,
+ },
+ {
+ name: "Sony Xperia Z, Z1",
+ width: 360,
+ height: 640,
+ pixelRatio: 3,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 4.2; en-us; SonyC6903 Build/14.1.G.1.518) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30",
+ touch: true,
+ },
+ ],
+ tablets: [
+ {
+ name: "Amazon Kindle Fire HDX 7″",
+ width: 1920,
+ height: 1200,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; U; en-us; KFTHWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true",
+ touch: true,
+ },
+ {
+ name: "Amazon Kindle Fire HDX 8.9″",
+ width: 2560,
+ height: 1600,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true",
+ touch: true,
+ },
+ {
+ name: "Amazon Kindle Fire (First Generation)",
+ width: 1024,
+ height: 600,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_3; en-us; Silk/1.0.141.16-Gen4_11004310) AppleWebkit/533.16 (KHTML, like Gecko) Version/5.0 Safari/533.16 Silk-Accelerated=true",
+ touch: true,
+ },
+ {
+ name: "Apple iPad 1 / 2 / iPad Mini",
+ width: 1024,
+ height: 768,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (iPad; CPU OS 4_3_5 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8L1 Safari/6533.18.5",
+ touch: true,
+ },
+ {
+ name: "Apple iPad 3 / 4",
+ width: 1024,
+ height: 768,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (iPad; CPU OS 7_0 like Mac OS X) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53",
+ touch: true,
+ },
+ {
+ name: "BlackBerry PlayBook",
+ width: 1024,
+ height: 600,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+",
+ touch: true,
+ },
+ {
+ name: "Google Nexus 10",
+ width: 1280,
+ height: 800,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; Android 4.3; Nexus 10 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.72 Safari/537.36",
+ touch: true,
+ },
+ {
+ name: "Google Nexus 7 2",
+ width: 960,
+ height: 600,
+ pixelRatio: 2,
+ userAgent: "Mozilla/5.0 (Linux; Android 4.3; Nexus 7 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.72 Safari/537.36",
+ touch: true,
+ },
+ {
+ name: "Google Nexus 7",
+ width: 966,
+ height: 604,
+ pixelRatio: 1.325,
+ userAgent: "Mozilla/5.0 (Linux; Android 4.3; Nexus 7 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.72 Safari/537.36",
+ touch: true,
+ },
+ {
+ name: "Motorola Xoom, Xyboard",
+ width: 1280,
+ height: 800,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/525.10 (KHTML, like Gecko) Version/3.0.4 Mobile Safari/523.12.2",
+ touch: true,
+ },
+ {
+ name: "Samsung Galaxy Tab 7.7, 8.9, 10.1",
+ width: 1280,
+ height: 800,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.2; en-us; SCH-I800 Build/FROYO) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ touch: true,
+ },
+ {
+ name: "Samsung Galaxy Tab",
+ width: 1024,
+ height: 600,
+ pixelRatio: 1,
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.2; en-us; SCH-I800 Build/FROYO) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ touch: true,
+ },
+ ],
+ notebooks: [
+ {
+ name: "Notebook with touch",
+ width: 1280,
+ height: 950,
+ pixelRatio: 1,
+ userAgent: "",
+ touch: true,
+ },
+ {
+ name: "Notebook with HiDPI screen",
+ width: 1440,
+ height: 900,
+ pixelRatio: 2,
+ userAgent: "",
+ touch: false,
+ },
+ {
+ name: "Generic notebook",
+ width: 1280,
+ height: 800,
+ pixelRatio: 1,
+ userAgent: "",
+ touch: false,
+ },
+ ],
+ televisions: [
+ ],
+ watches: [
+ ],
+};
diff --git a/toolkit/devtools/shared/doorhanger.js b/toolkit/devtools/shared/doorhanger.js
new file mode 100644
index 000000000..c2315c5f5
--- /dev/null
+++ b/toolkit/devtools/shared/doorhanger.js
@@ -0,0 +1,160 @@
+/* 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 { Ci, Cc } = require("chrome");
+const { Services } = require("resource://gre/modules/Services.jsm");
+const { DOMHelpers } = require("resource:///modules/devtools/DOMHelpers.jsm");
+const { Task } = require("resource://gre/modules/Task.jsm");
+const { Promise } = require("resource://gre/modules/Promise.jsm");
+const { setTimeout } = require("sdk/timers");
+const { getMostRecentBrowserWindow } = require("sdk/window/utils");
+
+const XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const DEV_EDITION_PROMO_URL = "chrome://browser/content/devtools/framework/dev-edition-promo.xul";
+const DEV_EDITION_PROMO_ENABLED_PREF = "devtools.devedition.promo.enabled";
+const DEV_EDITION_PROMO_SHOWN_PREF = "devtools.devedition.promo.shown";
+const DEV_EDITION_PROMO_URL_PREF = "devtools.devedition.promo.url";
+const LOCALE = Cc["@mozilla.org/chrome/chrome-registry;1"]
+ .getService(Ci.nsIXULChromeRegistry)
+ .getSelectedLocale("global");
+
+/**
+ * Only show Dev Edition promo if it's enabled (beta channel),
+ * if it has not been shown before, and it's a locale build
+ * for `en-US`
+ */
+function shouldDevEditionPromoShow () {
+ return Services.prefs.getBoolPref(DEV_EDITION_PROMO_ENABLED_PREF) &&
+ !Services.prefs.getBoolPref(DEV_EDITION_PROMO_SHOWN_PREF) &&
+ LOCALE === "en-US";
+}
+
+let TYPES = {
+ // The Developer Edition promo doorhanger, called by
+ // opening the toolbox, browser console, WebIDE, or responsive design mode
+ // in Beta releases. Only displayed once per profile.
+ deveditionpromo: {
+ predicate: shouldDevEditionPromoShow,
+ success: () => Services.prefs.setBoolPref(DEV_EDITION_PROMO_SHOWN_PREF, true),
+ action: () => {
+ let url = Services.prefs.getCharPref(DEV_EDITION_PROMO_URL_PREF);
+ getGBrowser().selectedTab = getGBrowser().addTab(url);
+ },
+ url: DEV_EDITION_PROMO_URL
+ }
+};
+
+let panelAttrs = {
+ orient: "vertical",
+ hidden: "false",
+ consumeoutsideclicks: "true",
+ noautofocus: "true",
+ align: "start",
+ role: "alert"
+};
+
+/**
+ * Helper to call a doorhanger, defined in `TYPES`, with defined conditions,
+ * success handlers and loads its own XUL in a frame. Takes an object with
+ * several properties:
+ *
+ * @param {XULWindow} window
+ * The window that should house the doorhanger.
+ * @param {String} type
+ * The type of doorhanger to be displayed is, using the `TYPES` definition.
+ * @param {String} selector
+ * The selector that the doorhanger should be appended to within `window`.
+ * Defaults to a XUL Document's `window` element.
+ */
+exports.showDoorhanger = Task.async(function *({ window, type, anchor }) {
+ let { predicate, success, url, action } = TYPES[type];
+ // Abort if predicate fails
+ if (!predicate()) {
+ return;
+ }
+
+ // Call success function to set preferences/cleanup immediately,
+ // so if triggered multiple times, only happens once (Windows/Linux)
+ success();
+
+ // Wait 200ms to prevent flickering where the popup is displayed
+ // before the underlying window (Windows 7, 64bit)
+ yield wait(200);
+
+ let document = window.document;
+
+ let panel = document.createElementNS(XULNS, "panel");
+ let frame = document.createElementNS(XULNS, "iframe");
+ let parentEl = document.querySelector("window");
+
+ frame.setAttribute("src", url);
+ let close = () => parentEl.removeChild(panel);
+
+ setDoorhangerStyle(panel, frame);
+
+ panel.appendChild(frame);
+ parentEl.appendChild(panel);
+
+ yield onFrameLoad(frame);
+
+ panel.openPopup(anchor);
+
+ let closeBtn = frame.contentDocument.querySelector("#close");
+ if (closeBtn) {
+ closeBtn.addEventListener("click", close);
+ }
+
+ let goBtn = frame.contentDocument.querySelector("#go");
+ if (goBtn) {
+ goBtn.addEventListener("click", () => {
+ if (action) {
+ action();
+ }
+ close();
+ });
+ }
+});
+
+function setDoorhangerStyle (panel, frame) {
+ Object.keys(panelAttrs).forEach(prop => panel.setAttribute(prop, panelAttrs[prop]));
+ panel.style.margin = "20px";
+ panel.style.borderRadius = "5px";
+ panel.style.border = "none";
+ panel.style.MozAppearance = "none";
+ panel.style.backgroundColor = "transparent";
+
+ frame.style.borderRadius = "5px";
+ frame.setAttribute("flex", "1");
+ frame.setAttribute("width", "450");
+ frame.setAttribute("height", "179");
+}
+
+function onFrameLoad (frame) {
+ let { resolve, promise } = Promise.defer();
+
+ if (frame.contentWindow) {
+ let domHelper = new DOMHelpers(frame.contentWindow);
+ domHelper.onceDOMReady(resolve);
+ } else {
+ let callback = () => {
+ frame.removeEventListener("DOMContentLoaded", callback);
+ resolve();
+ }
+ frame.addEventListener("DOMContentLoaded", callback);
+ }
+
+ return promise;
+}
+
+function getGBrowser () {
+ return getMostRecentBrowserWindow().gBrowser;
+}
+
+function wait (n) {
+ let { resolve, promise } = Promise.defer();
+ setTimeout(resolve, n);
+ return promise;
+}
diff --git a/toolkit/devtools/shared/frame-script-utils.js b/toolkit/devtools/shared/frame-script-utils.js
new file mode 100644
index 000000000..a2236a715
--- /dev/null
+++ b/toolkit/devtools/shared/frame-script-utils.js
@@ -0,0 +1,114 @@
+/* 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 Cu = Components.utils;
+
+const { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+devtools.lazyImporter(this, "promise", "resource://gre/modules/Promise.jsm", "Promise");
+devtools.lazyImporter(this, "Task", "resource://gre/modules/Task.jsm", "Task");
+
+addMessageListener("devtools:test:history", function ({ data }) {
+ content.history[data.direction]();
+});
+
+addMessageListener("devtools:test:navigate", function ({ data }) {
+ content.location = data.location;
+});
+
+addMessageListener("devtools:test:reload", function ({ data }) {
+ data = data || {};
+ content.location.reload(data.forceget);
+});
+
+addMessageListener("devtools:test:console", function ({ data }) {
+ let method = data.shift();
+ content.console[method].apply(content.console, data);
+});
+
+/**
+ * Performs a single XMLHttpRequest and returns a promise that resolves once
+ * the request has loaded.
+ *
+ * @param Object data
+ * { method: the request method (default: "GET"),
+ * url: the url to request (default: content.location.href),
+ * body: the request body to send (default: ""),
+ * nocache: append an unique token to the query string (default: true)
+ * }
+ *
+ * @return Promise A promise that's resolved with object
+ * { status: XMLHttpRequest.status,
+ * response: XMLHttpRequest.response }
+ *
+ */
+function promiseXHR(data) {
+ let xhr = new content.XMLHttpRequest();
+
+ let method = data.method || "GET";
+ let url = data.url || content.location.href;
+ let body = data.body || "";
+
+ if (data.nocache) {
+ url += "?devtools-cachebust=" + Math.random();
+ }
+
+ let deferred = promise.defer();
+ xhr.addEventListener("loadend", function loadend(event) {
+ xhr.removeEventListener("loadend", loadend);
+ deferred.resolve({ status: xhr.status, response: xhr.response });
+ });
+
+ xhr.open(method, url);
+ xhr.send(body);
+ return deferred.promise;
+
+}
+
+/**
+ * Performs XMLHttpRequest request(s) in the context of the page. The data
+ * parameter can be either a single object or an array of objects described below.
+ * The requests will be performed one at a time in the order they appear in the data.
+ *
+ * The objects should have following form (any of them can be omitted; defaults
+ * shown below):
+ * {
+ * method: "GET",
+ * url: content.location.href,
+ * body: "",
+ * nocache: true, // Adds a cache busting random token to the URL
+ * }
+ *
+ * The handler will respond with devtools:test:xhr message after all requests
+ * have finished. Following data will be available for each requests
+ * (in the same order as requests):
+ * {
+ * status: XMLHttpRequest.status
+ * response: XMLHttpRequest.response
+ * }
+ */
+addMessageListener("devtools:test:xhr", Task.async(function* ({ data }) {
+ let requests = Array.isArray(data) ? data : [data];
+ let responses = [];
+
+ for (let request of requests) {
+ let response = yield promiseXHR(request);
+ responses.push(response);
+ }
+
+ sendAsyncMessage("devtools:test:xhr", responses);
+}));
+
+// To eval in content, look at `evalInDebuggee` in the head.js of canvasdebugger
+// for an example.
+addMessageListener("devtools:test:eval", function ({ data }) {
+ sendAsyncMessage("devtools:test:eval:response", {
+ value: content.eval(data.script),
+ id: data.id
+ });
+});
+
+addEventListener("load", function() {
+ sendAsyncMessage("devtools:test:load");
+}, true);
diff --git a/toolkit/devtools/shared/inplace-editor.js b/toolkit/devtools/shared/inplace-editor.js
new file mode 100644
index 000000000..522da1843
--- /dev/null
+++ b/toolkit/devtools/shared/inplace-editor.js
@@ -0,0 +1,1226 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+
+/**
+ * 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/.
+ *
+ * Basic use:
+ * let spanToEdit = document.getElementById("somespan");
+ *
+ * editableField({
+ * element: spanToEdit,
+ * done: function(value, commit, direction) {
+ * if (commit) {
+ * spanToEdit.textContent = value;
+ * }
+ * },
+ * trigger: "dblclick"
+ * });
+ *
+ * See editableField() for more options.
+ */
+
+"use strict";
+
+const {Ci, Cu, Cc} = require("chrome");
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const CONTENT_TYPES = {
+ PLAIN_TEXT: 0,
+ CSS_VALUE: 1,
+ CSS_MIXED: 2,
+ CSS_PROPERTY: 3,
+};
+const MAX_POPUP_ENTRIES = 10;
+
+const FOCUS_FORWARD = Ci.nsIFocusManager.MOVEFOCUS_FORWARD;
+const FOCUS_BACKWARD = Ci.nsIFocusManager.MOVEFOCUS_BACKWARD;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/devtools/event-emitter.js");
+
+/**
+ * Mark a span editable. |editableField| will listen for the span to
+ * be focused and create an InlineEditor to handle text input.
+ * Changes will be committed when the InlineEditor's input is blurred
+ * or dropped when the user presses escape.
+ *
+ * @param {object} aOptions
+ * Options for the editable field, including:
+ * {Element} element:
+ * (required) The span to be edited on focus.
+ * {function} canEdit:
+ * Will be called before creating the inplace editor. Editor
+ * won't be created if canEdit returns false.
+ * {function} start:
+ * Will be called when the inplace editor is initialized.
+ * {function} change:
+ * Will be called when the text input changes. Will be called
+ * with the current value of the text input.
+ * {function} done:
+ * Called when input is committed or blurred. Called with
+ * current value, a boolean telling the caller whether to
+ * commit the change, and the direction of the next element to be
+ * selected. Direction may be one of nsIFocusManager.MOVEFOCUS_FORWARD,
+ * nsIFocusManager.MOVEFOCUS_BACKWARD, or null (no movement).
+ * This function is called before the editor has been torn down.
+ * {function} destroy:
+ * Called when the editor is destroyed and has been torn down.
+ * {string} advanceChars:
+ * If any characters in advanceChars are typed, focus will advance
+ * to the next element.
+ * {boolean} stopOnReturn:
+ * If true, the return key will not advance the editor to the next
+ * focusable element.
+ * {boolean} stopOnTab:
+ * If true, the tab key will not advance the editor to the next
+ * focusable element.
+ * {boolean} stopOnShiftTab:
+ * If true, shift tab will not advance the editor to the previous
+ * focusable element.
+ * {string} trigger: The DOM event that should trigger editing,
+ * defaults to "click"
+ */
+function editableField(aOptions)
+{
+ return editableItem(aOptions, function(aElement, aEvent) {
+ new InplaceEditor(aOptions, aEvent);
+ });
+}
+
+exports.editableField = editableField;
+
+/**
+ * Handle events for an element that should respond to
+ * clicks and sit in the editing tab order, and call
+ * a callback when it is activated.
+ *
+ * @param {object} aOptions
+ * The options for this editor, including:
+ * {Element} element: The DOM element.
+ * {string} trigger: The DOM event that should trigger editing,
+ * defaults to "click"
+ * @param {function} aCallback
+ * Called when the editor is activated.
+ * @return {function} function which calls aCallback
+ */
+function editableItem(aOptions, aCallback)
+{
+ let trigger = aOptions.trigger || "click"
+ let element = aOptions.element;
+ element.addEventListener(trigger, function(evt) {
+ if (evt.target.nodeName !== "a") {
+ let win = this.ownerDocument.defaultView;
+ let selection = win.getSelection();
+ if (trigger != "click" || selection.isCollapsed) {
+ aCallback(element, evt);
+ }
+ evt.stopPropagation();
+ }
+ }, false);
+
+ // If focused by means other than a click, start editing by
+ // pressing enter or space.
+ element.addEventListener("keypress", function(evt) {
+ if (evt.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN ||
+ evt.charCode === Ci.nsIDOMKeyEvent.DOM_VK_SPACE) {
+ aCallback(element);
+ }
+ }, true);
+
+ // Ugly workaround - the element is focused on mousedown but
+ // the editor is activated on click/mouseup. This leads
+ // to an ugly flash of the focus ring before showing the editor.
+ // So hide the focus ring while the mouse is down.
+ element.addEventListener("mousedown", function(evt) {
+ if (evt.target.nodeName !== "a") {
+ let cleanup = function() {
+ element.style.removeProperty("outline-style");
+ element.removeEventListener("mouseup", cleanup, false);
+ element.removeEventListener("mouseout", cleanup, false);
+ };
+ element.style.setProperty("outline-style", "none");
+ element.addEventListener("mouseup", cleanup, false);
+ element.addEventListener("mouseout", cleanup, false);
+ }
+ }, false);
+
+ // Mark the element editable field for tab
+ // navigation while editing.
+ element._editable = true;
+
+ // Save the trigger type so we can dispatch this later
+ element._trigger = trigger;
+
+ return function turnOnEditMode() {
+ aCallback(element);
+ }
+}
+
+exports.editableItem = this.editableItem;
+
+/*
+ * Various API consumers (especially tests) sometimes want to grab the
+ * inplaceEditor expando off span elements. However, when each global has its
+ * own compartment, those expandos live on Xray wrappers that are only visible
+ * within this JSM. So we provide a little workaround here.
+ */
+
+function getInplaceEditorForSpan(aSpan)
+{
+ return aSpan.inplaceEditor;
+};
+exports.getInplaceEditorForSpan = getInplaceEditorForSpan;
+
+function InplaceEditor(aOptions, aEvent)
+{
+ this.elt = aOptions.element;
+ let doc = this.elt.ownerDocument;
+ this.doc = doc;
+ this.elt.inplaceEditor = this;
+
+ this.change = aOptions.change;
+ this.done = aOptions.done;
+ this.destroy = aOptions.destroy;
+ this.initial = aOptions.initial ? aOptions.initial : this.elt.textContent;
+ this.multiline = aOptions.multiline || false;
+ this.stopOnShiftTab = !!aOptions.stopOnShiftTab;
+ this.stopOnTab = !!aOptions.stopOnTab;
+ this.stopOnReturn = !!aOptions.stopOnReturn;
+ this.contentType = aOptions.contentType || CONTENT_TYPES.PLAIN_TEXT;
+ this.property = aOptions.property;
+ this.popup = aOptions.popup;
+
+ this._onBlur = this._onBlur.bind(this);
+ this._onKeyPress = this._onKeyPress.bind(this);
+ this._onInput = this._onInput.bind(this);
+ this._onKeyup = this._onKeyup.bind(this);
+
+ this._createInput();
+ this._autosize();
+ this.inputCharWidth = this._getInputCharWidth();
+
+ // Pull out character codes for advanceChars, listing the
+ // characters that should trigger a blur.
+ this._advanceCharCodes = {};
+ let advanceChars = aOptions.advanceChars || '';
+ for (let i = 0; i < advanceChars.length; i++) {
+ this._advanceCharCodes[advanceChars.charCodeAt(i)] = true;
+ }
+
+ // Hide the provided element and add our editor.
+ this.originalDisplay = this.elt.style.display;
+ this.elt.style.display = "none";
+ this.elt.parentNode.insertBefore(this.input, this.elt);
+
+ if (typeof(aOptions.selectAll) == "undefined" || aOptions.selectAll) {
+ this.input.select();
+ }
+ this.input.focus();
+
+ if (this.contentType == CONTENT_TYPES.CSS_VALUE && this.input.value == "") {
+ this._maybeSuggestCompletion(true);
+ }
+
+ this.input.addEventListener("blur", this._onBlur, false);
+ this.input.addEventListener("keypress", this._onKeyPress, false);
+ this.input.addEventListener("input", this._onInput, false);
+
+ this.input.addEventListener("dblclick",
+ (e) => { e.stopPropagation(); }, false);
+ this.input.addEventListener("mousedown",
+ (e) => { e.stopPropagation(); }, false);
+
+ this.validate = aOptions.validate;
+
+ if (this.validate) {
+ this.input.addEventListener("keyup", this._onKeyup, false);
+ }
+
+ if (aOptions.start) {
+ aOptions.start(this, aEvent);
+ }
+
+ EventEmitter.decorate(this);
+}
+
+exports.InplaceEditor = InplaceEditor;
+
+InplaceEditor.CONTENT_TYPES = CONTENT_TYPES;
+
+InplaceEditor.prototype = {
+ _createInput: function InplaceEditor_createEditor()
+ {
+ this.input =
+ this.doc.createElementNS(HTML_NS, this.multiline ? "textarea" : "input");
+ this.input.inplaceEditor = this;
+ this.input.classList.add("styleinspector-propertyeditor");
+ this.input.value = this.initial;
+
+ copyTextStyles(this.elt, this.input);
+ },
+
+ /**
+ * Get rid of the editor.
+ */
+ _clear: function InplaceEditor_clear()
+ {
+ if (!this.input) {
+ // Already cleared.
+ return;
+ }
+
+ this.input.removeEventListener("blur", this._onBlur, false);
+ this.input.removeEventListener("keypress", this._onKeyPress, false);
+ this.input.removeEventListener("keyup", this._onKeyup, false);
+ this.input.removeEventListener("oninput", this._onInput, false);
+ this._stopAutosize();
+
+ this.elt.style.display = this.originalDisplay;
+ this.elt.focus();
+
+ this.elt.parentNode.removeChild(this.input);
+ this.input = null;
+
+ delete this.elt.inplaceEditor;
+ delete this.elt;
+
+ if (this.destroy) {
+ this.destroy();
+ }
+ },
+
+ /**
+ * Keeps the editor close to the size of its input string. This is pretty
+ * crappy, suggestions for improvement welcome.
+ */
+ _autosize: function InplaceEditor_autosize()
+ {
+ // Create a hidden, absolutely-positioned span to measure the text
+ // in the input. Boo.
+
+ // We can't just measure the original element because a) we don't
+ // change the underlying element's text ourselves (we leave that
+ // up to the client), and b) without tweaking the style of the
+ // original element, it might wrap differently or something.
+ this._measurement =
+ this.doc.createElementNS(HTML_NS, this.multiline ? "pre" : "span");
+ this._measurement.className = "autosizer";
+ this.elt.parentNode.appendChild(this._measurement);
+ let style = this._measurement.style;
+ style.visibility = "hidden";
+ style.position = "absolute";
+ style.top = "0";
+ style.left = "0";
+ copyTextStyles(this.input, this._measurement);
+ this._updateSize();
+ },
+
+ /**
+ * Clean up the mess created by _autosize().
+ */
+ _stopAutosize: function InplaceEditor_stopAutosize()
+ {
+ if (!this._measurement) {
+ return;
+ }
+ this._measurement.parentNode.removeChild(this._measurement);
+ delete this._measurement;
+ },
+
+ /**
+ * Size the editor to fit its current contents.
+ */
+ _updateSize: function InplaceEditor_updateSize()
+ {
+ // Replace spaces with non-breaking spaces. Otherwise setting
+ // the span's textContent will collapse spaces and the measurement
+ // will be wrong.
+ this._measurement.textContent = this.input.value.replace(/ /g, '\u00a0');
+
+ // We add a bit of padding to the end. Should be enough to fit
+ // any letter that could be typed, otherwise we'll scroll before
+ // we get a chance to resize. Yuck.
+ let width = this._measurement.offsetWidth + 10;
+
+ if (this.multiline) {
+ // Make sure there's some content in the current line. This is a hack to
+ // account for the fact that after adding a newline the <pre> doesn't grow
+ // unless there's text content on the line.
+ width += 15;
+ this._measurement.textContent += "M";
+ this.input.style.height = this._measurement.offsetHeight + "px";
+ }
+
+ this.input.style.width = width + "px";
+ },
+
+ /**
+ * Get the width of a single character in the input to properly position the
+ * autocompletion popup.
+ */
+ _getInputCharWidth: function InplaceEditor_getInputCharWidth()
+ {
+ // Just make the text content to be 'x' to get the width of any character in
+ // a monospace font.
+ this._measurement.textContent = "x";
+ return this._measurement.offsetWidth;
+ },
+
+ /**
+ * Increment property values in rule view.
+ *
+ * @param {number} increment
+ * The amount to increase/decrease the property value.
+ * @return {bool} true if value has been incremented.
+ */
+ _incrementValue: function InplaceEditor_incrementValue(increment)
+ {
+ let value = this.input.value;
+ let selectionStart = this.input.selectionStart;
+ let selectionEnd = this.input.selectionEnd;
+
+ let newValue = this._incrementCSSValue(value, increment, selectionStart,
+ selectionEnd);
+
+ if (!newValue) {
+ return false;
+ }
+
+ this.input.value = newValue.value;
+ this.input.setSelectionRange(newValue.start, newValue.end);
+ this._doValidation();
+
+ // Call the user's change handler if available.
+ if (this.change) {
+ this.change(this.input.value.trim());
+ }
+
+ return true;
+ },
+
+ /**
+ * Increment the property value based on the property type.
+ *
+ * @param {string} value
+ * Property value.
+ * @param {number} increment
+ * Amount to increase/decrease the property value.
+ * @param {number} selStart
+ * Starting index of the value.
+ * @param {number} selEnd
+ * Ending index of the value.
+ * @return {object} object with properties 'value', 'start', and 'end'.
+ */
+ _incrementCSSValue: function InplaceEditor_incrementCSSValue(value, increment,
+ selStart, selEnd)
+ {
+ let range = this._parseCSSValue(value, selStart);
+ let type = (range && range.type) || "";
+ let rawValue = (range ? value.substring(range.start, range.end) : "");
+ let incrementedValue = null, selection;
+
+ if (type === "num") {
+ let newValue = this._incrementRawValue(rawValue, increment);
+ if (newValue !== null) {
+ incrementedValue = newValue;
+ selection = [0, incrementedValue.length];
+ }
+ } else if (type === "hex") {
+ let exprOffset = selStart - range.start;
+ let exprOffsetEnd = selEnd - range.start;
+ let newValue = this._incHexColor(rawValue, increment, exprOffset,
+ exprOffsetEnd);
+ if (newValue) {
+ incrementedValue = newValue.value;
+ selection = newValue.selection;
+ }
+ } else {
+ let info;
+ if (type === "rgb" || type === "hsl") {
+ info = {};
+ let part = value.substring(range.start, selStart).split(",").length - 1;
+ if (part === 3) { // alpha
+ info.minValue = 0;
+ info.maxValue = 1;
+ } else if (type === "rgb") {
+ info.minValue = 0;
+ info.maxValue = 255;
+ } else if (part !== 0) { // hsl percentage
+ info.minValue = 0;
+ info.maxValue = 100;
+
+ // select the previous number if the selection is at the end of a
+ // percentage sign.
+ if (value.charAt(selStart - 1) === "%") {
+ --selStart;
+ }
+ }
+ }
+ return this._incrementGenericValue(value, increment, selStart, selEnd, info);
+ }
+
+ if (incrementedValue === null) {
+ return;
+ }
+
+ let preRawValue = value.substr(0, range.start);
+ let postRawValue = value.substr(range.end);
+
+ return {
+ value: preRawValue + incrementedValue + postRawValue,
+ start: range.start + selection[0],
+ end: range.start + selection[1]
+ };
+ },
+
+ /**
+ * Parses the property value and type.
+ *
+ * @param {string} value
+ * Property value.
+ * @param {number} offset
+ * Starting index of value.
+ * @return {object} object with properties 'value', 'start', 'end', and 'type'.
+ */
+ _parseCSSValue: function InplaceEditor_parseCSSValue(value, offset)
+ {
+ const reSplitCSS = /(url\("?[^"\)]+"?\)?)|(rgba?\([^)]*\)?)|(hsla?\([^)]*\)?)|(#[\dA-Fa-f]+)|(-?\d*\.?\d+(%|[a-z]{1,4})?)|"([^"]*)"?|'([^']*)'?|([^,\s\/!\(\)]+)|(!(.*)?)/;
+ let start = 0;
+ let m;
+
+ // retreive values from left to right until we find the one at our offset
+ while ((m = reSplitCSS.exec(value)) &&
+ (m.index + m[0].length < offset)) {
+ value = value.substr(m.index + m[0].length);
+ start += m.index + m[0].length;
+ offset -= m.index + m[0].length;
+ }
+
+ if (!m) {
+ return;
+ }
+
+ let type;
+ if (m[1]) {
+ type = "url";
+ } else if (m[2]) {
+ type = "rgb";
+ } else if (m[3]) {
+ type = "hsl";
+ } else if (m[4]) {
+ type = "hex";
+ } else if (m[5]) {
+ type = "num";
+ }
+
+ return {
+ value: m[0],
+ start: start + m.index,
+ end: start + m.index + m[0].length,
+ type: type
+ };
+ },
+
+ /**
+ * Increment the property value for types other than
+ * number or hex, such as rgb, hsl, and file names.
+ *
+ * @param {string} value
+ * Property value.
+ * @param {number} increment
+ * Amount to increment/decrement.
+ * @param {number} offset
+ * Starting index of the property value.
+ * @param {number} offsetEnd
+ * Ending index of the property value.
+ * @param {object} info
+ * Object with details about the property value.
+ * @return {object} object with properties 'value', 'start', and 'end'.
+ */
+ _incrementGenericValue:
+ function InplaceEditor_incrementGenericValue(value, increment, offset,
+ offsetEnd, info)
+ {
+ // Try to find a number around the cursor to increment.
+ let start, end;
+ // Check if we are incrementing in a non-number context (such as a URL)
+ if (/^-?[0-9.]/.test(value.substring(offset, offsetEnd)) &&
+ !(/\d/.test(value.charAt(offset - 1) + value.charAt(offsetEnd)))) {
+ // We have a number selected, possibly with a suffix, and we are not in
+ // the disallowed case of just part of a known number being selected.
+ // Use that number.
+ start = offset;
+ end = offsetEnd;
+ } else {
+ // Parse periods as belonging to the number only if we are in a known number
+ // context. (This makes incrementing the 1 in 'image1.gif' work.)
+ let pattern = "[" + (info ? "0-9." : "0-9") + "]*";
+ let before = new RegExp(pattern + "$").exec(value.substr(0, offset))[0].length;
+ let after = new RegExp("^" + pattern).exec(value.substr(offset))[0].length;
+
+ start = offset - before;
+ end = offset + after;
+
+ // Expand the number to contain an initial minus sign if it seems
+ // free-standing.
+ if (value.charAt(start - 1) === "-" &&
+ (start - 1 === 0 || /[ (:,='"]/.test(value.charAt(start - 2)))) {
+ --start;
+ }
+ }
+
+ if (start !== end)
+ {
+ // Include percentages as part of the incremented number (they are
+ // common enough).
+ if (value.charAt(end) === "%") {
+ ++end;
+ }
+
+ let first = value.substr(0, start);
+ let mid = value.substring(start, end);
+ let last = value.substr(end);
+
+ mid = this._incrementRawValue(mid, increment, info);
+
+ if (mid !== null) {
+ return {
+ value: first + mid + last,
+ start: start,
+ end: start + mid.length
+ };
+ }
+ }
+ },
+
+ /**
+ * Increment the property value for numbers.
+ *
+ * @param {string} rawValue
+ * Raw value to increment.
+ * @param {number} increment
+ * Amount to increase/decrease the raw value.
+ * @param {object} info
+ * Object with info about the property value.
+ * @return {string} the incremented value.
+ */
+ _incrementRawValue:
+ function InplaceEditor_incrementRawValue(rawValue, increment, info)
+ {
+ let num = parseFloat(rawValue);
+
+ if (isNaN(num)) {
+ return null;
+ }
+
+ let number = /\d+(\.\d+)?/.exec(rawValue);
+ let units = rawValue.substr(number.index + number[0].length);
+
+ // avoid rounding errors
+ let newValue = Math.round((num + increment) * 1000) / 1000;
+
+ if (info && "minValue" in info) {
+ newValue = Math.max(newValue, info.minValue);
+ }
+ if (info && "maxValue" in info) {
+ newValue = Math.min(newValue, info.maxValue);
+ }
+
+ newValue = newValue.toString();
+
+ return newValue + units;
+ },
+
+ /**
+ * Increment the property value for hex.
+ *
+ * @param {string} value
+ * Property value.
+ * @param {number} increment
+ * Amount to increase/decrease the property value.
+ * @param {number} offset
+ * Starting index of the property value.
+ * @param {number} offsetEnd
+ * Ending index of the property value.
+ * @return {object} object with properties 'value' and 'selection'.
+ */
+ _incHexColor:
+ function InplaceEditor_incHexColor(rawValue, increment, offset, offsetEnd)
+ {
+ // Return early if no part of the rawValue is selected.
+ if (offsetEnd > rawValue.length && offset >= rawValue.length) {
+ return;
+ }
+ if (offset < 1 && offsetEnd <= 1) {
+ return;
+ }
+ // Ignore the leading #.
+ rawValue = rawValue.substr(1);
+ --offset;
+ --offsetEnd;
+
+ // Clamp the selection to within the actual value.
+ offset = Math.max(offset, 0);
+ offsetEnd = Math.min(offsetEnd, rawValue.length);
+ offsetEnd = Math.max(offsetEnd, offset);
+
+ // Normalize #ABC -> #AABBCC.
+ if (rawValue.length === 3) {
+ rawValue = rawValue.charAt(0) + rawValue.charAt(0) +
+ rawValue.charAt(1) + rawValue.charAt(1) +
+ rawValue.charAt(2) + rawValue.charAt(2);
+ offset *= 2;
+ offsetEnd *= 2;
+ }
+
+ if (rawValue.length !== 6) {
+ return;
+ }
+
+ // If no selection, increment an adjacent color, preferably one to the left.
+ if (offset === offsetEnd) {
+ if (offset === 0) {
+ offsetEnd = 1;
+ } else {
+ offset = offsetEnd - 1;
+ }
+ }
+
+ // Make the selection cover entire parts.
+ offset -= offset % 2;
+ offsetEnd += offsetEnd % 2;
+
+ // Remap the increments from [0.1, 1, 10] to [1, 1, 16].
+ if (-1 < increment && increment < 1) {
+ increment = (increment < 0 ? -1 : 1);
+ }
+ if (Math.abs(increment) === 10) {
+ increment = (increment < 0 ? -16 : 16);
+ }
+
+ let isUpper = (rawValue.toUpperCase() === rawValue);
+
+ for (let pos = offset; pos < offsetEnd; pos += 2) {
+ // Increment the part in [pos, pos+2).
+ let mid = rawValue.substr(pos, 2);
+ let value = parseInt(mid, 16);
+
+ if (isNaN(value)) {
+ return;
+ }
+
+ mid = Math.min(Math.max(value + increment, 0), 255).toString(16);
+
+ while (mid.length < 2) {
+ mid = "0" + mid;
+ }
+ if (isUpper) {
+ mid = mid.toUpperCase();
+ }
+
+ rawValue = rawValue.substr(0, pos) + mid + rawValue.substr(pos + 2);
+ }
+
+ return {
+ value: "#" + rawValue,
+ selection: [offset + 1, offsetEnd + 1]
+ };
+ },
+
+ /**
+ * Cycle through the autocompletion suggestions in the popup.
+ *
+ * @param {boolean} aReverse
+ * true to select previous item from the popup.
+ * @param {boolean} aNoSelect
+ * true to not select the text after selecting the newly selectedItem
+ * from the popup.
+ */
+ _cycleCSSSuggestion:
+ function InplaceEditor_cycleCSSSuggestion(aReverse, aNoSelect)
+ {
+ // selectedItem can be null when nothing is selected in an empty editor.
+ let {label, preLabel} = this.popup.selectedItem || {label: "", preLabel: ""};
+ if (aReverse) {
+ this.popup.selectPreviousItem();
+ } else {
+ this.popup.selectNextItem();
+ }
+ this._selectedIndex = this.popup.selectedIndex;
+ let input = this.input;
+ let pre = "";
+ if (input.selectionStart < input.selectionEnd) {
+ pre = input.value.slice(0, input.selectionStart);
+ }
+ else {
+ pre = input.value.slice(0, input.selectionStart - label.length +
+ preLabel.length);
+ }
+ let post = input.value.slice(input.selectionEnd, input.value.length);
+ let item = this.popup.selectedItem;
+ let toComplete = item.label.slice(item.preLabel.length);
+ input.value = pre + toComplete + post;
+ if (!aNoSelect) {
+ input.setSelectionRange(pre.length, pre.length + toComplete.length);
+ }
+ else {
+ input.setSelectionRange(pre.length + toComplete.length,
+ pre.length + toComplete.length);
+ }
+ this._updateSize();
+ // This emit is mainly for the purpose of making the test flow simpler.
+ this.emit("after-suggest");
+ },
+
+ /**
+ * Call the client's done handler and clear out.
+ */
+ _apply: function InplaceEditor_apply(aEvent, direction)
+ {
+ if (this._applied) {
+ return;
+ }
+
+ this._applied = true;
+
+ if (this.done) {
+ let val = this.input.value.trim();
+ return this.done(this.cancelled ? this.initial : val, !this.cancelled, direction);
+ }
+
+ return null;
+ },
+
+ /**
+ * Handle loss of focus by calling done if it hasn't been called yet.
+ */
+ _onBlur: function InplaceEditor_onBlur(aEvent, aDoNotClear)
+ {
+ if (aEvent && this.popup && this.popup.isOpen &&
+ this.popup.selectedIndex >= 0) {
+ let label, preLabel;
+ if (this._selectedIndex === undefined) {
+ ({label, preLabel}) = this.popup.getItemAtIndex(this.popup.selectedIndex);
+ }
+ else {
+ ({label, preLabel}) = this.popup.getItemAtIndex(this._selectedIndex);
+ }
+ let input = this.input;
+ let pre = "";
+ if (input.selectionStart < input.selectionEnd) {
+ pre = input.value.slice(0, input.selectionStart);
+ }
+ else {
+ pre = input.value.slice(0, input.selectionStart - label.length +
+ preLabel.length);
+ }
+ let post = input.value.slice(input.selectionEnd, input.value.length);
+ let item = this.popup.selectedItem;
+ this._selectedIndex = this.popup.selectedIndex;
+ let toComplete = item.label.slice(item.preLabel.length);
+ input.value = pre + toComplete + post;
+ input.setSelectionRange(pre.length + toComplete.length,
+ pre.length + toComplete.length);
+ this._updateSize();
+ // Wait for the popup to hide and then focus input async otherwise it does
+ // not work.
+ let onPopupHidden = () => {
+ this.popup._panel.removeEventListener("popuphidden", onPopupHidden);
+ this.doc.defaultView.setTimeout(()=> {
+ input.focus();
+ this.emit("after-suggest");
+ }, 0);
+ };
+ this.popup._panel.addEventListener("popuphidden", onPopupHidden);
+ this.popup.hidePopup();
+ // Content type other than CSS_MIXED is used in rule-view where the values
+ // are live previewed. So we apply the value before returning.
+ if (this.contentType != CONTENT_TYPES.CSS_MIXED) {
+ this._apply();
+ }
+ return;
+ }
+ this._apply();
+ if (!aDoNotClear) {
+ this._clear();
+ }
+ },
+
+ /**
+ * Handle the input field's keypress event.
+ */
+ _onKeyPress: function InplaceEditor_onKeyPress(aEvent)
+ {
+ let prevent = false;
+
+ const largeIncrement = 100;
+ const mediumIncrement = 10;
+ const smallIncrement = 0.1;
+
+ let increment = 0;
+
+ if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_UP
+ || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP) {
+ increment = 1;
+ } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_DOWN
+ || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN) {
+ increment = -1;
+ }
+
+ if (aEvent.shiftKey && !aEvent.altKey) {
+ if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP
+ || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN) {
+ increment *= largeIncrement;
+ } else {
+ increment *= mediumIncrement;
+ }
+ } else if (aEvent.altKey && !aEvent.shiftKey) {
+ increment *= smallIncrement;
+ }
+
+ let cycling = false;
+ if (increment && this._incrementValue(increment) ) {
+ this._updateSize();
+ prevent = true;
+ cycling = true;
+ } else if (increment && this.popup && this.popup.isOpen) {
+ cycling = true;
+ prevent = true;
+ this._cycleCSSSuggestion(increment > 0);
+ this._doValidation();
+ }
+
+ if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_BACK_SPACE ||
+ aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_DELETE ||
+ aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_LEFT ||
+ aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RIGHT) {
+ if (this.popup && this.popup.isOpen) {
+ this.popup.hidePopup();
+ }
+ } else if (!cycling && !aEvent.metaKey && !aEvent.altKey && !aEvent.ctrlKey) {
+ this._maybeSuggestCompletion();
+ }
+
+ if (this.multiline &&
+ aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN &&
+ aEvent.shiftKey) {
+ prevent = false;
+ } else if (aEvent.charCode in this._advanceCharCodes
+ || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN
+ || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB) {
+ prevent = true;
+
+ let direction = FOCUS_FORWARD;
+ if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB &&
+ aEvent.shiftKey) {
+ if (this.stopOnShiftTab) {
+ direction = null;
+ } else {
+ direction = FOCUS_BACKWARD;
+ }
+ }
+ if ((this.stopOnReturn &&
+ aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN) ||
+ (this.stopOnTab && aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB)) {
+ direction = null;
+ }
+
+ // Now we don't want to suggest anything as we are moving out.
+ this._preventSuggestions = true;
+ // But we still want to show suggestions for css values. i.e. moving out
+ // of css property input box in forward direction
+ if (this.contentType == CONTENT_TYPES.CSS_PROPERTY &&
+ direction == FOCUS_FORWARD) {
+ this._preventSuggestions = false;
+ }
+
+ let input = this.input;
+
+ if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB &&
+ this.contentType == CONTENT_TYPES.CSS_MIXED) {
+ if (this.popup && input.selectionStart < input.selectionEnd) {
+ aEvent.preventDefault();
+ input.setSelectionRange(input.selectionEnd, input.selectionEnd);
+ this.emit("after-suggest");
+ return;
+ }
+ else if (this.popup && this.popup.isOpen) {
+ aEvent.preventDefault();
+ this._cycleCSSSuggestion(aEvent.shiftKey, true);
+ return;
+ }
+ }
+
+ this._apply(aEvent, direction);
+
+ // Close the popup if open
+ if (this.popup && this.popup.isOpen) {
+ this.popup.hidePopup();
+ }
+
+ if (direction !== null && focusManager.focusedElement === input) {
+ // If the focused element wasn't changed by the done callback,
+ // move the focus as requested.
+ let next = moveFocus(this.doc.defaultView, direction);
+
+ // If the next node to be focused has been tagged as an editable
+ // node, trigger editing using the configured event
+ if (next && next.ownerDocument === this.doc && next._editable) {
+ let e = this.doc.createEvent('Event');
+ e.initEvent(next._trigger, true, true);
+ next.dispatchEvent(e);
+ }
+ }
+
+ this._clear();
+ } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE) {
+ // Cancel and blur ourselves.
+ // Now we don't want to suggest anything as we are moving out.
+ this._preventSuggestions = true;
+ // Close the popup if open
+ if (this.popup && this.popup.isOpen) {
+ this.popup.hidePopup();
+ }
+ prevent = true;
+ this.cancelled = true;
+ this._apply();
+ this._clear();
+ aEvent.stopPropagation();
+ } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_SPACE) {
+ // No need for leading spaces here. This is particularly
+ // noticable when adding a property: it's very natural to type
+ // <name>: (which advances to the next property) then spacebar.
+ prevent = !this.input.value;
+ }
+
+ if (prevent) {
+ aEvent.preventDefault();
+ }
+ },
+
+ /**
+ * Handle the input field's keyup event.
+ */
+ _onKeyup: function(aEvent) {
+ this._applied = false;
+ },
+
+ /**
+ * Handle changes to the input text.
+ */
+ _onInput: function InplaceEditor_onInput(aEvent)
+ {
+ // Validate the entered value.
+ this._doValidation();
+
+ // Update size if we're autosizing.
+ if (this._measurement) {
+ this._updateSize();
+ }
+
+ // Call the user's change handler if available.
+ if (this.change) {
+ this.change(this.input.value.trim());
+ }
+ },
+
+ /**
+ * Fire validation callback with current input
+ */
+ _doValidation: function()
+ {
+ if (this.validate && this.input) {
+ this.validate(this.input.value);
+ }
+ },
+
+ /**
+ * Handles displaying suggestions based on the current input.
+ *
+ * @param {boolean} aNoAutoInsert
+ * true if you don't want to automatically insert the first suggestion
+ */
+ _maybeSuggestCompletion: function(aNoAutoInsert) {
+ // Input can be null in cases when you intantaneously switch out of it.
+ if (!this.input) {
+ return;
+ }
+ let preTimeoutQuery = this.input.value;
+ // Since we are calling this method from a keypress event handler, the
+ // |input.value| does not include currently typed character. Thus we perform
+ // this method async.
+ this.doc.defaultView.setTimeout(() => {
+ if (this._preventSuggestions) {
+ this._preventSuggestions = false;
+ return;
+ }
+ if (this.contentType == CONTENT_TYPES.PLAIN_TEXT) {
+ return;
+ }
+ if (!this.input) {
+ return;
+ }
+ let input = this.input;
+ // The length of input.value should be increased by 1
+ if (input.value.length - preTimeoutQuery.length > 1) {
+ return;
+ }
+ let query = input.value.slice(0, input.selectionStart);
+ let startCheckQuery = query;
+ if (query == null) {
+ return;
+ }
+ // If nothing is selected and there is a non-space character after the
+ // cursor, do not autocomplete.
+ if (input.selectionStart == input.selectionEnd &&
+ input.selectionStart < input.value.length &&
+ input.value.slice(input.selectionStart)[0] != " ") {
+ // This emit is mainly to make the test flow simpler.
+ this.emit("after-suggest", "nothing to autocomplete");
+ return;
+ }
+ let list = [];
+ if (this.contentType == CONTENT_TYPES.CSS_PROPERTY) {
+ list = CSSPropertyList;
+ } else if (this.contentType == CONTENT_TYPES.CSS_VALUE) {
+ // Get the last query to be completed before the caret.
+ let match = /([^\s,.\/]+$)/.exec(query);
+ if (match) {
+ startCheckQuery = match[0];
+ } else {
+ startCheckQuery = "";
+ }
+
+ list =
+ ["!important", ...domUtils.getCSSValuesForProperty(this.property.name)];
+
+ if (query == "") {
+ // Do not suggest '!important' without any manually typed character.
+ list.splice(0, 1);
+ }
+ } else if (this.contentType == CONTENT_TYPES.CSS_MIXED &&
+ /^\s*style\s*=/.test(query)) {
+ // Detecting if cursor is at property or value;
+ let match = query.match(/([:;"'=]?)\s*([^"';:=]+)?$/);
+ if (match && match.length >= 2) {
+ if (match[1] == ":") { // We are in CSS value completion
+ let propertyName =
+ query.match(/[;"'=]\s*([^"';:= ]+)\s*:\s*[^"';:=]*$/)[1];
+ list =
+ ["!important;", ...domUtils.getCSSValuesForProperty(propertyName)];
+ let matchLastQuery = /([^\s,.\/]+$)/.exec(match[2] || "");
+ if (matchLastQuery) {
+ startCheckQuery = matchLastQuery[0];
+ } else {
+ startCheckQuery = "";
+ }
+ if (!match[2]) {
+ // Don't suggest '!important' without any manually typed character
+ list.splice(0, 1);
+ }
+ } else if (match[1]) { // We are in CSS property name completion
+ list = CSSPropertyList;
+ startCheckQuery = match[2];
+ }
+ if (startCheckQuery == null) {
+ // This emit is mainly to make the test flow simpler.
+ this.emit("after-suggest", "nothing to autocomplete");
+ return;
+ }
+ }
+ }
+ if (!aNoAutoInsert) {
+ list.some(item => {
+ if (startCheckQuery != null && item.startsWith(startCheckQuery)) {
+ input.value = query + item.slice(startCheckQuery.length) +
+ input.value.slice(query.length);
+ input.setSelectionRange(query.length, query.length + item.length -
+ startCheckQuery.length);
+ this._updateSize();
+ return true;
+ }
+ });
+ }
+
+ if (!this.popup) {
+ // This emit is mainly to make the test flow simpler.
+ this.emit("after-suggest", "no popup");
+ return;
+ }
+ let finalList = [];
+ let length = list.length;
+ for (let i = 0, count = 0; i < length && count < MAX_POPUP_ENTRIES; i++) {
+ if (startCheckQuery != null && list[i].startsWith(startCheckQuery)) {
+ count++;
+ finalList.push({
+ preLabel: startCheckQuery,
+ label: list[i]
+ });
+ }
+ else if (count > 0) {
+ // Since count was incremented, we had already crossed the entries
+ // which would have started with query, assuming that list is sorted.
+ break;
+ }
+ else if (startCheckQuery != null && list[i][0] > startCheckQuery[0]) {
+ // We have crossed all possible matches alphabetically.
+ break;
+ }
+ }
+
+ if (finalList.length > 1) {
+ // Calculate the offset for the popup to be opened.
+ let x = (this.input.selectionStart - startCheckQuery.length) *
+ this.inputCharWidth;
+ this.popup.setItems(finalList);
+ this.popup.openPopup(this.input, x);
+ if (aNoAutoInsert) {
+ this.popup.selectedIndex = -1;
+ }
+ } else {
+ this.popup.hidePopup();
+ }
+ // This emit is mainly for the purpose of making the test flow simpler.
+ this.emit("after-suggest");
+ this._doValidation();
+ }, 0);
+ }
+};
+
+/**
+ * Copy text-related styles from one element to another.
+ */
+function copyTextStyles(aFrom, aTo)
+{
+ let win = aFrom.ownerDocument.defaultView;
+ let style = win.getComputedStyle(aFrom);
+ aTo.style.fontFamily = style.getPropertyCSSValue("font-family").cssText;
+ aTo.style.fontSize = style.getPropertyCSSValue("font-size").cssText;
+ aTo.style.fontWeight = style.getPropertyCSSValue("font-weight").cssText;
+ aTo.style.fontStyle = style.getPropertyCSSValue("font-style").cssText;
+}
+
+/**
+ * Trigger a focus change similar to pressing tab/shift-tab.
+ */
+function moveFocus(aWin, aDirection)
+{
+ return focusManager.moveFocus(aWin, null, aDirection, 0);
+}
+
+
+XPCOMUtils.defineLazyGetter(this, "focusManager", function() {
+ return Services.focus;
+});
+
+XPCOMUtils.defineLazyGetter(this, "CSSPropertyList", function() {
+ return domUtils.getCSSPropertyNames(domUtils.INCLUDE_ALIASES).sort();
+});
+
+XPCOMUtils.defineLazyGetter(this, "domUtils", function() {
+ return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
+});
diff --git a/toolkit/devtools/shared/moz.build b/toolkit/devtools/shared/moz.build
index 046454922..320a3e3d1 100644
--- a/toolkit/devtools/shared/moz.build
+++ b/toolkit/devtools/shared/moz.build
@@ -6,6 +6,73 @@
BROWSER_CHROME_MANIFESTS += ['tests/browser/browser.ini']
+if CONFIG['MOZ_DEVTOOLS']:
+ BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
+ XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
+
EXTRA_JS_MODULES.devtools.shared += [
'async-storage.js'
]
+
+if CONFIG['MOZ_DEVTOOLS']:
+ EXTRA_JS_MODULES.devtools += [
+ 'AppCacheUtils.jsm',
+ 'Curl.jsm',
+ 'DeveloperToolbar.jsm',
+ 'DOMHelpers.jsm',
+ 'Jsbeautify.jsm',
+ 'Parser.jsm',
+ 'SplitView.jsm',
+ ]
+
+ EXTRA_JS_MODULES.devtools += [
+ 'widgets/AbstractTreeItem.jsm',
+ 'widgets/BreadcrumbsWidget.jsm',
+ 'widgets/Chart.jsm',
+ 'widgets/FlameGraph.jsm',
+ 'widgets/Graphs.jsm',
+ 'widgets/GraphsWorker.js',
+ 'widgets/SideMenuWidget.jsm',
+ 'widgets/SimpleListWidget.jsm',
+ 'widgets/VariablesView.jsm',
+ 'widgets/VariablesViewController.jsm',
+ 'widgets/ViewHelpers.jsm',
+ ]
+
+ EXTRA_JS_MODULES.devtools.shared.profiler += [
+ 'profiler/global.js',
+ 'profiler/tree-model.js',
+ 'profiler/tree-view.js',
+ ]
+
+ EXTRA_JS_MODULES.devtools.shared.timeline += [
+ 'timeline/global.js',
+ 'timeline/marker-details.js',
+ 'timeline/markers-overview.js',
+ 'timeline/memory-overview.js',
+ 'timeline/waterfall.js',
+ ]
+
+ EXTRA_JS_MODULES.devtools.shared += [
+ 'autocomplete-popup.js',
+ 'd3.js',
+ 'devices.js',
+ 'doorhanger.js',
+ 'frame-script-utils.js',
+ 'inplace-editor.js',
+ 'observable-object.js',
+ 'options-view.js',
+ 'telemetry.js',
+ 'theme-switching.js',
+ 'theme.js',
+ 'undo.js',
+ ]
+
+ EXTRA_JS_MODULES.devtools.shared.widgets += [
+ 'widgets/CubicBezierWidget.js',
+ 'widgets/FastListWidget.js',
+ 'widgets/Spectrum.js',
+ 'widgets/TableWidget.js',
+ 'widgets/Tooltip.js',
+ 'widgets/TreeWidget.js',
+ ]
diff --git a/toolkit/devtools/shared/observable-object.js b/toolkit/devtools/shared/observable-object.js
new file mode 100644
index 000000000..c18d668a9
--- /dev/null
+++ b/toolkit/devtools/shared/observable-object.js
@@ -0,0 +1,129 @@
+/* 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/. */
+
+/**
+ * ObservableObject
+ *
+ * An observable object is a JSON-like object that throws
+ * events when its direct properties or properties of any
+ * contained objects, are getting accessed or set.
+ *
+ * Inherits from EventEmitter.
+ *
+ * Properties:
+ * ⬩ object: JSON-like object
+ *
+ * Events:
+ * ⬩ "get" / path (array of property names)
+ * ⬩ "set" / path / new value
+ *
+ * Example:
+ *
+ * let emitter = new ObservableObject({ x: { y: [10] } });
+ * emitter.on("set", console.log);
+ * emitter.on("get", console.log);
+ * let obj = emitter.object;
+ * obj.x.y[0] = 50;
+ *
+ */
+
+"use strict";
+
+const EventEmitter = require("devtools/toolkit/event-emitter");
+
+function ObservableObject(object = {}) {
+ EventEmitter.decorate(this);
+ let handler = new Handler(this);
+ this.object = new Proxy(object, handler);
+ handler._wrappers.set(this.object, object);
+ handler._paths.set(object, []);
+}
+
+module.exports = ObservableObject;
+
+function isObject(x) {
+ if (typeof x === "object")
+ return x !== null;
+ return typeof x === "function";
+}
+
+function Handler(emitter) {
+ this._emitter = emitter;
+ this._wrappers = new WeakMap();
+ this._values = new WeakMap();
+ this._paths = new WeakMap();
+}
+
+Handler.prototype = {
+ wrap: function(target, key, value) {
+ let path;
+ if (!isObject(value)) {
+ path = this._paths.get(target).concat(key);
+ } else if (this._wrappers.has(value)) {
+ path = this._paths.get(value);
+ } else if (this._paths.has(value)) {
+ path = this._paths.get(value);
+ value = this._values.get(value);
+ } else {
+ path = this._paths.get(target).concat(key);
+ this._paths.set(value, path);
+ let wrapper = new Proxy(value, this);
+ this._wrappers.set(wrapper, value);
+ this._values.set(value, wrapper);
+ value = wrapper;
+ }
+ return [value, path];
+ },
+ unwrap: function(target, key, value) {
+ if (!isObject(value) || !this._wrappers.has(value)) {
+ return [value, this._paths.get(target).concat(key)];
+ }
+ return [this._wrappers.get(value), this._paths.get(target).concat(key)];
+ },
+ get: function(target, key) {
+ let value = target[key];
+ let [wrapped, path] = this.wrap(target, key, value);
+ this._emitter.emit("get", path, value);
+ return wrapped;
+ },
+ set: function(target, key, value) {
+ let [wrapped, path] = this.unwrap(target, key, value);
+ target[key] = value;
+ this._emitter.emit("set", path, value);
+ },
+ getOwnPropertyDescriptor: function(target, key) {
+ let desc = Object.getOwnPropertyDescriptor(target, key);
+ if (desc) {
+ if ("value" in desc) {
+ let [wrapped, path] = this.wrap(target, key, desc.value);
+ desc.value = wrapped;
+ this._emitter.emit("get", path, desc.value);
+ } else {
+ if ("get" in desc) {
+ [desc.get] = this.wrap(target, "get "+key, desc.get);
+ }
+ if ("set" in desc) {
+ [desc.set] = this.wrap(target, "set "+key, desc.set);
+ }
+ }
+ }
+ return desc;
+ },
+ defineProperty: function(target, key, desc) {
+ if ("value" in desc) {
+ let [unwrapped, path] = this.unwrap(target, key, desc.value);
+ desc.value = unwrapped;
+ Object.defineProperty(target, key, desc);
+ this._emitter.emit("set", path, desc.value);
+ } else {
+ if ("get" in desc) {
+ [desc.get] = this.unwrap(target, "get "+key, desc.get);
+ }
+ if ("set" in desc) {
+ [desc.set] = this.unwrap(target, "set "+key, desc.set);
+ }
+ Object.defineProperty(target, key, desc);
+ }
+ }
+};
diff --git a/toolkit/devtools/shared/options-view.js b/toolkit/devtools/shared/options-view.js
new file mode 100644
index 000000000..2ca4d91ac
--- /dev/null
+++ b/toolkit/devtools/shared/options-view.js
@@ -0,0 +1,178 @@
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const { Services } = require("resource://gre/modules/Services.jsm");
+
+const OPTIONS_SHOWN_EVENT = "options-shown";
+const OPTIONS_HIDDEN_EVENT = "options-hidden";
+const PREF_CHANGE_EVENT = "pref-changed";
+
+/**
+ * OptionsView constructor. Takes several options, all required:
+ * - branchName: The name of the prefs branch, like "devtools.debugger."
+ * - menupopup: The XUL `menupopup` item that contains the pref buttons.
+ *
+ * Fires an event, PREF_CHANGE_EVENT, with the preference name that changed as the second
+ * argument. Fires events on opening/closing the XUL panel (OPTIONS_SHOW_EVENT, OPTIONS_HIDDEN_EVENT)
+ * as the second argument in the listener, used for tests mostly.
+ */
+const OptionsView = function (options={}) {
+ this.branchName = options.branchName;
+ this.menupopup = options.menupopup;
+ this.window = this.menupopup.ownerDocument.defaultView;
+ let { document } = this.window;
+ this.$ = document.querySelector.bind(document);
+ this.$$ = document.querySelectorAll.bind(document);
+
+ this.prefObserver = new PrefObserver(this.branchName);
+
+ EventEmitter.decorate(this);
+};
+exports.OptionsView = OptionsView;
+
+OptionsView.prototype = {
+ /**
+ * Binds the events and observers for the OptionsView.
+ */
+ initialize: function () {
+ let { MutationObserver } = this.window;
+ this._onPrefChange = this._onPrefChange.bind(this);
+ this._onOptionChange = this._onOptionChange.bind(this);
+ this._onPopupShown = this._onPopupShown.bind(this);
+ this._onPopupHidden = this._onPopupHidden.bind(this);
+
+ // We use a mutation observer instead of a click handler
+ // because the click handler is fired before the XUL menuitem updates
+ // it's checked status, which cascades incorrectly with the Preference observer.
+ this.mutationObserver = new MutationObserver(this._onOptionChange);
+ let observerConfig = { attributes: true, attributeFilter: ["checked"]};
+
+ // Sets observers and default options for all options
+ for (let $el of this.$$("menuitem", this.menupopup)) {
+ let prefName = $el.getAttribute("data-pref");
+
+ if (this.prefObserver.get(prefName)) {
+ $el.setAttribute("checked", "true");
+ } else {
+ $el.removeAttribute("checked");
+ }
+ this.mutationObserver.observe($el, observerConfig);
+ }
+
+ // Listen to any preference change in the specified branch
+ this.prefObserver.register();
+ this.prefObserver.on(PREF_CHANGE_EVENT, this._onPrefChange);
+
+ // Bind to menupopup's open and close event
+ this.menupopup.addEventListener("popupshown", this._onPopupShown);
+ this.menupopup.addEventListener("popuphidden", this._onPopupHidden);
+ },
+
+ /**
+ * Removes event handlers for all of the option buttons and
+ * preference observer.
+ */
+ destroy: function () {
+ this.mutationObserver.disconnect();
+ this.prefObserver.off(PREF_CHANGE_EVENT, this._onPrefChange);
+ this.menupopup.removeEventListener("popupshown", this._onPopupShown);
+ this.menupopup.removeEventListener("popuphidden", this._onPopupHidden);
+ },
+
+ /**
+ * Returns the value for the specified `prefName`
+ */
+ getPref: function (prefName) {
+ return this.prefObserver.get(prefName);
+ },
+
+ /**
+ * Called when a preference is changed (either via clicking an option
+ * button or by changing it in about:config). Updates the checked status
+ * of the corresponding button.
+ */
+ _onPrefChange: function (_, prefName) {
+ let $el = this.$(`menuitem[data-pref="${prefName}"]`, this.menupopup);
+ let value = this.prefObserver.get(prefName);
+
+ // If options panel does not contain a menuitem for the
+ // pref, emit an event and do nothing.
+ if (!$el) {
+ this.emit(PREF_CHANGE_EVENT, prefName);
+ return;
+ }
+
+ if (value) {
+ $el.setAttribute("checked", value);
+ } else {
+ $el.removeAttribute("checked");
+ }
+
+ this.emit(PREF_CHANGE_EVENT, prefName);
+ },
+
+ /**
+ * Mutation handler for handling a change on an options button.
+ * Sets the preference accordingly.
+ */
+ _onOptionChange: function (mutations) {
+ let { target } = mutations[0];
+ let prefName = target.getAttribute("data-pref");
+ let value = target.getAttribute("checked") === "true";
+
+ this.prefObserver.set(prefName, value);
+ },
+
+ /**
+ * Fired when the `menupopup` is opened, bound via XUL.
+ * Fires an event used in tests.
+ */
+ _onPopupShown: function () {
+ this.emit(OPTIONS_SHOWN_EVENT);
+ },
+
+ /**
+ * Fired when the `menupopup` is closed, bound via XUL.
+ * Fires an event used in tests.
+ */
+ _onPopupHidden: function () {
+ this.emit(OPTIONS_HIDDEN_EVENT);
+ }
+};
+
+/**
+ * Constructor for PrefObserver. Small helper for observing changes
+ * on a preference branch. Takes a `branchName`, like "devtools.debugger."
+ *
+ * Fires an event of PREF_CHANGE_EVENT with the preference name that changed
+ * as the second argument in the listener.
+ */
+const PrefObserver = function (branchName) {
+ this.branchName = branchName;
+ this.branch = Services.prefs.getBranch(branchName);
+ EventEmitter.decorate(this);
+};
+
+PrefObserver.prototype = {
+ /**
+ * Returns `prefName`'s value. Does not require the branch name.
+ */
+ get: function (prefName) {
+ let fullName = this.branchName + prefName;
+ return Services.prefs.getBoolPref(fullName);
+ },
+ /**
+ * Sets `prefName`'s `value`. Does not require the branch name.
+ */
+ set: function (prefName, value) {
+ let fullName = this.branchName + prefName;
+ Services.prefs.setBoolPref(fullName, value);
+ },
+ register: function () {
+ this.branch.addObserver("", this, false);
+ },
+ unregister: function () {
+ this.branch.removeObserver("", this);
+ },
+ observe: function (subject, topic, prefName) {
+ this.emit(PREF_CHANGE_EVENT, prefName);
+ }
+};
diff --git a/toolkit/devtools/shared/profiler/global.js b/toolkit/devtools/shared/profiler/global.js
new file mode 100644
index 000000000..0a89be089
--- /dev/null
+++ b/toolkit/devtools/shared/profiler/global.js
@@ -0,0 +1,108 @@
+/* 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, Cr} = require("chrome");
+
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+/**
+ * Localization convenience methods.
+ */
+const STRINGS_URI = "chrome://browser/locale/devtools/profiler.properties";
+const L10N = new ViewHelpers.L10N(STRINGS_URI);
+
+/**
+ * Details about each profile entry cateogry.
+ * @see CATEGORY_MAPPINGS.
+ */
+const CATEGORIES = [
+ { ordinal: 7, color: "#5e88b0", abbrev: "other", label: L10N.getStr("category.other") },
+ { ordinal: 4, color: "#46afe3", abbrev: "css", label: L10N.getStr("category.css") },
+ { ordinal: 1, color: "#d96629", abbrev: "js", label: L10N.getStr("category.js") },
+ { ordinal: 2, color: "#eb5368", abbrev: "gc", label: L10N.getStr("category.gc") },
+ { ordinal: 0, color: "#df80ff", abbrev: "network", label: L10N.getStr("category.network") },
+ { ordinal: 5, color: "#70bf53", abbrev: "graphics", label: L10N.getStr("category.graphics") },
+ { ordinal: 6, color: "#8fa1b2", abbrev: "storage", label: L10N.getStr("category.storage") },
+ { ordinal: 3, color: "#d99b28", abbrev: "events", label: L10N.getStr("category.events") }
+];
+
+/**
+ * Mapping from category bitmasks in the profiler data to additional details.
+ * To be kept in sync with the js::ProfileEntry::Category in ProfilingStack.h
+ */
+const CATEGORY_MAPPINGS = {
+ "16": CATEGORIES[0], // js::ProfileEntry::Category::OTHER
+ "32": CATEGORIES[1], // js::ProfileEntry::Category::CSS
+ "64": CATEGORIES[2], // js::ProfileEntry::Category::JS
+ "128": CATEGORIES[3], // js::ProfileEntry::Category::GC
+ "256": CATEGORIES[3], // js::ProfileEntry::Category::CC
+ "512": CATEGORIES[4], // js::ProfileEntry::Category::NETWORK
+ "1024": CATEGORIES[5], // js::ProfileEntry::Category::GRAPHICS
+ "2048": CATEGORIES[6], // js::ProfileEntry::Category::STORAGE
+ "4096": CATEGORIES[7], // js::ProfileEntry::Category::EVENTS
+};
+
+/**
+ * Get the numeric bitmask (or set of masks) for the given category
+ * abbreviation. See CATEGORIES and CATEGORY_MAPPINGS above.
+ *
+ * CATEGORY_MASK can be called with just a name if it is expected that the
+ * category is mapped to by exactly one bitmask. If the category is mapped
+ * to by multiple masks, CATEGORY_MASK for that name must be called with
+ * an additional argument specifying the desired id (in ascending order).
+ */
+const [CATEGORY_MASK, CATEGORY_MASK_LIST] = (function () {
+ let mappings = {};
+ for (let category of CATEGORIES) {
+ let numList = Object.keys(CATEGORY_MAPPINGS)
+ .filter(k => CATEGORY_MAPPINGS[k] == category)
+ .map(k => +k);
+ numList.sort();
+ mappings[category.abbrev] = numList;
+ }
+
+ return [
+ function (name, num) {
+ if (!(name in mappings)) {
+ throw new Error(`Category abbreviation '${name}' does not exist.`);
+ }
+ if (arguments.length == 1) {
+ if (mappings[name].length != 1) {
+ throw new Error(`Expected exactly one category number for '${name}'.`);
+ }
+ return mappings[name][0];
+ }
+ if (num > mappings[name].length) {
+ throw new Error(`Num '${num}' too high for category '${name}'.`);
+ }
+ return mappings[name][num - 1];
+ },
+
+ function (name) {
+ if (!(name in mappings)) {
+ throw new Error(`Category abbreviation '${name}' does not exist.`);
+ }
+ return mappings[name];
+ }
+ ];
+})();
+
+// Human-readable "other" category bitmask. Older Geckos don't have all the
+// necessary instrumentation in the sampling profiler backend for creating
+// a categories graph, in which case we default to the "other" category.
+const CATEGORY_OTHER = CATEGORY_MASK('other');
+
+// Human-readable JIT category bitmask. Certain pseudo-frames in a sample,
+// like "EnterJIT", don't have any associated `cateogry` information.
+const CATEGORY_JIT = CATEGORY_MASK('js');
+
+// Exported symbols.
+exports.L10N = L10N;
+exports.CATEGORIES = CATEGORIES;
+exports.CATEGORY_MAPPINGS = CATEGORY_MAPPINGS;
+exports.CATEGORY_OTHER = CATEGORY_OTHER;
+exports.CATEGORY_JIT = CATEGORY_JIT;
+exports.CATEGORY_MASK = CATEGORY_MASK;
+exports.CATEGORY_MASK_LIST = CATEGORY_MASK_LIST;
diff --git a/toolkit/devtools/shared/profiler/tree-model.js b/toolkit/devtools/shared/profiler/tree-model.js
new file mode 100644
index 000000000..b513904cb
--- /dev/null
+++ b/toolkit/devtools/shared/profiler/tree-model.js
@@ -0,0 +1,281 @@
+/* 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, Cr} = require("chrome");
+
+loader.lazyRequireGetter(this, "Services");
+loader.lazyRequireGetter(this, "L10N",
+ "devtools/shared/profiler/global", true);
+loader.lazyRequireGetter(this, "CATEGORY_MAPPINGS",
+ "devtools/shared/profiler/global", true);
+loader.lazyRequireGetter(this, "CATEGORY_JIT",
+ "devtools/shared/profiler/global", true);
+
+const CHROME_SCHEMES = ["chrome://", "resource://"];
+const CONTENT_SCHEMES = ["http://", "https://", "file://"];
+
+exports.ThreadNode = ThreadNode;
+exports.FrameNode = FrameNode;
+exports.FrameNode.isContent = isContent;
+
+/**
+ * A call tree for a thread. This is essentially a linkage between all frames
+ * of all samples into a single tree structure, with additional information
+ * on each node, like the time spent (in milliseconds) and samples count.
+ *
+ * Example:
+ * {
+ * duration: number,
+ * calls: {
+ * "FunctionName (url:line)": {
+ * line: number,
+ * category: number,
+ * samples: number,
+ * duration: number,
+ * calls: {
+ * ...
+ * }
+ * }, // FrameNode
+ * ...
+ * }
+ * } // ThreadNode
+ *
+ * @param object threadSamples
+ * The raw samples array received from the backend.
+ * @param object options
+ * Additional supported options, @see ThreadNode.prototype.insert
+ * - number startTime [optional]
+ * - number endTime [optional]
+ * - boolean contentOnly [optional]
+ * - boolean invertTree [optional]
+ */
+function ThreadNode(threadSamples, options = {}) {
+ this.samples = 0;
+ this.duration = 0;
+ this.calls = {};
+ this._previousSampleTime = 0;
+
+ for (let sample of threadSamples) {
+ this.insert(sample, options);
+ }
+}
+
+ThreadNode.prototype = {
+ /**
+ * Adds function calls in the tree from a sample's frames.
+ *
+ * @param object sample
+ * The { frames, time } sample, containing an array of frames and
+ * the time the sample was taken. This sample is assumed to be older
+ * than the most recently inserted one.
+ * @param object options [optional]
+ * Additional supported options:
+ * - number startTime: the earliest sample to start at (in milliseconds)
+ * - number endTime: the latest sample to end at (in milliseconds)
+ * - boolean contentOnly: if platform frames shouldn't be used
+ * - boolean invertTree: if the call tree should be inverted
+ */
+ insert: function(sample, options = {}) {
+ let startTime = options.startTime || 0;
+ let endTime = options.endTime || Infinity;
+ let sampleTime = sample.time;
+ if (!sampleTime || sampleTime < startTime || sampleTime > endTime) {
+ return;
+ }
+
+ let sampleFrames = sample.frames;
+
+ // Filter out platform frames if only content-related function calls
+ // should be taken into consideration.
+ if (options.contentOnly) {
+ // The (root) node is not considered a content function, it'll be removed.
+ sampleFrames = sampleFrames.filter(isContent);
+ } else {
+ // Remove the (root) node manually.
+ sampleFrames = sampleFrames.slice(1);
+ }
+ // If no frames remain after filtering, then this is a leaf node, no need
+ // to continue.
+ if (!sampleFrames.length) {
+ return;
+ }
+ // Invert the tree after filtering, if preferred.
+ if (options.invertTree) {
+ sampleFrames.reverse();
+ }
+
+ let sampleDuration = sampleTime - this._previousSampleTime;
+ this._previousSampleTime = sampleTime;
+ this.samples++;
+ this.duration += sampleDuration;
+
+ FrameNode.prototype.insert(
+ sampleFrames, 0, sampleTime, sampleDuration, this.calls);
+ },
+
+ /**
+ * Gets additional details about this node.
+ * @return object
+ */
+ getInfo: function() {
+ return {
+ nodeType: "Thread",
+ functionName: L10N.getStr("table.root"),
+ categoryData: {}
+ };
+ }
+};
+
+/**
+ * A function call node in a tree.
+ *
+ * @param string location
+ * The location of this function call. Note that this isn't sanitized,
+ * so it may very well (not?) include the function name, url, etc.
+ * @param number line
+ * The line number inside the source containing this function call.
+ * @param number column
+ * The column number inside the source containing this function call.
+ * @param number category
+ * The category type of this function call ("js", "graphics" etc.).
+ * @param number allocations
+ * The number of memory allocations performed in this frame.
+ */
+function FrameNode({ location, line, column, category, allocations }) {
+ this.location = location;
+ this.line = line;
+ this.column = column;
+ this.category = category;
+ this.allocations = allocations || 0;
+ this.sampleTimes = [];
+ this.samples = 0;
+ this.duration = 0;
+ this.calls = {};
+}
+
+FrameNode.prototype = {
+ /**
+ * Adds function calls in the tree from a sample's frames. For example, given
+ * the the frames below (which would account for three calls to `insert` on
+ * the root frame), the following tree structure is created:
+ *
+ * A
+ * A -> B -> C / \
+ * A -> B -> D ~> B E
+ * A -> E -> F / \ \
+ * C D F
+ * @param frames
+ * The sample call stack.
+ * @param index
+ * The index of the call in the stack representing this node.
+ * @param number time
+ * The delta time (in milliseconds) when the frame was sampled.
+ * @param number duration
+ * The amount of time spent executing all functions on the stack.
+ */
+ insert: function(frames, index, time, duration, _store = this.calls) {
+ let frame = frames[index];
+ if (!frame) {
+ return;
+ }
+ let location = frame.location;
+ let child = _store[location] || (_store[location] = new FrameNode(frame));
+ child.sampleTimes.push({ start: time, end: time + duration });
+ child.samples++;
+ child.duration += duration;
+ child.insert(frames, ++index, time, duration);
+ },
+
+ /**
+ * Parses the raw location of this function call to retrieve the actual
+ * function name and source url.
+ *
+ * @return object
+ * The computed { name, file, url, line } properties for this
+ * function call.
+ */
+ getInfo: function() {
+ // "EnterJIT" pseudoframes are special, not actually on the stack.
+ if (this.location == "EnterJIT") {
+ this.category = CATEGORY_JIT;
+ }
+
+ // Since only C++ stack frames have associated category information,
+ // default to an "unknown" category otherwise.
+ let categoryData = CATEGORY_MAPPINGS[this.category] || {};
+
+ // Parse the `location` for the function name, source url, line, column etc.
+ let lineAndColumn = this.location.match(/((:\d+)*)\)?$/)[1];
+ let [, line, column] = lineAndColumn.split(":");
+ line = line || this.line;
+ column = column || this.column;
+
+ let firstParenIndex = this.location.indexOf("(");
+ let lineAndColumnIndex = this.location.indexOf(lineAndColumn);
+ let resource = this.location.substring(firstParenIndex + 1, lineAndColumnIndex);
+
+ let url = resource.split(" -> ").pop();
+ let uri = nsIURL(url);
+ let functionName, fileName, hostName;
+
+ // If the URI digged out from the `location` is valid, this is a JS frame.
+ if (uri) {
+ functionName = this.location.substring(0, firstParenIndex - 1);
+ fileName = (uri.fileName + (uri.ref ? "#" + uri.ref : "")) || "/";
+ hostName = uri.host;
+ } else {
+ functionName = this.location;
+ url = null;
+ }
+
+ return {
+ nodeType: "Frame",
+ functionName: functionName,
+ fileName: fileName,
+ hostName: hostName,
+ url: url,
+ line: line,
+ column: column,
+ categoryData: categoryData,
+ isContent: !!isContent(this)
+ };
+ }
+};
+
+/**
+ * Checks if the specified function represents a chrome or content frame.
+ *
+ * @param object frame
+ * The { category, location } properties of the frame.
+ * @return boolean
+ * True if a content frame, false if a chrome frame.
+ */
+function isContent({ category, location }) {
+ // Only C++ stack frames have associated category information.
+ return !category &&
+ !CHROME_SCHEMES.find(e => location.contains(e)) &&
+ CONTENT_SCHEMES.find(e => location.contains(e));
+}
+
+/**
+ * Helper for getting an nsIURL instance out of a string.
+ */
+function nsIURL(url) {
+ let cached = gNSURLStore.get(url);
+ if (cached) {
+ return cached;
+ }
+ let uri = null;
+ try {
+ uri = Services.io.newURI(url, null, null).QueryInterface(Ci.nsIURL);
+ } catch(e) {
+ // The passed url string is invalid.
+ }
+ gNSURLStore.set(url, uri);
+ return uri;
+}
+
+// The cache used in the `nsIURL` function.
+let gNSURLStore = new Map();
diff --git a/toolkit/devtools/shared/profiler/tree-view.js b/toolkit/devtools/shared/profiler/tree-view.js
new file mode 100644
index 000000000..9a05e5dee
--- /dev/null
+++ b/toolkit/devtools/shared/profiler/tree-view.js
@@ -0,0 +1,345 @@
+/* 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, Cr} = require("chrome");
+
+loader.lazyRequireGetter(this, "L10N",
+ "devtools/shared/profiler/global", true);
+
+loader.lazyImporter(this, "Heritage",
+ "resource:///modules/devtools/ViewHelpers.jsm");
+loader.lazyImporter(this, "AbstractTreeItem",
+ "resource:///modules/devtools/AbstractTreeItem.jsm");
+
+const MILLISECOND_UNITS = L10N.getStr("table.ms");
+const PERCENTAGE_UNITS = L10N.getStr("table.percentage");
+const URL_LABEL_TOOLTIP = L10N.getStr("table.url.tooltiptext");
+const ZOOM_BUTTON_TOOLTIP = L10N.getStr("table.zoom.tooltiptext");
+const CALL_TREE_AUTO_EXPAND = 3; // depth
+const CALL_TREE_INDENTATION = 16; // px
+const DEFAULT_SORTING_PREDICATE = (a, b) => a.frame.samples < b.frame.samples ? 1 : -1;
+
+const clamp = (val, min, max) => Math.max(min, Math.min(max, val));
+const sum = vals => vals.reduce((a, b) => a + b, 0);
+
+exports.CallView = CallView;
+
+/**
+ * An item in a call tree view, which looks like this:
+ *
+ * Time (ms) | Cost | Calls | Function
+ * ============================================================================
+ * 1,000.00 | 100.00% | | ▼ (root)
+ * 500.12 | 50.01% | 300 | ▼ foo Categ. 1
+ * 300.34 | 30.03% | 1500 | ▼ bar Categ. 2
+ * 10.56 | 0.01% | 42 | ▶ call_with_children Categ. 3
+ * 90.78 | 0.09% | 25 | call_without_children Categ. 4
+ *
+ * Every instance of a `CallView` represents a row in the call tree. The same
+ * parent node is used for all rows.
+ *
+ * @param CallView caller
+ * The CallView considered the "caller" frame. This instance will be
+ * represent the "callee". Should be null for root nodes.
+ * @param ThreadNode | FrameNode frame
+ * Details about this function, like { samples, duration, calls } etc.
+ * @param number level
+ * The indentation level in the call tree. The root node is at level 0.
+ * @param boolean hidden [optional]
+ * Whether this node should be hidden and not contribute to depth/level
+ * calculations. Defaults to false.
+ * @param boolean inverted [optional]
+ * Whether the call tree has been inverted (bottom up, rather than
+ * top-down). Defaults to false.
+ * @param function sortingPredicate [optional]
+ * The predicate used to sort the tree items when created. Defaults to
+ * the caller's sortingPredicate if a caller exists, otherwise defaults
+ * to DEFAULT_SORTING_PREDICATE. The two passed arguments are FrameNodes.
+ * @param number autoExpandDepth [optional]
+ * The depth to which the tree should automatically expand. Defualts to
+ * the caller's `autoExpandDepth` if a caller exists, otherwise defaults
+ * to CALL_TREE_AUTO_EXPAND.
+ */
+function CallView({ caller, frame, level, hidden, inverted, sortingPredicate, autoExpandDepth }) {
+ // Assume no indentation if this tree item's level is not specified.
+ level = level || 0;
+
+ // Don't increase indentation if this tree item is hidden.
+ if (hidden) {
+ level--;
+ }
+
+ AbstractTreeItem.call(this, { parent: caller, level });
+
+ this.sortingPredicate = sortingPredicate != null
+ ? sortingPredicate
+ : caller ? caller.sortingPredicate
+ : DEFAULT_SORTING_PREDICATE
+
+ this.autoExpandDepth = autoExpandDepth != null
+ ? autoExpandDepth
+ : caller ? caller.autoExpandDepth
+ : CALL_TREE_AUTO_EXPAND;
+
+ this.caller = caller;
+ this.frame = frame;
+ this.hidden = hidden;
+ this.inverted = inverted;
+
+ this._onUrlClick = this._onUrlClick.bind(this);
+ this._onZoomClick = this._onZoomClick.bind(this);
+};
+
+CallView.prototype = Heritage.extend(AbstractTreeItem.prototype, {
+ /**
+ * Creates the view for this tree node.
+ * @param nsIDOMNode document
+ * @param nsIDOMNode arrowNode
+ * @return nsIDOMNode
+ */
+ _displaySelf: function(document, arrowNode) {
+ this.document = document;
+
+ let frameInfo = this.frame.getInfo();
+ let framePercentage = this._getPercentage(this.frame.samples);
+
+ let selfPercentage;
+ let selfDuration;
+ let totalAllocations;
+
+ if (!this._getChildCalls().length) {
+ selfPercentage = framePercentage;
+ selfDuration = this.frame.duration;
+ totalAllocations = this.frame.allocations;
+ } else {
+ let childrenPercentage = sum(
+ [this._getPercentage(c.samples) for (c of this._getChildCalls())]);
+ let childrenDuration = sum(
+ [c.duration for (c of this._getChildCalls())]);
+ let childrenAllocations = sum(
+ [c.allocations for (c of this._getChildCalls())]);
+
+ selfPercentage = clamp(framePercentage - childrenPercentage, 0, 100);
+ selfDuration = this.frame.duration - childrenDuration;
+ totalAllocations = this.frame.allocations + childrenAllocations;
+
+ if (this.inverted) {
+ selfPercentage = framePercentage - selfPercentage;
+ selfDuration = this.frame.duration - selfDuration;
+ }
+ }
+
+ let durationCell = this._createTimeCell(this.frame.duration);
+ let selfDurationCell = this._createTimeCell(selfDuration, true);
+ let percentageCell = this._createExecutionCell(framePercentage);
+ let selfPercentageCell = this._createExecutionCell(selfPercentage, true);
+ let allocationsCell = this._createAllocationsCell(totalAllocations);
+ let selfAllocationsCell = this._createAllocationsCell(this.frame.allocations, true);
+ let samplesCell = this._createSamplesCell(this.frame.samples);
+ let functionCell = this._createFunctionCell(arrowNode, frameInfo, this.level);
+
+ let targetNode = document.createElement("hbox");
+ targetNode.className = "call-tree-item";
+ targetNode.setAttribute("origin", frameInfo.isContent ? "content" : "chrome");
+ targetNode.setAttribute("category", frameInfo.categoryData.abbrev || "");
+ targetNode.setAttribute("tooltiptext", this.frame.location || "");
+ if (this.hidden) {
+ targetNode.style.display = "none";
+ }
+
+ let isRoot = frameInfo.nodeType == "Thread";
+ if (isRoot) {
+ functionCell.querySelector(".call-tree-zoom").hidden = true;
+ functionCell.querySelector(".call-tree-category").hidden = true;
+ }
+
+ targetNode.appendChild(durationCell);
+ targetNode.appendChild(percentageCell);
+ targetNode.appendChild(allocationsCell);
+ targetNode.appendChild(selfDurationCell);
+ targetNode.appendChild(selfPercentageCell);
+ targetNode.appendChild(selfAllocationsCell);
+ targetNode.appendChild(samplesCell);
+ targetNode.appendChild(functionCell);
+
+ return targetNode;
+ },
+
+ /**
+ * Calculate what percentage of all samples the given number of samples is.
+ */
+ _getPercentage: function(samples) {
+ return samples / this.root.frame.samples * 100;
+ },
+
+ /**
+ * Return an array of this frame's child calls.
+ */
+ _getChildCalls: function() {
+ return Object.keys(this.frame.calls).map(k => this.frame.calls[k]);
+ },
+
+ /**
+ * Populates this node in the call tree with the corresponding "callees".
+ * These are defined in the `frame` data source for this call view.
+ * @param array:AbstractTreeItem children
+ */
+ _populateSelf: function(children) {
+ let newLevel = this.level + 1;
+
+ for (let newFrame of this._getChildCalls()) {
+ children.push(new CallView({
+ caller: this,
+ frame: newFrame,
+ level: newLevel,
+ inverted: this.inverted
+ }));
+ }
+
+ // Sort the "callees" asc. by samples, before inserting them in the tree,
+ // if no other sorting predicate was specified on this on the root item.
+ children.sort(this.sortingPredicate);
+ },
+
+ /**
+ * Functions creating each cell in this call view.
+ * Invoked by `_displaySelf`.
+ */
+ _createTimeCell: function(duration, isSelf = false) {
+ let cell = this.document.createElement("label");
+ cell.className = "plain call-tree-cell";
+ cell.setAttribute("type", isSelf ? "self-duration" : "duration");
+ cell.setAttribute("crop", "end");
+ cell.setAttribute("value", L10N.numberWithDecimals(duration, 2) + " " + MILLISECOND_UNITS);
+ return cell;
+ },
+ _createExecutionCell: function(percentage, isSelf = false) {
+ let cell = this.document.createElement("label");
+ cell.className = "plain call-tree-cell";
+ cell.setAttribute("type", isSelf ? "self-percentage" : "percentage");
+ cell.setAttribute("crop", "end");
+ cell.setAttribute("value", L10N.numberWithDecimals(percentage, 2) + PERCENTAGE_UNITS);
+ return cell;
+ },
+ _createAllocationsCell: function(count, isSelf = false) {
+ let cell = this.document.createElement("label");
+ cell.className = "plain call-tree-cell";
+ cell.setAttribute("type", isSelf ? "self-allocations" : "allocations");
+ cell.setAttribute("crop", "end");
+ cell.setAttribute("value", count || 0);
+ return cell;
+ },
+ _createSamplesCell: function(count) {
+ let cell = this.document.createElement("label");
+ cell.className = "plain call-tree-cell";
+ cell.setAttribute("type", "samples");
+ cell.setAttribute("crop", "end");
+ cell.setAttribute("value", count || "");
+ return cell;
+ },
+ _createFunctionCell: function(arrowNode, frameInfo, frameLevel) {
+ let cell = this.document.createElement("hbox");
+ cell.className = "call-tree-cell";
+ cell.style.MozMarginStart = (frameLevel * CALL_TREE_INDENTATION) + "px";
+ cell.setAttribute("type", "function");
+ cell.appendChild(arrowNode);
+
+ let nameNode = this.document.createElement("label");
+ nameNode.className = "plain call-tree-name";
+ nameNode.setAttribute("flex", "1");
+ nameNode.setAttribute("crop", "end");
+ nameNode.setAttribute("value", frameInfo.functionName || "");
+ cell.appendChild(nameNode);
+
+ let urlNode = this.document.createElement("label");
+ urlNode.className = "plain call-tree-url";
+ urlNode.setAttribute("flex", "1");
+ urlNode.setAttribute("crop", "end");
+ urlNode.setAttribute("value", frameInfo.fileName || "");
+ urlNode.setAttribute("tooltiptext", URL_LABEL_TOOLTIP + " → " + frameInfo.url);
+ urlNode.addEventListener("mousedown", this._onUrlClick);
+ cell.appendChild(urlNode);
+
+ let lineNode = this.document.createElement("label");
+ lineNode.className = "plain call-tree-line";
+ lineNode.setAttribute("value", frameInfo.line ? ":" + frameInfo.line : "");
+ cell.appendChild(lineNode);
+
+ let columnNode = this.document.createElement("label");
+ columnNode.className = "plain call-tree-column";
+ columnNode.setAttribute("value", frameInfo.column ? ":" + frameInfo.column : "");
+ cell.appendChild(columnNode);
+
+ let hostNode = this.document.createElement("label");
+ hostNode.className = "plain call-tree-host";
+ hostNode.setAttribute("value", frameInfo.hostName || "");
+ cell.appendChild(hostNode);
+
+ let zoomNode = this.document.createElement("button");
+ zoomNode.className = "plain call-tree-zoom";
+ zoomNode.setAttribute("tooltiptext", ZOOM_BUTTON_TOOLTIP);
+ zoomNode.addEventListener("mousedown", this._onZoomClick);
+ cell.appendChild(zoomNode);
+
+ let spacerNode = this.document.createElement("spacer");
+ spacerNode.setAttribute("flex", "10000");
+ cell.appendChild(spacerNode);
+
+ let categoryNode = this.document.createElement("label");
+ categoryNode.className = "plain call-tree-category";
+ categoryNode.style.color = frameInfo.categoryData.color;
+ categoryNode.setAttribute("value", frameInfo.categoryData.label || "");
+ cell.appendChild(categoryNode);
+
+ let hasDescendants = Object.keys(this.frame.calls).length > 0;
+ if (hasDescendants == false) {
+ arrowNode.setAttribute("invisible", "");
+ }
+
+ return cell;
+ },
+
+ /**
+ * Toggles the allocations information hidden or visible.
+ * @param boolean visible
+ */
+ toggleAllocations: function(visible) {
+ if (!visible) {
+ this.container.setAttribute("allocations-hidden", "");
+ } else {
+ this.container.removeAttribute("allocations-hidden");
+ }
+ },
+
+ /**
+ * Toggles the category information hidden or visible.
+ * @param boolean visible
+ */
+ toggleCategories: function(visible) {
+ if (!visible) {
+ this.container.setAttribute("categories-hidden", "");
+ } else {
+ this.container.removeAttribute("categories-hidden");
+ }
+ },
+
+ /**
+ * Handler for the "click" event on the url node of this call view.
+ */
+ _onUrlClick: function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.root.emit("link", this);
+ },
+
+ /**
+ * Handler for the "click" event on the zoom node of this call view.
+ */
+ _onZoomClick: function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.root.emit("zoom", this);
+ }
+});
diff --git a/toolkit/devtools/shared/splitview.css b/toolkit/devtools/shared/splitview.css
new file mode 100644
index 000000000..38e35e593
--- /dev/null
+++ b/toolkit/devtools/shared/splitview.css
@@ -0,0 +1,99 @@
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* 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/. */
+
+box,
+.splitview-nav {
+ -moz-box-flex: 1;
+ -moz-box-orient: vertical;
+}
+
+.splitview-nav-container {
+ -moz-box-pack: center;
+}
+
+.loading .splitview-nav-container > .placeholder {
+ display: none !important;
+}
+
+.splitview-controller,
+.splitview-main {
+ -moz-box-flex: 0;
+}
+
+.splitview-controller {
+ min-height: 3em;
+ max-height: 14em;
+ max-width: 400px;
+ min-width: 200px;
+}
+
+.splitview-nav {
+ display: -moz-box;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+/* only the active details pane is shown */
+.splitview-side-details > * {
+ display: none;
+}
+.splitview-side-details > .splitview-active {
+ display: -moz-box;
+}
+
+.splitview-landscape-resizer {
+ cursor: ew-resize;
+}
+
+/* this is to keep in sync with SplitView.jsm's LANDSCAPE_MEDIA_QUERY */
+@media (min-width: 551px) {
+ .splitview-root {
+ -moz-box-orient: horizontal;
+ }
+ .splitview-controller {
+ max-height: none;
+ }
+ .splitview-details {
+ display: none;
+ }
+ .splitview-details.splitview-active {
+ display: -moz-box;
+ }
+}
+
+/* filtered items are hidden */
+ol.splitview-nav > li.splitview-filtered {
+ display: none;
+}
+
+/* "empty list" and "all filtered" placeholders are hidden */
+.splitview-nav:empty,
+.splitview-nav.splitview-all-filtered,
+.splitview-nav + .splitview-nav.placeholder {
+ display: none;
+}
+.splitview-nav.splitview-all-filtered ~ .splitview-nav.placeholder.all-filtered,
+.splitview-nav:empty ~ .splitview-nav.placeholder.empty {
+ display: -moz-box;
+}
+
+.splitview-portrait-resizer {
+ display: none;
+}
+
+/* portrait mode */
+@media (max-width: 550px) {
+ .splitview-landscape-splitter {
+ display: none;
+ }
+
+ .splitview-portrait-resizer {
+ display: -moz-box;
+ }
+
+ .splitview-controller {
+ max-width: none;
+ }
+}
diff --git a/toolkit/devtools/shared/telemetry.js b/toolkit/devtools/shared/telemetry.js
new file mode 100644
index 000000000..5221da113
--- /dev/null
+++ b/toolkit/devtools/shared/telemetry.js
@@ -0,0 +1,315 @@
+/* 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/. */
+
+/**
+ * Telemetry.
+ *
+ * To add metrics for a tool:
+ *
+ * 1. Create boolean, flag and exponential entries in
+ * toolkit/components/telemetry/Histograms.json. Each type is optional but it
+ * is best if all three can be included.
+ *
+ * 2. Add your chart entries to browser/devtools/shared/telemetry.js
+ * (Telemetry.prototype._histograms):
+ * mytoolname: {
+ * histogram: "DEVTOOLS_MYTOOLNAME_OPENED_BOOLEAN",
+ * userHistogram: "DEVTOOLS_MYTOOLNAME_OPENED_PER_USER_FLAG",
+ * timerHistogram: "DEVTOOLS_MYTOOLNAME_TIME_ACTIVE_SECONDS"
+ * },
+ *
+ * 3. Include this module at the top of your tool. Use:
+ * let Telemetry = require("devtools/shared/telemetry")
+ *
+ * 4. Create a telemetry instance in your tool's constructor:
+ * this._telemetry = new Telemetry();
+ *
+ * 5. When your tool is opened call:
+ * this._telemetry.toolOpened("mytoolname");
+ *
+ * 6. When your tool is closed call:
+ * this._telemetry.toolClosed("mytoolname");
+ *
+ * Note:
+ * You can view telemetry stats for your local Firefox instance via
+ * about:telemetry.
+ *
+ * You can view telemetry stats for large groups of Firefox users at
+ * telemetry.mozilla.org.
+ */
+
+const TOOLS_OPENED_PREF = "devtools.telemetry.tools.opened.version";
+
+this.Telemetry = function() {
+ // Bind pretty much all functions so that callers do not need to.
+ this.toolOpened = this.toolOpened.bind(this);
+ this.toolClosed = this.toolClosed.bind(this);
+ this.log = this.log.bind(this);
+ this.logOncePerBrowserVersion = this.logOncePerBrowserVersion.bind(this);
+ this.destroy = this.destroy.bind(this);
+
+ this._timers = new Map();
+};
+
+module.exports = Telemetry;
+
+let {Cc, Ci, Cu} = require("chrome");
+let {Services} = Cu.import("resource://gre/modules/Services.jsm", {});
+let {XPCOMUtils} = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {});
+
+Telemetry.prototype = {
+ _histograms: {
+ toolbox: {
+ histogram: "DEVTOOLS_TOOLBOX_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_TOOLBOX_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_TOOLBOX_TIME_ACTIVE_SECONDS"
+ },
+ options: {
+ histogram: "DEVTOOLS_OPTIONS_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_OPTIONS_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_OPTIONS_TIME_ACTIVE_SECONDS"
+ },
+ webconsole: {
+ histogram: "DEVTOOLS_WEBCONSOLE_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_WEBCONSOLE_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_WEBCONSOLE_TIME_ACTIVE_SECONDS"
+ },
+ browserconsole: {
+ histogram: "DEVTOOLS_BROWSERCONSOLE_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_BROWSERCONSOLE_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_BROWSERCONSOLE_TIME_ACTIVE_SECONDS"
+ },
+ inspector: {
+ histogram: "DEVTOOLS_INSPECTOR_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_INSPECTOR_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_INSPECTOR_TIME_ACTIVE_SECONDS"
+ },
+ ruleview: {
+ histogram: "DEVTOOLS_RULEVIEW_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_RULEVIEW_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_RULEVIEW_TIME_ACTIVE_SECONDS"
+ },
+ computedview: {
+ histogram: "DEVTOOLS_COMPUTEDVIEW_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_COMPUTEDVIEW_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_COMPUTEDVIEW_TIME_ACTIVE_SECONDS"
+ },
+ layoutview: {
+ histogram: "DEVTOOLS_LAYOUTVIEW_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_LAYOUTVIEW_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_LAYOUTVIEW_TIME_ACTIVE_SECONDS"
+ },
+ fontinspector: {
+ histogram: "DEVTOOLS_FONTINSPECTOR_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_FONTINSPECTOR_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_FONTINSPECTOR_TIME_ACTIVE_SECONDS"
+ },
+ animationinspector: {
+ histogram: "DEVTOOLS_ANIMATIONINSPECTOR_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_ANIMATIONINSPECTOR_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_ANIMATIONINSPECTOR_TIME_ACTIVE_SECONDS"
+ },
+ jsdebugger: {
+ histogram: "DEVTOOLS_JSDEBUGGER_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_JSDEBUGGER_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_JSDEBUGGER_TIME_ACTIVE_SECONDS"
+ },
+ jsbrowserdebugger: {
+ histogram: "DEVTOOLS_JSBROWSERDEBUGGER_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_JSBROWSERDEBUGGER_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_JSBROWSERDEBUGGER_TIME_ACTIVE_SECONDS"
+ },
+ styleeditor: {
+ histogram: "DEVTOOLS_STYLEEDITOR_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_STYLEEDITOR_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_STYLEEDITOR_TIME_ACTIVE_SECONDS"
+ },
+ shadereditor: {
+ histogram: "DEVTOOLS_SHADEREDITOR_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_SHADEREDITOR_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_SHADEREDITOR_TIME_ACTIVE_SECONDS"
+ },
+ webaudioeditor: {
+ histogram: "DEVTOOLS_WEBAUDIOEDITOR_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_WEBAUDIOEDITOR_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_WEBAUDIOEDITOR_TIME_ACTIVE_SECONDS"
+ },
+ canvasdebugger: {
+ histogram: "DEVTOOLS_CANVASDEBUGGER_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_CANVASDEBUGGER_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_CANVASDEBUGGER_TIME_ACTIVE_SECONDS"
+ },
+ jsprofiler: {
+ histogram: "DEVTOOLS_JSPROFILER_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_JSPROFILER_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_JSPROFILER_TIME_ACTIVE_SECONDS"
+ },
+ netmonitor: {
+ histogram: "DEVTOOLS_NETMONITOR_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_NETMONITOR_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_NETMONITOR_TIME_ACTIVE_SECONDS"
+ },
+ storage: {
+ histogram: "DEVTOOLS_STORAGE_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_STORAGE_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_STORAGE_TIME_ACTIVE_SECONDS"
+ },
+ tilt: {
+ histogram: "DEVTOOLS_TILT_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_TILT_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_TILT_TIME_ACTIVE_SECONDS"
+ },
+ paintflashing: {
+ histogram: "DEVTOOLS_PAINTFLASHING_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_PAINTFLASHING_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_PAINTFLASHING_TIME_ACTIVE_SECONDS"
+ },
+ scratchpad: {
+ histogram: "DEVTOOLS_SCRATCHPAD_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_SCRATCHPAD_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_SCRATCHPAD_TIME_ACTIVE_SECONDS"
+ },
+ responsive: {
+ histogram: "DEVTOOLS_RESPONSIVE_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_RESPONSIVE_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_RESPONSIVE_TIME_ACTIVE_SECONDS"
+ },
+ eyedropper: {
+ histogram: "DEVTOOLS_EYEDROPPER_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_EYEDROPPER_OPENED_PER_USER_FLAG",
+ },
+ menueyedropper: {
+ histogram: "DEVTOOLS_MENU_EYEDROPPER_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_MENU_EYEDROPPER_OPENED_PER_USER_FLAG",
+ },
+ pickereyedropper: {
+ histogram: "DEVTOOLS_PICKER_EYEDROPPER_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_PICKER_EYEDROPPER_OPENED_PER_USER_FLAG",
+ },
+ developertoolbar: {
+ histogram: "DEVTOOLS_DEVELOPERTOOLBAR_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_DEVELOPERTOOLBAR_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_DEVELOPERTOOLBAR_TIME_ACTIVE_SECONDS"
+ },
+ webide: {
+ histogram: "DEVTOOLS_WEBIDE_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_WEBIDE_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_WEBIDE_TIME_ACTIVE_SECONDS"
+ },
+ custom: {
+ histogram: "DEVTOOLS_CUSTOM_OPENED_BOOLEAN",
+ userHistogram: "DEVTOOLS_CUSTOM_OPENED_PER_USER_FLAG",
+ timerHistogram: "DEVTOOLS_CUSTOM_TIME_ACTIVE_SECONDS"
+ }
+ },
+
+ /**
+ * Add an entry to a histogram.
+ *
+ * @param {String} id
+ * Used to look up the relevant histogram ID and log true to that
+ * histogram.
+ */
+ toolOpened: function(id) {
+ let charts = this._histograms[id] || this._histograms.custom;
+
+ if (charts.histogram) {
+ this.log(charts.histogram, true);
+ }
+ if (charts.userHistogram) {
+ this.logOncePerBrowserVersion(charts.userHistogram, true);
+ }
+ if (charts.timerHistogram) {
+ this.startTimer(charts.timerHistogram);
+ }
+ },
+
+ toolClosed: function(id) {
+ let charts = this._histograms[id];
+
+ if (!charts || !charts.timerHistogram) {
+ return;
+ }
+
+ this.stopTimer(charts.timerHistogram);
+ },
+
+ /**
+ * Record the start time for a timing-based histogram entry.
+ *
+ * @param String histogramId
+ * Histogram in which the data is to be stored.
+ */
+ startTimer: function(histogramId) {
+ this._timers.set(histogramId, new Date());
+ },
+
+ /**
+ * Stop the timer and log elasped time for a timing-based histogram entry.
+ *
+ * @param String histogramId
+ * Histogram in which the data is to be stored.
+ */
+ stopTimer: function(histogramId) {
+ let startTime = this._timers.get(histogramId);
+ if (startTime) {
+ let time = (new Date() - startTime) / 1000;
+ this.log(histogramId, time);
+ this._timers.delete(histogramId);
+ }
+ },
+
+ /**
+ * Log a value to a histogram.
+ *
+ * @param {String} histogramId
+ * Histogram in which the data is to be stored.
+ * @param value
+ * Value to store.
+ */
+ log: function(histogramId, value) {
+ if (histogramId) {
+ try {
+ let histogram = Services.telemetry.getHistogramById(histogramId);
+ histogram.add(value);
+ } catch(e) {
+ dump("Warning: An attempt was made to write to the " + histogramId +
+ " histogram, which is not defined in Histograms.json\n");
+ }
+ }
+ },
+
+ /**
+ * Log info about usage once per browser version. This allows us to discover
+ * how many individual users are using our tools for each browser version.
+ *
+ * @param {String} perUserHistogram
+ * Histogram in which the data is to be stored.
+ */
+ logOncePerBrowserVersion: function(perUserHistogram, value) {
+ let currentVersion = appInfo.version;
+ let latest = Services.prefs.getCharPref(TOOLS_OPENED_PREF);
+ let latestObj = JSON.parse(latest);
+
+ let lastVersionHistogramUpdated = latestObj[perUserHistogram];
+
+ if (typeof lastVersionHistogramUpdated == "undefined" ||
+ lastVersionHistogramUpdated !== currentVersion) {
+ latestObj[perUserHistogram] = currentVersion;
+ latest = JSON.stringify(latestObj);
+ Services.prefs.setCharPref(TOOLS_OPENED_PREF, latest);
+ this.log(perUserHistogram, value);
+ }
+ },
+
+ destroy: function() {
+ for (let histogramId of this._timers.keys()) {
+ this.stopTimer(histogramId);
+ }
+ }
+};
+
+XPCOMUtils.defineLazyGetter(this, "appInfo", function() {
+ return Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo);
+});
diff --git a/toolkit/devtools/shared/test/browser.ini b/toolkit/devtools/shared/test/browser.ini
new file mode 100644
index 000000000..096238c21
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser.ini
@@ -0,0 +1,98 @@
+[DEFAULT]
+subsuite = devtools
+support-files =
+ browser_layoutHelpers.html
+ browser_layoutHelpers-getBoxQuads.html
+ browser_layoutHelpers_iframe.html
+ browser_templater_basic.html
+ browser_toolbar_basic.html
+ browser_toolbar_webconsole_errors_count.html
+ doc_options-view.xul
+ head.js
+ leakhunt.js
+
+[browser_css_color.js]
+[browser_cubic-bezier-01.js]
+[browser_cubic-bezier-02.js]
+[browser_cubic-bezier-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-04.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-hash.js]
+[browser_graphs-01.js]
+[browser_graphs-02.js]
+[browser_graphs-03.js]
+[browser_graphs-04.js]
+[browser_graphs-05.js]
+[browser_graphs-06.js]
+[browser_graphs-07a.js]
+[browser_graphs-07b.js]
+[browser_graphs-08.js]
+[browser_graphs-09a.js]
+[browser_graphs-09b.js]
+[browser_graphs-09c.js]
+[browser_graphs-09d.js]
+[browser_graphs-09e.js]
+[browser_graphs-09f.js]
+[browser_graphs-10a.js]
+[browser_graphs-10b.js]
+[browser_graphs-11a.js]
+[browser_graphs-11b.js]
+[browser_graphs-12.js]
+[browser_graphs-13.js]
+[browser_graphs-14.js]
+[browser_inplace-editor.js]
+[browser_layoutHelpers.js]
+skip-if = e10s # Layouthelpers test should not run in a content page.
+[browser_layoutHelpers-getBoxQuads.js]
+skip-if = e10s # Layouthelpers test should not run in a content page.
+[browser_num-l10n.js]
+[browser_observableobject.js]
+[browser_options-view-01.js]
+[browser_outputparser.js]
+skip-if = e10s # Test intermittently fails with e10s. Bug 1124162.
+[browser_prefs.js]
+[browser_require_basic.js]
+[browser_spectrum.js]
+[browser_theme.js]
+[browser_tableWidget_basic.js]
+[browser_tableWidget_keyboard_interaction.js]
+[browser_tableWidget_mouse_interaction.js]
+skip-if = buildapp == 'mulet'
+[browser_telemetry_button_eyedropper.js]
+[browser_telemetry_button_paintflashing.js]
+skip-if = e10s # Bug 937167 - e10s paintflashing
+[browser_telemetry_button_responsive.js]
+skip-if = e10s # Bug 1067145 - e10s responsiveview
+[browser_telemetry_button_scratchpad.js]
+[browser_telemetry_button_tilt.js]
+skip-if = e10s # Bug 1086492 - Disable tilt for e10s
+ # Bug 937166 - Make tilt work in E10S mode
+[browser_telemetry_sidebar.js]
+[browser_telemetry_toolbox.js]
+[browser_telemetry_toolboxtabs_canvasdebugger.js]
+[browser_telemetry_toolboxtabs_inspector.js]
+[browser_telemetry_toolboxtabs_jsdebugger.js]
+[browser_telemetry_toolboxtabs_jsprofiler.js]
+[browser_telemetry_toolboxtabs_netmonitor.js]
+[browser_telemetry_toolboxtabs_options.js]
+[browser_telemetry_toolboxtabs_shadereditor.js]
+[browser_telemetry_toolboxtabs_storage.js]
+[browser_telemetry_toolboxtabs_styleeditor.js]
+[browser_telemetry_toolboxtabs_webaudioeditor.js]
+[browser_telemetry_toolboxtabs_webconsole.js]
+[browser_templater_basic.js]
+[browser_toolbar_basic.js]
+[browser_toolbar_tooltip.js]
+[browser_toolbar_webconsole_errors_count.js]
+skip-if = buildapp == 'mulet' || e10s # The developertoolbar error count isn't correct with e10s
+[browser_treeWidget_basic.js]
+[browser_treeWidget_keyboard_interaction.js]
+[browser_treeWidget_mouse_interaction.js]
diff --git a/toolkit/devtools/shared/test/browser_css_color.js b/toolkit/devtools/shared/test/browser_css_color.js
new file mode 100644
index 000000000..4f4f29be5
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_css_color.js
@@ -0,0 +1,316 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const COLOR_UNIT_PREF = "devtools.defaultColorUnit";
+const TEST_URI = "data:text/html;charset=utf-8,browser_css_color.js";
+let {colorUtils} = devtools.require("devtools/css-color");
+let origColorUnit;
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ let [host, win, doc] = yield createHost("bottom", TEST_URI);
+ origColorUnit = Services.prefs.getCharPref(COLOR_UNIT_PREF);
+
+ info("Creating a test canvas element to test colors");
+ let canvas = createTestCanvas(doc);
+ info("Starting the test");
+ testColorUtils(canvas);
+
+ host.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+function createTestCanvas(doc) {
+ let canvas = doc.createElement("canvas");
+ canvas.width = canvas.height = 10;
+ doc.body.appendChild(canvas);
+ return canvas;
+}
+
+function testColorUtils(canvas) {
+ let data = getTestData();
+
+ for (let {authored, name, hex, hsl, rgb} of data) {
+ let color = new colorUtils.CssColor(authored);
+
+ // Check all values.
+ info("Checking values for " + authored);
+ is(color.name, name, "color.name === name");
+ is(color.hex, hex, "color.hex === hex");
+ is(color.hsl, hsl, "color.hsl === hsl");
+ is(color.rgb, rgb, "color.rgb === rgb");
+
+ testToString(color, name, hex, hsl, rgb);
+ testColorMatch(name, hex, hsl, rgb, color.rgba, canvas);
+ }
+
+ testProcessCSSString();
+ testSetAlpha();
+}
+
+function testToString(color, name, hex, hsl, rgb) {
+ switchColorUnit(colorUtils.CssColor.COLORUNIT.name);
+ is(color.toString(), name, "toString() with authored type");
+
+ switchColorUnit(colorUtils.CssColor.COLORUNIT.hex);
+ is(color.toString(), hex, "toString() with hex type");
+
+ switchColorUnit(colorUtils.CssColor.COLORUNIT.hsl);
+ is(color.toString(), hsl, "toString() with hsl type");
+
+ switchColorUnit(colorUtils.CssColor.COLORUNIT.rgb);
+ is(color.toString(), rgb, "toString() with rgb type");
+}
+
+function switchColorUnit(unit) {
+ Services.prefs.setCharPref(COLOR_UNIT_PREF, unit);
+}
+
+function testColorMatch(name, hex, hsl, rgb, rgba, canvas) {
+ let target;
+ let ctx = canvas.getContext("2d");
+
+ let clearCanvas = function() {
+ canvas.width = 1;
+ };
+ let setColor = function(aColor) {
+ ctx.fillStyle = aColor;
+ ctx.fillRect(0, 0, 1, 1);
+ };
+ let setTargetColor = function() {
+ clearCanvas();
+ // All colors have rgba so we can use this to compare against.
+ setColor(rgba);
+ let [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
+ target = {r: r, g: g, b: b, a: a};
+ };
+ let test = function(aColor, type) {
+ let tolerance = 3; // hsla -> rgba -> hsla produces inaccurate results so we
+ // need some tolerence here.
+ clearCanvas();
+
+ setColor(aColor);
+ let [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
+
+ let rgbFail = Math.abs(r - target.r) > tolerance ||
+ Math.abs(g - target.g) > tolerance ||
+ Math.abs(b - target.b) > tolerance;
+ ok(!rgbFail, "color " + rgba + " matches target. Type: " + type);
+ if (rgbFail) {
+ info("target: " + (target.toSource()) + ", color: [r: " + r + ", g: " + g + ", b: " + b + ", a: " + a + "]");
+ }
+
+ let alphaFail = a !== target.a;
+ ok(!alphaFail, "color " + rgba + " alpha value matches target.");
+ };
+
+ setTargetColor();
+
+ test(name, "name");
+ test(hex, "hex");
+ test(hsl, "hsl");
+ test(rgb, "rgb");
+ switchColorUnit(origColorUnit);
+}
+
+function testProcessCSSString() {
+ let before = "border: 1px solid red; border-radius: 5px; " +
+ "color rgb(0, 255, 0); font-weight: bold; " +
+ "background-color: transparent; " +
+ "border-top-color: rgba(0, 0, 255, 0.5);";
+ let expected = "border: 1px solid #F00; border-radius: 5px; " +
+ "color #0F0; font-weight: bold; " +
+ "background-color: transparent; " +
+ "border-top-color: rgba(0, 0, 255, 0.5);";
+ let after = colorUtils.processCSSString(before);
+
+ is(after, expected, "CSS string processed correctly");
+}
+
+function testSetAlpha() {
+ let values = [
+ ["longhex", "#ff0000", 0.5, "rgba(255, 0, 0, 0.5)"],
+ ["hex", "#f0f", 0.2, "rgba(255, 0, 255, 0.2)"],
+ ["rgba", "rgba(120, 34, 23, 1)", 0.25, "rgba(120, 34, 23, 0.25)"],
+ ["rgb", "rgb(120, 34, 23)", 0.25, "rgba(120, 34, 23, 0.25)"],
+ ["hsl", "hsl(208, 100%, 97%)", 0.75, "rgba(239, 247, 255, 0.75)"],
+ ["hsla", "hsla(208, 100%, 97%, 1)", 0.75, "rgba(239, 247, 255, 0.75)"]
+ ];
+ values.forEach(([type, value, alpha, expected]) => {
+ is(colorUtils.setAlpha(value, alpha), expected, "correctly sets alpha value for " + type);
+ });
+
+ try {
+ colorUtils.setAlpha("rgb(24, 25, 45, 1)", 1);
+ ok(false, "Should fail when passing in an invalid color.");
+ } catch (e) {
+ ok(true, "Fails when setAlpha receives an invalid color.");
+ }
+
+ is(colorUtils.setAlpha("#fff"), "rgba(255, 255, 255, 1)", "sets alpha to 1 if invalid.");
+}
+
+function getTestData() {
+ return [
+ {authored: "aliceblue", name: "aliceblue", hex: "#F0F8FF", hsl: "hsl(208, 100%, 97%)", rgb: "rgb(240, 248, 255)"},
+ {authored: "antiquewhite", name: "antiquewhite", hex: "#FAEBD7", hsl: "hsl(34, 78%, 91%)", rgb: "rgb(250, 235, 215)"},
+ {authored: "aqua", name: "aqua", hex: "#0FF", hsl: "hsl(180, 100%, 50%)", rgb: "rgb(0, 255, 255)"},
+ {authored: "aquamarine", name: "aquamarine", hex: "#7FFFD4", hsl: "hsl(160, 100%, 75%)", rgb: "rgb(127, 255, 212)"},
+ {authored: "azure", name: "azure", hex: "#F0FFFF", hsl: "hsl(180, 100%, 97%)", rgb: "rgb(240, 255, 255)"},
+ {authored: "beige", name: "beige", hex: "#F5F5DC", hsl: "hsl(60, 56%, 91%)", rgb: "rgb(245, 245, 220)"},
+ {authored: "bisque", name: "bisque", hex: "#FFE4C4", hsl: "hsl(33, 100%, 88%)", rgb: "rgb(255, 228, 196)"},
+ {authored: "black", name: "black", hex: "#000", hsl: "hsl(0, 0%, 0%)", rgb: "rgb(0, 0, 0)"},
+ {authored: "blanchedalmond", name: "blanchedalmond", hex: "#FFEBCD", hsl: "hsl(36, 100%, 90%)", rgb: "rgb(255, 235, 205)"},
+ {authored: "blue", name: "blue", hex: "#00F", hsl: "hsl(240, 100%, 50%)", rgb: "rgb(0, 0, 255)"},
+ {authored: "blueviolet", name: "blueviolet", hex: "#8A2BE2", hsl: "hsl(271, 76%, 53%)", rgb: "rgb(138, 43, 226)"},
+ {authored: "brown", name: "brown", hex: "#A52A2A", hsl: "hsl(0, 59%, 41%)", rgb: "rgb(165, 42, 42)"},
+ {authored: "burlywood", name: "burlywood", hex: "#DEB887", hsl: "hsl(34, 57%, 70%)", rgb: "rgb(222, 184, 135)"},
+ {authored: "cadetblue", name: "cadetblue", hex: "#5F9EA0", hsl: "hsl(182, 25%, 50%)", rgb: "rgb(95, 158, 160)"},
+ {authored: "chartreuse", name: "chartreuse", hex: "#7FFF00", hsl: "hsl(90, 100%, 50%)", rgb: "rgb(127, 255, 0)"},
+ {authored: "chocolate", name: "chocolate", hex: "#D2691E", hsl: "hsl(25, 75%, 47%)", rgb: "rgb(210, 105, 30)"},
+ {authored: "coral", name: "coral", hex: "#FF7F50", hsl: "hsl(16, 100%, 66%)", rgb: "rgb(255, 127, 80)"},
+ {authored: "cornflowerblue", name: "cornflowerblue", hex: "#6495ED", hsl: "hsl(219, 79%, 66%)", rgb: "rgb(100, 149, 237)"},
+ {authored: "cornsilk", name: "cornsilk", hex: "#FFF8DC", hsl: "hsl(48, 100%, 93%)", rgb: "rgb(255, 248, 220)"},
+ {authored: "crimson", name: "crimson", hex: "#DC143C", hsl: "hsl(348, 83%, 47%)", rgb: "rgb(220, 20, 60)"},
+ {authored: "cyan", name: "aqua", hex: "#0FF", hsl: "hsl(180, 100%, 50%)", rgb: "rgb(0, 255, 255)"},
+ {authored: "darkblue", name: "darkblue", hex: "#00008B", hsl: "hsl(240, 100%, 27%)", rgb: "rgb(0, 0, 139)"},
+ {authored: "darkcyan", name: "darkcyan", hex: "#008B8B", hsl: "hsl(180, 100%, 27%)", rgb: "rgb(0, 139, 139)"},
+ {authored: "darkgoldenrod", name: "darkgoldenrod", hex: "#B8860B", hsl: "hsl(43, 89%, 38%)", rgb: "rgb(184, 134, 11)"},
+ {authored: "darkgray", name: "darkgray", hex: "#A9A9A9", hsl: "hsl(0, 0%, 66%)", rgb: "rgb(169, 169, 169)"},
+ {authored: "darkgreen", name: "darkgreen", hex: "#006400", hsl: "hsl(120, 100%, 20%)", rgb: "rgb(0, 100, 0)"},
+ {authored: "darkgrey", name: "darkgray", hex: "#A9A9A9", hsl: "hsl(0, 0%, 66%)", rgb: "rgb(169, 169, 169)"},
+ {authored: "darkkhaki", name: "darkkhaki", hex: "#BDB76B", hsl: "hsl(56, 38%, 58%)", rgb: "rgb(189, 183, 107)"},
+ {authored: "darkmagenta", name: "darkmagenta", hex: "#8B008B", hsl: "hsl(300, 100%, 27%)", rgb: "rgb(139, 0, 139)"},
+ {authored: "darkolivegreen", name: "darkolivegreen", hex: "#556B2F", hsl: "hsl(82, 39%, 30%)", rgb: "rgb(85, 107, 47)"},
+ {authored: "darkorange", name: "darkorange", hex: "#FF8C00", hsl: "hsl(33, 100%, 50%)", rgb: "rgb(255, 140, 0)"},
+ {authored: "darkorchid", name: "darkorchid", hex: "#9932CC", hsl: "hsl(280, 61%, 50%)", rgb: "rgb(153, 50, 204)"},
+ {authored: "darkred", name: "darkred", hex: "#8B0000", hsl: "hsl(0, 100%, 27%)", rgb: "rgb(139, 0, 0)"},
+ {authored: "darksalmon", name: "darksalmon", hex: "#E9967A", hsl: "hsl(15, 72%, 70%)", rgb: "rgb(233, 150, 122)"},
+ {authored: "darkseagreen", name: "darkseagreen", hex: "#8FBC8F", hsl: "hsl(120, 25%, 65%)", rgb: "rgb(143, 188, 143)"},
+ {authored: "darkslateblue", name: "darkslateblue", hex: "#483D8B", hsl: "hsl(248, 39%, 39%)", rgb: "rgb(72, 61, 139)"},
+ {authored: "darkslategray", name: "darkslategray", hex: "#2F4F4F", hsl: "hsl(180, 25%, 25%)", rgb: "rgb(47, 79, 79)"},
+ {authored: "darkslategrey", name: "darkslategray", hex: "#2F4F4F", hsl: "hsl(180, 25%, 25%)", rgb: "rgb(47, 79, 79)"},
+ {authored: "darkturquoise", name: "darkturquoise", hex: "#00CED1", hsl: "hsl(181, 100%, 41%)", rgb: "rgb(0, 206, 209)"},
+ {authored: "darkviolet", name: "darkviolet", hex: "#9400D3", hsl: "hsl(282, 100%, 41%)", rgb: "rgb(148, 0, 211)"},
+ {authored: "deeppink", name: "deeppink", hex: "#FF1493", hsl: "hsl(328, 100%, 54%)", rgb: "rgb(255, 20, 147)"},
+ {authored: "deepskyblue", name: "deepskyblue", hex: "#00BFFF", hsl: "hsl(195, 100%, 50%)", rgb: "rgb(0, 191, 255)"},
+ {authored: "dimgray", name: "dimgray", hex: "#696969", hsl: "hsl(0, 0%, 41%)", rgb: "rgb(105, 105, 105)"},
+ {authored: "dodgerblue", name: "dodgerblue", hex: "#1E90FF", hsl: "hsl(210, 100%, 56%)", rgb: "rgb(30, 144, 255)"},
+ {authored: "firebrick", name: "firebrick", hex: "#B22222", hsl: "hsl(0, 68%, 42%)", rgb: "rgb(178, 34, 34)"},
+ {authored: "floralwhite", name: "floralwhite", hex: "#FFFAF0", hsl: "hsl(40, 100%, 97%)", rgb: "rgb(255, 250, 240)"},
+ {authored: "forestgreen", name: "forestgreen", hex: "#228B22", hsl: "hsl(120, 61%, 34%)", rgb: "rgb(34, 139, 34)"},
+ {authored: "fuchsia", name: "fuchsia", hex: "#F0F", hsl: "hsl(300, 100%, 50%)", rgb: "rgb(255, 0, 255)"},
+ {authored: "gainsboro", name: "gainsboro", hex: "#DCDCDC", hsl: "hsl(0, 0%, 86%)", rgb: "rgb(220, 220, 220)"},
+ {authored: "ghostwhite", name: "ghostwhite", hex: "#F8F8FF", hsl: "hsl(240, 100%, 99%)", rgb: "rgb(248, 248, 255)"},
+ {authored: "gold", name: "gold", hex: "#FFD700", hsl: "hsl(51, 100%, 50%)", rgb: "rgb(255, 215, 0)"},
+ {authored: "goldenrod", name: "goldenrod", hex: "#DAA520", hsl: "hsl(43, 74%, 49%)", rgb: "rgb(218, 165, 32)"},
+ {authored: "gray", name: "gray", hex: "#808080", hsl: "hsl(0, 0%, 50%)", rgb: "rgb(128, 128, 128)"},
+ {authored: "green", name: "green", hex: "#008000", hsl: "hsl(120, 100%, 25%)", rgb: "rgb(0, 128, 0)"},
+ {authored: "greenyellow", name: "greenyellow", hex: "#ADFF2F", hsl: "hsl(84, 100%, 59%)", rgb: "rgb(173, 255, 47)"},
+ {authored: "grey", name: "gray", hex: "#808080", hsl: "hsl(0, 0%, 50%)", rgb: "rgb(128, 128, 128)"},
+ {authored: "honeydew", name: "honeydew", hex: "#F0FFF0", hsl: "hsl(120, 100%, 97%)", rgb: "rgb(240, 255, 240)"},
+ {authored: "hotpink", name: "hotpink", hex: "#FF69B4", hsl: "hsl(330, 100%, 71%)", rgb: "rgb(255, 105, 180)"},
+ {authored: "indianred", name: "indianred", hex: "#CD5C5C", hsl: "hsl(0, 53%, 58%)", rgb: "rgb(205, 92, 92)"},
+ {authored: "indigo", name: "indigo", hex: "#4B0082", hsl: "hsl(275, 100%, 25%)", rgb: "rgb(75, 0, 130)"},
+ {authored: "ivory", name: "ivory", hex: "#FFFFF0", hsl: "hsl(60, 100%, 97%)", rgb: "rgb(255, 255, 240)"},
+ {authored: "khaki", name: "khaki", hex: "#F0E68C", hsl: "hsl(54, 77%, 75%)", rgb: "rgb(240, 230, 140)"},
+ {authored: "lavender", name: "lavender", hex: "#E6E6FA", hsl: "hsl(240, 67%, 94%)", rgb: "rgb(230, 230, 250)"},
+ {authored: "lavenderblush", name: "lavenderblush", hex: "#FFF0F5", hsl: "hsl(340, 100%, 97%)", rgb: "rgb(255, 240, 245)"},
+ {authored: "lawngreen", name: "lawngreen", hex: "#7CFC00", hsl: "hsl(90, 100%, 49%)", rgb: "rgb(124, 252, 0)"},
+ {authored: "lemonchiffon", name: "lemonchiffon", hex: "#FFFACD", hsl: "hsl(54, 100%, 90%)", rgb: "rgb(255, 250, 205)"},
+ {authored: "lightblue", name: "lightblue", hex: "#ADD8E6", hsl: "hsl(195, 53%, 79%)", rgb: "rgb(173, 216, 230)"},
+ {authored: "lightcoral", name: "lightcoral", hex: "#F08080", hsl: "hsl(0, 79%, 72%)", rgb: "rgb(240, 128, 128)"},
+ {authored: "lightcyan", name: "lightcyan", hex: "#E0FFFF", hsl: "hsl(180, 100%, 94%)", rgb: "rgb(224, 255, 255)"},
+ {authored: "lightgoldenrodyellow", name: "lightgoldenrodyellow", hex: "#FAFAD2", hsl: "hsl(60, 80%, 90%)", rgb: "rgb(250, 250, 210)"},
+ {authored: "lightgray", name: "lightgray", hex: "#D3D3D3", hsl: "hsl(0, 0%, 83%)", rgb: "rgb(211, 211, 211)"},
+ {authored: "lightgreen", name: "lightgreen", hex: "#90EE90", hsl: "hsl(120, 73%, 75%)", rgb: "rgb(144, 238, 144)"},
+ {authored: "lightgrey", name: "lightgray", hex: "#D3D3D3", hsl: "hsl(0, 0%, 83%)", rgb: "rgb(211, 211, 211)"},
+ {authored: "lightpink", name: "lightpink", hex: "#FFB6C1", hsl: "hsl(351, 100%, 86%)", rgb: "rgb(255, 182, 193)"},
+ {authored: "lightsalmon", name: "lightsalmon", hex: "#FFA07A", hsl: "hsl(17, 100%, 74%)", rgb: "rgb(255, 160, 122)"},
+ {authored: "lightseagreen", name: "lightseagreen", hex: "#20B2AA", hsl: "hsl(177, 70%, 41%)", rgb: "rgb(32, 178, 170)"},
+ {authored: "lightskyblue", name: "lightskyblue", hex: "#87CEFA", hsl: "hsl(203, 92%, 75%)", rgb: "rgb(135, 206, 250)"},
+ {authored: "lightslategray", name: "lightslategray", hex: "#789", hsl: "hsl(210, 14%, 53%)", rgb: "rgb(119, 136, 153)"},
+ {authored: "lightslategrey", name: "lightslategray", hex: "#789", hsl: "hsl(210, 14%, 53%)", rgb: "rgb(119, 136, 153)"},
+ {authored: "lightsteelblue", name: "lightsteelblue", hex: "#B0C4DE", hsl: "hsl(214, 41%, 78%)", rgb: "rgb(176, 196, 222)"},
+ {authored: "lightyellow", name: "lightyellow", hex: "#FFFFE0", hsl: "hsl(60, 100%, 94%)", rgb: "rgb(255, 255, 224)"},
+ {authored: "lime", name: "lime", hex: "#0F0", hsl: "hsl(120, 100%, 50%)", rgb: "rgb(0, 255, 0)"},
+ {authored: "limegreen", name: "limegreen", hex: "#32CD32", hsl: "hsl(120, 61%, 50%)", rgb: "rgb(50, 205, 50)"},
+ {authored: "linen", name: "linen", hex: "#FAF0E6", hsl: "hsl(30, 67%, 94%)", rgb: "rgb(250, 240, 230)"},
+ {authored: "magenta", name: "fuchsia", hex: "#F0F", hsl: "hsl(300, 100%, 50%)", rgb: "rgb(255, 0, 255)"},
+ {authored: "maroon", name: "maroon", hex: "#800000", hsl: "hsl(0, 100%, 25%)", rgb: "rgb(128, 0, 0)"},
+ {authored: "mediumaquamarine", name: "mediumaquamarine", hex: "#66CDAA", hsl: "hsl(160, 51%, 60%)", rgb: "rgb(102, 205, 170)"},
+ {authored: "mediumblue", name: "mediumblue", hex: "#0000CD", hsl: "hsl(240, 100%, 40%)", rgb: "rgb(0, 0, 205)"},
+ {authored: "mediumorchid", name: "mediumorchid", hex: "#BA55D3", hsl: "hsl(288, 59%, 58%)", rgb: "rgb(186, 85, 211)"},
+ {authored: "mediumpurple", name: "mediumpurple", hex: "#9370DB", hsl: "hsl(260, 60%, 65%)", rgb: "rgb(147, 112, 219)"},
+ {authored: "mediumseagreen", name: "mediumseagreen", hex: "#3CB371", hsl: "hsl(147, 50%, 47%)", rgb: "rgb(60, 179, 113)"},
+ {authored: "mediumslateblue", name: "mediumslateblue", hex: "#7B68EE", hsl: "hsl(249, 80%, 67%)", rgb: "rgb(123, 104, 238)"},
+ {authored: "mediumspringgreen", name: "mediumspringgreen", hex: "#00FA9A", hsl: "hsl(157, 100%, 49%)", rgb: "rgb(0, 250, 154)"},
+ {authored: "mediumturquoise", name: "mediumturquoise", hex: "#48D1CC", hsl: "hsl(178, 60%, 55%)", rgb: "rgb(72, 209, 204)"},
+ {authored: "mediumvioletred", name: "mediumvioletred", hex: "#C71585", hsl: "hsl(322, 81%, 43%)", rgb: "rgb(199, 21, 133)"},
+ {authored: "midnightblue", name: "midnightblue", hex: "#191970", hsl: "hsl(240, 64%, 27%)", rgb: "rgb(25, 25, 112)"},
+ {authored: "mintcream", name: "mintcream", hex: "#F5FFFA", hsl: "hsl(150, 100%, 98%)", rgb: "rgb(245, 255, 250)"},
+ {authored: "mistyrose", name: "mistyrose", hex: "#FFE4E1", hsl: "hsl(6, 100%, 94%)", rgb: "rgb(255, 228, 225)"},
+ {authored: "moccasin", name: "moccasin", hex: "#FFE4B5", hsl: "hsl(38, 100%, 85%)", rgb: "rgb(255, 228, 181)"},
+ {authored: "navajowhite", name: "navajowhite", hex: "#FFDEAD", hsl: "hsl(36, 100%, 84%)", rgb: "rgb(255, 222, 173)"},
+ {authored: "navy", name: "navy", hex: "#000080", hsl: "hsl(240, 100%, 25%)", rgb: "rgb(0, 0, 128)"},
+ {authored: "oldlace", name: "oldlace", hex: "#FDF5E6", hsl: "hsl(39, 85%, 95%)", rgb: "rgb(253, 245, 230)"},
+ {authored: "olive", name: "olive", hex: "#808000", hsl: "hsl(60, 100%, 25%)", rgb: "rgb(128, 128, 0)"},
+ {authored: "olivedrab", name: "olivedrab", hex: "#6B8E23", hsl: "hsl(80, 60%, 35%)", rgb: "rgb(107, 142, 35)"},
+ {authored: "orange", name: "orange", hex: "#FFA500", hsl: "hsl(39, 100%, 50%)", rgb: "rgb(255, 165, 0)"},
+ {authored: "orangered", name: "orangered", hex: "#FF4500", hsl: "hsl(16, 100%, 50%)", rgb: "rgb(255, 69, 0)"},
+ {authored: "orchid", name: "orchid", hex: "#DA70D6", hsl: "hsl(302, 59%, 65%)", rgb: "rgb(218, 112, 214)"},
+ {authored: "palegoldenrod", name: "palegoldenrod", hex: "#EEE8AA", hsl: "hsl(55, 67%, 80%)", rgb: "rgb(238, 232, 170)"},
+ {authored: "palegreen", name: "palegreen", hex: "#98FB98", hsl: "hsl(120, 93%, 79%)", rgb: "rgb(152, 251, 152)"},
+ {authored: "paleturquoise", name: "paleturquoise", hex: "#AFEEEE", hsl: "hsl(180, 65%, 81%)", rgb: "rgb(175, 238, 238)"},
+ {authored: "palevioletred", name: "palevioletred", hex: "#DB7093", hsl: "hsl(340, 60%, 65%)", rgb: "rgb(219, 112, 147)"},
+ {authored: "papayawhip", name: "papayawhip", hex: "#FFEFD5", hsl: "hsl(37, 100%, 92%)", rgb: "rgb(255, 239, 213)"},
+ {authored: "peachpuff", name: "peachpuff", hex: "#FFDAB9", hsl: "hsl(28, 100%, 86%)", rgb: "rgb(255, 218, 185)"},
+ {authored: "peru", name: "peru", hex: "#CD853F", hsl: "hsl(30, 59%, 53%)", rgb: "rgb(205, 133, 63)"},
+ {authored: "pink", name: "pink", hex: "#FFC0CB", hsl: "hsl(350, 100%, 88%)", rgb: "rgb(255, 192, 203)"},
+ {authored: "plum", name: "plum", hex: "#DDA0DD", hsl: "hsl(300, 47%, 75%)", rgb: "rgb(221, 160, 221)"},
+ {authored: "powderblue", name: "powderblue", hex: "#B0E0E6", hsl: "hsl(187, 52%, 80%)", rgb: "rgb(176, 224, 230)"},
+ {authored: "purple", name: "purple", hex: "#800080", hsl: "hsl(300, 100%, 25%)", rgb: "rgb(128, 0, 128)"},
+ {authored: "rebeccapurple", name: "rebeccapurple", hex: "#639", hsl: "hsl(270, 50%, 40%)", rgb: "rgb(102, 51, 153)"},
+ {authored: "red", name: "red", hex: "#F00", hsl: "hsl(0, 100%, 50%)", rgb: "rgb(255, 0, 0)"},
+ {authored: "rosybrown", name: "rosybrown", hex: "#BC8F8F", hsl: "hsl(0, 25%, 65%)", rgb: "rgb(188, 143, 143)"},
+ {authored: "royalblue", name: "royalblue", hex: "#4169E1", hsl: "hsl(225, 73%, 57%)", rgb: "rgb(65, 105, 225)"},
+ {authored: "saddlebrown", name: "saddlebrown", hex: "#8B4513", hsl: "hsl(25, 76%, 31%)", rgb: "rgb(139, 69, 19)"},
+ {authored: "salmon", name: "salmon", hex: "#FA8072", hsl: "hsl(6, 93%, 71%)", rgb: "rgb(250, 128, 114)"},
+ {authored: "sandybrown", name: "sandybrown", hex: "#F4A460", hsl: "hsl(28, 87%, 67%)", rgb: "rgb(244, 164, 96)"},
+ {authored: "seagreen", name: "seagreen", hex: "#2E8B57", hsl: "hsl(146, 50%, 36%)", rgb: "rgb(46, 139, 87)"},
+ {authored: "seashell", name: "seashell", hex: "#FFF5EE", hsl: "hsl(25, 100%, 97%)", rgb: "rgb(255, 245, 238)"},
+ {authored: "sienna", name: "sienna", hex: "#A0522D", hsl: "hsl(19, 56%, 40%)", rgb: "rgb(160, 82, 45)"},
+ {authored: "silver", name: "silver", hex: "#C0C0C0", hsl: "hsl(0, 0%, 75%)", rgb: "rgb(192, 192, 192)"},
+ {authored: "skyblue", name: "skyblue", hex: "#87CEEB", hsl: "hsl(197, 71%, 73%)", rgb: "rgb(135, 206, 235)"},
+ {authored: "slateblue", name: "slateblue", hex: "#6A5ACD", hsl: "hsl(248, 53%, 58%)", rgb: "rgb(106, 90, 205)"},
+ {authored: "slategray", name: "slategray", hex: "#708090", hsl: "hsl(210, 13%, 50%)", rgb: "rgb(112, 128, 144)"},
+ {authored: "slategrey", name: "slategray", hex: "#708090", hsl: "hsl(210, 13%, 50%)", rgb: "rgb(112, 128, 144)"},
+ {authored: "snow", name: "snow", hex: "#FFFAFA", hsl: "hsl(0, 100%, 99%)", rgb: "rgb(255, 250, 250)"},
+ {authored: "springgreen", name: "springgreen", hex: "#00FF7F", hsl: "hsl(150, 100%, 50%)", rgb: "rgb(0, 255, 127)"},
+ {authored: "steelblue", name: "steelblue", hex: "#4682B4", hsl: "hsl(207, 44%, 49%)", rgb: "rgb(70, 130, 180)"},
+ {authored: "tan", name: "tan", hex: "#D2B48C", hsl: "hsl(34, 44%, 69%)", rgb: "rgb(210, 180, 140)"},
+ {authored: "teal", name: "teal", hex: "#008080", hsl: "hsl(180, 100%, 25%)", rgb: "rgb(0, 128, 128)"},
+ {authored: "thistle", name: "thistle", hex: "#D8BFD8", hsl: "hsl(300, 24%, 80%)", rgb: "rgb(216, 191, 216)"},
+ {authored: "tomato", name: "tomato", hex: "#FF6347", hsl: "hsl(9, 100%, 64%)", rgb: "rgb(255, 99, 71)"},
+ {authored: "turquoise", name: "turquoise", hex: "#40E0D0", hsl: "hsl(174, 72%, 56%)", rgb: "rgb(64, 224, 208)"},
+ {authored: "violet", name: "violet", hex: "#EE82EE", hsl: "hsl(300, 76%, 72%)", rgb: "rgb(238, 130, 238)"},
+ {authored: "wheat", name: "wheat", hex: "#F5DEB3", hsl: "hsl(39, 77%, 83%)", rgb: "rgb(245, 222, 179)"},
+ {authored: "white", name: "white", hex: "#FFF", hsl: "hsl(0, 0%, 100%)", rgb: "rgb(255, 255, 255)"},
+ {authored: "whitesmoke", name: "whitesmoke", hex: "#F5F5F5", hsl: "hsl(0, 0%, 96%)", rgb: "rgb(245, 245, 245)"},
+ {authored: "yellow", name: "yellow", hex: "#FF0", hsl: "hsl(60, 100%, 50%)", rgb: "rgb(255, 255, 0)"},
+ {authored: "yellowgreen", name: "yellowgreen", hex: "#9ACD32", hsl: "hsl(80, 61%, 50%)", rgb: "rgb(154, 205, 50)"},
+ {authored: "rgba(0, 0, 0, 0)", name: "rgba(0, 0, 0, 0)", hex: "rgba(0, 0, 0, 0)", hsl: "hsla(0, 0%, 0%, 0)", rgb: "rgba(0, 0, 0, 0)"},
+ {authored: "hsla(0, 0%, 0%, 0)", name: "rgba(0, 0, 0, 0)", hex: "rgba(0, 0, 0, 0)", hsl: "hsla(0, 0%, 0%, 0)", rgb: "rgba(0, 0, 0, 0)"},
+ {authored: "rgba(50, 60, 70, 0.5)", name: "rgba(50, 60, 70, 0.5)", hex: "rgba(50, 60, 70, 0.5)", hsl: "hsla(210, 17%, 24%, 0.5)", rgb: "rgba(50, 60, 70, 0.5)"},
+ {authored: "rgba(0, 0, 0, 0.3)", name: "rgba(0, 0, 0, 0.3)", hex: "rgba(0, 0, 0, 0.3)", hsl: "hsla(0, 0%, 0%, 0.3)", rgb: "rgba(0, 0, 0, 0.3)"},
+ {authored: "rgba(255, 255, 255, 0.6)", name: "rgba(255, 255, 255, 0.6)", hex: "rgba(255, 255, 255, 0.6)", hsl: "hsla(0, 0%, 100%, 0.6)", rgb: "rgba(255, 255, 255, 0.6)"},
+ {authored: "rgba(127, 89, 45, 1)", name: "#7F592D", hex: "#7F592D", hsl: "hsl(32, 48%, 34%)", rgb: "rgb(127, 89, 45)"},
+ {authored: "hsla(19.304, 56%, 40%, 1)", name: "#9F512C", hex: "#9F512C", hsl: "hsl(19, 57%, 40%)", rgb: "rgb(159, 81, 44)"},
+ {authored: "currentcolor", name: "currentcolor", hex: "currentcolor", hsl: "currentcolor", rgb: "currentcolor"},
+ {authored: "inherit", name: "inherit", hex: "inherit", hsl: "inherit", rgb: "inherit"},
+ {authored: "initial", name: "initial", hex: "initial", hsl: "initial", rgb: "initial"},
+ {authored: "invalidColor", name: "", hex: "", hsl: "", rgb: ""},
+ {authored: "transparent", name: "transparent", hex: "transparent", hsl: "transparent", rgb: "transparent"},
+ {authored: "unset", name: "unset", hex: "unset", hsl: "unset", rgb: "unset"}
+ ];
+}
diff --git a/toolkit/devtools/shared/test/browser_cubic-bezier-01.js b/toolkit/devtools/shared/test/browser_cubic-bezier-01.js
new file mode 100644
index 000000000..2e288c0ea
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_cubic-bezier-01.js
@@ -0,0 +1,37 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests that the CubicBezierWidget generates content in a given parent node
+
+const TEST_URI = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml";
+const {CubicBezierWidget} = devtools.require("devtools/shared/widgets/CubicBezierWidget");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ let [host, win, doc] = yield createHost("bottom", TEST_URI);
+
+ info("Checking that the markup is created in the parent");
+ let container = doc.querySelector("#container");
+ let w = new CubicBezierWidget(container);
+
+ ok(container.querySelector(".coordinate-plane"),
+ "The coordinate plane has been added");
+ let buttons = container.querySelectorAll("button");
+ is(buttons.length, 2,
+ "The 2 control points have been added");
+ is(buttons[0].className, "control-point");
+ is(buttons[0].id, "P1");
+ is(buttons[1].className, "control-point");
+ is(buttons[1].id, "P2");
+ ok(container.querySelector("canvas"), "The curve canvas has been added");
+
+ info("Destroying the widget");
+ w.destroy();
+ is(container.children.length, 0, "All nodes have been removed");
+
+ host.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/devtools/shared/test/browser_cubic-bezier-02.js b/toolkit/devtools/shared/test/browser_cubic-bezier-02.js
new file mode 100644
index 000000000..30887a74d
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_cubic-bezier-02.js
@@ -0,0 +1,149 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests the CubicBezierWidget events
+
+const TEST_URI = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml";
+const {CubicBezierWidget, PREDEFINED} =
+ devtools.require("devtools/shared/widgets/CubicBezierWidget");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ let [host, win, doc] = yield createHost("bottom", TEST_URI);
+
+ let container = doc.querySelector("#container");
+ let w = new CubicBezierWidget(container, PREDEFINED.linear);
+
+ yield pointsCanBeDragged(w, win, doc);
+ yield curveCanBeClicked(w, win, doc);
+ yield pointsCanBeMovedWithKeyboard(w, win, doc);
+
+ w.destroy();
+ host.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+function* pointsCanBeDragged(widget, win, doc) {
+ info("Checking that the control points can be dragged with the mouse");
+
+ info("Listening for the update event");
+ let onUpdated = widget.once("updated");
+
+ info("Generating a mousedown/move/up on P1");
+ widget._onPointMouseDown({target: widget.p1});
+ doc.onmousemove({pageX: 0, pageY: 100});
+ doc.onmouseup();
+
+ let bezier = yield onUpdated;
+ ok(true, "The widget fired the updated event");
+ ok(bezier, "The updated event contains a bezier argument");
+ is(bezier.P1[0], 0, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], 1, "The new P1 progress coordinate is correct");
+
+ info("Listening for the update event");
+ onUpdated = widget.once("updated");
+
+ info("Generating a mousedown/move/up on P2");
+ widget._onPointMouseDown({target: widget.p2});
+ doc.onmousemove({pageX: 200, pageY: 300});
+ doc.onmouseup();
+
+ bezier = yield onUpdated;
+ is(bezier.P2[0], 1, "The new P2 time coordinate is correct");
+ is(bezier.P2[1], 0, "The new P2 progress coordinate is correct");
+}
+
+function* curveCanBeClicked(widget, win, doc) {
+ info("Checking that clicking on the curve moves the closest control point");
+
+ info("Listening for the update event");
+ let onUpdated = widget.once("updated");
+
+ info("Click close to P1");
+ widget._onCurveClick({pageX: 50, pageY: 150});
+
+ let bezier = yield onUpdated;
+ ok(true, "The widget fired the updated event");
+ is(bezier.P1[0], 0.25, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct");
+ is(bezier.P2[0], 1, "P2 time coordinate remained unchanged");
+ is(bezier.P2[1], 0, "P2 progress coordinate remained unchanged");
+
+ info("Listening for the update event");
+ onUpdated = widget.once("updated");
+
+ info("Click close to P2");
+ widget._onCurveClick({pageX: 150, pageY: 250});
+
+ bezier = yield onUpdated;
+ is(bezier.P2[0], 0.75, "The new P2 time coordinate is correct");
+ is(bezier.P2[1], 0.25, "The new P2 progress coordinate is correct");
+ is(bezier.P1[0], 0.25, "P1 time coordinate remained unchanged");
+ is(bezier.P1[1], 0.75, "P1 progress coordinate remained unchanged");
+}
+
+function* pointsCanBeMovedWithKeyboard(widget, win, doc) {
+ info("Checking that points respond to keyboard events");
+
+ info("Moving P1 to the left");
+ let onUpdated = widget.once("updated");
+ widget._onPointKeyDown(getKeyEvent(widget.p1, 37));
+ let bezier = yield onUpdated;
+ is(bezier.P1[0], 0.235, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct");
+
+ info("Moving P1 to the left, fast");
+ onUpdated = widget.once("updated");
+ widget._onPointKeyDown(getKeyEvent(widget.p1, 37, true));
+ bezier = yield onUpdated;
+ is(bezier.P1[0], 0.085, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct");
+
+ info("Moving P1 to the right, fast");
+ onUpdated = widget.once("updated");
+ widget._onPointKeyDown(getKeyEvent(widget.p1, 39, true));
+ bezier = yield onUpdated;
+ is(bezier.P1[0], 0.235, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct");
+
+ info("Moving P1 to the bottom");
+ onUpdated = widget.once("updated");
+ widget._onPointKeyDown(getKeyEvent(widget.p1, 40));
+ bezier = yield onUpdated;
+ is(bezier.P1[0], 0.235, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], 0.735, "The new P1 progress coordinate is correct");
+
+ info("Moving P1 to the bottom, fast");
+ onUpdated = widget.once("updated");
+ widget._onPointKeyDown(getKeyEvent(widget.p1, 40, true));
+ bezier = yield onUpdated;
+ is(bezier.P1[0], 0.235, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], 0.585, "The new P1 progress coordinate is correct");
+
+ info("Moving P1 to the top, fast");
+ onUpdated = widget.once("updated");
+ widget._onPointKeyDown(getKeyEvent(widget.p1, 38, true));
+ bezier = yield onUpdated;
+ is(bezier.P1[0], 0.235, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], 0.735, "The new P1 progress coordinate is correct");
+
+ info("Checking that keyboard events also work with P2");
+ info("Moving P2 to the left");
+ onUpdated = widget.once("updated");
+ widget._onPointKeyDown(getKeyEvent(widget.p2, 37));
+ bezier = yield onUpdated;
+ is(bezier.P2[0], 0.735, "The new P2 time coordinate is correct");
+ is(bezier.P2[1], 0.25, "The new P2 progress coordinate is correct");
+}
+
+function getKeyEvent(target, keyCode, shift=false) {
+ return {
+ target: target,
+ keyCode: keyCode,
+ shiftKey: shift,
+ preventDefault: () => {}
+ };
+}
diff --git a/toolkit/devtools/shared/test/browser_cubic-bezier-03.js b/toolkit/devtools/shared/test/browser_cubic-bezier-03.js
new file mode 100644
index 000000000..2ce5fe456
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_cubic-bezier-03.js
@@ -0,0 +1,68 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests that coordinates can be changed programatically in the CubicBezierWidget
+
+const TEST_URI = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml";
+const {CubicBezierWidget, PREDEFINED} =
+ devtools.require("devtools/shared/widgets/CubicBezierWidget");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ let [host, win, doc] = yield createHost("bottom", TEST_URI);
+
+ let container = doc.querySelector("#container");
+ let w = new CubicBezierWidget(container, PREDEFINED.linear);
+
+ yield coordinatesCanBeChangedByProvidingAnArray(w);
+ yield coordinatesCanBeChangedByProvidingAValue(w);
+
+ w.destroy();
+ host.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+function* coordinatesCanBeChangedByProvidingAnArray(widget) {
+ info("Listening for the update event");
+ let onUpdated = widget.once("updated");
+
+ info("Setting new coordinates");
+ widget.coordinates = [0,1,1,0];
+
+ let bezier = yield onUpdated;
+ ok(true, "The updated event was fired as a result of setting coordinates");
+
+ is(bezier.P1[0], 0, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], 1, "The new P1 progress coordinate is correct");
+ is(bezier.P2[0], 1, "The new P2 time coordinate is correct");
+ is(bezier.P2[1], 0, "The new P2 progress coordinate is correct");
+}
+
+function* coordinatesCanBeChangedByProvidingAValue(widget) {
+ info("Listening for the update event");
+ let onUpdated = widget.once("updated");
+
+ info("Setting linear css value");
+ widget.cssCubicBezierValue = "linear";
+ let bezier = yield onUpdated;
+ ok(true, "The updated event was fired as a result of setting cssValue");
+
+ is(bezier.P1[0], 0, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], 0, "The new P1 progress coordinate is correct");
+ is(bezier.P2[0], 1, "The new P2 time coordinate is correct");
+ is(bezier.P2[1], 1, "The new P2 progress coordinate is correct");
+
+ info("Setting a custom cubic-bezier css value");
+ onUpdated = widget.once("updated");
+ widget.cssCubicBezierValue = "cubic-bezier(.25,-0.5, 1, 1.45)";
+ bezier = yield onUpdated;
+ ok(true, "The updated event was fired as a result of setting cssValue");
+
+ is(bezier.P1[0], .25, "The new P1 time coordinate is correct");
+ is(bezier.P1[1], -.5, "The new P1 progress coordinate is correct");
+ is(bezier.P2[0], 1, "The new P2 time coordinate is correct");
+ is(bezier.P2[1], 1.45, "The new P2 progress coordinate is correct");
+}
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-01.js b/toolkit/devtools/shared/test/browser_flame-graph-01.js
new file mode 100644
index 000000000..733841b9e
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-01.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that flame graph widget works properly.
+
+let {FlameGraph} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("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);
+
+ let readyEventEmitted;
+ graph.once("ready", () => readyEventEmitted = true);
+
+ yield graph.ready();
+ ok(readyEventEmitted, "The 'ready' event should have been emitted");
+
+ testGraph(host, graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(host, graph) {
+ ok(graph._container.classList.contains("flame-graph-widget-container"),
+ "The correct graph container was created.");
+ ok(graph._canvas.classList.contains("flame-graph-widget-canvas"),
+ "The correct graph container was created.");
+
+ let bounds = host.frame.getBoundingClientRect();
+
+ is(graph.width, bounds.width * window.devicePixelRatio,
+ "The graph has the correct width.");
+ is(graph.height, bounds.height * window.devicePixelRatio,
+ "The graph has the correct height.");
+
+ ok(graph._selection.start === null,
+ "The graph's selection start value is initially null.");
+ ok(graph._selection.end === null,
+ "The graph's selection end value is initially null.");
+
+ ok(graph._selectionDragger.origin === null,
+ "The graph's dragger origin value is initially null.");
+ ok(graph._selectionDragger.anchor.start === null,
+ "The graph's dragger anchor start value is initially null.");
+ ok(graph._selectionDragger.anchor.end === null,
+ "The graph's dragger anchor end value is initially null.");
+}
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-02.js b/toolkit/devtools/shared/test/browser_flame-graph-02.js
new file mode 100644
index 000000000..ce01e4354
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-02.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that flame graph widgets may have a fixed width or height.
+
+let {FlameGraph} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("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);
+ graph.fixedWidth = 200;
+ graph.fixedHeight = 100;
+
+ yield graph.ready();
+ testGraph(host, graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(host, graph) {
+ let bounds = host.frame.getBoundingClientRect();
+
+ isnot(graph.width, bounds.width * window.devicePixelRatio,
+ "The graph should not span all the parent node's width.");
+ isnot(graph.height, bounds.height * window.devicePixelRatio,
+ "The graph should not span all the parent node's height.");
+
+ is(graph.width, graph.fixedWidth * window.devicePixelRatio,
+ "The graph has the correct width.");
+ is(graph.height, graph.fixedHeight * window.devicePixelRatio,
+ "The graph has the correct height.");
+}
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-03a.js b/toolkit/devtools/shared/test/browser_flame-graph-03a.js
new file mode 100644
index 000000000..2913f60dd
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-03a.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that selections in the flame graph widget work properly.
+
+let 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" }] }];
+let TEST_BOUNDS = { startTime: 0, endTime: 150 };
+let TEST_WIDTH = 200;
+let TEST_HEIGHT = 100;
+
+let {FlameGraph} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("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, 1);
+ graph.fixedWidth = TEST_WIDTH;
+ graph.fixedHeight = TEST_HEIGHT;
+
+ yield graph.ready();
+
+ testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(graph) {
+ graph.setData({ data: TEST_DATA, bounds: TEST_BOUNDS });
+
+ is(graph.getViewRange().startTime, 0,
+ "The selection start boundary is correct (1).");
+ is(graph.getViewRange().endTime, 150,
+ "The selection end boundary is correct (1).");
+
+ scroll(graph, 200, HORIZONTAL_AXIS, 10);
+ is(graph.getViewRange().startTime | 0, 75,
+ "The selection start boundary is correct (2).");
+ is(graph.getViewRange().endTime | 0, 150,
+ "The selection end boundary is correct (2).");
+
+ scroll(graph, -200, HORIZONTAL_AXIS, 10);
+ is(graph.getViewRange().startTime | 0, 37,
+ "The selection start boundary is correct (3).");
+ is(graph.getViewRange().endTime | 0, 112,
+ "The selection end boundary is correct (3).");
+
+ scroll(graph, 200, VERTICAL_AXIS, TEST_WIDTH / 2);
+ is(graph.getViewRange().startTime | 0, 34,
+ "The selection start boundary is correct (4).");
+ is(graph.getViewRange().endTime | 0, 115,
+ "The selection end boundary is correct (4).");
+
+ scroll(graph, -200, VERTICAL_AXIS, TEST_WIDTH / 2);
+ is(graph.getViewRange().startTime | 0, 37,
+ "The selection start boundary is correct (5).");
+ is(graph.getViewRange().endTime | 0, 112,
+ "The selection end boundary is correct (5).");
+
+ dragStart(graph, TEST_WIDTH / 2);
+ is(graph.getViewRange().startTime | 0, 37,
+ "The selection start boundary is correct (6).");
+ is(graph.getViewRange().endTime | 0, 112,
+ "The selection end boundary is correct (6).");
+
+ hover(graph, TEST_WIDTH / 2 - 10);
+ is(graph.getViewRange().startTime | 0, 41,
+ "The selection start boundary is correct (7).");
+ is(graph.getViewRange().endTime | 0, 116,
+ "The selection end boundary is correct (7).");
+
+ dragStop(graph, 10);
+ is(graph.getViewRange().startTime | 0, 71,
+ "The selection start boundary is correct (8).");
+ is(graph.getViewRange().endTime | 0, 145,
+ "The selection end boundary is correct (8).");
+}
+
+// EventUtils just doesn't work!
+
+function hover(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+}
+
+function dragStart(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseDown({ clientX: x, clientY: y });
+}
+
+function dragStop(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseUp({ clientX: x, clientY: y });
+}
+
+let HORIZONTAL_AXIS = 1;
+let VERTICAL_AXIS = 2;
+
+function scroll(graph, wheel, axis, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseWheel({ clientX: x, clientY: y, axis, detail: wheel, axis,
+ HORIZONTAL_AXIS,
+ VERTICAL_AXIS
+ });
+}
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-03b.js b/toolkit/devtools/shared/test/browser_flame-graph-03b.js
new file mode 100644
index 000000000..707ed9ec2
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-03b.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that selections in the flame graph widget work properly on HiDPI.
+
+let 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" }] }];
+let TEST_BOUNDS = { startTime: 0, endTime: 150 };
+let TEST_WIDTH = 200;
+let TEST_HEIGHT = 100;
+let TEST_DPI_DENSITIY = 2;
+
+let {FlameGraph} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("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);
+ graph.fixedWidth = TEST_WIDTH;
+ graph.fixedHeight = TEST_HEIGHT;
+
+ yield graph.ready();
+
+ testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(graph) {
+ graph.setData({ data: TEST_DATA, bounds: TEST_BOUNDS });
+
+ is(graph.getViewRange().startTime, 0,
+ "The selection start boundary is correct on HiDPI (1).");
+ is(graph.getViewRange().endTime, 150,
+ "The selection end boundary is correct on HiDPI (1).");
+
+ is(graph.getOuterBounds().startTime, 0,
+ "The bounds start boundary is correct on HiDPI (1).");
+ is(graph.getOuterBounds().endTime, 150,
+ "The bounds end boundary is correct on HiDPI (1).");
+
+ scroll(graph, 10000, HORIZONTAL_AXIS, 1);
+
+ is(Math.round(graph.getViewRange().startTime), 150,
+ "The selection start boundary is correct on HiDPI (2).");
+ is(Math.round(graph.getViewRange().endTime), 150,
+ "The selection end boundary is correct on HiDPI (2).");
+
+ is(graph.getOuterBounds().startTime, 0,
+ "The bounds start boundary is correct on HiDPI (2).");
+ is(graph.getOuterBounds().endTime, 150,
+ "The bounds end boundary is correct on HiDPI (2).");
+}
+
+// EventUtils just doesn't work!
+
+let HORIZONTAL_AXIS = 1;
+let VERTICAL_AXIS = 2;
+
+function scroll(graph, wheel, axis, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseWheel({ clientX: x, clientY: y, axis, detail: wheel, axis,
+ HORIZONTAL_AXIS,
+ VERTICAL_AXIS
+ });
+}
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-04.js b/toolkit/devtools/shared/test/browser_flame-graph-04.js
new file mode 100644
index 000000000..44cdf4e03
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-04.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that text metrics in the flame graph widget work properly.
+
+let HTML_NS = "http://www.w3.org/1999/xhtml";
+let FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE = 8; // px
+let FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY = "sans-serif";
+let {ViewHelpers} = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {});
+let {FlameGraph} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+let L10N = new ViewHelpers.L10N();
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new FlameGraph(doc.body, 1);
+ yield graph.ready();
+
+ testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(graph) {
+ is(graph._averageCharWidth, getAverageCharWidth(),
+ "The average char width was calculated correctly.");
+ is(graph._overflowCharWidth, getCharWidth(L10N.ellipsis),
+ "The ellipsis char width was calculated correctly.");
+
+ let text = "This text is maybe overflowing";
+ let text1000px = graph._getFittedText(text, 1000);
+ let text50px = graph._getFittedText(text, 50);
+ let text10px = graph._getFittedText(text, 10);
+ let text1px = graph._getFittedText(text, 1);
+
+ is(graph._getTextWidthApprox(text), getAverageCharWidth() * text.length,
+ "The approximate width was calculated correctly.");
+
+ info("Text at 1000px width: " + text1000px);
+ info("Text at 50px width : " + text50px);
+ info("Text at 10px width : " + text10px);
+ info("Text at 1px width : " + text1px);
+
+ is(text1000px, text,
+ "The fitted text for 1000px width is correct.");
+
+ isnot(text50px, text,
+ "The fitted text for 50px width is correct (1).");
+
+ ok(text50px.contains(L10N.ellipsis),
+ "The fitted text for 50px width is correct (2).");
+
+ is(graph._getFittedText(text, 10), L10N.ellipsis,
+ "The fitted text for 10px width is correct.");
+
+ is(graph._getFittedText(text, 1), "",
+ "The fitted text for 1px width is correct.");
+}
+
+function getAverageCharWidth() {
+ let letterWidthsSum = 0;
+ let start = 32; // space
+ let end = 123; // "z"
+
+ for (let i = start; i < end; i++) {
+ let char = String.fromCharCode(i);
+ letterWidthsSum += getCharWidth(char);
+ }
+
+ return letterWidthsSum / (end - start);
+}
+
+function getCharWidth(char) {
+ let canvas = document.createElementNS(HTML_NS, "canvas");
+ let ctx = canvas.getContext("2d");
+
+ let fontSize = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE;
+ let fontFamily = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY;
+ ctx.font = fontSize + "px " + fontFamily;
+
+ return ctx.measureText(char).width;
+}
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-utils-01.js b/toolkit/devtools/shared/test/browser_flame-graph-utils-01.js
new file mode 100644
index 000000000..e31341d59
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-utils-01.js
@@ -0,0 +1,261 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that text metrics and data conversion from profiler samples
+// widget work properly in the flame graph.
+
+let {FlameGraphUtils} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let out = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA);
+
+ ok(out, "Some data was outputted properly");
+ is(out.length, 10, "The outputted length is correct.");
+
+ info("Got flame graph data:\n" + out.toSource() + "\n");
+
+ for (let i = 0; i < out.length; i++) {
+ let found = out[i];
+ let expected = EXPECTED_OUTPUT[i];
+
+ is(found.blocks.length, expected.blocks.length,
+ "The correct number of blocks were found in this bucket.");
+
+ for (let j = 0; j < found.blocks.length; j++) {
+ is(found.blocks[j].x, expected.blocks[j].x,
+ "The expected block X position is correct for this frame.");
+ is(found.blocks[j].y, expected.blocks[j].y,
+ "The expected block Y position is correct for this frame.");
+ is(found.blocks[j].width, expected.blocks[j].width,
+ "The expected block width is correct for this frame.");
+ is(found.blocks[j].height, expected.blocks[j].height,
+ "The expected block height is correct for this frame.");
+ is(found.blocks[j].text, expected.blocks[j].text,
+ "The expected block text is correct for this frame.");
+ }
+ }
+}
+
+let TEST_DATA = [{
+ frames: [{
+ location: "M"
+ }, {
+ location: "N",
+ }, {
+ location: "P"
+ }],
+ time: 50,
+}, {
+ frames: [{
+ location: "A"
+ }, {
+ location: "B",
+ }, {
+ location: "C"
+ }],
+ time: 100,
+}, {
+ frames: [{
+ location: "A"
+ }, {
+ location: "B",
+ }, {
+ location: "D"
+ }],
+ time: 210,
+}, {
+ frames: [{
+ location: "A"
+ }, {
+ location: "E",
+ }, {
+ location: "F"
+ }],
+ time: 330,
+}, {
+ frames: [{
+ location: "A"
+ }, {
+ location: "B",
+ }, {
+ location: "C"
+ }],
+ time: 460,
+}, {
+ frames: [{
+ location: "X"
+ }, {
+ location: "Y",
+ }, {
+ location: "Z"
+ }],
+ time: 500
+}];
+
+let EXPECTED_OUTPUT = [{
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 50,
+ rawLocation: "A"
+ },
+ x: 50,
+ y: 0,
+ width: 410,
+ height: 11,
+ text: "A"
+ }]
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 50,
+ rawLocation: "B"
+ },
+ x: 50,
+ y: 11,
+ width: 160,
+ height: 11,
+ text: "B"
+ }, {
+ srcData: {
+ startTime: 330,
+ rawLocation: "B"
+ },
+ x: 330,
+ y: 11,
+ width: 130,
+ height: 11,
+ text: "B"
+ }]
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 0,
+ rawLocation: "M"
+ },
+ x: 0,
+ y: 0,
+ width: 50,
+ height: 11,
+ text: "M"
+ }, {
+ srcData: {
+ startTime: 50,
+ rawLocation: "C"
+ },
+ x: 50,
+ y: 22,
+ width: 50,
+ height: 11,
+ text: "C"
+ }, {
+ srcData: {
+ startTime: 330,
+ rawLocation: "C"
+ },
+ x: 330,
+ y: 22,
+ width: 130,
+ height: 11,
+ text: "C"
+ }]
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 0,
+ rawLocation: "N"
+ },
+ x: 0,
+ y: 11,
+ width: 50,
+ height: 11,
+ text: "N"
+ }, {
+ srcData: {
+ startTime: 100,
+ rawLocation: "D"
+ },
+ x: 100,
+ y: 22,
+ width: 110,
+ height: 11,
+ text: "D"
+ }, {
+ srcData: {
+ startTime: 460,
+ rawLocation: "X"
+ },
+ x: 460,
+ y: 0,
+ width: 40,
+ height: 11,
+ text: "X"
+ }]
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 210,
+ rawLocation: "E"
+ },
+ x: 210,
+ y: 11,
+ width: 120,
+ height: 11,
+ text: "E"
+ }, {
+ srcData: {
+ startTime: 460,
+ rawLocation: "Y"
+ },
+ x: 460,
+ y: 11,
+ width: 40,
+ height: 11,
+ text: "Y"
+ }]
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 0,
+ rawLocation: "P"
+ },
+ x: 0,
+ y: 22,
+ width: 50,
+ height: 11,
+ text: "P"
+ }, {
+ srcData: {
+ startTime: 210,
+ rawLocation: "F"
+ },
+ x: 210,
+ y: 22,
+ width: 120,
+ height: 11,
+ text: "F"
+ }, {
+ srcData: {
+ startTime: 460,
+ rawLocation: "Z"
+ },
+ x: 460,
+ y: 22,
+ width: 40,
+ height: 11,
+ text: "Z"
+ }]
+}, {
+ blocks: []
+}, {
+ blocks: []
+}];
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-utils-02.js b/toolkit/devtools/shared/test/browser_flame-graph-utils-02.js
new file mode 100644
index 000000000..2104b87b0
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-utils-02.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests consecutive duplicate frames are removed from the flame graph data.
+
+let {FlameGraphUtils} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let out = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA, {
+ flattenRecursion: true
+ });
+
+ ok(out, "Some data was outputted properly");
+ is(out.length, 10, "The outputted length is correct.");
+
+ info("Got flame graph data:\n" + out.toSource() + "\n");
+
+ for (let i = 0; i < out.length; i++) {
+ let found = out[i];
+ let expected = EXPECTED_OUTPUT[i];
+
+ is(found.blocks.length, expected.blocks.length,
+ "The correct number of blocks were found in this bucket.");
+
+ for (let j = 0; j < found.blocks.length; j++) {
+ is(found.blocks[j].x, expected.blocks[j].x,
+ "The expected block X position is correct for this frame.");
+ is(found.blocks[j].y, expected.blocks[j].y,
+ "The expected block Y position is correct for this frame.");
+ is(found.blocks[j].width, expected.blocks[j].width,
+ "The expected block width is correct for this frame.");
+ is(found.blocks[j].height, expected.blocks[j].height,
+ "The expected block height is correct for this frame.");
+ is(found.blocks[j].text, expected.blocks[j].text,
+ "The expected block text is correct for this frame.");
+ }
+ }
+}
+
+let TEST_DATA = [{
+ frames: [{
+ location: "A"
+ }, {
+ location: "A"
+ }, {
+ location: "A"
+ }, {
+ location: "B",
+ }, {
+ location: "B",
+ }, {
+ location: "C"
+ }],
+ time: 50,
+}];
+
+let EXPECTED_OUTPUT = [{
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 0,
+ rawLocation: "A"
+ },
+ x: 0,
+ y: 0,
+ width: 50,
+ height: 11,
+ text: "A"
+ }]
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 0,
+ rawLocation: "B"
+ },
+ x: 0,
+ y: 11,
+ width: 50,
+ height: 11,
+ text: "B"
+ }]
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: []
+}];
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-utils-03.js b/toolkit/devtools/shared/test/browser_flame-graph-utils-03.js
new file mode 100644
index 000000000..562236d11
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-utils-03.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if platform frames are removed from the flame graph data.
+
+let {FlameGraphUtils} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {FrameNode} = devtools.require("devtools/shared/profiler/tree-model");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let out = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA, {
+ filterFrames: FrameNode.isContent
+ });
+
+ ok(out, "Some data was outputted properly");
+ is(out.length, 10, "The outputted length is correct.");
+
+ info("Got flame graph data:\n" + out.toSource() + "\n");
+
+ for (let i = 0; i < out.length; i++) {
+ let found = out[i];
+ let expected = EXPECTED_OUTPUT[i];
+
+ is(found.blocks.length, expected.blocks.length,
+ "The correct number of blocks were found in this bucket.");
+
+ for (let j = 0; j < found.blocks.length; j++) {
+ is(found.blocks[j].x, expected.blocks[j].x,
+ "The expected block X position is correct for this frame.");
+ is(found.blocks[j].y, expected.blocks[j].y,
+ "The expected block Y position is correct for this frame.");
+ is(found.blocks[j].width, expected.blocks[j].width,
+ "The expected block width is correct for this frame.");
+ is(found.blocks[j].height, expected.blocks[j].height,
+ "The expected block height is correct for this frame.");
+ is(found.blocks[j].text, expected.blocks[j].text,
+ "The expected block text is correct for this frame.");
+ }
+ }
+}
+
+let TEST_DATA = [{
+ frames: [{
+ location: "http://A"
+ }, {
+ location: "https://B"
+ }, {
+ location: "file://C",
+ }, {
+ location: "chrome://D"
+ }, {
+ location: "resource://E"
+ }],
+ time: 50,
+}];
+
+let EXPECTED_OUTPUT = [{
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 0,
+ rawLocation: "http://A"
+ },
+ x: 0,
+ y: 0,
+ width: 50,
+ height: 11,
+ text: "http://A"
+ }, {
+ srcData: {
+ startTime: 0,
+ rawLocation: "file://C"
+ },
+ x: 0,
+ y: 22,
+ width: 50,
+ height: 11,
+ text: "file://C"
+ }]
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 0,
+ rawLocation: "https://B"
+ },
+ x: 0,
+ y: 11,
+ width: 50,
+ height: 11,
+ text: "https://B"
+ }]
+}, {
+ blocks: []
+}];
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-utils-04.js b/toolkit/devtools/shared/test/browser_flame-graph-utils-04.js
new file mode 100644
index 000000000..907f85c4c
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-utils-04.js
@@ -0,0 +1,166 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if (idle) nodes are added when necessary in the flame graph data.
+
+let {FlameGraphUtils} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {FrameNode} = devtools.require("devtools/shared/profiler/tree-model");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let out = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA, {
+ flattenRecursion: true,
+ filterFrames: FrameNode.isContent,
+ showIdleBlocks: "\m/"
+ });
+
+ ok(out, "Some data was outputted properly");
+ is(out.length, 10, "The outputted length is correct.");
+
+ info("Got flame graph data:\n" + out.toSource() + "\n");
+
+ for (let i = 0; i < out.length; i++) {
+ let found = out[i];
+ let expected = EXPECTED_OUTPUT[i];
+
+ is(found.blocks.length, expected.blocks.length,
+ "The correct number of blocks were found in this bucket.");
+
+ for (let j = 0; j < found.blocks.length; j++) {
+ is(found.blocks[j].x, expected.blocks[j].x,
+ "The expected block X position is correct for this frame.");
+ is(found.blocks[j].y, expected.blocks[j].y,
+ "The expected block Y position is correct for this frame.");
+ is(found.blocks[j].width, expected.blocks[j].width,
+ "The expected block width is correct for this frame.");
+ is(found.blocks[j].height, expected.blocks[j].height,
+ "The expected block height is correct for this frame.");
+ is(found.blocks[j].text, expected.blocks[j].text,
+ "The expected block text is correct for this frame.");
+ }
+ }
+}
+
+let TEST_DATA = [{
+ frames: [{
+ location: "http://A"
+ }, {
+ location: "http://A"
+ }, {
+ location: "http://A"
+ }, {
+ location: "https://B"
+ }, {
+ location: "https://B"
+ }, {
+ location: "file://C",
+ }, {
+ location: "chrome://D"
+ }, {
+ location: "resource://E"
+ }],
+ time: 50
+}, {
+ frames: [{
+ location: "chrome://D"
+ }, {
+ location: "resource://E"
+ }],
+ time: 100
+}, {
+ frames: [{
+ location: "http://A"
+ }, {
+ location: "https://B"
+ }, {
+ location: "file://C",
+ }],
+ time: 150
+}];
+
+let EXPECTED_OUTPUT = [{
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 0,
+ rawLocation: "http://A"
+ },
+ x: 0,
+ y: 0,
+ width: 50,
+ height: 11,
+ text: "http://A"
+ }, {
+ srcData: {
+ startTime: 0,
+ rawLocation: "file://C"
+ },
+ x: 0,
+ y: 22,
+ width: 50,
+ height: 11,
+ text: "file://C"
+ }, {
+ srcData: {
+ startTime: 100,
+ rawLocation: "http://A"
+ },
+ x: 100,
+ y: 0,
+ width: 50,
+ height: 11,
+ text: "http://A"
+ }]
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 50,
+ rawLocation: "\m/"
+ },
+ x: 50,
+ y: 0,
+ width: 50,
+ height: 11,
+ text: "\m/"
+ }]
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: []
+}, {
+ blocks: [{
+ srcData: {
+ startTime: 0,
+ rawLocation: "https://B"
+ },
+ x: 0,
+ y: 11,
+ width: 50,
+ height: 11,
+ text: "https://B"
+ }, {
+ srcData: {
+ startTime: 100,
+ rawLocation: "https://B"
+ },
+ x: 100,
+ y: 11,
+ width: 50,
+ height: 11,
+ text: "https://B"
+ }]
+}, {
+ blocks: []
+}];
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-utils-05.js b/toolkit/devtools/shared/test/browser_flame-graph-utils-05.js
new file mode 100644
index 000000000..ca65f253a
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-utils-05.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that flame graph data is cached, and that the cache may be cleared.
+
+let {FlameGraphUtils} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let out1 = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA);
+ let out2 = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA);
+ is(out1, out2, "The outputted data is identical.")
+
+ let out3 = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA, { flattenRecursion: true });
+ is(out2, out3, "The outputted data is still identical.");
+
+ FlameGraphUtils.removeFromCache(TEST_DATA);
+ let out4 = FlameGraphUtils.createFlameGraphDataFromSamples(TEST_DATA, { flattenRecursion: true });
+ isnot(out3, out4, "The outputted data is not identical anymore.");
+}
+
+let TEST_DATA = [{
+ frames: [{
+ location: "A"
+ }, {
+ location: "A"
+ }, {
+ location: "A"
+ }, {
+ location: "B",
+ }, {
+ location: "B",
+ }, {
+ location: "C"
+ }],
+ time: 50,
+}];
diff --git a/toolkit/devtools/shared/test/browser_flame-graph-utils-hash.js b/toolkit/devtools/shared/test/browser_flame-graph-utils-hash.js
new file mode 100644
index 000000000..e5509e482
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_flame-graph-utils-hash.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if (idle) nodes are added when necessary in the flame graph data.
+
+let {FlameGraphUtils} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+
+let test = Task.async(function*() {
+ let hash1 = FlameGraphUtils._getStringHash("abc");
+ let hash2 = FlameGraphUtils._getStringHash("acb");
+ let hash3 = FlameGraphUtils._getStringHash(Array.from(Array(100000)).join("a"));
+ let hash4 = FlameGraphUtils._getStringHash(Array.from(Array(100000)).join("b"));
+
+ isnot(hash1, hash2, "The hashes should not be equal (1).");
+ isnot(hash2, hash3, "The hashes should not be equal (2).");
+ isnot(hash3, hash4, "The hashes should not be equal (3).");
+
+ ok(Number.isInteger(hash1), "The hashes should be integers, not Infinity or NaN (1).");
+ ok(Number.isInteger(hash2), "The hashes should be integers, not Infinity or NaN (2).");
+ ok(Number.isInteger(hash3), "The hashes should be integers, not Infinity or NaN (3).");
+ ok(Number.isInteger(hash4), "The hashes should be integers, not Infinity or NaN (4).");
+
+ finish();
+});
diff --git a/toolkit/devtools/shared/test/browser_graphs-01.js b/toolkit/devtools/shared/test/browser_graphs-01.js
new file mode 100644
index 000000000..3a4555d1c
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-01.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets works properly.
+
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+ finish();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ doc.body.setAttribute("style", "position: fixed; width: 100%; height: 100%; margin: 0;");
+
+ let graph = new LineGraphWidget(doc.body, "fps");
+
+ let readyEventEmitted;
+ graph.once("ready", () => readyEventEmitted = true);
+
+ yield graph.ready();
+ ok(readyEventEmitted, "The 'ready' event should have been emitted");
+
+ testGraph(host, graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(host, graph) {
+ ok(graph._container.classList.contains("line-graph-widget-container"),
+ "The correct graph container was created.");
+ ok(graph._canvas.classList.contains("line-graph-widget-canvas"),
+ "The correct graph container was created.");
+
+ let bounds = host.frame.getBoundingClientRect();
+
+ is(graph.width, bounds.width * window.devicePixelRatio,
+ "The graph has the correct width.");
+ is(graph.height, bounds.height * window.devicePixelRatio,
+ "The graph has the correct height.");
+
+ ok(graph._cursor.x === null,
+ "The graph's cursor X coordinate is initially null.");
+ ok(graph._cursor.y === null,
+ "The graph's cursor Y coordinate is initially null.");
+
+ ok(graph._selection.start === null,
+ "The graph's selection start value is initially null.");
+ ok(graph._selection.end === null,
+ "The graph's selection end value is initially null.");
+
+ ok(graph._selectionDragger.origin === null,
+ "The graph's dragger origin value is initially null.");
+ ok(graph._selectionDragger.anchor.start === null,
+ "The graph's dragger anchor start value is initially null.");
+ ok(graph._selectionDragger.anchor.end === null,
+ "The graph's dragger anchor end value is initially null.");
+
+ ok(graph._selectionResizer.margin === null,
+ "The graph's resizer margin value is initially null.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-02.js b/toolkit/devtools/shared/test/browser_graphs-02.js
new file mode 100644
index 000000000..907cb5701
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-02.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets can properly add data, regions and highlights.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+const TEST_REGIONS = [{ start: 320, end: 460 }, { start: 780, end: 860 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+ yield graph.once("ready");
+
+ testDataAndRegions(graph);
+ testHighlights(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testDataAndRegions(graph) {
+ let thrown1;
+ try {
+ graph.setRegions(TEST_REGIONS);
+ } catch (e) {
+ thrown1 = true;
+ }
+ ok(thrown1, "Setting regions before setting data shouldn't work.");
+
+ graph.setData(TEST_DATA);
+ graph.setRegions(TEST_REGIONS);
+
+ let thrown2;
+ try {
+ graph.setRegions(TEST_REGIONS);
+ } catch (e) {
+ thrown2 = true;
+ }
+ ok(thrown2, "Setting regions twice shouldn't work.");
+
+ ok(graph.hasData(), "The graph should now have the data source set.");
+ ok(graph.hasRegions(), "The graph should now have the regions set.");
+
+ is(graph.dataScaleX,
+ graph.width / 4180, // last & first tick in TEST_DATA
+ "The data scale on the X axis is correct.");
+
+ is(graph.dataScaleY,
+ graph.height / 60 * 0.85, // max value in TEST_DATA * GRAPH_DAMPEN_VALUES
+ "The data scale on the Y axis is correct.");
+
+ for (let i = 0; i < TEST_REGIONS.length; i++) {
+ let original = TEST_REGIONS[i];
+ let normalized = graph._regions[i];
+
+ is(original.start * graph.dataScaleX, normalized.start,
+ "The region's start value was properly normalized.");
+ is(original.end * graph.dataScaleX, normalized.end,
+ "The region's end value was properly normalized.");
+ }
+}
+
+function testHighlights(graph) {
+ graph.setMask(TEST_REGIONS);
+ ok(graph.hasMask(),
+ "The graph should now have the highlights set.");
+
+ graph.setMask([]);
+ ok(graph.hasMask(),
+ "The graph shouldn't have anything highlighted.");
+
+ graph.setMask(null);
+ ok(!graph.hasMask(),
+ "The graph should have everything highlighted.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-03.js b/toolkit/devtools/shared/test/browser_graphs-03.js
new file mode 100644
index 000000000..b97acbffc
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-03.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets can handle clients getting/setting the
+// selection or cursor.
+
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+ yield graph.once("ready");
+
+ yield testSelection(graph);
+ yield testCursor(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function* testSelection(graph) {
+ ok(graph.getSelection().start === null,
+ "The graph's selection should initially have a null start value.");
+ ok(graph.getSelection().end === null,
+ "The graph's selection should initially have a null end value.");
+ ok(!graph.hasSelection(),
+ "There shouldn't initially be any selection.");
+
+ let selected = graph.once("selecting");
+ graph.setSelection({ start: 100, end: 200 });
+
+ yield selected;
+ ok(true, "A 'selecting' event has been fired.");
+
+ ok(graph.hasSelection(),
+ "There should now be a selection.");
+ is(graph.getSelection().start, 100,
+ "The graph's selection now has an updated start value.");
+ is(graph.getSelection().end, 200,
+ "The graph's selection now has an updated end value.");
+
+ let thrown;
+ try {
+ graph.setSelection({ start: null, end: null });
+ } catch(e) {
+ thrown = true;
+ }
+ ok(thrown, "Setting a null selection shouldn't work.");
+
+ ok(graph.hasSelection(),
+ "There should still be a selection.");
+
+ let deselected = graph.once("deselecting");
+ graph.dropSelection();
+
+ yield deselected;
+ ok(true, "A 'deselecting' event has been fired.");
+
+ ok(!graph.hasSelection(),
+ "There shouldn't be any selection anymore.");
+ ok(graph.getSelection().start === null,
+ "The graph's selection now has a null start value.");
+ ok(graph.getSelection().end === null,
+ "The graph's selection now has a null end value.");
+}
+
+function* testCursor(graph) {
+ ok(graph.getCursor().x === null,
+ "The graph's cursor should initially have a null X value.");
+ ok(graph.getCursor().y === null,
+ "The graph's cursor should initially have a null Y value.");
+ ok(!graph.hasCursor(),
+ "There shouldn't initially be any cursor.");
+
+ graph.setCursor({ x: 100, y: 50 });
+
+ ok(graph.hasCursor(),
+ "There should now be a cursor.");
+ is(graph.getCursor().x, 100,
+ "The graph's cursor now has an updated start value.");
+ is(graph.getCursor().y, 50,
+ "The graph's cursor now has an updated end value.");
+
+ let thrown;
+ try {
+ graph.setCursor({ x: null, y: null });
+ } catch(e) {
+ thrown = true;
+ }
+ ok(thrown, "Setting a null cursor shouldn't work.");
+
+ ok(graph.hasCursor(),
+ "There should still be a cursor.");
+
+ graph.dropCursor();
+
+ ok(!graph.hasCursor(),
+ "There shouldn't be any cursor anymore.");
+ ok(graph.getCursor().x === null,
+ "The graph's cursor now has a null start value.");
+ ok(graph.getCursor().y === null,
+ "The graph's cursor now has a null end value.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-04.js b/toolkit/devtools/shared/test/browser_graphs-04.js
new file mode 100644
index 000000000..2d90b09bf
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-04.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets can correctly compare selections and cursors.
+
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+ yield graph.once("ready");
+
+ testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(graph) {
+ ok(!graph.hasSelection(),
+ "There shouldn't initially be any selection.");
+ is(graph.getSelectionWidth(), 0,
+ "The selection width should be 0 when there's no selection.");
+
+ graph.setSelection({ start: 100, end: 200 });
+
+ ok(graph.hasSelection(),
+ "There should now be a selection.");
+ is(graph.getSelectionWidth(), 100,
+ "The selection width should now be 100.");
+
+ ok(graph.isSelectionDifferent({ start: 100, end: 201 }),
+ "The selection was correctly reported to be different (1).");
+ ok(graph.isSelectionDifferent({ start: 101, end: 200 }),
+ "The selection was correctly reported to be different (2).");
+ ok(graph.isSelectionDifferent({ start: null, end: null }),
+ "The selection was correctly reported to be different (3).");
+ ok(graph.isSelectionDifferent(null),
+ "The selection was correctly reported to be different (4).");
+
+ ok(!graph.isSelectionDifferent({ start: 100, end: 200 }),
+ "The selection was incorrectly reported to be different (1).");
+ ok(!graph.isSelectionDifferent(graph.getSelection()),
+ "The selection was incorrectly reported to be different (2).");
+
+ graph.setCursor({ x: 100, y: 50 });
+
+ ok(graph.isCursorDifferent({ x: 100, y: 51 }),
+ "The cursor was correctly reported to be different (1).");
+ ok(graph.isCursorDifferent({ x: 101, y: 50 }),
+ "The cursor was correctly reported to be different (2).");
+ ok(graph.isCursorDifferent({ x: null, y: null }),
+ "The cursor was correctly reported to be different (3).");
+ ok(graph.isCursorDifferent(null),
+ "The cursor was correctly reported to be different (4).");
+
+ ok(!graph.isCursorDifferent({ x: 100, y: 50 }),
+ "The cursor was incorrectly reported to be different (1).");
+ ok(!graph.isCursorDifferent(graph.getCursor()),
+ "The cursor was incorrectly reported to be different (2).");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-05.js b/toolkit/devtools/shared/test/browser_graphs-05.js
new file mode 100644
index 000000000..78fdfbf06
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-05.js
@@ -0,0 +1,132 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets can correctly determine which regions are hovered.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+const TEST_REGIONS = [{ start: 320, end: 460 }, { start: 780, end: 860 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+ yield graph.once("ready");
+
+ testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(graph) {
+ ok(!graph.getHoveredRegion(),
+ "There should be no hovered region yet because there's no regions.");
+
+ ok(!graph._isHoveringStartBoundary(),
+ "The graph start boundary should not be hovered.");
+ ok(!graph._isHoveringEndBoundary(),
+ "The graph end boundary should not be hovered.");
+ ok(!graph._isHoveringSelectionContents(),
+ "The graph contents should not be hovered.");
+ ok(!graph._isHoveringSelectionContentsOrBoundaries(),
+ "The graph contents or boundaries should not be hovered.");
+
+ graph.setData(TEST_DATA);
+ graph.setRegions(TEST_REGIONS);
+
+ ok(!graph.getHoveredRegion(),
+ "There should be no hovered region yet because there's no cursor.");
+
+ graph.setCursor({ x: TEST_REGIONS[0].start * graph.dataScaleX - 1, y: 0 });
+ ok(!graph.getHoveredRegion(),
+ "There shouldn't be any hovered region yet.");
+
+ graph.setCursor({ x: TEST_REGIONS[0].start * graph.dataScaleX + 1, y: 0 });
+ ok(graph.getHoveredRegion(),
+ "There should be a hovered region now.");
+ is(graph.getHoveredRegion().start, 320 * graph.dataScaleX,
+ "The reported hovered region is correct (1).");
+ is(graph.getHoveredRegion().end, 460 * graph.dataScaleX,
+ "The reported hovered region is correct (2).");
+
+ graph.setSelection({ start: 100, end: 200 });
+
+ info("Setting cursor over the left boundary.");
+ graph.setCursor({ x: 100, y: 0 });
+
+ ok(graph._isHoveringStartBoundary(),
+ "The graph start boundary should be hovered.");
+ ok(!graph._isHoveringEndBoundary(),
+ "The graph end boundary should not be hovered.");
+ ok(!graph._isHoveringSelectionContents(),
+ "The graph contents should not be hovered.");
+ ok(graph._isHoveringSelectionContentsOrBoundaries(),
+ "The graph contents or boundaries should be hovered.");
+
+ info("Setting cursor near the left boundary.");
+ graph.setCursor({ x: 105, y: 0 });
+
+ ok(graph._isHoveringStartBoundary(),
+ "The graph start boundary should be hovered.");
+ ok(!graph._isHoveringEndBoundary(),
+ "The graph end boundary should not be hovered.");
+ ok(graph._isHoveringSelectionContents(),
+ "The graph contents should be hovered.");
+ ok(graph._isHoveringSelectionContentsOrBoundaries(),
+ "The graph contents or boundaries should be hovered.");
+
+ info("Setting cursor over the selection.");
+ graph.setCursor({ x: 150, y: 0 });
+
+ ok(!graph._isHoveringStartBoundary(),
+ "The graph start boundary should not be hovered.");
+ ok(!graph._isHoveringEndBoundary(),
+ "The graph end boundary should not be hovered.");
+ ok(graph._isHoveringSelectionContents(),
+ "The graph contents should be hovered.");
+ ok(graph._isHoveringSelectionContentsOrBoundaries(),
+ "The graph contents or boundaries should be hovered.");
+
+ info("Setting cursor near the right boundary.");
+ graph.setCursor({ x: 195, y: 0 });
+
+ ok(!graph._isHoveringStartBoundary(),
+ "The graph start boundary should not be hovered.");
+ ok(graph._isHoveringEndBoundary(),
+ "The graph end boundary should be hovered.");
+ ok(graph._isHoveringSelectionContents(),
+ "The graph contents should be hovered.");
+ ok(graph._isHoveringSelectionContentsOrBoundaries(),
+ "The graph contents or boundaries should be hovered.");
+
+ info("Setting cursor over the right boundary.");
+ graph.setCursor({ x: 200, y: 0 });
+
+ ok(!graph._isHoveringStartBoundary(),
+ "The graph start boundary should not be hovered.");
+ ok(graph._isHoveringEndBoundary(),
+ "The graph end boundary should be hovered.");
+ ok(!graph._isHoveringSelectionContents(),
+ "The graph contents should not be hovered.");
+ ok(graph._isHoveringSelectionContentsOrBoundaries(),
+ "The graph contents or boundaries should be hovered.");
+
+ info("Setting away from the selection.");
+ graph.setCursor({ x: 300, y: 0 });
+
+ ok(!graph._isHoveringStartBoundary(),
+ "The graph start boundary should not be hovered.");
+ ok(!graph._isHoveringEndBoundary(),
+ "The graph end boundary should not be hovered.");
+ ok(!graph._isHoveringSelectionContents(),
+ "The graph contents should not be hovered.");
+ ok(!graph._isHoveringSelectionContentsOrBoundaries(),
+ "The graph contents or boundaries should not be hovered.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-06.js b/toolkit/devtools/shared/test/browser_graphs-06.js
new file mode 100644
index 000000000..781b0ed9c
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-06.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if clicking on regions adds a selection spanning that region.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+const TEST_REGIONS = [{ start: 320, end: 460 }, { start: 780, end: 860 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+ yield graph.once("ready");
+
+ testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(graph) {
+ graph.setData(TEST_DATA);
+ graph.setRegions(TEST_REGIONS);
+
+ click(graph, (graph._regions[0].start + graph._regions[0].end) / 2);
+ is(graph.getSelection().start, graph._regions[0].start,
+ "The first region is now selected (1).");
+ is(graph.getSelection().end, graph._regions[0].end,
+ "The first region is now selected (2).");
+
+ let min = map(graph.getSelection().start, 0, graph.width, 112, 4180);
+ let max = map(graph.getSelection().end, 0, graph.width, 112, 4180);
+ is(graph.getMappedSelection().min, min,
+ "The mapped selection's min value is correct (1).");
+ is(graph.getMappedSelection().max, max,
+ "The mapped selection's max value is correct (2).");
+
+ click(graph, (graph._regions[1].start + graph._regions[1].end) / 2);
+ is(graph.getSelection().start, graph._regions[1].start,
+ "The second region is now selected (1).");
+ is(graph.getSelection().end, graph._regions[1].end,
+ "The second region is now selected (2).");
+
+ min = map(graph.getSelection().start, 0, graph.width, 112, 4180);
+ max = map(graph.getSelection().end, 0, graph.width, 112, 4180);
+ is(graph.getMappedSelection().min, min,
+ "The mapped selection's min value is correct (3).");
+ is(graph.getMappedSelection().max, max,
+ "The mapped selection's max value is correct (4).");
+
+ graph.setSelection({ start: graph.width, end: 0 });
+ min = map(0, 0, graph.width, 112, 4180);
+ max = map(graph.width, 0, graph.width, 112, 4180);
+ is(graph.getMappedSelection().min, min,
+ "The mapped selection's min value is correct (5).");
+ is(graph.getMappedSelection().max, max,
+ "The mapped selection's max value is correct (6).");
+
+ graph.setSelection({ start: graph.width + 100, end: -100 });
+ min = map(0, 0, graph.width, 112, 4180);
+ max = map(graph.width, 0, graph.width, 112, 4180);
+ is(graph.getMappedSelection().min, min,
+ "The mapped selection's min value is correct (7).");
+ is(graph.getMappedSelection().max, max,
+ "The mapped selection's max value is correct (8).");
+}
+
+/**
+ * Maps a value from one range to another.
+ */
+function map(value, istart, istop, ostart, ostop) {
+ return ostart + (ostop - ostart) * ((value - istart) / (istop - istart));
+}
+
+// EventUtils just doesn't work!
+
+function click(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseDown({ clientX: x, clientY: y });
+ graph._onMouseUp({ clientX: x, clientY: y });
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-07a.js b/toolkit/devtools/shared/test/browser_graphs-07a.js
new file mode 100644
index 000000000..d0828b2ee
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-07a.js
@@ -0,0 +1,201 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if selecting, resizing, moving selections and zooming in/out works.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+ yield graph.once("ready");
+
+ testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(graph) {
+ graph.setData(TEST_DATA);
+
+ info("Making a selection.");
+
+ dragStart(graph, 300);
+ ok(graph.hasSelectionInProgress(),
+ "The selection should start (1).");
+ is(graph.getSelection().start, 300,
+ "The current selection start value is correct (1).");
+ is(graph.getSelection().end, 300,
+ "The current selection end value is correct (1).");
+
+ hover(graph, 400);
+ ok(graph.hasSelectionInProgress(),
+ "The selection should still be in progress (2).");
+ is(graph.getSelection().start, 300,
+ "The current selection start value is correct (2).");
+ is(graph.getSelection().end, 400,
+ "The current selection end value is correct (2).");
+
+ dragStop(graph, 500);
+ ok(!graph.hasSelectionInProgress(),
+ "The selection should have stopped (3).");
+ is(graph.getSelection().start, 300,
+ "The current selection start value is correct (3).");
+ is(graph.getSelection().end, 500,
+ "The current selection end value is correct (3).");
+
+ info("Making a new selection.");
+
+ dragStart(graph, 200);
+ ok(graph.hasSelectionInProgress(),
+ "The selection should start (4).");
+ is(graph.getSelection().start, 200,
+ "The current selection start value is correct (4).");
+ is(graph.getSelection().end, 200,
+ "The current selection end value is correct (4).");
+
+ hover(graph, 300);
+ ok(graph.hasSelectionInProgress(),
+ "The selection should still be in progress (5).");
+ is(graph.getSelection().start, 200,
+ "The current selection start value is correct (5).");
+ is(graph.getSelection().end, 300,
+ "The current selection end value is correct (5).");
+
+ dragStop(graph, 400);
+ ok(!graph.hasSelectionInProgress(),
+ "The selection should have stopped (6).");
+ is(graph.getSelection().start, 200,
+ "The current selection start value is correct (6).");
+ is(graph.getSelection().end, 400,
+ "The current selection end value is correct (6).");
+
+ info("Resizing by dragging the end handlebar.");
+
+ dragStart(graph, 400);
+ is(graph.getSelection().start, 200,
+ "The current selection start value is correct (7).");
+ is(graph.getSelection().end, 400,
+ "The current selection end value is correct (7).");
+
+ dragStop(graph, 600);
+ is(graph.getSelection().start, 200,
+ "The current selection start value is correct (8).");
+ is(graph.getSelection().end, 600,
+ "The current selection end value is correct (8).");
+
+ info("Resizing by dragging the start handlebar.");
+
+ dragStart(graph, 200);
+ is(graph.getSelection().start, 200,
+ "The current selection start value is correct (9).");
+ is(graph.getSelection().end, 600,
+ "The current selection end value is correct (9).");
+
+ dragStop(graph, 100);
+ is(graph.getSelection().start, 100,
+ "The current selection start value is correct (10).");
+ is(graph.getSelection().end, 600,
+ "The current selection end value is correct (10).");
+
+ info("Moving by dragging the selection.");
+
+ dragStart(graph, 300);
+ hover(graph, 400);
+ is(graph.getSelection().start, 200,
+ "The current selection start value is correct (11).");
+ is(graph.getSelection().end, 700,
+ "The current selection end value is correct (11).");
+
+ dragStop(graph, 500);
+ is(graph.getSelection().start, 300,
+ "The current selection start value is correct (12).");
+ is(graph.getSelection().end, 800,
+ "The current selection end value is correct (12).");
+
+ info("Zooming in by scrolling inside the selection.");
+
+ scroll(graph, -1000, 600);
+ is(graph.getSelection().start, 525,
+ "The current selection start value is correct (13).");
+ is(graph.getSelection().end, 650,
+ "The current selection end value is correct (13).");
+
+ info("Zooming out by scrolling inside the selection.");
+
+ scroll(graph, 1000, 600);
+ is(graph.getSelection().start, 468.75,
+ "The current selection start value is correct (14).");
+ is(graph.getSelection().end, 687.5,
+ "The current selection end value is correct (14).");
+
+ info("Sliding left by scrolling outside the selection.");
+
+ scroll(graph, 100, 900);
+ is(graph.getSelection().start, 458.75,
+ "The current selection start value is correct (15).");
+ is(graph.getSelection().end, 677.5,
+ "The current selection end value is correct (15).");
+
+ info("Sliding right by scrolling outside the selection.");
+
+ scroll(graph, -100, 900);
+ is(graph.getSelection().start, 468.75,
+ "The current selection start value is correct (16).");
+ is(graph.getSelection().end, 687.5,
+ "The current selection end value is correct (16).");
+
+ info("Zooming out a lot.");
+
+ scroll(graph, Number.MAX_SAFE_INTEGER, 500);
+ is(graph.getSelection().start, 1,
+ "The current selection start value is correct (17).");
+ is(graph.getSelection().end, graph.width - 1,
+ "The current selection end value is correct (17).");
+}
+
+// EventUtils just doesn't work!
+
+function hover(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+}
+
+function click(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseDown({ clientX: x, clientY: y });
+ graph._onMouseUp({ clientX: x, clientY: y });
+}
+
+function dragStart(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseDown({ clientX: x, clientY: y });
+}
+
+function dragStop(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseUp({ clientX: x, clientY: y });
+}
+
+function scroll(graph, wheel, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseWheel({ clientX: x, clientY: y, detail: wheel });
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-07b.js b/toolkit/devtools/shared/test/browser_graphs-07b.js
new file mode 100644
index 000000000..fe24cf64f
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-07b.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if selections can't be added via clicking, while not allowed.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+ yield graph.once("ready");
+
+ testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(graph) {
+ graph.setData(TEST_DATA);
+ graph.selectionEnabled = false;
+
+ info("Attempting to make a selection.");
+
+ dragStart(graph, 300);
+ is(graph.hasSelection() || graph.hasSelectionInProgress(), false,
+ "The graph shouldn't have a selection (1).");
+
+ hover(graph, 400);
+ is(graph.hasSelection() || graph.hasSelectionInProgress(), false,
+ "The graph shouldn't have a selection (2).");
+
+ dragStop(graph, 500);
+ is(graph.hasSelection() || graph.hasSelectionInProgress(), false,
+ "The graph shouldn't have a selection (3).");
+}
+
+// EventUtils just doesn't work!
+
+function hover(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+}
+
+function dragStart(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseDown({ clientX: x, clientY: y });
+}
+
+function dragStop(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseUp({ clientX: x, clientY: y });
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-08.js b/toolkit/devtools/shared/test/browser_graphs-08.js
new file mode 100644
index 000000000..158e12823
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-08.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if a selection is dropped when clicking outside of it.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+ yield graph.once("ready");
+
+ testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(graph) {
+ graph.setData(TEST_DATA);
+
+ dragStart(graph, 300);
+ dragStop(graph, 500);
+ ok(graph.hasSelection(),
+ "A selection should be available.");
+ is(graph.getSelection().start, 300,
+ "The current selection start value is correct.");
+ is(graph.getSelection().end, 500,
+ "The current selection end value is correct.");
+
+ click(graph, 600);
+ ok(!graph.hasSelection(),
+ "The selection should be dropped.");
+}
+
+// EventUtils just doesn't work!
+
+function click(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseDown({ clientX: x, clientY: y });
+ graph._onMouseUp({ clientX: x, clientY: y });
+}
+
+function dragStart(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseDown({ clientX: x, clientY: y });
+}
+
+function dragStop(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseUp({ clientX: x, clientY: y });
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-09a.js b/toolkit/devtools/shared/test/browser_graphs-09a.js
new file mode 100644
index 000000000..ff59ce997
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-09a.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that line graphs properly create the gutter and tooltips.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, { metric: "fps" });
+
+ yield testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function* testGraph(graph) {
+ info("Should be able to set the graph data before waiting for the ready event.");
+
+ yield graph.setDataWhenReady(TEST_DATA);
+ ok(graph.hasData(), "Data was set successfully.");
+
+ is(graph._gutter.hidden, false,
+ "The gutter should not be hidden because the tooltips have arrows.");
+ is(graph._maxTooltip.hidden, false,
+ "The max tooltip should not be hidden.");
+ is(graph._avgTooltip.hidden, false,
+ "The avg tooltip should not be hidden.");
+ is(graph._minTooltip.hidden, false,
+ "The min tooltip should not be hidden.");
+
+ is(graph._maxTooltip.getAttribute("with-arrows"), "true",
+ "The maximum tooltip has the correct 'with-arrows' attribute.");
+ is(graph._avgTooltip.getAttribute("with-arrows"), "true",
+ "The average tooltip has the correct 'with-arrows' attribute.");
+ is(graph._minTooltip.getAttribute("with-arrows"), "true",
+ "The minimum tooltip has the correct 'with-arrows' attribute.");
+
+ is(graph._maxTooltip.querySelector("[text=info]").textContent, "max",
+ "The maximum tooltip displays the correct info.");
+ is(graph._avgTooltip.querySelector("[text=info]").textContent, "avg",
+ "The average tooltip displays the correct info.");
+ is(graph._minTooltip.querySelector("[text=info]").textContent, "min",
+ "The minimum tooltip displays the correct info.");
+
+ is(graph._maxTooltip.querySelector("[text=value]").textContent, "60",
+ "The maximum tooltip displays the correct value.");
+ is(graph._avgTooltip.querySelector("[text=value]").textContent, "41.71",
+ "The average tooltip displays the correct value.");
+ is(graph._minTooltip.querySelector("[text=value]").textContent, "10",
+ "The minimum tooltip displays the correct value.");
+
+ is(graph._maxTooltip.querySelector("[text=metric]").textContent, "fps",
+ "The maximum tooltip displays the correct metric.");
+ is(graph._avgTooltip.querySelector("[text=metric]").textContent, "fps",
+ "The average tooltip displays the correct metric.");
+ is(graph._minTooltip.querySelector("[text=metric]").textContent, "fps",
+ "The minimum tooltip displays the correct metric.");
+
+ is(parseInt(graph._maxTooltip.style.top), 22,
+ "The maximum tooltip is positioned correctly.");
+ is(parseInt(graph._avgTooltip.style.top), 61,
+ "The average tooltip is positioned correctly.");
+ is(parseInt(graph._minTooltip.style.top), 128,
+ "The minimum tooltip is positioned correctly.");
+
+ is(parseInt(graph._maxGutterLine.style.top), 22,
+ "The maximum gutter line is positioned correctly.");
+ is(parseInt(graph._avgGutterLine.style.top), 61,
+ "The average gutter line is positioned correctly.");
+ is(parseInt(graph._minGutterLine.style.top), 128,
+ "The minimum gutter line is positioned correctly.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-09b.js b/toolkit/devtools/shared/test/browser_graphs-09b.js
new file mode 100644
index 000000000..0cae0d467
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-09b.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that line graphs properly use the tooltips configuration properties.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+ graph.withTooltipArrows = false;
+ graph.withFixedTooltipPositions = true;
+
+ yield testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function* testGraph(graph) {
+ yield graph.setDataWhenReady(TEST_DATA);
+
+ is(graph._gutter.hidden, false,
+ "The gutter should be visible even if the tooltips don't have arrows.");
+ is(graph._maxTooltip.hidden, false,
+ "The max tooltip should not be hidden.");
+ is(graph._avgTooltip.hidden, false,
+ "The avg tooltip should not be hidden.");
+ is(graph._minTooltip.hidden, false,
+ "The min tooltip should not be hidden.");
+
+ is(graph._maxTooltip.getAttribute("with-arrows"), "false",
+ "The maximum tooltip has the correct 'with-arrows' attribute.");
+ is(graph._avgTooltip.getAttribute("with-arrows"), "false",
+ "The average tooltip has the correct 'with-arrows' attribute.");
+ is(graph._minTooltip.getAttribute("with-arrows"), "false",
+ "The minimum tooltip has the correct 'with-arrows' attribute.");
+
+ is(parseInt(graph._maxTooltip.style.top), 8,
+ "The maximum tooltip is positioned correctly.");
+ is(parseInt(graph._avgTooltip.style.top), 8,
+ "The average tooltip is positioned correctly.");
+ is(parseInt(graph._minTooltip.style.top), 142,
+ "The minimum tooltip is positioned correctly.");
+
+ is(parseInt(graph._maxGutterLine.style.top), 22,
+ "The maximum gutter line is positioned correctly.");
+ is(parseInt(graph._avgGutterLine.style.top), 61,
+ "The average gutter line is positioned correctly.");
+ is(parseInt(graph._minGutterLine.style.top), 128,
+ "The minimum gutter line is positioned correctly.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-09c.js b/toolkit/devtools/shared/test/browser_graphs-09c.js
new file mode 100644
index 000000000..97e4dd7e5
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-09c.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that line graphs hide the tooltips when there's no data available.
+
+const TEST_DATA = [];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+
+ yield testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function* testGraph(graph) {
+ yield graph.setDataWhenReady(TEST_DATA);
+
+ is(graph._gutter.hidden, true,
+ "The gutter should be hidden, since there's no data available.");
+ is(graph._maxTooltip.hidden, true,
+ "The max tooltip should be hidden.");
+ is(graph._avgTooltip.hidden, true,
+ "The avg tooltip should be hidden.");
+ is(graph._minTooltip.hidden, true,
+ "The min tooltip should be hidden.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-09d.js b/toolkit/devtools/shared/test/browser_graphs-09d.js
new file mode 100644
index 000000000..c56163aa6
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-09d.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that line graphs hide the 'max' tooltip when the distance between
+// the 'min' and 'max' tooltip is too small.
+
+const TEST_DATA = [{ delta: 100, value: 60 }, { delta: 200, value: 59.9 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+
+ yield testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function* testGraph(graph) {
+ yield graph.setDataWhenReady(TEST_DATA);
+
+ is(graph._gutter.hidden, false,
+ "The gutter should not be hidden.");
+ is(graph._maxTooltip.hidden, true,
+ "The max tooltip should be hidden.");
+ is(graph._avgTooltip.hidden, false,
+ "The avg tooltip should not be hidden.");
+ is(graph._minTooltip.hidden, false,
+ "The min tooltip should not be hidden.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-09e.js b/toolkit/devtools/shared/test/browser_graphs-09e.js
new file mode 100644
index 000000000..2f75f3099
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-09e.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that line graphs hide the gutter and tooltips when there's no data,
+// but show them when there is.
+
+const NO_DATA = [];
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+
+ yield testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function* testGraph(graph) {
+ yield graph.setDataWhenReady(NO_DATA);
+
+ is(graph._gutter.hidden, true,
+ "The gutter should be hidden when there's no data available.");
+ is(graph._maxTooltip.hidden, true,
+ "The max tooltip should be hidden when there's no data available.");
+ is(graph._avgTooltip.hidden, true,
+ "The avg tooltip should be hidden when there's no data available.");
+ is(graph._minTooltip.hidden, true,
+ "The min tooltip should be hidden when there's no data available.");
+
+ yield graph.setDataWhenReady(TEST_DATA);
+
+ is(graph._gutter.hidden, false,
+ "The gutter should be visible now.");
+ is(graph._maxTooltip.hidden, false,
+ "The max tooltip should be visible now.");
+ is(graph._avgTooltip.hidden, false,
+ "The avg tooltip should be visible now.");
+ is(graph._minTooltip.hidden, false,
+ "The min tooltip should be visible now.");
+
+ yield graph.setDataWhenReady(NO_DATA);
+
+ is(graph._gutter.hidden, true,
+ "The gutter should be hidden again.");
+ is(graph._maxTooltip.hidden, true,
+ "The max tooltip should be hidden again.");
+ is(graph._avgTooltip.hidden, true,
+ "The avg tooltip should be hidden again.");
+ is(graph._minTooltip.hidden, true,
+ "The min tooltip should be hidden again.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-09f.js b/toolkit/devtools/shared/test/browser_graphs-09f.js
new file mode 100644
index 000000000..7b43a4c41
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-09f.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests the constructor options for `min`, `max` and `avg` on displaying the
+// gutter/tooltips and lines.
+
+const TEST_DATA = [{ delta: 100, value: 60 }, { delta: 200, value: 1 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+
+ yield testGraph(doc.body, { avg: false });
+ yield testGraph(doc.body, { min: false });
+ yield testGraph(doc.body, { max: false });
+ yield testGraph(doc.body, { min: false, max: false, avg: false });
+ yield testGraph(doc.body, {});
+
+ host.destroy();
+}
+
+function* testGraph (parent, options) {
+ options.metric = "fps";
+ let graph = new LineGraphWidget(parent, options);
+ yield graph.setDataWhenReady(TEST_DATA);
+ let shouldGutterShow = options.min === false && options.max === false;
+
+ is(graph._gutter.hidden, shouldGutterShow,
+ `The gutter should ${shouldGutterShow ? "" : "not "}be shown`);
+
+ is(graph._maxTooltip.hidden, options.max === false,
+ `The max tooltip should ${options.max === false ? "not " : ""}be shown`);
+ is(graph._maxGutterLine.hidden, options.max === false,
+ `The max gutter should ${options.max === false ? "not " : ""}be shown`);
+ is(graph._minTooltip.hidden, options.min === false,
+ `The min tooltip should ${options.min === false ? "not " : ""}be shown`);
+ is(graph._minGutterLine.hidden, options.min === false,
+ `The min gutter should ${options.min === false ? "not " : ""}be shown`);
+ is(graph._avgTooltip.hidden, options.avg === false,
+ `The avg tooltip should ${options.avg === false ? "not " : ""}be shown`);
+ is(graph._avgGutterLine.hidden, options.avg === false,
+ `The avg gutter should ${options.avg === false ? "not " : ""}be shown`);
+
+ graph.destroy();
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-10a.js b/toolkit/devtools/shared/test/browser_graphs-10a.js
new file mode 100644
index 000000000..c5f2f8d94
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-10a.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graphs properly handle resizing.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost("window");
+ doc.body.setAttribute("style", "position: fixed; width: 100%; height: 100%; margin: 0;");
+
+ let graph = new LineGraphWidget(doc.body, "fps");
+ yield graph.once("ready");
+
+ let refreshCount = 0;
+ graph.on("refresh", () => refreshCount++);
+
+ yield testGraph(host, graph);
+
+ is(refreshCount, 2, "The graph should've been refreshed 2 times.");
+
+ graph.destroy();
+ host.destroy();
+}
+
+function* testGraph(host, graph) {
+ graph.setData(TEST_DATA);
+ let initialBounds = host.frame.getBoundingClientRect();
+
+ host._window.resizeBy(-100, -100);
+ yield graph.once("refresh");
+ let newBounds = host.frame.getBoundingClientRect();
+
+ is(initialBounds.width - newBounds.width, 100,
+ "The window was properly resized (1).");
+ is(initialBounds.height - newBounds.height, 100,
+ "The window was properly resized (2).");
+
+ is(graph.width, newBounds.width * window.devicePixelRatio,
+ "The graph has the correct width (1).");
+ is(graph.height, newBounds.height * window.devicePixelRatio,
+ "The graph has the correct height (1).");
+
+ info("Making a selection.");
+
+ dragStart(graph, 300);
+ ok(graph.hasSelectionInProgress(),
+ "The selection should start (1).");
+ is(graph.getSelection().start, 300,
+ "The current selection start value is correct (1).");
+ is(graph.getSelection().end, 300,
+ "The current selection end value is correct (1).");
+
+ hover(graph, 400);
+ ok(graph.hasSelectionInProgress(),
+ "The selection should still be in progress (2).");
+ is(graph.getSelection().start, 300,
+ "The current selection start value is correct (2).");
+ is(graph.getSelection().end, 400,
+ "The current selection end value is correct (2).");
+
+ dragStop(graph, 500);
+ ok(!graph.hasSelectionInProgress(),
+ "The selection should have stopped (3).");
+ is(graph.getSelection().start, 300,
+ "The current selection start value is correct (3).");
+ is(graph.getSelection().end, 500,
+ "The current selection end value is correct (3).");
+
+ host._window.resizeBy(100, 100);
+ yield graph.once("refresh");
+ let newerBounds = host.frame.getBoundingClientRect();
+
+ is(initialBounds.width - newerBounds.width, 0,
+ "The window was properly resized (3).");
+ is(initialBounds.height - newerBounds.height, 0,
+ "The window was properly resized (4).");
+
+ is(graph.width, newerBounds.width * window.devicePixelRatio,
+ "The graph has the correct width (2).");
+ is(graph.height, newerBounds.height * window.devicePixelRatio,
+ "The graph has the correct height (2).");
+
+ info("Making a new selection.");
+
+ dragStart(graph, 200);
+ ok(graph.hasSelectionInProgress(),
+ "The selection should start (4).");
+ is(graph.getSelection().start, 200,
+ "The current selection start value is correct (4).");
+ is(graph.getSelection().end, 200,
+ "The current selection end value is correct (4).");
+
+ hover(graph, 300);
+ ok(graph.hasSelectionInProgress(),
+ "The selection should still be in progress (5).");
+ is(graph.getSelection().start, 200,
+ "The current selection start value is correct (5).");
+ is(graph.getSelection().end, 300,
+ "The current selection end value is correct (5).");
+
+ dragStop(graph, 400);
+ ok(!graph.hasSelectionInProgress(),
+ "The selection should have stopped (6).");
+ is(graph.getSelection().start, 200,
+ "The current selection start value is correct (6).");
+ is(graph.getSelection().end, 400,
+ "The current selection end value is correct (6).");
+}
+
+// EventUtils just doesn't work!
+
+function hover(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+}
+
+function dragStart(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseDown({ clientX: x, clientY: y });
+}
+
+function dragStop(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseUp({ clientX: x, clientY: y });
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-10b.js b/toolkit/devtools/shared/test/browser_graphs-10b.js
new file mode 100644
index 000000000..ddb0f5a0f
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-10b.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graphs aren't refreshed when the owner window resizes but
+// the graph dimensions stay the same.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost("window");
+ doc.body.setAttribute("style", "position: fixed; width: 100%; height: 100%; margin: 0;");
+
+ let graph = new LineGraphWidget(doc.body, "fps");
+ graph.fixedWidth = 200;
+ graph.fixedHeight = 100;
+ yield graph.once("ready");
+
+ let refreshCount = 0;
+ let refreshCancelledCount = 0;
+ graph.on("refresh", () => refreshCount++);
+ graph.on("refresh-cancelled", () => refreshCancelledCount++);
+
+ yield testGraph(host, graph);
+
+ is(refreshCount, 0, "The graph shouldn't have been refreshed at all.");
+ is(refreshCancelledCount, 2, "The graph should've had 2 refresh attempts.");
+
+ graph.destroy();
+ host.destroy();
+}
+
+function* testGraph(host, graph) {
+ graph.setData(TEST_DATA);
+
+ host._window.resizeBy(-100, -100);
+ yield graph.once("refresh-cancelled");
+
+ host._window.resizeBy(100, 100);
+ yield graph.once("refresh-cancelled");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-11a.js b/toolkit/devtools/shared/test/browser_graphs-11a.js
new file mode 100644
index 000000000..51f8b5a02
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-11a.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that bar graph create a legend as expected.
+
+let {BarGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+const CATEGORIES = [
+ { color: "#46afe3", label: "Foo" },
+ { color: "#eb5368", label: "Bar" },
+ { color: "#70bf53", label: "Baz" }
+];
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new BarGraphWidget(doc.body);
+ yield graph.once("ready");
+
+ testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(graph) {
+ graph.format = CATEGORIES;
+ graph.setData([{ delta: 0, values: [] }]);
+
+ let legendContainer = graph._document.querySelector(".bar-graph-widget-legend");
+ ok(legendContainer,
+ "A legend container should be available.");
+ is(legendContainer.childNodes.length, 3,
+ "Three legend items should have been created.");
+
+ let legendItems = graph._document.querySelectorAll(".bar-graph-widget-legend-item");
+ is(legendItems.length, 3,
+ "Three legend items should exist in the entire graph.");
+
+ is(legendItems[0].querySelector("[view=color]").style.backgroundColor, "rgb(70, 175, 227)",
+ "The first legend item has the correct color.");
+ is(legendItems[1].querySelector("[view=color]").style.backgroundColor, "rgb(235, 83, 104)",
+ "The second legend item has the correct color.");
+ is(legendItems[2].querySelector("[view=color]").style.backgroundColor, "rgb(112, 191, 83)",
+ "The third legend item has the correct color.");
+
+ is(legendItems[0].querySelector("[view=label]").textContent, "Foo",
+ "The first legend item has the correct label.");
+ is(legendItems[1].querySelector("[view=label]").textContent, "Bar",
+ "The second legend item has the correct label.");
+ is(legendItems[2].querySelector("[view=label]").textContent, "Baz",
+ "The third legend item has the correct label.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-11b.js b/toolkit/devtools/shared/test/browser_graphs-11b.js
new file mode 100644
index 000000000..bf283e2d3
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-11b.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that bar graph's legend items handle mouseover/mouseout.
+
+let {BarGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+const CATEGORIES = [
+ { color: "#46afe3", label: "Foo" },
+ { color: "#eb5368", label: "Bar" },
+ { color: "#70bf53", label: "Baz" }
+];
+
+add_task(function*() {
+ yield promiseTab("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 BarGraphWidget(doc.body, 1);
+ graph.fixedWidth = 200;
+ graph.fixedHeight = 100;
+
+ yield graph.once("ready");
+ yield testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function* testGraph(graph) {
+ graph.format = CATEGORIES;
+ graph.dataOffsetX = 1000;
+ graph.setData([{
+ delta: 1100, values: [0, 2, 3]
+ }, {
+ delta: 1200, values: [1, 0, 2]
+ }, {
+ delta: 1300, values: [2, 1, 0]
+ }, {
+ delta: 1400, values: [0, 3, 1]
+ }, {
+ delta: 1500, values: [3, 0, 2]
+ }, {
+ delta: 1600, values: [3, 2, 0]
+ }]);
+
+ is(graph._blocksBoundingRects.toSource(), "[{type:1, start:0, end:33.33333333333333, top:70, bottom:100}, {type:2, start:0, end:33.33333333333333, top:24, bottom:69}, {type:0, start:34.33333333333333, end:66.66666666666666, top:85, bottom:100}, {type:2, start:34.33333333333333, end:66.66666666666666, top:54, bottom:84}, {type:0, start:67.66666666666666, end:100, top:70, bottom:100}, {type:1, start:67.66666666666666, end:100, top:54, bottom:69}, {type:1, start:101, end:133.33333333333331, top:55, bottom:100}, {type:2, start:101, end:133.33333333333331, top:39, bottom:54}, {type:0, start:134.33333333333331, end:166.66666666666666, top:55, bottom:100}, {type:2, start:134.33333333333331, end:166.66666666666666, top:24, bottom:54}, {type:0, start:167.66666666666666, end:200, top:55, bottom:100}, {type:1, start:167.66666666666666, end:200, top:24, bottom:54}]",
+ "The correct blocks bounding rects were calculated for the bar graph.");
+
+ let legendItems = graph._document.querySelectorAll(".bar-graph-widget-legend-item");
+ is(legendItems.length, 3,
+ "Three legend items should exist in the entire graph.");
+
+ yield testLegend(graph, 0, {
+ highlights: "[{type:0, start:34.33333333333333, end:66.66666666666666, top:85, bottom:100}, {type:0, start:67.66666666666666, end:100, top:70, bottom:100}, {type:0, start:134.33333333333331, end:166.66666666666666, top:55, bottom:100}, {type:0, start:167.66666666666666, end:200, top:55, bottom:100}]",
+ selection: "({start:34.33333333333333, end:200})",
+ leftmost: "({type:0, start:34.33333333333333, end:66.66666666666666, top:85, bottom:100})",
+ rightmost: "({type:0, start:167.66666666666666, end:200, top:55, bottom:100})"
+ });
+ yield testLegend(graph, 1, {
+ highlights: "[{type:1, start:0, end:33.33333333333333, top:70, bottom:100}, {type:1, start:67.66666666666666, end:100, top:54, bottom:69}, {type:1, start:101, end:133.33333333333331, top:55, bottom:100}, {type:1, start:167.66666666666666, end:200, top:24, bottom:54}]",
+ selection: "({start:0, end:200})",
+ leftmost: "({type:1, start:0, end:33.33333333333333, top:70, bottom:100})",
+ rightmost: "({type:1, start:167.66666666666666, end:200, top:24, bottom:54})"
+ });
+ yield testLegend(graph, 2, {
+ highlights: "[{type:2, start:0, end:33.33333333333333, top:24, bottom:69}, {type:2, start:34.33333333333333, end:66.66666666666666, top:54, bottom:84}, {type:2, start:101, end:133.33333333333331, top:39, bottom:54}, {type:2, start:134.33333333333331, end:166.66666666666666, top:24, bottom:54}]",
+ selection: "({start:0, end:166.66666666666666})",
+ leftmost: "({type:2, start:0, end:33.33333333333333, top:24, bottom:69})",
+ rightmost: "({type:2, start:134.33333333333331, end:166.66666666666666, top:24, bottom:54})"
+ });
+}
+
+function* testLegend(graph, index, { highlights, selection, leftmost, rightmost }) {
+ // Hover.
+
+ let legendItems = graph._document.querySelectorAll(".bar-graph-widget-legend-item");
+ let colorBlock = legendItems[index].querySelector("[view=color]");
+
+ let debounced = graph.once("legend-hover");
+ graph._onLegendMouseOver({ target: colorBlock });
+ ok(!graph.hasMask(), "The graph shouldn't get highlights immediately.");
+
+ let [type, rects] = yield debounced;
+ ok(graph.hasMask(), "The graph should now have highlights.");
+
+ is(type, index,
+ "The legend item was correctly hovered.");
+ is(rects.toSource(), highlights,
+ "The legend item highlighted the correct regions.");
+
+ // Unhover.
+
+ let unhovered = graph.once("legend-unhover");
+ graph._onLegendMouseOut();
+ ok(!graph.hasMask(), "The graph shouldn't have highlights anymore.");
+
+ yield unhovered;
+ ok(true, "The 'legend-mouseout' event was emitted.");
+
+ // Select.
+
+ let selected = graph.once("legend-selection");
+ graph._onLegendMouseDown(mockEvent(colorBlock));
+ ok(graph.hasSelection(), "The graph should now have a selection.");
+ is(graph.getSelection().toSource(), selection, "The graph has a correct selection.");
+
+ let [left, right] = yield selected;
+ is(left.toSource(), leftmost, "The correct leftmost data block was found.");
+ is(right.toSource(), rightmost, "The correct rightmost data block was found.");
+
+ // Deselect.
+
+ graph.dropSelection();
+}
+
+function mockEvent(node) {
+ return {
+ target: node,
+ preventDefault: () => {},
+ stopPropagation: () => {}
+ };
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-12.js b/toolkit/devtools/shared/test/browser_graphs-12.js
new file mode 100644
index 000000000..1517f3efb
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-12.js
@@ -0,0 +1,153 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that canvas graphs can have their selection linked.
+
+let {LineGraphWidget, BarGraphWidget, CanvasGraphUtils} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("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 first = document.createElement("div");
+ first.setAttribute("style", "display: inline-block; width: 100%; height: 50%;");
+ doc.body.appendChild(first);
+
+ let second = document.createElement("div");
+ second.setAttribute("style", "display: inline-block; width: 100%; height: 50%;");
+ doc.body.appendChild(second);
+
+ let graph1 = new LineGraphWidget(first, "js");
+ let graph2 = new BarGraphWidget(second);
+
+ CanvasGraphUtils.linkAnimation(graph1, graph2);
+ CanvasGraphUtils.linkSelection(graph1, graph2);
+
+ yield graph1.ready();
+ yield graph2.ready();
+
+ testGraphs(graph1, graph2);
+
+ graph1.destroy();
+ graph2.destroy();
+ host.destroy();
+}
+
+function testGraphs(graph1, graph2) {
+ info("Making a selection in the first graph.");
+
+ dragStart(graph1, 300);
+ ok(graph1.hasSelectionInProgress(),
+ "The selection should start (1.1).");
+ ok(!graph2.hasSelectionInProgress(),
+ "The selection should not start yet in the second graph (1.2).");
+ is(graph1.getSelection().start, 300,
+ "The current selection start value is correct (1.1).");
+ is(graph2.getSelection().start, 300,
+ "The current selection start value is correct (1.2).");
+ is(graph1.getSelection().end, 300,
+ "The current selection end value is correct (1.1).");
+ is(graph2.getSelection().end, 300,
+ "The current selection end value is correct (1.2).");
+
+ hover(graph1, 400);
+ ok(graph1.hasSelectionInProgress(),
+ "The selection should still be in progress (2.1).");
+ ok(!graph2.hasSelectionInProgress(),
+ "The selection should not be in progress in the second graph (2.2).");
+ is(graph1.getSelection().start, 300,
+ "The current selection start value is correct (2.1).");
+ is(graph2.getSelection().start, 300,
+ "The current selection start value is correct (2.2).");
+ is(graph1.getSelection().end, 400,
+ "The current selection end value is correct (2.1).");
+ is(graph2.getSelection().end, 400,
+ "The current selection end value is correct (2.2).");
+
+ dragStop(graph1, 500);
+ ok(!graph1.hasSelectionInProgress(),
+ "The selection should have stopped (3.1).");
+ ok(!graph2.hasSelectionInProgress(),
+ "The selection should have stopped (3.2).");
+ is(graph1.getSelection().start, 300,
+ "The current selection start value is correct (3.1).");
+ is(graph2.getSelection().start, 300,
+ "The current selection start value is correct (3.2).");
+ is(graph1.getSelection().end, 500,
+ "The current selection end value is correct (3.1).");
+ is(graph2.getSelection().end, 500,
+ "The current selection end value is correct (3.2).");
+
+ info("Making a new selection in the second graph.");
+
+ dragStart(graph2, 200);
+ ok(!graph1.hasSelectionInProgress(),
+ "The selection should not start yet in the first graph (4.1).");
+ ok(graph2.hasSelectionInProgress(),
+ "The selection should start (4.2).");
+ is(graph1.getSelection().start, 200,
+ "The current selection start value is correct (4.1).");
+ is(graph2.getSelection().start, 200,
+ "The current selection start value is correct (4.2).");
+ is(graph1.getSelection().end, 200,
+ "The current selection end value is correct (4.1).");
+ is(graph2.getSelection().end, 200,
+ "The current selection end value is correct (4.2).");
+
+ hover(graph2, 300);
+ ok(!graph1.hasSelectionInProgress(),
+ "The selection should not be in progress in the first graph (2.2).");
+ ok(graph2.hasSelectionInProgress(),
+ "The selection should still be in progress (5.2).");
+ is(graph1.getSelection().start, 200,
+ "The current selection start value is correct (5.1).");
+ is(graph2.getSelection().start, 200,
+ "The current selection start value is correct (5.2).");
+ is(graph1.getSelection().end, 300,
+ "The current selection end value is correct (5.1).");
+ is(graph2.getSelection().end, 300,
+ "The current selection end value is correct (5.2).");
+
+ dragStop(graph2, 400);
+ ok(!graph1.hasSelectionInProgress(),
+ "The selection should have stopped (6.1).");
+ ok(!graph2.hasSelectionInProgress(),
+ "The selection should have stopped (6.2).");
+ is(graph1.getSelection().start, 200,
+ "The current selection start value is correct (6.1).");
+ is(graph2.getSelection().start, 200,
+ "The current selection start value is correct (6.2).");
+ is(graph1.getSelection().end, 400,
+ "The current selection end value is correct (6.1).");
+ is(graph2.getSelection().end, 400,
+ "The current selection end value is correct (6.2).");
+}
+
+// EventUtils just doesn't work!
+
+function hover(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+}
+
+function dragStart(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseDown({ clientX: x, clientY: y });
+}
+
+function dragStop(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseUp({ clientX: x, clientY: y });
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-13.js b/toolkit/devtools/shared/test/browser_graphs-13.js
new file mode 100644
index 000000000..f0f09203c
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-13.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets may have a fixed width or height.
+
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("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 LineGraphWidget(doc.body, "fps");
+ graph.fixedWidth = 200;
+ graph.fixedHeight = 100;
+
+ yield graph.ready();
+ testGraph(host, graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function testGraph(host, graph) {
+ let bounds = host.frame.getBoundingClientRect();
+
+ isnot(graph.width, bounds.width * window.devicePixelRatio,
+ "The graph should not span all the parent node's width.");
+ isnot(graph.height, bounds.height * window.devicePixelRatio,
+ "The graph should not span all the parent node's height.");
+
+ is(graph.width, graph.fixedWidth * window.devicePixelRatio,
+ "The graph has the correct width.");
+ is(graph.height, graph.fixedHeight * window.devicePixelRatio,
+ "The graph has the correct height.");
+}
diff --git a/toolkit/devtools/shared/test/browser_graphs-14.js b/toolkit/devtools/shared/test/browser_graphs-14.js
new file mode 100644
index 000000000..ce7b96a5d
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_graphs-14.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets correctly emit mouse input events.
+
+const TEST_DATA = [{ delta: 112, value: 48 }, { delta: 213, value: 59 }, { delta: 313, value: 60 }, { delta: 413, value: 59 }, { delta: 530, value: 59 }, { delta: 646, value: 58 }, { delta: 747, value: 60 }, { delta: 863, value: 48 }, { delta: 980, value: 37 }, { delta: 1097, value: 30 }, { delta: 1213, value: 29 }, { delta: 1330, value: 23 }, { delta: 1430, value: 10 }, { delta: 1534, value: 17 }, { delta: 1645, value: 20 }, { delta: 1746, value: 22 }, { delta: 1846, value: 39 }, { delta: 1963, value: 26 }, { delta: 2080, value: 27 }, { delta: 2197, value: 35 }, { delta: 2312, value: 47 }, { delta: 2412, value: 53 }, { delta: 2514, value: 60 }, { delta: 2630, value: 37 }, { delta: 2730, value: 36 }, { delta: 2830, value: 37 }, { delta: 2946, value: 36 }, { delta: 3046, value: 40 }, { delta: 3163, value: 47 }, { delta: 3280, value: 41 }, { delta: 3380, value: 35 }, { delta: 3480, value: 27 }, { delta: 3580, value: 39 }, { delta: 3680, value: 42 }, { delta: 3780, value: 49 }, { delta: 3880, value: 55 }, { delta: 3980, value: 60 }, { delta: 4080, value: 60 }, { delta: 4180, value: 60 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost();
+ let graph = new LineGraphWidget(doc.body, "fps");
+
+ yield testGraph(graph);
+
+ graph.destroy();
+ host.destroy();
+}
+
+function* testGraph(graph) {
+ let mouseDownEvents = 0;
+ let mouseUpEvents = 0;
+ let scrollEvents = 0;
+ graph.on("mousedown", () => mouseDownEvents++);
+ graph.on("mouseup", () => mouseUpEvents++);
+ graph.on("scroll", () => scrollEvents++);
+
+ yield graph.setDataWhenReady(TEST_DATA);
+
+ info("Making a selection.");
+
+ dragStart(graph, 300);
+ dragStop(graph, 500);
+ is(graph.getSelection().start, 300,
+ "The current selection start value is correct (1).");
+ is(graph.getSelection().end, 500,
+ "The current selection end value is correct (1).");
+
+ is(mouseDownEvents, 1,
+ "One mousedown event should have been fired.");
+ is(mouseUpEvents, 1,
+ "One mouseup event should have been fired.");
+ is(scrollEvents, 0,
+ "No scroll event should have been fired.");
+
+ info("Zooming in by scrolling inside the selection.");
+
+ scroll(graph, -1000, 400);
+ is(graph.getSelection().start, 375,
+ "The current selection start value is correct (2).");
+ is(graph.getSelection().end, 425,
+ "The current selection end value is correct (2).");
+
+ is(mouseDownEvents, 1,
+ "No more mousedown events should have been fired.");
+ is(mouseUpEvents, 1,
+ "No more mouseup events should have been fired.");
+ is(scrollEvents, 1,
+ "One scroll event should have been fired.");
+}
+
+// EventUtils just doesn't work!
+
+function dragStart(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseDown({ clientX: x, clientY: y });
+}
+
+function dragStop(graph, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseUp({ clientX: x, clientY: y });
+}
+
+function scroll(graph, wheel, x, y = 1) {
+ x /= window.devicePixelRatio;
+ y /= window.devicePixelRatio;
+ graph._onMouseMove({ clientX: x, clientY: y });
+ graph._onMouseWheel({ clientX: x, clientY: y, detail: wheel });
+}
diff --git a/toolkit/devtools/shared/test/browser_inplace-editor.js b/toolkit/devtools/shared/test/browser_inplace-editor.js
new file mode 100644
index 000000000..11718884b
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_inplace-editor.js
@@ -0,0 +1,123 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
+let {editableField, getInplaceEditorForSpan: inplaceEditor} = devtools.require("devtools/shared/inplace-editor");
+
+// Test the inplace-editor behavior.
+
+add_task(function*() {
+ yield promiseTab("data:text/html;charset=utf-8,inline editor tests");
+ let [host, win, doc] = yield createHost();
+
+ yield testReturnCommit(doc);
+ yield testBlurCommit(doc);
+ yield testAdvanceCharCommit(doc);
+
+ host.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+function testReturnCommit(doc) {
+ info("Testing that pressing return commits the new value");
+ let def = promise.defer();
+
+ createInplaceEditorAndClick({
+ initial: "explicit initial",
+ start: function(editor) {
+ is(editor.input.value, "explicit initial", "Explicit initial value should be used.");
+ editor.input.value = "Test Value";
+ EventUtils.sendKey("return");
+ },
+ done: onDone("Test Value", true, def)
+ }, doc);
+
+ return def.promise;
+}
+
+function testBlurCommit(doc) {
+ info("Testing that bluring the field commits the new value");
+ let def = promise.defer();
+
+ createInplaceEditorAndClick({
+ start: function(editor) {
+ is(editor.input.value, "Edit Me!", "textContent of the span used.");
+ editor.input.value = "Test Value";
+ editor.input.blur();
+ },
+ done: onDone("Test Value", true, def)
+ }, doc);
+
+ return def.promise;
+}
+
+function testAdvanceCharCommit(doc) {
+ info("Testing that configured advanceChars commit the new value");
+ let def = promise.defer();
+
+ createInplaceEditorAndClick({
+ advanceChars: ":",
+ start: function(editor) {
+ let input = editor.input;
+ for each (let ch in "Test:") {
+ EventUtils.sendChar(ch);
+ }
+ },
+ done: onDone("Test", true, def)
+ }, doc);
+
+ return def.promise;
+}
+
+function testEscapeCancel(doc) {
+ info("Testing that escape cancels the new value");
+ let def = promise.defer();
+
+ createInplaceEditorAndClick({
+ initial: "initial text",
+ start: function(editor) {
+ editor.input.value = "Test Value";
+ EventUtils.sendKey("escape");
+ },
+ done: onDone("initial text", false, def)
+ }, doc);
+
+ return def.promise;
+}
+
+function onDone(value, isCommit, def) {
+ return function(actualValue, actualCommit) {
+ info("Inplace-editor's done callback executed, checking its state");
+ is(actualValue, value, "The value is correct");
+ is(actualCommit, isCommit, "The commit boolean is correct");
+ def.resolve();
+ }
+}
+
+function createInplaceEditorAndClick(options, doc) {
+ clearBody(doc);
+ let span = options.element = createSpan(doc);
+
+ info("Creating an inplace-editor field");
+ editableField(options);
+
+ info("Clicking on the inplace-editor field to turn to edit mode");
+ span.click();
+}
+
+function clearBody(doc) {
+ info("Clearing the page body");
+ doc.body.innerHTML = "";
+}
+
+function createSpan(doc) {
+ info("Creating a new span element");
+ let span = doc.createElement("span");
+ span.setAttribute("tabindex", "0");
+ span.textContent = "Edit Me!";
+ doc.body.appendChild(span);
+ return span;
+}
diff --git a/toolkit/devtools/shared/test/browser_layoutHelpers-getBoxQuads.html b/toolkit/devtools/shared/test/browser_layoutHelpers-getBoxQuads.html
new file mode 100644
index 000000000..070792b9a
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_layoutHelpers-getBoxQuads.html
@@ -0,0 +1,65 @@
+<!doctype html>
+<meta charset=utf-8>
+<title>Layout Helpers</title>
+<style id="styles">
+ body {
+ margin: 0;
+ padding: 0;
+ }
+
+ #hidden-node {
+ display: none;
+ }
+
+ #simple-node-with-margin-padding-border {
+ width: 200px;
+ height: 200px;
+ background: #f06;
+
+ padding: 20px;
+ margin: 50px;
+ border: 10px solid black;
+ }
+
+ #scrolled-node {
+ position: absolute;
+ top: 0;
+ left: 0;
+
+ width: 300px;
+ height: 100px;
+ overflow: scroll;
+ background: linear-gradient(red, pink);
+ }
+
+ #sub-scrolled-node {
+ width: 200px;
+ height: 200px;
+ overflow: scroll;
+ background: linear-gradient(yellow, green);
+ }
+
+ #inner-scrolled-node {
+ width: 100px;
+ height: 400px;
+ background: linear-gradient(black, white);
+ }
+</style>
+<div id="hidden-node"></div>
+<div id="simple-node-with-margin-padding-border"></div>
+<!-- The inline encoded code below corresponds to:
+<iframe style="margin:10px;border:0;width:300px;height:300px;">
+ <iframe style="margin:10px;border:0;width:200px;height:200px;">
+ <div id="inner-node" style="width:100px;height:100px;border:10px solid red;margin:10px;padding:10px;"></div>
+ </iframe>
+</iframe>
+ -->
+<iframe src="data:text/html,%3Cstyle%3Ebody%7Bmargin:0;padding:0;%7D%3C/style%3E%3Ciframe%20src=%22data:text/html,%253Cstyle%253Ebody%257Bmargin:0;padding:0;%257D%253C/style%253E%253Cdiv%2520id='inner-node'%2520style='width:100px;height:100px;border:10px%2520solid%2520red;margin:10px;padding:10px;'%253E%253C/div%253E%22%20style=%22margin:10px;border:0;width:200px;height:200px;%22%3E%3C/iframe%3E" style="margin:10px;border:0;width:300px;height:300px;"></iframe>
+<div id="scrolled-node">
+ <div id="sub-scrolled-node">
+ <div id="inner-scrolled-node"></div>
+ </div>
+</div>
+<span id="inline">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus porttitor luctus sem id scelerisque. Cras quis velit sed risus euismod lacinia. Donec viverra enim eu ligula efficitur, quis vulputate metus cursus. Duis sed interdum risus. Ut blandit velit vitae faucibus efficitur. Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br/ >
+Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Sed vitae dolor metus. Aliquam sed velit sit amet libero vestibulum aliquam vel a lorem. Integer eget ex eget justo auctor ullamcorper.<br/ >
+Praesent tristique maximus lacus, nec ultricies neque ultrices non. Phasellus vel lobortis justo. </span> \ No newline at end of file
diff --git a/toolkit/devtools/shared/test/browser_layoutHelpers-getBoxQuads.js b/toolkit/devtools/shared/test/browser_layoutHelpers-getBoxQuads.js
new file mode 100644
index 000000000..7fe1c84fc
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_layoutHelpers-getBoxQuads.js
@@ -0,0 +1,222 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that LayoutHelpers.getAdjustedQuads works properly in a variety of use
+// cases including iframes, scroll and zoom
+
+const {utils: Cu} = Components;
+const {LayoutHelpers} = Cu.import("resource://gre/modules/devtools/LayoutHelpers.jsm", {});
+
+const TEST_URI = TEST_URI_ROOT + "browser_layoutHelpers-getBoxQuads.html";
+
+function test() {
+ addTab(TEST_URI, function(browser, tab) {
+ let doc = browser.contentDocument;
+ let win = doc.defaultView;
+
+ info("Creating a new LayoutHelpers instance for the test window");
+ let helper = new LayoutHelpers(win);
+ ok(helper.getAdjustedQuads, "getAdjustedQuads is defined");
+
+ info("Running tests");
+
+ returnsTheRightDataStructure(doc, helper);
+ isEmptyForMissingNode(doc, helper);
+ isEmptyForHiddenNodes(doc, helper);
+ defaultsToBorderBoxIfNoneProvided(doc, helper);
+ returnsLikeGetBoxQuadsInSimpleCase(doc, helper);
+ takesIframesOffsetsIntoAccount(doc, helper);
+ takesScrollingIntoAccount(doc, helper);
+ takesZoomIntoAccount(doc, helper);
+ returnsMultipleItemsForWrappingInlineElements(doc, helper);
+
+ gBrowser.removeCurrentTab();
+ finish();
+ });
+}
+
+function returnsTheRightDataStructure(doc, helper) {
+ info("Checks that the returned data contains bounds and 4 points");
+
+ let node = doc.querySelector("body");
+ let [res] = helper.getAdjustedQuads(node, "content");
+
+ ok("bounds" in res, "The returned data has a bounds property");
+ ok("p1" in res, "The returned data has a p1 property");
+ ok("p2" in res, "The returned data has a p2 property");
+ ok("p3" in res, "The returned data has a p3 property");
+ ok("p4" in res, "The returned data has a p4 property");
+
+ for (let boundProp of
+ ["bottom", "top", "right", "left", "width", "height", "x", "y"]) {
+ ok(boundProp in res.bounds, "The bounds has a " + boundProp + " property");
+ }
+
+ for (let point of ["p1", "p2", "p3", "p4"]) {
+ for (let pointProp of ["x", "y", "z", "w"]) {
+ ok(pointProp in res[point], point + " has a " + pointProp + " property");
+ }
+ }
+}
+
+function isEmptyForMissingNode(doc, helper) {
+ info("Checks that null is returned for invalid nodes");
+
+ for (let input of [null, undefined, "", 0]) {
+ is(helper.getAdjustedQuads(input).length, 0, "A 0-length array is returned" +
+ "for input " + input);
+ }
+}
+
+function isEmptyForHiddenNodes(doc, helper) {
+ info("Checks that null is returned for nodes that aren't rendered");
+
+ let style = doc.querySelector("#styles");
+ is(helper.getAdjustedQuads(style).length, 0,
+ "null is returned for a <style> node");
+
+ let hidden = doc.querySelector("#hidden-node");
+ is(helper.getAdjustedQuads(hidden).length, 0,
+ "null is returned for a hidden node");
+}
+
+function defaultsToBorderBoxIfNoneProvided(doc, helper) {
+ info("Checks that if no boxtype is passed, then border is the default one");
+
+ let node = doc.querySelector("#simple-node-with-margin-padding-border");
+ let [withBoxType] = helper.getAdjustedQuads(node, "border");
+ let [withoutBoxType] = helper.getAdjustedQuads(node);
+
+ for (let boundProp of
+ ["bottom", "top", "right", "left", "width", "height", "x", "y"]) {
+ is(withBoxType.bounds[boundProp], withoutBoxType.bounds[boundProp],
+ boundProp + " bound is equal with or without the border box type");
+ }
+
+ for (let point of ["p1", "p2", "p3", "p4"]) {
+ for (let pointProp of ["x", "y", "z", "w"]) {
+ is(withBoxType[point][pointProp], withoutBoxType[point][pointProp],
+ point + "." + pointProp +
+ " is equal with or without the border box type");
+ }
+ }
+}
+
+function returnsLikeGetBoxQuadsInSimpleCase(doc, helper) {
+ info("Checks that for an element in the main frame, without scroll nor zoom" +
+ "that the returned value is similar to the returned value of getBoxQuads");
+
+ let node = doc.querySelector("#simple-node-with-margin-padding-border");
+
+ for (let region of ["content", "padding", "border", "margin"]) {
+ let expected = node.getBoxQuads({
+ box: region
+ })[0];
+ let [actual] = helper.getAdjustedQuads(node, region);
+
+ for (let boundProp of
+ ["bottom", "top", "right", "left", "width", "height", "x", "y"]) {
+ is(actual.bounds[boundProp], expected.bounds[boundProp],
+ boundProp + " bound is equal to the one returned by getBoxQuads for " +
+ region + " box");
+ }
+
+ for (let point of ["p1", "p2", "p3", "p4"]) {
+ for (let pointProp of ["x", "y", "z", "w"]) {
+ is(actual[point][pointProp], expected[point][pointProp],
+ point + "." + pointProp +
+ " is equal to the one returned by getBoxQuads for " + region + " box");
+ }
+ }
+ }
+}
+
+function takesIframesOffsetsIntoAccount(doc, helper) {
+ info("Checks that the quad returned for a node inside iframes that have " +
+ "margins takes those offsets into account");
+
+ let rootIframe = doc.querySelector("iframe");
+ let subIframe = rootIframe.contentDocument.querySelector("iframe");
+ let innerNode = subIframe.contentDocument.querySelector("#inner-node");
+
+ let [quad] = helper.getAdjustedQuads(innerNode, "content");
+
+ //rootIframe margin + subIframe margin + node margin + node border + node padding
+ let p1x = 10 + 10 + 10 + 10 + 10;
+ is(quad.p1.x, p1x, "The inner node's p1 x position is correct");
+
+ // Same as p1x + the inner node width
+ let p2x = p1x + 100;
+ is(quad.p2.x, p2x, "The inner node's p2 x position is correct");
+}
+
+function takesScrollingIntoAccount(doc, helper) {
+ info("Checks that the quad returned for a node inside multiple scrolled " +
+ "containers takes the scroll values into account");
+
+ // For info, the container being tested here is absolutely positioned at 0 0
+ // to simplify asserting the coordinates
+
+ info("Scroll the container nodes down");
+ let scrolledNode = doc.querySelector("#scrolled-node");
+ scrolledNode.scrollTop = 100;
+ let subScrolledNode = doc.querySelector("#sub-scrolled-node");
+ subScrolledNode.scrollTop = 200;
+ let innerNode = doc.querySelector("#inner-scrolled-node");
+
+ let [quad] = helper.getAdjustedQuads(innerNode, "content");
+ is(quad.p1.x, 0, "p1.x of the scrolled node is correct after scrolling down");
+ is(quad.p1.y, -300, "p1.y of the scrolled node is correct after scrolling down");
+
+ info("Scrolling back up");
+ scrolledNode.scrollTop = 0;
+ subScrolledNode.scrollTop = 0;
+
+ [quad] = helper.getAdjustedQuads(innerNode, "content");
+ is(quad.p1.x, 0, "p1.x of the scrolled node is correct after scrolling up");
+ is(quad.p1.y, 0, "p1.y of the scrolled node is correct after scrolling up");
+}
+
+function takesZoomIntoAccount(doc, helper) {
+ info("Checks that if the page is zoomed in/out, the quad returned is correct");
+
+ // Hard-coding coordinates in this zoom test is a bad idea as it can vary
+ // depending on the platform, so we simply test that zooming in produces a
+ // bigger quad and zooming out produces a smaller quad
+
+ let node = doc.querySelector("#simple-node-with-margin-padding-border");
+ let [defaultQuad] = helper.getAdjustedQuads(node);
+
+ info("Zoom in");
+ window.FullZoom.enlarge();
+ let [zoomedInQuad] = helper.getAdjustedQuads(node);
+
+ ok(zoomedInQuad.bounds.width > defaultQuad.bounds.width,
+ "The zoomed in quad is bigger than the default one");
+ ok(zoomedInQuad.bounds.height > defaultQuad.bounds.height,
+ "The zoomed in quad is bigger than the default one");
+
+ info("Zoom out");
+ window.FullZoom.reset();
+ window.FullZoom.reduce();
+ let [zoomedOutQuad] = helper.getAdjustedQuads(node);
+
+ ok(zoomedOutQuad.bounds.width < defaultQuad.bounds.width,
+ "The zoomed out quad is smaller than the default one");
+ ok(zoomedOutQuad.bounds.height < defaultQuad.bounds.height,
+ "The zoomed out quad is smaller than the default one");
+
+ window.FullZoom.reset();
+}
+
+function returnsMultipleItemsForWrappingInlineElements(doc, helper) {
+ info("Checks that several quads are returned for inline elements that span line-breaks");
+
+ let node = doc.querySelector("#inline");
+ let quads = helper.getAdjustedQuads(node, "content");
+ // At least 3 because of the 2 <br />, maybe more depending on the window size.
+ ok(quads.length >= 3, "Multiple quads were returned");
+
+ is(quads.length, node.getBoxQuads().length,
+ "The same number of boxes as getBoxQuads was returned");
+}
diff --git a/toolkit/devtools/shared/test/browser_layoutHelpers.html b/toolkit/devtools/shared/test/browser_layoutHelpers.html
new file mode 100644
index 000000000..3b9a285b4
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_layoutHelpers.html
@@ -0,0 +1,25 @@
+<!doctype html>
+<meta charset=utf-8>
+<title> Layout Helpers </title>
+
+<style>
+ html {
+ height: 300%;
+ width: 300%;
+ }
+ div#some {
+ position: absolute;
+ background: black;
+ width: 2px;
+ height: 2px;
+ }
+ iframe {
+ position: absolute;
+ width: 40px;
+ height: 40px;
+ border: 0;
+ }
+</style>
+
+<div id=some></div>
+<iframe id=frame src='./browser_layoutHelpers_iframe.html'></iframe>
diff --git a/toolkit/devtools/shared/test/browser_layoutHelpers.js b/toolkit/devtools/shared/test/browser_layoutHelpers.js
new file mode 100644
index 000000000..3ee8665f1
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_layoutHelpers.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that scrollIntoViewIfNeeded works properly.
+
+let imported = {};
+Components.utils.import("resource://gre/modules/devtools/LayoutHelpers.jsm",
+ imported);
+registerCleanupFunction(function () {
+ imported = {};
+});
+
+let LayoutHelpers = imported.LayoutHelpers;
+
+const TEST_URI = TEST_URI_ROOT + "browser_layoutHelpers.html";
+
+function test() {
+ addTab(TEST_URI, function(browser, tab) {
+ info("Starting browser_layoutHelpers.js");
+ let doc = browser.contentDocument;
+ runTest(doc.defaultView, doc.getElementById('some'));
+ gBrowser.removeCurrentTab();
+ finish();
+ });
+}
+
+function runTest(win, some) {
+ let lh = new LayoutHelpers(win);
+
+ some.style.top = win.innerHeight + 'px';
+ some.style.left = win.innerWidth + 'px';
+ // The tests start with a black 2x2 pixels square below bottom right.
+ // Do not resize the window during the tests.
+
+ win.scroll(win.innerWidth / 2, win.innerHeight + 2); // Above the viewport.
+ lh.scrollIntoViewIfNeeded(some);
+ is(win.scrollY, Math.floor(win.innerHeight / 2) + 1,
+ 'Element completely hidden above should appear centered.');
+
+ win.scroll(win.innerWidth / 2, win.innerHeight + 1); // On the top edge.
+ lh.scrollIntoViewIfNeeded(some);
+ is(win.scrollY, win.innerHeight,
+ 'Element partially visible above should appear above.');
+
+ win.scroll(win.innerWidth / 2, 0); // Just below the viewport.
+ lh.scrollIntoViewIfNeeded(some);
+ is(win.scrollY, Math.floor(win.innerHeight / 2) + 1,
+ 'Element completely hidden below should appear centered.');
+
+ win.scroll(win.innerWidth / 2, 1); // On the bottom edge.
+ lh.scrollIntoViewIfNeeded(some);
+ is(win.scrollY, 2,
+ 'Element partially visible below should appear below.');
+
+
+ win.scroll(win.innerWidth / 2, win.innerHeight + 2); // Above the viewport.
+ lh.scrollIntoViewIfNeeded(some, false);
+ is(win.scrollY, win.innerHeight,
+ 'Element completely hidden above should appear above ' +
+ 'if parameter is false.');
+
+ win.scroll(win.innerWidth / 2, win.innerHeight + 1); // On the top edge.
+ lh.scrollIntoViewIfNeeded(some, false);
+ is(win.scrollY, win.innerHeight,
+ 'Element partially visible above should appear above ' +
+ 'if parameter is false.');
+
+ win.scroll(win.innerWidth / 2, 0); // Below the viewport.
+ lh.scrollIntoViewIfNeeded(some, false);
+ is(win.scrollY, 2,
+ 'Element completely hidden below should appear below ' +
+ 'if parameter is false.');
+
+ win.scroll(win.innerWidth / 2, 1); // On the bottom edge.
+ lh.scrollIntoViewIfNeeded(some, false);
+ is(win.scrollY, 2,
+ 'Element partially visible below should appear below ' +
+ 'if parameter is false.');
+
+ // The case of iframes.
+ win.scroll(0, 0);
+
+ let frame = win.document.getElementById('frame');
+ let fwin = frame.contentWindow;
+
+ frame.style.top = win.innerHeight + 'px';
+ frame.style.left = win.innerWidth + 'px';
+
+ fwin.addEventListener('load', function frameLoad() {
+ let some = fwin.document.getElementById('some');
+ lh.scrollIntoViewIfNeeded(some);
+ is(win.scrollX, Math.floor(win.innerWidth / 2) + 20,
+ 'Scrolling from an iframe should center the iframe vertically.');
+ is(win.scrollY, Math.floor(win.innerHeight / 2) + 20,
+ 'Scrolling from an iframe should center the iframe horizontally.');
+ is(fwin.scrollX, Math.floor(fwin.innerWidth / 2) + 1,
+ 'Scrolling from an iframe should center the element vertically.');
+ is(fwin.scrollY, Math.floor(fwin.innerHeight / 2) + 1,
+ 'Scrolling from an iframe should center the element horizontally.');
+ }, false);
+}
diff --git a/toolkit/devtools/shared/test/browser_layoutHelpers_iframe.html b/toolkit/devtools/shared/test/browser_layoutHelpers_iframe.html
new file mode 100644
index 000000000..66ef5b293
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_layoutHelpers_iframe.html
@@ -0,0 +1,19 @@
+<!doctype html>
+<meta charset=utf-8>
+<title> Layout Helpers </title>
+
+<style>
+ html {
+ height: 300%;
+ width: 300%;
+ }
+ div#some {
+ position: absolute;
+ background: black;
+ width: 2px;
+ height: 2px;
+ }
+</style>
+
+<div id=some></div>
+
diff --git a/toolkit/devtools/shared/test/browser_num-l10n.js b/toolkit/devtools/shared/test/browser_num-l10n.js
new file mode 100644
index 000000000..a7a70abaa
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_num-l10n.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that ViewHelpers.Prefs work properly.
+
+let {ViewHelpers} = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {});
+
+function test() {
+ let l10n = new ViewHelpers.L10N();
+
+ is(l10n.numberWithDecimals(1234.56789, 2), "1,234.56",
+ "The first number was properly localized.");
+ is(l10n.numberWithDecimals(0.0001, 2), "0",
+ "The second number was properly localized.");
+ is(l10n.numberWithDecimals(1.0001, 2), "1",
+ "The third number was properly localized.");
+ is(l10n.numberWithDecimals(NaN, 2), "0",
+ "NaN was properly localized.");
+ is(l10n.numberWithDecimals(null, 2), "0",
+ "`null` was properly localized.");
+ is(l10n.numberWithDecimals(undefined, 2), "0",
+ "`undefined` was properly localized.");
+
+ finish();
+}
diff --git a/toolkit/devtools/shared/test/browser_observableobject.js b/toolkit/devtools/shared/test/browser_observableobject.js
new file mode 100644
index 000000000..8bd1d5169
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_observableobject.js
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function test() {
+ let tmp = {};
+ Cu.import("resource://gre/modules/devtools/Loader.jsm", tmp);
+ let ObservableObject = tmp.devtools.require("devtools/shared/observable-object");
+
+ let rawObject = {};
+ let oe = new ObservableObject(rawObject);
+
+ function str(o) {
+ return JSON.stringify(o);
+ }
+
+ function areObjectsSynced() {
+ is(str(rawObject), str(oe.object), "Objects are synced");
+ }
+
+ areObjectsSynced();
+
+ let index = 0;
+ let expected = [
+ {type: "set", path: "foo", value: 4},
+ {type: "get", path: "foo", value: 4},
+ {type: "get", path: "foo", value: 4},
+ {type: "get", path: "bar", value: undefined},
+ {type: "get", path: "bar", value: undefined},
+ {type: "set", path: "bar", value: {}},
+ {type: "get", path: "bar", value: {}},
+ {type: "get", path: "bar", value: {}},
+ {type: "set", path: "bar.a", value: [1,2,3,4]},
+ {type: "get", path: "bar", value: {a:[1,2,3,4]}},
+ {type: "set", path: "bar.mop", value: 1},
+ {type: "set", path: "bar", value: {}},
+ {type: "set", path: "foo", value: [{a:42}]},
+ {type: "get", path: "foo", value: [{a:42}]},
+ {type: "get", path: "foo.0", value: {a:42}},
+ {type: "get", path: "foo.0.a", value: 42},
+ {type: "get", path: "foo", value: [{a:42}]},
+ {type: "get", path: "foo.0", value: {a:42}},
+ {type: "set", path: "foo.0.a", value: 2},
+ {type: "get", path: "foo", value: [{a:2}]},
+ {type: "get", path: "bar", value: {}},
+ {type: "set", path: "foo.1", value: {}},
+ ];
+
+ function callback(event, path, value) {
+ oe.off("get", callback);
+ ok(event, "event defined");
+ ok(path, "path defined");
+ if (index >= expected.length) {
+ return;
+ }
+ let e = expected[index];
+ is(event, e.type, "[" + index + "] Right event received");
+ is(path.join("."), e.path, "[" + index + "] Path valid");
+ is(str(value), str(e.value), "[" + index + "] Value valid");
+ index++;
+ areObjectsSynced();
+ oe.on("get", callback);
+ }
+
+ oe.on("set", callback);
+ oe.on("get", callback);
+
+ oe.object.foo = 4;
+ oe.object.foo;
+ Object.getOwnPropertyDescriptor(oe.object, "foo")
+ oe.object["bar"];
+ oe.object.bar;
+ oe.object.bar = {};
+ oe.object.bar;
+ oe.object.bar.a = [1,2,3,4];
+ Object.defineProperty(oe.object.bar, "mop", {value:1});
+ oe.object.bar = {};
+ oe.object.foo = [{a:42}];
+ oe.object.foo[0].a;
+ oe.object.foo[0].a = 2;
+ oe.object.foo[1] = oe.object.bar;
+
+ is(index, expected.length, "Event count is right");
+ is(oe.object.bar, oe.object.bar, "Object attributes are wrapped only once");
+
+ finish();
+}
diff --git a/toolkit/devtools/shared/test/browser_options-view-01.js b/toolkit/devtools/shared/test/browser_options-view-01.js
new file mode 100644
index 000000000..398b5175a
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_options-view-01.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that options-view OptionsView responds to events correctly.
+
+const {OptionsView} = devtools.require("devtools/shared/options-view");
+const {Services} = devtools.require("resource://gre/modules/Services.jsm");
+
+const BRANCH = "devtools.debugger.";
+const BLACK_BOX_PREF = "auto-black-box";
+const PRETTY_PRINT_PREF = "auto-pretty-print";
+
+let originalBlackBox = Services.prefs.getBoolPref(BRANCH + BLACK_BOX_PREF);
+let originalPrettyPrint = Services.prefs.getBoolPref(BRANCH + PRETTY_PRINT_PREF);
+
+add_task(function*() {
+ info("Setting a couple of preferences");
+ Services.prefs.setBoolPref(BRANCH + BLACK_BOX_PREF, false);
+ Services.prefs.setBoolPref(BRANCH + PRETTY_PRINT_PREF, true);
+
+ info("Opening a test tab and a toolbox host to create the options view in");
+ yield promiseTab("about:blank");
+ let [host, win, doc] = yield createHost("bottom", OPTIONS_VIEW_URL);
+
+ yield testOptionsView(win);
+
+ info("Closing the host and current tab");
+ host.destroy();
+ gBrowser.removeCurrentTab();
+
+ info("Resetting the preferences");
+ Services.prefs.setBoolPref(BRANCH + BLACK_BOX_PREF, originalBlackBox);
+ Services.prefs.setBoolPref(BRANCH + PRETTY_PRINT_PREF, originalPrettyPrint);
+});
+
+function* testOptionsView(win) {
+ let events = [];
+ let options = createOptionsView(win);
+ yield options.initialize();
+
+ let $ = win.document.querySelector.bind(win.document);
+
+ options.on("pref-changed", (_, pref) => events.push(pref));
+
+ let ppEl = $("menuitem[data-pref='auto-pretty-print']");
+ let bbEl = $("menuitem[data-pref='auto-black-box']");
+
+ // Test default config
+ is(ppEl.getAttribute("checked"), "true", "`true` prefs are checked on start");
+ is(bbEl.getAttribute("checked"), "", "`false` prefs are unchecked on start");
+
+ // Test buttons update when preferences update outside of the menu
+ Services.prefs.setBoolPref(BRANCH + PRETTY_PRINT_PREF, false);
+ Services.prefs.setBoolPref(BRANCH + BLACK_BOX_PREF, true);
+
+ is(options.getPref(PRETTY_PRINT_PREF), false, "getPref returns correct value");
+ is(options.getPref(BLACK_BOX_PREF), true, "getPref returns correct value");
+
+ is(ppEl.getAttribute("checked"), "", "menuitems update when preferences change");
+ is(bbEl.getAttribute("checked"), "true", "menuitems update when preferences change");
+
+ // Tests events are fired when preferences update outside of the menu
+ is(events.length, 2, "two 'pref-changed' events fired");
+ is(events[0], "auto-pretty-print", "correct pref passed in 'pref-changed' event (auto-pretty-print)");
+ is(events[1], "auto-black-box", "correct pref passed in 'pref-changed' event (auto-black-box)");
+
+ // Test buttons update when clicked and preferences are updated
+ yield click(options, win, ppEl);
+ is(ppEl.getAttribute("checked"), "true", "menuitems update when clicked");
+ is(Services.prefs.getBoolPref(BRANCH + PRETTY_PRINT_PREF), true, "preference updated via click");
+
+ yield click(options, win, bbEl);
+ is(bbEl.getAttribute("checked"), "", "menuitems update when clicked");
+ is(Services.prefs.getBoolPref(BRANCH + BLACK_BOX_PREF), false, "preference updated via click");
+
+ // Tests events are fired when preferences updated via click
+ is(events.length, 4, "two 'pref-changed' events fired");
+ is(events[2], "auto-pretty-print", "correct pref passed in 'pref-changed' event (auto-pretty-print)");
+ is(events[3], "auto-black-box", "correct pref passed in 'pref-changed' event (auto-black-box)");
+
+ yield options.destroy();
+}
+
+function createOptionsView(win) {
+ return new OptionsView({
+ branchName: BRANCH,
+ menupopup: win.document.querySelector("#options-menupopup")
+ });
+}
+
+function* click(view, win, menuitem) {
+ let opened = view.once("options-shown");
+ let closed = view.once("options-hidden");
+
+ let button = win.document.querySelector("#options-button");
+ EventUtils.synthesizeMouseAtCenter(button, {}, win);
+ yield opened;
+
+ EventUtils.synthesizeMouseAtCenter(menuitem, {}, win);
+ yield closed;
+}
diff --git a/toolkit/devtools/shared/test/browser_outputparser.js b/toolkit/devtools/shared/test/browser_outputparser.js
new file mode 100644
index 000000000..583f74fad
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_outputparser.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+let {Services} = Cu.import("resource://gre/modules/Services.jsm", {});
+let {Loader} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
+let {OutputParser} = devtools.require("devtools/output-parser");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost("bottom", "data:text/html," +
+ "<h1>browser_outputParser.js</h1><div></div>");
+
+ let parser = new OutputParser();
+ testParseCssProperty(doc, parser);
+ testParseCssVar(doc, parser);
+ testParseHTMLAttribute(doc, parser);
+ testParseNonCssHTMLAttribute(doc, parser);
+
+ host.destroy();
+}
+
+function testParseCssProperty(doc, parser) {
+ let frag = parser.parseCssProperty("border", "1px solid red", {
+ colorSwatchClass: "test-colorswatch"
+ });
+
+ let target = doc.querySelector("div");
+ ok(target, "captain, we have the div");
+ target.appendChild(frag);
+
+ is(target.innerHTML,
+ '1px solid <span data-color="#F00"><span style="background-color:red" class="test-colorswatch"></span><span>#F00</span></span>',
+ "CSS property correctly parsed");
+
+ target.innerHTML = "";
+
+ frag = parser.parseCssProperty("background-image", "linear-gradient(to right, #F60 10%, rgba(0,0,0,1))", {
+ colorSwatchClass: "test-colorswatch",
+ colorClass: "test-color"
+ });
+ target.appendChild(frag);
+ is(target.innerHTML,
+ 'linear-gradient(to right, <span data-color="#F60"><span style="background-color:#F60" class="test-colorswatch"></span><span class="test-color">#F60</span></span> 10%, ' +
+ '<span data-color="#000"><span style="background-color:rgba(0,0,0,1)" class="test-colorswatch"></span><span class="test-color">#000</span></span>)',
+ "Gradient CSS property correctly parsed");
+
+ target.innerHTML = "";
+}
+
+function testParseCssVar(doc, parser) {
+ let frag = parser.parseCssProperty("color", "var(--some-kind-of-green)", {
+ colorSwatchClass: "test-colorswatch"
+ });
+
+ let target = doc.querySelector("div");
+ ok(target, "captain, we have the div");
+ target.appendChild(frag);
+
+ is(target.innerHTML, "var(--some-kind-of-green)", "CSS property correctly parsed");
+
+ target.innerHTML = "";
+}
+
+function testParseHTMLAttribute(doc, parser) {
+ let attrib = "color:red; font-size: 12px; background-image: " +
+ "url(chrome://branding/content/about-logo.png)";
+ let frag = parser.parseHTMLAttribute(attrib, {
+ urlClass: "theme-link",
+ colorClass: "theme-color"
+ });
+
+ let target = doc.querySelector("div");
+ ok(target, "captain, we have the div");
+ target.appendChild(frag);
+
+ let expected = 'color:<span data-color="#F00"><span class="theme-color">#F00</span></span>; font-size: 12px; ' +
+ 'background-image: url("<a href="chrome://branding/content/about-logo.png" ' +
+ 'class="theme-link" ' +
+ 'target="_blank">chrome://branding/content/about-logo.png</a>")';
+
+ is(target.innerHTML, expected, "HTML Attribute correctly parsed");
+ target.innerHTML = "";
+}
+
+function testParseNonCssHTMLAttribute(doc, parser) {
+ let attrib = "someclass background someotherclass red";
+ let frag = parser.parseHTMLAttribute(attrib);
+
+ let target = doc.querySelector("div");
+ ok(target, "captain, we have the div");
+ target.appendChild(frag);
+
+ let expected = 'someclass background someotherclass red';
+
+ is(target.innerHTML, expected, "Non-CSS HTML Attribute correctly parsed");
+ target.innerHTML = "";
+}
diff --git a/toolkit/devtools/shared/test/browser_prefs.js b/toolkit/devtools/shared/test/browser_prefs.js
new file mode 100644
index 000000000..3d6b99c0a
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_prefs.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that ViewHelpers.Prefs work properly.
+
+let {ViewHelpers} = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {});
+
+function test() {
+ let Prefs = new ViewHelpers.Prefs("devtools.debugger", {
+ "foo": ["Bool", "enabled"]
+ });
+
+ let originalPrefValue = Services.prefs.getBoolPref("devtools.debugger.enabled");
+ is(Prefs.foo, originalPrefValue, "The pref value was correctly fetched.");
+
+ Prefs.foo = !originalPrefValue;
+ is(Prefs.foo, !originalPrefValue,
+ "The pref was was correctly changed (1).");
+ is(Services.prefs.getBoolPref("devtools.debugger.enabled"), !originalPrefValue,
+ "The pref was was correctly changed (2).");
+
+ Services.prefs.setBoolPref("devtools.debugger.enabled", originalPrefValue);
+ info("The pref value was reset.");
+
+ is(Prefs.foo, !originalPrefValue,
+ "The cached pref value hasn't changed yet.");
+
+ Prefs.refresh();
+ is(Prefs.foo, originalPrefValue,
+ "The cached pref value has changed now.");
+
+ finish();
+}
diff --git a/toolkit/devtools/shared/test/browser_require_basic.js b/toolkit/devtools/shared/test/browser_require_basic.js
new file mode 100644
index 000000000..f86974df4
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_require_basic.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that source URLs in the Web Console can be clicked to display the
+// standard View Source window.
+
+let [ define, require ] = (function() {
+ let tempScope = {};
+ Components.utils.import("resource://gre/modules/devtools/Require.jsm", tempScope);
+ return [ tempScope.define, tempScope.require ];
+})();
+
+function test() {
+ addTab("about:blank", function() {
+ info("Starting Require Tests");
+ setup();
+
+ testWorking();
+ testDomains();
+ testLeakage();
+ testMultiImport();
+ testRecursive();
+ testUncompilable();
+ testFirebug();
+
+ shutdown();
+ });
+}
+
+function setup() {
+ define('gclitest/requirable', [ 'require', 'exports', 'module' ], function(require, exports, module) {
+ exports.thing1 = 'thing1';
+ exports.thing2 = 2;
+
+ let status = 'initial';
+ exports.setStatus = function(aStatus) { status = aStatus; };
+ exports.getStatus = function() { return status; };
+ });
+
+ define('gclitest/unrequirable', [ 'require', 'exports', 'module' ], function(require, exports, module) {
+ null.throwNPE();
+ });
+
+ define('gclitest/recurse', [ 'require', 'exports', 'module', 'gclitest/recurse' ], function(require, exports, module) {
+ require('gclitest/recurse');
+ });
+
+ define('gclitest/firebug', [ 'gclitest/requirable' ], function(requirable) {
+ return { requirable: requirable, fb: true };
+ });
+}
+
+function shutdown() {
+ delete define.modules['gclitest/requirable'];
+ delete define.globalDomain.modules['gclitest/requirable'];
+ delete define.modules['gclitest/unrequirable'];
+ delete define.globalDomain.modules['gclitest/unrequirable'];
+ delete define.modules['gclitest/recurse'];
+ delete define.globalDomain.modules['gclitest/recurse'];
+ delete define.modules['gclitest/firebug'];
+ delete define.globalDomain.modules['gclitest/firebug'];
+
+ define = undefined;
+ require = undefined;
+
+ finish();
+}
+
+function testWorking() {
+ // There are lots of requirement tests that we could be doing here
+ // The fact that we can get anything at all working is a testament to
+ // require doing what it should - we don't need to test the
+ let requireable = require('gclitest/requirable');
+ is('thing1', requireable.thing1, 'thing1 was required');
+ is(2, requireable.thing2, 'thing2 was required');
+ is(requireable.thing3, undefined, 'thing3 was not required');
+}
+
+function testDomains() {
+ let requireable = require('gclitest/requirable');
+ is(requireable.status, undefined, 'requirable has no status');
+ requireable.setStatus(null);
+ is(null, requireable.getStatus(), 'requirable.getStatus changed to null');
+ is(requireable.status, undefined, 'requirable still has no status');
+ requireable.setStatus('42');
+ is('42', requireable.getStatus(), 'requirable.getStatus changed to 42');
+ is(requireable.status, undefined, 'requirable *still* has no status');
+
+ let domain = new define.Domain();
+ let requireable2 = domain.require('gclitest/requirable');
+ is(requireable2.status, undefined, 'requirable2 has no status');
+ is('initial', requireable2.getStatus(), 'requirable2.getStatus is initial');
+ requireable2.setStatus(999);
+ is(999, requireable2.getStatus(), 'requirable2.getStatus changed to 999');
+ is(requireable2.status, undefined, 'requirable2 still has no status');
+
+ is('42', requireable.getStatus(), 'status 42');
+ ok(requireable.status === undefined, 'requirable has no status (as expected)');
+
+ delete domain.modules['gclitest/requirable'];
+}
+
+function testLeakage() {
+ let requireable = require('gclitest/requirable');
+ is(requireable.setup, null, 'leakage of setup');
+ is(requireable.shutdown, null, 'leakage of shutdown');
+ is(requireable.testWorking, null, 'leakage of testWorking');
+}
+
+function testMultiImport() {
+ let r1 = require('gclitest/requirable');
+ let r2 = require('gclitest/requirable');
+ is(r1, r2, 'double require was strict equal');
+}
+
+function testUncompilable() {
+ // It's not totally clear how a module loader should perform with unusable
+ // modules, however at least it should go into a flat spin ...
+ // GCLI mini_require reports an error as it should
+ try {
+ let unrequireable = require('gclitest/unrequirable');
+ fail();
+ }
+ catch (ex) {
+ // an exception is expected
+ }
+}
+
+function testRecursive() {
+ // See Bug 658583
+ // require('gclitest/recurse');
+ // Also see the comments in the testRecursive() function
+}
+
+function testFirebug() {
+ let requirable = require('gclitest/requirable');
+ let firebug = require('gclitest/firebug');
+ ok(firebug.fb, 'firebug.fb is true');
+ is(requirable, firebug.requirable, 'requirable pass-through');
+}
diff --git a/toolkit/devtools/shared/test/browser_spectrum.js b/toolkit/devtools/shared/test/browser_spectrum.js
new file mode 100644
index 000000000..0812a8caf
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_spectrum.js
@@ -0,0 +1,114 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the spectrum color picker works correctly
+
+const TEST_URI = "chrome://browser/content/devtools/spectrum-frame.xhtml";
+const {Spectrum} = devtools.require("devtools/shared/widgets/Spectrum");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ yield performTest();
+ gBrowser.removeCurrentTab();
+});
+
+function* performTest() {
+ let [host, win, doc] = yield createHost("bottom", TEST_URI);
+
+ yield testCreateAndDestroyShouldAppendAndRemoveElements(doc);
+ yield testPassingAColorAtInitShouldSetThatColor(doc);
+ yield testSettingAndGettingANewColor(doc);
+ yield testChangingColorShouldEmitEvents(doc);
+ yield testSettingColorShoudUpdateTheUI(doc);
+
+ host.destroy();
+}
+
+function testCreateAndDestroyShouldAppendAndRemoveElements(doc) {
+ let containerElement = doc.querySelector("#spectrum");
+ ok(containerElement, "We have the root node to append spectrum to");
+ is(containerElement.childElementCount, 0, "Root node is empty");
+
+ let s = new Spectrum(containerElement, [255, 126, 255, 1]);
+ s.show();
+ ok(containerElement.childElementCount > 0, "Spectrum has appended elements");
+
+ s.destroy();
+ is(containerElement.childElementCount, 0, "Destroying spectrum removed all nodes");
+}
+
+function testPassingAColorAtInitShouldSetThatColor(doc) {
+ let initRgba = [255, 126, 255, 1];
+
+ let s = new Spectrum(doc.querySelector("#spectrum"), initRgba);
+ s.show();
+
+ let setRgba = s.rgb;
+
+ is(initRgba[0], setRgba[0], "Spectrum initialized with the right color");
+ is(initRgba[1], setRgba[1], "Spectrum initialized with the right color");
+ is(initRgba[2], setRgba[2], "Spectrum initialized with the right color");
+ is(initRgba[3], setRgba[3], "Spectrum initialized with the right color");
+
+ s.destroy();
+}
+
+function testSettingAndGettingANewColor(doc) {
+ let s = new Spectrum(doc.querySelector("#spectrum"), [0, 0, 0, 1]);
+ s.show();
+
+ let colorToSet = [255, 255, 255, 1];
+ s.rgb = colorToSet;
+ let newColor = s.rgb;
+
+ is(colorToSet[0], newColor[0], "Spectrum set with the right color");
+ is(colorToSet[1], newColor[1], "Spectrum set with the right color");
+ is(colorToSet[2], newColor[2], "Spectrum set with the right color");
+ is(colorToSet[3], newColor[3], "Spectrum set with the right color");
+
+ s.destroy();
+}
+
+function testChangingColorShouldEmitEvents(doc) {
+ return new Promise(resolve => {
+ let s = new Spectrum(doc.querySelector("#spectrum"), [255, 255, 255, 1]);
+ s.show();
+
+ s.once("changed", (event, rgba, color) => {
+ ok(true, "Changed event was emitted on color change");
+ is(rgba[0], 128, "New color is correct");
+ is(rgba[1], 64, "New color is correct");
+ is(rgba[2], 64, "New color is correct");
+ is(rgba[3], 1, "New color is correct");
+ is("rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ", " + rgba[3] + ")", color, "RGBA and css color correspond");
+
+ s.destroy();
+ resolve();
+ });
+
+ // Simulate a drag move event by calling the handler directly.
+ s.onDraggerMove(s.dragger.offsetWidth/2, s.dragger.offsetHeight/2);
+ });
+}
+
+function testSettingColorShoudUpdateTheUI(doc) {
+ let s = new Spectrum(doc.querySelector("#spectrum"), [255, 255, 255, 1]);
+ s.show();
+ let dragHelperOriginalPos = [s.dragHelper.style.top, s.dragHelper.style.left];
+ let alphaHelperOriginalPos = s.alphaSliderHelper.style.left;
+
+ s.rgb = [50, 240, 234, .2];
+ s.updateUI();
+
+ ok(s.alphaSliderHelper.style.left != alphaHelperOriginalPos, "Alpha helper has moved");
+ ok(s.dragHelper.style.top !== dragHelperOriginalPos[0], "Drag helper has moved");
+ ok(s.dragHelper.style.left !== dragHelperOriginalPos[1], "Drag helper has moved");
+
+ s.rgb = [240, 32, 124, 0];
+ s.updateUI();
+ is(s.alphaSliderHelper.style.left, - (s.alphaSliderHelper.offsetWidth/2) + "px",
+ "Alpha range UI has been updated again");
+
+ s.destroy();
+}
diff --git a/toolkit/devtools/shared/test/browser_tableWidget_basic.js b/toolkit/devtools/shared/test/browser_tableWidget_basic.js
new file mode 100644
index 000000000..ba0ac4c83
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_tableWidget_basic.js
@@ -0,0 +1,382 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the table widget api works fine
+
+const TEST_URI = "data:text/xml;charset=UTF-8,<?xml version='1.0'?>" +
+ "<?xml-stylesheet href='chrome://global/skin/global.css'?>" +
+ "<?xml-stylesheet href='chrome://browser/skin/devtools/common.css'?>" +
+ "<?xml-stylesheet href='chrome://browser/skin/devtools/widgets.css'?>" +
+ "<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'" +
+ " title='Table Widget' width='600' height='500'><box flex='1'/></window>";
+const TEST_OPT = "chrome,titlebar,toolbar,centerscreen,resizable,dialog=no";
+
+const {TableWidget} = devtools.require("devtools/shared/widgets/TableWidget");
+
+let doc, table;
+
+function test() {
+ waitForExplicitFinish();
+ let win = Services.ww.openWindow(null, TEST_URI, "_blank", TEST_OPT, null);
+
+ win.addEventListener("load", function onLoad() {
+ win.removeEventListener("load", onLoad, false);
+
+ waitForFocus(function () {
+ doc = win.document;
+ table = new TableWidget(doc.querySelector("box"), {
+ initialColumns: {
+ col1: "Column 1",
+ col2: "Column 2",
+ col3: "Column 3",
+ col4: "Column 4"
+ },
+ uniqueId: "col1",
+ emptyText: "This is dummy empty text",
+ highlightUpdated: true,
+ removableColumns: true,
+ firstColumn: "col4"
+ });
+ startTests();
+ });
+ });
+}
+
+function endTests() {
+ table.destroy();
+ doc.defaultView.close();
+ doc = table = null;
+ finish();
+}
+
+function startTests() {
+ populateTable();
+ testTreeItemInsertedCorrectly();
+ testAPI();
+ endTests();
+}
+
+function populateTable() {
+ table.push({
+ col1: "id1",
+ col2: "value10",
+ col3: "value20",
+ col4: "value30"
+ });
+ table.push({
+ col1: "id2",
+ col2: "value14",
+ col3: "value29",
+ col4: "value32"
+ });
+ table.push({
+ col1: "id3",
+ col2: "value17",
+ col3: "value21",
+ col4: "value31",
+ extraData: "foobar",
+ extraData2: 42
+ });
+ table.push({
+ col1: "id4",
+ col2: "value12",
+ col3: "value26",
+ col4: "value33"
+ });
+ table.push({
+ col1: "id5",
+ col2: "value19",
+ col3: "value26",
+ col4: "value37"
+ });
+ table.push({
+ col1: "id6",
+ col2: "value15",
+ col3: "value25",
+ col4: "value37"
+ });
+ table.push({
+ col1: "id7",
+ col2: "value18",
+ col3: "value21",
+ col4: "value36",
+ somethingExtra: "Hello World!"
+ });
+ table.push({
+ col1: "id8",
+ col2: "value11",
+ col3: "value27",
+ col4: "value34"
+ });
+
+ let span = doc.createElement("span");
+ span.textContent = "domnode";
+
+ table.push({
+ col1: "id9",
+ col2: "value11",
+ col3: "value23",
+ col4: span
+ });
+}
+
+/**
+ * Test if the nodes are inserted correctly in the table.
+ */
+function testTreeItemInsertedCorrectly() {
+ is(table.tbody.children.length, 4*2 /* double because splitters */,
+ "4 columns exist");
+
+ // Test firstColumn option and check if the nodes are inserted correctly
+ is(table.tbody.children[0].firstChild.children.length, 9 + 1 /* header */,
+ "Correct rows in column 4");
+ is(table.tbody.children[0].firstChild.firstChild.value, "Column 4",
+ "Correct column header value");
+
+ for (let i = 1; i < 4; i++) {
+ is(table.tbody.children[i * 2].firstChild.children.length, 9 + 1 /* header */,
+ "Correct rows in column " + i);
+ is(table.tbody.children[i * 2].firstChild.firstChild.value, "Column " + i,
+ "Correct column header value");
+ }
+ for (let i = 1; i < 10; i++) {
+ is(table.tbody.children[2].firstChild.children[i].value, "id" + i,
+ "Correct value in row " + i);
+ }
+
+ // Remove firstColumn option and reset the table
+ table.clear();
+ table.firstColumn = "";
+ table.setColumns({
+ col1: "Column 1",
+ col2: "Column 2",
+ col3: "Column 3",
+ col4: "Column 4"
+ });
+ populateTable();
+
+ // Check if the nodes are inserted correctly without firstColumn option
+ for (let i = 0; i < 4; i++) {
+ is(table.tbody.children[i * 2].firstChild.children.length, 9 + 1 /* header */,
+ "Correct rows in column " + i);
+ is(table.tbody.children[i * 2].firstChild.firstChild.value, "Column " + (i + 1),
+ "Correct column header value");
+ }
+ for (let i = 1; i < 10; i++) {
+ is(table.tbody.firstChild.firstChild.children[i].value, "id" + i,
+ "Correct value in row " + i);
+ }
+}
+
+/**
+ * Tests if the API exposed by TableWidget works properly
+ */
+function testAPI() {
+ info("Testing TableWidget API");
+ // Check if selectRow and selectedRow setter works as expected
+ // Nothing should be selected beforehand
+ ok(!doc.querySelector(".theme-selected"), "Nothing is selected");
+ table.selectRow("id4");
+ let node = doc.querySelector(".theme-selected");
+ ok(!!node, "Somthing got selected");
+ is(node.getAttribute("data-id"), "id4", "Correct node selected");
+
+ table.selectRow("id7");
+ let node2 = doc.querySelector(".theme-selected");
+ ok(!!node2, "Somthing is still selected");
+ isnot(node, node2, "Newly selected node is different from previous");
+ is(node2.getAttribute("data-id"), "id7", "Correct node selected");
+
+ // test if selectedIRow getter works
+ is(table.selectedRow["col1"], "id7", "Correct result of selectedRow getter");
+
+ // test if isSelected works
+ ok(table.isSelected("id7"), "isSelected with column id works");
+ ok(table.isSelected({
+ col1: "id7",
+ col2: "value18",
+ col3: "value21",
+ col4: "value36",
+ somethingExtra: "Hello World!"
+ }), "isSelected with json works");
+
+ table.selectedRow = "id4";
+ let node3 = doc.querySelector(".theme-selected");
+ ok(!!node3, "Somthing is still selected");
+ isnot(node2, node3, "Newly selected node is different from previous");
+ is(node3, node, "First and third selected nodes should be same");
+ is(node3.getAttribute("data-id"), "id4", "Correct node selected");
+
+ // test if selectedRow getter works
+ is(table.selectedRow["col1"], "id4", "Correct result of selectedRow getter");
+
+ // test if clear selection works
+ table.clearSelection();
+ ok(!doc.querySelector(".theme-selected"),
+ "Nothing selected after clear selection call");
+
+ // test if selectNextRow and selectPreviousRow work
+ table.selectedRow = "id7";
+ ok(table.isSelected("id7"), "Correct row selected");
+ table.selectNextRow();
+ ok(table.isSelected("id8"), "Correct row selected after selectNextRow call");
+
+ table.selectNextRow();
+ ok(table.isSelected("id9"), "Correct row selected after selectNextRow call");
+
+ table.selectNextRow();
+ ok(table.isSelected("id1"),
+ "Properly cycled to first row after selectNextRow call on last row");
+
+ table.selectNextRow();
+ ok(table.isSelected("id2"), "Correct row selected after selectNextRow call");
+
+ table.selectPreviousRow();
+ ok(table.isSelected("id1"), "Correct row selected after selectPreviousRow call");
+
+ table.selectPreviousRow();
+ ok(table.isSelected("id9"),
+ "Properly cycled to last row after selectPreviousRow call on first row");
+
+ // test if remove works
+ ok(doc.querySelector("[data-id='id4']"), "id4 row exists before removal");
+ table.remove("id4");
+ ok(!doc.querySelector("[data-id='id4']"),
+ "id4 row does not exist after removal through id");
+
+ ok(doc.querySelector("[data-id='id6']"), "id6 row exists before removal");
+ table.remove({
+ col1: "id6",
+ col2: "value15",
+ col3: "value25",
+ col4: "value37"
+ });
+ ok(!doc.querySelector("[data-id='id6']"),
+ "id6 row does not exist after removal through json");
+
+ table.push({
+ col1: "id4",
+ col2: "value12",
+ col3: "value26",
+ col4: "value33"
+ });
+ table.push({
+ col1: "id6",
+ col2: "value15",
+ col3: "value25",
+ col4: "value37"
+ });
+
+ // test if selectedIndex getter setter works
+ table.selectedIndex = 2;
+ ok(table.isSelected("id3"), "Correct row selected by selectedIndex setter");
+
+ table.selectedIndex = 4;
+ ok(table.isSelected("id5"), "Correct row selected by selectedIndex setter");
+
+ table.selectRow("id8");
+ is(table.selectedIndex, 7, "Correct value of selectedIndex getter");
+
+ // testing if clear works
+ table.clear();
+ is(table.tbody.children.length, 4*2 /* double because splitters */,
+ "4 columns exist even after clear");
+ for (let i = 0; i < 4; i++) {
+ is(table.tbody.children[i*2].firstChild.children.length, 1 /* header */,
+ "Only header in the column " + i + " after clear call");
+ is(table.tbody.children[i*2].firstChild.firstChild.value, "Column " + (i + 1),
+ "Correct column header value");
+ }
+
+ // testing if setColumns work
+ table.setColumns({
+ col1: "Foobar",
+ col2: "Testing"
+ });
+
+ is(table.tbody.children.length, 2*2 /* double because splitters */,
+ "2 columns exist after setColumn call");
+ is(table.tbody.children[0].firstChild.firstChild.value, "Foobar",
+ "Correct column header value for first column");
+ is(table.tbody.children[2].firstChild.firstChild.value, "Testing",
+ "Correct column header value for second column");
+
+ table.setColumns({
+ col1: "Column 1",
+ col2: "Column 2",
+ col3: "Column 3",
+ col4: "Column 4"
+ });
+ is(table.tbody.children.length, 4*2 /* double because splitters */,
+ "4 columns exist after second setColumn call");
+
+ populateTable();
+
+ // testing if update works
+ is(doc.querySelectorAll("[data-id='id4']")[1].value, "value12",
+ "Correct value before update");
+ table.update({
+ col1: "id4",
+ col2: "UPDATED",
+ col3: "value26",
+ col4: "value33"
+ });
+ is(doc.querySelectorAll("[data-id='id4']")[1].value, "UPDATED",
+ "Correct value after update");
+
+ // testing if sorting works
+ // calling it once on an already sorted column should sort in descending manner
+ table.sortBy("col1");
+ for (let i = 1; i < 10; i++) {
+ is(table.tbody.firstChild.firstChild.children[i].value, "id" + (10 - i),
+ "Correct value in row " + i + " after descending sort by on col1");
+ }
+ // Calling it on an unsorted column should sort by it in ascending manner
+ table.sortBy("col2");
+ let cell = table.tbody.children[2].firstChild.children[2];
+ checkAscendingOrder(cell);
+
+ // Calling it again should sort by it in descending manner
+ table.sortBy("col2");
+ cell = table.tbody.children[2].firstChild.lastChild.previousSibling;
+ checkDescendingOrder(cell);
+
+ // Calling it again should sort by it in ascending manner
+ table.sortBy("col2");
+ cell = table.tbody.children[2].firstChild.children[2];
+ checkAscendingOrder(cell);
+
+ table.clear();
+ populateTable();
+
+ // testing if sorting works should sort by ascending manner
+ table.sortBy("col4");
+ cell = table.tbody.children[6].firstChild.children[1];
+ is(cell.textContent, "domnode", "DOMNode sorted correctly");
+ checkAscendingOrder(cell.nextSibling);
+
+ // Calling it again should sort it in descending order
+ table.sortBy("col4");
+ cell = table.tbody.children[6].firstChild.children[9];
+ is(cell.textContent, "domnode", "DOMNode sorted correctly");
+ checkDescendingOrder(cell.previousSibling);
+}
+
+function checkAscendingOrder(cell) {
+ while(cell) {
+ let currentCell = cell.value || cell.textContent;
+ let prevCell = cell.previousSibling.value || cell.previousSibling.textContent;
+ ok(currentCell >= prevCell, "Sorting is in ascending order");
+ cell = cell.nextSibling;
+ }
+}
+
+function checkDescendingOrder(cell) {
+ while(cell != cell.parentNode.firstChild) {
+ let currentCell = cell.value || cell.textContent;
+ let nextCell = cell.nextSibling.value || cell.nextSibling.textContent;
+ ok(currentCell >= nextCell, "Sorting is in descending order");
+ cell = cell.previousSibling;
+ }
+}
diff --git a/toolkit/devtools/shared/test/browser_tableWidget_keyboard_interaction.js b/toolkit/devtools/shared/test/browser_tableWidget_keyboard_interaction.js
new file mode 100644
index 000000000..0ec5355e0
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_tableWidget_keyboard_interaction.js
@@ -0,0 +1,227 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that keyboard interaction works fine with the table widget
+
+const TEST_URI = "data:text/xml;charset=UTF-8,<?xml version='1.0'?>" +
+ "<?xml-stylesheet href='chrome://global/skin/global.css'?>" +
+ "<?xml-stylesheet href='chrome://browser/skin/devtools/common.css'?>" +
+ "<?xml-stylesheet href='chrome://browser/skin/devtools/widgets.css'?>" +
+ "<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'" +
+ " title='Table Widget' width='600' height='500'><box flex='1'/></window>";
+const TEST_OPT = "chrome,titlebar,toolbar,centerscreen,resizable,dialog=no";
+
+const {TableWidget} = devtools.require("devtools/shared/widgets/TableWidget");
+let {Task} = devtools.require("resource://gre/modules/Task.jsm");
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+let doc, table;
+
+function test() {
+ waitForExplicitFinish();
+ let win = Services.ww.openWindow(null, TEST_URI, "_blank", TEST_OPT, null);
+
+ win.addEventListener("load", function onLoad() {
+ win.removeEventListener("load", onLoad, false);
+
+ waitForFocus(function () {
+ doc = win.document;
+ table = new TableWidget(doc.querySelector("box"), {
+ initialColumns: {
+ col1: "Column 1",
+ col2: "Column 2",
+ col3: "Column 3",
+ col4: "Column 4"
+ },
+ uniqueId: "col1",
+ emptyText: "This is dummy empty text",
+ highlightUpdated: true,
+ removableColumns: true,
+ });
+ startTests();
+ });
+ });
+}
+
+function endTests() {
+ table.destroy();
+ doc.defaultView.close();
+ doc = table = null;
+ finish();
+}
+
+let startTests = Task.async(function*() {
+ populateTable();
+ yield testKeyboardInteraction();
+ endTests();
+});
+
+function populateTable() {
+ table.push({
+ col1: "id1",
+ col2: "value10",
+ col3: "value20",
+ col4: "value30"
+ });
+ table.push({
+ col1: "id2",
+ col2: "value14",
+ col3: "value29",
+ col4: "value32"
+ });
+ table.push({
+ col1: "id3",
+ col2: "value17",
+ col3: "value21",
+ col4: "value31",
+ extraData: "foobar",
+ extraData2: 42
+ });
+ table.push({
+ col1: "id4",
+ col2: "value12",
+ col3: "value26",
+ col4: "value33"
+ });
+ table.push({
+ col1: "id5",
+ col2: "value19",
+ col3: "value26",
+ col4: "value37"
+ });
+ table.push({
+ col1: "id6",
+ col2: "value15",
+ col3: "value25",
+ col4: "value37"
+ });
+ table.push({
+ col1: "id7",
+ col2: "value18",
+ col3: "value21",
+ col4: "value36",
+ somethingExtra: "Hello World!"
+ });
+ table.push({
+ col1: "id8",
+ col2: "value11",
+ col3: "value27",
+ col4: "value34"
+ });
+ table.push({
+ col1: "id9",
+ col2: "value11",
+ col3: "value23",
+ col4: "value38"
+ });
+}
+
+// Sends a click event on the passed DOM node in an async manner
+function click(node, button = 0) {
+ if (button == 0) {
+ executeSoon(() => EventUtils.synthesizeMouseAtCenter(node, {}, doc.defaultView));
+ } else {
+ executeSoon(() => EventUtils.synthesizeMouseAtCenter(node, {
+ button: button,
+ type: "contextmenu"
+ }, doc.defaultView));
+ }
+}
+
+/**
+ * Tests if pressing navigation keys on the table items does the expected behavior
+ */
+let testKeyboardInteraction = Task.async(function*() {
+ info("Testing keyboard interaction with the table");
+ info("clicking on first row");
+ let node = table.tbody.firstChild.firstChild.children[1];
+ let event = table.once(TableWidget.EVENTS.ROW_SELECTED);
+ click(node);
+ let [name, id] = yield event;
+
+ node = table.tbody.firstChild.firstChild.children[2];
+ // node should not have selected class
+ ok(!node.classList.contains("theme-selected"),
+ "Row should not have selected class");
+ info("Pressing down key to select next row");
+ event = table.once(TableWidget.EVENTS.ROW_SELECTED);
+ EventUtils.sendKey("DOWN", doc.defaultView);
+ id = yield event;
+ is(id, "id2", "Correct row was selected after pressing down");
+ ok(node.classList.contains("theme-selected"), "row has selected class");
+ let nodes = doc.querySelectorAll(".theme-selected");
+ for (let i = 0; i < nodes.length; i++) {
+ is(nodes[i].getAttribute("data-id"), "id2",
+ "Correct cell selected in all columns");
+ }
+
+ node = table.tbody.firstChild.firstChild.children[3];
+ // node should not have selected class
+ ok(!node.classList.contains("theme-selected"),
+ "Row should not have selected class");
+ info("Pressing down key to select next row");
+ event = table.once(TableWidget.EVENTS.ROW_SELECTED);
+ EventUtils.sendKey("DOWN", doc.defaultView);
+ id = yield event;
+ is(id, "id3", "Correct row was selected after pressing down");
+ ok(node.classList.contains("theme-selected"), "row has selected class");
+ nodes = doc.querySelectorAll(".theme-selected");
+ for (let i = 0; i < nodes.length; i++) {
+ is(nodes[i].getAttribute("data-id"), "id3",
+ "Correct cell selected in all columns");
+ }
+
+ // pressing up arrow key to select previous row
+ node = table.tbody.firstChild.firstChild.children[2];
+ // node should not have selected class
+ ok(!node.classList.contains("theme-selected"),
+ "Row should not have selected class");
+ info("Pressing up key to select previous row");
+ event = table.once(TableWidget.EVENTS.ROW_SELECTED);
+ EventUtils.sendKey("UP", doc.defaultView);
+ id = yield event;
+ is(id, "id2", "Correct row was selected after pressing down");
+ ok(node.classList.contains("theme-selected"), "row has selected class");
+ nodes = doc.querySelectorAll(".theme-selected");
+ for (let i = 0; i < nodes.length; i++) {
+ is(nodes[i].getAttribute("data-id"), "id2",
+ "Correct cell selected in all columns");
+ }
+
+ // selecting last item node to test edge navigation cycling case
+ table.selectedRow = "id9";
+ // pressing down now should move to first row.
+ node = table.tbody.firstChild.firstChild.children[1];
+ // node should not have selected class
+ ok(!node.classList.contains("theme-selected"),
+ "Row should not have selected class");
+ info("Pressing down key on last row to select first row");
+ event = table.once(TableWidget.EVENTS.ROW_SELECTED);
+ EventUtils.sendKey("DOWN", doc.defaultView);
+ id = yield event;
+ is(id, "id1", "Correct row was selected after pressing down");
+ ok(node.classList.contains("theme-selected"), "row has selected class");
+ nodes = doc.querySelectorAll(".theme-selected");
+ for (let i = 0; i < nodes.length; i++) {
+ is(nodes[i].getAttribute("data-id"), "id1",
+ "Correct cell selected in all columns");
+ }
+
+ // pressing up now should move to last row.
+ node = table.tbody.firstChild.firstChild.lastChild;
+ // node should not have selected class
+ ok(!node.classList.contains("theme-selected"),
+ "Row should not have selected class");
+ info("Pressing down key on last row to select first row");
+ event = table.once(TableWidget.EVENTS.ROW_SELECTED);
+ EventUtils.sendKey("UP", doc.defaultView);
+ id = yield event;
+ is(id, "id9", "Correct row was selected after pressing down");
+ ok(node.classList.contains("theme-selected"), "row has selected class");
+ nodes = doc.querySelectorAll(".theme-selected");
+ for (let i = 0; i < nodes.length; i++) {
+ is(nodes[i].getAttribute("data-id"), "id9",
+ "Correct cell selected in all columns");
+ }
+});
diff --git a/toolkit/devtools/shared/test/browser_tableWidget_mouse_interaction.js b/toolkit/devtools/shared/test/browser_tableWidget_mouse_interaction.js
new file mode 100644
index 000000000..efdc8ee07
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_tableWidget_mouse_interaction.js
@@ -0,0 +1,298 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that mosue interaction works fine with the table widget
+
+const TEST_URI = "data:text/xml;charset=UTF-8,<?xml version='1.0'?>" +
+ "<?xml-stylesheet href='chrome://global/skin/global.css'?>" +
+ "<?xml-stylesheet href='chrome://browser/skin/devtools/common.css'?>" +
+ "<?xml-stylesheet href='chrome://browser/skin/devtools/widgets.css'?>" +
+ "<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'" +
+ " title='Table Widget' width='600' height='500'><box flex='1'/></window>";
+const TEST_OPT = "chrome,titlebar,toolbar,centerscreen,resizable,dialog=no";
+
+const {TableWidget} = devtools.require("devtools/shared/widgets/TableWidget");
+let {Task} = devtools.require("resource://gre/modules/Task.jsm");
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+let doc, table;
+
+function test() {
+ waitForExplicitFinish();
+ let win = Services.ww.openWindow(null, TEST_URI, "_blank", TEST_OPT, null);
+
+ win.addEventListener("load", function onLoad() {
+ win.removeEventListener("load", onLoad, false);
+
+ waitForFocus(function () {
+ doc = win.document;
+ table = new TableWidget(doc.querySelector("box"), {
+ initialColumns: {
+ col1: "Column 1",
+ col2: "Column 2",
+ col3: "Column 3",
+ col4: "Column 4"
+ },
+ uniqueId: "col1",
+ emptyText: "This is dummy empty text",
+ highlightUpdated: true,
+ removableColumns: true,
+ });
+ startTests();
+ });
+ });
+}
+
+function endTests() {
+ table.destroy();
+ doc.defaultView.close();
+ doc = table = null;
+ finish();
+}
+
+let startTests = Task.async(function*() {
+ populateTable();
+ yield testMouseInteraction();
+ endTests();
+});
+
+function populateTable() {
+ table.push({
+ col1: "id1",
+ col2: "value10",
+ col3: "value20",
+ col4: "value30"
+ });
+ table.push({
+ col1: "id2",
+ col2: "value14",
+ col3: "value29",
+ col4: "value32"
+ });
+ table.push({
+ col1: "id3",
+ col2: "value17",
+ col3: "value21",
+ col4: "value31",
+ extraData: "foobar",
+ extraData2: 42
+ });
+ table.push({
+ col1: "id4",
+ col2: "value12",
+ col3: "value26",
+ col4: "value33"
+ });
+ table.push({
+ col1: "id5",
+ col2: "value19",
+ col3: "value26",
+ col4: "value37"
+ });
+ table.push({
+ col1: "id6",
+ col2: "value15",
+ col3: "value25",
+ col4: "value37"
+ });
+ table.push({
+ col1: "id7",
+ col2: "value18",
+ col3: "value21",
+ col4: "value36",
+ somethingExtra: "Hello World!"
+ });
+ table.push({
+ col1: "id8",
+ col2: "value11",
+ col3: "value27",
+ col4: "value34"
+ });
+ table.push({
+ col1: "id9",
+ col2: "value11",
+ col3: "value23",
+ col4: "value38"
+ });
+}
+
+// Sends a click event on the passed DOM node in an async manner
+function click(node, button = 0) {
+ if (button == 0) {
+ executeSoon(() => EventUtils.synthesizeMouseAtCenter(node, {}, doc.defaultView));
+ } else {
+ executeSoon(() => EventUtils.synthesizeMouseAtCenter(node, {
+ button: button,
+ type: "contextmenu"
+ }, doc.defaultView));
+ }
+}
+
+/**
+ * Tests if clicking the table items does the expected behavior
+ */
+let testMouseInteraction = Task.async(function*() {
+ info("Testing mouse interaction with the table");
+ ok(!table.selectedRow, "Nothing should be selected beforehand");
+
+ let event = table.once(TableWidget.EVENTS.ROW_SELECTED);
+ let node = table.tbody.firstChild.firstChild.children[1];
+ info("clicking on the first row");
+ ok(!node.classList.contains("theme-selected"),
+ "Node should not have selected class before clicking");
+ click(node);
+ let id = yield event;
+ ok(node.classList.contains("theme-selected"), "Node has selected class after click");
+ is(id, "id1", "Correct row was selected");
+
+ info("clicking on third row to select it");
+ event = table.once(TableWidget.EVENTS.ROW_SELECTED);
+ let node2 = table.tbody.firstChild.firstChild.children[3];
+ // node should not have selected class
+ ok(!node2.classList.contains("theme-selected"),
+ "New node should not have selected class before clicking");
+ click(node2);
+ id = yield event;
+ ok(node2.classList.contains("theme-selected"),
+ "New node has selected class after clicking");
+ is(id, "id3", "Correct table path is emitted for new node")
+ isnot(node, node2, "Old and new node are different");
+ ok(!node.classList.contains("theme-selected"),
+ "Old node should not have selected class after the click on new node");
+
+ // clicking on table header to sort by it
+ event = table.once(TableWidget.EVENTS.COLUMN_SORTED);
+ node = table.tbody.children[6].firstChild.children[0];
+ info("clicking on the 4th coulmn header to sort the table by it");
+ ok(!node.hasAttribute("sorted"),
+ "Node should not have sorted attribute before clicking");
+ ok(doc.querySelector("[sorted]"), "Although, something else should be sorted on");
+ isnot(doc.querySelector("[sorted]"), node, "Which is not equal to this node");
+ click(node);
+ id = yield event;
+ is(id, "col4", "Correct column was sorted on");
+ ok(node.hasAttribute("sorted"),
+ "Node should now have sorted attribute after clicking");
+ is(doc.querySelectorAll("[sorted]").length, 1,
+ "Now only one column should be sorted on");
+ is(doc.querySelector("[sorted]"), node, "Which should be this column");
+
+ // test context menu opening.
+ // hiding second column
+ // event listener for popupshown
+ event = Promise.defer();
+ table.menupopup.addEventListener("popupshown", function onPopupShown(e) {
+ table.menupopup.removeEventListener("popupshown", onPopupShown);
+ event.resolve();
+ })
+ info("right clicking on the first column header");
+ node = table.tbody.firstChild.firstChild.firstChild;
+ click(node, 2);
+ yield event.promise;
+ is(table.menupopup.querySelectorAll("[disabled]").length, 1,
+ "Only 1 menuitem is disabled");
+ is(table.menupopup.querySelector("[disabled]"),
+ table.menupopup.querySelector("[data-id='col1']"),
+ "Which is the unique column");
+ // popup should be open now
+ // clicking on second column label
+ event = table.once(TableWidget.EVENTS.HEADER_CONTEXT_MENU);
+ node = table.menupopup.querySelector("[data-id='col2']");
+ info("selecting to hide the second column");
+ ok(!table.tbody.children[2].hasAttribute("hidden"),
+ "Column is not hidden before hiding it");
+ click(node);
+ id = yield event;
+ is(id, "col2", "Correct column was triggered to be hidden");
+ is(table.tbody.children[2].getAttribute("hidden"), "true",
+ "Column is hidden after hiding it");
+
+ // hiding third column
+ // event listener for popupshown
+ event = Promise.defer();
+ table.menupopup.addEventListener("popupshown", function onPopupShown(e) {
+ table.menupopup.removeEventListener("popupshown", onPopupShown);
+ event.resolve();
+ })
+ info("right clicking on the first column header");
+ node = table.tbody.firstChild.firstChild.firstChild;
+ click(node, 2);
+ yield event.promise;
+ is(table.menupopup.querySelectorAll("[disabled]").length, 1,
+ "Only 1 menuitem is disabled");
+ // popup should be open now
+ // clicking on second column label
+ event = table.once(TableWidget.EVENTS.HEADER_CONTEXT_MENU);
+ node = table.menupopup.querySelector("[data-id='col3']");
+ info("selecting to hide the second column");
+ ok(!table.tbody.children[4].hasAttribute("hidden"),
+ "Column is not hidden before hiding it");
+ click(node);
+ id = yield event;
+ is(id, "col3", "Correct column was triggered to be hidden");
+ is(table.tbody.children[4].getAttribute("hidden"), "true",
+ "Column is hidden after hiding it");
+
+ // opening again to see if 2 items are disabled now
+ // event listener for popupshown
+ event = Promise.defer();
+ table.menupopup.addEventListener("popupshown", function onPopupShown(e) {
+ table.menupopup.removeEventListener("popupshown", onPopupShown);
+ event.resolve();
+ })
+ info("right clicking on the first column header");
+ node = table.tbody.firstChild.firstChild.firstChild;
+ click(node, 2);
+ yield event.promise;
+ is(table.menupopup.querySelectorAll("[disabled]").length, 2,
+ "2 menuitems are disabled now as only 2 columns remain visible");
+ is(table.menupopup.querySelectorAll("[disabled]")[0],
+ table.menupopup.querySelector("[data-id='col1']"),
+ "First is the unique column");
+ is(table.menupopup.querySelectorAll("[disabled]")[1],
+ table.menupopup.querySelector("[data-id='col4']"),
+ "Second is the last column");
+
+ // showing back 2nd column
+ // popup should be open now
+ // clicking on second column label
+ event = table.once(TableWidget.EVENTS.HEADER_CONTEXT_MENU);
+ node = table.menupopup.querySelector("[data-id='col2']");
+ info("selecting to hide the second column");
+ is(table.tbody.children[2].getAttribute("hidden"), "true",
+ "Column is hidden before unhiding it");
+ click(node);
+ id = yield event;
+ is(id, "col2", "Correct column was triggered to be hidden");
+ ok(!table.tbody.children[2].hasAttribute("hidden"),
+ "Column is not hidden after unhiding it");
+
+ // showing back 3rd column
+ // event listener for popupshown
+ event = Promise.defer();
+ table.menupopup.addEventListener("popupshown", function onPopupShown(e) {
+ table.menupopup.removeEventListener("popupshown", onPopupShown);
+ event.resolve();
+ })
+ info("right clicking on the first column header");
+ node = table.tbody.firstChild.firstChild.firstChild;
+ click(node, 2);
+ yield event.promise;
+ // popup should be open now
+ // clicking on second column label
+ event = table.once(TableWidget.EVENTS.HEADER_CONTEXT_MENU);
+ node = table.menupopup.querySelector("[data-id='col3']");
+ info("selecting to hide the second column");
+ is(table.tbody.children[4].getAttribute("hidden"), "true",
+ "Column is hidden before unhiding it");
+ click(node);
+ id = yield event;
+ is(id, "col3", "Correct column was triggered to be hidden");
+ ok(!table.tbody.children[4].hasAttribute("hidden"),
+ "Column is not hidden after unhiding it");
+
+ // reset table state
+ table.clearSelection();
+ table.sortBy("col1");
+});
diff --git a/toolkit/devtools/shared/test/browser_telemetry_button_eyedropper.js b/toolkit/devtools/shared/test/browser_telemetry_button_eyedropper.js
new file mode 100644
index 000000000..946e4174e
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_button_eyedropper.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8," +
+ "<p>browser_telemetry_button_eyedropper.js</p><div>test</div>";
+
+let {EyedropperManager} = require("devtools/eyedropper/eyedropper");
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ let target = TargetFactory.forTab(gBrowser.selectedTab);
+ let toolbox = yield gDevTools.showToolbox(target, "inspector");
+ info("inspector opened");
+
+ info("testing the eyedropper button");
+ testButton(toolbox, Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ yield gDevTools.closeToolbox(target);
+ gBrowser.removeCurrentTab();
+});
+
+function testButton(toolbox, Telemetry) {
+ let button = toolbox.doc.querySelector("#command-button-eyedropper");
+ ok(button, "Captain, we have the eyedropper button");
+
+ info("clicking the button to open the eyedropper");
+ button.click();
+
+ checkResults("_EYEDROPPER_", Telemetry);
+}
+
+function checkResults(histIdFocus, Telemetry) {
+ let result = Telemetry.prototype.telemetryInfo;
+
+ for (let [histId, value] of Iterator(result)) {
+ if (histId.startsWith("DEVTOOLS_INSPECTOR_") ||
+ !histId.contains(histIdFocus)) {
+ // Inspector stats are tested in
+ // browser_telemetry_toolboxtabs_{toolname}.js so we skip them here
+ // because we only open the inspector once for this test.
+ continue;
+ }
+
+ if (histId.endsWith("OPENED_PER_USER_FLAG")) {
+ ok(value.length === 1 && value[0] === true,
+ "Per user value " + histId + " has a single value of true");
+ } else if (histId.endsWith("OPENED_BOOLEAN")) {
+ is(value.length, 1, histId + " has one entry");
+
+ let okay = value.every(element => element === true);
+ ok(okay, "All " + histId + " entries are === true");
+ }
+ }
+}
diff --git a/toolkit/devtools/shared/test/browser_telemetry_button_paintflashing.js b/toolkit/devtools/shared/test/browser_telemetry_button_paintflashing.js
new file mode 100644
index 000000000..692df44c4
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_button_paintflashing.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8," +
+ "<p>browser_telemetry_button_paintflashing.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ let target = TargetFactory.forTab(gBrowser.selectedTab);
+ let toolbox = yield gDevTools.showToolbox(target, "inspector");
+ info("inspector opened");
+
+ info("testing the paintflashing button");
+ yield testButton(toolbox, Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ yield gDevTools.closeToolbox(target);
+ gBrowser.removeCurrentTab();
+});
+
+function* testButton(toolbox, Telemetry) {
+ info("Testing command-button-paintflashing");
+
+ let button = toolbox.doc.querySelector("#command-button-paintflashing");
+ ok(button, "Captain, we have the button");
+
+ yield delayedClicks(button, 4);
+ checkResults("_PAINTFLASHING_", Telemetry);
+}
+
+function delayedClicks(node, clicks) {
+ return new Promise(resolve => {
+ let clicked = 0;
+
+ // See TOOL_DELAY for why we need setTimeout here
+ setTimeout(function delayedClick() {
+ info("Clicking button " + node.id);
+ node.click();
+ clicked++;
+
+ if (clicked >= clicks) {
+ resolve(node);
+ } else {
+ setTimeout(delayedClick, TOOL_DELAY);
+ }
+ }, TOOL_DELAY);
+ });
+}
+
+function checkResults(histIdFocus, Telemetry) {
+ let result = Telemetry.prototype.telemetryInfo;
+
+ for (let [histId, value] of Iterator(result)) {
+ if (histId.startsWith("DEVTOOLS_INSPECTOR_") ||
+ !histId.contains(histIdFocus)) {
+ // Inspector stats are tested in
+ // browser_telemetry_toolboxtabs_{toolname}.js so we skip them here
+ // because we only open the inspector once for this test.
+ continue;
+ }
+
+ if (histId.endsWith("OPENED_PER_USER_FLAG")) {
+ ok(value.length === 1 && value[0] === true,
+ "Per user value " + histId + " has a single value of true");
+ } else if (histId.endsWith("OPENED_BOOLEAN")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element === true;
+ });
+
+ ok(okay, "All " + histId + " entries are === true");
+ } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element > 0;
+ });
+
+ ok(okay, "All " + histId + " entries have time > 0");
+ }
+ }
+}
diff --git a/toolkit/devtools/shared/test/browser_telemetry_button_responsive.js b/toolkit/devtools/shared/test/browser_telemetry_button_responsive.js
new file mode 100644
index 000000000..fd9a33460
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_button_responsive.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8," +
+ "<p>browser_telemetry_button_responsive.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ let target = TargetFactory.forTab(gBrowser.selectedTab);
+ let toolbox = yield gDevTools.showToolbox(target, "inspector");
+ info("inspector opened");
+
+ info("testing the responsivedesign button");
+ yield testButton(toolbox, Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ yield gDevTools.closeToolbox(target);
+ gBrowser.removeCurrentTab();
+});
+
+function* testButton(toolbox, Telemetry) {
+ info("Testing command-button-responsive");
+
+ let button = toolbox.doc.querySelector("#command-button-responsive");
+ ok(button, "Captain, we have the button");
+
+ yield delayedClicks(button, 4);
+ checkResults("_RESPONSIVE_", Telemetry);
+}
+
+function delayedClicks(node, clicks) {
+ return new Promise(resolve => {
+ let clicked = 0;
+
+ // See TOOL_DELAY for why we need setTimeout here
+ setTimeout(function delayedClick() {
+ info("Clicking button " + node.id);
+ node.click();
+ clicked++;
+
+ if (clicked >= clicks) {
+ resolve(node);
+ } else {
+ setTimeout(delayedClick, TOOL_DELAY);
+ }
+ }, TOOL_DELAY);
+ });
+}
+
+function checkResults(histIdFocus, Telemetry) {
+ let result = Telemetry.prototype.telemetryInfo;
+
+ for (let [histId, value] of Iterator(result)) {
+ if (histId.startsWith("DEVTOOLS_INSPECTOR_") ||
+ !histId.contains(histIdFocus)) {
+ // Inspector stats are tested in
+ // browser_telemetry_toolboxtabs_{toolname}.js so we skip them here
+ // because we only open the inspector once for this test.
+ continue;
+ }
+
+ if (histId.endsWith("OPENED_PER_USER_FLAG")) {
+ ok(value.length === 1 && value[0] === true,
+ "Per user value " + histId + " has a single value of true");
+ } else if (histId.endsWith("OPENED_BOOLEAN")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element === true;
+ });
+
+ ok(okay, "All " + histId + " entries are === true");
+ } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element > 0;
+ });
+
+ ok(okay, "All " + histId + " entries have time > 0");
+ }
+ }
+}
diff --git a/toolkit/devtools/shared/test/browser_telemetry_button_scratchpad.js b/toolkit/devtools/shared/test/browser_telemetry_button_scratchpad.js
new file mode 100644
index 000000000..f96755fcf
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_button_scratchpad.js
@@ -0,0 +1,127 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8," +
+ "<p>browser_telemetry_button_scratchpad.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ let target = TargetFactory.forTab(gBrowser.selectedTab);
+ let toolbox = yield gDevTools.showToolbox(target, "inspector");
+ info("inspector opened");
+
+ let onAllWindowsOpened = trackScratchpadWindows();
+
+ info("testing the scratchpad button");
+ yield testButton(toolbox, Telemetry);
+ yield onAllWindowsOpened;
+
+ checkResults("_SCRATCHPAD_", Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ yield gDevTools.closeToolbox(target);
+ gBrowser.removeCurrentTab();
+});
+
+function trackScratchpadWindows() {
+ info("register the window observer to track when scratchpad windows open");
+
+ let numScratchpads = 0;
+
+ return new Promise(resolve => {
+ Services.ww.registerNotification(function observer(subject, topic) {
+ if (topic == "domwindowopened") {
+ let win = subject.QueryInterface(Ci.nsIDOMWindow);
+ win.addEventListener("load", function onLoad() {
+ win.removeEventListener("load", onLoad, false);
+
+ if (win.Scratchpad) {
+ win.Scratchpad.addObserver({
+ onReady: function() {
+ win.Scratchpad.removeObserver(this);
+ numScratchpads++;
+ win.close();
+
+ info("another scratchpad was opened and closed, count is now " + numScratchpads);
+
+ if (numScratchpads === 4) {
+ Services.ww.unregisterNotification(observer);
+ info("4 scratchpads have been opened and closed, checking results");
+ resolve();
+ }
+ },
+ });
+ }
+ }, false);
+ }
+ });
+ });
+}
+
+function* testButton(toolbox, Telemetry) {
+ info("Testing command-button-scratchpad");
+ let button = toolbox.doc.querySelector("#command-button-scratchpad");
+ ok(button, "Captain, we have the button");
+
+ yield delayedClicks(button, 4);
+}
+
+function delayedClicks(node, clicks) {
+ return new Promise(resolve => {
+ let clicked = 0;
+
+ // See TOOL_DELAY for why we need setTimeout here
+ setTimeout(function delayedClick() {
+ info("Clicking button " + node.id);
+ node.click();
+ clicked++;
+
+ if (clicked >= clicks) {
+ resolve(node);
+ } else {
+ setTimeout(delayedClick, TOOL_DELAY);
+ }
+ }, TOOL_DELAY);
+ });
+}
+
+function checkResults(histIdFocus, Telemetry) {
+ let result = Telemetry.prototype.telemetryInfo;
+
+ for (let [histId, value] of Iterator(result)) {
+ if (histId.startsWith("DEVTOOLS_INSPECTOR_") ||
+ !histId.contains(histIdFocus)) {
+ // Inspector stats are tested in
+ // browser_telemetry_toolboxtabs_{toolname}.js so we skip them here
+ // because we only open the inspector once for this test.
+ continue;
+ }
+
+ if (histId.endsWith("OPENED_PER_USER_FLAG")) {
+ ok(value.length === 1 && value[0] === true,
+ "Per user value " + histId + " has a single value of true");
+ } else if (histId.endsWith("OPENED_BOOLEAN")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element === true;
+ });
+
+ ok(okay, "All " + histId + " entries are === true");
+ } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element > 0;
+ });
+
+ ok(okay, "All " + histId + " entries have time > 0");
+ }
+ }
+}
diff --git a/toolkit/devtools/shared/test/browser_telemetry_button_tilt.js b/toolkit/devtools/shared/test/browser_telemetry_button_tilt.js
new file mode 100644
index 000000000..4e36618ca
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_button_tilt.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8," +
+ "<p>browser_telemetry_button_tilt.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ let target = TargetFactory.forTab(gBrowser.selectedTab);
+ let toolbox = yield gDevTools.showToolbox(target, "inspector");
+ info("inspector opened");
+
+ info("testing the tilt button");
+ yield testButton(toolbox, Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ yield gDevTools.closeToolbox(target);
+ gBrowser.removeCurrentTab();
+});
+
+function* testButton(toolbox, Telemetry) {
+ info("Testing command-button-tilt");
+
+ let button = toolbox.doc.querySelector("#command-button-tilt");
+ ok(button, "Captain, we have the button");
+
+ yield delayedClicks(button, 4)
+ checkResults("_TILT_", Telemetry);
+}
+
+function delayedClicks(node, clicks) {
+ return new Promise(resolve => {
+ let clicked = 0;
+
+ // See TOOL_DELAY for why we need setTimeout here
+ setTimeout(function delayedClick() {
+ info("Clicking button " + node.id);
+ node.click();
+ clicked++;
+
+ if (clicked >= clicks) {
+ resolve(node);
+ } else {
+ setTimeout(delayedClick, TOOL_DELAY);
+ }
+ }, TOOL_DELAY);
+ });
+}
+
+function checkResults(histIdFocus, Telemetry) {
+ let result = Telemetry.prototype.telemetryInfo;
+
+ for (let [histId, value] of Iterator(result)) {
+ if (histId.startsWith("DEVTOOLS_INSPECTOR_") ||
+ !histId.contains(histIdFocus)) {
+ // Inspector stats are tested in
+ // browser_telemetry_toolboxtabs_{toolname}.js so we skip them here
+ // because we only open the inspector once for this test.
+ continue;
+ }
+
+ if (histId.endsWith("OPENED_PER_USER_FLAG")) {
+ ok(value.length === 1 && value[0] === true,
+ "Per user value " + histId + " has a single value of true");
+ } else if (histId.endsWith("OPENED_BOOLEAN")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element === true;
+ });
+
+ ok(okay, "All " + histId + " entries are === true");
+ } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element > 0;
+ });
+
+ ok(okay, "All " + histId + " entries have time > 0");
+ }
+ }
+}
diff --git a/toolkit/devtools/shared/test/browser_telemetry_sidebar.js b/toolkit/devtools/shared/test/browser_telemetry_sidebar.js
new file mode 100644
index 000000000..b80930e0e
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_sidebar.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_sidebar.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ let target = TargetFactory.forTab(gBrowser.selectedTab);
+ let toolbox = yield gDevTools.showToolbox(target, "inspector");
+ info("inspector opened");
+
+ yield testSidebar(toolbox);
+ checkResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ yield gDevTools.closeToolbox(target);
+ gBrowser.removeCurrentTab();
+});
+
+function* testSidebar(toolbox) {
+ info("Testing sidebar");
+
+ let inspector = toolbox.getCurrentPanel();
+ let sidebarTools = ["ruleview", "computedview", "fontinspector",
+ "layoutview", "animationinspector"];
+
+ // Concatenate the array with itself so that we can open each tool twice.
+ sidebarTools.push.apply(sidebarTools, sidebarTools);
+
+ return new Promise(resolve => {
+ // See TOOL_DELAY for why we need setTimeout here
+ setTimeout(function selectSidebarTab() {
+ let tool = sidebarTools.pop();
+ if (tool) {
+ inspector.sidebar.select(tool);
+ setTimeout(function() {
+ setTimeout(selectSidebarTab, TOOL_DELAY);
+ }, TOOL_DELAY);
+ } else {
+ resolve();
+ }
+ }, TOOL_DELAY);
+ });
+}
+
+function checkResults(Telemetry) {
+ let result = Telemetry.prototype.telemetryInfo;
+
+ for (let [histId, value] of Iterator(result)) {
+ if (histId.startsWith("DEVTOOLS_INSPECTOR_")) {
+ // Inspector stats are tested in browser_telemetry_toolboxtabs.js so we
+ // skip them here because we only open the inspector once for this test.
+ continue;
+ }
+
+ if (histId.endsWith("OPENED_PER_USER_FLAG")) {
+ ok(value.length === 1 && value[0] === true,
+ "Per user value " + histId + " has a single value of true");
+ } else if (histId === "DEVTOOLS_TOOLBOX_OPENED_BOOLEAN") {
+ is(value.length, 1, histId + " has only one entry");
+ } else if (histId.endsWith("OPENED_BOOLEAN")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element === true;
+ });
+
+ ok(okay, "All " + histId + " entries are === true");
+ } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element > 0;
+ });
+
+ ok(okay, "All " + histId + " entries have time > 0");
+ }
+ }
+}
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolbox.js b/toolkit/devtools/shared/test/browser_telemetry_toolbox.js
new file mode 100644
index 000000000..366f64699
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolbox.js
@@ -0,0 +1,20 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8," +
+ "<p>browser_telemetry_toolbox.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(3, TOOL_DELAY, "inspector");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_canvasdebugger.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_canvasdebugger.js
new file mode 100644
index 000000000..c9c07099a
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_canvasdebugger.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8," +
+ "<p>browser_telemetry_toolboxtabs_canvasdebugger.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ info("Activate the canvasdebugger");
+ let originalPref = Services.prefs.getBoolPref("devtools.canvasdebugger.enabled");
+ Services.prefs.setBoolPref("devtools.canvasdebugger.enabled", true);
+
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "canvasdebugger");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+
+ info("De-activate the canvasdebugger");
+ Services.prefs.setBoolPref("devtools.canvasdebugger.enabled", originalPref);
+});
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_inspector.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_inspector.js
new file mode 100644
index 000000000..61dff980a
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_inspector.js
@@ -0,0 +1,20 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8," +
+ "<p>browser_telemetry_toolboxtabs_inspector.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "inspector");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js
new file mode 100644
index 000000000..a86255183
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js
@@ -0,0 +1,20 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8," +
+ "<p>browser_telemetry_toolboxtabs_jsdebugger.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "jsdebugger");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js
new file mode 100644
index 000000000..afe2aeb29
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js
@@ -0,0 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_jsprofiler.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "jsprofiler");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_netmonitor.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_netmonitor.js
new file mode 100644
index 000000000..e7554e549
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_netmonitor.js
@@ -0,0 +1,20 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_netmonitor.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "netmonitor");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+});
+
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_options.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_options.js
new file mode 100644
index 000000000..14256814e
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_options.js
@@ -0,0 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_options.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "options");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_shadereditor.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_shadereditor.js
new file mode 100644
index 000000000..476fbd320
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_shadereditor.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+///////////////////
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+thisTestLeaksUncaughtRejectionsAndShouldBeFixed("Error: Shader Editor is still waiting for a WebGL context to be created.");
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_shadereditor.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ info("Active the sharer editor");
+ let originalPref = Services.prefs.getBoolPref("devtools.shadereditor.enabled");
+ Services.prefs.setBoolPref("devtools.shadereditor.enabled", true);
+
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "shadereditor");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+
+ info("De-activate the sharer editor");
+ Services.prefs.setBoolPref("devtools.shadereditor.enabled", originalPref);
+});
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_storage.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_storage.js
new file mode 100644
index 000000000..93348b96d
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_storage.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_storage.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ info("Activating the storage inspector");
+ Services.prefs.setBoolPref("devtools.storage.enabled", true);
+
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "storage");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+
+ info("De-activating the storage inspector");
+ Services.prefs.clearUserPref("devtools.storage.enabled");
+});
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_styleeditor.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_styleeditor.js
new file mode 100644
index 000000000..15c4a9c08
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_styleeditor.js
@@ -0,0 +1,20 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_styleeditor.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "styleeditor");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+});
+
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_webaudioeditor.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_webaudioeditor.js
new file mode 100644
index 000000000..033791a72
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_webaudioeditor.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_webaudioeditor.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ info("Activating the webaudioeditor");
+ let originalPref = Services.prefs.getBoolPref("devtools.webaudioeditor.enabled");
+ Services.prefs.setBoolPref("devtools.webaudioeditor.enabled", true);
+
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "webaudioeditor");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+
+ info("De-activating the webaudioeditor");
+ Services.prefs.setBoolPref("devtools.webaudioeditor.enabled", originalPref);
+});
diff --git a/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_webconsole.js b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_webconsole.js
new file mode 100644
index 000000000..b989a1426
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_telemetry_toolboxtabs_webconsole.js
@@ -0,0 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_styleeditor_webconsole.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+add_task(function*() {
+ yield promiseTab(TEST_URI);
+ let Telemetry = loadTelemetryAndRecordLogs();
+
+ yield openAndCloseToolbox(2, TOOL_DELAY, "webconsole");
+ checkTelemetryResults(Telemetry);
+
+ stopRecordingTelemetryLogs(Telemetry);
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/devtools/shared/test/browser_templater_basic.html b/toolkit/devtools/shared/test/browser_templater_basic.html
new file mode 100644
index 000000000..473c731f3
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_templater_basic.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<html>
+<head>
+ <title>DOM Template Tests</title>
+</head>
+<body>
+
+</body>
+</html>
+
diff --git a/toolkit/devtools/shared/test/browser_templater_basic.js b/toolkit/devtools/shared/test/browser_templater_basic.js
new file mode 100644
index 000000000..03ca1657c
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_templater_basic.js
@@ -0,0 +1,285 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the DOM Template engine works properly
+
+/*
+ * These tests run both in Mozilla/Mochitest and plain browsers (as does
+ * domtemplate)
+ * We should endevour to keep the source in sync.
+ */
+
+const template = Cu.import("resource://gre/modules/devtools/Templater.jsm", {}).template;
+
+const TEST_URI = TEST_URI_ROOT + "browser_templater_basic.html";
+
+let test = Task.async(function*() {
+ yield promiseTab("about:blank");
+ let [host, win, doc] = yield createHost("bottom", TEST_URI);
+
+ info("Starting DOM Templater Tests");
+ runTest(0, host, doc);
+});
+
+function runTest(index, host, doc) {
+ var options = tests[index] = tests[index]();
+ var holder = doc.createElement('div');
+ holder.id = options.name;
+ var body = doc.body;
+ body.appendChild(holder);
+ holder.innerHTML = options.template;
+
+ info('Running ' + options.name);
+ template(holder, options.data, options.options);
+
+ if (typeof options.result == 'string') {
+ is(holder.innerHTML, options.result, options.name);
+ }
+ else {
+ ok(holder.innerHTML.match(options.result) != null,
+ options.name + ' result=\'' + holder.innerHTML + '\'');
+ }
+
+ if (options.also) {
+ options.also(options);
+ }
+
+ function runNextTest() {
+ index++;
+ if (index < tests.length) {
+ runTest(index, host, doc);
+ }
+ else {
+ finished(host);
+ }
+ }
+
+ if (options.later) {
+ var ais = is.bind(this);
+
+ function createTester(holder, options) {
+ return () => {
+ ais(holder.innerHTML, options.later, options.name + ' later');
+ runNextTest();
+ };
+ }
+
+ executeSoon(createTester(holder, options));
+ }
+ else {
+ runNextTest();
+ }
+}
+
+function finished(host) {
+ host.destroy();
+ gBrowser.removeCurrentTab();
+ info("Finishing DOM Templater Tests");
+ tests = null;
+ finish();
+}
+
+/**
+ * Why have an array of functions that return data rather than just an array
+ * of the data itself? Some of these tests contain calls to delayReply() which
+ * sets up async processing using executeSoon(). Since the execution of these
+ * tests is asynchronous, the delayed reply will probably arrive before the
+ * test is executed, making the test be synchronous. So we wrap the data in a
+ * function so we only set it up just before we use it.
+ */
+var tests = [
+ function() { return {
+ name: 'simpleNesting',
+ template: '<div id="ex1">${nested.value}</div>',
+ data: { nested:{ value:'pass 1' } },
+ result: '<div id="ex1">pass 1</div>'
+ };},
+
+ function() { return {
+ name: 'returnDom',
+ template: '<div id="ex2">${__element.ownerDocument.createTextNode(\'pass 2\')}</div>',
+ options: { allowEval: true },
+ data: {},
+ result: '<div id="ex2">pass 2</div>'
+ };},
+
+ function() { return {
+ name: 'srcChange',
+ template: '<img _src="${fred}" id="ex3">',
+ data: { fred:'green.png' },
+ result: /<img( id="ex3")? src="green.png"( id="ex3")?>/
+ };},
+
+ function() { return {
+ name: 'ifTrue',
+ template: '<p if="${name !== \'jim\'}">hello ${name}</p>',
+ options: { allowEval: true },
+ data: { name: 'fred' },
+ result: '<p>hello fred</p>'
+ };},
+
+ function() { return {
+ name: 'ifFalse',
+ template: '<p if="${name !== \'jim\'}">hello ${name}</p>',
+ options: { allowEval: true },
+ data: { name: 'jim' },
+ result: ''
+ };},
+
+ function() { return {
+ name: 'simpleLoop',
+ template: '<p foreach="index in ${[ 1, 2, 3 ]}">${index}</p>',
+ options: { allowEval: true },
+ data: {},
+ result: '<p>1</p><p>2</p><p>3</p>'
+ };},
+
+ function() { return {
+ name: 'loopElement',
+ template: '<loop foreach="i in ${array}">${i}</loop>',
+ data: { array: [ 1, 2, 3 ] },
+ result: '123'
+ };},
+
+ // Bug 692028: DOMTemplate memory leak with asynchronous arrays
+ // Bug 692031: DOMTemplate async loops do not drop the loop element
+ function() { return {
+ name: 'asyncLoopElement',
+ template: '<loop foreach="i in ${array}">${i}</loop>',
+ data: { array: delayReply([1, 2, 3]) },
+ result: '<span></span>',
+ later: '123'
+ };},
+
+ function() { return {
+ name: 'saveElement',
+ template: '<p save="${element}">${name}</p>',
+ data: { name: 'pass 8' },
+ result: '<p>pass 8</p>',
+ also: function(options) {
+ ok(options.data.element.innerHTML, 'pass 9', 'saveElement saved');
+ delete options.data.element;
+ }
+ };},
+
+ function() { return {
+ name: 'useElement',
+ template: '<p id="pass9">${adjust(__element)}</p>',
+ options: { allowEval: true },
+ data: {
+ adjust: function(element) {
+ is('pass9', element.id, 'useElement adjust');
+ return 'pass 9b'
+ }
+ },
+ result: '<p id="pass9">pass 9b</p>'
+ };},
+
+ function() { return {
+ name: 'asyncInline',
+ template: '${delayed}',
+ data: { delayed: delayReply('inline') },
+ result: '<span></span>',
+ later: 'inline'
+ };},
+
+ // Bug 692028: DOMTemplate memory leak with asynchronous arrays
+ function() { return {
+ name: 'asyncArray',
+ template: '<p foreach="i in ${delayed}">${i}</p>',
+ data: { delayed: delayReply([1, 2, 3]) },
+ result: '<span></span>',
+ later: '<p>1</p><p>2</p><p>3</p>'
+ };},
+
+ function() { return {
+ name: 'asyncMember',
+ template: '<p foreach="i in ${delayed}">${i}</p>',
+ data: { delayed: [delayReply(4), delayReply(5), delayReply(6)] },
+ result: '<span></span><span></span><span></span>',
+ later: '<p>4</p><p>5</p><p>6</p>'
+ };},
+
+ // Bug 692028: DOMTemplate memory leak with asynchronous arrays
+ function() { return {
+ name: 'asyncBoth',
+ template: '<p foreach="i in ${delayed}">${i}</p>',
+ data: {
+ delayed: delayReply([
+ delayReply(4),
+ delayReply(5),
+ delayReply(6)
+ ])
+ },
+ result: '<span></span>',
+ later: '<p>4</p><p>5</p><p>6</p>'
+ };},
+
+ // Bug 701762: DOMTemplate fails when ${foo()} returns undefined
+ function() { return {
+ name: 'functionReturningUndefiend',
+ template: '<p>${foo()}</p>',
+ options: { allowEval: true },
+ data: {
+ foo: function() {}
+ },
+ result: '<p>undefined</p>'
+ };},
+
+ // Bug 702642: DOMTemplate is relatively slow when evaluating JS ${}
+ function() { return {
+ name: 'propertySimple',
+ template: '<p>${a.b.c}</p>',
+ data: { a: { b: { c: 'hello' } } },
+ result: '<p>hello</p>'
+ };},
+
+ function() { return {
+ name: 'propertyPass',
+ template: '<p>${Math.max(1, 2)}</p>',
+ options: { allowEval: true },
+ result: '<p>2</p>'
+ };},
+
+ function() { return {
+ name: 'propertyFail',
+ template: '<p>${Math.max(1, 2)}</p>',
+ result: '<p>${Math.max(1, 2)}</p>'
+ };},
+
+ // Bug 723431: DOMTemplate should allow customisation of display of
+ // null/undefined values
+ function() { return {
+ name: 'propertyUndefAttrFull',
+ template: '<p>${nullvar}|${undefinedvar1}|${undefinedvar2}</p>',
+ data: { nullvar: null, undefinedvar1: undefined },
+ result: '<p>null|undefined|undefined</p>'
+ };},
+
+ function() { return {
+ name: 'propertyUndefAttrBlank',
+ template: '<p>${nullvar}|${undefinedvar1}|${undefinedvar2}</p>',
+ data: { nullvar: null, undefinedvar1: undefined },
+ options: { blankNullUndefined: true },
+ result: '<p>||</p>'
+ };},
+
+ function() { return {
+ name: 'propertyUndefAttrFull',
+ template: '<div><p value="${nullvar}"></p><p value="${undefinedvar1}"></p><p value="${undefinedvar2}"></p></div>',
+ data: { nullvar: null, undefinedvar1: undefined },
+ result: '<div><p value="null"></p><p value="undefined"></p><p value="undefined"></p></div>'
+ };},
+
+ function() { return {
+ name: 'propertyUndefAttrBlank',
+ template: '<div><p value="${nullvar}"></p><p value="${undefinedvar1}"></p><p value="${undefinedvar2}"></p></div>',
+ data: { nullvar: null, undefinedvar1: undefined },
+ options: { blankNullUndefined: true },
+ result: '<div><p value=""></p><p value=""></p><p value=""></p></div>'
+ };}
+];
+
+function delayReply(data) {
+ return new Promise(resolve => resolve(data));
+}
diff --git a/toolkit/devtools/shared/test/browser_theme.js b/toolkit/devtools/shared/test/browser_theme.js
new file mode 100644
index 000000000..632e4538f
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_theme.js
@@ -0,0 +1,81 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that theme utilities work
+
+let {getColor, getTheme, setTheme} = devtools.require("devtools/shared/theme");
+
+function test() {
+ testGetTheme();
+ testSetTheme();
+ testGetColor();
+ testColorExistence();
+}
+
+function testGetTheme () {
+ let originalTheme = getTheme();
+ ok(originalTheme, "has some theme to start with.");
+ Services.prefs.setCharPref("devtools.theme", "light");
+ is(getTheme(), "light", "getTheme() correctly returns light theme");
+ Services.prefs.setCharPref("devtools.theme", "dark");
+ is(getTheme(), "dark", "getTheme() correctly returns dark theme");
+ Services.prefs.setCharPref("devtools.theme", "unknown");
+ is(getTheme(), "unknown", "getTheme() correctly returns an unknown theme");
+ Services.prefs.setCharPref("devtools.theme", originalTheme);
+}
+
+function testSetTheme () {
+ let originalTheme = getTheme();
+ setTheme("dark");
+ is(Services.prefs.getCharPref("devtools.theme"), "dark", "setTheme() correctly sets dark theme.");
+ setTheme("light");
+ is(Services.prefs.getCharPref("devtools.theme"), "light", "setTheme() correctly sets light theme.");
+ setTheme("unknown");
+ is(Services.prefs.getCharPref("devtools.theme"), "unknown", "setTheme() correctly sets an unknown theme.");
+ Services.prefs.setCharPref("devtools.theme", originalTheme);
+}
+
+function testGetColor () {
+ let BLUE_DARK = "#3689b2";
+ let BLUE_LIGHT = "hsl(208,56%,40%)";
+ let originalTheme = getTheme();
+
+ setTheme("dark");
+ is(getColor("highlight-blue"), BLUE_DARK, "correctly gets color for enabled theme.");
+ setTheme("light");
+ is(getColor("highlight-blue"), BLUE_LIGHT, "correctly gets color for enabled theme.");
+ setTheme("metal");
+ is(getColor("highlight-blue"), BLUE_LIGHT, "correctly uses light for default theme if enabled theme not found");
+
+ is(getColor("highlight-blue", "dark"), BLUE_DARK, "if provided and found, uses the provided theme.");
+ is(getColor("highlight-blue", "metal"), BLUE_LIGHT, "if provided and not found, defaults to light theme.");
+ is(getColor("somecomponents"), null, "if a type cannot be found, should return null.");
+
+ setTheme(originalTheme);
+}
+
+function testColorExistence () {
+ var vars = ["body-background", "sidebar-background", "contrast-background", "tab-toolbar-background",
+ "toolbar-background", "selection-background", "selection-color",
+ "selection-background-semitransparent", "splitter-color", "comment", "body-color",
+ "body-color-alt", "content-color1", "content-color2", "content-color3",
+ "highlight-green", "highlight-blue", "highlight-bluegrey", "highlight-purple",
+ "highlight-lightorange", "highlight-orange", "highlight-red", "highlight-pink"
+ ];
+
+ for (let type of vars) {
+ ok(getColor(type, "light"), `${type} is a valid color in light theme`);
+ ok(getColor(type, "dark"), `${type} is a valid color in light theme`);
+ }
+}
+
+function isColor (s) {
+ // Regexes from Heather Arthur's `color-string`
+ // https://github.com/harthur/color-string
+ // MIT License
+ return /^#([a-fA-F0-9]{3})$/.test(s) ||
+ /^#([a-fA-F0-9]{6})$/.test(s) ||
+ /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d\.]+)\s*)?\)$/.test(s) ||
+ /^rgba?\(\s*([\d\.]+)\%\s*,\s*([\d\.]+)\%\s*,\s*([\d\.]+)\%\s*(?:,\s*([\d\.]+)\s*)?\)$/.test(s);
+}
diff --git a/toolkit/devtools/shared/test/browser_toolbar_basic.html b/toolkit/devtools/shared/test/browser_toolbar_basic.html
new file mode 100644
index 000000000..7ec012b0e
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_toolbar_basic.html
@@ -0,0 +1,35 @@
+<!doctype html>
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Developer Toolbar Tests</title>
+ <style type="text/css">
+ #single { color: red; }
+ </style>
+ <script type="text/javascript">var a=1;</script>
+</head>
+<body>
+
+<p id=single>
+1
+</p>
+
+<p class=twin>
+2a
+</p>
+
+<p class=twin>
+2b
+</p>
+
+<style>
+.twin { color: blue; }
+</style>
+<script>var b=2;</script>
+
+</body>
+</html>
+
diff --git a/toolkit/devtools/shared/test/browser_toolbar_basic.js b/toolkit/devtools/shared/test/browser_toolbar_basic.js
new file mode 100644
index 000000000..cfeb7f958
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_toolbar_basic.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the developer toolbar works properly
+
+const TEST_URI = TEST_URI_ROOT + "browser_toolbar_basic.html";
+
+function test() {
+ addTab(TEST_URI, function(browser, tab) {
+ info("Starting browser_toolbar_basic.js");
+ runTest();
+ });
+}
+
+function runTest() {
+ ok(!DeveloperToolbar.visible, "DeveloperToolbar is not visible in runTest");
+
+ oneTimeObserve(DeveloperToolbar.NOTIFICATIONS.SHOW, catchFail(checkOpen));
+ document.getElementById("Tools:DevToolbar").doCommand();
+}
+
+function isChecked(b) {
+ return b.getAttribute("checked") == "true";
+}
+
+function checkOpen() {
+ ok(DeveloperToolbar.visible, "DeveloperToolbar is visible in checkOpen");
+ let close = document.getElementById("developer-toolbar-closebutton");
+ ok(close, "Close button exists");
+
+ let toggleToolbox =
+ document.getElementById("devtoolsMenuBroadcaster_DevToolbox");
+ ok(!isChecked(toggleToolbox), "toggle toolbox button is not checked");
+
+ let target = TargetFactory.forTab(gBrowser.selectedTab);
+ gDevTools.showToolbox(target, "inspector").then(function(toolbox) {
+ ok(isChecked(toggleToolbox), "toggle toolbox button is checked");
+
+ addTab("about:blank", function(browser, tab) {
+ info("Opened a new tab");
+
+ ok(!isChecked(toggleToolbox), "toggle toolbox button is not checked");
+
+ gBrowser.removeCurrentTab();
+
+ oneTimeObserve(DeveloperToolbar.NOTIFICATIONS.HIDE, catchFail(checkClosed));
+ document.getElementById("Tools:DevToolbar").doCommand();
+ });
+ });
+}
+
+function checkClosed() {
+ ok(!DeveloperToolbar.visible, "DeveloperToolbar is not visible in checkClosed");
+
+ oneTimeObserve(DeveloperToolbar.NOTIFICATIONS.SHOW, catchFail(checkReOpen));
+ document.getElementById("Tools:DevToolbar").doCommand();
+}
+
+function checkReOpen() {
+ ok(DeveloperToolbar.visible, "DeveloperToolbar is visible in checkReOpen");
+
+ let toggleToolbox =
+ document.getElementById("devtoolsMenuBroadcaster_DevToolbox");
+ ok(isChecked(toggleToolbox), "toggle toolbox button is checked");
+
+ oneTimeObserve(DeveloperToolbar.NOTIFICATIONS.HIDE, catchFail(checkReClosed));
+ document.getElementById("developer-toolbar-closebutton").doCommand();
+}
+
+function checkReClosed() {
+ ok(!DeveloperToolbar.visible, "DeveloperToolbar is not visible in checkReClosed");
+
+ finish();
+}
diff --git a/toolkit/devtools/shared/test/browser_toolbar_tooltip.js b/toolkit/devtools/shared/test/browser_toolbar_tooltip.js
new file mode 100644
index 000000000..a62996d61
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_toolbar_tooltip.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the developer toolbar works properly
+
+///////////////////
+//
+// Whitelisting this test.
+// As part of bug 1077403, the leaking uncaught rejection should be fixed.
+//
+thisTestLeaksUncaughtRejectionsAndShouldBeFixed("Protocol error (unknownError): Error: Got an invalid root window in DocumentWalker");
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>Tooltip Tests</p>";
+
+function test() {
+ addTab(TEST_URI, function() {
+ Task.spawn(runTest).catch(err => {
+ ok(false, ex);
+ console.error(ex);
+ }).then(finish);
+ });
+}
+
+function* runTest() {
+ info("Starting browser_toolbar_tooltip.js");
+
+ ok(!DeveloperToolbar.visible, "DeveloperToolbar is not visible in runTest");
+
+ let showPromise = observeOnce(DeveloperToolbar.NOTIFICATIONS.SHOW);
+ document.getElementById("Tools:DevToolbar").doCommand();
+ yield showPromise;
+
+ let tooltipPanel = DeveloperToolbar.tooltipPanel;
+
+ DeveloperToolbar.display.focusManager.helpRequest();
+ yield DeveloperToolbar.display.inputter.setInput('help help');
+
+ DeveloperToolbar.display.inputter.setCursor({ start: 'help help'.length });
+ is(tooltipPanel._dimensions.start, 'help '.length,
+ 'search param start, when cursor at end');
+ ok(getLeftMargin() > 30, 'tooltip offset, when cursor at end')
+
+ DeveloperToolbar.display.inputter.setCursor({ start: 'help'.length });
+ is(tooltipPanel._dimensions.start, 0,
+ 'search param start, when cursor at end of command');
+ ok(getLeftMargin() > 9, 'tooltip offset, when cursor at end of command')
+
+ DeveloperToolbar.display.inputter.setCursor({ start: 'help help'.length - 1 });
+ is(tooltipPanel._dimensions.start, 'help '.length,
+ 'search param start, when cursor at penultimate position');
+ ok(getLeftMargin() > 30, 'tooltip offset, when cursor at penultimate position')
+
+ DeveloperToolbar.display.inputter.setCursor({ start: 0 });
+ is(tooltipPanel._dimensions.start, 0,
+ 'search param start, when cursor at start');
+ ok(getLeftMargin() > 9, 'tooltip offset, when cursor at start')
+}
+
+function getLeftMargin() {
+ let style = DeveloperToolbar.tooltipPanel._panel.style.marginLeft;
+ return parseInt(style.slice(0, -2), 10);
+}
+
+function observeOnce(topic, ownsWeak=false) {
+ return new Promise(function(resolve, reject) {
+ let resolver = function(subject) {
+ Services.obs.removeObserver(resolver, topic);
+ resolve(subject);
+ };
+ Services.obs.addObserver(resolver, topic, ownsWeak);
+ }.bind(this));
+}
diff --git a/toolkit/devtools/shared/test/browser_toolbar_webconsole_errors_count.html b/toolkit/devtools/shared/test/browser_toolbar_webconsole_errors_count.html
new file mode 100644
index 000000000..216cc0d49
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_toolbar_webconsole_errors_count.html
@@ -0,0 +1,32 @@
+<!DOCTYPE HTML>
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Developer Toolbar Tests - errors count in the Web Console button</title>
+ <script type="text/javascript">
+ console.log("foobarBug762996consoleLog");
+ window.onload = function() {
+ window.foobarBug762996load();
+ };
+ window.foobarBug762996a();
+ </script>
+ <script type="text/javascript">
+ window.foobarBug762996b();
+ </script>
+</head>
+<body>
+ <p>Hello world! Test for errors count in the Web Console button (developer
+ toolbar).</p>
+ <p style="color: foobarBug762996css"><button>click me</button></p>
+ <script type="text/javascript;version=1.8">
+ "use strict";
+ let testObj = {};
+ document.querySelector("button").onclick = function() {
+ let test = testObj.fooBug788445 + "warning";
+ window.foobarBug762996click();
+ };
+ </script>
+</body>
+</html>
diff --git a/toolkit/devtools/shared/test/browser_toolbar_webconsole_errors_count.js b/toolkit/devtools/shared/test/browser_toolbar_webconsole_errors_count.js
new file mode 100644
index 000000000..8d6ed78bb
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_toolbar_webconsole_errors_count.js
@@ -0,0 +1,246 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the developer toolbar errors count works properly.
+
+function test() {
+ const TEST_URI = TEST_URI_ROOT + "browser_toolbar_webconsole_errors_count.html";
+
+ let gDevTools = Cu.import("resource:///modules/devtools/gDevTools.jsm",
+ {}).gDevTools;
+
+ let webconsole = document.getElementById("developer-toolbar-toolbox-button");
+ let tab1, tab2;
+
+ Services.prefs.setBoolPref("javascript.options.strict", true);
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("javascript.options.strict");
+ });
+
+ ignoreAllUncaughtExceptions();
+ addTab(TEST_URI, openToolbar);
+
+ function openToolbar(browser, tab) {
+ tab1 = tab;
+ ignoreAllUncaughtExceptions(false);
+
+ expectUncaughtException();
+
+ if (!DeveloperToolbar.visible) {
+ DeveloperToolbar.show(true, onOpenToolbar);
+ }
+ else {
+ onOpenToolbar();
+ }
+ }
+
+ function onOpenToolbar() {
+ ok(DeveloperToolbar.visible, "DeveloperToolbar is visible");
+
+ waitForButtonUpdate({
+ name: "web console button shows page errors",
+ errors: 3,
+ warnings: 0,
+ callback: addErrors,
+ });
+ }
+
+ function addErrors() {
+ expectUncaughtException();
+
+ waitForFocus(function() {
+ let button = content.document.querySelector("button");
+ executeSoon(function() {
+ EventUtils.synthesizeMouse(button, 3, 2, {}, content);
+ });
+ }, content);
+
+ waitForButtonUpdate({
+ name: "button shows one more error after click in page",
+ errors: 4,
+ warnings: 1,
+ callback: () => {
+ ignoreAllUncaughtExceptions();
+ addTab(TEST_URI, onOpenSecondTab);
+ },
+ });
+ }
+
+ function onOpenSecondTab(browser, tab) {
+ tab2 = tab;
+
+ ignoreAllUncaughtExceptions(false);
+ expectUncaughtException();
+
+ waitForButtonUpdate({
+ name: "button shows correct number of errors after new tab is open",
+ errors: 3,
+ warnings: 0,
+ callback: switchToTab1,
+ });
+ }
+
+ function switchToTab1() {
+ gBrowser.selectedTab = tab1;
+ waitForButtonUpdate({
+ name: "button shows the page errors from tab 1",
+ errors: 4,
+ warnings: 1,
+ callback: openWebConsole.bind(null, tab1, onWebConsoleOpen),
+ });
+ }
+
+ function onWebConsoleOpen(hud) {
+ dump("lolz!!\n");
+ waitForValue({
+ name: "web console shows the page errors",
+ validator: function() {
+ return hud.outputNode.querySelectorAll(".message[category=exception][severity=error]").length;
+ },
+ value: 4,
+ success: checkConsoleOutput.bind(null, hud),
+ failure: () => {
+ finish();
+ },
+ });
+ }
+
+ function checkConsoleOutput(hud) {
+ let msgs = ["foobarBug762996a", "foobarBug762996b", "foobarBug762996load",
+ "foobarBug762996click", "foobarBug762996consoleLog",
+ "foobarBug762996css", "fooBug788445"];
+ msgs.forEach(function(msg) {
+ isnot(hud.outputNode.textContent.indexOf(msg), -1,
+ msg + " found in the Web Console output");
+ });
+
+ hud.jsterm.clearOutput();
+
+ is(hud.outputNode.textContent.indexOf("foobarBug762996color"), -1,
+ "clearOutput() worked");
+
+ expectUncaughtException();
+ let button = content.document.querySelector("button");
+ EventUtils.synthesizeMouse(button, 2, 2, {}, content);
+
+ waitForButtonUpdate({
+ name: "button shows one more error after another click in page",
+ errors: 5,
+ warnings: 1, // warnings are not repeated by the js engine
+ callback: () => waitForValue(waitForNewError),
+ });
+
+ let waitForNewError = {
+ name: "the Web Console displays the new error",
+ validator: function() {
+ return hud.outputNode.textContent.indexOf("foobarBug762996click") > -1;
+ },
+ success: doClearConsoleButton.bind(null, hud),
+ failure: finish,
+ };
+ }
+
+ function doClearConsoleButton(hud) {
+ let clearButton = hud.ui.rootElement
+ .querySelector(".webconsole-clear-console-button");
+ EventUtils.synthesizeMouse(clearButton, 2, 2, {}, hud.iframeWindow);
+
+ is(hud.outputNode.textContent.indexOf("foobarBug762996click"), -1,
+ "clear console button worked");
+ is(getErrorsCount(), 0, "page errors counter has been reset");
+ let tooltip = getTooltipValues();
+ is(tooltip[1], 0, "page warnings counter has been reset");
+
+ doPageReload(hud);
+ }
+
+ function doPageReload(hud) {
+ tab1.linkedBrowser.addEventListener("load", onReload, true);
+
+ ignoreAllUncaughtExceptions();
+ content.location.reload();
+
+ function onReload() {
+ tab1.linkedBrowser.removeEventListener("load", onReload, true);
+ ignoreAllUncaughtExceptions(false);
+ expectUncaughtException();
+
+ waitForButtonUpdate({
+ name: "the Web Console button count has been reset after page reload",
+ errors: 3,
+ warnings: 0,
+ callback: waitForValue.bind(null, waitForConsoleOutputAfterReload),
+ });
+ }
+
+ let waitForConsoleOutputAfterReload = {
+ name: "the Web Console displays the correct number of errors after reload",
+ validator: function() {
+ return hud.outputNode.querySelectorAll(".message[category=exception][severity=error]").length;
+ },
+ value: 3,
+ success: function() {
+ isnot(hud.outputNode.textContent.indexOf("foobarBug762996load"), -1,
+ "foobarBug762996load found in console output after page reload");
+ testEnd();
+ },
+ failure: testEnd,
+ };
+ }
+
+ function testEnd() {
+ document.getElementById("developer-toolbar-closebutton").doCommand();
+ let target1 = TargetFactory.forTab(tab1);
+ gDevTools.closeToolbox(target1).then(() => {
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+ finish();
+ });
+ }
+
+ // Utility functions
+
+ function getErrorsCount() {
+ let count = webconsole.getAttribute("error-count");
+ return count ? count : "0";
+ }
+
+ function getTooltipValues() {
+ let matches = webconsole.getAttribute("tooltiptext")
+ .match(/(\d+) errors?, (\d+) warnings?/);
+ return matches ? [matches[1], matches[2]] : [0, 0];
+ }
+
+ function waitForButtonUpdate(options) {
+ function check() {
+ let errors = getErrorsCount();
+ let tooltip = getTooltipValues();
+ let result = errors == options.errors && tooltip[1] == options.warnings;
+ if (result) {
+ ok(true, options.name);
+ is(errors, tooltip[0], "button error-count is the same as in the tooltip");
+
+ // Get out of the toolbar event execution loop.
+ executeSoon(options.callback);
+ }
+ return result;
+ }
+
+ if (!check()) {
+ info("wait for: " + options.name);
+ DeveloperToolbar.on("errors-counter-updated", function onUpdate(event) {
+ if (check()) {
+ DeveloperToolbar.off(event, onUpdate);
+ }
+ });
+ }
+ }
+
+ function openWebConsole(tab, callback)
+ {
+ let target = TargetFactory.forTab(tab);
+ gDevTools.showToolbox(target, "webconsole").then((toolbox) =>
+ callback(toolbox.getCurrentPanel().hud));
+ }
+}
diff --git a/toolkit/devtools/shared/test/browser_treeWidget_basic.js b/toolkit/devtools/shared/test/browser_treeWidget_basic.js
new file mode 100644
index 000000000..170c75cf7
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_treeWidget_basic.js
@@ -0,0 +1,254 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the tree widget api works fine
+
+const TEST_URI = "data:text/html;charset=utf-8,<head><link rel='stylesheet' " +
+ "type='text/css' href='chrome://browser/skin/devtools/common.css'><link " +
+ "rel='stylesheet' type='text/css' href='chrome://browser/skin/devtools/widg" +
+ "ets.css'></head><body><div></div><span></span></body>";
+const {TreeWidget} = devtools.require("devtools/shared/widgets/TreeWidget");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ let [host, win, doc] = yield createHost("bottom", TEST_URI);
+
+ let tree = new TreeWidget(doc.querySelector("div"), {
+ defaultType: "store"
+ });
+
+ populateTree(tree, doc);
+ testTreeItemInsertedCorrectly(tree, doc);
+ testAPI(tree, doc);
+ populateUnsortedTree(tree, doc);
+ testUnsortedTreeItemInsertedCorrectly(tree, doc);
+
+ tree.destroy();
+ host.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+function populateTree(tree, doc) {
+ tree.add([{
+ id: "level1",
+ label: "Level 1"
+ }, {
+ id: "level2-1",
+ label: "Level 2"
+ }, {
+ id: "level3-1",
+ label: "Level 3 - Child 1",
+ type: "dir"
+ }]);
+ tree.add(["level1", "level2-1", { id: "level3-2", label: "Level 3 - Child 2"}]);
+ tree.add(["level1", "level2-1", { id: "level3-3", label: "Level 3 - Child 3"}]);
+ tree.add(["level1", {
+ id: "level2-2",
+ label: "Level 2.1"
+ }, {
+ id: "level3-1",
+ label: "Level 3.1"
+ }]);
+ tree.add([{
+ id: "level1",
+ label: "Level 1"
+ }, {
+ id: "level2",
+ label: "Level 2"
+ }, {
+ id: "level3",
+ label: "Level 3",
+ type: "js"
+ }]);
+ tree.add(["level1.1", "level2", {id: "level3", type: "url"}]);
+}
+
+/**
+ * Test if the nodes are inserted correctly in the tree.
+ */
+function testTreeItemInsertedCorrectly(tree, doc) {
+ is(tree.root.children.children.length, 2, "Number of top level elements match");
+ is(tree.root.children.firstChild.lastChild.children.length, 3,
+ "Number of first second level elements match");
+ is(tree.root.children.lastChild.lastChild.children.length, 1,
+ "Number of second second level elements match");
+
+ ok(tree.root.items.has("level1"), "Level1 top level element exists");
+ is(tree.root.children.firstChild.dataset.id, JSON.stringify(["level1"]),
+ "Data id of first top level element matches");
+ is(tree.root.children.firstChild.firstChild.textContent, "Level 1",
+ "Text content of first top level element matches");
+
+ ok(tree.root.items.has("level1.1"), "Level1.1 top level element exists");
+ is(tree.root.children.firstChild.nextSibling.dataset.id,
+ JSON.stringify(["level1.1"]),
+ "Data id of second top level element matches");
+ is(tree.root.children.firstChild.nextSibling.firstChild.textContent, "level1.1",
+ "Text content of second top level element matches");
+
+ // Adding a new non text item in the tree.
+ let node = doc.createElement("div");
+ node.textContent = "Foo Bar";
+ node.className = "foo bar";
+ tree.add([{
+ id: "level1.2",
+ node: node,
+ attachment: {
+ foo: "bar"
+ }
+ }]);
+
+ is(tree.root.children.children.length, 3,
+ "Number of top level elements match after update");
+ ok(tree.root.items.has("level1.2"), "New level node got added");
+ ok(tree.attachments.has(JSON.stringify(["level1.2"])),
+ "Attachment is present for newly added node");
+ // The item should be added before level1 and level 1.1 as lexical sorting
+ is(tree.root.children.firstChild.dataset.id, JSON.stringify(["level1.2"]),
+ "Data id of last top level element matches");
+ is(tree.root.children.firstChild.firstChild.firstChild, node,
+ "Newly added node is inserted at the right location");
+}
+
+/**
+ * Populate the unsorted tree.
+ */
+function populateUnsortedTree(tree, doc) {
+ tree.sorted = false;
+
+ tree.add([{ id: "g-1", label: "g-1"}])
+ tree.add(["g-1", { id: "d-2", label: "d-2.1"}]);
+ tree.add(["g-1", { id: "b-2", label: "b-2.2"}]);
+ tree.add(["g-1", { id: "a-2", label: "a-2.3"}]);
+}
+
+/**
+ * Test if the nodes are inserted correctly in the unsorted tree.
+ */
+function testUnsortedTreeItemInsertedCorrectly(tree, doc) {
+ ok(tree.root.items.has("g-1"), "g-1 top level element exists");
+
+ is(tree.root.children.firstChild.lastChild.children.length, 3,
+ "Number of children for g-1 matches");
+ is(tree.root.children.firstChild.dataset.id, JSON.stringify(["g-1"]),
+ "Data id of g-1 matches");
+ is(tree.root.children.firstChild.firstChild.textContent, "g-1",
+ "Text content of g-1 matches");
+ is(tree.root.children.firstChild.lastChild.firstChild.dataset.id,
+ JSON.stringify(["g-1", "d-2"]),
+ "Data id of d-2 matches");
+ is(tree.root.children.firstChild.lastChild.firstChild.textContent, "d-2.1",
+ "Text content of d-2 matches");
+ is(tree.root.children.firstChild.lastChild.firstChild.nextSibling.textContent,
+ "b-2.2", "Text content of b-2 matches");
+ is(tree.root.children.firstChild.lastChild.lastChild.textContent, "a-2.3",
+ "Text content of a-2 matches");
+}
+
+/**
+ * Tests if the API exposed by TreeWidget works properly
+ */
+function testAPI(tree, doc) {
+ info("Testing TreeWidget API");
+ // Check if selectItem and selectedItem setter works as expected
+ // Nothing should be selected beforehand
+ ok(!doc.querySelector(".theme-selected"), "Nothing is selected");
+ tree.selectItem(["level1"]);
+ let node = doc.querySelector(".theme-selected");
+ ok(!!node, "Something got selected");
+ is(node.parentNode.dataset.id, JSON.stringify(["level1"]),
+ "Correct node selected");
+
+ tree.selectItem(["level1", "level2"]);
+ let node2 = doc.querySelector(".theme-selected");
+ ok(!!node2, "Something is still selected");
+ isnot(node, node2, "Newly selected node is different from previous");
+ is(node2.parentNode.dataset.id, JSON.stringify(["level1", "level2"]),
+ "Correct node selected");
+
+ // test if selectedItem getter works
+ is(tree.selectedItem.length, 2, "Correct length of selected item");
+ is(tree.selectedItem[0], "level1", "Correct selected item");
+ is(tree.selectedItem[1], "level2", "Correct selected item");
+
+ // test if isSelected works
+ ok(tree.isSelected(["level1", "level2"]), "isSelected works");
+
+ tree.selectedItem = ["level1"];
+ let node3 = doc.querySelector(".theme-selected");
+ ok(!!node3, "Something is still selected");
+ isnot(node2, node3, "Newly selected node is different from previous");
+ is(node3, node, "First and third selected nodes should be same");
+ is(node3.parentNode.dataset.id, JSON.stringify(["level1"]),
+ "Correct node selected");
+
+ // test if selectedItem getter works
+ is(tree.selectedItem.length, 1, "Correct length of selected item");
+ is(tree.selectedItem[0], "level1", "Correct selected item");
+
+ // test if clear selection works
+ tree.clearSelection();
+ ok(!doc.querySelector(".theme-selected"),
+ "Nothing selected after clear selection call")
+
+ // test if collapseAll/expandAll work
+ ok(doc.querySelectorAll("[expanded]").length > 0,
+ "Some nodes are expanded");
+ tree.collapseAll();
+ is(doc.querySelectorAll("[expanded]").length, 0,
+ "Nothing is expanded after collapseAll call");
+ tree.expandAll();
+ is(doc.querySelectorAll("[expanded]").length, 13,
+ "All tree items expanded after expandAll call");
+
+ // test if selectNextItem and selectPreviousItem work
+ tree.selectedItem = ["level1", "level2"];
+ ok(tree.isSelected(["level1", "level2"]), "Correct item selected");
+ tree.selectNextItem();
+ ok(tree.isSelected(["level1", "level2", "level3"]),
+ "Correct item selected after selectNextItem call");
+
+ tree.selectNextItem();
+ ok(tree.isSelected(["level1", "level2-1"]),
+ "Correct item selected after second selectNextItem call");
+
+ tree.selectNextItem();
+ ok(tree.isSelected(["level1", "level2-1", "level3-1"]),
+ "Correct item selected after third selectNextItem call");
+
+ tree.selectPreviousItem();
+ ok(tree.isSelected(["level1", "level2-1"]),
+ "Correct item selected after selectPreviousItem call");
+
+ tree.selectPreviousItem();
+ ok(tree.isSelected(["level1", "level2", "level3"]),
+ "Correct item selected after second selectPreviousItem call");
+
+ // test if remove works
+ ok(doc.querySelector("[data-id='" +
+ JSON.stringify(["level1", "level2", "level3"]) + "']"),
+ "level1-level2-level3 item exists before removing");
+ tree.remove(["level1", "level2", "level3"]);
+ ok(!doc.querySelector("[data-id='" +
+ JSON.stringify(["level1", "level2", "level3"]) + "']"),
+ "level1-level2-level3 item does not exist after removing");
+ tree.add([{
+ id: "level1",
+ label: "Level 1"
+ }, {
+ id: "level2",
+ label: "Level 2"
+ }, {
+ id: "level3",
+ label: "Level 3",
+ type: "js"
+ }]);
+
+ // test if clearing the tree works
+ is(doc.querySelectorAll("[level='1']").length, 3,
+ "Correct number of top level items before clearing");
+ tree.clear();
+ is(doc.querySelectorAll("[level='1']").length, 0,
+ "No top level item after clearing the tree");
+}
diff --git a/toolkit/devtools/shared/test/browser_treeWidget_keyboard_interaction.js b/toolkit/devtools/shared/test/browser_treeWidget_keyboard_interaction.js
new file mode 100644
index 000000000..c1a4a9055
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_treeWidget_keyboard_interaction.js
@@ -0,0 +1,228 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that keyboard interaction works fine with the tree widget
+
+const TEST_URI = "data:text/html;charset=utf-8,<head><link rel='stylesheet' " +
+ "type='text/css' href='chrome://browser/skin/devtools/common.css'><link " +
+ "rel='stylesheet' type='text/css' href='chrome://browser/skin/devtools/widg" +
+ "ets.css'></head><body><div></div><span></span></body>";
+const {TreeWidget} = devtools.require("devtools/shared/widgets/TreeWidget");
+const {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ let [host, win, doc] = yield createHost("bottom", TEST_URI);
+
+ let tree = new TreeWidget(doc.querySelector("div"), {
+ defaultType: "store"
+ });
+
+ populateTree(tree, doc);
+ yield testKeyboardInteraction(tree, win);
+
+ tree.destroy();
+ host.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+function populateTree(tree, doc) {
+ tree.add([{
+ id: "level1",
+ label: "Level 1"
+ }, {
+ id: "level2-1",
+ label: "Level 2"
+ }, {
+ id: "level3-1",
+ label: "Level 3 - Child 1",
+ type: "dir"
+ }]);
+ tree.add(["level1", "level2-1", { id: "level3-2", label: "Level 3 - Child 2"}]);
+ tree.add(["level1", "level2-1", { id: "level3-3", label: "Level 3 - Child 3"}]);
+ tree.add(["level1", {
+ id: "level2-2",
+ label: "Level 2.1"
+ }, {
+ id: "level3-1",
+ label: "Level 3.1"
+ }]);
+ tree.add([{
+ id: "level1",
+ label: "Level 1"
+ }, {
+ id: "level2",
+ label: "Level 2"
+ }, {
+ id: "level3",
+ label: "Level 3",
+ type: "js"
+ }]);
+ tree.add(["level1.1", "level2", {id: "level3", type: "url"}]);
+
+ // Adding a new non text item in the tree.
+ let node = doc.createElement("div");
+ node.textContent = "Foo Bar";
+ node.className = "foo bar";
+ tree.add([{
+ id: "level1.2",
+ node: node,
+ attachment: {
+ foo: "bar"
+ }
+ }]);
+}
+
+// Sends a click event on the passed DOM node in an async manner
+function click(node) {
+ let win = node.ownerDocument.defaultView;
+ executeSoon(() => EventUtils.synthesizeMouseAtCenter(node, {}, win));
+}
+
+/**
+ * Tests if pressing navigation keys on the tree items does the expected behavior
+ */
+function* testKeyboardInteraction(tree, win) {
+ info("Testing keyboard interaction with the tree");
+ let event;
+ let pass = (e, d, a) => event.resolve([e, d, a]);
+
+ info("clicking on first top level item");
+ let node = tree.root.children.firstChild.firstChild;
+ event = Promise.defer();
+ tree.once("select", pass);
+ click(node);
+ yield event.promise;
+ node = tree.root.children.firstChild.nextSibling.firstChild;
+ // node should not have selected class
+ ok(!node.classList.contains("theme-selected"), "Node should not have selected class");
+ ok(!node.hasAttribute("expanded"), "Node is not expanded");
+
+ info("Pressing down key to select next item");
+ event = Promise.defer();
+ tree.once("select", pass);
+ EventUtils.sendKey("DOWN", win);
+ let [name, data, attachment] = yield event.promise;
+ is(name, "select", "Select event was fired after pressing down");
+ is(data[0], "level1", "Correct item was selected after pressing down");
+ ok(!attachment, "null attachment was emitted");
+ ok(node.classList.contains("theme-selected"), "Node has selected class");
+ ok(node.hasAttribute("expanded"), "Node is expanded now");
+
+ info("Pressing down key again to select next item");
+ event = Promise.defer();
+ tree.once("select", pass);
+ EventUtils.sendKey("DOWN", win);
+ [name, data, attachment] = yield event.promise;
+ is(data.length, 2, "Correct level item was selected after second down keypress");
+ is(data[0], "level1", "Correct parent level");
+ is(data[1], "level2", "Correct second level");
+
+ info("Pressing down key again to select next item");
+ event = Promise.defer();
+ tree.once("select", pass);
+ EventUtils.sendKey("DOWN", win);
+ [name, data, attachment] = yield event.promise;
+ is(data.length, 3, "Correct level item was selected after third down keypress");
+ is(data[0], "level1", "Correct parent level");
+ is(data[1], "level2", "Correct second level");
+ is(data[2], "level3", "Correct third level");
+
+ info("Pressing down key again to select next item");
+ event = Promise.defer();
+ tree.once("select", pass);
+ EventUtils.sendKey("DOWN", win);
+ [name, data, attachment] = yield event.promise;
+ is(data.length, 2, "Correct level item was selected after fourth down keypress");
+ is(data[0], "level1", "Correct parent level");
+ is(data[1], "level2-1", "Correct second level");
+
+ // pressing left to check expand collapse feature.
+ // This does not emit any event, so listening for keypress
+ tree.root.children.addEventListener("keypress", function onClick() {
+ tree.root.children.removeEventListener("keypress", onClick);
+ // executeSoon so that other listeners on the same method are executed first
+ executeSoon(() => event.resolve(null));
+ });
+ info("Pressing left key to collapse the item");
+ event = Promise.defer();
+ node = tree._selectedLabel;
+ ok(node.hasAttribute("expanded"), "Item is expanded before left keypress");
+ EventUtils.sendKey("LEFT", win);
+ yield event.promise;
+
+ ok(!node.hasAttribute("expanded"), "Item is not expanded after left keypress");
+
+ // pressing left on collapsed item should select the previous item
+
+ info("Pressing left key on collapsed item to select previous");
+ tree.once("select", pass);
+ event = Promise.defer();
+ // parent node should have no effect of this keypress
+ node = tree.root.children.firstChild.nextSibling.firstChild;
+ ok(node.hasAttribute("expanded"), "Parent is expanded");
+ EventUtils.sendKey("LEFT", win);
+ [name, data] = yield event.promise;
+ is(data.length, 3, "Correct level item was selected after second left keypress");
+ is(data[0], "level1", "Correct parent level");
+ is(data[1], "level2", "Correct second level");
+ is(data[2], "level3", "Correct third level");
+ ok(node.hasAttribute("expanded"), "Parent is still expanded after left keypress");
+
+ // pressing down again
+
+ info("Pressing down key to select next item");
+ event = Promise.defer();
+ tree.once("select", pass);
+ EventUtils.sendKey("DOWN", win);
+ [name, data, attachment] = yield event.promise;
+ is(data.length, 2, "Correct level item was selected after fifth down keypress");
+ is(data[0], "level1", "Correct parent level");
+ is(data[1], "level2-1", "Correct second level");
+
+ // collapsing the item to check expand feature.
+
+ tree.root.children.addEventListener("keypress", function onClick() {
+ tree.root.children.removeEventListener("keypress", onClick);
+ executeSoon(() => event.resolve(null));
+ });
+ info("Pressing left key to collapse the item");
+ event = Promise.defer();
+ node = tree._selectedLabel;
+ ok(node.hasAttribute("expanded"), "Item is expanded before left keypress");
+ EventUtils.sendKey("LEFT", win);
+ yield event.promise;
+ ok(!node.hasAttribute("expanded"), "Item is collapsed after left keypress");
+
+ // pressing right should expand this now.
+
+ tree.root.children.addEventListener("keypress", function onClick() {
+ tree.root.children.removeEventListener("keypress", onClick);
+ executeSoon(() => event.resolve(null));
+ });
+ info("Pressing right key to expend the collapsed item");
+ event = Promise.defer();
+ node = tree._selectedLabel;
+ ok(!node.hasAttribute("expanded"), "Item is collapsed before right keypress");
+ EventUtils.sendKey("RIGHT", win);
+ yield event.promise;
+ ok(node.hasAttribute("expanded"), "Item is expanded after right keypress");
+
+ // selecting last item node to test edge navigation case
+
+ tree.selectedItem = ["level1.1", "level2", "level3"];
+ node = tree._selectedLabel;
+ // pressing down again should not change selection
+ event = Promise.defer();
+ tree.root.children.addEventListener("keypress", function onClick() {
+ tree.root.children.removeEventListener("keypress", onClick);
+ executeSoon(() => event.resolve(null));
+ });
+ info("Pressing down key on last item of the tree");
+ EventUtils.sendKey("DOWN", win);
+ yield event.promise;
+
+ ok(tree.isSelected(["level1.1", "level2", "level3"]),
+ "Last item is still selected after pressing down on last item of the tree");
+}
diff --git a/toolkit/devtools/shared/test/browser_treeWidget_mouse_interaction.js b/toolkit/devtools/shared/test/browser_treeWidget_mouse_interaction.js
new file mode 100644
index 000000000..cf8829489
--- /dev/null
+++ b/toolkit/devtools/shared/test/browser_treeWidget_mouse_interaction.js
@@ -0,0 +1,137 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that mouse interaction works fine with tree widget
+
+const TEST_URI = "data:text/html;charset=utf-8,<head><link rel='stylesheet' " +
+ "type='text/css' href='chrome://browser/skin/devtools/common.css'><link " +
+ "rel='stylesheet' type='text/css' href='chrome://browser/skin/devtools/widg" +
+ "ets.css'></head><body><div></div><span></span></body>";
+const {TreeWidget} = devtools.require("devtools/shared/widgets/TreeWidget");
+const {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+
+add_task(function*() {
+ yield promiseTab("about:blank");
+ let [host, win, doc] = yield createHost("bottom", TEST_URI);
+
+ let tree = new TreeWidget(doc.querySelector("div"), {
+ defaultType: "store"
+ });
+
+ populateTree(tree, doc);
+ yield testMouseInteraction(tree);
+
+ tree.destroy();
+ host.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+function populateTree(tree, doc) {
+ tree.add([{
+ id: "level1",
+ label: "Level 1"
+ }, {
+ id: "level2-1",
+ label: "Level 2"
+ }, {
+ id: "level3-1",
+ label: "Level 3 - Child 1",
+ type: "dir"
+ }]);
+ tree.add(["level1", "level2-1", { id: "level3-2", label: "Level 3 - Child 2"}]);
+ tree.add(["level1", "level2-1", { id: "level3-3", label: "Level 3 - Child 3"}]);
+ tree.add(["level1", {
+ id: "level2-2",
+ label: "Level 2.1"
+ }, {
+ id: "level3-1",
+ label: "Level 3.1"
+ }]);
+ tree.add([{
+ id: "level1",
+ label: "Level 1"
+ }, {
+ id: "level2",
+ label: "Level 2"
+ }, {
+ id: "level3",
+ label: "Level 3",
+ type: "js"
+ }]);
+ tree.add(["level1.1", "level2", {id: "level3", type: "url"}]);
+
+ // Adding a new non text item in the tree.
+ let node = doc.createElement("div");
+ node.textContent = "Foo Bar";
+ node.className = "foo bar";
+ tree.add([{
+ id: "level1.2",
+ node: node,
+ attachment: {
+ foo: "bar"
+ }
+ }]);
+}
+
+// Sends a click event on the passed DOM node in an async manner
+function click(node) {
+ let win = node.ownerDocument.defaultView;
+ executeSoon(() => EventUtils.synthesizeMouseAtCenter(node, {}, win));
+}
+
+/**
+ * Tests if clicking the tree items does the expected behavior
+ */
+function* testMouseInteraction(tree) {
+ info("Testing mouse interaction with the tree");
+ let event;
+ let pass = (e, d, a) => event.resolve([e, d, a]);
+
+ ok(!tree.selectedItem, "Nothing should be selected beforehand");
+
+ tree.once("select", pass);
+ let node = tree.root.children.firstChild.firstChild;
+ info("clicking on first top level item");
+ event = Promise.defer();
+ ok(!node.classList.contains("theme-selected"),
+ "Node should not have selected class before clicking");
+ click(node);
+ let [name, data, attachment] = yield event.promise;
+ ok(node.classList.contains("theme-selected"),
+ "Node has selected class after click");
+ is(data[0], "level1.2", "Correct tree path is emitted")
+ ok(attachment && attachment.foo, "Correct attachment is emitted")
+ is(attachment.foo, "bar", "Correct attachment value is emitted");
+
+ info("clicking second top level item with children to check if it expands");
+ let node2 = tree.root.children.firstChild.nextSibling.firstChild;
+ event = Promise.defer();
+ // node should not have selected class
+ ok(!node2.classList.contains("theme-selected"),
+ "New node should not have selected class before clicking");
+ ok(!node2.hasAttribute("expanded"), "New node is not expanded before clicking");
+ tree.once("select", pass);
+ click(node2);
+ [name, data, attachment] = yield event.promise;
+ ok(node2.classList.contains("theme-selected"),
+ "New node has selected class after clicking");
+ is(data[0], "level1", "Correct tree path is emitted for new node")
+ ok(!attachment, "null attachment should be emitted for new node")
+ ok(node2.hasAttribute("expanded"), "New node expanded after click");
+
+ ok(!node.classList.contains("theme-selected"),
+ "Old node should not have selected class after the click on new node");
+
+
+ // clicking again should just collapse
+ // this will not emit "select" event
+ event = Promise.defer();
+ node2.addEventListener("click", function onClick() {
+ node2.removeEventListener("click", onClick);
+ executeSoon(() => event.resolve(null));
+ });
+ click(node2);
+ yield event.promise;
+ ok(!node2.hasAttribute("expanded"), "New node collapsed after click again");
+}
diff --git a/toolkit/devtools/shared/test/doc_options-view.xul b/toolkit/devtools/shared/test/doc_options-view.xul
new file mode 100644
index 000000000..78d9956e9
--- /dev/null
+++ b/toolkit/devtools/shared/test/doc_options-view.xul
@@ -0,0 +1,27 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<?xml-stylesheet href="chrome://browser/skin/" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/common.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/devtools/widgets.css" type="text/css"?>
+<!DOCTYPE window []>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <popupset id="options-popupset">
+ <menupopup id="options-menupopup" position="before_end">
+ <menuitem id="option-autoprettyprint"
+ type="checkbox"
+ data-pref="auto-pretty-print"
+ label="pretty print"/>
+ <menuitem id="option-autoblackbox"
+ type="checkbox"
+ data-pref="auto-black-box"
+ label="black box"/>
+ </menupopup>
+ </popupset>
+ <button id="options-button"
+ popup="options-menupopup"/>
+</window>
diff --git a/toolkit/devtools/shared/test/head.js b/toolkit/devtools/shared/test/head.js
new file mode 100644
index 000000000..121f75f69
--- /dev/null
+++ b/toolkit/devtools/shared/test/head.js
@@ -0,0 +1,241 @@
+/* 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/. */
+
+let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+let {TargetFactory, require} = devtools;
+let {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
+let {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
+const {DOMHelpers} = Cu.import("resource:///modules/devtools/DOMHelpers.jsm", {});
+const {Hosts} = require("devtools/framework/toolbox-hosts");
+
+gDevTools.testing = true;
+SimpleTest.registerCleanupFunction(() => {
+ gDevTools.testing = false;
+});
+
+const TEST_URI_ROOT = "http://example.com/browser/browser/devtools/shared/test/";
+const OPTIONS_VIEW_URL = TEST_URI_ROOT + "doc_options-view.xul";
+
+/**
+ * Open a new tab at a URL and call a callback on load
+ */
+function addTab(aURL, aCallback)
+{
+ waitForExplicitFinish();
+
+ gBrowser.selectedTab = gBrowser.addTab();
+ content.location = aURL;
+
+ let tab = gBrowser.selectedTab;
+ let browser = gBrowser.getBrowserForTab(tab);
+
+ function onTabLoad() {
+ browser.removeEventListener("load", onTabLoad, true);
+ aCallback(browser, tab, browser.contentDocument);
+ }
+
+ browser.addEventListener("load", onTabLoad, true);
+}
+
+function promiseTab(aURL) {
+ return new Promise(resolve =>
+ addTab(aURL, resolve));
+}
+
+registerCleanupFunction(function tearDown() {
+ while (gBrowser.tabs.length > 1) {
+ gBrowser.removeCurrentTab();
+ }
+
+ console = undefined;
+});
+
+function catchFail(func) {
+ return function() {
+ try {
+ return func.apply(null, arguments);
+ }
+ catch (ex) {
+ ok(false, ex);
+ console.error(ex);
+ finish();
+ throw ex;
+ }
+ };
+}
+
+/**
+ * Polls a given function waiting for the given value.
+ *
+ * @param object aOptions
+ * Options object with the following properties:
+ * - validator
+ * A validator function that should return the expected value. This is
+ * called every few milliseconds to check if the result is the expected
+ * one. When the returned result is the expected one, then the |success|
+ * function is called and polling stops. If |validator| never returns
+ * the expected value, then polling timeouts after several tries and
+ * a failure is recorded - the given |failure| function is invoked.
+ * - success
+ * A function called when the validator function returns the expected
+ * value.
+ * - failure
+ * A function called if the validator function timeouts - fails to return
+ * the expected value in the given time.
+ * - name
+ * Name of test. This is used to generate the success and failure
+ * messages.
+ * - timeout
+ * Timeout for validator function, in milliseconds. Default is 5000 ms.
+ * - value
+ * The expected value. If this option is omitted then the |validator|
+ * function must return a trueish value.
+ * Each of the provided callback functions will receive two arguments:
+ * the |aOptions| object and the last value returned by |validator|.
+ */
+function waitForValue(aOptions)
+{
+ let start = Date.now();
+ let timeout = aOptions.timeout || 5000;
+ let lastValue;
+
+ function wait(validatorFn, successFn, failureFn)
+ {
+ if ((Date.now() - start) > timeout) {
+ // Log the failure.
+ ok(false, "Timed out while waiting for: " + aOptions.name);
+ let expected = "value" in aOptions ?
+ "'" + aOptions.value + "'" :
+ "a trueish value";
+ info("timeout info :: got '" + lastValue + "', expected " + expected);
+ failureFn(aOptions, lastValue);
+ return;
+ }
+
+ lastValue = validatorFn(aOptions, lastValue);
+ let successful = "value" in aOptions ?
+ lastValue == aOptions.value :
+ lastValue;
+ if (successful) {
+ ok(true, aOptions.name);
+ successFn(aOptions, lastValue);
+ }
+ else {
+ setTimeout(function() wait(validatorFn, successFn, failureFn), 100);
+ }
+ }
+
+ wait(aOptions.validator, aOptions.success, aOptions.failure);
+}
+
+function oneTimeObserve(name, callback) {
+ var func = function() {
+ Services.obs.removeObserver(func, name);
+ callback();
+ };
+ Services.obs.addObserver(func, name, false);
+}
+
+let createHost = Task.async(function*(type = "bottom", src = "data:text/html;charset=utf-8,") {
+ let host = new Hosts[type](gBrowser.selectedTab);
+ let iframe = yield host.create();
+
+ yield new Promise(resolve => {
+ let domHelper = new DOMHelpers(iframe.contentWindow);
+ iframe.setAttribute("src", src);
+ domHelper.onceDOMReady(resolve);
+ });
+
+ return [host, iframe.contentWindow, iframe.contentDocument];
+});
+
+/**
+ * Load the Telemetry utils, then stub Telemetry.prototype.log in order to
+ * record everything that's logged in it.
+ * Store all recordings on Telemetry.telemetryInfo.
+ * @return {Telemetry}
+ */
+function loadTelemetryAndRecordLogs() {
+ info("Mock the Telemetry log function to record logged information");
+
+ let Telemetry = require("devtools/shared/telemetry");
+ Telemetry.prototype.telemetryInfo = {};
+ Telemetry.prototype._oldlog = Telemetry.prototype.log;
+ Telemetry.prototype.log = function(histogramId, value) {
+ if (!this.telemetryInfo) {
+ // Can be removed when Bug 992911 lands (see Bug 1011652 Comment 10)
+ return;
+ }
+ if (histogramId) {
+ if (!this.telemetryInfo[histogramId]) {
+ this.telemetryInfo[histogramId] = [];
+ }
+
+ this.telemetryInfo[histogramId].push(value);
+ }
+ };
+
+ return Telemetry;
+}
+
+/**
+ * Stop recording the Telemetry logs and put back the utils as it was before.
+ */
+function stopRecordingTelemetryLogs(Telemetry) {
+ Telemetry.prototype.log = Telemetry.prototype._oldlog;
+ delete Telemetry.prototype._oldlog;
+ delete Telemetry.prototype.telemetryInfo;
+}
+
+/**
+ * Check the correctness of the data recorded in Telemetry after
+ * loadTelemetryAndRecordLogs was called.
+ */
+function checkTelemetryResults(Telemetry) {
+ let result = Telemetry.prototype.telemetryInfo;
+
+ for (let [histId, value] of Iterator(result)) {
+ if (histId.endsWith("OPENED_PER_USER_FLAG")) {
+ ok(value.length === 1 && value[0] === true,
+ "Per user value " + histId + " has a single value of true");
+ } else if (histId.endsWith("OPENED_BOOLEAN")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element === true;
+ });
+
+ ok(okay, "All " + histId + " entries are === true");
+ } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) {
+ ok(value.length > 1, histId + " has more than one entry");
+
+ let okay = value.every(function(element) {
+ return element > 0;
+ });
+
+ ok(okay, "All " + histId + " entries have time > 0");
+ }
+ }
+}
+
+/**
+ * Open and close the toolbox in the current browser tab, several times, waiting
+ * some amount of time in between.
+ * @param {Number} nbOfTimes
+ * @param {Number} usageTime in milliseconds
+ * @param {String} toolId
+ */
+function* openAndCloseToolbox(nbOfTimes, usageTime, toolId) {
+ for (let i = 0; i < nbOfTimes; i ++) {
+ info("Opening toolbox " + (i + 1));
+ let target = TargetFactory.forTab(gBrowser.selectedTab);
+ yield gDevTools.showToolbox(target, toolId)
+
+ // We use a timeout to check the toolbox's active time
+ yield new Promise(resolve => setTimeout(resolve, usageTime));
+
+ info("Closing toolbox " + (i + 1));
+ yield gDevTools.closeToolbox(target);
+ }
+}
diff --git a/toolkit/devtools/shared/test/leakhunt.js b/toolkit/devtools/shared/test/leakhunt.js
new file mode 100644
index 000000000..66d067a3c
--- /dev/null
+++ b/toolkit/devtools/shared/test/leakhunt.js
@@ -0,0 +1,170 @@
+/* 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/. */
+
+/**
+ * Memory leak hunter. Walks a tree of objects looking for DOM nodes.
+ * Usage:
+ * leakHunt({
+ * thing: thing,
+ * otherthing: otherthing
+ * });
+ */
+function leakHunt(root) {
+ var path = [];
+ var seen = [];
+
+ try {
+ var output = leakHunt.inner(root, path, seen);
+ output.forEach(function(line) {
+ dump(line + '\n');
+ });
+ }
+ catch (ex) {
+ dump(ex + '\n');
+ }
+}
+
+leakHunt.inner = function LH_inner(root, path, seen) {
+ var prefix = new Array(path.length).join(' ');
+
+ var reply = [];
+ function log(msg) {
+ reply.push(msg);
+ }
+
+ var direct
+ try {
+ direct = Object.keys(root);
+ }
+ catch (ex) {
+ log(prefix + ' Error enumerating: ' + ex);
+ return reply;
+ }
+
+ try {
+ var index = 0;
+ for (var data of root) {
+ var prop = '' + index;
+ leakHunt.digProperty(prop, data, path, seen, direct, log);
+ index++;
+ }
+ }
+ catch (ex) { /* Ignore things that are not enumerable */ }
+
+ for (var prop in root) {
+ var data;
+ try {
+ data = root[prop];
+ }
+ catch (ex) {
+ log(prefix + ' ' + prop + ' = Error: ' + ex.toString().substring(0, 30));
+ continue;
+ }
+
+ leakHunt.digProperty(prop, data, path, seen, direct, log);
+ }
+
+ return reply;
+}
+
+leakHunt.hide = [ /^string$/, /^number$/, /^boolean$/, /^null/, /^undefined/ ];
+
+leakHunt.noRecurse = [
+ /^string$/, /^number$/, /^boolean$/, /^null/, /^undefined/,
+ /^Window$/, /^Document$/,
+ /^XULDocument$/, /^XULElement$/,
+ /^DOMWindow$/, /^HTMLDocument$/, /^HTML.*Element$/, /^ChromeWindow$/
+];
+
+leakHunt.digProperty = function LH_digProperty(prop, data, path, seen, direct, log) {
+ var newPath = path.slice();
+ newPath.push(prop);
+ var prefix = new Array(newPath.length).join(' ');
+
+ var recurse = true;
+ var message = leakHunt.getType(data);
+
+ if (leakHunt.matchesAnyPattern(message, leakHunt.hide)) {
+ return;
+ }
+
+ if (message === 'function' && direct.indexOf(prop) == -1) {
+ return;
+ }
+
+ if (message === 'string') {
+ var extra = data.length > 10 ? data.substring(0, 9) + '_' : data;
+ message += ' "' + extra.replace(/\n/g, "|") + '"';
+ recurse = false;
+ }
+ else if (leakHunt.matchesAnyPattern(message, leakHunt.noRecurse)) {
+ message += ' (no recurse)'
+ recurse = false;
+ }
+ else if (seen.indexOf(data) !== -1) {
+ message += ' (already seen)';
+ recurse = false;
+ }
+
+ if (recurse) {
+ seen.push(data);
+ var lines = leakHunt.inner(data, newPath, seen);
+ if (lines.length == 0) {
+ if (message !== 'function') {
+ log(prefix + prop + ' = ' + message + ' { }');
+ }
+ }
+ else {
+ log(prefix + prop + ' = ' + message + ' {');
+ lines.forEach(function(line) {
+ log(line);
+ });
+ log(prefix + '}');
+ }
+ }
+ else {
+ log(prefix + prop + ' = ' + message);
+ }
+};
+
+leakHunt.matchesAnyPattern = function LH_matchesAnyPattern(str, patterns) {
+ var match = false;
+ patterns.forEach(function(pattern) {
+ if (str.match(pattern)) {
+ match = true;
+ }
+ });
+ return match;
+};
+
+leakHunt.getType = function LH_getType(data) {
+ if (data === null) {
+ return 'null';
+ }
+ if (data === undefined) {
+ return 'undefined';
+ }
+
+ var type = typeof data;
+ if (type === 'object' || type === 'Object') {
+ type = leakHunt.getCtorName(data);
+ }
+
+ return type;
+};
+
+leakHunt.getCtorName = function LH_getCtorName(aObj) {
+ try {
+ if (aObj.constructor && aObj.constructor.name) {
+ return aObj.constructor.name;
+ }
+ }
+ catch (ex) {
+ return 'UnknownObject';
+ }
+
+ // If that fails, use Objects toString which sometimes gives something
+ // better than 'Object', and at least defaults to Object if nothing better
+ return Object.prototype.toString.call(aObj).slice(8, -1);
+};
diff --git a/toolkit/devtools/shared/test/unit/test_VariablesView_getString_promise.js b/toolkit/devtools/shared/test/unit/test_VariablesView_getString_promise.js
new file mode 100644
index 000000000..3c42e3ffb
--- /dev/null
+++ b/toolkit/devtools/shared/test/unit/test_VariablesView_getString_promise.js
@@ -0,0 +1,75 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const Cu = Components.utils;
+const { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+const { VariablesView } = Cu.import("resource:///modules/devtools/VariablesView.jsm", {});
+
+const PENDING = {
+ "type": "object",
+ "class": "Promise",
+ "actor": "conn0.pausedobj35",
+ "extensible": true,
+ "frozen": false,
+ "sealed": false,
+ "promiseState": {
+ "state": "pending"
+ },
+ "preview": {
+ "kind": "Object",
+ "ownProperties": {},
+ "ownPropertiesLength": 0,
+ "safeGetterValues": {}
+ }
+};
+
+const FULFILLED = {
+ "type": "object",
+ "class": "Promise",
+ "actor": "conn0.pausedobj35",
+ "extensible": true,
+ "frozen": false,
+ "sealed": false,
+ "promiseState": {
+ "state": "fulfilled",
+ "value": 10
+ },
+ "preview": {
+ "kind": "Object",
+ "ownProperties": {},
+ "ownPropertiesLength": 0,
+ "safeGetterValues": {}
+ }
+};
+
+const REJECTED = {
+ "type": "object",
+ "class": "Promise",
+ "actor": "conn0.pausedobj35",
+ "extensible": true,
+ "frozen": false,
+ "sealed": false,
+ "promiseState": {
+ "state": "rejected",
+ "reason": 10
+ },
+ "preview": {
+ "kind": "Object",
+ "ownProperties": {},
+ "ownPropertiesLength": 0,
+ "safeGetterValues": {}
+ }
+};
+
+function run_test() {
+ equal(VariablesView.getString(PENDING, { concise: true }), "Promise");
+ equal(VariablesView.getString(PENDING), 'Promise {<state>: "pending"}');
+
+ equal(VariablesView.getString(FULFILLED, { concise: true }), "Promise");
+ equal(VariablesView.getString(FULFILLED), 'Promise {<state>: "fulfilled", <value>: 10}');
+
+ equal(VariablesView.getString(REJECTED, { concise: true }), "Promise");
+ equal(VariablesView.getString(REJECTED), 'Promise {<state>: "rejected", <reason>: 10}');
+}
diff --git a/toolkit/devtools/shared/test/unit/test_bezierCanvas.js b/toolkit/devtools/shared/test/unit/test_bezierCanvas.js
new file mode 100644
index 000000000..21ffad1f0
--- /dev/null
+++ b/toolkit/devtools/shared/test/unit/test_bezierCanvas.js
@@ -0,0 +1,113 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests the BezierCanvas API in the CubicBezierWidget module
+
+const Cu = Components.utils;
+let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+let require = devtools.require;
+let {CubicBezier, BezierCanvas} = require("devtools/shared/widgets/CubicBezierWidget");
+
+function run_test() {
+ offsetsGetterReturnsData();
+ convertsOffsetsToCoordinates();
+ plotsCanvas();
+}
+
+function offsetsGetterReturnsData() {
+ do_print("offsets getter returns an array of 2 offset objects");
+
+ let b = new BezierCanvas(getCanvasMock(), getCubicBezier(), [.25, 0]);
+ let offsets = b.offsets;
+
+ do_check_eq(offsets.length, 2);
+
+ do_check_true("top" in offsets[0]);
+ do_check_true("left" in offsets[0]);
+ do_check_true("top" in offsets[1]);
+ do_check_true("left" in offsets[1]);
+
+ do_check_eq(offsets[0].top, "300px");
+ do_check_eq(offsets[0].left, "0px");
+ do_check_eq(offsets[1].top, "100px");
+ do_check_eq(offsets[1].left, "200px");
+
+ do_print("offsets getter returns data according to current padding");
+
+ b = new BezierCanvas(getCanvasMock(), getCubicBezier(), [0, 0]);
+ offsets = b.offsets;
+
+ do_check_eq(offsets[0].top, "400px");
+ do_check_eq(offsets[0].left, "0px");
+ do_check_eq(offsets[1].top, "0px");
+ do_check_eq(offsets[1].left, "200px");
+}
+
+function convertsOffsetsToCoordinates() {
+ do_print("Converts offsets to coordinates");
+
+ let b = new BezierCanvas(getCanvasMock(), getCubicBezier(), [.25, 0]);
+
+ let coordinates = b.offsetsToCoordinates({style: {
+ left: "0px",
+ top: "0px"
+ }});
+ do_check_eq(coordinates.length, 2);
+ do_check_eq(coordinates[0], 0);
+ do_check_eq(coordinates[1], 1.5);
+
+ coordinates = b.offsetsToCoordinates({style: {
+ left: "0px",
+ top: "300px"
+ }});
+ do_check_eq(coordinates[0], 0);
+ do_check_eq(coordinates[1], 0);
+
+ coordinates = b.offsetsToCoordinates({style: {
+ left: "200px",
+ top: "100px"
+ }});
+ do_check_eq(coordinates[0], 1);
+ do_check_eq(coordinates[1], 1);
+}
+
+function plotsCanvas() {
+ do_print("Plots the curve to the canvas");
+
+ let hasDrawnCurve = false;
+ let b = new BezierCanvas(getCanvasMock(), getCubicBezier(), [.25, 0]);
+ b.ctx.bezierCurveTo = () => hasDrawnCurve = true;
+ b.plot();
+
+ do_check_true(hasDrawnCurve);
+}
+
+function getCubicBezier() {
+ return new CubicBezier([0,0,1,1]);
+}
+
+function getCanvasMock(w=200, h=400) {
+ return {
+ getContext: function() {
+ return {
+ scale: () => {},
+ translate: () => {},
+ clearRect: () => {},
+ beginPath: () => {},
+ closePath: () => {},
+ moveTo: () => {},
+ lineTo: () => {},
+ stroke: () => {},
+ arc: () => {},
+ fill: () => {},
+ bezierCurveTo: () => {}
+ };
+ },
+ width: w,
+ height: h
+ };
+}
diff --git a/toolkit/devtools/shared/test/unit/test_cubicBezier.js b/toolkit/devtools/shared/test/unit/test_cubicBezier.js
new file mode 100644
index 000000000..b8a6231b2
--- /dev/null
+++ b/toolkit/devtools/shared/test/unit/test_cubicBezier.js
@@ -0,0 +1,102 @@
+/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests the CubicBezier API in the CubicBezierWidget module
+
+const Cu = Components.utils;
+let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+let require = devtools.require;
+let {CubicBezier} = require("devtools/shared/widgets/CubicBezierWidget");
+
+function run_test() {
+ throwsWhenMissingCoordinates();
+ throwsWhenIncorrectCoordinates();
+ convertsStringCoordinates();
+ coordinatesToStringOutputsAString();
+ pointGettersReturnPointCoordinatesArrays();
+ toStringOutputsCubicBezierValue();
+}
+
+function throwsWhenMissingCoordinates() {
+ do_check_throws(() => {
+ new CubicBezier();
+ }, "Throws an exception when coordinates are missing");
+}
+
+function throwsWhenIncorrectCoordinates() {
+ do_check_throws(() => {
+ new CubicBezier([]);
+ }, "Throws an exception when coordinates are incorrect (empty array)");
+
+ do_check_throws(() => {
+ new CubicBezier([0,0]);
+ }, "Throws an exception when coordinates are incorrect (incomplete array)");
+
+ do_check_throws(() => {
+ new CubicBezier(["a", "b", "c", "d"]);
+ }, "Throws an exception when coordinates are incorrect (invalid type)");
+
+ do_check_throws(() => {
+ new CubicBezier([1.5, 0, 1.5, 0]);
+ }, "Throws an exception when coordinates are incorrect (time range invalid)");
+
+ do_check_throws(() => {
+ new CubicBezier([-0.5, 0, -0.5, 0]);
+ }, "Throws an exception when coordinates are incorrect (time range invalid)");
+}
+
+function convertsStringCoordinates() {
+ do_print("Converts string coordinates to numbers");
+ let c = new CubicBezier(["0", "1", ".5", "-2"]);
+
+ do_check_eq(c.coordinates[0], 0);
+ do_check_eq(c.coordinates[1], 1);
+ do_check_eq(c.coordinates[2], .5);
+ do_check_eq(c.coordinates[3], -2);
+}
+
+function coordinatesToStringOutputsAString() {
+ do_print("coordinates.toString() outputs a string representation");
+
+ let c = new CubicBezier(["0", "1", "0.5", "-2"]);
+ let string = c.coordinates.toString();
+ do_check_eq(string, "0,1,.5,-2");
+
+ c = new CubicBezier([1, 1, 1, 1]);
+ string = c.coordinates.toString();
+ do_check_eq(string, "1,1,1,1");
+}
+
+function pointGettersReturnPointCoordinatesArrays() {
+ do_print("Points getters return arrays of coordinates");
+
+ let c = new CubicBezier([0, .2, .5, 1]);
+ do_check_eq(c.P1[0], 0);
+ do_check_eq(c.P1[1], .2);
+ do_check_eq(c.P2[0], .5);
+ do_check_eq(c.P2[1], 1);
+}
+
+function toStringOutputsCubicBezierValue() {
+ do_print("toString() outputs the cubic-bezier() value");
+
+ let c = new CubicBezier([0, 0, 1, 1]);
+ do_check_eq(c.toString(), "cubic-bezier(0,0,1,1)");
+}
+
+function do_check_throws(cb, info) {
+ do_print(info);
+
+ let hasThrown = false;
+ try {
+ cb();
+ } catch (e) {
+ hasThrown = true;
+ }
+
+ do_check_true(hasThrown);
+}
diff --git a/toolkit/devtools/shared/test/unit/test_undoStack.js b/toolkit/devtools/shared/test/unit/test_undoStack.js
new file mode 100644
index 000000000..ffc0666f9
--- /dev/null
+++ b/toolkit/devtools/shared/test/unit/test_undoStack.js
@@ -0,0 +1,98 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const Cu = Components.utils;
+let {Loader} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
+
+let loader = new Loader.Loader({
+ paths: {
+ "": "resource://gre/modules/commonjs/",
+ "devtools": "resource:///modules/devtools",
+ },
+ globals: {},
+});
+let require = Loader.Require(loader, { id: "undo-test" })
+
+let {UndoStack} = require("devtools/shared/undo");
+
+const MAX_SIZE = 5;
+
+function run_test()
+{
+ let str = "";
+ let stack = new UndoStack(MAX_SIZE);
+
+ function add(ch) {
+ stack.do(function() {
+ str += ch;
+ }, function() {
+ str = str.slice(0, -1);
+ });
+ }
+
+ do_check_false(stack.canUndo());
+ do_check_false(stack.canRedo());
+
+ // Check adding up to the limit of the size
+ add("a");
+ do_check_true(stack.canUndo());
+ do_check_false(stack.canRedo());
+
+ add("b");
+ add("c");
+ add("d");
+ add("e");
+
+ do_check_eq(str, "abcde");
+
+ // Check a simple undo+redo
+ stack.undo();
+
+ do_check_eq(str, "abcd");
+ do_check_true(stack.canRedo());
+
+ stack.redo();
+ do_check_eq(str, "abcde")
+ do_check_false(stack.canRedo());
+
+ // Check an undo followed by a new action
+ stack.undo();
+ do_check_eq(str, "abcd");
+
+ add("q");
+ do_check_eq(str, "abcdq");
+ do_check_false(stack.canRedo());
+
+ stack.undo();
+ do_check_eq(str, "abcd");
+ stack.redo();
+ do_check_eq(str, "abcdq");
+
+ // Revert back to the beginning of the queue...
+ while (stack.canUndo()) {
+ stack.undo();
+ }
+ do_check_eq(str, "");
+
+ // Now put it all back....
+ while (stack.canRedo()) {
+ stack.redo();
+ }
+ do_check_eq(str, "abcdq");
+
+ // Now go over the undo limit...
+ add("1");
+ add("2");
+ add("3");
+
+ do_check_eq(str, "abcdq123");
+
+ // And now undoing the whole stack should only undo 5 actions.
+ while (stack.canUndo()) {
+ stack.undo();
+ }
+
+ do_check_eq(str, "abc");
+}
diff --git a/toolkit/devtools/shared/test/unit/xpcshell.ini b/toolkit/devtools/shared/test/unit/xpcshell.ini
new file mode 100644
index 000000000..b7b9b8a1b
--- /dev/null
+++ b/toolkit/devtools/shared/test/unit/xpcshell.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+head =
+tail =
+firefox-appdir = browser
+skip-if = toolkit == 'android' || toolkit == 'gonk'
+
+[test_bezierCanvas.js]
+[test_cubicBezier.js]
+[test_undoStack.js]
+[test_VariablesView_getString_promise.js] \ No newline at end of file
diff --git a/toolkit/devtools/shared/theme-switching.js b/toolkit/devtools/shared/theme-switching.js
new file mode 100644
index 000000000..0407a42f8
--- /dev/null
+++ b/toolkit/devtools/shared/theme-switching.js
@@ -0,0 +1,120 @@
+/* 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/. */
+
+(function() {
+ const DEVTOOLS_SKIN_URL = "chrome://browser/skin/devtools/";
+ let documentElement = document.documentElement;
+
+ function forceStyle() {
+ let computedStyle = window.getComputedStyle(documentElement);
+ if (!computedStyle) {
+ // Null when documentElement is not ready. This method is anyways not
+ // required then as scrollbars would be in their state without flushing.
+ return;
+ }
+ let display = computedStyle.display; // Save display value
+ documentElement.style.display = "none";
+ window.getComputedStyle(documentElement).display; // Flush
+ documentElement.style.display = display; // Restore
+ }
+
+ function switchTheme(newTheme, oldTheme) {
+ if (newTheme === oldTheme) {
+ return;
+ }
+
+ let oldThemeDef = gDevTools.getThemeDefinition(oldTheme);
+
+ // Unload all theme stylesheets related to the old theme.
+ if (oldThemeDef) {
+ for (let url of oldThemeDef.stylesheets) {
+ StylesheetUtils.removeSheet(window, url, "author");
+ }
+ }
+
+ // Load all stylesheets associated with the new theme.
+ let newThemeDef = gDevTools.getThemeDefinition(newTheme);
+
+ // The theme might not be available anymore (e.g. uninstalled)
+ // Use the default one.
+ if (!newThemeDef) {
+ newThemeDef = gDevTools.getThemeDefinition("light");
+ }
+
+ for (let url of newThemeDef.stylesheets) {
+ StylesheetUtils.loadSheet(window, url, "author");
+ }
+
+ // Floating scroll-bars like in OSX
+ let hiddenDOMWindow = Cc["@mozilla.org/appshell/appShellService;1"]
+ .getService(Ci.nsIAppShellService)
+ .hiddenDOMWindow;
+
+ // TODO: extensions might want to customize scrollbar styles too.
+ if (!hiddenDOMWindow.matchMedia("(-moz-overlay-scrollbars)").matches) {
+ let scrollbarsUrl = Services.io.newURI(
+ DEVTOOLS_SKIN_URL + "floating-scrollbars-light.css", null, null);
+
+ if (newTheme == "dark") {
+ StylesheetUtils.loadSheet(
+ window,
+ scrollbarsUrl,
+ "agent"
+ );
+ } else if (oldTheme == "dark") {
+ StylesheetUtils.removeSheet(
+ window,
+ scrollbarsUrl,
+ "agent"
+ );
+ }
+ forceStyle();
+ }
+
+ if (oldThemeDef) {
+ for (let name of oldThemeDef.classList) {
+ documentElement.classList.remove(name);
+ }
+
+ if (oldThemeDef.onUnapply) {
+ oldThemeDef.onUnapply(window, newTheme);
+ }
+ }
+
+ for (let name of newThemeDef.classList) {
+ documentElement.classList.add(name);
+ }
+
+ if (newThemeDef.onApply) {
+ newThemeDef.onApply(window, oldTheme);
+ }
+
+ // Final notification for further theme-switching related logic.
+ gDevTools.emit("theme-switched", window, newTheme, oldTheme);
+ }
+
+ function handlePrefChange(event, data) {
+ if (data.pref == "devtools.theme") {
+ switchTheme(data.newValue, data.oldValue);
+ }
+ }
+
+ const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+ Cu.import("resource://gre/modules/Services.jsm");
+ Cu.import("resource:///modules/devtools/gDevTools.jsm");
+ const {devtools} = Components.utils.import("resource://gre/modules/devtools/Loader.jsm", {});
+ const StylesheetUtils = devtools.require("sdk/stylesheet/utils");
+
+ if (documentElement.hasAttribute("force-theme")) {
+ switchTheme(documentElement.getAttribute("force-theme"));
+ } else {
+ switchTheme(Services.prefs.getCharPref("devtools.theme"));
+
+ gDevTools.on("pref-changed", handlePrefChange);
+ window.addEventListener("unload", function() {
+ gDevTools.off("pref-changed", handlePrefChange);
+ });
+ }
+})();
diff --git a/toolkit/devtools/shared/theme.js b/toolkit/devtools/shared/theme.js
new file mode 100644
index 000000000..a65b75b62
--- /dev/null
+++ b/toolkit/devtools/shared/theme.js
@@ -0,0 +1,90 @@
+/* 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";
+
+/**
+ * Colors for themes taken from:
+ * https://developer.mozilla.org/en-US/docs/Tools/DevToolsColors
+ */
+
+const { Cu } = require("chrome");
+const { NetUtil } = Cu.import("resource://gre/modules/NetUtil.jsm", {});
+loader.lazyRequireGetter(this, "Services");
+loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm");
+
+const themeURIs = {
+ light: "chrome://browser/skin/devtools/light-theme.css",
+ dark: "chrome://browser/skin/devtools/dark-theme.css"
+}
+
+const cachedThemes = {};
+
+/**
+ * Returns a string of the file found at URI
+ */
+function readURI (uri) {
+ let stream = NetUtil.newChannel(uri, "UTF-8", null).open();
+ let count = stream.available();
+ let data = NetUtil.readInputStreamToString(stream, count, { charset: "UTF-8" });
+ stream.close();
+ return data;
+}
+
+/**
+ * Takes a theme name and either returns it from the cache,
+ * or fetches the theme CSS file and caches it.
+ */
+function getThemeFile (name) {
+ // Use the cached theme, or generate it
+ let themeFile = cachedThemes[name] || readURI(themeURIs[name]).match(/--theme-.*: .*;/g).join("\n");
+
+ // Cache if not already cached
+ if (!cachedThemes[name]) {
+ cachedThemes[name] = themeFile;
+ }
+
+ return themeFile;
+}
+
+/**
+ * Returns the string value of the current theme,
+ * like "dark" or "light".
+ */
+const getTheme = exports.getTheme = () => Services.prefs.getCharPref("devtools.theme");
+
+/**
+ * Returns a color indicated by `type` (like "toolbar-background", or "highlight-red"),
+ * with the ability to specify a theme, or use whatever the current theme is
+ * if left unset. If theme not found, falls back to "light" theme. Returns null
+ * if the type cannot be found for the theme given.
+ */
+const getColor = exports.getColor = (type, theme) => {
+ let themeName = theme || getTheme();
+
+ // If there's no theme URIs for this theme, use `light` as default.
+ if (!themeURIs[themeName]) {
+ themeName = "light";
+ }
+
+ let themeFile = getThemeFile(themeName);
+ let match;
+
+ // Return the appropriate variable in the theme, or otherwise, null.
+ return (match = themeFile.match(new RegExp("--theme-" + type + ": (.*);"))) ? match[1] : null;
+};
+
+/**
+ * Mimics selecting the theme selector in the toolbox;
+ * sets the preference and emits an event on gDevTools to trigger
+ * the themeing.
+ */
+const setTheme = exports.setTheme = (newTheme) => {
+ Services.prefs.setCharPref("devtools.theme", newTheme);
+ gDevTools.emit("pref-changed", {
+ pref: "devtools.theme",
+ newValue: newTheme,
+ oldValue: getTheme()
+ });
+};
diff --git a/toolkit/devtools/shared/timeline/global.js b/toolkit/devtools/shared/timeline/global.js
new file mode 100644
index 000000000..913a37afe
--- /dev/null
+++ b/toolkit/devtools/shared/timeline/global.js
@@ -0,0 +1,69 @@
+/* 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, Cr} = require("chrome");
+
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+/**
+ * Localization convenience methods.
+ */
+const STRINGS_URI = "chrome://browser/locale/devtools/timeline.properties";
+const L10N = new ViewHelpers.L10N(STRINGS_URI);
+
+/**
+ * A simple schema for mapping markers to the timeline UI. The keys correspond
+ * to marker names, while the values are objects with the following format:
+ * - group: the row index in the timeline overview graph; multiple markers
+ * can be added on the same row. @see <overview.js/buildGraphImage>
+ * - fill: a fill color used when drawing the marker
+ * - stroke: a stroke color used when drawing the marker
+ * - label: the label used in the waterfall to identify the marker
+ *
+ * Whenever this is changed, browser_timeline_waterfall-styles.js *must* be
+ * updated as well.
+ */
+const TIMELINE_BLUEPRINT = {
+ "Styles": {
+ group: 0,
+ fill: "hsl(285,50%,68%)",
+ stroke: "hsl(285,50%,48%)",
+ label: L10N.getStr("timeline.label.styles2")
+ },
+ "Reflow": {
+ group: 0,
+ fill: "hsl(285,50%,68%)",
+ stroke: "hsl(285,50%,48%)",
+ label: L10N.getStr("timeline.label.reflow2")
+ },
+ "Paint": {
+ group: 0,
+ fill: "hsl(104,57%,71%)",
+ stroke: "hsl(104,57%,51%)",
+ label: L10N.getStr("timeline.label.paint")
+ },
+ "DOMEvent": {
+ group: 1,
+ fill: "hsl(39,82%,69%)",
+ stroke: "hsl(39,82%,49%)",
+ label: L10N.getStr("timeline.label.domevent")
+ },
+ "Javascript": {
+ group: 1,
+ fill: "hsl(39,82%,69%)",
+ stroke: "hsl(39,82%,49%)",
+ label: L10N.getStr("timeline.label.javascript2")
+ },
+ "ConsoleTime": {
+ group: 2,
+ fill: "hsl(0,0%,80%)",
+ stroke: "hsl(0,0%,60%)",
+ label: L10N.getStr("timeline.label.consoleTime")
+ },
+};
+
+// Exported symbols.
+exports.L10N = L10N;
+exports.TIMELINE_BLUEPRINT = TIMELINE_BLUEPRINT;
diff --git a/toolkit/devtools/shared/timeline/marker-details.js b/toolkit/devtools/shared/timeline/marker-details.js
new file mode 100644
index 000000000..8fbdc9f23
--- /dev/null
+++ b/toolkit/devtools/shared/timeline/marker-details.js
@@ -0,0 +1,301 @@
+/* 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";
+
+let { Ci } = require("chrome");
+let WebConsoleUtils = require("devtools/toolkit/webconsole/utils").Utils;
+
+/**
+ * This file contains the rendering code for the marker sidebar.
+ */
+
+loader.lazyRequireGetter(this, "L10N",
+ "devtools/shared/timeline/global", true);
+loader.lazyRequireGetter(this, "TIMELINE_BLUEPRINT",
+ "devtools/shared/timeline/global", true);
+loader.lazyRequireGetter(this, "EventEmitter",
+ "devtools/toolkit/event-emitter");
+
+/**
+ * A detailed view for one single marker.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the view.
+ * @param nsIDOMNode splitter
+ * The splitter node that the resize event is bound to.
+ */
+function MarkerDetails(parent, splitter) {
+ EventEmitter.decorate(this);
+ this._document = parent.ownerDocument;
+ this._parent = parent;
+ this._splitter = splitter;
+ this._splitter.addEventListener("mouseup", () => this.emit("resize"));
+}
+
+MarkerDetails.prototype = {
+ /**
+ * Removes any node references from this view.
+ */
+ destroy: function() {
+ this.empty();
+ this._parent = null;
+ this._splitter = null;
+ },
+
+ /**
+ * Clears the view.
+ */
+ empty: function() {
+ this._parent.innerHTML = "";
+ },
+
+ /**
+ * Builds the label representing marker's type.
+ *
+ * @param string type
+ * Could be "Paint", "Reflow", "Styles", ...
+ * See TIMELINE_BLUEPRINT in widgets/global.js
+ */
+ buildMarkerTypeLabel: function(type) {
+ let blueprint = TIMELINE_BLUEPRINT[type];
+
+ let hbox = this._document.createElement("hbox");
+ hbox.setAttribute("align", "center");
+
+ let bullet = this._document.createElement("hbox");
+ bullet.className = "marker-details-bullet";
+ bullet.style.backgroundColor = blueprint.fill;
+ bullet.style.borderColor = blueprint.stroke;
+
+ let label = this._document.createElement("label");
+ label.className = "marker-details-type";
+ label.setAttribute("value", blueprint.label);
+
+ hbox.appendChild(bullet);
+ hbox.appendChild(label);
+
+ return hbox;
+ },
+
+ /**
+ * Builds labels for name:value pairs. Like "Start: 100ms",
+ * "Duration: 200ms", ...
+ *
+ * @param string l10nName
+ * String identifier for label's name.
+ * @param string value
+ * Label's value.
+ */
+ buildNameValueLabel: function(l10nName, value) {
+ let hbox = this._document.createElement("hbox");
+ let labelName = this._document.createElement("label");
+ let labelValue = this._document.createElement("label");
+ labelName.className = "plain marker-details-labelname";
+ labelValue.className = "plain marker-details-labelvalue";
+ labelName.setAttribute("value", L10N.getStr(l10nName));
+ labelValue.setAttribute("value", value);
+ hbox.appendChild(labelName);
+ hbox.appendChild(labelValue);
+ return hbox;
+ },
+
+ /**
+ * Populates view with marker's details.
+ *
+ * @param object params
+ * An options object holding:
+ * toolbox - The toolbox.
+ * marker - The marker to display.
+ * frames - Array of stack frame information; see stack.js.
+ */
+ render: function({toolbox: toolbox, marker: marker, frames: frames}) {
+ this.empty();
+
+ // UI for any marker
+
+ let title = this.buildMarkerTypeLabel(marker.name);
+
+ let toMs = ms => L10N.getFormatStrWithNumbers("timeline.tick", ms);
+
+ let start = this.buildNameValueLabel("timeline.markerDetail.start", toMs(marker.start));
+ let end = this.buildNameValueLabel("timeline.markerDetail.end", toMs(marker.end));
+ let duration = this.buildNameValueLabel("timeline.markerDetail.duration", toMs(marker.end - marker.start));
+
+ start.classList.add("marker-details-start");
+ end.classList.add("marker-details-end");
+ duration.classList.add("marker-details-duration");
+
+ this._parent.appendChild(title);
+ this._parent.appendChild(start);
+ this._parent.appendChild(end);
+ this._parent.appendChild(duration);
+
+ // UI for specific markers
+
+ switch (marker.name) {
+ case "ConsoleTime":
+ this.renderConsoleTimeMarker(this._parent, marker);
+ break;
+ case "DOMEvent":
+ this.renderDOMEventMarker(this._parent, marker);
+ break;
+ default:
+ }
+
+ if (marker.stack) {
+ let property = "timeline.markerDetail.stack";
+ if (marker.endStack) {
+ property = "timeline.markerDetail.startStack";
+ }
+ this.renderStackTrace({toolbox: toolbox, parent: this._parent, property: property,
+ frameIndex: marker.stack, frames: frames});
+ }
+
+ if (marker.endStack) {
+ this.renderStackTrace({toolbox: toolbox, parent: this._parent, property: "timeline.markerDetail.endStack",
+ frameIndex: marker.endStack, frames: frames});
+ }
+ },
+
+ /**
+ * Render a stack trace.
+ *
+ * @param object params
+ * An options object with the following members:
+ * object toolbox - The toolbox.
+ * nsIDOMNode parent - The parent node holding the view.
+ * string property - String identifier for label's name.
+ * integer frameIndex - The index of the topmost stack frame.
+ * array frames - Array of stack frames.
+ */
+ renderStackTrace: function({toolbox: toolbox, parent: parent,
+ property: property, frameIndex: frameIndex,
+ frames: frames}) {
+ let labelName = this._document.createElement("label");
+ labelName.className = "plain marker-details-labelname";
+ labelName.setAttribute("value", L10N.getStr(property));
+ parent.appendChild(labelName);
+
+ while (frameIndex > 0) {
+ let frame = frames[frameIndex];
+ let url = frame.source;
+ let displayName = frame.functionDisplayName;
+ let line = frame.line;
+
+ let hbox = this._document.createElement("hbox");
+
+ if (displayName) {
+ let functionLabel = this._document.createElement("label");
+ functionLabel.setAttribute("value", displayName);
+ hbox.appendChild(functionLabel);
+ }
+
+ if (url) {
+ let aNode = this._document.createElement("a");
+ aNode.className = "waterfall-marker-location theme-link devtools-monospace";
+ aNode.href = url;
+ aNode.draggable = false;
+ aNode.setAttribute("title", url);
+
+ let text = WebConsoleUtils.abbreviateSourceURL(url) + ":" + line;
+ let label = this._document.createElement("label");
+ label.setAttribute("value", text);
+ aNode.appendChild(label);
+ hbox.appendChild(aNode);
+
+ aNode.addEventListener("click", (event) => {
+ event.preventDefault();
+ viewSourceInDebugger(toolbox, url, line);
+ });
+ }
+
+ if (!displayName && !url) {
+ let label = this._document.createElement("label");
+ label.setAttribute("value", L10N.getStr("timeline.markerDetail.unknownFrame"));
+ hbox.appendChild(label);
+ }
+
+ parent.appendChild(hbox);
+
+ frameIndex = frame.parent;
+ }
+ },
+
+ /**
+ * Render details of a console marker (console.time).
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the view.
+ * @param object marker
+ * The marker to display.
+ */
+ renderConsoleTimeMarker: function(parent, marker) {
+ if ("causeName" in marker) {
+ let timerName = this.buildNameValueLabel("timeline.markerDetail.consoleTimerName", marker.causeName);
+ this._parent.appendChild(timerName);
+ }
+ },
+
+ /**
+ * Render details of a DOM Event marker.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the view.
+ * @param object marker
+ * The marker to display.
+ */
+ renderDOMEventMarker: function(parent, marker) {
+ if ("type" in marker) {
+ let type = this.buildNameValueLabel("timeline.markerDetail.DOMEventType", marker.type);
+ this._parent.appendChild(type);
+ }
+ if ("eventPhase" in marker) {
+ let phaseL10NProp;
+ if (marker.eventPhase == Ci.nsIDOMEvent.AT_TARGET) {
+ phaseL10NProp = "timeline.markerDetail.DOMEventTargetPhase";
+ }
+ if (marker.eventPhase == Ci.nsIDOMEvent.CAPTURING_PHASE) {
+ phaseL10NProp = "timeline.markerDetail.DOMEventCapturingPhase";
+ }
+ if (marker.eventPhase == Ci.nsIDOMEvent.BUBBLING_PHASE) {
+ phaseL10NProp = "timeline.markerDetail.DOMEventBubblingPhase";
+ }
+ let phase = this.buildNameValueLabel("timeline.markerDetail.DOMEventPhase", L10N.getStr(phaseL10NProp));
+ this._parent.appendChild(phase);
+ }
+ },
+
+};
+
+/**
+ * Opens/selects the debugger in this toolbox and jumps to the specified
+ * file name and line number.
+ * @param object toolbox
+ * The toolbox.
+ * @param string url
+ * @param number line
+ */
+let viewSourceInDebugger = Task.async(function *(toolbox, url, line) {
+ // If the Debugger was already open, switch to it and try to show the
+ // source immediately. Otherwise, initialize it and wait for the sources
+ // to be added first.
+ let debuggerAlreadyOpen = toolbox.getPanel("jsdebugger");
+ let { panelWin: dbg } = yield toolbox.selectTool("jsdebugger");
+
+ if (!debuggerAlreadyOpen) {
+ yield dbg.once(dbg.EVENTS.SOURCES_ADDED);
+ }
+
+ let { DebuggerView } = dbg;
+ let { Sources } = DebuggerView;
+
+ let item = Sources.getItemForAttachment(a => a.source.url === url);
+ if (item) {
+ return DebuggerView.setEditorLocation(item.attachment.source.actor, line, { noDebug: true });
+ }
+
+ return Promise.reject("Couldn't find the specified source in the debugger.");
+});
+
+exports.MarkerDetails = MarkerDetails;
diff --git a/toolkit/devtools/shared/timeline/markers-overview.js b/toolkit/devtools/shared/timeline/markers-overview.js
new file mode 100644
index 000000000..5ad805eaa
--- /dev/null
+++ b/toolkit/devtools/shared/timeline/markers-overview.js
@@ -0,0 +1,230 @@
+/* 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";
+
+/**
+ * This file contains the "markers overview" graph, which is a minimap of all
+ * the timeline data. Regions inside it may be selected, determining which
+ * markers are visible in the "waterfall".
+ */
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+
+Cu.import("resource:///modules/devtools/Graphs.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+const { colorUtils: { setAlpha }} = require("devtools/css-color");
+const { getColor } = require("devtools/shared/theme");
+
+loader.lazyRequireGetter(this, "L10N",
+ "devtools/shared/timeline/global", true);
+
+const OVERVIEW_HEADER_HEIGHT = 14; // px
+const OVERVIEW_ROW_HEIGHT = 11; // px
+
+const OVERVIEW_SELECTION_LINE_COLOR = "#666";
+const OVERVIEW_CLIPHEAD_LINE_COLOR = "#555";
+
+const OVERVIEW_HEADER_TICKS_MULTIPLE = 100; // ms
+const OVERVIEW_HEADER_TICKS_SPACING_MIN = 75; // px
+const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px
+const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif";
+const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6; // px
+const OVERVIEW_HEADER_TEXT_PADDING_TOP = 1; // px
+const OVERVIEW_MARKERS_COLOR_STOPS = [0, 0.1, 0.75, 1];
+const OVERVIEW_MARKER_WIDTH_MIN = 4; // px
+const OVERVIEW_GROUP_VERTICAL_PADDING = 5; // px
+
+/**
+ * An overview for the markers data.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the overview.
+ * @param Object blueprint
+ * List of names and colors defining markers.
+ */
+function MarkersOverview(parent, blueprint, ...args) {
+ AbstractCanvasGraph.apply(this, [parent, "markers-overview", ...args]);
+ this.setTheme();
+ this.setBlueprint(blueprint);
+}
+
+MarkersOverview.prototype = Heritage.extend(AbstractCanvasGraph.prototype, {
+ clipheadLineColor: OVERVIEW_CLIPHEAD_LINE_COLOR,
+ selectionLineColor: OVERVIEW_SELECTION_LINE_COLOR,
+ headerHeight: OVERVIEW_HEADER_HEIGHT,
+ rowHeight: OVERVIEW_ROW_HEIGHT,
+ groupPadding: OVERVIEW_GROUP_VERTICAL_PADDING,
+
+ /**
+ * Compute the height of the overview.
+ */
+ get fixedHeight() {
+ return this.headerHeight + this.rowHeight * (this._lastGroup + 1);
+ },
+
+ /**
+ * List of names and colors used to paint this overview.
+ * @see TIMELINE_BLUEPRINT in timeline/widgets/global.js
+ */
+ setBlueprint: function(blueprint) {
+ this._paintBatches = new Map();
+ this._lastGroup = 0;
+
+ for (let type in blueprint) {
+ this._paintBatches.set(type, { style: blueprint[type], batch: [] });
+ this._lastGroup = Math.max(this._lastGroup, blueprint[type].group);
+ }
+ },
+
+ /**
+ * Disables selection and empties this graph.
+ */
+ clearView: function() {
+ this.selectionEnabled = false;
+ this.dropSelection();
+ this.setData({ duration: 0, markers: [] });
+ },
+
+ /**
+ * Renders the graph's data source.
+ * @see AbstractCanvasGraph.prototype.buildGraphImage
+ */
+ buildGraphImage: function() {
+ let { markers, duration } = this._data;
+
+ let { canvas, ctx } = this._getNamedCanvas("markers-overview-data");
+ let canvasWidth = this._width;
+ let canvasHeight = this._height;
+
+ // Group markers into separate paint batches. This is necessary to
+ // draw all markers sharing the same style at once.
+
+ for (let marker of markers) {
+ let markerType = this._paintBatches.get(marker.name);
+ if (markerType) {
+ markerType.batch.push(marker);
+ }
+ }
+
+ // Calculate each row's height, and the time-based scaling.
+
+ let totalGroups = this._lastGroup + 1;
+ let groupHeight = this.rowHeight * this._pixelRatio;
+ let groupPadding = this.groupPadding * this._pixelRatio;
+ let headerHeight = this.headerHeight * this._pixelRatio;
+ let dataScale = this.dataScaleX = canvasWidth / duration;
+
+ // Draw the header and overview background.
+
+ ctx.fillStyle = this.headerBackgroundColor;
+ ctx.fillRect(0, 0, canvasWidth, headerHeight);
+
+ ctx.fillStyle = this.backgroundColor;
+ ctx.fillRect(0, headerHeight, canvasWidth, canvasHeight);
+
+ // Draw the alternating odd/even group backgrounds.
+
+ ctx.fillStyle = this.alternatingBackgroundColor;
+ ctx.beginPath();
+
+ for (let i = 0; i < totalGroups; i += 2) {
+ let top = headerHeight + i * groupHeight;
+ ctx.rect(0, top, canvasWidth, groupHeight);
+ }
+
+ ctx.fill();
+
+ // Draw the timeline header ticks.
+
+ let fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio;
+ let fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY;
+ let textPaddingLeft = OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio;
+ let textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio;
+ let tickInterval = this._findOptimalTickInterval(dataScale);
+
+ ctx.textBaseline = "middle";
+ ctx.font = fontSize + "px " + fontFamily;
+ ctx.fillStyle = this.headerTextColor;
+ ctx.strokeStyle = this.headerTimelineStrokeColor;
+ ctx.beginPath();
+
+ for (let x = 0; x < canvasWidth; x += tickInterval) {
+ let lineLeft = x;
+ let textLeft = lineLeft + textPaddingLeft;
+ let time = Math.round(x / dataScale);
+ let label = L10N.getFormatStr("timeline.tick", time);
+ ctx.fillText(label, textLeft, headerHeight / 2 + textPaddingTop);
+ ctx.moveTo(lineLeft, 0);
+ ctx.lineTo(lineLeft, canvasHeight);
+ }
+
+ ctx.stroke();
+
+ // Draw the timeline markers.
+
+ for (let [, { style, batch }] of this._paintBatches) {
+ let top = headerHeight + style.group * groupHeight + groupPadding / 2;
+ let height = groupHeight - groupPadding;
+
+ let gradient = ctx.createLinearGradient(0, top, 0, top + height);
+ gradient.addColorStop(OVERVIEW_MARKERS_COLOR_STOPS[0], style.stroke);
+ gradient.addColorStop(OVERVIEW_MARKERS_COLOR_STOPS[1], style.fill);
+ gradient.addColorStop(OVERVIEW_MARKERS_COLOR_STOPS[2], style.fill);
+ gradient.addColorStop(OVERVIEW_MARKERS_COLOR_STOPS[3], style.stroke);
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+
+ for (let { start, end } of batch) {
+ let left = start * dataScale;
+ let width = Math.max((end - start) * dataScale, OVERVIEW_MARKER_WIDTH_MIN);
+ ctx.rect(left, top, width, height);
+ }
+
+ ctx.fill();
+
+ // Since all the markers in this batch (thus sharing the same style) have
+ // been drawn, empty it. The next time new markers will be available,
+ // they will be sorted and drawn again.
+ batch.length = 0;
+ }
+
+ return canvas;
+ },
+
+ /**
+ * Finds the optimal tick interval between time markers in this overview.
+ */
+ _findOptimalTickInterval: function(dataScale) {
+ let timingStep = OVERVIEW_HEADER_TICKS_MULTIPLE;
+ let spacingMin = OVERVIEW_HEADER_TICKS_SPACING_MIN * this._pixelRatio;
+
+ while (true) {
+ let scaledStep = dataScale * timingStep;
+ if (scaledStep < spacingMin) {
+ timingStep <<= 1;
+ continue;
+ }
+ return scaledStep;
+ }
+ },
+
+ /**
+ * Sets the theme via `theme` to either "light" or "dark",
+ * and updates the internal styling to match. Requires a redraw
+ * to see the effects.
+ */
+ setTheme: function (theme) {
+ theme = theme || "light";
+ this.backgroundColor = getColor("body-background", theme);
+ this.selectionBackgroundColor = setAlpha(getColor("selection-background", theme), 0.25);
+ this.selectionStripesColor = setAlpha("#fff", 0.1);
+ this.headerBackgroundColor = getColor("body-background", theme);
+ this.headerTextColor = getColor("body-color", theme);
+ this.headerTimelineStrokeColor = setAlpha(getColor("body-color-alt", theme), 0.25);
+ this.alternatingBackgroundColor = setAlpha(getColor("body-color", theme), 0.05);
+ }
+});
+
+exports.MarkersOverview = MarkersOverview;
diff --git a/toolkit/devtools/shared/timeline/memory-overview.js b/toolkit/devtools/shared/timeline/memory-overview.js
new file mode 100644
index 000000000..7e097c44c
--- /dev/null
+++ b/toolkit/devtools/shared/timeline/memory-overview.js
@@ -0,0 +1,80 @@
+/* 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";
+
+/**
+ * This file contains the "memory overview" graph, a simple representation of
+ * of all the memory measurements taken while streaming the timeline data.
+ */
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+
+Cu.import("resource:///modules/devtools/Graphs.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+const { colorUtils: { setAlpha }} = require("devtools/css-color");
+const { getColor } = require("devtools/shared/theme");
+
+loader.lazyRequireGetter(this, "L10N",
+ "devtools/shared/timeline/global", true);
+
+const OVERVIEW_DAMPEN_VALUES = 0.95;
+
+const OVERVIEW_HEIGHT = 30; // px
+const OVERVIEW_STROKE_WIDTH = 1; // px
+const OVERVIEW_MAXIMUM_LINE_COLOR = "rgba(0,136,204,0.4)";
+const OVERVIEW_AVERAGE_LINE_COLOR = "rgba(0,136,204,0.7)";
+const OVERVIEW_MINIMUM_LINE_COLOR = "rgba(0,136,204,0.9)";
+const OVERVIEW_CLIPHEAD_LINE_COLOR = "#666";
+const OVERVIEW_SELECTION_LINE_COLOR = "#555";
+
+/**
+ * An overview for the memory data.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the overview.
+ */
+function MemoryOverview(parent) {
+ LineGraphWidget.call(this, parent, { metric: L10N.getStr("graphs.memory") });
+ this.setTheme();
+}
+
+MemoryOverview.prototype = Heritage.extend(LineGraphWidget.prototype, {
+ dampenValuesFactor: OVERVIEW_DAMPEN_VALUES,
+ fixedHeight: OVERVIEW_HEIGHT,
+ strokeWidth: OVERVIEW_STROKE_WIDTH,
+ maximumLineColor: OVERVIEW_MAXIMUM_LINE_COLOR,
+ averageLineColor: OVERVIEW_AVERAGE_LINE_COLOR,
+ minimumLineColor: OVERVIEW_MINIMUM_LINE_COLOR,
+ clipheadLineColor: OVERVIEW_CLIPHEAD_LINE_COLOR,
+ selectionLineColor: OVERVIEW_SELECTION_LINE_COLOR,
+ withTooltipArrows: false,
+ withFixedTooltipPositions: true,
+
+ /**
+ * Disables selection and empties this graph.
+ */
+ clearView: function() {
+ this.selectionEnabled = false;
+ this.dropSelection();
+ this.setData([]);
+ },
+
+ /**
+ * Sets the theme via `theme` to either "light" or "dark",
+ * and updates the internal styling to match. Requires a redraw
+ * to see the effects.
+ */
+ setTheme: function (theme) {
+ theme = theme || "light";
+ this.backgroundColor = getColor("body-background", theme);
+ this.backgroundGradientStart = setAlpha(getColor("highlight-blue", theme), 0.1);
+ this.backgroundGradientEnd = setAlpha(getColor("highlight-blue", theme), 0);
+ this.strokeColor = getColor("highlight-blue", theme);
+ this.selectionBackgroundColor = setAlpha(getColor("selection-background", theme), 0.25);
+ this.selectionStripesColor = "rgba(255, 255, 255, 0.1)";
+ }
+});
+
+exports.MemoryOverview = MemoryOverview;
diff --git a/toolkit/devtools/shared/timeline/waterfall.js b/toolkit/devtools/shared/timeline/waterfall.js
new file mode 100644
index 000000000..8093d3088
--- /dev/null
+++ b/toolkit/devtools/shared/timeline/waterfall.js
@@ -0,0 +1,619 @@
+/* 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";
+
+/**
+ * This file contains the "waterfall" view, essentially a detailed list
+ * of all the markers in the timeline data.
+ */
+
+const {Ci, Cu} = require("chrome");
+
+loader.lazyRequireGetter(this, "L10N",
+ "devtools/shared/timeline/global", true);
+
+loader.lazyImporter(this, "setNamedTimeout",
+ "resource:///modules/devtools/ViewHelpers.jsm");
+loader.lazyImporter(this, "clearNamedTimeout",
+ "resource:///modules/devtools/ViewHelpers.jsm");
+loader.lazyRequireGetter(this, "EventEmitter",
+ "devtools/toolkit/event-emitter");
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+const WATERFALL_SIDEBAR_WIDTH = 150; // px
+
+const WATERFALL_IMMEDIATE_DRAW_MARKERS_COUNT = 30;
+const WATERFALL_FLUSH_OUTSTANDING_MARKERS_DELAY = 75; // ms
+
+const WATERFALL_HEADER_TICKS_MULTIPLE = 5; // ms
+const WATERFALL_HEADER_TICKS_SPACING_MIN = 50; // px
+const WATERFALL_HEADER_TEXT_PADDING = 3; // px
+
+const WATERFALL_BACKGROUND_TICKS_MULTIPLE = 5; // ms
+const WATERFALL_BACKGROUND_TICKS_SCALES = 3;
+const WATERFALL_BACKGROUND_TICKS_SPACING_MIN = 10; // px
+const WATERFALL_BACKGROUND_TICKS_COLOR_RGB = [128, 136, 144];
+const WATERFALL_BACKGROUND_TICKS_OPACITY_MIN = 32; // byte
+const WATERFALL_BACKGROUND_TICKS_OPACITY_ADD = 32; // byte
+const WATERFALL_MARKER_BAR_WIDTH_MIN = 5; // px
+
+const WATERFALL_ROWCOUNT_ONPAGEUPDOWN = 10;
+
+/**
+ * A detailed waterfall view for the timeline data.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the waterfall.
+ * @param nsIDOMNode container
+ * The container node that key events should be bound to.
+ * @param Object blueprint
+ * List of names and colors defining markers.
+ */
+function Waterfall(parent, container, blueprint) {
+ EventEmitter.decorate(this);
+
+ this._parent = parent;
+ this._document = parent.ownerDocument;
+ this._container = container;
+ this._fragment = this._document.createDocumentFragment();
+ this._outstandingMarkers = [];
+
+ this._headerContents = this._document.createElement("hbox");
+ this._headerContents.className = "waterfall-header-contents";
+ this._parent.appendChild(this._headerContents);
+
+ this._listContents = this._document.createElement("vbox");
+ this._listContents.className = "waterfall-list-contents";
+ this._listContents.setAttribute("flex", "1");
+ this._parent.appendChild(this._listContents);
+
+ this.setupKeys();
+
+ this._isRTL = this._getRTL();
+
+ // Lazy require is a bit slow, and these are hot objects.
+ this._l10n = L10N;
+ this._blueprint = blueprint;
+ this._setNamedTimeout = setNamedTimeout;
+ this._clearNamedTimeout = clearNamedTimeout;
+
+ // Selected row index. By default, we want the first
+ // row to be selected.
+ this._selectedRowIdx = 0;
+
+ // Default rowCount
+ this.rowCount = WATERFALL_ROWCOUNT_ONPAGEUPDOWN;
+}
+
+Waterfall.prototype = {
+ /**
+ * Removes any node references from this view.
+ */
+ destroy: function() {
+ this._parent = this._document = this._container = null;
+ },
+
+ /**
+ * Populates this view with the provided data source.
+ *
+ * @param object data
+ * An object containing the following properties:
+ * - markers: a list of markers received from the controller
+ * - interval: the { startTime, endTime }, in milliseconds
+ */
+ setData: function({ markers, interval }) {
+ this.clearView();
+ this._markers = markers;
+ this._interval = interval;
+
+ let { startTime, endTime } = interval;
+ let dataScale = this._waterfallWidth / (endTime - startTime);
+ this._drawWaterfallBackground(dataScale);
+
+ this._buildHeader(this._headerContents, startTime, dataScale);
+ this._buildMarkers(this._listContents, markers, startTime, endTime, dataScale);
+ this.selectRow(this._selectedRowIdx);
+ },
+
+ /**
+ * List of names and colors used to paint markers.
+ * @see TIMELINE_BLUEPRINT in timeline/widgets/global.js
+ */
+ setBlueprint: function(blueprint) {
+ this._blueprint = blueprint;
+ },
+
+ /**
+ * Keybindings.
+ */
+ setupKeys: function() {
+ let pane = this._container;
+ pane.addEventListener("keydown", e => {
+ if (e.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_UP) {
+ e.preventDefault();
+ this.selectNearestRow(this._selectedRowIdx - 1);
+ }
+ if (e.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_DOWN) {
+ e.preventDefault();
+ this.selectNearestRow(this._selectedRowIdx + 1);
+ }
+ if (e.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_HOME) {
+ e.preventDefault();
+ this.selectNearestRow(0);
+ }
+ if (e.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_END) {
+ e.preventDefault();
+ this.selectNearestRow(this._listContents.children.length);
+ }
+ if (e.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP) {
+ e.preventDefault();
+ this.selectNearestRow(this._selectedRowIdx - this.rowCount);
+ }
+ if (e.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN) {
+ e.preventDefault();
+ this.selectNearestRow(this._selectedRowIdx + this.rowCount);
+ }
+ }, true);
+ },
+
+ /**
+ * Depopulates this view.
+ */
+ clearView: function() {
+ while (this._headerContents.hasChildNodes()) {
+ this._headerContents.firstChild.remove();
+ }
+ while (this._listContents.hasChildNodes()) {
+ this._listContents.firstChild.remove();
+ }
+ this._listContents.scrollTop = 0;
+ this._outstandingMarkers.length = 0;
+ this._clearNamedTimeout("flush-outstanding-markers");
+ },
+
+ /**
+ * Calculates and stores the available width for the waterfall.
+ * This should be invoked every time the container window is resized.
+ */
+ recalculateBounds: function() {
+ let bounds = this._parent.getBoundingClientRect();
+ this._waterfallWidth = bounds.width - WATERFALL_SIDEBAR_WIDTH;
+ },
+
+ /**
+ * Creates the header part of this view.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the header.
+ * @param number startTime
+ * @see Waterfall.prototype.setData
+ * @param number dataScale
+ * The time scale of the data source.
+ */
+ _buildHeader: function(parent, startTime, dataScale) {
+ let container = this._document.createElement("hbox");
+ container.className = "waterfall-header-container";
+ container.setAttribute("flex", "1");
+
+ let sidebar = this._document.createElement("hbox");
+ sidebar.className = "waterfall-sidebar theme-sidebar";
+ sidebar.setAttribute("width", WATERFALL_SIDEBAR_WIDTH);
+ sidebar.setAttribute("align", "center");
+ container.appendChild(sidebar);
+
+ let name = this._document.createElement("label");
+ name.className = "plain waterfall-header-name";
+ name.setAttribute("value", this._l10n.getStr("timeline.records"));
+ sidebar.appendChild(name);
+
+ let ticks = this._document.createElement("hbox");
+ ticks.className = "waterfall-header-ticks waterfall-background-ticks";
+ ticks.setAttribute("align", "center");
+ ticks.setAttribute("flex", "1");
+ container.appendChild(ticks);
+
+ let offset = this._isRTL ? this._waterfallWidth : 0;
+ let direction = this._isRTL ? -1 : 1;
+ let tickInterval = this._findOptimalTickInterval({
+ ticksMultiple: WATERFALL_HEADER_TICKS_MULTIPLE,
+ ticksSpacingMin: WATERFALL_HEADER_TICKS_SPACING_MIN,
+ dataScale: dataScale
+ });
+
+ for (let x = 0; x < this._waterfallWidth; x += tickInterval) {
+ let left = x + direction * WATERFALL_HEADER_TEXT_PADDING;
+ let time = Math.round(x / dataScale + startTime);
+ let label = this._l10n.getFormatStr("timeline.tick", time);
+
+ let node = this._document.createElement("label");
+ node.className = "plain waterfall-header-tick";
+ node.style.transform = "translateX(" + (left - offset) + "px)";
+ node.setAttribute("value", label);
+ ticks.appendChild(node);
+ }
+
+ parent.appendChild(container);
+ },
+
+ /**
+ * Creates the markers part of this view.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the markers.
+ * @param number startTime
+ * @see Waterfall.prototype.setData
+ * @param number dataScale
+ * The time scale of the data source.
+ */
+ _buildMarkers: function(parent, markers, startTime, endTime, dataScale) {
+ let rowsCount = 0;
+ let markerIdx = -1;
+
+ for (let marker of markers) {
+ markerIdx++;
+
+ if (!isMarkerInRange(marker, startTime, endTime)) {
+ continue;
+ }
+ if (!(marker.name in this._blueprint)) {
+ continue;
+ }
+
+ // Only build and display a finite number of markers initially, to
+ // preserve a snappy UI. After a certain delay, continue building the
+ // outstanding markers while there's (hopefully) no user interaction.
+ let arguments_ = [this._fragment, marker, startTime, dataScale, markerIdx, rowsCount];
+ if (rowsCount++ < WATERFALL_IMMEDIATE_DRAW_MARKERS_COUNT) {
+ this._buildMarker.apply(this, arguments_);
+ } else {
+ this._outstandingMarkers.push(arguments_);
+ }
+ }
+
+ // If there are no outstanding markers, add a dummy "spacer" at the end
+ // to fill up any remaining available space in the UI.
+ if (!this._outstandingMarkers.length) {
+ this._buildMarker(this._fragment, null);
+ }
+ // Otherwise prepare flushing the outstanding markers after a small delay.
+ else {
+ let delay = WATERFALL_FLUSH_OUTSTANDING_MARKERS_DELAY;
+ let func = () => this._buildOutstandingMarkers(parent);
+ this._setNamedTimeout("flush-outstanding-markers", delay, func);
+ }
+
+ parent.appendChild(this._fragment);
+ },
+
+ /**
+ * Finishes building the outstanding markers in this view.
+ * @see Waterfall.prototype._buildMarkers
+ */
+ _buildOutstandingMarkers: function(parent) {
+ if (!this._outstandingMarkers.length) {
+ return;
+ }
+ for (let args of this._outstandingMarkers) {
+ this._buildMarker.apply(this, args);
+ }
+ this._outstandingMarkers.length = 0;
+ parent.appendChild(this._fragment);
+ this.selectRow(this._selectedRowIdx);
+ },
+
+ /**
+ * Creates a single marker in this view.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the marker.
+ * @param object marker
+ * The { name, start, end } marker in the data source.
+ * @param startTime
+ * @see Waterfall.prototype.setData
+ * @param number dataScale
+ * @see Waterfall.prototype._buildMarkers
+ * @param number markerIdx
+ * Index of the marker in this._markers
+ * @param number rowIdx
+ * Index of current row
+ */
+ _buildMarker: function(parent, marker, startTime, dataScale, markerIdx, rowIdx) {
+ let container = this._document.createElement("hbox");
+ container.setAttribute("markerIdx", markerIdx);
+ container.className = "waterfall-marker-container";
+
+ if (marker) {
+ this._buildMarkerSidebar(container, marker);
+ this._buildMarkerWaterfall(container, marker, startTime, dataScale, markerIdx);
+ container.onclick = () => this.selectRow(rowIdx);
+ } else {
+ this._buildMarkerSpacer(container);
+ container.setAttribute("flex", "1");
+ container.setAttribute("is-spacer", "");
+ }
+
+ parent.appendChild(container);
+ },
+
+ /**
+ * Select first row.
+ */
+ resetSelection: function() {
+ this.selectRow(0);
+ },
+
+ /**
+ * Select a marker in the waterfall.
+ *
+ * @param number idx
+ * Index of the row to select. -1 clears the selection.
+ */
+ selectRow: function(idx) {
+ let prev = this._listContents.children[this._selectedRowIdx];
+ if (prev) {
+ prev.classList.remove("selected");
+ }
+
+ this._selectedRowIdx = idx;
+
+ let row = this._listContents.children[idx];
+ if (row && !row.hasAttribute("is-spacer")) {
+ row.focus();
+ row.classList.add("selected");
+
+ let markerIdx = row.getAttribute("markerIdx");
+ this.emit("selected", this._markers[markerIdx]);
+ this.ensureRowIsVisible(row);
+ } else {
+ this.emit("unselected");
+ }
+ },
+
+ /**
+ * Find a valid row to select.
+ *
+ * @param number idx
+ * Index of the row to select.
+ */
+ selectNearestRow: function(idx) {
+ if (this._listContents.children.length == 0) {
+ return;
+ }
+ idx = Math.max(idx, 0);
+ idx = Math.min(idx, this._listContents.children.length - 1);
+ let row = this._listContents.children[idx];
+ if (row && row.hasAttribute("is-spacer")) {
+ if (idx > 0) {
+ return this.selectNearestRow(idx - 1);
+ } else {
+ return;
+ }
+ }
+ this.selectRow(idx);
+ },
+
+ /**
+ * Scroll waterfall to ensure row is in the viewport.
+ *
+ * @param number idx
+ * Index of the row to select.
+ */
+ ensureRowIsVisible: function(row) {
+ let parent = row.parentNode;
+ let parentRect = parent.getBoundingClientRect();
+ let rowRect = row.getBoundingClientRect();
+ let yDelta = rowRect.top - parentRect.top;
+ if (yDelta < 0) {
+ parent.scrollTop += yDelta;
+ }
+ yDelta = parentRect.bottom - rowRect.bottom;
+ if (yDelta < 0) {
+ parent.scrollTop -= yDelta;
+ }
+ },
+
+ /**
+ * Creates the sidebar part of a marker in this view.
+ *
+ * @param nsIDOMNode container
+ * The container node representing the marker in this view.
+ * @param object marker
+ * @see Waterfall.prototype._buildMarker
+ */
+ _buildMarkerSidebar: function(container, marker) {
+ let blueprint = this._blueprint[marker.name];
+
+ let sidebar = this._document.createElement("hbox");
+ sidebar.className = "waterfall-sidebar theme-sidebar";
+ sidebar.setAttribute("width", WATERFALL_SIDEBAR_WIDTH);
+ sidebar.setAttribute("align", "center");
+
+ let bullet = this._document.createElement("hbox");
+ bullet.className = "waterfall-marker-bullet";
+ bullet.style.backgroundColor = blueprint.fill;
+ bullet.style.borderColor = blueprint.stroke;
+ bullet.setAttribute("type", marker.name);
+ sidebar.appendChild(bullet);
+
+ let name = this._document.createElement("label");
+ name.setAttribute("crop", "end");
+ name.setAttribute("flex", "1");
+ name.className = "plain waterfall-marker-name";
+
+ let label;
+ if (marker.causeName) {
+ label = this._l10n.getFormatStr("timeline.markerDetailFormat",
+ blueprint.label,
+ marker.causeName);
+ } else {
+ label = blueprint.label;
+ }
+ name.setAttribute("value", label);
+ name.setAttribute("tooltiptext", label);
+ sidebar.appendChild(name);
+
+ container.appendChild(sidebar);
+ },
+
+ /**
+ * Creates the waterfall part of a marker in this view.
+ *
+ * @param nsIDOMNode container
+ * The container node representing the marker.
+ * @param object marker
+ * @see Waterfall.prototype._buildMarker
+ * @param startTime
+ * @see Waterfall.prototype.setData
+ * @param number dataScale
+ * @see Waterfall.prototype._buildMarkers
+ */
+ _buildMarkerWaterfall: function(container, marker, startTime, dataScale) {
+ let blueprint = this._blueprint[marker.name];
+
+ let waterfall = this._document.createElement("hbox");
+ waterfall.className = "waterfall-marker-item waterfall-background-ticks";
+ waterfall.setAttribute("align", "center");
+ waterfall.setAttribute("flex", "1");
+
+ let start = (marker.start - startTime) * dataScale;
+ let width = (marker.end - marker.start) * dataScale;
+ let offset = this._isRTL ? this._waterfallWidth : 0;
+
+ let bar = this._document.createElement("hbox");
+ bar.className = "waterfall-marker-bar";
+ bar.style.backgroundColor = blueprint.fill;
+ bar.style.borderColor = blueprint.stroke;
+ bar.style.transform = "translateX(" + (start - offset) + "px)";
+ // Save border color. It will change when marker is selected.
+ bar.setAttribute("borderColor", blueprint.stroke);
+ bar.setAttribute("type", marker.name);
+ bar.setAttribute("width", Math.max(width, WATERFALL_MARKER_BAR_WIDTH_MIN));
+ waterfall.appendChild(bar);
+
+ container.appendChild(waterfall);
+ },
+
+ /**
+ * Creates a dummy spacer as an empty marker.
+ *
+ * @param nsIDOMNode container
+ * The container node representing the marker.
+ */
+ _buildMarkerSpacer: function(container) {
+ let sidebarSpacer = this._document.createElement("spacer");
+ sidebarSpacer.className = "waterfall-sidebar theme-sidebar";
+ sidebarSpacer.setAttribute("width", WATERFALL_SIDEBAR_WIDTH);
+
+ let waterfallSpacer = this._document.createElement("spacer");
+ waterfallSpacer.className = "waterfall-marker-item waterfall-background-ticks";
+ waterfallSpacer.setAttribute("flex", "1");
+
+ container.appendChild(sidebarSpacer);
+ container.appendChild(waterfallSpacer);
+ },
+
+ /**
+ * Creates the background displayed on the marker's waterfall.
+ *
+ * @param number dataScale
+ * @see Waterfall.prototype._buildMarkers
+ */
+ _drawWaterfallBackground: function(dataScale) {
+ if (!this._canvas || !this._ctx) {
+ this._canvas = this._document.createElementNS(HTML_NS, "canvas");
+ this._ctx = this._canvas.getContext("2d");
+ }
+ let canvas = this._canvas;
+ let ctx = this._ctx;
+
+ // Nuke the context.
+ let canvasWidth = canvas.width = this._waterfallWidth;
+ let canvasHeight = canvas.height = 1; // Awww yeah, 1px, repeats on Y axis.
+
+ // Start over.
+ let imageData = ctx.createImageData(canvasWidth, canvasHeight);
+ let pixelArray = imageData.data;
+
+ let buf = new ArrayBuffer(pixelArray.length);
+ let view8bit = new Uint8ClampedArray(buf);
+ let view32bit = new Uint32Array(buf);
+
+ // Build new millisecond tick lines...
+ let [r, g, b] = WATERFALL_BACKGROUND_TICKS_COLOR_RGB;
+ let alphaComponent = WATERFALL_BACKGROUND_TICKS_OPACITY_MIN;
+ let tickInterval = this._findOptimalTickInterval({
+ ticksMultiple: WATERFALL_BACKGROUND_TICKS_MULTIPLE,
+ ticksSpacingMin: WATERFALL_BACKGROUND_TICKS_SPACING_MIN,
+ dataScale: dataScale
+ });
+
+ // Insert one pixel for each division on each scale.
+ for (let i = 1; i <= WATERFALL_BACKGROUND_TICKS_SCALES; i++) {
+ let increment = tickInterval * Math.pow(2, i);
+ for (let x = 0; x < canvasWidth; x += increment) {
+ let position = x | 0;
+ view32bit[position] = (alphaComponent << 24) | (b << 16) | (g << 8) | r;
+ }
+ alphaComponent += WATERFALL_BACKGROUND_TICKS_OPACITY_ADD;
+ }
+
+ // Flush the image data and cache the waterfall background.
+ pixelArray.set(view8bit);
+ ctx.putImageData(imageData, 0, 0);
+ this._document.mozSetImageElement("waterfall-background", canvas);
+ },
+
+ /**
+ * Finds the optimal tick interval between time markers in this timeline.
+ *
+ * @param number ticksMultiple
+ * @param number ticksSpacingMin
+ * @param number dataScale
+ * @return number
+ */
+ _findOptimalTickInterval: function({ ticksMultiple, ticksSpacingMin, dataScale }) {
+ let timingStep = ticksMultiple;
+
+ while (true) {
+ let scaledStep = dataScale * timingStep;
+ if (scaledStep < ticksSpacingMin) {
+ timingStep <<= 1;
+ continue;
+ }
+ return scaledStep;
+ }
+ },
+
+ /**
+ * Returns true if this is document is in RTL mode.
+ * @return boolean
+ */
+ _getRTL: function() {
+ let win = this._document.defaultView;
+ let doc = this._document.documentElement;
+ return win.getComputedStyle(doc, null).direction == "rtl";
+ }
+};
+
+/**
+ * Checks if a given marker is in the specified time range.
+ *
+ * @param object e
+ * The marker containing the { start, end } timestamps.
+ * @param number start
+ * The earliest allowed time.
+ * @param number end
+ * The latest allowed time.
+ * @return boolean
+ * True if the marker fits inside the specified time range.
+ */
+function isMarkerInRange(e, start, end) {
+ return (e.start >= start && e.end <= end) || // bounds inside
+ (e.start < start && e.end > end) || // bounds outside
+ (e.start < start && e.end >= start && e.end <= end) || // overlap start
+ (e.end > end && e.start >= start && e.start <= end); // overlap end
+}
+
+exports.Waterfall = Waterfall;
diff --git a/toolkit/devtools/shared/undo.js b/toolkit/devtools/shared/undo.js
new file mode 100644
index 000000000..08ff18311
--- /dev/null
+++ b/toolkit/devtools/shared/undo.js
@@ -0,0 +1,206 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+
+/**
+ * A simple undo stack manager.
+ *
+ * Actions are added along with the necessary code to
+ * reverse the action.
+ *
+ * @param function aChange Called whenever the size or position
+ * of the undo stack changes, to use for updating undo-related
+ * UI.
+ * @param integer aMaxUndo Maximum number of undo steps.
+ * defaults to 50.
+ */
+function UndoStack(aMaxUndo)
+{
+ this.maxUndo = aMaxUndo || 50;
+ this._stack = [];
+}
+
+exports.UndoStack = UndoStack;
+
+UndoStack.prototype = {
+ // Current index into the undo stack. Is positioned after the last
+ // currently-applied change.
+ _index: 0,
+
+ // The current batch depth (see startBatch() for details)
+ _batchDepth: 0,
+
+ destroy: function Undo_destroy()
+ {
+ this.uninstallController();
+ delete this._stack;
+ },
+
+ /**
+ * Start a collection of related changes. Changes will be batched
+ * together into one undo/redo item until endBatch() is called.
+ *
+ * Batches can be nested, in which case the outer batch will contain
+ * all items from the inner batches. This allows larger user
+ * actions made up of a collection of smaller actions to be
+ * undone as a single action.
+ */
+ startBatch: function Undo_startBatch()
+ {
+ if (this._batchDepth++ === 0) {
+ this._batch = [];
+ }
+ },
+
+ /**
+ * End a batch of related changes, performing its action and adding
+ * it to the undo stack.
+ */
+ endBatch: function Undo_endBatch()
+ {
+ if (--this._batchDepth > 0) {
+ return;
+ }
+
+ // Cut off the end of the undo stack at the current index,
+ // and the beginning to prevent a stack larger than maxUndo.
+ let start = Math.max((this._index + 1) - this.maxUndo, 0);
+ this._stack = this._stack.slice(start, this._index);
+
+ let batch = this._batch;
+ delete this._batch;
+ let entry = {
+ do: function() {
+ for (let item of batch) {
+ item.do();
+ }
+ },
+ undo: function() {
+ for (let i = batch.length - 1; i >= 0; i--) {
+ batch[i].undo();
+ }
+ }
+ };
+ this._stack.push(entry);
+ this._index = this._stack.length;
+ entry.do();
+ this._change();
+ },
+
+ /**
+ * Perform an action, adding it to the undo stack.
+ *
+ * @param function aDo Called to perform the action.
+ * @param function aUndo Called to reverse the action.
+ */
+ do: function Undo_do(aDo, aUndo) {
+ this.startBatch();
+ this._batch.push({ do: aDo, undo: aUndo });
+ this.endBatch();
+ },
+
+ /*
+ * Returns true if undo() will do anything.
+ */
+ canUndo: function Undo_canUndo()
+ {
+ return this._index > 0;
+ },
+
+ /**
+ * Undo the top of the undo stack.
+ *
+ * @return true if an action was undone.
+ */
+ undo: function Undo_canUndo()
+ {
+ if (!this.canUndo()) {
+ return false;
+ }
+ this._stack[--this._index].undo();
+ this._change();
+ return true;
+ },
+
+ /**
+ * Returns true if redo() will do anything.
+ */
+ canRedo: function Undo_canRedo()
+ {
+ return this._stack.length > this._index;
+ },
+
+ /**
+ * Redo the most recently undone action.
+ *
+ * @return true if an action was redone.
+ */
+ redo: function Undo_canRedo()
+ {
+ if (!this.canRedo()) {
+ return false;
+ }
+ this._stack[this._index++].do();
+ this._change();
+ return true;
+ },
+
+ _change: function Undo__change()
+ {
+ if (this._controllerWindow) {
+ this._controllerWindow.goUpdateCommand("cmd_undo");
+ this._controllerWindow.goUpdateCommand("cmd_redo");
+ }
+ },
+
+ /**
+ * ViewController implementation for undo/redo.
+ */
+
+ /**
+ * Install this object as a command controller.
+ */
+ installController: function Undo_installController(aControllerWindow)
+ {
+ this._controllerWindow = aControllerWindow;
+ aControllerWindow.controllers.appendController(this);
+ },
+
+ /**
+ * Uninstall this object from the command controller.
+ */
+ uninstallController: function Undo_uninstallController()
+ {
+ if (!this._controllerWindow) {
+ return;
+ }
+ this._controllerWindow.controllers.removeController(this);
+ },
+
+ supportsCommand: function Undo_supportsCommand(aCommand)
+ {
+ return (aCommand == "cmd_undo" ||
+ aCommand == "cmd_redo");
+ },
+
+ isCommandEnabled: function Undo_isCommandEnabled(aCommand)
+ {
+ switch(aCommand) {
+ case "cmd_undo": return this.canUndo();
+ case "cmd_redo": return this.canRedo();
+ };
+ return false;
+ },
+
+ doCommand: function Undo_doCommand(aCommand)
+ {
+ switch(aCommand) {
+ case "cmd_undo": return this.undo();
+ case "cmd_redo": return this.redo();
+ }
+ },
+
+ onEvent: function Undo_onEvent(aEvent) {},
+}
diff --git a/toolkit/devtools/shared/widgets/AbstractTreeItem.jsm b/toolkit/devtools/shared/widgets/AbstractTreeItem.jsm
new file mode 100644
index 000000000..308ef3036
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/AbstractTreeItem.jsm
@@ -0,0 +1,481 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource://gre/modules/devtools/event-emitter.js");
+XPCOMUtils.defineLazyModuleGetter(this, "console", "resource://gre/modules/devtools/Console.jsm");
+
+this.EXPORTED_SYMBOLS = ["AbstractTreeItem"];
+
+/**
+ * A very generic and low-level tree view implementation. It is not intended
+ * to be used alone, but as a base class that you can extend to build your
+ * own custom implementation.
+ *
+ * Language:
+ * - An "item" is an instance of an AbstractTreeItem.
+ * - An "element" or "node" is an nsIDOMNode.
+ *
+ * The following events are emitted by this tree, always from the root item,
+ * with the first argument pointing to the affected child item:
+ * - "expand": when an item is expanded in the tree
+ * - "collapse": when an item is collapsed in the tree
+ * - "focus": when an item is selected in the tree
+ *
+ * For example, you can extend this abstract class like this:
+ *
+ * function MyCustomTreeItem(dataSrc, properties) {
+ * AbstractTreeItem.call(this, properties);
+ * this.itemDataSrc = dataSrc;
+ * }
+ *
+ * MyCustomTreeItem.prototype = Heritage.extend(AbstractTreeItem.prototype, {
+ * _displaySelf: function(document, arrowNode) {
+ * let node = document.createElement("hbox");
+ * ...
+ * // Append the provided arrow node wherever you want.
+ * node.appendChild(arrowNode);
+ * ...
+ * // Use `this.itemDataSrc` to customize the tree item and
+ * // `this.level` to calculate the indentation.
+ * node.MozMarginStart = (this.level * 10) + "px";
+ * node.appendChild(document.createTextNode(this.itemDataSrc.label));
+ * ...
+ * return node;
+ * },
+ * _populateSelf: function(children) {
+ * ...
+ * // Use `this.itemDataSrc` to get the data source for the child items.
+ * let someChildDataSrc = this.itemDataSrc.children[0];
+ * ...
+ * children.push(new MyCustomTreeItem(someChildDataSrc, {
+ * parent: this,
+ * level: this.level + 1
+ * }));
+ * ...
+ * }
+ * });
+ *
+ * And then you could use it like this:
+ *
+ * let dataSrc = {
+ * label: "root",
+ * children: [{
+ * label: "foo",
+ * children: []
+ * }, {
+ * label: "bar",
+ * children: [{
+ * label: "baz",
+ * children: []
+ * }]
+ * }]
+ * };
+ * let root = new MyCustomTreeItem(dataSrc, { parent: null });
+ * root.attachTo(nsIDOMNode);
+ * root.expand();
+ *
+ * The following tree view will be generated (after expanding all nodes):
+ * ▼ root
+ * ▶ foo
+ * ▼ bar
+ * ▶ baz
+ *
+ * The way the data source is implemented is completely up to you. There's
+ * no assumptions made and you can use it however you like inside the
+ * `_displaySelf` and `populateSelf` methods. If you need to add children to a
+ * node at a later date, you just need to modify the data source:
+ *
+ * dataSrc[...path-to-foo...].children.push({
+ * label: "lazily-added-node"
+ * children: []
+ * });
+ *
+ * The existing tree view will be modified like so (after expanding `foo`):
+ * ▼ root
+ * ▼ foo
+ * ▶ lazily-added-node
+ * ▼ bar
+ * ▶ baz
+ *
+ * Everything else is taken care of automagically!
+ *
+ * @param AbstractTreeItem parent
+ * The parent tree item. Should be null for root items.
+ * @param number level
+ * The indentation level in the tree. The root item is at level 0.
+ */
+function AbstractTreeItem({ parent, level }) {
+ this._rootItem = parent ? parent._rootItem : this;
+ this._parentItem = parent;
+ this._level = level || 0;
+ this._childTreeItems = [];
+ this._onArrowClick = this._onArrowClick.bind(this);
+ this._onClick = this._onClick.bind(this);
+ this._onDoubleClick = this._onDoubleClick.bind(this);
+ this._onKeyPress = this._onKeyPress.bind(this);
+ this._onFocus = this._onFocus.bind(this);
+
+ EventEmitter.decorate(this);
+}
+
+AbstractTreeItem.prototype = {
+ _containerNode: null,
+ _targetNode: null,
+ _arrowNode: null,
+ _constructed: false,
+ _populated: false,
+ _expanded: false,
+
+ /**
+ * Optionally, trees may be allowed to automatically expand a few levels deep
+ * to avoid initially displaying a completely collapsed tree.
+ */
+ autoExpandDepth: 0,
+
+ /**
+ * Creates the view for this tree item. Implement this method in the
+ * inheriting classes to create the child node displayed in the tree.
+ * Use `this.level` and the provided `arrowNode` as you see fit.
+ *
+ * @param nsIDOMNode document
+ * @param nsIDOMNode arrowNode
+ * @return nsIDOMNode
+ */
+ _displaySelf: function(document, arrowNode) {
+ throw "This method needs to be implemented by inheriting classes.";
+ },
+
+ /**
+ * Populates this tree item with child items, whenever it's expanded.
+ * Implement this method in the inheriting classes to fill the provided
+ * `children` array with AbstractTreeItem instances, which will then be
+ * magically handled by this tree item.
+ *
+ * @param array:AbstractTreeItem children
+ */
+ _populateSelf: function(children) {
+ throw "This method needs to be implemented by inheriting classes.";
+ },
+
+ /**
+ * Gets the root item of this tree.
+ * @return AbstractTreeItem
+ */
+ get root() {
+ return this._rootItem;
+ },
+
+ /**
+ * Gets the parent of this tree item.
+ * @return AbstractTreeItem
+ */
+ get parent() {
+ return this._parentItem;
+ },
+
+ /**
+ * Gets the indentation level of this tree item.
+ */
+ get level() {
+ return this._level;
+ },
+
+ /**
+ * Gets the element displaying this tree item.
+ */
+ get target() {
+ return this._targetNode;
+ },
+
+ /**
+ * Gets the element containing all tree items.
+ * @return nsIDOMNode
+ */
+ get container() {
+ return this._containerNode;
+ },
+
+ /**
+ * Returns whether or not this item is populated in the tree.
+ * Collapsed items can still be populated.
+ * @return boolean
+ */
+ get populated() {
+ return this._populated;
+ },
+
+ /**
+ * Returns whether or not this item is expanded in the tree.
+ * Expanded items with no children aren't consudered `populated`.
+ * @return boolean
+ */
+ get expanded() {
+ return this._expanded;
+ },
+
+ /**
+ * Creates and appends this tree item to the specified parent element.
+ *
+ * @param nsIDOMNode containerNode
+ * The parent element for this tree item (and every other tree item).
+ * @param nsIDOMNode beforeNode
+ * The child element which should succeed this tree item.
+ */
+ attachTo: function(containerNode, beforeNode = null) {
+ this._containerNode = containerNode;
+ this._constructTargetNode();
+ containerNode.insertBefore(this._targetNode, beforeNode);
+
+ if (this._level < this.autoExpandDepth) {
+ this.expand();
+ }
+ },
+
+ /**
+ * Permanently removes this tree item (and all subsequent children) from the
+ * parent container.
+ */
+ remove: function() {
+ this._targetNode.remove();
+ this._hideChildren();
+ this._childTreeItems.length = 0;
+ },
+
+ /**
+ * Focuses this item in the tree.
+ */
+ focus: function() {
+ this._targetNode.focus();
+ },
+
+ /**
+ * Expands this item in the tree.
+ */
+ expand: function() {
+ if (this._expanded) {
+ return;
+ }
+ this._expanded = true;
+ this._arrowNode.setAttribute("open", "");
+ this._toggleChildren(true);
+ this._rootItem.emit("expand", this);
+ },
+
+ /**
+ * Collapses this item in the tree.
+ */
+ collapse: function() {
+ if (!this._expanded) {
+ return;
+ }
+ this._expanded = false;
+ this._arrowNode.removeAttribute("open");
+ this._toggleChildren(false);
+ this._rootItem.emit("collapse", this);
+ },
+
+ /**
+ * Returns the child item at the specified index.
+ *
+ * @param number index
+ * @return AbstractTreeItem
+ */
+ getChild: function(index = 0) {
+ return this._childTreeItems[index];
+ },
+
+ /**
+ * Shows or hides all the children of this item in the tree. If neessary,
+ * populates this item with children.
+ *
+ * @param boolean visible
+ * True if the children should be visible, false otherwise.
+ */
+ _toggleChildren: function(visible) {
+ if (visible) {
+ if (!this._populated) {
+ this._populateSelf(this._childTreeItems);
+ this._populated = this._childTreeItems.length > 0;
+ }
+ this._showChildren();
+ } else {
+ this._hideChildren();
+ }
+ },
+
+ /**
+ * Shows all children of this item in the tree.
+ */
+ _showChildren: function() {
+ let childTreeItems = this._childTreeItems;
+ let expandedChildTreeItems = childTreeItems.filter(e => e._expanded);
+ let nextNode = this._getSiblingAtDelta(1);
+
+ // First append the child items, and afterwards append any descendants.
+ // Otherwise, the tree will become garbled and nodes will intertwine.
+ for (let item of childTreeItems) {
+ item.attachTo(this._containerNode, nextNode);
+ }
+ for (let item of expandedChildTreeItems) {
+ item._showChildren();
+ }
+ },
+
+ /**
+ * Hides all children of this item in the tree.
+ */
+ _hideChildren: function() {
+ for (let item of this._childTreeItems) {
+ item._targetNode.remove();
+ item._hideChildren();
+ }
+ },
+
+ /**
+ * Constructs and stores the target node displaying this tree item.
+ */
+ _constructTargetNode: function() {
+ if (this._constructed) {
+ return;
+ }
+ let document = this._containerNode.ownerDocument;
+
+ let arrowNode = this._arrowNode = document.createElement("hbox");
+ arrowNode.className = "arrow theme-twisty";
+ arrowNode.addEventListener("mousedown", this._onArrowClick);
+
+ let targetNode = this._targetNode = this._displaySelf(document, arrowNode);
+ targetNode.style.MozUserFocus = "normal";
+
+ targetNode.addEventListener("mousedown", this._onClick);
+ targetNode.addEventListener("dblclick", this._onDoubleClick);
+ targetNode.addEventListener("keypress", this._onKeyPress);
+ targetNode.addEventListener("focus", this._onFocus);
+
+ this._constructed = true;
+ },
+
+ /**
+ * Gets the element displaying an item in the tree at the specified offset
+ * relative to this item.
+ *
+ * @param number delta
+ * The offset from this item to the target item.
+ * @return nsIDOMNode
+ * The element displaying the target item at the specified offset.
+ */
+ _getSiblingAtDelta: function(delta) {
+ let childNodes = this._containerNode.childNodes;
+ let indexOfSelf = Array.indexOf(childNodes, this._targetNode);
+ return childNodes[indexOfSelf + delta];
+ },
+
+ /**
+ * Focuses the next item in this tree.
+ */
+ _focusNextNode: function() {
+ let nextElement = this._getSiblingAtDelta(1);
+ if (nextElement) nextElement.focus(); // nsIDOMNode
+ },
+
+ /**
+ * Focuses the previous item in this tree.
+ */
+ _focusPrevNode: function() {
+ let prevElement = this._getSiblingAtDelta(-1);
+ if (prevElement) prevElement.focus(); // nsIDOMNode
+ },
+
+ /**
+ * Focuses the parent item in this tree.
+ *
+ * The parent item is not always the previous item, because any tree item
+ * may have multiple children.
+ */
+ _focusParentNode: function() {
+ let parentItem = this._parentItem;
+ if (parentItem) parentItem.focus(); // AbstractTreeItem
+ },
+
+ /**
+ * Handler for the "click" event on the arrow node of this tree item.
+ */
+ _onArrowClick: function(e) {
+ if (!this._expanded) {
+ this.expand();
+ } else {
+ this.collapse();
+ }
+ },
+
+ /**
+ * Handler for the "click" event on the element displaying this tree item.
+ */
+ _onClick: function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.focus();
+ },
+
+ /**
+ * Handler for the "dblclick" event on the element displaying this tree item.
+ */
+ _onDoubleClick: function(e) {
+ // Ignore dblclick on the arrow as it has already recived and handled two
+ // click events.
+ if (!e.target.classList.contains("arrow")) {
+ this._onArrowClick(e);
+ }
+
+ this.focus();
+ },
+
+ /**
+ * Handler for the "keypress" event on the element displaying this tree item.
+ */
+ _onKeyPress: function(e) {
+ // Prevent scrolling when pressing navigation keys.
+ ViewHelpers.preventScrolling(e);
+
+ switch (e.keyCode) {
+ case e.DOM_VK_UP:
+ this._focusPrevNode();
+ return;
+
+ case e.DOM_VK_DOWN:
+ this._focusNextNode();
+ return;
+
+ case e.DOM_VK_LEFT:
+ if (this._expanded && this._populated) {
+ this.collapse();
+ } else {
+ this._focusParentNode();
+ }
+ return;
+
+ case e.DOM_VK_RIGHT:
+ if (!this._expanded) {
+ this.expand();
+ } else {
+ this._focusNextNode();
+ }
+ return;
+ }
+ },
+
+ /**
+ * Handler for the "focus" event on the element displaying this tree item.
+ */
+ _onFocus: function(e) {
+ this._rootItem.emit("focus", this);
+ }
+};
diff --git a/toolkit/devtools/shared/widgets/BreadcrumbsWidget.jsm b/toolkit/devtools/shared/widgets/BreadcrumbsWidget.jsm
new file mode 100644
index 000000000..0e6920e3b
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/BreadcrumbsWidget.jsm
@@ -0,0 +1,260 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Ci = Components.interfaces;
+const Cu = Components.utils;
+
+const ENSURE_SELECTION_VISIBLE_DELAY = 50; // ms
+
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource://gre/modules/devtools/event-emitter.js");
+
+this.EXPORTED_SYMBOLS = ["BreadcrumbsWidget"];
+
+/**
+ * A breadcrumb-like list of items.
+ *
+ * Note: this widget should be used in tandem with the WidgetMethods in
+ * ViewHelpers.jsm.
+ *
+ * @param nsIDOMNode aNode
+ * The element associated with the widget.
+ * @param Object aOptions
+ * - smoothScroll: specifies if smooth scrolling on selection is enabled.
+ */
+this.BreadcrumbsWidget = function BreadcrumbsWidget(aNode, aOptions={}) {
+ this.document = aNode.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = aNode;
+
+ // Create an internal arrowscrollbox container.
+ this._list = this.document.createElement("arrowscrollbox");
+ this._list.className = "breadcrumbs-widget-container";
+ this._list.setAttribute("flex", "1");
+ this._list.setAttribute("orient", "horizontal");
+ this._list.setAttribute("clicktoscroll", "true");
+ this._list.setAttribute("smoothscroll", !!aOptions.smoothScroll);
+ this._list.addEventListener("keypress", e => this.emit("keyPress", e), false);
+ this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false);
+ this._parent.appendChild(this._list);
+
+ // By default, hide the arrows. We let the arrowscrollbox show them
+ // in case of overflow.
+ this._list._scrollButtonUp.collapsed = true;
+ this._list._scrollButtonDown.collapsed = true;
+ this._list.addEventListener("underflow", this._onUnderflow.bind(this), false);
+ this._list.addEventListener("overflow", this._onOverflow.bind(this), false);
+
+ // These separators are used for CSS purposes only, and are positioned
+ // off screen, but displayed with -moz-element.
+ this._separators = this.document.createElement("box");
+ this._separators.className = "breadcrumb-separator-container";
+ this._separators.innerHTML =
+ "<box id='breadcrumb-separator-before'></box>" +
+ "<box id='breadcrumb-separator-after'></box>" +
+ "<box id='breadcrumb-separator-normal'></box>";
+ this._parent.appendChild(this._separators);
+
+ // This widget emits events that can be handled in a MenuContainer.
+ EventEmitter.decorate(this);
+
+ // Delegate some of the associated node's methods to satisfy the interface
+ // required by MenuContainer instances.
+ ViewHelpers.delegateWidgetAttributeMethods(this, aNode);
+ ViewHelpers.delegateWidgetEventMethods(this, aNode);
+};
+
+BreadcrumbsWidget.prototype = {
+ /**
+ * Inserts an item in this container at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ insertItemAt: function(aIndex, aContents) {
+ let list = this._list;
+ let breadcrumb = new Breadcrumb(this, aContents);
+ return list.insertBefore(breadcrumb._target, list.childNodes[aIndex]);
+ },
+
+ /**
+ * Returns the child node in this container situated at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ getItemAtIndex: function(aIndex) {
+ return this._list.childNodes[aIndex];
+ },
+
+ /**
+ * Removes the specified child node from this container.
+ *
+ * @param nsIDOMNode aChild
+ * The element associated with the displayed item.
+ */
+ removeChild: function(aChild) {
+ this._list.removeChild(aChild);
+
+ if (this._selectedItem == aChild) {
+ this._selectedItem = null;
+ }
+ },
+
+ /**
+ * Removes all of the child nodes from this container.
+ */
+ removeAllItems: function() {
+ let list = this._list;
+
+ while (list.hasChildNodes()) {
+ list.firstChild.remove();
+ }
+
+ this._selectedItem = null;
+ },
+
+ /**
+ * Gets the currently selected child node in this container.
+ * @return nsIDOMNode
+ */
+ get selectedItem() {
+ return this._selectedItem;
+ },
+
+ /**
+ * Sets the currently selected child node in this container.
+ * @param nsIDOMNode aChild
+ */
+ set selectedItem(aChild) {
+ let childNodes = this._list.childNodes;
+
+ if (!aChild) {
+ this._selectedItem = null;
+ }
+ for (let node of childNodes) {
+ if (node == aChild) {
+ node.setAttribute("checked", "");
+ this._selectedItem = node;
+ } else {
+ node.removeAttribute("checked");
+ }
+ }
+ },
+
+ /**
+ * Returns the value of the named attribute on this container.
+ *
+ * @param string aName
+ * The name of the attribute.
+ * @return string
+ * The current attribute value.
+ */
+ getAttribute: function(aName) {
+ if (aName == "scrollPosition") return this._list.scrollPosition;
+ if (aName == "scrollWidth") return this._list.scrollWidth;
+ return this._parent.getAttribute(aName);
+ },
+
+ /**
+ * Ensures the specified element is visible.
+ *
+ * @param nsIDOMNode aElement
+ * The element to make visible.
+ */
+ ensureElementIsVisible: function(aElement) {
+ if (!aElement) {
+ return;
+ }
+
+ // Repeated calls to ensureElementIsVisible would interfere with each other
+ // and may sometimes result in incorrect scroll positions.
+ setNamedTimeout("breadcrumb-select", ENSURE_SELECTION_VISIBLE_DELAY, () => {
+ if (this._list.ensureElementIsVisible) {
+ this._list.ensureElementIsVisible(aElement);
+ }
+ });
+ },
+
+ /**
+ * The underflow and overflow listener for the arrowscrollbox container.
+ */
+ _onUnderflow: function({ target }) {
+ if (target != this._list) {
+ return;
+ }
+ target._scrollButtonUp.collapsed = true;
+ target._scrollButtonDown.collapsed = true;
+ target.removeAttribute("overflows");
+ },
+
+ /**
+ * The underflow and overflow listener for the arrowscrollbox container.
+ */
+ _onOverflow: function({ target }) {
+ if (target != this._list) {
+ return;
+ }
+ target._scrollButtonUp.collapsed = false;
+ target._scrollButtonDown.collapsed = false;
+ target.setAttribute("overflows", "");
+ },
+
+ window: null,
+ document: null,
+ _parent: null,
+ _list: null,
+ _selectedItem: null
+};
+
+/**
+ * A Breadcrumb constructor for the BreadcrumbsWidget.
+ *
+ * @param BreadcrumbsWidget aWidget
+ * The widget to contain this breadcrumb.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ */
+function Breadcrumb(aWidget, aContents) {
+ this.document = aWidget.document;
+ this.window = aWidget.window;
+ this.ownerView = aWidget;
+
+ this._target = this.document.createElement("hbox");
+ this._target.className = "breadcrumbs-widget-item";
+ this._target.setAttribute("align", "center");
+ this.contents = aContents;
+}
+
+Breadcrumb.prototype = {
+ /**
+ * Sets the contents displayed in this item's view.
+ *
+ * @param string | nsIDOMNode aContents
+ * The string or node displayed in the container.
+ */
+ set contents(aContents) {
+ // If there are already some contents displayed, replace them.
+ if (this._target.hasChildNodes()) {
+ this._target.replaceChild(aContents, this._target.firstChild);
+ return;
+ }
+ // These are the first contents ever displayed.
+ this._target.appendChild(aContents);
+ },
+
+ window: null,
+ document: null,
+ ownerView: null,
+ _target: null
+};
diff --git a/toolkit/devtools/shared/widgets/Chart.jsm b/toolkit/devtools/shared/widgets/Chart.jsm
new file mode 100644
index 000000000..f487121b3
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/Chart.jsm
@@ -0,0 +1,450 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Ci = Components.interfaces;
+const Cu = Components.utils;
+
+const NET_STRINGS_URI = "chrome://browser/locale/devtools/netmonitor.properties";
+const SVG_NS = "http://www.w3.org/2000/svg";
+const PI = Math.PI;
+const TAU = PI * 2;
+const EPSILON = 0.0000001;
+const NAMED_SLICE_MIN_ANGLE = TAU / 8;
+const NAMED_SLICE_TEXT_DISTANCE_RATIO = 1.9;
+const HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO = 20;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource://gre/modules/devtools/event-emitter.js");
+
+this.EXPORTED_SYMBOLS = ["Chart"];
+
+/**
+ * Localization convenience methods.
+ */
+let L10N = new ViewHelpers.L10N(NET_STRINGS_URI);
+
+/**
+ * A factory for creating charts.
+ * Example usage: let myChart = Chart.Pie(document, { ... });
+ */
+let Chart = {
+ Pie: createPieChart,
+ Table: createTableChart,
+ PieTable: createPieTableChart
+};
+
+/**
+ * A simple pie chart proxy for the underlying view.
+ * Each item in the `slices` property represents a [data, node] pair containing
+ * the data used to create the slice and the nsIDOMNode displaying it.
+ *
+ * @param nsIDOMNode node
+ * The node representing the view for this chart.
+ */
+function PieChart(node) {
+ this.node = node;
+ this.slices = new WeakMap();
+ EventEmitter.decorate(this);
+}
+
+/**
+ * A simple table chart proxy for the underlying view.
+ * Each item in the `rows` property represents a [data, node] pair containing
+ * the data used to create the row and the nsIDOMNode displaying it.
+ *
+ * @param nsIDOMNode node
+ * The node representing the view for this chart.
+ */
+function TableChart(node) {
+ this.node = node;
+ this.rows = new WeakMap();
+ EventEmitter.decorate(this);
+}
+
+/**
+ * A simple pie+table chart proxy for the underlying view.
+ *
+ * @param nsIDOMNode node
+ * The node representing the view for this chart.
+ * @param PieChart pie
+ * The pie chart proxy.
+ * @param TableChart table
+ * The table chart proxy.
+ */
+function PieTableChart(node, pie, table) {
+ this.node = node;
+ this.pie = pie;
+ this.table = table;
+ EventEmitter.decorate(this);
+}
+
+/**
+ * Creates the DOM for a pie+table chart.
+ *
+ * @param nsIDocument document
+ * The document responsible with creating the DOM.
+ * @param object
+ * An object containing all or some of the following properties:
+ * - title: a string displayed as the table chart's (description)/local
+ * - diameter: the diameter of the pie chart, in pixels
+ * - data: an array of items used to display each slice in the pie
+ * and each row in the table;
+ * @see `createPieChart` and `createTableChart` for details.
+ * - strings: @see `createTableChart` for details.
+ * - totals: @see `createTableChart` for details.
+ * - sorted: a flag specifying if the `data` should be sorted
+ * ascending by `size`.
+ * @return PieTableChart
+ * A pie+table chart proxy instance, which emits the following events:
+ * - "mouseover", when the mouse enters a slice or a row
+ * - "mouseout", when the mouse leaves a slice or a row
+ * - "click", when the mouse enters a slice or a row
+ */
+function createPieTableChart(document, { title, diameter, data, strings, totals, sorted }) {
+ if (data && sorted) {
+ data = data.slice().sort((a, b) => +(a.size < b.size));
+ }
+
+ let pie = Chart.Pie(document, {
+ width: diameter,
+ data: data
+ });
+
+ let table = Chart.Table(document, {
+ title: title,
+ data: data,
+ strings: strings,
+ totals: totals
+ });
+
+ let container = document.createElement("hbox");
+ container.className = "pie-table-chart-container";
+ container.appendChild(pie.node);
+ container.appendChild(table.node);
+
+ let proxy = new PieTableChart(container, pie, table);
+
+ pie.on("click", (event, item) => {
+ proxy.emit(event, item)
+ });
+
+ table.on("click", (event, item) => {
+ proxy.emit(event, item)
+ });
+
+ pie.on("mouseover", (event, item) => {
+ proxy.emit(event, item);
+ if (table.rows.has(item)) {
+ table.rows.get(item).setAttribute("focused", "");
+ }
+ });
+
+ pie.on("mouseout", (event, item) => {
+ proxy.emit(event, item);
+ if (table.rows.has(item)) {
+ table.rows.get(item).removeAttribute("focused");
+ }
+ });
+
+ table.on("mouseover", (event, item) => {
+ proxy.emit(event, item);
+ if (pie.slices.has(item)) {
+ pie.slices.get(item).setAttribute("focused", "");
+ }
+ });
+
+ table.on("mouseout", (event, item) => {
+ proxy.emit(event, item);
+ if (pie.slices.has(item)) {
+ pie.slices.get(item).removeAttribute("focused");
+ }
+ });
+
+ return proxy;
+}
+
+/**
+ * Creates the DOM for a pie chart based on the specified properties.
+ *
+ * @param nsIDocument document
+ * The document responsible with creating the DOM.
+ * @param object
+ * An object containing all or some of the following properties:
+ * - data: an array of items used to display each slice; all the items
+ * should be objects containing a `size` and a `label` property.
+ * e.g: [{
+ * size: 1,
+ * label: "foo"
+ * }, {
+ * size: 2,
+ * label: "bar"
+ * }];
+ * - width: the width of the chart, in pixels
+ * - height: optional, the height of the chart, in pixels.
+ * - centerX: optional, the X-axis center of the chart, in pixels.
+ * - centerY: optional, the Y-axis center of the chart, in pixels.
+ * - radius: optional, the radius of the chart, in pixels.
+ * @return PieChart
+ * A pie chart proxy instance, which emits the following events:
+ * - "mouseover", when the mouse enters a slice
+ * - "mouseout", when the mouse leaves a slice
+ * - "click", when the mouse clicks a slice
+ */
+function createPieChart(document, { data, width, height, centerX, centerY, radius }) {
+ height = height || width;
+ centerX = centerX || width / 2;
+ centerY = centerY || height / 2;
+ radius = radius || (width + height) / 4;
+ let isPlaceholder = false;
+
+ // Filter out very small sizes, as they'll just render invisible slices.
+ data = data ? data.filter(e => e.size > EPSILON) : null;
+
+ // If there's no data available, display an empty placeholder.
+ if (!data) {
+ data = loadingPieChartData;
+ isPlaceholder = true;
+ }
+ if (!data.length) {
+ data = emptyPieChartData;
+ isPlaceholder = true;
+ }
+
+ let container = document.createElementNS(SVG_NS, "svg");
+ container.setAttribute("class", "generic-chart-container pie-chart-container");
+ container.setAttribute("pack", "center");
+ container.setAttribute("flex", "1");
+ container.setAttribute("width", width);
+ container.setAttribute("height", height);
+ container.setAttribute("viewBox", "0 0 " + width + " " + height);
+ container.setAttribute("slices", data.length);
+ container.setAttribute("placeholder", isPlaceholder);
+
+ let proxy = new PieChart(container);
+
+ let total = data.reduce((acc, e) => acc + e.size, 0);
+ let angles = data.map(e => e.size / total * (TAU - EPSILON));
+ let largest = data.reduce((a, b) => a.size > b.size ? a : b);
+ let smallest = data.reduce((a, b) => a.size < b.size ? a : b);
+
+ let textDistance = radius / NAMED_SLICE_TEXT_DISTANCE_RATIO;
+ let translateDistance = radius / HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO;
+ let startAngle = TAU;
+ let endAngle = 0;
+ let midAngle = 0;
+ radius -= translateDistance;
+
+ for (let i = data.length - 1; i >= 0; i--) {
+ let sliceInfo = data[i];
+ let sliceAngle = angles[i];
+ if (!sliceInfo.size || sliceAngle < EPSILON) {
+ continue;
+ }
+
+ endAngle = startAngle - sliceAngle;
+ midAngle = (startAngle + endAngle) / 2;
+
+ let x1 = centerX + radius * Math.sin(startAngle);
+ let y1 = centerY - radius * Math.cos(startAngle);
+ let x2 = centerX + radius * Math.sin(endAngle);
+ let y2 = centerY - radius * Math.cos(endAngle);
+ let largeArcFlag = Math.abs(startAngle - endAngle) > PI ? 1 : 0;
+
+ let pathNode = document.createElementNS(SVG_NS, "path");
+ pathNode.setAttribute("class", "pie-chart-slice chart-colored-blob");
+ pathNode.setAttribute("name", sliceInfo.label);
+ pathNode.setAttribute("d",
+ " M " + centerX + "," + centerY +
+ " L " + x2 + "," + y2 +
+ " A " + radius + "," + radius +
+ " 0 " + largeArcFlag +
+ " 1 " + x1 + "," + y1 +
+ " Z");
+
+ if (sliceInfo == largest) {
+ pathNode.setAttribute("largest", "");
+ }
+ if (sliceInfo == smallest) {
+ pathNode.setAttribute("smallest", "");
+ }
+
+ let hoverX = translateDistance * Math.sin(midAngle);
+ let hoverY = -translateDistance * Math.cos(midAngle);
+ let hoverTransform = "transform: translate(" + hoverX + "px, " + hoverY + "px)";
+ pathNode.setAttribute("style", data.length > 1 ? hoverTransform : "");
+
+ proxy.slices.set(sliceInfo, pathNode);
+ delegate(proxy, ["click", "mouseover", "mouseout"], pathNode, sliceInfo);
+ container.appendChild(pathNode);
+
+ if (sliceInfo.label && sliceAngle > NAMED_SLICE_MIN_ANGLE) {
+ let textX = centerX + textDistance * Math.sin(midAngle);
+ let textY = centerY - textDistance * Math.cos(midAngle);
+ let label = document.createElementNS(SVG_NS, "text");
+ label.appendChild(document.createTextNode(sliceInfo.label));
+ label.setAttribute("class", "pie-chart-label");
+ label.setAttribute("style", data.length > 1 ? hoverTransform : "");
+ label.setAttribute("x", data.length > 1 ? textX : centerX);
+ label.setAttribute("y", data.length > 1 ? textY : centerY);
+ container.appendChild(label);
+ }
+
+ startAngle = endAngle;
+ }
+
+ return proxy;
+}
+
+/**
+ * Creates the DOM for a table chart based on the specified properties.
+ *
+ * @param nsIDocument document
+ * The document responsible with creating the DOM.
+ * @param object
+ * An object containing all or some of the following properties:
+ * - title: a string displayed as the chart's (description)/local
+ * - data: an array of items used to display each row; all the items
+ * should be objects representing columns, for which the
+ * properties' values will be displayed in each cell of a row.
+ * e.g: [{
+ * label1: 1,
+ * label2: 3,
+ * label3: "foo"
+ * }, {
+ * label1: 4,
+ * label2: 6,
+ * label3: "bar
+ * }];
+ * - strings: an object specifying for which rows in the `data` array
+ * their cell values should be stringified and localized
+ * based on a predicate function;
+ * e.g: {
+ * label1: value => l10n.getFormatStr("...", value)
+ * }
+ * - totals: an object specifying for which rows in the `data` array
+ * the sum of their cells is to be displayed in the chart;
+ * e.g: {
+ * label1: total => l10n.getFormatStr("...", total), // 5
+ * label2: total => l10n.getFormatStr("...", total), // 9
+ * }
+ * @return TableChart
+ * A table chart proxy instance, which emits the following events:
+ * - "mouseover", when the mouse enters a row
+ * - "mouseout", when the mouse leaves a row
+ * - "click", when the mouse clicks a row
+ */
+function createTableChart(document, { title, data, strings, totals }) {
+ strings = strings || {};
+ totals = totals || {};
+ let isPlaceholder = false;
+
+ // If there's no data available, display an empty placeholder.
+ if (!data) {
+ data = loadingTableChartData;
+ isPlaceholder = true;
+ }
+ if (!data.length) {
+ data = emptyTableChartData;
+ isPlaceholder = true;
+ }
+
+ let container = document.createElement("vbox");
+ container.className = "generic-chart-container table-chart-container";
+ container.setAttribute("pack", "center");
+ container.setAttribute("flex", "1");
+ container.setAttribute("rows", data.length);
+ container.setAttribute("placeholder", isPlaceholder);
+
+ let proxy = new TableChart(container);
+
+ let titleNode = document.createElement("label");
+ titleNode.className = "plain table-chart-title";
+ titleNode.setAttribute("value", title);
+ container.appendChild(titleNode);
+
+ let tableNode = document.createElement("vbox");
+ tableNode.className = "plain table-chart-grid";
+ container.appendChild(tableNode);
+
+ for (let rowInfo of data) {
+ let rowNode = document.createElement("hbox");
+ rowNode.className = "table-chart-row";
+ rowNode.setAttribute("align", "center");
+
+ let boxNode = document.createElement("hbox");
+ boxNode.className = "table-chart-row-box chart-colored-blob";
+ boxNode.setAttribute("name", rowInfo.label);
+ rowNode.appendChild(boxNode);
+
+ for (let [key, value] in Iterator(rowInfo)) {
+ let index = data.indexOf(rowInfo);
+ let stringified = strings[key] ? strings[key](value, index) : value;
+ let labelNode = document.createElement("label");
+ labelNode.className = "plain table-chart-row-label";
+ labelNode.setAttribute("name", key);
+ labelNode.setAttribute("value", stringified);
+ rowNode.appendChild(labelNode);
+ }
+
+ proxy.rows.set(rowInfo, rowNode);
+ delegate(proxy, ["click", "mouseover", "mouseout"], rowNode, rowInfo);
+ tableNode.appendChild(rowNode);
+ }
+
+ let totalsNode = document.createElement("vbox");
+ totalsNode.className = "table-chart-totals";
+
+ for (let [key, value] in Iterator(totals)) {
+ let total = data.reduce((acc, e) => acc + e[key], 0);
+ let stringified = totals[key] ? totals[key](total || 0) : total;
+ let labelNode = document.createElement("label");
+ labelNode.className = "plain table-chart-summary-label";
+ labelNode.setAttribute("name", key);
+ labelNode.setAttribute("value", stringified);
+ totalsNode.appendChild(labelNode);
+ }
+
+ container.appendChild(totalsNode);
+
+ return proxy;
+}
+
+XPCOMUtils.defineLazyGetter(this, "loadingPieChartData", () => {
+ return [{ size: 1, label: L10N.getStr("pieChart.loading") }];
+});
+
+XPCOMUtils.defineLazyGetter(this, "emptyPieChartData", () => {
+ return [{ size: 1, label: L10N.getStr("pieChart.unavailable") }];
+});
+
+XPCOMUtils.defineLazyGetter(this, "loadingTableChartData", () => {
+ return [{ size: "", label: L10N.getStr("tableChart.loading") }];
+});
+
+XPCOMUtils.defineLazyGetter(this, "emptyTableChartData", () => {
+ return [{ size: "", label: L10N.getStr("tableChart.unavailable") }];
+});
+
+/**
+ * Delegates DOM events emitted by an nsIDOMNode to an EventEmitter proxy.
+ *
+ * @param EventEmitter emitter
+ * The event emitter proxy instance.
+ * @param array events
+ * An array of events, e.g. ["mouseover", "mouseout"].
+ * @param nsIDOMNode node
+ * The element firing the DOM events.
+ * @param any args
+ * The arguments passed when emitting events through the proxy.
+ */
+function delegate(emitter, events, node, args) {
+ for (let event of events) {
+ node.addEventListener(event, emitter.emit.bind(emitter, event, args));
+ }
+}
diff --git a/toolkit/devtools/shared/widgets/CubicBezierWidget.js b/toolkit/devtools/shared/widgets/CubicBezierWidget.js
new file mode 100644
index 000000000..9177253b8
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/CubicBezierWidget.js
@@ -0,0 +1,556 @@
+/**
+ * Copyright (c) 2013 Lea Verou. All rights reserved.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+
+// Based on www.cubic-bezier.com by Lea Verou
+// See https://github.com/LeaVerou/cubic-bezier
+
+"use strict";
+
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const {setTimeout, clearTimeout} = require("sdk/timers");
+
+const PREDEFINED = exports.PREDEFINED = {
+ "ease": [.25, .1, .25, 1],
+ "linear": [0, 0, 1, 1],
+ "ease-in": [.42, 0, 1, 1],
+ "ease-out": [0, 0, .58, 1],
+ "ease-in-out": [.42, 0, .58, 1]
+};
+
+/**
+ * CubicBezier data structure helper
+ * Accepts an array of coordinates and exposes a few useful getters
+ * @param {Array} coordinates i.e. [.42, 0, .58, 1]
+ */
+function CubicBezier(coordinates) {
+ if (!coordinates) {
+ throw "No offsets were defined";
+ }
+
+ this.coordinates = coordinates.map(n => +n);
+
+ for (let i = 4; i--;) {
+ let xy = this.coordinates[i];
+ if (isNaN(xy) || (!(i%2) && (xy < 0 || xy > 1))) {
+ throw "Wrong coordinate at " + i + "(" + xy + ")";
+ }
+ }
+
+ this.coordinates.toString = function() {
+ return this.map(n => {
+ return (Math.round(n * 100)/100 + '').replace(/^0\./, '.');
+ }) + "";
+ }
+}
+
+exports.CubicBezier = CubicBezier;
+
+CubicBezier.prototype = {
+ get P1() {
+ return this.coordinates.slice(0, 2);
+ },
+
+ get P2() {
+ return this.coordinates.slice(2);
+ },
+
+ toString: function() {
+ return 'cubic-bezier(' + this.coordinates + ')';
+ }
+};
+
+/**
+ * Bezier curve canvas plotting class
+ * @param {DOMNode} canvas
+ * @param {CubicBezier} bezier
+ * @param {Array} padding Amount of horizontal,vertical padding around the graph
+ */
+function BezierCanvas(canvas, bezier, padding) {
+ this.canvas = canvas;
+ this.bezier = bezier;
+ this.padding = getPadding(padding);
+
+ // Convert to a cartesian coordinate system with axes from 0 to 1
+ this.ctx = this.canvas.getContext('2d');
+ let p = this.padding;
+
+ this.ctx.scale(canvas.width * (1 - p[1] - p[3]),
+ -canvas.height * (1 - p[0] - p[2]));
+ this.ctx.translate(p[3] / (1 - p[1] - p[3]),
+ -1 - p[0] / (1 - p[0] - p[2]));
+};
+
+exports.BezierCanvas = BezierCanvas;
+
+BezierCanvas.prototype = {
+ /**
+ * Get P1 and P2 current top/left offsets so they can be positioned
+ * @return {Array} Returns an array of 2 {top:String,left:String} objects
+ */
+ get offsets() {
+ let p = this.padding, w = this.canvas.width, h = this.canvas.height;
+
+ return [{
+ left: w * (this.bezier.coordinates[0] * (1 - p[3] - p[1]) - p[3]) + 'px',
+ top: h * (1 - this.bezier.coordinates[1] * (1 - p[0] - p[2]) - p[0]) + 'px'
+ }, {
+ left: w * (this.bezier.coordinates[2] * (1 - p[3] - p[1]) - p[3]) + 'px',
+ top: h * (1 - this.bezier.coordinates[3] * (1 - p[0] - p[2]) - p[0]) + 'px'
+ }]
+ },
+
+ /**
+ * Convert an element's left/top offsets into coordinates
+ */
+ offsetsToCoordinates: function(element) {
+ let p = this.padding, w = this.canvas.width, h = this.canvas.height;
+
+ // Convert padding percentage to actual padding
+ p = p.map(function(a, i) { return a * (i % 2? w : h)});
+
+ return [
+ (parseInt(element.style.left) - p[3]) / (w + p[1] + p[3]),
+ (h - parseInt(element.style.top) - p[2]) / (h - p[0] - p[2])
+ ];
+ },
+
+ /**
+ * Draw the cubic bezier curve for the current coordinates
+ */
+ plot: function(settings={}) {
+ let xy = this.bezier.coordinates;
+
+ let defaultSettings = {
+ handleColor: '#666',
+ handleThickness: .008,
+ bezierColor: '#4C9ED9',
+ bezierThickness: .015
+ };
+
+ for (let setting in settings) {
+ defaultSettings[setting] = settings[setting];
+ }
+
+ this.ctx.clearRect(-.5,-.5, 2, 2);
+
+ // Draw control handles
+ this.ctx.beginPath();
+ this.ctx.fillStyle = defaultSettings.handleColor;
+ this.ctx.lineWidth = defaultSettings.handleThickness;
+ this.ctx.strokeStyle = defaultSettings.handleColor;
+
+ this.ctx.moveTo(0, 0);
+ this.ctx.lineTo(xy[0], xy[1]);
+ this.ctx.moveTo(1,1);
+ this.ctx.lineTo(xy[2], xy[3]);
+
+ this.ctx.stroke();
+ this.ctx.closePath();
+
+ function circle(ctx, cx, cy, r) {
+ return ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, 2*Math.PI, !1);
+ ctx.closePath();
+ }
+
+ circle(this.ctx, xy[0], xy[1], 1.5 * defaultSettings.handleThickness);
+ this.ctx.fill();
+ circle(this.ctx, xy[2], xy[3], 1.5 * defaultSettings.handleThickness);
+ this.ctx.fill();
+
+ // Draw bezier curve
+ this.ctx.beginPath();
+ this.ctx.lineWidth = defaultSettings.bezierThickness;
+ this.ctx.strokeStyle = defaultSettings.bezierColor;
+ this.ctx.moveTo(0,0);
+ this.ctx.bezierCurveTo(xy[0], xy[1], xy[2], xy[3], 1,1);
+ this.ctx.stroke();
+ this.ctx.closePath();
+ }
+};
+
+/**
+ * Cubic-bezier widget. Uses the BezierCanvas class to draw the curve and
+ * adds the control points and user interaction
+ * @param {DOMNode} parent The container where the graph should be created
+ * @param {Array} coordinates Coordinates of the curve to be drawn
+ *
+ * Emits "updated" events whenever the curve is changed. Along with the event is
+ * sent a CubicBezier object
+ */
+function CubicBezierWidget(parent, coordinates=PREDEFINED["ease-in-out"]) {
+ this.parent = parent;
+ let {curve, p1, p2} = this._initMarkup();
+
+ this.curve = curve;
+ this.curveBoundingBox = curve.getBoundingClientRect();
+ this.p1 = p1;
+ this.p2 = p2;
+
+ // Create and plot the bezier curve
+ this.bezierCanvas = new BezierCanvas(this.curve,
+ new CubicBezier(coordinates), [.25, 0]);
+ this.bezierCanvas.plot();
+
+ // Place the control points
+ let offsets = this.bezierCanvas.offsets;
+ this.p1.style.left = offsets[0].left;
+ this.p1.style.top = offsets[0].top;
+ this.p2.style.left = offsets[1].left;
+ this.p2.style.top = offsets[1].top;
+
+ this._onPointMouseDown = this._onPointMouseDown.bind(this);
+ this._onPointKeyDown = this._onPointKeyDown.bind(this);
+ this._onCurveClick = this._onCurveClick.bind(this);
+ this._initEvents();
+
+ // Add the timing function previewer
+ this.timingPreview = new TimingFunctionPreviewWidget(parent);
+
+ EventEmitter.decorate(this);
+}
+
+exports.CubicBezierWidget = CubicBezierWidget;
+
+CubicBezierWidget.prototype = {
+ _initMarkup: function() {
+ let doc = this.parent.ownerDocument;
+
+ let plane = doc.createElement("div");
+ plane.className = "coordinate-plane";
+
+ let p1 = doc.createElement("button");
+ p1.className = "control-point";
+ p1.id = "P1";
+ plane.appendChild(p1);
+
+ let p2 = doc.createElement("button");
+ p2.className = "control-point";
+ p2.id = "P2";
+ plane.appendChild(p2);
+
+ let curve = doc.createElement("canvas");
+ curve.setAttribute("height", "400");
+ curve.setAttribute("width", "200");
+ curve.id = "curve";
+ plane.appendChild(curve);
+
+ this.parent.appendChild(plane);
+
+ return {
+ p1: p1,
+ p2: p2,
+ curve: curve
+ }
+ },
+
+ _removeMarkup: function() {
+ this.parent.ownerDocument.querySelector(".coordinate-plane").remove();
+ },
+
+ _initEvents: function() {
+ this.p1.addEventListener("mousedown", this._onPointMouseDown);
+ this.p2.addEventListener("mousedown", this._onPointMouseDown);
+
+ this.p1.addEventListener("keydown", this._onPointKeyDown);
+ this.p2.addEventListener("keydown", this._onPointKeyDown);
+
+ this.curve.addEventListener("click", this._onCurveClick);
+ },
+
+ _removeEvents: function() {
+ this.p1.removeEventListener("mousedown", this._onPointMouseDown);
+ this.p2.removeEventListener("mousedown", this._onPointMouseDown);
+
+ this.p1.removeEventListener("keydown", this._onPointKeyDown);
+ this.p2.removeEventListener("keydown", this._onPointKeyDown);
+
+ this.curve.removeEventListener("click", this._onCurveClick);
+ },
+
+ _onPointMouseDown: function(event) {
+ // Updating the boundingbox in case it has changed
+ this.curveBoundingBox = this.curve.getBoundingClientRect();
+
+ let point = event.target;
+ let doc = point.ownerDocument;
+ let self = this;
+
+ doc.onmousemove = function drag(e) {
+ let x = e.pageX;
+ let y = e.pageY;
+ let left = self.curveBoundingBox.left;
+ let top = self.curveBoundingBox.top;
+
+ if (x === 0 && y == 0) {
+ return;
+ }
+
+ // Constrain x
+ x = Math.min(Math.max(left, x), left + self.curveBoundingBox.width);
+
+ point.style.left = x - left + "px";
+ point.style.top = y - top + "px";
+
+ self._updateFromPoints();
+ };
+
+ doc.onmouseup = function () {
+ point.focus();
+ doc.onmousemove = doc.onmouseup = null;
+ }
+ },
+
+ _onPointKeyDown: function(event) {
+ let point = event.target;
+ let code = event.keyCode;
+
+ if (code >= 37 && code <= 40) {
+ event.preventDefault();
+
+ // Arrow keys pressed
+ let left = parseInt(point.style.left);
+ let top = parseInt(point.style.top);
+ let offset = 3 * (event.shiftKey ? 10 : 1);
+
+ switch (code) {
+ case 37: point.style.left = left - offset + 'px'; break;
+ case 38: point.style.top = top - offset + 'px'; break;
+ case 39: point.style.left = left + offset + 'px'; break;
+ case 40: point.style.top = top + offset + 'px'; break;
+ }
+
+ this._updateFromPoints();
+ }
+ },
+
+ _onCurveClick: function(event) {
+ let left = this.curveBoundingBox.left;
+ let top = this.curveBoundingBox.top;
+ let x = event.pageX - left;
+ let y = event.pageY - top;
+
+ // Find which point is closer
+ let distP1 = distance(x, y,
+ parseInt(this.p1.style.left), parseInt(this.p1.style.top));
+ let distP2 = distance(x, y,
+ parseInt(this.p2.style.left), parseInt(this.p2.style.top));
+
+ let point = distP1 < distP2 ? this.p1 : this.p2;
+ point.style.left = x + "px";
+ point.style.top = y + "px";
+
+ this._updateFromPoints();
+ },
+
+ /**
+ * Get the current point coordinates and redraw the curve to match
+ */
+ _updateFromPoints: function() {
+ // Get the new coordinates from the point's offsets
+ let coordinates = this.bezierCanvas.offsetsToCoordinates(this.p1)
+ coordinates = coordinates.concat(this.bezierCanvas.offsetsToCoordinates(this.p2));
+
+ this._redraw(coordinates);
+ },
+
+ /**
+ * Redraw the curve
+ * @param {Array} coordinates The array of control point coordinates
+ */
+ _redraw: function(coordinates) {
+ // Provide a new CubicBezier to the canvas and plot the curve
+ this.bezierCanvas.bezier = new CubicBezier(coordinates);
+ this.bezierCanvas.plot();
+ this.emit("updated", this.bezierCanvas.bezier);
+
+ this.timingPreview.preview(this.bezierCanvas.bezier + "");
+ },
+
+ /**
+ * Set new coordinates for the control points and redraw the curve
+ * @param {Array} coordinates
+ */
+ set coordinates(coordinates) {
+ this._redraw(coordinates)
+
+ // Move the points
+ let offsets = this.bezierCanvas.offsets;
+ this.p1.style.left = offsets[0].left;
+ this.p1.style.top = offsets[0].top;
+ this.p2.style.left = offsets[1].left;
+ this.p2.style.top = offsets[1].top;
+ },
+
+ /**
+ * Set new coordinates for the control point and redraw the curve
+ * @param {String} value A string value. E.g. "linear", "cubic-bezier(0,0,1,1)"
+ */
+ set cssCubicBezierValue(value) {
+ if (!value) {
+ return;
+ }
+
+ value = value.trim();
+
+ // Try with one of the predefined values
+ let coordinates = PREDEFINED[value];
+
+ // Otherwise parse the coordinates from the cubic-bezier function
+ if (!coordinates && value.startsWith("cubic-bezier")) {
+ coordinates = value.replace(/cubic-bezier|\(|\)/g, "").split(",").map(parseFloat);
+ }
+
+ this.coordinates = coordinates;
+ },
+
+ destroy: function() {
+ this._removeEvents();
+ this._removeMarkup();
+
+ this.timingPreview.destroy();
+
+ this.curve = this.p1 = this.p2 = null;
+ }
+};
+
+/**
+ * The TimingFunctionPreviewWidget animates a dot on a scale with a given
+ * timing-function
+ * @param {DOMNode} parent The container where this widget should go
+ */
+function TimingFunctionPreviewWidget(parent) {
+ this.previousValue = null;
+ this.autoRestartAnimation = null;
+
+ this.parent = parent;
+ this._initMarkup();
+}
+
+TimingFunctionPreviewWidget.prototype = {
+ PREVIEW_DURATION: 1000,
+
+ _initMarkup: function() {
+ let doc = this.parent.ownerDocument;
+
+ let container = doc.createElement("div");
+ container.className = "timing-function-preview";
+
+ this.dot = doc.createElement("div");
+ this.dot.className = "dot";
+ container.appendChild(this.dot);
+
+ let scale = doc.createElement("div");
+ scale.className = "scale";
+ container.appendChild(scale);
+
+ this.parent.appendChild(container);
+ },
+
+ destroy: function() {
+ clearTimeout(this.autoRestartAnimation);
+ this.parent.querySelector(".timing-function-preview").remove();
+ this.parent = this.dot = null;
+ },
+
+ /**
+ * Preview a new timing function. The current preview will only be stopped if
+ * the supplied function value is different from the previous one. If the
+ * supplied function is invalid, the preview will stop.
+ * @param {String} value
+ */
+ preview: function(value) {
+ // Don't restart the preview animation if the value is the same
+ if (value === this.previousValue) {
+ return false;
+ }
+
+ clearTimeout(this.autoRestartAnimation);
+
+ if (isValidTimingFunction(value)) {
+ this.dot.style.animationTimingFunction = value;
+ this.restartAnimation();
+ }
+
+ this.previousValue = value;
+ },
+
+ /**
+ * Re-start the preview animation from the beginning
+ */
+ restartAnimation: function() {
+ // Reset the animation duration in case it was changed
+ this.dot.style.animationDuration = (this.PREVIEW_DURATION * 2) + "ms";
+
+ // Just toggling the class won't do it unless there's a sync reflow
+ this.dot.classList.remove("animate");
+ let w = this.dot.offsetWidth;
+ this.dot.classList.add("animate");
+
+ // Restart it again after a while
+ this.autoRestartAnimation = setTimeout(this.restartAnimation.bind(this),
+ this.PREVIEW_DURATION * 2);
+ }
+};
+
+// Helpers
+
+function getPadding(padding) {
+ let p = typeof padding === 'number'? [padding] : padding;
+
+ if (p.length === 1) {
+ p[1] = p[0];
+ }
+
+ if (p.length === 2) {
+ p[2] = p[0];
+ }
+
+ if (p.length === 3) {
+ p[3] = p[1];
+ }
+
+ return p;
+}
+
+function distance(x1, y1, x2, y2) {
+ return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
+}
+
+/**
+ * Checks whether a string is a valid timing-function value
+ * @param {String} value
+ * @return {Boolean}
+ */
+function isValidTimingFunction(value) {
+ // Either it's a predefined value
+ if (value in PREDEFINED) {
+ return true;
+ }
+
+ // Or it has to match a cubic-bezier expression
+ if (value.match(/^cubic-bezier\(([0-9.\- ]+,){3}[0-9.\- ]+\)/)) {
+ return true;
+ }
+
+ return false;
+}
diff --git a/toolkit/devtools/shared/widgets/FastListWidget.js b/toolkit/devtools/shared/widgets/FastListWidget.js
new file mode 100644
index 000000000..ffdb23025
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/FastListWidget.js
@@ -0,0 +1,250 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 EventEmitter = require("devtools/toolkit/event-emitter");
+const { Cu, Ci } = require("chrome");
+const { ViewHelpers } = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {});
+
+/**
+ * A list menu widget that attempts to be very fast.
+ *
+ * Note: this widget should be used in tandem with the WidgetMethods in
+ * ViewHelpers.jsm.
+ *
+ * @param nsIDOMNode aNode
+ * The element associated with the widget.
+ */
+const FastListWidget = module.exports = function FastListWidget(aNode) {
+ this.document = aNode.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = aNode;
+ this._fragment = this.document.createDocumentFragment();
+
+ // This is a prototype element that each item added to the list clones.
+ this._templateElement = this.document.createElement("hbox");
+
+ // Create an internal scrollbox container.
+ this._list = this.document.createElement("scrollbox");
+ this._list.className = "fast-list-widget-container theme-body";
+ this._list.setAttribute("flex", "1");
+ this._list.setAttribute("orient", "vertical");
+ this._list.setAttribute("tabindex", "0");
+ this._list.addEventListener("keypress", e => this.emit("keyPress", e), false);
+ this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false);
+ this._parent.appendChild(this._list);
+
+ this._orderedMenuElementsArray = [];
+ this._itemsByElement = new Map();
+
+ // This widget emits events that can be handled in a MenuContainer.
+ EventEmitter.decorate(this);
+
+ // Delegate some of the associated node's methods to satisfy the interface
+ // required by MenuContainer instances.
+ ViewHelpers.delegateWidgetAttributeMethods(this, aNode);
+ ViewHelpers.delegateWidgetEventMethods(this, aNode);
+}
+
+FastListWidget.prototype = {
+ /**
+ * Inserts an item in this container at the specified index, optionally
+ * grouping by name.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @param nsIDOMNode aContents
+ * The node to be displayed in the container.
+ * @param Object aAttachment [optional]
+ * Extra data for the user.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ insertItemAt: function(aIndex, aContents, aAttachment={}) {
+ let element = this._templateElement.cloneNode();
+ element.appendChild(aContents);
+
+ if (aIndex >= 0) {
+ throw new Error("FastListWidget only supports appending items.");
+ }
+
+ this._fragment.appendChild(element);
+ this._orderedMenuElementsArray.push(element);
+ this._itemsByElement.set(element, this);
+
+ return element;
+ },
+
+ /**
+ * This is a non-standard widget implementation method. When appending items,
+ * they are queued in a document fragment. This method appends the document
+ * fragment to the dom.
+ */
+ flush: function() {
+ this._list.appendChild(this._fragment);
+ },
+
+ /**
+ * Removes all of the child nodes from this container.
+ */
+ removeAllItems: function() {
+ let parent = this._parent;
+ let list = this._list;
+
+ while (list.hasChildNodes()) {
+ list.firstChild.remove();
+ }
+
+ this._selectedItem = null;
+
+ this._orderedMenuElementsArray.length = 0;
+ this._itemsByElement.clear();
+ },
+
+ /**
+ * Remove the given item.
+ */
+ removeChild: function(child) {
+ throw new Error("Not yet implemented");
+ },
+
+ /**
+ * Gets the currently selected child node in this container.
+ * @return nsIDOMNode
+ */
+ get selectedItem() {
+ return this._selectedItem;
+ },
+
+ /**
+ * Sets the currently selected child node in this container.
+ * @param nsIDOMNode child
+ */
+ set selectedItem(child) {
+ let menuArray = this._orderedMenuElementsArray;
+
+ if (!child) {
+ this._selectedItem = null;
+ }
+ for (let node of menuArray) {
+ if (node == child) {
+ node.classList.add("selected");
+ this._selectedItem = node;
+ } else {
+ node.classList.remove("selected");
+ }
+ }
+
+ this.ensureElementIsVisible(this.selectedItem);
+ },
+
+ /**
+ * Returns the child node in this container situated at the specified index.
+ *
+ * @param number index
+ * The position in the container intended for this item.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ getItemAtIndex: function(index) {
+ return this._orderedMenuElementsArray[index];
+ },
+
+ /**
+ * Adds a new attribute or changes an existing attribute on this container.
+ *
+ * @param string name
+ * The name of the attribute.
+ * @param string value
+ * The desired attribute value.
+ */
+ setAttribute: function(name, value) {
+ this._parent.setAttribute(name, value);
+
+ if (name == "emptyText") {
+ this._textWhenEmpty = value;
+ }
+ },
+
+ /**
+ * Removes an attribute on this container.
+ *
+ * @param string name
+ * The name of the attribute.
+ */
+ removeAttribute: function(name) {
+ this._parent.removeAttribute(name);
+
+ if (name == "emptyText") {
+ this._removeEmptyText();
+ }
+ },
+
+ /**
+ * Ensures the specified element is visible.
+ *
+ * @param nsIDOMNode element
+ * The element to make visible.
+ */
+ ensureElementIsVisible: function(element) {
+ if (!element) {
+ return;
+ }
+
+ // Ensure the element is visible but not scrolled horizontally.
+ let boxObject = this._list.boxObject;
+ boxObject.ensureElementIsVisible(element);
+ boxObject.scrollBy(-this._list.clientWidth, 0);
+ },
+
+ /**
+ * Sets the text displayed in this container when empty.
+ * @param string aValue
+ */
+ set _textWhenEmpty(aValue) {
+ if (this._emptyTextNode) {
+ this._emptyTextNode.setAttribute("value", aValue);
+ }
+ this._emptyTextValue = aValue;
+ this._showEmptyText();
+ },
+
+ /**
+ * Creates and appends a label signaling that this container is empty.
+ */
+ _showEmptyText: function() {
+ if (this._emptyTextNode || !this._emptyTextValue) {
+ return;
+ }
+ let label = this.document.createElement("label");
+ label.className = "plain fast-list-widget-empty-text";
+ label.setAttribute("value", this._emptyTextValue);
+
+ this._parent.insertBefore(label, this._list);
+ this._emptyTextNode = label;
+ },
+
+ /**
+ * Removes the label signaling that this container is empty.
+ */
+ _removeEmptyText: function() {
+ if (!this._emptyTextNode) {
+ return;
+ }
+ this._parent.removeChild(this._emptyTextNode);
+ this._emptyTextNode = null;
+ },
+
+ window: null,
+ document: null,
+ _parent: null,
+ _list: null,
+ _selectedItem: null,
+ _orderedMenuElementsArray: null,
+ _itemsByElement: null,
+ _emptyTextNode: null,
+ _emptyTextValue: ""
+};
diff --git a/toolkit/devtools/shared/widgets/FlameGraph.jsm b/toolkit/devtools/shared/widgets/FlameGraph.jsm
new file mode 100644
index 000000000..208e2e3d2
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/FlameGraph.jsm
@@ -0,0 +1,1023 @@
+/* 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 Cu = Components.utils;
+
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource:///modules/devtools/Graphs.jsm");
+const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
+const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
+
+this.EXPORTED_SYMBOLS = [
+ "FlameGraph",
+ "FlameGraphUtils"
+];
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const GRAPH_SRC = "chrome://browser/content/devtools/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_MIN_SELECTION_WIDTH = 0.001; // ms
+
+const TIMELINE_TICKS_MULTIPLE = 5; // ms
+const TIMELINE_TICKS_SPACING_MIN = 75; // px
+
+const OVERVIEW_HEADER_HEIGHT = 16; // px
+const OVERVIEW_HEADER_TEXT_COLOR = "#18191a";
+const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px
+const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif";
+const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6; // px
+const OVERVIEW_HEADER_TEXT_PADDING_TOP = 5; // px
+const OVERVIEW_TIMELINE_STROKES = "#ddd";
+
+const FLAME_GRAPH_BLOCK_BORDER = 1; // px
+const FLAME_GRAPH_BLOCK_TEXT_COLOR = "#000";
+const FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE = 8; // px
+const FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY = "sans-serif";
+const FLAME_GRAPH_BLOCK_TEXT_PADDING_TOP = 0; // px
+const FLAME_GRAPH_BLOCK_TEXT_PADDING_LEFT = 3; // px
+const FLAME_GRAPH_BLOCK_TEXT_PADDING_RIGHT = 3; // px
+
+/**
+ * A flamegraph visualization. This implementation is responsable only with
+ * drawing the graph, using a data source consisting of rectangles and
+ * their corresponding widths.
+ *
+ * Example usage:
+ * let graph = new FlameGraph(node);
+ * graph.once("ready", () => {
+ * let data = FlameGraphUtils.createFlameGraphDataFromSamples(samples);
+ * let bounds = { startTime, endTime };
+ * graph.setData({ data, bounds });
+ * });
+ *
+ * Data source format:
+ * [
+ * {
+ * color: "string",
+ * blocks: [
+ * {
+ * x: number,
+ * y: number,
+ * width: number,
+ * height: number,
+ * text: "string"
+ * },
+ * ...
+ * ]
+ * },
+ * {
+ * color: "string",
+ * blocks: [...]
+ * },
+ * ...
+ * {
+ * color: "string",
+ * blocks: [...]
+ * }
+ * ]
+ *
+ * Use `FlameGraphUtils` to convert profiler data (or any other data source)
+ * into a drawable format.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the graph.
+ * @param number sharpness [optional]
+ * Defaults to the current device pixel ratio.
+ */
+function FlameGraph(parent, sharpness) {
+ EventEmitter.decorate(this);
+
+ this._parent = parent;
+ this._ready = promise.defer();
+
+ AbstractCanvasGraph.createIframe(GRAPH_SRC, parent, iframe => {
+ this._iframe = iframe;
+ this._window = iframe.contentWindow;
+ this._document = iframe.contentDocument;
+ this._pixelRatio = sharpness || this._window.devicePixelRatio;
+
+ let container = this._container = this._document.getElementById("graph-container");
+ container.className = "flame-graph-widget-container graph-widget-container";
+
+ let canvas = this._canvas = this._document.getElementById("graph-canvas");
+ canvas.className = "flame-graph-widget-canvas graph-widget-canvas";
+
+ let bounds = parent.getBoundingClientRect();
+ bounds.width = this.fixedWidth || bounds.width;
+ bounds.height = this.fixedHeight || bounds.height;
+ iframe.setAttribute("width", bounds.width);
+ iframe.setAttribute("height", bounds.height);
+
+ this._width = canvas.width = bounds.width * this._pixelRatio;
+ this._height = canvas.height = bounds.height * this._pixelRatio;
+ this._ctx = canvas.getContext("2d");
+
+ this._bounds = new GraphSelection();
+ this._selection = new GraphSelection();
+ this._selectionDragger = new GraphSelectionDragger();
+
+ // 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._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("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);
+
+ this._animationId = this._window.requestAnimationFrame(this._onAnimationFrame);
+
+ this._ready.resolve(this);
+ this.emit("ready", this);
+ });
+}
+
+FlameGraph.prototype = {
+ /**
+ * Read-only width and height of the canvas.
+ * @return number
+ */
+ get width() {
+ return this._width;
+ },
+ get height() {
+ return this._height;
+ },
+
+ /**
+ * Returns a promise resolved once this graph is ready to receive data.
+ */
+ ready: function() {
+ return this._ready.promise;
+ },
+
+ /**
+ * Destroys this graph.
+ */
+ destroy: function() {
+ 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;
+ ownerWindow.removeEventListener("resize", this._onResize);
+
+ this._window.cancelAnimationFrame(this._animationId);
+ this._iframe.remove();
+
+ this._bounds = null;
+ this._selection = null;
+ this._selectionDragger = null;
+ this._textWidthsCache = null;
+
+ this._data = null;
+
+ this.emit("destroyed");
+ },
+
+ /**
+ * Rendering options. Subclasses should override these.
+ */
+ overviewHeaderTextColor: OVERVIEW_HEADER_TEXT_COLOR,
+ overviewTimelineStrokes: OVERVIEW_TIMELINE_STROKES,
+ blockTextColor: FLAME_GRAPH_BLOCK_TEXT_COLOR,
+
+ /**
+ * Makes sure the canvas graph is of the specified width or height, and
+ * doesn't flex to fit all the available space.
+ */
+ fixedWidth: null,
+ fixedHeight: null,
+
+ /**
+ * The units used in the overhead ticks. Could be "ms", for example.
+ * Overwrite this with your own localized format.
+ */
+ timelineTickUnits: "",
+
+ /**
+ * Character used when a block's text is overflowing.
+ * Defaults to an ellipsis.
+ */
+ overflowChar: L10N.ellipsis,
+
+ /**
+ * Sets the data source for this graph.
+ *
+ * @param object data
+ * An object containing the following properties:
+ * - data: the data source; see the constructor for more info
+ * - bounds: the minimum/maximum { start, end }, in ms or px
+ * - visible: optional, the shown { start, end }, in ms or px
+ */
+ setData: function({ data, bounds, visible }) {
+ this._data = data;
+ this.setOuterBounds(bounds);
+ this.setViewRange(visible || bounds);
+ },
+
+ /**
+ * Same as `setData`, but waits for this graph to finish initializing first.
+ *
+ * @param object data
+ * The data source. See the constructor for more information.
+ * @return promise
+ * A promise resolved once the data is set.
+ */
+ setDataWhenReady: Task.async(function*(data) {
+ yield this.ready();
+ this.setData(data);
+ }),
+
+ /**
+ * Gets whether or not this graph has a data source.
+ * @return boolean
+ */
+ hasData: function() {
+ return !!this._data;
+ },
+
+ /**
+ * Sets the maximum selection (i.e. the 'graph bounds').
+ * @param object { start, end }
+ */
+ setOuterBounds: function({ startTime, endTime }) {
+ this._bounds.start = startTime * this._pixelRatio;
+ this._bounds.end = endTime * this._pixelRatio;
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Sets the selection (i.e. the 'view range') bounds.
+ * @return number
+ */
+ setViewRange: function({ startTime, endTime }) {
+ this._selection.start = startTime * this._pixelRatio;
+ this._selection.end = endTime * this._pixelRatio;
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Gets the maximum selection (i.e. the 'graph bounds').
+ * @return number
+ */
+ getOuterBounds: function() {
+ return {
+ startTime: this._bounds.start / this._pixelRatio,
+ endTime: this._bounds.end / this._pixelRatio
+ };
+ },
+
+ /**
+ * Gets the current selection (i.e. the 'view range').
+ * @return number
+ */
+ getViewRange: function() {
+ return {
+ startTime: this._selection.start / this._pixelRatio,
+ endTime: this._selection.end / this._pixelRatio
+ };
+ },
+
+ /**
+ * Updates this graph to reflect the new dimensions of the parent node.
+ */
+ refresh: function() {
+ let bounds = this._parent.getBoundingClientRect();
+ let newWidth = this.fixedWidth || bounds.width;
+ let newHeight = this.fixedHeight || bounds.height;
+
+ // Prevent redrawing everything if the graph's width & height won't change.
+ if (this._width == newWidth * this._pixelRatio &&
+ this._height == newHeight * this._pixelRatio) {
+ this.emit("refresh-cancelled");
+ return;
+ }
+
+ bounds.width = newWidth;
+ bounds.height = newHeight;
+ this._iframe.setAttribute("width", bounds.width);
+ this._iframe.setAttribute("height", bounds.height);
+ this._width = this._canvas.width = bounds.width * this._pixelRatio;
+ this._height = this._canvas.height = bounds.height * this._pixelRatio;
+
+ this._shouldRedraw = true;
+ this.emit("refresh");
+ },
+
+ /**
+ * The contents of this graph are redrawn only when something changed,
+ * like the data source, or the selection bounds etc. This flag tracks
+ * if the rendering is "dirty" and needs to be refreshed.
+ */
+ _shouldRedraw: false,
+
+ /**
+ * Animation frame callback, invoked on each tick of the refresh driver.
+ */
+ _onAnimationFrame: function() {
+ this._animationId = this._window.requestAnimationFrame(this._onAnimationFrame);
+ this._drawWidget();
+ },
+
+ /**
+ * 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;
+ }
+ 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, selection.start, selectionScale);
+
+ this._shouldRedraw = false;
+ },
+
+ /**
+ * Draws the overhead 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.
+ */
+ _drawTicks: function(dataOffset, dataScale) {
+ let ctx = this._ctx;
+ let canvasWidth = this._width;
+ let canvasHeight = this._height;
+ let scaledOffset = dataOffset * dataScale;
+
+ let fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio;
+ let fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY;
+ let textPaddingLeft = OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio;
+ let textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio;
+ let tickInterval = this._findOptimalTickInterval(dataScale);
+
+ ctx.textBaseline = "top";
+ ctx.font = fontSize + "px " + fontFamily;
+ ctx.fillStyle = this.overviewHeaderTextColor;
+ ctx.strokeStyle = this.overviewTimelineStrokes;
+ ctx.beginPath();
+
+ for (let x = -scaledOffset % tickInterval; x < canvasWidth; x += tickInterval) {
+ let lineLeft = x;
+ let textLeft = lineLeft + textPaddingLeft;
+ let time = Math.round((x / dataScale + dataOffset) / this._pixelRatio);
+ let label = time + " " + this.timelineTickUnits;
+ ctx.fillText(label, textLeft, textPaddingTop);
+ ctx.moveTo(lineLeft, 0);
+ ctx.lineTo(lineLeft, canvasHeight);
+ }
+
+ ctx.stroke();
+ },
+
+ /**
+ * Draws the blocks and text in this graph.
+ *
+ * @param object dataSource
+ * The data source. See the constructor for more information.
+ * @param number dataOffset, dataScale
+ * Offsets and scales the data source by the specified amount.
+ * This is used for scrolling the visualization.
+ */
+ _drawPyramid: function(dataSource, dataOffset, dataScale) {
+ let ctx = this._ctx;
+
+ let fontSize = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE * this._pixelRatio;
+ let fontFamily = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY;
+ let visibleBlocks = this._drawPyramidFill(dataSource, dataOffset, dataScale);
+
+ ctx.textBaseline = "middle";
+ ctx.font = fontSize + "px " + fontFamily;
+ ctx.fillStyle = this.blockTextColor;
+
+ this._drawPyramidText(visibleBlocks, dataOffset, dataScale);
+ },
+
+ /**
+ * Fills all block inside this graph's pyramid.
+ * @see FlameGraph.prototype._drawPyramid
+ */
+ _drawPyramidFill: function(dataSource, dataOffset, dataScale) {
+ let visibleBlocksStore = [];
+ let minVisibleBlockWidth = this._overflowCharWidth;
+
+ for (let { color, blocks } of dataSource) {
+ this._drawBlocksFill(
+ color, blocks, dataOffset, dataScale,
+ visibleBlocksStore, minVisibleBlockWidth);
+ }
+
+ return visibleBlocksStore;
+ },
+
+ /**
+ * Adds the text for all block inside this graph's pyramid.
+ * @see FlameGraph.prototype._drawPyramid
+ */
+ _drawPyramidText: function(blocks, dataOffset, dataScale) {
+ for (let block of blocks) {
+ this._drawBlockText(block, dataOffset, dataScale);
+ }
+ },
+
+ /**
+ * Fills a group of blocks sharing the same style.
+ *
+ * @param string color
+ * The color used as the block's background.
+ * @param array blocks
+ * A list of { x, y, width, height } objects visually representing
+ * all the blocks sharing this particular style.
+ * @param number dataOffset, dataScale
+ * Offsets and scales the data source by the specified amount.
+ * This is used for scrolling the visualization.
+ * @param array visibleBlocksStore
+ * An array to store all the visible blocks into, after drawing them.
+ * The provided array will be populated.
+ * @param number minVisibleBlockWidth
+ * The minimum width of the blocks that will be added into
+ * the `visibleBlocksStore`.
+ */
+ _drawBlocksFill: function(
+ color, blocks, dataOffset, dataScale,
+ visibleBlocksStore, minVisibleBlockWidth)
+ {
+ let ctx = this._ctx;
+ let canvasWidth = this._width;
+ let canvasHeight = this._height;
+ let scaledOffset = dataOffset * dataScale;
+
+ ctx.fillStyle = color;
+ ctx.beginPath();
+
+ for (let block of blocks) {
+ let { x, y, width, height } = block;
+ let rectLeft = x * this._pixelRatio * dataScale - scaledOffset;
+ let rectTop = (y + OVERVIEW_HEADER_HEIGHT) * this._pixelRatio;
+ let rectWidth = width * this._pixelRatio * dataScale;
+ let rectHeight = height * this._pixelRatio;
+
+ if (rectLeft > canvasWidth || // Too far right.
+ rectLeft < -rectWidth || // Too far left.
+ rectTop > canvasHeight) { // Too far bottom.
+ continue;
+ }
+
+ // Clamp the blocks position to start at 0. Avoid negative X coords,
+ // to properly place the text inside the blocks.
+ if (rectLeft < 0) {
+ rectWidth += rectLeft;
+ rectLeft = 0;
+ }
+
+ // Avoid drawing blocks that are too narrow.
+ if (rectWidth <= FLAME_GRAPH_BLOCK_BORDER ||
+ rectHeight <= FLAME_GRAPH_BLOCK_BORDER) {
+ continue;
+ }
+
+ ctx.rect(
+ rectLeft, rectTop,
+ rectWidth - FLAME_GRAPH_BLOCK_BORDER,
+ rectHeight - FLAME_GRAPH_BLOCK_BORDER);
+
+ // Populate the visible blocks store with this block if the width
+ // is longer than a given threshold.
+ if (rectWidth > minVisibleBlockWidth) {
+ visibleBlocksStore.push(block);
+ }
+ }
+
+ ctx.fill();
+ },
+
+ /**
+ * Adds text for a single block.
+ *
+ * @param object block
+ * A single { x, y, width, height, text } object visually representing
+ * the block containing the text.
+ * @param number dataOffset, dataScale
+ * Offsets and scales the data source by the specified amount.
+ * This is used for scrolling the visualization.
+ */
+ _drawBlockText: function(block, dataOffset, dataScale) {
+ let ctx = this._ctx;
+ let scaledOffset = dataOffset * dataScale;
+
+ let { x, y, width, height, text } = block;
+
+ let paddingTop = FLAME_GRAPH_BLOCK_TEXT_PADDING_TOP * this._pixelRatio;
+ let paddingLeft = FLAME_GRAPH_BLOCK_TEXT_PADDING_LEFT * this._pixelRatio;
+ let paddingRight = FLAME_GRAPH_BLOCK_TEXT_PADDING_RIGHT * this._pixelRatio;
+ let totalHorizontalPadding = paddingLeft + paddingRight;
+
+ let rectLeft = x * this._pixelRatio * dataScale - scaledOffset;
+ let rectWidth = width * this._pixelRatio * dataScale;
+
+ // Clamp the blocks position to start at 0. Avoid negative X coords,
+ // to properly place the text inside the blocks.
+ if (rectLeft < 0) {
+ rectWidth += rectLeft;
+ rectLeft = 0;
+ }
+
+ let textLeft = rectLeft + paddingLeft;
+ let textTop = (y + height / 2 + OVERVIEW_HEADER_HEIGHT) * this._pixelRatio + paddingTop;
+ let textAvailableWidth = rectWidth - totalHorizontalPadding;
+
+ // Massage the text to fit inside a given width. This clamps the string
+ // at the end to avoid overflowing.
+ let fittedText = this._getFittedText(text, textAvailableWidth);
+ if (fittedText.length < 1) {
+ return;
+ }
+
+ ctx.fillText(fittedText, textLeft, textTop);
+ },
+
+ /**
+ * 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.
+ */
+ _textWidthsCache: null,
+ _overflowCharWidth: null,
+ _averageCharWidth: null,
+
+ /**
+ * Gets the width of the specified text, for the current context state
+ * (font size, family etc.).
+ *
+ * @param string text
+ * The text to analyze.
+ * @return number
+ * The text width.
+ */
+ _getTextWidth: function(text) {
+ let cachedWidth = this._textWidthsCache[text];
+ if (cachedWidth) {
+ return cachedWidth;
+ }
+ let metrics = this._ctx.measureText(text);
+ return (this._textWidthsCache[text] = metrics.width);
+ },
+
+ /**
+ * Gets an approximate width of the specified text. This is much faster
+ * than `_getTextWidth`, but inexact.
+ *
+ * @param string text
+ * The text to analyze.
+ * @return number
+ * The approximate text width.
+ */
+ _getTextWidthApprox: function(text) {
+ return text.length * this._averageCharWidth;
+ },
+
+ /**
+ * Gets the average letter width in the English alphabet, for the current
+ * context state (font size, family etc.). This provides a close enough
+ * value to use in `_getTextWidthApprox`.
+ *
+ * @return number
+ * The average letter width.
+ */
+ _calcAverageCharWidth: function() {
+ let letterWidthsSum = 0;
+ let start = 32; // space
+ let end = 123; // "z"
+
+ for (let i = start; i < end; i++) {
+ let char = String.fromCharCode(i);
+ letterWidthsSum += this._getTextWidth(char);
+ }
+
+ return letterWidthsSum / (end - start);
+ },
+
+ /**
+ * Massage a text to fit inside a given width. This clamps the string
+ * at the end to avoid overflowing.
+ *
+ * @param string text
+ * The text to fit inside the given width.
+ * @param number maxWidth
+ * The available width for the given text.
+ * @return string
+ * The fitted text.
+ */
+ _getFittedText: function(text, maxWidth) {
+ let textWidth = this._getTextWidth(text);
+ if (textWidth < maxWidth) {
+ return text;
+ }
+ if (this._overflowCharWidth > maxWidth) {
+ return "";
+ }
+ for (let i = 1, len = text.length; i <= len; i++) {
+ let trimmedText = text.substring(0, len - i);
+ let trimmedWidth = this._getTextWidthApprox(trimmedText) + this._overflowCharWidth;
+ if (trimmedWidth < maxWidth) {
+ return trimmedText + this.overflowChar;
+ }
+ }
+ return "";
+ },
+
+ /**
+ * Listener for the "mousemove" event on the graph's container.
+ */
+ _onMouseMove: function(e) {
+ let offset = this._getContainerOffset();
+ let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+
+ let canvasWidth = this._width;
+ let canvasHeight = this._height;
+
+ let selection = this._selection;
+ let selectionWidth = selection.end - selection.start;
+ let selectionScale = canvasWidth / selectionWidth;
+
+ let dragger = this._selectionDragger;
+ if (dragger.origin != null) {
+ selection.start = dragger.anchor.start + (dragger.origin - mouseX) / selectionScale;
+ selection.end = dragger.anchor.end + (dragger.origin - mouseX) / selectionScale;
+ this._normalizeSelectionBounds();
+ this._shouldRedraw = true;
+ this.emit("selecting");
+ }
+ },
+
+ /**
+ * Listener for the "mousedown" event on the graph's container.
+ */
+ _onMouseDown: function(e) {
+ let offset = this._getContainerOffset();
+ let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+
+ this._selectionDragger.origin = mouseX;
+ this._selectionDragger.anchor.start = this._selection.start;
+ this._selectionDragger.anchor.end = this._selection.end;
+ this._canvas.setAttribute("input", "adjusting-selection-boundary");
+ },
+
+ /**
+ * Listener for the "mouseup" event on the graph's container.
+ */
+ _onMouseUp: function() {
+ this._selectionDragger.origin = null;
+ this._canvas.removeAttribute("input");
+ },
+
+ /**
+ * Listener for the "wheel" event on the graph's container.
+ */
+ _onMouseWheel: function(e) {
+ let offset = this._getContainerOffset();
+ let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+
+ let canvasWidth = this._width;
+ let canvasHeight = this._height;
+
+ let selection = this._selection;
+ let selectionWidth = selection.end - selection.start;
+ let selectionScale = canvasWidth / selectionWidth;
+
+ switch (e.axis) {
+ case e.VERTICAL_AXIS: {
+ let distFromStart = mouseX;
+ let distFromEnd = canvasWidth - mouseX;
+ let vector = e.detail * GRAPH_WHEEL_ZOOM_SENSITIVITY / selectionScale;
+ selection.start -= distFromStart * vector;
+ selection.end += distFromEnd * vector;
+ break;
+ }
+ case e.HORIZONTAL_AXIS: {
+ let vector = e.detail * GRAPH_WHEEL_SCROLL_SENSITIVITY / selectionScale;
+ selection.start += vector;
+ selection.end += vector;
+ break;
+ }
+ }
+
+ this._normalizeSelectionBounds();
+ this._shouldRedraw = true;
+ this.emit("selecting");
+ },
+
+ /**
+ * Makes sure the start and end points of the current selection
+ * are withing the graph's visible bounds, and that they form a selection
+ * wider than the allowed minimum width.
+ */
+ _normalizeSelectionBounds: function() {
+ let boundsStart = this._bounds.start;
+ let boundsEnd = this._bounds.end;
+ let selectionStart = this._selection.start;
+ let selectionEnd = this._selection.end;
+
+ if (selectionStart < boundsStart) {
+ selectionStart = boundsStart;
+ }
+ if (selectionEnd < boundsStart) {
+ selectionStart = boundsStart;
+ selectionEnd = GRAPH_MIN_SELECTION_WIDTH;
+ }
+ if (selectionEnd > boundsEnd) {
+ selectionEnd = boundsEnd;
+ }
+ if (selectionStart > boundsEnd) {
+ selectionEnd = boundsEnd;
+ selectionStart = boundsEnd - GRAPH_MIN_SELECTION_WIDTH;
+ }
+ if (selectionEnd - selectionStart < GRAPH_MIN_SELECTION_WIDTH) {
+ let midPoint = (selectionStart + selectionEnd) / 2;
+ selectionStart = midPoint - GRAPH_MIN_SELECTION_WIDTH / 2;
+ selectionEnd = midPoint + GRAPH_MIN_SELECTION_WIDTH / 2;
+ }
+
+ this._selection.start = selectionStart;
+ this._selection.end = selectionEnd;
+ },
+
+ /**
+ *
+ * Finds the optimal tick interval between time markers in this graph.
+ *
+ * @param number dataScale
+ * @return number
+ */
+ _findOptimalTickInterval: function(dataScale) {
+ let timingStep = TIMELINE_TICKS_MULTIPLE;
+ let spacingMin = TIMELINE_TICKS_SPACING_MIN * this._pixelRatio;
+
+ if (dataScale > spacingMin) {
+ return dataScale;
+ }
+
+ while (true) {
+ let scaledStep = dataScale * timingStep;
+ if (scaledStep < spacingMin) {
+ timingStep <<= 1;
+ continue;
+ }
+ return scaledStep;
+ }
+ },
+
+ /**
+ * Gets the offset of this graph's container relative to the owner window.
+ *
+ * @return object
+ * The { left, top } offset.
+ */
+ _getContainerOffset: function() {
+ let node = this._canvas;
+ let x = 0;
+ let y = 0;
+
+ while ((node = node.offsetParent)) {
+ x += node.offsetLeft;
+ y += node.offsetTop;
+ }
+
+ return { left: x, top: y };
+ },
+
+ /**
+ * Listener for the "resize" event on the graph's parent node.
+ */
+ _onResize: function() {
+ if (this.hasData()) {
+ setNamedTimeout(this._uid, GRAPH_RESIZE_EVENTS_DRAIN, this.refresh);
+ }
+ }
+};
+
+const FLAME_GRAPH_BLOCK_HEIGHT = 11; // px
+
+const PALLETTE_SIZE = 10;
+const PALLETTE_HUE_OFFSET = Math.random() * 90;
+const PALLETTE_HUE_RANGE = 270;
+const PALLETTE_SATURATION = 60;
+const PALLETTE_BRIGHTNESS = 75;
+const PALLETTE_OPACITY = 0.7;
+
+const COLOR_PALLETTE = Array.from(Array(PALLETTE_SIZE)).map((_, i) => "hsla" +
+ "(" + ((PALLETTE_HUE_OFFSET + (i / PALLETTE_SIZE * PALLETTE_HUE_RANGE))|0 % 360) +
+ "," + PALLETTE_SATURATION + "%" +
+ "," + PALLETTE_BRIGHTNESS + "%" +
+ "," + PALLETTE_OPACITY +
+ ")"
+);
+
+/**
+ * A collection of utility functions converting various data sources
+ * into a format drawable by the FlameGraph.
+ */
+let FlameGraphUtils = {
+ _cache: new WeakMap(),
+
+ /**
+ * Converts a list of samples from the profiler data to something that's
+ * drawable by a FlameGraph widget.
+ *
+ * The outputted data will be cached, so the next time this method is called
+ * the previous output is returned. If this is undesirable, or should the
+ * options change, use `removeFromCache`.
+ *
+ * @param array samples
+ * A list of { time, frames: [{ location }] } objects.
+ * @param object options [optional]
+ * Additional options supported by this operation:
+ * - invertStack: specifies if the frames array in every sample
+ * should be reversed
+ * - flattenRecursion: specifies if identical consecutive frames
+ * should be omitted from the output
+ * - filterFrames: predicate used for filtering all frames, passing
+ * in each frame, its index and the sample array
+ * - showIdleBlocks: adds "idle" blocks when no frames are available
+ * using the provided localized text
+ * @param array out [optional]
+ * An output storage to reuse for storing the flame graph data.
+ * @return array
+ * The flame graph data.
+ */
+ createFlameGraphDataFromSamples: function(samples, options = {}, out = []) {
+ let cached = this._cache.get(samples);
+ if (cached) {
+ return cached;
+ }
+
+ // 1. Create a map of colors to arrays, representing buckets of
+ // blocks inside the flame graph pyramid sharing the same style.
+
+ let buckets = new Map();
+
+ for (let color of COLOR_PALLETTE) {
+ buckets.set(color, []);
+ }
+
+ // 2. Populate the buckets by iterating over every frame in every sample.
+
+ let prevTime = 0;
+ let prevFrames = [];
+
+ for (let { frames, time } of samples) {
+ let frameIndex = 0;
+
+ // Flatten recursion if preferred, by removing consecutive frames
+ // sharing the same location.
+ if (options.flattenRecursion) {
+ frames = frames.filter(this._isConsecutiveDuplicate);
+ }
+
+ // Apply a provided filter function. This can be used, for example, to
+ // filter out platform frames if only content-related function calls
+ // should be taken into consideration.
+ if (options.filterFrames) {
+ frames = frames.filter(options.filterFrames);
+ }
+
+ // Invert the stack if preferred, reversing the frames array in place.
+ if (options.invertStack) {
+ frames.reverse();
+ }
+
+ // If no frames are available, add a pseudo "idle" block in between.
+ if (options.showIdleBlocks && frames.length == 0) {
+ frames = [{ location: options.showIdleBlocks || "" }];
+ }
+
+ for (let { location } of frames) {
+ let prevFrame = prevFrames[frameIndex];
+
+ // Frames at the same location and the same depth will be reused.
+ // If there is a block already created, change its width.
+ if (prevFrame && prevFrame.srcData.rawLocation == location) {
+ prevFrame.width = (time - prevFrame.srcData.startTime);
+ }
+ // Otherwise, create a new block for this frame at this depth,
+ // using a simple location based salt for picking a color.
+ else {
+ let hash = this._getStringHash(location);
+ let color = COLOR_PALLETTE[hash % PALLETTE_SIZE];
+ let bucket = buckets.get(color);
+
+ bucket.push(prevFrames[frameIndex] = {
+ srcData: { startTime: prevTime, rawLocation: location },
+ x: prevTime,
+ y: frameIndex * FLAME_GRAPH_BLOCK_HEIGHT,
+ width: time - prevTime,
+ height: FLAME_GRAPH_BLOCK_HEIGHT,
+ text: location
+ });
+ }
+
+ frameIndex++;
+ }
+
+ // Previous frames at stack depths greater than the current sample's
+ // maximum need to be nullified. It's nonsensical to reuse them.
+ prevFrames.length = frameIndex;
+ prevTime = time;
+ }
+
+ // 3. Convert the buckets into a data source usable by the FlameGraph.
+ // This is a simple conversion from a Map to an Array.
+
+ for (let [color, blocks] of buckets) {
+ out.push({ color, blocks });
+ }
+
+ this._cache.set(samples, out);
+ return out;
+ },
+
+ /**
+ * Clears the cached flame graph data created for the given source.
+ * @param any source
+ */
+ removeFromCache: function(source) {
+ this._cache.delete(source);
+ },
+
+ /**
+ * Checks if the provided frame is the same as the next one in a sample.
+ *
+ * @param object e
+ * An object containing a { location } property.
+ * @param number index
+ * The index of the object in the parent array.
+ * @param array array
+ * The parent array.
+ * @return boolean
+ * True if the next frame shares the same location, false otherwise.
+ */
+ _isConsecutiveDuplicate: function(e, index, array) {
+ return index < array.length - 1 && e.location != array[index + 1].location;
+ },
+
+ /**
+ * Very dumb hashing of a string. Used to pick colors from a pallette.
+ *
+ * @param string input
+ * @return number
+ */
+ _getStringHash: function(input) {
+ const STRING_HASH_PRIME1 = 7;
+ const STRING_HASH_PRIME2 = 31;
+
+ let hash = STRING_HASH_PRIME1;
+
+ for (let i = 0, len = input.length; i < len; i++) {
+ hash *= STRING_HASH_PRIME2;
+ hash += input.charCodeAt(i);
+
+ if (hash > Number.MAX_SAFE_INTEGER / STRING_HASH_PRIME2) {
+ return hash;
+ }
+ }
+
+ return hash;
+ }
+};
diff --git a/toolkit/devtools/shared/widgets/Graphs.jsm b/toolkit/devtools/shared/widgets/Graphs.jsm
new file mode 100644
index 000000000..ac27bd10e
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/Graphs.jsm
@@ -0,0 +1,2199 @@
+/* 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 Cu = Components.utils;
+
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
+const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
+
+this.EXPORTED_SYMBOLS = [
+ "GraphCursor",
+ "GraphSelection",
+ "GraphSelectionDragger",
+ "GraphSelectionResizer",
+ "AbstractCanvasGraph",
+ "LineGraphWidget",
+ "BarGraphWidget",
+ "CanvasGraphUtils"
+];
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const GRAPH_SRC = "chrome://browser/content/devtools/graphs-frame.xhtml";
+const WORKER_URL = "resource:///modules/devtools/GraphsWorker.js";
+const L10N = new ViewHelpers.L10N();
+
+// Generic constants.
+
+const GRAPH_RESIZE_EVENTS_DRAIN = 100; // ms
+const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00075;
+const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.1;
+const GRAPH_WHEEL_MIN_SELECTION_WIDTH = 10; // px
+
+const GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH = 4; // px
+const GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD = 10; // px
+const GRAPH_MAX_SELECTION_LEFT_PADDING = 1;
+const GRAPH_MAX_SELECTION_RIGHT_PADDING = 1;
+
+const GRAPH_REGION_LINE_WIDTH = 1; // px
+const GRAPH_REGION_LINE_COLOR = "rgba(237,38,85,0.8)";
+
+const GRAPH_STRIPE_PATTERN_WIDTH = 16; // px
+const GRAPH_STRIPE_PATTERN_HEIGHT = 16; // px
+const GRAPH_STRIPE_PATTERN_LINE_WIDTH = 2; // px
+const GRAPH_STRIPE_PATTERN_LINE_SPACING = 4; // px
+
+// Line graph constants.
+
+const LINE_GRAPH_DAMPEN_VALUES = 0.85;
+const LINE_GRAPH_TOOLTIP_SAFE_BOUNDS = 8; // px
+const LINE_GRAPH_MIN_MAX_TOOLTIP_DISTANCE = 14; // px
+
+const LINE_GRAPH_BACKGROUND_COLOR = "#0088cc";
+const LINE_GRAPH_STROKE_WIDTH = 1; // px
+const LINE_GRAPH_STROKE_COLOR = "rgba(255,255,255,0.9)";
+const LINE_GRAPH_HELPER_LINES_DASH = [5]; // px
+const LINE_GRAPH_HELPER_LINES_WIDTH = 1; // px
+const LINE_GRAPH_MAXIMUM_LINE_COLOR = "rgba(255,255,255,0.4)";
+const LINE_GRAPH_AVERAGE_LINE_COLOR = "rgba(255,255,255,0.7)";
+const LINE_GRAPH_MINIMUM_LINE_COLOR = "rgba(255,255,255,0.9)";
+const LINE_GRAPH_BACKGROUND_GRADIENT_START = "rgba(255,255,255,0.25)";
+const LINE_GRAPH_BACKGROUND_GRADIENT_END = "rgba(255,255,255,0.0)";
+
+const LINE_GRAPH_CLIPHEAD_LINE_COLOR = "#fff";
+const LINE_GRAPH_SELECTION_LINE_COLOR = "#fff";
+const LINE_GRAPH_SELECTION_BACKGROUND_COLOR = "rgba(44,187,15,0.25)";
+const LINE_GRAPH_SELECTION_STRIPES_COLOR = "rgba(255,255,255,0.1)";
+const LINE_GRAPH_REGION_BACKGROUND_COLOR = "transparent";
+const LINE_GRAPH_REGION_STRIPES_COLOR = "rgba(237,38,85,0.2)";
+
+// Bar graph constants.
+
+const BAR_GRAPH_DAMPEN_VALUES = 0.75;
+const BAR_GRAPH_BARS_MARGIN_TOP = 1; // px
+const BAR_GRAPH_BARS_MARGIN_END = 1; // px
+const BAR_GRAPH_MIN_BARS_WIDTH = 5; // px
+const BAR_GRAPH_MIN_BLOCKS_HEIGHT = 1; // px
+
+const BAR_GRAPH_BACKGROUND_GRADIENT_START = "rgba(0,136,204,0.0)";
+const BAR_GRAPH_BACKGROUND_GRADIENT_END = "rgba(255,255,255,0.25)";
+
+const BAR_GRAPH_CLIPHEAD_LINE_COLOR = "#666";
+const BAR_GRAPH_SELECTION_LINE_COLOR = "#555";
+const BAR_GRAPH_SELECTION_BACKGROUND_COLOR = "rgba(0,136,204,0.25)";
+const BAR_GRAPH_SELECTION_STRIPES_COLOR = "rgba(255,255,255,0.1)";
+const BAR_GRAPH_REGION_BACKGROUND_COLOR = "transparent";
+const BAR_GRAPH_REGION_STRIPES_COLOR = "rgba(237,38,85,0.2)";
+
+const BAR_GRAPH_HIGHLIGHTS_MASK_BACKGROUND = "rgba(255,255,255,0.75)";
+const BAR_GRAPH_HIGHLIGHTS_MASK_STRIPES = "rgba(255,255,255,0.5)";
+
+const BAR_GRAPH_LEGEND_MOUSEOVER_DEBOUNCE = 50; // ms
+
+/**
+ * Small data primitives for all graphs.
+ */
+this.GraphCursor = function() {
+ this.x = null;
+ this.y = null;
+};
+
+this.GraphSelection = function() {
+ this.start = null;
+ this.end = null;
+};
+
+this.GraphSelectionDragger = function() {
+ this.origin = null;
+ this.anchor = new GraphSelection();
+};
+
+this.GraphSelectionResizer = function() {
+ this.margin = null;
+};
+
+/**
+ * Base class for all graphs using a canvas to render the data source. Handles
+ * frame creation, data source, selection bounds, cursor position, etc.
+ *
+ * Language:
+ * - The "data" represents the values used when building the graph.
+ * Its specific format is defined by the inheriting classes.
+ *
+ * - A "cursor" is the cliphead position across the X axis of the graph.
+ *
+ * - A "selection" is defined by a "start" and an "end" value and
+ * represents the selected bounds in the graph.
+ *
+ * - A "region" is a highlighted area in the graph, also defined by a
+ * "start" and an "end" value, but distinct from the "selection". It is
+ * simply used to highlight important regions in the data.
+ *
+ * Instances of this class are EventEmitters with the following events:
+ * - "ready": when the container iframe and canvas are created.
+ * - "selecting": when the selection is set or changed.
+ * - "deselecting": when the selection is dropped.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the graph.
+ * @param string name
+ * The graph type, used for setting the correct class names.
+ * Currently supported: "line-graph" only.
+ * @param number sharpness [optional]
+ * Defaults to the current device pixel ratio.
+ */
+this.AbstractCanvasGraph = function(parent, name, sharpness) {
+ EventEmitter.decorate(this);
+
+ this._parent = parent;
+ this._ready = promise.defer();
+
+ this._uid = "canvas-graph-" + Date.now();
+ this._renderTargets = new Map();
+
+ AbstractCanvasGraph.createIframe(GRAPH_SRC, parent, iframe => {
+ this._iframe = iframe;
+ this._window = iframe.contentWindow;
+ this._document = iframe.contentDocument;
+ this._pixelRatio = sharpness || this._window.devicePixelRatio;
+
+ let container = this._container = this._document.getElementById("graph-container");
+ container.className = name + "-widget-container graph-widget-container";
+
+ let canvas = this._canvas = this._document.getElementById("graph-canvas");
+ canvas.className = name + "-widget-canvas graph-widget-canvas";
+
+ let bounds = parent.getBoundingClientRect();
+ bounds.width = this.fixedWidth || bounds.width;
+ bounds.height = this.fixedHeight || bounds.height;
+ iframe.setAttribute("width", bounds.width);
+ iframe.setAttribute("height", bounds.height);
+
+ this._width = canvas.width = bounds.width * this._pixelRatio;
+ this._height = canvas.height = bounds.height * this._pixelRatio;
+ this._ctx = canvas.getContext("2d");
+ this._ctx.mozImageSmoothingEnabled = false;
+
+ this._cursor = new GraphCursor();
+ this._selection = new GraphSelection();
+ this._selectionDragger = new GraphSelectionDragger();
+ this._selectionResizer = new GraphSelectionResizer();
+
+ this._onAnimationFrame = this._onAnimationFrame.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._onMouseOut = this._onMouseOut.bind(this);
+ this._onResize = this._onResize.bind(this);
+ this.refresh = this.refresh.bind(this);
+
+ this._window.addEventListener("mousemove", this._onMouseMove);
+ this._window.addEventListener("mousedown", this._onMouseDown);
+ this._window.addEventListener("mouseup", this._onMouseUp);
+ this._window.addEventListener("MozMousePixelScroll", this._onMouseWheel);
+ this._window.addEventListener("mouseout", this._onMouseOut);
+
+ let ownerWindow = this._parent.ownerDocument.defaultView;
+ ownerWindow.addEventListener("resize", this._onResize);
+
+ this._animationId = this._window.requestAnimationFrame(this._onAnimationFrame);
+
+ this._ready.resolve(this);
+ this.emit("ready", this);
+ });
+};
+
+AbstractCanvasGraph.prototype = {
+ /**
+ * Read-only width and height of the canvas.
+ * @return number
+ */
+ get width() {
+ return this._width;
+ },
+ get height() {
+ return this._height;
+ },
+
+ /**
+ * Returns a promise resolved once this graph is ready to receive data.
+ */
+ ready: function() {
+ return this._ready.promise;
+ },
+
+ /**
+ * Destroys this graph.
+ */
+ destroy: function() {
+ this._window.removeEventListener("mousemove", this._onMouseMove);
+ this._window.removeEventListener("mousedown", this._onMouseDown);
+ this._window.removeEventListener("mouseup", this._onMouseUp);
+ this._window.removeEventListener("MozMousePixelScroll", this._onMouseWheel);
+ this._window.removeEventListener("mouseout", this._onMouseOut);
+
+ let ownerWindow = this._parent.ownerDocument.defaultView;
+ ownerWindow.removeEventListener("resize", this._onResize);
+
+ this._window.cancelAnimationFrame(this._animationId);
+ this._iframe.remove();
+
+ this._cursor = null;
+ this._selection = null;
+ this._selectionDragger = null;
+ this._selectionResizer = null;
+
+ this._data = null;
+ this._mask = null;
+ this._maskArgs = null;
+ this._regions = null;
+
+ this._cachedBackgroundImage = null;
+ this._cachedGraphImage = null;
+ this._cachedMaskImage = null;
+ this._renderTargets.clear();
+ gCachedStripePattern.clear();
+
+ this.emit("destroyed");
+ },
+
+ /**
+ * Rendering options. Subclasses should override these.
+ */
+ clipheadLineWidth: 1,
+ clipheadLineColor: "transparent",
+ selectionLineWidth: 1,
+ selectionLineColor: "transparent",
+ selectionBackgroundColor: "transparent",
+ selectionStripesColor: "transparent",
+ regionBackgroundColor: "transparent",
+ regionStripesColor: "transparent",
+
+ /**
+ * Makes sure the canvas graph is of the specified width or height, and
+ * doesn't flex to fit all the available space.
+ */
+ fixedWidth: null,
+ fixedHeight: null,
+
+ /**
+ * Optionally builds and caches a background image for this graph.
+ * Inheriting classes may override this method.
+ */
+ buildBackgroundImage: function() {
+ return null;
+ },
+
+ /**
+ * Builds and caches a graph image, based on the data source supplied
+ * in `setData`. The graph image is not rebuilt on each frame, but
+ * only when the data source changes.
+ */
+ buildGraphImage: function() {
+ throw "This method needs to be implemented by inheriting classes.";
+ },
+
+ /**
+ * Optionally builds and caches a mask image for this graph, composited
+ * over the data image created via `buildGraphImage`. Inheriting classes
+ * may override this method.
+ */
+ buildMaskImage: function() {
+ return null;
+ },
+
+ /**
+ * When setting the data source, the coordinates and values may be
+ * stretched or squeezed on the X/Y axis, to fit into the available space.
+ */
+ dataScaleX: 1,
+ dataScaleY: 1,
+
+ /**
+ * Sets the data source for this graph.
+ *
+ * @param object data
+ * The data source. The actual format is specified by subclasses.
+ */
+ setData: function(data) {
+ this._data = data;
+ this._cachedBackgroundImage = this.buildBackgroundImage();
+ this._cachedGraphImage = this.buildGraphImage();
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Same as `setData`, but waits for this graph to finish initializing first.
+ *
+ * @param object data
+ * The data source. The actual format is specified by subclasses.
+ * @return promise
+ * A promise resolved once the data is set.
+ */
+ setDataWhenReady: Task.async(function*(data) {
+ yield this.ready();
+ this.setData(data);
+ }),
+
+ /**
+ * Adds a mask to this graph.
+ *
+ * @param any mask, options
+ * See `buildMaskImage` in inheriting classes for the required args.
+ */
+ setMask: function(mask, ...options) {
+ this._mask = mask;
+ this._maskArgs = [mask, ...options];
+ this._cachedMaskImage = this.buildMaskImage.apply(this, this._maskArgs);
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Adds regions to this graph.
+ *
+ * See the "Language" section in the constructor documentation
+ * for details about what "regions" represent.
+ *
+ * @param array regions
+ * A list of { start, end } values.
+ */
+ setRegions: function(regions) {
+ if (!this._cachedGraphImage) {
+ throw "Can't highlight regions on a graph with no data displayed.";
+ }
+ if (this._regions) {
+ throw "Regions were already highlighted on the graph.";
+ }
+ this._regions = regions.map(e => ({
+ start: e.start * this.dataScaleX,
+ end: e.end * this.dataScaleX
+ }));
+ this._bakeRegions(this._regions, this._cachedGraphImage);
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Gets whether or not this graph has a data source.
+ * @return boolean
+ */
+ hasData: function() {
+ return !!this._data;
+ },
+
+ /**
+ * Gets whether or not this graph has any mask applied.
+ * @return boolean
+ */
+ hasMask: function() {
+ return !!this._mask;
+ },
+
+ /**
+ * Gets whether or not this graph has any regions.
+ * @return boolean
+ */
+ hasRegions: function() {
+ return !!this._regions;
+ },
+
+ /**
+ * Sets the selection bounds.
+ * Use `dropSelection` to remove the selection.
+ *
+ * If the bounds aren't different, no "selection" event is emitted.
+ *
+ * See the "Language" section in the constructor documentation
+ * for details about what a "selection" represents.
+ *
+ * @param object selection
+ * The selection's { start, end } values.
+ */
+ setSelection: function(selection) {
+ if (!selection || selection.start == null || selection.end == null) {
+ throw "Invalid selection coordinates";
+ }
+ if (!this.isSelectionDifferent(selection)) {
+ return;
+ }
+ this._selection.start = selection.start;
+ this._selection.end = selection.end;
+ this._shouldRedraw = true;
+ this.emit("selecting");
+ },
+
+ /**
+ * Gets the selection bounds.
+ * If there's no selection, the bounds have null values.
+ *
+ * @return object
+ * The selection's { start, end } values.
+ */
+ getSelection: function() {
+ if (this.hasSelection()) {
+ return { start: this._selection.start, end: this._selection.end };
+ }
+ if (this.hasSelectionInProgress()) {
+ return { start: this._selection.start, end: this._cursor.x };
+ }
+ return { start: null, end: null };
+ },
+
+ /**
+ * Sets the selection bounds, scaled to correlate with the data source ranges,
+ * such that a [0, max width] selection maps to [first value, last value].
+ *
+ * @param object selection
+ * The selection's { start, end } values.
+ * @param object { mapStart, mapEnd } mapping [optional]
+ * Invoked when retrieving the numbers in the data source representing
+ * the first and last values, on the X axis.
+ */
+ setMappedSelection: function(selection, mapping = {}) {
+ if (!this.hasData()) {
+ throw "A data source is necessary for retrieving a mapped selection.";
+ }
+ if (!selection || selection.start == null || selection.end == null) {
+ throw "Invalid selection coordinates";
+ }
+
+ let { mapStart, mapEnd } = mapping;
+ let startTime = (mapStart || (e => e.delta))(this._data[0]);
+ let endTime = (mapEnd || (e => e.delta))(this._data[this._data.length - 1]);
+
+ // The selection's start and end values are not guaranteed to be ascending.
+ // Also make sure that the selection bounds fit inside the data bounds.
+ let min = Math.max(Math.min(selection.start, selection.end), startTime);
+ let max = Math.min(Math.max(selection.start, selection.end), endTime);
+ min = map(min, startTime, endTime, 0, this._width);
+ max = map(max, startTime, endTime, 0, this._width);
+
+ this.setSelection({ start: min, end: max });
+ },
+
+ /**
+ * Gets the selection bounds, scaled to correlate with the data source ranges,
+ * such that a [0, max width] selection maps to [first value, last value].
+ *
+ * @param object { mapStart, mapEnd } mapping [optional]
+ * Invoked when retrieving the numbers in the data source representing
+ * the first and last values, on the X axis.
+ * @return object
+ * The mapped selection's { min, max } values.
+ */
+ getMappedSelection: function(mapping = {}) {
+ if (!this.hasData()) {
+ throw "A data source is necessary for retrieving a mapped selection.";
+ }
+ if (!this.hasSelection() && !this.hasSelectionInProgress()) {
+ return { min: null, max: null };
+ }
+
+ let { mapStart, mapEnd } = mapping;
+ let startTime = (mapStart || (e => e.delta))(this._data[0]);
+ let endTime = (mapEnd || (e => e.delta))(this._data[this._data.length - 1]);
+
+ // The selection's start and end values are not guaranteed to be ascending.
+ // This can happen, for example, when click & dragging from right to left.
+ // Also make sure that the selection bounds fit inside the canvas bounds.
+ let selection = this.getSelection();
+ let min = Math.max(Math.min(selection.start, selection.end), 0);
+ let max = Math.min(Math.max(selection.start, selection.end), this._width);
+ min = map(min, 0, this._width, startTime, endTime);
+ max = map(max, 0, this._width, startTime, endTime);
+
+ return { min: min, max: max };
+ },
+
+ /**
+ * Removes the selection.
+ */
+ dropSelection: function() {
+ if (!this.hasSelection() && !this.hasSelectionInProgress()) {
+ return;
+ }
+ this._selection.start = null;
+ this._selection.end = null;
+ this._shouldRedraw = true;
+ this.emit("deselecting");
+ },
+
+ /**
+ * Gets whether or not this graph has a selection.
+ * @return boolean
+ */
+ hasSelection: function() {
+ return this._selection &&
+ this._selection.start != null && this._selection.end != null;
+ },
+
+ /**
+ * Gets whether or not a selection is currently being made, for example
+ * via a click+drag operation.
+ * @return boolean
+ */
+ hasSelectionInProgress: function() {
+ return this._selection &&
+ this._selection.start != null && this._selection.end == null;
+ },
+
+ /**
+ * Specifies whether or not mouse selection is allowed.
+ * @type boolean
+ */
+ selectionEnabled: true,
+
+ /**
+ * Sets the selection bounds.
+ * Use `dropCursor` to hide the cursor.
+ *
+ * @param object cursor
+ * The cursor's { x, y } position.
+ */
+ setCursor: function(cursor) {
+ if (!cursor || cursor.x == null || cursor.y == null) {
+ throw "Invalid cursor coordinates";
+ }
+ if (!this.isCursorDifferent(cursor)) {
+ return;
+ }
+ this._cursor.x = cursor.x;
+ this._cursor.y = cursor.y;
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Gets the cursor position.
+ * If there's no cursor, the position has null values.
+ *
+ * @return object
+ * The cursor's { x, y } values.
+ */
+ getCursor: function() {
+ return { x: this._cursor.x, y: this._cursor.y };
+ },
+
+ /**
+ * Hides the cursor.
+ */
+ dropCursor: function() {
+ if (!this.hasCursor()) {
+ return;
+ }
+ this._cursor.x = null;
+ this._cursor.y = null;
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Gets whether or not this graph has a visible cursor.
+ * @return boolean
+ */
+ hasCursor: function() {
+ return this._cursor && this._cursor.x != null;
+ },
+
+ /**
+ * Specifies if this graph's selection is different from another one.
+ *
+ * @param object other
+ * The other graph's selection, as { start, end } values.
+ */
+ isSelectionDifferent: function(other) {
+ if (!other) return true;
+ let current = this.getSelection();
+ return current.start != other.start || current.end != other.end;
+ },
+
+ /**
+ * Specifies if this graph's cursor is different from another one.
+ *
+ * @param object other
+ * The other graph's position, as { x, y } values.
+ */
+ isCursorDifferent: function(other) {
+ if (!other) return true;
+ let current = this.getCursor();
+ return current.x != other.x || current.y != other.y;
+ },
+
+ /**
+ * Gets the width of the current selection.
+ * If no selection is available, 0 is returned.
+ *
+ * @return number
+ * The selection width.
+ */
+ getSelectionWidth: function() {
+ let selection = this.getSelection();
+ return Math.abs(selection.start - selection.end);
+ },
+
+ /**
+ * Gets the currently hovered region, if any.
+ * If no region is currently hovered, null is returned.
+ *
+ * @return object
+ * The hovered region, as { start, end } values.
+ */
+ getHoveredRegion: function() {
+ if (!this.hasRegions() || !this.hasCursor()) {
+ return null;
+ }
+ let { x } = this._cursor;
+ return this._regions.find(({ start, end }) =>
+ (start < end && start < x && end > x) ||
+ (start > end && end < x && start > x));
+ },
+
+ /**
+ * Updates this graph to reflect the new dimensions of the parent node.
+ *
+ * @param boolean options.force
+ * Force redrawing everything
+ */
+ refresh: function(options={}) {
+ let bounds = this._parent.getBoundingClientRect();
+ let newWidth = this.fixedWidth || bounds.width;
+ let newHeight = this.fixedHeight || bounds.height;
+
+ // Prevent redrawing everything if the graph's width & height won't change,
+ // except if force=true.
+ if (!options.force &&
+ this._width == newWidth * this._pixelRatio &&
+ this._height == newHeight * this._pixelRatio) {
+ this.emit("refresh-cancelled");
+ return;
+ }
+
+ bounds.width = newWidth;
+ bounds.height = newHeight;
+ this._iframe.setAttribute("width", bounds.width);
+ this._iframe.setAttribute("height", bounds.height);
+ this._width = this._canvas.width = bounds.width * this._pixelRatio;
+ this._height = this._canvas.height = bounds.height * this._pixelRatio;
+
+ if (this.hasData()) {
+ this._cachedBackgroundImage = this.buildBackgroundImage();
+ this._cachedGraphImage = this.buildGraphImage();
+ }
+ if (this.hasMask()) {
+ this._cachedMaskImage = this.buildMaskImage.apply(this, this._maskArgs);
+ }
+ if (this.hasRegions()) {
+ this._bakeRegions(this._regions, this._cachedGraphImage);
+ }
+
+ this._shouldRedraw = true;
+ this.emit("refresh");
+ },
+
+ /**
+ * Gets a canvas with the specified name, for this graph.
+ *
+ * If it doesn't exist yet, it will be created, otherwise the cached instance
+ * will be cleared and returned.
+ *
+ * @param string name
+ * The canvas name.
+ * @param number width, height [optional]
+ * A custom width and height for the canvas. Defaults to this graph's
+ * container canvas width and height.
+ */
+ _getNamedCanvas: function(name, width = this._width, height = this._height) {
+ let cachedRenderTarget = this._renderTargets.get(name);
+ if (cachedRenderTarget) {
+ let { canvas, ctx } = cachedRenderTarget;
+ canvas.width = width;
+ canvas.height = height;
+ ctx.clearRect(0, 0, width, height);
+ return cachedRenderTarget;
+ }
+
+ let canvas = this._document.createElementNS(HTML_NS, "canvas");
+ let ctx = canvas.getContext("2d");
+ canvas.width = width;
+ canvas.height = height;
+
+ let renderTarget = { canvas: canvas, ctx: ctx };
+ this._renderTargets.set(name, renderTarget);
+ return renderTarget;
+ },
+
+ /**
+ * The contents of this graph are redrawn only when something changed,
+ * like the data source, or the selection bounds etc. This flag tracks
+ * if the rendering is "dirty" and needs to be refreshed.
+ */
+ _shouldRedraw: false,
+
+ /**
+ * Animation frame callback, invoked on each tick of the refresh driver.
+ */
+ _onAnimationFrame: function() {
+ this._animationId = this._window.requestAnimationFrame(this._onAnimationFrame);
+ this._drawWidget();
+ },
+
+ /**
+ * 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;
+ }
+ let ctx = this._ctx;
+ ctx.clearRect(0, 0, this._width, this._height);
+
+ if (this._cachedGraphImage) {
+ ctx.drawImage(this._cachedGraphImage, 0, 0, this._width, this._height);
+ }
+ if (this._cachedMaskImage) {
+ ctx.globalCompositeOperation = "destination-out";
+ ctx.drawImage(this._cachedMaskImage, 0, 0, this._width, this._height);
+ }
+ if (this._cachedBackgroundImage) {
+ ctx.globalCompositeOperation = "destination-over";
+ ctx.drawImage(this._cachedBackgroundImage, 0, 0, this._width, this._height);
+ }
+
+ // Revert to the original global composition operation.
+ if (this._cachedMaskImage || this._cachedBackgroundImage) {
+ ctx.globalCompositeOperation = "source-over";
+ }
+
+ if (this.hasCursor()) {
+ this._drawCliphead();
+ }
+ if (this.hasSelection() || this.hasSelectionInProgress()) {
+ this._drawSelection();
+ }
+
+ this._shouldRedraw = false;
+ },
+
+ /**
+ * Draws the cliphead, if available and necessary.
+ */
+ _drawCliphead: function() {
+ if (this._isHoveringSelectionContentsOrBoundaries() || this._isHoveringRegion()) {
+ return;
+ }
+
+ let ctx = this._ctx;
+ ctx.lineWidth = this.clipheadLineWidth;
+ ctx.strokeStyle = this.clipheadLineColor;
+ ctx.beginPath();
+ ctx.moveTo(this._cursor.x, 0);
+ ctx.lineTo(this._cursor.x, this._height);
+ ctx.stroke();
+ },
+
+ /**
+ * Draws the selection, if available and necessary.
+ */
+ _drawSelection: function() {
+ let { start, end } = this.getSelection();
+ let input = this._canvas.getAttribute("input");
+
+ let ctx = this._ctx;
+ ctx.strokeStyle = this.selectionLineColor;
+
+ // Fill selection.
+
+ let pattern = AbstractCanvasGraph.getStripePattern({
+ ownerDocument: this._document,
+ backgroundColor: this.selectionBackgroundColor,
+ stripesColor: this.selectionStripesColor
+ });
+ ctx.fillStyle = pattern;
+ ctx.fillRect(start, 0, end - start, this._height);
+
+ // Draw left boundary.
+
+ if (input == "hovering-selection-start-boundary") {
+ ctx.lineWidth = GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH;
+ } else {
+ ctx.lineWidth = this.clipheadLineWidth;
+ }
+ ctx.beginPath();
+ ctx.moveTo(start, 0);
+ ctx.lineTo(start, this._height);
+ ctx.stroke();
+
+ // Draw right boundary.
+
+ if (input == "hovering-selection-end-boundary") {
+ ctx.lineWidth = GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH;
+ } else {
+ ctx.lineWidth = this.clipheadLineWidth;
+ }
+ ctx.beginPath();
+ ctx.moveTo(end, this._height);
+ ctx.lineTo(end, 0);
+ ctx.stroke();
+ },
+
+ /**
+ * Draws regions into the cached graph image, created via `buildGraphImage`.
+ * Called when new regions are set.
+ */
+ _bakeRegions: function(regions, destination) {
+ let ctx = destination.getContext("2d");
+
+ let pattern = AbstractCanvasGraph.getStripePattern({
+ ownerDocument: this._document,
+ backgroundColor: this.regionBackgroundColor,
+ stripesColor: this.regionStripesColor
+ });
+ ctx.fillStyle = pattern;
+ ctx.strokeStyle = GRAPH_REGION_LINE_COLOR;
+ ctx.lineWidth = GRAPH_REGION_LINE_WIDTH;
+
+ let y = -GRAPH_REGION_LINE_WIDTH;
+ let height = this._height + GRAPH_REGION_LINE_WIDTH;
+
+ for (let { start, end } of regions) {
+ let x = start;
+ let width = end - start;
+ ctx.fillRect(x, y, width, height);
+ ctx.strokeRect(x, y, width, height);
+ }
+ },
+
+ /**
+ * Checks whether the start handle of the selection is hovered.
+ * @return boolean
+ */
+ _isHoveringStartBoundary: function() {
+ if (!this.hasSelection() || !this.hasCursor()) {
+ return;
+ }
+ let { x } = this._cursor;
+ let { start } = this._selection;
+ let threshold = GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD * this._pixelRatio;
+ return Math.abs(start - x) < threshold;
+ },
+
+ /**
+ * Checks whether the end handle of the selection is hovered.
+ * @return boolean
+ */
+ _isHoveringEndBoundary: function() {
+ if (!this.hasSelection() || !this.hasCursor()) {
+ return;
+ }
+ let { x } = this._cursor;
+ let { end } = this._selection;
+ let threshold = GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD * this._pixelRatio;
+ return Math.abs(end - x) < threshold;
+ },
+
+ /**
+ * Checks whether the selection is hovered.
+ * @return boolean
+ */
+ _isHoveringSelectionContents: function() {
+ if (!this.hasSelection() || !this.hasCursor()) {
+ return;
+ }
+ let { x } = this._cursor;
+ let { start, end } = this._selection;
+ return (start < end && start < x && end > x) ||
+ (start > end && end < x && start > x);
+ },
+
+ /**
+ * Checks whether the selection or its handles are hovered.
+ * @return boolean
+ */
+ _isHoveringSelectionContentsOrBoundaries: function() {
+ return this._isHoveringSelectionContents() ||
+ this._isHoveringStartBoundary() ||
+ this._isHoveringEndBoundary();
+ },
+
+ /**
+ * Checks whether a region is hovered.
+ * @return boolean
+ */
+ _isHoveringRegion: function() {
+ return !!this.getHoveredRegion();
+ },
+
+ /**
+ * Gets the offset of this graph's container relative to the owner window.
+ *
+ * @return object
+ * The { left, top } offset.
+ */
+ _getContainerOffset: function() {
+ let node = this._canvas;
+ let x = 0;
+ let y = 0;
+
+ while (node = node.offsetParent) {
+ x += node.offsetLeft;
+ y += node.offsetTop;
+ }
+
+ return { left: x, top: y };
+ },
+
+ /**
+ * Listener for the "mousemove" event on the graph's container.
+ */
+ _onMouseMove: function(e) {
+ let offset = this._getContainerOffset();
+ let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+ let mouseY = (e.clientY - offset.top) * this._pixelRatio;
+ this._cursor.x = mouseX;
+ this._cursor.y = mouseY;
+
+ let resizer = this._selectionResizer;
+ if (resizer.margin != null) {
+ this._selection[resizer.margin] = mouseX;
+ this._shouldRedraw = true;
+ this.emit("selecting");
+ return;
+ }
+
+ let dragger = this._selectionDragger;
+ if (dragger.origin != null) {
+ this._selection.start = dragger.anchor.start - dragger.origin + mouseX;
+ this._selection.end = dragger.anchor.end - dragger.origin + mouseX;
+ this._shouldRedraw = true;
+ this.emit("selecting");
+ return;
+ }
+
+ if (this.hasSelectionInProgress()) {
+ this._shouldRedraw = true;
+ this.emit("selecting");
+ return;
+ }
+
+ if (this.hasSelection()) {
+ if (this._isHoveringStartBoundary()) {
+ this._canvas.setAttribute("input", "hovering-selection-start-boundary");
+ this._shouldRedraw = true;
+ return;
+ }
+ if (this._isHoveringEndBoundary()) {
+ this._canvas.setAttribute("input", "hovering-selection-end-boundary");
+ this._shouldRedraw = true;
+ return;
+ }
+ if (this._isHoveringSelectionContents()) {
+ this._canvas.setAttribute("input", "hovering-selection-contents");
+ this._shouldRedraw = true;
+ return;
+ }
+ }
+
+ let region = this.getHoveredRegion();
+ if (region) {
+ this._canvas.setAttribute("input", "hovering-region");
+ } else {
+ this._canvas.setAttribute("input", "hovering-background");
+ }
+
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Listener for the "mousedown" event on the graph's container.
+ */
+ _onMouseDown: function(e) {
+ let offset = this._getContainerOffset();
+ let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+
+ switch (this._canvas.getAttribute("input")) {
+ case "hovering-background":
+ case "hovering-region":
+ if (!this.selectionEnabled) {
+ break;
+ }
+ this._selection.start = mouseX;
+ this._selection.end = null;
+ this.emit("selecting");
+ break;
+
+ case "hovering-selection-start-boundary":
+ this._selectionResizer.margin = "start";
+ break;
+
+ case "hovering-selection-end-boundary":
+ this._selectionResizer.margin = "end";
+ break;
+
+ case "hovering-selection-contents":
+ this._selectionDragger.origin = mouseX;
+ this._selectionDragger.anchor.start = this._selection.start;
+ this._selectionDragger.anchor.end = this._selection.end;
+ this._canvas.setAttribute("input", "dragging-selection-contents");
+ break;
+ }
+
+ this._shouldRedraw = true;
+ this.emit("mousedown");
+ },
+
+ /**
+ * Listener for the "mouseup" event on the graph's container.
+ */
+ _onMouseUp: function(e) {
+ let offset = this._getContainerOffset();
+ let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+
+ switch (this._canvas.getAttribute("input")) {
+ case "hovering-background":
+ case "hovering-region":
+ if (!this.selectionEnabled) {
+ break;
+ }
+ if (this.getSelectionWidth() < 1) {
+ let region = this.getHoveredRegion();
+ if (region) {
+ this._selection.start = region.start;
+ this._selection.end = region.end;
+ this.emit("selecting");
+ } else {
+ this._selection.start = null;
+ this._selection.end = null;
+ this.emit("deselecting");
+ }
+ } else {
+ this._selection.end = mouseX;
+ this.emit("selecting");
+ }
+ break;
+
+ case "hovering-selection-start-boundary":
+ case "hovering-selection-end-boundary":
+ this._selectionResizer.margin = null;
+ break;
+
+ case "dragging-selection-contents":
+ this._selectionDragger.origin = null;
+ this._canvas.setAttribute("input", "hovering-selection-contents");
+ break;
+ }
+
+ this._shouldRedraw = true;
+ this.emit("mouseup");
+ },
+
+ /**
+ * Listener for the "wheel" event on the graph's container.
+ */
+ _onMouseWheel: function(e) {
+ if (!this.hasSelection()) {
+ return;
+ }
+
+ let offset = this._getContainerOffset();
+ let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+ let focusX = mouseX;
+
+ let selection = this._selection;
+ let vector = 0;
+
+ // If the selection is hovered, "zoom" towards or away the cursor,
+ // by shrinking or growing the selection.
+ if (this._isHoveringSelectionContentsOrBoundaries()) {
+ let distStart = selection.start - focusX;
+ let distEnd = selection.end - focusX;
+ vector = e.detail * GRAPH_WHEEL_ZOOM_SENSITIVITY;
+ selection.start = selection.start + distStart * vector;
+ selection.end = selection.end + distEnd * vector;
+ }
+ // Otherwise, simply pan the selection towards the left or right.
+ else {
+ let direction = 0;
+ if (focusX > selection.end) {
+ direction = Math.sign(focusX - selection.end);
+ } else if (focusX < selection.start) {
+ direction = Math.sign(focusX - selection.start);
+ }
+ vector = direction * e.detail * GRAPH_WHEEL_SCROLL_SENSITIVITY;
+ selection.start -= vector;
+ selection.end -= vector;
+ }
+
+ // Make sure the selection bounds are still comfortably inside the
+ // graph's bounds when zooming out, to keep the margin handles accessible.
+
+ let minStart = GRAPH_MAX_SELECTION_LEFT_PADDING;
+ let maxEnd = this._width - GRAPH_MAX_SELECTION_RIGHT_PADDING;
+ if (selection.start < minStart) {
+ selection.start = minStart;
+ }
+ if (selection.start > maxEnd) {
+ selection.start = maxEnd;
+ }
+ if (selection.end < minStart) {
+ selection.end = minStart;
+ }
+ if (selection.end > maxEnd) {
+ selection.end = maxEnd;
+ }
+
+ // Make sure the selection doesn't get too narrow when zooming in.
+
+ let thickness = Math.abs(selection.start - selection.end);
+ if (thickness < GRAPH_WHEEL_MIN_SELECTION_WIDTH) {
+ let midPoint = (selection.start + selection.end) / 2;
+ selection.start = midPoint - GRAPH_WHEEL_MIN_SELECTION_WIDTH / 2;
+ selection.end = midPoint + GRAPH_WHEEL_MIN_SELECTION_WIDTH / 2;
+ }
+
+ this._shouldRedraw = true;
+ this.emit("selecting");
+ this.emit("scroll");
+ },
+
+ /**
+ * Listener for the "mouseout" event on the graph's container.
+ */
+ _onMouseOut: function() {
+ if (this.hasSelectionInProgress()) {
+ this.dropSelection();
+ }
+
+ this._cursor.x = null;
+ this._cursor.y = null;
+ this._selectionResizer.margin = null;
+ this._selectionDragger.origin = null;
+
+ this._canvas.removeAttribute("input");
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Listener for the "resize" event on the graph's parent node.
+ */
+ _onResize: function() {
+ if (this.hasData()) {
+ setNamedTimeout(this._uid, GRAPH_RESIZE_EVENTS_DRAIN, this.refresh);
+ }
+ }
+};
+
+/**
+ * A basic line graph, plotting values on a curve and adding helper lines
+ * and tooltips for maximum, average and minimum values.
+ *
+ * @see AbstractCanvasGraph for emitted events and other options.
+ *
+ * Example usage:
+ * let graph = new LineGraphWidget(node, "units");
+ * graph.once("ready", () => {
+ * graph.setData(src);
+ * });
+ *
+ * Data source format:
+ * [
+ * { delta: x1, value: y1 },
+ * { delta: x2, value: y2 },
+ * ...
+ * { delta: xn, value: yn }
+ * ]
+ * where each item in the array represents a point in the graph.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the graph.
+ * @param object options [optional]
+ * `metric`: The metric displayed in the graph, e.g. "fps" or "bananas".
+ * `min`: Boolean whether to show the min tooltip/gutter/line (default: true)
+ * `max`: Boolean whether to show the max tooltip/gutter/line (default: true)
+ * `avg`: Boolean whether to show the avg tooltip/gutter/line (default: true)
+ */
+this.LineGraphWidget = function(parent, options, ...args) {
+ options = options || {};
+ let metric = options.metric;
+
+ this._showMin = options.min !== false;
+ this._showMax = options.max !== false;
+ this._showAvg = options.avg !== false;
+ AbstractCanvasGraph.apply(this, [parent, "line-graph", ...args]);
+
+ this.once("ready", () => {
+ // Create all gutters and tooltips incase the showing of min/max/avg
+ // are changed later
+ this._gutter = this._createGutter();
+
+ this._maxGutterLine = this._createGutterLine("maximum");
+ this._maxTooltip = this._createTooltip("maximum", "start", "max", metric);
+ this._minGutterLine = this._createGutterLine("minimum");
+ this._minTooltip = this._createTooltip("minimum", "start", "min", metric);
+ this._avgGutterLine = this._createGutterLine("average");
+ this._avgTooltip = this._createTooltip("average", "end", "avg", metric);
+ });
+};
+
+LineGraphWidget.prototype = Heritage.extend(AbstractCanvasGraph.prototype, {
+ backgroundColor: LINE_GRAPH_BACKGROUND_COLOR,
+ backgroundGradientStart: LINE_GRAPH_BACKGROUND_GRADIENT_START,
+ backgroundGradientEnd: LINE_GRAPH_BACKGROUND_GRADIENT_END,
+ strokeColor: LINE_GRAPH_STROKE_COLOR,
+ strokeWidth: LINE_GRAPH_STROKE_WIDTH,
+ maximumLineColor: LINE_GRAPH_MAXIMUM_LINE_COLOR,
+ averageLineColor: LINE_GRAPH_AVERAGE_LINE_COLOR,
+ minimumLineColor: LINE_GRAPH_MINIMUM_LINE_COLOR,
+ clipheadLineColor: LINE_GRAPH_CLIPHEAD_LINE_COLOR,
+ selectionLineColor: LINE_GRAPH_SELECTION_LINE_COLOR,
+ selectionBackgroundColor: LINE_GRAPH_SELECTION_BACKGROUND_COLOR,
+ selectionStripesColor: LINE_GRAPH_SELECTION_STRIPES_COLOR,
+ regionBackgroundColor: LINE_GRAPH_REGION_BACKGROUND_COLOR,
+ regionStripesColor: LINE_GRAPH_REGION_STRIPES_COLOR,
+
+ /**
+ * Optionally offsets the `delta` in the data source by this scalar.
+ */
+ dataOffsetX: 0,
+
+ /**
+ * Optionally uses this value instead of the last tick in the data source
+ * to compute the horizontal scaling.
+ */
+ dataDuration: 0,
+
+ /**
+ * The scalar used to multiply the graph values to leave some headroom.
+ */
+ dampenValuesFactor: LINE_GRAPH_DAMPEN_VALUES,
+
+ /**
+ * Specifies if min/max/avg tooltips have arrow handlers on their sides.
+ */
+ withTooltipArrows: true,
+
+ /**
+ * Specifies if min/max/avg tooltips are positioned based on the actual
+ * values, or just placed next to the graph corners.
+ */
+ withFixedTooltipPositions: false,
+
+ /**
+ * Takes a list of numbers and plots them on a line graph representing
+ * the rate of occurences in a specified interval. Useful for drawing
+ * framerate, for example, from a sequence of timestamps.
+ *
+ * @param array timestamps
+ * A list of numbers representing time, ordered ascending. For example,
+ * this can be the raw data received from the framerate actor, which
+ * represents the elapsed time on each refresh driver tick.
+ * @param number interval
+ * The maximum amount of time to wait between calculations.
+ */
+ setDataFromTimestamps: Task.async(function*(timestamps, interval) {
+ let {
+ plottedData,
+ plottedMinMaxSum
+ } = yield CanvasGraphUtils._performTaskInWorker("plotTimestampsGraph", {
+ timestamps: timestamps,
+ interval: interval
+ });
+
+ this._tempMinMaxSum = plottedMinMaxSum;
+ this.setData(plottedData);
+ }),
+
+ /**
+ * Renders the graph's data source.
+ * @see AbstractCanvasGraph.prototype.buildGraphImage
+ */
+ buildGraphImage: function() {
+ let { canvas, ctx } = this._getNamedCanvas("line-graph-data");
+ let width = this._width;
+ let height = this._height;
+
+ let totalTicks = this._data.length;
+ let firstTick = totalTicks ? this._data[0].delta : 0;
+ let lastTick = totalTicks ? this._data[totalTicks - 1].delta : 0;
+ let maxValue = Number.MIN_SAFE_INTEGER;
+ let minValue = Number.MAX_SAFE_INTEGER;
+ let avgValue = 0;
+
+ if (this._tempMinMaxSum) {
+ maxValue = this._tempMinMaxSum.maxValue;
+ minValue = this._tempMinMaxSum.minValue;
+ avgValue = this._tempMinMaxSum.avgValue;
+ } else {
+ let sumValues = 0;
+ for (let { delta, value } of this._data) {
+ maxValue = Math.max(value, maxValue);
+ minValue = Math.min(value, minValue);
+ sumValues += value;
+ }
+ avgValue = sumValues / totalTicks;
+ }
+
+ let duration = this.dataDuration || lastTick;
+ let dataScaleX = this.dataScaleX = width / (duration - this.dataOffsetX);
+ let dataScaleY = this.dataScaleY = height / maxValue * this.dampenValuesFactor;
+
+ // Draw the background.
+
+ ctx.fillStyle = this.backgroundColor;
+ ctx.fillRect(0, 0, width, height);
+
+ // Draw the graph.
+
+ let gradient = ctx.createLinearGradient(0, height / 2, 0, height);
+ gradient.addColorStop(0, this.backgroundGradientStart);
+ gradient.addColorStop(1, this.backgroundGradientEnd);
+ ctx.fillStyle = gradient;
+ ctx.strokeStyle = this.strokeColor;
+ ctx.lineWidth = this.strokeWidth * this._pixelRatio;
+ ctx.beginPath();
+
+ for (let { delta, value } of this._data) {
+ let currX = (delta - this.dataOffsetX) * dataScaleX;
+ let currY = height - value * dataScaleY;
+
+ if (delta == firstTick) {
+ ctx.moveTo(-LINE_GRAPH_STROKE_WIDTH, height);
+ ctx.lineTo(-LINE_GRAPH_STROKE_WIDTH, currY);
+ }
+
+ ctx.lineTo(currX, currY);
+
+ if (delta == lastTick) {
+ ctx.lineTo(width + LINE_GRAPH_STROKE_WIDTH, currY);
+ ctx.lineTo(width + LINE_GRAPH_STROKE_WIDTH, height);
+ }
+ }
+
+ ctx.fill();
+ ctx.stroke();
+
+ this._drawOverlays(ctx, minValue, maxValue, avgValue, dataScaleY);
+
+ return canvas;
+ },
+
+ /**
+ * Draws the min, max and average horizontal lines, along with their
+ * repsective tooltips.
+ *
+ * @param CanvasRenderingContext2D ctx
+ * @param number minValue
+ * @param number maxValue
+ * @param number avgValue
+ * @param number dataScaleY
+ */
+ _drawOverlays: function(ctx, minValue, maxValue, avgValue, dataScaleY) {
+ let width = this._width;
+ let height = this._height;
+ let totalTicks = this._data.length;
+
+ // Draw the maximum value horizontal line.
+ if (this._showMax) {
+ ctx.strokeStyle = this.maximumLineColor;
+ ctx.lineWidth = LINE_GRAPH_HELPER_LINES_WIDTH;
+ ctx.setLineDash(LINE_GRAPH_HELPER_LINES_DASH);
+ ctx.beginPath();
+ let maximumY = height - maxValue * dataScaleY;
+ ctx.moveTo(0, maximumY);
+ ctx.lineTo(width, maximumY);
+ ctx.stroke();
+ }
+
+ // Draw the average value horizontal line.
+ if (this._showAvg) {
+ ctx.strokeStyle = this.averageLineColor;
+ ctx.lineWidth = LINE_GRAPH_HELPER_LINES_WIDTH;
+ ctx.setLineDash(LINE_GRAPH_HELPER_LINES_DASH);
+ ctx.beginPath();
+ let averageY = height - avgValue * dataScaleY;
+ ctx.moveTo(0, averageY);
+ ctx.lineTo(width, averageY);
+ ctx.stroke();
+ }
+
+ // Draw the minimum value horizontal line.
+ if (this._showMin) {
+ ctx.strokeStyle = this.minimumLineColor;
+ ctx.lineWidth = LINE_GRAPH_HELPER_LINES_WIDTH;
+ ctx.setLineDash(LINE_GRAPH_HELPER_LINES_DASH);
+ ctx.beginPath();
+ let minimumY = height - minValue * dataScaleY;
+ ctx.moveTo(0, minimumY);
+ ctx.lineTo(width, minimumY);
+ ctx.stroke();
+ }
+
+ // Update the tooltips text and gutter lines.
+
+ this._maxTooltip.querySelector("[text=value]").textContent =
+ L10N.numberWithDecimals(maxValue, 2);
+ this._avgTooltip.querySelector("[text=value]").textContent =
+ L10N.numberWithDecimals(avgValue, 2);
+ this._minTooltip.querySelector("[text=value]").textContent =
+ L10N.numberWithDecimals(minValue, 2);
+
+ let bottom = height / this._pixelRatio;
+ let maxPosY = map(maxValue * this.dampenValuesFactor, 0, maxValue, bottom, 0);
+ let avgPosY = map(avgValue * this.dampenValuesFactor, 0, maxValue, bottom, 0);
+ let minPosY = map(minValue * this.dampenValuesFactor, 0, maxValue, bottom, 0);
+
+ let safeTop = LINE_GRAPH_TOOLTIP_SAFE_BOUNDS;
+ let safeBottom = bottom - LINE_GRAPH_TOOLTIP_SAFE_BOUNDS;
+
+ let maxTooltipTop = (this.withFixedTooltipPositions
+ ? safeTop : clamp(maxPosY, safeTop, safeBottom));
+ let avgTooltipTop = (this.withFixedTooltipPositions
+ ? safeTop : clamp(avgPosY, safeTop, safeBottom));
+ let minTooltipTop = (this.withFixedTooltipPositions
+ ? safeBottom : clamp(minPosY, safeTop, safeBottom));
+
+ this._maxTooltip.style.top = maxTooltipTop + "px";
+ this._avgTooltip.style.top = avgTooltipTop + "px";
+ this._minTooltip.style.top = minTooltipTop + "px";
+
+ this._maxGutterLine.style.top = maxPosY + "px";
+ this._avgGutterLine.style.top = avgPosY + "px";
+ this._minGutterLine.style.top = minPosY + "px";
+
+ this._maxTooltip.setAttribute("with-arrows", this.withTooltipArrows);
+ this._avgTooltip.setAttribute("with-arrows", this.withTooltipArrows);
+ this._minTooltip.setAttribute("with-arrows", this.withTooltipArrows);
+
+ let distanceMinMax = Math.abs(maxTooltipTop - minTooltipTop);
+ this._maxTooltip.hidden = this._showMax === false || !totalTicks || distanceMinMax < LINE_GRAPH_MIN_MAX_TOOLTIP_DISTANCE;
+ this._avgTooltip.hidden = this._showAvg === false || !totalTicks;
+ this._minTooltip.hidden = this._showMin === false || !totalTicks;
+ this._gutter.hidden = (this._showMin === false && this._showAvg === false && this._showMax === false) || !totalTicks;
+
+ this._maxGutterLine.hidden = this._showMax === false;
+ this._avgGutterLine.hidden = this._showAvg === false;
+ this._minGutterLine.hidden = this._showMin === false;
+ },
+
+ /**
+ * Creates the gutter node when constructing this graph.
+ * @return nsIDOMNode
+ */
+ _createGutter: function() {
+ let gutter = this._document.createElementNS(HTML_NS, "div");
+ gutter.className = "line-graph-widget-gutter";
+ gutter.setAttribute("hidden", true);
+ this._container.appendChild(gutter);
+
+ return gutter;
+ },
+
+ /**
+ * Creates the gutter line nodes when constructing this graph.
+ * @return nsIDOMNode
+ */
+ _createGutterLine: function(type) {
+ let line = this._document.createElementNS(HTML_NS, "div");
+ line.className = "line-graph-widget-gutter-line";
+ line.setAttribute("type", type);
+ this._gutter.appendChild(line);
+
+ return line;
+ },
+
+ /**
+ * Creates the tooltip nodes when constructing this graph.
+ * @return nsIDOMNode
+ */
+ _createTooltip: function(type, arrow, info, metric) {
+ let tooltip = this._document.createElementNS(HTML_NS, "div");
+ tooltip.className = "line-graph-widget-tooltip";
+ tooltip.setAttribute("type", type);
+ tooltip.setAttribute("arrow", arrow);
+ tooltip.setAttribute("hidden", true);
+
+ let infoNode = this._document.createElementNS(HTML_NS, "span");
+ infoNode.textContent = info;
+ infoNode.setAttribute("text", "info");
+
+ let valueNode = this._document.createElementNS(HTML_NS, "span");
+ valueNode.textContent = 0;
+ valueNode.setAttribute("text", "value");
+
+ let metricNode = this._document.createElementNS(HTML_NS, "span");
+ metricNode.textContent = metric;
+ metricNode.setAttribute("text", "metric");
+
+ tooltip.appendChild(infoNode);
+ tooltip.appendChild(valueNode);
+ tooltip.appendChild(metricNode);
+ this._container.appendChild(tooltip);
+
+ return tooltip;
+ }
+});
+
+/**
+ * A bar graph, plotting tuples of values as rectangles.
+ *
+ * @see AbstractCanvasGraph for emitted events and other options.
+ *
+ * Example usage:
+ * let graph = new BarGraphWidget(node);
+ * graph.format = ...;
+ * graph.once("ready", () => {
+ * graph.setData(src);
+ * });
+ *
+ * The `graph.format` traits are mandatory and will determine how the values
+ * are styled as "blocks" in every "bar":
+ * [
+ * { color: "#f00", label: "Foo" },
+ * { color: "#0f0", label: "Bar" },
+ * ...
+ * { color: "#00f", label: "Baz" }
+ * ]
+ *
+ * Data source format:
+ * [
+ * { delta: x1, values: [y11, y12, ... y1n] },
+ * { delta: x2, values: [y21, y22, ... y2n] },
+ * ...
+ * { delta: xm, values: [ym1, ym2, ... ymn] }
+ * ]
+ * where each item in the array represents a "bar", for which every value
+ * represents a "block" inside that "bar", plotted at the "delta" position.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the graph.
+ */
+this.BarGraphWidget = function(parent, ...args) {
+ AbstractCanvasGraph.apply(this, [parent, "bar-graph", ...args]);
+
+ // Populated with [node, event, listener] entries which need to be removed
+ // when this graph is being destroyed.
+ this.outstandingEventListeners = [];
+
+ this.once("ready", () => {
+ this._onLegendMouseOver = this._onLegendMouseOver.bind(this);
+ this._onLegendMouseOut = this._onLegendMouseOut.bind(this);
+ this._onLegendMouseDown = this._onLegendMouseDown.bind(this);
+ this._onLegendMouseUp = this._onLegendMouseUp.bind(this);
+ this._createLegend();
+ });
+
+ this.once("destroyed", () => {
+ for (let [node, event, listener] of this.outstandingEventListeners) {
+ node.removeEventListener(event, listener);
+ }
+ this.outstandingEventListeners = null;
+ });
+};
+
+BarGraphWidget.prototype = Heritage.extend(AbstractCanvasGraph.prototype, {
+ clipheadLineColor: BAR_GRAPH_CLIPHEAD_LINE_COLOR,
+ selectionLineColor: BAR_GRAPH_SELECTION_LINE_COLOR,
+ selectionBackgroundColor: BAR_GRAPH_SELECTION_BACKGROUND_COLOR,
+ selectionStripesColor: BAR_GRAPH_SELECTION_STRIPES_COLOR,
+ regionBackgroundColor: BAR_GRAPH_REGION_BACKGROUND_COLOR,
+ regionStripesColor: BAR_GRAPH_REGION_STRIPES_COLOR,
+
+ /**
+ * List of colors used to fill each block inside every bar, also
+ * corresponding to labels displayed in this graph's legend.
+ */
+ format: null,
+
+ /**
+ * Optionally offsets the `delta` in the data source by this scalar.
+ */
+ dataOffsetX: 0,
+
+ /**
+ * The scalar used to multiply the graph values to leave some headroom
+ * on the top.
+ */
+ dampenValuesFactor: BAR_GRAPH_DAMPEN_VALUES,
+
+ /**
+ * Bars that are too close too each other in the graph will be combined.
+ * This scalar specifies the required minimum width of each bar.
+ */
+ minBarsWidth: BAR_GRAPH_MIN_BARS_WIDTH,
+
+ /**
+ * Blocks in a bar that are too thin inside the bar will not be rendered.
+ * This scalar specifies the required minimum height of each block.
+ */
+ minBlocksHeight: BAR_GRAPH_MIN_BLOCKS_HEIGHT,
+
+ /**
+ * Renders the graph's background.
+ * @see AbstractCanvasGraph.prototype.buildBackgroundImage
+ */
+ buildBackgroundImage: function() {
+ let { canvas, ctx } = this._getNamedCanvas("bar-graph-background");
+ let width = this._width;
+ let height = this._height;
+
+ let gradient = ctx.createLinearGradient(0, 0, 0, height);
+ gradient.addColorStop(0, BAR_GRAPH_BACKGROUND_GRADIENT_START);
+ gradient.addColorStop(1, BAR_GRAPH_BACKGROUND_GRADIENT_END);
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, width, height);
+
+ return canvas;
+ },
+
+ /**
+ * Renders the graph's data source.
+ * @see AbstractCanvasGraph.prototype.buildGraphImage
+ */
+ buildGraphImage: function() {
+ if (!this.format || !this.format.length) {
+ throw "The graph format traits are mandatory to style the data source.";
+ }
+ let { canvas, ctx } = this._getNamedCanvas("bar-graph-data");
+ let width = this._width;
+ let height = this._height;
+
+ let totalTypes = this.format.length;
+ let totalTicks = this._data.length;
+ let lastTick = this._data[totalTicks - 1].delta;
+
+ let minBarsWidth = this.minBarsWidth * this._pixelRatio;
+ let minBlocksHeight = this.minBlocksHeight * this._pixelRatio;
+
+ let dataScaleX = this.dataScaleX = width / (lastTick - this.dataOffsetX);
+ let dataScaleY = this.dataScaleY = height / this._calcMaxHeight({
+ data: this._data,
+ dataScaleX: dataScaleX,
+ minBarsWidth: minBarsWidth
+ }) * this.dampenValuesFactor;
+
+ // Draw the graph.
+
+ // Iterate over the blocks, then the bars, to draw all rectangles of
+ // the same color in a single pass. See the @constructor for more
+ // information about the data source, and how a "bar" contains "blocks".
+
+ this._blocksBoundingRects = [];
+ let prevHeight = [];
+ let scaledMarginEnd = BAR_GRAPH_BARS_MARGIN_END * this._pixelRatio;
+ let unscaledMarginTop = BAR_GRAPH_BARS_MARGIN_TOP;
+
+ for (let type = 0; type < totalTypes; type++) {
+ ctx.fillStyle = this.format[type].color || "#000";
+ ctx.beginPath();
+
+ let prevRight = 0;
+ let skippedCount = 0;
+ let skippedHeight = 0;
+
+ for (let tick = 0; tick < totalTicks; tick++) {
+ let delta = this._data[tick].delta;
+ let value = this._data[tick].values[type] || 0;
+ let blockRight = (delta - this.dataOffsetX) * dataScaleX;
+ let blockHeight = value * dataScaleY;
+
+ let blockWidth = blockRight - prevRight;
+ if (blockWidth < minBarsWidth) {
+ skippedCount++;
+ skippedHeight += blockHeight;
+ continue;
+ }
+
+ let averageHeight = (blockHeight + skippedHeight) / (skippedCount + 1);
+ if (averageHeight >= minBlocksHeight) {
+ let bottom = height - ~~prevHeight[tick];
+ ctx.moveTo(prevRight, bottom);
+ ctx.lineTo(prevRight, bottom - averageHeight);
+ ctx.lineTo(blockRight, bottom - averageHeight);
+ ctx.lineTo(blockRight, bottom);
+
+ // Remember this block's type and location.
+ this._blocksBoundingRects.push({
+ type: type,
+ start: prevRight,
+ end: blockRight,
+ top: bottom - averageHeight,
+ bottom: bottom
+ });
+
+ if (prevHeight[tick] === undefined) {
+ prevHeight[tick] = averageHeight + unscaledMarginTop;
+ } else {
+ prevHeight[tick] += averageHeight + unscaledMarginTop;
+ }
+ }
+
+ prevRight += blockWidth + scaledMarginEnd;
+ skippedHeight = 0;
+ skippedCount = 0;
+ }
+
+ ctx.fill();
+ }
+
+ // The blocks bounding rects isn't guaranteed to be sorted ascending by
+ // block location on the X axis. This should be the case, for better
+ // cache cohesion and a faster `buildMaskImage`.
+ this._blocksBoundingRects.sort((a, b) => a.start > b.start ? 1 : -1);
+
+ // Update the legend.
+
+ while (this._legendNode.hasChildNodes()) {
+ this._legendNode.firstChild.remove();
+ }
+ for (let { color, label } of this.format) {
+ this._createLegendItem(color, label);
+ }
+
+ return canvas;
+ },
+
+ /**
+ * Renders the graph's mask.
+ * Fades in only the parts of the graph that are inside the specified areas.
+ *
+ * @param array highlights
+ * A list of { start, end } values. Optionally, each object
+ * in the list may also specify { top, bottom } pixel values if the
+ * highlighting shouldn't span across the full height of the graph.
+ * @param boolean inPixels
+ * Set this to true if the { start, end } values in the highlights
+ * list are pixel values, and not values from the data source.
+ * @param function unpack [optional]
+ * @see AbstractCanvasGraph.prototype.getMappedSelection
+ */
+ buildMaskImage: function(highlights, inPixels = false, unpack = e => e.delta) {
+ // A null `highlights` array is used to clear the mask. An empty array
+ // will mask the entire graph.
+ if (!highlights) {
+ return null;
+ }
+
+ // Get a render target for the highlights. It will be overlaid on top of
+ // the existing graph, masking the areas that aren't highlighted.
+
+ let { canvas, ctx } = this._getNamedCanvas("graph-highlights");
+ let width = this._width;
+ let height = this._height;
+
+ // Draw the background mask.
+
+ let pattern = AbstractCanvasGraph.getStripePattern({
+ ownerDocument: this._document,
+ backgroundColor: BAR_GRAPH_HIGHLIGHTS_MASK_BACKGROUND,
+ stripesColor: BAR_GRAPH_HIGHLIGHTS_MASK_STRIPES
+ });
+ ctx.fillStyle = pattern;
+ ctx.fillRect(0, 0, width, height);
+
+ // Clear highlighted areas.
+
+ let totalTicks = this._data.length;
+ let firstTick = unpack(this._data[0]);
+ let lastTick = unpack(this._data[totalTicks - 1]);
+
+ for (let { start, end, top, bottom } of highlights) {
+ if (!inPixels) {
+ start = map(start, firstTick, lastTick, 0, width);
+ end = map(end, firstTick, lastTick, 0, width);
+ }
+ let firstSnap = findFirst(this._blocksBoundingRects, e => e.start >= start);
+ let lastSnap = findLast(this._blocksBoundingRects, e => e.start >= start && e.end <= end);
+
+ let x1 = firstSnap ? firstSnap.start : start;
+ let x2 = lastSnap ? lastSnap.end : firstSnap ? firstSnap.end : end;
+ let y1 = top || 0;
+ let y2 = bottom || height;
+ ctx.clearRect(x1, y1, x2 - x1, y2 - y1);
+ }
+
+ return canvas;
+ },
+
+ /**
+ * A list storing the bounding rectangle for each drawn block in the graph.
+ * Created whenever `buildGraphImage` is invoked.
+ */
+ _blocksBoundingRects: null,
+
+ /**
+ * Calculates the height of the tallest bar that would eventially be rendered
+ * in this graph.
+ *
+ * Bars that are too close too each other in the graph will be combined.
+ * @see `minBarsWidth`
+ *
+ * @return number
+ * The tallest bar height in this graph.
+ */
+ _calcMaxHeight: function({ data, dataScaleX, minBarsWidth }) {
+ let maxHeight = 0;
+ let prevRight = 0;
+ let skippedCount = 0;
+ let skippedHeight = 0;
+ let scaledMarginEnd = BAR_GRAPH_BARS_MARGIN_END * this._pixelRatio;
+
+ for (let { delta, values } of data) {
+ let barRight = (delta - this.dataOffsetX) * dataScaleX;
+ let barHeight = values.reduce((a, b) => a + b, 0);
+
+ let barWidth = barRight - prevRight;
+ if (barWidth < minBarsWidth) {
+ skippedCount++;
+ skippedHeight += barHeight;
+ continue;
+ }
+
+ let averageHeight = (barHeight + skippedHeight) / (skippedCount + 1);
+ maxHeight = Math.max(averageHeight, maxHeight);
+
+ prevRight += barWidth + scaledMarginEnd;
+ skippedHeight = 0;
+ skippedCount = 0;
+ }
+
+ return maxHeight;
+ },
+
+ /**
+ * Creates the legend container when constructing this graph.
+ */
+ _createLegend: function() {
+ let legendNode = this._legendNode = this._document.createElementNS(HTML_NS, "div");
+ legendNode.className = "bar-graph-widget-legend";
+ this._container.appendChild(legendNode);
+ },
+
+ /**
+ * Creates a legend item when constructing this graph.
+ */
+ _createLegendItem: function(color, label) {
+ let itemNode = this._document.createElementNS(HTML_NS, "div");
+ itemNode.className = "bar-graph-widget-legend-item";
+
+ let colorNode = this._document.createElementNS(HTML_NS, "span");
+ colorNode.setAttribute("view", "color");
+ colorNode.setAttribute("data-index", this._legendNode.childNodes.length);
+ colorNode.style.backgroundColor = color;
+ colorNode.addEventListener("mouseover", this._onLegendMouseOver);
+ colorNode.addEventListener("mouseout", this._onLegendMouseOut);
+ colorNode.addEventListener("mousedown", this._onLegendMouseDown);
+ colorNode.addEventListener("mouseup", this._onLegendMouseUp);
+
+ this.outstandingEventListeners.push([colorNode, "mouseover", this._onLegendMouseOver]);
+ this.outstandingEventListeners.push([colorNode, "mouseout", this._onLegendMouseOut]);
+ this.outstandingEventListeners.push([colorNode, "mousedown", this._onLegendMouseDown]);
+ this.outstandingEventListeners.push([colorNode, "mouseup", this._onLegendMouseUp]);
+
+ let labelNode = this._document.createElementNS(HTML_NS, "span");
+ labelNode.setAttribute("view", "label");
+ labelNode.textContent = label;
+
+ itemNode.appendChild(colorNode);
+ itemNode.appendChild(labelNode);
+ this._legendNode.appendChild(itemNode);
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is hovered.
+ */
+ _onLegendMouseOver: function(e) {
+ setNamedTimeout("bar-graph-debounce", BAR_GRAPH_LEGEND_MOUSEOVER_DEBOUNCE, () => {
+ let type = e.target.dataset.index;
+ let rects = this._blocksBoundingRects.filter(e => e.type == type);
+
+ this._originalHighlights = this._mask;
+ this._hasCustomHighlights = true;
+ this.setMask(rects, true);
+
+ this.emit("legend-hover", [type, rects]);
+ });
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is unhovered.
+ */
+ _onLegendMouseOut: function() {
+ clearNamedTimeout("bar-graph-debounce");
+
+ if (this._hasCustomHighlights) {
+ this.setMask(this._originalHighlights);
+ this._hasCustomHighlights = false;
+ this._originalHighlights = null;
+ }
+
+ this.emit("legend-unhover");
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is pressed.
+ */
+ _onLegendMouseDown: function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ let type = e.target.dataset.index;
+ let rects = this._blocksBoundingRects.filter(e => e.type == type);
+ let leftmost = rects[0];
+ let rightmost = rects[rects.length - 1];
+ if (!leftmost || !rightmost) {
+ this.dropSelection();
+ } else {
+ this.setSelection({ start: leftmost.start, end: rightmost.end });
+ }
+
+ this.emit("legend-selection", [leftmost, rightmost]);
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is released.
+ */
+ _onLegendMouseUp: function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+});
+
+// Helper functions.
+
+/**
+ * Creates an iframe element with the provided source URL, appends it to
+ * the specified node and invokes the callback once the content is loaded.
+ *
+ * @param string url
+ * The desired source URL for the iframe.
+ * @param nsIDOMNode parent
+ * The desired parent node for the iframe.
+ * @param function callback
+ * Invoked once the content is loaded, with the iframe as an argument.
+ */
+AbstractCanvasGraph.createIframe = function(url, parent, callback) {
+ let iframe = parent.ownerDocument.createElementNS(HTML_NS, "iframe");
+
+ iframe.addEventListener("DOMContentLoaded", function onLoad() {
+ iframe.removeEventListener("DOMContentLoaded", onLoad);
+ callback(iframe);
+ });
+
+ iframe.setAttribute("frameborder", "0");
+ iframe.src = url;
+
+ parent.appendChild(iframe);
+};
+
+/**
+ * Gets a striped pattern used as a background in selections and regions.
+ *
+ * @param object data
+ * The following properties are required:
+ * - ownerDocument: the nsIDocumentElement owning the canvas
+ * - backgroundColor: a string representing the fill style
+ * - stripesColor: a string representing the stroke style
+ * @return nsIDOMCanvasPattern
+ * The custom striped pattern.
+ */
+AbstractCanvasGraph.getStripePattern = function(data) {
+ let { ownerDocument, backgroundColor, stripesColor } = data;
+ let id = [backgroundColor, stripesColor].join(",");
+
+ if (gCachedStripePattern.has(id)) {
+ return gCachedStripePattern.get(id);
+ }
+
+ let canvas = ownerDocument.createElementNS(HTML_NS, "canvas");
+ let ctx = canvas.getContext("2d");
+ let width = canvas.width = GRAPH_STRIPE_PATTERN_WIDTH;
+ let height = canvas.height = GRAPH_STRIPE_PATTERN_HEIGHT;
+
+ ctx.fillStyle = backgroundColor;
+ ctx.fillRect(0, 0, width, height);
+
+ let pixelRatio = ownerDocument.defaultView.devicePixelRatio;
+ let scaledLineWidth = GRAPH_STRIPE_PATTERN_LINE_WIDTH * pixelRatio;
+ let scaledLineSpacing = GRAPH_STRIPE_PATTERN_LINE_SPACING * pixelRatio;
+
+ ctx.strokeStyle = stripesColor;
+ ctx.lineWidth = scaledLineWidth;
+ ctx.lineCap = "square";
+ ctx.beginPath();
+
+ for (let i = -height; i <= height; i += scaledLineSpacing) {
+ ctx.moveTo(width, i);
+ ctx.lineTo(0, i + height);
+ }
+
+ ctx.stroke();
+
+ let pattern = ctx.createPattern(canvas, "repeat");
+ gCachedStripePattern.set(id, pattern);
+ return pattern;
+};
+
+/**
+ * Cache used by `AbstractCanvasGraph.getStripePattern`.
+ */
+const gCachedStripePattern = new Map();
+
+/**
+ * Utility functions for graph canvases.
+ */
+this.CanvasGraphUtils = {
+ _graphUtilsWorker: null,
+ _graphUtilsTaskId: 0,
+
+ /**
+ * Merges the animation loop of two graphs.
+ */
+ linkAnimation: Task.async(function*(graph1, graph2) {
+ if (!graph1 || !graph2) {
+ return;
+ }
+ yield graph1.ready();
+ yield graph2.ready();
+
+ let window = graph1._window;
+ window.cancelAnimationFrame(graph1._animationId);
+ window.cancelAnimationFrame(graph2._animationId);
+
+ let loop = () => {
+ window.requestAnimationFrame(loop);
+ graph1._drawWidget();
+ graph2._drawWidget();
+ };
+
+ window.requestAnimationFrame(loop);
+ }),
+
+ /**
+ * Makes sure selections in one graph are reflected in another.
+ */
+ linkSelection: function(graph1, graph2) {
+ if (!graph1 || !graph2) {
+ return;
+ }
+
+ if (graph1.hasSelection()) {
+ graph2.setSelection(graph1.getSelection());
+ } else {
+ graph2.dropSelection();
+ }
+
+ graph1.on("selecting", () => {
+ graph2.setSelection(graph1.getSelection());
+ });
+ graph2.on("selecting", () => {
+ graph1.setSelection(graph2.getSelection());
+ });
+ graph1.on("deselecting", () => {
+ graph2.dropSelection();
+ });
+ graph2.on("deselecting", () => {
+ graph1.dropSelection();
+ });
+ },
+
+ /**
+ * Performs the given task in a chrome worker, assuming it exists.
+ *
+ * @param string task
+ * The task name. Currently supported: "plotTimestampsGraph".
+ * @param any args
+ * Extra arguments to pass to the worker.
+ * @param array transferrable [optional]
+ * A list of transferrable objects, if any.
+ * @return object
+ * A promise that is resolved once the worker finishes the task.
+ */
+ _performTaskInWorker: function(task, args, transferrable) {
+ let worker = this._graphUtilsWorker || new ChromeWorker(WORKER_URL);
+ let id = this._graphUtilsTaskId++;
+ worker.postMessage({ task, id, args }, transferrable);
+ return this._waitForWorkerResponse(worker, id);
+ },
+
+ /**
+ * Waits for the specified worker to finish a task.
+ *
+ * @param ChromeWorker worker
+ * The worker for which to add a message listener.
+ * @param number id
+ * The worker task id.
+ */
+ _waitForWorkerResponse: function(worker, id) {
+ let deferred = promise.defer();
+
+ worker.addEventListener("message", function listener({ data }) {
+ if (data.id != id) {
+ return;
+ }
+ worker.removeEventListener("message", listener);
+ deferred.resolve(data);
+ });
+
+ return deferred.promise;
+ }
+};
+
+/**
+ * Maps a value from one range to another.
+ * @param number value, istart, istop, ostart, ostop
+ * @return number
+ */
+function map(value, istart, istop, ostart, ostop) {
+ let ratio = istop - istart;
+ if (ratio == 0) {
+ return value;
+ }
+ return ostart + (ostop - ostart) * ((value - istart) / ratio);
+}
+
+/**
+ * Constrains a value to a range.
+ * @param number value, min, max
+ * @return number
+ */
+function clamp(value, min, max) {
+ if (value < min) return min;
+ if (value > max) return max;
+ return value;
+}
+
+/**
+ * Calculates the squared distance between two 2D points.
+ */
+function distSquared(x0, y0, x1, y1) {
+ let xs = x1 - x0;
+ let ys = y1 - y0;
+ return xs * xs + ys * ys;
+}
+
+/**
+ * Finds the first element in an array that validates a predicate.
+ * @param array
+ * @param function predicate
+ * @return number
+ */
+function findFirst(array, predicate) {
+ for (let i = 0, len = array.length; i < len; i++) {
+ let element = array[i];
+ if (predicate(element)) return element;
+ }
+}
+
+/**
+ * Finds the last element in an array that validates a predicate.
+ * @param array
+ * @param function predicate
+ * @return number
+ */
+function findLast(array, predicate) {
+ for (let i = array.length - 1; i >= 0; i--) {
+ let element = array[i];
+ if (predicate(element)) return element;
+ }
+}
diff --git a/toolkit/devtools/shared/widgets/GraphsWorker.js b/toolkit/devtools/shared/widgets/GraphsWorker.js
new file mode 100644
index 000000000..ec00eb9b8
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/GraphsWorker.js
@@ -0,0 +1,107 @@
+/* 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";
+
+self.onmessage = e => {
+ const { id, task, args } = e.data;
+
+ switch (task) {
+ case "plotTimestampsGraph":
+ plotTimestampsGraph(id, args);
+ break;
+ default:
+ self.postMessage({ id, error: e.message + "\n" + e.stack });
+ break;
+ }
+};
+
+/**
+ * @see LineGraphWidget.prototype.setDataFromTimestamps in Graphs.jsm
+ * @param number id
+ * @param array timestamps
+ * @param number interval
+ */
+function plotTimestampsGraph(id, args) {
+ let plottedData = plotTimestamps(args.timestamps, args.interval);
+ let plottedMinMaxSum = getMinMaxSum(plottedData);
+
+ let response = { id, plottedData, plottedMinMaxSum };
+ self.postMessage(response);
+}
+
+/**
+ * Gets the min, max and average of the values in an array.
+ * @param array source
+ * @return object
+ */
+function getMinMaxSum(source) {
+ let totalTicks = source.length;
+ let maxValue = Number.MIN_SAFE_INTEGER;
+ let minValue = Number.MAX_SAFE_INTEGER;
+ let avgValue = 0;
+ let sumValues = 0;
+
+ for (let { value } of source) {
+ maxValue = Math.max(value, maxValue);
+ minValue = Math.min(value, minValue);
+ sumValues += value;
+ }
+ avgValue = sumValues / totalTicks;
+
+ return { minValue, maxValue, avgValue };
+}
+
+/**
+ * Takes a list of numbers and plots them on a line graph representing
+ * the rate of occurences in a specified interval.
+ *
+ * XXX: Copied almost verbatim from toolkit/devtools/server/actors/framerate.js
+ * Remove that dead code after the Performance panel lands, bug 1075567.
+ *
+ * @param array timestamps
+ * A list of numbers representing time, ordered ascending. For example,
+ * this can be the raw data received from the framerate actor, which
+ * represents the elapsed time on each refresh driver tick.
+ * @param number interval
+ * The maximum amount of time to wait between calculations.
+ * @param number clamp
+ * The maximum allowed value.
+ * @return array
+ * A collection of { delta, value } objects representing the
+ * plotted value at every delta time.
+ */
+function plotTimestamps(timestamps, interval = 100, clamp = 60) {
+ let timeline = [];
+ let totalTicks = timestamps.length;
+
+ // If the refresh driver didn't get a chance to tick before the
+ // recording was stopped, assume rate was 0.
+ if (totalTicks == 0) {
+ timeline.push({ delta: 0, value: 0 });
+ timeline.push({ delta: interval, value: 0 });
+ return timeline;
+ }
+
+ let frameCount = 0;
+ let prevTime = +timestamps[0];
+
+ for (let i = 1; i < totalTicks; i++) {
+ let currTime = +timestamps[i];
+ frameCount++;
+
+ let elapsedTime = currTime - prevTime;
+ if (elapsedTime < interval) {
+ continue;
+ }
+
+ let rate = Math.min(1000 / (elapsedTime / frameCount), clamp);
+ timeline.push({ delta: prevTime, value: rate });
+ timeline.push({ delta: currTime, value: rate });
+
+ frameCount = 0;
+ prevTime = currTime;
+ }
+
+ return timeline;
+}
diff --git a/toolkit/devtools/shared/widgets/SideMenuWidget.jsm b/toolkit/devtools/shared/widgets/SideMenuWidget.jsm
new file mode 100644
index 000000000..9f79e6c89
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/SideMenuWidget.jsm
@@ -0,0 +1,676 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource://gre/modules/devtools/event-emitter.js");
+
+this.EXPORTED_SYMBOLS = ["SideMenuWidget"];
+
+/**
+ * A simple side menu, with the ability of grouping menu items.
+ *
+ * Note: this widget should be used in tandem with the WidgetMethods in
+ * ViewHelpers.jsm.
+ *
+ * @param nsIDOMNode aNode
+ * The element associated with the widget.
+ * @param Object aOptions
+ * - showArrows: specifies if items should display horizontal arrows.
+ * - showItemCheckboxes: specifies if items should display checkboxes.
+ * - showGroupCheckboxes: specifies if groups should display checkboxes.
+ */
+this.SideMenuWidget = function SideMenuWidget(aNode, aOptions={}) {
+ this.document = aNode.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = aNode;
+
+ let { showArrows, showItemCheckboxes, showGroupCheckboxes } = aOptions;
+ this._showArrows = showArrows || false;
+ this._showItemCheckboxes = showItemCheckboxes || false;
+ this._showGroupCheckboxes = showGroupCheckboxes || false;
+
+ // Create an internal scrollbox container.
+ this._list = this.document.createElement("scrollbox");
+ this._list.className = "side-menu-widget-container theme-sidebar";
+ this._list.setAttribute("flex", "1");
+ this._list.setAttribute("orient", "vertical");
+ this._list.setAttribute("with-arrows", this._showArrows);
+ this._list.setAttribute("with-item-checkboxes", this._showItemCheckboxes);
+ this._list.setAttribute("with-group-checkboxes", this._showGroupCheckboxes);
+ this._list.setAttribute("tabindex", "0");
+ this._list.addEventListener("keypress", e => this.emit("keyPress", e), false);
+ this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false);
+ this._parent.appendChild(this._list);
+
+ // Menu items can optionally be grouped.
+ this._groupsByName = new Map(); // Can't use a WeakMap because keys are strings.
+ this._orderedGroupElementsArray = [];
+ this._orderedMenuElementsArray = [];
+ this._itemsByElement = new Map();
+
+ // This widget emits events that can be handled in a MenuContainer.
+ EventEmitter.decorate(this);
+
+ // Delegate some of the associated node's methods to satisfy the interface
+ // required by MenuContainer instances.
+ ViewHelpers.delegateWidgetAttributeMethods(this, aNode);
+ ViewHelpers.delegateWidgetEventMethods(this, aNode);
+};
+
+SideMenuWidget.prototype = {
+ /**
+ * Specifies if groups in this container should be sorted.
+ */
+ sortedGroups: true,
+
+ /**
+ * The comparator used to sort groups.
+ */
+ groupSortPredicate: function(a, b) a.localeCompare(b),
+
+ /**
+ * Specifies that the container viewport should be "stuck" to the
+ * bottom. That is, the container is automatically scrolled down to
+ * keep appended items visible, but only when the scroll position is
+ * already at the bottom.
+ */
+ autoscrollWithAppendedItems: false,
+
+ /**
+ * Inserts an item in this container at the specified index, optionally
+ * grouping by name.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ * @param object aAttachment [optional]
+ * Some attached primitive/object. Custom options supported:
+ * - group: a string specifying the group to place this item into
+ * - checkboxState: the checked state of the checkbox, if shown
+ * - checkboxTooltip: the tooltip text for the checkbox, if shown
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ insertItemAt: function(aIndex, aContents, aAttachment={}) {
+ // Maintaining scroll position at the bottom when a new item is inserted
+ // depends on several factors (the order of testing is important to avoid
+ // needlessly expensive operations that may cause reflows):
+ let maintainScrollAtBottom =
+ // 1. The behavior should be enabled,
+ this.autoscrollWithAppendedItems &&
+ // 2. There shouldn't currently be any selected item in the list.
+ !this._selectedItem &&
+ // 3. The new item should be appended at the end of the list.
+ (aIndex < 0 || aIndex >= this._orderedMenuElementsArray.length) &&
+ // 4. The list should already be scrolled at the bottom.
+ (this._list.scrollTop + this._list.clientHeight >= this._list.scrollHeight);
+
+ let group = this._getMenuGroupForName(aAttachment.group);
+ let item = this._getMenuItemForGroup(group, aContents, aAttachment);
+ let element = item.insertSelfAt(aIndex);
+
+ if (maintainScrollAtBottom) {
+ this._list.scrollTop = this._list.scrollHeight;
+ }
+
+ return element;
+ },
+
+ /**
+ * Returns the child node in this container situated at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ getItemAtIndex: function(aIndex) {
+ return this._orderedMenuElementsArray[aIndex];
+ },
+
+ /**
+ * Removes the specified child node from this container.
+ *
+ * @param nsIDOMNode aChild
+ * The element associated with the displayed item.
+ */
+ removeChild: function(aChild) {
+ this._getNodeForContents(aChild).remove();
+
+ this._orderedMenuElementsArray.splice(
+ this._orderedMenuElementsArray.indexOf(aChild), 1);
+
+ this._itemsByElement.delete(aChild);
+
+ if (this._selectedItem == aChild) {
+ this._selectedItem = null;
+ }
+ },
+
+ /**
+ * Removes all of the child nodes from this container.
+ */
+ removeAllItems: function() {
+ let parent = this._parent;
+ let list = this._list;
+
+ while (list.hasChildNodes()) {
+ list.firstChild.remove();
+ }
+
+ this._selectedItem = null;
+
+ this._groupsByName.clear();
+ this._orderedGroupElementsArray.length = 0;
+ this._orderedMenuElementsArray.length = 0;
+ this._itemsByElement.clear();
+ },
+
+ /**
+ * Gets the currently selected child node in this container.
+ * @return nsIDOMNode
+ */
+ get selectedItem() {
+ return this._selectedItem;
+ },
+
+ /**
+ * Sets the currently selected child node in this container.
+ * @param nsIDOMNode aChild
+ */
+ set selectedItem(aChild) {
+ let menuArray = this._orderedMenuElementsArray;
+
+ if (!aChild) {
+ this._selectedItem = null;
+ }
+ for (let node of menuArray) {
+ if (node == aChild) {
+ this._getNodeForContents(node).classList.add("selected");
+ this._selectedItem = node;
+ } else {
+ this._getNodeForContents(node).classList.remove("selected");
+ }
+ }
+ },
+
+ /**
+ * Ensures the specified element is visible.
+ *
+ * @param nsIDOMNode aElement
+ * The element to make visible.
+ */
+ ensureElementIsVisible: function(aElement) {
+ if (!aElement) {
+ return;
+ }
+
+ // Ensure the element is visible but not scrolled horizontally.
+ let boxObject = this._list.boxObject;
+ boxObject.ensureElementIsVisible(aElement);
+ boxObject.scrollBy(-this._list.clientWidth, 0);
+ },
+
+ /**
+ * Shows all the groups, even the ones with no visible children.
+ */
+ showEmptyGroups: function() {
+ for (let group of this._orderedGroupElementsArray) {
+ group.hidden = false;
+ }
+ },
+
+ /**
+ * Hides all the groups which have no visible children.
+ */
+ hideEmptyGroups: function() {
+ let visibleChildNodes = ".side-menu-widget-item-contents:not([hidden=true])";
+
+ for (let group of this._orderedGroupElementsArray) {
+ group.hidden = group.querySelectorAll(visibleChildNodes).length == 0;
+ }
+ for (let menuItem of this._orderedMenuElementsArray) {
+ menuItem.parentNode.hidden = menuItem.hidden;
+ }
+ },
+
+ /**
+ * Adds a new attribute or changes an existing attribute on this container.
+ *
+ * @param string aName
+ * The name of the attribute.
+ * @param string aValue
+ * The desired attribute value.
+ */
+ setAttribute: function(aName, aValue) {
+ this._parent.setAttribute(aName, aValue);
+
+ if (aName == "emptyText") {
+ this._textWhenEmpty = aValue;
+ }
+ },
+
+ /**
+ * Removes an attribute on this container.
+ *
+ * @param string aName
+ * The name of the attribute.
+ */
+ removeAttribute: function(aName) {
+ this._parent.removeAttribute(aName);
+
+ if (aName == "emptyText") {
+ this._removeEmptyText();
+ }
+ },
+
+ /**
+ * Set the checkbox state for the item associated with the given node.
+ *
+ * @param nsIDOMNode aNode
+ * The dom node for an item we want to check.
+ * @param boolean aCheckState
+ * True to check, false to uncheck.
+ */
+ checkItem: function(aNode, aCheckState) {
+ const widgetItem = this._itemsByElement.get(aNode);
+ if (!widgetItem) {
+ throw new Error("No item for " + aNode);
+ }
+ widgetItem.check(aCheckState);
+ },
+
+ /**
+ * Sets the text displayed in this container when empty.
+ * @param string aValue
+ */
+ set _textWhenEmpty(aValue) {
+ if (this._emptyTextNode) {
+ this._emptyTextNode.setAttribute("value", aValue);
+ }
+ this._emptyTextValue = aValue;
+ this._showEmptyText();
+ },
+
+ /**
+ * Creates and appends a label signaling that this container is empty.
+ */
+ _showEmptyText: function() {
+ if (this._emptyTextNode || !this._emptyTextValue) {
+ return;
+ }
+ let label = this.document.createElement("label");
+ label.className = "plain side-menu-widget-empty-text";
+ label.setAttribute("value", this._emptyTextValue);
+
+ this._parent.insertBefore(label, this._list);
+ this._emptyTextNode = label;
+ },
+
+ /**
+ * Removes the label representing a notice in this container.
+ */
+ _removeEmptyText: function() {
+ if (!this._emptyTextNode) {
+ return;
+ }
+
+ this._parent.removeChild(this._emptyTextNode);
+ this._emptyTextNode = null;
+ },
+
+ /**
+ * Gets a container representing a group for menu items. If the container
+ * is not available yet, it is immediately created.
+ *
+ * @param string aName
+ * The required group name.
+ * @return SideMenuGroup
+ * The newly created group.
+ */
+ _getMenuGroupForName: function(aName) {
+ let cachedGroup = this._groupsByName.get(aName);
+ if (cachedGroup) {
+ return cachedGroup;
+ }
+
+ let group = new SideMenuGroup(this, aName, {
+ showCheckbox: this._showGroupCheckboxes
+ });
+
+ this._groupsByName.set(aName, group);
+ group.insertSelfAt(this.sortedGroups ? group.findExpectedIndexForSelf(this.groupSortPredicate) : -1);
+
+ return group;
+ },
+
+ /**
+ * Gets a menu item to be displayed inside a group.
+ * @see SideMenuWidget.prototype._getMenuGroupForName
+ *
+ * @param SideMenuGroup aGroup
+ * The group to contain the menu item.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ * @param object aAttachment [optional]
+ * Some attached primitive/object.
+ */
+ _getMenuItemForGroup: function(aGroup, aContents, aAttachment) {
+ return new SideMenuItem(aGroup, aContents, aAttachment, {
+ showArrow: this._showArrows,
+ showCheckbox: this._showItemCheckboxes
+ });
+ },
+
+ /**
+ * Returns the .side-menu-widget-item node corresponding to a SideMenuItem.
+ * To optimize the markup, some redundant elemenst are skipped when creating
+ * these child items, in which case we need to be careful on which nodes
+ * .selected class names are added, or which nodes are removed.
+ *
+ * @param nsIDOMNode aChild
+ * An element which is the target node of a SideMenuItem.
+ * @return nsIDOMNode
+ * The wrapper node if there is one, or the same child otherwise.
+ */
+ _getNodeForContents: function(aChild) {
+ if (aChild.hasAttribute("merged-item-contents")) {
+ return aChild;
+ } else {
+ return aChild.parentNode;
+ }
+ },
+
+ window: null,
+ document: null,
+ _showArrows: false,
+ _showItemCheckboxes: false,
+ _showGroupCheckboxes: false,
+ _parent: null,
+ _list: null,
+ _selectedItem: null,
+ _groupsByName: null,
+ _orderedGroupElementsArray: null,
+ _orderedMenuElementsArray: null,
+ _itemsByElement: null,
+ _emptyTextNode: null,
+ _emptyTextValue: ""
+};
+
+/**
+ * A SideMenuGroup constructor for the BreadcrumbsWidget.
+ * Represents a group which should contain SideMenuItems.
+ *
+ * @param SideMenuWidget aWidget
+ * The widget to contain this menu item.
+ * @param string aName
+ * The string displayed in the container.
+ * @param object aOptions [optional]
+ * An object containing the following properties:
+ * - showCheckbox: specifies if a checkbox should be displayed.
+ */
+function SideMenuGroup(aWidget, aName, aOptions={}) {
+ this.document = aWidget.document;
+ this.window = aWidget.window;
+ this.ownerView = aWidget;
+ this.identifier = aName;
+
+ // Create an internal title and list container.
+ if (aName) {
+ let target = this._target = this.document.createElement("vbox");
+ target.className = "side-menu-widget-group";
+ target.setAttribute("name", aName);
+
+ let list = this._list = this.document.createElement("vbox");
+ list.className = "side-menu-widget-group-list";
+
+ let title = this._title = this.document.createElement("hbox");
+ title.className = "side-menu-widget-group-title";
+
+ let name = this._name = this.document.createElement("label");
+ name.className = "plain name";
+ name.setAttribute("value", aName);
+ name.setAttribute("crop", "end");
+ name.setAttribute("flex", "1");
+
+ // Show a checkbox before the content.
+ if (aOptions.showCheckbox) {
+ let checkbox = this._checkbox = makeCheckbox(title, { description: aName });
+ checkbox.className = "side-menu-widget-group-checkbox";
+ }
+
+ title.appendChild(name);
+ target.appendChild(title);
+ target.appendChild(list);
+ }
+ // Skip a few redundant nodes when no title is shown.
+ else {
+ let target = this._target = this._list = this.document.createElement("vbox");
+ target.className = "side-menu-widget-group side-menu-widget-group-list";
+ target.setAttribute("merged-group-contents", "");
+ }
+}
+
+SideMenuGroup.prototype = {
+ get _orderedGroupElementsArray() this.ownerView._orderedGroupElementsArray,
+ get _orderedMenuElementsArray() this.ownerView._orderedMenuElementsArray,
+ get _itemsByElement() { return this.ownerView._itemsByElement; },
+
+ /**
+ * Inserts this group in the parent container at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this group.
+ */
+ insertSelfAt: function(aIndex) {
+ let ownerList = this.ownerView._list;
+ let groupsArray = this._orderedGroupElementsArray;
+
+ if (aIndex >= 0) {
+ ownerList.insertBefore(this._target, groupsArray[aIndex]);
+ groupsArray.splice(aIndex, 0, this._target);
+ } else {
+ ownerList.appendChild(this._target);
+ groupsArray.push(this._target);
+ }
+ },
+
+ /**
+ * Finds the expected index of this group based on its name.
+ *
+ * @return number
+ * The expected index.
+ */
+ findExpectedIndexForSelf: function(sortPredicate) {
+ let identifier = this.identifier;
+ let groupsArray = this._orderedGroupElementsArray;
+
+ for (let group of groupsArray) {
+ let name = group.getAttribute("name");
+ if (sortPredicate(name, identifier) > 0 && // Insertion sort at its best :)
+ !name.contains(identifier)) { // Least significant group should be last.
+ return groupsArray.indexOf(group);
+ }
+ }
+ return -1;
+ },
+
+ window: null,
+ document: null,
+ ownerView: null,
+ identifier: "",
+ _target: null,
+ _checkbox: null,
+ _title: null,
+ _name: null,
+ _list: null
+};
+
+/**
+ * A SideMenuItem constructor for the BreadcrumbsWidget.
+ *
+ * @param SideMenuGroup aGroup
+ * The group to contain this menu item.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ * @param object aAttachment [optional]
+ * The attachment object.
+ * @param object aOptions [optional]
+ * An object containing the following properties:
+ * - showArrow: specifies if a horizontal arrow should be displayed.
+ * - showCheckbox: specifies if a checkbox should be displayed.
+ */
+function SideMenuItem(aGroup, aContents, aAttachment={}, aOptions={}) {
+ this.document = aGroup.document;
+ this.window = aGroup.window;
+ this.ownerView = aGroup;
+
+ if (aOptions.showArrow || aOptions.showCheckbox) {
+ let container = this._container = this.document.createElement("hbox");
+ container.className = "side-menu-widget-item";
+
+ let target = this._target = this.document.createElement("vbox");
+ target.className = "side-menu-widget-item-contents";
+
+ // Show a checkbox before the content.
+ if (aOptions.showCheckbox) {
+ let checkbox = this._checkbox = makeCheckbox(container, aAttachment);
+ checkbox.className = "side-menu-widget-item-checkbox";
+ }
+
+ container.appendChild(target);
+
+ // Show a horizontal arrow towards the content.
+ if (aOptions.showArrow) {
+ let arrow = this._arrow = this.document.createElement("hbox");
+ arrow.className = "side-menu-widget-item-arrow";
+ container.appendChild(arrow);
+ }
+ }
+ // Skip a few redundant nodes when no horizontal arrow or checkbox is shown.
+ else {
+ let target = this._target = this._container = this.document.createElement("hbox");
+ target.className = "side-menu-widget-item side-menu-widget-item-contents";
+ target.setAttribute("merged-item-contents", "");
+ }
+
+ this._target.setAttribute("flex", "1");
+ this.contents = aContents;
+}
+
+SideMenuItem.prototype = {
+ get _orderedGroupElementsArray() this.ownerView._orderedGroupElementsArray,
+ get _orderedMenuElementsArray() this.ownerView._orderedMenuElementsArray,
+ get _itemsByElement() { return this.ownerView._itemsByElement; },
+
+ /**
+ * Inserts this item in the parent group at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ insertSelfAt: function(aIndex) {
+ let ownerList = this.ownerView._list;
+ let menuArray = this._orderedMenuElementsArray;
+
+ if (aIndex >= 0) {
+ ownerList.insertBefore(this._container, ownerList.childNodes[aIndex]);
+ menuArray.splice(aIndex, 0, this._target);
+ } else {
+ ownerList.appendChild(this._container);
+ menuArray.push(this._target);
+ }
+ this._itemsByElement.set(this._target, this);
+
+ return this._target;
+ },
+
+ /**
+ * Check or uncheck the checkbox associated with this item.
+ *
+ * @param boolean aCheckState
+ * True to check, false to uncheck.
+ */
+ check: function(aCheckState) {
+ if (!this._checkbox) {
+ throw new Error("Cannot check items that do not have checkboxes.");
+ }
+ // Don't set or remove the "checked" attribute, assign the property instead.
+ // Otherwise, the "CheckboxStateChange" event will not be fired. XUL!!
+ this._checkbox.checked = !!aCheckState;
+ },
+
+ /**
+ * Sets the contents displayed in this item's view.
+ *
+ * @param string | nsIDOMNode aContents
+ * The string or node displayed in the container.
+ */
+ set contents(aContents) {
+ // If there are already some contents displayed, replace them.
+ if (this._target.hasChildNodes()) {
+ this._target.replaceChild(aContents, this._target.firstChild);
+ return;
+ }
+ // These are the first contents ever displayed.
+ this._target.appendChild(aContents);
+ },
+
+ window: null,
+ document: null,
+ ownerView: null,
+ _target: null,
+ _container: null,
+ _checkbox: null,
+ _arrow: null
+};
+
+/**
+ * Creates a checkbox to a specified parent node. Emits a "check" event
+ * whenever the checkbox is checked or unchecked by the user.
+ *
+ * @param nsIDOMNode aParentNode
+ * The parent node to contain this checkbox.
+ * @param object aOptions
+ * An object containing some or all of the following properties:
+ * - description: defaults to "item" if unspecified
+ * - checkboxState: true for checked, false for unchecked
+ * - checkboxTooltip: the tooltip text of the checkbox
+ */
+function makeCheckbox(aParentNode, aOptions) {
+ let checkbox = aParentNode.ownerDocument.createElement("checkbox");
+ checkbox.setAttribute("tooltiptext", aOptions.checkboxTooltip);
+
+ if (aOptions.checkboxState) {
+ checkbox.setAttribute("checked", true);
+ } else {
+ checkbox.removeAttribute("checked");
+ }
+
+ // Stop the toggling of the checkbox from selecting the list item.
+ checkbox.addEventListener("mousedown", e => {
+ e.stopPropagation();
+ }, false);
+
+ // Emit an event from the checkbox when it is toggled. Don't listen for the
+ // "command" event! It won't fire for programmatic changes. XUL!!
+ checkbox.addEventListener("CheckboxStateChange", e => {
+ ViewHelpers.dispatchEvent(checkbox, "check", {
+ description: aOptions.description || "item",
+ checked: checkbox.checked
+ });
+ }, false);
+
+ aParentNode.appendChild(checkbox);
+ return checkbox;
+}
diff --git a/toolkit/devtools/shared/widgets/SimpleListWidget.jsm b/toolkit/devtools/shared/widgets/SimpleListWidget.jsm
new file mode 100644
index 000000000..92c8a40b6
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/SimpleListWidget.jsm
@@ -0,0 +1,253 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+this.EXPORTED_SYMBOLS = ["SimpleListWidget"];
+
+/**
+ * A very simple vertical list view.
+ *
+ * Note: this widget should be used in tandem with the WidgetMethods in
+ * ViewHelpers.jsm.
+ *
+ * @param nsIDOMNode aNode
+ * The element associated with the widget.
+ */
+function SimpleListWidget(aNode) {
+ this.document = aNode.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = aNode;
+
+ // Create an internal list container.
+ this._list = this.document.createElement("scrollbox");
+ this._list.className = "simple-list-widget-container theme-body";
+ this._list.setAttribute("flex", "1");
+ this._list.setAttribute("orient", "vertical");
+ this._parent.appendChild(this._list);
+
+ // Delegate some of the associated node's methods to satisfy the interface
+ // required by WidgetMethods instances.
+ ViewHelpers.delegateWidgetAttributeMethods(this, aNode);
+ ViewHelpers.delegateWidgetEventMethods(this, aNode);
+}
+
+SimpleListWidget.prototype = {
+ /**
+ * Inserts an item in this container at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @param nsIDOMNode aContents
+ * The node displayed in the container.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ insertItemAt: function(aIndex, aContents) {
+ aContents.classList.add("simple-list-widget-item");
+
+ let list = this._list;
+ return list.insertBefore(aContents, list.childNodes[aIndex]);
+ },
+
+ /**
+ * Returns the child node in this container situated at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @return nsIDOMNode
+ * The element associated with the displayed item.
+ */
+ getItemAtIndex: function(aIndex) {
+ return this._list.childNodes[aIndex];
+ },
+
+ /**
+ * Immediately removes the specified child node from this container.
+ *
+ * @param nsIDOMNode aChild
+ * The element associated with the displayed item.
+ */
+ removeChild: function(aChild) {
+ this._list.removeChild(aChild);
+
+ if (this._selectedItem == aChild) {
+ this._selectedItem = null;
+ }
+ },
+
+ /**
+ * Removes all of the child nodes from this container.
+ */
+ removeAllItems: function() {
+ let list = this._list;
+ let parent = this._parent;
+
+ while (list.hasChildNodes()) {
+ list.firstChild.remove();
+ }
+
+ parent.scrollTop = 0;
+ parent.scrollLeft = 0;
+ this._selectedItem = null;
+ },
+
+ /**
+ * Gets the currently selected child node in this container.
+ * @return nsIDOMNode
+ */
+ get selectedItem() {
+ return this._selectedItem;
+ },
+
+ /**
+ * Sets the currently selected child node in this container.
+ * @param nsIDOMNode aChild
+ */
+ set selectedItem(aChild) {
+ let childNodes = this._list.childNodes;
+
+ if (!aChild) {
+ this._selectedItem = null;
+ }
+ for (let node of childNodes) {
+ if (node == aChild) {
+ node.classList.add("selected");
+ this._selectedItem = node;
+ } else {
+ node.classList.remove("selected");
+ }
+ }
+ },
+
+ /**
+ * Adds a new attribute or changes an existing attribute on this container.
+ *
+ * @param string aName
+ * The name of the attribute.
+ * @param string aValue
+ * The desired attribute value.
+ */
+ setAttribute: function(aName, aValue) {
+ this._parent.setAttribute(aName, aValue);
+
+ if (aName == "emptyText") {
+ this._textWhenEmpty = aValue;
+ } else if (aName == "headerText") {
+ this._textAsHeader = aValue;
+ }
+ },
+
+ /**
+ * Removes an attribute on this container.
+ *
+ * @param string aName
+ * The name of the attribute.
+ */
+ removeAttribute: function(aName) {
+ this._parent.removeAttribute(aName);
+
+ if (aName == "emptyText") {
+ this._removeEmptyText();
+ }
+ },
+
+ /**
+ * Ensures the specified element is visible.
+ *
+ * @param nsIDOMNode aElement
+ * The element to make visible.
+ */
+ ensureElementIsVisible: function(aElement) {
+ if (!aElement) {
+ return;
+ }
+
+ // Ensure the element is visible but not scrolled horizontally.
+ let boxObject = this._list.boxObject;
+ boxObject.ensureElementIsVisible(aElement);
+ boxObject.scrollBy(-this._list.clientWidth, 0);
+ },
+
+ /**
+ * Sets the text displayed permanently in this container as a header.
+ * @param string aValue
+ */
+ set _textAsHeader(aValue) {
+ if (this._headerTextNode) {
+ this._headerTextNode.setAttribute("value", aValue);
+ }
+ this._headerTextValue = aValue;
+ this._showHeaderText();
+ },
+
+ /**
+ * Sets the text displayed in this container when empty.
+ * @param string aValue
+ */
+ set _textWhenEmpty(aValue) {
+ if (this._emptyTextNode) {
+ this._emptyTextNode.setAttribute("value", aValue);
+ }
+ this._emptyTextValue = aValue;
+ this._showEmptyText();
+ },
+
+ /**
+ * Creates and appends a label displayed as this container's header.
+ */
+ _showHeaderText: function() {
+ if (this._headerTextNode || !this._headerTextValue) {
+ return;
+ }
+ let label = this.document.createElement("label");
+ label.className = "plain simple-list-widget-perma-text";
+ label.setAttribute("value", this._headerTextValue);
+
+ this._parent.insertBefore(label, this._list);
+ this._headerTextNode = label;
+ },
+
+ /**
+ * Creates and appends a label signaling that this container is empty.
+ */
+ _showEmptyText: function() {
+ if (this._emptyTextNode || !this._emptyTextValue) {
+ return;
+ }
+ let label = this.document.createElement("label");
+ label.className = "plain simple-list-widget-empty-text";
+ label.setAttribute("value", this._emptyTextValue);
+
+ this._parent.appendChild(label);
+ this._emptyTextNode = label;
+ },
+
+ /**
+ * Removes the label signaling that this container is empty.
+ */
+ _removeEmptyText: function() {
+ if (!this._emptyTextNode) {
+ return;
+ }
+ this._parent.removeChild(this._emptyTextNode);
+ this._emptyTextNode = null;
+ },
+
+ window: null,
+ document: null,
+ _parent: null,
+ _list: null,
+ _selectedItem: null,
+ _headerTextNode: null,
+ _headerTextValue: "",
+ _emptyTextNode: null,
+ _emptyTextValue: ""
+};
diff --git a/toolkit/devtools/shared/widgets/Spectrum.js b/toolkit/devtools/shared/widgets/Spectrum.js
new file mode 100644
index 000000000..8901319cb
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/Spectrum.js
@@ -0,0 +1,337 @@
+/* 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 EventEmitter = require("devtools/toolkit/event-emitter");
+
+/**
+ * Spectrum creates a color picker widget in any container you give it.
+ *
+ * Simple usage example:
+ *
+ * const {Spectrum} = require("devtools/shared/widgets/Spectrum");
+ * let s = new Spectrum(containerElement, [255, 126, 255, 1]);
+ * s.on("changed", (event, rgba, color) => {
+ * console.log("rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ", " + rgba[3] + ")");
+ * });
+ * s.show();
+ * s.destroy();
+ *
+ * Note that the color picker is hidden by default and you need to call show to
+ * make it appear. This 2 stages initialization helps in cases you are creating
+ * the color picker in a parent element that hasn't been appended anywhere yet
+ * or that is hidden. Calling show() when the parent element is appended and
+ * visible will allow spectrum to correctly initialize its various parts.
+ *
+ * Fires the following events:
+ * - changed : When the user changes the current color
+ */
+function Spectrum(parentEl, rgb) {
+ EventEmitter.decorate(this);
+
+ this.element = parentEl.ownerDocument.createElement('div');
+ this.parentEl = parentEl;
+
+ this.element.className = "spectrum-container";
+ this.element.innerHTML = [
+ "<div class='spectrum-top'>",
+ "<div class='spectrum-fill'></div>",
+ "<div class='spectrum-top-inner'>",
+ "<div class='spectrum-color spectrum-box'>",
+ "<div class='spectrum-sat'>",
+ "<div class='spectrum-val'>",
+ "<div class='spectrum-dragger'></div>",
+ "</div>",
+ "</div>",
+ "</div>",
+ "<div class='spectrum-hue spectrum-box'>",
+ "<div class='spectrum-slider spectrum-slider-control'></div>",
+ "</div>",
+ "</div>",
+ "</div>",
+ "<div class='spectrum-alpha spectrum-checker spectrum-box'>",
+ "<div class='spectrum-alpha-inner'>",
+ "<div class='spectrum-alpha-handle spectrum-slider-control'></div>",
+ "</div>",
+ "</div>",
+ ].join("");
+
+ this.onElementClick = this.onElementClick.bind(this);
+ this.element.addEventListener("click", this.onElementClick, false);
+
+ this.parentEl.appendChild(this.element);
+
+ this.slider = this.element.querySelector(".spectrum-hue");
+ this.slideHelper = this.element.querySelector(".spectrum-slider");
+ Spectrum.draggable(this.slider, this.onSliderMove.bind(this));
+
+ this.dragger = this.element.querySelector(".spectrum-color");
+ this.dragHelper = this.element.querySelector(".spectrum-dragger");
+ Spectrum.draggable(this.dragger, this.onDraggerMove.bind(this));
+
+ this.alphaSlider = this.element.querySelector(".spectrum-alpha");
+ this.alphaSliderInner = this.element.querySelector(".spectrum-alpha-inner");
+ this.alphaSliderHelper = this.element.querySelector(".spectrum-alpha-handle");
+ Spectrum.draggable(this.alphaSliderInner, this.onAlphaSliderMove.bind(this));
+
+ if (rgb) {
+ this.rgb = rgb;
+ this.updateUI();
+ }
+}
+
+module.exports.Spectrum = Spectrum;
+
+Spectrum.hsvToRgb = function(h, s, v, a) {
+ let r, g, b;
+
+ let i = Math.floor(h * 6);
+ let f = h * 6 - i;
+ let p = v * (1 - s);
+ let q = v * (1 - f * s);
+ let t = v * (1 - (1 - f) * s);
+
+ switch(i % 6) {
+ case 0: r = v, g = t, b = p; break;
+ case 1: r = q, g = v, b = p; break;
+ case 2: r = p, g = v, b = t; break;
+ case 3: r = p, g = q, b = v; break;
+ case 4: r = t, g = p, b = v; break;
+ case 5: r = v, g = p, b = q; break;
+ }
+
+ return [r * 255, g * 255, b * 255, a];
+};
+
+Spectrum.rgbToHsv = function(r, g, b, a) {
+ r = r / 255;
+ g = g / 255;
+ b = b / 255;
+
+ let max = Math.max(r, g, b), min = Math.min(r, g, b);
+ let h, s, v = max;
+
+ let d = max - min;
+ s = max == 0 ? 0 : d / max;
+
+ if(max == min) {
+ h = 0; // achromatic
+ }
+ else {
+ switch(max) {
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+ case g: h = (b - r) / d + 2; break;
+ case b: h = (r - g) / d + 4; break;
+ }
+ h /= 6;
+ }
+ return [h, s, v, a];
+};
+
+Spectrum.getOffset = function(el) {
+ let curleft = 0, curtop = 0;
+ if (el.offsetParent) {
+ while (el) {
+ curleft += el.offsetLeft;
+ curtop += el.offsetTop;
+ el = el.offsetParent;
+ }
+ }
+ return {
+ left: curleft,
+ top: curtop
+ };
+};
+
+Spectrum.draggable = function(element, onmove, onstart, onstop) {
+ onmove = onmove || function() {};
+ onstart = onstart || function() {};
+ onstop = onstop || function() {};
+
+ let doc = element.ownerDocument;
+ let dragging = false;
+ let offset = {};
+ let maxHeight = 0;
+ let maxWidth = 0;
+
+ function prevent(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+
+ function move(e) {
+ if (dragging) {
+ let pageX = e.pageX;
+ let pageY = e.pageY;
+
+ let dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth));
+ let dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight));
+
+ onmove.apply(element, [dragX, dragY]);
+ }
+ }
+
+ function start(e) {
+ let rightclick = e.which === 3;
+
+ if (!rightclick && !dragging) {
+ if (onstart.apply(element, arguments) !== false) {
+ dragging = true;
+ maxHeight = element.offsetHeight;
+ maxWidth = element.offsetWidth;
+
+ offset = Spectrum.getOffset(element);
+
+ move(e);
+
+ doc.addEventListener("selectstart", prevent, false);
+ doc.addEventListener("dragstart", prevent, false);
+ doc.addEventListener("mousemove", move, false);
+ doc.addEventListener("mouseup", stop, false);
+
+ prevent(e);
+ }
+ }
+ }
+
+ function stop() {
+ if (dragging) {
+ doc.removeEventListener("selectstart", prevent, false);
+ doc.removeEventListener("dragstart", prevent, false);
+ doc.removeEventListener("mousemove", move, false);
+ doc.removeEventListener("mouseup", stop, false);
+ onstop.apply(element, arguments);
+ }
+ dragging = false;
+ }
+
+ element.addEventListener("mousedown", start, false);
+};
+
+Spectrum.prototype = {
+ set rgb(color) {
+ this.hsv = Spectrum.rgbToHsv(color[0], color[1], color[2], color[3]);
+ },
+
+ get rgb() {
+ let rgb = Spectrum.hsvToRgb(this.hsv[0], this.hsv[1], this.hsv[2], this.hsv[3]);
+ return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), Math.round(rgb[3]*100)/100];
+ },
+
+ get rgbNoSatVal() {
+ let rgb = Spectrum.hsvToRgb(this.hsv[0], 1, 1);
+ return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), rgb[3]];
+ },
+
+ get rgbCssString() {
+ let rgb = this.rgb;
+ return "rgba(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ", " + rgb[3] + ")";
+ },
+
+ show: function() {
+ this.element.classList.add('spectrum-show');
+
+ this.slideHeight = this.slider.offsetHeight;
+ this.dragWidth = this.dragger.offsetWidth;
+ this.dragHeight = this.dragger.offsetHeight;
+ this.dragHelperHeight = this.dragHelper.offsetHeight;
+ this.slideHelperHeight = this.slideHelper.offsetHeight;
+ this.alphaSliderWidth = this.alphaSliderInner.offsetWidth;
+ this.alphaSliderHelperWidth = this.alphaSliderHelper.offsetWidth;
+
+ this.updateUI();
+ },
+
+ onElementClick: function(e) {
+ e.stopPropagation();
+ },
+
+ onSliderMove: function(dragX, dragY) {
+ this.hsv[0] = (dragY / this.slideHeight);
+ this.updateUI();
+ this.onChange();
+ },
+
+ onDraggerMove: function(dragX, dragY) {
+ this.hsv[1] = dragX / this.dragWidth;
+ this.hsv[2] = (this.dragHeight - dragY) / this.dragHeight;
+ this.updateUI();
+ this.onChange();
+ },
+
+ onAlphaSliderMove: function(dragX, dragY) {
+ this.hsv[3] = dragX / this.alphaSliderWidth;
+ this.updateUI();
+ this.onChange();
+ },
+
+ onChange: function() {
+ this.emit("changed", this.rgb, this.rgbCssString);
+ },
+
+ updateHelperLocations: function() {
+ // If the UI hasn't been shown yet then none of the dimensions will be correct
+ if (!this.element.classList.contains('spectrum-show'))
+ return;
+
+ let h = this.hsv[0];
+ let s = this.hsv[1];
+ let v = this.hsv[2];
+
+ // Placing the color dragger
+ let dragX = s * this.dragWidth;
+ let dragY = this.dragHeight - (v * this.dragHeight);
+ let helperDim = this.dragHelperHeight/2;
+
+ dragX = Math.max(
+ -helperDim,
+ Math.min(this.dragWidth - helperDim, dragX - helperDim)
+ );
+ dragY = Math.max(
+ -helperDim,
+ Math.min(this.dragHeight - helperDim, dragY - helperDim)
+ );
+
+ this.dragHelper.style.top = dragY + "px";
+ this.dragHelper.style.left = dragX + "px";
+
+ // Placing the hue slider
+ let slideY = (h * this.slideHeight) - this.slideHelperHeight/2;
+ this.slideHelper.style.top = slideY + "px";
+
+ // Placing the alpha slider
+ let alphaSliderX = (this.hsv[3] * this.alphaSliderWidth) - (this.alphaSliderHelperWidth / 2);
+ this.alphaSliderHelper.style.left = alphaSliderX + "px";
+ },
+
+ updateUI: function() {
+ this.updateHelperLocations();
+
+ let rgb = this.rgb;
+ let rgbNoSatVal = this.rgbNoSatVal;
+
+ let flatColor = "rgb(" + rgbNoSatVal[0] + ", " + rgbNoSatVal[1] + ", " + rgbNoSatVal[2] + ")";
+ let fullColor = "rgba(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ", " + rgb[3] + ")";
+
+ this.dragger.style.backgroundColor = flatColor;
+
+ var rgbNoAlpha = "rgb(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ")";
+ var rgbAlpha0 = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ", 0)";
+ var alphaGradient = "linear-gradient(to right, " + rgbAlpha0 + ", " + rgbNoAlpha + ")";
+ this.alphaSliderInner.style.background = alphaGradient;
+ },
+
+ destroy: function() {
+ this.element.removeEventListener("click", this.onElementClick, false);
+
+ this.parentEl.removeChild(this.element);
+
+ this.slider = null;
+ this.dragger = null;
+ this.alphaSlider = this.alphaSliderInner = this.alphaSliderHelper = null;
+ this.parentEl = null;
+ this.element = null;
+ }
+};
diff --git a/toolkit/devtools/shared/widgets/TableWidget.js b/toolkit/devtools/shared/widgets/TableWidget.js
new file mode 100644
index 000000000..e3f6ea549
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/TableWidget.js
@@ -0,0 +1,983 @@
+/* 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 EventEmitter = require("devtools/toolkit/event-emitter");
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+
+// Different types of events emitted by the Various components of the TableWidget
+const EVENTS = {
+ TABLE_CLEARED: "table-cleared",
+ COLUMN_SORTED: "column-sorted",
+ COLUMN_TOGGLED: "column-toggled",
+ ROW_SELECTED: "row-selected",
+ ROW_UPDATED: "row-updated",
+ HEADER_CONTEXT_MENU: "header-context-menu",
+ ROW_CONTEXT_MENU: "row-context-menu"
+};
+
+// Maximum number of character visible in any cell in the table. This is to avoid
+// making the cell take up all the space in a row.
+const MAX_VISIBLE_STRING_SIZE = 100;
+
+/**
+ * A table widget with various features like resizble/toggleable columns,
+ * sorting, keyboard navigation etc.
+ *
+ * @param {nsIDOMNode} node
+ * The container element for the table widget.
+ * @param {object} options
+ * - initialColumns: map of key vs display name for initial columns of
+ * the table. See @setupColumns for more info.
+ * - uniqueId: the column which will be the unique identifier of each
+ * entry in the table. Default: name.
+ * - emptyText: text to display when no entries in the table to display.
+ * - highlightUpdated: true to highlight the changed/added row.
+ * - removableColumns: Whether columns are removeable. If set to false,
+ * the context menu in the headers will not appear.
+ * - firstColumn: key of the first column that should appear.
+ */
+function TableWidget(node, options={}) {
+ EventEmitter.decorate(this);
+
+ this.document = node.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = node;
+
+ let {initialColumns, emptyText, uniqueId, highlightUpdated, removableColumns,
+ firstColumn} = options;
+ this.emptyText = emptyText || "";
+ this.uniqueId = uniqueId || "name";
+ this.firstColumn = firstColumn || "";
+ this.highlightUpdated = highlightUpdated || false;
+ this.removableColumns = removableColumns !== false;
+
+ this.tbody = this.document.createElementNS(XUL_NS, "hbox");
+ this.tbody.className = "table-widget-body theme-body";
+ this.tbody.setAttribute("flex", "1");
+ this.tbody.setAttribute("tabindex", "0");
+ this._parent.appendChild(this.tbody);
+
+ this.placeholder = this.document.createElementNS(XUL_NS, "label");
+ this.placeholder.className = "plain table-widget-empty-text";
+ this.placeholder.setAttribute("flex", "1");
+ this._parent.appendChild(this.placeholder);
+
+ this.items = new Map();
+ this.columns = new Map();
+
+ // Setup the column headers context menu to allow users to hide columns at will
+ if (this.removableColumns) {
+ this.onPopupCommand = this.onPopupCommand.bind(this)
+ this.setupHeadersContextMenu();
+ }
+
+ if (initialColumns) {
+ this.setColumns(initialColumns, uniqueId);
+ } else if (this.emptyText) {
+ this.setPlaceholderText(this.emptyText);
+ }
+
+ this.bindSelectedRow = (event, id) => {
+ this.selectedRow = id;
+ };
+ this.on(EVENTS.ROW_SELECTED, this.bindSelectedRow);
+};
+
+TableWidget.prototype = {
+
+ items: null,
+
+ /**
+ * Getter for the headers context menu popup id.
+ */
+ get headersContextMenu() {
+ if (this.menupopup) {
+ return this.menupopup.id;
+ }
+ return null;
+ },
+
+ /**
+ * Select the row corresponding to the json object `id`
+ */
+ set selectedRow(id) {
+ for (let column of this.columns.values()) {
+ column.selectRow(id[this.uniqueId] || id);
+ }
+ },
+
+ /**
+ * Returns the json object corresponding to the selected row.
+ */
+ get selectedRow() {
+ return this.items.get(this.columns.get(this.uniqueId).selectedRow);
+ },
+
+ /**
+ * Selects the row at index `index`.
+ */
+ set selectedIndex(index) {
+ for (let column of this.columns.values()) {
+ column.selectRowAt(index);
+ }
+ },
+
+ /**
+ * Returns the index of the selected row.
+ */
+ get selectedIndex() {
+ return this.columns.get(this.uniqueId).selectedIndex;
+ },
+
+ destroy: function() {
+ this.off(EVENTS.ROW_SELECTED, this.bindSelectedRow);
+ if (this.menupopup) {
+ this.menupopup.remove();
+ }
+ },
+
+ /**
+ * Sets the text to be shown when the table is empty.
+ */
+ setPlaceholderText: function(text) {
+ this.placeholder.setAttribute("value", text);
+ },
+
+ /**
+ * Prepares the context menu for the headers of the table columns. This context
+ * menu allows users to toggle various columns, only with an exception of the
+ * unique columns and when only two columns are visible in the table.
+ */
+ setupHeadersContextMenu: function() {
+ let popupset = this.document.getElementsByTagName("popupset")[0];
+ if (!popupset) {
+ popupset = this.document.createElementNS(XUL_NS, "popupset");
+ this.document.documentElement.appendChild(popupset);
+ }
+
+ this.menupopup = this.document.createElementNS(XUL_NS, "menupopup");
+ this.menupopup.id = "table-widget-column-select";
+ this.menupopup.addEventListener("command", this.onPopupCommand);
+ popupset.appendChild(this.menupopup);
+ this.populateMenuPopup();
+ },
+
+ /**
+ * Populates the header context menu with the names of the columns along with
+ * displaying which columns are hidden or visible.
+ */
+ populateMenuPopup: function() {
+ if (!this.menupopup) {
+ return;
+ }
+
+ while (this.menupopup.firstChild) {
+ this.menupopup.firstChild.remove();
+ }
+
+ for (let column of this.columns.values()) {
+ let menuitem = this.document.createElementNS(XUL_NS, "menuitem");
+ menuitem.setAttribute("label", column.header.getAttribute("value"));
+ menuitem.setAttribute("data-id", column.id);
+ menuitem.setAttribute("type", "checkbox");
+ menuitem.setAttribute("checked", !column.wrapper.getAttribute("hidden"));
+ if (column.id == this.uniqueId) {
+ menuitem.setAttribute("disabled", "true");
+ }
+ this.menupopup.appendChild(menuitem);
+ }
+ let checked = this.menupopup.querySelectorAll("menuitem[checked]");
+ if (checked.length == 2) {
+ checked[checked.length - 1].setAttribute("disabled", "true");
+ }
+ },
+
+ /**
+ * Event handler for the `command` event on the column headers context menu
+ */
+ onPopupCommand: function(event) {
+ let item = event.originalTarget;
+ let checked = !!item.getAttribute("checked");
+ let id = item.getAttribute("data-id");
+ this.emit(EVENTS.HEADER_CONTEXT_MENU, id, checked);
+ checked = this.menupopup.querySelectorAll("menuitem[checked]");
+ let disabled = this.menupopup.querySelectorAll("menuitem[disabled]");
+ if (checked.length == 2) {
+ checked[checked.length - 1].setAttribute("disabled", "true");
+ } else if (disabled.length > 1) {
+ disabled[disabled.length - 1].removeAttribute("disabled");
+ }
+ },
+
+ /**
+ * Creates the columns in the table. Without calling this method, data cannot
+ * be inserted into the table unless `initialColumns` was supplied.
+ *
+ * @param {object} columns
+ * A key value pair representing the columns of the table. Where the
+ * key represents the id of the column and the value is the displayed
+ * label in the header of the column.
+ * @param {string} sortOn
+ * The id of the column on which the table will be initially sorted on.
+ * @param {array} hiddenColumns
+ * Ids of all the columns that are hidden by default.
+ */
+ setColumns: function(columns, sortOn = this.sortedOn, hiddenColumns = []) {
+ for (let column of this.columns.values()) {
+ column.destroy();
+ }
+
+ this.columns.clear();
+
+ if (!(sortOn in columns)) {
+ sortOn = null;
+ }
+
+ if (!(this.firstColumn in columns)) {
+ this.firstColumn = null;
+ }
+
+ if (this.firstColumn) {
+ this.columns.set(this.firstColumn,
+ new Column(this, this.firstColumn, columns[this.firstColumn]));
+ }
+
+ for (let id in columns) {
+ if (!sortOn) {
+ sortOn = id;
+ }
+
+ if (this.firstColumn && id == this.firstColumn) {
+ continue;
+ }
+
+ this.columns.set(id, new Column(this, id, columns[id]));
+ if (hiddenColumns.indexOf(id) > -1) {
+ this.columns.get(id).toggleColumn();
+ }
+ }
+ this.sortedOn = sortOn;
+ this.sortBy(this.sortedOn);
+ this.populateMenuPopup();
+ },
+
+ /**
+ * Returns true if the passed string or the row json object corresponds to the
+ * selected item in the table.
+ */
+ isSelected: function(item) {
+ if (typeof item == "object") {
+ item = item[this.uniqueId];
+ }
+
+ return this.selectedRow && item == this.selectedRow[this.uniqueId];
+ },
+
+ /**
+ * Selects the row corresponding to the `id` json.
+ */
+ selectRow: function(id) {
+ this.selectedRow = id;
+ },
+
+ /**
+ * Selects the next row. Cycles over to the first row if last row is selected
+ */
+ selectNextRow: function() {
+ for (let column of this.columns.values()) {
+ column.selectNextRow();
+ }
+ },
+
+ /**
+ * Selects the previous row. Cycles over to the last row if first row is selected
+ */
+ selectPreviousRow: function() {
+ for (let column of this.columns.values()) {
+ column.selectPreviousRow();
+ }
+ },
+
+ /**
+ * Clears any selected row.
+ */
+ clearSelection: function() {
+ this.selectedIndex = -1;
+ },
+
+ /**
+ * Adds a row into the table.
+ *
+ * @param {object} item
+ * The object from which the key-value pairs will be taken and added
+ * into the row. This object can have any arbitarary key value pairs,
+ * but only those will be used whose keys match to the ids of the
+ * columns.
+ * @param {boolean} suppressFlash
+ * true to not flash the row while inserting the row.
+ */
+ push: function(item, suppressFlash) {
+ if (!this.sortedOn || !this.columns) {
+ Cu.reportError("Can't insert item without defining columns first");
+ return;
+ }
+
+ if (this.items.has(item[this.uniqueId])) {
+ this.update(item);
+ return;
+ }
+
+ let index = this.columns.get(this.sortedOn).push(item);
+ for (let [key, column] of this.columns) {
+ if (key != this.sortedOn) {
+ column.insertAt(item, index);
+ }
+ }
+ this.items.set(item[this.uniqueId], item);
+ this.tbody.removeAttribute("empty");
+ if (!suppressFlash) {
+ this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]);
+ }
+ },
+
+ /**
+ * Removes the row associated with the `item` object.
+ */
+ remove: function(item) {
+ if (typeof item == "string") {
+ item = this.items.get(item);
+ }
+ let removed = this.items.delete(item[this.uniqueId]);
+
+ if (!removed) {
+ return;
+ }
+
+ for (let column of this.columns.values()) {
+ column.remove(item);
+ }
+ if (this.items.size == 0) {
+ this.tbody.setAttribute("empty", "empty");
+ }
+ },
+
+ /**
+ * Updates the items in the row corresponding to the `item` object previously
+ * used to insert the row using `push` method. The linking is done via the
+ * `uniqueId` key's value.
+ */
+ update: function(item) {
+ let oldItem = this.items.get(item[this.uniqueId]);
+ if (!oldItem) {
+ return;
+ }
+ this.items.set(item[this.uniqueId], item);
+
+ let changed = false;
+ for (let column of this.columns.values()) {
+ if (item[column.id] != oldItem[column.id]) {
+ column.update(item);
+ changed = true;
+ }
+ }
+ if (changed) {
+ this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]);
+ }
+ },
+
+ /**
+ * Removes all of the rows from the table.
+ */
+ clear: function() {
+ this.items.clear();
+ for (let column of this.columns.values()) {
+ column.clear();
+ }
+ this.tbody.setAttribute("empty", "empty");
+ this.setPlaceholderText(this.emptyText);
+ },
+
+ /**
+ * Sorts the table by a given column.
+ *
+ * @param {string} column
+ * The id of the column on which the table should be sorted.
+ */
+ sortBy: function(column) {
+ this.emit(EVENTS.COLUMN_SORTED, column);
+ this.sortedOn = column;
+
+ if (!this.items.size) {
+ return;
+ }
+
+ let sortedItems = this.columns.get(column).sort([...this.items.values()]);
+ for (let [id, column] of this.columns) {
+ if (id != column) {
+ column.sort(sortedItems);
+ }
+ }
+ }
+};
+
+TableWidget.EVENTS = EVENTS;
+
+module.exports.TableWidget = TableWidget;
+
+/**
+ * A single column object in the table.
+ *
+ * @param {TableWidget} table
+ * The table object to which the column belongs.
+ * @param {string} id
+ * Id of the column.
+ * @param {String} header
+ * The displayed string on the column's header.
+ */
+function Column(table, id, header) {
+ this.tbody = table.tbody;
+ this.document = table.document;
+ this.window = table.window;
+ this.id = id;
+ this.uniqueId = table.uniqueId;
+ this.table = table;
+ this.cells = [];
+ this.items = {};
+
+ this.highlightUpdated = table.highlightUpdated;
+
+ // This wrapping element is required solely so that position:sticky works on
+ // the headers of the columns.
+ this.wrapper = this.document.createElementNS(XUL_NS, "vbox");
+ this.wrapper.className = "table-widget-wrapper";
+ this.wrapper.setAttribute("flex", "1");
+ this.wrapper.setAttribute("tabindex", "0");
+ this.tbody.appendChild(this.wrapper);
+
+ this.splitter = this.document.createElementNS(XUL_NS, "splitter");
+ this.splitter.className = "devtools-side-splitter";
+ this.tbody.appendChild(this.splitter);
+
+ this.column = this.document.createElementNS(HTML_NS, "div");
+ this.column.id = id;
+ this.column.className = "table-widget-column";
+ this.wrapper.appendChild(this.column);
+
+ this.header = this.document.createElementNS(XUL_NS, "label");
+ this.header.className = "plain devtools-toolbar table-widget-column-header";
+ this.header.setAttribute("value", header);
+ this.column.appendChild(this.header);
+ if (table.headersContextMenu) {
+ this.header.setAttribute("context", table.headersContextMenu);
+ }
+ this.toggleColumn = this.toggleColumn.bind(this);
+ this.table.on(EVENTS.HEADER_CONTEXT_MENU, this.toggleColumn);
+
+ this.onColumnSorted = this.onColumnSorted.bind(this);
+ this.table.on(EVENTS.COLUMN_SORTED, this.onColumnSorted);
+
+ this.onRowUpdated = this.onRowUpdated.bind(this);
+ this.table.on(EVENTS.ROW_UPDATED, this.onRowUpdated);
+
+ this.onClick = this.onClick.bind(this);
+ this.onMousedown = this.onMousedown.bind(this);
+ this.onKeydown = this.onKeydown.bind(this);
+ this.column.addEventListener("click", this.onClick);
+ this.column.addEventListener("mousedown", this.onMousedown);
+ this.column.addEventListener("keydown", this.onKeydown);
+}
+
+Column.prototype = {
+
+ // items is a cell-id to cell-index map. It is basically a reverse map of the
+ // this.cells object and is used to quickly reverse lookup a cell by its id
+ // instead of looping through the cells array. This reverse map is not kept
+ // upto date in sync with the cells array as updating it is in itself a loop
+ // through all the cells of the columns. Thus update it on demand when it goes
+ // out of sync with this.cells.
+ items: null,
+
+ // _itemsDirty is a flag which becomes true when this.items goes out of sync
+ // with this.cells
+ _itemsDirty: null,
+
+ selectedRow: null,
+
+ cells: null,
+
+ /**
+ * Gets whether the table is sorted on this column or not.
+ * 0 - not sorted.
+ * 1 - ascending order
+ * 2 - descending order
+ */
+ get sorted() {
+ return this._sortState || 0;
+ },
+
+ /**
+ * Sets the sorted value
+ */
+ set sorted(value) {
+ if (!value) {
+ this.header.removeAttribute("sorted");
+ } else {
+ this.header.setAttribute("sorted",
+ value == 1 ? "ascending" : "descending");
+ }
+ this._sortState = value;
+ },
+
+ /**
+ * Gets the selected row in the column.
+ */
+ get selectedIndex() {
+ if (!this.selectedRow) {
+ return -1;
+ }
+ return this.items[this.selectedRow];
+ },
+
+ /**
+ * Called when the column is sorted by.
+ *
+ * @param {string} event
+ * The event name of the event. i.e. EVENTS.COLUMN_SORTED
+ * @param {string} column
+ * The id of the column being sorted by.
+ */
+ onColumnSorted: function(event, column) {
+ if (column != this.id) {
+ this.sorted = 0;
+ return;
+ } else if (this.sorted == 0 || this.sorted == 2) {
+ this.sorted = 1;
+ } else {
+ this.sorted = 2;
+ }
+ },
+
+ /**
+ * Called when a row is updated.
+ *
+ * @param {string} event
+ * The event name of the event. i.e. EVENTS.ROW_UPDATED
+ * @param {string} id
+ * The unique id of the object associated with the row.
+ */
+ onRowUpdated: function(event, id) {
+ this._updateItems();
+ if (this.highlightUpdated && this.items[id] != null) {
+ this.cells[this.items[id]].flash();
+ }
+ },
+
+ destroy: function() {
+ this.table.off(EVENTS.COLUMN_SORTED, this.onColumnSorted);
+ this.table.off(EVENTS.HEADER_CONTEXT_MENU, this.toggleColumn);
+ this.table.off(EVENTS.ROW_UPDATED, this.onRowUpdated);
+ this.splitter.remove();
+ this.column.parentNode.remove();
+ this.cells = null;
+ this.items = null;
+ this.selectedRow = null;
+ },
+
+ /**
+ * Selects the row at the `index` index
+ */
+ selectRowAt: function(index) {
+ if (this.selectedRow != null) {
+ this.cells[this.items[this.selectedRow]].toggleClass("theme-selected");
+ }
+ if (index < 0) {
+ this.selectedRow = null;
+ return;
+ }
+ let cell = this.cells[index];
+ cell.toggleClass("theme-selected");
+ cell.focus();
+ this.selectedRow = cell.id;
+ },
+
+ /**
+ * Selects the row with the object having the `uniqueId` value as `id`
+ */
+ selectRow: function(id) {
+ this._updateItems();
+ this.selectRowAt(this.items[id]);
+ },
+
+ /**
+ * Selects the next row. Cycles to first if last row is selected.
+ */
+ selectNextRow: function() {
+ this._updateItems();
+ let index = this.items[this.selectedRow] + 1;
+ if (index == this.cells.length) {
+ index = 0;
+ }
+ this.selectRowAt(index);
+ },
+
+ /**
+ * Selects the previous row. Cycles to last if first row is selected.
+ */
+ selectPreviousRow: function() {
+ this._updateItems();
+ let index = this.items[this.selectedRow] - 1;
+ if (index == -1) {
+ index = this.cells.length - 1;
+ }
+ this.selectRowAt(index);
+ },
+
+ /**
+ * Pushes the `item` object into the column. If this column is sorted on,
+ * then inserts the object at the right position based on the column's id key's
+ * value.
+ *
+ * @returns {number}
+ * The index of the currently pushed item.
+ */
+ push: function(item) {
+ let value = item[this.id];
+
+ if (this.sorted) {
+ let index;
+ if (this.sorted == 1) {
+ index = this.cells.findIndex(element => {
+ return value < element.value;
+ });
+ } else {
+ index = this.cells.findIndex(element => {
+ return value > element.value;
+ });
+ }
+ index = index >= 0 ? index : this.cells.length;
+ if (index < this.cells.length) {
+ this._itemsDirty = true;
+ }
+ this.items[item[this.uniqueId]] = index;
+ this.cells.splice(index, 0, new Cell(this, item, this.cells[index]));
+ return index;
+ }
+
+ this.items[item[this.uniqueId]] = this.cells.length;
+ return this.cells.push(new Cell(this, item)) - 1;
+ },
+
+ /**
+ * Inserts the `item` object at the given `index` index in the table.
+ */
+ insertAt: function(item, index) {
+ if (index < this.cells.length) {
+ this._itemsDirty = true;
+ }
+ this.items[item[this.uniqueId]] = index;
+ this.cells.splice(index, 0, new Cell(this, item, this.cells[index]));
+ },
+
+ /**
+ * Event handler for the command event coming from the header context menu.
+ * Toggles the column if it was requested by the user.
+ * When called explicitly without parameters, it toggles the corresponding
+ * column.
+ *
+ * @param {string} event
+ * The name of the event. i.e. EVENTS.HEADER_CONTEXT_MENU
+ * @param {string} id
+ * Id of the column to be toggled
+ * @param {string} checked
+ * true if the column is visible
+ */
+ toggleColumn: function(event, id, checked) {
+ if (arguments.length == 0) {
+ // Act like a toggling method when called with no params
+ id = this.id;
+ checked = this.wrapper.hasAttribute("hidden");
+ }
+ if (id != this.id) {
+ return;
+ }
+ if (checked) {
+ this.wrapper.removeAttribute("hidden");
+ } else {
+ this.wrapper.setAttribute("hidden", "true");
+ }
+ },
+
+ /**
+ * Removes the corresponding item from the column.
+ */
+ remove: function(item) {
+ this._updateItems();
+ let index = this.items[item[this.uniqueId]];
+ if (index == null) {
+ return;
+ }
+
+ if (index < this.cells.length) {
+ this._itemsDirty = true;
+ }
+ this.cells[index].destroy();
+ this.cells.splice(index, 1);
+ delete this.items[item[this.uniqueId]];
+ },
+
+ /**
+ * Updates the corresponding item from the column.
+ */
+ update: function(item) {
+ this._updateItems();
+
+ let index = this.items[item[this.uniqueId]];
+ if (index == null) {
+ return;
+ }
+
+ this.cells[index].value = item[this.id];
+ },
+
+ /**
+ * Updates the `this.items` cell-id vs cell-index map to be in sync with
+ * `this.cells`.
+ */
+ _updateItems: function() {
+ if (!this._itemsDirty) {
+ return;
+ }
+ for (let i = 0; i < this.cells.length; i++) {
+ this.items[this.cells[i].id] = i;
+ }
+ this._itemsDirty = false;
+ },
+
+ /**
+ * Clears the current column
+ */
+ clear: function() {
+ this.cells = [];
+ this.items = {};
+ this._itemsDirty = false;
+ this.selectedRow = null;
+ while (this.header.nextSibling) {
+ this.header.nextSibling.remove();
+ }
+ },
+
+ /**
+ * Sorts the given items and returns the sorted list if the table was sorted
+ * by this column.
+ */
+ sort: function(items) {
+ // Only sort the array if we are sorting based on this column
+ if (this.sorted == 1) {
+ items.sort((a, b) => {
+ let val1 = (a[this.id] instanceof Ci.nsIDOMNode) ?
+ a[this.id].textContent : a[this.id];
+ let val2 = (b[this.id] instanceof Ci.nsIDOMNode) ?
+ b[this.id].textContent : b[this.id];
+ return val1 > val2;
+ });
+ } else if (this.sorted > 1) {
+ items.sort((a, b) => {
+ let val1 = (a[this.id] instanceof Ci.nsIDOMNode) ?
+ a[this.id].textContent : a[this.id];
+ let val2 = (b[this.id] instanceof Ci.nsIDOMNode) ?
+ b[this.id].textContent : b[this.id];
+ return val2 > val1;
+ });
+ }
+
+ if (this.selectedRow) {
+ this.cells[this.items[this.selectedRow]].toggleClass("theme-selected");
+ }
+ this.items = {};
+ // Otherwise, just use the sorted array passed to update the cells value.
+ items.forEach((item, i) => {
+ this.items[item[this.uniqueId]] = i;
+ this.cells[i].value = item[this.id];
+ this.cells[i].id = item[this.uniqueId];
+ });
+ if (this.selectedRow) {
+ this.cells[this.items[this.selectedRow]].toggleClass("theme-selected");
+ }
+ this._itemsDirty = false;
+ return items;
+ },
+
+ /**
+ * Click event handler for the column. Used to detect click on header for
+ * for sorting.
+ */
+ onClick: function(event) {
+ if (event.originalTarget == this.column) {
+ return;
+ }
+
+ if (event.button == 0 && event.originalTarget == this.header) {
+ return this.table.sortBy(this.id);
+ }
+ },
+
+ /**
+ * Mousedown event handler for the column. Used to select rows.
+ */
+ onMousedown: function(event) {
+ if (event.originalTarget == this.column ||
+ event.originalTarget == this.header) {
+ return;
+ }
+ if (event.button == 0) {
+ let target = event.originalTarget;
+ let dataid = null;
+
+ while (target) {
+ dataid = target.getAttribute("data-id");
+ if (dataid) {
+ break;
+ }
+ target = target.parentNode;
+ }
+
+ this.table.emit(EVENTS.ROW_SELECTED, dataid);
+ }
+ },
+
+ /**
+ * Keydown event handler for the column. Used for keyboard navigation amongst
+ * rows.
+ */
+ onKeydown: function(event) {
+ if (event.originalTarget == this.column ||
+ event.originalTarget == this.header) {
+ return;
+ }
+
+ switch (event.keyCode) {
+ case event.DOM_VK_ESCAPE:
+ case event.DOM_VK_LEFT:
+ case event.DOM_VK_RIGHT:
+ return;
+ case event.DOM_VK_HOME:
+ case event.DOM_VK_END:
+ return;
+ case event.DOM_VK_UP:
+ event.preventDefault();
+ let prevRow = event.originalTarget.previousSibling;
+ if (this.header == prevRow) {
+ prevRow = this.column.lastChild;
+ }
+ this.table.emit(EVENTS.ROW_SELECTED, prevRow.getAttribute("data-id"));
+ break;
+
+ case event.DOM_VK_DOWN:
+ event.preventDefault();
+ let nextRow = event.originalTarget.nextSibling ||
+ this.header.nextSibling;
+ this.table.emit(EVENTS.ROW_SELECTED, nextRow.getAttribute("data-id"));
+ break;
+ }
+ }
+};
+
+/**
+ * A single cell in a column
+ *
+ * @param {Column} column
+ * The column object to which the cell belongs.
+ * @param {object} item
+ * The object representing the row. It contains a key value pair
+ * representing the column id and its associated value. The value
+ * can be a DOMNode that is appended or a string value.
+ * @param {Cell} nextCell
+ * The cell object which is next to this cell. null if this cell is last
+ * cell of the column
+ */
+function Cell(column, item, nextCell) {
+ let document = column.document;
+
+ this.label = document.createElementNS(XUL_NS, "label");
+ this.label.setAttribute("crop", "end");
+ this.label.className = "plain table-widget-cell";
+ if (nextCell) {
+ column.column.insertBefore(this.label, nextCell.label);
+ } else {
+ column.column.appendChild(this.label);
+ }
+
+ this.value = item[column.id];
+ this.id = item[column.uniqueId];
+}
+
+Cell.prototype = {
+
+ set id(value) {
+ this._id = value;
+ this.label.setAttribute("data-id", value);
+ },
+
+ get id() {
+ return this._id;
+ },
+
+ set value(value) {
+ this._value = value;
+ if (value == null) {
+ this.label.setAttribute("value", "");
+ return;
+ }
+
+ if (!(value instanceof Ci.nsIDOMNode) &&
+ value.length > MAX_VISIBLE_STRING_SIZE) {
+ value = value .substr(0, MAX_VISIBLE_STRING_SIZE) + "\u2026"; // …
+ }
+
+ if (value instanceof Ci.nsIDOMNode) {
+ this.label.removeAttribute("value");
+
+ while (this.label.firstChild) {
+ this.label.removeChild(this.label.firstChild);
+ }
+
+ this.label.appendChild(value);
+ } else {
+ this.label.setAttribute("value", value + "");
+ }
+ },
+
+ get value() {
+ return this._value;
+ },
+
+ toggleClass: function(className) {
+ this.label.classList.toggle(className);
+ },
+
+ /**
+ * Flashes the cell for a brief time. This when done for ith cells in all
+ * columns, makes it look like the row is being highlighted/flashed.
+ */
+ flash: function() {
+ this.label.classList.remove("flash-out");
+ // Cause a reflow so that the animation retriggers on adding back the class
+ let a = this.label.parentNode.offsetWidth;
+ this.label.classList.add("flash-out");
+ },
+
+ focus: function() {
+ this.label.focus();
+ },
+
+ destroy: function() {
+ this.label.remove();
+ this.label = null;
+ }
+}
diff --git a/toolkit/devtools/shared/widgets/Tooltip.js b/toolkit/devtools/shared/widgets/Tooltip.js
new file mode 100644
index 000000000..3b802d5ed
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/Tooltip.js
@@ -0,0 +1,1489 @@
+/* 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, Cu, Ci} = require("chrome");
+const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
+const IOService = Cc["@mozilla.org/network/io-service;1"]
+ .getService(Ci.nsIIOService);
+const {Spectrum} = require("devtools/shared/widgets/Spectrum");
+const {CubicBezierWidget} = require("devtools/shared/widgets/CubicBezierWidget");
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const {colorUtils} = require("devtools/css-color");
+const Heritage = require("sdk/core/heritage");
+const {Eyedropper} = require("devtools/eyedropper/eyedropper");
+const Editor = require("devtools/sourceeditor/editor");
+const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+
+devtools.lazyRequireGetter(this, "beautify", "devtools/jsbeautify");
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "setNamedTimeout",
+ "resource:///modules/devtools/ViewHelpers.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "clearNamedTimeout",
+ "resource:///modules/devtools/ViewHelpers.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "VariablesView",
+ "resource:///modules/devtools/VariablesView.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "VariablesViewController",
+ "resource:///modules/devtools/VariablesViewController.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+
+const GRADIENT_RE = /\b(repeating-)?(linear|radial)-gradient\(((rgb|hsl)a?\(.+?\)|[^\)])+\)/gi;
+const BORDERCOLOR_RE = /^border-[-a-z]*color$/ig;
+const BORDER_RE = /^border(-(top|bottom|left|right))?$/ig;
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const SPECTRUM_FRAME = "chrome://browser/content/devtools/spectrum-frame.xhtml";
+const CUBIC_BEZIER_FRAME = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml";
+const ESCAPE_KEYCODE = Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE;
+const RETURN_KEYCODE = Ci.nsIDOMKeyEvent.DOM_VK_RETURN;
+const POPUP_EVENTS = ["shown", "hidden", "showing", "hiding"];
+
+/**
+ * Tooltip widget.
+ *
+ * This widget is intended at any tool that may need to show rich content in the
+ * form of floating panels.
+ * A common use case is image previewing in the CSS rule view, but more complex
+ * use cases may include color pickers, object inspection, etc...
+ *
+ * Tooltips are based on XUL (namely XUL arrow-type <panel>s), and therefore
+ * need a XUL Document to live in.
+ * This is pretty much the only requirement they have on their environment.
+ *
+ * The way to use a tooltip is simply by instantiating a tooltip yourself and
+ * attaching some content in it, or using one of the ready-made content types.
+ *
+ * A convenient `startTogglingOnHover` method may avoid having to register event
+ * handlers yourself if the tooltip has to be shown when hovering over a
+ * specific element or group of elements (which is usually the most common case)
+ */
+
+/**
+ * Container used for dealing with optional parameters.
+ *
+ * @param {Object} defaults
+ * An object with all default options {p1: v1, p2: v2, ...}
+ * @param {Object} options
+ * The actual values.
+ */
+function OptionsStore(defaults, options) {
+ this.defaults = defaults || {};
+ this.options = options || {};
+}
+
+OptionsStore.prototype = {
+ /**
+ * Get the value for a given option name.
+ * @return {Object} Returns the value for that option, coming either for the
+ * actual values that have been set in the constructor, or from the
+ * defaults if that options was not specified.
+ */
+ get: function(name) {
+ if (typeof this.options[name] !== "undefined") {
+ return this.options[name];
+ } else {
+ return this.defaults[name];
+ }
+ }
+};
+
+/**
+ * The low level structure of a tooltip is a XUL element (a <panel>).
+ */
+let PanelFactory = {
+ /**
+ * Get a new XUL panel instance.
+ * @param {XULDocument} doc
+ * The XUL document to put that panel into
+ * @param {OptionsStore} options
+ * An options store to get some configuration from
+ */
+ get: function(doc, options) {
+ // Create the tooltip
+ let panel = doc.createElement("panel");
+ panel.setAttribute("hidden", true);
+ panel.setAttribute("ignorekeys", true);
+ panel.setAttribute("animate", false);
+
+ panel.setAttribute("consumeoutsideclicks", options.get("consumeOutsideClick"));
+ panel.setAttribute("noautofocus", options.get("noAutoFocus"));
+ panel.setAttribute("type", "arrow");
+ panel.setAttribute("level", "top");
+
+ panel.setAttribute("class", "devtools-tooltip theme-tooltip-panel");
+ doc.querySelector("window").appendChild(panel);
+
+ return panel;
+ }
+};
+
+/**
+ * Tooltip class.
+ *
+ * Basic usage:
+ * let t = new Tooltip(xulDoc);
+ * t.content = someXulContent;
+ * t.show();
+ * t.hide();
+ * t.destroy();
+ *
+ * Better usage:
+ * let t = new Tooltip(xulDoc);
+ * t.startTogglingOnHover(container, target => {
+ * if (<condition based on target>) {
+ * t.setImageContent("http://image.png");
+ * return true;
+ * }
+ * });
+ * t.destroy();
+ *
+ * @param {XULDocument} doc
+ * The XUL document hosting this tooltip
+ * @param {Object} options
+ * Optional options that give options to consumers:
+ * - consumeOutsideClick {Boolean} Wether the first click outside of the
+ * tooltip should close the tooltip and be consumed or not.
+ * Defaults to false.
+ * - closeOnKeys {Array} An array of key codes that should close the
+ * tooltip. Defaults to [27] (escape key).
+ * - closeOnEvents [{emitter: {Object}, event: {String}, useCapture: {Boolean}}]
+ * Provide an optional list of emitter objects and event names here to
+ * trigger the closing of the tooltip when these events are fired by the
+ * emitters. The emitter objects should either implement on/off(event, cb)
+ * or addEventListener/removeEventListener(event, cb). Defaults to [].
+ * For instance, the following would close the tooltip whenever the
+ * toolbox selects a new tool and when a DOM node gets scrolled:
+ * new Tooltip(doc, {
+ * closeOnEvents: [
+ * {emitter: toolbox, event: "select"},
+ * {emitter: myContainer, event: "scroll", useCapture: true}
+ * ]
+ * });
+ * - noAutoFocus {Boolean} Should the focus automatically go to the panel
+ * when it opens. Defaults to true.
+ *
+ * Fires these events:
+ * - showing : just before the tooltip shows
+ * - shown : when the tooltip is shown
+ * - hiding : just before the tooltip closes
+ * - hidden : when the tooltip gets hidden
+ * - keypress : when any key gets pressed, with keyCode
+ */
+function Tooltip(doc, options) {
+ EventEmitter.decorate(this);
+
+ this.doc = doc;
+ this.options = new OptionsStore({
+ consumeOutsideClick: false,
+ closeOnKeys: [ESCAPE_KEYCODE],
+ noAutoFocus: true,
+ closeOnEvents: []
+ }, options);
+ this.panel = PanelFactory.get(doc, this.options);
+
+ // Used for namedTimeouts in the mouseover handling
+ this.uid = "tooltip-" + Date.now();
+
+ // Emit show/hide events
+ for (let event of POPUP_EVENTS) {
+ this["_onPopup" + event] = ((e) => {
+ return () => this.emit(e);
+ })(event);
+ this.panel.addEventListener("popup" + event,
+ this["_onPopup" + event], false);
+ }
+
+ // Listen to keypress events to close the tooltip if configured to do so
+ let win = this.doc.querySelector("window");
+ this._onKeyPress = event => {
+ if (this.panel.hidden) {
+ return;
+ }
+
+ this.emit("keypress", event.keyCode);
+ if (this.options.get("closeOnKeys").indexOf(event.keyCode) !== -1) {
+ event.stopPropagation();
+ this.hide();
+ }
+ };
+ win.addEventListener("keypress", this._onKeyPress, false);
+
+ // Listen to custom emitters' events to close the tooltip
+ this.hide = this.hide.bind(this);
+ let closeOnEvents = this.options.get("closeOnEvents");
+ for (let {emitter, event, useCapture} of closeOnEvents) {
+ for (let add of ["addEventListener", "on"]) {
+ if (add in emitter) {
+ emitter[add](event, this.hide, useCapture);
+ break;
+ }
+ }
+ }
+}
+
+module.exports.Tooltip = Tooltip;
+
+Tooltip.prototype = {
+ defaultPosition: "before_start",
+ defaultOffsetX: 0, // px
+ defaultOffsetY: 0, // px
+ defaultShowDelay: 50, // ms
+
+ /**
+ * Show the tooltip. It might be wise to append some content first if you
+ * don't want the tooltip to be empty. You may access the content of the
+ * tooltip by setting a XUL node to t.content.
+ * @param {node} anchor
+ * Which node should the tooltip be shown on
+ * @param {string} position [optional]
+ * Optional tooltip position. Defaults to before_start
+ * https://developer.mozilla.org/en-US/docs/XUL/PopupGuide/Positioning
+ * @param {number} x, y [optional]
+ * The left and top offset coordinates, in pixels.
+ */
+ show: function(anchor,
+ position = this.defaultPosition,
+ x = this.defaultOffsetX,
+ y = this.defaultOffsetY) {
+ this.panel.hidden = false;
+ this.panel.openPopup(anchor, position, x, y);
+ },
+
+ /**
+ * Hide the tooltip
+ */
+ hide: function() {
+ this.panel.hidden = true;
+ this.panel.hidePopup();
+ },
+
+ isShown: function() {
+ return this.panel &&
+ this.panel.state !== "closed" &&
+ this.panel.state !== "hiding";
+ },
+
+ setSize: function(width, height) {
+ this.panel.sizeTo(width, height);
+ },
+
+ /**
+ * Empty the tooltip's content
+ */
+ empty: function() {
+ while (this.panel.hasChildNodes()) {
+ this.panel.removeChild(this.panel.firstChild);
+ }
+ },
+
+ /**
+ * Gets this panel's visibility state.
+ * @return boolean
+ */
+ isHidden: function() {
+ return this.panel.state == "closed" || this.panel.state == "hiding";
+ },
+
+ /**
+ * Gets if this panel has any child nodes.
+ * @return boolean
+ */
+ isEmpty: function() {
+ return !this.panel.hasChildNodes();
+ },
+
+ /**
+ * Get rid of references and event listeners
+ */
+ destroy: function () {
+ this.hide();
+
+ for (let event of POPUP_EVENTS) {
+ this.panel.removeEventListener("popup" + event,
+ this["_onPopup" + event], false);
+ }
+
+ let win = this.doc.querySelector("window");
+ win.removeEventListener("keypress", this._onKeyPress, false);
+
+ let closeOnEvents = this.options.get("closeOnEvents");
+ for (let {emitter, event, useCapture} of closeOnEvents) {
+ for (let remove of ["removeEventListener", "off"]) {
+ if (remove in emitter) {
+ emitter[remove](event, this.hide, useCapture);
+ break;
+ }
+ }
+ }
+
+ this.content = null;
+
+ if (this._basedNode) {
+ this.stopTogglingOnHover();
+ }
+
+ this.doc = null;
+
+ this.panel.remove();
+ this.panel = null;
+ },
+
+ /**
+ * Show/hide the tooltip when the mouse hovers over particular nodes.
+ *
+ * 2 Ways to make this work:
+ * - Provide a single node to attach the tooltip to, as the baseNode, and
+ * omit the second targetNodeCb argument
+ * - Provide a baseNode that is the container of possibly numerous children
+ * elements that may receive a tooltip. In this case, provide the second
+ * targetNodeCb argument to decide wether or not a child should receive
+ * a tooltip.
+ *
+ * This works by tracking mouse movements on a base container node (baseNode)
+ * and showing the tooltip when the mouse stops moving. The targetNodeCb
+ * callback is used to know whether or not the particular element being
+ * hovered over should indeed receive the tooltip. If you don't provide it
+ * it's equivalent to a function that always returns true.
+ *
+ * Note that if you call this function a second time, it will itself call
+ * stopTogglingOnHover before adding mouse tracking listeners again.
+ *
+ * @param {node} baseNode
+ * The container for all target nodes
+ * @param {Function} targetNodeCb
+ * A function that accepts a node argument and returns true or false
+ * (or a promise that resolves or rejects) to signify if the tooltip
+ * should be shown on that node or not.
+ * Additionally, the function receives a second argument which is the
+ * tooltip instance itself, to be used to add/modify the content of the
+ * tooltip if needed. If omitted, the tooltip will be shown everytime.
+ * @param {Number} showDelay
+ * An optional delay that will be observed before showing the tooltip.
+ * Defaults to this.defaultShowDelay.
+ */
+ startTogglingOnHover: function(baseNode, targetNodeCb, showDelay=this.defaultShowDelay) {
+ if (this._basedNode) {
+ this.stopTogglingOnHover();
+ }
+ if (!baseNode) {
+ // Calling tool is in the process of being destroyed.
+ return;
+ }
+
+ this._basedNode = baseNode;
+ this._showDelay = showDelay;
+ this._targetNodeCb = targetNodeCb || (() => true);
+
+ this._onBaseNodeMouseMove = this._onBaseNodeMouseMove.bind(this);
+ this._onBaseNodeMouseLeave = this._onBaseNodeMouseLeave.bind(this);
+
+ baseNode.addEventListener("mousemove", this._onBaseNodeMouseMove, false);
+ baseNode.addEventListener("mouseleave", this._onBaseNodeMouseLeave, false);
+ },
+
+ /**
+ * If the startTogglingOnHover function has been used previously, and you want
+ * to get rid of this behavior, then call this function to remove the mouse
+ * movement tracking
+ */
+ stopTogglingOnHover: function() {
+ clearNamedTimeout(this.uid);
+
+ if (!this._basedNode) {
+ return;
+ }
+
+ this._basedNode.removeEventListener("mousemove",
+ this._onBaseNodeMouseMove, false);
+ this._basedNode.removeEventListener("mouseleave",
+ this._onBaseNodeMouseLeave, false);
+
+ this._basedNode = null;
+ this._targetNodeCb = null;
+ this._lastHovered = null;
+ },
+
+ _onBaseNodeMouseMove: function(event) {
+ if (event.target !== this._lastHovered) {
+ this.hide();
+ this._lastHovered = event.target;
+ setNamedTimeout(this.uid, this._showDelay, () => {
+ this.isValidHoverTarget(event.target).then(target => {
+ this.show(target);
+ }, reason => {
+ if (reason === false) {
+ // isValidHoverTarget rejects with false if the tooltip should
+ // not be shown. This can be safely ignored.
+ return;
+ }
+ // Report everything else. Reason might be error that should not be
+ // hidden.
+ console.error("isValidHoverTarget rejected with an unexpected reason:");
+ console.error(reason);
+ });
+ });
+ }
+ },
+
+ /**
+ * Is the given target DOMNode a valid node for toggling the tooltip on hover.
+ * This delegates to the user-defined _targetNodeCb callback.
+ * @return a promise that resolves or rejects depending if the tooltip should
+ * be shown or not. If it resolves, it does to the actual anchor to be used
+ */
+ isValidHoverTarget: function(target) {
+ // Execute the user-defined callback which should return either true/false
+ // or a promise that resolves or rejects
+ let res = this._targetNodeCb(target, this);
+
+ // The callback can additionally return a DOMNode to replace the anchor of
+ // the tooltip when shown
+ if (res && res.then) {
+ return res.then(arg => {
+ return arg instanceof Ci.nsIDOMNode ? arg : target;
+ }, () => {
+ return false;
+ });
+ } else {
+ let newTarget = res instanceof Ci.nsIDOMNode ? res : target;
+ return res ? promise.resolve(newTarget) : promise.reject(false);
+ }
+ },
+
+ _onBaseNodeMouseLeave: function() {
+ clearNamedTimeout(this.uid);
+ this._lastHovered = null;
+ this.hide();
+ },
+
+ /**
+ * Set the content of this tooltip. Will first empty the tooltip and then
+ * append the new content element.
+ * Consider using one of the set<type>Content() functions instead.
+ * @param {node} content
+ * A node that can be appended in the tooltip XUL element
+ */
+ set content(content) {
+ if (this.content == content) {
+ return;
+ }
+
+ this.empty();
+ this.panel.removeAttribute("clamped-dimensions");
+ this.panel.removeAttribute("clamped-dimensions-no-min-height");
+ this.panel.removeAttribute("clamped-dimensions-no-max-or-min-height");
+ this.panel.removeAttribute("wide");
+
+ if (content) {
+ this.panel.appendChild(content);
+ }
+ },
+
+ get content() {
+ return this.panel.firstChild;
+ },
+
+ /**
+ * Sets some text as the content of this tooltip.
+ *
+ * @param {array} messages
+ * A list of text messages.
+ * @param {string} messagesClass [optional]
+ * A style class for the text messages.
+ * @param {string} containerClass [optional]
+ * A style class for the text messages container.
+ * @param {boolean} isAlertTooltip [optional]
+ * Pass true to add an alert image for your tooltip.
+ */
+ setTextContent: function(
+ {
+ messages,
+ messagesClass,
+ containerClass,
+ isAlertTooltip
+ },
+ extraButtons = []) {
+ messagesClass = messagesClass || "default-tooltip-simple-text-colors";
+ containerClass = containerClass || "default-tooltip-simple-text-colors";
+
+ let vbox = this.doc.createElement("vbox");
+ vbox.className = "devtools-tooltip-simple-text-container " + containerClass;
+ vbox.setAttribute("flex", "1");
+
+ for (let text of messages) {
+ let description = this.doc.createElement("description");
+ description.setAttribute("flex", "1");
+ description.className = "devtools-tooltip-simple-text " + messagesClass;
+ description.textContent = text;
+ vbox.appendChild(description);
+ }
+
+ for (let { label, className, command } of extraButtons) {
+ let button = this.doc.createElement("button");
+ button.className = className;
+ button.setAttribute("label", label);
+ button.addEventListener("command", command);
+ vbox.appendChild(button);
+ }
+
+ if (isAlertTooltip) {
+ let hbox = this.doc.createElement("hbox");
+ hbox.setAttribute("align", "start");
+
+ let alertImg = this.doc.createElement("image");
+ alertImg.className = "devtools-tooltip-alert-icon";
+ hbox.appendChild(alertImg);
+ hbox.appendChild(vbox);
+ this.content = hbox;
+ } else {
+ this.content = vbox;
+ }
+ },
+
+ /**
+ * Sets some event listener info as the content of this tooltip.
+ *
+ * @param {Object} (destructuring assignment)
+ * @0 {array} eventListenerInfos
+ * A list of event listeners.
+ * @1 {toolbox} toolbox
+ * Toolbox used to select debugger panel.
+ */
+ setEventContent: function({ eventListenerInfos, toolbox }) {
+ new EventTooltip(this, eventListenerInfos, toolbox);
+ },
+
+ /**
+ * Fill the tooltip with a variables view, inspecting an object via its
+ * corresponding object actor, as specified in the remote debugging protocol.
+ *
+ * @param {object} objectActor
+ * The value grip for the object actor.
+ * @param {object} viewOptions [optional]
+ * Options for the variables view visualization.
+ * @param {object} controllerOptions [optional]
+ * Options for the variables view controller.
+ * @param {object} relayEvents [optional]
+ * A collection of events to listen on the variables view widget.
+ * For example, { fetched: () => ... }
+ * @param {boolean} reuseCachedWidget [optional]
+ * Pass false to instantiate a brand new widget for this variable.
+ * Otherwise, if a variable was previously inspected, its widget
+ * will be reused.
+ * @param {Toolbox} toolbox [optional]
+ * Pass the instance of the current toolbox if you want the variables
+ * view widget to allow highlighting and selection of DOM nodes
+ */
+ setVariableContent: function(
+ objectActor,
+ viewOptions = {},
+ controllerOptions = {},
+ relayEvents = {},
+ extraButtons = [],
+ toolbox = null) {
+
+ let vbox = this.doc.createElement("vbox");
+ vbox.className = "devtools-tooltip-variables-view-box";
+ vbox.setAttribute("flex", "1");
+
+ let innerbox = this.doc.createElement("vbox");
+ innerbox.className = "devtools-tooltip-variables-view-innerbox";
+ innerbox.setAttribute("flex", "1");
+ vbox.appendChild(innerbox);
+
+ for (let { label, className, command } of extraButtons) {
+ let button = this.doc.createElement("button");
+ button.className = className;
+ button.setAttribute("label", label);
+ button.addEventListener("command", command);
+ vbox.appendChild(button);
+ }
+
+ let widget = new VariablesView(innerbox, viewOptions);
+
+ // If a toolbox was provided, link it to the vview
+ if (toolbox) {
+ widget.toolbox = toolbox;
+ }
+
+ // Analyzing state history isn't useful with transient object inspectors.
+ widget.commitHierarchy = () => {};
+
+ for (let e in relayEvents) widget.on(e, relayEvents[e]);
+ VariablesViewController.attach(widget, controllerOptions);
+
+ // Some of the view options are allowed to change between uses.
+ widget.searchPlaceholder = viewOptions.searchPlaceholder;
+ widget.searchEnabled = viewOptions.searchEnabled;
+
+ // Use the object actor's grip to display it as a variable in the widget.
+ // The controller options are allowed to change between uses.
+ widget.controller.setSingleVariable(
+ { objectActor: objectActor }, controllerOptions);
+
+ this.content = vbox;
+ this.panel.setAttribute("clamped-dimensions", "");
+ },
+
+ /**
+ * Uses the provided inspectorFront's getImageDataFromURL method to resolve
+ * the relative URL on the server-side, in the page context, and then sets the
+ * tooltip content with the resulting image just like |setImageContent| does.
+ * @return a promise that resolves when the image is shown in the tooltip or
+ * resolves when the broken image tooltip content is ready, but never rejects.
+ */
+ setRelativeImageContent: Task.async(function*(imageUrl, inspectorFront, maxDim) {
+ if (imageUrl.startsWith("data:")) {
+ // If the imageUrl already is a data-url, save ourselves a round-trip
+ this.setImageContent(imageUrl, {maxDim: maxDim});
+ } else if (inspectorFront) {
+ try {
+ let {data, size} = yield inspectorFront.getImageDataFromURL(imageUrl, maxDim);
+ size.maxDim = maxDim;
+ let str = yield data.string();
+ this.setImageContent(str, size);
+ } catch (e) {
+ this.setBrokenImageContent();
+ }
+ }
+ }),
+
+ /**
+ * Fill the tooltip with a message explaining the the image is missing
+ */
+ setBrokenImageContent: function() {
+ this.setTextContent({
+ messages: [l10n.strings.GetStringFromName("previewTooltip.image.brokenImage")]
+ });
+ },
+
+ /**
+ * Fill the tooltip with an image and add the image dimension at the bottom.
+ *
+ * Only use this for absolute URLs that can be queried from the devtools
+ * client-side. For relative URLs, use |setRelativeImageContent|.
+ *
+ * @param {string} imageUrl
+ * The url to load the image from
+ * @param {Object} options
+ * The following options are supported:
+ * - resized : whether or not the image identified by imageUrl has been
+ * resized before this function was called.
+ * - naturalWidth/naturalHeight : the original size of the image before
+ * it was resized, if if was resized before this function was called.
+ * If not provided, will be measured on the loaded image.
+ * - maxDim : if the image should be resized before being shown, pass
+ * a number here.
+ * - hideDimensionLabel : if the dimension label should be appended
+ * after the image.
+ */
+ setImageContent: function(imageUrl, options={}) {
+ if (!imageUrl) {
+ return;
+ }
+
+ // Main container
+ let vbox = this.doc.createElement("vbox");
+ vbox.setAttribute("align", "center");
+
+ // Display the image
+ let image = this.doc.createElement("image");
+ image.setAttribute("src", imageUrl);
+ if (options.maxDim) {
+ image.style.maxWidth = options.maxDim + "px";
+ image.style.maxHeight = options.maxDim + "px";
+ }
+ vbox.appendChild(image);
+
+ if (!options.hideDimensionLabel) {
+ let label = this.doc.createElement("label");
+ label.classList.add("devtools-tooltip-caption");
+ label.classList.add("theme-comment");
+
+ if (options.naturalWidth && options.naturalHeight) {
+ label.textContent = this._getImageDimensionLabel(options.naturalWidth,
+ options.naturalHeight);
+ } else {
+ // If no dimensions were provided, load the image to get them
+ label.textContent = l10n.strings.GetStringFromName("previewTooltip.image.brokenImage");
+ let imgObj = new this.doc.defaultView.Image();
+ imgObj.src = imageUrl;
+ imgObj.onload = () => {
+ imgObj.onload = null;
+ label.textContent = this._getImageDimensionLabel(imgObj.naturalWidth,
+ imgObj.naturalHeight);
+ };
+ }
+
+ vbox.appendChild(label);
+ }
+
+ this.content = vbox;
+ },
+
+ _getImageDimensionLabel: (w, h) => w + " \u00D7 " + h,
+
+ /**
+ * Fill the tooltip with a new instance of the spectrum color picker widget
+ * initialized with the given color, and return a promise that resolves to
+ * the instance of spectrum
+ */
+ setColorPickerContent: function(color) {
+ let def = promise.defer();
+
+ // Create an iframe to contain spectrum
+ let iframe = this.doc.createElementNS(XHTML_NS, "iframe");
+ iframe.setAttribute("transparent", true);
+ iframe.setAttribute("width", "210");
+ iframe.setAttribute("height", "216");
+ iframe.setAttribute("flex", "1");
+ iframe.setAttribute("class", "devtools-tooltip-iframe");
+
+ let panel = this.panel;
+ let xulWin = this.doc.ownerGlobal;
+
+ // Wait for the load to initialize spectrum
+ function onLoad() {
+ iframe.removeEventListener("load", onLoad, true);
+ let win = iframe.contentWindow.wrappedJSObject;
+
+ let container = win.document.getElementById("spectrum");
+ let spectrum = new Spectrum(container, color);
+
+ function finalizeSpectrum() {
+ spectrum.show();
+ def.resolve(spectrum);
+ }
+
+ // Finalize spectrum's init when the tooltip becomes visible
+ if (panel.state == "open") {
+ finalizeSpectrum();
+ }
+ else {
+ panel.addEventListener("popupshown", function shown() {
+ panel.removeEventListener("popupshown", shown, true);
+ finalizeSpectrum();
+ }, true);
+ }
+ }
+ iframe.addEventListener("load", onLoad, true);
+ iframe.setAttribute("src", SPECTRUM_FRAME);
+
+ // Put the iframe in the tooltip
+ this.content = iframe;
+
+ return def.promise;
+ },
+
+ /**
+ * Fill the tooltip with a new instance of the cubic-bezier widget
+ * initialized with the given value, and return a promise that resolves to
+ * the instance of the widget
+ */
+ setCubicBezierContent: function(bezier) {
+ let def = promise.defer();
+
+ // Create an iframe to host the cubic-bezier widget
+ let iframe = this.doc.createElementNS(XHTML_NS, "iframe");
+ iframe.setAttribute("transparent", true);
+ iframe.setAttribute("width", "200");
+ iframe.setAttribute("height", "415");
+ iframe.setAttribute("flex", "1");
+ iframe.setAttribute("class", "devtools-tooltip-iframe");
+
+ let panel = this.panel;
+ let xulWin = this.doc.ownerGlobal;
+
+ // Wait for the load to initialize the widget
+ function onLoad() {
+ iframe.removeEventListener("load", onLoad, true);
+ let win = iframe.contentWindow.wrappedJSObject;
+
+ let container = win.document.getElementById("container");
+ let widget = new CubicBezierWidget(container, bezier);
+
+ // Resolve to the widget instance whenever the popup becomes visible
+ if (panel.state == "open") {
+ def.resolve(widget);
+ } else {
+ panel.addEventListener("popupshown", function shown() {
+ panel.removeEventListener("popupshown", shown, true);
+ def.resolve(widget);
+ }, true);
+ }
+ }
+ iframe.addEventListener("load", onLoad, true);
+ iframe.setAttribute("src", CUBIC_BEZIER_FRAME);
+
+ // Put the iframe in the tooltip
+ this.content = iframe;
+
+ return def.promise;
+ },
+
+ /**
+ * Set the content of the tooltip to display a font family preview.
+ * This is based on Lea Verou's Dablet. See https://github.com/LeaVerou/dabblet
+ * for more info.
+ * @param {String} font The font family value.
+ * @param {object} nodeFront
+ * The NodeActor that will used to retrieve the dataURL for the font
+ * family tooltip contents.
+ * @return A promise that resolves when the font tooltip content is ready, or
+ * rejects if no font is provided
+ */
+ setFontFamilyContent: Task.async(function*(font, nodeFront) {
+ if (!font || !nodeFront) {
+ throw "Missing font";
+ }
+
+ if (typeof nodeFront.getFontFamilyDataURL === "function") {
+ font = font.replace(/"/g, "'");
+ font = font.replace("!important", "");
+ font = font.trim();
+
+ let fillStyle = (Services.prefs.getCharPref("devtools.theme") === "light") ?
+ "black" : "white";
+
+ let {data, size} = yield nodeFront.getFontFamilyDataURL(font, fillStyle);
+ let str = yield data.string();
+ this.setImageContent(str, { hideDimensionLabel: true, maxDim: size });
+ }
+ })
+};
+
+/**
+ * Base class for all (color, gradient, ...)-swatch based value editors inside
+ * tooltips
+ *
+ * @param {XULDocument} doc
+ */
+function SwatchBasedEditorTooltip(doc) {
+ // Creating a tooltip instance
+ // This one will consume outside clicks as it makes more sense to let the user
+ // close the tooltip by clicking out
+ // It will also close on <escape> and <enter>
+ this.tooltip = new Tooltip(doc, {
+ consumeOutsideClick: true,
+ closeOnKeys: [ESCAPE_KEYCODE, RETURN_KEYCODE],
+ noAutoFocus: false
+ });
+
+ // By default, swatch-based editor tooltips revert value change on <esc> and
+ // commit value change on <enter>
+ this._onTooltipKeypress = (event, code) => {
+ if (code === ESCAPE_KEYCODE) {
+ this.revert();
+ } else if (code === RETURN_KEYCODE) {
+ this.commit();
+ }
+ };
+ this.tooltip.on("keypress", this._onTooltipKeypress);
+
+ // All target swatches are kept in a map, indexed by swatch DOM elements
+ this.swatches = new Map();
+
+ // When a swatch is clicked, and for as long as the tooltip is shown, the
+ // activeSwatch property will hold the reference to the swatch DOM element
+ // that was clicked
+ this.activeSwatch = null;
+
+ this._onSwatchClick = this._onSwatchClick.bind(this);
+}
+
+SwatchBasedEditorTooltip.prototype = {
+ show: function() {
+ if (this.activeSwatch) {
+ this.tooltip.show(this.activeSwatch, "topcenter bottomleft");
+
+ // When the tooltip is closed by clicking outside the panel we want to
+ // commit any changes. Because the "hidden" event destroys the tooltip we
+ // need to do this before the tooltip is destroyed (in the "hiding" event).
+ this.tooltip.once("hiding", () => {
+ if (!this._reverted && !this.eyedropperOpen) {
+ this.commit();
+ }
+ this._reverted = false;
+ });
+
+ // Once the tooltip is hidden we need to clean up any remaining objects.
+ this.tooltip.once("hidden", () => {
+ if (!this.eyedropperOpen) {
+ this.activeSwatch = null;
+ }
+ });
+ }
+ },
+
+ hide: function() {
+ this.tooltip.hide();
+ },
+
+ /**
+ * Add a new swatch DOM element to the list of swatch elements this editor
+ * tooltip knows about. That means from now on, clicking on that swatch will
+ * toggle the editor.
+ *
+ * @param {node} swatchEl
+ * The element to add
+ * @param {object} callbacks
+ * Callbacks that will be executed when the editor wants to preview a
+ * value change, or revert a change, or commit a change.
+ * - onPreview: will be called when one of the sub-classes calls preview
+ * - onRevert: will be called when the user ESCapes out of the tooltip
+ * - onCommit: will be called when the user presses ENTER or clicks
+ * outside the tooltip.
+ */
+ addSwatch: function(swatchEl, callbacks={}) {
+ if (!callbacks.onPreview) callbacks.onPreview = function() {};
+ if (!callbacks.onRevert) callbacks.onRevert = function() {};
+ if (!callbacks.onCommit) callbacks.onCommit = function() {};
+
+ this.swatches.set(swatchEl, {
+ callbacks: callbacks
+ });
+ swatchEl.addEventListener("click", this._onSwatchClick, false);
+ },
+
+ removeSwatch: function(swatchEl) {
+ if (this.swatches.has(swatchEl)) {
+ if (this.activeSwatch === swatchEl) {
+ this.hide();
+ this.activeSwatch = null;
+ }
+ swatchEl.removeEventListener("click", this._onSwatchClick, false);
+ this.swatches.delete(swatchEl);
+ }
+ },
+
+ _onSwatchClick: function(event) {
+ let swatch = this.swatches.get(event.target);
+ if (swatch) {
+ this.activeSwatch = event.target;
+ this.show();
+ event.stopPropagation();
+ }
+ },
+
+ /**
+ * Not called by this parent class, needs to be taken care of by sub-classes
+ */
+ preview: function(value) {
+ if (this.activeSwatch) {
+ let swatch = this.swatches.get(this.activeSwatch);
+ swatch.callbacks.onPreview(value);
+ }
+ },
+
+ /**
+ * This parent class only calls this on <esc> keypress
+ */
+ revert: function() {
+ if (this.activeSwatch) {
+ let swatch = this.swatches.get(this.activeSwatch);
+ swatch.callbacks.onRevert();
+ this._reverted = true;
+ }
+ },
+
+ /**
+ * This parent class only calls this on <enter> keypress
+ */
+ commit: function() {
+ if (this.activeSwatch) {
+ let swatch = this.swatches.get(this.activeSwatch);
+ swatch.callbacks.onCommit();
+ }
+ },
+
+ destroy: function() {
+ this.swatches.clear();
+ this.activeSwatch = null;
+ this.tooltip.off("keypress", this._onTooltipKeypress);
+ this.tooltip.destroy();
+ }
+};
+
+/**
+ * The swatch color picker tooltip class is a specific class meant to be used
+ * along with output-parser's generated color swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * color picker.
+ *
+ * @param {XULDocument} doc
+ */
+function SwatchColorPickerTooltip(doc) {
+ SwatchBasedEditorTooltip.call(this, doc);
+
+ // Creating a spectrum instance. this.spectrum will always be a promise that
+ // resolves to the spectrum instance
+ this.spectrum = this.tooltip.setColorPickerContent([0, 0, 0, 1]);
+ this._onSpectrumColorChange = this._onSpectrumColorChange.bind(this);
+ this._openEyeDropper = this._openEyeDropper.bind(this);
+}
+
+module.exports.SwatchColorPickerTooltip = SwatchColorPickerTooltip;
+
+SwatchColorPickerTooltip.prototype = Heritage.extend(SwatchBasedEditorTooltip.prototype, {
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set spectrum's
+ * color.
+ */
+ show: function() {
+ // Call then parent class' show function
+ SwatchBasedEditorTooltip.prototype.show.call(this);
+ // Then set spectrum's color and listen to color changes to preview them
+ if (this.activeSwatch) {
+ this.currentSwatchColor = this.activeSwatch.nextSibling;
+ let color = this.activeSwatch.style.backgroundColor;
+ this.spectrum.then(spectrum => {
+ spectrum.off("changed", this._onSpectrumColorChange);
+ spectrum.rgb = this._colorToRgba(color);
+ spectrum.on("changed", this._onSpectrumColorChange);
+ spectrum.updateUI();
+ });
+ }
+
+ let tooltipDoc = this.tooltip.content.contentDocument;
+ let eyeButton = tooltipDoc.querySelector("#eyedropper-button");
+ eyeButton.addEventListener("click", this._openEyeDropper);
+ },
+
+ _onSpectrumColorChange: function(event, rgba, cssColor) {
+ this._selectColor(cssColor);
+ },
+
+ _selectColor: function(color) {
+ if (this.activeSwatch) {
+ this.activeSwatch.style.backgroundColor = color;
+ this.activeSwatch.parentNode.dataset.color = color;
+
+ color = this._toDefaultType(color);
+ this.currentSwatchColor.textContent = color;
+ this.preview(color);
+
+ if (this.eyedropperOpen) {
+ this.commit();
+ }
+ }
+ },
+
+ _openEyeDropper: function() {
+ let chromeWindow = this.tooltip.doc.defaultView.top;
+ let windowType = chromeWindow.document.documentElement
+ .getAttribute("windowtype");
+ let toolboxWindow;
+ if (windowType != "navigator:browser") {
+ // this means the toolbox is in a seperate window. We need to make
+ // sure we'll be inspecting the browser window instead
+ toolboxWindow = chromeWindow;
+ chromeWindow = Services.wm.getMostRecentWindow("navigator:browser");
+ chromeWindow.focus();
+ }
+ let dropper = new Eyedropper(chromeWindow, { copyOnSelect: false,
+ context: "picker" });
+
+ dropper.once("select", (event, color) => {
+ if (toolboxWindow) {
+ toolboxWindow.focus();
+ }
+ this._selectColor(color);
+ });
+
+ dropper.once("destroy", () => {
+ this.eyedropperOpen = false;
+ this.activeSwatch = null;
+ });
+
+ dropper.open();
+ this.eyedropperOpen = true;
+
+ // close the colorpicker tooltip so that only the eyedropper is open.
+ this.hide();
+
+ this.tooltip.emit("eyedropper-opened", dropper);
+ },
+
+ _colorToRgba: function(color) {
+ color = new colorUtils.CssColor(color);
+ let rgba = color._getRGBATuple();
+ return [rgba.r, rgba.g, rgba.b, rgba.a];
+ },
+
+ _toDefaultType: function(color) {
+ let colorObj = new colorUtils.CssColor(color);
+ return colorObj.toString();
+ },
+
+ destroy: function() {
+ SwatchBasedEditorTooltip.prototype.destroy.call(this);
+ this.currentSwatchColor = null;
+ this.spectrum.then(spectrum => {
+ spectrum.off("changed", this._onSpectrumColorChange);
+ spectrum.destroy();
+ });
+ }
+});
+
+function EventTooltip(tooltip, eventListenerInfos, toolbox) {
+ this._tooltip = tooltip;
+ this._eventListenerInfos = eventListenerInfos;
+ this._toolbox = toolbox;
+ this._tooltip.eventEditors = new WeakMap();
+
+ this._headerClicked = this._headerClicked.bind(this);
+ this._debugClicked = this._debugClicked.bind(this);
+ this.destroy = this.destroy.bind(this);
+
+ this._init();
+}
+
+EventTooltip.prototype = {
+ _init: function() {
+ let config = {
+ mode: Editor.modes.js,
+ lineNumbers: false,
+ lineWrapping: false,
+ readOnly: true,
+ styleActiveLine: true,
+ extraKeys: {},
+ theme: "mozilla markup-view"
+ };
+
+ let doc = this._tooltip.doc;
+ let container = doc.createElement("vbox");
+ container.setAttribute("id", "devtools-tooltip-events-container");
+
+ for (let listener of this._eventListenerInfos) {
+ let phase = listener.capturing ? "Capturing" : "Bubbling";
+ let level = listener.DOM0 ? "DOM0" : "DOM2";
+
+ // Header
+ let header = doc.createElement("hbox");
+ header.className = "event-header devtools-toolbar";
+ container.appendChild(header);
+
+ if (!listener.hide.debugger) {
+ let debuggerIcon = doc.createElement("image");
+ debuggerIcon.className = "event-tooltip-debugger-icon";
+ debuggerIcon.setAttribute("src", "chrome://browser/skin/devtools/tool-debugger.svg");
+ let openInDebugger = l10n.strings.GetStringFromName("eventsTooltip.openInDebugger");
+ debuggerIcon.setAttribute("tooltiptext", openInDebugger);
+ header.appendChild(debuggerIcon);
+ }
+
+ if (!listener.hide.type) {
+ let eventTypeLabel = doc.createElement("label");
+ eventTypeLabel.className = "event-tooltip-event-type";
+ eventTypeLabel.setAttribute("value", listener.type);
+ eventTypeLabel.setAttribute("tooltiptext", listener.type);
+ header.appendChild(eventTypeLabel);
+ }
+
+ if (!listener.hide.filename) {
+ let filename = doc.createElement("label");
+ filename.className = "event-tooltip-filename devtools-monospace";
+ filename.setAttribute("value", listener.origin);
+ filename.setAttribute("tooltiptext", listener.origin);
+ filename.setAttribute("crop", "left");
+ header.appendChild(filename);
+ }
+
+ let attributesContainer = doc.createElement("hbox");
+ attributesContainer.setAttribute("class", "event-tooltip-attributes-container");
+ header.appendChild(attributesContainer);
+
+ if (!listener.hide.capturing) {
+ let attributesBox = doc.createElement("box");
+ attributesBox.setAttribute("class", "event-tooltip-attributes-box");
+ attributesContainer.appendChild(attributesBox);
+
+ let capturing = doc.createElement("label");
+ capturing.className = "event-tooltip-attributes";
+ capturing.setAttribute("value", phase);
+ capturing.setAttribute("tooltiptext", phase);
+ attributesBox.appendChild(capturing);
+ }
+
+ if (listener.tags) {
+ for (let tag of listener.tags.split(",")) {
+ let attributesBox = doc.createElement("box");
+ attributesBox.setAttribute("class", "event-tooltip-attributes-box");
+ attributesContainer.appendChild(attributesBox);
+
+ let tagBox = doc.createElement("label");
+ tagBox.className = "event-tooltip-attributes";
+ tagBox.setAttribute("value", tag);
+ tagBox.setAttribute("tooltiptext", tag);
+ attributesBox.appendChild(tagBox);
+ }
+ }
+
+ if (!listener.hide.dom0) {
+ let attributesBox = doc.createElement("box");
+ attributesBox.setAttribute("class", "event-tooltip-attributes-box");
+ attributesContainer.appendChild(attributesBox);
+
+ let dom0 = doc.createElement("label");
+ dom0.className = "event-tooltip-attributes";
+ dom0.setAttribute("value", level);
+ dom0.setAttribute("tooltiptext", level);
+ attributesBox.appendChild(dom0);
+ }
+
+ // Content
+ let content = doc.createElement("box");
+ let editor = new Editor(config);
+ this._tooltip.eventEditors.set(content, {
+ editor: editor,
+ handler: listener.handler,
+ searchString: listener.searchString,
+ uri: listener.origin,
+ dom0: listener.DOM0,
+ appended: false
+ });
+
+ content.className = "event-tooltip-content-box";
+ container.appendChild(content);
+
+ this._addContentListeners(header);
+ }
+
+ this._tooltip.content = container;
+ this._tooltip.panel.setAttribute("clamped-dimensions-no-max-or-min-height", "");
+ this._tooltip.panel.setAttribute("wide", "");
+
+ this._tooltip.panel.addEventListener("popuphiding", () => {
+ this.destroy(container);
+ }, false);
+ },
+
+ _addContentListeners: function(header) {
+ header.addEventListener("click", this._headerClicked);
+ },
+
+ _headerClicked: function(event) {
+ if (event.target.classList.contains("event-tooltip-debugger-icon")) {
+ this._debugClicked(event);
+ event.stopPropagation();
+ return;
+ }
+
+ let doc = this._tooltip.doc;
+ let header = event.currentTarget;
+ let content = header.nextElementSibling;
+
+ if (content.hasAttribute("open")) {
+ content.removeAttribute("open");
+ } else {
+ let contentNodes = doc.querySelectorAll(".event-tooltip-content-box");
+
+ for (let node of contentNodes) {
+ if (node !== content) {
+ node.removeAttribute("open");
+ }
+ }
+
+ content.setAttribute("open", "");
+
+ let eventEditors = this._tooltip.eventEditors.get(content);
+
+ if (eventEditors.appended) {
+ return;
+ }
+
+ let {editor, handler} = eventEditors;
+
+ let iframe = doc.createElement("iframe");
+ iframe.setAttribute("style", "width:100%;");
+
+ editor.appendTo(content, iframe).then(() => {
+ let tidied = beautify.js(handler, { indent_size: 2 });
+
+ editor.setText(tidied);
+
+ eventEditors.appended = true;
+
+ let container = header.parentElement.getBoundingClientRect();
+ if (header.getBoundingClientRect().top < container.top) {
+ header.scrollIntoView(true);
+ } else if (content.getBoundingClientRect().bottom > container.bottom) {
+ content.scrollIntoView(false);
+ }
+
+ this._tooltip.emit("event-tooltip-ready");
+ });
+ }
+ },
+
+ _debugClicked: function(event) {
+ let header = event.currentTarget;
+ let content = header.nextElementSibling;
+
+ let {uri, searchString, dom0} =
+ this._tooltip.eventEditors.get(content);
+
+ if (uri && uri !== "?") {
+ // Save a copy of toolbox as it will be set to null when we hide the
+ // tooltip.
+ let toolbox = this._toolbox;
+
+ this._tooltip.hide();
+
+ uri = uri.replace(/"/g, "");
+
+ let showSource = ({ DebuggerView }) => {
+ let matches = uri.match(/(.*):(\d+$)/);
+ let line = 1;
+
+ if (matches) {
+ uri = matches[1];
+ line = matches[2];
+ }
+
+ let item = DebuggerView.Sources.getItemForAttachment(
+ a => a.source.url === uri
+ );
+ if (item) {
+ let actor = item.attachment.source.actor;
+ DebuggerView.setEditorLocation(actor, line, {noDebug: true}).then(() => {
+ if (dom0) {
+ let text = DebuggerView.editor.getText();
+ let index = text.indexOf(searchString);
+ let lastIndex = text.lastIndexOf(searchString);
+
+ // To avoid confusion we only search for DOM0 event handlers when
+ // there is only one possible match in the file.
+ if (index !== -1 && index === lastIndex) {
+ text = text.substr(0, index);
+ let matches = text.match(/\n/g);
+
+ if (matches) {
+ DebuggerView.editor.setCursor({
+ line: matches.length
+ });
+ }
+ }
+ }
+ });
+ }
+ };
+
+ let debuggerAlreadyOpen = toolbox.getPanel("jsdebugger");
+ toolbox.selectTool("jsdebugger").then(({ panelWin: dbg }) => {
+ if (debuggerAlreadyOpen) {
+ showSource(dbg);
+ } else {
+ dbg.once(dbg.EVENTS.SOURCES_ADDED, () => showSource(dbg));
+ }
+ });
+ }
+ },
+
+ destroy: function(container) {
+ if (this._tooltip) {
+ this._tooltip.panel.removeEventListener("popuphiding", this.destroy, false);
+
+ let boxes = container.querySelectorAll(".event-tooltip-content-box");
+
+ for (let box of boxes) {
+ let {editor} = this._tooltip.eventEditors.get(box);
+ editor.destroy();
+ }
+
+ this._tooltip.eventEditors.clear();
+ this._tooltip.eventEditors = null;
+ }
+
+ let headerNodes = container.querySelectorAll(".event-header");
+
+ for (let node of headerNodes) {
+ node.removeEventListener("click", this._headerClicked);
+ }
+
+ let sourceNodes = container.querySelectorAll(".event-tooltip-debugger-icon");
+ for (let node of sourceNodes) {
+ node.removeEventListener("click", this._debugClicked);
+ }
+
+ this._eventListenerInfos = this._toolbox = this._tooltip = null;
+ }
+};
+
+/**
+ * The swatch cubic-bezier tooltip class is a specific class meant to be used
+ * along with rule-view's generated cubic-bezier swatches.
+ * It extends the parent SwatchBasedEditorTooltip class.
+ * It just wraps a standard Tooltip and sets its content with an instance of a
+ * CubicBezierWidget.
+ *
+ * @param {XULDocument} doc
+ */
+function SwatchCubicBezierTooltip(doc) {
+ SwatchBasedEditorTooltip.call(this, doc);
+
+ // Creating a cubic-bezier instance.
+ // this.widget will always be a promise that resolves to the widget instance
+ this.widget = this.tooltip.setCubicBezierContent([0, 0, 1, 1]);
+ this._onUpdate = this._onUpdate.bind(this);
+}
+
+module.exports.SwatchCubicBezierTooltip = SwatchCubicBezierTooltip;
+
+SwatchCubicBezierTooltip.prototype = Heritage.extend(SwatchBasedEditorTooltip.prototype, {
+ /**
+ * Overriding the SwatchBasedEditorTooltip.show function to set the cubic
+ * bezier curve in the widget
+ */
+ show: function() {
+ // Call then parent class' show function
+ SwatchBasedEditorTooltip.prototype.show.call(this);
+ // Then set the curve and listen to changes to preview them
+ if (this.activeSwatch) {
+ this.currentBezierValue = this.activeSwatch.nextSibling;
+ let swatch = this.swatches.get(this.activeSwatch);
+ this.widget.then(widget => {
+ widget.off("updated", this._onUpdate);
+ widget.cssCubicBezierValue = this.currentBezierValue.textContent;
+ widget.on("updated", this._onUpdate);
+ });
+ }
+ },
+
+ _onUpdate: function(event, bezier) {
+ if (!this.activeSwatch) {
+ return;
+ }
+
+ this.currentBezierValue.textContent = bezier + "";
+ this.preview(bezier + "");
+ },
+
+ destroy: function() {
+ SwatchBasedEditorTooltip.prototype.destroy.call(this);
+ this.currentBezierValue = null;
+ this.widget.then(widget => {
+ widget.off("updated", this._onUpdate);
+ widget.destroy();
+ });
+ }
+});
+
+/**
+ * L10N utility class
+ */
+function L10N() {}
+L10N.prototype = {};
+
+let l10n = new L10N();
+
+loader.lazyGetter(L10N.prototype, "strings", () => {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/devtools/inspector.properties");
+});
diff --git a/toolkit/devtools/shared/widgets/TreeWidget.js b/toolkit/devtools/shared/widgets/TreeWidget.js
new file mode 100644
index 000000000..14d8753d3
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/TreeWidget.js
@@ -0,0 +1,597 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Services = require("Services")
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+const EventEmitter = require("devtools/toolkit/event-emitter");
+
+/**
+ * A tree widget with keyboard navigation and collapsable structure.
+ *
+ * @param {nsIDOMNode} node
+ * The container element for the tree widget.
+ * @param {Object} options
+ * - emptyText {string}: text to display when no entries in the table.
+ * - defaultType {string}: The default type of the tree items. For ex. 'js'
+ * - sorted {boolean}: Defaults to true. If true, tree items are kept in
+ * lexical order. If false, items will be kept in insertion order.
+ */
+function TreeWidget(node, options={}) {
+ EventEmitter.decorate(this);
+
+ this.document = node.ownerDocument;
+ this.window = this.document.defaultView;
+ this._parent = node;
+
+ this.emptyText = options.emptyText || "";
+ this.defaultType = options.defaultType;
+ this.sorted = options.sorted !== false;
+
+ this.setupRoot();
+
+ this.placeholder = this.document.createElementNS(HTML_NS, "label");
+ this.placeholder.className = "tree-widget-empty-text";
+ this._parent.appendChild(this.placeholder);
+
+ if (this.emptyText) {
+ this.setPlaceholderText(this.emptyText);
+ }
+ // A map to hold all the passed attachment to each leaf in the tree.
+ this.attachments = new Map();
+};
+
+TreeWidget.prototype = {
+
+ _selectedLabel: null,
+ _selectedItem: null,
+
+ /**
+ * Select any node in the tree.
+ *
+ * @param {array} id
+ * An array of ids leading upto the selected item
+ */
+ set selectedItem(id) {
+ if (this._selectedLabel) {
+ this._selectedLabel.classList.remove("theme-selected");
+ }
+ let currentSelected = this._selectedLabel;
+ if (id == -1) {
+ this._selectedLabel = this._selectedItem = null;
+ return;
+ }
+ if (!Array.isArray(id)) {
+ return;
+ }
+ this._selectedLabel = this.root.setSelectedItem(id);
+ if (!this._selectedLabel) {
+ this._selectedItem = null;
+ } else {
+ if (currentSelected != this._selectedLabel) {
+ this.ensureSelectedVisible();
+ }
+ this._selectedItem =
+ JSON.parse(this._selectedLabel.parentNode.getAttribute("data-id"));
+ }
+ },
+
+ /**
+ * Gets the selected item in the tree.
+ *
+ * @return {array}
+ * An array of ids leading upto the selected item
+ */
+ get selectedItem() {
+ return this._selectedItem;
+ },
+
+ /**
+ * Returns if the passed array corresponds to the selected item in the tree.
+ *
+ * @return {array}
+ * An array of ids leading upto the requested item
+ */
+ isSelected: function(item) {
+ if (!this._selectedItem || this._selectedItem.length != item.length) {
+ return false;
+ }
+
+ for (let i = 0; i < this._selectedItem.length; i++) {
+ if (this._selectedItem[i] != item[i]) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ destroy: function() {
+ this.root.remove();
+ this.root = null;
+ },
+
+ /**
+ * Sets up the root container of the TreeWidget.
+ */
+ setupRoot: function() {
+ this.root = new TreeItem(this.document);
+ this._parent.appendChild(this.root.children);
+
+ this.root.children.addEventListener("click", e => this.onClick(e));
+ this.root.children.addEventListener("keypress", e => this.onKeypress(e));
+ },
+
+ /**
+ * Sets the text to be shown when no node is present in the tree
+ */
+ setPlaceholderText: function(text) {
+ this.placeholder.textContent = text;
+ },
+
+ /**
+ * Select any node in the tree.
+ *
+ * @param {array} id
+ * An array of ids leading upto the selected item
+ */
+ selectItem: function(id) {
+ this.selectedItem = id;
+ },
+
+ /**
+ * Selects the next visible item in the tree.
+ */
+ selectNextItem: function() {
+ let next = this.getNextVisibleItem();
+ if (next) {
+ this.selectedItem = next;
+ }
+ },
+
+ /**
+ * Selects the previos visible item in the tree
+ */
+ selectPreviousItem: function() {
+ let prev = this.getPreviousVisibleItem();
+ if (prev) {
+ this.selectedItem = prev;
+ }
+ },
+
+ /**
+ * Returns the next visible item in the tree
+ */
+ getNextVisibleItem: function() {
+ let node = this._selectedLabel;
+ if (node.hasAttribute("expanded") && node.nextSibling.firstChild) {
+ return JSON.parse(node.nextSibling.firstChild.getAttribute("data-id"));
+ }
+ node = node.parentNode;
+ if (node.nextSibling) {
+ return JSON.parse(node.nextSibling.getAttribute("data-id"));
+ }
+ node = node.parentNode;
+ while (node.parentNode && node != this.root.children) {
+ if (node.parentNode && node.parentNode.nextSibling) {
+ return JSON.parse(node.parentNode.nextSibling.getAttribute("data-id"));
+ }
+ node = node.parentNode;
+ }
+ return null;
+ },
+
+ /**
+ * Returns the previous visible item in the tree
+ */
+ getPreviousVisibleItem: function() {
+ let node = this._selectedLabel.parentNode;
+ if (node.previousSibling) {
+ node = node.previousSibling.firstChild;
+ while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) {
+ if (!node.nextSibling.lastChild) {
+ break;
+ }
+ node = node.nextSibling.lastChild.firstChild;
+ }
+ return JSON.parse(node.parentNode.getAttribute("data-id"));
+ }
+ node = node.parentNode;
+ if (node.parentNode && node != this.root.children) {
+ node = node.parentNode;
+ while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) {
+ if (!node.nextSibling.firstChild) {
+ break;
+ }
+ node = node.nextSibling.firstChild.firstChild;
+ }
+ return JSON.parse(node.getAttribute("data-id"));
+ }
+ return null;
+ },
+
+ clearSelection: function() {
+ this.selectedItem = -1;
+ },
+
+ /**
+ * Adds an item in the tree. The item can be added as a child to any node in
+ * the tree. The method will also create any subnode not present in the process.
+ *
+ * @param {[string|object]} items
+ * An array of either string or objects where each increasing index
+ * represents an item corresponding to an equivalent depth in the tree.
+ * Each array element can be either just a string with the value as the
+ * id of of that item as well as the display value, or it can be an
+ * object with the following propeties:
+ * - id {string} The id of the item
+ * - label {string} The display value of the item
+ * - node {DOMNode} The dom node if you want to insert some custom
+ * element as the item. The label property is not used in this
+ * case
+ * - attachment {object} Any object to be associated with this item.
+ * - type {string} The type of this particular item. If this is null,
+ * then defaultType will be used.
+ * For example, if items = ["foo", "bar", { id: "id1", label: "baz" }]
+ * and the tree is empty, then the following hierarchy will be created
+ * in the tree:
+ * foo
+ * └ bar
+ * └ baz
+ * Passing the string id instead of the complete object helps when you
+ * are simply adding children to an already existing node and you know
+ * its id.
+ */
+ add: function(items) {
+ this.root.add(items, this.defaultType, this.sorted);
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].attachment) {
+ this.attachments.set(JSON.stringify(
+ items.slice(0, i + 1).map(item => item.id || item)
+ ), items[i].attachment);
+ }
+ }
+ // Empty the empty-tree-text
+ this.setPlaceholderText("");
+ },
+
+ /**
+ * Removes the specified item and all of its child items from the tree.
+ *
+ * @param {array} item
+ * The array of ids leading up to the item.
+ */
+ remove: function(item) {
+ this.root.remove(item)
+ this.attachments.delete(JSON.stringify(item));
+ // Display the empty tree text
+ if (this.root.items.size == 0 && this.emptyText) {
+ this.setPlaceholderText(this.emptyText);
+ }
+ },
+
+ /**
+ * Removes all of the child nodes from this tree.
+ */
+ clear: function() {
+ this.root.remove();
+ this.setupRoot();
+ this.attachments.clear();
+ if (this.emptyText) {
+ this.setPlaceholderText(this.emptyText);
+ }
+ },
+
+ /**
+ * Expands the tree completely
+ */
+ expandAll: function() {
+ this.root.expandAll();
+ },
+
+ /**
+ * Collapses the tree completely
+ */
+ collapseAll: function() {
+ this.root.collapseAll();
+ },
+
+ /**
+ * Click handler for the tree. Used to select, open and close the tree nodes.
+ */
+ onClick: function(event) {
+ let target = event.originalTarget;
+ while (target && !target.classList.contains("tree-widget-item")) {
+ if (target == this.root.children) {
+ return;
+ }
+ target = target.parentNode;
+ }
+ if (!target) {
+ return;
+ }
+ if (target.hasAttribute("expanded")) {
+ target.removeAttribute("expanded");
+ } else {
+ target.setAttribute("expanded", "true");
+ }
+ if (this._selectedLabel) {
+ this._selectedLabel.classList.remove("theme-selected");
+ }
+ if (this._selectedLabel != target) {
+ let ids = target.parentNode.getAttribute("data-id");
+ this._selectedItem = JSON.parse(ids);
+ this.emit("select", this._selectedItem, this.attachments.get(ids));
+ this._selectedLabel = target;
+ }
+ target.classList.add("theme-selected");
+ },
+
+ /**
+ * Keypress handler for this tree. Used to select next and previous visible
+ * items, as well as collapsing and expanding any item.
+ */
+ onKeypress: function(event) {
+ let currentSelected = this._selectedLabel;
+ switch(event.keyCode) {
+ case event.DOM_VK_UP:
+ this.selectPreviousItem();
+ break;
+
+ case event.DOM_VK_DOWN:
+ this.selectNextItem();
+ break;
+
+ case event.DOM_VK_RIGHT:
+ if (this._selectedLabel.hasAttribute("expanded")) {
+ this.selectNextItem();
+ } else {
+ this._selectedLabel.setAttribute("expanded", "true");
+ }
+ break;
+
+ case event.DOM_VK_LEFT:
+ if (this._selectedLabel.hasAttribute("expanded") &&
+ !this._selectedLabel.hasAttribute("empty")) {
+ this._selectedLabel.removeAttribute("expanded");
+ } else {
+ this.selectPreviousItem();
+ }
+ break;
+
+ default: return;
+ }
+ event.preventDefault();
+ if (this._selectedLabel != currentSelected) {
+ let ids = JSON.stringify(this._selectedItem);
+ this.emit("select", this._selectedItem, this.attachments.get(ids));
+ this.ensureSelectedVisible();
+ }
+ },
+
+ /**
+ * Scrolls the viewport of the tree so that the selected item is always
+ * visible.
+ */
+ ensureSelectedVisible: function() {
+ let {top, bottom} = this._selectedLabel.getBoundingClientRect();
+ let height = this.root.children.parentNode.clientHeight;
+ if (top < 0) {
+ this._selectedLabel.scrollIntoView();
+ } else if (bottom > height) {
+ this._selectedLabel.scrollIntoView(false);
+ }
+ }
+};
+
+module.exports.TreeWidget = TreeWidget;
+
+/**
+ * Any item in the tree. This can be an empty leaf node also.
+ *
+ * @param {HTMLDocument} document
+ * The document element used for creating new nodes.
+ * @param {TreeItem} parent
+ * The parent item for this item.
+ * @param {string|DOMElement} label
+ * Either the dom node to be used as the item, or the string to be
+ * displayed for this node in the tree
+ * @param {string} type
+ * The type of the current node. For ex. "js"
+ */
+function TreeItem(document, parent, label, type) {
+ this.document = document
+ this.node = this.document.createElementNS(HTML_NS, "li");
+ this.node.setAttribute("tabindex", "0");
+ this.isRoot = !parent;
+ this.parent = parent;
+ if (this.parent) {
+ this.level = this.parent.level + 1;
+ }
+ if (!!label) {
+ this.label = this.document.createElementNS(HTML_NS, "div");
+ this.label.setAttribute("empty", "true");
+ this.label.setAttribute("level", this.level);
+ this.label.className = "tree-widget-item";
+ if (type) {
+ this.label.setAttribute("type", type);
+ }
+ if (typeof label == "string") {
+ this.label.textContent = label
+ } else {
+ this.label.appendChild(label);
+ }
+ this.node.appendChild(this.label);
+ }
+ this.children = this.document.createElementNS(HTML_NS, "ul");
+ if (this.isRoot) {
+ this.children.className = "tree-widget-container";
+ } else {
+ this.children.className = "tree-widget-children";
+ }
+ this.node.appendChild(this.children);
+ this.items = new Map();
+}
+
+TreeItem.prototype = {
+
+ items: null,
+
+ isSelected: false,
+
+ expanded: false,
+
+ isRoot: false,
+
+ parent: null,
+
+ children: null,
+
+ level: 0,
+
+ /**
+ * Adds the item to the sub tree contained by this node. The item to be inserted
+ * can be a direct child of this node, or further down the tree.
+ *
+ * @param {array} items
+ * Same as TreeWidget.add method's argument
+ * @param {string} defaultType
+ * The default type of the item to be used when items[i].type is null
+ * @param {boolean} sorted
+ * true if the tree items are inserted in a lexically sorted manner.
+ * Otherwise, false if the item are to be appended to their parent.
+ */
+ add: function(items, defaultType, sorted) {
+ if (items.length == this.level) {
+ // This is the exit condition of recursive TreeItem.add calls
+ return;
+ }
+ // Get the id and label corresponding to this level inside the tree.
+ let id = items[this.level].id || items[this.level];
+ if (this.items.has(id)) {
+ // An item with same id already exists, thus calling the add method of that
+ // child to add the passed node at correct position.
+ this.items.get(id).add(items, defaultType, sorted);
+ return;
+ }
+ // No item with the id `id` exists, so we create one and call the add
+ // method of that item.
+ // The display string of the item can be the label, the id, or the item itself
+ // if its a plain string.
+ let label = items[this.level].label || items[this.level].id || items[this.level];
+ let node = items[this.level].node;
+ if (node) {
+ // The item is supposed to be a DOMNode, so we fetch the textContent in
+ // order to find the correct sorted location of this new item.
+ label = node.textContent;
+ }
+ let treeItem = new TreeItem(this.document, this, node || label,
+ items[this.level].type || defaultType);
+
+ treeItem.add(items, defaultType, sorted);
+ treeItem.node.setAttribute("data-id", JSON.stringify(
+ items.slice(0, this.level + 1).map(item => item.id || item)
+ ));
+
+ if (sorted) {
+ // Inserting this newly created item at correct position
+ let nextSibling = [...this.items.values()].find(child => {
+ return child.label.textContent >= label;
+ });
+
+ if (nextSibling) {
+ this.children.insertBefore(treeItem.node, nextSibling.node);
+ } else {
+ this.children.appendChild(treeItem.node);
+ }
+ } else {
+ this.children.appendChild(treeItem.node);
+ }
+
+ if (this.label) {
+ this.label.removeAttribute("empty");
+ }
+ this.items.set(id, treeItem);
+ },
+
+ /**
+ * If this item is to be removed, then removes this item and thus all of its
+ * subtree. Otherwise, call the remove method of appropriate child. This
+ * recursive method goes on till we have reached the end of the branch or the
+ * current item is to be removed.
+ *
+ * @param {array} items
+ * Ids of items leading up to the item to be removed.
+ */
+ remove: function(items = []) {
+ let id = items.shift();
+ if (id && this.items.has(id)) {
+ let deleted = this.items.get(id);
+ if (!items.length) {
+ this.items.delete(id);
+ }
+ deleted.remove(items);
+ } else if (!id) {
+ this.destroy();
+ }
+ },
+
+ /**
+ * If this item is to be selected, then selected and expands the item.
+ * Otherwise, if a child item is to be selected, just expands this item.
+ *
+ * @param {array} items
+ * Ids of items leading up to the item to be selected.
+ */
+ setSelectedItem: function(items) {
+ if (!items[this.level]) {
+ this.label.classList.add("theme-selected");
+ this.label.setAttribute("expanded", "true");
+ return this.label;
+ }
+ if (this.items.has(items[this.level])) {
+ let label = this.items.get(items[this.level]).setSelectedItem(items);
+ if (label && this.label) {
+ this.label.setAttribute("expanded", true);
+ }
+ return label;
+ }
+ return null;
+ },
+
+ /**
+ * Collapses this item and all of its sub tree items
+ */
+ collapseAll: function() {
+ if (this.label) {
+ this.label.removeAttribute("expanded");
+ }
+ for (let child of this.items.values()) {
+ child.collapseAll();
+ }
+ },
+
+ /**
+ * Expands this item and all of its sub tree items
+ */
+ expandAll: function() {
+ if (this.label) {
+ this.label.setAttribute("expanded", "true");
+ }
+ for (let child of this.items.values()) {
+ child.expandAll();
+ }
+ },
+
+ destroy: function() {
+ this.children.remove();
+ this.node.remove();
+ this.label = null;
+ this.items = null;
+ this.children = null;
+ }
+};
diff --git a/toolkit/devtools/shared/widgets/VariablesView.jsm b/toolkit/devtools/shared/widgets/VariablesView.jsm
new file mode 100644
index 000000000..be20169d1
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/VariablesView.jsm
@@ -0,0 +1,4131 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Ci = Components.interfaces;
+const Cu = Components.utils;
+
+const DBG_STRINGS_URI = "chrome://browser/locale/devtools/debugger.properties";
+const LAZY_EMPTY_DELAY = 150; // ms
+const LAZY_EXPAND_DELAY = 50; // ms
+const SCROLL_PAGE_SIZE_DEFAULT = 0;
+const APPEND_PAGE_SIZE_DEFAULT = 500;
+const PAGE_SIZE_SCROLL_HEIGHT_RATIO = 100;
+const PAGE_SIZE_MAX_JUMPS = 30;
+const SEARCH_ACTION_MAX_DELAY = 300; // ms
+const ITEM_FLASH_DURATION = 300 // ms
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource://gre/modules/devtools/event-emitter.js");
+Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+let {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
+
+XPCOMUtils.defineLazyModuleGetter(this, "devtools",
+ "resource://gre/modules/devtools/Loader.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
+ "resource://gre/modules/PluralForm.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper",
+ "@mozilla.org/widget/clipboardhelper;1",
+ "nsIClipboardHelper");
+
+Object.defineProperty(this, "WebConsoleUtils", {
+ get: function() {
+ return devtools.require("devtools/toolkit/webconsole/utils").Utils;
+ },
+ configurable: true,
+ enumerable: true
+});
+
+Object.defineProperty(this, "NetworkHelper", {
+ get: function() {
+ return devtools.require("devtools/toolkit/webconsole/network-helper");
+ },
+ configurable: true,
+ enumerable: true
+});
+
+this.EXPORTED_SYMBOLS = ["VariablesView", "escapeHTML"];
+
+/**
+ * Debugger localization strings.
+ */
+const STR = Services.strings.createBundle(DBG_STRINGS_URI);
+
+/**
+ * A tree view for inspecting scopes, objects and properties.
+ * Iterable via "for (let [id, scope] of instance) { }".
+ * Requires the devtools common.css and debugger.css skin stylesheets.
+ *
+ * To allow replacing variable or property values in this view, provide an
+ * "eval" function property. To allow replacing variable or property names,
+ * provide a "switch" function. To handle deleting variables or properties,
+ * provide a "delete" function.
+ *
+ * @param nsIDOMNode aParentNode
+ * The parent node to hold this view.
+ * @param object aFlags [optional]
+ * An object contaning initialization options for this view.
+ * e.g. { lazyEmpty: true, searchEnabled: true ... }
+ */
+this.VariablesView = function VariablesView(aParentNode, aFlags = {}) {
+ this._store = []; // Can't use a Map because Scope names needn't be unique.
+ this._itemsByElement = new WeakMap();
+ this._prevHierarchy = new Map();
+ this._currHierarchy = new Map();
+
+ this._parent = aParentNode;
+ this._parent.classList.add("variables-view-container");
+ this._parent.classList.add("theme-body");
+ this._appendEmptyNotice();
+
+ this._onSearchboxInput = this._onSearchboxInput.bind(this);
+ this._onSearchboxKeyPress = this._onSearchboxKeyPress.bind(this);
+ this._onViewKeyPress = this._onViewKeyPress.bind(this);
+ this._onViewKeyDown = this._onViewKeyDown.bind(this);
+
+ // Create an internal scrollbox container.
+ this._list = this.document.createElement("scrollbox");
+ this._list.setAttribute("orient", "vertical");
+ this._list.addEventListener("keypress", this._onViewKeyPress, false);
+ this._list.addEventListener("keydown", this._onViewKeyDown, false);
+ this._parent.appendChild(this._list);
+
+ for (let name in aFlags) {
+ this[name] = aFlags[name];
+ }
+
+ EventEmitter.decorate(this);
+};
+
+VariablesView.prototype = {
+ /**
+ * Helper setter for populating this container with a raw object.
+ *
+ * @param object aObject
+ * The raw object to display. You can only provide this object
+ * if you want the variables view to work in sync mode.
+ */
+ set rawObject(aObject) {
+ this.empty();
+ this.addScope()
+ .addItem("", { enumerable: true })
+ .populate(aObject, { sorted: true });
+ },
+
+ /**
+ * Adds a scope to contain any inspected variables.
+ *
+ * This new scope will be considered the parent of any other scope
+ * added afterwards.
+ *
+ * @param string aName
+ * The scope's name (e.g. "Local", "Global" etc.).
+ * @return Scope
+ * The newly created Scope instance.
+ */
+ addScope: function(aName = "") {
+ this._removeEmptyNotice();
+ this._toggleSearchVisibility(true);
+
+ let scope = new Scope(this, aName);
+ this._store.push(scope);
+ this._itemsByElement.set(scope._target, scope);
+ this._currHierarchy.set(aName, scope);
+ scope.header = !!aName;
+
+ return scope;
+ },
+
+ /**
+ * Removes all items from this container.
+ *
+ * @param number aTimeout [optional]
+ * The number of milliseconds to delay the operation if
+ * lazy emptying of this container is enabled.
+ */
+ empty: function(aTimeout = this.lazyEmptyDelay) {
+ // If there are no items in this container, emptying is useless.
+ if (!this._store.length) {
+ return;
+ }
+
+ this._store.length = 0;
+ this._itemsByElement.clear();
+ this._prevHierarchy = this._currHierarchy;
+ this._currHierarchy = new Map(); // Don't clear, this is just simple swapping.
+
+ // Check if this empty operation may be executed lazily.
+ if (this.lazyEmpty && aTimeout > 0) {
+ this._emptySoon(aTimeout);
+ return;
+ }
+
+ while (this._list.hasChildNodes()) {
+ this._list.firstChild.remove();
+ }
+
+ this._appendEmptyNotice();
+ this._toggleSearchVisibility(false);
+ },
+
+ /**
+ * Emptying this container and rebuilding it immediately afterwards would
+ * result in a brief redraw flicker, because the previously expanded nodes
+ * may get asynchronously re-expanded, after fetching the prototype and
+ * properties from a server.
+ *
+ * To avoid such behaviour, a normal container list is rebuild, but not
+ * immediately attached to the parent container. The old container list
+ * is kept around for a short period of time, hopefully accounting for the
+ * data fetching delay. In the meantime, any operations can be executed
+ * normally.
+ *
+ * @see VariablesView.empty
+ * @see VariablesView.commitHierarchy
+ */
+ _emptySoon: function(aTimeout) {
+ let prevList = this._list;
+ let currList = this._list = this.document.createElement("scrollbox");
+
+ this.window.setTimeout(() => {
+ prevList.removeEventListener("keypress", this._onViewKeyPress, false);
+ prevList.removeEventListener("keydown", this._onViewKeyDown, false);
+ currList.addEventListener("keypress", this._onViewKeyPress, false);
+ currList.addEventListener("keydown", this._onViewKeyDown, false);
+ currList.setAttribute("orient", "vertical");
+
+ this._parent.removeChild(prevList);
+ this._parent.appendChild(currList);
+
+ if (!this._store.length) {
+ this._appendEmptyNotice();
+ this._toggleSearchVisibility(false);
+ }
+ }, aTimeout);
+ },
+
+ /**
+ * Optional DevTools toolbox containing this VariablesView. Used to
+ * communicate with the inspector and highlighter.
+ */
+ toolbox: null,
+
+ /**
+ * The controller for this VariablesView, if it has one.
+ */
+ controller: null,
+
+ /**
+ * The amount of time (in milliseconds) it takes to empty this view lazily.
+ */
+ lazyEmptyDelay: LAZY_EMPTY_DELAY,
+
+ /**
+ * Specifies if this view may be emptied lazily.
+ * @see VariablesView.prototype.empty
+ */
+ lazyEmpty: false,
+
+ /**
+ * Specifies if nodes in this view may be searched lazily.
+ */
+ lazySearch: true,
+
+ /**
+ * The number of elements in this container to jump when Page Up or Page Down
+ * keys are pressed. If falsy, then the page size will be based on the
+ * container height.
+ */
+ scrollPageSize: SCROLL_PAGE_SIZE_DEFAULT,
+
+ /**
+ * The maximum number of elements allowed in a scope, variable or property
+ * that allows pagination when appending children.
+ */
+ appendPageSize: APPEND_PAGE_SIZE_DEFAULT,
+
+ /**
+ * Function called each time a variable or property's value is changed via
+ * user interaction. If null, then value changes are disabled.
+ *
+ * This property is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ eval: null,
+
+ /**
+ * Function called each time a variable or property's name is changed via
+ * user interaction. If null, then name changes are disabled.
+ *
+ * This property is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ switch: null,
+
+ /**
+ * Function called each time a variable or property is deleted via
+ * user interaction. If null, then deletions are disabled.
+ *
+ * This property is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ delete: null,
+
+ /**
+ * Function called each time a property is added via user interaction. If
+ * null, then property additions are disabled.
+ *
+ * This property is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ new: null,
+
+ /**
+ * Specifies if after an eval or switch operation, the variable or property
+ * which has been edited should be disabled.
+ */
+ preventDisableOnChange: false,
+
+ /**
+ * Specifies if, whenever a variable or property descriptor is available,
+ * configurable, enumerable, writable, frozen, sealed and extensible
+ * attributes should not affect presentation.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ preventDescriptorModifiers: false,
+
+ /**
+ * The tooltip text shown on a variable or property's value if an |eval|
+ * function is provided, in order to change the variable or property's value.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ editableValueTooltip: STR.GetStringFromName("variablesEditableValueTooltip"),
+
+ /**
+ * The tooltip text shown on a variable or property's name if a |switch|
+ * function is provided, in order to change the variable or property's name.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ editableNameTooltip: STR.GetStringFromName("variablesEditableNameTooltip"),
+
+ /**
+ * The tooltip text shown on a variable or property's edit button if an
+ * |eval| function is provided and a getter/setter descriptor is present,
+ * in order to change the variable or property to a plain value.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ editButtonTooltip: STR.GetStringFromName("variablesEditButtonTooltip"),
+
+ /**
+ * The tooltip text shown on a variable or property's value if that value is
+ * a DOMNode that can be highlighted and selected in the inspector.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ domNodeValueTooltip: STR.GetStringFromName("variablesDomNodeValueTooltip"),
+
+ /**
+ * The tooltip text shown on a variable or property's delete button if a
+ * |delete| function is provided, in order to delete the variable or property.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ deleteButtonTooltip: STR.GetStringFromName("variablesCloseButtonTooltip"),
+
+ /**
+ * Specifies the context menu attribute set on variables and properties.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ contextMenuId: "",
+
+ /**
+ * The separator label between the variables or properties name and value.
+ *
+ * This flag is applied recursively onto each scope in this view and
+ * affects only the child nodes when they're created.
+ */
+ separatorStr: STR.GetStringFromName("variablesSeparatorLabel"),
+
+ /**
+ * Specifies if enumerable properties and variables should be displayed.
+ * These variables and properties are visible by default.
+ * @param boolean aFlag
+ */
+ set enumVisible(aFlag) {
+ this._enumVisible = aFlag;
+
+ for (let scope of this._store) {
+ scope._enumVisible = aFlag;
+ }
+ },
+
+ /**
+ * Specifies if non-enumerable properties and variables should be displayed.
+ * These variables and properties are visible by default.
+ * @param boolean aFlag
+ */
+ set nonEnumVisible(aFlag) {
+ this._nonEnumVisible = aFlag;
+
+ for (let scope of this._store) {
+ scope._nonEnumVisible = aFlag;
+ }
+ },
+
+ /**
+ * Specifies if only enumerable properties and variables should be displayed.
+ * Both types of these variables and properties are visible by default.
+ * @param boolean aFlag
+ */
+ set onlyEnumVisible(aFlag) {
+ if (aFlag) {
+ this.enumVisible = true;
+ this.nonEnumVisible = false;
+ } else {
+ this.enumVisible = true;
+ this.nonEnumVisible = true;
+ }
+ },
+
+ /**
+ * Sets if the variable and property searching is enabled.
+ * @param boolean aFlag
+ */
+ set searchEnabled(aFlag) aFlag ? this._enableSearch() : this._disableSearch(),
+
+ /**
+ * Gets if the variable and property searching is enabled.
+ * @return boolean
+ */
+ get searchEnabled() !!this._searchboxContainer,
+
+ /**
+ * Sets the text displayed for the searchbox in this container.
+ * @param string aValue
+ */
+ set searchPlaceholder(aValue) {
+ if (this._searchboxNode) {
+ this._searchboxNode.setAttribute("placeholder", aValue);
+ }
+ this._searchboxPlaceholder = aValue;
+ },
+
+ /**
+ * Gets the text displayed for the searchbox in this container.
+ * @return string
+ */
+ get searchPlaceholder() this._searchboxPlaceholder,
+
+ /**
+ * Enables variable and property searching in this view.
+ * Use the "searchEnabled" setter to enable searching.
+ */
+ _enableSearch: function() {
+ // If searching was already enabled, no need to re-enable it again.
+ if (this._searchboxContainer) {
+ return;
+ }
+ let document = this.document;
+ let ownerNode = this._parent.parentNode;
+
+ let container = this._searchboxContainer = document.createElement("hbox");
+ container.className = "devtools-toolbar";
+
+ // Hide the variables searchbox container if there are no variables or
+ // properties to display.
+ container.hidden = !this._store.length;
+
+ let searchbox = this._searchboxNode = document.createElement("textbox");
+ searchbox.className = "variables-view-searchinput devtools-searchinput";
+ searchbox.setAttribute("placeholder", this._searchboxPlaceholder);
+ searchbox.setAttribute("type", "search");
+ searchbox.setAttribute("flex", "1");
+ searchbox.addEventListener("input", this._onSearchboxInput, false);
+ searchbox.addEventListener("keypress", this._onSearchboxKeyPress, false);
+
+ container.appendChild(searchbox);
+ ownerNode.insertBefore(container, this._parent);
+ },
+
+ /**
+ * Disables variable and property searching in this view.
+ * Use the "searchEnabled" setter to disable searching.
+ */
+ _disableSearch: function() {
+ // If searching was already disabled, no need to re-disable it again.
+ if (!this._searchboxContainer) {
+ return;
+ }
+ this._searchboxContainer.remove();
+ this._searchboxNode.removeEventListener("input", this._onSearchboxInput, false);
+ this._searchboxNode.removeEventListener("keypress", this._onSearchboxKeyPress, false);
+
+ this._searchboxContainer = null;
+ this._searchboxNode = null;
+ },
+
+ /**
+ * Sets the variables searchbox container hidden or visible.
+ * It's hidden by default.
+ *
+ * @param boolean aVisibleFlag
+ * Specifies the intended visibility.
+ */
+ _toggleSearchVisibility: function(aVisibleFlag) {
+ // If searching was already disabled, there's no need to hide it.
+ if (!this._searchboxContainer) {
+ return;
+ }
+ this._searchboxContainer.hidden = !aVisibleFlag;
+ },
+
+ /**
+ * Listener handling the searchbox input event.
+ */
+ _onSearchboxInput: function() {
+ this.scheduleSearch(this._searchboxNode.value);
+ },
+
+ /**
+ * Listener handling the searchbox key press event.
+ */
+ _onSearchboxKeyPress: function(e) {
+ switch(e.keyCode) {
+ case e.DOM_VK_RETURN:
+ this._onSearchboxInput();
+ return;
+ case e.DOM_VK_ESCAPE:
+ this._searchboxNode.value = "";
+ this._onSearchboxInput();
+ return;
+ }
+ },
+
+ /**
+ * Schedules searching for variables or properties matching the query.
+ *
+ * @param string aToken
+ * The variable or property to search for.
+ * @param number aWait
+ * The amount of milliseconds to wait until draining.
+ */
+ scheduleSearch: function(aToken, aWait) {
+ // Check if this search operation may not be executed lazily.
+ if (!this.lazySearch) {
+ this._doSearch(aToken);
+ return;
+ }
+
+ // The amount of time to wait for the requests to settle.
+ let maxDelay = SEARCH_ACTION_MAX_DELAY;
+ let delay = aWait === undefined ? maxDelay / aToken.length : aWait;
+
+ // Allow requests to settle down first.
+ setNamedTimeout("vview-search", delay, () => this._doSearch(aToken));
+ },
+
+ /**
+ * Performs a case insensitive search for variables or properties matching
+ * the query, and hides non-matched items.
+ *
+ * If aToken is falsy, then all the scopes are unhidden and expanded,
+ * while the available variables and properties inside those scopes are
+ * just unhidden.
+ *
+ * @param string aToken
+ * The variable or property to search for.
+ */
+ _doSearch: function(aToken) {
+ for (let scope of this._store) {
+ switch (aToken) {
+ case "":
+ case null:
+ case undefined:
+ scope.expand();
+ scope._performSearch("");
+ break;
+ default:
+ scope._performSearch(aToken.toLowerCase());
+ break;
+ }
+ }
+ },
+
+ /**
+ * Find the first item in the tree of visible items in this container that
+ * matches the predicate. Searches in visual order (the order seen by the
+ * user). Descends into each scope to check the scope and its children.
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The first visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItems: function(aPredicate) {
+ for (let scope of this._store) {
+ let result = scope._findInVisibleItems(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Find the last item in the tree of visible items in this container that
+ * matches the predicate. Searches in reverse visual order (opposite of the
+ * order seen by the user). Descends into each scope to check the scope and
+ * its children.
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The last visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItemsReverse: function(aPredicate) {
+ for (let i = this._store.length - 1; i >= 0; i--) {
+ let scope = this._store[i];
+ let result = scope._findInVisibleItemsReverse(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Gets the scope at the specified index.
+ *
+ * @param number aIndex
+ * The scope's index.
+ * @return Scope
+ * The scope if found, undefined if not.
+ */
+ getScopeAtIndex: function(aIndex) {
+ return this._store[aIndex];
+ },
+
+ /**
+ * Recursively searches this container for the scope, variable or property
+ * displayed by the specified node.
+ *
+ * @param nsIDOMNode aNode
+ * The node to search for.
+ * @return Scope | Variable | Property
+ * The matched scope, variable or property, or null if nothing is found.
+ */
+ getItemForNode: function(aNode) {
+ return this._itemsByElement.get(aNode);
+ },
+
+ /**
+ * Gets the scope owning a Variable or Property.
+ *
+ * @param Variable | Property
+ * The variable or property to retrieven the owner scope for.
+ * @return Scope
+ * The owner scope.
+ */
+ getOwnerScopeForVariableOrProperty: function(aItem) {
+ if (!aItem) {
+ return null;
+ }
+ // If this is a Scope, return it.
+ if (!(aItem instanceof Variable)) {
+ return aItem;
+ }
+ // If this is a Variable or Property, find its owner scope.
+ if (aItem instanceof Variable && aItem.ownerView) {
+ return this.getOwnerScopeForVariableOrProperty(aItem.ownerView);
+ }
+ return null;
+ },
+
+ /**
+ * Gets the parent scopes for a specified Variable or Property.
+ * The returned list will not include the owner scope.
+ *
+ * @param Variable | Property
+ * The variable or property for which to find the parent scopes.
+ * @return array
+ * A list of parent Scopes.
+ */
+ getParentScopesForVariableOrProperty: function(aItem) {
+ let scope = this.getOwnerScopeForVariableOrProperty(aItem);
+ return this._store.slice(0, Math.max(this._store.indexOf(scope), 0));
+ },
+
+ /**
+ * Gets the currently focused scope, variable or property in this view.
+ *
+ * @return Scope | Variable | Property
+ * The focused scope, variable or property, or null if nothing is found.
+ */
+ getFocusedItem: function() {
+ let focused = this.document.commandDispatcher.focusedElement;
+ return this.getItemForNode(focused);
+ },
+
+ /**
+ * Focuses the first visible scope, variable, or property in this container.
+ */
+ focusFirstVisibleItem: function() {
+ let focusableItem = this._findInVisibleItems(item => item.focusable);
+ if (focusableItem) {
+ this._focusItem(focusableItem);
+ }
+ this._parent.scrollTop = 0;
+ this._parent.scrollLeft = 0;
+ },
+
+ /**
+ * Focuses the last visible scope, variable, or property in this container.
+ */
+ focusLastVisibleItem: function() {
+ let focusableItem = this._findInVisibleItemsReverse(item => item.focusable);
+ if (focusableItem) {
+ this._focusItem(focusableItem);
+ }
+ this._parent.scrollTop = this._parent.scrollHeight;
+ this._parent.scrollLeft = 0;
+ },
+
+ /**
+ * Focuses the next scope, variable or property in this view.
+ */
+ focusNextItem: function() {
+ this.focusItemAtDelta(+1);
+ },
+
+ /**
+ * Focuses the previous scope, variable or property in this view.
+ */
+ focusPrevItem: function() {
+ this.focusItemAtDelta(-1);
+ },
+
+ /**
+ * Focuses another scope, variable or property in this view, based on
+ * the index distance from the currently focused item.
+ *
+ * @param number aDelta
+ * A scalar specifying by how many items should the selection change.
+ */
+ focusItemAtDelta: function(aDelta) {
+ let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus";
+ let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta));
+ while (distance--) {
+ if (!this._focusChange(direction)) {
+ break; // Out of bounds.
+ }
+ }
+ },
+
+ /**
+ * Focuses the next or previous scope, variable or property in this view.
+ *
+ * @param string aDirection
+ * Either "advanceFocus" or "rewindFocus".
+ * @return boolean
+ * False if the focus went out of bounds and the first or last element
+ * in this view was focused instead.
+ */
+ _focusChange: function(aDirection) {
+ let commandDispatcher = this.document.commandDispatcher;
+ let prevFocusedElement = commandDispatcher.focusedElement;
+ let currFocusedItem = null;
+
+ do {
+ commandDispatcher.suppressFocusScroll = true;
+ commandDispatcher[aDirection]();
+
+ // Make sure the newly focused item is a part of this view.
+ // If the focus goes out of bounds, revert the previously focused item.
+ if (!(currFocusedItem = this.getFocusedItem())) {
+ prevFocusedElement.focus();
+ return false;
+ }
+ } while (!currFocusedItem.focusable);
+
+ // Focus remained within bounds.
+ return true;
+ },
+
+ /**
+ * Focuses a scope, variable or property and makes sure it's visible.
+ *
+ * @param aItem Scope | Variable | Property
+ * The item to focus.
+ * @param boolean aCollapseFlag
+ * True if the focused item should also be collapsed.
+ * @return boolean
+ * True if the item was successfully focused.
+ */
+ _focusItem: function(aItem, aCollapseFlag) {
+ if (!aItem.focusable) {
+ return false;
+ }
+ if (aCollapseFlag) {
+ aItem.collapse();
+ }
+ aItem._target.focus();
+ this.boxObject.ensureElementIsVisible(aItem._arrow);
+ return true;
+ },
+
+ /**
+ * Listener handling a key press event on the view.
+ */
+ _onViewKeyPress: function(e) {
+ let item = this.getFocusedItem();
+
+ // Prevent scrolling when pressing navigation keys.
+ ViewHelpers.preventScrolling(e);
+
+ switch (e.keyCode) {
+ case e.DOM_VK_UP:
+ // Always rewind focus.
+ this.focusPrevItem(true);
+ return;
+
+ case e.DOM_VK_DOWN:
+ // Always advance focus.
+ this.focusNextItem(true);
+ return;
+
+ case e.DOM_VK_LEFT:
+ // Collapse scopes, variables and properties before rewinding focus.
+ if (item._isExpanded && item._isArrowVisible) {
+ item.collapse();
+ } else {
+ this._focusItem(item.ownerView);
+ }
+ return;
+
+ case e.DOM_VK_RIGHT:
+ // Nothing to do here if this item never expands.
+ if (!item._isArrowVisible) {
+ return;
+ }
+ // Expand scopes, variables and properties before advancing focus.
+ if (!item._isExpanded) {
+ item.expand();
+ } else {
+ this.focusNextItem(true);
+ }
+ return;
+
+ case e.DOM_VK_PAGE_UP:
+ // Rewind a certain number of elements based on the container height.
+ this.focusItemAtDelta(-(this.scrollPageSize || Math.min(Math.floor(this._list.scrollHeight /
+ PAGE_SIZE_SCROLL_HEIGHT_RATIO),
+ PAGE_SIZE_MAX_JUMPS)));
+ return;
+
+ case e.DOM_VK_PAGE_DOWN:
+ // Advance a certain number of elements based on the container height.
+ this.focusItemAtDelta(+(this.scrollPageSize || Math.min(Math.floor(this._list.scrollHeight /
+ PAGE_SIZE_SCROLL_HEIGHT_RATIO),
+ PAGE_SIZE_MAX_JUMPS)));
+ return;
+
+ case e.DOM_VK_HOME:
+ this.focusFirstVisibleItem();
+ return;
+
+ case e.DOM_VK_END:
+ this.focusLastVisibleItem();
+ return;
+
+ case e.DOM_VK_RETURN:
+ // Start editing the value or name of the Variable or Property.
+ if (item instanceof Variable) {
+ if (e.metaKey || e.altKey || e.shiftKey) {
+ item._activateNameInput();
+ } else {
+ item._activateValueInput();
+ }
+ }
+ return;
+
+ case e.DOM_VK_DELETE:
+ case e.DOM_VK_BACK_SPACE:
+ // Delete the Variable or Property if allowed.
+ if (item instanceof Variable) {
+ item._onDelete(e);
+ }
+ return;
+
+ case e.DOM_VK_INSERT:
+ item._onAddProperty(e);
+ return;
+ }
+ },
+
+ /**
+ * Listener handling a key down event on the view.
+ */
+ _onViewKeyDown: function(e) {
+ if (e.keyCode == e.DOM_VK_C) {
+ // Copy current selection to clipboard.
+ if (e.ctrlKey || e.metaKey) {
+ let item = this.getFocusedItem();
+ clipboardHelper.copyString(
+ item._nameString + item.separatorStr + item._valueString
+ );
+ }
+ }
+ },
+
+ /**
+ * Sets the text displayed in this container when there are no available items.
+ * @param string aValue
+ */
+ set emptyText(aValue) {
+ if (this._emptyTextNode) {
+ this._emptyTextNode.setAttribute("value", aValue);
+ }
+ this._emptyTextValue = aValue;
+ this._appendEmptyNotice();
+ },
+
+ /**
+ * Creates and appends a label signaling that this container is empty.
+ */
+ _appendEmptyNotice: function() {
+ if (this._emptyTextNode || !this._emptyTextValue) {
+ return;
+ }
+
+ let label = this.document.createElement("label");
+ label.className = "variables-view-empty-notice";
+ label.setAttribute("value", this._emptyTextValue);
+
+ this._parent.appendChild(label);
+ this._emptyTextNode = label;
+ },
+
+ /**
+ * Removes the label signaling that this container is empty.
+ */
+ _removeEmptyNotice: function() {
+ if (!this._emptyTextNode) {
+ return;
+ }
+
+ this._parent.removeChild(this._emptyTextNode);
+ this._emptyTextNode = null;
+ },
+
+ /**
+ * Gets if all values should be aligned together.
+ * @return boolean
+ */
+ get alignedValues() {
+ return this._alignedValues;
+ },
+
+ /**
+ * Sets if all values should be aligned together.
+ * @param boolean aFlag
+ */
+ set alignedValues(aFlag) {
+ this._alignedValues = aFlag;
+ if (aFlag) {
+ this._parent.setAttribute("aligned-values", "");
+ } else {
+ this._parent.removeAttribute("aligned-values");
+ }
+ },
+
+ /**
+ * Gets if action buttons (like delete) should be placed at the beginning or
+ * end of a line.
+ * @return boolean
+ */
+ get actionsFirst() {
+ return this._actionsFirst;
+ },
+
+ /**
+ * Sets if action buttons (like delete) should be placed at the beginning or
+ * end of a line.
+ * @param boolean aFlag
+ */
+ set actionsFirst(aFlag) {
+ this._actionsFirst = aFlag;
+ if (aFlag) {
+ this._parent.setAttribute("actions-first", "");
+ } else {
+ this._parent.removeAttribute("actions-first");
+ }
+ },
+
+ /**
+ * Gets the parent node holding this view.
+ * @return nsIDOMNode
+ */
+ get boxObject() this._list.boxObject,
+
+ /**
+ * Gets the parent node holding this view.
+ * @return nsIDOMNode
+ */
+ get parentNode() this._parent,
+
+ /**
+ * Gets the owner document holding this view.
+ * @return nsIHTMLDocument
+ */
+ get document() this._document || (this._document = this._parent.ownerDocument),
+
+ /**
+ * Gets the default window holding this view.
+ * @return nsIDOMWindow
+ */
+ get window() this._window || (this._window = this.document.defaultView),
+
+ _document: null,
+ _window: null,
+
+ _store: null,
+ _itemsByElement: null,
+ _prevHierarchy: null,
+ _currHierarchy: null,
+
+ _enumVisible: true,
+ _nonEnumVisible: true,
+ _alignedValues: false,
+ _actionsFirst: false,
+
+ _parent: null,
+ _list: null,
+ _searchboxNode: null,
+ _searchboxContainer: null,
+ _searchboxPlaceholder: "",
+ _emptyTextNode: null,
+ _emptyTextValue: ""
+};
+
+VariablesView.NON_SORTABLE_CLASSES = [
+ "Array",
+ "Int8Array",
+ "Uint8Array",
+ "Uint8ClampedArray",
+ "Int16Array",
+ "Uint16Array",
+ "Int32Array",
+ "Uint32Array",
+ "Float32Array",
+ "Float64Array",
+ "NodeList"
+];
+
+/**
+ * Determine whether an object's properties should be sorted based on its class.
+ *
+ * @param string aClassName
+ * The class of the object.
+ */
+VariablesView.isSortable = function(aClassName) {
+ return VariablesView.NON_SORTABLE_CLASSES.indexOf(aClassName) == -1;
+};
+
+/**
+ * Generates the string evaluated when performing simple value changes.
+ *
+ * @param Variable | Property aItem
+ * The current variable or property.
+ * @param string aCurrentString
+ * The trimmed user inputted string.
+ * @param string aPrefix [optional]
+ * Prefix for the symbolic name.
+ * @return string
+ * The string to be evaluated.
+ */
+VariablesView.simpleValueEvalMacro = function(aItem, aCurrentString, aPrefix = "") {
+ return aPrefix + aItem.symbolicName + "=" + aCurrentString;
+};
+
+/**
+ * Generates the string evaluated when overriding getters and setters with
+ * plain values.
+ *
+ * @param Property aItem
+ * The current getter or setter property.
+ * @param string aCurrentString
+ * The trimmed user inputted string.
+ * @param string aPrefix [optional]
+ * Prefix for the symbolic name.
+ * @return string
+ * The string to be evaluated.
+ */
+VariablesView.overrideValueEvalMacro = function(aItem, aCurrentString, aPrefix = "") {
+ let property = "\"" + aItem._nameString + "\"";
+ let parent = aPrefix + aItem.ownerView.symbolicName || "this";
+
+ return "Object.defineProperty(" + parent + "," + property + "," +
+ "{ value: " + aCurrentString +
+ ", enumerable: " + parent + ".propertyIsEnumerable(" + property + ")" +
+ ", configurable: true" +
+ ", writable: true" +
+ "})";
+};
+
+/**
+ * Generates the string evaluated when performing getters and setters changes.
+ *
+ * @param Property aItem
+ * The current getter or setter property.
+ * @param string aCurrentString
+ * The trimmed user inputted string.
+ * @param string aPrefix [optional]
+ * Prefix for the symbolic name.
+ * @return string
+ * The string to be evaluated.
+ */
+VariablesView.getterOrSetterEvalMacro = function(aItem, aCurrentString, aPrefix = "") {
+ let type = aItem._nameString;
+ let propertyObject = aItem.ownerView;
+ let parentObject = propertyObject.ownerView;
+ let property = "\"" + propertyObject._nameString + "\"";
+ let parent = aPrefix + parentObject.symbolicName || "this";
+
+ switch (aCurrentString) {
+ case "":
+ case "null":
+ case "undefined":
+ let mirrorType = type == "get" ? "set" : "get";
+ let mirrorLookup = type == "get" ? "__lookupSetter__" : "__lookupGetter__";
+
+ // If the parent object will end up without any getter or setter,
+ // morph it into a plain value.
+ if ((type == "set" && propertyObject.getter.type == "undefined") ||
+ (type == "get" && propertyObject.setter.type == "undefined")) {
+ // Make sure the right getter/setter to value override macro is applied
+ // to the target object.
+ return propertyObject.evaluationMacro(propertyObject, "undefined", aPrefix);
+ }
+
+ // Construct and return the getter/setter removal evaluation string.
+ // e.g: Object.defineProperty(foo, "bar", {
+ // get: foo.__lookupGetter__("bar"),
+ // set: undefined,
+ // enumerable: true,
+ // configurable: true
+ // })
+ return "Object.defineProperty(" + parent + "," + property + "," +
+ "{" + mirrorType + ":" + parent + "." + mirrorLookup + "(" + property + ")" +
+ "," + type + ":" + undefined +
+ ", enumerable: " + parent + ".propertyIsEnumerable(" + property + ")" +
+ ", configurable: true" +
+ "})";
+
+ default:
+ // Wrap statements inside a function declaration if not already wrapped.
+ if (!aCurrentString.startsWith("function")) {
+ let header = "function(" + (type == "set" ? "value" : "") + ")";
+ let body = "";
+ // If there's a return statement explicitly written, always use the
+ // standard function definition syntax
+ if (aCurrentString.includes("return ")) {
+ body = "{" + aCurrentString + "}";
+ }
+ // If block syntax is used, use the whole string as the function body.
+ else if (aCurrentString.startsWith("{")) {
+ body = aCurrentString;
+ }
+ // Prefer an expression closure.
+ else {
+ body = "(" + aCurrentString + ")";
+ }
+ aCurrentString = header + body;
+ }
+
+ // Determine if a new getter or setter should be defined.
+ let defineType = type == "get" ? "__defineGetter__" : "__defineSetter__";
+
+ // Make sure all quotes are escaped in the expression's syntax,
+ let defineFunc = "eval(\"(" + aCurrentString.replace(/"/g, "\\$&") + ")\")";
+
+ // Construct and return the getter/setter evaluation string.
+ // e.g: foo.__defineGetter__("bar", eval("(function() { return 42; })"))
+ return parent + "." + defineType + "(" + property + "," + defineFunc + ")";
+ }
+};
+
+/**
+ * Function invoked when a getter or setter is deleted.
+ *
+ * @param Property aItem
+ * The current getter or setter property.
+ */
+VariablesView.getterOrSetterDeleteCallback = function(aItem) {
+ aItem._disable();
+
+ // Make sure the right getter/setter to value override macro is applied
+ // to the target object.
+ aItem.ownerView.eval(aItem, "");
+
+ return true; // Don't hide the element.
+};
+
+
+/**
+ * A Scope is an object holding Variable instances.
+ * Iterable via "for (let [name, variable] of instance) { }".
+ *
+ * @param VariablesView aView
+ * The view to contain this scope.
+ * @param string aName
+ * The scope's name.
+ * @param object aFlags [optional]
+ * Additional options or flags for this scope.
+ */
+function Scope(aView, aName, aFlags = {}) {
+ this.ownerView = aView;
+
+ this._onClick = this._onClick.bind(this);
+ this._openEnum = this._openEnum.bind(this);
+ this._openNonEnum = this._openNonEnum.bind(this);
+
+ // Inherit properties and flags from the parent view. You can override
+ // each of these directly onto any scope, variable or property instance.
+ this.scrollPageSize = aView.scrollPageSize;
+ this.appendPageSize = aView.appendPageSize;
+ this.eval = aView.eval;
+ this.switch = aView.switch;
+ this.delete = aView.delete;
+ this.new = aView.new;
+ this.preventDisableOnChange = aView.preventDisableOnChange;
+ this.preventDescriptorModifiers = aView.preventDescriptorModifiers;
+ this.editableNameTooltip = aView.editableNameTooltip;
+ this.editableValueTooltip = aView.editableValueTooltip;
+ this.editButtonTooltip = aView.editButtonTooltip;
+ this.deleteButtonTooltip = aView.deleteButtonTooltip;
+ this.domNodeValueTooltip = aView.domNodeValueTooltip;
+ this.contextMenuId = aView.contextMenuId;
+ this.separatorStr = aView.separatorStr;
+
+ this._init(aName.trim(), aFlags);
+}
+
+Scope.prototype = {
+ /**
+ * Whether this Scope should be prefetched when it is remoted.
+ */
+ shouldPrefetch: true,
+
+ /**
+ * Whether this Scope should paginate its contents.
+ */
+ allowPaginate: false,
+
+ /**
+ * The class name applied to this scope's target element.
+ */
+ targetClassName: "variables-view-scope",
+
+ /**
+ * Create a new Variable that is a child of this Scope.
+ *
+ * @param string aName
+ * The name of the new Property.
+ * @param object aDescriptor
+ * The variable's descriptor.
+ * @return Variable
+ * The newly created child Variable.
+ */
+ _createChild: function(aName, aDescriptor) {
+ return new Variable(this, aName, aDescriptor);
+ },
+
+ /**
+ * Adds a child to contain any inspected properties.
+ *
+ * @param string aName
+ * The child's name.
+ * @param object aDescriptor
+ * Specifies the value and/or type & class of the child,
+ * or 'get' & 'set' accessor properties. If the type is implicit,
+ * it will be inferred from the value. If this parameter is omitted,
+ * a property without a value will be added (useful for branch nodes).
+ * e.g. - { value: 42 }
+ * - { value: true }
+ * - { value: "nasu" }
+ * - { value: { type: "undefined" } }
+ * - { value: { type: "null" } }
+ * - { value: { type: "object", class: "Object" } }
+ * - { get: { type: "object", class: "Function" },
+ * set: { type: "undefined" } }
+ * @param boolean aRelaxed [optional]
+ * Pass true if name duplicates should be allowed.
+ * You probably shouldn't do it. Use this with caution.
+ * @return Variable
+ * The newly created Variable instance, null if it already exists.
+ */
+ addItem: function(aName = "", aDescriptor = {}, aRelaxed = false) {
+ if (this._store.has(aName) && !aRelaxed) {
+ return null;
+ }
+
+ let child = this._createChild(aName, aDescriptor);
+ this._store.set(aName, child);
+ this._variablesView._itemsByElement.set(child._target, child);
+ this._variablesView._currHierarchy.set(child.absoluteName, child);
+ child.header = !!aName;
+
+ return child;
+ },
+
+ /**
+ * Adds items for this variable.
+ *
+ * @param object aItems
+ * An object containing some { name: descriptor } data properties,
+ * specifying the value and/or type & class of the variable,
+ * or 'get' & 'set' accessor properties. If the type is implicit,
+ * it will be inferred from the value.
+ * e.g. - { someProp0: { value: 42 },
+ * someProp1: { value: true },
+ * someProp2: { value: "nasu" },
+ * someProp3: { value: { type: "undefined" } },
+ * someProp4: { value: { type: "null" } },
+ * someProp5: { value: { type: "object", class: "Object" } },
+ * someProp6: { get: { type: "object", class: "Function" },
+ * set: { type: "undefined" } } }
+ * @param object aOptions [optional]
+ * Additional options for adding the properties. Supported options:
+ * - sorted: true to sort all the properties before adding them
+ * - callback: function invoked after each item is added
+ * @param string aKeysType [optional]
+ * Helper argument in the case of paginated items. Can be either
+ * "just-strings" or "just-numbers". Humans shouldn't use this argument.
+ */
+ addItems: function(aItems, aOptions = {}, aKeysType = "") {
+ let names = Object.keys(aItems);
+
+ // Building the view when inspecting an object with a very large number of
+ // properties may take a long time. To avoid blocking the UI, group
+ // the items into several lazily populated pseudo-items.
+ let exceedsThreshold = names.length >= this.appendPageSize;
+ let shouldPaginate = exceedsThreshold && aKeysType != "just-strings";
+ if (shouldPaginate && this.allowPaginate) {
+ // Group the items to append into two separate arrays, one containing
+ // number-like keys, the other one containing string keys.
+ if (aKeysType == "just-numbers") {
+ var numberKeys = names;
+ var stringKeys = [];
+ } else {
+ var numberKeys = [];
+ var stringKeys = [];
+ for (let name of names) {
+ // Be very careful. Avoid Infinity, NaN and non Natural number keys.
+ let coerced = +name;
+ if (Number.isInteger(coerced) && coerced > -1) {
+ numberKeys.push(name);
+ } else {
+ stringKeys.push(name);
+ }
+ }
+ }
+
+ // This object contains a very large number of properties, but they're
+ // almost all strings that can't be coerced to numbers. Don't paginate.
+ if (numberKeys.length < this.appendPageSize) {
+ this.addItems(aItems, aOptions, "just-strings");
+ return;
+ }
+
+ // Slices a section of the { name: descriptor } data properties.
+ let paginate = (aArray, aBegin = 0, aEnd = aArray.length) => {
+ let store = {}
+ for (let i = aBegin; i < aEnd; i++) {
+ let name = aArray[i];
+ store[name] = aItems[name];
+ }
+ return store;
+ };
+
+ // Creates a pseudo-item that populates itself with the data properties
+ // from the corresponding page range.
+ let createRangeExpander = (aArray, aBegin, aEnd, aOptions, aKeyTypes) => {
+ let rangeVar = this.addItem(aArray[aBegin] + Scope.ellipsis + aArray[aEnd - 1]);
+ rangeVar.onexpand = () => {
+ let pageItems = paginate(aArray, aBegin, aEnd);
+ rangeVar.addItems(pageItems, aOptions, aKeyTypes);
+ }
+ rangeVar.showArrow();
+ rangeVar.target.setAttribute("pseudo-item", "");
+ };
+
+ // Divide the number keys into quarters.
+ let page = +Math.round(numberKeys.length / 4).toPrecision(1);
+ createRangeExpander(numberKeys, 0, page, aOptions, "just-numbers");
+ createRangeExpander(numberKeys, page, page * 2, aOptions, "just-numbers");
+ createRangeExpander(numberKeys, page * 2, page * 3, aOptions, "just-numbers");
+ createRangeExpander(numberKeys, page * 3, numberKeys.length, aOptions, "just-numbers");
+
+ // Append all the string keys together.
+ this.addItems(paginate(stringKeys), aOptions, "just-strings");
+ return;
+ }
+
+ // Sort all of the properties before adding them, if preferred.
+ if (aOptions.sorted && aKeysType != "just-numbers") {
+ names.sort();
+ }
+
+ // Add the properties to the current scope.
+ for (let name of names) {
+ let descriptor = aItems[name];
+ let item = this.addItem(name, descriptor);
+
+ if (aOptions.callback) {
+ aOptions.callback(item, descriptor.value);
+ }
+ }
+ },
+
+ /**
+ * Remove this Scope from its parent and remove all children recursively.
+ */
+ remove: function() {
+ let view = this._variablesView;
+ view._store.splice(view._store.indexOf(this), 1);
+ view._itemsByElement.delete(this._target);
+ view._currHierarchy.delete(this._nameString);
+
+ this._target.remove();
+
+ for (let variable of this._store.values()) {
+ variable.remove();
+ }
+ },
+
+ /**
+ * Gets the variable in this container having the specified name.
+ *
+ * @param string aName
+ * The name of the variable to get.
+ * @return Variable
+ * The matched variable, or null if nothing is found.
+ */
+ get: function(aName) {
+ return this._store.get(aName);
+ },
+
+ /**
+ * Recursively searches for the variable or property in this container
+ * displayed by the specified node.
+ *
+ * @param nsIDOMNode aNode
+ * The node to search for.
+ * @return Variable | Property
+ * The matched variable or property, or null if nothing is found.
+ */
+ find: function(aNode) {
+ for (let [, variable] of this._store) {
+ let match;
+ if (variable._target == aNode) {
+ match = variable;
+ } else {
+ match = variable.find(aNode);
+ }
+ if (match) {
+ return match;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Determines if this scope is a direct child of a parent variables view,
+ * scope, variable or property.
+ *
+ * @param VariablesView | Scope | Variable | Property
+ * The parent to check.
+ * @return boolean
+ * True if the specified item is a direct child, false otherwise.
+ */
+ isChildOf: function(aParent) {
+ return this.ownerView == aParent;
+ },
+
+ /**
+ * Determines if this scope is a descendant of a parent variables view,
+ * scope, variable or property.
+ *
+ * @param VariablesView | Scope | Variable | Property
+ * The parent to check.
+ * @return boolean
+ * True if the specified item is a descendant, false otherwise.
+ */
+ isDescendantOf: function(aParent) {
+ if (this.isChildOf(aParent)) {
+ return true;
+ }
+
+ // Recurse to parent if it is a Scope, Variable, or Property.
+ if (this.ownerView instanceof Scope) {
+ return this.ownerView.isDescendantOf(aParent);
+ }
+
+ return false;
+ },
+
+ /**
+ * Shows the scope.
+ */
+ show: function() {
+ this._target.hidden = false;
+ this._isContentVisible = true;
+
+ if (this.onshow) {
+ this.onshow(this);
+ }
+ },
+
+ /**
+ * Hides the scope.
+ */
+ hide: function() {
+ this._target.hidden = true;
+ this._isContentVisible = false;
+
+ if (this.onhide) {
+ this.onhide(this);
+ }
+ },
+
+ /**
+ * Expands the scope, showing all the added details.
+ */
+ expand: function() {
+ if (this._isExpanded || this._isLocked) {
+ return;
+ }
+ if (this._variablesView._enumVisible) {
+ this._openEnum();
+ }
+ if (this._variablesView._nonEnumVisible) {
+ Services.tm.currentThread.dispatch({ run: this._openNonEnum }, 0);
+ }
+ this._isExpanded = true;
+
+ if (this.onexpand) {
+ this.onexpand(this);
+ }
+ },
+
+ /**
+ * Collapses the scope, hiding all the added details.
+ */
+ collapse: function() {
+ if (!this._isExpanded || this._isLocked) {
+ return;
+ }
+ this._arrow.removeAttribute("open");
+ this._enum.removeAttribute("open");
+ this._nonenum.removeAttribute("open");
+ this._isExpanded = false;
+
+ if (this.oncollapse) {
+ this.oncollapse(this);
+ }
+ },
+
+ /**
+ * Toggles between the scope's collapsed and expanded state.
+ */
+ toggle: function(e) {
+ if (e && e.button != 0) {
+ // Only allow left-click to trigger this event.
+ return;
+ }
+ this.expanded ^= 1;
+
+ // Make sure the scope and its contents are visibile.
+ for (let [, variable] of this._store) {
+ variable.header = true;
+ variable._matched = true;
+ }
+ if (this.ontoggle) {
+ this.ontoggle(this);
+ }
+ },
+
+ /**
+ * Shows the scope's title header.
+ */
+ showHeader: function() {
+ if (this._isHeaderVisible || !this._nameString) {
+ return;
+ }
+ this._target.removeAttribute("untitled");
+ this._isHeaderVisible = true;
+ },
+
+ /**
+ * Hides the scope's title header.
+ * This action will automatically expand the scope.
+ */
+ hideHeader: function() {
+ if (!this._isHeaderVisible) {
+ return;
+ }
+ this.expand();
+ this._target.setAttribute("untitled", "");
+ this._isHeaderVisible = false;
+ },
+
+ /**
+ * Shows the scope's expand/collapse arrow.
+ */
+ showArrow: function() {
+ if (this._isArrowVisible) {
+ return;
+ }
+ this._arrow.removeAttribute("invisible");
+ this._isArrowVisible = true;
+ },
+
+ /**
+ * Hides the scope's expand/collapse arrow.
+ */
+ hideArrow: function() {
+ if (!this._isArrowVisible) {
+ return;
+ }
+ this._arrow.setAttribute("invisible", "");
+ this._isArrowVisible = false;
+ },
+
+ /**
+ * Gets the visibility state.
+ * @return boolean
+ */
+ get visible() this._isContentVisible,
+
+ /**
+ * Gets the expanded state.
+ * @return boolean
+ */
+ get expanded() this._isExpanded,
+
+ /**
+ * Gets the header visibility state.
+ * @return boolean
+ */
+ get header() this._isHeaderVisible,
+
+ /**
+ * Gets the twisty visibility state.
+ * @return boolean
+ */
+ get twisty() this._isArrowVisible,
+
+ /**
+ * Gets the expand lock state.
+ * @return boolean
+ */
+ get locked() this._isLocked,
+
+ /**
+ * Sets the visibility state.
+ * @param boolean aFlag
+ */
+ set visible(aFlag) aFlag ? this.show() : this.hide(),
+
+ /**
+ * Sets the expanded state.
+ * @param boolean aFlag
+ */
+ set expanded(aFlag) aFlag ? this.expand() : this.collapse(),
+
+ /**
+ * Sets the header visibility state.
+ * @param boolean aFlag
+ */
+ set header(aFlag) aFlag ? this.showHeader() : this.hideHeader(),
+
+ /**
+ * Sets the twisty visibility state.
+ * @param boolean aFlag
+ */
+ set twisty(aFlag) aFlag ? this.showArrow() : this.hideArrow(),
+
+ /**
+ * Sets the expand lock state.
+ * @param boolean aFlag
+ */
+ set locked(aFlag) this._isLocked = aFlag,
+
+ /**
+ * Specifies if this target node may be focused.
+ * @return boolean
+ */
+ get focusable() {
+ // Check if this target node is actually visibile.
+ if (!this._nameString ||
+ !this._isContentVisible ||
+ !this._isHeaderVisible ||
+ !this._isMatch) {
+ return false;
+ }
+ // Check if all parent objects are expanded.
+ let item = this;
+
+ // Recurse while parent is a Scope, Variable, or Property
+ while ((item = item.ownerView) && item instanceof Scope) {
+ if (!item._isExpanded) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Focus this scope.
+ */
+ focus: function() {
+ this._variablesView._focusItem(this);
+ },
+
+ /**
+ * Adds an event listener for a certain event on this scope's title.
+ * @param string aName
+ * @param function aCallback
+ * @param boolean aCapture
+ */
+ addEventListener: function(aName, aCallback, aCapture) {
+ this._title.addEventListener(aName, aCallback, aCapture);
+ },
+
+ /**
+ * Removes an event listener for a certain event on this scope's title.
+ * @param string aName
+ * @param function aCallback
+ * @param boolean aCapture
+ */
+ removeEventListener: function(aName, aCallback, aCapture) {
+ this._title.removeEventListener(aName, aCallback, aCapture);
+ },
+
+ /**
+ * Gets the id associated with this item.
+ * @return string
+ */
+ get id() this._idString,
+
+ /**
+ * Gets the name associated with this item.
+ * @return string
+ */
+ get name() this._nameString,
+
+ /**
+ * Gets the displayed value for this item.
+ * @return string
+ */
+ get displayValue() this._valueString,
+
+ /**
+ * Gets the class names used for the displayed value.
+ * @return string
+ */
+ get displayValueClassName() this._valueClassName,
+
+ /**
+ * Gets the element associated with this item.
+ * @return nsIDOMNode
+ */
+ get target() this._target,
+
+ /**
+ * Initializes this scope's id, view and binds event listeners.
+ *
+ * @param string aName
+ * The scope's name.
+ * @param object aFlags [optional]
+ * Additional options or flags for this scope.
+ */
+ _init: function(aName, aFlags) {
+ this._idString = generateId(this._nameString = aName);
+ this._displayScope(aName, this.targetClassName, "devtools-toolbar");
+ this._addEventListeners();
+ this.parentNode.appendChild(this._target);
+ },
+
+ /**
+ * Creates the necessary nodes for this scope.
+ *
+ * @param string aName
+ * The scope's name.
+ * @param string aTargetClassName
+ * A custom class name for this scope's target element.
+ * @param string aTitleClassName [optional]
+ * A custom class name for this scope's title element.
+ */
+ _displayScope: function(aName, aTargetClassName, aTitleClassName = "") {
+ let document = this.document;
+
+ let element = this._target = document.createElement("vbox");
+ element.id = this._idString;
+ element.className = aTargetClassName;
+
+ let arrow = this._arrow = document.createElement("hbox");
+ arrow.className = "arrow theme-twisty";
+
+ let name = this._name = document.createElement("label");
+ name.className = "plain name";
+ name.setAttribute("value", aName);
+
+ let title = this._title = document.createElement("hbox");
+ title.className = "title " + aTitleClassName;
+ title.setAttribute("align", "center");
+
+ let enumerable = this._enum = document.createElement("vbox");
+ let nonenum = this._nonenum = document.createElement("vbox");
+ enumerable.className = "variables-view-element-details enum";
+ nonenum.className = "variables-view-element-details nonenum";
+
+ title.appendChild(arrow);
+ title.appendChild(name);
+
+ element.appendChild(title);
+ element.appendChild(enumerable);
+ element.appendChild(nonenum);
+ },
+
+ /**
+ * Adds the necessary event listeners for this scope.
+ */
+ _addEventListeners: function() {
+ this._title.addEventListener("mousedown", this._onClick, false);
+ },
+
+ /**
+ * The click listener for this scope's title.
+ */
+ _onClick: function(e) {
+ if (this.editing ||
+ e.button != 0 ||
+ e.target == this._editNode ||
+ e.target == this._deleteNode ||
+ e.target == this._addPropertyNode) {
+ return;
+ }
+ this.toggle();
+ this.focus();
+ },
+
+ /**
+ * Opens the enumerable items container.
+ */
+ _openEnum: function() {
+ this._arrow.setAttribute("open", "");
+ this._enum.setAttribute("open", "");
+ },
+
+ /**
+ * Opens the non-enumerable items container.
+ */
+ _openNonEnum: function() {
+ this._nonenum.setAttribute("open", "");
+ },
+
+ /**
+ * Specifies if enumerable properties and variables should be displayed.
+ * @param boolean aFlag
+ */
+ set _enumVisible(aFlag) {
+ for (let [, variable] of this._store) {
+ variable._enumVisible = aFlag;
+
+ if (!this._isExpanded) {
+ continue;
+ }
+ if (aFlag) {
+ this._enum.setAttribute("open", "");
+ } else {
+ this._enum.removeAttribute("open");
+ }
+ }
+ },
+
+ /**
+ * Specifies if non-enumerable properties and variables should be displayed.
+ * @param boolean aFlag
+ */
+ set _nonEnumVisible(aFlag) {
+ for (let [, variable] of this._store) {
+ variable._nonEnumVisible = aFlag;
+
+ if (!this._isExpanded) {
+ continue;
+ }
+ if (aFlag) {
+ this._nonenum.setAttribute("open", "");
+ } else {
+ this._nonenum.removeAttribute("open");
+ }
+ }
+ },
+
+ /**
+ * Performs a case insensitive search for variables or properties matching
+ * the query, and hides non-matched items.
+ *
+ * @param string aLowerCaseQuery
+ * The lowercased name of the variable or property to search for.
+ */
+ _performSearch: function(aLowerCaseQuery) {
+ for (let [, variable] of this._store) {
+ let currentObject = variable;
+ let lowerCaseName = variable._nameString.toLowerCase();
+ let lowerCaseValue = variable._valueString.toLowerCase();
+
+ // Non-matched variables or properties require a corresponding attribute.
+ if (!lowerCaseName.includes(aLowerCaseQuery) &&
+ !lowerCaseValue.includes(aLowerCaseQuery)) {
+ variable._matched = false;
+ }
+ // Variable or property is matched.
+ else {
+ variable._matched = true;
+
+ // If the variable was ever expanded, there's a possibility it may
+ // contain some matched properties, so make sure they're visible
+ // ("expand downwards").
+ if (variable._store.size) {
+ variable.expand();
+ }
+
+ // If the variable is contained in another Scope, Variable, or Property,
+ // the parent may not be a match, thus hidden. It should be visible
+ // ("expand upwards").
+ while ((variable = variable.ownerView) && variable instanceof Scope) {
+ variable._matched = true;
+ variable.expand();
+ }
+ }
+
+ // Proceed with the search recursively inside this variable or property.
+ if (currentObject._store.size || currentObject.getter || currentObject.setter) {
+ currentObject._performSearch(aLowerCaseQuery);
+ }
+ }
+ },
+
+ /**
+ * Sets if this object instance is a matched or non-matched item.
+ * @param boolean aStatus
+ */
+ set _matched(aStatus) {
+ if (this._isMatch == aStatus) {
+ return;
+ }
+ if (aStatus) {
+ this._isMatch = true;
+ this.target.removeAttribute("unmatched");
+ } else {
+ this._isMatch = false;
+ this.target.setAttribute("unmatched", "");
+ }
+ },
+
+ /**
+ * Find the first item in the tree of visible items in this item that matches
+ * the predicate. Searches in visual order (the order seen by the user).
+ * Tests itself, then descends into first the enumerable children and then
+ * the non-enumerable children (since they are presented in separate groups).
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The first visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItems: function(aPredicate) {
+ if (aPredicate(this)) {
+ return this;
+ }
+
+ if (this._isExpanded) {
+ if (this._variablesView._enumVisible) {
+ for (let item of this._enumItems) {
+ let result = item._findInVisibleItems(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+
+ if (this._variablesView._nonEnumVisible) {
+ for (let item of this._nonEnumItems) {
+ let result = item._findInVisibleItems(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Find the last item in the tree of visible items in this item that matches
+ * the predicate. Searches in reverse visual order (opposite of the order
+ * seen by the user). Descends into first the non-enumerable children, then
+ * the enumerable children (since they are presented in separate groups), and
+ * finally tests itself.
+ *
+ * @param function aPredicate
+ * A function that returns true when a match is found.
+ * @return Scope | Variable | Property
+ * The last visible scope, variable or property, or null if nothing
+ * is found.
+ */
+ _findInVisibleItemsReverse: function(aPredicate) {
+ if (this._isExpanded) {
+ if (this._variablesView._nonEnumVisible) {
+ for (let i = this._nonEnumItems.length - 1; i >= 0; i--) {
+ let item = this._nonEnumItems[i];
+ let result = item._findInVisibleItemsReverse(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+
+ if (this._variablesView._enumVisible) {
+ for (let i = this._enumItems.length - 1; i >= 0; i--) {
+ let item = this._enumItems[i];
+ let result = item._findInVisibleItemsReverse(aPredicate);
+ if (result) {
+ return result;
+ }
+ }
+ }
+ }
+
+ if (aPredicate(this)) {
+ return this;
+ }
+
+ return null;
+ },
+
+ /**
+ * Gets top level variables view instance.
+ * @return VariablesView
+ */
+ get _variablesView() this._topView || (this._topView = (function(self) {
+ let parentView = self.ownerView;
+ let topView;
+
+ while ((topView = parentView.ownerView)) {
+ parentView = topView;
+ }
+ return parentView;
+ })(this)),
+
+ /**
+ * Gets the parent node holding this scope.
+ * @return nsIDOMNode
+ */
+ get parentNode() this.ownerView._list,
+
+ /**
+ * Gets the owner document holding this scope.
+ * @return nsIHTMLDocument
+ */
+ get document() this._document || (this._document = this.ownerView.document),
+
+ /**
+ * Gets the default window holding this scope.
+ * @return nsIDOMWindow
+ */
+ get window() this._window || (this._window = this.ownerView.window),
+
+ _topView: null,
+ _document: null,
+ _window: null,
+
+ ownerView: null,
+ eval: null,
+ switch: null,
+ delete: null,
+ new: null,
+ preventDisableOnChange: false,
+ preventDescriptorModifiers: false,
+ editing: false,
+ editableNameTooltip: "",
+ editableValueTooltip: "",
+ editButtonTooltip: "",
+ deleteButtonTooltip: "",
+ domNodeValueTooltip: "",
+ contextMenuId: "",
+ separatorStr: "",
+
+ _store: null,
+ _enumItems: null,
+ _nonEnumItems: null,
+ _fetched: false,
+ _committed: false,
+ _isLocked: false,
+ _isExpanded: false,
+ _isContentVisible: true,
+ _isHeaderVisible: true,
+ _isArrowVisible: true,
+ _isMatch: true,
+ _idString: "",
+ _nameString: "",
+ _target: null,
+ _arrow: null,
+ _name: null,
+ _title: null,
+ _enum: null,
+ _nonenum: null,
+};
+
+// Creating maps and arrays thousands of times for variables or properties
+// with a large number of children fills up a lot of memory. Make sure
+// these are instantiated only if needed.
+DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_store", () => new Map());
+DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_enumItems", Array);
+DevToolsUtils.defineLazyPrototypeGetter(Scope.prototype, "_nonEnumItems", Array);
+
+// An ellipsis symbol (usually "…") used for localization.
+XPCOMUtils.defineLazyGetter(Scope, "ellipsis", () =>
+ Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data);
+
+/**
+ * A Variable is a Scope holding Property instances.
+ * Iterable via "for (let [name, property] of instance) { }".
+ *
+ * @param Scope aScope
+ * The scope to contain this variable.
+ * @param string aName
+ * The variable's name.
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+function Variable(aScope, aName, aDescriptor) {
+ this._setTooltips = this._setTooltips.bind(this);
+ this._activateNameInput = this._activateNameInput.bind(this);
+ this._activateValueInput = this._activateValueInput.bind(this);
+ this.openNodeInInspector = this.openNodeInInspector.bind(this);
+ this.highlightDomNode = this.highlightDomNode.bind(this);
+ this.unhighlightDomNode = this.unhighlightDomNode.bind(this);
+
+ // Treat safe getter descriptors as descriptors with a value.
+ if ("getterValue" in aDescriptor) {
+ aDescriptor.value = aDescriptor.getterValue;
+ delete aDescriptor.get;
+ delete aDescriptor.set;
+ }
+
+ Scope.call(this, aScope, aName, this._initialDescriptor = aDescriptor);
+ this.setGrip(aDescriptor.value);
+}
+
+Variable.prototype = Heritage.extend(Scope.prototype, {
+ /**
+ * Whether this Variable should be prefetched when it is remoted.
+ */
+ get shouldPrefetch() {
+ return this.name == "window" || this.name == "this";
+ },
+
+ /**
+ * Whether this Variable should paginate its contents.
+ */
+ get allowPaginate() {
+ return this.name != "window" && this.name != "this";
+ },
+
+ /**
+ * The class name applied to this variable's target element.
+ */
+ targetClassName: "variables-view-variable variable-or-property",
+
+ /**
+ * Create a new Property that is a child of Variable.
+ *
+ * @param string aName
+ * The name of the new Property.
+ * @param object aDescriptor
+ * The property's descriptor.
+ * @return Property
+ * The newly created child Property.
+ */
+ _createChild: function(aName, aDescriptor) {
+ return new Property(this, aName, aDescriptor);
+ },
+
+ /**
+ * Remove this Variable from its parent and remove all children recursively.
+ */
+ remove: function() {
+ if (this._linkedToInspector) {
+ this.unhighlightDomNode();
+ this._valueLabel.removeEventListener("mouseover", this.highlightDomNode, false);
+ this._valueLabel.removeEventListener("mouseout", this.unhighlightDomNode, false);
+ this._openInspectorNode.removeEventListener("mousedown", this.openNodeInInspector, false);
+ }
+
+ this.ownerView._store.delete(this._nameString);
+ this._variablesView._itemsByElement.delete(this._target);
+ this._variablesView._currHierarchy.delete(this.absoluteName);
+
+ this._target.remove();
+
+ for (let property of this._store.values()) {
+ property.remove();
+ }
+ },
+
+ /**
+ * Populates this variable to contain all the properties of an object.
+ *
+ * @param object aObject
+ * The raw object you want to display.
+ * @param object aOptions [optional]
+ * Additional options for adding the properties. Supported options:
+ * - sorted: true to sort all the properties before adding them
+ * - expanded: true to expand all the properties after adding them
+ */
+ populate: function(aObject, aOptions = {}) {
+ // Retrieve the properties only once.
+ if (this._fetched) {
+ return;
+ }
+ this._fetched = true;
+
+ let propertyNames = Object.getOwnPropertyNames(aObject);
+ let prototype = Object.getPrototypeOf(aObject);
+
+ // Sort all of the properties before adding them, if preferred.
+ if (aOptions.sorted) {
+ propertyNames.sort();
+ }
+ // Add all the variable properties.
+ for (let name of propertyNames) {
+ let descriptor = Object.getOwnPropertyDescriptor(aObject, name);
+ if (descriptor.get || descriptor.set) {
+ let prop = this._addRawNonValueProperty(name, descriptor);
+ if (aOptions.expanded) {
+ prop.expanded = true;
+ }
+ } else {
+ let prop = this._addRawValueProperty(name, descriptor, aObject[name]);
+ if (aOptions.expanded) {
+ prop.expanded = true;
+ }
+ }
+ }
+ // Add the variable's __proto__.
+ if (prototype) {
+ this._addRawValueProperty("__proto__", {}, prototype);
+ }
+ },
+
+ /**
+ * Populates a specific variable or property instance to contain all the
+ * properties of an object
+ *
+ * @param Variable | Property aVar
+ * The target variable to populate.
+ * @param object aObject [optional]
+ * The raw object you want to display. If unspecified, the object is
+ * assumed to be defined in a _sourceValue property on the target.
+ */
+ _populateTarget: function(aVar, aObject = aVar._sourceValue) {
+ aVar.populate(aObject);
+ },
+
+ /**
+ * Adds a property for this variable based on a raw value descriptor.
+ *
+ * @param string aName
+ * The property's name.
+ * @param object aDescriptor
+ * Specifies the exact property descriptor as returned by a call to
+ * Object.getOwnPropertyDescriptor.
+ * @param object aValue
+ * The raw property value you want to display.
+ * @return Property
+ * The newly added property instance.
+ */
+ _addRawValueProperty: function(aName, aDescriptor, aValue) {
+ let descriptor = Object.create(aDescriptor);
+ descriptor.value = VariablesView.getGrip(aValue);
+
+ let propertyItem = this.addItem(aName, descriptor);
+ propertyItem._sourceValue = aValue;
+
+ // Add an 'onexpand' callback for the property, lazily handling
+ // the addition of new child properties.
+ if (!VariablesView.isPrimitive(descriptor)) {
+ propertyItem.onexpand = this._populateTarget;
+ }
+ return propertyItem;
+ },
+
+ /**
+ * Adds a property for this variable based on a getter/setter descriptor.
+ *
+ * @param string aName
+ * The property's name.
+ * @param object aDescriptor
+ * Specifies the exact property descriptor as returned by a call to
+ * Object.getOwnPropertyDescriptor.
+ * @return Property
+ * The newly added property instance.
+ */
+ _addRawNonValueProperty: function(aName, aDescriptor) {
+ let descriptor = Object.create(aDescriptor);
+ descriptor.get = VariablesView.getGrip(aDescriptor.get);
+ descriptor.set = VariablesView.getGrip(aDescriptor.set);
+
+ return this.addItem(aName, descriptor);
+ },
+
+ /**
+ * Gets this variable's path to the topmost scope in the form of a string
+ * meant for use via eval() or a similar approach.
+ * For example, a symbolic name may look like "arguments['0']['foo']['bar']".
+ * @return string
+ */
+ get symbolicName() {
+ return this._nameString;
+ },
+
+ /**
+ * Gets full path to this variable, including name of the scope.
+ * @return string
+ */
+ get absoluteName() {
+ if (this._absoluteName) {
+ return this._absoluteName;
+ }
+
+ this._absoluteName = this.ownerView._nameString + "[\"" + this._nameString + "\"]";
+ return this._absoluteName;
+ },
+
+ /**
+ * Gets this variable's symbolic path to the topmost scope.
+ * @return array
+ * @see Variable._buildSymbolicPath
+ */
+ get symbolicPath() {
+ if (this._symbolicPath) {
+ return this._symbolicPath;
+ }
+ this._symbolicPath = this._buildSymbolicPath();
+ return this._symbolicPath;
+ },
+
+ /**
+ * Build this variable's path to the topmost scope in form of an array of
+ * strings, one for each segment of the path.
+ * For example, a symbolic path may look like ["0", "foo", "bar"].
+ * @return array
+ */
+ _buildSymbolicPath: function(path = []) {
+ if (this.name) {
+ path.unshift(this.name);
+ if (this.ownerView instanceof Variable) {
+ return this.ownerView._buildSymbolicPath(path);
+ }
+ }
+ return path;
+ },
+
+ /**
+ * Returns this variable's value from the descriptor if available.
+ * @return any
+ */
+ get value() this._initialDescriptor.value,
+
+ /**
+ * Returns this variable's getter from the descriptor if available.
+ * @return object
+ */
+ get getter() this._initialDescriptor.get,
+
+ /**
+ * Returns this variable's getter from the descriptor if available.
+ * @return object
+ */
+ get setter() this._initialDescriptor.set,
+
+ /**
+ * Sets the specific grip for this variable (applies the text content and
+ * class name to the value label).
+ *
+ * The grip should contain the value or the type & class, as defined in the
+ * remote debugger protocol. For convenience, undefined and null are
+ * both considered types.
+ *
+ * @param any aGrip
+ * Specifies the value and/or type & class of the variable.
+ * e.g. - 42
+ * - true
+ * - "nasu"
+ * - { type: "undefined" }
+ * - { type: "null" }
+ * - { type: "object", class: "Object" }
+ */
+ setGrip: function(aGrip) {
+ // Don't allow displaying grip information if there's no name available
+ // or the grip is malformed.
+ if (!this._nameString || aGrip === undefined || aGrip === null) {
+ return;
+ }
+ // Getters and setters should display grip information in sub-properties.
+ if (this.getter || this.setter) {
+ return;
+ }
+
+ let prevGrip = this._valueGrip;
+ if (prevGrip) {
+ this._valueLabel.classList.remove(VariablesView.getClass(prevGrip));
+ }
+ this._valueGrip = aGrip;
+
+ if(aGrip && (aGrip.optimizedOut || aGrip.uninitialized || aGrip.missingArguments)) {
+ if(aGrip.optimizedOut) {
+ this._valueString = STR.GetStringFromName("variablesViewOptimizedOut")
+ }
+ else if(aGrip.uninitialized) {
+ this._valueString = STR.GetStringFromName("variablesViewUninitialized")
+ }
+ else if(aGrip.missingArguments) {
+ this._valueString = STR.GetStringFromName("variablesViewMissingArgs")
+ }
+ this.eval = null;
+ }
+ else {
+ this._valueString = VariablesView.getString(aGrip, {
+ concise: true,
+ noEllipsis: true,
+ });
+ this.eval = this.ownerView.eval;
+ }
+
+ this._valueClassName = VariablesView.getClass(aGrip);
+
+ this._valueLabel.classList.add(this._valueClassName);
+ this._valueLabel.setAttribute("value", this._valueString);
+ this._separatorLabel.hidden = false;
+
+ // DOMNodes get special treatment since they can be linked to the inspector
+ if (this._valueGrip.preview && this._valueGrip.preview.kind === "DOMNode") {
+ this._linkToInspector();
+ }
+ },
+
+ /**
+ * Marks this variable as overridden.
+ *
+ * @param boolean aFlag
+ * Whether this variable is overridden or not.
+ */
+ setOverridden: function(aFlag) {
+ if (aFlag) {
+ this._target.setAttribute("overridden", "");
+ } else {
+ this._target.removeAttribute("overridden");
+ }
+ },
+
+ /**
+ * Briefly flashes this variable.
+ *
+ * @param number aDuration [optional]
+ * An optional flash animation duration.
+ */
+ flash: function(aDuration = ITEM_FLASH_DURATION) {
+ let fadeInDelay = this._variablesView.lazyEmptyDelay + 1;
+ let fadeOutDelay = fadeInDelay + aDuration;
+
+ setNamedTimeout("vview-flash-in" + this.absoluteName,
+ fadeInDelay, () => this._target.setAttribute("changed", ""));
+
+ setNamedTimeout("vview-flash-out" + this.absoluteName,
+ fadeOutDelay, () => this._target.removeAttribute("changed"));
+ },
+
+ /**
+ * Initializes this variable's id, view and binds event listeners.
+ *
+ * @param string aName
+ * The variable's name.
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+ _init: function(aName, aDescriptor) {
+ this._idString = generateId(this._nameString = aName);
+ this._displayScope(aName, this.targetClassName);
+ this._displayVariable();
+ this._customizeVariable();
+ this._prepareTooltips();
+ this._setAttributes();
+ this._addEventListeners();
+
+ if (this._initialDescriptor.enumerable ||
+ this._nameString == "this" ||
+ this._nameString == "<return>" ||
+ this._nameString == "<exception>") {
+ this.ownerView._enum.appendChild(this._target);
+ this.ownerView._enumItems.push(this);
+ } else {
+ this.ownerView._nonenum.appendChild(this._target);
+ this.ownerView._nonEnumItems.push(this);
+ }
+ },
+
+ /**
+ * Creates the necessary nodes for this variable.
+ */
+ _displayVariable: function() {
+ let document = this.document;
+ let descriptor = this._initialDescriptor;
+
+ let separatorLabel = this._separatorLabel = document.createElement("label");
+ separatorLabel.className = "plain separator";
+ separatorLabel.setAttribute("value", this.separatorStr + " ");
+
+ let valueLabel = this._valueLabel = document.createElement("label");
+ valueLabel.className = "plain value";
+ valueLabel.setAttribute("flex", "1");
+ valueLabel.setAttribute("crop", "center");
+
+ this._title.appendChild(separatorLabel);
+ this._title.appendChild(valueLabel);
+
+ if (VariablesView.isPrimitive(descriptor)) {
+ this.hideArrow();
+ }
+
+ // If no value will be displayed, we don't need the separator.
+ if (!descriptor.get && !descriptor.set && !("value" in descriptor)) {
+ separatorLabel.hidden = true;
+ }
+
+ // If this is a getter/setter property, create two child pseudo-properties
+ // called "get" and "set" that display the corresponding functions.
+ if (descriptor.get || descriptor.set) {
+ separatorLabel.hidden = true;
+ valueLabel.hidden = true;
+
+ // Changing getter/setter names is never allowed.
+ this.switch = null;
+
+ // Getter/setter properties require special handling when it comes to
+ // evaluation and deletion.
+ if (this.ownerView.eval) {
+ this.delete = VariablesView.getterOrSetterDeleteCallback;
+ this.evaluationMacro = VariablesView.overrideValueEvalMacro;
+ }
+ // Deleting getters and setters individually is not allowed if no
+ // evaluation method is provided.
+ else {
+ this.delete = null;
+ this.evaluationMacro = null;
+ }
+
+ let getter = this.addItem("get", { value: descriptor.get });
+ let setter = this.addItem("set", { value: descriptor.set });
+ getter.evaluationMacro = VariablesView.getterOrSetterEvalMacro;
+ setter.evaluationMacro = VariablesView.getterOrSetterEvalMacro;
+
+ getter.hideArrow();
+ setter.hideArrow();
+ this.expand();
+ }
+ },
+
+ /**
+ * Adds specific nodes for this variable based on custom flags.
+ */
+ _customizeVariable: function() {
+ let ownerView = this.ownerView;
+ let descriptor = this._initialDescriptor;
+
+ if (ownerView.eval && this.getter || this.setter) {
+ let editNode = this._editNode = this.document.createElement("toolbarbutton");
+ editNode.className = "plain variables-view-edit";
+ editNode.addEventListener("mousedown", this._onEdit.bind(this), false);
+ this._title.insertBefore(editNode, this._spacer);
+ }
+
+ if (ownerView.delete) {
+ let deleteNode = this._deleteNode = this.document.createElement("toolbarbutton");
+ deleteNode.className = "plain variables-view-delete";
+ deleteNode.addEventListener("click", this._onDelete.bind(this), false);
+ this._title.appendChild(deleteNode);
+ }
+
+ if (ownerView.new) {
+ let addPropertyNode = this._addPropertyNode = this.document.createElement("toolbarbutton");
+ addPropertyNode.className = "plain variables-view-add-property";
+ addPropertyNode.addEventListener("mousedown", this._onAddProperty.bind(this), false);
+ this._title.appendChild(addPropertyNode);
+
+ // Can't add properties to primitive values, hide the node in those cases.
+ if (VariablesView.isPrimitive(descriptor)) {
+ addPropertyNode.setAttribute("invisible", "");
+ }
+ }
+
+ if (ownerView.contextMenuId) {
+ this._title.setAttribute("context", ownerView.contextMenuId);
+ }
+
+ if (ownerView.preventDescriptorModifiers) {
+ return;
+ }
+
+ if (!descriptor.writable && !ownerView.getter && !ownerView.setter) {
+ let nonWritableIcon = this.document.createElement("hbox");
+ nonWritableIcon.className = "plain variable-or-property-non-writable-icon";
+ nonWritableIcon.setAttribute("optional-visibility", "");
+ this._title.appendChild(nonWritableIcon);
+ }
+ if (descriptor.value && typeof descriptor.value == "object") {
+ if (descriptor.value.frozen) {
+ let frozenLabel = this.document.createElement("label");
+ frozenLabel.className = "plain variable-or-property-frozen-label";
+ frozenLabel.setAttribute("optional-visibility", "");
+ frozenLabel.setAttribute("value", "F");
+ this._title.appendChild(frozenLabel);
+ }
+ if (descriptor.value.sealed) {
+ let sealedLabel = this.document.createElement("label");
+ sealedLabel.className = "plain variable-or-property-sealed-label";
+ sealedLabel.setAttribute("optional-visibility", "");
+ sealedLabel.setAttribute("value", "S");
+ this._title.appendChild(sealedLabel);
+ }
+ if (!descriptor.value.extensible) {
+ let nonExtensibleLabel = this.document.createElement("label");
+ nonExtensibleLabel.className = "plain variable-or-property-non-extensible-label";
+ nonExtensibleLabel.setAttribute("optional-visibility", "");
+ nonExtensibleLabel.setAttribute("value", "N");
+ this._title.appendChild(nonExtensibleLabel);
+ }
+ }
+ },
+
+ /**
+ * Prepares all tooltips for this variable.
+ */
+ _prepareTooltips: function() {
+ this._target.addEventListener("mouseover", this._setTooltips, false);
+ },
+
+ /**
+ * Sets all tooltips for this variable.
+ */
+ _setTooltips: function() {
+ this._target.removeEventListener("mouseover", this._setTooltips, false);
+
+ let ownerView = this.ownerView;
+ if (ownerView.preventDescriptorModifiers) {
+ return;
+ }
+
+ let tooltip = this.document.createElement("tooltip");
+ tooltip.id = "tooltip-" + this._idString;
+ tooltip.setAttribute("orient", "horizontal");
+
+ let labels = [
+ "configurable", "enumerable", "writable",
+ "frozen", "sealed", "extensible", "overridden", "WebIDL"];
+
+ for (let type of labels) {
+ let labelElement = this.document.createElement("label");
+ labelElement.className = type;
+ labelElement.setAttribute("value", STR.GetStringFromName(type + "Tooltip"));
+ tooltip.appendChild(labelElement);
+ }
+
+ this._target.appendChild(tooltip);
+ this._target.setAttribute("tooltip", tooltip.id);
+
+ if (this._editNode && ownerView.eval) {
+ this._editNode.setAttribute("tooltiptext", ownerView.editButtonTooltip);
+ }
+ if (this._openInspectorNode && this._linkedToInspector) {
+ this._openInspectorNode.setAttribute("tooltiptext", this.ownerView.domNodeValueTooltip);
+ }
+ if (this._valueLabel && ownerView.eval) {
+ this._valueLabel.setAttribute("tooltiptext", ownerView.editableValueTooltip);
+ }
+ if (this._name && ownerView.switch) {
+ this._name.setAttribute("tooltiptext", ownerView.editableNameTooltip);
+ }
+ if (this._deleteNode && ownerView.delete) {
+ this._deleteNode.setAttribute("tooltiptext", ownerView.deleteButtonTooltip);
+ }
+ },
+
+ /**
+ * Get the parent variablesview toolbox, if any.
+ */
+ get toolbox() {
+ return this._variablesView.toolbox;
+ },
+
+ /**
+ * Checks if this variable is a DOMNode and is part of a variablesview that
+ * has been linked to the toolbox, so that highlighting and jumping to the
+ * inspector can be done.
+ */
+ _isLinkableToInspector: function() {
+ let isDomNode = this._valueGrip && this._valueGrip.preview.kind === "DOMNode";
+ let hasBeenLinked = this._linkedToInspector;
+ let hasToolbox = !!this.toolbox;
+
+ return isDomNode && !hasBeenLinked && hasToolbox;
+ },
+
+ /**
+ * If the variable is a DOMNode, and if a toolbox is set, then link it to the
+ * inspector (highlight on hover, and jump to markup-view on click)
+ */
+ _linkToInspector: function() {
+ if (!this._isLinkableToInspector()) {
+ return;
+ }
+
+ // Listen to value mouseover/click events to highlight and jump
+ this._valueLabel.addEventListener("mouseover", this.highlightDomNode, false);
+ this._valueLabel.addEventListener("mouseout", this.unhighlightDomNode, false);
+
+ // Add a button to open the node in the inspector
+ this._openInspectorNode = this.document.createElement("toolbarbutton");
+ this._openInspectorNode.className = "plain variables-view-open-inspector";
+ this._openInspectorNode.addEventListener("mousedown", this.openNodeInInspector, false);
+ this._title.appendChild(this._openInspectorNode);
+
+ this._linkedToInspector = true;
+ },
+
+ /**
+ * In case this variable is a DOMNode and part of a variablesview that has been
+ * linked to the toolbox's inspector, then select the corresponding node in
+ * the inspector, and switch the inspector tool in the toolbox
+ * @return a promise that resolves when the node is selected and the inspector
+ * has been switched to and is ready
+ */
+ openNodeInInspector: function(event) {
+ if (!this.toolbox) {
+ return promise.reject(new Error("Toolbox not available"));
+ }
+
+ event && event.stopPropagation();
+
+ return Task.spawn(function*() {
+ yield this.toolbox.initInspector();
+
+ let nodeFront = this._nodeFront;
+ if (!nodeFront) {
+ nodeFront = yield this.toolbox.walker.getNodeActorFromObjectActor(this._valueGrip.actor);
+ }
+
+ if (nodeFront) {
+ yield this.toolbox.selectTool("inspector");
+
+ let inspectorReady = promise.defer();
+ this.toolbox.getPanel("inspector").once("inspector-updated", inspectorReady.resolve);
+ yield this.toolbox.selection.setNodeFront(nodeFront, "variables-view");
+ yield inspectorReady.promise;
+ }
+ }.bind(this));
+ },
+
+ /**
+ * In case this variable is a DOMNode and part of a variablesview that has been
+ * linked to the toolbox's inspector, then highlight the corresponding node
+ */
+ highlightDomNode: function() {
+ if (this.toolbox) {
+ if (this._nodeFront) {
+ // If the nodeFront has been retrieved before, no need to ask the server
+ // again for it
+ this.toolbox.highlighterUtils.highlightNodeFront(this._nodeFront);
+ return;
+ }
+
+ this.toolbox.highlighterUtils.highlightDomValueGrip(this._valueGrip).then(front => {
+ this._nodeFront = front;
+ });
+ }
+ },
+
+ /**
+ * Unhighlight a previously highlit node
+ * @see highlightDomNode
+ */
+ unhighlightDomNode: function() {
+ if (this.toolbox) {
+ this.toolbox.highlighterUtils.unhighlight();
+ }
+ },
+
+ /**
+ * Sets a variable's configurable, enumerable and writable attributes,
+ * and specifies if it's a 'this', '<exception>', '<return>' or '__proto__'
+ * reference.
+ */
+ _setAttributes: function() {
+ let ownerView = this.ownerView;
+ if (ownerView.preventDescriptorModifiers) {
+ return;
+ }
+
+ let descriptor = this._initialDescriptor;
+ let target = this._target;
+ let name = this._nameString;
+
+ if (ownerView.eval) {
+ target.setAttribute("editable", "");
+ }
+
+ if (!descriptor.configurable) {
+ target.setAttribute("non-configurable", "");
+ }
+ if (!descriptor.enumerable) {
+ target.setAttribute("non-enumerable", "");
+ }
+ if (!descriptor.writable && !ownerView.getter && !ownerView.setter) {
+ target.setAttribute("non-writable", "");
+ }
+
+ if (descriptor.value && typeof descriptor.value == "object") {
+ if (descriptor.value.frozen) {
+ target.setAttribute("frozen", "");
+ }
+ if (descriptor.value.sealed) {
+ target.setAttribute("sealed", "");
+ }
+ if (!descriptor.value.extensible) {
+ target.setAttribute("non-extensible", "");
+ }
+ }
+
+ if (descriptor && "getterValue" in descriptor) {
+ target.setAttribute("safe-getter", "");
+ }
+
+ if (name == "this") {
+ target.setAttribute("self", "");
+ }
+ else if (name == "<exception>") {
+ target.setAttribute("exception", "");
+ target.setAttribute("pseudo-item", "");
+ }
+ else if (name == "<return>") {
+ target.setAttribute("return", "");
+ target.setAttribute("pseudo-item", "");
+ }
+ else if (name == "__proto__") {
+ target.setAttribute("proto", "");
+ target.setAttribute("pseudo-item", "");
+ }
+
+ if (Object.keys(descriptor).length == 0) {
+ target.setAttribute("pseudo-item", "");
+ }
+ },
+
+ /**
+ * Adds the necessary event listeners for this variable.
+ */
+ _addEventListeners: function() {
+ this._name.addEventListener("dblclick", this._activateNameInput, false);
+ this._valueLabel.addEventListener("mousedown", this._activateValueInput, false);
+ this._title.addEventListener("mousedown", this._onClick, false);
+ },
+
+ /**
+ * Makes this variable's name editable.
+ */
+ _activateNameInput: function(e) {
+ if (!this._variablesView.alignedValues) {
+ this._separatorLabel.hidden = true;
+ this._valueLabel.hidden = true;
+ }
+
+ EditableName.create(this, {
+ onSave: aKey => {
+ if (!this._variablesView.preventDisableOnChange) {
+ this._disable();
+ }
+ this.ownerView.switch(this, aKey);
+ },
+ onCleanup: () => {
+ if (!this._variablesView.alignedValues) {
+ this._separatorLabel.hidden = false;
+ this._valueLabel.hidden = false;
+ }
+ }
+ }, e);
+ },
+
+ /**
+ * Makes this variable's value editable.
+ */
+ _activateValueInput: function(e) {
+ EditableValue.create(this, {
+ onSave: aString => {
+ if (this._linkedToInspector) {
+ this.unhighlightDomNode();
+ }
+ if (!this._variablesView.preventDisableOnChange) {
+ this._disable();
+ }
+ this.ownerView.eval(this, aString);
+ }
+ }, e);
+ },
+
+ /**
+ * Disables this variable prior to a new name switch or value evaluation.
+ */
+ _disable: function() {
+ // Prevent the variable from being collapsed or expanded.
+ this.hideArrow();
+
+ // Hide any nodes that may offer information about the variable.
+ for (let node of this._title.childNodes) {
+ node.hidden = node != this._arrow && node != this._name;
+ }
+ this._enum.hidden = true;
+ this._nonenum.hidden = true;
+ },
+
+ /**
+ * The current macro used to generate the string evaluated when performing
+ * a variable or property value change.
+ */
+ evaluationMacro: VariablesView.simpleValueEvalMacro,
+
+ /**
+ * The click listener for the edit button.
+ */
+ _onEdit: function(e) {
+ if (e.button != 0) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+ this._activateValueInput();
+ },
+
+ /**
+ * The click listener for the delete button.
+ */
+ _onDelete: function(e) {
+ if ("button" in e && e.button != 0) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (this.ownerView.delete) {
+ if (!this.ownerView.delete(this)) {
+ this.hide();
+ }
+ }
+ },
+
+ /**
+ * The click listener for the add property button.
+ */
+ _onAddProperty: function(e) {
+ if ("button" in e && e.button != 0) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.expanded = true;
+
+ let item = this.addItem(" ", {
+ value: undefined,
+ configurable: true,
+ enumerable: true,
+ writable: true
+ }, true);
+
+ // Force showing the separator.
+ item._separatorLabel.hidden = false;
+
+ EditableNameAndValue.create(item, {
+ onSave: ([aKey, aValue]) => {
+ if (!this._variablesView.preventDisableOnChange) {
+ this._disable();
+ }
+ this.ownerView.new(this, aKey, aValue);
+ }
+ }, e);
+ },
+
+ _symbolicName: null,
+ _symbolicPath: null,
+ _absoluteName: null,
+ _initialDescriptor: null,
+ _separatorLabel: null,
+ _valueLabel: null,
+ _spacer: null,
+ _editNode: null,
+ _deleteNode: null,
+ _addPropertyNode: null,
+ _tooltip: null,
+ _valueGrip: null,
+ _valueString: "",
+ _valueClassName: "",
+ _prevExpandable: false,
+ _prevExpanded: false
+});
+
+/**
+ * A Property is a Variable holding additional child Property instances.
+ * Iterable via "for (let [name, property] of instance) { }".
+ *
+ * @param Variable aVar
+ * The variable to contain this property.
+ * @param string aName
+ * The property's name.
+ * @param object aDescriptor
+ * The property's descriptor.
+ */
+function Property(aVar, aName, aDescriptor) {
+ Variable.call(this, aVar, aName, aDescriptor);
+}
+
+Property.prototype = Heritage.extend(Variable.prototype, {
+ /**
+ * The class name applied to this property's target element.
+ */
+ targetClassName: "variables-view-property variable-or-property",
+
+ /**
+ * @see Variable.symbolicName
+ * @return string
+ */
+ get symbolicName() {
+ if (this._symbolicName) {
+ return this._symbolicName;
+ }
+
+ this._symbolicName = this.ownerView.symbolicName + "[\"" + this._nameString + "\"]";
+ return this._symbolicName;
+ },
+
+ /**
+ * @see Variable.absoluteName
+ * @return string
+ */
+ get absoluteName() {
+ if (this._absoluteName) {
+ return this._absoluteName;
+ }
+
+ this._absoluteName = this.ownerView.absoluteName + "[\"" + this._nameString + "\"]";
+ return this._absoluteName;
+ }
+});
+
+/**
+ * A generator-iterator over the VariablesView, Scopes, Variables and Properties.
+ */
+VariablesView.prototype[Symbol.iterator] =
+Scope.prototype[Symbol.iterator] =
+Variable.prototype[Symbol.iterator] =
+Property.prototype[Symbol.iterator] = function*() {
+ yield* this._store;
+};
+
+/**
+ * Forget everything recorded about added scopes, variables or properties.
+ * @see VariablesView.commitHierarchy
+ */
+VariablesView.prototype.clearHierarchy = function() {
+ this._prevHierarchy.clear();
+ this._currHierarchy.clear();
+};
+
+/**
+ * Perform operations on all the VariablesView Scopes, Variables and Properties
+ * after you've added all the items you wanted.
+ *
+ * Calling this method is optional, and does the following:
+ * - styles the items overridden by other items in parent scopes
+ * - reopens the items which were previously expanded
+ * - flashes the items whose values changed
+ */
+VariablesView.prototype.commitHierarchy = function() {
+ for (let [, currItem] of this._currHierarchy) {
+ // Avoid performing expensive operations.
+ if (this.commitHierarchyIgnoredItems[currItem._nameString]) {
+ continue;
+ }
+ let overridden = this.isOverridden(currItem);
+ if (overridden) {
+ currItem.setOverridden(true);
+ }
+ let expanded = !currItem._committed && this.wasExpanded(currItem);
+ if (expanded) {
+ currItem.expand();
+ }
+ let changed = !currItem._committed && this.hasChanged(currItem);
+ if (changed) {
+ currItem.flash();
+ }
+ currItem._committed = true;
+ }
+ if (this.oncommit) {
+ this.oncommit(this);
+ }
+};
+
+// Some variables are likely to contain a very large number of properties.
+// It would be a bad idea to re-expand them or perform expensive operations.
+VariablesView.prototype.commitHierarchyIgnoredItems = Heritage.extend(null, {
+ "window": true,
+ "this": true
+});
+
+/**
+ * Checks if the an item was previously expanded, if it existed in a
+ * previous hierarchy.
+ *
+ * @param Scope | Variable | Property aItem
+ * The item to verify.
+ * @return boolean
+ * Whether the item was expanded.
+ */
+VariablesView.prototype.wasExpanded = function(aItem) {
+ if (!(aItem instanceof Scope)) {
+ return false;
+ }
+ let prevItem = this._prevHierarchy.get(aItem.absoluteName || aItem._nameString);
+ return prevItem ? prevItem._isExpanded : false;
+};
+
+/**
+ * Checks if the an item's displayed value (a representation of the grip)
+ * has changed, if it existed in a previous hierarchy.
+ *
+ * @param Variable | Property aItem
+ * The item to verify.
+ * @return boolean
+ * Whether the item has changed.
+ */
+VariablesView.prototype.hasChanged = function(aItem) {
+ // Only analyze Variables and Properties for displayed value changes.
+ // Scopes are just collections of Variables and Properties and
+ // don't have a "value", so they can't change.
+ if (!(aItem instanceof Variable)) {
+ return false;
+ }
+ let prevItem = this._prevHierarchy.get(aItem.absoluteName);
+ return prevItem ? prevItem._valueString != aItem._valueString : false;
+};
+
+/**
+ * Checks if the an item was previously expanded, if it existed in a
+ * previous hierarchy.
+ *
+ * @param Scope | Variable | Property aItem
+ * The item to verify.
+ * @return boolean
+ * Whether the item was expanded.
+ */
+VariablesView.prototype.isOverridden = function(aItem) {
+ // Only analyze Variables for being overridden in different Scopes.
+ if (!(aItem instanceof Variable) || aItem instanceof Property) {
+ return false;
+ }
+ let currVariableName = aItem._nameString;
+ let parentScopes = this.getParentScopesForVariableOrProperty(aItem);
+
+ for (let otherScope of parentScopes) {
+ for (let [otherVariableName] of otherScope) {
+ if (otherVariableName == currVariableName) {
+ return true;
+ }
+ }
+ }
+ return false;
+};
+
+/**
+ * Returns true if the descriptor represents an undefined, null or
+ * primitive value.
+ *
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+VariablesView.isPrimitive = function(aDescriptor) {
+ // For accessor property descriptors, the getter and setter need to be
+ // contained in 'get' and 'set' properties.
+ let getter = aDescriptor.get;
+ let setter = aDescriptor.set;
+ if (getter || setter) {
+ return false;
+ }
+
+ // As described in the remote debugger protocol, the value grip
+ // must be contained in a 'value' property.
+ let grip = aDescriptor.value;
+ if (typeof grip != "object") {
+ return true;
+ }
+
+ // For convenience, undefined, null, Infinity, -Infinity, NaN, -0, and long
+ // strings are considered types.
+ let type = grip.type;
+ if (type == "undefined" ||
+ type == "null" ||
+ type == "Infinity" ||
+ type == "-Infinity" ||
+ type == "NaN" ||
+ type == "-0" ||
+ type == "symbol" ||
+ type == "longString") {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Returns true if the descriptor represents an undefined value.
+ *
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+VariablesView.isUndefined = function(aDescriptor) {
+ // For accessor property descriptors, the getter and setter need to be
+ // contained in 'get' and 'set' properties.
+ let getter = aDescriptor.get;
+ let setter = aDescriptor.set;
+ if (typeof getter == "object" && getter.type == "undefined" &&
+ typeof setter == "object" && setter.type == "undefined") {
+ return true;
+ }
+
+ // As described in the remote debugger protocol, the value grip
+ // must be contained in a 'value' property.
+ let grip = aDescriptor.value;
+ if (typeof grip == "object" && grip.type == "undefined") {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Returns true if the descriptor represents a falsy value.
+ *
+ * @param object aDescriptor
+ * The variable's descriptor.
+ */
+VariablesView.isFalsy = function(aDescriptor) {
+ // As described in the remote debugger protocol, the value grip
+ // must be contained in a 'value' property.
+ let grip = aDescriptor.value;
+ if (typeof grip != "object") {
+ return !grip;
+ }
+
+ // For convenience, undefined, null, NaN, and -0 are all considered types.
+ let type = grip.type;
+ if (type == "undefined" ||
+ type == "null" ||
+ type == "NaN" ||
+ type == "-0") {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Returns true if the value is an instance of Variable or Property.
+ *
+ * @param any aValue
+ * The value to test.
+ */
+VariablesView.isVariable = function(aValue) {
+ return aValue instanceof Variable;
+};
+
+/**
+ * Returns a standard grip for a value.
+ *
+ * @param any aValue
+ * The raw value to get a grip for.
+ * @return any
+ * The value's grip.
+ */
+VariablesView.getGrip = function(aValue) {
+ switch (typeof aValue) {
+ case "boolean":
+ case "string":
+ return aValue;
+ case "number":
+ if (aValue === Infinity) {
+ return { type: "Infinity" };
+ } else if (aValue === -Infinity) {
+ return { type: "-Infinity" };
+ } else if (Number.isNaN(aValue)) {
+ return { type: "NaN" };
+ } else if (1 / aValue === -Infinity) {
+ return { type: "-0" };
+ }
+ return aValue;
+ case "undefined":
+ // document.all is also "undefined"
+ if (aValue === undefined) {
+ return { type: "undefined" };
+ }
+ case "object":
+ if (aValue === null) {
+ return { type: "null" };
+ }
+ case "function":
+ return { type: "object",
+ class: WebConsoleUtils.getObjectClassName(aValue) };
+ default:
+ Cu.reportError("Failed to provide a grip for value of " + typeof value +
+ ": " + aValue);
+ return null;
+ }
+};
+
+/**
+ * Returns a custom formatted property string for a grip.
+ *
+ * @param any aGrip
+ * @see Variable.setGrip
+ * @param object aOptions
+ * Options:
+ * - concise: boolean that tells you want a concisely formatted string.
+ * - noStringQuotes: boolean that tells to not quote strings.
+ * - noEllipsis: boolean that tells to not add an ellipsis after the
+ * initial text of a longString.
+ * @return string
+ * The formatted property string.
+ */
+VariablesView.getString = function(aGrip, aOptions = {}) {
+ if (aGrip && typeof aGrip == "object") {
+ switch (aGrip.type) {
+ case "undefined":
+ case "null":
+ case "NaN":
+ case "Infinity":
+ case "-Infinity":
+ case "-0":
+ return aGrip.type;
+ default:
+ let stringifier = VariablesView.stringifiers.byType[aGrip.type];
+ if (stringifier) {
+ let result = stringifier(aGrip, aOptions);
+ if (result != null) {
+ return result;
+ }
+ }
+
+ if (aGrip.displayString) {
+ return VariablesView.getString(aGrip.displayString, aOptions);
+ }
+
+ if (aGrip.type == "object" && aOptions.concise) {
+ return aGrip.class;
+ }
+
+ return "[" + aGrip.type + " " + aGrip.class + "]";
+ }
+ }
+
+ switch (typeof aGrip) {
+ case "string":
+ return VariablesView.stringifiers.byType.string(aGrip, aOptions);
+ case "boolean":
+ return aGrip ? "true" : "false";
+ case "number":
+ if (!aGrip && 1 / aGrip === -Infinity) {
+ return "-0";
+ }
+ default:
+ return aGrip + "";
+ }
+};
+
+/**
+ * The VariablesView stringifiers are used by VariablesView.getString(). These
+ * are organized by object type, object class and by object actor preview kind.
+ * Some objects share identical ways for previews, for example Arrays, Sets and
+ * NodeLists.
+ *
+ * Any stringifier function must return a string. If null is returned, * then
+ * the default stringifier will be used. When invoked, the stringifier is
+ * given the same two arguments as those given to VariablesView.getString().
+ */
+VariablesView.stringifiers = {};
+
+VariablesView.stringifiers.byType = {
+ string: function(aGrip, {noStringQuotes}) {
+ if (noStringQuotes) {
+ return aGrip;
+ }
+ return '"' + aGrip + '"';
+ },
+
+ longString: function({initial}, {noStringQuotes, noEllipsis}) {
+ let ellipsis = noEllipsis ? "" : Scope.ellipsis;
+ if (noStringQuotes) {
+ return initial + ellipsis;
+ }
+ let result = '"' + initial + '"';
+ if (!ellipsis) {
+ return result;
+ }
+ return result.substr(0, result.length - 1) + ellipsis + '"';
+ },
+
+ object: function(aGrip, aOptions) {
+ let {preview} = aGrip;
+ let stringifier;
+ if (preview && preview.kind) {
+ stringifier = VariablesView.stringifiers.byObjectKind[preview.kind];
+ }
+ if (!stringifier && aGrip.class) {
+ stringifier = VariablesView.stringifiers.byObjectClass[aGrip.class];
+ }
+ if (stringifier) {
+ return stringifier(aGrip, aOptions);
+ }
+ return null;
+ },
+
+ symbol: function(aGrip, aOptions) {
+ const name = aGrip.name || "";
+ return "Symbol(" + name + ")";
+ },
+}; // VariablesView.stringifiers.byType
+
+VariablesView.stringifiers.byObjectClass = {
+ Function: function(aGrip, {concise}) {
+ // TODO: Bug 948484 - support arrow functions and ES6 generators
+
+ let name = aGrip.userDisplayName || aGrip.displayName || aGrip.name || "";
+ name = VariablesView.getString(name, { noStringQuotes: true });
+
+ // TODO: Bug 948489 - Support functions with destructured parameters and
+ // rest parameters
+ let params = aGrip.parameterNames || "";
+ if (!concise) {
+ return "function " + name + "(" + params + ")";
+ }
+ return (name || "function ") + "(" + params + ")";
+ },
+
+ RegExp: function({displayString}) {
+ return VariablesView.getString(displayString, { noStringQuotes: true });
+ },
+
+ Date: function({preview}) {
+ if (!preview || !("timestamp" in preview)) {
+ return null;
+ }
+
+ if (typeof preview.timestamp != "number") {
+ return new Date(preview.timestamp).toString(); // invalid date
+ }
+
+ return "Date " + new Date(preview.timestamp).toISOString();
+ },
+
+ String: function({displayString}) {
+ if (displayString === undefined) {
+ return null;
+ }
+ return VariablesView.getString(displayString);
+ },
+
+ Number: function({preview}) {
+ if (preview === undefined) {
+ return null;
+ }
+ return VariablesView.getString(preview.value);
+ },
+}; // VariablesView.stringifiers.byObjectClass
+
+VariablesView.stringifiers.byObjectClass.Boolean =
+ VariablesView.stringifiers.byObjectClass.Number;
+
+VariablesView.stringifiers.byObjectKind = {
+ ArrayLike: function(aGrip, {concise}) {
+ let {preview} = aGrip;
+ if (concise) {
+ return aGrip.class + "[" + preview.length + "]";
+ }
+
+ if (!preview.items) {
+ return null;
+ }
+
+ let shown = 0, result = [], lastHole = null;
+ for (let item of preview.items) {
+ if (item === null) {
+ if (lastHole !== null) {
+ result[lastHole] += ",";
+ } else {
+ result.push("");
+ }
+ lastHole = result.length - 1;
+ } else {
+ lastHole = null;
+ result.push(VariablesView.getString(item, { concise: true }));
+ }
+ shown++;
+ }
+
+ if (shown < preview.length) {
+ let n = preview.length - shown;
+ result.push(VariablesView.stringifiers._getNMoreString(n));
+ } else if (lastHole !== null) {
+ // make sure we have the right number of commas...
+ result[lastHole] += ",";
+ }
+
+ let prefix = aGrip.class == "Array" ? "" : aGrip.class + " ";
+ return prefix + "[" + result.join(", ") + "]";
+ },
+
+ MapLike: function(aGrip, {concise}) {
+ let {preview} = aGrip;
+ if (concise || !preview.entries) {
+ let size = typeof preview.size == "number" ?
+ "[" + preview.size + "]" : "";
+ return aGrip.class + size;
+ }
+
+ let entries = [];
+ for (let [key, value] of preview.entries) {
+ let keyString = VariablesView.getString(key, {
+ concise: true,
+ noStringQuotes: true,
+ });
+ let valueString = VariablesView.getString(value, { concise: true });
+ entries.push(keyString + ": " + valueString);
+ }
+
+ if (typeof preview.size == "number" && preview.size > entries.length) {
+ let n = preview.size - entries.length;
+ entries.push(VariablesView.stringifiers._getNMoreString(n));
+ }
+
+ return aGrip.class + " {" + entries.join(", ") + "}";
+ },
+
+ ObjectWithText: function(aGrip, {concise}) {
+ if (concise) {
+ return aGrip.class;
+ }
+
+ return aGrip.class + " " + VariablesView.getString(aGrip.preview.text);
+ },
+
+ ObjectWithURL: function(aGrip, {concise}) {
+ let result = aGrip.class;
+ let url = aGrip.preview.url;
+ if (!VariablesView.isFalsy({ value: url })) {
+ result += " \u2192 " + WebConsoleUtils.abbreviateSourceURL(url,
+ { onlyCropQuery: !concise });
+ }
+ return result;
+ },
+
+ // Stringifier for any kind of object.
+ Object: function(aGrip, {concise}) {
+ if (concise) {
+ return aGrip.class;
+ }
+
+ let {preview} = aGrip;
+ let props = [];
+
+ if (aGrip.class == "Promise" && aGrip.promiseState) {
+ let { state, value, reason } = aGrip.promiseState;
+ props.push("<state>: " + VariablesView.getString(state));
+ if (state == "fulfilled") {
+ props.push("<value>: " + VariablesView.getString(value, { concise: true }));
+ } else if (state == "rejected") {
+ props.push("<reason>: " + VariablesView.getString(reason, { concise: true }));
+ }
+ }
+
+ for (let key of Object.keys(preview.ownProperties || {})) {
+ let value = preview.ownProperties[key];
+ let valueString = "";
+ if (value.get) {
+ valueString = "Getter";
+ } else if (value.set) {
+ valueString = "Setter";
+ } else {
+ valueString = VariablesView.getString(value.value, { concise: true });
+ }
+ props.push(key + ": " + valueString);
+ }
+
+ for (let key of Object.keys(preview.safeGetterValues || {})) {
+ let value = preview.safeGetterValues[key];
+ let valueString = VariablesView.getString(value.getterValue,
+ { concise: true });
+ props.push(key + ": " + valueString);
+ }
+
+ if (!props.length) {
+ return null;
+ }
+
+ if (preview.ownPropertiesLength) {
+ let previewLength = Object.keys(preview.ownProperties).length;
+ let diff = preview.ownPropertiesLength - previewLength;
+ if (diff > 0) {
+ props.push(VariablesView.stringifiers._getNMoreString(diff));
+ }
+ }
+
+ let prefix = aGrip.class != "Object" ? aGrip.class + " " : "";
+ return prefix + "{" + props.join(", ") + "}";
+ }, // Object
+
+ Error: function(aGrip, {concise}) {
+ let {preview} = aGrip;
+ let name = VariablesView.getString(preview.name, { noStringQuotes: true });
+ if (concise) {
+ return name || aGrip.class;
+ }
+
+ let msg = name + ": " +
+ VariablesView.getString(preview.message, { noStringQuotes: true });
+
+ if (!VariablesView.isFalsy({ value: preview.stack })) {
+ msg += "\n" + STR.GetStringFromName("variablesViewErrorStacktrace") +
+ "\n" + preview.stack;
+ }
+
+ return msg;
+ },
+
+ DOMException: function(aGrip, {concise}) {
+ let {preview} = aGrip;
+ if (concise) {
+ return preview.name || aGrip.class;
+ }
+
+ let msg = aGrip.class + " [" + preview.name + ": " +
+ VariablesView.getString(preview.message) + "\n" +
+ "code: " + preview.code + "\n" +
+ "nsresult: 0x" + (+preview.result).toString(16);
+
+ if (preview.filename) {
+ msg += "\nlocation: " + preview.filename;
+ if (preview.lineNumber) {
+ msg += ":" + preview.lineNumber;
+ }
+ }
+
+ return msg + "]";
+ },
+
+ DOMEvent: function(aGrip, {concise}) {
+ let {preview} = aGrip;
+ if (!preview.type) {
+ return null;
+ }
+
+ if (concise) {
+ return aGrip.class + " " + preview.type;
+ }
+
+ let result = preview.type;
+
+ if (preview.eventKind == "key" && preview.modifiers &&
+ preview.modifiers.length) {
+ result += " " + preview.modifiers.join("-");
+ }
+
+ let props = [];
+ if (preview.target) {
+ let target = VariablesView.getString(preview.target, { concise: true });
+ props.push("target: " + target);
+ }
+
+ for (let prop in preview.properties) {
+ let value = preview.properties[prop];
+ props.push(prop + ": " + VariablesView.getString(value, { concise: true }));
+ }
+
+ return result + " {" + props.join(", ") + "}";
+ }, // DOMEvent
+
+ DOMNode: function(aGrip, {concise}) {
+ let {preview} = aGrip;
+
+ switch (preview.nodeType) {
+ case Ci.nsIDOMNode.DOCUMENT_NODE: {
+ let result = aGrip.class;
+ if (preview.location) {
+ let location = WebConsoleUtils.abbreviateSourceURL(preview.location,
+ { onlyCropQuery: !concise });
+ result += " \u2192 " + location;
+ }
+
+ return result;
+ }
+
+ case Ci.nsIDOMNode.ATTRIBUTE_NODE: {
+ let value = VariablesView.getString(preview.value, { noStringQuotes: true });
+ return preview.nodeName + '="' + escapeHTML(value) + '"';
+ }
+
+ case Ci.nsIDOMNode.TEXT_NODE:
+ return preview.nodeName + " " +
+ VariablesView.getString(preview.textContent);
+
+ case Ci.nsIDOMNode.COMMENT_NODE: {
+ let comment = VariablesView.getString(preview.textContent,
+ { noStringQuotes: true });
+ return "<!--" + comment + "-->";
+ }
+
+ case Ci.nsIDOMNode.DOCUMENT_FRAGMENT_NODE: {
+ if (concise || !preview.childNodes) {
+ return aGrip.class + "[" + preview.childNodesLength + "]";
+ }
+ let nodes = [];
+ for (let node of preview.childNodes) {
+ nodes.push(VariablesView.getString(node));
+ }
+ if (nodes.length < preview.childNodesLength) {
+ let n = preview.childNodesLength - nodes.length;
+ nodes.push(VariablesView.stringifiers._getNMoreString(n));
+ }
+ return aGrip.class + " [" + nodes.join(", ") + "]";
+ }
+
+ case Ci.nsIDOMNode.ELEMENT_NODE: {
+ let attrs = preview.attributes;
+ if (!concise) {
+ let n = 0, result = "<" + preview.nodeName;
+ for (let name in attrs) {
+ let value = VariablesView.getString(attrs[name],
+ { noStringQuotes: true });
+ result += " " + name + '="' + escapeHTML(value) + '"';
+ n++;
+ }
+ if (preview.attributesLength > n) {
+ result += " " + Scope.ellipsis;
+ }
+ return result + ">";
+ }
+
+ let result = "<" + preview.nodeName;
+ if (attrs.id) {
+ result += "#" + attrs.id;
+ }
+
+ if (attrs.class) {
+ result += "." + attrs.class.trim().replace(/\s+/, ".");
+ }
+ return result + ">";
+ }
+
+ default:
+ return null;
+ }
+ }, // DOMNode
+}; // VariablesView.stringifiers.byObjectKind
+
+
+/**
+ * Get the "N more…" formatted string, given an N. This is used for displaying
+ * how many elements are not displayed in an object preview (eg. an array).
+ *
+ * @private
+ * @param number aNumber
+ * @return string
+ */
+VariablesView.stringifiers._getNMoreString = function(aNumber) {
+ let str = STR.GetStringFromName("variablesViewMoreObjects");
+ return PluralForm.get(aNumber, str).replace("#1", aNumber);
+};
+
+/**
+ * Returns a custom class style for a grip.
+ *
+ * @param any aGrip
+ * @see Variable.setGrip
+ * @return string
+ * The custom class style.
+ */
+VariablesView.getClass = function(aGrip) {
+ if (aGrip && typeof aGrip == "object") {
+ if (aGrip.preview) {
+ switch (aGrip.preview.kind) {
+ case "DOMNode":
+ return "token-domnode";
+ }
+ }
+
+ switch (aGrip.type) {
+ case "undefined":
+ return "token-undefined";
+ case "null":
+ return "token-null";
+ case "Infinity":
+ case "-Infinity":
+ case "NaN":
+ case "-0":
+ return "token-number";
+ case "longString":
+ return "token-string";
+ }
+ }
+ switch (typeof aGrip) {
+ case "string":
+ return "token-string";
+ case "boolean":
+ return "token-boolean";
+ case "number":
+ return "token-number";
+ default:
+ return "token-other";
+ }
+};
+
+/**
+ * A monotonically-increasing counter, that guarantees the uniqueness of scope,
+ * variables and properties ids.
+ *
+ * @param string aName
+ * An optional string to prefix the id with.
+ * @return number
+ * A unique id.
+ */
+let generateId = (function() {
+ let count = 0;
+ return function(aName = "") {
+ return aName.toLowerCase().trim().replace(/\s+/g, "-") + (++count);
+ };
+})();
+
+/**
+ * Escape some HTML special characters. We do not need full HTML serialization
+ * here, we just want to make strings safe to display in HTML attributes, for
+ * the stringifiers.
+ *
+ * @param string aString
+ * @return string
+ */
+function escapeHTML(aString) {
+ return aString.replace(/&/g, "&amp;")
+ .replace(/"/g, "&quot;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;");
+}
+
+
+/**
+ * An Editable encapsulates the UI of an edit box that overlays a label,
+ * allowing the user to edit the value.
+ *
+ * @param Variable aVariable
+ * The Variable or Property to make editable.
+ * @param object aOptions
+ * - onSave
+ * The callback to call with the value when editing is complete.
+ * - onCleanup
+ * The callback to call when the editable is removed for any reason.
+ */
+function Editable(aVariable, aOptions) {
+ this._variable = aVariable;
+ this._onSave = aOptions.onSave;
+ this._onCleanup = aOptions.onCleanup;
+}
+
+Editable.create = function(aVariable, aOptions, aEvent) {
+ let editable = new this(aVariable, aOptions);
+ editable.activate(aEvent);
+ return editable;
+};
+
+Editable.prototype = {
+ /**
+ * The class name for targeting this Editable type's label element. Overridden
+ * by inheriting classes.
+ */
+ className: null,
+
+ /**
+ * Boolean indicating whether this Editable should activate. Overridden by
+ * inheriting classes.
+ */
+ shouldActivate: null,
+
+ /**
+ * The label element for this Editable. Overridden by inheriting classes.
+ */
+ label: null,
+
+ /**
+ * Activate this editable by replacing the input box it overlays and
+ * initialize the handlers.
+ *
+ * @param Event e [optional]
+ * Optionally, the Event object that was used to activate the Editable.
+ */
+ activate: function(e) {
+ if (!this.shouldActivate) {
+ this._onCleanup && this._onCleanup();
+ return;
+ }
+
+ let { label } = this;
+ let initialString = label.getAttribute("value");
+
+ if (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ // Create a texbox input element which will be shown in the current
+ // element's specified label location.
+ let input = this._input = this._variable.document.createElement("textbox");
+ input.className = "plain " + this.className;
+ input.setAttribute("value", initialString);
+ input.setAttribute("flex", "1");
+
+ // Replace the specified label with a textbox input element.
+ label.parentNode.replaceChild(input, label);
+ this._variable._variablesView.boxObject.ensureElementIsVisible(input);
+ input.select();
+
+ // When the value is a string (displayed as "value"), then we probably want
+ // to change it to another string in the textbox, so to avoid typing the ""
+ // again, tackle with the selection bounds just a bit.
+ if (initialString.match(/^".+"$/)) {
+ input.selectionEnd--;
+ input.selectionStart++;
+ }
+
+ this._onKeypress = this._onKeypress.bind(this);
+ this._onBlur = this._onBlur.bind(this);
+ input.addEventListener("keypress", this._onKeypress);
+ input.addEventListener("blur", this._onBlur);
+
+ this._prevExpandable = this._variable.twisty;
+ this._prevExpanded = this._variable.expanded;
+ this._variable.collapse();
+ this._variable.hideArrow();
+ this._variable.locked = true;
+ this._variable.editing = true;
+ },
+
+ /**
+ * Remove the input box and restore the Variable or Property to its previous
+ * state.
+ */
+ deactivate: function() {
+ this._input.removeEventListener("keypress", this._onKeypress);
+ this._input.removeEventListener("blur", this.deactivate);
+ this._input.parentNode.replaceChild(this.label, this._input);
+ this._input = null;
+
+ let { boxObject } = this._variable._variablesView;
+ boxObject.scrollBy(-this._variable._target, 0);
+ this._variable.locked = false;
+ this._variable.twisty = this._prevExpandable;
+ this._variable.expanded = this._prevExpanded;
+ this._variable.editing = false;
+ this._onCleanup && this._onCleanup();
+ },
+
+ /**
+ * Save the current value and deactivate the Editable.
+ */
+ _save: function() {
+ let initial = this.label.getAttribute("value");
+ let current = this._input.value.trim();
+ this.deactivate();
+ if (initial != current) {
+ this._onSave(current);
+ }
+ },
+
+ /**
+ * Called when tab is pressed, allowing subclasses to link different
+ * behavior to tabbing if desired.
+ */
+ _next: function() {
+ this._save();
+ },
+
+ /**
+ * Called when escape is pressed, indicating a cancelling of editing without
+ * saving.
+ */
+ _reset: function() {
+ this.deactivate();
+ this._variable.focus();
+ },
+
+ /**
+ * Event handler for when the input loses focus.
+ */
+ _onBlur: function() {
+ this.deactivate();
+ },
+
+ /**
+ * Event handler for when the input receives a key press.
+ */
+ _onKeypress: function(e) {
+ e.stopPropagation();
+
+ switch (e.keyCode) {
+ case e.DOM_VK_TAB:
+ this._next();
+ break;
+ case e.DOM_VK_RETURN:
+ this._save();
+ break;
+ case e.DOM_VK_ESCAPE:
+ this._reset();
+ break;
+ }
+ },
+};
+
+
+/**
+ * An Editable specific to editing the name of a Variable or Property.
+ */
+function EditableName(aVariable, aOptions) {
+ Editable.call(this, aVariable, aOptions);
+}
+
+EditableName.create = Editable.create;
+
+EditableName.prototype = Heritage.extend(Editable.prototype, {
+ className: "element-name-input",
+
+ get label() {
+ return this._variable._name;
+ },
+
+ get shouldActivate() {
+ return !!this._variable.ownerView.switch;
+ },
+});
+
+
+/**
+ * An Editable specific to editing the value of a Variable or Property.
+ */
+function EditableValue(aVariable, aOptions) {
+ Editable.call(this, aVariable, aOptions);
+}
+
+EditableValue.create = Editable.create;
+
+EditableValue.prototype = Heritage.extend(Editable.prototype, {
+ className: "element-value-input",
+
+ get label() {
+ return this._variable._valueLabel;
+ },
+
+ get shouldActivate() {
+ return !!this._variable.ownerView.eval;
+ },
+});
+
+
+/**
+ * An Editable specific to editing the key and value of a new property.
+ */
+function EditableNameAndValue(aVariable, aOptions) {
+ EditableName.call(this, aVariable, aOptions);
+}
+
+EditableNameAndValue.create = Editable.create;
+
+EditableNameAndValue.prototype = Heritage.extend(EditableName.prototype, {
+ _reset: function(e) {
+ // Hide the Variable or Property if the user presses escape.
+ this._variable.remove();
+ this.deactivate();
+ },
+
+ _next: function(e) {
+ // Override _next so as to set both key and value at the same time.
+ let key = this._input.value;
+ this.label.setAttribute("value", key);
+
+ let valueEditable = EditableValue.create(this._variable, {
+ onSave: aValue => {
+ this._onSave([key, aValue]);
+ }
+ });
+ valueEditable._reset = () => {
+ this._variable.remove();
+ valueEditable.deactivate();
+ };
+ },
+
+ _save: function(e) {
+ // Both _save and _next activate the value edit box.
+ this._next(e);
+ }
+});
diff --git a/toolkit/devtools/shared/widgets/VariablesView.xul b/toolkit/devtools/shared/widgets/VariablesView.xul
new file mode 100644
index 000000000..2baf7ecbe
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/VariablesView.xul
@@ -0,0 +1,19 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://browser/content/devtools/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/common.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/widgets.css" type="text/css"?>
+<!DOCTYPE window [
+ <!ENTITY % viewDTD SYSTEM "chrome://browser/locale/devtools/VariablesView.dtd">
+ %viewDTD;
+]>
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ title="&PropertiesViewWindowTitle;">
+
+ <script type="application/javascript;version=1.8"
+ src="chrome://browser/content/devtools/theme-switching.js"/>
+ <vbox id="variables" flex="1"/>
+</window>
diff --git a/toolkit/devtools/shared/widgets/VariablesViewController.jsm b/toolkit/devtools/shared/widgets/VariablesViewController.jsm
new file mode 100644
index 000000000..0b773b574
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/VariablesViewController.jsm
@@ -0,0 +1,588 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+let {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
+Cu.import("resource:///modules/devtools/VariablesView.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "devtools",
+ "resource://gre/modules/devtools/Loader.jsm");
+
+Object.defineProperty(this, "WebConsoleUtils", {
+ get: function() {
+ return devtools.require("devtools/toolkit/webconsole/utils").Utils;
+ },
+ configurable: true,
+ enumerable: true
+});
+
+XPCOMUtils.defineLazyGetter(this, "VARIABLES_SORTING_ENABLED", () =>
+ Services.prefs.getBoolPref("devtools.debugger.ui.variables-sorting-enabled")
+);
+
+XPCOMUtils.defineLazyModuleGetter(this, "console",
+ "resource://gre/modules/devtools/Console.jsm");
+
+const MAX_LONG_STRING_LENGTH = 200000;
+const DBG_STRINGS_URI = "chrome://browser/locale/devtools/debugger.properties";
+
+this.EXPORTED_SYMBOLS = ["VariablesViewController", "StackFrameUtils"];
+
+
+/**
+ * Controller for a VariablesView that handles interfacing with the debugger
+ * protocol. Is able to populate scopes and variables via the protocol as well
+ * as manage actor lifespans.
+ *
+ * @param VariablesView aView
+ * The view to attach to.
+ * @param object aOptions [optional]
+ * Options for configuring the controller. Supported options:
+ * - getObjectClient: @see this._setClientGetters
+ * - getLongStringClient: @see this._setClientGetters
+ * - getEnvironmentClient: @see this._setClientGetters
+ * - releaseActor: @see this._setClientGetters
+ * - overrideValueEvalMacro: @see _setEvaluationMacros
+ * - getterOrSetterEvalMacro: @see _setEvaluationMacros
+ * - simpleValueEvalMacro: @see _setEvaluationMacros
+ */
+function VariablesViewController(aView, aOptions = {}) {
+ this.addExpander = this.addExpander.bind(this);
+
+ this._setClientGetters(aOptions);
+ this._setEvaluationMacros(aOptions);
+
+ this._actors = new Set();
+ this.view = aView;
+ this.view.controller = this;
+}
+
+VariablesViewController.prototype = {
+ /**
+ * The default getter/setter evaluation macro.
+ */
+ _getterOrSetterEvalMacro: VariablesView.getterOrSetterEvalMacro,
+
+ /**
+ * The default override value evaluation macro.
+ */
+ _overrideValueEvalMacro: VariablesView.overrideValueEvalMacro,
+
+ /**
+ * The default simple value evaluation macro.
+ */
+ _simpleValueEvalMacro: VariablesView.simpleValueEvalMacro,
+
+ /**
+ * Set the functions used to retrieve debugger client grips.
+ *
+ * @param object aOptions
+ * Options for getting the client grips. Supported options:
+ * - getObjectClient: callback for creating an object grip client
+ * - getLongStringClient: callback for creating a long string grip client
+ * - getEnvironmentClient: callback for creating an environment client
+ * - releaseActor: callback for releasing an actor when it's no longer needed
+ */
+ _setClientGetters: function(aOptions) {
+ if (aOptions.getObjectClient) {
+ this._getObjectClient = aOptions.getObjectClient;
+ }
+ if (aOptions.getLongStringClient) {
+ this._getLongStringClient = aOptions.getLongStringClient;
+ }
+ if (aOptions.getEnvironmentClient) {
+ this._getEnvironmentClient = aOptions.getEnvironmentClient;
+ }
+ if (aOptions.releaseActor) {
+ this._releaseActor = aOptions.releaseActor;
+ }
+ },
+
+ /**
+ * Sets the functions used when evaluating strings in the variables view.
+ *
+ * @param object aOptions
+ * Options for configuring the macros. Supported options:
+ * - overrideValueEvalMacro: callback for creating an overriding eval macro
+ * - getterOrSetterEvalMacro: callback for creating a getter/setter eval macro
+ * - simpleValueEvalMacro: callback for creating a simple value eval macro
+ */
+ _setEvaluationMacros: function(aOptions) {
+ if (aOptions.overrideValueEvalMacro) {
+ this._overrideValueEvalMacro = aOptions.overrideValueEvalMacro;
+ }
+ if (aOptions.getterOrSetterEvalMacro) {
+ this._getterOrSetterEvalMacro = aOptions.getterOrSetterEvalMacro;
+ }
+ if (aOptions.simpleValueEvalMacro) {
+ this._simpleValueEvalMacro = aOptions.simpleValueEvalMacro;
+ }
+ },
+
+ /**
+ * Populate a long string into a target using a grip.
+ *
+ * @param Variable aTarget
+ * The target Variable/Property to put the retrieved string into.
+ * @param LongStringActor aGrip
+ * The long string grip that use to retrieve the full string.
+ * @return Promise
+ * The promise that will be resolved when the string is retrieved.
+ */
+ _populateFromLongString: function(aTarget, aGrip){
+ let deferred = promise.defer();
+
+ let from = aGrip.initial.length;
+ let to = Math.min(aGrip.length, MAX_LONG_STRING_LENGTH);
+
+ this._getLongStringClient(aGrip).substring(from, to, aResponse => {
+ // Stop tracking the actor because it's no longer needed.
+ this.releaseActor(aGrip);
+
+ // Replace the preview with the full string and make it non-expandable.
+ aTarget.onexpand = null;
+ aTarget.setGrip(aGrip.initial + aResponse.substring);
+ aTarget.hideArrow();
+
+ deferred.resolve();
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Adds properties to a Scope, Variable, or Property in the view. Triggered
+ * when a scope is expanded or certain variables are hovered.
+ *
+ * @param Scope aTarget
+ * The Scope where the properties will be placed into.
+ * @param object aGrip
+ * The grip to use to populate the target.
+ */
+ _populateFromObject: function(aTarget, aGrip) {
+ let deferred = promise.defer();
+
+ if (aGrip.class === "Promise" && aGrip.promiseState) {
+ const { state, value, reason } = aGrip.promiseState;
+ aTarget.addItem("<state>", { value: state });
+ if (state === "fulfilled") {
+ this.addExpander(aTarget.addItem("<value>", { value }), value);
+ } else if (state === "rejected") {
+ this.addExpander(aTarget.addItem("<reason>", { value: reason }), reason);
+ }
+ }
+
+ let objectClient = this._getObjectClient(aGrip);
+ objectClient.getPrototypeAndProperties(aResponse => {
+ let { ownProperties, prototype } = aResponse;
+ // 'safeGetterValues' is new and isn't necessary defined on old actors.
+ let safeGetterValues = aResponse.safeGetterValues || {};
+ let sortable = VariablesView.isSortable(aGrip.class);
+
+ // Merge the safe getter values into one object such that we can use it
+ // in VariablesView.
+ for (let name of Object.keys(safeGetterValues)) {
+ if (name in ownProperties) {
+ let { getterValue, getterPrototypeLevel } = safeGetterValues[name];
+ ownProperties[name].getterValue = getterValue;
+ ownProperties[name].getterPrototypeLevel = getterPrototypeLevel;
+ } else {
+ ownProperties[name] = safeGetterValues[name];
+ }
+ }
+
+ // Add all the variable properties.
+ if (ownProperties) {
+ aTarget.addItems(ownProperties, {
+ // Not all variables need to force sorted properties.
+ sorted: sortable,
+ // Expansion handlers must be set after the properties are added.
+ callback: this.addExpander
+ });
+ }
+
+ // Add the variable's __proto__.
+ if (prototype && prototype.type != "null") {
+ let proto = aTarget.addItem("__proto__", { value: prototype });
+ // Expansion handlers must be set after the properties are added.
+ this.addExpander(proto, prototype);
+ }
+
+ // If the object is a function we need to fetch its scope chain
+ // to show them as closures for the respective function.
+ if (aGrip.class == "Function") {
+ objectClient.getScope(aResponse => {
+ if (aResponse.error) {
+ // This function is bound to a built-in object or it's not present
+ // in the current scope chain. Not necessarily an actual error,
+ // it just means that there's no closure for the function.
+ console.warn(aResponse.error + ": " + aResponse.message);
+ return void deferred.resolve();
+ }
+ this._populateWithClosure(aTarget, aResponse.scope).then(deferred.resolve);
+ });
+ } else {
+ deferred.resolve();
+ }
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Adds the scope chain elements (closures) of a function variable.
+ *
+ * @param Variable aTarget
+ * The variable where the properties will be placed into.
+ * @param Scope aScope
+ * The lexical environment form as specified in the protocol.
+ */
+ _populateWithClosure: function(aTarget, aScope) {
+ let objectScopes = [];
+ let environment = aScope;
+ let funcScope = aTarget.addItem("<Closure>");
+ funcScope.target.setAttribute("scope", "");
+ funcScope.showArrow();
+
+ do {
+ // Create a scope to contain all the inspected variables.
+ let label = StackFrameUtils.getScopeLabel(environment);
+
+ // Block scopes may have the same label, so make addItem allow duplicates.
+ let closure = funcScope.addItem(label, undefined, true);
+ closure.target.setAttribute("scope", "");
+ closure.showArrow();
+
+ // Add nodes for every argument and every other variable in scope.
+ if (environment.bindings) {
+ this._populateWithEnvironmentBindings(closure, environment.bindings);
+ } else {
+ let deferred = promise.defer();
+ objectScopes.push(deferred.promise);
+ this._getEnvironmentClient(environment).getBindings(response => {
+ this._populateWithEnvironmentBindings(closure, response.bindings);
+ deferred.resolve();
+ });
+ }
+ } while ((environment = environment.parent));
+
+ return promise.all(objectScopes).then(() => {
+ // Signal that scopes have been fetched.
+ this.view.emit("fetched", "scopes", funcScope);
+ });
+ },
+
+ /**
+ * Adds nodes for every specified binding to the closure node.
+ *
+ * @param Variable aTarget
+ * The variable where the bindings will be placed into.
+ * @param object aBindings
+ * The bindings form as specified in the protocol.
+ */
+ _populateWithEnvironmentBindings: function(aTarget, aBindings) {
+ // Add nodes for every argument in the scope.
+ aTarget.addItems(aBindings.arguments.reduce((accumulator, arg) => {
+ let name = Object.getOwnPropertyNames(arg)[0];
+ let descriptor = arg[name];
+ accumulator[name] = descriptor;
+ return accumulator;
+ }, {}), {
+ // Arguments aren't sorted.
+ sorted: false,
+ // Expansion handlers must be set after the properties are added.
+ callback: this.addExpander
+ });
+
+ // Add nodes for every other variable in the scope.
+ aTarget.addItems(aBindings.variables, {
+ // Not all variables need to force sorted properties.
+ sorted: VARIABLES_SORTING_ENABLED,
+ // Expansion handlers must be set after the properties are added.
+ callback: this.addExpander
+ });
+ },
+
+ /**
+ * Adds an 'onexpand' callback for a variable, lazily handling
+ * the addition of new properties.
+ *
+ * @param Variable aTarget
+ * The variable where the properties will be placed into.
+ * @param any aSource
+ * The source to use to populate the target.
+ */
+ addExpander: function(aTarget, aSource) {
+ // Attach evaluation macros as necessary.
+ if (aTarget.getter || aTarget.setter) {
+ aTarget.evaluationMacro = this._overrideValueEvalMacro;
+ let getter = aTarget.get("get");
+ if (getter) {
+ getter.evaluationMacro = this._getterOrSetterEvalMacro;
+ }
+ let setter = aTarget.get("set");
+ if (setter) {
+ setter.evaluationMacro = this._getterOrSetterEvalMacro;
+ }
+ } else {
+ aTarget.evaluationMacro = this._simpleValueEvalMacro;
+ }
+
+ // If the source is primitive then an expander is not needed.
+ if (VariablesView.isPrimitive({ value: aSource })) {
+ return;
+ }
+
+ // If the source is a long string then show the arrow.
+ if (WebConsoleUtils.isActorGrip(aSource) && aSource.type == "longString") {
+ aTarget.showArrow();
+ }
+
+ // Make sure that properties are always available on expansion.
+ aTarget.onexpand = () => this.populate(aTarget, aSource);
+
+ // Some variables are likely to contain a very large number of properties.
+ // It's a good idea to be prepared in case of an expansion.
+ if (aTarget.shouldPrefetch) {
+ aTarget.addEventListener("mouseover", aTarget.onexpand, false);
+ }
+
+ // Register all the actors that this controller now depends on.
+ for (let grip of [aTarget.value, aTarget.getter, aTarget.setter]) {
+ if (WebConsoleUtils.isActorGrip(grip)) {
+ this._actors.add(grip.actor);
+ }
+ }
+ },
+
+ /**
+ * Adds properties to a Scope, Variable, or Property in the view. Triggered
+ * when a scope is expanded or certain variables are hovered.
+ *
+ * This does not expand the target, it only populates it.
+ *
+ * @param Scope aTarget
+ * The Scope to be expanded.
+ * @param object aSource
+ * The source to use to populate the target.
+ * @return Promise
+ * The promise that is resolved once the target has been expanded.
+ */
+ populate: function(aTarget, aSource) {
+ // Fetch the variables only once.
+ if (aTarget._fetched) {
+ return aTarget._fetched;
+ }
+ // Make sure the source grip is available.
+ if (!aSource) {
+ return promise.reject(new Error("No actor grip was given for the variable."));
+ }
+
+ let deferred = promise.defer();
+ aTarget._fetched = deferred.promise;
+
+ // If the target is a Variable or Property then we're fetching properties.
+ if (VariablesView.isVariable(aTarget)) {
+ this._populateFromObject(aTarget, aSource).then(() => {
+ // Signal that properties have been fetched.
+ this.view.emit("fetched", "properties", aTarget);
+ // Commit the hierarchy because new items were added.
+ this.view.commitHierarchy();
+ deferred.resolve();
+ });
+ return deferred.promise;
+ }
+
+ switch (aSource.type) {
+ case "longString":
+ this._populateFromLongString(aTarget, aSource).then(() => {
+ // Signal that a long string has been fetched.
+ this.view.emit("fetched", "longString", aTarget);
+ deferred.resolve();
+ });
+ break;
+ case "with":
+ case "object":
+ this._populateFromObject(aTarget, aSource.object).then(() => {
+ // Signal that variables have been fetched.
+ this.view.emit("fetched", "variables", aTarget);
+ // Commit the hierarchy because new items were added.
+ this.view.commitHierarchy();
+ deferred.resolve();
+ });
+ break;
+ case "block":
+ case "function":
+ this._populateWithEnvironmentBindings(aTarget, aSource.bindings);
+ // No need to signal that variables have been fetched, since
+ // the scope arguments and variables are already attached to the
+ // environment bindings, so pausing the active thread is unnecessary.
+ // Commit the hierarchy because new items were added.
+ this.view.commitHierarchy();
+ deferred.resolve();
+ break;
+ default:
+ let error = "Unknown Debugger.Environment type: " + aSource.type;
+ Cu.reportError(error);
+ deferred.reject(error);
+ }
+
+ return deferred.promise;
+ },
+
+ /**
+ * Release an actor from the controller.
+ *
+ * @param object aActor
+ * The actor to release.
+ */
+ releaseActor: function(aActor){
+ if (this._releaseActor) {
+ this._releaseActor(aActor);
+ }
+ this._actors.delete(aActor);
+ },
+
+ /**
+ * Release all the actors referenced by the controller, optionally filtered.
+ *
+ * @param function aFilter [optional]
+ * Callback to filter which actors are released.
+ */
+ releaseActors: function(aFilter) {
+ for (let actor of this._actors) {
+ if (!aFilter || aFilter(actor)) {
+ this.releaseActor(actor);
+ }
+ }
+ },
+
+ /**
+ * Helper function for setting up a single Scope with a single Variable
+ * contained within it.
+ *
+ * This function will empty the variables view.
+ *
+ * @param object aOptions
+ * Options for the contents of the view:
+ * - objectActor: the grip of the new ObjectActor to show.
+ * - rawObject: the raw object to show.
+ * - label: the label for the inspected object.
+ * @param object aConfiguration
+ * Additional options for the controller:
+ * - overrideValueEvalMacro: @see _setEvaluationMacros
+ * - getterOrSetterEvalMacro: @see _setEvaluationMacros
+ * - simpleValueEvalMacro: @see _setEvaluationMacros
+ * @return Object
+ * - variable: the created Variable.
+ * - expanded: the Promise that resolves when the variable expands.
+ */
+ setSingleVariable: function(aOptions, aConfiguration = {}) {
+ this._setEvaluationMacros(aConfiguration);
+ this.view.empty();
+
+ let scope = this.view.addScope(aOptions.label);
+ scope.expanded = true; // Expand the scope by default.
+ scope.locked = true; // Prevent collpasing the scope.
+
+ let variable = scope.addItem("", { enumerable: true });
+ let populated;
+
+ if (aOptions.objectActor) {
+ populated = this.populate(variable, aOptions.objectActor);
+ variable.expand();
+ } else if (aOptions.rawObject) {
+ variable.populate(aOptions.rawObject, { expanded: true });
+ populated = promise.resolve();
+ }
+
+ return { variable: variable, expanded: populated };
+ },
+};
+
+
+/**
+ * Attaches a VariablesViewController to a VariablesView if it doesn't already
+ * have one.
+ *
+ * @param VariablesView aView
+ * The view to attach to.
+ * @param object aOptions
+ * The options to use in creating the controller.
+ * @return VariablesViewController
+ */
+VariablesViewController.attach = function(aView, aOptions) {
+ if (aView.controller) {
+ return aView.controller;
+ }
+ return new VariablesViewController(aView, aOptions);
+};
+
+/**
+ * Utility functions for handling stackframes.
+ */
+let StackFrameUtils = {
+ /**
+ * Create a textual representation for the specified stack frame
+ * to display in the stackframes container.
+ *
+ * @param object aFrame
+ * The stack frame to label.
+ */
+ getFrameTitle: function(aFrame) {
+ if (aFrame.type == "call") {
+ let c = aFrame.callee;
+ return (c.name || c.userDisplayName || c.displayName || "(anonymous)");
+ }
+ return "(" + aFrame.type + ")";
+ },
+
+ /**
+ * Constructs a scope label based on its environment.
+ *
+ * @param object aEnv
+ * The scope's environment.
+ * @return string
+ * The scope's label.
+ */
+ getScopeLabel: function(aEnv) {
+ let name = "";
+
+ // Name the outermost scope Global.
+ if (!aEnv.parent) {
+ name = L10N.getStr("globalScopeLabel");
+ }
+ // Otherwise construct the scope name.
+ else {
+ name = aEnv.type.charAt(0).toUpperCase() + aEnv.type.slice(1);
+ }
+
+ let label = L10N.getFormatStr("scopeLabel", name);
+ switch (aEnv.type) {
+ case "with":
+ case "object":
+ label += " [" + aEnv.object.class + "]";
+ break;
+ case "function":
+ let f = aEnv.function;
+ label += " [" +
+ (f.name || f.userDisplayName || f.displayName || "(anonymous)") +
+ "]";
+ break;
+ }
+ return label;
+ }
+};
+
+/**
+ * Localization convenience methods.
+ */
+let L10N = new ViewHelpers.L10N(DBG_STRINGS_URI);
diff --git a/toolkit/devtools/shared/widgets/ViewHelpers.jsm b/toolkit/devtools/shared/widgets/ViewHelpers.jsm
new file mode 100644
index 000000000..aac494789
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/ViewHelpers.jsm
@@ -0,0 +1,1735 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+const PANE_APPEARANCE_DELAY = 50;
+const PAGE_SIZE_ITEM_COUNT_RATIO = 5;
+const WIDGET_FOCUSABLE_NODES = new Set(["vbox", "hbox"]);
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm");
+
+this.EXPORTED_SYMBOLS = [
+ "Heritage", "ViewHelpers", "WidgetMethods",
+ "setNamedTimeout", "clearNamedTimeout",
+ "setConditionalTimeout", "clearConditionalTimeout",
+];
+
+/**
+ * Inheritance helpers from the addon SDK's core/heritage.
+ * Remove these when all devtools are loadered.
+ */
+this.Heritage = {
+ /**
+ * @see extend in sdk/core/heritage.
+ */
+ extend: function(aPrototype, aProperties = {}) {
+ return Object.create(aPrototype, this.getOwnPropertyDescriptors(aProperties));
+ },
+
+ /**
+ * @see getOwnPropertyDescriptors in sdk/core/heritage.
+ */
+ getOwnPropertyDescriptors: function(aObject) {
+ return Object.getOwnPropertyNames(aObject).reduce((aDescriptor, aName) => {
+ aDescriptor[aName] = Object.getOwnPropertyDescriptor(aObject, aName);
+ return aDescriptor;
+ }, {});
+ }
+};
+
+/**
+ * Helper for draining a rapid succession of events and invoking a callback
+ * once everything settles down.
+ *
+ * @param string aId
+ * A string identifier for the named timeout.
+ * @param number aWait
+ * The amount of milliseconds to wait after no more events are fired.
+ * @param function aCallback
+ * Invoked when no more events are fired after the specified time.
+ */
+this.setNamedTimeout = function setNamedTimeout(aId, aWait, aCallback) {
+ clearNamedTimeout(aId);
+
+ namedTimeoutsStore.set(aId, setTimeout(() =>
+ namedTimeoutsStore.delete(aId) && aCallback(), aWait));
+};
+
+/**
+ * Clears a named timeout.
+ * @see setNamedTimeout
+ *
+ * @param string aId
+ * A string identifier for the named timeout.
+ */
+this.clearNamedTimeout = function clearNamedTimeout(aId) {
+ if (!namedTimeoutsStore) {
+ return;
+ }
+ clearTimeout(namedTimeoutsStore.get(aId));
+ namedTimeoutsStore.delete(aId);
+};
+
+/**
+ * Same as `setNamedTimeout`, but invokes the callback only if the provided
+ * predicate function returns true. Otherwise, the timeout is re-triggered.
+ *
+ * @param string aId
+ * A string identifier for the conditional timeout.
+ * @param number aWait
+ * The amount of milliseconds to wait after no more events are fired.
+ * @param function aPredicate
+ * The predicate function used to determine whether the timeout restarts.
+ * @param function aCallback
+ * Invoked when no more events are fired after the specified time, and
+ * the provided predicate function returns true.
+ */
+this.setConditionalTimeout = function setConditionalTimeout(aId, aWait, aPredicate, aCallback) {
+ setNamedTimeout(aId, aWait, function maybeCallback() {
+ if (aPredicate()) {
+ aCallback();
+ return;
+ }
+ setConditionalTimeout(aId, aWait, aPredicate, aCallback);
+ });
+};
+
+/**
+ * Clears a conditional timeout.
+ * @see setConditionalTimeout
+ *
+ * @param string aId
+ * A string identifier for the conditional timeout.
+ */
+this.clearConditionalTimeout = function clearConditionalTimeout(aId) {
+ clearNamedTimeout(aId);
+};
+
+XPCOMUtils.defineLazyGetter(this, "namedTimeoutsStore", () => new Map());
+
+/**
+ * Helpers for creating and messaging between UI components.
+ */
+this.ViewHelpers = {
+ /**
+ * Convenience method, dispatching a custom event.
+ *
+ * @param nsIDOMNode aTarget
+ * A custom target element to dispatch the event from.
+ * @param string aType
+ * The name of the event.
+ * @param any aDetail
+ * The data passed when initializing the event.
+ * @return boolean
+ * True if the event was cancelled or a registered handler
+ * called preventDefault.
+ */
+ dispatchEvent: function(aTarget, aType, aDetail) {
+ if (!(aTarget instanceof Ci.nsIDOMNode)) {
+ return true; // Event cancelled.
+ }
+ let document = aTarget.ownerDocument || aTarget;
+ let dispatcher = aTarget.ownerDocument ? aTarget : document.documentElement;
+
+ let event = document.createEvent("CustomEvent");
+ event.initCustomEvent(aType, true, true, aDetail);
+ return dispatcher.dispatchEvent(event);
+ },
+
+ /**
+ * Helper delegating some of the DOM attribute methods of a node to a widget.
+ *
+ * @param object aWidget
+ * The widget to assign the methods to.
+ * @param nsIDOMNode aNode
+ * A node to delegate the methods to.
+ */
+ delegateWidgetAttributeMethods: function(aWidget, aNode) {
+ aWidget.getAttribute =
+ aWidget.getAttribute || aNode.getAttribute.bind(aNode);
+ aWidget.setAttribute =
+ aWidget.setAttribute || aNode.setAttribute.bind(aNode);
+ aWidget.removeAttribute =
+ aWidget.removeAttribute || aNode.removeAttribute.bind(aNode);
+ },
+
+ /**
+ * Helper delegating some of the DOM event methods of a node to a widget.
+ *
+ * @param object aWidget
+ * The widget to assign the methods to.
+ * @param nsIDOMNode aNode
+ * A node to delegate the methods to.
+ */
+ delegateWidgetEventMethods: function(aWidget, aNode) {
+ aWidget.addEventListener =
+ aWidget.addEventListener || aNode.addEventListener.bind(aNode);
+ aWidget.removeEventListener =
+ aWidget.removeEventListener || aNode.removeEventListener.bind(aNode);
+ },
+
+ /**
+ * Checks if the specified object looks like it's been decorated by an
+ * event emitter.
+ *
+ * @return boolean
+ * True if it looks, walks and quacks like an event emitter.
+ */
+ isEventEmitter: function(aObject) {
+ return aObject && aObject.on && aObject.off && aObject.once && aObject.emit;
+ },
+
+ /**
+ * Checks if the specified object is an instance of a DOM node.
+ *
+ * @return boolean
+ * True if it's a node, false otherwise.
+ */
+ isNode: function(aObject) {
+ return aObject instanceof Ci.nsIDOMNode ||
+ aObject instanceof Ci.nsIDOMElement ||
+ aObject instanceof Ci.nsIDOMDocumentFragment;
+ },
+
+ /**
+ * Prevents event propagation when navigation keys are pressed.
+ *
+ * @param Event e
+ * The event to be prevented.
+ */
+ preventScrolling: function(e) {
+ switch (e.keyCode) {
+ case e.DOM_VK_UP:
+ case e.DOM_VK_DOWN:
+ case e.DOM_VK_LEFT:
+ case e.DOM_VK_RIGHT:
+ case e.DOM_VK_PAGE_UP:
+ case e.DOM_VK_PAGE_DOWN:
+ case e.DOM_VK_HOME:
+ case e.DOM_VK_END:
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ },
+
+ /**
+ * Sets a side pane hidden or visible.
+ *
+ * @param object aFlags
+ * An object containing some of the following properties:
+ * - visible: true if the pane should be shown, false to hide
+ * - animated: true to display an animation on toggle
+ * - delayed: true to wait a few cycles before toggle
+ * - callback: a function to invoke when the toggle finishes
+ * @param nsIDOMNode aPane
+ * The element representing the pane to toggle.
+ */
+ togglePane: function(aFlags, aPane) {
+ // Make sure a pane is actually available first.
+ if (!aPane) {
+ return;
+ }
+
+ // Hiding is always handled via margins, not the hidden attribute.
+ aPane.removeAttribute("hidden");
+
+ // Add a class to the pane to handle min-widths, margins and animations.
+ if (!aPane.classList.contains("generic-toggled-side-pane")) {
+ aPane.classList.add("generic-toggled-side-pane");
+ }
+
+ // Avoid useless toggles.
+ if (aFlags.visible == !aPane.hasAttribute("pane-collapsed")) {
+ if (aFlags.callback) aFlags.callback();
+ return;
+ }
+
+ // The "animated" attributes enables animated toggles (slide in-out).
+ if (aFlags.animated) {
+ aPane.setAttribute("animated", "");
+ } else {
+ aPane.removeAttribute("animated");
+ }
+
+ // Computes and sets the pane margins in order to hide or show it.
+ let doToggle = () => {
+ if (aFlags.visible) {
+ aPane.style.marginLeft = "0";
+ aPane.style.marginRight = "0";
+ aPane.removeAttribute("pane-collapsed");
+ } else {
+ let margin = ~~(aPane.getAttribute("width")) + 1;
+ aPane.style.marginLeft = -margin + "px";
+ aPane.style.marginRight = -margin + "px";
+ aPane.setAttribute("pane-collapsed", "");
+ }
+
+ // Invoke the callback when the transition ended.
+ if (aFlags.animated) {
+ aPane.addEventListener("transitionend", function onEvent() {
+ aPane.removeEventListener("transitionend", onEvent, false);
+ if (aFlags.callback) aFlags.callback();
+ }, false);
+ }
+ // Invoke the callback immediately since there's no transition.
+ else {
+ if (aFlags.callback) aFlags.callback();
+ }
+ }
+
+ // Sometimes it's useful delaying the toggle a few ticks to ensure
+ // a smoother slide in-out animation.
+ if (aFlags.delayed) {
+ aPane.ownerDocument.defaultView.setTimeout(doToggle, PANE_APPEARANCE_DELAY);
+ } else {
+ doToggle();
+ }
+ }
+};
+
+/**
+ * Localization convenience methods.
+ *
+ * @param string aStringBundleName
+ * The desired string bundle's name.
+ */
+ViewHelpers.L10N = function(aStringBundleName) {
+ XPCOMUtils.defineLazyGetter(this, "stringBundle", () =>
+ Services.strings.createBundle(aStringBundleName));
+
+ XPCOMUtils.defineLazyGetter(this, "ellipsis", () =>
+ Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data);
+};
+
+ViewHelpers.L10N.prototype = {
+ stringBundle: null,
+
+ /**
+ * L10N shortcut function.
+ *
+ * @param string aName
+ * @return string
+ */
+ getStr: function(aName) {
+ return this.stringBundle.GetStringFromName(aName);
+ },
+
+ /**
+ * L10N shortcut function.
+ *
+ * @param string aName
+ * @param array aArgs
+ * @return string
+ */
+ getFormatStr: function(aName, ...aArgs) {
+ return this.stringBundle.formatStringFromName(aName, aArgs, aArgs.length);
+ },
+
+ /**
+ * L10N shortcut function for numeric arguments that need to be formatted.
+ * All numeric arguments will be fixed to 2 decimals and given a localized
+ * decimal separator. Other arguments will be left alone.
+ *
+ * @param string aName
+ * @param array aArgs
+ * @return string
+ */
+ getFormatStrWithNumbers: function(aName, ...aArgs) {
+ let newArgs = aArgs.map(x => typeof x == "number" ? this.numberWithDecimals(x, 2) : x);
+ return this.stringBundle.formatStringFromName(aName, newArgs, newArgs.length);
+ },
+
+ /**
+ * Converts a number to a locale-aware string format and keeps a certain
+ * number of decimals.
+ *
+ * @param number aNumber
+ * The number to convert.
+ * @param number aDecimals [optional]
+ * Total decimals to keep.
+ * @return string
+ * The localized number as a string.
+ */
+ numberWithDecimals: function(aNumber, aDecimals = 0) {
+ // If this is an integer, don't do anything special.
+ if (aNumber == (aNumber | 0)) {
+ return aNumber;
+ }
+ if (isNaN(aNumber) || aNumber == null) {
+ return "0";
+ }
+ // Remove {n} trailing decimals. Can't use toFixed(n) because
+ // toLocaleString converts the number to a string. Also can't use
+ // toLocaleString(, { maximumFractionDigits: n }) because it's not
+ // implemented on OS X (bug 368838). Gross.
+ let localized = aNumber.toLocaleString(); // localize
+
+ // If no grouping or decimal separators are available, bail out, because
+ // padding with zeros at the end of the string won't make sense anymore.
+ if (!localized.match(/[^\d]/)) {
+ return localized;
+ }
+
+ let padded = localized + new Array(aDecimals).join("0"); // pad with zeros
+ let match = padded.match("([^]*?\\d{" + aDecimals + "})\\d*$");
+ return match.pop();
+ }
+};
+
+/**
+ * Shortcuts for lazily accessing and setting various preferences.
+ * Usage:
+ * let prefs = new ViewHelpers.Prefs("root.path.to.branch", {
+ * myIntPref: ["Int", "leaf.path.to.my-int-pref"],
+ * myCharPref: ["Char", "leaf.path.to.my-char-pref"],
+ * ...
+ * });
+ *
+ * prefs.myCharPref = "foo";
+ * let aux = prefs.myCharPref;
+ *
+ * @param string aPrefsRoot
+ * The root path to the required preferences branch.
+ * @param object aPrefsObject
+ * An object containing { accessorName: [prefType, prefName] } keys.
+ */
+ViewHelpers.Prefs = function(aPrefsRoot = "", aPrefsObject = {}) {
+ this._root = aPrefsRoot;
+ this._cache = new Map();
+
+ for (let accessorName in aPrefsObject) {
+ let [prefType, prefName] = aPrefsObject[accessorName];
+ this.map(accessorName, prefType, prefName);
+ }
+};
+
+ViewHelpers.Prefs.prototype = {
+ /**
+ * Helper method for getting a pref value.
+ *
+ * @param string aType
+ * @param string aPrefName
+ * @return any
+ */
+ _get: function(aType, aPrefName) {
+ let cachedPref = this._cache.get(aPrefName);
+ if (cachedPref !== undefined) {
+ return cachedPref;
+ }
+ let value = Services.prefs["get" + aType + "Pref"](aPrefName);
+ this._cache.set(aPrefName, value);
+ return value;
+ },
+
+ /**
+ * Helper method for setting a pref value.
+ *
+ * @param string aType
+ * @param string aPrefName
+ * @param any aValue
+ */
+ _set: function(aType, aPrefName, aValue) {
+ Services.prefs["set" + aType + "Pref"](aPrefName, aValue);
+ this._cache.set(aPrefName, aValue);
+ },
+
+ /**
+ * Maps a property name to a pref, defining lazy getters and setters.
+ * Supported types are "Bool", "Char", "Int" and "Json" (which is basically
+ * just sugar for "Char" using the standard JSON serializer).
+ *
+ * @param string aAccessorName
+ * @param string aType
+ * @param string aPrefName
+ * @param array aSerializer
+ */
+ map: function(aAccessorName, aType, aPrefName, aSerializer = { in: e => e, out: e => e }) {
+ if (aType == "Json") {
+ this.map(aAccessorName, "Char", aPrefName, { in: JSON.parse, out: JSON.stringify });
+ return;
+ }
+
+ Object.defineProperty(this, aAccessorName, {
+ get: () => aSerializer.in(this._get(aType, [this._root, aPrefName].join("."))),
+ set: (e) => this._set(aType, [this._root, aPrefName].join("."), aSerializer.out(e))
+ });
+ },
+
+ /**
+ * Clears all the cached preferences' values.
+ */
+ refresh: function() {
+ this._cache.clear();
+ }
+};
+
+/**
+ * A generic Item is used to describe children present in a Widget.
+ *
+ * This is basically a very thin wrapper around an nsIDOMNode, with a few
+ * characteristics, like a `value` and an `attachment`.
+ *
+ * The characteristics are optional, and their meaning is entirely up to you.
+ * - The `value` should be a string, passed as an argument.
+ * - The `attachment` is any kind of primitive or object, passed as an argument.
+ *
+ * Iterable via "for (let childItem of parentItem) { }".
+ *
+ * @param object aOwnerView
+ * The owner view creating this item.
+ * @param nsIDOMNode aElement
+ * A prebuilt node to be wrapped.
+ * @param string aValue
+ * A string identifying the node.
+ * @param any aAttachment
+ * Some attached primitive/object.
+ */
+function Item(aOwnerView, aElement, aValue, aAttachment) {
+ this.ownerView = aOwnerView;
+ this.attachment = aAttachment;
+ this._value = aValue + "";
+ this._prebuiltNode = aElement;
+};
+
+Item.prototype = {
+ get value() { return this._value; },
+ get target() { return this._target; },
+ get prebuiltNode() { return this._prebuiltNode; },
+
+ /**
+ * Immediately appends a child item to this item.
+ *
+ * @param nsIDOMNode aElement
+ * An nsIDOMNode representing the child element to append.
+ * @param object aOptions [optional]
+ * Additional options or flags supported by this operation:
+ * - attachment: some attached primitive/object for the item
+ * - attributes: a batch of attributes set to the displayed element
+ * - finalize: function invoked when the child item is removed
+ * @return Item
+ * The item associated with the displayed element.
+ */
+ append: function(aElement, aOptions = {}) {
+ let item = new Item(this, aElement, "", aOptions.attachment);
+
+ // Entangle the item with the newly inserted child node.
+ // Make sure this is done with the value returned by appendChild(),
+ // to avoid storing a potential DocumentFragment.
+ this._entangleItem(item, this._target.appendChild(aElement));
+
+ // Handle any additional options after entangling the item.
+ if (aOptions.attributes) {
+ aOptions.attributes.forEach(e => item._target.setAttribute(e[0], e[1]));
+ }
+ if (aOptions.finalize) {
+ item.finalize = aOptions.finalize;
+ }
+
+ // Return the item associated with the displayed element.
+ return item;
+ },
+
+ /**
+ * Immediately removes the specified child item from this item.
+ *
+ * @param Item aItem
+ * The item associated with the element to remove.
+ */
+ remove: function(aItem) {
+ if (!aItem) {
+ return;
+ }
+ this._target.removeChild(aItem._target);
+ this._untangleItem(aItem);
+ },
+
+ /**
+ * Entangles an item (model) with a displayed node element (view).
+ *
+ * @param Item aItem
+ * The item describing a target element.
+ * @param nsIDOMNode aElement
+ * The element displaying the item.
+ */
+ _entangleItem: function(aItem, aElement) {
+ this._itemsByElement.set(aElement, aItem);
+ aItem._target = aElement;
+ },
+
+ /**
+ * Untangles an item (model) from a displayed node element (view).
+ *
+ * @param Item aItem
+ * The item describing a target element.
+ */
+ _untangleItem: function(aItem) {
+ if (aItem.finalize) {
+ aItem.finalize(aItem);
+ }
+ for (let childItem of aItem) {
+ aItem.remove(childItem);
+ }
+
+ this._unlinkItem(aItem);
+ aItem._target = null;
+ },
+
+ /**
+ * Deletes an item from the its parent's storage maps.
+ *
+ * @param Item aItem
+ * The item describing a target element.
+ */
+ _unlinkItem: function(aItem) {
+ this._itemsByElement.delete(aItem._target);
+ },
+
+ /**
+ * Returns a string representing the object.
+ * Avoid using `toString` to avoid accidental JSONification.
+ * @return string
+ */
+ stringify: function() {
+ return JSON.stringify({
+ value: this._value,
+ target: this._target + "",
+ prebuiltNode: this._prebuiltNode + "",
+ attachment: this.attachment
+ }, null, 2);
+ },
+
+ _value: "",
+ _target: null,
+ _prebuiltNode: null,
+ finalize: null,
+ attachment: null
+};
+
+// Creating maps thousands of times for widgets with a large number of children
+// fills up a lot of memory. Make sure these are instantiated only if needed.
+DevToolsUtils.defineLazyPrototypeGetter(Item.prototype, "_itemsByElement", () => new Map());
+
+/**
+ * Some generic Widget methods handling Item instances.
+ * Iterable via "for (let childItem of wrappedView) { }".
+ *
+ * Usage:
+ * function MyView() {
+ * this.widget = new MyWidget(document.querySelector(".my-node"));
+ * }
+ *
+ * MyView.prototype = Heritage.extend(WidgetMethods, {
+ * myMethod: function() {},
+ * ...
+ * });
+ *
+ * See https://gist.github.com/victorporof/5749386 for more details.
+ * The devtools/shared/widgets/SimpleListWidget.jsm is an implementation example.
+ *
+ * Language:
+ * - An "item" is an instance of an Item.
+ * - An "element" or "node" is a nsIDOMNode.
+ *
+ * The supplied widget can be any object implementing the following methods:
+ * - function:nsIDOMNode insertItemAt(aIndex:number, aNode:nsIDOMNode, aValue:string)
+ * - function:nsIDOMNode getItemAtIndex(aIndex:number)
+ * - function removeChild(aChild:nsIDOMNode)
+ * - function removeAllItems()
+ * - get:nsIDOMNode selectedItem()
+ * - set selectedItem(aChild:nsIDOMNode)
+ * - function getAttribute(aName:string)
+ * - function setAttribute(aName:string, aValue:string)
+ * - function removeAttribute(aName:string)
+ * - function addEventListener(aName:string, aCallback:function, aBubbleFlag:boolean)
+ * - function removeEventListener(aName:string, aCallback:function, aBubbleFlag:boolean)
+ *
+ * Optional methods that can be implemented by the widget:
+ * - function ensureElementIsVisible(aChild:nsIDOMNode)
+ *
+ * Optional attributes that may be handled (when calling get/set/removeAttribute):
+ * - "emptyText": label temporarily added when there are no items present
+ * - "headerText": label permanently added as a header
+ *
+ * For automagical keyboard and mouse accessibility, the widget should be an
+ * event emitter with the following events:
+ * - "keyPress" -> (aName:string, aEvent:KeyboardEvent)
+ * - "mousePress" -> (aName:string, aEvent:MouseEvent)
+ */
+this.WidgetMethods = {
+ /**
+ * Sets the element node or widget associated with this container.
+ * @param nsIDOMNode | object aWidget
+ */
+ set widget(aWidget) {
+ this._widget = aWidget;
+
+
+ // Can't use a WeakMap for _itemsByValue because keys are strings, and
+ // can't use one for _itemsByElement either, since it needs to be iterable.
+ XPCOMUtils.defineLazyGetter(this, "_itemsByValue", () => new Map());
+ XPCOMUtils.defineLazyGetter(this, "_itemsByElement", () => new Map());
+ XPCOMUtils.defineLazyGetter(this, "_stagedItems", () => []);
+
+ // Handle internal events emitted by the widget if necessary.
+ if (ViewHelpers.isEventEmitter(aWidget)) {
+ aWidget.on("keyPress", this._onWidgetKeyPress.bind(this));
+ aWidget.on("mousePress", this._onWidgetMousePress.bind(this));
+ }
+ },
+
+ /**
+ * Gets the element node or widget associated with this container.
+ * @return nsIDOMNode | object
+ */
+ get widget() this._widget,
+
+ /**
+ * Prepares an item to be added to this container. This allows, for example,
+ * for a large number of items to be batched up before being sorted & added.
+ *
+ * If the "staged" flag is *not* set to true, the item will be immediately
+ * inserted at the correct position in this container, so that all the items
+ * still remain sorted. This can (possibly) be much slower than batching up
+ * multiple items.
+ *
+ * By default, this container assumes that all the items should be displayed
+ * sorted by their value. This can be overridden with the "index" flag,
+ * specifying on which position should an item be appended. The "staged" and
+ * "index" flags are mutually exclusive, meaning that all staged items
+ * will always be appended.
+ *
+ * @param nsIDOMNode aElement
+ * A prebuilt node to be wrapped.
+ * @param string aValue
+ * A string identifying the node.
+ * @param object aOptions [optional]
+ * Additional options or flags supported by this operation:
+ * - attachment: some attached primitive/object for the item
+ * - staged: true to stage the item to be appended later
+ * - index: specifies on which position should the item be appended
+ * - attributes: a batch of attributes set to the displayed element
+ * - finalize: function invoked when the item is removed
+ * @return Item
+ * The item associated with the displayed element if an unstaged push,
+ * undefined if the item was staged for a later commit.
+ */
+ push: function([aElement, aValue], aOptions = {}) {
+ let item = new Item(this, aElement, aValue, aOptions.attachment);
+
+ // Batch the item to be added later.
+ if (aOptions.staged) {
+ // An ulterior commit operation will ignore any specified index, so
+ // no reason to keep it around.
+ aOptions.index = undefined;
+ return void this._stagedItems.push({ item: item, options: aOptions });
+ }
+ // Find the target position in this container and insert the item there.
+ if (!("index" in aOptions)) {
+ return this._insertItemAt(this._findExpectedIndexFor(item), item, aOptions);
+ }
+ // Insert the item at the specified index. If negative or out of bounds,
+ // the item will be simply appended.
+ return this._insertItemAt(aOptions.index, item, aOptions);
+ },
+
+ /**
+ * Flushes all the prepared items into this container.
+ * Any specified index on the items will be ignored. Everything is appended.
+ *
+ * @param object aOptions [optional]
+ * Additional options or flags supported by this operation:
+ * - sorted: true to sort all the items before adding them
+ */
+ commit: function(aOptions = {}) {
+ let stagedItems = this._stagedItems;
+
+ // Sort the items before adding them to this container, if preferred.
+ if (aOptions.sorted) {
+ stagedItems.sort((a, b) => this._currentSortPredicate(a.item, b.item));
+ }
+ // Append the prepared items to this container.
+ for (let { item, options } of stagedItems) {
+ this._insertItemAt(-1, item, options);
+ }
+ // Recreate the temporary items list for ulterior pushes.
+ this._stagedItems.length = 0;
+ },
+
+ /**
+ * Immediately removes the specified item from this container.
+ *
+ * @param Item aItem
+ * The item associated with the element to remove.
+ */
+ remove: function(aItem) {
+ if (!aItem) {
+ return;
+ }
+ this._widget.removeChild(aItem._target);
+ this._untangleItem(aItem);
+
+ if (!this._itemsByElement.size) {
+ this._preferredValue = this.selectedValue;
+ this._widget.selectedItem = null;
+ this._widget.setAttribute("emptyText", this._emptyText);
+ }
+ },
+
+ /**
+ * Removes the item at the specified index from this container.
+ *
+ * @param number aIndex
+ * The index of the item to remove.
+ */
+ removeAt: function(aIndex) {
+ this.remove(this.getItemAtIndex(aIndex));
+ },
+
+ /**
+ * Removes the items in this container based on a predicate.
+ */
+ removeForPredicate: function(aPredicate) {
+ let item;
+ while ((item = this.getItemForPredicate(aPredicate))) {
+ this.remove(item);
+ }
+ },
+
+ /**
+ * Removes all items from this container.
+ */
+ empty: function() {
+ this._preferredValue = this.selectedValue;
+ this._widget.selectedItem = null;
+ this._widget.removeAllItems();
+ this._widget.setAttribute("emptyText", this._emptyText);
+
+ for (let [, item] of this._itemsByElement) {
+ this._untangleItem(item);
+ }
+
+ this._itemsByValue.clear();
+ this._itemsByElement.clear();
+ this._stagedItems.length = 0;
+ },
+
+ /**
+ * Ensures the specified item is visible in this container.
+ *
+ * @param Item aItem
+ * The item to bring into view.
+ */
+ ensureItemIsVisible: function(aItem) {
+ this._widget.ensureElementIsVisible(aItem._target);
+ },
+
+ /**
+ * Ensures the item at the specified index is visible in this container.
+ *
+ * @param number aIndex
+ * The index of the item to bring into view.
+ */
+ ensureIndexIsVisible: function(aIndex) {
+ this.ensureItemIsVisible(this.getItemAtIndex(aIndex));
+ },
+
+ /**
+ * Sugar for ensuring the selected item is visible in this container.
+ */
+ ensureSelectedItemIsVisible: function() {
+ this.ensureItemIsVisible(this.selectedItem);
+ },
+
+ /**
+ * If supported by the widget, the label string temporarily added to this
+ * container when there are no child items present.
+ */
+ set emptyText(aValue) {
+ this._emptyText = aValue;
+
+ // Apply the emptyText attribute right now if there are no child items.
+ if (!this._itemsByElement.size) {
+ this._widget.setAttribute("emptyText", aValue);
+ }
+ },
+
+ /**
+ * If supported by the widget, the label string permanently added to this
+ * container as a header.
+ * @param string aValue
+ */
+ set headerText(aValue) {
+ this._headerText = aValue;
+ this._widget.setAttribute("headerText", aValue);
+ },
+
+ /**
+ * Toggles all the items in this container hidden or visible.
+ *
+ * This does not change the default filtering predicate, so newly inserted
+ * items will always be visible. Use WidgetMethods.filterContents if you care.
+ *
+ * @param boolean aVisibleFlag
+ * Specifies the intended visibility.
+ */
+ toggleContents: function(aVisibleFlag) {
+ for (let [element, item] of this._itemsByElement) {
+ element.hidden = !aVisibleFlag;
+ }
+ },
+
+ /**
+ * Toggles all items in this container hidden or visible based on a predicate.
+ *
+ * @param function aPredicate [optional]
+ * Items are toggled according to the return value of this function,
+ * which will become the new default filtering predicate in this container.
+ * If unspecified, all items will be toggled visible.
+ */
+ filterContents: function(aPredicate = this._currentFilterPredicate) {
+ this._currentFilterPredicate = aPredicate;
+
+ for (let [element, item] of this._itemsByElement) {
+ element.hidden = !aPredicate(item);
+ }
+ },
+
+ /**
+ * Sorts all the items in this container based on a predicate.
+ *
+ * @param function aPredicate [optional]
+ * Items are sorted according to the return value of the function,
+ * which will become the new default sorting predicate in this container.
+ * If unspecified, all items will be sorted by their value.
+ */
+ sortContents: function(aPredicate = this._currentSortPredicate) {
+ let sortedItems = this.items.sort(this._currentSortPredicate = aPredicate);
+
+ for (let i = 0, len = sortedItems.length; i < len; i++) {
+ this.swapItems(this.getItemAtIndex(i), sortedItems[i]);
+ }
+ },
+
+ /**
+ * Visually swaps two items in this container.
+ *
+ * @param Item aFirst
+ * The first item to be swapped.
+ * @param Item aSecond
+ * The second item to be swapped.
+ */
+ swapItems: function(aFirst, aSecond) {
+ if (aFirst == aSecond) { // We're just dandy, thank you.
+ return;
+ }
+ let { _prebuiltNode: firstPrebuiltTarget, _target: firstTarget } = aFirst;
+ let { _prebuiltNode: secondPrebuiltTarget, _target: secondTarget } = aSecond;
+
+ // If the two items were constructed with prebuilt nodes as DocumentFragments,
+ // then those DocumentFragments are now empty and need to be reassembled.
+ if (firstPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) {
+ for (let node of firstTarget.childNodes) {
+ firstPrebuiltTarget.appendChild(node.cloneNode(true));
+ }
+ }
+ if (secondPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) {
+ for (let node of secondTarget.childNodes) {
+ secondPrebuiltTarget.appendChild(node.cloneNode(true));
+ }
+ }
+
+ // 1. Get the indices of the two items to swap.
+ let i = this._indexOfElement(firstTarget);
+ let j = this._indexOfElement(secondTarget);
+
+ // 2. Remeber the selection index, to reselect an item, if necessary.
+ let selectedTarget = this._widget.selectedItem;
+ let selectedIndex = -1;
+ if (selectedTarget == firstTarget) {
+ selectedIndex = i;
+ } else if (selectedTarget == secondTarget) {
+ selectedIndex = j;
+ }
+
+ // 3. Silently nuke both items, nobody needs to know about this.
+ this._widget.removeChild(firstTarget);
+ this._widget.removeChild(secondTarget);
+ this._unlinkItem(aFirst);
+ this._unlinkItem(aSecond);
+
+ // 4. Add the items again, but reversing their indices.
+ this._insertItemAt.apply(this, i < j ? [i, aSecond] : [j, aFirst]);
+ this._insertItemAt.apply(this, i < j ? [j, aFirst] : [i, aSecond]);
+
+ // 5. Restore the previous selection, if necessary.
+ if (selectedIndex == i) {
+ this._widget.selectedItem = aFirst._target;
+ } else if (selectedIndex == j) {
+ this._widget.selectedItem = aSecond._target;
+ }
+
+ // 6. Let the outside world know that these two items were swapped.
+ ViewHelpers.dispatchEvent(aFirst.target, "swap", [aSecond, aFirst]);
+ },
+
+ /**
+ * Visually swaps two items in this container at specific indices.
+ *
+ * @param number aFirst
+ * The index of the first item to be swapped.
+ * @param number aSecond
+ * The index of the second item to be swapped.
+ */
+ swapItemsAtIndices: function(aFirst, aSecond) {
+ this.swapItems(this.getItemAtIndex(aFirst), this.getItemAtIndex(aSecond));
+ },
+
+ /**
+ * Checks whether an item with the specified value is among the elements
+ * shown in this container.
+ *
+ * @param string aValue
+ * The item's value.
+ * @return boolean
+ * True if the value is known, false otherwise.
+ */
+ containsValue: function(aValue) {
+ return this._itemsByValue.has(aValue) ||
+ this._stagedItems.some(({ item }) => item._value == aValue);
+ },
+
+ /**
+ * Gets the "preferred value". This is the latest selected item's value,
+ * remembered just before emptying this container.
+ * @return string
+ */
+ get preferredValue() {
+ return this._preferredValue;
+ },
+
+ /**
+ * Retrieves the item associated with the selected element.
+ * @return Item | null
+ */
+ get selectedItem() {
+ let selectedElement = this._widget.selectedItem;
+ if (selectedElement) {
+ return this._itemsByElement.get(selectedElement);
+ }
+ return null;
+ },
+
+ /**
+ * Retrieves the selected element's index in this container.
+ * @return number
+ */
+ get selectedIndex() {
+ let selectedElement = this._widget.selectedItem;
+ if (selectedElement) {
+ return this._indexOfElement(selectedElement);
+ }
+ return -1;
+ },
+
+ /**
+ * Retrieves the value of the selected element.
+ * @return string
+ */
+ get selectedValue() {
+ let selectedElement = this._widget.selectedItem;
+ if (selectedElement) {
+ return this._itemsByElement.get(selectedElement)._value;
+ }
+ return "";
+ },
+
+ /**
+ * Retrieves the attachment of the selected element.
+ * @return object | null
+ */
+ get selectedAttachment() {
+ let selectedElement = this._widget.selectedItem;
+ if (selectedElement) {
+ return this._itemsByElement.get(selectedElement).attachment;
+ }
+ return null;
+ },
+
+ /**
+ * Selects the element with the entangled item in this container.
+ * @param Item | function aItem
+ */
+ set selectedItem(aItem) {
+ // A predicate is allowed to select a specific item.
+ // If no item is matched, then the current selection is removed.
+ if (typeof aItem == "function") {
+ aItem = this.getItemForPredicate(aItem);
+ }
+
+ // A falsy item is allowed to invalidate the current selection.
+ let targetElement = aItem ? aItem._target : null;
+ let prevElement = this._widget.selectedItem;
+
+ // Make sure the selected item's target element is focused and visible.
+ if (this.autoFocusOnSelection && targetElement) {
+ targetElement.focus();
+ }
+ if (this.maintainSelectionVisible && targetElement) {
+ // Some methods are optional. See the WidgetMethods object documentation
+ // for a comprehensive list.
+ if ("ensureElementIsVisible" in this._widget) {
+ this._widget.ensureElementIsVisible(targetElement);
+ }
+ }
+
+ // Prevent selecting the same item again and avoid dispatching
+ // a redundant selection event, so return early.
+ if (targetElement != prevElement) {
+ this._widget.selectedItem = targetElement;
+ let dispTarget = targetElement || prevElement;
+ let dispName = this.suppressSelectionEvents ? "suppressed-select" : "select";
+ ViewHelpers.dispatchEvent(dispTarget, dispName, aItem);
+ }
+ },
+
+ /**
+ * Selects the element at the specified index in this container.
+ * @param number aIndex
+ */
+ set selectedIndex(aIndex) {
+ let targetElement = this._widget.getItemAtIndex(aIndex);
+ if (targetElement) {
+ this.selectedItem = this._itemsByElement.get(targetElement);
+ return;
+ }
+ this.selectedItem = null;
+ },
+
+ /**
+ * Selects the element with the specified value in this container.
+ * @param string aValue
+ */
+ set selectedValue(aValue) {
+ this.selectedItem = this._itemsByValue.get(aValue);
+ },
+
+ /**
+ * Deselects and re-selects an item in this container.
+ *
+ * Useful when you want a "select" event to be emitted, even though
+ * the specified item was already selected.
+ *
+ * @param Item | function aItem
+ * @see `set selectedItem`
+ */
+ forceSelect: function(aItem) {
+ this.selectedItem = null;
+ this.selectedItem = aItem;
+ },
+
+ /**
+ * Specifies if this container should try to keep the selected item visible.
+ * (For example, when new items are added the selection is brought into view).
+ */
+ maintainSelectionVisible: true,
+
+ /**
+ * Specifies if "select" events dispatched from the elements in this container
+ * when their respective items are selected should be suppressed or not.
+ *
+ * If this flag is set to true, then consumers of this container won't
+ * be normally notified when items are selected.
+ */
+ suppressSelectionEvents: false,
+
+ /**
+ * Focus this container the first time an element is inserted?
+ *
+ * If this flag is set to true, then when the first item is inserted in
+ * this container (and thus it's the only item available), its corresponding
+ * target element is focused as well.
+ */
+ autoFocusOnFirstItem: true,
+
+ /**
+ * Focus on selection?
+ *
+ * If this flag is set to true, then whenever an item is selected in
+ * this container (e.g. via the selectedIndex or selectedItem setters),
+ * its corresponding target element is focused as well.
+ *
+ * You can disable this flag, for example, to maintain a certain node
+ * focused but visually indicate a different selection in this container.
+ */
+ autoFocusOnSelection: true,
+
+ /**
+ * Focus on input (e.g. mouse click)?
+ *
+ * If this flag is set to true, then whenever an item receives user input in
+ * this container, its corresponding target element is focused as well.
+ */
+ autoFocusOnInput: true,
+
+ /**
+ * When focusing on input, allow right clicks?
+ * @see WidgetMethods.autoFocusOnInput
+ */
+ allowFocusOnRightClick: false,
+
+ /**
+ * The number of elements in this container to jump when Page Up or Page Down
+ * keys are pressed. If falsy, then the page size will be based on the
+ * number of visible items in the container.
+ */
+ pageSize: 0,
+
+ /**
+ * Focuses the first visible item in this container.
+ */
+ focusFirstVisibleItem: function() {
+ this.focusItemAtDelta(-this.itemCount);
+ },
+
+ /**
+ * Focuses the last visible item in this container.
+ */
+ focusLastVisibleItem: function() {
+ this.focusItemAtDelta(+this.itemCount);
+ },
+
+ /**
+ * Focuses the next item in this container.
+ */
+ focusNextItem: function() {
+ this.focusItemAtDelta(+1);
+ },
+
+ /**
+ * Focuses the previous item in this container.
+ */
+ focusPrevItem: function() {
+ this.focusItemAtDelta(-1);
+ },
+
+ /**
+ * Focuses another item in this container based on the index distance
+ * from the currently focused item.
+ *
+ * @param number aDelta
+ * A scalar specifying by how many items should the selection change.
+ */
+ focusItemAtDelta: function(aDelta) {
+ // Make sure the currently selected item is also focused, so that the
+ // command dispatcher mechanism has a relative node to work with.
+ // If there's no selection, just select an item at a corresponding index
+ // (e.g. the first item in this container if aDelta <= 1).
+ let selectedElement = this._widget.selectedItem;
+ if (selectedElement) {
+ selectedElement.focus();
+ } else {
+ this.selectedIndex = Math.max(0, aDelta - 1);
+ return;
+ }
+
+ let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus";
+ let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta));
+ while (distance--) {
+ if (!this._focusChange(direction)) {
+ break; // Out of bounds.
+ }
+ }
+
+ // Synchronize the selected item as being the currently focused element.
+ this.selectedItem = this.getItemForElement(this._focusedElement);
+ },
+
+ /**
+ * Focuses the next or previous item in this container.
+ *
+ * @param string aDirection
+ * Either "advanceFocus" or "rewindFocus".
+ * @return boolean
+ * False if the focus went out of bounds and the first or last item
+ * in this container was focused instead.
+ */
+ _focusChange: function(aDirection) {
+ let commandDispatcher = this._commandDispatcher;
+ let prevFocusedElement = commandDispatcher.focusedElement;
+ let currFocusedElement;
+
+ do {
+ commandDispatcher.suppressFocusScroll = true;
+ commandDispatcher[aDirection]();
+ currFocusedElement = commandDispatcher.focusedElement;
+
+ // Make sure the newly focused item is a part of this container. If the
+ // focus goes out of bounds, revert the previously focused item.
+ if (!this.getItemForElement(currFocusedElement)) {
+ prevFocusedElement.focus();
+ return false;
+ }
+ } while (!WIDGET_FOCUSABLE_NODES.has(currFocusedElement.tagName));
+
+ // Focus remained within bounds.
+ return true;
+ },
+
+ /**
+ * Gets the command dispatcher instance associated with this container's DOM.
+ * If there are no items displayed in this container, null is returned.
+ * @return nsIDOMXULCommandDispatcher | null
+ */
+ get _commandDispatcher() {
+ if (this._cachedCommandDispatcher) {
+ return this._cachedCommandDispatcher;
+ }
+ let someElement = this._widget.getItemAtIndex(0);
+ if (someElement) {
+ let commandDispatcher = someElement.ownerDocument.commandDispatcher;
+ return this._cachedCommandDispatcher = commandDispatcher;
+ }
+ return null;
+ },
+
+ /**
+ * Gets the currently focused element in this container.
+ *
+ * @return nsIDOMNode
+ * The focused element, or null if nothing is found.
+ */
+ get _focusedElement() {
+ let commandDispatcher = this._commandDispatcher;
+ if (commandDispatcher) {
+ return commandDispatcher.focusedElement;
+ }
+ return null;
+ },
+
+ /**
+ * Gets the item in the container having the specified index.
+ *
+ * @param number aIndex
+ * The index used to identify the element.
+ * @return Item
+ * The matched item, or null if nothing is found.
+ */
+ getItemAtIndex: function(aIndex) {
+ return this.getItemForElement(this._widget.getItemAtIndex(aIndex));
+ },
+
+ /**
+ * Gets the item in the container having the specified value.
+ *
+ * @param string aValue
+ * The value used to identify the element.
+ * @return Item
+ * The matched item, or null if nothing is found.
+ */
+ getItemByValue: function(aValue) {
+ return this._itemsByValue.get(aValue);
+ },
+
+ /**
+ * Gets the item in the container associated with the specified element.
+ *
+ * @param nsIDOMNode aElement
+ * The element used to identify the item.
+ * @param object aFlags [optional]
+ * Additional options for showing the source. Supported options:
+ * - noSiblings: if siblings shouldn't be taken into consideration
+ * when searching for the associated item.
+ * @return Item
+ * The matched item, or null if nothing is found.
+ */
+ getItemForElement: function(aElement, aFlags = {}) {
+ while (aElement) {
+ let item = this._itemsByElement.get(aElement);
+
+ // Also search the siblings if allowed.
+ if (!aFlags.noSiblings) {
+ item = item ||
+ this._itemsByElement.get(aElement.nextElementSibling) ||
+ this._itemsByElement.get(aElement.previousElementSibling);
+ }
+ if (item) {
+ return item;
+ }
+ aElement = aElement.parentNode;
+ }
+ return null;
+ },
+
+ /**
+ * Gets a visible item in this container validating a specified predicate.
+ *
+ * @param function aPredicate
+ * The first item which validates this predicate is returned
+ * @return Item
+ * The matched item, or null if nothing is found.
+ */
+ getItemForPredicate: function(aPredicate, aOwner = this) {
+ // Recursively check the items in this widget for a predicate match.
+ for (let [element, item] of aOwner._itemsByElement) {
+ let match;
+ if (aPredicate(item) && !element.hidden) {
+ match = item;
+ } else {
+ match = this.getItemForPredicate(aPredicate, item);
+ }
+ if (match) {
+ return match;
+ }
+ }
+ // Also check the staged items. No need to do this recursively since
+ // they're not even appended to the view yet.
+ for (let { item } of this._stagedItems) {
+ if (aPredicate(item)) {
+ return item;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Shortcut function for getItemForPredicate which works on item attachments.
+ * @see getItemForPredicate
+ */
+ getItemForAttachment: function(aPredicate, aOwner = this) {
+ return this.getItemForPredicate(e => aPredicate(e.attachment));
+ },
+
+ /**
+ * Finds the index of an item in the container.
+ *
+ * @param Item aItem
+ * The item get the index for.
+ * @return number
+ * The index of the matched item, or -1 if nothing is found.
+ */
+ indexOfItem: function(aItem) {
+ return this._indexOfElement(aItem._target);
+ },
+
+ /**
+ * Finds the index of an element in the container.
+ *
+ * @param nsIDOMNode aElement
+ * The element get the index for.
+ * @return number
+ * The index of the matched element, or -1 if nothing is found.
+ */
+ _indexOfElement: function(aElement) {
+ for (let i = 0; i < this._itemsByElement.size; i++) {
+ if (this._widget.getItemAtIndex(i) == aElement) {
+ return i;
+ }
+ }
+ return -1;
+ },
+
+ /**
+ * Gets the total number of items in this container.
+ * @return number
+ */
+ get itemCount() {
+ return this._itemsByElement.size;
+ },
+
+ /**
+ * Returns a list of items in this container, in the displayed order.
+ * @return array
+ */
+ get items() {
+ let store = [];
+ let itemCount = this.itemCount;
+ for (let i = 0; i < itemCount; i++) {
+ store.push(this.getItemAtIndex(i));
+ }
+ return store;
+ },
+
+ /**
+ * Returns a list of values in this container, in the displayed order.
+ * @return array
+ */
+ get values() {
+ return this.items.map(e => e._value);
+ },
+
+ /**
+ * Returns a list of attachments in this container, in the displayed order.
+ * @return array
+ */
+ get attachments() {
+ return this.items.map(e => e.attachment);
+ },
+
+ /**
+ * Returns a list of all the visible (non-hidden) items in this container,
+ * in the displayed order
+ * @return array
+ */
+ get visibleItems() {
+ return this.items.filter(e => !e._target.hidden);
+ },
+
+ /**
+ * Checks if an item is unique in this container. If an item's value is an
+ * empty string, "undefined" or "null", it is considered unique.
+ *
+ * @param Item aItem
+ * The item for which to verify uniqueness.
+ * @return boolean
+ * True if the item is unique, false otherwise.
+ */
+ isUnique: function(aItem) {
+ let value = aItem._value;
+ if (value == "" || value == "undefined" || value == "null") {
+ return true;
+ }
+ return !this._itemsByValue.has(value);
+ },
+
+ /**
+ * Checks if an item is eligible for this container. By default, this checks
+ * whether an item is unique and has a prebuilt target node.
+ *
+ * @param Item aItem
+ * The item for which to verify eligibility.
+ * @return boolean
+ * True if the item is eligible, false otherwise.
+ */
+ isEligible: function(aItem) {
+ return this.isUnique(aItem) && aItem._prebuiltNode;
+ },
+
+ /**
+ * Finds the expected item index in this container based on the default
+ * sort predicate.
+ *
+ * @param Item aItem
+ * The item for which to get the expected index.
+ * @return number
+ * The expected item index.
+ */
+ _findExpectedIndexFor: function(aItem) {
+ let itemCount = this.itemCount;
+ for (let i = 0; i < itemCount; i++) {
+ if (this._currentSortPredicate(this.getItemAtIndex(i), aItem) > 0) {
+ return i;
+ }
+ }
+ return itemCount;
+ },
+
+ /**
+ * Immediately inserts an item in this container at the specified index.
+ *
+ * @param number aIndex
+ * The position in the container intended for this item.
+ * @param Item aItem
+ * The item describing a target element.
+ * @param object aOptions [optional]
+ * Additional options or flags supported by this operation:
+ * - attributes: a batch of attributes set to the displayed element
+ * - finalize: function when the item is untangled (removed)
+ * @return Item
+ * The item associated with the displayed element, null if rejected.
+ */
+ _insertItemAt: function(aIndex, aItem, aOptions = {}) {
+ if (!this.isEligible(aItem)) {
+ return null;
+ }
+
+ // Entangle the item with the newly inserted node.
+ // Make sure this is done with the value returned by insertItemAt(),
+ // to avoid storing a potential DocumentFragment.
+ let node = aItem._prebuiltNode;
+ let attachment = aItem.attachment;
+ this._entangleItem(aItem, this._widget.insertItemAt(aIndex, node, attachment));
+
+ // Handle any additional options after entangling the item.
+ if (!this._currentFilterPredicate(aItem)) {
+ aItem._target.hidden = true;
+ }
+ if (this.autoFocusOnFirstItem && this._itemsByElement.size == 1) {
+ aItem._target.focus();
+ }
+ if (aOptions.attributes) {
+ aOptions.attributes.forEach(e => aItem._target.setAttribute(e[0], e[1]));
+ }
+ if (aOptions.finalize) {
+ aItem.finalize = aOptions.finalize;
+ }
+
+ // Hide the empty text if the selection wasn't lost.
+ this._widget.removeAttribute("emptyText");
+
+ // Return the item associated with the displayed element.
+ return aItem;
+ },
+
+ /**
+ * Entangles an item (model) with a displayed node element (view).
+ *
+ * @param Item aItem
+ * The item describing a target element.
+ * @param nsIDOMNode aElement
+ * The element displaying the item.
+ */
+ _entangleItem: function(aItem, aElement) {
+ this._itemsByValue.set(aItem._value, aItem);
+ this._itemsByElement.set(aElement, aItem);
+ aItem._target = aElement;
+ },
+
+ /**
+ * Untangles an item (model) from a displayed node element (view).
+ *
+ * @param Item aItem
+ * The item describing a target element.
+ */
+ _untangleItem: function(aItem) {
+ if (aItem.finalize) {
+ aItem.finalize(aItem);
+ }
+ for (let childItem of aItem) {
+ aItem.remove(childItem);
+ }
+
+ this._unlinkItem(aItem);
+ aItem._target = null;
+ },
+
+ /**
+ * Deletes an item from the its parent's storage maps.
+ *
+ * @param Item aItem
+ * The item describing a target element.
+ */
+ _unlinkItem: function(aItem) {
+ this._itemsByValue.delete(aItem._value);
+ this._itemsByElement.delete(aItem._target);
+ },
+
+ /**
+ * The keyPress event listener for this container.
+ * @param string aName
+ * @param KeyboardEvent aEvent
+ */
+ _onWidgetKeyPress: function(aName, aEvent) {
+ // Prevent scrolling when pressing navigation keys.
+ ViewHelpers.preventScrolling(aEvent);
+
+ switch (aEvent.keyCode) {
+ case aEvent.DOM_VK_UP:
+ case aEvent.DOM_VK_LEFT:
+ this.focusPrevItem();
+ return;
+ case aEvent.DOM_VK_DOWN:
+ case aEvent.DOM_VK_RIGHT:
+ this.focusNextItem();
+ return;
+ case aEvent.DOM_VK_PAGE_UP:
+ this.focusItemAtDelta(-(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO)));
+ return;
+ case aEvent.DOM_VK_PAGE_DOWN:
+ this.focusItemAtDelta(+(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO)));
+ return;
+ case aEvent.DOM_VK_HOME:
+ this.focusFirstVisibleItem();
+ return;
+ case aEvent.DOM_VK_END:
+ this.focusLastVisibleItem();
+ return;
+ }
+ },
+
+ /**
+ * The mousePress event listener for this container.
+ * @param string aName
+ * @param MouseEvent aEvent
+ */
+ _onWidgetMousePress: function(aName, aEvent) {
+ if (aEvent.button != 0 && !this.allowFocusOnRightClick) {
+ // Only allow left-click to trigger this event.
+ return;
+ }
+
+ let item = this.getItemForElement(aEvent.target);
+ if (item) {
+ // The container is not empty and we clicked on an actual item.
+ this.selectedItem = item;
+ // Make sure the current event's target element is also focused.
+ this.autoFocusOnInput && item._target.focus();
+ }
+ },
+
+ /**
+ * The predicate used when filtering items. By default, all items in this
+ * view are visible.
+ *
+ * @param Item aItem
+ * The item passing through the filter.
+ * @return boolean
+ * True if the item should be visible, false otherwise.
+ */
+ _currentFilterPredicate: function(aItem) {
+ return true;
+ },
+
+ /**
+ * The predicate used when sorting items. By default, items in this view
+ * are sorted by their label.
+ *
+ * @param Item aFirst
+ * The first item used in the comparison.
+ * @param Item aSecond
+ * The second item used in the comparison.
+ * @return number
+ * -1 to sort aFirst to a lower index than aSecond
+ * 0 to leave aFirst and aSecond unchanged with respect to each other
+ * 1 to sort aSecond to a lower index than aFirst
+ */
+ _currentSortPredicate: function(aFirst, aSecond) {
+ return +(aFirst._value.toLowerCase() > aSecond._value.toLowerCase());
+ },
+
+ /**
+ * Call a method on this widget named `aMethodName`. Any further arguments are
+ * passed on to the method. Returns the result of the method call.
+ *
+ * @param String aMethodName
+ * The name of the method you want to call.
+ * @param aArgs
+ * Optional. Any arguments you want to pass through to the method.
+ */
+ callMethod: function(aMethodName, ...aArgs) {
+ return this._widget[aMethodName].apply(this._widget, aArgs);
+ },
+
+ _widget: null,
+ _emptyText: "",
+ _headerText: "",
+ _preferredValue: "",
+ _cachedCommandDispatcher: null
+};
+
+/**
+ * A generator-iterator over all the items in this container.
+ */
+Item.prototype[Symbol.iterator] =
+WidgetMethods[Symbol.iterator] = function*() {
+ yield* this._itemsByElement.values();
+};
diff --git a/toolkit/devtools/shared/widgets/cubic-bezier-frame.xhtml b/toolkit/devtools/shared/widgets/cubic-bezier-frame.xhtml
new file mode 100644
index 000000000..f3c7a65b0
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/cubic-bezier-frame.xhtml
@@ -0,0 +1,25 @@
+<?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/. -->
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+ <link rel="stylesheet" href="chrome://browser/skin/devtools/common.css" type="text/css"/>
+ <link rel="stylesheet" href="chrome://browser/content/devtools/cubic-bezier.css" ype="text/css"/>
+ <script type="application/javascript;version=1.8" src="theme-switching.js"/>
+ <style>
+ body {
+ margin: 0;
+ padding: 0;
+ width: 200px;
+ height: 415px;
+ }
+ </style>
+</head>
+<body role="application">
+ <div id="container"></div>
+</body>
+</html>
diff --git a/toolkit/devtools/shared/widgets/cubic-bezier.css b/toolkit/devtools/shared/widgets/cubic-bezier.css
new file mode 100644
index 000000000..306e0ebd8
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/cubic-bezier.css
@@ -0,0 +1,142 @@
+/* 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/. */
+
+/* Based on Lea Verou www.cubic-bezier.com
+ See https://github.com/LeaVerou/cubic-bezier */
+
+.coordinate-plane {
+ position: absolute;
+ line-height: 0;
+ height: 400px;
+ width: 200px;
+}
+
+.coordinate-plane:before,
+.coordinate-plane:after {
+ position: absolute;
+ bottom: 25%;
+ left: 0;
+ width: 100%;
+}
+
+.coordinate-plane:before {
+ content: "";
+ border-bottom: 2px solid;
+ transform: rotate(-90deg) translateY(2px);
+ transform-origin: bottom left;
+}
+
+.coordinate-plane:after {
+ content: "";
+ border-top: 2px solid;
+ margin-bottom: -2px;
+}
+
+.theme-dark .coordinate-plane:before,
+.theme-dark .coordinate-plane:after {
+ border-color: #eee;
+}
+
+.control-point {
+ position: absolute;
+ z-index: 1;
+ height: 10px;
+ width: 10px;
+ border: 0;
+ background: #666;
+ display: block;
+ margin: -5px 0 0 -5px;
+ outline: none;
+ border-radius: 5px;
+ padding: 0;
+
+ cursor: pointer;
+}
+
+#P1x, #P1y {
+ color: #f08;
+}
+
+#P2x, #P2y {
+ color: #0ab;
+}
+
+canvas#curve {
+ background:
+ linear-gradient(-45deg, transparent 49.7%, rgba(0,0,0,.2) 49.7%, rgba(0,0,0,.2) 50.3%, transparent 50.3%) center no-repeat,
+ repeating-linear-gradient(transparent, #eee 0, #eee .5%, transparent .5%, transparent 10%) no-repeat,
+ repeating-linear-gradient(-90deg, transparent, #eee 0, #eee .5%, transparent .5%, transparent 10%) no-repeat;
+
+ background-size: 100% 50%, 100% 50%, 100% 50%;
+ background-position: 25%, 0, 0;
+
+ -moz-user-select: none;
+}
+
+.theme-dark canvas#curve {
+ background:
+ linear-gradient(-45deg, transparent 49.7%, #eee 49.7%, #eee 50.3%, transparent 50.3%) center no-repeat,
+ repeating-linear-gradient(transparent, rgba(0,0,0,.2) 0, rgba(0,0,0,.2) .5%, transparent .5%, transparent 10%) no-repeat,
+ repeating-linear-gradient(-90deg, transparent, rgba(0,0,0,.2) 0, rgba(0,0,0,.2) .5%, transparent .5%, transparent 10%) no-repeat;
+
+ background-size: 100% 50%, 100% 50%, 100% 50%;
+ background-position: 25%, 0, 0;
+}
+
+/* Timing function preview widget */
+
+.timing-function-preview {
+ position: absolute;
+ top: 400px;
+}
+
+.timing-function-preview .scale {
+ position: absolute;
+ top: 6px;
+ left: 0;
+ z-index: 1;
+
+ width: 200px;
+ height: 1px;
+
+ background: #ccc;
+}
+
+.timing-function-preview .dot {
+ position: absolute;
+ top: 0;
+ left: -7px;
+ z-index: 2;
+
+ width: 10px;
+ height: 10px;
+
+ border-radius: 50%;
+ border: 2px solid white;
+ background: #4C9ED9;
+}
+
+.timing-function-preview .dot.animate {
+ animation-duration: 2.5s;
+ animation-fill-mode: forwards;
+ animation-name: timing-function-preview;
+}
+
+@keyframes timing-function-preview {
+ 0% {
+ left: -7px;
+ }
+ 33% {
+ left: 193px;
+ }
+ 50% {
+ left: 193px;
+ }
+ 83% {
+ left: -7px;
+ }
+ 100% {
+ left: -7px;
+ }
+}
diff --git a/toolkit/devtools/shared/widgets/graphs-frame.xhtml b/toolkit/devtools/shared/widgets/graphs-frame.xhtml
new file mode 100644
index 000000000..d9835742b
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/graphs-frame.xhtml
@@ -0,0 +1,26 @@
+<?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/. -->
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+ <link rel="stylesheet" href="chrome://browser/skin/devtools/common.css" type="text/css"/>
+ <link rel="stylesheet" href="chrome://browser/skin/devtools/widgets.css" ype="text/css"/>
+ <script type="application/javascript;version=1.8" src="theme-switching.js"/>
+ <style>
+ body {
+ overflow: hidden;
+ margin: 0;
+ padding: 0;
+ }
+ </style>
+</head>
+<body role="application">
+ <div id="graph-container">
+ <canvas id="graph-canvas"></canvas>
+ </div>
+</body>
+</html>
diff --git a/toolkit/devtools/shared/widgets/spectrum-frame.xhtml b/toolkit/devtools/shared/widgets/spectrum-frame.xhtml
new file mode 100644
index 000000000..955ed99ed
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/spectrum-frame.xhtml
@@ -0,0 +1,24 @@
+<?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/. -->
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+ <link rel="stylesheet" href="chrome://browser/skin/devtools/common.css" type="text/css"/>
+ <link rel="stylesheet" href="chrome://browser/content/devtools/spectrum.css" ype="text/css"/>
+ <script type="application/javascript;version=1.8" src="theme-switching.js"/>
+ <style>
+ body {
+ margin: 0;
+ padding: 0;
+ }
+ </style>
+</head>
+<body role="application">
+ <div id="spectrum"></div>
+ <div id="eyedropper-button"></div>
+</body>
+</html> \ No newline at end of file
diff --git a/toolkit/devtools/shared/widgets/spectrum.css b/toolkit/devtools/shared/widgets/spectrum.css
new file mode 100644
index 000000000..b519eb9f9
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/spectrum.css
@@ -0,0 +1,176 @@
+/* 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/. */
+
+#eyedropper-button {
+ background-image: url("chrome://browser/skin/devtools/command-eyedropper.png");
+ width: 16px;
+ height: 16px;
+ background-size: 64px 16px;
+ background-position: 0 center;
+ background-repeat: no-repeat;
+ -moz-margin-start: 5px;
+ border-radius: 2px;
+ cursor: pointer;
+}
+
+.theme-light #eyedropper-button {
+ filter: url(chrome://browser/skin/devtools/filters.svg#invert);
+ border: 1px solid #AAA;
+}
+
+.theme-dark #eyedropper-button {
+ border: 1px solid #444;
+}
+
+#eyedropper-button:hover {
+ background-position: -16px center;
+}
+#eyedropper-button:hover:active {
+ background-position: -32px center;
+}
+#eyedropper-button[checked=true] {
+ background-position: -48px center;
+}
+
+@media (min-resolution: 2dppx) {
+ #eyedropper-button {
+ background-image: url("chrome://browser/skin/devtools/command-eyedropper@2x.png");
+ }
+}
+
+/* Mix-in classes */
+
+.spectrum-checker {
+ background-color: #eee;
+ background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc),
+ linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc);
+ background-size: 12px 12px;
+ background-position: 0 0, 6px 6px;
+}
+
+.spectrum-slider-control {
+ cursor: pointer;
+ border: 1px solid black;
+ background: white;
+ opacity: .7;
+}
+
+.spectrum-box {
+ border: solid 1px #333;
+}
+
+/* Elements */
+
+.spectrum-container {
+ position: relative;
+ display: none;
+ top: 0;
+ left: 0;
+ border-radius: 0;
+ width: 200px;
+ padding: 5px;
+}
+
+.spectrum-show {
+ display: inline-block;
+}
+
+/* Keep aspect ratio:
+http://www.briangrinstead.com/blog/keep-aspect-ratio-with-html-and-css */
+.spectrum-top {
+ position: relative;
+ width: 100%;
+ display: inline-block;
+}
+
+.spectrum-top-inner {
+ position: absolute;
+ top:0;
+ left:0;
+ bottom:0;
+ right:0;
+}
+
+.spectrum-color {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 20%;
+}
+
+.spectrum-hue {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 83%;
+}
+
+.spectrum-fill {
+ /* Same as spectrum-color width */
+ margin-top: 85%;
+}
+
+.spectrum-sat, .spectrum-val {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+.spectrum-dragger, .spectrum-slider {
+ -moz-user-select: none;
+}
+
+.spectrum-alpha {
+ position: relative;
+ height: 8px;
+ margin-top: 3px;
+}
+
+.spectrum-alpha-inner {
+ height: 100%;
+}
+
+.spectrum-alpha-handle {
+ position: absolute;
+ top: -3px;
+ bottom: -3px;
+ width: 5px;
+ left: 50%;
+}
+
+.spectrum-sat {
+ background-image: linear-gradient(to right, #FFF, rgba(204, 154, 129, 0));
+}
+
+.spectrum-val {
+ background-image: linear-gradient(to top, #000000, rgba(204, 154, 129, 0));
+}
+
+.spectrum-hue {
+ background: linear-gradient(to bottom, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
+}
+
+.spectrum-dragger {
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ cursor: pointer;
+ border-radius: 50%;
+ height: 8px;
+ width: 8px;
+ border: 1px solid white;
+ background: black;
+}
+
+.spectrum-slider {
+ position: absolute;
+ top: 0;
+ height: 5px;
+ left: -3px;
+ right: -3px;
+}
diff --git a/toolkit/devtools/shared/widgets/widgets.css b/toolkit/devtools/shared/widgets/widgets.css
new file mode 100644
index 000000000..b979cf266
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/widgets.css
@@ -0,0 +1,109 @@
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* 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/. */
+
+/* BreacrumbsWidget */
+
+.breadcrumbs-widget-item {
+ direction: ltr;
+}
+
+.breadcrumbs-widget-item {
+ -moz-user-focus: normal;
+}
+
+/* SimpleListWidget */
+
+.simple-list-widget-container {
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+/* FastListWidget */
+
+.fast-list-widget-container {
+ overflow: auto;
+}
+
+/* SideMenuWidget */
+
+.side-menu-widget-container {
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.side-menu-widget-item-contents {
+ -moz-user-focus: normal;
+}
+
+.side-menu-widget-group-checkbox .checkbox-label-box,
+.side-menu-widget-item-checkbox .checkbox-label-box {
+ display: none; /* See bug 669507 */
+}
+
+/* VariablesView */
+
+.variables-view-container {
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.variables-view-element-details:not([open]) {
+ display: none;
+}
+
+.variables-view-scope,
+.variable-or-property {
+ -moz-user-focus: normal;
+}
+
+.variables-view-scope > .title,
+.variable-or-property > .title {
+ overflow: hidden;
+}
+
+.variables-view-scope[untitled] > .title,
+.variable-or-property[untitled] > .title,
+.variable-or-property[unmatched] > .title {
+ display: none;
+}
+
+.variable-or-property:not([safe-getter]) > tooltip > label.WebIDL,
+.variable-or-property:not([overridden]) > tooltip > label.overridden,
+.variable-or-property:not([non-extensible]) > tooltip > label.extensible,
+.variable-or-property:not([frozen]) > tooltip > label.frozen,
+.variable-or-property:not([sealed]) > tooltip > label.sealed {
+ display: none;
+}
+
+.variable-or-property[pseudo-item] > tooltip,
+.variable-or-property[pseudo-item] > .title > .variables-view-edit,
+.variable-or-property[pseudo-item] > .title > .variables-view-delete,
+.variable-or-property[pseudo-item] > .title > .variables-view-add-property,
+.variable-or-property[pseudo-item] > .title > .variables-view-open-inspector,
+.variable-or-property[pseudo-item] > .title > .variable-or-property-frozen-label,
+.variable-or-property[pseudo-item] > .title > .variable-or-property-sealed-label,
+.variable-or-property[pseudo-item] > .title > .variable-or-property-non-extensible-label,
+.variable-or-property[pseudo-item] > .title > .variable-or-property-non-writable-icon {
+ display: none;
+}
+
+.variable-or-property > .title .toolbarbutton-text {
+ display: none;
+}
+
+*:not(:hover) .variables-view-delete,
+*:not(:hover) .variables-view-add-property,
+*:not(:hover) .variables-view-open-inspector {
+ visibility: hidden;
+}
+
+.variables-view-container[aligned-values] [optional-visibility] {
+ display: none;
+}
+
+/* Table Widget */
+.table-widget-body > .devtools-side-splitter:last-child {
+ display: none;
+}