├── .gitignore ├── .npmignore ├── .travis.yml ├── Gruntfile.coffee ├── LICENSE.md ├── README.md ├── package.json ├── spec ├── model-spec.coffee ├── sequence-spec.coffee └── spec-helper.coffee └── src ├── model.coffee ├── sequence.coffee └── theorist.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swp 3 | lib 4 | npm-debug.log 5 | .coffee 6 | api.json 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | spec 2 | script 3 | src 4 | *.coffee 5 | .npmignore 6 | .DS_Store 7 | npm-debug.log 8 | .travis.yml 9 | .pairs 10 | .coffee 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | notifications: 4 | email: 5 | on_success: never 6 | on_failure: change 7 | 8 | node_js: 9 | - 0.10 10 | 11 | git: 12 | depth: 10 13 | -------------------------------------------------------------------------------- /Gruntfile.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (grunt) -> 2 | grunt.initConfig 3 | pkg: grunt.file.readJSON('package.json') 4 | 5 | coffee: 6 | glob_to_multiple: 7 | expand: true 8 | cwd: 'src' 9 | src: ['**/*.coffee'] 10 | dest: 'lib' 11 | ext: '.js' 12 | 13 | coffeelint: 14 | options: 15 | no_empty_param_list: 16 | level: 'error' 17 | max_line_length: 18 | level: 'ignore' 19 | indentation: 20 | level: 'ignore' 21 | 22 | src: ['src/*.coffee'] 23 | test: ['spec/*.coffee'] 24 | gruntfile: ['Gruntfile.coffee'] 25 | 26 | shell: 27 | test: 28 | command: 'node --harmony_collections node_modules/.bin/jasmine-focused --coffee --captureExceptions spec' 29 | options: 30 | stdout: true 31 | stderr: true 32 | failOnError: true 33 | 34 | grunt.loadNpmTasks('grunt-contrib-coffee') 35 | grunt.loadNpmTasks('grunt-shell') 36 | grunt.loadNpmTasks('grunt-coffeelint') 37 | grunt.loadNpmTasks('grunt-atomdoc') 38 | 39 | grunt.registerTask 'clean', -> 40 | require('rimraf').sync('lib') 41 | require('rimraf').sync('api.json') 42 | 43 | grunt.registerTask('lint', ['coffeelint']) 44 | grunt.registerTask('default', ['coffee', 'lint']) 45 | grunt.registerTask('test', ['coffee', 'lint', 'shell:test']) 46 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 GitHub Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ##### Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/) 2 | # Theorist [![Build Status](https://travis-ci.org/atom/theorist.svg?branch=master)](https://travis-ci.org/atom/theorist) 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "theorist", 3 | "version": "1.0.2", 4 | "description": "A reactive model toolkit.", 5 | "main": "./lib/theorist", 6 | "scripts": { 7 | "prepublish": "grunt clean lint coffee atomdoc", 8 | "test": "grunt test" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/atom/theorist.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/atom/theorist/issues" 16 | }, 17 | "licenses": [ 18 | { 19 | "type": "MIT", 20 | "url": "http://github.com/atom/theorist/raw/master/LICENSE.md" 21 | } 22 | ], 23 | "dependencies": { 24 | "emissary": "1.x", 25 | "underscore-plus": "1.x", 26 | "property-accessors": "1.x", 27 | "delegato": "1.x" 28 | }, 29 | "devDependencies": { 30 | "coffee-script": "~1.6.3", 31 | "jasmine-focused": "~0.19.0", 32 | "grunt-contrib-coffee": "~0.7.0", 33 | "grunt-cli": "~0.1.8", 34 | "grunt": "~0.4.1", 35 | "grunt-shell": "~0.2.2", 36 | "grunt-coffeelint": "0.0.6", 37 | "rimraf": "~2.2.2", 38 | "coffee-cache": "~0.2.0", 39 | "grunt-atomdoc": "^0.2.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /spec/model-spec.coffee: -------------------------------------------------------------------------------- 1 | {Behavior, Signal} = require 'emissary' 2 | Model = require '../src/model' 3 | 4 | describe "Model", -> 5 | describe "declared properties", -> 6 | it "assigns declared properties in the default constructor", -> 7 | class TestModel extends Model 8 | @properties 'foo', 'bar' 9 | 10 | model = new TestModel(foo: 1, bar: 2, baz: 3) 11 | expect(model.foo).toBe 1 12 | expect(model.bar).toBe 2 13 | expect(model.baz).toBeUndefined() 14 | 15 | it "allows declared properties to be associated with default values, which are assigned on construction", -> 16 | class TestModel extends Model 17 | @properties 18 | foo: 1 19 | bar: 2 20 | baz: -> defaultValue 21 | 22 | defaultValue = 3 23 | model = new TestModel(foo: 4) 24 | defaultValue = 10 25 | expect(model.foo).toBe 4 26 | expect(model.bar).toBe 2 27 | expect(model.baz).toBe 3 28 | 29 | it "does not assign default values over existing values", -> 30 | class TestModel extends Model 31 | bar: 3 32 | @properties 33 | foo: 1 34 | bar: 2 35 | 36 | model = Object.create(TestModel.prototype) 37 | model.bar = 3 38 | TestModel.call(model) 39 | expect(model.foo).toBe 1 40 | expect(model.bar).toBe 3 41 | 42 | it "evaluates default values lazily if the constructor is overridden", -> 43 | class TestModel extends Model 44 | @properties 45 | foo: -> defaultValue 46 | 47 | constructor: -> 48 | 49 | defaultValue = 1 50 | model = new TestModel 51 | defaultValue = 2 52 | expect(model.foo).toBe 2 53 | 54 | it "associates declared properties with $-prefixed behavior accessors", -> 55 | class TestModel extends Model 56 | @properties 'foo', 'bar' 57 | 58 | model = new TestModel(foo: 1, bar: 2) 59 | 60 | fooValues = [] 61 | barValues = [] 62 | model.$foo.onValue (v) -> fooValues.push(v) 63 | model.$bar.onValue (v) -> barValues.push(v) 64 | 65 | model.foo = 10 66 | model.set(foo: 20, bar: 21) 67 | model.foo = 20 68 | 69 | expect(fooValues).toEqual [1, 10, 20] 70 | expect(barValues).toEqual [2, 21] 71 | 72 | describe ".behavior", -> 73 | it "defines behavior accessors based on the given name and definition", -> 74 | class TestModel extends Model 75 | @property 'foo', 0 76 | @behavior 'bar', -> @$foo.map (v) -> v + 1 77 | 78 | model = new TestModel 79 | 80 | expect(model.bar).toBe 1 81 | values = [] 82 | model.$bar.onValue (v) -> values.push(v) 83 | 84 | model.foo = 10 85 | expect(model.bar).toBe 11 86 | expect(values).toEqual [1, 11] 87 | 88 | it "releases behaviors when the model is destroyed", -> 89 | behavior = new Behavior(0) 90 | class TestModel extends Model 91 | @property 'foo', 0 92 | @behavior 'bar', -> behavior 93 | 94 | model = new TestModel 95 | model.bar # force retention of behavior 96 | 97 | expect(behavior.retainCount).toBeGreaterThan 0 98 | model.destroy() 99 | expect(behavior.retainCount).toBe 0 100 | 101 | describe "instance ids", -> 102 | it "assigns a unique id to each model instance", -> 103 | model1 = new Model 104 | model2 = new Model 105 | 106 | expect(model1.id).toBeDefined() 107 | expect(model2.id).toBeDefined() 108 | expect(model1.id).not.toBe model2.id 109 | 110 | it "honors explicit id assignments in the params hash", -> 111 | model1 = new Model(id: 22) 112 | model2 = new Model(id: 33) 113 | expect(model1.id).toBe 22 114 | expect(model2.id).toBe 33 115 | 116 | # auto-generates a higher id than what was explicitly assigned 117 | model3 = new Model 118 | expect(model3.id).toBe 34 119 | 120 | describe "::destroy()", -> 121 | it "marks the model as no longer alive, unsubscribes, calls an optional destroyed hook, and emits a 'destroyed' event", -> 122 | class TestModel extends Model 123 | destroyedCallCount: 0 124 | destroyed: -> @destroyedCallCount++ 125 | 126 | emitter = new Model 127 | model = new TestModel 128 | model.subscribe emitter, 'foo', -> 129 | model.on 'destroyed', destroyedHandler = jasmine.createSpy("destroyedHandler") 130 | 131 | expect(model.isAlive()).toBe true 132 | expect(model.isDestroyed()).toBe false 133 | expect(emitter.getSubscriptionCount()).toBe 1 134 | 135 | model.destroy() 136 | model.destroy() 137 | 138 | expect(model.isAlive()).toBe false 139 | expect(model.isDestroyed()).toBe true 140 | expect(model.destroyedCallCount).toBe 1 141 | expect(destroyedHandler.callCount).toBe 1 142 | expect(emitter.getSubscriptionCount()).toBe 0 143 | 144 | describe "::when(signal, callback)", -> 145 | describe "when called with a callback", -> 146 | it "calls the callback when the signal yields a truthy value", -> 147 | signal = new Signal 148 | model = new Model 149 | model.when signal, callback = jasmine.createSpy("callback").andCallFake -> expect(this).toBe model 150 | signal.emitValue(0) 151 | signal.emitValue(null) 152 | signal.emitValue('') 153 | expect(callback.callCount).toBe 0 154 | signal.emitValue(1) 155 | expect(callback.callCount).toBe 1 156 | 157 | describe "when called with a method name", -> 158 | it "calls the named method when the signal yields a truthy value", -> 159 | signal = new Signal 160 | model = new Model 161 | model.action = jasmine.createSpy("action") 162 | model.when signal, 'action' 163 | signal.emitValue(0) 164 | signal.emitValue(null) 165 | signal.emitValue('') 166 | expect(model.action.callCount).toBe 0 167 | signal.emitValue(1) 168 | expect(model.action.callCount).toBe 1 169 | -------------------------------------------------------------------------------- /spec/sequence-spec.coffee: -------------------------------------------------------------------------------- 1 | Sequence = require '../src/sequence' 2 | 3 | describe "Sequence", -> 4 | [sequence, changes] = [] 5 | 6 | beforeEach -> 7 | sequence = Sequence("abcdefg".split('')...) 8 | changes = [] 9 | sequence.on 'changed', (change) -> changes.push(change) 10 | 11 | it "reports itself as an instance of both Sequence and Array", -> 12 | expect(sequence instanceof Sequence).toBe true 13 | expect(sequence instanceof Array).toBe true 14 | 15 | describe ".fromArray", -> 16 | it "constructs a sequence from the given array", -> 17 | expect(Sequence.fromArray(['a', 'b', 'c'])).toEqual ['a', 'b', 'c'] 18 | 19 | describe "property access via ::[]", -> 20 | it "allows sequence elements to be read via numeric keys", -> 21 | expect(sequence[0]).toBe 'a' 22 | expect(sequence['1']).toBe 'b' 23 | 24 | # This can be enabled when harmony proxies are stable when proxying arrays 25 | xit "updates the sequence and emits 'changed' events when assigning elements via numeric keys", -> 26 | sequence[2] = 'C' 27 | expect(sequence).toEqual "abCdefg".split('') 28 | expect(changes).toEqual [{ 29 | index: 2 30 | removedValues: ['c'] 31 | insertedValues: ['C'] 32 | }] 33 | 34 | changes = [] 35 | sequence[9] = 'X' 36 | expect(sequence).toEqual "abCdefg".split('').concat([undefined, undefined, 'X']) 37 | expect(changes).toEqual [{ 38 | index: 7 39 | removedValues: [] 40 | insertedValues: [undefined, undefined, 'X'] 41 | }] 42 | 43 | it "allows non-numeric properties to be accessed via non-numeric keys", -> 44 | sequence.foo = "bar" 45 | expect(sequence.foo).toBe "bar" 46 | 47 | describe "::length", -> 48 | it "returns the current length of the sequence", -> 49 | expect(sequence.length).toBe 7 50 | 51 | # This can be enabled when harmony proxies are stable when proxying arrays 52 | xdescribe "when assigning a value shorter than the current length", -> 53 | it "truncates the sequence and emits a 'changed' event", -> 54 | sequence.length = 4 55 | expect(sequence).toEqual "abcd".split('') 56 | expect(changes).toEqual [{ 57 | index: 4 58 | removedValues: ['e', 'f', 'g'] 59 | insertedValues: [] 60 | }] 61 | 62 | # This can be enabled when harmony proxies are stable when proxying arrays 63 | xdescribe "when assigning a value greater than the current length", -> 64 | it "expands the sequence and emits a 'changed' event'", -> 65 | sequence.length = 9 66 | expect(sequence).toEqual "abcdefg".split('').concat([undefined, undefined]) 67 | expect(changes).toEqual [{ 68 | index: 7 69 | removedValues: [] 70 | insertedValues: [undefined, undefined] 71 | }] 72 | 73 | describe "::$length", -> 74 | it "returns a behavior based on the current length of the array", -> 75 | lengths = [] 76 | sequence.$length.onValue (l) -> lengths.push(l) 77 | 78 | expect(lengths).toEqual [7] 79 | sequence.push('X') 80 | sequence.splice(2, 3, 'Y') 81 | sequence.splice(0, 1, 'Z') 82 | expect(lengths).toEqual [7, 8, 6] 83 | 84 | describe "iteration", -> 85 | it "can iterate over the sequence with standard coffee-script syntax", -> 86 | values = (value for value in sequence) 87 | expect(values).toEqual sequence 88 | 89 | describe "::splice", -> 90 | it "splices the sequence and emits a 'changed' event", -> 91 | result = sequence.splice(3, 2, 'D', 'E', 'F') 92 | expect(result).toEqual ['d', 'e'] 93 | expect(sequence).toEqual "abcDEFfg".split('') 94 | expect(changes).toEqual [{ 95 | index: 3 96 | removedValues: ['d', 'e'] 97 | insertedValues: ['D', 'E', 'F'] 98 | }] 99 | 100 | describe "::push", -> 101 | it "pushes to the sequence and emits a 'changed' event", -> 102 | result = sequence.push('X', 'Y', 'Z') 103 | expect(result).toBe 10 104 | expect(sequence).toEqual "abcdefgXYZ".split('') 105 | expect(changes).toEqual [{ 106 | index: 7 107 | removedValues: [] 108 | insertedValues: ['X', 'Y', 'Z'] 109 | }] 110 | 111 | describe "::pop", -> 112 | it "pops the sequence and emits a 'changed' event", -> 113 | result = sequence.pop() 114 | expect(result).toBe 'g' 115 | expect(sequence).toEqual "abcdef".split('') 116 | expect(changes).toEqual [{ 117 | index: 6 118 | removedValues: ['g'] 119 | insertedValues: [] 120 | }] 121 | 122 | describe "::unshift", -> 123 | it "unshifts to the sequence and emits a 'changed' event", -> 124 | result = sequence.unshift('X', 'Y', 'Z') 125 | expect(result).toBe 10 126 | expect(sequence).toEqual "XYZabcdefg".split('') 127 | expect(changes).toEqual [{ 128 | index: 0 129 | removedValues: [] 130 | insertedValues: ['X', 'Y', 'Z'] 131 | }] 132 | 133 | describe "::shift", -> 134 | it "shifts from the sequence and emits a 'changed' event", -> 135 | result = sequence.shift() 136 | expect(result).toBe 'a' 137 | expect(sequence).toEqual "bcdefg".split('') 138 | expect(changes).toEqual [{ 139 | index: 0 140 | removedValues: ['a'] 141 | insertedValues: [] 142 | }] 143 | 144 | describe "::onEach", -> 145 | it "calls the given callback for all current and future elements in the array", -> 146 | values = [] 147 | indices = [] 148 | sequence.onEach (v, i) -> values.push(v); indices.push(i) 149 | expect(values).toEqual "abcdefg".split('') 150 | expect(indices).toEqual [0..6] 151 | 152 | values = [] 153 | indices = [] 154 | 155 | sequence.push('H', 'I') 156 | sequence.splice(2, 2, 'X') 157 | expect(values).toEqual ['H', 'I', 'X'] 158 | expect(indices).toEqual [7, 8, 2] 159 | 160 | describe "::onRemoval", -> 161 | it "calls the given callback with each element that's removed from the array", -> 162 | values = [] 163 | indices = [] 164 | sequence.onRemoval (v, i) -> values.push(v); indices.push(i) 165 | sequence.splice(2, 2, 'X') 166 | sequence.splice(5, 1) 167 | expect(values).toEqual ['c', 'd', 'g'] 168 | expect(indices).toEqual [2, 2, 5] 169 | -------------------------------------------------------------------------------- /spec/spec-helper.coffee: -------------------------------------------------------------------------------- 1 | require 'coffee-cache' 2 | jasmine.getEnv().addEqualityTester(require('underscore-plus').isEqual) 3 | 4 | Model = require '../src/model' 5 | beforeEach -> Model.resetNextInstanceId() 6 | -------------------------------------------------------------------------------- /src/model.coffee: -------------------------------------------------------------------------------- 1 | {Behavior, Subscriber, Emitter} = require 'emissary' 2 | PropertyAccessors = require 'property-accessors' 3 | Delegator = require 'delegato' 4 | 5 | nextInstanceId = 1 6 | 7 | module.exports = 8 | class Model 9 | Subscriber.includeInto(this) 10 | Emitter.includeInto(this) 11 | PropertyAccessors.includeInto(this) 12 | Delegator.includeInto(this) 13 | 14 | @resetNextInstanceId: -> nextInstanceId = 1 15 | 16 | @properties: (args...) -> 17 | if typeof args[0] is 'object' 18 | @property name, defaultValue for name, defaultValue of args[0] 19 | else 20 | @property arg for arg in args 21 | 22 | @property: (name, defaultValue) -> 23 | @declaredProperties ?= {} 24 | @declaredProperties[name] = defaultValue 25 | 26 | @::accessor name, 27 | get: -> @get(name) 28 | set: (value) -> @set(name, value) 29 | 30 | @::accessor "$#{name}", 31 | get: -> @behavior(name) 32 | 33 | @behavior: (name, definition) -> 34 | @declaredBehaviors ?= {} 35 | @declaredBehaviors[name] = definition 36 | 37 | @::accessor name, 38 | get: -> @behavior(name).getValue() 39 | 40 | @::accessor "$#{name}", 41 | get: -> @behavior(name) 42 | 43 | @hasDeclaredProperty: (name) -> 44 | @declaredProperties?.hasOwnProperty(name) 45 | 46 | @hasDeclaredBehavior: (name) -> 47 | @declaredBehaviors?.hasOwnProperty(name) 48 | 49 | @evaluateDeclaredBehavior: (name, instance) -> 50 | @declaredBehaviors[name].call(instance) 51 | 52 | declaredPropertyValues: null 53 | behaviors: null 54 | alive: true 55 | 56 | constructor: (params) -> 57 | @assignId(params?.id) 58 | for propertyName of @constructor.declaredProperties 59 | if params?.hasOwnProperty(propertyName) 60 | @set(propertyName, params[propertyName]) 61 | else 62 | @setDefault(propertyName) unless @get(propertyName, true)? 63 | 64 | assignId: (id) -> 65 | @id ?= id ? nextInstanceId++ 66 | 67 | setDefault: (name) -> 68 | defaultValue = @constructor.declaredProperties?[name] 69 | defaultValue = defaultValue.call(this) if typeof defaultValue is 'function' 70 | @set(name, defaultValue) 71 | 72 | get: (name, suppressDefault) -> 73 | if @constructor.hasDeclaredProperty(name) 74 | @declaredPropertyValues ?= {} 75 | @setDefault(name) unless suppressDefault or @declaredPropertyValues.hasOwnProperty(name) 76 | @declaredPropertyValues[name] 77 | else 78 | @[name] 79 | 80 | set: (name, value) -> 81 | if typeof name is 'object' 82 | properties = name 83 | @set(name, value) for name, value of properties 84 | properties 85 | else 86 | unless @get(name, true) is value 87 | if @constructor.hasDeclaredProperty(name) 88 | @declaredPropertyValues ?= {} 89 | @declaredPropertyValues[name] = value 90 | else 91 | @[name] = value 92 | @behaviors?[name]?.emitValue(value) 93 | value 94 | 95 | @::advisedAccessor 'id', 96 | set: (id) -> nextInstanceId = id + 1 if id >= nextInstanceId 97 | 98 | behavior: (name) -> 99 | @behaviors ?= {} 100 | if behavior = @behaviors[name] 101 | behavior 102 | else 103 | if @constructor.hasDeclaredProperty(name) 104 | @behaviors[name] = new Behavior(@get(name)).retain() 105 | else if @constructor.hasDeclaredBehavior(name) 106 | @behaviors[name] = @constructor.evaluateDeclaredBehavior(name, this).retain() 107 | 108 | when: (signal, action) -> 109 | @subscribe signal, (value) => 110 | if value 111 | if typeof action is 'function' 112 | action.call(this) 113 | else 114 | this[action]() 115 | 116 | destroy: -> 117 | return unless @isAlive() 118 | @alive = false 119 | @destroyed?() 120 | @unsubscribe() 121 | behavior.release() for name, behavior of @behaviors 122 | @emit 'destroyed' 123 | 124 | isAlive: -> @alive 125 | 126 | isDestroyed: -> not @isAlive() 127 | -------------------------------------------------------------------------------- /src/sequence.coffee: -------------------------------------------------------------------------------- 1 | {isEqual} = require 'underscore-plus' 2 | {Emitter} = require 'emissary' 3 | PropertyAccessors = require 'property-accessors' 4 | 5 | module.exports = 6 | class Sequence extends Array 7 | Emitter.includeInto(this) 8 | PropertyAccessors.includeInto(this) 9 | 10 | suppressChangeEvents: false 11 | 12 | @fromArray: (array=[]) -> 13 | array = array.slice() 14 | array.__proto__ = @prototype 15 | array 16 | 17 | constructor: (elements...) -> 18 | return Sequence.fromArray(elements) 19 | 20 | set: (index, value) -> 21 | if index >= @length 22 | oldLength = @length 23 | removedValues = [] 24 | @[index] = value 25 | insertedValues = @[oldLength..index + 1] 26 | index = oldLength 27 | else 28 | removedValues = [@[index]] 29 | insertedValues = [value] 30 | @[index] = value 31 | 32 | @emitChanged {index, removedValues, insertedValues} 33 | 34 | splice: (index, count, insertedValues...) -> 35 | removedValues = super 36 | @emitChanged {index, removedValues, insertedValues} 37 | removedValues 38 | 39 | push: (insertedValues...) -> 40 | index = @length 41 | @suppressChangeEvents = true 42 | result = super 43 | @suppressChangeEvents = false 44 | @emitChanged {index, removedValues: [], insertedValues} 45 | result 46 | 47 | pop: -> 48 | @suppressChangeEvents = true 49 | result = super 50 | @suppressChangeEvents = false 51 | @emitChanged {index: @length, removedValues: [result], insertedValues: []} 52 | result 53 | 54 | unshift: (insertedValues...) -> 55 | @suppressChangeEvents = true 56 | result = super 57 | @suppressChangeEvents = false 58 | @emitChanged {index: 0, removedValues: [], insertedValues} 59 | result 60 | 61 | shift: -> 62 | @suppressChangeEvents = true 63 | result = super 64 | @suppressChangeEvents = false 65 | @emitChanged {index: 0, removedValues: [result], insertedValues: []} 66 | result 67 | 68 | isEqual: (other) -> 69 | (this is other) or isEqual((v for v in this), (v for v in other)) 70 | 71 | onEach: (callback) -> 72 | @forEach(callback) 73 | @on 'changed', ({index, insertedValues}) -> 74 | for value, i in insertedValues 75 | callback(value, index + i) 76 | 77 | onRemoval: (callback) -> 78 | @on 'changed', ({index, removedValues}) -> 79 | for value in removedValues 80 | callback(value, index) 81 | 82 | @::lazyAccessor '$length', -> 83 | @signal('changed').map(=> @length).distinctUntilChanged().toBehavior(@length) 84 | 85 | setLength: (length) -> 86 | if length < @length 87 | index = length 88 | removedValues = @[index..] 89 | insertedValues = [] 90 | @length = length 91 | @emitChanged {index, removedValues, insertedValues} 92 | else if length > @length 93 | index = @length 94 | removedValues = [] 95 | @length = length 96 | insertedValues = @[index..] 97 | @emitChanged {index, removedValues, insertedValues} 98 | 99 | emitChanged: (event) -> 100 | @emit 'changed', event unless @suppressChangeEvents 101 | -------------------------------------------------------------------------------- /src/theorist.coffee: -------------------------------------------------------------------------------- 1 | module.exports = 2 | Model: require './model' 3 | Sequence: require './sequence' 4 | --------------------------------------------------------------------------------