gecko/browser/devtools/canvasdebugger/canvasdebugger.js

1257 lines
43 KiB
JavaScript

/* 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, results: Cr } = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource:///modules/devtools/SideMenuWidget.jsm");
Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
const EventEmitter = require("devtools/toolkit/event-emitter");
const { CallWatcherFront } = require("devtools/server/actors/call-watcher");
const { CanvasFront } = require("devtools/server/actors/canvas");
const Telemetry = require("devtools/shared/telemetry");
const telemetry = new Telemetry();
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
"resource://gre/modules/PluralForm.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
"resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
"resource://gre/modules/NetUtil.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "DevToolsUtils",
"resource://gre/modules/devtools/DevToolsUtils.jsm");
// The panel's window global is an EventEmitter firing the following events:
const EVENTS = {
// When the UI is reset from tab navigation.
UI_RESET: "CanvasDebugger:UIReset",
// When all the animation frame snapshots are removed by the user.
SNAPSHOTS_LIST_CLEARED: "CanvasDebugger:SnapshotsListCleared",
// When an animation frame snapshot starts/finishes being recorded.
SNAPSHOT_RECORDING_STARTED: "CanvasDebugger:SnapshotRecordingStarted",
SNAPSHOT_RECORDING_FINISHED: "CanvasDebugger:SnapshotRecordingFinished",
// When an animation frame snapshot was selected and all its data displayed.
SNAPSHOT_RECORDING_SELECTED: "CanvasDebugger:SnapshotRecordingSelected",
// After all the function calls associated with an animation frame snapshot
// are displayed in the UI.
CALL_LIST_POPULATED: "CanvasDebugger:CallListPopulated",
// After the stack associated with a call in an animation frame snapshot
// is displayed in the UI.
CALL_STACK_DISPLAYED: "CanvasDebugger:CallStackDisplayed",
// After a screenshot associated with a call in an animation frame snapshot
// is displayed in the UI.
CALL_SCREENSHOT_DISPLAYED: "CanvasDebugger:ScreenshotDisplayed",
// After all the thumbnails associated with an animation frame snapshot
// are displayed in the UI.
THUMBNAILS_DISPLAYED: "CanvasDebugger:ThumbnailsDisplayed",
// When a source is shown in the JavaScript Debugger at a specific location.
SOURCE_SHOWN_IN_JS_DEBUGGER: "CanvasDebugger:SourceShownInJsDebugger",
SOURCE_NOT_FOUND_IN_JS_DEBUGGER: "CanvasDebugger:SourceNotFoundInJsDebugger"
};
const HTML_NS = "http://www.w3.org/1999/xhtml";
const STRINGS_URI = "chrome://browser/locale/devtools/canvasdebugger.properties"
const SNAPSHOT_START_RECORDING_DELAY = 10; // ms
const SNAPSHOT_DATA_EXPORT_MAX_BLOCK = 1000; // ms
const SNAPSHOT_DATA_DISPLAY_DELAY = 10; // ms
const SCREENSHOT_DISPLAY_DELAY = 100; // ms
const STACK_FUNC_INDENTATION = 14; // px
// This identifier string is simply used to tentatively ascertain whether or not
// a JSON loaded from disk is actually something generated by this tool or not.
// It isn't, of course, a definitive verification, but a Good Enough™
// approximation before continuing the import. Don't localize this.
const CALLS_LIST_SERIALIZER_IDENTIFIER = "Recorded Animation Frame Snapshot";
const CALLS_LIST_SERIALIZER_VERSION = 1;
const CALLS_LIST_SLOW_SAVE_DELAY = 100; // ms
/**
* The current target and the Canvas front, set by this tool's host.
*/
let gToolbox, gTarget, gFront;
/**
* Initializes the canvas debugger controller and views.
*/
function startupCanvasDebugger() {
return promise.all([
EventsHandler.initialize(),
SnapshotsListView.initialize(),
CallsListView.initialize()
]);
}
/**
* Destroys the canvas debugger controller and views.
*/
function shutdownCanvasDebugger() {
return promise.all([
EventsHandler.destroy(),
SnapshotsListView.destroy(),
CallsListView.destroy()
]);
}
/**
* Functions handling target-related lifetime events.
*/
let EventsHandler = {
/**
* Listen for events emitted by the current tab target.
*/
initialize: function() {
telemetry.toolOpened("canvasdebugger");
this._onTabNavigated = this._onTabNavigated.bind(this);
gTarget.on("will-navigate", this._onTabNavigated);
gTarget.on("navigate", this._onTabNavigated);
},
/**
* Remove events emitted by the current tab target.
*/
destroy: function() {
telemetry.toolClosed("canvasdebugger");
gTarget.off("will-navigate", this._onTabNavigated);
gTarget.off("navigate", this._onTabNavigated);
},
/**
* Called for each location change in the debugged tab.
*/
_onTabNavigated: function(event) {
if (event != "will-navigate") {
return;
}
// Make sure the backend is prepared to handle <canvas> contexts.
gFront.setup({ reload: false });
// Reset UI.
SnapshotsListView.empty();
CallsListView.empty();
$("#record-snapshot").removeAttribute("checked");
$("#record-snapshot").removeAttribute("disabled");
$("#record-snapshot").hidden = false;
$("#reload-notice").hidden = true;
$("#empty-notice").hidden = false;
$("#import-notice").hidden = true;
$("#debugging-pane-contents").hidden = true;
$("#screenshot-container").hidden = true;
$("#snapshot-filmstrip").hidden = true;
window.emit(EVENTS.UI_RESET);
}
};
/**
* Functions handling the recorded animation frame snapshots UI.
*/
let SnapshotsListView = Heritage.extend(WidgetMethods, {
/**
* Initialization function, called when the tool is started.
*/
initialize: function() {
this.widget = new SideMenuWidget($("#snapshots-list"), {
showArrows: true
});
this._onSelect = this._onSelect.bind(this);
this._onClearButtonClick = this._onClearButtonClick.bind(this);
this._onRecordButtonClick = this._onRecordButtonClick.bind(this);
this._onImportButtonClick = this._onImportButtonClick.bind(this);
this._onSaveButtonClick = this._onSaveButtonClick.bind(this);
this.emptyText = L10N.getStr("noSnapshotsText");
this.widget.addEventListener("select", this._onSelect, false);
},
/**
* Destruction function, called when the tool is closed.
*/
destroy: function() {
this.widget.removeEventListener("select", this._onSelect, false);
},
/**
* Adds a snapshot entry to this container.
*
* @return object
* The newly inserted item.
*/
addSnapshot: function() {
let contents = document.createElement("hbox");
contents.className = "snapshot-item";
let thumbnail = document.createElementNS(HTML_NS, "canvas");
thumbnail.className = "snapshot-item-thumbnail";
thumbnail.width = CanvasFront.THUMBNAIL_SIZE;
thumbnail.height = CanvasFront.THUMBNAIL_SIZE;
let title = document.createElement("label");
title.className = "plain snapshot-item-title";
title.setAttribute("value",
L10N.getFormatStr("snapshotsList.itemLabel", this.itemCount + 1));
let calls = document.createElement("label");
calls.className = "plain snapshot-item-calls";
calls.setAttribute("value",
L10N.getStr("snapshotsList.loadingLabel"));
let save = document.createElement("label");
save.className = "plain snapshot-item-save";
save.addEventListener("click", this._onSaveButtonClick, false);
let spacer = document.createElement("spacer");
spacer.setAttribute("flex", "1");
let footer = document.createElement("hbox");
footer.className = "snapshot-item-footer";
footer.appendChild(save);
let details = document.createElement("vbox");
details.className = "snapshot-item-details";
details.appendChild(title);
details.appendChild(calls);
details.appendChild(spacer);
details.appendChild(footer);
contents.appendChild(thumbnail);
contents.appendChild(details);
// Append a recorded snapshot item to this container.
return this.push([contents], {
attachment: {
// The snapshot and function call actors, along with the thumbnails
// will be available as soon as recording finishes.
actor: null,
calls: null,
thumbnails: null,
screenshot: null
}
});
},
/**
* Customizes a shapshot in this container.
*
* @param Item snapshotItem
* An item inserted via `SnapshotsListView.addSnapshot`.
* @param object snapshotActor
* The frame snapshot actor received from the backend.
* @param object snapshotOverview
* Additional data about the snapshot received from the backend.
*/
customizeSnapshot: function(snapshotItem, snapshotActor, snapshotOverview) {
// Make sure the function call actors are stored on the item,
// to be used when populating the CallsListView.
snapshotItem.attachment.actor = snapshotActor;
let functionCalls = snapshotItem.attachment.calls = snapshotOverview.calls;
let thumbnails = snapshotItem.attachment.thumbnails = snapshotOverview.thumbnails;
let screenshot = snapshotItem.attachment.screenshot = snapshotOverview.screenshot;
let lastThumbnail = thumbnails[thumbnails.length - 1];
let { width, height, flipped, pixels } = lastThumbnail;
let thumbnailNode = $(".snapshot-item-thumbnail", snapshotItem.target);
thumbnailNode.setAttribute("flipped", flipped);
drawImage(thumbnailNode, width, height, pixels, { centered: true });
let callsNode = $(".snapshot-item-calls", snapshotItem.target);
let drawCalls = functionCalls.filter(e => CanvasFront.DRAW_CALLS.has(e.name));
let drawCallsStr = PluralForm.get(drawCalls.length,
L10N.getStr("snapshotsList.drawCallsLabel"));
let funcCallsStr = PluralForm.get(functionCalls.length,
L10N.getStr("snapshotsList.functionCallsLabel"));
callsNode.setAttribute("value",
drawCallsStr.replace("#1", drawCalls.length) + ", " +
funcCallsStr.replace("#1", functionCalls.length));
let saveNode = $(".snapshot-item-save", snapshotItem.target);
saveNode.setAttribute("disabled", !!snapshotItem.isLoadedFromDisk);
saveNode.setAttribute("value", snapshotItem.isLoadedFromDisk
? L10N.getStr("snapshotsList.loadedLabel")
: L10N.getStr("snapshotsList.saveLabel"));
// Make sure there's always a selected item available.
if (!this.selectedItem) {
this.selectedIndex = 0;
}
},
/**
* The select listener for this container.
*/
_onSelect: function({ detail: snapshotItem }) {
if (!snapshotItem) {
return;
}
let { calls, thumbnails, screenshot } = snapshotItem.attachment;
$("#reload-notice").hidden = true;
$("#empty-notice").hidden = true;
$("#import-notice").hidden = false;
$("#debugging-pane-contents").hidden = true;
$("#screenshot-container").hidden = true;
$("#snapshot-filmstrip").hidden = true;
Task.spawn(function*() {
// Wait for a few milliseconds between presenting the function calls,
// screenshot and thumbnails, to allow each component being
// sequentially drawn. This gives the illusion of snappiness.
yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
CallsListView.showCalls(calls);
$("#debugging-pane-contents").hidden = false;
$("#import-notice").hidden = true;
yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
CallsListView.showThumbnails(thumbnails);
$("#snapshot-filmstrip").hidden = false;
yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
CallsListView.showScreenshot(screenshot);
$("#screenshot-container").hidden = false;
window.emit(EVENTS.SNAPSHOT_RECORDING_SELECTED);
});
},
/**
* The click listener for the "clear" button in this container.
*/
_onClearButtonClick: function() {
Task.spawn(function*() {
SnapshotsListView.empty();
CallsListView.empty();
$("#reload-notice").hidden = true;
$("#empty-notice").hidden = true;
$("#import-notice").hidden = true;
if (yield gFront.isInitialized()) {
$("#empty-notice").hidden = false;
} else {
$("#reload-notice").hidden = false;
}
$("#debugging-pane-contents").hidden = true;
$("#screenshot-container").hidden = true;
$("#snapshot-filmstrip").hidden = true;
window.emit(EVENTS.SNAPSHOTS_LIST_CLEARED);
});
},
/**
* The click listener for the "record" button in this container.
*/
_onRecordButtonClick: function() {
Task.spawn(function*() {
$("#record-snapshot").setAttribute("checked", "true");
$("#record-snapshot").setAttribute("disabled", "true");
// Insert a "dummy" snapshot item in the view, to hint that recording
// has now started. However, wait for a few milliseconds before actually
// starting the recording, since that might block rendering and prevent
// the dummy snapshot item from being drawn.
let snapshotItem = this.addSnapshot();
// If this is the first item, immediately show the "Loading…" notice.
if (this.itemCount == 1) {
$("#empty-notice").hidden = true;
$("#import-notice").hidden = false;
}
yield DevToolsUtils.waitForTime(SNAPSHOT_START_RECORDING_DELAY);
window.emit(EVENTS.SNAPSHOT_RECORDING_STARTED);
let snapshotActor = yield gFront.recordAnimationFrame();
let snapshotOverview = yield snapshotActor.getOverview();
this.customizeSnapshot(snapshotItem, snapshotActor, snapshotOverview);
$("#record-snapshot").removeAttribute("checked");
$("#record-snapshot").removeAttribute("disabled");
window.emit(EVENTS.SNAPSHOT_RECORDING_FINISHED);
}.bind(this));
},
/**
* The click listener for the "import" button in this container.
*/
_onImportButtonClick: function() {
let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeOpen);
fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
if (fp.show() != Ci.nsIFilePicker.returnOK) {
return;
}
let channel = NetUtil.newChannel(fp.file);
channel.contentType = "text/plain";
NetUtil.asyncFetch(channel, (inputStream, status) => {
if (!Components.isSuccessCode(status)) {
console.error("Could not import recorded animation frame snapshot file.");
return;
}
try {
let string = NetUtil.readInputStreamToString(inputStream, inputStream.available());
var data = JSON.parse(string);
} catch (e) {
console.error("Could not read animation frame snapshot file.");
return;
}
if (data.fileType != CALLS_LIST_SERIALIZER_IDENTIFIER) {
console.error("Unrecognized animation frame snapshot file.");
return;
}
// Add a `isLoadedFromDisk` flag on everything to avoid sending invalid
// requests to the backend, since we're not dealing with actors anymore.
let snapshotItem = this.addSnapshot();
snapshotItem.isLoadedFromDisk = true;
data.calls.forEach(e => e.isLoadedFromDisk = true);
this.customizeSnapshot(snapshotItem, data.calls, data);
});
},
/**
* The click listener for the "save" button of each item in this container.
*/
_onSaveButtonClick: function(e) {
let snapshotItem = this.getItemForElement(e.target);
let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeSave);
fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
fp.defaultString = "snapshot.json";
// Start serializing all the function call actors for the specified snapshot,
// while the nsIFilePicker dialog is being opened. Snappy.
let serialized = Task.spawn(function*() {
let data = {
fileType: CALLS_LIST_SERIALIZER_IDENTIFIER,
version: CALLS_LIST_SERIALIZER_VERSION,
calls: [],
thumbnails: [],
screenshot: null
};
let functionCalls = snapshotItem.attachment.calls;
let thumbnails = snapshotItem.attachment.thumbnails;
let screenshot = snapshotItem.attachment.screenshot;
// Prepare all the function calls for serialization.
yield DevToolsUtils.yieldingEach(functionCalls, (call, i) => {
let { type, name, file, line, argsPreview, callerPreview } = call;
return call.getDetails().then(({ stack }) => {
data.calls[i] = {
type: type,
name: name,
file: file,
line: line,
stack: stack,
argsPreview: argsPreview,
callerPreview: callerPreview
};
});
});
// Prepare all the thumbnails for serialization.
yield DevToolsUtils.yieldingEach(thumbnails, (thumbnail, i) => {
let { index, width, height, flipped, pixels } = thumbnail;
data.thumbnails.push({ index, width, height, flipped, pixels });
});
// Prepare the screenshot for serialization.
let { index, width, height, flipped, pixels } = screenshot;
data.screenshot = { index, width, height, flipped, pixels };
let string = JSON.stringify(data);
let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
createInstance(Ci.nsIScriptableUnicodeConverter);
converter.charset = "UTF-8";
return converter.convertToInputStream(string);
});
// Open the nsIFilePicker and wait for the function call actors to finish
// being serialized, in order to save the generated JSON data to disk.
fp.open({ done: result => {
if (result == Ci.nsIFilePicker.returnCancel) {
return;
}
let footer = $(".snapshot-item-footer", snapshotItem.target);
let save = $(".snapshot-item-save", snapshotItem.target);
// Show a throbber and a "Saving…" label if serializing isn't immediate.
setNamedTimeout("call-list-save", CALLS_LIST_SLOW_SAVE_DELAY, () => {
footer.setAttribute("saving", "");
save.setAttribute("disabled", "true");
save.setAttribute("value", L10N.getStr("snapshotsList.savingLabel"));
});
serialized.then(inputStream => {
let outputStream = FileUtils.openSafeFileOutputStream(fp.file);
NetUtil.asyncCopy(inputStream, outputStream, status => {
if (!Components.isSuccessCode(status)) {
console.error("Could not save recorded animation frame snapshot file.");
}
clearNamedTimeout("call-list-save");
footer.removeAttribute("saving");
save.removeAttribute("disabled");
save.setAttribute("value", L10N.getStr("snapshotsList.saveLabel"));
});
});
}});
}
});
/**
* Functions handling details about a single recorded animation frame snapshot
* (the calls list, rendering preview, thumbnails filmstrip etc.).
*/
let CallsListView = Heritage.extend(WidgetMethods, {
/**
* Initialization function, called when the tool is started.
*/
initialize: function() {
this.widget = new SideMenuWidget($("#calls-list"));
this._slider = $("#calls-slider");
this._searchbox = $("#calls-searchbox");
this._filmstrip = $("#snapshot-filmstrip");
this._onSelect = this._onSelect.bind(this);
this._onSlideMouseDown = this._onSlideMouseDown.bind(this);
this._onSlideMouseUp = this._onSlideMouseUp.bind(this);
this._onSlide = this._onSlide.bind(this);
this._onSearch = this._onSearch.bind(this);
this._onScroll = this._onScroll.bind(this);
this._onExpand = this._onExpand.bind(this);
this._onStackFileClick = this._onStackFileClick.bind(this);
this._onThumbnailClick = this._onThumbnailClick.bind(this);
this.widget.addEventListener("select", this._onSelect, false);
this._slider.addEventListener("mousedown", this._onSlideMouseDown, false);
this._slider.addEventListener("mouseup", this._onSlideMouseUp, false);
this._slider.addEventListener("change", this._onSlide, false);
this._searchbox.addEventListener("input", this._onSearch, false);
this._filmstrip.addEventListener("wheel", this._onScroll, false);
},
/**
* Destruction function, called when the tool is closed.
*/
destroy: function() {
this.widget.removeEventListener("select", this._onSelect, false);
this._slider.removeEventListener("mousedown", this._onSlideMouseDown, false);
this._slider.removeEventListener("mouseup", this._onSlideMouseUp, false);
this._slider.removeEventListener("change", this._onSlide, false);
this._searchbox.removeEventListener("input", this._onSearch, false);
this._filmstrip.removeEventListener("wheel", this._onScroll, false);
},
/**
* Populates this container with a list of function calls.
*
* @param array functionCalls
* A list of function call actors received from the backend.
*/
showCalls: function(functionCalls) {
this.empty();
for (let i = 0, len = functionCalls.length; i < len; i++) {
let call = functionCalls[i];
let view = document.createElement("vbox");
view.className = "call-item-view devtools-monospace";
view.setAttribute("flex", "1");
let contents = document.createElement("hbox");
contents.className = "call-item-contents";
contents.setAttribute("align", "center");
contents.addEventListener("dblclick", this._onExpand);
view.appendChild(contents);
let index = document.createElement("label");
index.className = "plain call-item-index";
index.setAttribute("flex", "1");
index.setAttribute("value", i + 1);
let gutter = document.createElement("hbox");
gutter.className = "call-item-gutter";
gutter.appendChild(index);
contents.appendChild(gutter);
// Not all function calls have a caller that was stringified (e.g.
// context calls have a "gl" or "ctx" caller preview).
if (call.callerPreview) {
let context = document.createElement("label");
context.className = "plain call-item-context";
context.setAttribute("value", call.callerPreview);
contents.appendChild(context);
let separator = document.createElement("label");
separator.className = "plain call-item-separator";
separator.setAttribute("value", ".");
contents.appendChild(separator);
}
let name = document.createElement("label");
name.className = "plain call-item-name";
name.setAttribute("value", call.name);
contents.appendChild(name);
let argsPreview = document.createElement("label");
argsPreview.className = "plain call-item-args";
argsPreview.setAttribute("crop", "end");
argsPreview.setAttribute("flex", "100");
// Getters and setters are displayed differently from regular methods.
if (call.type == CallWatcherFront.METHOD_FUNCTION) {
argsPreview.setAttribute("value", "(" + call.argsPreview + ")");
} else {
argsPreview.setAttribute("value", " = " + call.argsPreview);
}
contents.appendChild(argsPreview);
let location = document.createElement("label");
location.className = "plain call-item-location";
location.setAttribute("value", getFileName(call.file) + ":" + call.line);
location.setAttribute("crop", "start");
location.setAttribute("flex", "1");
location.addEventListener("mousedown", this._onExpand);
contents.appendChild(location);
// Append a function call item to this container.
this.push([view], {
staged: true,
attachment: {
actor: call
}
});
// Highlight certain calls that are probably more interesting than
// everything else, making it easier to quickly glance over them.
if (CanvasFront.DRAW_CALLS.has(call.name)) {
view.setAttribute("draw-call", "");
}
if (CanvasFront.INTERESTING_CALLS.has(call.name)) {
view.setAttribute("interesting-call", "");
}
}
// Flushes all the prepared function call items into this container.
this.commit();
window.emit(EVENTS.CALL_LIST_POPULATED);
// Resetting the function selection slider's value (shown in this
// container's toolbar) would trigger a selection event, which should be
// ignored in this case.
this._ignoreSliderChanges = true;
this._slider.value = 0;
this._slider.max = functionCalls.length - 1;
this._ignoreSliderChanges = false;
},
/**
* Displays an image in the rendering preview of this container, generated
* for the specified draw call in the recorded animation frame snapshot.
*
* @param array screenshot
* A single "snapshot-image" instance received from the backend.
*/
showScreenshot: function(screenshot) {
let { index, width, height, scaling, flipped, pixels } = screenshot;
let screenshotNode = $("#screenshot-image");
screenshotNode.setAttribute("flipped", flipped);
drawBackground("screenshot-rendering", width, height, pixels);
let dimensionsNode = $("#screenshot-dimensions");
let actualWidth = (width / scaling) | 0;
let actualHeight = (height / scaling) | 0;
dimensionsNode.setAttribute("value", actualWidth + " x " + actualHeight);
window.emit(EVENTS.CALL_SCREENSHOT_DISPLAYED);
},
/**
* Populates this container's footer with a list of thumbnails, one generated
* for each draw call in the recorded animation frame snapshot.
*
* @param array thumbnails
* An array of "snapshot-image" instances received from the backend.
*/
showThumbnails: function(thumbnails) {
while (this._filmstrip.hasChildNodes()) {
this._filmstrip.firstChild.remove();
}
for (let thumbnail of thumbnails) {
this.appendThumbnail(thumbnail);
}
window.emit(EVENTS.THUMBNAILS_DISPLAYED);
},
/**
* Displays an image in the thumbnails list of this container, generated
* for the specified draw call in the recorded animation frame snapshot.
*
* @param array thumbnail
* A single "snapshot-image" instance received from the backend.
*/
appendThumbnail: function(thumbnail) {
let { index, width, height, flipped, pixels } = thumbnail;
let thumbnailNode = document.createElementNS(HTML_NS, "canvas");
thumbnailNode.setAttribute("flipped", flipped);
thumbnailNode.width = Math.max(CanvasFront.THUMBNAIL_SIZE, width);
thumbnailNode.height = Math.max(CanvasFront.THUMBNAIL_SIZE, height);
drawImage(thumbnailNode, width, height, pixels, { centered: true });
thumbnailNode.className = "filmstrip-thumbnail";
thumbnailNode.onmousedown = e => this._onThumbnailClick(e, index);
thumbnailNode.setAttribute("index", index);
this._filmstrip.appendChild(thumbnailNode);
},
/**
* Sets the currently highlighted thumbnail in this container.
* A screenshot will always correlate to a thumbnail in the filmstrip,
* both being identified by the same 'index' of the context function call.
*
* @param number index
* The context function call's index.
*/
set highlightedThumbnail(index) {
let currHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + index + "']");
if (currHighlightedThumbnail == null) {
return;
}
let prevIndex = this._highlightedThumbnailIndex
let prevHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + prevIndex + "']");
if (prevHighlightedThumbnail) {
prevHighlightedThumbnail.removeAttribute("highlighted");
}
currHighlightedThumbnail.setAttribute("highlighted", "");
currHighlightedThumbnail.scrollIntoView();
this._highlightedThumbnailIndex = index;
},
/**
* Gets the currently highlighted thumbnail in this container.
* @return number
*/
get highlightedThumbnail() {
return this._highlightedThumbnailIndex;
},
/**
* The select listener for this container.
*/
_onSelect: function({ detail: callItem }) {
if (!callItem) {
return;
}
// Some of the stepping buttons don't make sense specifically while the
// last function call is selected.
if (this.selectedIndex == this.itemCount - 1) {
$("#resume").setAttribute("disabled", "true");
$("#step-over").setAttribute("disabled", "true");
$("#step-out").setAttribute("disabled", "true");
} else {
$("#resume").removeAttribute("disabled");
$("#step-over").removeAttribute("disabled");
$("#step-out").removeAttribute("disabled");
}
// Correlate the currently selected item with the function selection
// slider's value. Avoid triggering a redundant selection event.
this._ignoreSliderChanges = true;
this._slider.value = this.selectedIndex;
this._ignoreSliderChanges = false;
// Can't generate screenshots for function call actors loaded from disk.
// XXX: Bug 984844.
if (callItem.attachment.actor.isLoadedFromDisk) {
return;
}
// To keep continuous selection buttery smooth (for example, while pressing
// the DOWN key or moving the slider), only display the screenshot after
// any kind of user input stops.
setConditionalTimeout("screenshot-display", SCREENSHOT_DISPLAY_DELAY, () => {
return !this._isSliding;
}, () => {
let frameSnapshot = SnapshotsListView.selectedItem.attachment.actor
let functionCall = callItem.attachment.actor;
frameSnapshot.generateScreenshotFor(functionCall).then(screenshot => {
this.showScreenshot(screenshot);
this.highlightedThumbnail = screenshot.index;
}).catch(Cu.reportError);
});
},
/**
* The mousedown listener for the call selection slider.
*/
_onSlideMouseDown: function() {
this._isSliding = true;
},
/**
* The mouseup listener for the call selection slider.
*/
_onSlideMouseUp: function() {
this._isSliding = false;
},
/**
* The change listener for the call selection slider.
*/
_onSlide: function() {
// Avoid performing any operations when programatically changing the value.
if (this._ignoreSliderChanges) {
return;
}
let selectedFunctionCallIndex = this.selectedIndex = this._slider.value;
// While sliding, immediately show the most relevant thumbnail for a
// function call, for a nice diff-like animation effect between draws.
let thumbnails = SnapshotsListView.selectedItem.attachment.thumbnails;
let thumbnail = getThumbnailForCall(thumbnails, selectedFunctionCallIndex);
// Avoid drawing and highlighting if the selected function call has the
// same thumbnail as the last one.
if (thumbnail.index == this.highlightedThumbnail) {
return;
}
// If a thumbnail wasn't found (e.g. the backend avoids creating thumbnails
// when rendering offscreen), simply defer to the first available one.
if (thumbnail.index == -1) {
thumbnail = thumbnails[0];
}
let { index, width, height, flipped, pixels } = thumbnail;
this.highlightedThumbnail = index;
let screenshotNode = $("#screenshot-image");
screenshotNode.setAttribute("flipped", flipped);
drawBackground("screenshot-rendering", width, height, pixels);
},
/**
* The input listener for the calls searchbox.
*/
_onSearch: function(e) {
let lowerCaseSearchToken = this._searchbox.value.toLowerCase();
this.filterContents(e => {
let call = e.attachment.actor;
let name = call.name.toLowerCase();
let file = call.file.toLowerCase();
let line = call.line.toString().toLowerCase();
let args = call.argsPreview.toLowerCase();
return name.contains(lowerCaseSearchToken) ||
file.contains(lowerCaseSearchToken) ||
line.contains(lowerCaseSearchToken) ||
args.contains(lowerCaseSearchToken);
});
},
/**
* The wheel listener for the filmstrip that contains all the thumbnails.
*/
_onScroll: function(e) {
this._filmstrip.scrollLeft += e.deltaX;
},
/**
* The click/dblclick listener for an item or location url in this container.
* When expanding an item, it's corresponding call stack will be displayed.
*/
_onExpand: function(e) {
let callItem = this.getItemForElement(e.target);
let view = $(".call-item-view", callItem.target);
// If the call stack nodes were already created, simply re-show them
// or jump to the corresponding file and line in the Debugger if a
// location link was clicked.
if (view.hasAttribute("call-stack-populated")) {
let isExpanded = view.getAttribute("call-stack-expanded") == "true";
// If clicking on the location, jump to the Debugger.
if (e.target.classList.contains("call-item-location")) {
let { file, line } = callItem.attachment.actor;
viewSourceInDebugger(file, line);
return;
}
// Otherwise hide the call stack.
else {
view.setAttribute("call-stack-expanded", !isExpanded);
$(".call-item-stack", view).hidden = isExpanded;
return;
}
}
let list = document.createElement("vbox");
list.className = "call-item-stack";
view.setAttribute("call-stack-populated", "");
view.setAttribute("call-stack-expanded", "true");
view.appendChild(list);
/**
* Creates a function call nodes in this container for a stack.
*/
let display = stack => {
for (let i = 1; i < stack.length; i++) {
let call = stack[i];
let contents = document.createElement("hbox");
contents.className = "call-item-stack-fn";
contents.style.MozPaddingStart = (i * STACK_FUNC_INDENTATION) + "px";
let name = document.createElement("label");
name.className = "plain call-item-stack-fn-name";
name.setAttribute("value", "↳ " + call.name + "()");
contents.appendChild(name);
let spacer = document.createElement("spacer");
spacer.setAttribute("flex", "100");
contents.appendChild(spacer);
let location = document.createElement("label");
location.className = "plain call-item-stack-fn-location";
location.setAttribute("value", getFileName(call.file) + ":" + call.line);
location.setAttribute("crop", "start");
location.setAttribute("flex", "1");
location.addEventListener("mousedown", e => this._onStackFileClick(e, call));
contents.appendChild(location);
list.appendChild(contents);
}
window.emit(EVENTS.CALL_STACK_DISPLAYED);
};
// If this animation snapshot is loaded from disk, there are no corresponding
// backend actors available and the data is immediately available.
let functionCall = callItem.attachment.actor;
if (functionCall.isLoadedFromDisk) {
display(functionCall.stack);
}
// ..otherwise we need to request the function call stack from the backend.
else {
callItem.attachment.actor.getDetails().then(fn => display(fn.stack));
}
},
/**
* The click listener for a location link in the call stack.
*
* @param string file
* The url of the source owning the function.
* @param number line
* The line of the respective function.
*/
_onStackFileClick: function(e, { file, line }) {
viewSourceInDebugger(file, line);
},
/**
* The click listener for a thumbnail in the filmstrip.
*
* @param number index
* The function index in the recorded animation frame snapshot.
*/
_onThumbnailClick: function(e, index) {
this.selectedIndex = index;
},
/**
* The click listener for the "resume" button in this container's toolbar.
*/
_onResume: function() {
// Jump to the next draw call in the recorded animation frame snapshot.
let drawCall = getNextDrawCall(this.items, this.selectedItem);
if (drawCall) {
this.selectedItem = drawCall;
return;
}
// If there are no more draw calls, just jump to the last context call.
this._onStepOut();
},
/**
* The click listener for the "step over" button in this container's toolbar.
*/
_onStepOver: function() {
this.selectedIndex++;
},
/**
* The click listener for the "step in" button in this container's toolbar.
*/
_onStepIn: function() {
if (this.selectedIndex == -1) {
this._onResume();
return;
}
let callItem = this.selectedItem;
let { file, line } = callItem.attachment.actor;
viewSourceInDebugger(file, line);
},
/**
* The click listener for the "step out" button in this container's toolbar.
*/
_onStepOut: function() {
this.selectedIndex = this.itemCount - 1;
}
});
/**
* Localization convenience methods.
*/
let L10N = new ViewHelpers.L10N(STRINGS_URI);
/**
* Convenient way of emitting events from the panel window.
*/
EventEmitter.decorate(this);
/**
* DOM query helpers.
*/
function $(selector, target = document) target.querySelector(selector);
function $all(selector, target = document) target.querySelectorAll(selector);
/**
* Helper for getting an nsIURL instance out of a string.
*/
function nsIURL(url, store = nsIURL.store) {
if (store.has(url)) {
return store.get(url);
}
let uri = Services.io.newURI(url, null, null).QueryInterface(Ci.nsIURL);
store.set(url, uri);
return uri;
}
// The cache used in the `nsIURL` function.
nsIURL.store = new Map();
/**
* Gets the fileName part of a string which happens to be an URL.
*/
function getFileName(url) {
try {
let { fileName } = nsIURL(url);
return fileName || "/";
} catch (e) {
// This doesn't look like a url, or nsIURL can't handle it.
return "";
}
}
/**
* Gets an image data object containing a buffer large enough to hold
* width * height pixels.
*
* This method avoids allocating memory and tries to reuse a common buffer
* as much as possible.
*
* @param number w
* The desired image data storage width.
* @param number h
* The desired image data storage height.
* @return ImageData
* The requested image data buffer.
*/
function getImageDataStorage(ctx, w, h) {
let storage = getImageDataStorage.cache;
if (storage && storage.width == w && storage.height == h) {
return storage;
}
return getImageDataStorage.cache = ctx.createImageData(w, h);
}
// The cache used in the `getImageDataStorage` function.
getImageDataStorage.cache = null;
/**
* Draws image data into a canvas.
*
* This method makes absolutely no assumptions about the canvas element
* dimensions, or pre-existing rendering. It's a dumb proxy that copies pixels.
*
* @param HTMLCanvasElement canvas
* The canvas element to put the image data into.
* @param number width
* The image data width.
* @param number height
* The image data height.
* @param array pixels
* An array buffer view of the image data.
* @param object options
* Additional options supported by this operation:
* - centered: specifies whether the image data should be centered
* when copied in the canvas; this is useful when the
* supplied pixels don't completely cover the canvas.
*/
function drawImage(canvas, width, height, pixels, options = {}) {
let ctx = canvas.getContext("2d");
// FrameSnapshot actors return "snapshot-image" type instances with just an
// empty pixel array if the source image is completely transparent.
if (pixels.length <= 1) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
return;
}
let imageData = getImageDataStorage(ctx, width, height);
imageData.data.set(pixels);
if (options.centered) {
let left = (canvas.width - width) / 2;
let top = (canvas.height - height) / 2;
ctx.putImageData(imageData, left, top);
} else {
ctx.putImageData(imageData, 0, 0);
}
}
/**
* Draws image data into a canvas, and sets that as the rendering source for
* an element with the specified id as the -moz-element background image.
*
* @param string id
* The id of the -moz-element background image.
* @param number width
* The image data width.
* @param number height
* The image data height.
* @param array pixels
* An array buffer view of the image data.
*/
function drawBackground(id, width, height, pixels) {
let canvas = document.createElementNS(HTML_NS, "canvas");
canvas.width = width;
canvas.height = height;
drawImage(canvas, width, height, pixels);
document.mozSetImageElement(id, canvas);
// Used in tests. Not emitting an event because this shouldn't be "interesting".
if (window._onMozSetImageElement) {
window._onMozSetImageElement(pixels);
}
}
/**
* Iterates forward to find the next draw call in a snapshot.
*/
function getNextDrawCall(calls, call) {
for (let i = calls.indexOf(call) + 1, len = calls.length; i < len; i++) {
let nextCall = calls[i];
let name = nextCall.attachment.actor.name;
if (CanvasFront.DRAW_CALLS.has(name)) {
return nextCall;
}
}
return null;
}
/**
* Iterates backwards to find the most recent screenshot for a function call
* in a snapshot loaded from disk.
*/
function getScreenshotFromCallLoadedFromDisk(calls, call) {
for (let i = calls.indexOf(call); i >= 0; i--) {
let prevCall = calls[i];
let screenshot = prevCall.screenshot;
if (screenshot) {
return screenshot;
}
}
return CanvasFront.INVALID_SNAPSHOT_IMAGE;
}
/**
* Iterates backwards to find the most recent thumbnail for a function call.
*/
function getThumbnailForCall(thumbnails, index) {
for (let i = thumbnails.length - 1; i >= 0; i--) {
let thumbnail = thumbnails[i];
if (thumbnail.index <= index) {
return thumbnail;
}
}
return CanvasFront.INVALID_SNAPSHOT_IMAGE;
}
/**
* Opens/selects the debugger in this toolbox and jumps to the specified
* file name and line number.
*/
function viewSourceInDebugger(url, line) {
let showSource = ({ DebuggerView }) => {
let item = DebuggerView.Sources.getItemForAttachment(a => a.source.url === url);
if (item) {
DebuggerView.setEditorLocation(item.attachment.source.actor, line, { noDebug: true }).then(() => {
window.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
}, () => {
window.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER);
});
}
}
// 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 = gToolbox.getPanel("jsdebugger");
gToolbox.selectTool("jsdebugger").then(({ panelWin: dbg }) => {
if (debuggerAlreadyOpen) {
showSource(dbg);
} else {
dbg.once(dbg.EVENTS.SOURCES_ADDED, () => showSource(dbg));
}
});
}