local utf8 = rawget(_G, 'utf8'); 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 utf8Len(value) value = tostring(value or ''); local ok, iter, state, init = pcall(utf8.codes, value); if not ok then return string.len(value); end local count = 0; for _ in iter, state, init do count = count + 1; end return count; end local function utf8Sub(value, startIndex, endIndex) value = tostring(value or ''); startIndex = math.max(1, math.floor(startIndex or 1)); endIndex = endIndex == nil and math.huge or math.floor(endIndex); if endIndex < startIndex then return ''; end local ok, iter, state, init = pcall(utf8.codes, value); if not ok then return string.sub(value, startIndex, endIndex); end local byteStart; local byteEnd; local index = 0; for pos, _ in iter, state, init do index = index + 1; if index == startIndex then byteStart = pos; end if index == endIndex + 1 then byteEnd = pos - 1; break; end end if not byteStart then return ''; end if not byteEnd then byteEnd = string.len(value); end return string.sub(value, byteStart, byteEnd); 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 local valueLength = utf8Len(value); if valueLength > width then return utf8Sub(value, 1, width); end return value .. string.rep(' ', width - valueLength); 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) .. ' '; local titleLength = utf8Len(titleText); if titleLength < rect.w - 1 then top = '+' .. titleText .. string.rep('-', rect.w - 2 - titleLength) .. '+'; 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 utf8Len(tostring(props.text or '')), props.height or 1; end if node.kind == NODE_BUTTON then return props.width or utf8Len(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.eventloop = eventloop; return api; end return createTui;