зеркало из https://github.com/mozilla/pjs.git
Bug 585991 - Show a popup listing possible completions; r=rcampbell,dtownsend sr=neil
This commit is contained in:
Родитель
c6784a3d8a
Коммит
76116cd872
|
@ -0,0 +1,395 @@
|
|||
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
|
||||
/* ***** BEGIN LICENSE BLOCK *****
|
||||
* Version: MPL 1.1/GPL 2.0/LGPL 2.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is Autocomplete Popup.
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* The Mozilla Foundation.
|
||||
* Portions created by the Initial Developer are Copyright (C) 2011
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mihai Sucan <mihai.sucan@gmail.com> (original author)
|
||||
*
|
||||
* Alternatively, the contents of this file may be used under the terms of
|
||||
* either the GNU General Public License Version 2 or later (the "GPL"), or
|
||||
* the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
|
||||
* in which case the provisions of the GPL or the LGPL are applicable instead
|
||||
* of those above. If you wish to allow use of your version of this file only
|
||||
* under the terms of either the GPL or the LGPL, and not to allow others to
|
||||
* use your version of this file under the terms of the MPL, indicate your
|
||||
* decision by deleting the provisions above and replace them with the notice
|
||||
* and other provisions required by the GPL or the LGPL. If you do not delete
|
||||
* the provisions above, a recipient may use your version of this file under
|
||||
* the terms of any one of the MPL, the GPL or the LGPL.
|
||||
*
|
||||
* ***** END LICENSE BLOCK ***** */
|
||||
|
||||
const Cu = Components.utils;
|
||||
|
||||
// The XUL and XHTML namespace.
|
||||
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
|
||||
const XHTML_NS = "http://www.w3.org/1999/xhtml";
|
||||
|
||||
const HUD_STRINGS_URI = "chrome://global/locale/headsUpDisplay.properties";
|
||||
|
||||
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "stringBundle", function () {
|
||||
return Services.strings.createBundle(HUD_STRINGS_URI);
|
||||
});
|
||||
|
||||
|
||||
var EXPORTED_SYMBOLS = ["AutocompletePopup"];
|
||||
|
||||
/**
|
||||
* Autocomplete popup UI implementation.
|
||||
*
|
||||
* @constructor
|
||||
* @param nsIDOMDocument aDocument
|
||||
* The document you want the popup attached to.
|
||||
*/
|
||||
function AutocompletePopup(aDocument)
|
||||
{
|
||||
this._document = aDocument;
|
||||
|
||||
// Reuse the existing popup elements.
|
||||
this._panel = this._document.getElementById("webConsole_autocompletePopup");
|
||||
if (!this._panel) {
|
||||
this._panel = this._document.createElementNS(XUL_NS, "panel");
|
||||
this._panel.setAttribute("id", "webConsole_autocompletePopup");
|
||||
this._panel.setAttribute("label",
|
||||
stringBundle.GetStringFromName("Autocomplete.label"));
|
||||
this._panel.setAttribute("noautofocus", "true");
|
||||
this._panel.setAttribute("ignorekeys", "true");
|
||||
|
||||
let mainPopupSet = this._document.getElementById("mainPopupSet");
|
||||
if (mainPopupSet) {
|
||||
mainPopupSet.appendChild(this._panel);
|
||||
}
|
||||
else {
|
||||
this._document.documentElement.appendChild(this._panel);
|
||||
}
|
||||
|
||||
this._list = this._document.createElementNS(XUL_NS, "richlistbox");
|
||||
this._list.flex = 1;
|
||||
this._panel.appendChild(this._list);
|
||||
|
||||
// Open and hide the panel, so we initialize the API of the richlistbox.
|
||||
this._panel.width = 1;
|
||||
this._panel.height = 1;
|
||||
this._panel.openPopup(null, "overlap", 0, 0, false, false);
|
||||
this._panel.hidePopup();
|
||||
this._panel.width = "";
|
||||
this._panel.height = "";
|
||||
}
|
||||
else {
|
||||
this._list = this._panel.firstChild;
|
||||
}
|
||||
}
|
||||
|
||||
AutocompletePopup.prototype = {
|
||||
_document: null,
|
||||
_panel: null,
|
||||
_list: null,
|
||||
|
||||
/**
|
||||
* Open the autocomplete popup panel.
|
||||
*
|
||||
* @param nsIDOMNode aAnchor
|
||||
* Optional node to anchor the panel to.
|
||||
*/
|
||||
openPopup: function AP_openPopup(aAnchor)
|
||||
{
|
||||
this._panel.openPopup(aAnchor, "after_start", 0, 0, false, false);
|
||||
|
||||
if (this.onSelect) {
|
||||
this._list.addEventListener("select", this.onSelect, false);
|
||||
}
|
||||
|
||||
if (this.onClick) {
|
||||
this._list.addEventListener("click", this.onClick, false);
|
||||
}
|
||||
|
||||
this._updateSize();
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide the autocomplete popup panel.
|
||||
*/
|
||||
hidePopup: function AP_hidePopup()
|
||||
{
|
||||
this._panel.hidePopup();
|
||||
|
||||
if (this.onSelect) {
|
||||
this._list.removeEventListener("select", this.onSelect, false);
|
||||
}
|
||||
|
||||
if (this.onClick) {
|
||||
this._list.removeEventListener("click", this.onClick, false);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the autocomplete popup is open.
|
||||
*/
|
||||
get isOpen() {
|
||||
return this._panel.state == "open";
|
||||
},
|
||||
|
||||
/**
|
||||
* Destroy the object instance. Please note that the panel DOM elements remain
|
||||
* in the DOM, because they might still be in use by other instances of the
|
||||
* same code. It is the responsability of the client code to perform DOM
|
||||
* cleanup.
|
||||
*/
|
||||
destroy: function AP_destroy()
|
||||
{
|
||||
if (this.isOpen) {
|
||||
this.hidePopup();
|
||||
}
|
||||
this.clearItems();
|
||||
|
||||
this._document = null;
|
||||
this._list = null;
|
||||
this._panel = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the autocomplete items array.
|
||||
*
|
||||
* @return array
|
||||
* The array of autocomplete items.
|
||||
*/
|
||||
getItems: function AP_getItems()
|
||||
{
|
||||
let items = [];
|
||||
|
||||
Array.forEach(this._list.childNodes, function(aItem) {
|
||||
items.push(aItem._autocompleteItem);
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the autocomplete items list, in one go.
|
||||
*
|
||||
* @param array aItems
|
||||
* The list of items you want displayed in the popup list.
|
||||
*/
|
||||
setItems: function AP_setItems(aItems)
|
||||
{
|
||||
this.clearItems();
|
||||
aItems.forEach(this.appendItem, this);
|
||||
|
||||
// Make sure that the new content is properly fitted by the XUL richlistbox.
|
||||
if (this.isOpen) {
|
||||
// We need the timeout to allow the content to reflow. Attempting to
|
||||
// update the richlistbox size too early does not work.
|
||||
this._document.defaultView.setTimeout(this._updateSize.bind(this), 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the panel size to fit the content.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updateSize: function AP__updateSize()
|
||||
{
|
||||
this._list.width = this._panel.clientWidth +
|
||||
this._scrollbarWidth;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all the items from the autocomplete list.
|
||||
*/
|
||||
clearItems: function AP_clearItems()
|
||||
{
|
||||
while (this._list.hasChildNodes()) {
|
||||
this._list.removeChild(this._list.firstChild);
|
||||
}
|
||||
this._list.width = "";
|
||||
},
|
||||
|
||||
/**
|
||||
* Getter for the index of the selected item.
|
||||
*
|
||||
* @type number
|
||||
*/
|
||||
get selectedIndex() {
|
||||
return this._list.selectedIndex;
|
||||
},
|
||||
|
||||
/**
|
||||
* Setter for the selected index.
|
||||
*
|
||||
* @param number aIndex
|
||||
* The number (index) of the item you want to select in the list.
|
||||
*/
|
||||
set selectedIndex(aIndex) {
|
||||
this._list.selectedIndex = aIndex;
|
||||
this._list.ensureIndexIsVisible(this._list.selectedIndex);
|
||||
},
|
||||
|
||||
/**
|
||||
* Getter for the selected item.
|
||||
* @type object
|
||||
*/
|
||||
get selectedItem() {
|
||||
return this._list.selectedItem ?
|
||||
this._list.selectedItem._autocompleteItem : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Setter for the selected item.
|
||||
*
|
||||
* @param object aItem
|
||||
* The object you want selected in the list.
|
||||
*/
|
||||
set selectedItem(aItem) {
|
||||
this._list.selectedItem = this._findListItem(aItem);
|
||||
this._list.ensureIndexIsVisible(this._list.selectedIndex);
|
||||
},
|
||||
|
||||
/**
|
||||
* Append an item into the autocomplete list.
|
||||
*
|
||||
* @param object aItem
|
||||
* The item you want appended to the list. The object must have a
|
||||
* "label" property which is used as the displayed value.
|
||||
*/
|
||||
appendItem: function AP_appendItem(aItem)
|
||||
{
|
||||
let description = this._document.createElementNS(XUL_NS, "description");
|
||||
description.textContent = aItem.label;
|
||||
|
||||
let listItem = this._document.createElementNS(XUL_NS, "richlistitem");
|
||||
listItem.appendChild(description);
|
||||
listItem._autocompleteItem = aItem;
|
||||
|
||||
this._list.appendChild(listItem);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find the richlistitem element that belongs to an item.
|
||||
*
|
||||
* @private
|
||||
*
|
||||
* @param object aItem
|
||||
* The object you want found in the list.
|
||||
*
|
||||
* @return nsIDOMNode|null
|
||||
* The nsIDOMNode that belongs to the given item object. This node is
|
||||
* the richlistitem element.
|
||||
*/
|
||||
_findListItem: function AP__findListItem(aItem)
|
||||
{
|
||||
for (let i = 0; i < this._list.childNodes.length; i++) {
|
||||
let child = this._list.childNodes[i];
|
||||
if (child._autocompleteItem == aItem) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove an item from the popup list.
|
||||
*
|
||||
* @param object aItem
|
||||
* The item you want removed.
|
||||
*/
|
||||
removeItem: function AP_removeItem(aItem)
|
||||
{
|
||||
let item = this._findListItem(aItem);
|
||||
if (!item) {
|
||||
throw new Error("Item not found!");
|
||||
}
|
||||
this._list.removeChild(item);
|
||||
},
|
||||
|
||||
/**
|
||||
* Getter for the number of items in the popup.
|
||||
* @type number
|
||||
*/
|
||||
get itemCount() {
|
||||
return this._list.childNodes.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Select the next item in the list.
|
||||
*
|
||||
* @return object
|
||||
* The newly selected item object.
|
||||
*/
|
||||
selectNextItem: function AP_selectNextItem()
|
||||
{
|
||||
if (this.selectedIndex < (this.itemCount - 1)) {
|
||||
this.selectedIndex++;
|
||||
}
|
||||
else {
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
return this.selectedItem;
|
||||
},
|
||||
|
||||
/**
|
||||
* Select the previous item in the list.
|
||||
*
|
||||
* @return object
|
||||
* The newly selected item object.
|
||||
*/
|
||||
selectPreviousItem: function AP_selectPreviousItem()
|
||||
{
|
||||
if (this.selectedIndex > -1) {
|
||||
this.selectedIndex--;
|
||||
}
|
||||
else {
|
||||
this.selectedIndex = this.itemCount - 1;
|
||||
}
|
||||
|
||||
return this.selectedItem;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine the scrollbar width in the current document.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
get _scrollbarWidth()
|
||||
{
|
||||
if (this.__scrollbarWidth) {
|
||||
return this.__scrollbarWidth;
|
||||
}
|
||||
|
||||
let hbox = this._document.createElementNS(XUL_NS, "hbox");
|
||||
hbox.setAttribute("style", "height: 0%; overflow: hidden");
|
||||
|
||||
let scrollbar = this._document.createElementNS(XUL_NS, "scrollbar");
|
||||
scrollbar.setAttribute("orient", "vertical");
|
||||
hbox.appendChild(scrollbar);
|
||||
|
||||
this._document.documentElement.appendChild(hbox);
|
||||
this.__scrollbarWidth = scrollbar.clientWidth;
|
||||
this._document.documentElement.removeChild(hbox);
|
||||
|
||||
return this.__scrollbarWidth;
|
||||
},
|
||||
};
|
||||
|
|
@ -86,6 +86,17 @@ XPCOMUtils.defineLazyGetter(this, "PropertyPanel", function () {
|
|||
return obj.PropertyPanel;
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "AutocompletePopup", function () {
|
||||
var obj = {};
|
||||
try {
|
||||
Cu.import("resource://gre/modules/AutocompletePopup.jsm", obj);
|
||||
}
|
||||
catch (err) {
|
||||
Cu.reportError(err);
|
||||
}
|
||||
return obj.AutocompletePopup;
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "namesAndValuesOf", function () {
|
||||
var obj = {};
|
||||
Cu.import("resource:///modules/PropertyPanel.jsm", obj);
|
||||
|
@ -524,9 +535,11 @@ ResponseListener.prototype =
|
|||
function createElement(aDocument, aTag, aAttributes)
|
||||
{
|
||||
let node = aDocument.createElement(aTag);
|
||||
for (var attr in aAttributes) {
|
||||
if (aAttributes) {
|
||||
for (let attr in aAttributes) {
|
||||
node.setAttribute(attr, aAttributes[attr]);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
|
@ -1763,6 +1776,8 @@ HUD_SERVICE.prototype =
|
|||
ownerDoc = outputNode.ownerDocument;
|
||||
ownerDoc.getElementById(id).parentNode.removeChild(outputNode);
|
||||
|
||||
this.hudReferences[id].jsterm.autocompletePopup.destroy();
|
||||
|
||||
this.hudReferences[id].consoleWindowUnregisterOnHide = false;
|
||||
|
||||
// remove the HeadsUpDisplay object from memory
|
||||
|
@ -1791,6 +1806,12 @@ HUD_SERVICE.prototype =
|
|||
Services.obs.notifyObservers(id, "web-console-destroyed", null);
|
||||
|
||||
if (Object.keys(this.hudReferences).length == 0) {
|
||||
let autocompletePopup = outputNode.ownerDocument.
|
||||
getElementById("webConsole_autocompletePopup");
|
||||
if (autocompletePopup) {
|
||||
autocompletePopup.parentNode.removeChild(autocompletePopup);
|
||||
}
|
||||
|
||||
this.suspend();
|
||||
}
|
||||
},
|
||||
|
@ -4158,13 +4179,12 @@ function JSPropertyProvider(aScope, aInputValue)
|
|||
let properties = completionPart.split('.');
|
||||
let matchProp;
|
||||
if (properties.length > 1) {
|
||||
matchProp = properties[properties.length - 1].trimLeft();
|
||||
properties.pop();
|
||||
for each (var prop in properties) {
|
||||
prop = prop.trim();
|
||||
matchProp = properties.pop().trimLeft();
|
||||
for (let i = 0; i < properties.length; i++) {
|
||||
let prop = properties[i].trim();
|
||||
|
||||
// If obj is undefined or null, then there is no change to run
|
||||
// completion on it. Exit here.
|
||||
// If obj is undefined or null, then there is no change to run completion
|
||||
// on it. Exit here.
|
||||
if (typeof obj === "undefined" || obj === null) {
|
||||
return null;
|
||||
}
|
||||
|
@ -4193,17 +4213,15 @@ function JSPropertyProvider(aScope, aInputValue)
|
|||
}
|
||||
|
||||
let matches = [];
|
||||
for (var prop in obj) {
|
||||
for (let prop in obj) {
|
||||
if (prop.indexOf(matchProp) == 0) {
|
||||
matches.push(prop);
|
||||
}
|
||||
|
||||
matches = matches.filter(function(item) {
|
||||
return item.indexOf(matchProp) == 0;
|
||||
}).sort();
|
||||
}
|
||||
|
||||
return {
|
||||
matchProp: matchProp,
|
||||
matches: matches
|
||||
matches: matches.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -4465,6 +4483,9 @@ function JSTerm(aContext, aParentNode, aMixin, aConsole)
|
|||
this.historyIndex = 0;
|
||||
this.historyPlaceHolder = 0; // this.history.length;
|
||||
this.log = LogFactory("*** JSTerm:");
|
||||
this.autocompletePopup = new AutocompletePopup(aParentNode.ownerDocument);
|
||||
this.autocompletePopup.onSelect = this.onAutocompleteSelect.bind(this);
|
||||
this.autocompletePopup.onClick = this.acceptProposedCompletion.bind(this);
|
||||
this.init();
|
||||
}
|
||||
|
||||
|
@ -4603,6 +4624,7 @@ JSTerm.prototype = {
|
|||
this.historyIndex++;
|
||||
this.historyPlaceHolder = this.history.length;
|
||||
this.setInputValue("");
|
||||
this.clearCompletion();
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -4957,54 +4979,57 @@ JSTerm.prototype = {
|
|||
return;
|
||||
}
|
||||
|
||||
let inputUpdated = false;
|
||||
|
||||
switch(aEvent.keyCode) {
|
||||
case Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE:
|
||||
if (this.autocompletePopup.isOpen) {
|
||||
this.clearCompletion();
|
||||
aEvent.preventDefault();
|
||||
}
|
||||
break;
|
||||
|
||||
case Ci.nsIDOMKeyEvent.DOM_VK_RETURN:
|
||||
if (this.autocompletePopup.isOpen) {
|
||||
this.acceptProposedCompletion();
|
||||
}
|
||||
else {
|
||||
this.execute();
|
||||
}
|
||||
aEvent.preventDefault();
|
||||
break;
|
||||
|
||||
case Ci.nsIDOMKeyEvent.DOM_VK_UP:
|
||||
// history previous
|
||||
if (this.canCaretGoPrevious()) {
|
||||
let updated = this.historyPeruse(HISTORY_BACK);
|
||||
if (updated && aEvent.cancelable) {
|
||||
aEvent.preventDefault();
|
||||
if (this.autocompletePopup.isOpen) {
|
||||
inputUpdated = this.complete(this.COMPLETE_BACKWARD);
|
||||
}
|
||||
else if (this.canCaretGoPrevious()) {
|
||||
inputUpdated = this.historyPeruse(HISTORY_BACK);
|
||||
}
|
||||
if (inputUpdated) {
|
||||
aEvent.preventDefault();
|
||||
}
|
||||
break;
|
||||
|
||||
case Ci.nsIDOMKeyEvent.DOM_VK_DOWN:
|
||||
// history next
|
||||
if (this.canCaretGoNext()) {
|
||||
let updated = this.historyPeruse(HISTORY_FORWARD);
|
||||
if (updated && aEvent.cancelable) {
|
||||
if (this.autocompletePopup.isOpen) {
|
||||
inputUpdated = this.complete(this.COMPLETE_FORWARD);
|
||||
}
|
||||
else if (this.canCaretGoNext()) {
|
||||
inputUpdated = this.historyPeruse(HISTORY_FORWARD);
|
||||
}
|
||||
if (inputUpdated) {
|
||||
aEvent.preventDefault();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Ci.nsIDOMKeyEvent.DOM_VK_RIGHT:
|
||||
// accept proposed completion
|
||||
this.acceptProposedCompletion();
|
||||
break;
|
||||
|
||||
case Ci.nsIDOMKeyEvent.DOM_VK_TAB:
|
||||
// If there are more than one possible completion, pressing tab
|
||||
// means taking the next completion, shift_tab means taking
|
||||
// the previous completion.
|
||||
var completionResult;
|
||||
if (aEvent.shiftKey) {
|
||||
completionResult = this.complete(this.COMPLETE_BACKWARD);
|
||||
}
|
||||
else {
|
||||
completionResult = this.complete(this.COMPLETE_FORWARD);
|
||||
}
|
||||
if (completionResult) {
|
||||
if (aEvent.cancelable) {
|
||||
// Generate a completion and accept the first proposed value.
|
||||
if (this.complete(this.COMPLETE_HINT_ONLY) &&
|
||||
this.lastCompletion &&
|
||||
this.acceptProposedCompletion()) {
|
||||
aEvent.preventDefault();
|
||||
}
|
||||
aEvent.target.focus();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
|
@ -5147,96 +5172,127 @@ JSTerm.prototype = {
|
|||
let inputValue = inputNode.value;
|
||||
// If the inputNode has no value, then don't try to complete on it.
|
||||
if (!inputValue) {
|
||||
this.lastCompletion = null;
|
||||
this.updateCompleteNode("");
|
||||
this.clearCompletion();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only complete if the selection is empty and at the end of the input.
|
||||
if (inputNode.selectionStart == inputNode.selectionEnd &&
|
||||
inputNode.selectionEnd != inputValue.length) {
|
||||
// TODO: shouldnt we do this in the other 'bail' cases?
|
||||
this.clearCompletion();
|
||||
return false;
|
||||
}
|
||||
|
||||
let popup = this.autocompletePopup;
|
||||
|
||||
if (!this.lastCompletion || this.lastCompletion.value != inputValue) {
|
||||
let properties = this.propertyProvider(this.sandbox.window, inputValue);
|
||||
if (!properties || !properties.matches.length) {
|
||||
this.clearCompletion();
|
||||
return false;
|
||||
}
|
||||
|
||||
let items = properties.matches.map(function(aMatch) {
|
||||
return {label: aMatch};
|
||||
});
|
||||
popup.setItems(items);
|
||||
this.lastCompletion = {value: inputValue,
|
||||
matchProp: properties.matchProp};
|
||||
|
||||
if (items.length > 1 && !popup.isOpen) {
|
||||
popup.openPopup(this.inputNode);
|
||||
}
|
||||
else if (items.length < 2 && popup.isOpen) {
|
||||
popup.hidePopup();
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
popup.selectedIndex = 0;
|
||||
if (items.length == 1) {
|
||||
// onSelect is not fired when the popup is not open.
|
||||
this.onAutocompleteSelect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let accepted = false;
|
||||
|
||||
if (type != this.COMPLETE_HINT_ONLY && popup.itemCount == 1) {
|
||||
this.acceptProposedCompletion();
|
||||
accepted = true;
|
||||
}
|
||||
else if (type == this.COMPLETE_BACKWARD) {
|
||||
this.autocompletePopup.selectPreviousItem();
|
||||
}
|
||||
else if (type == this.COMPLETE_FORWARD) {
|
||||
this.autocompletePopup.selectNextItem();
|
||||
}
|
||||
|
||||
return accepted || popup.itemCount > 0;
|
||||
},
|
||||
|
||||
onAutocompleteSelect: function JSTF_onAutocompleteSelect()
|
||||
{
|
||||
let currentItem = this.autocompletePopup.selectedItem;
|
||||
if (currentItem && this.lastCompletion) {
|
||||
let suffix = currentItem.label.substring(this.lastCompletion.
|
||||
matchProp.length);
|
||||
this.updateCompleteNode(suffix);
|
||||
}
|
||||
else {
|
||||
this.updateCompleteNode("");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current completion information and close the autocomplete popup,
|
||||
* if needed.
|
||||
*/
|
||||
clearCompletion: function JSTF_clearCompletion()
|
||||
{
|
||||
this.autocompletePopup.clearItems();
|
||||
this.lastCompletion = null;
|
||||
this.updateCompleteNode("");
|
||||
return false;
|
||||
if (this.autocompletePopup.isOpen) {
|
||||
this.autocompletePopup.hidePopup();
|
||||
}
|
||||
|
||||
let matches;
|
||||
let matchIndexToUse;
|
||||
let matchOffset;
|
||||
|
||||
// If there is a saved completion from last time and the used value for
|
||||
// completion stayed the same, then use the stored completion.
|
||||
if (this.lastCompletion && inputValue == this.lastCompletion.value) {
|
||||
matches = this.lastCompletion.matches;
|
||||
matchOffset = this.lastCompletion.matchOffset;
|
||||
if (type === this.COMPLETE_BACKWARD) {
|
||||
this.lastCompletion.index --;
|
||||
}
|
||||
else if (type === this.COMPLETE_FORWARD) {
|
||||
this.lastCompletion.index ++;
|
||||
}
|
||||
matchIndexToUse = this.lastCompletion.index;
|
||||
}
|
||||
else {
|
||||
// Look up possible completion values.
|
||||
let completion = this.propertyProvider(this.sandbox.window, inputValue);
|
||||
if (!completion) {
|
||||
this.updateCompleteNode("");
|
||||
return false;
|
||||
}
|
||||
matches = completion.matches;
|
||||
matchIndexToUse = 0;
|
||||
matchOffset = completion.matchProp.length;
|
||||
// Store this match;
|
||||
this.lastCompletion = {
|
||||
index: 0,
|
||||
value: inputValue,
|
||||
matches: matches,
|
||||
matchOffset: matchOffset
|
||||
};
|
||||
}
|
||||
|
||||
if (type != this.COMPLETE_HINT_ONLY && matches.length == 1) {
|
||||
this.acceptProposedCompletion();
|
||||
return true;
|
||||
}
|
||||
else if (matches.length != 0) {
|
||||
// Ensure that the matchIndexToUse is always a valid array index.
|
||||
if (matchIndexToUse < 0) {
|
||||
matchIndexToUse = matches.length + (matchIndexToUse % matches.length);
|
||||
if (matchIndexToUse == matches.length) {
|
||||
matchIndexToUse = 0;
|
||||
}
|
||||
}
|
||||
else {
|
||||
matchIndexToUse = matchIndexToUse % matches.length;
|
||||
}
|
||||
|
||||
let completionStr = matches[matchIndexToUse].substring(matchOffset);
|
||||
this.updateCompleteNode(completionStr);
|
||||
return completionStr ? true : false;
|
||||
}
|
||||
else {
|
||||
this.updateCompleteNode("");
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Accept the proposed input completion.
|
||||
*
|
||||
* @return boolean
|
||||
* True if there was a selected completion item and the input value
|
||||
* was updated, false otherwise.
|
||||
*/
|
||||
acceptProposedCompletion: function JSTF_acceptProposedCompletion()
|
||||
{
|
||||
this.setInputValue(this.inputNode.value + this.completionValue);
|
||||
this.updateCompleteNode("");
|
||||
let updated = false;
|
||||
|
||||
let currentItem = this.autocompletePopup.selectedItem;
|
||||
if (currentItem && this.lastCompletion) {
|
||||
let suffix = currentItem.label.substring(this.lastCompletion.
|
||||
matchProp.length);
|
||||
this.setInputValue(this.inputNode.value + suffix);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
this.clearCompletion();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
updateCompleteNode: function JSTF_updateCompleteNode(suffix)
|
||||
/**
|
||||
* Update the node that displays the currently selected autocomplete proposal.
|
||||
*
|
||||
* @param string aSuffix
|
||||
* The proposed suffix for the inputNode value.
|
||||
*/
|
||||
updateCompleteNode: function JSTF_updateCompleteNode(aSuffix)
|
||||
{
|
||||
this.completionValue = suffix;
|
||||
|
||||
// completion prefix = input, with non-control chars replaced by spaces
|
||||
let prefix = this.inputNode.value.replace(/[\S]/g, " ");
|
||||
this.completeNode.value = prefix + this.completionValue;
|
||||
let prefix = aSuffix ? this.inputNode.value.replace(/[\S]/g, " ") : "";
|
||||
this.completeNode.value = prefix + aSuffix;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -46,6 +46,7 @@ include $(DEPTH)/config/autoconf.mk
|
|||
EXTRA_JS_MODULES = HUDService.jsm \
|
||||
PropertyPanel.jsm \
|
||||
NetworkHelper.jsm \
|
||||
AutocompletePopup.jsm \
|
||||
$(NULL)
|
||||
|
||||
ifdef ENABLE_TESTS
|
||||
|
|
|
@ -138,6 +138,8 @@ _BROWSER_TEST_FILES = \
|
|||
browser_webconsole_bug_646025_console_file_location.js \
|
||||
browser_webconsole_position_ui.js \
|
||||
browser_webconsole_bug_642615_autocomplete.js \
|
||||
browser_webconsole_bug_585991_autocomplete_popup.js \
|
||||
browser_webconsole_bug_585991_autocomplete_keys.js \
|
||||
head.js \
|
||||
$(NULL)
|
||||
|
||||
|
|
|
@ -0,0 +1,204 @@
|
|||
/* vim:set ts=2 sw=2 sts=2 et: */
|
||||
/* ***** BEGIN LICENSE BLOCK *****
|
||||
* Version: MPL 1.1/GPL 2.0/LGPL 2.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is Web Console test suite.
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* The Mozilla Foundation.
|
||||
* Portions created by the Initial Developer are Copyright (C) 2011
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mihai Sucan <mihai.sucan@gmail.com>
|
||||
*
|
||||
* Alternatively, the contents of this file may be used under the terms of
|
||||
* either the GNU General Public License Version 2 or later (the "GPL"), or
|
||||
* the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
|
||||
* in which case the provisions of the GPL or the LGPL are applicable instead
|
||||
* of those above. If you wish to allow use of your version of this file only
|
||||
* under the terms of either the GPL or the LGPL, and not to allow others to
|
||||
* use your version of this file under the terms of the MPL, indicate your
|
||||
* decision by deleting the provisions above and replace them with the notice
|
||||
* and other provisions required by the GPL or the LGPL. If you do not delete
|
||||
* the provisions above, a recipient may use your version of this file under
|
||||
* the terms of any one of the MPL, the GPL or the LGPL.
|
||||
*
|
||||
* ***** END LICENSE BLOCK ***** */
|
||||
|
||||
const TEST_URI = "data:text/html,<p>bug 585991 - autocomplete popup keyboard usage test";
|
||||
|
||||
function test() {
|
||||
addTab(TEST_URI);
|
||||
browser.addEventListener("load", tabLoaded, true);
|
||||
}
|
||||
|
||||
function tabLoaded() {
|
||||
browser.removeEventListener("load", tabLoaded, true);
|
||||
openConsole();
|
||||
|
||||
content.wrappedJSObject.foobarBug585991 = {
|
||||
"item0": "value0",
|
||||
"item1": "value1",
|
||||
"item2": "value2",
|
||||
"item3": "value3",
|
||||
};
|
||||
|
||||
let hudId = HUDService.getHudIdByWindow(content);
|
||||
HUD = HUDService.hudReferences[hudId];
|
||||
let jsterm = HUD.jsterm;
|
||||
let popup = jsterm.autocompletePopup;
|
||||
let completeNode = jsterm.completeNode;
|
||||
|
||||
ok(!popup.isOpen, "popup is not open");
|
||||
|
||||
popup._panel.addEventListener("popupshown", function() {
|
||||
popup._panel.removeEventListener("popupshown", arguments.callee, false);
|
||||
|
||||
ok(popup.isOpen, "popup is open");
|
||||
|
||||
is(popup.itemCount, 4, "popup.itemCount is correct");
|
||||
|
||||
let sameItems = popup.getItems();
|
||||
is(sameItems.every(function(aItem, aIndex) {
|
||||
return aItem.label == "item" + aIndex;
|
||||
}), true, "getItems returns back the same items");
|
||||
|
||||
let prefix = jsterm.inputNode.value.replace(/[\S]/g, " ");
|
||||
|
||||
is(popup.selectedIndex, 0, "index 0 is selected");
|
||||
is(popup.selectedItem.label, "item0", "item0 is selected");
|
||||
is(completeNode.value, prefix + "item0", "completeNode.value holds item0");
|
||||
|
||||
EventUtils.synthesizeKey("VK_DOWN", {});
|
||||
|
||||
is(popup.selectedIndex, 1, "index 1 is selected");
|
||||
is(popup.selectedItem.label, "item1", "item1 is selected");
|
||||
is(completeNode.value, prefix + "item1", "completeNode.value holds item1");
|
||||
|
||||
EventUtils.synthesizeKey("VK_UP", {});
|
||||
|
||||
is(popup.selectedIndex, 0, "index 0 is selected");
|
||||
is(popup.selectedItem.label, "item0", "item0 is selected");
|
||||
is(completeNode.value, prefix + "item0", "completeNode.value holds item0");
|
||||
|
||||
popup._panel.addEventListener("popuphidden", autocompletePopupHidden, false);
|
||||
|
||||
EventUtils.synthesizeKey("VK_TAB", {});
|
||||
}, false);
|
||||
|
||||
jsterm.setInputValue("window.foobarBug585991");
|
||||
EventUtils.synthesizeKey(".", {});
|
||||
}
|
||||
|
||||
function autocompletePopupHidden()
|
||||
{
|
||||
let jsterm = HUD.jsterm;
|
||||
let popup = jsterm.autocompletePopup;
|
||||
let completeNode = jsterm.completeNode;
|
||||
let inputNode = jsterm.inputNode;
|
||||
|
||||
popup._panel.removeEventListener("popuphidden", arguments.callee, false);
|
||||
|
||||
ok(!popup.isOpen, "popup is not open");
|
||||
|
||||
is(inputNode.value, "window.foobarBug585991.item0",
|
||||
"completion was successful after VK_TAB");
|
||||
|
||||
ok(!completeNode.value, "completeNode is empty");
|
||||
|
||||
popup._panel.addEventListener("popupshown", function() {
|
||||
popup._panel.removeEventListener("popupshown", arguments.callee, false);
|
||||
|
||||
ok(popup.isOpen, "popup is open");
|
||||
|
||||
is(popup.itemCount, 4, "popup.itemCount is correct");
|
||||
|
||||
let prefix = jsterm.inputNode.value.replace(/[\S]/g, " ");
|
||||
|
||||
is(popup.selectedIndex, 0, "index 0 is selected");
|
||||
is(popup.selectedItem.label, "item0", "item0 is selected");
|
||||
is(completeNode.value, prefix + "item0", "completeNode.value holds item0");
|
||||
|
||||
popup._panel.addEventListener("popuphidden", function() {
|
||||
popup._panel.removeEventListener("popuphidden", arguments.callee, false);
|
||||
|
||||
ok(!popup.isOpen, "popup is not open after VK_ESCAPE");
|
||||
|
||||
is(inputNode.value, "window.foobarBug585991.",
|
||||
"completion was cancelled");
|
||||
|
||||
ok(!completeNode.value, "completeNode is empty");
|
||||
|
||||
executeSoon(testReturnKey);
|
||||
}, false);
|
||||
|
||||
executeSoon(function() {
|
||||
EventUtils.synthesizeKey("VK_ESCAPE", {});
|
||||
});
|
||||
}, false);
|
||||
|
||||
executeSoon(function() {
|
||||
jsterm.setInputValue("window.foobarBug585991");
|
||||
EventUtils.synthesizeKey(".", {});
|
||||
});
|
||||
}
|
||||
|
||||
function testReturnKey()
|
||||
{
|
||||
let jsterm = HUD.jsterm;
|
||||
let popup = jsterm.autocompletePopup;
|
||||
let completeNode = jsterm.completeNode;
|
||||
let inputNode = jsterm.inputNode;
|
||||
|
||||
popup._panel.addEventListener("popupshown", function() {
|
||||
popup._panel.removeEventListener("popupshown", arguments.callee, false);
|
||||
|
||||
ok(popup.isOpen, "popup is open");
|
||||
|
||||
is(popup.itemCount, 4, "popup.itemCount is correct");
|
||||
|
||||
let prefix = jsterm.inputNode.value.replace(/[\S]/g, " ");
|
||||
|
||||
is(popup.selectedIndex, 0, "index 0 is selected");
|
||||
is(popup.selectedItem.label, "item0", "item0 is selected");
|
||||
is(completeNode.value, prefix + "item0", "completeNode.value holds item0");
|
||||
|
||||
EventUtils.synthesizeKey("VK_DOWN", {});
|
||||
|
||||
is(popup.selectedIndex, 1, "index 1 is selected");
|
||||
is(popup.selectedItem.label, "item1", "item1 is selected");
|
||||
is(completeNode.value, prefix + "item1", "completeNode.value holds item1");
|
||||
|
||||
popup._panel.addEventListener("popuphidden", function() {
|
||||
popup._panel.removeEventListener("popuphidden", arguments.callee, false);
|
||||
|
||||
ok(!popup.isOpen, "popup is not open after VK_RETURN");
|
||||
|
||||
is(inputNode.value, "window.foobarBug585991.item1",
|
||||
"completion was successful after VK_RETURN");
|
||||
|
||||
ok(!completeNode.value, "completeNode is empty");
|
||||
|
||||
executeSoon(finishTest);
|
||||
}, false);
|
||||
|
||||
EventUtils.synthesizeKey("VK_RETURN", {});
|
||||
}, false);
|
||||
|
||||
executeSoon(function() {
|
||||
jsterm.setInputValue("window.foobarBug58599");
|
||||
EventUtils.synthesizeKey("1", {});
|
||||
EventUtils.synthesizeKey(".", {});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
/* vim:set ts=2 sw=2 sts=2 et: */
|
||||
/* ***** BEGIN LICENSE BLOCK *****
|
||||
* Version: MPL 1.1/GPL 2.0/LGPL 2.1
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License Version
|
||||
* 1.1 (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS" basis,
|
||||
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing rights and limitations under the
|
||||
* License.
|
||||
*
|
||||
* The Original Code is Web Console test suite.
|
||||
*
|
||||
* The Initial Developer of the Original Code is
|
||||
* The Mozilla Foundation.
|
||||
* Portions created by the Initial Developer are Copyright (C) 2011
|
||||
* the Initial Developer. All Rights Reserved.
|
||||
*
|
||||
* Contributor(s):
|
||||
* Mihai Sucan <mihai.sucan@gmail.com>
|
||||
*
|
||||
* Alternatively, the contents of this file may be used under the terms of
|
||||
* either the GNU General Public License Version 2 or later (the "GPL"), or
|
||||
* the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
|
||||
* in which case the provisions of the GPL or the LGPL are applicable instead
|
||||
* of those above. If you wish to allow use of your version of this file only
|
||||
* under the terms of either the GPL or the LGPL, and not to allow others to
|
||||
* use your version of this file under the terms of the MPL, indicate your
|
||||
* decision by deleting the provisions above and replace them with the notice
|
||||
* and other provisions required by the GPL or the LGPL. If you do not delete
|
||||
* the provisions above, a recipient may use your version of this file under
|
||||
* the terms of any one of the MPL, the GPL or the LGPL.
|
||||
*
|
||||
* ***** END LICENSE BLOCK ***** */
|
||||
|
||||
const TEST_URI = "data:text/html,<p>bug 585991 - autocomplete popup test";
|
||||
|
||||
function test() {
|
||||
addTab(TEST_URI);
|
||||
browser.addEventListener("load", tabLoaded, true);
|
||||
}
|
||||
|
||||
function tabLoaded() {
|
||||
browser.removeEventListener("load", tabLoaded, true);
|
||||
openConsole();
|
||||
|
||||
let items = [
|
||||
{label: "item0", value: "value0"},
|
||||
{label: "item1", value: "value1"},
|
||||
{label: "item2", value: "value2"},
|
||||
];
|
||||
|
||||
let hudId = HUDService.getHudIdByWindow(content);
|
||||
let HUD = HUDService.hudReferences[hudId];
|
||||
let popup = HUD.jsterm.autocompletePopup;
|
||||
|
||||
ok(!popup.isOpen, "popup is not open");
|
||||
|
||||
popup._panel.addEventListener("popupshown", function() {
|
||||
popup._panel.removeEventListener("popupshown", arguments.callee, false);
|
||||
|
||||
ok(popup.isOpen, "popup is open");
|
||||
|
||||
is(popup.itemCount, 0, "no items");
|
||||
|
||||
popup.setItems(items);
|
||||
|
||||
is(popup.itemCount, items.length, "items added");
|
||||
|
||||
let sameItems = popup.getItems();
|
||||
is(sameItems.every(function(aItem, aIndex) {
|
||||
return aItem === items[aIndex];
|
||||
}), true, "getItems returns back the same items");
|
||||
|
||||
is(popup.selectedIndex, -1, "no index is selected");
|
||||
ok(!popup.selectedItem, "no item is selected");
|
||||
|
||||
popup.selectedIndex = 1;
|
||||
|
||||
is(popup.selectedIndex, 1, "index 1 is selected");
|
||||
is(popup.selectedItem, items[1], "item1 is selected");
|
||||
|
||||
popup.selectedItem = items[2];
|
||||
|
||||
is(popup.selectedIndex, 2, "index 2 is selected");
|
||||
is(popup.selectedItem, items[2], "item2 is selected");
|
||||
|
||||
is(popup.selectPreviousItem(), items[1], "selectPreviousItem() works");
|
||||
|
||||
is(popup.selectedIndex, 1, "index 1 is selected");
|
||||
is(popup.selectedItem, items[1], "item1 is selected");
|
||||
|
||||
is(popup.selectNextItem(), items[2], "selectPreviousItem() works");
|
||||
|
||||
is(popup.selectedIndex, 2, "index 2 is selected");
|
||||
is(popup.selectedItem, items[2], "item2 is selected");
|
||||
|
||||
ok(!popup.selectNextItem(), "selectPreviousItem() works");
|
||||
|
||||
is(popup.selectedIndex, -1, "no index is selected");
|
||||
ok(!popup.selectedItem, "no item is selected");
|
||||
|
||||
items.push({label: "label3", value: "value3"});
|
||||
popup.appendItem(items[3]);
|
||||
|
||||
is(popup.itemCount, items.length, "item3 appended");
|
||||
|
||||
popup.selectedIndex = 3;
|
||||
is(popup.selectedItem, items[3], "item3 is selected");
|
||||
|
||||
popup.removeItem(items[2]);
|
||||
|
||||
is(popup.selectedIndex, 2, "index2 is selected");
|
||||
is(popup.selectedItem, items[3], "item3 is still selected");
|
||||
is(popup.itemCount, items.length - 1, "item2 removed");
|
||||
|
||||
popup.clearItems();
|
||||
is(popup.itemCount, 0, "items cleared");
|
||||
|
||||
popup.hidePopup();
|
||||
finishTest();
|
||||
}, false);
|
||||
|
||||
popup.openPopup();
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ function tabLoad(aEvent) {
|
|||
|
||||
jsterm.clearOutput();
|
||||
|
||||
ok(!jsterm.completionValue, "no completionValue");
|
||||
ok(!jsterm.completeNode.value, "no completeNode.value");
|
||||
|
||||
jsterm.setInputValue("doc");
|
||||
|
||||
|
@ -28,28 +28,28 @@ function tabLoad(aEvent) {
|
|||
jsterm.inputNode.addEventListener("keyup", function() {
|
||||
jsterm.inputNode.removeEventListener("keyup", arguments.callee, false);
|
||||
|
||||
let completionValue = jsterm.completionValue;
|
||||
ok(completionValue, "we have a completionValue");
|
||||
let completionValue = jsterm.completeNode.value;
|
||||
ok(completionValue, "we have a completeNode.value");
|
||||
|
||||
// wait for paste
|
||||
jsterm.inputNode.addEventListener("input", function() {
|
||||
jsterm.inputNode.removeEventListener("input", arguments.callee, false);
|
||||
|
||||
ok(!jsterm.completionValue, "no completionValue after clipboard paste");
|
||||
ok(!jsterm.completeNode.value, "no completeNode.value after clipboard paste");
|
||||
|
||||
// wait for undo
|
||||
jsterm.inputNode.addEventListener("input", function() {
|
||||
jsterm.inputNode.removeEventListener("input", arguments.callee, false);
|
||||
|
||||
is(jsterm.completionValue, completionValue,
|
||||
"same completionValue after undo");
|
||||
is(jsterm.completeNode.value, completionValue,
|
||||
"same completeNode.value after undo");
|
||||
|
||||
// wait for paste (via keyboard event)
|
||||
jsterm.inputNode.addEventListener("keyup", function() {
|
||||
jsterm.inputNode.removeEventListener("keyup", arguments.callee, false);
|
||||
|
||||
ok(!jsterm.completionValue,
|
||||
"no completionValue after clipboard paste (via keyboard event)");
|
||||
ok(!jsterm.completeNode.value,
|
||||
"no completeNode.value after clipboard paste (via keyboard event)");
|
||||
|
||||
executeSoon(finishTest);
|
||||
}, false);
|
||||
|
|
|
@ -139,6 +139,10 @@ webConsolePositionWindow=Window
|
|||
# title.
|
||||
webConsoleOwnWindowTitle=Web Console
|
||||
|
||||
# LOCALIZATION NOTE (Autocomplete.label):
|
||||
# The autocomplete popup panel label/title.
|
||||
Autocomplete.label=Autocomplete popup
|
||||
|
||||
# LOCALIZATION NOTE (stacktrace.anonymousFunction):
|
||||
# This string is used to display JavaScript functions that have no given name -
|
||||
# they are said to be anonymous. See stacktrace.outputMessage.
|
||||
|
|
Загрузка…
Ссылка в новой задаче