diff -r 000000000000 -r 6474c204b198 browser/devtools/canvasdebugger/canvasdebugger.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser/devtools/canvasdebugger/canvasdebugger.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,1270 @@ +/* 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"); + +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() { + 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() { + 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 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_HEIGHT; + thumbnail.height = CanvasFront.THUMBNAIL_HEIGHT; + + 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); + + // Create array buffers from the parsed pixel arrays. + for (let thumbnail of data.thumbnails) { + let thumbnailPixelsArray = thumbnail.pixels.split(","); + thumbnail.pixels = new Uint32Array(thumbnailPixelsArray); + } + let screenshotPixelsArray = data.screenshot.pixels.split(","); + data.screenshot.pixels = new Uint32Array(screenshotPixelsArray); + + 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: index, + width: width, + height: height, + flipped: flipped, + pixels: Array.join(pixels, ",") + }); + }); + + // Prepare the screenshot for serialization. + let { index, width, height, flipped, pixels } = screenshot; + data.screenshot = { + index: index, + width: width, + height: height, + flipped: flipped, + pixels: Array.join(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, flipped, pixels } = screenshot; + + let screenshotNode = $("#screenshot-image"); + screenshotNode.setAttribute("flipped", flipped); + drawBackground("screenshot-rendering", width, height, pixels); + + let dimensionsNode = $("#screenshot-dimensions"); + dimensionsNode.setAttribute("value", ~~width + " x " + ~~height); + + 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_HEIGHT, width); + thumbnailNode.height = Math.max(CanvasFront.THUMBNAIL_HEIGHT, 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; + }); + }); + }, + + /** + * 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 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 arrayBuffer = new Uint8Array(pixels.buffer); + let imageData = getImageDataStorage(ctx, width, height); + imageData.data.set(arrayBuffer); + + 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 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 }) => { + if (DebuggerView.Sources.containsValue(url)) { + DebuggerView.setEditorLocation(url, 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)); + } + }); +}