diff --git a/src/conf.lua b/src/conf.lua new file mode 100644 index 0000000..c208411 --- /dev/null +++ b/src/conf.lua @@ -0,0 +1,6 @@ +love.conf = function(t) + t.window = { + width = 960, + height = 540, + } +end diff --git a/src/lib/utility.lua b/src/lib/utility.lua new file mode 100644 index 0000000..f0e18cf --- /dev/null +++ b/src/lib/utility.lua @@ -0,0 +1,626 @@ +math.randomseed(os.time()) + +local utility + +if package.config:sub(1, 1) == "\\" then + utility = { + OS = "Windows", + path_separator = "\\", + temp_directory = "C:\\Windows\\Temp\\", + commands = { + recursive_remove = "rmdir /s /q ", + list = "dir /w /b ", + which = "where ", + move = "move ", + silence_output = " >nul 2>nul", + silence_errors = " 2>nul", + }, + } +else + utility = { + OS = "Linux", + path_separator = "/", + temp_directory = "/tmp/", + commands = { + recursive_remove = "rm -r ", + list = "ls -1a ", + which = "which ", + move = "mv ", + silence_output = " >/dev/null 2>/dev/null", + silence_errors = " 2>/dev/null", + }, + } +end + +utility.version = "1.6.0-from-llm-tricks" +-- WARNING: This will return "./" if the original script is called locally instead of with an absolute path! +if arg[0] ~= nil then + utility.path = (arg[0]:match("@?(.*/)") or arg[0]:match("@?(.*\\)")) -- inspired by discussion in https://stackoverflow.com/q/6380820 +else + utility.path = "./" +end + + + +local function standard_library_addition(tab, name, func) + if tab[name] then + print("WARNING: " .. tab .. "." .. name .. " was defined by another library. lua-utility may encounter errors due to a differing implementation.") + else + tab[name] = func + end +end + + + +-- trim6 from Lua users wiki (best all-round pure Lua performance) +standard_library_addition(string, "trim", function(s) + return s:match'^()%s*$' and '' or s:match'^%s*(.*%S)' +end) + +standard_library_addition(string, "enquote", function(s) + return "\"" .. s:gsub("\"", "\\\"") .. "\"" +end) + +standard_library_addition(string, "gsplit", function(s, delimiter) + local function escape_special_characters(s) + local special_characters = "[()%%.[^$%]*+%-?]" + if s == nil then return end + return (s:gsub(special_characters, "%%%1")) + end + + delimiter = delimiter or "," + if s:sub(-#delimiter) ~= delimiter then s = s .. delimiter end + return s:gmatch("(.-)" .. escape_special_characters(delimiter)) +end) + +standard_library_addition(string, "split", function(s, delimiter) + local result = {} + for item in s:gsplit(delimiter) do + result[#result + 1] = item + end + return result +end) + +utility.leftpad = function(text, length, character) + return string.rep(character or " ", length - #(tostring(text))) .. text +end + + + +utility.require = function(...) + -- if libraries adjacent to this one aren't already loadable, make sure they are! + if not package.path:find(utility.path, 1, true) then + package.path = utility.path .. "?.lua;" .. package.path + end + return require(...) +end + +-- errors if specified program isn't in the path +local _required_program_cache = {} +utility.required_program = function(name) + if _required_program_cache[name] then + return true + end + if os.execute(utility.commands.which .. tostring(name) .. utility.commands.silence_output) == 0 then + _required_program_cache[name] = true + else + error("\n\n" .. tostring(name) .. " must be installed and in the path\n") + end +end + + + +-- always uses outputting to a temporary file to guarantee safety +utility.capture_safe = function(command, get_status) + local file_name = utility.tmp_file_name() + command = command .. " > " .. file_name + if get_status then + command = command .. "\necho $? >> " .. file_name + end + os.execute(command) + + local file = io.open(file_name, "r") + local output = file:read("*all") + file:close() + os.execute("rm " .. file_name) + + if get_status then + local start, finish = output:find("\n.-\n$") + return tonumber(output:sub(start + 1, finish - 1)), output:sub(1, start) + end + + return output +end +-- WARNING DEPRECATED +utility.capture = function(...) + print("WARNING: Use utility.capture_safe or utility.capture_unsafe. This function will be removed.") + return utility.capture_safe(...) +end + +-- can hang indefinitely; not always available +utility.capture_unsafe = function(command) + if io.popen then + local file = assert(io.popen(command, 'r')) + local output = assert(file:read('*all')) + file:close() + return output + else + print("WARNING: io.popen not available, using a temporary file to receive output from:\n", command) + return utility.capture_safe(command) + end +end + + + +-- modified from my fork of lume +utility.uuid = function() + local fn = function(x) + local r = math.random(16) - 1 + r = (x == "x") and (r + 1) or (r % 4) + 9 + return ("0123456789abcdef"):sub(r, r) + end + return (("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"):gsub("[xy]", fn)) +end + +utility.tmp_file_name = function() + return utility.temp_directory .. utility.uuid() +end + + + +-- while I could replace this with a better implementation, I'm used to how it works and I might break existing scripts +utility.make_safe_file_name = function(file_name) + file_name = file_name:gsub("[%\"%:%\\%!%@%#%$%%%^%*%=%{%}%|%;%<%>%?%/]", "") -- everything except the & + file_name = file_name:gsub(" %&", ",") -- replacing & with a comma works for 99% of things + file_name = file_name:gsub("%&", ",") -- replacing & with a comma works for 99% of things + file_name = file_name:gsub("[%s+]", " ") -- more than one space in succession should be a single space + return file_name +end + +utility.split_path_components = function(file_path) + local path, name, extension = string.match(file_path, "(.-)([^\\/]-%.?([^%.\\/]*))$") + if name == extension then + extension = nil + end + return path, name, extension +end + +-- wrapper around io.open to prevent leaving a file handle open accidentally +-- throws errors instead of returning them +-- usage: utility.open(file_name, mode, function(file_handle) --[[ your code ]] end) +-- or utility.open(file_name, mode)(function(file_handle) --[[ your code ]] end) +utility.open = function(file_name, mode, func) + local file, err = io.open(file_name, mode) + if not file then error(err) end + if func then + local success, result = pcall(function() return func(file) end) + file:close() + if not success then error(result) end + return result + else + return function(fn) + local success, result = pcall(function() return fn(file) end) + file:close() + if not success then + error(result) + end + return result + end + end +end + +-- run a function on each file name in a directory +-- example list items: utility.list(".", print) OR utility.list(".")(print) +utility.list = function(path, func) + local command = utility.commands.list + if path then + command = command .. path:enquote() + end + + local output = utility.capture_safe(command) + + local run = function(fn) + for line in output:gmatch("[^\r\n]+") do -- thanks to https://stackoverflow.com/a/32847589 + if not ((line == ".") or (line == "..")) then + fn(line) + end + end + end + + if func then + run(func) + else + return run + end +end +-- WARNING DEPRECATED +utility.ls = function(...) + print("WARNING: Use utility.list. This function will be removed.") + return utility.list(...) +end + +local tree +tree = function(path, options, fn) + if type(options) == "function" then + fn = options + options = {} + end + + utility.list(path or ".", function(path_name) + if options.blacklist and options.blacklist[path_name] then return end + if options.whitelist and (not options.whitelist[path_name]) then return end + + if utility.is_file(path_name) then + if options.extension_blacklist or options.extension_whitelist then + local _, _, extension = utility.split_path_components(path_name) + if options.extension_blacklist and options.extension_blacklist[extension] then return end + if options.extension_whitelist and (not options.extension_whitelist[extension]) then return end + end + + fn(path_name) + else + tree(path .. utility.path_separator .. path_name, options, fn) + end + end) +end +utility.tree = function(path, options, fn) + tree(path, options, function(path_name) + if path_name:find(path) == 1 then + fn(path_name) + else + fn(path .. utility.path_separator .. path_name) + end + end) +end + +utility.read_file = function(file_name) + return utility.open(file_name, "r", function(file) + return file:read("*all") + end) +end + +utility.write_file = function(file_name, ...) + local text = table.concat{...} + return utility.open(file_name, "w", function(file) + file:write(text) + -- file:write("\n") -- I need to make sure /I/ handle this instead of trying to automate it + end) +end + +utility.path_exists = function(file_name) + local file = io.open(file_name, "r") + if file then file:close() return true else return false end +end +-- WARNING DEPRECATED +utility.file_exists = function(...) + print("WARNING: Use utility.path_exists instead, or utility.is_file to check for a file existing.") + return utility.path_exists(...) +end + +utility.is_file = function(file_name) + local file = io.open(file_name, "r") + if file then + file:close() + file = io.open(file_name, "a") + if file then + file:close() + return true + end + end + return false +end + +utility.file_size = function(file_path) + return utility.open(file_path, "rb", function(file) return file:seek("end") end) +end + +utility.sha512sum = function(file_path) + local sha512sum + if (utility.OS == "Linux") or (utility.OS == "macOS") then + sha512sum = utility.capture_safe("shasum -U -a 512 " .. file_path:enquote()) + elseif utility.OS == "Windows" then + error("utility.sha512sum() not implemented for Windows.") + end + return sha512sum:sub(1, 128) +end + + + +utility.escape_quotes_and_escapes = function(input) + -- the order of these commands is important and must be preserved + input = input:gsub("\\", "\\\\") + input = input:gsub("\"", "\\\"") + return input +end + + + +-- only use for brief loads/saves, as this will block until a lock can be established +-- returns a UUID that can be checked on release to make sure unforeseen errors did not occur +utility.get_lock = function(file_path) + local lock_obtained, lock_uuid, lock_file_path = false, utility.uuid(), file_path .. ".lock" + repeat + if not utility.is_file(lock_file_path) then + pcall(function() + utility.open(lock_file_path, "w", function(file) + file:write(lock_uuid) + end) + utility.open(lock_file_path, "r", function(file) + if file:read("*all") == lock_uuid then + lock_obtained = true + end + end) + end) + end + if not lock_obtained then + print("Trying to obtain file lock: " .. lock_file_path) + os.execute("sleep 1") + end + until lock_obtained + return lock_uuid +end + +-- specifying lock_uuid is optional, to error if a conflict occurred despite the lock (should not be possible) +utility.release_lock = function(file_path, lock_uuid) + local lock_file_path = file_path .. ".lock" + if lock_uuid then + utility.open(lock_file_path, "r", function(file) + if not (file:read("*all") == lock_uuid) then + error("\n\n Lock UUID changed while lock was obtained. Data loss may have occurred. \n\n") + end + end) + end + os.execute("rm " .. lock_file_path:enquote()) +end + + + +local config_path = utility.path .. "config.json" +local config, config_lock +utility.get_config = function(skip_lock) + if not config then + if utility.is_file(config_path) then + if not skip_lock then + config_lock = utility.get_lock(config_path) + end + utility.open(config_path, "r", function(config_file) + local json = utility.require("dkjson") + config = json.decode(config_file:read("*all")) + end) + else + config = {} + end + end + return config +end + +utility.save_config = function() + if config then + if not config_lock then + print("Warning: A config lock file was not established.") + end + utility.open(config_path, "w", function(config_file) + local json = utility.require("dkjson") + config_file:write(json.encode(config, { indent = true })) + end) + if config_lock then + utility.release_lock(config_path, config_lock) + end + else + error("utility config not loaded") + end +end + +utility.get_config_with_defaults = function(defaults) + local config = utility.get_config() + + local loop, changes_made + loop = function(config_level, default_level) + for k,v in pairs(default_level) do + if not config_level[k] then + config_level[k] = v + changes_made = true + elseif type(v) == "table" then + loop(config_level[k], v) + end + end + end + loop(config, defaults) + + if changes_made then + utility.save_config() + else + utility.release_lock(config_path) + end + + return config +end + + + +local data_file_locations = {} +utility.load_data = function(file_path) + local data = utility.open(file_path, "r", function(data_file) + local json = utility.require("dkjson") + return json.decode(data_file:read("*all")) + end) + data_file_locations[data] = file_path + return data +end + +utility.save_data = function(data, file_path) + local keys, loop = {} + loop = function(tab) + if type(tab) == "table" then + for k,v in pairs(tab) do + if not (type(k) == "number") then + keys[k] = true + end + loop(v) + end + end + end + loop(data) + local order = {} + for k in pairs(keys) do order[#order + 1] = k end + table.sort(order) + + file_path = file_path or data_file_locations[data] + assert(file_path, "The object must have been loaded by utility.load_data or you must pass a path as the second argument.") + utility.open(file_path, "w", function(data_file) + local json = utility.require("dkjson") + data_file:write(json.encode(data, { indent = true, keyorder = order, })) + data_file:write("\n") + end) +end + + + +utility.deepcopy = function(tab) + local copy + if type(tab) == "table" then + copy = {} + for key, value in next, tab, nil do + copy[utility.deepcopy(key)] = utility.deepcopy(value) + end + setmetatable(copy, utility.deepcopy(getmetatable(tab))) + else + copy = tab + end + return copy +end + +utility.enumerate = function(list) + local result = {} + for _, value in ipairs(list) do + result[value] = { name = value } + end + return result +end + +local _ +_, utility.inspect = pcall(function() return utility.require("inspect") end) +if _ then + utility.print_table = function(tab) + print(utility.inspect(tab)) + end +else + utility.inspect = nil + -- much simpler (and worse) print_table as fallback + utility.print_table = function(tab, depth) + depth = depth or 0 + if type(tab) == "table" then + for k, v in pairs(tab) do + print(string.rep(" ", depth) .. tostring(k) .. ":") + if type(v) == "table" then + utility.print_table(v, depth + 1) + else + print(string.rep(" ", depth + 1) .. tostring(v)) + end + end + else + print(string.rep(" ", depth) .. tostring(tab)) + end + end +end + +utility.list_reverse = function(tab) + for i = 1, math.floor(#tab/2) do + tab[i], tab[#tab-i+1] = tab[#tab-i+1], tab[i] + end +end + + + +-- a super common need I'm encountering is wanting content from a URL without side effects +utility.curl_read = function(download_url, curl_options) + utility.required_program("curl") + local tmp_file_name = utility.tmp_file_name() + local command = "curl " + if curl_options then + command = command .. curl_options .. " " + end + os.execute(command .. download_url:enquote() .. " > " .. tmp_file_name) + local file_contents + utility.open(tmp_file_name, "r", function(file) + file_contents = file:read("*all") + end) + return file_contents +end + + + +utility.llm_prompt = function(text, model, stabilize) + utility.required_program("ollama") + + -- if stabilize == nil then stabilize = true end -- I may switch this back to defaulting on + if type(text) == "table" then text = table.concat(text, "\n") end + + local config = utility.get_config("read-only") + model = model or (config.ollama and config.ollama.default_model) or "gemma4:12b-mlx" + + local tmp_file_name = utility.tmp_file_name() + utility.open(tmp_file_name, "w", function(file) + file:write(text) + end) + + -- word wrap fucks up usability of output in other things badly + local output = utility.capture_safe("cat " .. tmp_file_name:enquote() .. " | ollama run " .. model .. " --nowordwrap") + -- in previous testing, this improved system stability at the cost of speed by ensuring memory was not overtaxed by repeated calls to an LLM + if stabilize then os.execute("ollama stop " .. model) end + + os.execute("rm " .. tmp_file_name) + assert(output, "ollama failed to generate output") + + local strip_reasoning = function(text) + local reasoning_lines = {} + local tab = text:split("\n") + table.remove(tab, 1) -- remove "Thinking..." + + while true do + local done = tab[1] == "...done thinking." + local line = table.remove(tab, 1) + if done then + table.remove(tab, 1) -- remove blank line after reasoning + return table.concat(tab, "\n"), table.concat(reasoning_lines, "\n") + elseif #tab < 1 then + return text -- can only be reached if theere was no reasoning output + else + reasoning_lines[#reasoning_lines + 1] = line + end + end + end + + output = output:sub(1, -2) -- strip extra newline from utility.capture_safe + return strip_reasoning(output) -- this returns the text AND reasoning +end + + + +-- additionally returns total and count +utility.mean = function(object) + local total, count = 0, 0 + for _, value in pairs(object) do + total = total + value + count = count + 1 + end + return total / count, total, count +end + +-- additionally returns total +utility.median = function(object) + local tab = {} + for _, value in pairs(object) do + tab[#tab + 1] = value + end + table.sort(tab) + return tab[math.floor(#tab / 2)], #tab +end + + + +if (utility.OS == "Linux") and (utility.capture_safe("uname"):find("Darwin") == 1) then + utility.OS = "macOS" +end + +return utility diff --git a/src/main.lua b/src/main.lua new file mode 100644 index 0000000..9e06725 --- /dev/null +++ b/src/main.lua @@ -0,0 +1,56 @@ +local utility = require "lib.utility" + +local progress_bar_metatable = { + __index = { + font = love.graphics.newFont(18), -- TODO customizable + set_progress = function(self, progress_percentage) + self.progress_percentage = progress_percentage + end, + set_text = function(self, new_text) + self.text = new_text + end, + draw = function(self) + local cyan = {0, 0.5, 0.5, 1} -- TODO make customizable; consider percentage range colors + love.graphics.setColor(cyan) + love.graphics.rectangle("fill", self.x_position, self.y_position, self.total_width * self.progress_percentage, self.total_height) + + local text_color = {1, 1, 1, 1} -- TODO customizable; default auto contrast with background color + love.graphics.setColor(text_color) + + if self.text then + local font_height = self.font:getHeight() + local buffer_width = 8 -- TODO this should not be hardcoded + love.graphics.setFont(self.font) + love.graphics.print(self.text, self.x_position + buffer_width, self.y_position + self.total_height / 2 - font_height / 2) + end + end, + } +} + +local new_progress_bar = function(x_position, y_position, total_width, total_height, progress_percentage) + return setmetatable({ + x_position = x_position, + y_position = y_position, + total_width = total_width, + total_height = total_height, + progress_percentage = progress_percentage or 0, + }, progress_bar_metatable) +end + +-- test progress_bar +local window_width, window_height = love.graphics.getDimensions() +local progress_bar_height = 30 -- TODO should not be hardcoded +local test = new_progress_bar(0, window_height - progress_bar_height, window_width, progress_bar_height) +test:set_text("This is a test text string of significant length. It exists to test printing over a progress bar.") +test:set_progress(math.random()) + +love.draw = function() + test:draw() +end + +love.keypressed = function(key) + if key == "escape" then + -- TODO save any remaining log + love.event.quit() + end +end