child_widget.js

/*
 * This file is part of AUX.
 *
 * AUX is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * AUX is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General
 * Public License along with this program; if not, write to the
 * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
 * Boston, MA  02110-1301  USA
 */

import { addClass, toggleClass } from './utils/dom.js';
import { warn } from './utils/log.js';
import { Widget } from './widgets/widget.js';
import { defineRender } from './renderer.js';

function getChildOptions(parent, name, options, config) {
  let ret = {};
  const pref = name + '.';

  const inherit_options = !!config.inherit_options;
  const blacklist_options = config.blacklist_options || [];

  let default_options = config.default_options;

  if (default_options) {
    if (typeof default_options === 'function')
      default_options = default_options.call(parent);

    ret = Object.assign(ret, default_options);
  }

  for (const key in options) {
    if (key.startsWith(pref)) {
      ret[key.substring(pref.length)] = options[key];
    } else if (inherit_options && blacklist_options.indexOf(key) < 0) {
      if (Object.prototype.hasOwnProperty.call(options, pref + key)) continue;
      if (config.create.hasOption(key) && !Widget.hasOption(key)) {
        ret[key] = options[key];
      }
    }
  }

  const map_options = config.map_options;

  if (map_options) {
    for (const key in map_options) {
      if (key in options) {
        if (key in ret && options[key] === parent.getDefault(key)) {
          continue;
        }

        ret[map_options[key]] = options[key];
      }
    }
  }

  return ret;
}

export function inheritChildOptions(dst, child_name, src, blacklist) {
  if (!blacklist) blacklist = [];

  const setCallback = function (value, key) {
    const C = this[child_name];

    if (C) C.set(key, value);
  };

  const inheritedOptions = [];

  for (const tmp in src.getOptionTypes()) {
    if (dst.hasOption(tmp)) continue;
    if (blacklist.indexOf(tmp) > -1) continue;
    inheritedOptions.push(tmp);
    dst.addStaticEvent('set_' + tmp, setCallback);
    if (!dst.hasOption(tmp))
      dst.defineOption(tmp, src.getOptionType(tmp), src.getDefault(tmp));
  }

  // we use initialized, which happens after initialize_children
  dst.addStaticEvent('initialized', function () {
    const child = this[child_name];

    if (!child) return;

    inheritedOptions.forEach((name) => {
      const value = this.get(name);
      if (value === src.getDefault(name)) return;
      child.set(name, this.get(name));
    });
  });
}

