gecko/browser/devtools/shadereditor/shadereditor.js

605 lines
19 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Task.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 {Tooltip} = require("devtools/shared/widgets/Tooltip");
const Editor = require("devtools/sourceeditor/editor");
// The panel's window global is an EventEmitter firing the following events:
const EVENTS = {
// When new programs are received from the server.
NEW_PROGRAM: "ShaderEditor:NewProgram",
PROGRAMS_ADDED: "ShaderEditor:ProgramsAdded",
// When the vertex and fragment sources were shown in the editor.
SOURCES_SHOWN: "ShaderEditor:SourcesShown",
// When a shader's source was edited and compiled via the editor.
SHADER_COMPILED: "ShaderEditor:ShaderCompiled",
// When the UI is reset from tab navigation
UI_RESET: "ShaderEditor:UIReset",
// When the editor's error markers are all removed
EDITOR_ERROR_MARKERS_REMOVED: "ShaderEditor:EditorCleaned"
};
const STRINGS_URI = "chrome://browser/locale/devtools/shadereditor.properties"
const HIGHLIGHT_TINT = [1, 0, 0.25, 1]; // rgba
const TYPING_MAX_DELAY = 500; // ms
const SHADERS_AUTOGROW_ITEMS = 4;
const GUTTER_ERROR_PANEL_OFFSET_X = 7; // px
const GUTTER_ERROR_PANEL_DELAY = 100; // ms
const DEFAULT_EDITOR_CONFIG = {
gutters: ["errors"],
lineNumbers: true,
showAnnotationRuler: true
};
/**
* The current target and the WebGL Editor front, set by this tool's host.
*/
let gToolbox, gTarget, gFront;
/**
* Initializes the shader editor controller and views.
*/
function startupShaderEditor() {
return promise.all([
EventsHandler.initialize(),
ShadersListView.initialize(),
ShadersEditorsView.initialize()
]);
}
/**
* Destroys the shader editor controller and views.
*/
function shutdownShaderEditor() {
return promise.all([
EventsHandler.destroy(),
ShadersListView.destroy(),
ShadersEditorsView.destroy()
]);
}
/**
* Functions handling target-related lifetime events.
*/
let EventsHandler = {
/**
* Listen for events emitted by the current tab target.
*/
initialize: function() {
this._onHostChanged = this._onHostChanged.bind(this);
this._onTabNavigated = this._onTabNavigated.bind(this);
this._onProgramLinked = this._onProgramLinked.bind(this);
this._onProgramsAdded = this._onProgramsAdded.bind(this);
gToolbox.on("host-changed", this._onHostChanged);
gTarget.on("will-navigate", this._onTabNavigated);
gTarget.on("navigate", this._onTabNavigated);
gFront.on("program-linked", this._onProgramLinked);
},
/**
* Remove events emitted by the current tab target.
*/
destroy: function() {
gToolbox.off("host-changed", this._onHostChanged);
gTarget.off("will-navigate", this._onTabNavigated);
gTarget.off("navigate", this._onTabNavigated);
gFront.off("program-linked", this._onProgramLinked);
},
/**
* Handles a host change event on the parent toolbox.
*/
_onHostChanged: function() {
if (gToolbox.hostType == "side") {
$("#shaders-pane").removeAttribute("height");
}
},
/**
* Called for each location change in the debugged tab.
*/
_onTabNavigated: function(event) {
switch (event) {
case "will-navigate": {
Task.spawn(function() {
// Make sure the backend is prepared to handle WebGL contexts.
gFront.setup({ reload: false });
// Reset UI.
ShadersListView.empty();
$("#reload-notice").hidden = true;
$("#waiting-notice").hidden = false;
yield ShadersEditorsView.setText({ vs: "", fs: "" });
$("#content").hidden = true;
}).then(() => window.emit(EVENTS.UI_RESET));
break;
}
case "navigate": {
// Manually retrieve the list of program actors known to the server,
// because the backend won't emit "program-linked" notifications
// in the case of a bfcache navigation (since no new programs are
// actually linked).
gFront.getPrograms().then(this._onProgramsAdded);
break;
}
}
},
/**
* Called every time a program was linked in the debugged tab.
*/
_onProgramLinked: function(programActor) {
this._addProgram(programActor);
window.emit(EVENTS.NEW_PROGRAM);
},
/**
* Callback for the front's getPrograms() method.
*/
_onProgramsAdded: function(programActors) {
programActors.forEach(this._addProgram);
window.emit(EVENTS.PROGRAMS_ADDED);
},
/**
* Adds a program to the shaders list and unhides any modal notices.
*/
_addProgram: function(programActor) {
$("#waiting-notice").hidden = true;
$("#reload-notice").hidden = true;
$("#content").hidden = false;
ShadersListView.addProgram(programActor);
}
};
/**
* Functions handling the sources UI.
*/
let ShadersListView = Heritage.extend(WidgetMethods, {
/**
* Initialization function, called when the tool is started.
*/
initialize: function() {
this.widget = new SideMenuWidget(this._pane = $("#shaders-pane"), {
showArrows: true,
showItemCheckboxes: true
});
this._onProgramSelect = this._onProgramSelect.bind(this);
this._onProgramCheck = this._onProgramCheck.bind(this);
this._onProgramMouseEnter = this._onProgramMouseEnter.bind(this);
this._onProgramMouseLeave = this._onProgramMouseLeave.bind(this);
this.widget.addEventListener("select", this._onProgramSelect, false);
this.widget.addEventListener("check", this._onProgramCheck, false);
this.widget.addEventListener("mouseenter", this._onProgramMouseEnter, true);
this.widget.addEventListener("mouseleave", this._onProgramMouseLeave, true);
},
/**
* Destruction function, called when the tool is closed.
*/
destroy: function() {
this.widget.removeEventListener("select", this._onProgramSelect, false);
this.widget.removeEventListener("check", this._onProgramCheck, false);
this.widget.removeEventListener("mouseenter", this._onProgramMouseEnter, true);
this.widget.removeEventListener("mouseleave", this._onProgramMouseLeave, true);
},
/**
* Adds a program to this programs container.
*
* @param object programActor
* The program actor coming from the active thread.
*/
addProgram: function(programActor) {
if (this.hasProgram(programActor)) {
return;
}
// Currently, there's no good way of differentiating between programs
// in a way that helps humans. It will be a good idea to implement a
// standard of allowing debuggees to add some identifiable metadata to their
// program sources or instances.
let label = L10N.getFormatStr("shadersList.programLabel", this.itemCount);
let contents = document.createElement("label");
contents.className = "plain program-item";
contents.setAttribute("value", label);
contents.setAttribute("crop", "start");
contents.setAttribute("flex", "1");
// Append a program item to this container.
this.push([contents], {
index: -1, /* specifies on which position should the item be appended */
attachment: {
label: label,
programActor: programActor,
checkboxState: true,
checkboxTooltip: L10N.getStr("shadersList.blackboxLabel")
}
});
// Make sure there's always a selected item available.
if (!this.selectedItem) {
this.selectedIndex = 0;
}
// Prevent this container from growing indefinitely in height when the
// toolbox is docked to the side.
if (gToolbox.hostType == "side" && this.itemCount == SHADERS_AUTOGROW_ITEMS) {
this._pane.setAttribute("height", this._pane.getBoundingClientRect().height);
}
},
/**
* Returns whether a program was already added to this programs container.
*
* @param object programActor
* The program actor coming from the active thread.
* @param boolean
* True if the program was added, false otherwise.
*/
hasProgram: function(programActor) {
return !!this.attachments.filter(e => e.programActor == programActor).length;
},
/**
* The select listener for the programs container.
*/
_onProgramSelect: function({ detail: sourceItem }) {
if (!sourceItem) {
return;
}
// The container is not empty and an actual item was selected.
let attachment = sourceItem.attachment;
function getShaders() {
return promise.all([
attachment.vs || (attachment.vs = attachment.programActor.getVertexShader()),
attachment.fs || (attachment.fs = attachment.programActor.getFragmentShader())
]);
}
function getSources([vertexShaderActor, fragmentShaderActor]) {
return promise.all([
vertexShaderActor.getText(),
fragmentShaderActor.getText()
]);
}
function showSources([vertexShaderText, fragmentShaderText]) {
return ShadersEditorsView.setText({
vs: vertexShaderText,
fs: fragmentShaderText
});
}
getShaders()
.then(getSources)
.then(showSources)
.then(null, Cu.reportError);
},
/**
* The check listener for the programs container.
*/
_onProgramCheck: function({ detail: { checked }, target }) {
let sourceItem = this.getItemForElement(target);
let attachment = sourceItem.attachment;
attachment.isBlackBoxed = !checked;
attachment.programActor[checked ? "unblackbox" : "blackbox"]();
},
/**
* The mouseenter listener for the programs container.
*/
_onProgramMouseEnter: function(e) {
let sourceItem = this.getItemForElement(e.target, { noSiblings: true });
if (sourceItem && !sourceItem.attachment.isBlackBoxed) {
sourceItem.attachment.programActor.highlight(HIGHLIGHT_TINT);
if (e instanceof Event) {
e.preventDefault();
e.stopPropagation();
}
}
},
/**
* The mouseleave listener for the programs container.
*/
_onProgramMouseLeave: function(e) {
let sourceItem = this.getItemForElement(e.target, { noSiblings: true });
if (sourceItem && !sourceItem.attachment.isBlackBoxed) {
sourceItem.attachment.programActor.unhighlight();
if (e instanceof Event) {
e.preventDefault();
e.stopPropagation();
}
}
}
});
/**
* Functions handling the editors displaying the vertex and fragment shaders.
*/
let ShadersEditorsView = {
/**
* Initialization function, called when the tool is started.
*/
initialize: function() {
XPCOMUtils.defineLazyGetter(this, "_editorPromises", () => new Map());
this._vsFocused = this._onFocused.bind(this, "vs", "fs");
this._fsFocused = this._onFocused.bind(this, "fs", "vs");
this._vsChanged = this._onChanged.bind(this, "vs");
this._fsChanged = this._onChanged.bind(this, "fs");
},
/**
* Destruction function, called when the tool is closed.
*/
destroy: function() {
this._toggleListeners("off");
},
/**
* Sets the text displayed in the vertex and fragment shader editors.
*
* @param object sources
* An object containing the following properties
* - vs: the vertex shader source code
* - fs: the fragment shader source code
* @return object
* A promise resolving upon completion of text setting.
*/
setText: function(sources) {
let view = this;
function setTextAndClearHistory(editor, text) {
editor.setText(text);
editor.clearHistory();
}
return Task.spawn(function() {
yield view._toggleListeners("off");
yield promise.all([
view._getEditor("vs").then(e => setTextAndClearHistory(e, sources.vs)),
view._getEditor("fs").then(e => setTextAndClearHistory(e, sources.fs))
]);
yield view._toggleListeners("on");
}).then(() => window.emit(EVENTS.SOURCES_SHOWN, sources));
},
/**
* Lazily initializes and returns a promise for an Editor instance.
*
* @param string type
* Specifies for which shader type should an editor be retrieved,
* either are "vs" for a vertex, or "fs" for a fragment shader.
* @return object
* Returns a promise that resolves to an editor instance
*/
_getEditor: function(type) {
if ($("#content").hidden) {
return promise.reject(new Error("Shader Editor is still waiting for a WebGL context to be created."));
}
if (this._editorPromises.has(type)) {
return this._editorPromises.get(type);
}
let deferred = promise.defer();
this._editorPromises.set(type, deferred.promise);
// Initialize the source editor and store the newly created instance
// in the ether of a resolved promise's value.
let parent = $("#" + type +"-editor");
let editor = new Editor(DEFAULT_EDITOR_CONFIG);
editor.config.mode = Editor.modes[type];
editor.appendTo(parent).then(() => deferred.resolve(editor));
return deferred.promise;
},
/**
* Toggles all the event listeners for the editors either on or off.
*
* @param string flag
* Either "on" to enable the event listeners, "off" to disable them.
* @return object
* A promise resolving upon completion of toggling the listeners.
*/
_toggleListeners: function(flag) {
return promise.all(["vs", "fs"].map(type => {
return this._getEditor(type).then(editor => {
editor[flag]("focus", this["_" + type + "Focused"]);
editor[flag]("change", this["_" + type + "Changed"]);
});
}));
},
/**
* The focus listener for a source editor.
*
* @param string focused
* The corresponding shader type for the focused editor (e.g. "vs").
* @param string focused
* The corresponding shader type for the other editor (e.g. "fs").
*/
_onFocused: function(focused, unfocused) {
$("#" + focused + "-editor-label").setAttribute("selected", "");
$("#" + unfocused + "-editor-label").removeAttribute("selected");
},
/**
* The change listener for a source editor.
*
* @param string type
* The corresponding shader type for the focused editor (e.g. "vs").
*/
_onChanged: function(type) {
setNamedTimeout("gl-typed", TYPING_MAX_DELAY, () => this._doCompile(type));
// Remove all the gutter markers and line classes from the editor.
this._cleanEditor(type);
},
/**
* Recompiles the source code for the shader being edited.
* This function is fired at a certain delay after the user stops typing.
*
* @param string type
* The corresponding shader type for the focused editor (e.g. "vs").
*/
_doCompile: function(type) {
Task.spawn(function() {
let editor = yield this._getEditor(type);
let shaderActor = yield ShadersListView.selectedAttachment[type];
try {
yield shaderActor.compile(editor.getText());
this._onSuccessfulCompilation();
} catch (e) {
this._onFailedCompilation(type, editor, e);
}
}.bind(this));
},
/**
* Called uppon a successful shader compilation.
*/
_onSuccessfulCompilation: function() {
// Signal that the shader was compiled successfully.
window.emit(EVENTS.SHADER_COMPILED, null);
},
/**
* Called uppon an unsuccessful shader compilation.
*/
_onFailedCompilation: function(type, editor, errors) {
let lineCount = editor.lineCount();
let currentLine = editor.getCursor().line;
let listeners = { mouseenter: this._onMarkerMouseEnter };
function matchLinesAndMessages(string) {
return {
// First number that is not equal to 0.
lineMatch: string.match(/\d{2,}|[1-9]/),
// The string after all the numbers, semicolons and spaces.
textMatch: string.match(/[^\s\d:][^\r\n|]*/)
};
}
function discardInvalidMatches(e) {
// Discard empty line and text matches.
return e.lineMatch && e.textMatch;
}
function sanitizeValidMatches(e) {
return {
// Drivers might yield confusing line numbers under some obscure
// circumstances. Don't throw the errors away in those cases,
// just display them on the currently edited line.
line: e.lineMatch[0] > lineCount ? currentLine : e.lineMatch[0] - 1,
// Trim whitespace from the beginning and the end of the message,
// and replace all other occurences of double spaces to a single space.
text: e.textMatch[0].trim().replace(/\s{2,}/g, " ")
};
}
function sortByLine(first, second) {
// Sort all the errors ascending by their corresponding line number.
return first.line > second.line ? 1 : -1;
}
function groupSameLineMessages(accumulator, current) {
// Group errors corresponding to the same line number to a single object.
let previous = accumulator[accumulator.length - 1];
if (!previous || previous.line != current.line) {
return [...accumulator, {
line: current.line,
messages: [current.text]
}];
} else {
previous.messages.push(current.text);
return accumulator;
}
}
function displayErrors({ line, messages }) {
// Add gutter markers and line classes for every error in the source.
editor.addMarker(line, "errors", "error");
editor.setMarkerListeners(line, "errors", "error", listeners, messages);
editor.addLineClass(line, "error-line");
}
(this._errors[type] = errors.link
.split("ERROR")
.map(matchLinesAndMessages)
.filter(discardInvalidMatches)
.map(sanitizeValidMatches)
.sort(sortByLine)
.reduce(groupSameLineMessages, []))
.forEach(displayErrors);
// Signal that the shader wasn't compiled successfully.
window.emit(EVENTS.SHADER_COMPILED, errors);
},
/**
* Event listener for the 'mouseenter' event on a marker in the editor gutter.
*/
_onMarkerMouseEnter: function(line, node, messages) {
if (node._markerErrorsTooltip) {
return;
}
let tooltip = node._markerErrorsTooltip = new Tooltip(document);
tooltip.defaultOffsetX = GUTTER_ERROR_PANEL_OFFSET_X;
tooltip.setTextContent({ messages: messages });
tooltip.startTogglingOnHover(node, () => true, GUTTER_ERROR_PANEL_DELAY);
},
/**
* Removes all the gutter markers and line classes from the editor.
*/
_cleanEditor: function(type) {
this._getEditor(type).then(editor => {
editor.removeAllMarkers("errors");
this._errors[type].forEach(e => editor.removeLineClass(e.line));
this._errors[type].length = 0;
window.emit(EVENTS.EDITOR_ERROR_MARKERS_REMOVED);
});
},
_errors: {
vs: [],
fs: []
}
};
/**
* Localization convenience methods.
*/
let L10N = new ViewHelpers.L10N(STRINGS_URI);
/**
* Convenient way of emitting events from the panel window.
*/
EventEmitter.decorate(this);
/**
* DOM query helper.
*/
function $(selector, target = document) target.querySelector(selector);