All checks were successful
NeoVim tests / plenary-tests (push) Successful in 10s
276 lines
6.8 KiB
Lua
276 lines
6.8 KiB
Lua
local M = {}
|
|
|
|
M.debug = false
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- class Signal
|
|
--------------------------------------------------------------------------------
|
|
|
|
--- @class Signal
|
|
--- @field name? string
|
|
--- @field private changing boolean
|
|
--- @field private value any
|
|
--- @field private subscribers table<function, boolean>
|
|
local Signal = {}
|
|
M.Signal = Signal
|
|
Signal.__index = Signal
|
|
|
|
--- @param value any
|
|
--- @param name? string
|
|
--- @return Signal
|
|
function Signal:new(value, name)
|
|
local obj = setmetatable({
|
|
name = name,
|
|
changing = false,
|
|
value = value,
|
|
subscribers = {},
|
|
}, self)
|
|
return obj
|
|
end
|
|
|
|
--- @param value any
|
|
function Signal:set(value)
|
|
self.value = value
|
|
|
|
-- We don't handle cyclic updates:
|
|
if self.changing then
|
|
if M.debug then
|
|
vim.notify('circular dependency detected' .. (self.name and (' in ' .. self.name) or ''), vim.log.levels.WARN)
|
|
end
|
|
return
|
|
end
|
|
|
|
local prev_changing = self.changing
|
|
self.changing = true
|
|
local ok = true
|
|
local err = nil
|
|
for _, cb in ipairs(self.subscribers) do
|
|
local ok2, err2 = pcall(cb, value)
|
|
if not ok2 then
|
|
ok = false
|
|
err = err or err2
|
|
end
|
|
end
|
|
self.changing = prev_changing
|
|
|
|
if not ok then
|
|
vim.notify(
|
|
'error notifying' .. (self.name and (' in ' .. self.name) or '') .. ': ' .. tostring(err),
|
|
vim.log.levels.WARN
|
|
)
|
|
error(err)
|
|
end
|
|
end
|
|
|
|
--- @return any
|
|
function Signal:get()
|
|
local ctx = M.ExecutionContext.current()
|
|
if ctx then ctx:track(self) end
|
|
return self.value
|
|
end
|
|
|
|
--- @param fn function
|
|
function Signal:update(fn) self:set(fn(self.value)) end
|
|
|
|
--- @generic U
|
|
--- @param fn fun(value: T): U
|
|
--- @return Signal --<U>
|
|
function Signal:map(fn)
|
|
return M.create_memo(function()
|
|
local value = self:get()
|
|
return fn(value)
|
|
end, self.name and self.name .. ':mapped' or nil)
|
|
end
|
|
|
|
--- @param fn fun(value: T): boolean
|
|
--- @return Signal -- <T>
|
|
function Signal:filter(fn)
|
|
local filtered_signal = M.create_signal(nil, self.name and self.name .. ':filtered' or nil)
|
|
M.create_effect(function()
|
|
local value = self:get()
|
|
if fn(value) then filtered_signal:set(value) end
|
|
end)
|
|
return filtered_signal
|
|
end
|
|
|
|
--- @param ms number
|
|
--- @return Signal -- <T>
|
|
function Signal:debounce(ms)
|
|
local function set_timeout(timeout, callback)
|
|
local timer = (vim.uv or vim.loop).new_timer()
|
|
timer:start(timeout, 0, function()
|
|
timer:stop()
|
|
timer:close()
|
|
callback()
|
|
end)
|
|
return timer
|
|
end
|
|
|
|
local filtered = M.create_signal(self.value, self.name and self.name .. ':debounced' or nil)
|
|
|
|
--- @type {
|
|
-- queued: { value: T, ts: number }[]
|
|
-- timer?: uv_timer_t
|
|
-- }
|
|
local state = { queued = {}, timer = nil }
|
|
local function clear_timeout()
|
|
if state.timer == nil then return end
|
|
pcall(function()
|
|
state.timer:stop()
|
|
state.timer:close()
|
|
end)
|
|
state.timer = nil
|
|
end
|
|
|
|
self:subscribe(function(value)
|
|
-- Stop any previously running timer:
|
|
if state.timer then clear_timeout() end
|
|
local now_ms = (vim.uv or vim.loop).hrtime() / 1e6
|
|
|
|
-- If there is anything older than `ms` in our queue, emit it:
|
|
local older_than_ms = vim.iter(state.queued):filter(function(item) return now_ms - item.ts > ms end):totable()
|
|
local last_older_than_ms = older_than_ms[#older_than_ms]
|
|
if last_older_than_ms then
|
|
filtered:set(last_older_than_ms.value)
|
|
state.queued = {}
|
|
end
|
|
|
|
-- overwrite anything young enough
|
|
table.insert(state.queued, { value = value, ts = now_ms })
|
|
state.timer = set_timeout(ms, function()
|
|
filtered:set(value)
|
|
-- If a timer was allowed to run to completion, that means that no other
|
|
-- item has been queued, since the timer is reset every time a new item
|
|
-- comes in. This means we can reset the queue
|
|
clear_timeout()
|
|
state.queued = {}
|
|
end)
|
|
end)
|
|
|
|
return filtered
|
|
end
|
|
|
|
--- @param callback function
|
|
function Signal:subscribe(callback)
|
|
table.insert(self.subscribers, callback)
|
|
return function() self:unsubscribe(callback) end
|
|
end
|
|
|
|
--- @param callback function
|
|
function Signal:unsubscribe(callback)
|
|
for i, cb in ipairs(self.subscribers) do
|
|
if cb == callback then
|
|
table.remove(self.subscribers, i)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
function Signal:dispose() self.subscribers = {} end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- class ExecutionContext
|
|
--------------------------------------------------------------------------------
|
|
|
|
CURRENT_CONTEXT = nil
|
|
|
|
--- @class ExecutionContext
|
|
--- @field signals table<Signal, boolean>
|
|
local ExecutionContext = {}
|
|
M.ExecutionContext = ExecutionContext
|
|
ExecutionContext.__index = ExecutionContext
|
|
|
|
--- @return ExecutionContext
|
|
function ExecutionContext:new()
|
|
return setmetatable({
|
|
signals = {},
|
|
subscribers = {},
|
|
}, ExecutionContext)
|
|
end
|
|
|
|
function ExecutionContext.current() return CURRENT_CONTEXT end
|
|
|
|
--- @param fn function
|
|
--- @param ctx ExecutionContext
|
|
function ExecutionContext:run(fn, ctx)
|
|
local oldCtx = CURRENT_CONTEXT
|
|
CURRENT_CONTEXT = ctx
|
|
local result
|
|
local success, err = pcall(function() result = fn() end)
|
|
|
|
CURRENT_CONTEXT = oldCtx
|
|
|
|
if not success then error(err) end
|
|
|
|
return result
|
|
end
|
|
|
|
function ExecutionContext:track(signal) self.signals[signal] = true end
|
|
|
|
--- @param callback function
|
|
function ExecutionContext:subscribe(callback)
|
|
local wrapped_callback = function() callback() end
|
|
for signal in pairs(self.signals) do
|
|
signal:subscribe(wrapped_callback)
|
|
end
|
|
|
|
return function()
|
|
for signal in pairs(self.signals) do
|
|
signal:unsubscribe(wrapped_callback)
|
|
end
|
|
end
|
|
end
|
|
|
|
function ExecutionContext:dispose()
|
|
for signal, _ in pairs(self.signals) do
|
|
signal:dispose()
|
|
end
|
|
self.signals = {}
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Helpers
|
|
--------------------------------------------------------------------------------
|
|
|
|
--- @param value any
|
|
--- @param name? string
|
|
--- @return Signal
|
|
function M.create_signal(value, name) return Signal:new(value, name) end
|
|
|
|
--- @param fn function
|
|
--- @param name? string
|
|
--- @return Signal
|
|
function M.create_memo(fn, name)
|
|
local result
|
|
M.create_effect(function()
|
|
local value = fn()
|
|
if name and M.debug then vim.notify(name) end
|
|
if result then
|
|
result:set(value)
|
|
else
|
|
result = M.create_signal(value, name and ('m.s:' .. name) or nil)
|
|
end
|
|
end, name)
|
|
return result
|
|
end
|
|
|
|
--- @param fn function
|
|
--- @param name? string
|
|
function M.create_effect(fn, name)
|
|
local ctx = M.ExecutionContext:new()
|
|
M.ExecutionContext:run(fn, ctx)
|
|
return ctx:subscribe(function()
|
|
if name and M.debug then
|
|
local deps = vim
|
|
.iter(vim.tbl_keys(ctx.signals))
|
|
:map(function(s) return s.name end)
|
|
:filter(function(nm) return nm ~= nil end)
|
|
:join ','
|
|
vim.notify(name .. '(deps=' .. deps .. ')')
|
|
end
|
|
fn()
|
|
end)
|
|
end
|
|
|
|
return M
|