diff options
Diffstat (limited to 'devtools/client/canvasdebugger/snapshotslist.js')
-rw-r--r-- | devtools/client/canvasdebugger/snapshotslist.js | 495 |
1 files changed, 495 insertions, 0 deletions
diff --git a/devtools/client/canvasdebugger/snapshotslist.js b/devtools/client/canvasdebugger/snapshotslist.js new file mode 100644 index 000000000..da3b4a7eb --- /dev/null +++ b/devtools/client/canvasdebugger/snapshotslist.js @@ -0,0 +1,495 @@ +/* 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/. */ +/* import-globals-from canvasdebugger.js */ +/* globals window, document */ +"use strict"; + +/** + * Functions handling the recorded animation frame snapshots UI. + */ +var 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._onRecordSuccess = this._onRecordSuccess.bind(this); + this._onRecordFailure = this._onRecordFailure.bind(this); + this._stopRecordingAnimation = this._stopRecordingAnimation.bind(this); + + window.on(EVENTS.SNAPSHOT_RECORDING_FINISHED, this._enableRecordButton); + this.emptyText = L10N.getStr("noSnapshotsText"); + this.widget.addEventListener("select", this._onSelect, false); + }, + + /** + * Destruction function, called when the tool is closed. + */ + destroy: function () { + clearNamedTimeout("canvas-actor-recording"); + window.off(EVENTS.SNAPSHOT_RECORDING_FINISHED, this._enableRecordButton); + 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 + } + }); + }, + + /** + * Removes the last snapshot added, in the event no requestAnimationFrame loop was found. + */ + removeLastSnapshot: function () { + this.removeAt(this.itemCount - 1); + // If this is the only item, revert back to the empty notice + if (this.itemCount === 0) { + $("#empty-notice").hidden = false; + $("#waiting-notice").hidden = true; + } + }, + + /** + * 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 }) { + // Check to ensure the attachment has an actor, like + // an in-progress recording. + if (!snapshotItem || !snapshotItem.attachment.actor) { + return; + } + let { calls, thumbnails, screenshot } = snapshotItem.attachment; + + $("#reload-notice").hidden = true; + $("#empty-notice").hidden = true; + $("#waiting-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; + $("#waiting-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; + $("#waiting-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 () { + this._disableRecordButton(); + + if (this._recording) { + this._stopRecordingAnimation(); + return; + } + + // 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. + this.addSnapshot(); + + // If this is the first item, immediately show the "Loading…" notice. + if (this.itemCount == 1) { + $("#empty-notice").hidden = true; + $("#waiting-notice").hidden = false; + } + + this._recordAnimation(); + }, + + /** + * Makes the record button able to be clicked again. + */ + _enableRecordButton: function () { + $("#record-snapshot").removeAttribute("disabled"); + }, + + /** + * Makes the record button unable to be clicked. + */ + _disableRecordButton: function () { + $("#record-snapshot").setAttribute("disabled", true); + }, + + /** + * Begins recording an animation. + */ + _recordAnimation: Task.async(function* () { + if (this._recording) { + return; + } + this._recording = true; + $("#record-snapshot").setAttribute("checked", "true"); + + setNamedTimeout("canvas-actor-recording", CANVAS_ACTOR_RECORDING_ATTEMPT, this._stopRecordingAnimation); + + yield DevToolsUtils.waitForTime(SNAPSHOT_START_RECORDING_DELAY); + window.emit(EVENTS.SNAPSHOT_RECORDING_STARTED); + + gFront.recordAnimationFrame().then(snapshot => { + if (snapshot) { + this._onRecordSuccess(snapshot); + } else { + this._onRecordFailure(); + } + }); + + // Wait another delay before reenabling the button to stop the recording + // if a recording is not found. + yield DevToolsUtils.waitForTime(SNAPSHOT_START_RECORDING_DELAY); + this._enableRecordButton(); + }), + + /** + * Stops recording animation. Called when a click on the stopwatch occurs during a recording, + * or if a recording times out. + */ + _stopRecordingAnimation: Task.async(function* () { + clearNamedTimeout("canvas-actor-recording"); + let actorCanStop = yield gTarget.actorHasMethod("canvas", "stopRecordingAnimationFrame"); + + if (actorCanStop) { + yield gFront.stopRecordingAnimationFrame(); + } + // If actor does not have the method to stop recording (Fx39+), + // manually call the record failure method. This will call a connection failure + // on disconnect as a result of `gFront.recordAnimationFrame()` never resolving, + // but this is better than it hanging when there is no requestAnimationFrame anyway. + else { + this._onRecordFailure(); + } + + this._recording = false; + $("#record-snapshot").removeAttribute("checked"); + this._enableRecordButton(); + }), + + /** + * Resolves from the front's recordAnimationFrame to setup the interface with the screenshots. + */ + _onRecordSuccess: Task.async(function* (snapshotActor) { + // Clear bail-out case if frame found in CANVAS_ACTOR_RECORDING_ATTEMPT milliseconds + clearNamedTimeout("canvas-actor-recording"); + let snapshotItem = this.getItemAtIndex(this.itemCount - 1); + let snapshotOverview = yield snapshotActor.getOverview(); + this.customizeSnapshot(snapshotItem, snapshotActor, snapshotOverview); + + this._recording = false; + $("#record-snapshot").removeAttribute("checked"); + + window.emit(EVENTS.SNAPSHOT_RECORDING_COMPLETED); + window.emit(EVENTS.SNAPSHOT_RECORDING_FINISHED); + }), + + /** + * Called as a reject from the front's recordAnimationFrame. + */ + _onRecordFailure: function () { + clearNamedTimeout("canvas-actor-recording"); + showNotification(gToolbox, "canvas-debugger-timeout", L10N.getStr("recordingTimeoutFailure")); + window.emit(EVENTS.SNAPSHOT_RECORDING_CANCELLED); + window.emit(EVENTS.SNAPSHOT_RECORDING_FINISHED); + this.removeLastSnapshot(); + }, + + /** + * 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({ + uri: NetUtil.newURI(fp.file), loadUsingSystemPrincipal: true}); + 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, timestamp, argsPreview, callerPreview } = call; + return call.getDetails().then(({ stack }) => { + data.calls[i] = { + type: type, + name: name, + file: file, + line: line, + stack: stack, + timestamp: timestamp, + 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.classList.add("devtools-throbber"); + 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.classList.remove("devtools-throbber"); + save.removeAttribute("disabled"); + save.setAttribute("value", L10N.getStr("snapshotsList.saveLabel")); + }); + }); + }}); + } +}); + +function showNotification(toolbox, name, message) { + let notificationBox = toolbox.getNotificationBox(); + let notification = notificationBox.getNotificationWithValue(name); + if (!notification) { + notificationBox.appendNotification(message, name, "", notificationBox.PRIORITY_WARNING_HIGH); + } +} |