/* 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 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); // 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, 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; }); }); }, /** * 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)); } }); }