diff --git a/Heimdall.toc b/Heimdall.toc index 7affc63..e587394 100644 --- a/Heimdall.toc +++ b/Heimdall.toc @@ -7,6 +7,7 @@ #core Modules/CLEUParser.lua +Modules/ReactiveValue.lua Modules/DumpTable.lua Modules/Spotter.lua Modules/Whoer.lua diff --git a/Modules/ReactiveValue.lua b/Modules/ReactiveValue.lua new file mode 100644 index 0000000..40ca42b --- /dev/null +++ b/Modules/ReactiveValue.lua @@ -0,0 +1,667 @@ +local function Init() + local metadata = { + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return ReactiveValue|nil + __add = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value + other._value + end + if otherType == "string" and self._type == otherType then + return self._value .. other + end + if otherType == "number" and self._type == otherType then + return self._value + other + end + return nil + end, + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return ReactiveValue|nil + __mul = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value * other._value + end + if otherType == "number" and self._type == otherType then + return self._value * other + end + return nil + end, + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return ReactiveValue|nil + __sub = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value - other._value + end + if otherType == "number" and self._type == otherType then + return self._value - other + end + return nil + end, + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return ReactiveValue|nil + __div = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value / other._value + end + if otherType == "number" and self._type == otherType then + return self._value / other + end + return nil + end, + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return ReactiveValue|nil + __mod = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value % other._value + end + if otherType == "number" and self._type == otherType then + return self._value % other + end + return nil + end, + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return ReactiveValue|nil + __pow = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value ^ other._value + end + if otherType == "number" and self._type == otherType then + return self._value ^ other + end + return nil + end, + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return boolean + __eq = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value == other._value + end + return self._value == other + end, + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return boolean + __lt = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value < other._value + end + return self._value < other + end, + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return boolean + __le = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value <= other._value + end + return self._value <= other + end, + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return boolean + __gt = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value > other._value + end + return self._value > other + end, + ---@param self ReactiveValue + ---@param other ReactiveValue + ---@return boolean + __ge = function(self, other) + local otherType = type(other) + if otherType == "table" and other._type and other._type == self._type and other._value then + return self._value >= other._value + end + return self._value >= other + end, + ---@param self ReactiveValue + ---@return number + __len = function(self) + if self._type == "table" then + return #self._value + end + if self._type == "string" then + return string.len(self._value) + end + return 0 + end, + ---@param self ReactiveValue + ---@return string + __tostring = function(self) + return tostring(self._value) + end, + ---@param self ReactiveValue + ---@param key string + ---@param value any + ---@return nil + __newindex = function(self, key, value) + local setupComplete = rawget(self, "_setupComplete") + if setupComplete == nil or setupComplete == false then + rawset(self, key, value) + return + end + if self._type ~= "table" then + rawset(self, key, value) + return + end + + self._value[key] = value + local ChangedKey = { key } + + -- If the value being assigned is a ReactiveValue + -- Then listen to changes on it as well + -- And propagate those changes upwards + if self._recursive and getmetatable(value) == getmetatable(self) then + self:_setupListeners(key, value) + end + + self:_notify() + self:_notifyFieldChanged(ChangedKey) + self:_notifyAnyFieldChanged(ChangedKey) + end, + ---@param self ReactiveValue + ---@param key string + ---@return any|nil + __index = function(self, key) + local value = rawget(self, key) + if value ~= nil then + return value + end + if rawget(self, "_type") ~= "table" then + return nil + end + local innerTable = rawget(self, "_value") + if innerTable ~= nil then + return rawget(innerTable, key) + end + return nil + end + -- __index = ReactiveValue + } + + --- Sadly I could not get @generic to play nice with this class + --- I think it's not ready yet, there are issues on github describing similar problems and it is marked as WIP... + --- Guess I'll have to live without it for now and specify type of a RV in #type + + ---## A type safe value that can be listened to for changes + ---### **Always use RV:set() for setting primitive values** + --- Supports primitive values and tables
+ --- Tables can be listened to for changes on any field or a specific field
+ ---### Example usage (value):
+ --- ```lua + --- local test = ReactiveValue.new(1) + --- test:onChange(function(value) + --- print("test changed to " .. value) + --- end) + --- test:set(2) + --- test:set(test + 3) + --- ``` + ---### Example usage (table):
+ --- ```lua + --- local test = ReactiveValue.new({1, 2, 3}) + --- test:onAnyFieldChange(function(field, value) + --- print(string.format("test.%s changed to %s", table.concat(field, "."), value)) + --- end) + --- test[1] = 4 -- test.1 changed to 4 + --- test[4] = {1, 2, 3} -- test.4 changed to + --- test[4][1] = 14 -- No log(!!) because test[4] is a table and not a ReactiveValue + --- ``` + ---### To trigger a callback for `test[4][1]` in the previous example do:
+ --- ```lua + --- local test = ReactiveValue.new({1, 2, 3}, true) + --- test:onAnyFieldChange(function(field, value) + --- print(string.format("test.%s changed to %s", table.concat(field, "."), value)) + --- end) + --- test[1] = 4 -- test.1 changed to 4 + --- test[4] = {1, 2, 3} -- test.4 changed to
+ --- test[4][1] = 14 -- test.4.1 changed to 14 + --- ``` + ---### To listen to a specific field of a table do:
+ --- ```lua + --- local test = ReactiveValue.new({1, 2, 3}, true) + --- test:onFieldChange("1", function(value) + --- print("test.1 changed to " .. value) + --- end) + --- test[1] = 4 -- test.1 changed to 4 + --- test[4] = {1, 2, 3} -- Does not trigger callback + -- ``` + ---@class ReactiveValue + ---@field _listeners table + ---@field _fieldListeners table> + ---@field _anyFieldListeners table> + ---@field _oneTimeListeners table + ---@field _value any + ---@field _type string + ---@field _recursive boolean? + ReactiveValue = { + ---#### Get the underlying value of a ReactiveValue + ---@param self ReactiveValue + ---@return any + get = function(self) end, + + ---### Set the underlying value of a ReactiveValue triggering listener callbacks + ---@param self ReactiveValue + ---@param newValue any + set = function(self, newValue) end, + + ---## EVENT + ---### Register a listener that is triggered whenever the underlying value changes + --- Returns a function that can be called to undo the callback + ---@param self ReactiveValue + ---@param callback fun(value: any, type: string) + ---@return fun(): nil + onChange = function(self, callback) end, + + ---## EVENT + ---### Register a listener that is triggered whenever a specific field of a table changes + --- Returns a function that can be called to undo the callback + ---@param self ReactiveValue + ---@param field string + ---@param callback fun(field: string[], value: any, type: string) + ---@return fun(): nil + onFieldChange = function(self, field, callback) end, + + ---## EVENT + ---### Register a listener that is triggered whenever any field of a table changes + --- Returns a function that can be called to undo the callback + ---@param self ReactiveValue + ---@param callback fun(field: string[], value: any, type: string) + ---@param depth number? How deep to listen for changes + ---@return fun(): nil + onAnyFieldChange = function(self, callback, depth) end, + + ---## EVENT + ---### Register a listener that is triggered ONCE whenever the underlying value changes + --- Returns a function that can be called to undo the callback + ---@param self ReactiveValue + ---@param callback fun(value: any, type: string) + ---@return fun(): nil + once = function(self, callback) end, + ---### Setup listeners for all fields of a table recursively + --- This is used to ensure that listeners are notified recursively + + ---@param self ReactiveValue + _setupAllListenersRecursively = function(self) end, + ---### Setup listeners for a specific field of a table recursively + --- This is used to ensure that listeners are notified recursively + + ---@param self ReactiveValue + _setupListeners = function(self, key, value, recursive) end, + + ---### Notify listeners that the underlying value has changed + ---@param self ReactiveValue + ---@return nil + ---#### Event contains: + --- 2. value: any - The new value of the changed field + --- 3. type: string - The type of the new value of the changed field + _notify = function(self) end, + + ---### Notify listeners that a specific field of the underlying value has changed + ---#### Event contains: + --- 1. field: table - A list of keys that lead to the changed field + --- 2. value: any - The new value of the changed field + --- 3. type: string - The type of the new value of the changed field + ---@param self ReactiveValue + _notifyFieldChanged = function(self, field) end, + + ---### Notify listeners that any field of the underlying value has changed + ---#### Event contains: + --- 1. field: table - A list of keys that lead to the changed field + --- 2. value: any - The new value of the changed field + --- 3. type: string - The type of the new value of the changed field + _notifyAnyFieldChanged = function(self, field) end, + } + ---### Constructor + ---@param initialValue any + ---@param recursive boolean? + ---@return ReactiveValue + ReactiveValue.new = function(initialValue, recursive) + local self = setmetatable({}, metadata) + self._listeners = {} + self._fieldListeners = {} + self._anyFieldListeners = {} + self._oneTimeListeners = {} + self._value = initialValue + self._type = type(initialValue) + self._recursive = recursive or false + + ---@return any + self.get = function(self) + return self._value + end + ---@param newValue any + self.set = function(self, newValue) + if self._value == newValue then + return + end + if type(newValue) ~= self._type then + error("Expected " .. self._type .. ", got " .. type(newValue)) + return + end + self._value = newValue + self:_notify() + end + self.onChange = function(self, callback) + if type(callback) ~= "function" then + error("Expected function, got " .. type(callback)) + return function() end + end + self._listeners[callback] = true + return function() + self._listeners[callback] = nil + end + end + self.onFieldChange = function(self, field, callback) + if type(callback) ~= "function" then + error("Expected function, got " .. type(callback)) + return function() end + end + if self._fieldListeners[field] == nil then + self._fieldListeners[field] = {} + end + self._fieldListeners[field][callback] = true + return function() + self._fieldListeners[field][callback] = nil + end + end + self.onAnyFieldChange = function(self, callback, depth) + depth = depth or 99999 + if type(callback) ~= "function" then + error("Expected function, got " .. type(callback)) + return function() end + end + if self._anyFieldListeners[depth] == nil then + self._anyFieldListeners[depth] = {} + end + self._anyFieldListeners[depth][callback] = true + return function() + self._anyFieldListeners[depth][callback] = nil + end + end + self.once = function(self, callback) + if type(callback) ~= "function" then + error("Expected function, got " .. type(callback)) + return function() end + end + self._oneTimeListeners[callback] = true + return function() + self._oneTimeListeners[callback] = nil + end + end + + self._setupAllListenersRecursively = function(self) + if self._type ~= "table" then + return + end + for key, value in pairs(self._value) do + self:_setupListeners(key, value, true) + end + end + ---@param key string + ---@param value any + ---@param recursive boolean? + self._setupListeners = function(self, key, value, recursive) + recursive = recursive or false + if self._type ~= "table" then + return + end + if getmetatable(value) ~= getmetatable(self) then + return + end + value._recursive = true + if value._type == "table" then + value:onAnyFieldChange(function(key2) + ChangedKey = { key, table.unpack(key2) } + self:_notifyFieldChanged(ChangedKey) + self:_notifyAnyFieldChanged(ChangedKey) + end) + else + value:onChange(function(newVal) + ChangedKey = { key } + self:_notifyFieldChanged(ChangedKey) + self:_notifyAnyFieldChanged(ChangedKey) + end) + end + + if recursive then + value:_setupAllListenersRecursively() + end + end + + if recursive then + self:_setupAllListenersRecursively() + end + + self._notify = function(self) + for listener, _ in pairs(self._oneTimeListeners) do + -- task.spawn(listener, self._value, self._type) + listener(self._value, self._type) + self._oneTimeListeners[listener] = nil + end + for listener, _ in pairs(self._listeners) do + -- task.spawn(listener, self._value, self._type) + listener(self._value, self._type) + end + end + -- TODO: Maybe implement some sort of regex here or something... + -- Such as listening to *.field1 or something + -- But this (having to loop over listeners and evaluate some condition) would tank performance + -- Compared to a simple lookup + -- So I'm not going to do anything about it for now, until I figure out a better way + ---@param field table A list of keys that lead to the changed field + ---@return nil + self._notifyFieldChanged = function(self, field) + local value = self._value + for _, key in ipairs(field) do + value = value[key] + end + + local strfield = table.concat(field, ".") + if self._fieldListeners[strfield] == nil then + return + end + for listener, _ in pairs(self._fieldListeners[strfield]) do + -- task.spawn(listener, value, type(value)) + listener(value, type(value)) + end + end + ---@param self ReactiveValue + ---@param field table A list of keys that lead to the changed field + ---@return nil + self._notifyAnyFieldChanged = function(self, field) + local value = self._value + for _, key in ipairs(field) do + value = value[key] + end + local keyDepth = #field + for listenerDepth, listeners in pairs(self._anyFieldListeners) do + if listenerDepth >= keyDepth then + for listener, _ in pairs(listeners) do + -- The reason this also returns type(value) is so that clients don't have to compute type(value) + -- I assume some of them might want to do it so computing it once is probably better than having every client compute it for themselves + -- task.spawn(listener, field, value, type(value)) + listener(field, value, type(value)) + end + end + end + end + + self._setupComplete = true + return self + end + + -- S -- begintest + -- S local invocations = 0 + -- S -- Integer example + -- S local test = ReactiveValue.new(1) + -- S test:onChange(function(value) + -- S invocations = invocations + 1 + -- S print("test changed to " .. value) + -- S end) + -- S test:set(2) + -- S assert(invocations == 1) + -- S + -- S invocations = 0 + -- String example + -- S test = ReactiveValue.new("test") + -- S test:onChange(function(value) + -- S invocations = invocations + 1 + -- S print("test changed to " .. value) + -- S end) + -- S test:set("test2") + -- S assert(invocations == 1) + -- S + -- S -- Type safety example + -- S local res, err = pcall(test.set, test, 1) + -- S assert(res == false) + -- S assert(err:find("Expected string, got number")) + -- S + -- S -- Table example + -- S invocations = 0 + -- S test = ReactiveValue.new({1, 2, 3}) + -- S local clbk = test:onChange(function(value) + -- S invocations = invocations + 1 + -- S print("test changed to") + -- S dumpTable(value, 0) + -- S end) + -- S test:set({1, 2, 3, 4}) + -- S assert(invocations == 1) + -- S + -- S -- Callback removal example + -- S clbk() + -- S + -- S invocations = 0 + -- S -- Any field change example + -- S clbk = test:onAnyFieldChange(function(field, value) + -- S invocations = invocations + 1 + -- S print("test." .. table.concat(field, ".") .. " changed to " .. tostring(value)) + -- S end) + -- S test.Pero = 1 + -- S test.Pero = nil + -- S assert(invocations == 2) + -- S clbk() + -- S + -- S invocations = 0 + -- S -- Field change example + -- S test:onFieldChange("Pero", function(value) + -- S invocations = invocations + 1 + -- S print("test.Pero changed to " .. value) + -- S end) + -- S test.Pero = 2 + -- S assert(invocations == 1) + -- S + -- S invocations = 0 + -- S -- One time listener example + -- S test:once(function(value) + -- S invocations = invocations + 1 + -- S print("test changed to") + -- S dumpTable(value, 0) + -- S end) + -- S test:set({3, 2, 1}) + -- S assert(invocations == 1) + -- S + -- S invocations = 0 + -- S -- Table push example + -- S test = ReactiveValue.new({}) + -- S test:onChange(function(value) + -- S invocations = invocations + 1 + -- S print("test changed to") + -- S dumpTable(value, 0) + -- S end) + -- S test:onAnyFieldChange(function(field, value) + -- S invocations = invocations + 1 + -- S print("test." .. table.concat(field, ".") .. " changed to " .. value) + -- S end) + -- S test[#test + 1] = 4 + -- S assert(invocations == 2) + -- S + -- S invocations = 0 + -- S test = ReactiveValue.new({ + -- S name = "pero", + -- S coins = ReactiveValue.new(1) + -- S }) + -- S test.coins:onChange(function(value) + -- S invocations = invocations + 1 + -- S print("test.coins changed to " .. value) + -- S end) + -- S test.coins:set(2) + -- S assert(invocations == 1) + -- S + -- S invocations = 0 + -- S test = ReactiveValue.new({ + -- S name = "pero", + -- S coins = ReactiveValue.new(1) + -- S }, true) + -- S test:onAnyFieldChange(function(field, value) + -- S invocations = invocations + 1 + -- S print("test." .. table.concat(field, ".") .. " changed to " .. tostring(value)) + -- S end) + -- S test.coins:set(2) + -- S test.pero2 = ReactiveValue.new({}) + -- S test.pero2.coins = ReactiveValue.new(1) + -- S test.pero2.coins:set(2) + -- S assert(invocations == 4) + -- S + -- S invocations = 0 + -- S test = ReactiveValue.new({ + -- S name = "pero", + -- S coins = ReactiveValue.new({ + -- S value = ReactiveValue.new(1) + -- S }) + -- S }, true) + -- S test:onAnyFieldChange(function(field, value) + -- S invocations = invocations + 1 + -- S print("test." .. table.concat(field, ".") .. " changed to " .. tostring(value)) + -- S end) + -- S test.coins.value:set(2) + -- S assert(invocations == 1) + -- S + -- S invocations = 0 + -- S test = ReactiveValue.new({}, true) + -- S test.coins = ReactiveValue.new({}) + -- S test.coins.value = ReactiveValue.new(1) + -- S test:onAnyFieldChange(function(field, value) + -- S invocations = invocations + 1 + -- S print("test." .. table.concat(field, ".") .. " changed to " .. tostring(value)) + -- S end) + -- S test.coins.value:set(3) + -- S assert(invocations == 1) + --S + --S invocations = 0 + --S test = ReactiveValue.new({}, true) + --S test:onAnyFieldChange(function(field, value) + --S invocations = invocations + 1 + --S print("test." .. table.concat(field, ".") .. " changed to " .. tostring(value)) + --S end, 1) + --S test.test2 = ReactiveValue.new({}, true) + --S test.test2.test3 = ReactiveValue.new(1) + --S assert(invocations == 1) + --S + -- S -- endtest +end + +local frame = CreateFrame("Frame") +frame:RegisterEvent("PLAYER_LOGIN") +frame:RegisterEvent("PLAYER_ENTERING_WORLD") +frame:RegisterEvent("GUILD_ROSTER_UPDATE") +frame:SetScript("OnEvent", function(self, event, ...) + Init() +end) +Init()