local common = {} ---Checks if the byte at offset is a UTF-8 continuation byte. --- ---UTF-8 encodes code points in 1 to 4 bytes. ---For a multi-byte sequence, each byte following the start byte is a continuation byte. ---@param s string ---@param offset? integer The offset of the string to start searching. Defaults to 1. ---@return boolean function common.is_utf8_cont(s, offset) local byte = s:byte(offset or 1) return byte >= 0x80 and byte < 0xc0 end ---Returns an iterator that yields a UTF-8 character on each iteration. ---@param text string ---@return fun(): string function common.utf8_chars(text) return text:gmatch("[\0-\x7f\xc2-\xf4][\x80-\xbf]*") end ---Clamps the number n between lo and hi. ---@param n number ---@param lo number ---@param hi number ---@return number function common.clamp(n, lo, hi) return math.max(math.min(n, hi), lo) end ---Returns a new table containing the contents of b merged into a. ---@param a table|nil ---@param b table? ---@return table function common.merge(a, b) a = type(a) == "table" and a or {} local t = {} for k, v in pairs(a) do t[k] = v end if b and type(b) == "table" then for k, v in pairs(b) do t[k] = v end end return t end ---Returns the value of a number rounded to the nearest integer. ---@param n number ---@return number function common.round(n) return n >= 0 and math.floor(n + 0.5) or math.ceil(n - 0.5) end ---Returns the first index where a subtable in tbl has prop set. ---If none is found, nil is returned. ---@param tbl table ---@param prop any ---@return number|nil function common.find_index(tbl, prop) for i, o in ipairs(tbl) do if o[prop] then return i end end end ---Returns a value between a and b on a linear scale, based on the ---interpolation point t. --- ---If a and b are tables, a table containing the result for all the ---elements in a and b is returned. ---@param a number ---@param b number ---@param t number ---@return number ---@overload fun(a: table, b: table, t: number): table function common.lerp(a, b, t) if type(a) ~= "table" then return a + (b - a) * t end local res = {} for k, v in pairs(b) do res[k] = common.lerp(a[k], v, t) end return res end ---Returns the euclidean distance between two points. ---@param x1 number ---@param y1 number ---@param x2 number ---@param y2 number ---@return number function common.distance(x1, y1, x2, y2) return math.sqrt(((x2-x1) ^ 2)+((y2-y1) ^ 2)) end ---Parses a CSS color string. --- ---Only these formats are supported: ---* `rgb(r, g, b)` ---* `rgba(r, g, b, a)` ---* `#rrggbbaa` ---* `#rrggbb` ---@param str string ---@return number r ---@return number g ---@return number b ---@return number a function common.color(str) local r, g, b, a = str:match("^#(%x%x)(%x%x)(%x%x)(%x?%x?)$") if r then r = tonumber(r, 16) g = tonumber(g, 16) b = tonumber(b, 16) a = tonumber(a, 16) or 0xff elseif str:match("rgba?%s*%([%d%s%.,]+%)") then local f = str:gmatch("[%d.]+") r = (f() or 0) g = (f() or 0) b = (f() or 0) a = (f() or 1) * 0xff else error(string.format("bad color string '%s'", str)) end return r, g, b, a end ---Splices a numerically indexed table. ---This function mutates the original table. ---@param t any[] ---@param at number Index at which to start splicing. ---@param remove number Number of elements to remove. ---@param insert? any[] A table containing elements to insert after splicing. function common.splice(t, at, remove, insert) assert(remove >= 0, "bad argument #3 to 'splice' (non-negative value expected)") insert = insert or {} local len = #insert if remove ~= len then table.move(t, at + remove, #t + remove, at + len) end table.move(insert, 1, len, at, t) end local function compare_score(a, b) return a.score > b.score end local function fuzzy_match_items(items, needle, files) local res = {} for _, item in ipairs(items) do local score = system.fuzzy_match(tostring(item), needle, files) if score then table.insert(res, { text = item, score = score }) end end table.sort(res, compare_score) for i, item in ipairs(res) do res[i] = item.text end return res end ---Performs fuzzy matching. --- ---If the haystack is a string, a score ranging from 0 to 1 is returned.
---If the haystack is a table, a table containing the haystack sorted in ascending ---order of similarity is returned. ---@param haystack string ---@param needle string ---@param files boolean If true, the matching process will be performed in reverse to better match paths. ---@return number ---@overload fun(haystack: string[], needle: string, files: boolean): string[] function common.fuzzy_match(haystack, needle, files) if type(haystack) == "table" then return fuzzy_match_items(haystack, needle, files) end return system.fuzzy_match(haystack, needle, files) end ---Performs fuzzy matching and returns recently used strings if needed. --- ---If the needle is empty, then a list of recently used strings ---are added to the result, followed by strings from the haystack. ---@param haystack string[] ---@param recents string[] ---@param needle string ---@return string[] function common.fuzzy_match_with_recents(haystack, recents, needle) if needle == "" then local recents_ext = {} for i = 2, #recents do table.insert(recents_ext, recents[i]) end table.insert(recents_ext, recents[1]) local others = common.fuzzy_match(haystack, "", true) for i = 1, #others do table.insert(recents_ext, others[i]) end return recents_ext else return fuzzy_match_items(haystack, needle, true) end end ---Returns a list of paths that are relative to the input path. --- ---If a root directory is specified, the function returns paths ---that are relative to the root directory. ---@param text string The input path. ---@param root? string The root directory. ---@return string[] function common.path_suggest(text, root) if root and root:sub(-1) ~= PATHSEP then root = root .. PATHSEP end local path, name = text:match("^(.-)([^"..PATHSEP.."]*)$") local clean_dotslash = false -- ignore root if path is absolute local is_absolute = common.is_absolute_path(text) if not is_absolute then if path == "" then path = root or "." clean_dotslash = not root else path = (root or "") .. path end end -- Only in Windows allow using both styles of PATHSEP if (PATHSEP == "\\" and not string.match(path:sub(-1), "[\\/]")) or (PATHSEP ~= "\\" and path:sub(-1) ~= PATHSEP) then path = path .. PATHSEP end local files = system.list_dir(path) or {} local res = {} for _, file in ipairs(files) do file = path .. file local info = system.get_file_info(file) if info then if info.type == "dir" then file = file .. PATHSEP end if root then -- remove root part from file path local s, e = file:find(root, nil, true) if s == 1 then file = file:sub(e + 1) end elseif clean_dotslash then -- remove added dot slash local s, e = file:find("." .. PATHSEP, nil, true) if s == 1 then file = file:sub(e + 1) end end if file:lower():find(text:lower(), nil, true) == 1 then table.insert(res, file) end end end return res end ---Returns a list of directories that are related to a path. ---@param text string The input path. ---@return string[] function common.dir_path_suggest(text) local path, name = text:match("^(.-)([^"..PATHSEP.."]*)$") local files = system.list_dir(path == "" and "." or path) or {} local res = {} for _, file in ipairs(files) do file = path .. file local info = system.get_file_info(file) if info and info.type == "dir" and file:lower():find(text:lower(), nil, true) == 1 then table.insert(res, file) end end return res end ---Filters a list of paths to find those that are related to the input path. ---@param text string The input path. ---@param dir_list string[] A list of paths to filter. ---@return string[] function common.dir_list_suggest(text, dir_list) local path, name = text:match("^(.-)([^"..PATHSEP.."]*)$") local res = {} for _, dir_path in ipairs(dir_list) do if dir_path:lower():find(text:lower(), nil, true) == 1 then table.insert(res, dir_path) end end return res end ---Matches a string against a list of patterns. --- ---If a match was found, its start and end index is returned. ---Otherwise, false is returned. ---@param text string ---@param pattern string|string[] ---@param ... any Other options for string.find(). ---@return number|boolean start_index ---@return number|nil end_index function common.match_pattern(text, pattern, ...) if type(pattern) == "string" then return text:find(pattern, ...) end for _, p in ipairs(pattern) do local s, e = common.match_pattern(text, p, ...) if s then return s, e end end return false end ---Draws text onto the window. ---The function returns the X and Y coordinates of the bottom-right ---corner of the text. ---@param font renderer.font ---@param color renderer.color ---@param text string ---@param align string ---| '"left"' # Align text to the left of the bounding box ---| '"right"' # Align text to the right of the bounding box ---| '"center"' # Center text in the bounding box ---@param x number ---@param y number ---@param w number ---@param h number ---@return number x_advance ---@return number y_advance function common.draw_text(font, color, text, align, x,y,w,h) local tw, th = font:get_width(text), font:get_height() if align == "center" then x = x + (w - tw) / 2 elseif align == "right" then x = x + (w - tw) end y = common.round(y + (h - th) / 2) return renderer.draw_text(font, text, x, y, color), y + th end ---Prints the execution time of a function. --- ---The execution time and percentage of frame time ---for the function is printed to standard output.
---The frame rate is always assumed to be 60 FPS, thus ---a value of 100% would mean that the benchmark took ---1/60 of a second to execute. ---@param name string ---@param fn fun(...: any): any ---@return any # The result returned by the function function common.bench(name, fn, ...) local start = system.get_time() local res = fn(...) local t = system.get_time() - start local ms = t * 1000 local per = (t / (1 / 60)) * 100 print(string.format("*** %-16s : %8.3fms %6.2f%%", name, ms, per)) return res end -- From gvx/Ser local oddvals = {[tostring(1/0)] = "1/0", [tostring(-1/0)] = "-1/0", [tostring(-(0/0))] = "-(0/0)", [tostring(0/0)] = "0/0"} local function serialize(val, pretty, indent_str, escape, sort, limit, level) local space = pretty and " " or "" local indent = pretty and string.rep(indent_str, level) or "" local newline = pretty and "\n" or "" local ty = type(val) if ty == "string" then local out = string.format("%q", val) if escape then out = string.gsub(out, "\\\n", "\\n") out = string.gsub(out, "\\7", "\\a") out = string.gsub(out, "\\8", "\\b") out = string.gsub(out, "\\9", "\\t") out = string.gsub(out, "\\11", "\\v") out = string.gsub(out, "\\12", "\\f") out = string.gsub(out, "\\13", "\\r") end return out elseif ty == "table" then -- early exit if level >= limit then return tostring(val) end local next_indent = pretty and (indent .. indent_str) or "" local t = {} for k, v in pairs(val) do table.insert(t, next_indent .. "[" .. serialize(k, pretty, indent_str, escape, sort, limit, level + 1) .. "]" .. space .. "=" .. space .. serialize(v, pretty, indent_str, escape, sort, limit, level + 1)) end if #t == 0 then return "{}" end if sort then table.sort(t) end return "{" .. newline .. table.concat(t, "," .. newline) .. newline .. indent .. "}" end if ty == "number" then -- tostring is locale-dependent, so we need to replace an eventual `,` with `.` local res, _ = tostring(val):gsub(",", ".") -- handle inf/nan return oddvals[res] or res end return tostring(val) end ---@class common.serializeoptions ---@field pretty boolean Enables pretty printing. ---@field indent_str string The indentation character to use. Defaults to `" "`. ---@field escape boolean Uses normal escape characters ("\n") instead of decimal escape sequences ("\10"). ---@field limit number Limits the depth when serializing nested tables. Defaults to `math.huge`. ---@field sort boolean Sorts the output if it is a sortable table. ---@field initial_indent number The initial indentation level. Defaults to 0. ---Serializes a value into a Lua string that is loadable with load(). --- ---Only these basic types are supported: ---* nil ---* boolean ---* number (except very large numbers and special constants, e.g. `math.huge`, `inf` and `nan`) ---* integer ---* string ---* table --- ---@param val any ---@param opts? common.serializeoptions ---@return string function common.serialize(val, opts) opts = opts or {} local indent_str = opts.indent_str or " " local initial_indent = opts.initial_indent or 0 local indent = opts.pretty and string.rep(indent_str, initial_indent) or "" local limit = (opts.limit or math.huge) + initial_indent return indent .. serialize(val, opts.pretty, indent_str, opts.escape, opts.sort, limit, initial_indent) end ---Returns the last portion of a path. ---@param path string ---@return string function common.basename(path) -- a path should never end by / or \ except if it is '/' (unix root) or -- 'X:\' (windows drive) return path:match("[^"..PATHSEP.."]+$") or path end ---Returns the base path with the pathsep, if needed. ---@param path string ---@return string function common.basepath(path) -- Check for AmigaOS 4 and MorphOS if the last character is semicolon -- In these systems the volume name doesn't have a / or \ after the name -- but it is like VOLUME: if (PLATFORM == "AmigaOS 4" or PLATFORM == "MorphOS") and (string.sub(path, -1) == ":") then return path end return path .. PATHSEP end ---Returns the directory name of a path. ---If the path doesn't have a directory, this function may return nil. ---@param path string ---@return string|nil function common.dirname(path) return path:match("(.+)["..PATHSEP.."][^"..PATHSEP.."]+$") end ---Returns a path where the user's home directory is replaced by `"~"`. ---@param text string ---@return string function common.home_encode(text) if HOME and string.find(text, HOME, 1, true) == 1 then local dir_pos = #HOME + 1 -- ensure we don't replace if the text is just "$HOME" or "$HOME/" so -- it must have a "/" following the $HOME and some characters following. if string.find(text, PATHSEP, dir_pos, true) == dir_pos and #text > dir_pos then return "~" .. text:sub(dir_pos) end end return text end ---Returns a list of paths where the user's home directory is replaced by `"~"`. ---@param paths string[] A list of paths to encode ---@return string[] function common.home_encode_list(paths) local t = {} for i = 1, #paths do t[i] = common.home_encode(paths[i]) end return t end ---Expands the `"~"` prefix in a path into the user's home directory. ---This function is not guaranteed to return an absolute path. ---@param text string ---@return string function common.home_expand(text) if text == nil then return HOME end return HOME and text:gsub("^~", HOME) or text end local function split_on_slash(s, sep_pattern) local t = {} if s:match("^["..PATHSEP.."]") then t[#t + 1] = "" end for fragment in string.gmatch(s, "([^"..PATHSEP.."]+)") do t[#t + 1] = fragment end return t end ---Normalizes the drive letter in a Windows path to uppercase. ---This function expects an absolute path, e.g. a path from `system.absolute_path`. --- ---This function is needed because the path returned by `system.absolute_path` ---may contain drive letters in upper or lowercase. ---@param filename string|nil The input path. ---@return string|nil function common.normalize_volume(filename) if not filename then return end if PATHSEP == '\\' then local drive, rem = filename:match('^([a-zA-Z]:\\)(.-)'..PATHSEP..'?$') if drive then return drive:upper() .. rem end end return filename end ---Normalizes a path into the same format across platforms. --- ---On Windows, all drive letters are converted to uppercase. ---UNC paths with drive letters are converted back to ordinary Windows paths. ---All path separators (`"/"`, `"\\"`) are converted to platform-specific ones. ---@param filename string|nil ---@return string|nil function common.normalize_path(filename) if not filename then return end local volume if PATHSEP == '\\' then filename = filename:gsub('[/\\]', '\\') local drive, rem = filename:match('^([a-zA-Z]:\\)(.*)') if drive then volume, filename = drive:upper(), rem else drive, rem = filename:match('^(\\\\[^\\]+\\[^\\]+\\)(.*)') if drive then volume, filename = drive, rem end end else local relpath = filename:match('^/(.+)') if relpath then volume, filename = "/", relpath end end local parts = split_on_slash(filename, PATHSEP) local accu = {} for _, part in ipairs(parts) do if part == '..' then if #accu > 0 and accu[#accu] ~= ".." then table.remove(accu) elseif volume then error("invalid path " .. volume .. filename) else table.insert(accu, part) end elseif part ~= '.' then table.insert(accu, part) end end local npath = table.concat(accu, PATHSEP) return (volume or "") .. (npath == "" and PATHSEP or npath) end ---Checks whether a path is absolute or relative. ---@param path string ---@return boolean function common.is_absolute_path(path) return path:sub(1, 1) == PATHSEP or path:match("^(%a):\\") or path:match('^(%w*):') end ---Checks whether a path belongs to a parent directory. ---@param filename string The path to check. ---@param path string The parent path. ---@return boolean function common.path_belongs_to(filename, path) return string.find(filename, path .. PATHSEP, 1, true) == 1 end ---Checks whether a path is relative to another path. ---@param ref_dir string The path to check against. ---@param dir string The input path. ---@return boolean function common.relative_path(ref_dir, dir) local drive_pattern = "^(%a):\\" local drive, ref_drive = dir:match(drive_pattern), ref_dir:match(drive_pattern) if drive and ref_drive and drive ~= ref_drive then -- Windows, different drives, system.absolute_path fails for C:\..\D:\ return dir end local ref_ls = split_on_slash(ref_dir) local dir_ls = split_on_slash(dir) local i = 1 while i <= #ref_ls do if dir_ls[i] ~= ref_ls[i] then break end i = i + 1 end local ups = "" for k = i, #ref_ls do ups = ups .. ".." .. PATHSEP end local rel_path = ups .. table.concat(dir_ls, PATHSEP, i) return rel_path ~= "" and rel_path or "." end ---Creates a directory recursively if necessary. ---@param path string ---@return boolean success ---@return string|nil error ---@return string|nil path The path where an error occured. function common.mkdirp(path) local stat = system.get_file_info(path) if stat and stat.type then return false, "path exists", path end local subdirs = {} while path and path ~= "" do local success_mkdir = system.mkdir(path) if success_mkdir then break end local updir, basedir = path:match("(.*)["..PATHSEP.."](.+)$") table.insert(subdirs, 1, basedir or path) path = updir end for _, dirname in ipairs(subdirs) do path = path and common.basepath(path) .. dirname or dirname if not system.mkdir(path) then return false, "cannot create directory", path end end return true end ---Removes a path. ---@param path string ---@param recursively boolean If true, the function will attempt to remove everything in the specified path. ---@return boolean success ---@return string|nil error ---@return string|nil path The path where the error occured. function common.rm(path, recursively) local stat = system.get_file_info(path) if not stat or (stat.type ~= "file" and stat.type ~= "dir") then return false, "invalid path given", path end if stat.type == "file" then local removed, error = os.remove(path) if not removed then return false, error, path end else local contents = system.list_dir(path) if #contents > 0 and not recursively then return false, "directory is not empty", path end for _, item in pairs(contents) do local item_path = path .. PATHSEP .. item local item_stat = system.get_file_info(item_path) if not item_stat then return false, "invalid file encountered", item_path end if item_stat.type == "dir" then local deleted, error, ipath = common.rm(item_path, recursively) if not deleted then return false, error, ipath end elseif item_stat.type == "file" then local removed, error = os.remove(item_path) if not removed then return false, error, item_path end end end local removed, error = system.rmdir(path) if not removed then return false, error, path end end return true end return common