模块:Meteor

来自「荏苒之境」

此模块的文档可以在模块:Meteor/doc创建

local table_clear = require('table.clear')

local pop = function(t)
    local len = #t
    local r = t[len]
    t[len] = nil
    return r
end

local push = function(t, v)
    t[#t+1] = v
end

---@class meteor.package
local meteor = {}

---@type table[]
meteor.array_pool = {}
meteor.signal_scope_pool = {}
meteor.scheduler_cache_pool = {}

--#region signal system

---@alias meteor.signal_listener fun(signal_type: any, ...)

---@class meteor.signal_system
---@field listeners { [any]: meteor.signal_listener[] }
local signal_system_mt = {}
signal_system_mt.__index = signal_system_mt

---@return meteor.signal_system
meteor.new_signal_system = function()
    return setmetatable({ listeners = {} }, signal_system_mt)
end

meteor.signal_system = meteor.new_signal_system()

---@param listeners? { [any]: meteor.signal_listener }
function signal_system_mt:push_scope(listeners)
    local scope = pop(meteor.signal_scope_pool) or {}
    scope.listeners = listeners
    self[#self+1] = scope
end

---@return any[]
function signal_system_mt:pop_scope()
    local i = #self
    local top = self[i]
    self[i] = nil
    return top
end

---@generic T
---@param f fun(): T
---@param listeners? { [any]: meteor.signal_listener }
---@return boolean success, table signals, T|string result-or-error
function signal_system_mt:with_scope(f, listeners)
    self:push_scope(listeners)
    local succ, res_or_err = pcall(f)
    return succ, self:pop_scope(), res_or_err
end

---@generic T
---@param signal_type T
---@param f meteor.signal_listener
function signal_system_mt:register_listener(signal_type, f)
    local ls = self.listeners[signal_type]
    if ls == nil then
        ls = {}
        self.listeners[signal_type] = ls
    end
    ls[#ls+1] = f
end

---@generic T
---@param signal_type T
---@param f meteor.signal_listener
---@return boolean
function signal_system_mt:unregister_listener(signal_type, f)
    local ls = self.listeners[signal_type]
    if ls == nil then
        return false
    end
    for i = 1, #ls do
        if ls[i] == f then
            for j = i, #ls do
                ls[j] = ls[j+1]
            end
            if #ls == 0 then
                self.listeners[signal_type] = nil
            end
            return true
        end
    end
    return false
end

---@param signal_type any
---@param ... any
function signal_system_mt:trigger(signal_type, ...)
    if signal_type == nil then
        error("#1 signal_type cannot be nil")
    end
    local signal = { signal_type, ... }

    local top = self[#self]
    if top ~= nil then
        top[#top+1] = signal

        local ls = top.listeners
        if ls ~= nil then
            local l = ls[signal_type]
            if l ~= nil then
                l(signal)
            end
        end
    end

    local global_ls = self.listeners[signal_type]
    if global_ls then
        for i = 1, #global_ls do
            global_ls[i](signal)
        end
    end
end

--#endregion signal system

--#region reactive object

local SIG_REACTIVE_READ = {}
local SIG_REACTIVE_WRITE = {}

local KEY_REACTIVE_VALUE = {}
local KEY_REACTIVE_TAG = {}
local KEY_REACTIVE_DEPS = {}
local KEY_REACTIVE_EXPR = {}
local KEY_REACTIVE_INDEX = {}

local KEY_VALUE = {}
meteor.KEY_VALUE = KEY_VALUE

---@alias meteor meteor.reactive<any>
---@alias meteor.reactive_tag 'value'|'expr'|'lazy'|'table'|table

---@class meteor.reactive<T>: { value: T? } }
local reactive_mt = {}

function reactive_mt:__index(k)
    meteor.signal_system:trigger(SIG_REACTIVE_READ, self)
    local v = rawget(self, KEY_REACTIVE_VALUE)
    if k == "value" then
        return v
    else
        return v[k]
    end
end

function reactive_mt:__newindex(k, new)
    local sig_sys = meteor.signal_system
    local v

    if k == "value" then
        v = new
        rawset(self, KEY_REACTIVE_VALUE, v)
        sig_sys:trigger(SIG_REACTIVE_WRITE, self, v)
    else
        if k == KEY_VALUE then
            k = "value"
        end
        v = rawget(self, KEY_REACTIVE_VALUE)
        local prev = v[k]
        v[k] = new
        sig_sys:trigger(SIG_REACTIVE_WRITE, self, v, k, prev, new)
    end
end

---@generic T
---@param initial T?
---@return meteor.reactive<T>
meteor.ref = function(initial)
    return setmetatable({ [KEY_REACTIVE_VALUE] = initial }, reactive_mt)
end

---@generic T
---@param ref meteor.reactive<T>
---@return T
meteor.peek_ref_value = function(ref)
    return rawget(ref, KEY_REACTIVE_VALUE)
end

---@param ref meteor
---@return meteor[]?
meteor.get_ref_deps = function(ref)
    return rawget(ref, KEY_REACTIVE_DEPS)
end

---@param ref meteor
---@return meteor.reactive_tag
meteor.get_ref_tag = function(ref)
    return rawget(ref, KEY_REACTIVE_TAG) or 'value'
end

---@generic T
---@param ref meteor.reactive<T>
---@return (fun(): T)?
meteor.get_ref_expr = function(ref)
    return rawget(ref, KEY_REACTIVE_EXPR)
end

-- TODO: support return reactive object directly
---@generic T
---@param f fun(): T
---@return meteor[], T
local function collect_reactive(f)
    local signal_sys = meteor.signal_system
    local succ, scope, res = signal_sys:with_scope(f)

    if not succ then
        error("error during reactive object collection: "..res)
    end

    local rs = pop(meteor.array_pool) or {}

    for i = 1, #scope do
        local sig = scope[i]
        if sig[1] ~= SIG_REACTIVE_READ then
            signal_sys:trigger(unpack(sig))
        else
            rs[#rs+1] = sig[2]
        end
    end
    table_clear(scope)
    push(meteor.signal_scope_pool, scope)
    return rs, res
end

---@generic T
---@param expr fun(): T
---@return meteor.reactive<T>
meteor.compute = function(expr)
    local rs, v = collect_reactive(expr)
    local r = meteor.ref(v)
    rawset(r, KEY_REACTIVE_TAG, 'expr')
    rawset(r, KEY_REACTIVE_DEPS, rs)
    rawset(r, KEY_REACTIVE_EXPR, expr)
    return r
end

---@generic T
---@param expr fun(): T
---@return meteor.reactive<T>
meteor.lazy = function(expr)
    local rs, v = collect_reactive(expr)
    local r = meteor.ref(v)
    rawset(r, KEY_REACTIVE_TAG, 'lazy')
    rawset(r, KEY_REACTIVE_DEPS, rs)
    rawset(r, KEY_REACTIVE_EXPR, expr)
    return r
end

---@generic T: table
---@param t T
---@return meteor.reactive<T>
meteor.table = function(t)
    local index_map = {}
    local r = meteor.compute(function()
        local o = {}
        for k, v in pairs(t) do
            if getmetatable(v) == reactive_mt then
                o[k] = v.value
                index_map[v] = k
            else
                o[k] = v
            end
        end
        return o
    end)
    rawset(r, KEY_REACTIVE_TAG, 'table')
    rawset(r, KEY_REACTIVE_INDEX, index_map)
    return r
end

---@generic K, V, U
---@param t table<K, V>
---@param f (fun(v: V, k?: K): meteor.reactive<U>)
---@return meteor.reactive<table<K, U>>
meteor.map = function(t, f)
    local exprs = {}
    for k, v in pairs(t) do
        exprs[k] = meteor.compute(function()
            return f(v, k).value
        end)
    end
    local r = meteor.table(exprs)
    rawset(r, KEY_REACTIVE_TAG, 'map')
    return r
end

---@alias meteor.inspect_config {
--- formatter: (fun(v: any): string),
--- indent_text: string,
---}

---@param t string[]
---@param r meteor
---@param indent integer
---@param config meteor.inspect_config
local function do_inspect(t, r, indent, config)
    local indent_text = config.indent_text or '  '
    for _ = 1, indent do
        t[#t+1] = indent_text
    end

    t[#t+1] = tostring(meteor.get_ref_tag(r))
    t[#t+1] = ': '
    t[#t+1] = config.formatter(meteor.peek_ref_value(r))
    t[#t+1] = '\n'

    local deps = meteor.get_ref_deps(r)
    if deps ~= nil then
        local indent_plus = indent + 1
        for i = 1, #deps do
            do_inspect(t, deps[i], indent_plus, config)
        end
    end
end

---@param r meteor
---@param config meteor.inspect_config
---@return string
meteor.inspect = function(r, config)
    local t = {}
    do_inspect(t, r, 0, config)
    return table.concat(t)
end

--#endregion reactive object

--#region scheduler

---@alias meteor.scheduler.reactive_cache {
--- tag: meteor.reactive_tag,
--- value: any,
--- downstreams: { [meteor]: true },
--- pinned: boolean,
--- version: integer,
--- lazy_dirty: boolean,
--- is_prev: boolean,
---}

---@class meteor.scheduler
---@field caches { [meteor]: meteor.scheduler.reactive_cache }
---@field dirty_seq meteor[]
---@field dirty_seq_upstream meteor[]
---@field version integer
---@field private read_listener meteor.signal_listener
---@field private write_listener meteor.signal_listener
local scheduler_mt = {}
scheduler_mt.__index = scheduler_mt

---@param schd meteor.scheduler
---@param r meteor
---@return meteor.scheduler.reactive_cache
local function register_reactive_to_scheduler(schd, r)
    local cache = schd.caches[r]
    if cache ~= nil then
        return cache
    end

    cache = pop(meteor.scheduler_cache_pool) or {}
    cache.tag = meteor.get_ref_tag(r)
    cache.value = meteor.peek_ref_value(r)
    cache.downstreams = {}
    schd.caches[r] = cache

    print("register: "..tostring(cache.value))

    local deps = meteor.get_ref_deps(r)
    if deps ~= nil then
        for i = 1, #deps do
            local dep_cache = register_reactive_to_scheduler(schd, deps[i])
            dep_cache.downstreams[r] = true
        end
    end

    return cache
end

---@param schd meteor.scheduler
---@param r meteor
local function unregister_reactive_from_scheduler(schd, r)
    print("unregister: "..meteor.peek_ref_value(r).name)

    local caches = schd.caches
    local cache = caches[r]
    caches[r] = nil

    table_clear(cache)
    push(meteor.scheduler_cache_pool, cache)

    local deps = meteor.get_ref_deps(r)
    if deps ~= nil then
        for i = 1, #deps do
            unregister_reactive_from_scheduler(schd, deps[i])
        end
    end
end

---@param schd meteor.scheduler
---@param r meteor
---@param upstream meteor
local function mark_reactive_dirty(schd, r, upstream)
    local cache = schd.caches[r]
    if cache.tag == 'lazy' then
        cache.lazy_dirty = true
    else
        local d = schd.dirty_seq
        local du = schd.dirty_seq_upstream
        d[#d+1] = r
        du[#du+1] = upstream
    end
end

---@param schd meteor.scheduler
---@param t table?
---@param new_t table
---@param initializer fun(cache: meteor.scheduler.reactive_cache, r: meteor, k: any, t: table, schd: meteor.scheduler)
---@param deinitializer fun(cache: meteor.scheduler.reactive_cache, r: meteor, k: any, t: table, schd: meteor.scheduler): boolean
local function generic_refresh_reactive_table(schd, t, new_t, initializer, deinitializer)
    local caches = schd.caches

    if t ~= nil then
        for _, r in pairs(t) do
            local cache = caches[r]
            if cache then
                cache.is_prev = true
            end
       end
    end

    for k, r in pairs(new_t) do
        if getmetatable(r) == reactive_mt then
            local cache = register_reactive_to_scheduler(schd, r)
            if cache.is_prev then
                cache.is_prev = nil
            else
                initializer(cache, r, k, new_t, schd)
            end
        end
    end

    if t ~= nil then
        for k, r in pairs(t) do
            local cache = caches[r]
            if cache ~= nil and cache.is_prev then
                cache.is_prev = nil
                if deinitializer(cache, r, k, t, schd) then
                    unregister_reactive_from_scheduler(schd, r)
                end
            end
        end
    end
end

---@param schd meteor.scheduler
---@param r meteor
---@param cache meteor.scheduler.reactive_cache
local function refresh_reactive_expr(schd, r, cache)
    local expr = meteor.get_ref_expr(r)
    if expr == nil then
        error('fatal: expr')
    end

    local prev_deps = meteor.get_ref_deps(r)
    local deps, v = collect_reactive(expr)

    rawset(r, KEY_REACTIVE_VALUE, v)
    rawset(r, KEY_REACTIVE_DEPS, deps)
    cache.value = v

    generic_refresh_reactive_table(schd, prev_deps, deps,
        function(dep_cache)
            dep_cache.downstreams[r] = true
        end,
        function(dep_cache)
            local ds = dep_cache.downstreams
            ds[r] = nil
            return next(ds) == nil and not cache.pinned
        end)

    if prev_deps ~= nil then
        table_clear(prev_deps)
        push(meteor.array_pool, prev_deps)
    end
end

---@param schd meteor.scheduler
---@param r meteor
---@param cache meteor.scheduler.reactive_cache
---@param upstream meteor?
---@param version integer
local function refresh_reactive(schd, r, cache, upstream, version)
    print('refresh '..cache.tag..' '..tostring(cache.value)..', upstream: '..tostring(upstream and meteor.peek_ref_value(upstream) or 'nil'))

    -- refresh reactive expr

    local tag = cache.tag
    if tag == 'expr' then
        if cache.version == version then
            print('---> same version!')
            return
        end
        cache.version = version
        refresh_reactive_expr(schd, r, cache)
    elseif tag == 'table' then
        local t = cache.value
        local index_map = rawget(r, KEY_REACTIVE_INDEX)
        if upstream == nil then
            error('upstream cannot be nil when refreshing reactive table')
        end
        local k = index_map[upstream]
        local new = meteor.peek_ref_value(upstream)
        if t[k] == new then
            print('---> table no change!')
            return
        end
        t[k] = new
    end

    local caches = schd.caches
    for dr in pairs(cache.downstreams) do
        refresh_reactive(schd, dr, caches[dr], r, version)
    end
end

---@param schd meteor.scheduler
---@return meteor.signal_listener
local function create_read_listener(schd)
    local caches = schd.caches
    return function(s)
        local r = s[2]
        local cache = caches[r]
        if cache == nil then
            return
        end
        if cache.lazy_dirty then
            refresh_reactive(schd, r, cache, nil, schd.version)
            for dr in pairs(cache.downstreams) do
                local dr_cache = caches[dr]
                if dr_cache.tag == 'lazy' then
                    dr_cache.lazy_dirty = true
                else
                    refresh_reactive(schd, r, cache, nil, schd.version)
                end
            end
        end
    end
end

---@param schd meteor.scheduler
---@return meteor.signal_listener
local function create_write_listener(schd)
    local caches = schd.caches
    return function(s)
        local r = s[2]
        local cache = caches[r]
        if cache == nil then
            return
        end

        local prev = cache.value
        local new, k = s[3], s[4]

        if k ~= nil and cache.tag == 'table' then
            local prev_v = s[5]
            local new_v = s[6]
            if prev_v == new_v then
                return
            end
            local index_map = rawget(r, KEY_REACTIVE_INDEX)

            if getmetatable(prev_v) == reactive_mt then
                local prev_cache = caches[prev_v]
                local prev_ds = prev_cache.downstreams
                prev_ds[r] = nil
                if next(prev_ds) == nil then
                    unregister_reactive_from_scheduler(schd, prev_v)
                end
                index_map[prev_v] = nil
            end

            if getmetatable(new_v) == reactive_mt then
                local new_cache = register_reactive_to_scheduler(schd, new_v)
                new_cache.downstreams[r] = true
                new[k] = new_cache.value
                index_map[new_v] = k
            end
        elseif prev ~= new then
            if cache.tag == 'table' then
                local index_map = rawget(r, KEY_REACTIVE_INDEX)
                generic_refresh_reactive_table(schd, prev, new,
                    function(v_cache, v, vk)
                        index_map[v] = vk
                        v_cache.downstreams[r] = true
                    end,
                    function(v_cache, v)
                        index_map[v] = nil
                        local ds = v_cache.downstreams
                        ds[r] = nil
                        return next(ds) == nil
                    end)
            end
            cache.value = new
        else
            return
        end

        for dr in pairs(cache.downstreams) do
            mark_reactive_dirty(schd, dr, r)
        end
    end
end

---@return meteor.scheduler
meteor.new_scheduler = function()
    local t = {
        caches = {},
        dirty_seq = {},
        dirty_seq_upstream = {},
        version = 0
    }

    t.read_listener = create_read_listener(t)
    t.write_listener = create_write_listener(t)

    setmetatable(t, scheduler_mt)
    meteor.signal_system:register_listener(SIG_REACTIVE_READ, t.read_listener)
    meteor.signal_system:register_listener(SIG_REACTIVE_WRITE, t.write_listener)
    return t
end

---@generic T
---@param expr fun(): T
---@return meteor.reactive<T>
function scheduler_mt:capture(expr)
    local r = meteor.compute(expr)
    local cache = register_reactive_to_scheduler(self, r)
    cache.pinned = true
    return r
end

function scheduler_mt:refresh()
    local dirty_seq = self.dirty_seq
    if #dirty_seq == 0 then
        return
    end

    local version = self.version
    self.version = version + 1

    local caches = self.caches
    local dirty_seq_upstream = self.dirty_seq_upstream

    for i = 1, #dirty_seq do
        local dr = dirty_seq[i]
        local cache = caches[dr]
        local upstream = dirty_seq_upstream[i]
        refresh_reactive(self, dr, cache, upstream, version)
    end

    table_clear(dirty_seq)
    table_clear(dirty_seq_upstream)
end

function scheduler_mt:dispose()
    meteor.signal_system:unregister_listener(SIG_REACTIVE_WRITE, self.write_listener)
end

return meteor