feat: libtui + tuidemo (WIP)

This commit is contained in:
Guillaume ARM 2026-06-08 00:41:19 +02:00
parent c47b6e0ae4
commit e7666715b0
3 changed files with 849 additions and 2 deletions

634
apis/libtui.lua Normal file
View File

@ -0,0 +1,634 @@
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;

View File

@ -1,6 +1,6 @@
{
"name": "TrapOS",
"version": "0.2.0",
"version": "0.3.0",
"branch": "master",
"files": [
"startup/motd.lua",
@ -9,9 +9,11 @@
"programs/router.lua",
"programs/events.lua",
"programs/ping.lua",
"programs/tuidemo.lua",
"programs/upgrade.lua",
"apis/net.lua",
"apis/eventloop.lua"
"apis/eventloop.lua",
"apis/libtui.lua"
],
"autostart": [
"servers/ping-server"

211
programs/tuidemo.lua Normal file
View File

@ -0,0 +1,211 @@
local _VERSION = '0.1.0';
local command = ...;
local function printUsage()
print('tuidemo usage:');
print();
print('\t\t\ttuidemo');
print('\t\t\ttuidemo version');
print('\t\t\ttuidemo help');
end
if command == 'version' or command == '-version' or command == '--version' then
print('tuidemo v' .. _VERSION);
return;
end
if command == 'help' or command == '-help' or command == '--help' then
printUsage();
return;
end
if command ~= nil and command ~= '' then
printUsage();
return;
end
local createEventLoop = require('/apis/eventloop');
local createTui = require('/apis/libtui');
local eventloop = createEventLoop();
local ui = createTui(eventloop);
local Text = ui.Text;
local Button = ui.Button;
local Box = ui.Box;
local List = ui.List;
local page = 1;
local pageCount = 3;
local function previousPage()
page = page - 1;
if page < 1 then
page = pageCount;
end
ui.rerender();
end
local function nextPage()
page = page + 1;
if page > pageCount then
page = 1;
end
ui.rerender();
end
local function Header()
return Box({
direction = 'row',
bgColor = colors.gray,
children = {
Text('Trap UI Demo', {
flex = 1,
color = colors.white,
bgColor = colors.gray,
}),
Button('X', {
color = colors.white,
bgColor = colors.red,
onClick = function(tui)
tui.exitUI('closed');
end,
}),
},
});
end
local function PageText()
return List({
gap = 1,
padding = 1,
children = {
Text('Text components render strings inside their assigned rectangle.'),
Text('This line uses pink background and black foreground.', {
color = colors.black,
bgColor = colors.pink,
}),
Text('Resize the terminal to force a redraw.'),
},
});
end
local function PageLayout()
return Box({
direction = 'row',
gap = 1,
padding = 1,
children = {
Box({
flex = 1,
border = true,
title = 'Left',
children = {
Text('flex = 1'),
},
}),
Box({
width = 18,
border = true,
title = 'Fixed',
children = {
Text('width = 18'),
},
}),
Box({
flex = 2,
border = true,
title = 'Right',
children = {
Text('flex = 2'),
},
}),
},
});
end
local function PageButtons()
return List({
gap = 1,
padding = 1,
children = {
Text('Buttons are clickable hitboxes.'),
Button('Click me to go next', {
color = colors.black,
bgColor = colors.lime,
onClick = function()
nextPage();
end,
}),
Button('Exit demo', {
color = colors.white,
bgColor = colors.red,
onClick = function(tui)
tui.exitUI('button');
end,
}),
},
});
end
local function CurrentPage()
if page == 1 then
return PageText();
end
if page == 2 then
return PageLayout();
end
return PageButtons();
end
local function Footer()
return Box({
direction = 'row',
gap = 1,
children = {
Button('Previous', {
onClick = function()
previousPage();
end,
}),
Text('Page ' .. page .. '/' .. pageCount, { flex = 1 }),
Button('Next', {
onClick = function()
nextPage();
end,
}),
},
});
end
local function App()
return Box({
direction = 'column',
children = {
Header(),
Box({
flex = 1,
border = true,
title = 'Demo page ' .. page,
children = {
CurrentPage(),
},
}),
Footer(),
},
});
end
local finalEvent = ui.render(App);
if finalEvent.type == 'terminate' then
print('> User terminated the app');
elseif finalEvent.type == 'error' then
print('> error name: ' .. tostring(finalEvent.error.name));
print('> error reason/details: ' .. tostring(finalEvent.error.reason));
elseif finalEvent.type == 'exitUI' then
print('> User exited the app using the UI');
end