--!strict --[=[ Throttles execution of a function with configurable leading and trailing behavior. @class ThrottledFunction ]=] --[=[ @interface ThrottleConfig .leading boolean? -- If true, will dispatch immediately after creating this ThrottledFunction. .trailing boolean? -- If true, will dispatch after the timeout with the latest-called args. .leadingFirstTimeOnly boolean? -- If true, will dispatch immediately after creating this ThrottledFunction, but from then on, will begin the window upon manual call and delay dispatch until seconds have passed (with latest-called args). @within ThrottledFunction ]=] export type ThrottleConfig = { leading: boolean?, trailing: boolean?, leadingFirstTimeOnly: boolean?, } local ThrottledFunction = {} ThrottledFunction.ClassName = "ThrottledFunction" ThrottledFunction.__index = ThrottledFunction export type Func = (T...) -> ...any export type ThrottledFunction = typeof(setmetatable( {} :: { _nextCallTimeStamp: number, _timeout: number, _func: Func, _trailingValue: any, _callLeading: boolean, _callTrailing: boolean, _callLeadingFirstTime: boolean?, _delayedDispatchThread: thread?, }, {} :: typeof({ __index = ThrottledFunction }) )) --[=[ @function new @within ThrottledFunction @param timeoutInSeconds number -- The (minimum) time in seconds to wait between each function dispatch; the "cooldown". @param func function -- The actual function whose calls will be throttled. @param config? ThrottleConfig -- The configuration for how throttling will behave. @return ThrottledFunction ]=] function ThrottledFunction.new( timeoutInSeconds: number, func: Func, config: ThrottleConfig? ): ThrottledFunction local self: ThrottledFunction = setmetatable({} :: any, ThrottledFunction) self._nextCallTimeStamp = 0 self._timeout = timeoutInSeconds or error("No timeoutInSeconds") self._func = func or error("No func") self._trailingValue = nil self._callLeading = true self._callTrailing = true self._delayedDispatchThread = nil self:_configureOrError(config or { leading = true, trailing = true, }) return self end --[=[ If leading = true, will enable Call() dispatching immediately after creating this ThrottledFunction. Else, will have to wait seconds before it dispatches with the latest-called args. If trailing = true, will dispatch after the timeout with the latest-called args. Else, will not automatically dispatch, and must manually call again after seconds. If leadingFirstTimeOnly = true, will enable Call() dispatching immediately after creating this ThrottledFunction, but from then on, will begin the window upon manual call and delay dispatch until seconds have passed (with latest-called args). @function Call @within ThrottledFunction ]=] function ThrottledFunction.Call(self: ThrottledFunction, ...: T...) local now = os.clock() if self._trailingValue then -- If it's not nil, we're likely in the middle of the cooldown window -- so all we can do is update the trailing value, waiting for the delayed dispatch to reset it to nil. self._trailingValue = table.pack(...) return end if self._nextCallTimeStamp <= now then -- We're outside the cooldown window if self._callLeading or self._callLeadingFirstTime then -- Dispatch immediately self._callLeadingFirstTime = false self._nextCallTimeStamp = now + self._timeout self._func(...) elseif self._callTrailing then -- Leading is disabled, but trailing is enabled; schedule for trailing. self:_scheduleTrailing(self._timeout, ...) else error("[ThrottledFunction.Call] - Trailing and leading are both disabled") end return end if self._callTrailing then -- We have no trailing value; it was dispatched a bit ago, or we just created this ThrottledFunction. -- We're inside the cooldown window, so it's not dispatched/created that far ago. (we can't dispatch immediately.) -- We should supply a trailing value, without immediately dispatching. self._callLeadingFirstTime = false local remainingTime = math.max(0, self._nextCallTimeStamp - now) self:_scheduleTrailing(remainingTime, ...) end -- But if we don't have trailing, best to ignore the call (the args are dropped.) end ThrottledFunction.__call = ThrottledFunction.Call function ThrottledFunction._scheduleTrailing(self: ThrottledFunction, delayTime: number, ...: T...) self._trailingValue = table.pack(...) if self._delayedDispatchThread then task.cancel(self._delayedDispatchThread) end self._delayedDispatchThread = task.delay(delayTime, function() if self.Destroy then self:_dispatch() end end) end function ThrottledFunction._dispatch(self: ThrottledFunction) self._nextCallTimeStamp = os.clock() + self._timeout self._delayedDispatchThread = nil local trailingValue = self._trailingValue if trailingValue then -- Clear before call so we are in valid state! self._trailingValue = nil self._func(unpack(trailingValue, 1, trailingValue.n)) end end function ThrottledFunction._configureOrError(self: ThrottledFunction, throttleConfig: ThrottleConfig) if throttleConfig == nil then return end assert(type(throttleConfig) == "table", "Bad throttleConfig") for key, value in throttleConfig do assert(type(value) == "boolean", "Bad throttleConfig entry") if key == "leading" then self._callLeading = value elseif key == "trailing" then self._callTrailing = value elseif key == "leadingFirstTimeOnly" then self._callLeadingFirstTime = value else error(string.format("Bad key %q in config", tostring(key))) end end assert(self._callLeading or self._callTrailing, "Cannot configure both leading and trailing disabled") end --[=[ Cancels any pending trailing calls. @function Destroy @within ThrottledFunction ]=] function ThrottledFunction.Destroy(self: ThrottledFunction) local private: any = self private._trailingValue = nil private._func = nil if private._delayedDispatchThread then task.cancel(private._delayedDispatchThread) end private._delayedDispatchThread = nil setmetatable(private, nil) end return ThrottledFunction