diff -r 000000000000 -r 6474c204b198 browser/devtools/shared/widgets/ViewHelpers.jsm --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser/devtools/shared/widgets/ViewHelpers.jsm Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,1681 @@ +/* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +const PANE_APPEARANCE_DELAY = 50; +const PAGE_SIZE_ITEM_COUNT_RATIO = 5; +const WIDGET_FOCUSABLE_NODES = new Set(["vbox", "hbox"]); + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); +Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm"); + +this.EXPORTED_SYMBOLS = [ + "Heritage", "ViewHelpers", "WidgetMethods", + "setNamedTimeout", "clearNamedTimeout", + "setConditionalTimeout", "clearConditionalTimeout", +]; + +/** + * Inheritance helpers from the addon SDK's core/heritage. + * Remove these when all devtools are loadered. + */ +this.Heritage = { + /** + * @see extend in sdk/core/heritage. + */ + extend: function(aPrototype, aProperties = {}) { + return Object.create(aPrototype, this.getOwnPropertyDescriptors(aProperties)); + }, + + /** + * @see getOwnPropertyDescriptors in sdk/core/heritage. + */ + getOwnPropertyDescriptors: function(aObject) { + return Object.getOwnPropertyNames(aObject).reduce((aDescriptor, aName) => { + aDescriptor[aName] = Object.getOwnPropertyDescriptor(aObject, aName); + return aDescriptor; + }, {}); + } +}; + +/** + * Helper for draining a rapid succession of events and invoking a callback + * once everything settles down. + * + * @param string aId + * A string identifier for the named timeout. + * @param number aWait + * The amount of milliseconds to wait after no more events are fired. + * @param function aCallback + * Invoked when no more events are fired after the specified time. + */ +this.setNamedTimeout = function setNamedTimeout(aId, aWait, aCallback) { + clearNamedTimeout(aId); + + namedTimeoutsStore.set(aId, setTimeout(() => + namedTimeoutsStore.delete(aId) && aCallback(), aWait)); +}; + +/** + * Clears a named timeout. + * @see setNamedTimeout + * + * @param string aId + * A string identifier for the named timeout. + */ +this.clearNamedTimeout = function clearNamedTimeout(aId) { + if (!namedTimeoutsStore) { + return; + } + clearTimeout(namedTimeoutsStore.get(aId)); + namedTimeoutsStore.delete(aId); +}; + +/** + * Same as `setNamedTimeout`, but invokes the callback only if the provided + * predicate function returns true. Otherwise, the timeout is re-triggered. + * + * @param string aId + * A string identifier for the conditional timeout. + * @param number aWait + * The amount of milliseconds to wait after no more events are fired. + * @param function aPredicate + * The predicate function used to determine whether the timeout restarts. + * @param function aCallback + * Invoked when no more events are fired after the specified time, and + * the provided predicate function returns true. + */ +this.setConditionalTimeout = function setConditionalTimeout(aId, aWait, aPredicate, aCallback) { + setNamedTimeout(aId, aWait, function maybeCallback() { + if (aPredicate()) { + aCallback(); + return; + } + setConditionalTimeout(aId, aWait, aPredicate, aCallback); + }); +}; + +/** + * Clears a conditional timeout. + * @see setConditionalTimeout + * + * @param string aId + * A string identifier for the conditional timeout. + */ +this.clearConditionalTimeout = function clearConditionalTimeout(aId) { + clearNamedTimeout(aId); +}; + +XPCOMUtils.defineLazyGetter(this, "namedTimeoutsStore", () => new Map()); + +/** + * Helpers for creating and messaging between UI components. + */ +this.ViewHelpers = { + /** + * Convenience method, dispatching a custom event. + * + * @param nsIDOMNode aTarget + * A custom target element to dispatch the event from. + * @param string aType + * The name of the event. + * @param any aDetail + * The data passed when initializing the event. + * @return boolean + * True if the event was cancelled or a registered handler + * called preventDefault. + */ + dispatchEvent: function(aTarget, aType, aDetail) { + if (!(aTarget instanceof Ci.nsIDOMNode)) { + return true; // Event cancelled. + } + let document = aTarget.ownerDocument || aTarget; + let dispatcher = aTarget.ownerDocument ? aTarget : document.documentElement; + + let event = document.createEvent("CustomEvent"); + event.initCustomEvent(aType, true, true, aDetail); + return dispatcher.dispatchEvent(event); + }, + + /** + * Helper delegating some of the DOM attribute methods of a node to a widget. + * + * @param object aWidget + * The widget to assign the methods to. + * @param nsIDOMNode aNode + * A node to delegate the methods to. + */ + delegateWidgetAttributeMethods: function(aWidget, aNode) { + aWidget.getAttribute = + aWidget.getAttribute || aNode.getAttribute.bind(aNode); + aWidget.setAttribute = + aWidget.setAttribute || aNode.setAttribute.bind(aNode); + aWidget.removeAttribute = + aWidget.removeAttribute || aNode.removeAttribute.bind(aNode); + }, + + /** + * Helper delegating some of the DOM event methods of a node to a widget. + * + * @param object aWidget + * The widget to assign the methods to. + * @param nsIDOMNode aNode + * A node to delegate the methods to. + */ + delegateWidgetEventMethods: function(aWidget, aNode) { + aWidget.addEventListener = + aWidget.addEventListener || aNode.addEventListener.bind(aNode); + aWidget.removeEventListener = + aWidget.removeEventListener || aNode.removeEventListener.bind(aNode); + }, + + /** + * Checks if the specified object looks like it's been decorated by an + * event emitter. + * + * @return boolean + * True if it looks, walks and quacks like an event emitter. + */ + isEventEmitter: function(aObject) { + return aObject && aObject.on && aObject.off && aObject.once && aObject.emit; + }, + + /** + * Checks if the specified object is an instance of a DOM node. + * + * @return boolean + * True if it's a node, false otherwise. + */ + isNode: function(aObject) { + return aObject instanceof Ci.nsIDOMNode || + aObject instanceof Ci.nsIDOMElement || + aObject instanceof Ci.nsIDOMDocumentFragment; + }, + + /** + * Prevents event propagation when navigation keys are pressed. + * + * @param Event e + * The event to be prevented. + */ + preventScrolling: function(e) { + switch (e.keyCode) { + case e.DOM_VK_UP: + case e.DOM_VK_DOWN: + case e.DOM_VK_LEFT: + case e.DOM_VK_RIGHT: + case e.DOM_VK_PAGE_UP: + case e.DOM_VK_PAGE_DOWN: + case e.DOM_VK_HOME: + case e.DOM_VK_END: + e.preventDefault(); + e.stopPropagation(); + } + }, + + /** + * Sets a side pane hidden or visible. + * + * @param object aFlags + * An object containing some of the following properties: + * - visible: true if the pane should be shown, false to hide + * - animated: true to display an animation on toggle + * - delayed: true to wait a few cycles before toggle + * - callback: a function to invoke when the toggle finishes + * @param nsIDOMNode aPane + * The element representing the pane to toggle. + */ + togglePane: function(aFlags, aPane) { + // Make sure a pane is actually available first. + if (!aPane) { + return; + } + + // Hiding is always handled via margins, not the hidden attribute. + aPane.removeAttribute("hidden"); + + // Add a class to the pane to handle min-widths, margins and animations. + if (!aPane.classList.contains("generic-toggled-side-pane")) { + aPane.classList.add("generic-toggled-side-pane"); + } + + // Avoid useless toggles. + if (aFlags.visible == !aPane.hasAttribute("pane-collapsed")) { + if (aFlags.callback) aFlags.callback(); + return; + } + + // The "animated" attributes enables animated toggles (slide in-out). + if (aFlags.animated) { + aPane.setAttribute("animated", ""); + } else { + aPane.removeAttribute("animated"); + } + + // Computes and sets the pane margins in order to hide or show it. + let doToggle = () => { + if (aFlags.visible) { + aPane.style.marginLeft = "0"; + aPane.style.marginRight = "0"; + aPane.removeAttribute("pane-collapsed"); + } else { + let margin = ~~(aPane.getAttribute("width")) + 1; + aPane.style.marginLeft = -margin + "px"; + aPane.style.marginRight = -margin + "px"; + aPane.setAttribute("pane-collapsed", ""); + } + + // Invoke the callback when the transition ended. + if (aFlags.animated) { + aPane.addEventListener("transitionend", function onEvent() { + aPane.removeEventListener("transitionend", onEvent, false); + if (aFlags.callback) aFlags.callback(); + }, false); + } + // Invoke the callback immediately since there's no transition. + else { + if (aFlags.callback) aFlags.callback(); + } + } + + // Sometimes it's useful delaying the toggle a few ticks to ensure + // a smoother slide in-out animation. + if (aFlags.delayed) { + aPane.ownerDocument.defaultView.setTimeout(doToggle, PANE_APPEARANCE_DELAY); + } else { + doToggle(); + } + } +}; + +/** + * Localization convenience methods. + * + * @param string aStringBundleName + * The desired string bundle's name. + */ +ViewHelpers.L10N = function(aStringBundleName) { + XPCOMUtils.defineLazyGetter(this, "stringBundle", () => + Services.strings.createBundle(aStringBundleName)); + + XPCOMUtils.defineLazyGetter(this, "ellipsis", () => + Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data); +}; + +ViewHelpers.L10N.prototype = { + stringBundle: null, + + /** + * L10N shortcut function. + * + * @param string aName + * @return string + */ + getStr: function(aName) { + return this.stringBundle.GetStringFromName(aName); + }, + + /** + * L10N shortcut function. + * + * @param string aName + * @param array aArgs + * @return string + */ + getFormatStr: function(aName, ...aArgs) { + return this.stringBundle.formatStringFromName(aName, aArgs, aArgs.length); + }, + + /** + * L10N shortcut function for numeric arguments that need to be formatted. + * All numeric arguments will be fixed to 2 decimals and given a localized + * decimal separator. Other arguments will be left alone. + * + * @param string aName + * @param array aArgs + * @return string + */ + getFormatStrWithNumbers: function(aName, ...aArgs) { + let newArgs = aArgs.map(x => typeof x == "number" ? this.numberWithDecimals(x, 2) : x); + return this.stringBundle.formatStringFromName(aName, newArgs, newArgs.length); + }, + + /** + * Converts a number to a locale-aware string format and keeps a certain + * number of decimals. + * + * @param number aNumber + * The number to convert. + * @param number aDecimals [optional] + * Total decimals to keep. + * @return string + * The localized number as a string. + */ + numberWithDecimals: function(aNumber, aDecimals = 0) { + // If this is an integer, don't do anything special. + if (aNumber == (aNumber | 0)) { + return aNumber; + } + // Remove {n} trailing decimals. Can't use toFixed(n) because + // toLocaleString converts the number to a string. Also can't use + // toLocaleString(, { maximumFractionDigits: n }) because it's not + // implemented on OS X (bug 368838). Gross. + let localized = aNumber.toLocaleString(); // localize + let padded = localized + new Array(aDecimals).join("0"); // pad with zeros + let match = padded.match("([^]*?\\d{" + aDecimals + "})\\d*$"); + return match.pop(); + } +}; + +/** + * Shortcuts for lazily accessing and setting various preferences. + * Usage: + * let prefs = new ViewHelpers.Prefs("root.path.to.branch", { + * myIntPref: ["Int", "leaf.path.to.my-int-pref"], + * myCharPref: ["Char", "leaf.path.to.my-char-pref"], + * ... + * }); + * + * prefs.myCharPref = "foo"; + * let aux = prefs.myCharPref; + * + * @param string aPrefsRoot + * The root path to the required preferences branch. + * @param object aPrefsObject + * An object containing { accessorName: [prefType, prefName] } keys. + */ +ViewHelpers.Prefs = function(aPrefsRoot = "", aPrefsObject = {}) { + this.root = aPrefsRoot; + + for (let accessorName in aPrefsObject) { + let [prefType, prefName] = aPrefsObject[accessorName]; + this.map(accessorName, prefType, prefName); + } +}; + +ViewHelpers.Prefs.prototype = { + /** + * Helper method for getting a pref value. + * + * @param string aType + * @param string aPrefName + * @return any + */ + _get: function(aType, aPrefName) { + if (this[aPrefName] === undefined) { + this[aPrefName] = Services.prefs["get" + aType + "Pref"](aPrefName); + } + return this[aPrefName]; + }, + + /** + * Helper method for setting a pref value. + * + * @param string aType + * @param string aPrefName + * @param any aValue + */ + _set: function(aType, aPrefName, aValue) { + Services.prefs["set" + aType + "Pref"](aPrefName, aValue); + this[aPrefName] = aValue; + }, + + /** + * Maps a property name to a pref, defining lazy getters and setters. + * Supported types are "Bool", "Char", "Int" and "Json" (which is basically + * just sugar for "Char" using the standard JSON serializer). + * + * @param string aAccessorName + * @param string aType + * @param string aPrefName + * @param array aSerializer + */ + map: function(aAccessorName, aType, aPrefName, aSerializer = { in: e => e, out: e => e }) { + if (aType == "Json") { + this.map(aAccessorName, "Char", aPrefName, { in: JSON.parse, out: JSON.stringify }); + return; + } + + Object.defineProperty(this, aAccessorName, { + get: () => aSerializer.in(this._get(aType, [this.root, aPrefName].join("."))), + set: (e) => this._set(aType, [this.root, aPrefName].join("."), aSerializer.out(e)) + }); + } +}; + +/** + * A generic Item is used to describe children present in a Widget. + * + * This is basically a very thin wrapper around an nsIDOMNode, with a few + * characteristics, like a `value` and an `attachment`. + * + * The characteristics are optional, and their meaning is entirely up to you. + * - The `value` should be a string, passed as an argument. + * - The `attachment` is any kind of primitive or object, passed as an argument. + * + * Iterable via "for (let childItem of parentItem) { }". + * + * @param object aOwnerView + * The owner view creating this item. + * @param nsIDOMNode aElement + * A prebuilt node to be wrapped. + * @param string aValue + * A string identifying the node. + * @param any aAttachment + * Some attached primitive/object. + */ +function Item(aOwnerView, aElement, aValue, aAttachment) { + this.ownerView = aOwnerView; + this.attachment = aAttachment; + this._value = aValue + ""; + this._prebuiltNode = aElement; +}; + +Item.prototype = { + get value() { return this._value; }, + get target() { return this._target; }, + + /** + * Immediately appends a child item to this item. + * + * @param nsIDOMNode aElement + * An nsIDOMNode representing the child element to append. + * @param object aOptions [optional] + * Additional options or flags supported by this operation: + * - attachment: some attached primitive/object for the item + * - attributes: a batch of attributes set to the displayed element + * - finalize: function invoked when the child item is removed + * @return Item + * The item associated with the displayed element. + */ + append: function(aElement, aOptions = {}) { + let item = new Item(this, aElement, "", aOptions.attachment); + + // Entangle the item with the newly inserted child node. + // Make sure this is done with the value returned by appendChild(), + // to avoid storing a potential DocumentFragment. + this._entangleItem(item, this._target.appendChild(aElement)); + + // Handle any additional options after entangling the item. + if (aOptions.attributes) { + aOptions.attributes.forEach(e => item._target.setAttribute(e[0], e[1])); + } + if (aOptions.finalize) { + item.finalize = aOptions.finalize; + } + + // Return the item associated with the displayed element. + return item; + }, + + /** + * Immediately removes the specified child item from this item. + * + * @param Item aItem + * The item associated with the element to remove. + */ + remove: function(aItem) { + if (!aItem) { + return; + } + this._target.removeChild(aItem._target); + this._untangleItem(aItem); + }, + + /** + * Entangles an item (model) with a displayed node element (view). + * + * @param Item aItem + * The item describing a target element. + * @param nsIDOMNode aElement + * The element displaying the item. + */ + _entangleItem: function(aItem, aElement) { + this._itemsByElement.set(aElement, aItem); + aItem._target = aElement; + }, + + /** + * Untangles an item (model) from a displayed node element (view). + * + * @param Item aItem + * The item describing a target element. + */ + _untangleItem: function(aItem) { + if (aItem.finalize) { + aItem.finalize(aItem); + } + for (let childItem of aItem) { + aItem.remove(childItem); + } + + this._unlinkItem(aItem); + aItem._target = null; + }, + + /** + * Deletes an item from the its parent's storage maps. + * + * @param Item aItem + * The item describing a target element. + */ + _unlinkItem: function(aItem) { + this._itemsByElement.delete(aItem._target); + }, + + /** + * Returns a string representing the object. + * @return string + */ + toString: function() { + return this._value + " :: " + this._target + " :: " + this.attachment; + }, + + _value: "", + _target: null, + _prebuiltNode: null, + finalize: null, + attachment: null +}; + +// Creating maps thousands of times for widgets with a large number of children +// fills up a lot of memory. Make sure these are instantiated only if needed. +DevToolsUtils.defineLazyPrototypeGetter(Item.prototype, "_itemsByElement", Map); + +/** + * Some generic Widget methods handling Item instances. + * Iterable via "for (let childItem of wrappedView) { }". + * + * Usage: + * function MyView() { + * this.widget = new MyWidget(document.querySelector(".my-node")); + * } + * + * MyView.prototype = Heritage.extend(WidgetMethods, { + * myMethod: function() {}, + * ... + * }); + * + * See https://gist.github.com/victorporof/5749386 for more details. + * The devtools/shared/widgets/SimpleListWidget.jsm is an implementation example. + * + * Language: + * - An "item" is an instance of an Item. + * - An "element" or "node" is a nsIDOMNode. + * + * The supplied widget can be any object implementing the following methods: + * - function:nsIDOMNode insertItemAt(aIndex:number, aNode:nsIDOMNode, aValue:string) + * - function:nsIDOMNode getItemAtIndex(aIndex:number) + * - function removeChild(aChild:nsIDOMNode) + * - function removeAllItems() + * - get:nsIDOMNode selectedItem() + * - set selectedItem(aChild:nsIDOMNode) + * - function getAttribute(aName:string) + * - function setAttribute(aName:string, aValue:string) + * - function removeAttribute(aName:string) + * - function addEventListener(aName:string, aCallback:function, aBubbleFlag:boolean) + * - function removeEventListener(aName:string, aCallback:function, aBubbleFlag:boolean) + * + * Optional methods that can be implemented by the widget: + * - function ensureElementIsVisible(aChild:nsIDOMNode) + * + * Optional attributes that may be handled (when calling get/set/removeAttribute): + * - "emptyText": label temporarily added when there are no items present + * - "headerText": label permanently added as a header + * + * For automagical keyboard and mouse accessibility, the widget should be an + * event emitter with the following events: + * - "keyPress" -> (aName:string, aEvent:KeyboardEvent) + * - "mousePress" -> (aName:string, aEvent:MouseEvent) + */ +this.WidgetMethods = { + /** + * Sets the element node or widget associated with this container. + * @param nsIDOMNode | object aWidget + */ + set widget(aWidget) { + this._widget = aWidget; + + + // Can't use a WeakMap for _itemsByValue because keys are strings, and + // can't use one for _itemsByElement either, since it needs to be iterable. + XPCOMUtils.defineLazyGetter(this, "_itemsByValue", () => new Map()); + XPCOMUtils.defineLazyGetter(this, "_itemsByElement", () => new Map()); + XPCOMUtils.defineLazyGetter(this, "_stagedItems", () => []); + + // Handle internal events emitted by the widget if necessary. + if (ViewHelpers.isEventEmitter(aWidget)) { + aWidget.on("keyPress", this._onWidgetKeyPress.bind(this)); + aWidget.on("mousePress", this._onWidgetMousePress.bind(this)); + } + }, + + /** + * Gets the element node or widget associated with this container. + * @return nsIDOMNode | object + */ + get widget() this._widget, + + /** + * Prepares an item to be added to this container. This allows, for example, + * for a large number of items to be batched up before being sorted & added. + * + * If the "staged" flag is *not* set to true, the item will be immediately + * inserted at the correct position in this container, so that all the items + * still remain sorted. This can (possibly) be much slower than batching up + * multiple items. + * + * By default, this container assumes that all the items should be displayed + * sorted by their value. This can be overridden with the "index" flag, + * specifying on which position should an item be appended. The "staged" and + * "index" flags are mutually exclusive, meaning that all staged items + * will always be appended. + * + * @param nsIDOMNode aElement + * A prebuilt node to be wrapped. + * @param string aValue + * A string identifying the node. + * @param object aOptions [optional] + * Additional options or flags supported by this operation: + * - attachment: some attached primitive/object for the item + * - staged: true to stage the item to be appended later + * - index: specifies on which position should the item be appended + * - attributes: a batch of attributes set to the displayed element + * - finalize: function invoked when the item is removed + * @return Item + * The item associated with the displayed element if an unstaged push, + * undefined if the item was staged for a later commit. + */ + push: function([aElement, aValue], aOptions = {}) { + let item = new Item(this, aElement, aValue, aOptions.attachment); + + // Batch the item to be added later. + if (aOptions.staged) { + // An ulterior commit operation will ignore any specified index, so + // no reason to keep it around. + aOptions.index = undefined; + return void this._stagedItems.push({ item: item, options: aOptions }); + } + // Find the target position in this container and insert the item there. + if (!("index" in aOptions)) { + return this._insertItemAt(this._findExpectedIndexFor(item), item, aOptions); + } + // Insert the item at the specified index. If negative or out of bounds, + // the item will be simply appended. + return this._insertItemAt(aOptions.index, item, aOptions); + }, + + /** + * Flushes all the prepared items into this container. + * Any specified index on the items will be ignored. Everything is appended. + * + * @param object aOptions [optional] + * Additional options or flags supported by this operation: + * - sorted: true to sort all the items before adding them + */ + commit: function(aOptions = {}) { + let stagedItems = this._stagedItems; + + // Sort the items before adding them to this container, if preferred. + if (aOptions.sorted) { + stagedItems.sort((a, b) => this._currentSortPredicate(a.item, b.item)); + } + // Append the prepared items to this container. + for (let { item, options } of stagedItems) { + this._insertItemAt(-1, item, options); + } + // Recreate the temporary items list for ulterior pushes. + this._stagedItems.length = 0; + }, + + /** + * Immediately removes the specified item from this container. + * + * @param Item aItem + * The item associated with the element to remove. + */ + remove: function(aItem) { + if (!aItem) { + return; + } + this._widget.removeChild(aItem._target); + this._untangleItem(aItem); + + if (!this._itemsByElement.size) { + this._preferredValue = this.selectedValue; + this._widget.selectedItem = null; + this._widget.setAttribute("emptyText", this._emptyText); + } + }, + + /** + * Removes the item at the specified index from this container. + * + * @param number aIndex + * The index of the item to remove. + */ + removeAt: function(aIndex) { + this.remove(this.getItemAtIndex(aIndex)); + }, + + /** + * Removes all items from this container. + */ + empty: function() { + this._preferredValue = this.selectedValue; + this._widget.selectedItem = null; + this._widget.removeAllItems(); + this._widget.setAttribute("emptyText", this._emptyText); + + for (let [, item] of this._itemsByElement) { + this._untangleItem(item); + } + + this._itemsByValue.clear(); + this._itemsByElement.clear(); + this._stagedItems.length = 0; + }, + + /** + * Ensures the specified item is visible in this container. + * + * @param Item aItem + * The item to bring into view. + */ + ensureItemIsVisible: function(aItem) { + this._widget.ensureElementIsVisible(aItem._target); + }, + + /** + * Ensures the item at the specified index is visible in this container. + * + * @param number aIndex + * The index of the item to bring into view. + */ + ensureIndexIsVisible: function(aIndex) { + this.ensureItemIsVisible(this.getItemAtIndex(aIndex)); + }, + + /** + * Sugar for ensuring the selected item is visible in this container. + */ + ensureSelectedItemIsVisible: function() { + this.ensureItemIsVisible(this.selectedItem); + }, + + /** + * If supported by the widget, the label string temporarily added to this + * container when there are no child items present. + */ + set emptyText(aValue) { + this._emptyText = aValue; + + // Apply the emptyText attribute right now if there are no child items. + if (!this._itemsByElement.size) { + this._widget.setAttribute("emptyText", aValue); + } + }, + + /** + * If supported by the widget, the label string permanently added to this + * container as a header. + * @param string aValue + */ + set headerText(aValue) { + this._headerText = aValue; + this._widget.setAttribute("headerText", aValue); + }, + + /** + * Toggles all the items in this container hidden or visible. + * + * This does not change the default filtering predicate, so newly inserted + * items will always be visible. Use WidgetMethods.filterContents if you care. + * + * @param boolean aVisibleFlag + * Specifies the intended visibility. + */ + toggleContents: function(aVisibleFlag) { + for (let [element, item] of this._itemsByElement) { + element.hidden = !aVisibleFlag; + } + }, + + /** + * Toggles all items in this container hidden or visible based on a predicate. + * + * @param function aPredicate [optional] + * Items are toggled according to the return value of this function, + * which will become the new default filtering predicate in this container. + * If unspecified, all items will be toggled visible. + */ + filterContents: function(aPredicate = this._currentFilterPredicate) { + this._currentFilterPredicate = aPredicate; + + for (let [element, item] of this._itemsByElement) { + element.hidden = !aPredicate(item); + } + }, + + /** + * Sorts all the items in this container based on a predicate. + * + * @param function aPredicate [optional] + * Items are sorted according to the return value of the function, + * which will become the new default sorting predicate in this container. + * If unspecified, all items will be sorted by their value. + */ + sortContents: function(aPredicate = this._currentSortPredicate) { + let sortedItems = this.items.sort(this._currentSortPredicate = aPredicate); + + for (let i = 0, len = sortedItems.length; i < len; i++) { + this.swapItems(this.getItemAtIndex(i), sortedItems[i]); + } + }, + + /** + * Visually swaps two items in this container. + * + * @param Item aFirst + * The first item to be swapped. + * @param Item aSecond + * The second item to be swapped. + */ + swapItems: function(aFirst, aSecond) { + if (aFirst == aSecond) { // We're just dandy, thank you. + return; + } + let { _prebuiltNode: firstPrebuiltTarget, _target: firstTarget } = aFirst; + let { _prebuiltNode: secondPrebuiltTarget, _target: secondTarget } = aSecond; + + // If the two items were constructed with prebuilt nodes as DocumentFragments, + // then those DocumentFragments are now empty and need to be reassembled. + if (firstPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) { + for (let node of firstTarget.childNodes) { + firstPrebuiltTarget.appendChild(node.cloneNode(true)); + } + } + if (secondPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) { + for (let node of secondTarget.childNodes) { + secondPrebuiltTarget.appendChild(node.cloneNode(true)); + } + } + + // 1. Get the indices of the two items to swap. + let i = this._indexOfElement(firstTarget); + let j = this._indexOfElement(secondTarget); + + // 2. Remeber the selection index, to reselect an item, if necessary. + let selectedTarget = this._widget.selectedItem; + let selectedIndex = -1; + if (selectedTarget == firstTarget) { + selectedIndex = i; + } else if (selectedTarget == secondTarget) { + selectedIndex = j; + } + + // 3. Silently nuke both items, nobody needs to know about this. + this._widget.removeChild(firstTarget); + this._widget.removeChild(secondTarget); + this._unlinkItem(aFirst); + this._unlinkItem(aSecond); + + // 4. Add the items again, but reversing their indices. + this._insertItemAt.apply(this, i < j ? [i, aSecond] : [j, aFirst]); + this._insertItemAt.apply(this, i < j ? [j, aFirst] : [i, aSecond]); + + // 5. Restore the previous selection, if necessary. + if (selectedIndex == i) { + this._widget.selectedItem = aFirst._target; + } else if (selectedIndex == j) { + this._widget.selectedItem = aSecond._target; + } + + // 6. Let the outside world know that these two items were swapped. + ViewHelpers.dispatchEvent(aFirst.target, "swap", [aSecond, aFirst]); + }, + + /** + * Visually swaps two items in this container at specific indices. + * + * @param number aFirst + * The index of the first item to be swapped. + * @param number aSecond + * The index of the second item to be swapped. + */ + swapItemsAtIndices: function(aFirst, aSecond) { + this.swapItems(this.getItemAtIndex(aFirst), this.getItemAtIndex(aSecond)); + }, + + /** + * Checks whether an item with the specified value is among the elements + * shown in this container. + * + * @param string aValue + * The item's value. + * @return boolean + * True if the value is known, false otherwise. + */ + containsValue: function(aValue) { + return this._itemsByValue.has(aValue) || + this._stagedItems.some(({ item }) => item._value == aValue); + }, + + /** + * Gets the "preferred value". This is the latest selected item's value, + * remembered just before emptying this container. + * @return string + */ + get preferredValue() { + return this._preferredValue; + }, + + /** + * Retrieves the item associated with the selected element. + * @return Item | null + */ + get selectedItem() { + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + return this._itemsByElement.get(selectedElement); + } + return null; + }, + + /** + * Retrieves the selected element's index in this container. + * @return number + */ + get selectedIndex() { + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + return this._indexOfElement(selectedElement); + } + return -1; + }, + + /** + * Retrieves the value of the selected element. + * @return string + */ + get selectedValue() { + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + return this._itemsByElement.get(selectedElement)._value; + } + return ""; + }, + + /** + * Retrieves the attachment of the selected element. + * @return object | null + */ + get selectedAttachment() { + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + return this._itemsByElement.get(selectedElement).attachment; + } + return null; + }, + + /** + * Selects the element with the entangled item in this container. + * @param Item | function aItem + */ + set selectedItem(aItem) { + // A predicate is allowed to select a specific item. + // If no item is matched, then the current selection is removed. + if (typeof aItem == "function") { + aItem = this.getItemForPredicate(aItem); + } + + // A falsy item is allowed to invalidate the current selection. + let targetElement = aItem ? aItem._target : null; + let prevElement = this._widget.selectedItem; + + // Make sure the selected item's target element is focused and visible. + if (this.autoFocusOnSelection && targetElement) { + targetElement.focus(); + } + if (this.maintainSelectionVisible && targetElement) { + if ("ensureElementIsVisible" in this._widget) { + this._widget.ensureElementIsVisible(targetElement); + } + } + + // Prevent selecting the same item again and avoid dispatching + // a redundant selection event, so return early. + if (targetElement != prevElement) { + this._widget.selectedItem = targetElement; + let dispTarget = targetElement || prevElement; + let dispName = this.suppressSelectionEvents ? "suppressed-select" : "select"; + ViewHelpers.dispatchEvent(dispTarget, dispName, aItem); + } + }, + + /** + * Selects the element at the specified index in this container. + * @param number aIndex + */ + set selectedIndex(aIndex) { + let targetElement = this._widget.getItemAtIndex(aIndex); + if (targetElement) { + this.selectedItem = this._itemsByElement.get(targetElement); + return; + } + this.selectedItem = null; + }, + + /** + * Selects the element with the specified value in this container. + * @param string aValue + */ + set selectedValue(aValue) { + this.selectedItem = this._itemsByValue.get(aValue); + }, + + /** + * Specifies if this container should try to keep the selected item visible. + * (For example, when new items are added the selection is brought into view). + */ + maintainSelectionVisible: true, + + /** + * Specifies if "select" events dispatched from the elements in this container + * when their respective items are selected should be suppressed or not. + * + * If this flag is set to true, then consumers of this container won't + * be normally notified when items are selected. + */ + suppressSelectionEvents: false, + + /** + * Focus this container the first time an element is inserted? + * + * If this flag is set to true, then when the first item is inserted in + * this container (and thus it's the only item available), its corresponding + * target element is focused as well. + */ + autoFocusOnFirstItem: true, + + /** + * Focus on selection? + * + * If this flag is set to true, then whenever an item is selected in + * this container (e.g. via the selectedIndex or selectedItem setters), + * its corresponding target element is focused as well. + * + * You can disable this flag, for example, to maintain a certain node + * focused but visually indicate a different selection in this container. + */ + autoFocusOnSelection: true, + + /** + * Focus on input (e.g. mouse click)? + * + * If this flag is set to true, then whenever an item receives user input in + * this container, its corresponding target element is focused as well. + */ + autoFocusOnInput: true, + + /** + * When focusing on input, allow right clicks? + * @see WidgetMethods.autoFocusOnInput + */ + allowFocusOnRightClick: false, + + /** + * The number of elements in this container to jump when Page Up or Page Down + * keys are pressed. If falsy, then the page size will be based on the + * number of visible items in the container. + */ + pageSize: 0, + + /** + * Focuses the first visible item in this container. + */ + focusFirstVisibleItem: function() { + this.focusItemAtDelta(-this.itemCount); + }, + + /** + * Focuses the last visible item in this container. + */ + focusLastVisibleItem: function() { + this.focusItemAtDelta(+this.itemCount); + }, + + /** + * Focuses the next item in this container. + */ + focusNextItem: function() { + this.focusItemAtDelta(+1); + }, + + /** + * Focuses the previous item in this container. + */ + focusPrevItem: function() { + this.focusItemAtDelta(-1); + }, + + /** + * Focuses another item in this container based on the index distance + * from the currently focused item. + * + * @param number aDelta + * A scalar specifying by how many items should the selection change. + */ + focusItemAtDelta: function(aDelta) { + // Make sure the currently selected item is also focused, so that the + // command dispatcher mechanism has a relative node to work with. + // If there's no selection, just select an item at a corresponding index + // (e.g. the first item in this container if aDelta <= 1). + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + selectedElement.focus(); + } else { + this.selectedIndex = Math.max(0, aDelta - 1); + return; + } + + let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus"; + let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta)); + while (distance--) { + if (!this._focusChange(direction)) { + break; // Out of bounds. + } + } + + // Synchronize the selected item as being the currently focused element. + this.selectedItem = this.getItemForElement(this._focusedElement); + }, + + /** + * Focuses the next or previous item in this container. + * + * @param string aDirection + * Either "advanceFocus" or "rewindFocus". + * @return boolean + * False if the focus went out of bounds and the first or last item + * in this container was focused instead. + */ + _focusChange: function(aDirection) { + let commandDispatcher = this._commandDispatcher; + let prevFocusedElement = commandDispatcher.focusedElement; + let currFocusedElement; + + do { + commandDispatcher.suppressFocusScroll = true; + commandDispatcher[aDirection](); + currFocusedElement = commandDispatcher.focusedElement; + + // Make sure the newly focused item is a part of this container. If the + // focus goes out of bounds, revert the previously focused item. + if (!this.getItemForElement(currFocusedElement)) { + prevFocusedElement.focus(); + return false; + } + } while (!WIDGET_FOCUSABLE_NODES.has(currFocusedElement.tagName)); + + // Focus remained within bounds. + return true; + }, + + /** + * Gets the command dispatcher instance associated with this container's DOM. + * If there are no items displayed in this container, null is returned. + * @return nsIDOMXULCommandDispatcher | null + */ + get _commandDispatcher() { + if (this._cachedCommandDispatcher) { + return this._cachedCommandDispatcher; + } + let someElement = this._widget.getItemAtIndex(0); + if (someElement) { + let commandDispatcher = someElement.ownerDocument.commandDispatcher; + return this._cachedCommandDispatcher = commandDispatcher; + } + return null; + }, + + /** + * Gets the currently focused element in this container. + * + * @return nsIDOMNode + * The focused element, or null if nothing is found. + */ + get _focusedElement() { + let commandDispatcher = this._commandDispatcher; + if (commandDispatcher) { + return commandDispatcher.focusedElement; + } + return null; + }, + + /** + * Gets the item in the container having the specified index. + * + * @param number aIndex + * The index used to identify the element. + * @return Item + * The matched item, or null if nothing is found. + */ + getItemAtIndex: function(aIndex) { + return this.getItemForElement(this._widget.getItemAtIndex(aIndex)); + }, + + /** + * Gets the item in the container having the specified value. + * + * @param string aValue + * The value used to identify the element. + * @return Item + * The matched item, or null if nothing is found. + */ + getItemByValue: function(aValue) { + return this._itemsByValue.get(aValue); + }, + + /** + * Gets the item in the container associated with the specified element. + * + * @param nsIDOMNode aElement + * The element used to identify the item. + * @param object aFlags [optional] + * Additional options for showing the source. Supported options: + * - noSiblings: if siblings shouldn't be taken into consideration + * when searching for the associated item. + * @return Item + * The matched item, or null if nothing is found. + */ + getItemForElement: function(aElement, aFlags = {}) { + while (aElement) { + let item = this._itemsByElement.get(aElement); + + // Also search the siblings if allowed. + if (!aFlags.noSiblings) { + item = item || + this._itemsByElement.get(aElement.nextElementSibling) || + this._itemsByElement.get(aElement.previousElementSibling); + } + if (item) { + return item; + } + aElement = aElement.parentNode; + } + return null; + }, + + /** + * Gets a visible item in this container validating a specified predicate. + * + * @param function aPredicate + * The first item which validates this predicate is returned + * @return Item + * The matched item, or null if nothing is found. + */ + getItemForPredicate: function(aPredicate, aOwner = this) { + // Recursively check the items in this widget for a predicate match. + for (let [element, item] of aOwner._itemsByElement) { + let match; + if (aPredicate(item) && !element.hidden) { + match = item; + } else { + match = this.getItemForPredicate(aPredicate, item); + } + if (match) { + return match; + } + } + // Also check the staged items. No need to do this recursively since + // they're not even appended to the view yet. + for (let { item } of this._stagedItems) { + if (aPredicate(item)) { + return item; + } + } + return null; + }, + + /** + * Shortcut function for getItemForPredicate which works on item attachments. + * @see getItemForPredicate + */ + getItemForAttachment: function(aPredicate, aOwner = this) { + return this.getItemForPredicate(e => aPredicate(e.attachment)); + }, + + /** + * Finds the index of an item in the container. + * + * @param Item aItem + * The item get the index for. + * @return number + * The index of the matched item, or -1 if nothing is found. + */ + indexOfItem: function(aItem) { + return this._indexOfElement(aItem._target); + }, + + /** + * Finds the index of an element in the container. + * + * @param nsIDOMNode aElement + * The element get the index for. + * @return number + * The index of the matched element, or -1 if nothing is found. + */ + _indexOfElement: function(aElement) { + for (let i = 0; i < this._itemsByElement.size; i++) { + if (this._widget.getItemAtIndex(i) == aElement) { + return i; + } + } + return -1; + }, + + /** + * Gets the total number of items in this container. + * @return number + */ + get itemCount() { + return this._itemsByElement.size; + }, + + /** + * Returns a list of items in this container, in the displayed order. + * @return array + */ + get items() { + let store = []; + let itemCount = this.itemCount; + for (let i = 0; i < itemCount; i++) { + store.push(this.getItemAtIndex(i)); + } + return store; + }, + + /** + * Returns a list of values in this container, in the displayed order. + * @return array + */ + get values() { + return this.items.map(e => e._value); + }, + + /** + * Returns a list of attachments in this container, in the displayed order. + * @return array + */ + get attachments() { + return this.items.map(e => e.attachment); + }, + + /** + * Returns a list of all the visible (non-hidden) items in this container, + * in the displayed order + * @return array + */ + get visibleItems() { + return this.items.filter(e => !e._target.hidden); + }, + + /** + * Checks if an item is unique in this container. If an item's value is an + * empty string, "undefined" or "null", it is considered unique. + * + * @param Item aItem + * The item for which to verify uniqueness. + * @return boolean + * True if the item is unique, false otherwise. + */ + isUnique: function(aItem) { + let value = aItem._value; + if (value == "" || value == "undefined" || value == "null") { + return true; + } + return !this._itemsByValue.has(value); + }, + + /** + * Checks if an item is eligible for this container. By default, this checks + * whether an item is unique and has a prebuilt target node. + * + * @param Item aItem + * The item for which to verify eligibility. + * @return boolean + * True if the item is eligible, false otherwise. + */ + isEligible: function(aItem) { + return this.isUnique(aItem) && aItem._prebuiltNode; + }, + + /** + * Finds the expected item index in this container based on the default + * sort predicate. + * + * @param Item aItem + * The item for which to get the expected index. + * @return number + * The expected item index. + */ + _findExpectedIndexFor: function(aItem) { + let itemCount = this.itemCount; + for (let i = 0; i < itemCount; i++) { + if (this._currentSortPredicate(this.getItemAtIndex(i), aItem) > 0) { + return i; + } + } + return itemCount; + }, + + /** + * Immediately inserts an item in this container at the specified index. + * + * @param number aIndex + * The position in the container intended for this item. + * @param Item aItem + * The item describing a target element. + * @param object aOptions [optional] + * Additional options or flags supported by this operation: + * - attributes: a batch of attributes set to the displayed element + * - finalize: function when the item is untangled (removed) + * @return Item + * The item associated with the displayed element, null if rejected. + */ + _insertItemAt: function(aIndex, aItem, aOptions = {}) { + if (!this.isEligible(aItem)) { + return null; + } + + // Entangle the item with the newly inserted node. + // Make sure this is done with the value returned by insertItemAt(), + // to avoid storing a potential DocumentFragment. + let node = aItem._prebuiltNode; + let attachment = aItem.attachment; + this._entangleItem(aItem, this._widget.insertItemAt(aIndex, node, attachment)); + + // Handle any additional options after entangling the item. + if (!this._currentFilterPredicate(aItem)) { + aItem._target.hidden = true; + } + if (this.autoFocusOnFirstItem && this._itemsByElement.size == 1) { + aItem._target.focus(); + } + if (aOptions.attributes) { + aOptions.attributes.forEach(e => aItem._target.setAttribute(e[0], e[1])); + } + if (aOptions.finalize) { + aItem.finalize = aOptions.finalize; + } + + // Hide the empty text if the selection wasn't lost. + this._widget.removeAttribute("emptyText"); + + // Return the item associated with the displayed element. + return aItem; + }, + + /** + * Entangles an item (model) with a displayed node element (view). + * + * @param Item aItem + * The item describing a target element. + * @param nsIDOMNode aElement + * The element displaying the item. + */ + _entangleItem: function(aItem, aElement) { + this._itemsByValue.set(aItem._value, aItem); + this._itemsByElement.set(aElement, aItem); + aItem._target = aElement; + }, + + /** + * Untangles an item (model) from a displayed node element (view). + * + * @param Item aItem + * The item describing a target element. + */ + _untangleItem: function(aItem) { + if (aItem.finalize) { + aItem.finalize(aItem); + } + for (let childItem of aItem) { + aItem.remove(childItem); + } + + this._unlinkItem(aItem); + aItem._target = null; + }, + + /** + * Deletes an item from the its parent's storage maps. + * + * @param Item aItem + * The item describing a target element. + */ + _unlinkItem: function(aItem) { + this._itemsByValue.delete(aItem._value); + this._itemsByElement.delete(aItem._target); + }, + + /** + * The keyPress event listener for this container. + * @param string aName + * @param KeyboardEvent aEvent + */ + _onWidgetKeyPress: function(aName, aEvent) { + // Prevent scrolling when pressing navigation keys. + ViewHelpers.preventScrolling(aEvent); + + switch (aEvent.keyCode) { + case aEvent.DOM_VK_UP: + case aEvent.DOM_VK_LEFT: + this.focusPrevItem(); + return; + case aEvent.DOM_VK_DOWN: + case aEvent.DOM_VK_RIGHT: + this.focusNextItem(); + return; + case aEvent.DOM_VK_PAGE_UP: + this.focusItemAtDelta(-(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO))); + return; + case aEvent.DOM_VK_PAGE_DOWN: + this.focusItemAtDelta(+(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO))); + return; + case aEvent.DOM_VK_HOME: + this.focusFirstVisibleItem(); + return; + case aEvent.DOM_VK_END: + this.focusLastVisibleItem(); + return; + } + }, + + /** + * The mousePress event listener for this container. + * @param string aName + * @param MouseEvent aEvent + */ + _onWidgetMousePress: function(aName, aEvent) { + if (aEvent.button != 0 && !this.allowFocusOnRightClick) { + // Only allow left-click to trigger this event. + return; + } + + let item = this.getItemForElement(aEvent.target); + if (item) { + // The container is not empty and we clicked on an actual item. + this.selectedItem = item; + // Make sure the current event's target element is also focused. + this.autoFocusOnInput && item._target.focus(); + } + }, + + /** + * The predicate used when filtering items. By default, all items in this + * view are visible. + * + * @param Item aItem + * The item passing through the filter. + * @return boolean + * True if the item should be visible, false otherwise. + */ + _currentFilterPredicate: function(aItem) { + return true; + }, + + /** + * The predicate used when sorting items. By default, items in this view + * are sorted by their label. + * + * @param Item aFirst + * The first item used in the comparison. + * @param Item aSecond + * The second item used in the comparison. + * @return number + * -1 to sort aFirst to a lower index than aSecond + * 0 to leave aFirst and aSecond unchanged with respect to each other + * 1 to sort aSecond to a lower index than aFirst + */ + _currentSortPredicate: function(aFirst, aSecond) { + return +(aFirst._value.toLowerCase() > aSecond._value.toLowerCase()); + }, + + /** + * Call a method on this widget named `aMethodName`. Any further arguments are + * passed on to the method. Returns the result of the method call. + * + * @param String aMethodName + * The name of the method you want to call. + * @param aArgs + * Optional. Any arguments you want to pass through to the method. + */ + callMethod: function(aMethodName, ...aArgs) { + return this._widget[aMethodName].apply(this._widget, aArgs); + }, + + _widget: null, + _emptyText: "", + _headerText: "", + _preferredValue: "", + _cachedCommandDispatcher: null +}; + +/** + * A generator-iterator over all the items in this container. + */ +Item.prototype["@@iterator"] = +WidgetMethods["@@iterator"] = function*() { + yield* this._itemsByElement.values(); +};