summaryrefslogtreecommitdiffstats
path: root/devtools/client/shadereditor/shadereditor.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shadereditor/shadereditor.js')
-rw-r--r--devtools/client/shadereditor/shadereditor.js633
1 files changed, 633 insertions, 0 deletions
diff --git a/devtools/client/shadereditor/shadereditor.js b/devtools/client/shadereditor/shadereditor.js
new file mode 100644
index 000000000..6b53302c4
--- /dev/null
+++ b/devtools/client/shadereditor/shadereditor.js
@@ -0,0 +1,633 @@
+/* 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";
+
+var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm");
+const {SideMenuWidget} = require("resource://devtools/client/shared/widgets/SideMenuWidget.jsm");
+const promise = require("promise");
+const Services = require("Services");
+const EventEmitter = require("devtools/shared/event-emitter");
+const Tooltip = require("devtools/client/shared/widgets/tooltip/Tooltip");
+const Editor = require("devtools/client/sourceeditor/editor");
+const {LocalizationHelper} = require("devtools/shared/l10n");
+const {Heritage, WidgetMethods, setNamedTimeout} =
+ require("devtools/client/shared/widgets/view-helpers");
+const {Task} = require("devtools/shared/task");
+
+// 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"
+};
+XPCOMUtils.defineConstant(this, "EVENTS", EVENTS);
+
+const STRINGS_URI = "devtools/client/locales/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.
+ */
+var 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.
+ */
+var 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);
+ this.reloadButton = $("#requests-menu-reload-notice-button");
+ this.reloadButton.addEventListener("command", this._onReloadCommand);
+ },
+
+ /**
+ * 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);
+ this.reloadButton.removeEventListener("command", this._onReloadCommand);
+ },
+
+ /**
+ * Handles a command event on reload button
+ */
+ _onReloadCommand() {
+ gFront.setup({ reload: true });
+ },
+
+ /**
+ * 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, {isFrameSwitching}) {
+ switch (event) {
+ case "will-navigate": {
+ // Make sure the backend is prepared to handle WebGL contexts.
+ if (!isFrameSwitching) {
+ gFront.setup({ reload: false });
+ }
+
+ // Reset UI.
+ ShadersListView.empty();
+ // When switching to an iframe, ensure displaying the reload button.
+ // As the document has already been loaded without being hooked.
+ if (isFrameSwitching) {
+ $("#reload-notice").hidden = false;
+ $("#waiting-notice").hidden = true;
+ } else {
+ $("#reload-notice").hidden = true;
+ $("#waiting-notice").hidden = false;
+ }
+
+ $("#content").hidden = true;
+ 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.
+ */
+var 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._onProgramMouseOver = this._onProgramMouseOver.bind(this);
+ this._onProgramMouseOut = this._onProgramMouseOut.bind(this);
+
+ this.widget.addEventListener("select", this._onProgramSelect, false);
+ this.widget.addEventListener("check", this._onProgramCheck, false);
+ this.widget.addEventListener("mouseover", this._onProgramMouseOver, true);
+ this.widget.addEventListener("mouseout", this._onProgramMouseOut, 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("mouseover", this._onProgramMouseOver, true);
+ this.widget.removeEventListener("mouseout", this._onProgramMouseOut, 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, e => console.error(e));
+ },
+
+ /**
+ * 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 mouseover listener for the programs container.
+ */
+ _onProgramMouseOver: 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 mouseout listener for the programs container.
+ */
+ _onProgramMouseOut: 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.
+ */
+var 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: Task.async(function* () {
+ this._destroyed = true;
+ yield this._toggleListeners("off");
+ for (let p of this._editorPromises.values()) {
+ let editor = yield p;
+ editor.destroy();
+ }
+ }),
+
+ /**
+ * 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 (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];
+
+ if (this._destroyed) {
+ deferred.resolve(editor);
+ } else {
+ 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 = { mouseover: this._onMarkerMouseOver };
+
+ 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 'mouseover' event on a marker in the editor gutter.
+ */
+ _onMarkerMouseOver: 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, {
+ toggleDelay: 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.
+ */
+var L10N = new LocalizationHelper(STRINGS_URI);
+
+/**
+ * Convenient way of emitting events from the panel window.
+ */
+EventEmitter.decorate(this);
+
+/**
+ * DOM query helper.
+ */
+var $ = (selector, target = document) => target.querySelector(selector);