export function defineChildWidget(widget, name, config) {
  /**
   * @function defineChildWidget
   *
   * @description Defines a {@link Widget} as a child for another widget. This function
   * is used internally to simplify widget definitions. E.g. the {@link Icon} of a
   * {@link Button} is defined as a child widget. Child widgets
   * are created/added after the initialization of the parent widget.
   * If not configured explicitly, all options of the child widget can
   * be accessed via <code>Widget.options[config.name + "." + option]</code>
   * on the parent widget.
   *
   * @param {Widget} widget - The {@link Widget} to add the ChildWidget to.
   * @param {string} name - The identifier of the element inside the parent element, <code>Widget[config.name]</code>.
   * @param {object} config - The configuration of the child element.
   *
   * @param {Widget} config.create - A Widget class derivate to be used as child widget.
   * @param {boolean} [config.fixed] - A fixed child widget cannot be removed after initialization.
   * @param {boolean} [config.show=false] - Show/hide a non-fixed child widget on initialization.
   * @param {string} [config.option="show_"+config.name] - A custom option of the parent widget
   *     to determine the visibility of the child element. If this is
   *     <code>null</code>, <code>Widget.options["show_"+  config.name]</code>
   *     is used to toggle its visibility. The child element is visible, if
   *     this options is <code>!== false</code>.
   * @param {function} [config.append] - A function overriding the generic
   *     append mechanism. If not <code>null</code>, this function is
   *     supposed to take care of adding the child widget to the parent
   *     widget's DOM. Otherwise the element of the child widget is added
   *     to the element of the parent widget.
   * @param {boolean} [config.inherit_options=false] - Defines if both widgets share the
   *     same set of options. If <code>true</code>, Setting an option on the
   *     parent widget also sets the same option on the child widget. If <code>false</code>,
   *     the options of the child widget can be accessed via <code>options[config.name + "." + option]</code>
   *     in the parent widget.
   * @param {array} [config.map_options=[]] - An array containing option names to be
   *     mapped between parent and child widget. If one of these options is set
   *     on the parent widget, it also gets set on the child widget. This is
   *     a fine-grained version of <code>config.inherit-options</code>.
   * @param {boolean} [config.userset_ignore=false] - Do not care about the <code>userset</code>
   *     event of the parent widget, only keep track of <code>set</code>.
   * @param {boolean} [config.userset_delegate=false] - Delegates all user interaction from
   *     the child to the parent element. If the user triggers an event on
   *     the child widget, the <code>userset</code> function of the parent
   *     element is called.
   * @param {array} [config.static_events=[]] - An array of static events to be
   *     added to the parent widget. Each entry is a mapping between
   *     the name of the event and the callback function.
   * @param {boolean} [config.toggle_class=false] - Defines if the parent widget
   *     receives the class <code>.aux-has-[name]</code> as soon as
   *     the child element is shown.
   * @param {array<string>} [config.blacklist_options] - Array containing options names
   *     which are skipped on `inherit_options`.
   * @param {boolean} [config.map_interacting=true] - If true, the interacting
   *     property will be true if it is true in the child.
   * @param {boolean} [config.no_resize=false] - If true, no `triggerResize` is called on the parent
   *     as soon as a child is added or removed.
   */

  const key = config.option || 'show_' + name;
  let tmp, m;
  const static_events = {};
  const map_interacting = config.map_interacting !== false;

  if (!config.userset_ignore)
    static_events.userset =
      config.inherit_options || config.userset_delegate
        ? function (key, value) {
            this.parent.userset(key, value);
            return false;
          }
        : function (key, value) {
            this.parent.userset(name + '.' + key, value);
            return false;
          };

  if ((m = config.static_events)) Object.assign(static_events, m);

  if (config.create === void 0) {
    warn("'create' is undefined. Skipping child widget ", name);
    return;
  }

  if (map_interacting) {
    static_events.set_interacting = function (value) {
      const self = this.parent;

      if (value) self.startInteracting();
      else self.stopInteracting();
    };
  }

  class ChildWidget extends config.create {
    static get _options() {
      return config.create.getOptionTypes();
    }

    static get static_events() {
      return static_events;
    }
  }

  /* trigger child widget creation after initialization */
  widget.addStaticEvent('initialize', function () {
    /* we do not want to trash the class cache */
    this[name] = null;
  });

  /* clean up on destroy */
  widget.addStaticEvent('destroy', function () {
    const child = this[name];

    if (child) {
      child.element.remove();
      child.destroy();
      this[name] = null;
    }
  });

  const fixed = config.fixed;
  const append = config.append === void 0 ? true : config.append;

  const createWidget = function () {
    const O = getChildOptions(this, name, this.options, config);
    const w = new ChildWidget(O);
    this[name] = w;
    this.addChild(w);
  };

  const appendWidget = function () {
    const w = this[name];

    if (!w || w.element.parentNode) return;

    const element = this.element;

    if (append === true) {
      element.appendChild(w.element);
    } else if (typeof append === 'function') {
      append.call(this);
    }
  };

  if (fixed) {
    widget.addStaticEvent('initialize_children', createWidget);
    widget.addTask(defineRender([], appendWidget));
    if (config.toggle_class) {
      widget.addTask(
        defineRender([], function () {
          addClass(this.element, 'aux-has-' + name);
        })
      );
    }
  } else {
    widget.addStaticEvent('initialize_children', function () {
      /* we do not want to trash the class cache */
      if (!this[name]) {
        this.set(key, this.options[key]);
      }
    });
    widget.addStaticEvent('set_' + key, function (val) {
      const show = val !== false;

      if ((this[name] !== null) === show) return;

      if (show) {
        createWidget.call(this);
      }
    });
    widget.addTask(
      defineRender([key], function (val) {
        const show = val !== false;
        const w = this[name];

        if (show === (w && !!w.element.parentNode)) return;

        if (config.toggle_class)
          toggleClass(this.element, 'aux-has-' + name, show);

        if (show) {
          appendWidget.call(this);
        } else if (w) {
          w.element.remove();
          this[name] = null;
          if (map_interacting && w.get('interacting')) {
            this.stopInteracting();
          }
          w.destroy();
        }
        if (!config.no_resize) this.triggerResize();
      })
    );
  }

  let setCallback = function (val, key) {
    if (this[name]) this[name].set(key.substring(name.length + 1), val);
  };

  for (tmp in ChildWidget.getOptionTypes()) {
    widget.addStaticEvent('set_' + name + '.' + tmp, setCallback);
    widget.defineOption(name + '.' + tmp, ChildWidget.getOptionType(tmp));
  }

  /* direct option inherit */
  const blacklist_options = config.blacklist_options || [];
  if (config.inherit_options) {
    setCallback = function (val, key) {
      if (this[name]) this[name].set(key, val);
    };
    for (tmp in ChildWidget.getOptionTypes()) {
      if (Widget.hasOption(tmp)) continue;
      if (blacklist_options.indexOf(tmp) > -1) continue;
      widget.addStaticEvent('set_' + tmp, setCallback);
      if (!widget.hasOption(tmp))
        widget.defineOption(tmp, ChildWidget.getOptionType(tmp));
    }
  }
  setCallback = function (key) {
    return function (val) {
      if (this[name]) this[name].set(key, val);
    };
  };

  const map_options = config.map_options;

  if (map_options) {
    for (const parent_key in map_options) {
      let child_key = map_options[parent_key];
      if (!Array.isArray(child_key)) child_key = [child_key];

      if (!widget.hasOption(parent_key)) {
        widget.defineOption(
          parent_key,
          ChildWidget.getOptionType(child_key[0]),
          ChildWidget.getDefault(child_key[0])
        );
      }

      child_key.forEach((key) => {
        widget.addStaticEvent('set_' + parent_key, setCallback(key));
      });
    }
  }
  if (!config.options && !widget.hasOption(key)) {
    widget.defineOption(key, 'boolean', fixed || !!config.show);
  }
}