--!A cross-platform build utility based on Lua -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. -- You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- -- Unless required by applicable law or agreed to in writing, software -- distributed under the License is distributed on an "AS IS" BASIS, -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. -- -- Copyright (C) 2015-present, Xmake Open Source Community. -- -- @author OpportunityLiu -- @file text.lua -- -- define module local text = text or {} -- load modules local string = require("base/string") local colors = require("base/colors") local math = require("base/math") local table = require("base/table") function text._iswbr(ch) if not text._CHARWBR then text._CHARWBR = { [(' '):byte()] = 100, [('\n'):byte()] = 100, [('\t'):byte()] = 100, [('\v'):byte()] = 100, [('\f'):byte()] = 100, [('\r'):byte()] = 100, [('-'):byte()] = 90, [(')'):byte()] = 50, [(']'):byte()] = 50, [('}'):byte()] = 50, [(','):byte()] = 50, [('='):byte()] = 40, [('|'):byte()] = 40, } end return text._CHARWBR[ch] or false end function text._charwidth(ch) if ch == 0x09 then -- TAB return 4 elseif ch == 0x08 then -- BS return -1 elseif ch <= 0x1F or ch == 0x7F then -- other control chars return 0 else return 1 end end function text._nextwbr(self, iter) local ptr = iter.pos + 1 local width = iter.width local str = self.str local e = self.j if ptr > e then return nil end while ptr <= e do local byte = str:byte(ptr) -- ansi sequence, skip it if byte == 27 and ptr + 2 <= e and str:byte(ptr + 1) == 91 then local ansiend = false ptr = ptr + 2 while ptr <= e do local b = str:byte(ptr) if b == 109 then ansiend = true break end ptr = ptr + 1 end if not ansiend then -- ansi sequence not finished in [i, j], no more wbr in the range return nil end else width = width + text._charwidth(byte) local wbr = text._iswbr(byte) if wbr then return { pos = ptr, width = width, quality = wbr } end end ptr = ptr + 1 end -- wbr at end of string return { pos = e, width = width, quality = 100 } end function text._iterwbr(str, i, j, wordbreak) if not i then i = 1 elseif i < 0 then i = #str + 1 + i end if not j then j = #str elseif j < 0 then j = #str + 1 + j end return text._nextwbr, { str = str, i = i, j = j, wordbreak = wordbreak }, { pos = i - 1, width = 0 } end -- @see https://unicode.org/reports/tr14/ function text._lastwbr(str, width, wordbreak) -- check assert(#str >= width) if wordbreak == "breakall" then -- To prevent overflow, word may be broken at any character return width else if (text._iswbr(str:byte(width + 1)) or -1) >= 100 then -- exact break return width end local wbr_candidate for wbr in text._iterwbr(str, 1, #str, wordbreak) do if not wbr_candidate then -- not candidate yet? always use a wbr wbr_candidate = wbr elseif (wbr.quality >= wbr_candidate.quality or wbr.quality >= 80) and wbr.width <= width then -- in range, replace with a higher quality wbr wbr_candidate = wbr end if wbr.width > width then break end end if wbr_candidate then return wbr_candidate.pos end -- not found in all str return #str end end -- break text into lines at the given width with word wrapping -- -- @param str the text string or lines array -- @param width the maximum line width in characters -- @param opt the options (optional) -- @return the wrapped lines array -- function text.wordwrap(str, width, opt) opt = opt or {} -- split to lines if type(str) == "table" then str = table.concat(str, "\n") end local lines = tostring(str):split("\n", {plain = true, strict = true}) local result = table.new(#lines, 0) local actual_width = 0 -- handle lines for _, v in ipairs(lines) do -- remove tailing spaces, include "\r", which will be produced by `("l1\r\nl2"):split(...)` v = v:rtrim() while #v > width do -- find word break chance local wbr = text._lastwbr(v, width, opt.wordbreak) -- break line local line = v:sub(1, wbr):rtrim() actual_width = math.max(#line, actual_width) table.insert(result, line) v = v:sub(wbr + 1):ltrim() -- prevent empty line if #v == 0 then v = nil break end end -- put remaining parts if v then actual_width = math.max(#v, actual_width) table.insert(result, v) end end -- ok return result, actual_width end function text._format_cell(cell, width, opt) local result = table.new(#cell, 0) local max_width = 0 for _, v in ipairs(cell) do local lines, aw = text.wordwrap(tostring(v), width[2], opt) table.move(lines, 1, #lines, #result + 1, result) max_width = math.max(max_width, aw) end cell.formatted = result cell.width = max_width end function text._format_col(col, width, opt) local max_width = 0 for i = 1, table.maxn(col) do local v = col[i] -- skip span cells if v and not v.span then text._format_cell(v, width, opt) max_width = math.max(max_width, v.width) end end col.width = max_width end -- make a table with colors -- -- @param data table data, array of array of cells with optional styles -- eg: { -- {"1", nil, "3"}, -- use nil to make previous cell to span next column -- {"4", "5", {"line1", "line2", style="${yellow}", align = "r"}}, -- multi-line content & set style or align for cell -- {"7", "8", {"9", style="${reset}${red}"}, style="${bright}", align = "c"}, -- set style or align for row -- style = {"${underline}"}, -- set style for columns -- -- or use "${underline}" for all columns -- width = { 20, {10, 50}, "auto"}, -- -- 2 numbers - min and max width (nil for not set, eg: {nil, 50}); -- -- a number - width, num is equivalent to {num, num}; -- -- nil - no limit, equivalent to {nil, nil} -- -- "auto" - use remain space of console, only one "auto" column is allowed -- align = {"l", "r", "c"} -- align mode for each column, "left", "center" or "right" -- -- or use a string for the whole table -- sep = "${dim} | ", -- table colunm sepertor, default is " | ", use "" to hide -- } -- priority of style and align: cell > row > col -- @param opt options for color rendering and word wrapping function text.table(data, opt) assert(data) -- init options opt = opt or { ignore_unknown = true } data.sep = data.sep or " | " opt.patch_reset = false -- col ordered cells local cols = table.new(1, 0) local n_row = table.maxn(data) local n_col = 1 -- count cols for i = 1, n_row do local row = data[i] if row == nil then data[i] = {{""}} else n_col = math.max(n_col, table.maxn(row)) end end -- reorder for i = 1, n_row do local row = data[i] local p_cell = nil for j = 1, n_col do local cell = row[j] if cell ~= nil and type(cell) ~= "table" then -- wrap cells if needed cell = {tostring(cell)} elseif cell == nil and j == 1 then cell = {""} end local col = cols[j] if not col then col = table.new(n_row, 0) cols[j] = col end if cell then col[i] = cell p_cell = cell else p_cell.span = (p_cell.span or 1) + 1 end end end -- load column options data.width = data.width or table.new(n_col, 0) data.align = data.align or table.new(n_col, 0) data.style = data.style or table.new(n_col, 0) local style = "" if type(data.style) == "string" then style = data.style data.style = table.new(n_col, 0) data.sep = style .. data.sep .. "${reset}" end local align = "l" if type(data.align) == "string" then align = data.align data.align = table.new(n_col, 0) end local sep = colors.translate(data.sep, opt) local sep_len = #colors.ignore(data.sep, opt) -- index of auto col local auto_col = nil for i = 1, n_col do -- load width local w = data.width[i] if w ~= "auto" then local wl, wu if w == nil then wl, wu = 0, math.huge elseif type(w) == "number" then if math.isnan(w) or math.isinf(w) then wl, wu = 0, math.huge else wl, wu = w, w end else wl, wu = w[1], w[2] end wl = wl or 0 wu = wu or math.huge data.width[i] = {wl, wu} else assert(not auto_col, "Only one 'auto' colunm is allowed.") auto_col = i end -- load align cols[i].align = (data.align[i] or align):sub(1, 1):lower() -- load style cols[i].style = data.style[i] or style end -- format table -- 1. format non-auto cols for i, col in ipairs(cols) do if i ~= auto_col then text._format_col(col, data.width[i], opt) end end if auto_col then -- 2. caculate auto col width local auto_width = os.getwinsize().width for i = 1, n_col do if i ~= auto_col then auto_width = auto_width - cols[i].width end end auto_width = math.max(0, auto_width - sep_len * (n_col - 1)) data.width[auto_col] = {0,auto_width} -- 3. format auto col text._format_col(cols[auto_col], data.width[auto_col], opt) end -- 4. format span cell for i, col in ipairs(cols) do for j = 1, n_row do local cell = col[j] if cell and cell.span then local w, wl = 0, 0 for ci = 0, (cell.span - 1) do -- actual width of spanned cols w = w + cols[i + ci].width -- min width of spanned cols wl = wl + data.width[i + ci][1] end text._format_cell(cell, {0, math.max(w, wl) + sep_len * (cell.span - 1)}, opt) end end end -- render cells -- row ordered cells local rows = table.new(n_row, 0) -- reorder for i = 1, n_row do local d_row = data[i] or {} local row = table.new(n_col, 0) local line = 1 for j = 1, n_col do local cell = cols[j][i] if cell then assert(cell.formatted) line = math.max(#cell.formatted, line) end row[j] = cell end row.line = line -- load align if d_row.align then row.align = d_row.align:sub(1, 1):lower() end -- load style row.style = d_row.style or "" rows[i] = row end local results = table.new(n_row, 0) local reset = colors.translate("${reset}", opt) for i, row in ipairs(rows) do for l = 1, row.line do local cells = table.new(n_col, 0) local j = 1 while j <= n_col do local cell = row[j] assert(cell) local col = cols[j] if l == 1 then cell.align = cell.align or row.align or col.align cell.style = colors.translate((col.style or "") .. (row.style or "") .. (cell.style or ""), opt) end local str = cell.formatted[l] or "" local width = col.width local span = cell.span or 1 if cell.span then for ci = (j + 1), (j + span - 1) do width = width + cols[ci].width end width = width + sep_len * (span - 1) end local padded if cell.align == "r" then -- right align padded = string.rep(" ", width - #str) .. str elseif cell.align == "c" then -- centered local padding = width - #str local lp = math.floor(padding / 2) local rp = math.ceil(padding / 2) padded = string.rep(" ", lp) .. str .. string.rep(" ", rp) else --left align, emit tailing spaces for last colunm padded = str .. ((j + span == n_col + 1) and "" or string.rep(" ", width - #str)) end table.insert(cells, cell.style .. padded .. reset) j = j + span end table.insert(results, table.concat(cells, sep)) end end -- concat rendered rows results[#results + 1] = "" return table.concat(results, "\n") end -- return module return text