Wed, 31 Dec 2014 06:09:35 +0100
Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
4 "use strict";
6 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
8 Cu.import("resource://gre/modules/Services.jsm");
9 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
10 Cu.import("resource:///modules/devtools/SideMenuWidget.jsm");
11 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
13 const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
14 const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
15 const EventEmitter = require("devtools/toolkit/event-emitter");
16 const { CallWatcherFront } = require("devtools/server/actors/call-watcher");
17 const { CanvasFront } = require("devtools/server/actors/canvas");
19 XPCOMUtils.defineLazyModuleGetter(this, "Task",
20 "resource://gre/modules/Task.jsm");
22 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
23 "resource://gre/modules/PluralForm.jsm");
25 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
26 "resource://gre/modules/FileUtils.jsm");
28 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
29 "resource://gre/modules/NetUtil.jsm");
31 XPCOMUtils.defineLazyModuleGetter(this, "DevToolsUtils",
32 "resource://gre/modules/devtools/DevToolsUtils.jsm");
34 // The panel's window global is an EventEmitter firing the following events:
35 const EVENTS = {
36 // When the UI is reset from tab navigation.
37 UI_RESET: "CanvasDebugger:UIReset",
39 // When all the animation frame snapshots are removed by the user.
40 SNAPSHOTS_LIST_CLEARED: "CanvasDebugger:SnapshotsListCleared",
42 // When an animation frame snapshot starts/finishes being recorded.
43 SNAPSHOT_RECORDING_STARTED: "CanvasDebugger:SnapshotRecordingStarted",
44 SNAPSHOT_RECORDING_FINISHED: "CanvasDebugger:SnapshotRecordingFinished",
46 // When an animation frame snapshot was selected and all its data displayed.
47 SNAPSHOT_RECORDING_SELECTED: "CanvasDebugger:SnapshotRecordingSelected",
49 // After all the function calls associated with an animation frame snapshot
50 // are displayed in the UI.
51 CALL_LIST_POPULATED: "CanvasDebugger:CallListPopulated",
53 // After the stack associated with a call in an animation frame snapshot
54 // is displayed in the UI.
55 CALL_STACK_DISPLAYED: "CanvasDebugger:CallStackDisplayed",
57 // After a screenshot associated with a call in an animation frame snapshot
58 // is displayed in the UI.
59 CALL_SCREENSHOT_DISPLAYED: "CanvasDebugger:ScreenshotDisplayed",
61 // After all the thumbnails associated with an animation frame snapshot
62 // are displayed in the UI.
63 THUMBNAILS_DISPLAYED: "CanvasDebugger:ThumbnailsDisplayed",
65 // When a source is shown in the JavaScript Debugger at a specific location.
66 SOURCE_SHOWN_IN_JS_DEBUGGER: "CanvasDebugger:SourceShownInJsDebugger",
67 SOURCE_NOT_FOUND_IN_JS_DEBUGGER: "CanvasDebugger:SourceNotFoundInJsDebugger"
68 };
70 const HTML_NS = "http://www.w3.org/1999/xhtml";
71 const STRINGS_URI = "chrome://browser/locale/devtools/canvasdebugger.properties"
73 const SNAPSHOT_START_RECORDING_DELAY = 10; // ms
74 const SNAPSHOT_DATA_EXPORT_MAX_BLOCK = 1000; // ms
75 const SNAPSHOT_DATA_DISPLAY_DELAY = 10; // ms
76 const SCREENSHOT_DISPLAY_DELAY = 100; // ms
77 const STACK_FUNC_INDENTATION = 14; // px
79 // This identifier string is simply used to tentatively ascertain whether or not
80 // a JSON loaded from disk is actually something generated by this tool or not.
81 // It isn't, of course, a definitive verification, but a Good Enough™
82 // approximation before continuing the import. Don't localize this.
83 const CALLS_LIST_SERIALIZER_IDENTIFIER = "Recorded Animation Frame Snapshot";
84 const CALLS_LIST_SERIALIZER_VERSION = 1;
85 const CALLS_LIST_SLOW_SAVE_DELAY = 100; // ms
87 /**
88 * The current target and the Canvas front, set by this tool's host.
89 */
90 let gToolbox, gTarget, gFront;
92 /**
93 * Initializes the canvas debugger controller and views.
94 */
95 function startupCanvasDebugger() {
96 return promise.all([
97 EventsHandler.initialize(),
98 SnapshotsListView.initialize(),
99 CallsListView.initialize()
100 ]);
101 }
103 /**
104 * Destroys the canvas debugger controller and views.
105 */
106 function shutdownCanvasDebugger() {
107 return promise.all([
108 EventsHandler.destroy(),
109 SnapshotsListView.destroy(),
110 CallsListView.destroy()
111 ]);
112 }
114 /**
115 * Functions handling target-related lifetime events.
116 */
117 let EventsHandler = {
118 /**
119 * Listen for events emitted by the current tab target.
120 */
121 initialize: function() {
122 this._onTabNavigated = this._onTabNavigated.bind(this);
123 gTarget.on("will-navigate", this._onTabNavigated);
124 gTarget.on("navigate", this._onTabNavigated);
125 },
127 /**
128 * Remove events emitted by the current tab target.
129 */
130 destroy: function() {
131 gTarget.off("will-navigate", this._onTabNavigated);
132 gTarget.off("navigate", this._onTabNavigated);
133 },
135 /**
136 * Called for each location change in the debugged tab.
137 */
138 _onTabNavigated: function(event) {
139 if (event != "will-navigate") {
140 return;
141 }
142 // Make sure the backend is prepared to handle <canvas> contexts.
143 gFront.setup({ reload: false });
145 // Reset UI.
146 SnapshotsListView.empty();
147 CallsListView.empty();
149 $("#record-snapshot").removeAttribute("checked");
150 $("#record-snapshot").removeAttribute("disabled");
151 $("#record-snapshot").hidden = false;
153 $("#reload-notice").hidden = true;
154 $("#empty-notice").hidden = false;
155 $("#import-notice").hidden = true;
157 $("#debugging-pane-contents").hidden = true;
158 $("#screenshot-container").hidden = true;
159 $("#snapshot-filmstrip").hidden = true;
161 window.emit(EVENTS.UI_RESET);
162 }
163 };
165 /**
166 * Functions handling the recorded animation frame snapshots UI.
167 */
168 let SnapshotsListView = Heritage.extend(WidgetMethods, {
169 /**
170 * Initialization function, called when the tool is started.
171 */
172 initialize: function() {
173 this.widget = new SideMenuWidget($("#snapshots-list"), {
174 showArrows: true
175 });
177 this._onSelect = this._onSelect.bind(this);
178 this._onClearButtonClick = this._onClearButtonClick.bind(this);
179 this._onRecordButtonClick = this._onRecordButtonClick.bind(this);
180 this._onImportButtonClick = this._onImportButtonClick.bind(this);
181 this._onSaveButtonClick = this._onSaveButtonClick.bind(this);
183 this.emptyText = L10N.getStr("noSnapshotsText");
184 this.widget.addEventListener("select", this._onSelect, false);
185 },
187 /**
188 * Destruction function, called when the tool is closed.
189 */
190 destroy: function() {
191 this.widget.removeEventListener("select", this._onSelect, false);
192 },
194 /**
195 * Adds a snapshot entry to this container.
196 *
197 * @return object
198 * The newly inserted item.
199 */
200 addSnapshot: function() {
201 let contents = document.createElement("hbox");
202 contents.className = "snapshot-item";
204 let thumbnail = document.createElementNS(HTML_NS, "canvas");
205 thumbnail.className = "snapshot-item-thumbnail";
206 thumbnail.width = CanvasFront.THUMBNAIL_HEIGHT;
207 thumbnail.height = CanvasFront.THUMBNAIL_HEIGHT;
209 let title = document.createElement("label");
210 title.className = "plain snapshot-item-title";
211 title.setAttribute("value",
212 L10N.getFormatStr("snapshotsList.itemLabel", this.itemCount + 1));
214 let calls = document.createElement("label");
215 calls.className = "plain snapshot-item-calls";
216 calls.setAttribute("value",
217 L10N.getStr("snapshotsList.loadingLabel"));
219 let save = document.createElement("label");
220 save.className = "plain snapshot-item-save";
221 save.addEventListener("click", this._onSaveButtonClick, false);
223 let spacer = document.createElement("spacer");
224 spacer.setAttribute("flex", "1");
226 let footer = document.createElement("hbox");
227 footer.className = "snapshot-item-footer";
228 footer.appendChild(save);
230 let details = document.createElement("vbox");
231 details.className = "snapshot-item-details";
232 details.appendChild(title);
233 details.appendChild(calls);
234 details.appendChild(spacer);
235 details.appendChild(footer);
237 contents.appendChild(thumbnail);
238 contents.appendChild(details);
240 // Append a recorded snapshot item to this container.
241 return this.push([contents], {
242 attachment: {
243 // The snapshot and function call actors, along with the thumbnails
244 // will be available as soon as recording finishes.
245 actor: null,
246 calls: null,
247 thumbnails: null,
248 screenshot: null
249 }
250 });
251 },
253 /**
254 * Customizes a shapshot in this container.
255 *
256 * @param Item snapshotItem
257 * An item inserted via `SnapshotsListView.addSnapshot`.
258 * @param object snapshotActor
259 * The frame snapshot actor received from the backend.
260 * @param object snapshotOverview
261 * Additional data about the snapshot received from the backend.
262 */
263 customizeSnapshot: function(snapshotItem, snapshotActor, snapshotOverview) {
264 // Make sure the function call actors are stored on the item,
265 // to be used when populating the CallsListView.
266 snapshotItem.attachment.actor = snapshotActor;
267 let functionCalls = snapshotItem.attachment.calls = snapshotOverview.calls;
268 let thumbnails = snapshotItem.attachment.thumbnails = snapshotOverview.thumbnails;
269 let screenshot = snapshotItem.attachment.screenshot = snapshotOverview.screenshot;
271 let lastThumbnail = thumbnails[thumbnails.length - 1];
272 let { width, height, flipped, pixels } = lastThumbnail;
274 let thumbnailNode = $(".snapshot-item-thumbnail", snapshotItem.target);
275 thumbnailNode.setAttribute("flipped", flipped);
276 drawImage(thumbnailNode, width, height, pixels, { centered: true });
278 let callsNode = $(".snapshot-item-calls", snapshotItem.target);
279 let drawCalls = functionCalls.filter(e => CanvasFront.DRAW_CALLS.has(e.name));
281 let drawCallsStr = PluralForm.get(drawCalls.length,
282 L10N.getStr("snapshotsList.drawCallsLabel"));
283 let funcCallsStr = PluralForm.get(functionCalls.length,
284 L10N.getStr("snapshotsList.functionCallsLabel"));
286 callsNode.setAttribute("value",
287 drawCallsStr.replace("#1", drawCalls.length) + ", " +
288 funcCallsStr.replace("#1", functionCalls.length));
290 let saveNode = $(".snapshot-item-save", snapshotItem.target);
291 saveNode.setAttribute("disabled", !!snapshotItem.isLoadedFromDisk);
292 saveNode.setAttribute("value", snapshotItem.isLoadedFromDisk
293 ? L10N.getStr("snapshotsList.loadedLabel")
294 : L10N.getStr("snapshotsList.saveLabel"));
296 // Make sure there's always a selected item available.
297 if (!this.selectedItem) {
298 this.selectedIndex = 0;
299 }
300 },
302 /**
303 * The select listener for this container.
304 */
305 _onSelect: function({ detail: snapshotItem }) {
306 if (!snapshotItem) {
307 return;
308 }
309 let { calls, thumbnails, screenshot } = snapshotItem.attachment;
311 $("#reload-notice").hidden = true;
312 $("#empty-notice").hidden = true;
313 $("#import-notice").hidden = false;
315 $("#debugging-pane-contents").hidden = true;
316 $("#screenshot-container").hidden = true;
317 $("#snapshot-filmstrip").hidden = true;
319 Task.spawn(function*() {
320 // Wait for a few milliseconds between presenting the function calls,
321 // screenshot and thumbnails, to allow each component being
322 // sequentially drawn. This gives the illusion of snappiness.
324 yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
325 CallsListView.showCalls(calls);
326 $("#debugging-pane-contents").hidden = false;
327 $("#import-notice").hidden = true;
329 yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
330 CallsListView.showThumbnails(thumbnails);
331 $("#snapshot-filmstrip").hidden = false;
333 yield DevToolsUtils.waitForTime(SNAPSHOT_DATA_DISPLAY_DELAY);
334 CallsListView.showScreenshot(screenshot);
335 $("#screenshot-container").hidden = false;
337 window.emit(EVENTS.SNAPSHOT_RECORDING_SELECTED);
338 });
339 },
341 /**
342 * The click listener for the "clear" button in this container.
343 */
344 _onClearButtonClick: function() {
345 Task.spawn(function*() {
346 SnapshotsListView.empty();
347 CallsListView.empty();
349 $("#reload-notice").hidden = true;
350 $("#empty-notice").hidden = true;
351 $("#import-notice").hidden = true;
353 if (yield gFront.isInitialized()) {
354 $("#empty-notice").hidden = false;
355 } else {
356 $("#reload-notice").hidden = false;
357 }
359 $("#debugging-pane-contents").hidden = true;
360 $("#screenshot-container").hidden = true;
361 $("#snapshot-filmstrip").hidden = true;
363 window.emit(EVENTS.SNAPSHOTS_LIST_CLEARED);
364 });
365 },
367 /**
368 * The click listener for the "record" button in this container.
369 */
370 _onRecordButtonClick: function() {
371 Task.spawn(function*() {
372 $("#record-snapshot").setAttribute("checked", "true");
373 $("#record-snapshot").setAttribute("disabled", "true");
375 // Insert a "dummy" snapshot item in the view, to hint that recording
376 // has now started. However, wait for a few milliseconds before actually
377 // starting the recording, since that might block rendering and prevent
378 // the dummy snapshot item from being drawn.
379 let snapshotItem = this.addSnapshot();
381 // If this is the first item, immediately show the "Loading…" notice.
382 if (this.itemCount == 1) {
383 $("#empty-notice").hidden = true;
384 $("#import-notice").hidden = false;
385 }
387 yield DevToolsUtils.waitForTime(SNAPSHOT_START_RECORDING_DELAY);
388 window.emit(EVENTS.SNAPSHOT_RECORDING_STARTED);
390 let snapshotActor = yield gFront.recordAnimationFrame();
391 let snapshotOverview = yield snapshotActor.getOverview();
392 this.customizeSnapshot(snapshotItem, snapshotActor, snapshotOverview);
394 $("#record-snapshot").removeAttribute("checked");
395 $("#record-snapshot").removeAttribute("disabled");
397 window.emit(EVENTS.SNAPSHOT_RECORDING_FINISHED);
398 }.bind(this));
399 },
401 /**
402 * The click listener for the "import" button in this container.
403 */
404 _onImportButtonClick: function() {
405 let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
406 fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeOpen);
407 fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
408 fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
410 if (fp.show() != Ci.nsIFilePicker.returnOK) {
411 return;
412 }
414 let channel = NetUtil.newChannel(fp.file);
415 channel.contentType = "text/plain";
417 NetUtil.asyncFetch(channel, (inputStream, status) => {
418 if (!Components.isSuccessCode(status)) {
419 console.error("Could not import recorded animation frame snapshot file.");
420 return;
421 }
422 try {
423 let string = NetUtil.readInputStreamToString(inputStream, inputStream.available());
424 var data = JSON.parse(string);
425 } catch (e) {
426 console.error("Could not read animation frame snapshot file.");
427 return;
428 }
429 if (data.fileType != CALLS_LIST_SERIALIZER_IDENTIFIER) {
430 console.error("Unrecognized animation frame snapshot file.");
431 return;
432 }
434 // Add a `isLoadedFromDisk` flag on everything to avoid sending invalid
435 // requests to the backend, since we're not dealing with actors anymore.
436 let snapshotItem = this.addSnapshot();
437 snapshotItem.isLoadedFromDisk = true;
438 data.calls.forEach(e => e.isLoadedFromDisk = true);
440 // Create array buffers from the parsed pixel arrays.
441 for (let thumbnail of data.thumbnails) {
442 let thumbnailPixelsArray = thumbnail.pixels.split(",");
443 thumbnail.pixels = new Uint32Array(thumbnailPixelsArray);
444 }
445 let screenshotPixelsArray = data.screenshot.pixels.split(",");
446 data.screenshot.pixels = new Uint32Array(screenshotPixelsArray);
448 this.customizeSnapshot(snapshotItem, data.calls, data);
449 });
450 },
452 /**
453 * The click listener for the "save" button of each item in this container.
454 */
455 _onSaveButtonClick: function(e) {
456 let snapshotItem = this.getItemForElement(e.target);
458 let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
459 fp.init(window, L10N.getStr("snapshotsList.saveDialogTitle"), Ci.nsIFilePicker.modeSave);
460 fp.appendFilter(L10N.getStr("snapshotsList.saveDialogJSONFilter"), "*.json");
461 fp.appendFilter(L10N.getStr("snapshotsList.saveDialogAllFilter"), "*.*");
462 fp.defaultString = "snapshot.json";
464 // Start serializing all the function call actors for the specified snapshot,
465 // while the nsIFilePicker dialog is being opened. Snappy.
466 let serialized = Task.spawn(function*() {
467 let data = {
468 fileType: CALLS_LIST_SERIALIZER_IDENTIFIER,
469 version: CALLS_LIST_SERIALIZER_VERSION,
470 calls: [],
471 thumbnails: [],
472 screenshot: null
473 };
474 let functionCalls = snapshotItem.attachment.calls;
475 let thumbnails = snapshotItem.attachment.thumbnails;
476 let screenshot = snapshotItem.attachment.screenshot;
478 // Prepare all the function calls for serialization.
479 yield DevToolsUtils.yieldingEach(functionCalls, (call, i) => {
480 let { type, name, file, line, argsPreview, callerPreview } = call;
481 return call.getDetails().then(({ stack }) => {
482 data.calls[i] = {
483 type: type,
484 name: name,
485 file: file,
486 line: line,
487 stack: stack,
488 argsPreview: argsPreview,
489 callerPreview: callerPreview
490 };
491 });
492 });
494 // Prepare all the thumbnails for serialization.
495 yield DevToolsUtils.yieldingEach(thumbnails, (thumbnail, i) => {
496 let { index, width, height, flipped, pixels } = thumbnail;
497 data.thumbnails.push({
498 index: index,
499 width: width,
500 height: height,
501 flipped: flipped,
502 pixels: Array.join(pixels, ",")
503 });
504 });
506 // Prepare the screenshot for serialization.
507 let { index, width, height, flipped, pixels } = screenshot;
508 data.screenshot = {
509 index: index,
510 width: width,
511 height: height,
512 flipped: flipped,
513 pixels: Array.join(pixels, ",")
514 };
516 let string = JSON.stringify(data);
517 let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
518 createInstance(Ci.nsIScriptableUnicodeConverter);
520 converter.charset = "UTF-8";
521 return converter.convertToInputStream(string);
522 });
524 // Open the nsIFilePicker and wait for the function call actors to finish
525 // being serialized, in order to save the generated JSON data to disk.
526 fp.open({ done: result => {
527 if (result == Ci.nsIFilePicker.returnCancel) {
528 return;
529 }
530 let footer = $(".snapshot-item-footer", snapshotItem.target);
531 let save = $(".snapshot-item-save", snapshotItem.target);
533 // Show a throbber and a "Saving…" label if serializing isn't immediate.
534 setNamedTimeout("call-list-save", CALLS_LIST_SLOW_SAVE_DELAY, () => {
535 footer.setAttribute("saving", "");
536 save.setAttribute("disabled", "true");
537 save.setAttribute("value", L10N.getStr("snapshotsList.savingLabel"));
538 });
540 serialized.then(inputStream => {
541 let outputStream = FileUtils.openSafeFileOutputStream(fp.file);
543 NetUtil.asyncCopy(inputStream, outputStream, status => {
544 if (!Components.isSuccessCode(status)) {
545 console.error("Could not save recorded animation frame snapshot file.");
546 }
547 clearNamedTimeout("call-list-save");
548 footer.removeAttribute("saving");
549 save.removeAttribute("disabled");
550 save.setAttribute("value", L10N.getStr("snapshotsList.saveLabel"));
551 });
552 });
553 }});
554 }
555 });
557 /**
558 * Functions handling details about a single recorded animation frame snapshot
559 * (the calls list, rendering preview, thumbnails filmstrip etc.).
560 */
561 let CallsListView = Heritage.extend(WidgetMethods, {
562 /**
563 * Initialization function, called when the tool is started.
564 */
565 initialize: function() {
566 this.widget = new SideMenuWidget($("#calls-list"));
567 this._slider = $("#calls-slider");
568 this._searchbox = $("#calls-searchbox");
569 this._filmstrip = $("#snapshot-filmstrip");
571 this._onSelect = this._onSelect.bind(this);
572 this._onSlideMouseDown = this._onSlideMouseDown.bind(this);
573 this._onSlideMouseUp = this._onSlideMouseUp.bind(this);
574 this._onSlide = this._onSlide.bind(this);
575 this._onSearch = this._onSearch.bind(this);
576 this._onScroll = this._onScroll.bind(this);
577 this._onExpand = this._onExpand.bind(this);
578 this._onStackFileClick = this._onStackFileClick.bind(this);
579 this._onThumbnailClick = this._onThumbnailClick.bind(this);
581 this.widget.addEventListener("select", this._onSelect, false);
582 this._slider.addEventListener("mousedown", this._onSlideMouseDown, false);
583 this._slider.addEventListener("mouseup", this._onSlideMouseUp, false);
584 this._slider.addEventListener("change", this._onSlide, false);
585 this._searchbox.addEventListener("input", this._onSearch, false);
586 this._filmstrip.addEventListener("wheel", this._onScroll, false);
587 },
589 /**
590 * Destruction function, called when the tool is closed.
591 */
592 destroy: function() {
593 this.widget.removeEventListener("select", this._onSelect, false);
594 this._slider.removeEventListener("mousedown", this._onSlideMouseDown, false);
595 this._slider.removeEventListener("mouseup", this._onSlideMouseUp, false);
596 this._slider.removeEventListener("change", this._onSlide, false);
597 this._searchbox.removeEventListener("input", this._onSearch, false);
598 this._filmstrip.removeEventListener("wheel", this._onScroll, false);
599 },
601 /**
602 * Populates this container with a list of function calls.
603 *
604 * @param array functionCalls
605 * A list of function call actors received from the backend.
606 */
607 showCalls: function(functionCalls) {
608 this.empty();
610 for (let i = 0, len = functionCalls.length; i < len; i++) {
611 let call = functionCalls[i];
613 let view = document.createElement("vbox");
614 view.className = "call-item-view devtools-monospace";
615 view.setAttribute("flex", "1");
617 let contents = document.createElement("hbox");
618 contents.className = "call-item-contents";
619 contents.setAttribute("align", "center");
620 contents.addEventListener("dblclick", this._onExpand);
621 view.appendChild(contents);
623 let index = document.createElement("label");
624 index.className = "plain call-item-index";
625 index.setAttribute("flex", "1");
626 index.setAttribute("value", i + 1);
628 let gutter = document.createElement("hbox");
629 gutter.className = "call-item-gutter";
630 gutter.appendChild(index);
631 contents.appendChild(gutter);
633 // Not all function calls have a caller that was stringified (e.g.
634 // context calls have a "gl" or "ctx" caller preview).
635 if (call.callerPreview) {
636 let context = document.createElement("label");
637 context.className = "plain call-item-context";
638 context.setAttribute("value", call.callerPreview);
639 contents.appendChild(context);
641 let separator = document.createElement("label");
642 separator.className = "plain call-item-separator";
643 separator.setAttribute("value", ".");
644 contents.appendChild(separator);
645 }
647 let name = document.createElement("label");
648 name.className = "plain call-item-name";
649 name.setAttribute("value", call.name);
650 contents.appendChild(name);
652 let argsPreview = document.createElement("label");
653 argsPreview.className = "plain call-item-args";
654 argsPreview.setAttribute("crop", "end");
655 argsPreview.setAttribute("flex", "100");
656 // Getters and setters are displayed differently from regular methods.
657 if (call.type == CallWatcherFront.METHOD_FUNCTION) {
658 argsPreview.setAttribute("value", "(" + call.argsPreview + ")");
659 } else {
660 argsPreview.setAttribute("value", " = " + call.argsPreview);
661 }
662 contents.appendChild(argsPreview);
664 let location = document.createElement("label");
665 location.className = "plain call-item-location";
666 location.setAttribute("value", getFileName(call.file) + ":" + call.line);
667 location.setAttribute("crop", "start");
668 location.setAttribute("flex", "1");
669 location.addEventListener("mousedown", this._onExpand);
670 contents.appendChild(location);
672 // Append a function call item to this container.
673 this.push([view], {
674 staged: true,
675 attachment: {
676 actor: call
677 }
678 });
680 // Highlight certain calls that are probably more interesting than
681 // everything else, making it easier to quickly glance over them.
682 if (CanvasFront.DRAW_CALLS.has(call.name)) {
683 view.setAttribute("draw-call", "");
684 }
685 if (CanvasFront.INTERESTING_CALLS.has(call.name)) {
686 view.setAttribute("interesting-call", "");
687 }
688 }
690 // Flushes all the prepared function call items into this container.
691 this.commit();
692 window.emit(EVENTS.CALL_LIST_POPULATED);
694 // Resetting the function selection slider's value (shown in this
695 // container's toolbar) would trigger a selection event, which should be
696 // ignored in this case.
697 this._ignoreSliderChanges = true;
698 this._slider.value = 0;
699 this._slider.max = functionCalls.length - 1;
700 this._ignoreSliderChanges = false;
701 },
703 /**
704 * Displays an image in the rendering preview of this container, generated
705 * for the specified draw call in the recorded animation frame snapshot.
706 *
707 * @param array screenshot
708 * A single "snapshot-image" instance received from the backend.
709 */
710 showScreenshot: function(screenshot) {
711 let { index, width, height, flipped, pixels } = screenshot;
713 let screenshotNode = $("#screenshot-image");
714 screenshotNode.setAttribute("flipped", flipped);
715 drawBackground("screenshot-rendering", width, height, pixels);
717 let dimensionsNode = $("#screenshot-dimensions");
718 dimensionsNode.setAttribute("value", ~~width + " x " + ~~height);
720 window.emit(EVENTS.CALL_SCREENSHOT_DISPLAYED);
721 },
723 /**
724 * Populates this container's footer with a list of thumbnails, one generated
725 * for each draw call in the recorded animation frame snapshot.
726 *
727 * @param array thumbnails
728 * An array of "snapshot-image" instances received from the backend.
729 */
730 showThumbnails: function(thumbnails) {
731 while (this._filmstrip.hasChildNodes()) {
732 this._filmstrip.firstChild.remove();
733 }
734 for (let thumbnail of thumbnails) {
735 this.appendThumbnail(thumbnail);
736 }
738 window.emit(EVENTS.THUMBNAILS_DISPLAYED);
739 },
741 /**
742 * Displays an image in the thumbnails list of this container, generated
743 * for the specified draw call in the recorded animation frame snapshot.
744 *
745 * @param array thumbnail
746 * A single "snapshot-image" instance received from the backend.
747 */
748 appendThumbnail: function(thumbnail) {
749 let { index, width, height, flipped, pixels } = thumbnail;
751 let thumbnailNode = document.createElementNS(HTML_NS, "canvas");
752 thumbnailNode.setAttribute("flipped", flipped);
753 thumbnailNode.width = Math.max(CanvasFront.THUMBNAIL_HEIGHT, width);
754 thumbnailNode.height = Math.max(CanvasFront.THUMBNAIL_HEIGHT, height);
755 drawImage(thumbnailNode, width, height, pixels, { centered: true });
757 thumbnailNode.className = "filmstrip-thumbnail";
758 thumbnailNode.onmousedown = e => this._onThumbnailClick(e, index);
759 thumbnailNode.setAttribute("index", index);
760 this._filmstrip.appendChild(thumbnailNode);
761 },
763 /**
764 * Sets the currently highlighted thumbnail in this container.
765 * A screenshot will always correlate to a thumbnail in the filmstrip,
766 * both being identified by the same 'index' of the context function call.
767 *
768 * @param number index
769 * The context function call's index.
770 */
771 set highlightedThumbnail(index) {
772 let currHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + index + "']");
773 if (currHighlightedThumbnail == null) {
774 return;
775 }
777 let prevIndex = this._highlightedThumbnailIndex
778 let prevHighlightedThumbnail = $(".filmstrip-thumbnail[index='" + prevIndex + "']");
779 if (prevHighlightedThumbnail) {
780 prevHighlightedThumbnail.removeAttribute("highlighted");
781 }
783 currHighlightedThumbnail.setAttribute("highlighted", "");
784 currHighlightedThumbnail.scrollIntoView();
785 this._highlightedThumbnailIndex = index;
786 },
788 /**
789 * Gets the currently highlighted thumbnail in this container.
790 * @return number
791 */
792 get highlightedThumbnail() {
793 return this._highlightedThumbnailIndex;
794 },
796 /**
797 * The select listener for this container.
798 */
799 _onSelect: function({ detail: callItem }) {
800 if (!callItem) {
801 return;
802 }
804 // Some of the stepping buttons don't make sense specifically while the
805 // last function call is selected.
806 if (this.selectedIndex == this.itemCount - 1) {
807 $("#resume").setAttribute("disabled", "true");
808 $("#step-over").setAttribute("disabled", "true");
809 $("#step-out").setAttribute("disabled", "true");
810 } else {
811 $("#resume").removeAttribute("disabled");
812 $("#step-over").removeAttribute("disabled");
813 $("#step-out").removeAttribute("disabled");
814 }
816 // Correlate the currently selected item with the function selection
817 // slider's value. Avoid triggering a redundant selection event.
818 this._ignoreSliderChanges = true;
819 this._slider.value = this.selectedIndex;
820 this._ignoreSliderChanges = false;
822 // Can't generate screenshots for function call actors loaded from disk.
823 // XXX: Bug 984844.
824 if (callItem.attachment.actor.isLoadedFromDisk) {
825 return;
826 }
828 // To keep continuous selection buttery smooth (for example, while pressing
829 // the DOWN key or moving the slider), only display the screenshot after
830 // any kind of user input stops.
831 setConditionalTimeout("screenshot-display", SCREENSHOT_DISPLAY_DELAY, () => {
832 return !this._isSliding;
833 }, () => {
834 let frameSnapshot = SnapshotsListView.selectedItem.attachment.actor
835 let functionCall = callItem.attachment.actor;
836 frameSnapshot.generateScreenshotFor(functionCall).then(screenshot => {
837 this.showScreenshot(screenshot);
838 this.highlightedThumbnail = screenshot.index;
839 });
840 });
841 },
843 /**
844 * The mousedown listener for the call selection slider.
845 */
846 _onSlideMouseDown: function() {
847 this._isSliding = true;
848 },
850 /**
851 * The mouseup listener for the call selection slider.
852 */
853 _onSlideMouseUp: function() {
854 this._isSliding = false;
855 },
857 /**
858 * The change listener for the call selection slider.
859 */
860 _onSlide: function() {
861 // Avoid performing any operations when programatically changing the value.
862 if (this._ignoreSliderChanges) {
863 return;
864 }
865 let selectedFunctionCallIndex = this.selectedIndex = this._slider.value;
867 // While sliding, immediately show the most relevant thumbnail for a
868 // function call, for a nice diff-like animation effect between draws.
869 let thumbnails = SnapshotsListView.selectedItem.attachment.thumbnails;
870 let thumbnail = getThumbnailForCall(thumbnails, selectedFunctionCallIndex);
872 // Avoid drawing and highlighting if the selected function call has the
873 // same thumbnail as the last one.
874 if (thumbnail.index == this.highlightedThumbnail) {
875 return;
876 }
877 // If a thumbnail wasn't found (e.g. the backend avoids creating thumbnails
878 // when rendering offscreen), simply defer to the first available one.
879 if (thumbnail.index == -1) {
880 thumbnail = thumbnails[0];
881 }
883 let { index, width, height, flipped, pixels } = thumbnail;
884 this.highlightedThumbnail = index;
886 let screenshotNode = $("#screenshot-image");
887 screenshotNode.setAttribute("flipped", flipped);
888 drawBackground("screenshot-rendering", width, height, pixels);
889 },
891 /**
892 * The input listener for the calls searchbox.
893 */
894 _onSearch: function(e) {
895 let lowerCaseSearchToken = this._searchbox.value.toLowerCase();
897 this.filterContents(e => {
898 let call = e.attachment.actor;
899 let name = call.name.toLowerCase();
900 let file = call.file.toLowerCase();
901 let line = call.line.toString().toLowerCase();
902 let args = call.argsPreview.toLowerCase();
904 return name.contains(lowerCaseSearchToken) ||
905 file.contains(lowerCaseSearchToken) ||
906 line.contains(lowerCaseSearchToken) ||
907 args.contains(lowerCaseSearchToken);
908 });
909 },
911 /**
912 * The wheel listener for the filmstrip that contains all the thumbnails.
913 */
914 _onScroll: function(e) {
915 this._filmstrip.scrollLeft += e.deltaX;
916 },
918 /**
919 * The click/dblclick listener for an item or location url in this container.
920 * When expanding an item, it's corresponding call stack will be displayed.
921 */
922 _onExpand: function(e) {
923 let callItem = this.getItemForElement(e.target);
924 let view = $(".call-item-view", callItem.target);
926 // If the call stack nodes were already created, simply re-show them
927 // or jump to the corresponding file and line in the Debugger if a
928 // location link was clicked.
929 if (view.hasAttribute("call-stack-populated")) {
930 let isExpanded = view.getAttribute("call-stack-expanded") == "true";
932 // If clicking on the location, jump to the Debugger.
933 if (e.target.classList.contains("call-item-location")) {
934 let { file, line } = callItem.attachment.actor;
935 viewSourceInDebugger(file, line);
936 return;
937 }
938 // Otherwise hide the call stack.
939 else {
940 view.setAttribute("call-stack-expanded", !isExpanded);
941 $(".call-item-stack", view).hidden = isExpanded;
942 return;
943 }
944 }
946 let list = document.createElement("vbox");
947 list.className = "call-item-stack";
948 view.setAttribute("call-stack-populated", "");
949 view.setAttribute("call-stack-expanded", "true");
950 view.appendChild(list);
952 /**
953 * Creates a function call nodes in this container for a stack.
954 */
955 let display = stack => {
956 for (let i = 1; i < stack.length; i++) {
957 let call = stack[i];
959 let contents = document.createElement("hbox");
960 contents.className = "call-item-stack-fn";
961 contents.style.MozPaddingStart = (i * STACK_FUNC_INDENTATION) + "px";
963 let name = document.createElement("label");
964 name.className = "plain call-item-stack-fn-name";
965 name.setAttribute("value", "↳ " + call.name + "()");
966 contents.appendChild(name);
968 let spacer = document.createElement("spacer");
969 spacer.setAttribute("flex", "100");
970 contents.appendChild(spacer);
972 let location = document.createElement("label");
973 location.className = "plain call-item-stack-fn-location";
974 location.setAttribute("value", getFileName(call.file) + ":" + call.line);
975 location.setAttribute("crop", "start");
976 location.setAttribute("flex", "1");
977 location.addEventListener("mousedown", e => this._onStackFileClick(e, call));
978 contents.appendChild(location);
980 list.appendChild(contents);
981 }
983 window.emit(EVENTS.CALL_STACK_DISPLAYED);
984 };
986 // If this animation snapshot is loaded from disk, there are no corresponding
987 // backend actors available and the data is immediately available.
988 let functionCall = callItem.attachment.actor;
989 if (functionCall.isLoadedFromDisk) {
990 display(functionCall.stack);
991 }
992 // ..otherwise we need to request the function call stack from the backend.
993 else {
994 callItem.attachment.actor.getDetails().then(fn => display(fn.stack));
995 }
996 },
998 /**
999 * The click listener for a location link in the call stack.
1000 *
1001 * @param string file
1002 * The url of the source owning the function.
1003 * @param number line
1004 * The line of the respective function.
1005 */
1006 _onStackFileClick: function(e, { file, line }) {
1007 viewSourceInDebugger(file, line);
1008 },
1010 /**
1011 * The click listener for a thumbnail in the filmstrip.
1012 *
1013 * @param number index
1014 * The function index in the recorded animation frame snapshot.
1015 */
1016 _onThumbnailClick: function(e, index) {
1017 this.selectedIndex = index;
1018 },
1020 /**
1021 * The click listener for the "resume" button in this container's toolbar.
1022 */
1023 _onResume: function() {
1024 // Jump to the next draw call in the recorded animation frame snapshot.
1025 let drawCall = getNextDrawCall(this.items, this.selectedItem);
1026 if (drawCall) {
1027 this.selectedItem = drawCall;
1028 return;
1029 }
1031 // If there are no more draw calls, just jump to the last context call.
1032 this._onStepOut();
1033 },
1035 /**
1036 * The click listener for the "step over" button in this container's toolbar.
1037 */
1038 _onStepOver: function() {
1039 this.selectedIndex++;
1040 },
1042 /**
1043 * The click listener for the "step in" button in this container's toolbar.
1044 */
1045 _onStepIn: function() {
1046 if (this.selectedIndex == -1) {
1047 this._onResume();
1048 return;
1049 }
1050 let callItem = this.selectedItem;
1051 let { file, line } = callItem.attachment.actor;
1052 viewSourceInDebugger(file, line);
1053 },
1055 /**
1056 * The click listener for the "step out" button in this container's toolbar.
1057 */
1058 _onStepOut: function() {
1059 this.selectedIndex = this.itemCount - 1;
1060 }
1061 });
1063 /**
1064 * Localization convenience methods.
1065 */
1066 let L10N = new ViewHelpers.L10N(STRINGS_URI);
1068 /**
1069 * Convenient way of emitting events from the panel window.
1070 */
1071 EventEmitter.decorate(this);
1073 /**
1074 * DOM query helpers.
1075 */
1076 function $(selector, target = document) target.querySelector(selector);
1077 function $all(selector, target = document) target.querySelectorAll(selector);
1079 /**
1080 * Helper for getting an nsIURL instance out of a string.
1081 */
1082 function nsIURL(url, store = nsIURL.store) {
1083 if (store.has(url)) {
1084 return store.get(url);
1085 }
1086 let uri = Services.io.newURI(url, null, null).QueryInterface(Ci.nsIURL);
1087 store.set(url, uri);
1088 return uri;
1089 }
1091 // The cache used in the `nsIURL` function.
1092 nsIURL.store = new Map();
1094 /**
1095 * Gets the fileName part of a string which happens to be an URL.
1096 */
1097 function getFileName(url) {
1098 try {
1099 let { fileName } = nsIURL(url);
1100 return fileName || "/";
1101 } catch (e) {
1102 // This doesn't look like a url, or nsIURL can't handle it.
1103 return "";
1104 }
1105 }
1107 /**
1108 * Gets an image data object containing a buffer large enough to hold
1109 * width * height pixels.
1110 *
1111 * This method avoids allocating memory and tries to reuse a common buffer
1112 * as much as possible.
1113 *
1114 * @param number w
1115 * The desired image data storage width.
1116 * @param number h
1117 * The desired image data storage height.
1118 * @return ImageData
1119 * The requested image data buffer.
1120 */
1121 function getImageDataStorage(ctx, w, h) {
1122 let storage = getImageDataStorage.cache;
1123 if (storage && storage.width == w && storage.height == h) {
1124 return storage;
1125 }
1126 return getImageDataStorage.cache = ctx.createImageData(w, h);
1127 }
1129 // The cache used in the `getImageDataStorage` function.
1130 getImageDataStorage.cache = null;
1132 /**
1133 * Draws image data into a canvas.
1134 *
1135 * This method makes absolutely no assumptions about the canvas element
1136 * dimensions, or pre-existing rendering. It's a dumb proxy that copies pixels.
1137 *
1138 * @param HTMLCanvasElement canvas
1139 * The canvas element to put the image data into.
1140 * @param number width
1141 * The image data width.
1142 * @param number height
1143 * The image data height.
1144 * @param pixels
1145 * An array buffer view of the image data.
1146 * @param object options
1147 * Additional options supported by this operation:
1148 * - centered: specifies whether the image data should be centered
1149 * when copied in the canvas; this is useful when the
1150 * supplied pixels don't completely cover the canvas.
1151 */
1152 function drawImage(canvas, width, height, pixels, options = {}) {
1153 let ctx = canvas.getContext("2d");
1155 // FrameSnapshot actors return "snapshot-image" type instances with just an
1156 // empty pixel array if the source image is completely transparent.
1157 if (pixels.length <= 1) {
1158 ctx.clearRect(0, 0, canvas.width, canvas.height);
1159 return;
1160 }
1162 let arrayBuffer = new Uint8Array(pixels.buffer);
1163 let imageData = getImageDataStorage(ctx, width, height);
1164 imageData.data.set(arrayBuffer);
1166 if (options.centered) {
1167 let left = (canvas.width - width) / 2;
1168 let top = (canvas.height - height) / 2;
1169 ctx.putImageData(imageData, left, top);
1170 } else {
1171 ctx.putImageData(imageData, 0, 0);
1172 }
1173 }
1175 /**
1176 * Draws image data into a canvas, and sets that as the rendering source for
1177 * an element with the specified id as the -moz-element background image.
1178 *
1179 * @param string id
1180 * The id of the -moz-element background image.
1181 * @param number width
1182 * The image data width.
1183 * @param number height
1184 * The image data height.
1185 * @param pixels
1186 * An array buffer view of the image data.
1187 */
1188 function drawBackground(id, width, height, pixels) {
1189 let canvas = document.createElementNS(HTML_NS, "canvas");
1190 canvas.width = width;
1191 canvas.height = height;
1193 drawImage(canvas, width, height, pixels);
1194 document.mozSetImageElement(id, canvas);
1196 // Used in tests. Not emitting an event because this shouldn't be "interesting".
1197 if (window._onMozSetImageElement) {
1198 window._onMozSetImageElement(pixels);
1199 }
1200 }
1202 /**
1203 * Iterates forward to find the next draw call in a snapshot.
1204 */
1205 function getNextDrawCall(calls, call) {
1206 for (let i = calls.indexOf(call) + 1, len = calls.length; i < len; i++) {
1207 let nextCall = calls[i];
1208 let name = nextCall.attachment.actor.name;
1209 if (CanvasFront.DRAW_CALLS.has(name)) {
1210 return nextCall;
1211 }
1212 }
1213 return null;
1214 }
1216 /**
1217 * Iterates backwards to find the most recent screenshot for a function call
1218 * in a snapshot loaded from disk.
1219 */
1220 function getScreenshotFromCallLoadedFromDisk(calls, call) {
1221 for (let i = calls.indexOf(call); i >= 0; i--) {
1222 let prevCall = calls[i];
1223 let screenshot = prevCall.screenshot;
1224 if (screenshot) {
1225 return screenshot;
1226 }
1227 }
1228 return CanvasFront.INVALID_SNAPSHOT_IMAGE;
1229 }
1231 /**
1232 * Iterates backwards to find the most recent thumbnail for a function call.
1233 */
1234 function getThumbnailForCall(thumbnails, index) {
1235 for (let i = thumbnails.length - 1; i >= 0; i--) {
1236 let thumbnail = thumbnails[i];
1237 if (thumbnail.index <= index) {
1238 return thumbnail;
1239 }
1240 }
1241 return CanvasFront.INVALID_SNAPSHOT_IMAGE;
1242 }
1244 /**
1245 * Opens/selects the debugger in this toolbox and jumps to the specified
1246 * file name and line number.
1247 */
1248 function viewSourceInDebugger(url, line) {
1249 let showSource = ({ DebuggerView }) => {
1250 if (DebuggerView.Sources.containsValue(url)) {
1251 DebuggerView.setEditorLocation(url, line, { noDebug: true }).then(() => {
1252 window.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
1253 }, () => {
1254 window.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER);
1255 });
1256 }
1257 }
1259 // If the Debugger was already open, switch to it and try to show the
1260 // source immediately. Otherwise, initialize it and wait for the sources
1261 // to be added first.
1262 let debuggerAlreadyOpen = gToolbox.getPanel("jsdebugger");
1263 gToolbox.selectTool("jsdebugger").then(({ panelWin: dbg }) => {
1264 if (debuggerAlreadyOpen) {
1265 showSource(dbg);
1266 } else {
1267 dbg.once(dbg.EVENTS.SOURCES_ADDED, () => showSource(dbg));
1268 }
1269 });
1270 }