---@module "cctAPI" ---example: ---``` ---for pos, code in codes("你好world") do --- print(pos, code) ---end ------> ---1 20320 ---4 22909 ---7 119 ---8 111 ---9 114 ---10 108 ---11 100 ---``` ---@param str string ---@return fun():pos:integer, code:integer local function codes(str) local len = #str local i = 0 local function illegalChar() error("Illegal UTF-8 character at position " .. tostring(i)) end return function() i = i + 1 ---@diagnostic disable-next-line if i > len then return end local pos = i local byte = string.byte(str, i) -- Single-byte character if byte < 0x80 then return pos, byte -- Multi-byte sequences elseif byte >= 0xC0 then local bytes_remaining, code = 0, 0 if byte < 0xE0 then -- 2-byte sequence bytes_remaining = 1 code = byte - 0xC0 elseif byte < 0xF0 then -- 3-byte sequence bytes_remaining = 2 code = byte - 0xE0 elseif byte < 0xF8 then -- 4-byte sequence bytes_remaining = 3 code = byte - 0xF0 else illegalChar() end -- Validate remaining bytes if i + bytes_remaining > len then illegalChar() end -- Calculate code point for j = 1, bytes_remaining do i = i + 1 local next_byte = string.byte(str, i) if next_byte < 0x80 or next_byte >= 0xC0 then illegalChar() end code = code * 0x40 + (next_byte - 0x80) end return pos, code else illegalChar() ---@diagnostic disable-next-line end end end ---@type FontFamily local defaultFontFamily = { maxHeight = 4, [1] = require("fonts/fusion-pixel-12px-proportional-zh_hans") } ---@param str string read in `"rb"` mode from file ---@param nStartPos integer? 1~n, -n~-1 to represent pos reverse ---@param nEndPos integer? 1~n, -n~-1 to represent pos reverse ---@return string substring sub string of utf8 character from `nStartPos` to `nEndPos` local function sub(str, nStartPos, nEndPos) local charPos = 0 local utf8charPosToStr = {} for pos, _ in codes(str) do charPos = charPos + 1 utf8charPosToStr[charPos] = pos end nStartPos = nStartPos or 1 nEndPos = nEndPos or charPos nStartPos = nStartPos < 0 and math.max(1, charPos + nStartPos + 1) or math.min(charPos, nStartPos) nEndPos = nEndPos < 0 and math.max(1, charPos + nEndPos + 1) or math.min(charPos, nEndPos) local startByte = utf8charPosToStr[nStartPos] local endByte = utf8charPosToStr[nEndPos + 1] and utf8charPosToStr[nEndPos + 1] - 1 or #str return string.sub(str, startByte, endByte) end ---@class FontFamily ---@field maxHeight integer ---@diagnostic disable-next-line ---@field [integer] Font get from `require(font_name)` ---@param ... string module names of font, font should be ---@return FontFamily local function getFontFamily(...) local fonts = { maxHeight = 0 } for i, path in ipairs({ ... }) do if not fs.exists(path .. ".lua") then error("Font module not found: " .. path) end local font = require(path) if not font[72] then error("'H'(ascII:72) not found in font " .. path) end fonts[i] = font fonts.maxHeight = math.max(fonts.maxHeight, #font[72]) end return fonts end ---@alias bitmap string[] the bitmap of a character ---@param code integer ---@param fontFamily FontFamily ---@return bitmap local function getCharMap(code, fontFamily) local cm for _, font in ipairs(fontFamily) do cm = font[code] if cm then return cm end end error(("char of utf8 %d is not supported"):format(code)) end ---@class Config ---@field fontFamily FontFamily? ---@field textColor number? ---@field backgroundColor number? ---@field masking [integer, integer, integer, integer]? ---@field autoScroll boolean ---@field autoNewLine boolean ---@field autoWrapMode "n"|"b"|"-"? ---@field autoWrapLen integer? ---@field avoidBorder boolean ---@field tabLen integer? ---@see Config ---@param preset "noauto"? ---@return Config local function getCfg(preset) ---@type Config local base = { fontFamily = defaultFontFamily, textColor = nil, backgroundColor = nil, masking = nil, autoScroll = true, autoNewLine = true, autoWrapMode = "b", autoWrapLen = nil, avoidBorder = true, tabLen = 2 } if preset == "noauto" then base.autoScroll = false base.autoNewLine = false base.autoWrapMode = "n" end return base end ---`str` should be read in `"rb"` mode from file
---`cfg` see [`getCfg()`](lua://Config) ---### Config ---- **textColor** ---- **backgroundColor** ---- **fontFamily** use [`getFontFamily()`](lua://FontFamily) to modify ---- **masking** representing x1, y1, x2, y2 of 2 coordinates, concent out of the rectange range won't print ---- **autoScroll** if true, if next line is over-height, [`term.scroll()`](https://tweaked.cc/module/term.html#v:scroll) will be called, masking will also be scrolled ---- **autoNewLine** if true, the output will be like a `\n` added to the end ---- **autoWrapMode**
----- `"n"` do not auto wrap
----- `"b"` English letter will not be broken
----- `"-"` English letter will be broken by a `'-'`
---here 'letter' matches regex `(? ---actually realized avoid using `luautf8` or regex matching ---- **autoWrapLen** the maximum distance a line goes from the terminal's left ---- **avoidBorder** if true, autoScroll and autoWrap will avoid printing border pixels, which have render issue
---for example, autoWrap will start new line at pos (2, y) instead of (1, y) ---- **tabLen** the count of `' '` to replace `'\t'` ---@param str string ---@param cfg Config? local function printUtf8(str, cfg) local cursorX, cursorY = term.getCursorPos() local termWidth, termHeight = term.getSize() local cfg = cfg or getCfg() local oriTextColor, oriBackgroundColor = term.getTextColor(), term.getBackgroundColor() local textColor = cfg.textColor or oriTextColor local backgroundColor = cfg.backgroundColor or oriBackgroundColor local masking = cfg.masking local autoScroll = cfg.autoScroll or true local autoNewLine = cfg.autoNewLine local autoWrapMode = cfg.autoWrapMode or "b" local autoWrapLen = cfg.autoWrapLen or termWidth local avoidBorder = cfg.avoidBorder local fontFamily = cfg.fontFamily or defaultFontFamily local tabLen = cfg.tabLen or 2 str = string.gsub(str, '\t', string.rep(" ", tabLen)) local fontHeight = fontFamily.maxHeight if avoidBorder and autoWrapMode ~= "n" then autoWrapLen = math.min(termWidth - 1, autoWrapLen) cursorX = math.max(2, cursorX) cursorY = math.max(2, cursorY) end local maxHeight = avoidBorder and termHeight - 1 or termHeight ---@type integer[] local letterBuffer = {} local dashWidth = #getCharMap(45, fontFamily)[1] ---@type { pos: [integer, integer], code: integer }[] local charBuffer = {} ---@param x integer ---@param y integer ---@return boolean local function bInMasking(x, y) ---@diagnostic disable-next-line return x >= masking[1] and x <= masking[3] and y >= masking[2] and y <= masking[4] end ---add new line to charBuffer local function posNewLine() cursorX = avoidBorder and 2 or 1 if autoScroll and cursorY + fontHeight > maxHeight then -- scroll term and masking term.scroll(fontHeight) for _, char in ipairs(charBuffer) do char.pos[2] = char.pos[2] - fontHeight end if masking then masking[2] = masking[2] - fontHeight masking[4] = masking[4] - fontHeight end else cursorY = cursorY + fontHeight end end ---print char in charBuffer local function printChar() local bIsLastReversed = false term.setTextColor(textColor) term.setBackgroundColor(backgroundColor) for _, char in ipairs(charBuffer) do ---@type bitmap local charMap local code = char.code for _, font in ipairs(fontFamily) do if font[code] then charMap = font[code] break end end local width, height = #charMap[1], #charMap local cursorX, cursorY = unpack(char.pos) if height < fontHeight then if bIsLastReversed then bIsLastReversed = false term.setTextColor(textColor) term.setBackgroundColor(backgroundColor) end for _ = 1, fontHeight - height do term.setCursorPos(cursorX, cursorY) term.write(string.rep(" ", width)) cursorY = cursorY + 1 end end for y = 1, height do local posY = cursorY + y - 1 term.setCursorPos(cursorX, posY) local sCharMapBuffer = charMap[y] for x = 1, width do if not masking or bInMasking(cursorX + x - 1, posY) then local code = string.byte(sCharMapBuffer, x) if code < 128 then code = code + 128 if not bIsLastReversed then bIsLastReversed = true term.setTextColor(backgroundColor) term.setBackgroundColor(textColor) end else if bIsLastReversed then bIsLastReversed = false term.setTextColor(textColor) term.setBackgroundColor(backgroundColor) end end term.write(string.char(code)) else term.setCursorPos(cursorX + x, posY) end end end end term.setTextColor(oriTextColor) term.setBackgroundColor(oriBackgroundColor) if autoNewLine then term.setCursorPos(1, cursorY + fontHeight) else term.setCursorPos(cursorX, cursorY) end end local bIsLetter = false local function releaseLetter() if bIsLetter then if autoWrapMode == "-" then local widthSum = cursorX - 1 local widthBuffer = {} for index, letter in ipairs(letterBuffer) do widthBuffer[index] = #getCharMap(letter, fontFamily)[1] end local ind = 1 local letterBufferLen = #letterBuffer local lastDashPos = 1 while ind <= letterBufferLen do widthSum = widthSum + widthBuffer[ind] if widthSum > autoWrapLen then while widthSum + dashWidth > autoWrapLen do if ind == lastDashPos then -- when have to insert dash in the first character, posNewLine() or throw if ind == 1 and cursorX ~= (avoidBorder and 2 or 1) then widthSum = 0 else error("dash too wide or autoWrapLen too small") end end widthSum = widthSum - widthBuffer[ind] ind = ind - 1 end -- pos letters before dash for i = lastDashPos, ind do charBuffer[#charBuffer + 1] = { pos = { cursorX, cursorY }, code = letterBuffer[i] } cursorX = cursorX + widthBuffer[i] end if widthSum >= 0 then -- add dash charBuffer[#charBuffer + 1] = { pos = { cursorX, cursorY }, code = 45 } end -- reset widthSum = 0 lastDashPos = ind + 1 posNewLine() end ind = ind + 1 end -- pos letters after dash for i = lastDashPos, letterBufferLen do charBuffer[#charBuffer + 1] = { pos = { cursorX, cursorY }, code = letterBuffer[i] } cursorX = cursorX + widthBuffer[i] end elseif autoWrapMode == "b" then local widthSum = cursorX - 1 local widthBuffer = {} local ind = 1 local letterBufferLen = #letterBuffer while ind <= letterBufferLen do local charMapWidth = #getCharMap(letterBuffer[ind], fontFamily)[1] widthBuffer[ind] = charMapWidth widthSum = widthSum + charMapWidth ind = ind + 1 end local forceNewLineFlag = false if widthSum > autoWrapLen then if cursorX == (avoidBorder and 2 or 1) then forceNewLineFlag = true else widthSum = widthSum - cursorX + 1 if widthSum > autoWrapLen then forceNewLineFlag = true else posNewLine() end end end if forceNewLineFlag then for i = 1, letterBufferLen do if cursorX + widthBuffer[i] > autoWrapLen then posNewLine() end charBuffer[#charBuffer + 1] = { pos = { cursorX, cursorY }, code = letterBuffer[i] } cursorX = cursorX + widthBuffer[i] end else for i = 1, letterBufferLen do charBuffer[#charBuffer + 1] = { pos = { cursorX, cursorY }, code = letterBuffer[i] } cursorX = cursorX + widthBuffer[i] end end elseif autoWrapMode == "n" then for _, letter in ipairs(letterBuffer) do charBuffer[#charBuffer + 1] = { pos = { cursorX, cursorY }, code = letter } cursorX = cursorX + #getCharMap(letter, fontFamily)[1] end end letterBuffer = {} bIsLetter = false end end local lastLR = false for _, code in codes(str) do -- handle break line, \r, \n and \r\n will be transferred if code == 13 then releaseLetter() lastLR = true posNewLine() elseif code == 10 and not lastLR then lastLR = false releaseLetter() posNewLine() -- record letter for break line elseif code == 45 and bIsLetter then lastLR = false letterBuffer[#letterBuffer + 1] = code releaseLetter() elseif code >= 65 and code <= 90 or (code >= 97 and code <= 122) or code == 39 then lastLR = false letterBuffer[#letterBuffer + 1] = code bIsLetter = true -- pos char else lastLR = false releaseLetter() local charMapWidth = #getCharMap(code, fontFamily)[1] if autoWrapMode ~= "n" and cursorX + charMapWidth - 1 > autoWrapLen then posNewLine() end charBuffer[#charBuffer + 1] = { pos = { cursorX, cursorY }, code = code } cursorX = cursorX + charMapWidth end end releaseLetter() printChar() end return { codes = codes, printUtf8 = printUtf8, getFontFamily = getFontFamily, sub = sub, getCfg = getCfg, getCharMap = getCharMap }