Source: select.js

define(function module(require) {

    var View = require('./view');

    var pushIfMissing = require('./utils').pushIfMissing;
    var removeIfExisting = require('./utils').removeIfExisting;
    var Stack = require('./utils').Stack;
    var isPrimitive = require('./utils').isPrimitive;

    cop.create('SelectionLayer')
        .refineObject(users.timfelgentreff.jsinterpreter, {
            get InterpreterVisitor() {
                return SelectionInterpreterVisitor;
            }
        });

    var PROPERTY_ACCESSOR_NAME = 'wrappedValue';
    var PropertyAccessor = Object.subclass('whjfqggkewgdkewgfiuewgfeldigdk3v3m', {
        initialize: function(obj, propName) {
            this.selectionItems = new Set();

            this.safeOldAccessors(obj, propName);

            try {
                obj.__defineGetter__(propName, function() {
                    return this[PROPERTY_ACCESSOR_NAME];
                }.bind(this));
            } catch (e) { /* Firefox raises for Array.length */ }
            var newGetter = obj.__lookupGetter__(propName);
            if (!newGetter) {
                // Chrome silently ignores __defineGetter__ for Array.length
                this.externalVariables(solver, null);
                return;
            }

            obj.__defineSetter__(propName, function(newValue) {
                var returnValue = this[PROPERTY_ACCESSOR_NAME] = newValue;
                console.log('newValue for', obj, propName, newValue);
                if(!isPrimitive(newValue)) {
                    this.recalculate();
                }
                this.applyCallbacks();
                return returnValue;
            }.bind(this));
        },

        safeOldAccessors: function(obj, propName) {
            // take existing getter, if existent, and assign to
            var existingSetter = obj.__lookupSetter__(propName),
                existingGetter = obj.__lookupGetter__(propName);
            if (existingGetter && existingSetter) {
                this.__defineGetter__(PROPERTY_ACCESSOR_NAME, existingGetter);
                this.__defineSetter__(PROPERTY_ACCESSOR_NAME, existingSetter);
            }

            // assign old value to new slot
            if (!existingGetter &&
                !existingSetter &&
                obj.hasOwnProperty(propName)
            ) {
                this[PROPERTY_ACCESSOR_NAME] = obj[propName];
            }
        },

        addCallback: function(selectionItem) {
            this.selectionItems.add(selectionItem);
            selectionItem.propertyAccessors.add(this);
        },
        applyCallbacks: function() {
            this.selectionItems.forEach(function(selectionItem) {
                selectionItem.callback();
            });
        },
        recalculate: function() {
            console.log('should recalculate');

            var selectionItems = [];
            this.selectionItems.forEach(function(selectionItem) {
                selectionItems.push(selectionItem);
            });

            selectionItems.forEach(function(selectionItem) {
                selectionItem.removeListeners();
            });
            selectionItems.forEach(function(selectionItem) {
                selectionItem.installListeners();
            });
        }
    });

    PropertyAccessor.accessors = new Map();
    PropertyAccessor.wrapProperties = function(obj, propName) {
        var mapObj;
        if(PropertyAccessor.accessors.has(obj)) {
            mapObj = PropertyAccessor.accessors.get(obj);
        } else {
            mapObj = {};
            PropertyAccessor.accessors.set(obj, mapObj);
        }

        if(!mapObj.hasOwnProperty(propName)) {
            mapObj[propName] = new PropertyAccessor(obj, propName);
        }

        return mapObj[propName];
    };

    users.timfelgentreff.jsinterpreter.InterpreterVisitor.subclass('SelectionInterpreterVisitor', {

        visitGetSlot: function($super, node) {

            var obj = this.visit(node.obj),
                propName = this.visit(node.slotName);

            PropertyAccessor
                .wrapProperties(obj, propName)
                .addCallback(View.current());

            return $super(node);
        },

        shouldInterpret: function(frame, fn) {
            if (this.isNative(fn)) return false;
            return typeof(fn.forInterpretation) == 'function';
        }
    });

    Object.subclass('Operator', {});
    Operator.subclass('IdentityOperator', {
        initialize: function(upstream, downstream) {
            this.downstream = downstream;
            upstream.downstream.push(this);
            upstream.now().forEach(function(item) {
                downstream.safeAdd(item);
            });
        },
        newItemFromUpstream: function(item) {
            this.downstream.safeAdd(item);
        },
        destroyItemFromUpstream: function(item) {
            this.downstream.safeRemove(item);
        }
    });

    IdentityOperator.subclass('FilterOperator', {
        initialize: function($super, upstream, downstream, expression, context) {
            this.expression = expression;
            this.expression.varMapping = context;

            this.selectionItems = [];

            this.downstream = downstream;
            upstream.downstream.push(this);
            upstream.now().forEach(function(item) {
                this.newItemFromUpstream(item);
            }, this);
        },
        newItemFromUpstream: function(item) {
            this.trackItem(item);
        },
        trackItem: function(item) {
            if(this.expression(item)) {
                this.downstream.safeAdd(item);
            }

            if(this.selectionItems.any(function(selectionItem) {
                    return selectionItem.item === item;
                })) {
                throw Error('Item already tracked', item);
            }

            var selectionItem = new SelectionItem(this, item, this.onChangeCallback.bind(this, item));

            this.selectionItems.push(selectionItem);

            selectionItem.installListeners();
        },
        onChangeCallback: function(item) {
            console.log('check');
            if(this.expression(item)) {
                this.addDueToFilterExpression(item);
            } else {
                this.removeDueToFilterExpression(item);
            }
        },
        addDueToFilterExpression: function(item) {
            this.downstream.safeAdd(item);
        },
        removeDueToFilterExpression: function(item) {
            this.downstream.safeRemove(item);
        },
        destroyItemFromUpstream: function(item) {
            var selectionItem = this.selectionItems.find(function(selectionItem) {
                return selectionItem.item === item;
            });

            if(!selectionItem) {
                console.warn('remove non-existing item from upstream', item, this);
                return;
            }

            selectionItem.removeListeners();

            var gotRemoved = removeIfExisting(this.selectionItems, selectionItem);
            if(gotRemoved) { console.log('removed via baseset', item); }

            this.downstream.safeRemove(selectionItem.item);
        }
    });

    var identity = require('./utils').identity;
    IdentityOperator.subclass('MapOperator', {
        initialize: function($super, upstream, downstream, mapFunction) {
            this.mapFunction = mapFunction || identity;
            this.items = [];
            this.outputItemsByItems = new Map();

            this.downstream = downstream;
            upstream.downstream.push(this);
            upstream.now().forEach(function(item) {
                this.newItemFromUpstream(item);
            }, this);
        },
        newItemFromUpstream: function(item) {
            var wasNewItem = pushIfMissing(this.items, item);
            if(wasNewItem) {
                var outputItem = this.mapFunction(item);
                this.outputItemsByItems.set(item, outputItem);
                this.downstream.safeAdd(outputItem);
            }
        },
        destroyItemFromUpstream: function(item) {
            var gotRemoved = removeIfExisting(this.items, item);
            if(gotRemoved) {
                var outputItem = this.outputItemsByItems.get(item);
                this.outputItemsByItems.delete(item);
                this.downstream.safeRemove(outputItem);
            }
        }
    });

    IdentityOperator.subclass('UnionOperator', {
        initialize: function($super, upstream1, upstream2, downstream) {
            this.upstream1 = upstream1;
            this.upstream2 = upstream2;
            this.downstream = downstream;
            upstream1.downstream.push(this);
            upstream2.downstream.push(this);

            upstream1.now().concat(upstream2.now()).forEach(function(item) {
                this.newItemFromUpstream(item);
            }, this);
        },
        newItemFromUpstream: function(item) {
            var itemAlreadyExists = this.downstream.now().includes(item);
            if(!itemAlreadyExists) {
                this.downstream.safeAdd(item);
            }
        },
        destroyItemFromUpstream: function(item) {
            var itemStillExists = this.upstream1.now().includes(item) || this.upstream2.now().include(item);
            if(!itemStillExists) {
                this.downstream.safeRemove(item);
            }
        }
    });

    // TODO: make this reusable
    Object.subclass('FlowToFunction', {
        initialize: function(upstream, create, destroy) {
            this.create = create;
            this.destroy = destroy;
            upstream.downstream.push(this);
        },
        newItemFromUpstream: function(item) {
            this.create(item);
        },
        destroyItemFromUpstream: function(item) {
            this.destroy(item);
        }
    });

    /**
     *
     * @class Pair
     * @classdesc This is used by the {@link View#cross} operator.
     * @property {Object} first
     * @property {Object} second
     */
    Object.subclass('Pair', {
        initialize: function(first, second) {
            this.first = first;
            this.second = second;
        }
    });

    IdentityOperator.subclass('CrossOperator', {
        initialize: function($super, upstream1, upstream2, downstream) {
            this.upstream1 = upstream1;
            this.upstream2 = upstream2;
            this.downstream = downstream;

            this.trackedItems = [[], []];
            this.pairs = new Map();

            new FlowToFunction(upstream1, this.newItemFromUpstream.bind(this, 0), this.destroyItemFromUpstream.bind(this, 0));
            new FlowToFunction(upstream2, this.newItemFromUpstream.bind(this, 1), this.destroyItemFromUpstream.bind(this, 1));
            upstream1.now().forEach(this.newItemFromUpstream.bind(this, 0));
            upstream2.now().forEach(this.newItemFromUpstream.bind(this, 1));
        },
        newItemFromUpstream: function(index, item) {
            var wasNewItem = pushIfMissing(this.trackedItems[index], item);
            if(wasNewItem) {
                this.forEachPairWithDo(index, item, function(pair) {
                    this.downstream.safeAdd(pair);
                });
            }
        },
        destroyItemFromUpstream: function(index, item) {
            var gotRemoved = removeIfExisting(this.trackedItems[index], item);
            if(gotRemoved) {
                this.forEachPairWithDo(index, item, function(pair) {
                    this.downstream.safeRemove(pair);
                });
            }
        },
        forEachPairWithDo: function(index, item, callback) {
            var zeroes = index === 0 ? [item] : this.trackedItems[0];
            var ones = index === 1 ? [item] : this.trackedItems[1];

            zeroes.forEach(function(zeroElement) {
                ones.forEach(function(oneElement) {
                    var pair = this.getOrCreatePairForCombination(zeroElement, oneElement);
                    callback.call(this, pair);
                }, this);
            }, this);
        },
        getOrCreatePairForCombination: function(zero, one) {
            if(!this.pairs.has(zero)) {
                this.pairs.set(zero, new Map());
            }
            var map = this.pairs.get(zero);
            if(!map.has(one)) {
                map.set(one, new Pair(zero, one));
            }
            return map.get(one);
        }
    });

    IdentityOperator.subclass('DelayOperator', {
        initialize: function($super, upstream, downstream, delayTime) {
            this.upstream = upstream;
            this.downstream = downstream;
            this.delayTime = delayTime;
            upstream.downstream.push(this);

            this.delays = new Map();

            upstream.now().forEach(function(item) {
                this.newItemFromUpstream(item);
            }, this);
        },
        newItemFromUpstream: function(item) {
            if(!this.delays.has(item)) {
                this.delays.set(item, setInterval((function() {
                    this.downstream.safeAdd(item);
                }).bind(this), this.delayTime));
            }
        },
        destroyItemFromUpstream: function(item) {
            this.downstream.safeRemove(item);
            if(this.delays.has(item)) {
                clearTimeout(this.delays.get(item));
                this.delays.delete(item);
            }
        }
    });

    IdentityOperator.subclass('ReduceOperator', {
        initialize: function($super, upstream, callback, reducer, initialValue) {
            this.callback = callback;
            this.reducer = reducer;
            this.initialValue = initialValue;
            this.upstream = upstream;
            upstream.downstream.push(this);

            this.newItemFromUpstream();
        },
        newItemFromUpstream: function() {
            this.callback(this.upstream.now().reduce(this.reducer, this.initialValue));
        },
        destroyItemFromUpstream: function() {
            this.newItemFromUpstream();
        }
    });

    Object.extend(View.prototype, {
        /**
         * Takes an additional filter function and returns a reactive object set. That set only contains the objects of the original set that also match the given filter function.
         * @function View#filter
         * @param {View~filterIterator} iterator
         * @return {View} The callee of this method.
         */
        filter: function(iterator, context) {
            var newSelection = new View();

            new FilterOperator(this, newSelection, iterator, context);

            return newSelection;
        },
        /**
         * Takes a mapping function and returns another reactive object set. That set always contains the mapped objects corresponding to the objects in the original set.
         * @function View#map
         * @param {View~mapIterator} iterator
         * @return {View} The callee of this method.
         */
        map: function(iterator) {
            var newSelection = new View();

            new MapOperator(this, newSelection, iterator);

            return newSelection;
        },
        /**
         * Create a new {@link View} containing all elements of the callee and the argument.
         * @function View#union
         * @param {View} otherView {@link View}
         * @return {View} Contains every object of both input Views.
         */
        union: function(otherView) {
            var newSelection = new View();

            new UnionOperator(this, otherView, newSelection);

            return newSelection;
        },
        /**
         * Create a new {@link View} containing all elements of the cartesian product of the callee and the argument as {@link Pair}.
         * @function View#cross
         * @param {View} otherView {@link View}
         * @return {View} Contains every combination of both input Views as two-element Array.
         */
        cross: function(otherView) {
            var newSelection = new View();

            new CrossOperator(this, otherView, newSelection);

            return newSelection;
        },

        /**
         * Delays the propagation of items of the callee.
         * Items are propagated to the returned {@link View} in {@link View#delay.delayTime} milliseconds,
         * if they are not removed from the callee before the timeout.
         * @function View#delay
         * @param {Number} delayTime - the time to delay given in milliSeconds.
         * @returns {View}
         */
        delay: function(delayTime) {
            var newSelection = new View();

            new DelayOperator(this, newSelection, delayTime);

            return newSelection;
        },

        /**
         * Whenever the callee is modified, this calls the given callback with the reduced value.
         * @function View#reduce
         * @param {View~reduceCallback} callback
         * @param {View~reducer} reducer
         * @param initialValue - the initial value passed to the {@View~reducer}.
         * @returns {View} the callee
         */
        reduce: function(callback, reducer, initialValue) {
            new ReduceOperator(this, callback, reducer, initialValue);

            return this;
        }
    });

    /**
     * The callback function called to determine whether an Object is in the derived {@link View}.
     * @callback View~filterIterator
     * @param {Object} item - item from the original {@link View}.
     * @return {Boolean}
     */

    /**
     * The callback that computes the item to be added to the mapped {@link View}.
     * @callback View~mapIterator
     * @param {Object} item - item from the original {@link View}.
     * @return {Object} mapped item
     */

    /**
     * The callback that is invoked when the {@link View} changes.
     * @callback View~reduceCallback
     */

    /**
     * The callback that computes the aggregation of the modified {@link View}.
     * @callback View~reducer
     * @param {Object} accumulator
     * @param {Object} item
     * @return {Object}
     */

    View.stack = new Stack();
    View.current = function() { return View.stack.top(); };
    View.withOnStack = function(el, callback, context) {
        View.stack.push(el);
        try {
            callback.call(context);
        } finally {
            View.stack.pop();
        }
    };

    Object.subclass('SelectionItem', {
        initialize: function(selection, item, callback) {
            this.selection = selection;
            this.item = item;
            this.callback = callback;

            this.propertyAccessors = new Set();
        },

        installListeners: function() {
            var item = this.item;
            View.withOnStack(this, function() {
                cop.withLayers([SelectionLayer], (function() {
                    this.expression.forInterpretation().apply(null, [item]);
                }).bind(this));
            }, this.selection);
        },

        removeListeners: function() {
            this.propertyAccessors.forEach(function(propertyAccessor) {
                propertyAccessor.selectionItems.delete(this);
            }, this);
            this.propertyAccessors.clear();
        }
    });

    /**
     * @function select
     * @param {Class} Class
     * @param {predicate} predicate
     * @return {View}
     */
    function select(Class, predicate, context) {
        var newSelection = new View();

        new FilterOperator(Class.__livingSet__, newSelection, predicate, context);

        return newSelection;
    }

    /**
     * This callback to determine whether an item should be part of the resulting {@link View}.
     * @callback predicate
     * @param {Object} item
     * @return {Boolean}
     */

    return select;

});