<?xml version="1.0"?>

<!-- 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/. -->

<bindings id="richlistboxBindings"
          xmlns="http://www.mozilla.org/xbl"
          xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
          xmlns:xbl="http://www.mozilla.org/xbl">

  <binding id="richlistbox"
           extends="chrome://global/content/bindings/listbox.xml#listbox-base">
    <resources>
      <stylesheet src="chrome://global/skin/richlistbox.css"/>
    </resources>

    <content>
      <children includes="listheader"/>
      <xul:scrollbox allowevents="true" orient="vertical" anonid="main-box"
                     flex="1" style="overflow: auto;" xbl:inherits="dir,pack">
        <children/>
      </xul:scrollbox>
    </content>

    <implementation>
      <field name="_scrollbox">
        document.getAnonymousElementByAttribute(this, "anonid", "main-box");
      </field>
      <field name="scrollBoxObject">
        this._scrollbox.boxObject;
      </field>
      <constructor>
        <![CDATA[
          // add a template build listener
          if (this.builder)
            this.builder.addListener(this._builderListener);
          else
            this._refreshSelection();
        ]]>
      </constructor>

      <destructor>
        <![CDATA[
          // remove the template build listener
          if (this.builder)
            this.builder.removeListener(this._builderListener);
        ]]>
      </destructor>

    <!-- Overriding baselistbox -->
      <method name="_fireOnSelect">
        <body>
          <![CDATA[
            // make sure not to modify last-selected when suppressing select events
            // (otherwise we'll lose the selection when a template gets rebuilt)
            if (this._suppressOnSelect || this.suppressOnSelect)
              return;

            // remember the current item and all selected items with IDs
            var state = this.currentItem ? this.currentItem.id : "";
            if (this.selType == "multiple" && this.selectedCount) {
              let getId = function getId(aItem) { return aItem.id; }
              state += " " + [... this.selectedItems].filter(getId).map(getId).join(" ");
            }
            if (state)
              this.setAttribute("last-selected", state);
            else
              this.removeAttribute("last-selected");

            // preserve the index just in case no IDs are available
            if (this.currentIndex > -1)
              this._currentIndex = this.currentIndex + 1;

            var event = document.createEvent("Events");
            event.initEvent("select", true, true);
            this.dispatchEvent(event);

            // always call this (allows a commandupdater without controller)
            document.commandDispatcher.updateCommands("richlistbox-select");
          ]]>
        </body>
      </method>

      <!-- We override base-listbox here because those methods don't take dir
           into account on listbox (which doesn't support dir yet) -->
      <method name="getNextItem">
        <parameter name="aStartItem"/>
        <parameter name="aDelta"/>
        <body>
        <![CDATA[
          var prop = this.dir == "reverse" && this._mayReverse ?
                                                "previousSibling" :
                                                "nextSibling";
          while (aStartItem) {
            aStartItem = aStartItem[prop];
            if (aStartItem && aStartItem instanceof
                Components.interfaces.nsIDOMXULSelectControlItemElement &&
                (!this._userSelecting || this._canUserSelect(aStartItem))) {
              --aDelta;
              if (aDelta == 0)
                return aStartItem;
            }
          }
          return null;
        ]]></body>
      </method>

      <method name="getPreviousItem">
        <parameter name="aStartItem"/>
        <parameter name="aDelta"/>
        <body>
        <![CDATA[
          var prop = this.dir == "reverse" && this._mayReverse ?
                                                "nextSibling" :
                                                "previousSibling";
          while (aStartItem) {
            aStartItem = aStartItem[prop];
            if (aStartItem && aStartItem instanceof
                Components.interfaces.nsIDOMXULSelectControlItemElement &&
                (!this._userSelecting || this._canUserSelect(aStartItem))) {
              --aDelta;
              if (aDelta == 0)
                return aStartItem;
            }
          }
          return null;
        ]]>
        </body>
      </method>

      <method name="appendItem">
        <parameter name="aLabel"/>
        <parameter name="aValue"/>
        <body>
          return this.insertItemAt(-1, aLabel, aValue);
        </body>
      </method>

      <method name="insertItemAt">
        <parameter name="aIndex"/>
        <parameter name="aLabel"/>
        <parameter name="aValue"/>
        <body>
          const XULNS =
            "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";

          var item =
            this.ownerDocument.createElementNS(XULNS, "richlistitem");
          item.setAttribute("value", aValue);

          var label = this.ownerDocument.createElementNS(XULNS, "label");
          label.setAttribute("value", aLabel);
          label.setAttribute("flex", "1");
          label.setAttribute("crop", "end");
          item.appendChild(label);

          var before = this.getItemAtIndex(aIndex);
          if (!before)
            this.appendChild(item);
          else
            this.insertBefore(item, before);

          return item;
        </body>
      </method>

      <property name="itemCount" readonly="true"
                onget="return this.children.length"/>

      <method name="getIndexOfItem">
        <parameter name="aItem"/>
        <body>
          <![CDATA[
            // don't search the children, if we're looking for none of them
            if (aItem == null)
              return -1;

            return this.children.indexOf(aItem);
          ]]>
        </body>
      </method>

      <method name="getItemAtIndex">
        <parameter name="aIndex"/>
        <body>
          return this.children[aIndex] || null;
        </body>
      </method>

      <method name="ensureIndexIsVisible">
        <parameter name="aIndex"/>
        <body>
          <![CDATA[
            // work around missing implementation in scrollBoxObject
            return this.ensureElementIsVisible(this.getItemAtIndex(aIndex));
          ]]>
        </body>
      </method>

      <method name="ensureElementIsVisible">
        <parameter name="aElement"/>
        <body>
          <![CDATA[
            if (!aElement)
              return;
            var targetRect = aElement.getBoundingClientRect();
            var scrollRect = this._scrollbox.getBoundingClientRect();
            var offset = targetRect.top - scrollRect.top;
            if (offset >= 0) {
              // scrollRect.bottom wouldn't take a horizontal scroll bar into account
              let scrollRectBottom = scrollRect.top + this._scrollbox.clientHeight;
              offset = targetRect.bottom - scrollRectBottom;
              if (offset <= 0)
                return;
            }
            this._scrollbox.scrollTop += offset;
          ]]>
        </body>
      </method>

      <method name="scrollToIndex">
        <parameter name="aIndex"/>
        <body>
          <![CDATA[
            var item = this.getItemAtIndex(aIndex);
            if (item)
              this.scrollBoxObject.scrollToElement(item);
          ]]>
        </body>
      </method>

      <method name="getNumberOfVisibleRows">
        <!-- returns the number of currently visible rows                -->
        <!-- don't rely on this function, if the items' height can vary! -->
        <body>
          <![CDATA[
            var children = this.children;

            for (var top = 0; top < children.length && !this._isItemVisible(children[top]); top++);
            for (var ix = top; ix < children.length && this._isItemVisible(children[ix]); ix++);

            return ix - top;
          ]]>
        </body>
      </method>

      <method name="getIndexOfFirstVisibleRow">
        <body>
          <![CDATA[
            var children = this.children;

            for (var ix = 0; ix < children.length; ix++)
              if (this._isItemVisible(children[ix]))
                return ix;

            return -1;
          ]]>
        </body>
      </method>

      <method name="getRowCount">
        <body>
          <![CDATA[
            return this.children.length;
          ]]>
        </body>
      </method>

      <method name="scrollOnePage">
        <parameter name="aDirection"/> <!-- Must be -1 or 1 -->
        <body>
          <![CDATA[
            var children = this.children;

            if (children.length == 0)
              return 0;

            // If nothing is selected, we just select the first element
            // at the extreme we're moving away from
            if (!this.currentItem)
              return aDirection == -1 ? children.length : 0;

            // If the current item is visible, scroll by one page so that
            // the new current item is at approximately the same position as
            // the existing current item.
            if (this._isItemVisible(this.currentItem))
              this.scrollBoxObject.scrollBy(0, this.scrollBoxObject.height * aDirection);

            // Figure out, how many items fully fit into the view port
            // (including the currently selected one), and determine
            // the index of the first one lying (partially) outside
            var height = this.scrollBoxObject.height;
            var startBorder = this.currentItem.boxObject.y;
            if (aDirection == -1)
              startBorder += this.currentItem.boxObject.height;

            var index = this.currentIndex;
            for (var ix = index; 0 <= ix && ix < children.length; ix += aDirection) {
              var boxObject = children[ix].boxObject;
              if (boxObject.height == 0)
                continue; // hidden children have a y of 0
              var endBorder = boxObject.y + (aDirection == -1 ? boxObject.height : 0);
              if ((endBorder - startBorder) * aDirection > height)
                break; // we've reached the desired distance
              index = ix;
            }

            return index != this.currentIndex ? index - this.currentIndex : aDirection;
          ]]>
        </body>
      </method>

    <!-- richlistbox specific -->
      <property name="children" readonly="true">
        <getter>
          <![CDATA[
            var childNodes = [];
            var isReverse = this.dir == "reverse" && this._mayReverse;
            var child = isReverse ? this.lastChild : this.firstChild;
            var prop = isReverse ? "previousSibling" : "nextSibling";
            while (child) {
              if (child instanceof Components.interfaces.nsIDOMXULSelectControlItemElement)
                childNodes.push(child);
              child = child[prop];
            }
            return childNodes;
          ]]>
        </getter>
      </property>

      <field name="_builderListener" readonly="true">
        <![CDATA[
          ({
            mOuter: this,
            item: null,
            willRebuild: function(builder) { },
            didRebuild: function(builder) {
              this.mOuter._refreshSelection();
            }
          });
        ]]>
      </field>

      <method name="_refreshSelection">
        <body>
          <![CDATA[
            // when this method is called, we know that either the currentItem
            // and selectedItems we have are null (ctor) or a reference to an
            // element no longer in the DOM (template).

            // first look for the last-selected attribute
            var state = this.getAttribute("last-selected");
            if (state) {
              var ids = state.split(" ");

              var suppressSelect = this._suppressOnSelect;
              this._suppressOnSelect = true;
              this.clearSelection();
              for (let i = 1; i < ids.length; i++) {
                var selectedItem = document.getElementById(ids[i]);
                if (selectedItem)
                  this.addItemToSelection(selectedItem);
              }

              var currentItem = document.getElementById(ids[0]);
              if (!currentItem && this._currentIndex)
                currentItem = this.getItemAtIndex(Math.min(
                  this._currentIndex - 1, this.getRowCount()));
              if (currentItem) {
                this.currentItem = currentItem;
                if (this.selType != "multiple" && this.selectedCount == 0)
                  this.selectedItem = currentItem;

                if (this.scrollBoxObject.height) {
                  this.ensureElementIsVisible(currentItem);
                }
                else {
                  // XXX hack around a bug in ensureElementIsVisible as it will
                  // scroll beyond the last element, bug 493645.
                  var previousElement = this.dir == "reverse" ? currentItem.nextSibling :
                                                                currentItem.previousSibling;
                  this.ensureElementIsVisible(previousElement);
                }
              }
              this._suppressOnSelect = suppressSelect;
              // XXX actually it's just a refresh, but at least
              // the Extensions manager expects this:
              this._fireOnSelect();
              return;
            }

            // try to restore the selected items according to their IDs
            // (applies after a template rebuild, if last-selected was not set)
            if (this.selectedItems) {
              let itemIds = [];
              for (let i = this.selectedCount - 1; i >= 0; i--) {
                let selectedItem = this.selectedItems[i];
                itemIds.push(selectedItem.id);
                this.selectedItems.remove(selectedItem);
              }
              for (let i = 0; i < itemIds.length; i++) {
                let selectedItem = document.getElementById(itemIds[i]);
                if (selectedItem) {
                  this.selectedItems.append(selectedItem);
                }
              }
            }
            if (this.currentItem && this.currentItem.id)
              this.currentItem = document.getElementById(this.currentItem.id);
            else
              this.currentItem = null;

            // if we have no previously current item or if the above check fails to
            // find the previous nodes (which causes it to clear selection)
            if (!this.currentItem && this.selectedCount == 0) {
              this.currentIndex = this._currentIndex ? this._currentIndex - 1 : 0;

              // cf. listbox constructor:
              // select items according to their attributes
              var children = this.children;
              for (let i = 0; i < children.length; ++i) {
                if (children[i].getAttribute("selected") == "true")
                  this.selectedItems.append(children[i]);
              }
            }

            if (this.selType != "multiple" && this.selectedCount == 0)
              this.selectedItem = this.currentItem;
          ]]>
        </body>
      </method>

      <method name="_isItemVisible">
        <parameter name="aItem"/>
        <body>
          <![CDATA[
            if (!aItem)
              return false;

            var y = this.scrollBoxObject.positionY + this.scrollBoxObject.y;

            // Partially visible items are also considered visible
            return (aItem.boxObject.y + aItem.boxObject.height > y) &&
                   (aItem.boxObject.y < y + this.scrollBoxObject.height);
          ]]>
        </body>
      </method>

      <field name="_currentIndex">null</field>

      <!-- For backwards-compatibility and for convenience.
        Use getIndexOfItem instead. -->
      <method name="getIndexOf">
        <parameter name="aElement"/>
        <body>
          <![CDATA[
            return this.getIndexOfItem(aElement);
          ]]>
        </body>
      </method>

      <!-- For backwards-compatibility and for convenience.
        Use ensureElementIsVisible instead -->
      <method name="ensureSelectedElementIsVisible">
        <body>
          <![CDATA[
            return this.ensureElementIsVisible(this.selectedItem);
          ]]>
        </body>
      </method>

      <!-- For backwards-compatibility and for convenience.
        Use moveByOffset instead. -->
      <method name="goUp">
        <body>
          <![CDATA[
            var index = this.currentIndex;
            this.moveByOffset(-1, true, false);
            return index != this.currentIndex;
          ]]>
        </body>
      </method>
      <method name="goDown">
        <body>
          <![CDATA[
            var index = this.currentIndex;
            this.moveByOffset(1, true, false);
            return index != this.currentIndex;
          ]]>
        </body>
      </method>

      <!-- deprecated (is implied by currentItem and selectItem) -->
      <method name="fireActiveItemEvent"><body/></method>
    </implementation>

    <handlers>
      <handler event="click">
        <![CDATA[
          // clicking into nothing should unselect
          if (event.originalTarget == this._scrollbox) {
            this.clearSelection();
            this.currentItem = null;
          }
        ]]>
      </handler>

      <handler event="MozSwipeGesture">
        <![CDATA[
          // Only handle swipe gestures up and down
          switch (event.direction) {
            case event.DIRECTION_DOWN:
              this._scrollbox.scrollTop = this._scrollbox.scrollHeight;
              break;
            case event.DIRECTION_UP:
              this._scrollbox.scrollTop = 0;
              break;
          }
        ]]>
      </handler>
    </handlers>
  </binding>

  <binding id="richlistitem"
           extends="chrome://global/content/bindings/listbox.xml#listitem">
    <content>
      <children/>
    </content>

    <resources>
      <stylesheet src="chrome://global/skin/richlistbox.css"/>
    </resources>

    <implementation>
      <destructor>
        <![CDATA[
          var control = this.control;
          if (!control)
            return;
          // When we are destructed and we are current or selected, unselect ourselves
          // so that richlistbox's selection doesn't point to something not in the DOM.
          // We don't want to reset last-selected, so we set _suppressOnSelect.
          if (this.selected) {
            var suppressSelect = control._suppressOnSelect;
            control._suppressOnSelect = true;
            control.removeItemFromSelection(this);
            control._suppressOnSelect = suppressSelect;
          }
          if (this.current)
            control.currentItem = null;
        ]]>
      </destructor>

      <property name="label" readonly="true">
        <!-- Setter purposely not implemented; the getter returns a
             concatentation of label text to expose via accessibility APIs -->
        <getter>
          <![CDATA[
            const XULNS =
              "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
            return Array.map(this.getElementsByTagNameNS(XULNS, "label"),
                             label => label.value)
                        .join(" ");
          ]]>
        </getter>
      </property>

      <property name="searchLabel">
        <getter>
          <![CDATA[
            return this.hasAttribute("searchlabel") ?
                   this.getAttribute("searchlabel") : this.label;
          ]]>
        </getter>
        <setter>
          <![CDATA[
            if (val !== null)
              this.setAttribute("searchlabel", val);
            else
              // fall back to the label property (default value)
              this.removeAttribute("searchlabel");
            return val;
          ]]>
        </setter>
      </property>
    </implementation>
  </binding>
</bindings>
