diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..543b5ee --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release + +permissions: write-all + +on: + push: + branches: + - main + +jobs: + release: + name: release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install release dependencies + run: | + npm install semantic-release@24 \ + @semantic-release/git@10 semantic-release-factorio@1.5.1 \ + conventional-changelog-conventionalcommits@8 + - name: Run semantic-release + run: npx semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e28d95 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +control.lua \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a1a2ec --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Reactorio + +React, but for Factorio. Packaged as a mod for easy consumption. + +```lua +local Reactorio = require("__reactorio__.react") + +script.on_event(defines.events.on_gui_opened, function(event) + if not event.player then return end + + local element = Reactorio.createElement( + "frame", + { caption = "Reactorio" }, + "Hello, world!" + ) + Reactorio.render(element, event.player.gui.screen) +end) +``` + +### Features + + - Function components + - `useState` + - `useReducer` + - `useEffect` + - Plain text components (generated via `label`) + - Props-based event API + - No need for a separate event listener! + - Supported events listed below + - `on_gui_checked_state_changed` + - `on_gui_click` + - `on_gui_confirmed` + - `on_gui_elem_changed` + - `on_gui_selection_state_changed` + - `on_gui_text_changed` + - `on_gui_value_changed` + +### Coming Soon™ + + - Component shorthand similar to JSX + +### Examples + +// TODO + + + + diff --git a/info.json b/info.json new file mode 100644 index 0000000..acba702 --- /dev/null +++ b/info.json @@ -0,0 +1,12 @@ +{ + "name": "reactorio", + "version": "0.0.1", + "title": "Reactorio (React for Factorio)", + "author": "maxpowa", + "contact": "", + "homepage": "https://github.com/maxpowa/reactorio", + "factorio_version": "1.1", + "dependencies": ["base >= 1.1"], + "description": "React-based GUI layer for Factorio mods. Inspired by my pain and suffering while trying to write clean, responsive GUI code." + } + \ No newline at end of file diff --git a/react.lua b/react.lua new file mode 100644 index 0000000..d0384ec --- /dev/null +++ b/react.lua @@ -0,0 +1,207 @@ +-- Reactorio (React for Factorio) + +-- require guard +if ... ~= "__reactorio__.react" then + return require("__reactorio__.react") +end + +-- TODO: prevent util from polluting scope +require "__reactorio__.util" + +function createElement(type, props, ...) + local children = { ... } + return { type = type, props = props or {}, children = children } +end + +local hooks; +local index = nil; +local forceUpdate; +local function getHook(value) + if not index then error("Hooks can only be called inside components") end + index = index + 1 + local hook = hooks[index] + if not hook then + hook = { value = value } + hooks[index] = hook + end + return hook +end + +function useReducer(reducer, initialState) + local hook = getHook(initialState) + local update = forceUpdate + local function dispatch(action) + hook.value = reducer(hook.value, action) + update() + end + return hook.value, dispatch +end + +function useState(initialState) + return useReducer(function(_, v) return v end, initialState) +end + +local function changed(a, b) + return not a or arr.some(b, function(arg, i) return arg ~= a[i + 1] end) +end +function useEffect(cb, deps) + local dependencies = deps or {} + local hook = getHook() + if changed(hook.deps, dependencies) then + hook.deps = dependencies + hook.cb = cb + end +end + +local eventHandlers = { + [defines.events.on_gui_checked_state_changed] = {}, + [defines.events.on_gui_click] = {}, + [defines.events.on_gui_confirmed] = {}, + [defines.events.on_gui_elem_changed] = {}, + [defines.events.on_gui_selection_state_changed] = {}, + [defines.events.on_gui_text_changed] = {}, + [defines.events.on_gui_value_changed] = {}, +} +local function on_event_handler(event) + local eventName = event.name + handlers = eventHandlers[eventName] + for _, eventHandler in pairs(handlers or {}) do + if (type(eventHandler) == "function") then + eventHandler(event) + end + end +end +-- internal handler for any gui related event +script.on_event(defines.events.on_gui_checked_state_changed, on_event_handler) +script.on_event(defines.events.on_gui_click, on_event_handler) +script.on_event(defines.events.on_gui_confirmed, on_event_handler) +script.on_event(defines.events.on_gui_elem_changed, on_event_handler) +script.on_event(defines.events.on_gui_selection_state_changed, on_event_handler) +script.on_event(defines.events.on_gui_text_changed, on_event_handler) +script.on_event(defines.events.on_gui_value_changed, on_event_handler) + +local function createScopedHandler(eventId, element, fn, index) + return function(event) + if (not element.valid) then + -- TODO: perform this cleanup when the element is destroyed + eventHandlers[eventId][index] = nil + elseif (event.element == element) then + fn(event) + end + end +end + +local function addElementToParent(v, parent) + if (v.type ~= nil) then + local props = {} + + -- omit event handlers from props + for k, _ in pairs(v.props or {}) do + if (k:find("on_gui_") ~= 1) then + props[k] = v.props[k] + end + end + + local mergedProps = tbl.merge(props, { type = v.type }); + return parent.add(mergedProps) + elseif (type(v) == "string") then + return parent.add({ type = "label", caption = v }) + else + error("Invalid element: " .. serpent.line(v)) + end +end + +function render(vlist, root_element, hookStorage) + if not arr.is_array(vlist) then + vlist = { vlist } + end + local ids = {} + local hs = hookStorage or {} + hookStorage = {} + for i, vnode in ipairs(vlist) do + forceUpdate = function() return render(vlist, root_element, hookStorage) end + + while (type(vnode.type) == "function") do + local k = vnode.props and vnode.props.key + if not k then + ids[vnode.type] = (ids[vnode.type] or 0) + 1 + k = '' .. ids[vnode.type] + end + + hooks = hs[k] or {} + index = 1 + vnode = vnode.type(vnode.props, vnode.children, forceUpdate) + -- reset index to nil to prevent hooks from being called outside of components + index = nil + hookStorage[k] = hooks + end + + local node = root_element.children[i] + local oldNodeIndex = node and node.index + if (not node) or (vnode.type and node.type ~= vnode.type) then + node = addElementToParent(vnode, root_element) + end + + if (node and (node.type == vnode.type)) then + for k, v in pairs(vnode.props) do + if (k:find("on_gui_") == 1 and type(v) == "function") then + local index = node.index + local eventId = defines.events[k] + eventHandlers[eventId][index] = createScopedHandler(eventId, node, v, index) + elseif (node[k] ~= v) then + node[k] = v + end + end + render(vnode.children, node, hookStorage); + end + + -- Reconciliation + + -- run new useEffect callbacks and store cleanup functions + for _, componentHooks in pairs(hookStorage) do + for _, h in pairs(componentHooks) do + if (h.cb) then + h.cleanup = h.cb() + h.cb = nil + end + end + end + + -- run cleanup functions for removed hooks + for key, _ in pairs(hs) do + if not hookStorage[key] then + for _, h in pairs(hs[key]) do + if (h.cleanup) then + h.cleanup() + end + hs[key] = nil + end + end + end + + -- since we can't directly insert elements at a specific index, we have to swap them around after adding + if (node and oldNodeIndex and (oldNodeIndex ~= node.index)) then + root_element.swap_children(oldNodeIndex, node.index) + end + + -- remove extra elements + while true do + child = root_element.children[#vlist + 1] + if child then + child.destroy() + render({}, root_element, hookStorage) + else + break + end + end + end +end + +return { + createElement = createElement, + h = createElement, + useReducer = useReducer, + useState = useState, + useEffect = useEffect, + render = render, +} diff --git a/util.lua b/util.lua new file mode 100644 index 0000000..f48a9a1 --- /dev/null +++ b/util.lua @@ -0,0 +1,63 @@ +arr = {} +tbl = {} +function tbl.merge(tbl1, tbl2) + local result = {} + for k, v in pairs(tbl1) do + result[k] = v + end + for k, v in pairs(tbl2) do + result[k] = v + end + return result +end + +-- Array utility functions +function arr.map(tbl, func) + local result = {} + for i, v in ipairs(tbl) do + result[i] = func(v, i) + end + return result +end + +function arr.filter(tbl, func) + local result = {} + for i, v in ipairs(tbl) do + if func(v, i) then + table.insert(result, v) + end + end + return result +end + +function arr.some(tbl, func) + for i, v in ipairs(tbl) do + if func(v, i) then + return true + end + end + return false +end + +function arr.is_array(value) + return type(value) == "table" and (value[1] ~= nil or next(value) == nil) +end + +-- predicate functions to be used with filter +function isEvent(prev, next) + return function(key) + return key:find("on_") == 1 + end +end + +function isNew(prev, next) + return function(key) + return prev[key] ~= next[key] + end +end + +function isGone(prev, next) + return function(key) + return not next[key] + end +end \ No newline at end of file