diff --git a/apis/libtui.lua b/apis/libtui.lua index 0657997..45d0bcc 100644 --- a/apis/libtui.lua +++ b/apis/libtui.lua @@ -1,4 +1,5 @@ -local _VERSION = '0.1.1'; +local _VERSION = '0.1.2'; +local utf8 = rawget(_G, 'utf8'); local NODE_TEXT = 'text'; local NODE_BUTTON = 'button'; @@ -65,6 +66,64 @@ local function shallowCopy(value) 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 { @@ -116,11 +175,12 @@ local function lineText(value, width) return ''; end - if string.len(value) > width then - return string.sub(value, 1, width); + local valueLength = utf8Len(value); + if valueLength > width then + return utf8Sub(value, 1, width); end - return value .. string.rep(' ', width - string.len(value)); + return value .. string.rep(' ', width - valueLength); end local function shrinkRect(rect, amount) @@ -171,8 +231,9 @@ local function drawBorder(rect, props) 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)) .. '+'; + local titleLength = utf8Len(titleText); + if titleLength < rect.w - 1 then + top = '+' .. titleText .. string.rep('-', rect.w - 2 - titleLength) .. '+'; end end @@ -314,11 +375,11 @@ function naturalSize(node) 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; + return props.width or utf8Len(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; + return props.width or utf8Len(buttonLabel(props)), props.height or 1; end local direction = props.direction or 'column'; diff --git a/manifest.json b/manifest.json index dd3a40e..2046f4e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.5.2", + "version": "0.5.3", "branch": "next", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index 10108db..d412981 100644 --- a/packages/index.json +++ b/packages/index.json @@ -4,8 +4,8 @@ "trapos-test": "0.2.0", "trapos-boot": "0.2.1", "trapos-net": "0.2.0", - "trapos-ui": "0.2.0", + "trapos-ui": "0.2.1", "trapos-ai": "0.3.0", - "trapos": "0.5.2" + "trapos": "0.5.3" } } diff --git a/packages/trapos-ui/ccpm.json b/packages/trapos-ui/ccpm.json index 07c7203..1f2b258 100644 --- a/packages/trapos-ui/ccpm.json +++ b/packages/trapos-ui/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-ui", - "version": "0.2.0", + "version": "0.2.1", "description": "TrapOS terminal UI toolkit and demo", "dependencies": ["trapos-core"], "files": [ diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index 10671ed..4505b1f 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.5.2", + "version": "0.5.3", "description": "TrapOS full install meta-package", "dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"], "files": [], diff --git a/tests/libtui.lua b/tests/libtui.lua new file mode 100644 index 0000000..f9406d9 --- /dev/null +++ b/tests/libtui.lua @@ -0,0 +1,133 @@ +-- luacheck: globals term + +local createLibTest = require('/apis/libtest'); +local createTui = require('/apis/libtui'); + +local testlib = createLibTest({ ... }); + +local function fakeEventLoop() + local onStart = nil; + local onStop = nil; + + return { + onStart = function(fn) onStart = fn; end, + onStop = function(fn) onStop = fn; end, + register = function() end, + startLoop = function() + if onStart then + onStart(); + end + return true; + end, + stopLoop = function() + if onStop then + onStop(); + end + end, + isRunningLoop = function() + return false; + end, + }; +end + +local function withFakeTerm(fn) + local originalTerm = term; + local writes = {}; + local state = { + textColor = colors.white, + bgColor = colors.black, + cursorX = 1, + cursorY = 1, + cursorBlink = false, + width = 20, + height = 5, + }; + + term = { + setCursorBlink = function(value) state.cursorBlink = value; end, + setTextColor = function(value) state.textColor = value; end, + setBackgroundColor = function(value) state.bgColor = value; end, + clear = function() writes[#writes + 1] = { kind = 'clear' }; end, + getTextColor = function() return state.textColor; end, + getBackgroundColor = function() return state.bgColor; end, + getCursorPos = function() return state.cursorX, state.cursorY; end, + getCursorBlink = function() return state.cursorBlink; end, + setCursorPos = function(x, y) + state.cursorX = x; + state.cursorY = y; + end, + getSize = function() return state.width, state.height; end, + write = function(text) + writes[#writes + 1] = { + kind = 'write', + text = text, + x = state.cursorX, + y = state.cursorY, + color = state.textColor, + bgColor = state.bgColor, + }; + state.cursorX = state.cursorX + string.len(text); + end, + }; + + local ok, err = pcall(fn, writes, state); + term = originalTerm; + + if not ok then + error(err, 0); + end +end + +testlib.test('text nodes pad utf8 text by characters', function() + withFakeTerm(function(writes) + local ui = createTui(fakeEventLoop()); + ui.render(ui.Box({ + children = { + ui.Box({ + width = 3, + height = 1, + children = { ui.Text('é') }, + }), + }, + })); + + local found = false; + for _, item in ipairs(writes) do + if item.kind == 'write' and item.text == 'é ' then + found = true; + break; + end + end + + testlib.assertTrue(found, 'expected padded utf8 text'); + end); +end); + +testlib.test('box borders keep utf8 titles intact', function() + withFakeTerm(function(writes) + local ui = createTui(fakeEventLoop()); + ui.render(ui.Box({ + children = { + ui.Box({ + width = 5, + height = 3, + border = true, + title = 'Ç', + children = {}, + }), + }, + })); + + local found = false; + for _, item in ipairs(writes) do + if item.kind == 'write' and item.text == '+ Ç +' then + found = true; + break; + end + end + + testlib.assertTrue(found, 'expected utf8 border title'); + end); +end); + +testlib.run();