From e7666715b09e99787eca734e3de021d95e34cbe1 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Mon, 8 Jun 2026 00:41:19 +0200 Subject: [PATCH] feat: libtui + tuidemo (WIP) --- apis/libtui.lua | 634 +++++++++++++++++++++++++++++++++++++++++++ manifest.json | 6 +- programs/tuidemo.lua | 211 ++++++++++++++ 3 files changed, 849 insertions(+), 2 deletions(-) create mode 100644 apis/libtui.lua create mode 100644 programs/tuidemo.lua diff --git a/apis/libtui.lua b/apis/libtui.lua new file mode 100644 index 0000000..c74e034 --- /dev/null +++ b/apis/libtui.lua @@ -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; diff --git a/manifest.json b/manifest.json index cd13fcd..854acdd 100644 --- a/manifest.json +++ b/manifest.json @@ -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" diff --git a/programs/tuidemo.lua b/programs/tuidemo.lua new file mode 100644 index 0000000..45b6e13 --- /dev/null +++ b/programs/tuidemo.lua @@ -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