Skip to content

Commit

Permalink
chore: initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
maxpowa committed Aug 25, 2024
1 parent f0bf430 commit 36b16e8
Show file tree
Hide file tree
Showing 6 changed files with 361 additions and 0 deletions.
30 changes: 30 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
control.lua
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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




12 changes: 12 additions & 0 deletions info.json
Original file line number Diff line number Diff line change
@@ -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."
}

207 changes: 207 additions & 0 deletions react.lua
Original file line number Diff line number Diff line change
@@ -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,
}
63 changes: 63 additions & 0 deletions util.lua
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 36b16e8

Please sign in to comment.