/* 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"); Cu.import("resource://gre/modules/devtools/Console.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"); const Telemetry = require("devtools/shared/telemetry"); const telemetry = new Telemetry(); // 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() { telemetry.toolOpened("shadereditor"); 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() { telemetry.toolClosed("shadereditor"); 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, {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. */ 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._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, 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 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. */ 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: 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, 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);