635 lines
14 KiB
Lua
635 lines
14 KiB
Lua
local _VERSION = '0.1.0';
|
|
|
|
local NODE_TEXT = 'text';
|
|
local NODE_BUTTON = 'button';
|
|
local NODE_BOX = 'box';
|
|
local NODE_FRAGMENT = 'fragment';
|
|
|
|
local DEFAULT_COLOR = colors.white;
|
|
local DEFAULT_BG_COLOR = colors.black;
|
|
local DISABLED_COLOR = colors.gray;
|
|
|
|
local function isArray(value)
|
|
if type(value) ~= 'table' then
|
|
return false;
|
|
end
|
|
|
|
if value.kind then
|
|
return false;
|
|
end
|
|
|
|
local count = 0;
|
|
for key, _ in pairs(value) do
|
|
if type(key) ~= 'number' then
|
|
return false;
|
|
end
|
|
if key > count then
|
|
count = key;
|
|
end
|
|
end
|
|
|
|
return count > 0;
|
|
end
|
|
|
|
local function firstColor(value, fallback)
|
|
if type(value) == 'table' then
|
|
return value[1] or fallback;
|
|
end
|
|
|
|
return value or fallback;
|
|
end
|
|
|
|
local function firstFunction(value)
|
|
if type(value) == 'function' then
|
|
return value;
|
|
end
|
|
|
|
if type(value) == 'table' and type(value[1]) == 'function' then
|
|
return value[1];
|
|
end
|
|
|
|
return nil;
|
|
end
|
|
|
|
local function shallowCopy(value)
|
|
local result = {};
|
|
|
|
if type(value) ~= 'table' then
|
|
return result;
|
|
end
|
|
|
|
for key, item in pairs(value) do
|
|
result[key] = item;
|
|
end
|
|
|
|
return result;
|
|
end
|
|
|
|
local function makeNode(kind, props)
|
|
props = props or {};
|
|
return {
|
|
kind = kind,
|
|
props = props,
|
|
};
|
|
end
|
|
|
|
local function makeText(value, props)
|
|
if type(value) == 'table' and props == nil then
|
|
props = shallowCopy(value);
|
|
else
|
|
props = shallowCopy(props);
|
|
props.text = value;
|
|
end
|
|
|
|
return makeNode(NODE_TEXT, props);
|
|
end
|
|
|
|
local function makeButton(value, props)
|
|
if type(value) == 'table' and props == nil then
|
|
props = shallowCopy(value);
|
|
else
|
|
props = shallowCopy(props);
|
|
props.text = value;
|
|
end
|
|
|
|
return makeNode(NODE_BUTTON, props);
|
|
end
|
|
|
|
local function makeBox(props)
|
|
return makeNode(NODE_BOX, shallowCopy(props));
|
|
end
|
|
|
|
local function makeList(props)
|
|
props = shallowCopy(props);
|
|
props.direction = 'column';
|
|
return makeNode(NODE_BOX, props);
|
|
end
|
|
|
|
local function makeFragment(children)
|
|
return makeNode(NODE_FRAGMENT, { children = children or {} });
|
|
end
|
|
|
|
local function lineText(value, width)
|
|
value = tostring(value or '');
|
|
|
|
if width <= 0 then
|
|
return '';
|
|
end
|
|
|
|
if string.len(value) > width then
|
|
return string.sub(value, 1, width);
|
|
end
|
|
|
|
return value .. string.rep(' ', width - string.len(value));
|
|
end
|
|
|
|
local function shrinkRect(rect, amount)
|
|
amount = amount or 0;
|
|
return {
|
|
x = rect.x + amount,
|
|
y = rect.y + amount,
|
|
w = rect.w - amount * 2,
|
|
h = rect.h - amount * 2,
|
|
};
|
|
end
|
|
|
|
local function isInside(rect, x, y)
|
|
return x >= rect.x and x < rect.x + rect.w and y >= rect.y and y < rect.y + rect.h;
|
|
end
|
|
|
|
local function fillRect(rect, bgColor)
|
|
if rect.w <= 0 or rect.h <= 0 then
|
|
return;
|
|
end
|
|
|
|
term.setBackgroundColor(bgColor);
|
|
for y = rect.y, rect.y + rect.h - 1 do
|
|
term.setCursorPos(rect.x, y);
|
|
term.write(string.rep(' ', rect.w));
|
|
end
|
|
end
|
|
|
|
local function writeAt(x, y, text, width)
|
|
if width <= 0 then
|
|
return;
|
|
end
|
|
|
|
term.setCursorPos(x, y);
|
|
term.write(lineText(text, width));
|
|
end
|
|
|
|
local function drawBorder(rect, props)
|
|
if rect.w <= 1 or rect.h <= 1 then
|
|
return;
|
|
end
|
|
|
|
local color = firstColor(props.color, DEFAULT_COLOR);
|
|
local bgColor = firstColor(props.bgColor, DEFAULT_BG_COLOR);
|
|
local top = '+' .. string.rep('-', rect.w - 2) .. '+';
|
|
local bottom = top;
|
|
local title = props.title;
|
|
|
|
if title then
|
|
local titleText = ' ' .. tostring(title) .. ' ';
|
|
if string.len(titleText) < rect.w - 1 then
|
|
top = '+' .. titleText .. string.rep('-', rect.w - 2 - string.len(titleText)) .. '+';
|
|
end
|
|
end
|
|
|
|
term.setTextColor(color);
|
|
term.setBackgroundColor(bgColor);
|
|
writeAt(rect.x, rect.y, top, rect.w);
|
|
writeAt(rect.x, rect.y + rect.h - 1, bottom, rect.w);
|
|
|
|
for y = rect.y + 1, rect.y + rect.h - 2 do
|
|
term.setCursorPos(rect.x, y);
|
|
term.write('|');
|
|
term.setCursorPos(rect.x + rect.w - 1, y);
|
|
term.write('|');
|
|
end
|
|
end
|
|
|
|
local function normalizeChildren(children)
|
|
local result = {};
|
|
|
|
if children == nil then
|
|
return result;
|
|
end
|
|
|
|
if type(children) ~= 'table' or children.kind then
|
|
return { children };
|
|
end
|
|
|
|
if isArray(children) then
|
|
for _, child in ipairs(children) do
|
|
table.insert(result, child);
|
|
end
|
|
else
|
|
table.insert(result, children);
|
|
end
|
|
|
|
return result;
|
|
end
|
|
|
|
local resolveNode;
|
|
|
|
local function resolveChildren(children)
|
|
local result = {};
|
|
|
|
for _, child in ipairs(normalizeChildren(children)) do
|
|
local node = resolveNode(child);
|
|
if node then
|
|
table.insert(result, node);
|
|
end
|
|
end
|
|
|
|
return result;
|
|
end
|
|
|
|
function resolveNode(input)
|
|
if input == nil then
|
|
return nil;
|
|
end
|
|
|
|
if type(input) == 'function' then
|
|
return resolveNode(input());
|
|
end
|
|
|
|
if type(input) == 'string' or type(input) == 'number' then
|
|
return makeText(tostring(input));
|
|
end
|
|
|
|
if type(input) ~= 'table' then
|
|
return makeText(tostring(input));
|
|
end
|
|
|
|
if input.kind then
|
|
local props = shallowCopy(input.props);
|
|
props.children = resolveChildren(props.children);
|
|
return makeNode(input.kind, props);
|
|
end
|
|
|
|
if isArray(input) then
|
|
return makeFragment(resolveChildren(input));
|
|
end
|
|
|
|
return makeFragment(resolveChildren(input.children));
|
|
end
|
|
|
|
local function buttonLabel(props)
|
|
return '[ ' .. tostring(props.text or props.label or '') .. ' ]';
|
|
end
|
|
|
|
local naturalSize;
|
|
|
|
local function childrenNaturalSize(children, direction, gap)
|
|
local width = 0;
|
|
local height = 0;
|
|
|
|
for index, child in ipairs(children) do
|
|
local childWidth, childHeight = naturalSize(child);
|
|
|
|
if direction == 'row' then
|
|
width = width + childWidth;
|
|
height = math.max(height, childHeight);
|
|
if index > 1 then
|
|
width = width + gap;
|
|
end
|
|
else
|
|
width = math.max(width, childWidth);
|
|
height = height + childHeight;
|
|
if index > 1 then
|
|
height = height + gap;
|
|
end
|
|
end
|
|
end
|
|
|
|
return width, height;
|
|
end
|
|
|
|
function naturalSize(node)
|
|
if not node then
|
|
return 0, 0;
|
|
end
|
|
|
|
local props = node.props or {};
|
|
|
|
if node.kind == NODE_TEXT then
|
|
return props.width or string.len(tostring(props.text or '')), props.height or 1;
|
|
end
|
|
|
|
if node.kind == NODE_BUTTON then
|
|
return props.width or string.len(buttonLabel(props)), props.height or 1;
|
|
end
|
|
|
|
local direction = props.direction or 'column';
|
|
local gap = props.gap or 0;
|
|
local padding = props.padding or 0;
|
|
local border = props.border and 2 or 0;
|
|
local childWidth, childHeight = childrenNaturalSize(props.children or {}, direction, gap);
|
|
|
|
if node.kind == NODE_FRAGMENT then
|
|
return childWidth, childHeight;
|
|
end
|
|
|
|
return props.width or childWidth + padding * 2 + border, props.height or childHeight + padding * 2 + border;
|
|
end
|
|
|
|
local function applyNodeColors(props)
|
|
term.setTextColor(firstColor(props.color, DEFAULT_COLOR));
|
|
term.setBackgroundColor(firstColor(props.bgColor, DEFAULT_BG_COLOR));
|
|
end
|
|
|
|
local function childAxisSize(child, direction)
|
|
local props = child.props or {};
|
|
local naturalWidth, naturalHeight = naturalSize(child);
|
|
|
|
if direction == 'row' then
|
|
return props.width or naturalWidth;
|
|
end
|
|
|
|
return props.height or naturalHeight;
|
|
end
|
|
|
|
local function layoutChildren(children, rect, direction, gap)
|
|
local fixedSize = 0;
|
|
local flexSize = 0;
|
|
local axisSize = direction == 'row' and rect.w or rect.h;
|
|
local layouts = {};
|
|
local remaining;
|
|
|
|
for _, child in ipairs(children) do
|
|
local props = child.props or {};
|
|
if props.flex then
|
|
flexSize = flexSize + props.flex;
|
|
else
|
|
fixedSize = fixedSize + childAxisSize(child, direction);
|
|
end
|
|
end
|
|
|
|
remaining = axisSize - fixedSize - math.max(#children - 1, 0) * gap;
|
|
if remaining < 0 then
|
|
remaining = 0;
|
|
end
|
|
|
|
local cursor = direction == 'row' and rect.x or rect.y;
|
|
local lastFlexIndex = nil;
|
|
local usedFlexSize = 0;
|
|
|
|
for index, child in ipairs(children) do
|
|
if (child.props or {}).flex then
|
|
lastFlexIndex = index;
|
|
end
|
|
end
|
|
|
|
for index, child in ipairs(children) do
|
|
local props = child.props or {};
|
|
local size;
|
|
|
|
if props.flex then
|
|
if index == lastFlexIndex then
|
|
size = remaining - usedFlexSize;
|
|
else
|
|
size = math.floor(remaining * props.flex / flexSize);
|
|
usedFlexSize = usedFlexSize + size;
|
|
end
|
|
else
|
|
size = childAxisSize(child, direction);
|
|
end
|
|
|
|
if size < 0 then
|
|
size = 0;
|
|
end
|
|
|
|
if direction == 'row' then
|
|
table.insert(layouts, { node = child, rect = { x = cursor, y = rect.y, w = size, h = props.height or rect.h } });
|
|
else
|
|
table.insert(layouts, { node = child, rect = { x = rect.x, y = cursor, w = props.width or rect.w, h = size } });
|
|
end
|
|
|
|
cursor = cursor + size + gap;
|
|
end
|
|
|
|
return layouts;
|
|
end
|
|
|
|
local function createTui(eventloop)
|
|
assert(type(eventloop) == 'table', 'bad argument #1 (eventloop expected)');
|
|
assert(type(eventloop.register) == 'function', 'bad argument #1 (eventloop expected)');
|
|
assert(type(eventloop.startLoop) == 'function', 'bad argument #1 (eventloop expected)');
|
|
|
|
local api = {};
|
|
local root = nil;
|
|
local clickables = {};
|
|
local finalEvent = nil;
|
|
local previousState = nil;
|
|
|
|
local function createErrorEvent(reason)
|
|
if type(reason) == 'table' then
|
|
return {
|
|
type = 'error',
|
|
error = {
|
|
name = reason.name or 'libtui error',
|
|
reason = reason.reason or tostring(reason),
|
|
},
|
|
};
|
|
end
|
|
|
|
return {
|
|
type = 'error',
|
|
error = {
|
|
name = 'libtui error',
|
|
reason = tostring(reason),
|
|
},
|
|
};
|
|
end
|
|
|
|
local function stopWith(event)
|
|
finalEvent = finalEvent or event;
|
|
if eventloop.isRunningLoop and eventloop.isRunningLoop() then
|
|
eventloop.stopLoop();
|
|
end
|
|
end
|
|
|
|
local renderNode;
|
|
|
|
local function addClickable(node, rect)
|
|
local props = node.props or {};
|
|
local handler = firstFunction(props.onClick);
|
|
|
|
if not handler or props.disabled then
|
|
return;
|
|
end
|
|
|
|
table.insert(clickables, {
|
|
rect = rect,
|
|
node = node,
|
|
handler = handler,
|
|
});
|
|
end
|
|
|
|
local function drawTextNode(node, rect)
|
|
local props = node.props or {};
|
|
|
|
if rect.w <= 0 or rect.h <= 0 then
|
|
return;
|
|
end
|
|
|
|
fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
|
|
applyNodeColors(props);
|
|
writeAt(rect.x, rect.y, props.text or '', rect.w);
|
|
addClickable(node, rect);
|
|
end
|
|
|
|
local function drawButtonNode(node, rect)
|
|
local props = node.props or {};
|
|
|
|
if rect.w <= 0 or rect.h <= 0 then
|
|
return;
|
|
end
|
|
|
|
fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
|
|
term.setTextColor(props.disabled and DISABLED_COLOR or firstColor(props.color, DEFAULT_COLOR));
|
|
term.setBackgroundColor(firstColor(props.bgColor, DEFAULT_BG_COLOR));
|
|
writeAt(rect.x, rect.y, buttonLabel(props), rect.w);
|
|
addClickable(node, rect);
|
|
end
|
|
|
|
local function drawBoxNode(node, rect)
|
|
local props = node.props or {};
|
|
local children = props.children or {};
|
|
local contentRect = rect;
|
|
local padding = props.padding or 0;
|
|
local direction = props.direction or 'column';
|
|
local gap = props.gap or 0;
|
|
|
|
if rect.w <= 0 or rect.h <= 0 then
|
|
return;
|
|
end
|
|
|
|
fillRect(rect, firstColor(props.bgColor, DEFAULT_BG_COLOR));
|
|
addClickable(node, rect);
|
|
|
|
if props.border then
|
|
drawBorder(rect, props);
|
|
contentRect = shrinkRect(contentRect, 1);
|
|
end
|
|
|
|
if padding > 0 then
|
|
contentRect = shrinkRect(contentRect, padding);
|
|
end
|
|
|
|
if contentRect.w <= 0 or contentRect.h <= 0 then
|
|
return;
|
|
end
|
|
|
|
for _, item in ipairs(layoutChildren(children, contentRect, direction, gap)) do
|
|
renderNode(item.node, item.rect);
|
|
end
|
|
end
|
|
|
|
function renderNode(node, rect)
|
|
if node.kind == NODE_TEXT then
|
|
drawTextNode(node, rect);
|
|
elseif node.kind == NODE_BUTTON then
|
|
drawButtonNode(node, rect);
|
|
else
|
|
drawBoxNode(node, rect);
|
|
end
|
|
end
|
|
|
|
local function redraw()
|
|
local width, height = term.getSize();
|
|
|
|
clickables = {};
|
|
term.setCursorBlink(false);
|
|
term.setTextColor(DEFAULT_COLOR);
|
|
term.setBackgroundColor(DEFAULT_BG_COLOR);
|
|
term.clear();
|
|
renderNode(resolveNode(root), { x = 1, y = 1, w = width, h = height });
|
|
end
|
|
|
|
local function safeRedraw()
|
|
local ok, reason = pcall(redraw);
|
|
if not ok then
|
|
stopWith(createErrorEvent(reason));
|
|
end
|
|
end
|
|
|
|
local function safeClick(handler, event)
|
|
local ok, reason = pcall(handler, api, event);
|
|
if not ok then
|
|
stopWith(createErrorEvent(reason));
|
|
end
|
|
end
|
|
|
|
function api.exitUI(reason)
|
|
stopWith({ type = 'exitUI', reason = reason });
|
|
end
|
|
|
|
function api.rerender()
|
|
safeRedraw();
|
|
end
|
|
|
|
function api.render(nextRoot)
|
|
root = nextRoot;
|
|
finalEvent = nil;
|
|
|
|
previousState = {
|
|
color = term.getTextColor(),
|
|
bgColor = term.getBackgroundColor(),
|
|
cursorX = 1,
|
|
cursorY = 1,
|
|
cursorBlink = false,
|
|
};
|
|
|
|
if term.getCursorPos then
|
|
previousState.cursorX, previousState.cursorY = term.getCursorPos();
|
|
end
|
|
|
|
if term.getCursorBlink then
|
|
previousState.cursorBlink = term.getCursorBlink();
|
|
end
|
|
|
|
eventloop.onStart(safeRedraw);
|
|
eventloop.onStop(function()
|
|
term.setTextColor(previousState.color);
|
|
term.setBackgroundColor(previousState.bgColor);
|
|
term.clear();
|
|
term.setCursorPos(previousState.cursorX, previousState.cursorY);
|
|
term.setCursorBlink(previousState.cursorBlink);
|
|
end);
|
|
|
|
eventloop.register('mouse_click', function(button, x, y)
|
|
for index = #clickables, 1, -1 do
|
|
local item = clickables[index];
|
|
if isInside(item.rect, x, y) then
|
|
safeClick(item.handler, {
|
|
type = 'mouse_click',
|
|
button = button,
|
|
x = x,
|
|
y = y,
|
|
node = item.node,
|
|
});
|
|
return;
|
|
end
|
|
end
|
|
end);
|
|
|
|
eventloop.register('term_resize', function()
|
|
safeRedraw();
|
|
end);
|
|
|
|
eventloop.register('terminate', function()
|
|
finalEvent = finalEvent or { type = 'terminate' };
|
|
end);
|
|
|
|
local ok, reason = pcall(eventloop.startLoop);
|
|
if not ok then
|
|
finalEvent = createErrorEvent(reason);
|
|
end
|
|
|
|
root = nil;
|
|
clickables = {};
|
|
|
|
return finalEvent or { type = 'exitUI' };
|
|
end
|
|
|
|
api.Text = makeText;
|
|
api.Button = makeButton;
|
|
api.Box = makeBox;
|
|
api.List = makeList;
|
|
api.Fragment = makeFragment;
|
|
api.version = _VERSION;
|
|
api.eventloop = eventloop;
|
|
|
|
api.text = makeText;
|
|
api.button = makeButton;
|
|
api.box = makeBox;
|
|
api.list = makeList;
|
|
|
|
return api;
|
|
end
|
|
|
|
return createTui;
|