cc-libs/apis/libtui.lua

660 lines
15 KiB
Lua

local _VERSION = '0.1.1';
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 function flexOf(child)
local f = (child.props or {}).flex;
if f == true then
return 1;
end
if type(f) == 'number' and f > 0 then
return f;
end
return nil;
end
local naturalSize;
local function childrenNaturalSize(children, direction, gap)
local width = 0;
local height = 0;
local mainCount = 0;
for _, child in ipairs(children) do
local hasFlex = flexOf(child) ~= nil;
local childWidth, childHeight = naturalSize(child);
if direction == 'row' then
height = math.max(height, childHeight);
if not hasFlex then
if mainCount > 0 then
width = width + gap;
end
width = width + childWidth;
mainCount = mainCount + 1;
end
else
width = math.max(width, childWidth);
if not hasFlex then
if mainCount > 0 then
height = height + gap;
end
height = height + childHeight;
mainCount = mainCount + 1;
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 lastFlexIndex = nil;
local flexes = {};
for index, child in ipairs(children) do
local flex = flexOf(child);
flexes[index] = flex;
if flex then
flexSize = flexSize + flex;
lastFlexIndex = index;
else
fixedSize = fixedSize + childAxisSize(child, direction);
end
end
local 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 usedFlexSize = 0;
for index, child in ipairs(children) do
local props = child.props or {};
local flex = flexes[index];
local size;
if flex then
if index == lastFlexIndex then
size = remaining - usedFlexSize;
else
size = math.floor(remaining * 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()
if root == nil then
return;
end
safeRedraw();
end
local function restoreTerminal()
if not previousState then
return;
end
term.setTextColor(previousState.color);
term.setBackgroundColor(previousState.bgColor);
term.clear();
term.setCursorPos(previousState.cursorX, previousState.cursorY);
term.setCursorBlink(previousState.cursorBlink);
previousState = nil;
end
function api.render(nextRoot)
finalEvent = nil;
root = nextRoot;
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(restoreTerminal);
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
pcall(restoreTerminal);
finalEvent = createErrorEvent(reason);
end
root = nil;
clickables = {};
return finalEvent or { type = 'exitUI' };
end
api.Text = makeText;
api.text = makeText;
api.Button = makeButton;
api.button = makeButton;
api.Box = makeBox;
api.box = makeBox;
api.List = makeList;
api.list = makeList;
api.Fragment = makeFragment;
api.version = _VERSION;
api.eventloop = eventloop;
return api;
end
return createTui;