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