From 6690b9b195e3f926102d1fba3238dc69a04b8785 Mon Sep 17 00:00:00 2001 From: Nitwel Date: Tue, 9 Apr 2024 15:30:23 +0200 Subject: [PATCH] add Rdot --- app/addons/rdot/Rdot.gd | 201 ++++++++++++++++++++++++++++++++ app/addons/rdot/array.gd | 13 +++ app/addons/rdot/computed.gd | 64 ++++++++++ app/addons/rdot/graph.gd | 225 ++++++++++++++++++++++++++++++++++++ app/addons/rdot/node.gd | 38 ++++++ app/addons/rdot/state.gd | 50 ++++++++ app/addons/rdot/store.gd | 37 ++++++ 7 files changed, 628 insertions(+) create mode 100644 app/addons/rdot/Rdot.gd create mode 100644 app/addons/rdot/array.gd create mode 100644 app/addons/rdot/computed.gd create mode 100644 app/addons/rdot/graph.gd create mode 100644 app/addons/rdot/node.gd create mode 100644 app/addons/rdot/state.gd create mode 100644 app/addons/rdot/store.gd diff --git a/app/addons/rdot/Rdot.gd b/app/addons/rdot/Rdot.gd new file mode 100644 index 0000000..7b52ca3 --- /dev/null +++ b/app/addons/rdot/Rdot.gd @@ -0,0 +1,201 @@ +class_name R + +static func state(value: Variant, options: Dictionary={}): + if value is Dictionary: + return store(value) + + return State.new(value, options) + +static func store(value: Dictionary): + return RdotStore.new(value) + +static func computed(computation: Callable, options: Dictionary={}): + return Computed.new(computation, options) + +## DIY overloading of +## bind(target, prop, value) +## bind(target, prop, value, watch_signal) +## bind(target, prop, store, key, watch_signal) +static func bind(target, prop, value, arg1=null, arg2=null): + if value is RdotStore: + return _bind_store(target, prop, value, arg1, arg2) + + if value is State or value is Computed: + return _bind_state(target, prop, value, arg1) + + assert(false, "Invalid arguments to bind, value must be a R.State or a RdotStore") + +static func _bind_store(target, prop, store: RdotStore, key, watch_signal=null): + store._access_property(key) + + return _bind_state(target, prop, store._proxied_value[key], watch_signal) + +static func _bind_state(target, prop, value, watch_signal=null): + var graph = RdotGraph.getInstance() + + var watch_c = func(new_value): + value.value = new_value + + var c = computed(func(_arg): + var oldValue=target.get(prop) + + if oldValue != value.value: + target.set(prop, value.value) + ) + + if watch_signal: + watch_signal.connect(watch_c) + + graph.watcher.watch([c]) + c.do_get() + + return func(): + graph.watcher.unwatch([c]) + if watch_signal: + watch_signal.disconnect(watch_c) + +static func effect(callback: Callable): + var graph = RdotGraph.getInstance() + + var deconstructor := Callable() + var c = computed(func(_arg): + if !deconstructor.is_null(): + deconstructor.call() + + var result=callback.call(_arg) + + if result is Callable: + deconstructor=result + ) + + graph.watcher.watch([c]) + c.do_get() + + return func(): + if !deconstructor.is_null(): + deconstructor.call() + + graph.watcher.unwatch([c]) + +class State: + var node: RdotState + var value = null: + get: + return do_get() + set(value): + do_set(value) + + func _init(value: Variant, options: Dictionary={}): + var ref = RdotState.createSignal(value) + var node = ref[1] + self.node = node + node.wrapper = self + + if !options.is_empty(): + var equals = options.get("equals") + + if !equals.is_null(): + node.equals = equals + + func do_get(): + return RdotState.signalGetFn.call(self.node) + + func do_set(value: Variant): + var graph = RdotGraph.getInstance() + + assert(graph.isInNotificationPhase() == false, "Writes to signals not permitted during Watcher callback") + + var ref = self.node + + RdotState.signalSetFn.call(ref, value) + +class Computed: + var node: RdotComputed + var value = null: + get: + return do_get() + set(value): + pass + + func _init(computation: Callable, options: Dictionary={}): + var ref = RdotComputed.createdComputed(computation) + var node = ref[1] + node.consumerAllowSignalWrites = true + self.node = node + node.wrapper = self + + if options: + var equals = options.get("equals") + + if !equals.is_null(): + node.equals = equals + + func do_get(): + return RdotComputed.computedGet(node) + +class Watcher: + var node: RdotNode + + func _init(notify: Callable): + var node = RdotNode.new() + node.wrapper = self + node.consumerMarkedDirty = notify + node.consumerIsAlwaysLive = true + node.consumerAllowSignalWrites = false + node.producerNode = [] + self.node = node + + ## signals: Array[RState | RComputed] + func _assertSignals(signals: Array): + for s in signals: + assert(s is State or s is Computed, "Watcher expects signals to be RState or RComputed") + + func watch(signals:=[]): + _assertSignals(signals) + + var graph = RdotGraph.getInstance() + + var node = self.node + node.dirty = false + var prev = graph.setActiveConsumer(node) + for s in signals: + graph.producerAccessed(s.node) + + graph.setActiveConsumer(prev) + + func unwatch(signals: Array): + _assertSignals(signals) + + var graph = RdotGraph.getInstance() + + var node = self.node + graph.assertConsumerNode(node) + + var indicesToShift = [] + for i in range(node.producerNode.size()): + if signals.has(node.producerNode[i].wrapper): + graph.producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]) + indicesToShift.append(i) + + for idx in indicesToShift: + var lastIdx = node.producerNode.size() - 1 + node.producerNode[idx] = node.producerNode[lastIdx] + node.producerIndexOfThis[idx] = node.producerIndexOfThis[lastIdx] + + node.producerNode.pop_back() + node.producerIndexOfThis.pop_back() + node.nextProducerIndex -= 1 + + if idx < node.producerNode.size(): + var idxConsumer = node.producerNode[idx] + var producer = node.producerNode[idx] + + graph.assertProducerNode(producer) + + producer.liveConsumerIndexOfThis[idxConsumer] = idx + + ## Returns Array[RComputed] + func getPending() -> Array: + var node = self.node + + return node.producerNode.filter(func(n): return n.dirty).map(func(n): return n.wrapper) diff --git a/app/addons/rdot/array.gd b/app/addons/rdot/array.gd new file mode 100644 index 0000000..5638d60 --- /dev/null +++ b/app/addons/rdot/array.gd @@ -0,0 +1,13 @@ +class_name RdotArray + +static func do_set(array: Array, index: int, value): + if index >= array.size(): + array.resize(index + 1) + + array[index] = value + +static func do_get(array: Array, index: int): + if index >= array.size(): + return null + + return array[index] \ No newline at end of file diff --git a/app/addons/rdot/computed.gd b/app/addons/rdot/computed.gd new file mode 100644 index 0000000..3449042 --- /dev/null +++ b/app/addons/rdot/computed.gd @@ -0,0 +1,64 @@ +extends RdotNode +class_name RdotComputed + +enum State { + SET = 0, + UNSET = 1, + COMPUTING = 2, + ERRORED = 3 +} + +var value: Variant = null +var state: State = State.UNSET +var error = null +var computation := Callable() +var equal := func(this, a, b): return a == b + +static func computedGet(node: RdotComputed) -> Variant: + var graph := RdotGraph.getInstance() + + graph.producerUpdateValueVersion(node) + graph.producerAccessed(node) + + assert(node.state != State.ERRORED, "Error in computed.") + + return node.value + +static func createdComputed(computation: Callable): + var node = RdotComputed.new() + node.computation = computation + + var computed = func(): + return computedGet(node) + + return [computed, node] + +func producerMustRecompute(node: RdotNode) -> bool: + return node.state == State.UNSET or node.state == State.COMPUTING + +func _init(): + self.dirty = true + self.producerRecomputeValue = func(node: RdotNode): + assert(node.state != State.COMPUTING, "Detected cycle in computations.") + + var graph := RdotGraph.getInstance() + + var oldState = node.state + var oldValue = node.value + node.state = State.COMPUTING + + var prevConsumer = graph.consumerBeforeComputation(node) + var newValue = node.computation.call(node.wrapper) + var oldOk = oldState != State.UNSET&&oldState != State.ERRORED + var wasEqual = oldOk&&node.equal.call(node.wrapper, oldValue, newValue) + + graph.consumerAfterComputation(node, prevConsumer) + + if wasEqual: + node.value = oldValue + node.state = oldState + return + + node.value = newValue + node.state = State.SET + node.version += 1 \ No newline at end of file diff --git a/app/addons/rdot/graph.gd b/app/addons/rdot/graph.gd new file mode 100644 index 0000000..6fde0d0 --- /dev/null +++ b/app/addons/rdot/graph.gd @@ -0,0 +1,225 @@ +extends RefCounted +class_name RdotGraph + +static var instance: RdotGraph = null + +static func getInstance() -> RdotGraph: + if instance == null: + instance = RdotGraph.new() + return instance + +var activeConsumer: RdotNode = null +var inNotificationPhase := false + +var epoch := 1 + +var postSignalSetFn := Callable() +var watcherPending := false + +var watcher = R.Watcher.new(func(_arg): + if watcherPending: + return + + watcherPending=true + var endOfFrame=func(): + + watcherPending=false + for s in watcher.getPending(): + s.do_get() + + watcher.watch() + + endOfFrame.call_deferred() +) + +func setActiveConsumer(consumer: RdotNode) -> RdotNode: + var prev = activeConsumer + activeConsumer = consumer + return prev + +func getActiveConsumer() -> RdotNode: + return activeConsumer + +func isInNotificationPhase() -> bool: + return inNotificationPhase + +func producerAccessed(node: RdotNode): + assert(inNotificationPhase == false, "Signal read during notification phase") + + if activeConsumer == null: + return + + if activeConsumer.consumerOnSignalRead.is_null() == false: + activeConsumer.consumerOnSignalRead.call(node) + + var idx = activeConsumer.nextProducerIndex; + activeConsumer.nextProducerIndex += 1 + + assertConsumerNode(activeConsumer) + + if idx < activeConsumer.producerNode.size()&&activeConsumer.producerNode[idx] != node: + if consumerIsLive(activeConsumer): + var staleProducer = activeConsumer.producerNode[idx] + producerRemoveLiveConsumerAtIndex(staleProducer, activeConsumer.producerIndexOfThis[idx]) + + if RdotArray.do_get(activeConsumer.producerNode, idx) != node: + RdotArray.do_set(activeConsumer.producerNode, idx, node) + RdotArray.do_set(activeConsumer.producerIndexOfThis, idx, producerAddLiveConsumer(node, activeConsumer, idx) if consumerIsLive(activeConsumer) else 0) + + RdotArray.do_set(activeConsumer.producerLastReadVersion, idx, node.version) + +func producerIncrementEpoch(): + epoch += 1 + +func producerUpdateValueVersion(node: RdotNode): + if consumerIsLive(node)&&!node.dirty: + return + + if !node.dirty&&node.lastCleanEpoch == epoch: + return + + if !node.producerMustRecompute(node)&&!consumerPollProducersForChange(node): + node.dirty = false; + node.lastCleanEpoch = epoch + return + + if node.producerRecomputeValue.is_null() == false: + node.producerRecomputeValue.call(node) + + node.dirty = false + node.lastCleanEpoch = epoch + +func producerNotifyConsumers(node: RdotNode): + if node.liveConsumerNode == null: + return + + var prev = inNotificationPhase + inNotificationPhase = true + + for consumer in node.liveConsumerNode: + if !consumer.dirty: + consumerMarkDirty(consumer) + + inNotificationPhase = prev + +func producerUpdatesAllowed() -> bool: + return activeConsumer == null||activeConsumer.consumerAllowSignalWrites != false + +func consumerMarkDirty(node: RdotNode): + node.dirty = true + producerNotifyConsumers(node) + + if node.consumerMarkedDirty.is_null() == false: + node.consumerMarkedDirty.call(node) + +func consumerBeforeComputation(node: RdotNode) -> RdotNode: + if node: + node.nextProducerIndex = 0 + + return setActiveConsumer(node) + +func consumerAfterComputation(node: RdotNode, prevConsumer: RdotNode): + setActiveConsumer(prevConsumer) + + if node == null||node.producerNode == null||node.producerIndexOfThis == null||node.producerLastReadVersion == null: + return + + if consumerIsLive(node): + for i in range(node.nextProducerIndex, node.producerNode.size()): + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]) + + while node.producerNode.size() > node.nextProducerIndex: + node.producerNode.pop_back() + node.producerLastReadVersion.pop_back() + node.producerIndexOfThis.pop_back() + +func consumerPollProducersForChange(node: RdotNode) -> bool: + assertConsumerNode(node) + + for i in range(node.producerNode.size()): + var producer = node.producerNode[i] + var seenVersion = node.producerLastReadVersion[i] + + if seenVersion != producer.version: + return true + + producerUpdateValueVersion(producer) + + if seenVersion != producer.version: + return true + + return false + +func consumerDestroy(node: RdotNode): + assertConsumerNode(node) + + if consumerIsLive(node): + for i in range(node.producerNode.size()): + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]) + + node.producerNode.clear() + node.producerLastReadVersion.clear() + node.producerIndexOfThis.clear() + + if node.liveConsumerNode: + node.liveConsumerNode.clear() + node.liveConsumerIndexOfThis.clear() + +static func producerAddLiveConsumer(node: RdotNode, consumer: RdotNode, indexOfThis: int) -> int: + assertProducerNode(node) + assertConsumerNode(node) + + if node.liveConsumerNode.size() == 0: + if node.watched.is_null() == false: + node.watched.call(node.wrapper) + + for i in range(node.producerNode.size()): + node.producerIndexOfThis[i] = producerAddLiveConsumer(node.producerNode[i], node, i) + + node.liveConsumerIndexOfThis.push_back(indexOfThis) + node.liveConsumerNode.push_back(consumer) + + return node.liveConsumerNode.size() - 1 + +static func producerRemoveLiveConsumerAtIndex(node: RdotNode, idx: int): + assertProducerNode(node) + assertConsumerNode(node) + + assert(idx < node.liveConsumerNode.size(), "active consumer index %s is out of bounds of %s consumers)" % [idx, node.liveConsumerNode.size()]) + + if node.liveConsumerNode.size() == 1: + if node.unwatched.is_null() == false: + node.unwatched.call(node.wrapper) + + for i in range(node.producerNode.size()): + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]) + + var lastIdx = node.liveConsumerNode.size() - 1 + node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx] + node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx] + + node.liveConsumerNode.pop_back() + node.liveConsumerIndexOfThis.pop_back() + + if idx < node.liveConsumerNode.size(): + var idxProducer = node.liveConsumerIndexOfThis[idx] + var consumer = node.liveConsumerNode[idx] + assertConsumerNode(consumer) + consumer.producerIndexOfThis[idxProducer] = idx + +static func consumerIsLive(node: RdotNode) -> bool: + return node.consumerIsAlwaysLive||(node.liveConsumerNode != null&&node.liveConsumerNode.size() > 0) + +static func assertConsumerNode(node: RdotNode): + if node.producerNode == null: + node.producerNode = [] + if node.producerIndexOfThis == null: + node.producerIndexOfThis = [] + if node.producerLastReadVersion == null: + node.producerLastReadVersion = [] + +static func assertProducerNode(node: RdotNode): + if node.liveConsumerNode == null: + node.liveConsumerNode = [] + if node.liveConsumerIndexOfThis == null: + node.liveConsumerIndexOfThis = [] \ No newline at end of file diff --git a/app/addons/rdot/node.gd b/app/addons/rdot/node.gd new file mode 100644 index 0000000..f8b273e --- /dev/null +++ b/app/addons/rdot/node.gd @@ -0,0 +1,38 @@ +extends RefCounted +class_name RdotNode + +var version := 0 +var lastCleanEpoch := 0 +var dirty := false + +## Array[RdotNode] | null +var producerNode = null + +## Array[int] | null +var producerLastReadVersion = null + +## Array[int] | null +var producerIndexOfThis = null +var nextProducerIndex := 0 + +## Array[RdotNode] | null +var liveConsumerNode = null + +## Array[int] | null +var liveConsumerIndexOfThis = null +var consumerAllowSignalWrites := false +var consumerIsAlwaysLive := false + +var watched: Callable = Callable() +var unwatched: Callable = Callable() +var wrapper + +func producerMustRecompute(node: RdotNode) -> bool: + return false + +var producerRecomputeValue: Callable = Callable() +var consumerMarkedDirty: Callable = Callable() +var consumerOnSignalRead: Callable = Callable() + +# func _to_string(): +# return "RdotNode {\n" + ",\n\t".join(get_property_list().map(func(dict): return dict.name + ": " + str(get(dict.name)))) + "\n}" diff --git a/app/addons/rdot/state.gd b/app/addons/rdot/state.gd new file mode 100644 index 0000000..dcbdc20 --- /dev/null +++ b/app/addons/rdot/state.gd @@ -0,0 +1,50 @@ +extends RdotNode +class_name RdotState + +var equal: Callable = func(this, a, b): a == b +var value: Variant = null + +static func createSignal(initialValue: Variant): + var node = RdotState.new() + node.value = initialValue + + var getter = func(): + RdotGraph.getInstance().producerAccessed(node) + return node.value + + return [getter, node] + +static func setPostSignalSetFn(fn: Callable) -> Callable: + var graph := RdotGraph.getInstance() + var prev = graph.postSignalSetFn + graph.postSignalSetFn = fn + return prev + +static func signalGetFn(this: RdotState): + RdotGraph.getInstance().producerAccessed(this) + return this.value + +static func signalSetFn(node: RdotState, newValue: Variant): + var graph := RdotGraph.getInstance() + + assert(graph.producerUpdatesAllowed()) + + if !node.equal.call(node.wrapper, node.value, newValue): + node.value = newValue + signalValueChanged(node) + +static func signalUpdateFn(node: RdotState, updater: Callable): + var graph := RdotGraph.getInstance() + + assert(graph.producerUpdatesAllowed()) + + signalSetFn(node, updater.call(node.value)) + +static func signalValueChanged(node: RdotState): + var graph := RdotGraph.getInstance() + + node.version += 1 + graph.producerIncrementEpoch() + graph.producerNotifyConsumers(node) + if !graph.postSignalSetFn.is_null(): + graph.postSignalSetFn.call() diff --git a/app/addons/rdot/store.gd b/app/addons/rdot/store.gd new file mode 100644 index 0000000..d3ef73f --- /dev/null +++ b/app/addons/rdot/store.gd @@ -0,0 +1,37 @@ +extends Object +class_name RdotStore + +var _proxied_value = {} +var _property_list = [] + +func _init(initial_value: Dictionary={}): + _proxied_value = initial_value + + _property_list = _proxied_value.keys().map(func(key): + return { + "name": key, + "type": typeof(_proxied_value[key]) + } + ) + +func _get(property): + _access_property(property) + + if _proxied_value[property] is RdotStore: + return _proxied_value[property] + + return _proxied_value[property].value + +func _set(property, value): + _access_property(property) + + _proxied_value[property].value = value + + return true + +func _access_property(property): + if (_proxied_value[property] is R.State) == false: + _proxied_value[property] = R.state(_proxied_value[property]) + +func _get_property_list(): + return _property_list