commit 0d8c32a444ecf1067ee236a7cb81c9110919e75a Author: Leon van Kammen Date: Sat Jun 6 12:35:31 2026 +0200 1st commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a7a057 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ + +## Install + +Make sure to [download the loVR runtime](https://lovr.org/downloads) and drag +the `src`-folder of this repository to the **lovr** application [as described in the docs](https://lovr.org/downloads) + +If you already have lovr installed, you can run this in your console as well: +``` +$ ./lovr src +``` + +or nix: + +``` +$ appimage-run ./lovr src +``` + diff --git a/src/browser.lua b/src/browser.lua new file mode 100644 index 0000000..820a85d --- /dev/null +++ b/src/browser.lua @@ -0,0 +1,24 @@ +local browser = {} + +browser.to = function(url,refererer) + print("surfing to " .. url) + local URL = api.url.parse(url) + foreach( api.protocol, function(name, p) + if URL.protocol:match("^"..name) then + protocol = p + end + end) + if protocol then + local status, data, headers = protocol.request(url) + local ctx = { + succes = (status >= 200 and status < 300), + status = status, + data = data, + headers = headers, + URL = URL + } + api.exec( api.ext, 'renderURI', ctx ) + end +end + +return browser diff --git a/src/conf.lua b/src/conf.lua new file mode 100644 index 0000000..01f1b48 --- /dev/null +++ b/src/conf.lua @@ -0,0 +1,8 @@ +package.path = package.path .. ';' .. + arg[0] .. '/lovr/?.lua;' .. + arg[0] .. '/lovr/?/init.lua;' .. + arg[0] .. '/lib/?.lua' + +if lovr ~= nil then + require("lovr/conf") +end diff --git a/src/ecs.lua b/src/ecs.lua new file mode 100644 index 0000000..dc13dc8 --- /dev/null +++ b/src/ecs.lua @@ -0,0 +1,37 @@ +local api = ... +local ecs = require("tiny-ecs") + +ecs.initers = { + renderer = function() + local renderer = ecs.processingSystem() + renderer.filter = ecs.requireAll('type') -- *TODO* might need filter later + function renderer:process(obj, pass) + local type = obj:type() + if type == "Model" then + pass:setMaterial() + pass:setCullMode('none') + pass:draw(obj, 0, 0, 0, 1, 0, 1, 0, 0, 1) + end + end + ecs.addSystem( ecs.world, renderer ) + end +} + +ecs.enabled = true +ecs.world = ecs.world() +ecs.world.update = ecs.update -- swap with extension update-func + +ecs.init = function() + ecs.initers.renderer() +end + +ecs.update = function(dt) + --ecs.world.update( ecs.world, dt ) -- *TODO* different filter +end + +ecs.draw = function(pass) + local opts = { + ecs.world.update( ecs.world, pass ) +end + +return ecs diff --git a/src/ext/gltf/main.lua b/src/ext/gltf/main.lua new file mode 100644 index 0000000..114a0cf --- /dev/null +++ b/src/ext/gltf/main.lua @@ -0,0 +1,27 @@ +local api = ... + +local gltf = { + name = "gltf", + enabled = true, + + init = function() end, + + renderURI = function(ctx) + local URL = ctx.URL + local me = api.ext.gltf + if ctx.succes and URL.extension == 'GLB' or URL.extension == 'GLTF' then + local model = api.graphics.newModel( api.data.newBlob(ctx.data) ) + local ecs = api.ecs + ecs.add( ecs.world, { + x = 0, + y = -7, + z = 0, + model = model + }) + + end + end + +} + +return gltf diff --git a/src/ext/kitchensink/main.lua b/src/ext/kitchensink/main.lua new file mode 100644 index 0000000..8110087 --- /dev/null +++ b/src/ext/kitchensink/main.lua @@ -0,0 +1,84 @@ +local api = ... + +local sample = require "sample" + +return { + name = "kitchensink", + enabled = true, + + init = function() end, + + update = function() + local iui = api.iui + local backend = api.backend + local mainWindow = api.mainWindow + + sample.main() + + end, + + load = function() + local iui = api.iui + local backend = api.backend + local mainWindow = api.mainWindow + + sample.load({ + gameSunsetImage = lovr.graphics.newTexture( + "lovr/sample/assets/game-sunset.png", {} + ), + nineSliceImage = { + image = lovr.graphics.newTexture( + "lovr/sample/assets/ui-box-slice.png", { mipmaps = false } + ), + l = 8, + t = 8, + r = 8, + b = 8 + }, + smileMSDFLayeredImage = { + { + image = lovr.graphics.newTexture( + "lovr/sample/assets/smile-bg.png", + { linear = true, mipmaps = false } + ), + color = iui.newColor(0.944, 0.794, 0.468) + }, + { + image = lovr.graphics.newTexture( + "lovr/sample/assets/smile-fg.png", + { linear = true, mipmaps = false } + ), + color = iui.newColor(0.157, 0.157, 0.157) + } + }, + nineSliceMSDFLayeredImage = { + { + image = { + image = lovr.graphics.newTexture( + "lovr/sample/assets/nine-slice-interior.png", + { linear = true, mipmaps = false } + ), + l = 16, + t = 24, + r = 16, + b = 24 + }, + color = iui.newColor(0.337, 0.653, 0.939) + }, + { + image = { + image = lovr.graphics.newTexture( + "lovr/sample/assets/nine-slice-frame.png", + { linear = true, mipmaps = false } + ), + l = 16, + t = 24, + r = 16, + b = 24 + }, + color = iui.newColor(0.258, 0.300, 0.572) + } + } + }) + end +} diff --git a/src/ext/startupscene/main.lua b/src/ext/startupscene/main.lua new file mode 100644 index 0000000..7bce0a5 --- /dev/null +++ b/src/ext/startupscene/main.lua @@ -0,0 +1,103 @@ +local api = ... +local envTex +local skybox +local environmentMap +local sphericalHarmonics +local shader + +return { + name = "startupscene", + enabled = true, + + init = function() end, + + update = function() + local iui = api.iui + local backend = api.backend + local mainWindow = api.mainWindow + end, + + load = function() + --if api.iui.idiom == "vr" then + envTex = lovr.graphics.newTexture("lovr/assets/img/env.png", {}) + skybox = lovr.graphics.newTexture({ + px = 'ext/startupscene/skybox/Dayright.jpg', -- 'px.png', + nx = 'ext/startupscene/skybox/Dayleft.jpg', -- 'nx.png', + py = 'ext/startupscene/skybox/Daytop.jpg', -- 'py.png', + ny = 'ext/startupscene/skybox/Daybottom.jpg', -- 'ny.png', + pz = 'ext/startupscene/skybox/Dayfront.jpg', -- 'pz.png', + nz = 'ext/startupscene/skybox/Dayback.jpg' -- 'nz.png' + }) + environmentMap = lovr.graphics.newTexture('ext/startupscene/skybox/ibl.ktx') + + sphericalHarmonics = lovr.graphics.newBuffer({ 'vec3', layout = 'std140' }, { + { 0.611764907836914, 0.599504590034485, 0.479980736970901 }, + { 0.659514904022217, 0.665349841117859, 0.567680120468140 }, + { 0.451633930206299, 0.450751245021820, 0.355226665735245 }, + { -0.044383134692907, -0.053154513239861, -0.019974749535322 }, + { -0.053045745939016, -0.057957146316767, -0.011247659102082 }, + { 0.485697060823441, 0.490428507328033, 0.397530466318130 }, + { -0.023690477013588, -0.024272611364722, -0.021886156871915 }, + { -0.179465517401695, -0.181243389844894, -0.141314014792442 }, + { -0.144527092576027, -0.143508568406105, -0.122757166624069 } + }) + shader = lovr.graphics.newShader([[ + vec4 lovrmain() { + return DefaultPosition; + } + ]], [[ + uniform textureCube cubemap; + uniform sphericalHarmonics { vec3 sh[9]; }; + + vec4 lovrmain() { + Surface surface; + initSurface(surface); + + vec3 color = vec3(0); + vec3 lightDirection = vec3(-1, -1, -1); + vec4 lightColorAndBrightness = vec4(1, 1, 1, 3); + float visibility = 1.; + color += getLighting(surface, lightDirection, lightColorAndBrightness, visibility); + color += getIndirectLighting(surface, cubemap, sh); + + return vec4(color, 1); + } + ]], { + flags = { + glow = true, + normalMap = true, + vertexTangents = false, -- DamagedHelmet doesn't have vertex tangents + tonemap = true + } + }) + --end + end, + + draw = function(pass) + if api.iui.idiom == "vr" then + -- skybox + pass:setCullMode('back') + pass:setBlendMode() + pass:skybox(skybox) + + pass:setColor(1, 1, 1) + pass:setMaterial(envTex) + pass:draw(envTex, 0, 0, 0, 512, math.pi * 0.5, 1, 0, 0) + + -- floor + --pass:setMaterial() + pass:setCullMode('front') + pass:setBlendMode('alpha') + pass:setMaterial() + pass:setColor(0.8,0.8,0.8,1) + pass:plane(0, 0, 0, 500, 500, math.pi * 0.5, 1, 0, 0, "fill", 5, 5) + pass:setColor( 0, 0, 0, 0.75 ) + pass:plane(0, 0.01, 0, 500, 500, math.pi * 0.5, 1, 0, 0, "line", 100, 100) + + pass:setShader(shader) + pass:send('cubemap', environmentMap) + pass:send('sphericalHarmonics', sphericalHarmonics) + + end + end +} diff --git a/src/ext/startupscene/skybox/Dayback.jpg b/src/ext/startupscene/skybox/Dayback.jpg new file mode 100755 index 0000000..2d125b8 Binary files /dev/null and b/src/ext/startupscene/skybox/Dayback.jpg differ diff --git a/src/ext/startupscene/skybox/Daybottom.jpg b/src/ext/startupscene/skybox/Daybottom.jpg new file mode 100755 index 0000000..d4f985c Binary files /dev/null and b/src/ext/startupscene/skybox/Daybottom.jpg differ diff --git a/src/ext/startupscene/skybox/Dayfront.jpg b/src/ext/startupscene/skybox/Dayfront.jpg new file mode 100755 index 0000000..725d819 Binary files /dev/null and b/src/ext/startupscene/skybox/Dayfront.jpg differ diff --git a/src/ext/startupscene/skybox/Dayleft.jpg b/src/ext/startupscene/skybox/Dayleft.jpg new file mode 100755 index 0000000..5ba15bb Binary files /dev/null and b/src/ext/startupscene/skybox/Dayleft.jpg differ diff --git a/src/ext/startupscene/skybox/Dayright.jpg b/src/ext/startupscene/skybox/Dayright.jpg new file mode 100755 index 0000000..383afaf Binary files /dev/null and b/src/ext/startupscene/skybox/Dayright.jpg differ diff --git a/src/ext/startupscene/skybox/Daytop.jpg b/src/ext/startupscene/skybox/Daytop.jpg new file mode 100755 index 0000000..b862771 Binary files /dev/null and b/src/ext/startupscene/skybox/Daytop.jpg differ diff --git a/src/ext/startupscene/skybox/ibl.ktx b/src/ext/startupscene/skybox/ibl.ktx new file mode 100644 index 0000000..d80a2e7 Binary files /dev/null and b/src/ext/startupscene/skybox/ibl.ktx differ diff --git a/src/ext/xrfragments/main.lua b/src/ext/xrfragments/main.lua new file mode 100644 index 0000000..8b4437c --- /dev/null +++ b/src/ext/xrfragments/main.lua @@ -0,0 +1,60 @@ +local api = ... +local xrf + +return { + enabled = true, + init = function() + -- create a (ecs) system which detects add/remove entities + local ecs = api.ecs + xrf = ecs.system({ + + filter = ecs.filter('x&y&z'), + + onAdd = function(self,obj) + function scanHrefs() + local tree = api.json.decode( obj['model']:getMetadata() ) + api.util.traverse( tree['nodes'], function(obj) + if obj['extras'] ~= nil then + if obj['extras']['href'] ~= nil then + print(obj['name'] .. ".href => " .. obj['extras']['href'] ) + + -- create collider + -- The "trick" is to set collider:setUserData on creation, to something unique that will act as some sort of identifier for your object. So when a hit is detected from world:raycast you get to know which object this collider is attached to. Hope it makes sense! + + --To achieve this in Lua, you can create a metatable that uses the __newindex metamethod to intercept assignments to the table's fields. Whenever x is updated, the onUpdate function will be called with the table itself as the argument. + --Here's how you can implement it: + -- + -- + --foo = { x = 0, y = 0, z = 0, onUpdate = function(self) print("Updated!") end } + -- + ---- Create a metatable + --local mt = { + -- __newindex = function(t, key, value) + -- rawset(t, key, value) -- Set the value + -- if key == "x" then + -- t.onUpdate(t) -- Call onUpdate if x is updated + -- end + -- end + --} + -- + ---- Set the metatable for foo + --setmetatable(foo, mt) + + end + end + end) + end + if obj['model'] ~= nil then + pcall(scanHrefs) + end + end + + onRemove = function(self,obj) + + end + + }) + ecs.addSystem( ecs.world, xrf ) + + end +} diff --git a/src/lib/json.lua b/src/lib/json.lua new file mode 100644 index 0000000..190fe20 --- /dev/null +++ b/src/lib/json.lua @@ -0,0 +1,386 @@ +-- +-- json.lua +-- +-- Copyright (c) 2020 rxi +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy of +-- this software and associated documentation files (the "Software"), to deal in +-- the Software without restriction, including without limitation the rights to +-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +-- of the Software, and to permit persons to whom the Software is furnished to do +-- so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. +-- + +local json = { _version = "0.1.2" } + +------------------------------------------------------------------------------- +-- Encode +------------------------------------------------------------------------------- + +local encode + +local escape_char_map = { + [ "\\" ] = "\\", + [ "\"" ] = "\"", + [ "\b" ] = "b", + [ "\f" ] = "f", + [ "\n" ] = "n", + [ "\r" ] = "r", + [ "\t" ] = "t", +} + +local escape_char_map_inv = { [ "/" ] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if rawget(val, 1) ~= nil or next(val) == nil then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return ( encode(val) ) +end + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + local line_count = 1 + local col_count = 1 + for i = 1, idx - 1 do + col_count = col_count + 1 + if str:sub(i, i) == "\n" then + line_count = line_count + 1 + col_count = 1 + end + end + error( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(1, 4), 16 ) + local n2 = tonumber( s:sub(7, 10), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local res = "" + local j = i + 1 + local k = j + + while j <= #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + + elseif x == 92 then -- `\`: Escape + res = res .. str:sub(k, j - 1) + j = j + 1 + local c = str:sub(j, j) + if c == "u" then + local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) + or str:match("^%x%x%x%x", j + 1) + or decode_error(str, j - 1, "invalid unicode escape in string") + res = res .. parse_unicode_escape(hex) + j = j + #hex + else + if not escape_chars[c] then + decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") + end + res = res .. escape_char_map_inv[c] + end + k = j + 1 + + elseif x == 34 then -- `"`: End of string + res = res .. str:sub(k, j - 1) + return res, j + 1 + end + + j = j + 1 + end + + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + local res, idx = parse(str, next_char(str, 1, space_chars, true)) + idx = next_char(str, idx, space_chars, true) + if idx <= #str then + decode_error(str, idx, "trailing garbage") + end + return res +end + +return json diff --git a/src/lib/tiny-ecs.lua b/src/lib/tiny-ecs.lua new file mode 100644 index 0000000..4dca2cd --- /dev/null +++ b/src/lib/tiny-ecs.lua @@ -0,0 +1,864 @@ +--[[ +Copyright (c) 2016 Calvin Rose + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +]] + +--- @module tiny-ecs +-- @author Calvin Rose +-- @license MIT +-- @copyright 2016 +local tiny = {} + +-- Local versions of standard lua functions +local tinsert = table.insert +local tremove = table.remove +local tsort = table.sort +local setmetatable = setmetatable +local type = type +local select = select + +-- Local versions of the library functions +local tiny_manageEntities +local tiny_manageSystems +local tiny_addEntity +local tiny_addSystem +local tiny_add +local tiny_removeEntity +local tiny_removeSystem + +--- Filter functions. +-- A Filter is a function that selects which Entities apply to a System. +-- Filters take two parameters, the System and the Entity, and return a boolean +-- value indicating if the Entity should be processed by the System. A truthy +-- value includes the entity, while a falsey (nil or false) value excludes the +-- entity. +-- +-- Filters must be added to Systems by setting the `filter` field of the System. +-- Filter's returned by tiny-ecs's Filter functions are immutable and can be +-- used by multiple Systems. +-- +-- local f1 = tiny.requireAll("position", "velocity", "size") +-- local f2 = tiny.requireAny("position", "velocity", "size") +-- +-- local e1 = { +-- position = {2, 3}, +-- velocity = {3, 3}, +-- size = {4, 4} +-- } +-- +-- local entity2 = { +-- position = {4, 5}, +-- size = {4, 4} +-- } +-- +-- local e3 = { +-- position = {2, 3}, +-- velocity = {3, 3} +-- } +-- +-- print(f1(nil, e1), f1(nil, e2), f1(nil, e3)) -- prints true, false, false +-- print(f2(nil, e1), f2(nil, e2), f2(nil, e3)) -- prints true, true, true +-- +-- Filters can also be passed as arguments to other Filter constructors. This is +-- a powerful way to create complex, custom Filters that select a very specific +-- set of Entities. +-- +-- -- Selects Entities with an "image" Component, but not Entities with a +-- -- "Player" or "Enemy" Component. +-- filter = tiny.requireAll("image", tiny.rejectAny("Player", "Enemy")) +-- +-- @section Filter + +-- A helper function to compile filters. +local filterJoin + +-- A helper function to filters from string +local filterBuildString + +do + + local loadstring = loadstring or load + local function getchr(c) + return "\\" .. c:byte() + end + local function make_safe(text) + return ("%q"):format(text):gsub('\n', 'n'):gsub("[\128-\255]", getchr) + end + + local function filterJoinRaw(prefix, seperator, ...) + local accum = {} + local build = {} + for i = 1, select('#', ...) do + local item = select(i, ...) + if type(item) == 'string' then + accum[#accum + 1] = ("(e[%s] ~= nil)"):format(make_safe(item)) + elseif type(item) == 'function' then + build[#build + 1] = ('local subfilter_%d_ = select(%d, ...)') + :format(i, i) + accum[#accum + 1] = ('(subfilter_%d_(system, e))'):format(i) + else + error 'Filter token must be a string or a filter function.' + end + end + local source = ('%s\nreturn function(system, e) return %s(%s) end') + :format( + table.concat(build, '\n'), + prefix, + table.concat(accum, seperator)) + local loader, err = loadstring(source) + if err then error(err) end + return loader(...) + end + + function filterJoin(...) + local state, value = pcall(filterJoinRaw, ...) + if state then return value else return nil, value end + end + + local function buildPart(str) + local accum = {} + local subParts = {} + str = str:gsub('%b()', function(p) + subParts[#subParts + 1] = buildPart(p:sub(2, -2)) + return ('\255%d'):format(#subParts) + end) + for invert, part, sep in str:gmatch('(%!?)([^%|%&%!]+)([%|%&]?)') do + if part:match('^\255%d+$') then + local partIndex = tonumber(part:match(part:sub(2))) + accum[#accum + 1] = ('%s(%s)') + :format(invert == '' and '' or 'not', subParts[partIndex]) + else + accum[#accum + 1] = ("(e[%s] %s nil)") + :format(make_safe(part), invert == '' and '~=' or '==') + end + if sep ~= '' then + accum[#accum + 1] = (sep == '|' and ' or ' or ' and ') + end + end + return table.concat(accum) + end + + function filterBuildString(str) + local source = ("return function(_, e) return %s end") + :format(buildPart(str)) + local loader, err = loadstring(source) + if err then + error(err) + end + return loader() + end + +end + +--- Makes a Filter that selects Entities with all specified Components and +-- Filters. +function tiny.requireAll(...) + return filterJoin('', ' and ', ...) +end + +--- Makes a Filter that selects Entities with at least one of the specified +-- Components and Filters. +function tiny.requireAny(...) + return filterJoin('', ' or ', ...) +end + +--- Makes a Filter that rejects Entities with all specified Components and +-- Filters, and selects all other Entities. +function tiny.rejectAll(...) + return filterJoin('not', ' and ', ...) +end + +--- Makes a Filter that rejects Entities with at least one of the specified +-- Components and Filters, and selects all other Entities. +function tiny.rejectAny(...) + return filterJoin('not', ' or ', ...) +end + +--- Makes a Filter from a string. Syntax of `pattern` is as follows. +-- +-- * Tokens are alphanumeric strings including underscores. +-- * Tokens can be separated by |, &, or surrounded by parentheses. +-- * Tokens can be prefixed with !, and are then inverted. +-- +-- Examples are best: +-- 'a|b|c' - Matches entities with an 'a' OR 'b' OR 'c'. +-- 'a&!b&c' - Matches entities with an 'a' AND NOT 'b' AND 'c'. +-- 'a|(b&c&d)|e - Matches 'a' OR ('b' AND 'c' AND 'd') OR 'e' +-- @param pattern +function tiny.filter(pattern) + local state, value = pcall(filterBuildString, pattern) + if state then return value else return nil, value end +end + +--- System functions. +-- A System is a wrapper around function callbacks for manipulating Entities. +-- Systems are implemented as tables that contain at least one method; +-- an update function that takes parameters like so: +-- +-- * `function system:update(dt)`. +-- +-- There are also a few other optional callbacks: +-- +-- * `function system:filter(entity)` - Returns true if this System should +-- include this Entity, otherwise should return false. If this isn't specified, +-- no Entities are included in the System. +-- * `function system:onAdd(entity)` - Called when an Entity is added to the +-- System. +-- * `function system:onRemove(entity)` - Called when an Entity is removed +-- from the System. +-- * `function system:onModify(dt)` - Called when the System is modified by +-- adding or removing Entities from the System. +-- * `function system:onAddToWorld(world)` - Called when the System is added +-- to the World, before any entities are added to the system. +-- * `function system:onRemoveFromWorld(world)` - Called when the System is +-- removed from the world, after all Entities are removed from the System. +-- * `function system:preWrap(dt)` - Called on each system before update is +-- called on any system. +-- * `function system:postWrap(dt)` - Called on each system in reverse order +-- after update is called on each system. The idea behind `preWrap` and +-- `postWrap` is to allow for systems that modify the behavior of other systems. +-- Say there is a DrawingSystem, which draws sprites to the screen, and a +-- PostProcessingSystem, that adds some blur and bloom effects. In the preWrap +-- method of the PostProcessingSystem, the System could set the drawing target +-- for the DrawingSystem to a special buffer instead the screen. In the postWrap +-- method, the PostProcessingSystem could then modify the buffer and render it +-- to the screen. In this setup, the PostProcessingSystem would be added to the +-- World after the drawingSystem (A similar but less flexible behavior could +-- be accomplished with a single custom update function in the DrawingSystem). +-- +-- For Filters, it is convenient to use `tiny.requireAll` or `tiny.requireAny`, +-- but one can write their own filters as well. Set the Filter of a System like +-- so: +-- system.filter = tiny.requireAll("a", "b", "c") +-- or +-- function system:filter(entity) +-- return entity.myRequiredComponentName ~= nil +-- end +-- +-- All Systems also have a few important fields that are initialized when the +-- system is added to the World. A few are important, and few should be less +-- commonly used. +-- +-- * The `world` field points to the World that the System belongs to. Useful +-- for adding and removing Entities from the world dynamically via the System. +-- * The `active` flag is whether or not the System is updated automatically. +-- Inactive Systems should be updated manually or not at all via +-- `system:update(dt)`. Defaults to true. +-- * The `entities` field is an ordered list of Entities in the System. This +-- list can be used to quickly iterate through all Entities in a System. +-- * The `interval` field is an optional field that makes Systems update at +-- certain intervals using buffered time, regardless of World update frequency. +-- For example, to make a System update once a second, set the System's interval +-- to 1. +-- * The `index` field is the System's index in the World. Lower indexed +-- Systems are processed before higher indices. The `index` is a read only +-- field; to set the `index`, use `tiny.setSystemIndex(world, system)`. +-- * The `indices` field is a table of Entity keys to their indices in the +-- `entities` list. Most Systems can ignore this. +-- * The `modified` flag is an indicator if the System has been modified in +-- the last update. If so, the `onModify` callback will be called on the System +-- in the next update, if it has one. This is usually managed by tiny-ecs, so +-- users should mostly ignore this, too. +-- +-- There is another option to (hopefully) increase performance in systems that +-- have items added to or removed from them often, and have lots of entities in +-- them. Setting the `nocache` field of the system might improve performance. +-- It is still experimental. There are some restriction to systems without +-- caching, however. +-- +-- * There is no `entities` table. +-- * Callbacks such onAdd, onRemove, and onModify will never be called +-- * Noncached systems cannot be sorted (There is no entities list to sort). +-- +-- @section System + +-- Use an empty table as a key for identifying Systems. Any table that contains +-- this key is considered a System rather than an Entity. +local systemTableKey = { "SYSTEM_TABLE_KEY" } + +-- Checks if a table is a System. +local function isSystem(table) + return table[systemTableKey] +end + +-- Update function for all Processing Systems. +local function processingSystemUpdate(system, dt) + local preProcess = system.preProcess + local process = system.process + local postProcess = system.postProcess + + if preProcess then + preProcess(system, dt) + end + + if process then + if system.nocache then + local entities = system.world.entities + local filter = system.filter + if filter then + for i = 1, #entities do + local entity = entities[i] + if filter(system, entity) then + process(system, entity, dt) + end + end + end + else + local entities = system.entities + for i = 1, #entities do + process(system, entities[i], dt) + end + end + end + + if postProcess then + postProcess(system, dt) + end +end + +-- Sorts Systems by a function system.sortDelegate(entity1, entity2) on modify. +local function sortedSystemOnModify(system) + local entities = system.entities + local indices = system.indices + local sortDelegate = system.sortDelegate + if not sortDelegate then + local compare = system.compare + sortDelegate = function(e1, e2) + return compare(system, e1, e2) + end + system.sortDelegate = sortDelegate + end + tsort(entities, sortDelegate) + for i = 1, #entities do + indices[entities[i]] = i + end +end + +--- Creates a new System or System class from the supplied table. If `table` is +-- nil, creates a new table. +function tiny.system(table) + table = table or {} + table[systemTableKey] = true + return table +end + +--- Creates a new Processing System or Processing System class. Processing +-- Systems process each entity individual, and are usually what is needed. +-- Processing Systems have three extra callbacks besides those inheritted from +-- vanilla Systems. +-- +-- function system:preProcess(dt) -- Called before iteration. +-- function system:process(entity, dt) -- Process each entity. +-- function system:postProcess(dt) -- Called after iteration. +-- +-- Processing Systems have their own `update` method, so don't implement a +-- a custom `update` callback for Processing Systems. +-- @see system +function tiny.processingSystem(table) + table = table or {} + table[systemTableKey] = true + table.update = processingSystemUpdate + return table +end + +--- Creates a new Sorted System or Sorted System class. Sorted Systems sort +-- their Entities according to a user-defined method, `system:compare(e1, e2)`, +-- which should return true if `e1` should come before `e2` and false otherwise. +-- Sorted Systems also override the default System's `onModify` callback, so be +-- careful if defining a custom callback. However, for processing the sorted +-- entities, consider `tiny.sortedProcessingSystem(table)`. +-- @see system +function tiny.sortedSystem(table) + table = table or {} + table[systemTableKey] = true + table.onModify = sortedSystemOnModify + return table +end + +--- Creates a new Sorted Processing System or Sorted Processing System class. +-- Sorted Processing Systems have both the aspects of Processing Systems and +-- Sorted Systems. +-- @see system +-- @see processingSystem +-- @see sortedSystem +function tiny.sortedProcessingSystem(table) + table = table or {} + table[systemTableKey] = true + table.update = processingSystemUpdate + table.onModify = sortedSystemOnModify + return table +end + +--- World functions. +-- A World is a container that manages Entities and Systems. Typically, a +-- program uses one World at a time. +-- +-- For all World functions except `tiny.world(...)`, object-oriented syntax can +-- be used instead of the documented syntax. For example, +-- `tiny.add(world, e1, e2, e3)` is the same as `world:add(e1, e2, e3)`. +-- @section World + +-- Forward declaration +local worldMetaTable + +--- Creates a new World. +-- Can optionally add default Systems and Entities. Returns the new World along +-- with default Entities and Systems. +function tiny.world(...) + local ret = setmetatable({ + + -- List of Entities to remove + entitiesToRemove = {}, + + -- List of Entities to change + entitiesToChange = {}, + + -- List of Entities to add + systemsToAdd = {}, + + -- List of Entities to remove + systemsToRemove = {}, + + -- Set of Entities + entities = {}, + + -- List of Systems + systems = {} + + }, worldMetaTable) + + tiny_add(ret, ...) + tiny_manageSystems(ret) + tiny_manageEntities(ret) + + return ret, ... +end + +--- Adds an Entity to the world. +-- Also call this on Entities that have changed Components such that they +-- match different Filters. Returns the Entity. +function tiny.addEntity(world, entity) + local e2c = world.entitiesToChange + e2c[#e2c + 1] = entity + return entity +end +tiny_addEntity = tiny.addEntity + +--- Adds a System to the world. Returns the System. +function tiny.addSystem(world, system) + assert(system.world == nil, "System already belongs to a World.") + local s2a = world.systemsToAdd + s2a[#s2a + 1] = system + system.world = world + return system +end +tiny_addSystem = tiny.addSystem + +--- Shortcut for adding multiple Entities and Systems to the World. Returns all +-- added Entities and Systems. +function tiny.add(world, ...) + for i = 1, select("#", ...) do + local obj = select(i, ...) + if obj then + if isSystem(obj) then + tiny_addSystem(world, obj) + else -- Assume obj is an Entity + tiny_addEntity(world, obj) + end + end + end + return ... +end +tiny_add = tiny.add + +--- Removes an Entity from the World. Returns the Entity. +function tiny.removeEntity(world, entity) + local e2r = world.entitiesToRemove + e2r[#e2r + 1] = entity + return entity +end +tiny_removeEntity = tiny.removeEntity + +--- Removes a System from the world. Returns the System. +function tiny.removeSystem(world, system) + assert(system.world == world, "System does not belong to this World.") + local s2r = world.systemsToRemove + s2r[#s2r + 1] = system + return system +end +tiny_removeSystem = tiny.removeSystem + +--- Shortcut for removing multiple Entities and Systems from the World. Returns +-- all removed Systems and Entities +function tiny.remove(world, ...) + for i = 1, select("#", ...) do + local obj = select(i, ...) + if obj then + if isSystem(obj) then + tiny_removeSystem(world, obj) + else -- Assume obj is an Entity + tiny_removeEntity(world, obj) + end + end + end + return ... +end + +-- Adds and removes Systems that have been marked from the World. +function tiny_manageSystems(world) + local s2a, s2r = world.systemsToAdd, world.systemsToRemove + + -- Early exit + if #s2a == 0 and #s2r == 0 then + return + end + + world.systemsToAdd = {} + world.systemsToRemove = {} + + local worldEntityList = world.entities + local systems = world.systems + + -- Remove Systems + for i = 1, #s2r do + local system = s2r[i] + local index = system.index + local onRemove = system.onRemove + if onRemove and not system.nocache then + local entityList = system.entities + for j = 1, #entityList do + onRemove(system, entityList[j]) + end + end + tremove(systems, index) + for j = index, #systems do + systems[j].index = j + end + local onRemoveFromWorld = system.onRemoveFromWorld + if onRemoveFromWorld then + onRemoveFromWorld(system, world) + end + s2r[i] = nil + + -- Clean up System + system.world = nil + system.entities = nil + system.indices = nil + system.index = nil + end + + -- Add Systems + for i = 1, #s2a do + local system = s2a[i] + if systems[system.index or 0] ~= system then + if not system.nocache then + system.entities = {} + system.indices = {} + end + if system.active == nil then + system.active = true + end + system.modified = true + system.world = world + local index = #systems + 1 + system.index = index + systems[index] = system + local onAddToWorld = system.onAddToWorld + if onAddToWorld then + onAddToWorld(system, world) + end + + -- Try to add Entities + if not system.nocache then + local entityList = system.entities + local entityIndices = system.indices + local onAdd = system.onAdd + local filter = system.filter + if filter then + for j = 1, #worldEntityList do + local entity = worldEntityList[j] + if filter(system, entity) then + local entityIndex = #entityList + 1 + entityList[entityIndex] = entity + entityIndices[entity] = entityIndex + if onAdd then + onAdd(system, entity) + end + end + end + end + end + end + s2a[i] = nil + end +end + +-- Adds, removes, and changes Entities that have been marked. +function tiny_manageEntities(world) + + local e2r = world.entitiesToRemove + local e2c = world.entitiesToChange + + -- Early exit + if #e2r == 0 and #e2c == 0 then + return + end + + world.entitiesToChange = {} + world.entitiesToRemove = {} + + local entities = world.entities + local systems = world.systems + + -- Change Entities + for i = 1, #e2c do + local entity = e2c[i] + -- Add if needed + if not entities[entity] then + local index = #entities + 1 + entities[entity] = index + entities[index] = entity + end + for j = 1, #systems do + local system = systems[j] + if not system.nocache then + local ses = system.entities + local seis = system.indices + local index = seis[entity] + local filter = system.filter + if filter and filter(system, entity) then + if not index then + system.modified = true + index = #ses + 1 + ses[index] = entity + seis[entity] = index + local onAdd = system.onAdd + if onAdd then + onAdd(system, entity) + end + end + elseif index then + system.modified = true + local tmpEntity = ses[#ses] + ses[index] = tmpEntity + seis[tmpEntity] = index + seis[entity] = nil + ses[#ses] = nil + local onRemove = system.onRemove + if onRemove then + onRemove(system, entity) + end + end + end + end + e2c[i] = nil + end + + -- Remove Entities + for i = 1, #e2r do + local entity = e2r[i] + e2r[i] = nil + local listIndex = entities[entity] + if listIndex then + -- Remove Entity from world state + local lastEntity = entities[#entities] + entities[lastEntity] = listIndex + entities[entity] = nil + entities[listIndex] = lastEntity + entities[#entities] = nil + -- Remove from cached systems + for j = 1, #systems do + local system = systems[j] + if not system.nocache then + local ses = system.entities + local seis = system.indices + local index = seis[entity] + if index then + system.modified = true + local tmpEntity = ses[#ses] + ses[index] = tmpEntity + seis[tmpEntity] = index + seis[entity] = nil + ses[#ses] = nil + local onRemove = system.onRemove + if onRemove then + onRemove(system, entity) + end + end + end + end + end + end +end + +--- Manages Entities and Systems marked for deletion or addition. Call this +-- before modifying Systems and Entities outside of a call to `tiny.update`. +-- Do not call this within a call to `tiny.update`. +function tiny.refresh(world) + tiny_manageSystems(world) + tiny_manageEntities(world) + local systems = world.systems + for i = #systems, 1, -1 do + local system = systems[i] + if system.active then + local onModify = system.onModify + if onModify and system.modified then + onModify(system, 0) + end + system.modified = false + end + end +end + +--- Updates the World by dt (delta time). Takes an optional parameter, `filter`, +-- which is a Filter that selects Systems from the World, and updates only those +-- Systems. If `filter` is not supplied, all Systems are updated. Put this +-- function in your main loop. +function tiny.update(world, dt, filter) + + tiny_manageSystems(world) + tiny_manageEntities(world) + + local systems = world.systems + + -- Iterate through Systems IN REVERSE ORDER + for i = #systems, 1, -1 do + local system = systems[i] + if system.active then + -- Call the modify callback on Systems that have been modified. + local onModify = system.onModify + if onModify and system.modified then + onModify(system, dt) + end + local preWrap = system.preWrap + if preWrap and + ((not filter) or filter(world, system)) then + preWrap(system, dt) + end + end + end + + -- Iterate through Systems IN ORDER + for i = 1, #systems do + local system = systems[i] + if system.active and ((not filter) or filter(world, system)) then + + -- Update Systems that have an update method (most Systems) + local update = system.update + if update then + local interval = system.interval + if interval then + local bufferedTime = (system.bufferedTime or 0) + dt + while bufferedTime >= interval do + bufferedTime = bufferedTime - interval + update(system, interval) + end + system.bufferedTime = bufferedTime + else + update(system, dt) + end + end + + system.modified = false + end + end + + -- Iterate through Systems IN ORDER AGAIN + for i = 1, #systems do + local system = systems[i] + local postWrap = system.postWrap + if postWrap and system.active and + ((not filter) or filter(world, system)) then + postWrap(system, dt) + end + end + +end + +--- Removes all Entities from the World. +function tiny.clearEntities(world) + local el = world.entities + for i = 1, #el do + tiny_removeEntity(world, el[i]) + end +end + +--- Removes all Systems from the World. +function tiny.clearSystems(world) + local systems = world.systems + for i = #systems, 1, -1 do + tiny_removeSystem(world, systems[i]) + end +end + +--- Gets number of Entities in the World. +function tiny.getEntityCount(world) + return #world.entities +end + +--- Gets number of Systems in World. +function tiny.getSystemCount(world) + return #world.systems +end + +--- Sets the index of a System in the World, and returns the old index. Changes +-- the order in which they Systems processed, because lower indexed Systems are +-- processed first. Returns the old system.index. +function tiny.setSystemIndex(world, system, index) + tiny_manageSystems(world) + local oldIndex = system.index + local systems = world.systems + + if index < 0 then + index = tiny.getSystemCount(world) + 1 + index + end + + tremove(systems, oldIndex) + tinsert(systems, index, system) + + for i = oldIndex, index, index >= oldIndex and 1 or -1 do + systems[i].index = i + end + + return oldIndex +end + +-- Construct world metatable. +worldMetaTable = { + __index = { + add = tiny.add, + addEntity = tiny.addEntity, + addSystem = tiny.addSystem, + remove = tiny.remove, + removeEntity = tiny.removeEntity, + removeSystem = tiny.removeSystem, + refresh = tiny.refresh, + update = tiny.update, + clearEntities = tiny.clearEntities, + clearSystems = tiny.clearSystems, + getEntityCount = tiny.getEntityCount, + getSystemCount = tiny.getSystemCount, + setSystemIndex = tiny.setSystemIndex + }, + __tostring = function() + return "" + end +} + +return tiny diff --git a/src/lovr/.gitignore b/src/lovr/.gitignore new file mode 100644 index 0000000..496ee2c --- /dev/null +++ b/src/lovr/.gitignore @@ -0,0 +1 @@ +.DS_Store \ No newline at end of file diff --git a/src/lovr/LICENSE b/src/lovr/LICENSE new file mode 100644 index 0000000..faf4102 --- /dev/null +++ b/src/lovr/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Donald Hays + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lovr/README.md b/src/lovr/README.md new file mode 100644 index 0000000..4c8ad1d --- /dev/null +++ b/src/lovr/README.md @@ -0,0 +1,4 @@ +# IUI-SAMPLE-LÖVR + +A sample project for the [IUI](https://github.com/DonaldHays/iui) immediate mode +GUI library using the [LÖVR](https://lovr.org/) game framework. diff --git a/src/lovr/assets/img/env.png b/src/lovr/assets/img/env.png new file mode 100644 index 0000000..8024762 Binary files /dev/null and b/src/lovr/assets/img/env.png differ diff --git a/src/lovr/conf.lua b/src/lovr/conf.lua new file mode 100644 index 0000000..64de556 --- /dev/null +++ b/src/lovr/conf.lua @@ -0,0 +1,24 @@ +local util = require("util") +local launch = require "launch" + +local function isVR() + return launch.mode == "vr" or util.match(arg,"--desktop") == false +end + +local function isResizable() + return launch.mode == "desktop" +end + +function lovr.conf(t) + if isVR() then + t.headset.supersample = 2 + else + t.modules.headset = nil + end + + t.identity = "xurfer" + t.window.title = "X U R F Ξ R" + t.window.width = 1280 + t.window.height = 720 + t.window.resizable = isResizable() +end diff --git a/src/lovr/launch.lua b/src/lovr/launch.lua new file mode 100644 index 0000000..564306f --- /dev/null +++ b/src/lovr/launch.lua @@ -0,0 +1,23 @@ +local system = require "lovr.system" + +--- A type that customizes how the app launches. +--- +--- Currently, the only launch option is the `mode`, which may be either +--- "desktop" or "vr". On PC, you can change the option in the table literal +--- below to change which mode the app launches in. +--- +--- @class LaunchOptions +--- @field mode IUIIdiom + +--- @type LaunchOptions +local launchOptions = { + mode = "desktop", +} + +--- If we're on Android, then we're on a mobile VR headset, so force the mode to +--- "vr", regardless of the prior setting. +if system.getOS() == "Android" then + launchOptions.mode = "vr" +end + +return launchOptions diff --git a/src/lovr/lib/iui/.gitignore b/src/lovr/lib/iui/.gitignore new file mode 100644 index 0000000..94f1119 --- /dev/null +++ b/src/lovr/lib/iui/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.vscode diff --git a/src/lovr/lib/iui/LICENSE b/src/lovr/lib/iui/LICENSE new file mode 100644 index 0000000..faf4102 --- /dev/null +++ b/src/lovr/lib/iui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Donald Hays + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lovr/lib/iui/README.md b/src/lovr/lib/iui/README.md new file mode 100644 index 0000000..fa28215 --- /dev/null +++ b/src/lovr/lib/iui/README.md @@ -0,0 +1,20 @@ +# IUI + +An immediate mode GUI library for LUA projects. + +[Documentation](https://donaldhays.com/iui-docs/) + +## Installation + +This library must be paired with a backend that connects it to your game engine +of choice. Some available backends: + +- [love-iui](https://github.com/DonaldHays/love-iui): LÖVE +- [lovr-iui](https://github.com/DonaldHays/lovr-iui): LÖVR + +## Samples + +Sample projects are available that show the library in use. + +- [iui-sample-love](https://github.com/DonaldHays/iui-sample-love): LÖVE +- [iui-sample-lovr](https://github.com/DonaldHays/iui-sample-lovr): LÖVR diff --git a/src/lovr/lib/iui/assets/cursor-default_sdf.png b/src/lovr/lib/iui/assets/cursor-default_sdf.png new file mode 100644 index 0000000..73f1bca Binary files /dev/null and b/src/lovr/lib/iui/assets/cursor-default_sdf.png differ diff --git a/src/lovr/lib/iui/assets/cursor-ibeam_sdf.png b/src/lovr/lib/iui/assets/cursor-ibeam_sdf.png new file mode 100644 index 0000000..d638daf Binary files /dev/null and b/src/lovr/lib/iui/assets/cursor-ibeam_sdf.png differ diff --git a/src/lovr/lib/iui/assets/cursor-inactive_sdf.png b/src/lovr/lib/iui/assets/cursor-inactive_sdf.png new file mode 100644 index 0000000..93c53df Binary files /dev/null and b/src/lovr/lib/iui/assets/cursor-inactive_sdf.png differ diff --git a/src/lovr/lib/iui/assets/cursor-sizens_sdf.png b/src/lovr/lib/iui/assets/cursor-sizens_sdf.png new file mode 100644 index 0000000..6d32d8d Binary files /dev/null and b/src/lovr/lib/iui/assets/cursor-sizens_sdf.png differ diff --git a/src/lovr/lib/iui/assets/cursor-sizewe_sdf.png b/src/lovr/lib/iui/assets/cursor-sizewe_sdf.png new file mode 100644 index 0000000..5c1d531 Binary files /dev/null and b/src/lovr/lib/iui/assets/cursor-sizewe_sdf.png differ diff --git a/src/lovr/lib/iui/assets/glyph-checkmark.png b/src/lovr/lib/iui/assets/glyph-checkmark.png new file mode 100644 index 0000000..2be3c66 Binary files /dev/null and b/src/lovr/lib/iui/assets/glyph-checkmark.png differ diff --git a/src/lovr/lib/iui/assets/glyph-disclosure.png b/src/lovr/lib/iui/assets/glyph-disclosure.png new file mode 100644 index 0000000..59925df Binary files /dev/null and b/src/lovr/lib/iui/assets/glyph-disclosure.png differ diff --git a/src/lovr/lib/iui/color.lua b/src/lovr/lib/iui/color.lua new file mode 100644 index 0000000..379e7ab --- /dev/null +++ b/src/lovr/lib/iui/color.lua @@ -0,0 +1,45 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- @class IUIColor +--- @field r number +--- @field g number +--- @field b number +--- @field a number +local color = {} +color.__index = color + +function color:set() + iui.graphics.setColor(self.r, self.g, self.b, self.a) +end + +--- @param r number +--- @param g number +--- @param b number +--- @param a? number +--- @return IUIColor +function iui.newColor(r, g, b, a) + return setmetatable({ r = r, g = g, b = b, a = a or 1 }, color) +end + +iui.colors = { + sysAccent500 = iui.newColor(14 / 255, 59 / 255, 173 / 255), + + sysGray0 = iui.newColor(0.014, 0.014, 0.016), + sysGray50 = iui.newColor(0.083, 0.083, 0.097), + sysGray100 = iui.newColor(0.13, 0.13, 0.15), + sysGray200 = iui.newColor(0.28, 0.28, 0.32), + sysGray300 = iui.newColor(0.403, 0.403, 0.457), + sysGray400 = iui.newColor(0.47, 0.47, 0.53), + sysGray500 = iui.newColor(0.543, 0.543, 0.597), + sysGray600 = iui.newColor(0.628, 0.628, 0.672), + sysGray700 = iui.newColor(0.723, 0.723, 0.757), + sysGray800 = iui.newColor(0.818, 0.818, 0.842), + sysGray900 = iui.newColor(0.914, 0.914, 0.926), + sysGray950 = iui.newColor(0.957, 0.957, 0.963), + sysGray1000 = iui.newColor(0.989, 0.989, 0.991), + + white = iui.newColor(1, 1, 1) +} diff --git a/src/lovr/lib/iui/draw-queue.lua b/src/lovr/lib/iui/draw-queue.lua new file mode 100644 index 0000000..aebdb8d --- /dev/null +++ b/src/lovr/lib/iui/draw-queue.lua @@ -0,0 +1,265 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +local backend --- @type IUIGraphicsBackend + +--- @class IUIDrawQueue: IUIGraphicsBackend +local drawQueue = {} + +--- @class (exact) IUIClipDrawCommand: IUIDrawCommand +--- @field x? number +--- @field y? number +--- @field w? number +--- @field h? number + +--- @param cmd IUIClipDrawCommand +local function commitClip(cmd) + backend.clip(cmd.x, cmd.y, cmd.w, cmd.h) +end + +function drawQueue.clip(x, y, w, h) + --- @type IUIClipDrawCommand + local cmd = iui.pool.get("clip_draw_command") + + cmd.x = x + cmd.y = y + cmd.w = w + cmd.h = h + cmd.commit = commitClip + + iui.draw.enqueue(cmd) +end + +--- @class (exact) IUISetColorDrawCommand: IUIDrawCommand +--- @field r number +--- @field g number +--- @field b number +--- @field a? number + +--- @param cmd IUISetColorDrawCommand +local function commitSetColor(cmd) + backend.setColor(cmd.r, cmd.g, cmd.b, cmd.a) +end + +function drawQueue.setColor(r, g, b, a) + --- @type IUISetColorDrawCommand + local cmd = iui.pool.get("set_color_draw_command") + + cmd.r = r + cmd.g = g + cmd.b = b + cmd.a = a + cmd.commit = commitSetColor + + iui.draw.enqueue(cmd) +end + +--- @class (exact) IUIRectangleDrawCommand: IUIDrawCommand +--- @field x number +--- @field y number +--- @field w number +--- @field h number +--- @field rx? number +--- @field ry? number + +--- @param cmd IUIRectangleDrawCommand +local function commitRectangle(cmd) + backend.rectangle(cmd.x, cmd.y, cmd.w, cmd.h, cmd.rx, cmd.ry) +end + +function drawQueue.rectangle(x, y, w, h, rx, ry) + --- @type IUIRectangleDrawCommand + local cmd = iui.pool.get("rectangle_draw_command") + + cmd.x = x + cmd.y = y + cmd.w = w + cmd.h = h + cmd.rx = rx + cmd.ry = ry + cmd.commit = commitRectangle + + iui.draw.enqueue(cmd) +end + +--- @class (exact) IUICircleDrawCommand: IUIDrawCommand +--- @field x number +--- @field y number +--- @field r number + +--- @param cmd IUICircleDrawCommand +local function commitCircle(cmd) + backend.circle(cmd.x, cmd.y, cmd.r) +end + +function drawQueue.circle(x, y, r) + --- @type IUICircleDrawCommand + local cmd = iui.pool.get("circle_draw_command") + + cmd.x = x + cmd.y = y + cmd.r = r + cmd.commit = commitCircle + + iui.draw.enqueue(cmd) +end + +--- @class (exact) IUISetFontDrawCommand: IUIDrawCommand +--- @field f any + +--- @param cmd IUISetFontDrawCommand +local function commitSetFont(cmd) + backend.setFont(cmd.f) +end + +function drawQueue.setFont(f) + --- @type IUISetFontDrawCommand + local cmd = iui.pool.get("set_font_draw_command") + + cmd.f = f + cmd.commit = commitSetFont + + iui.draw.enqueue(cmd) +end + +--- @class (exact) IUIPrintDrawCommand: IUIDrawCommand +--- @field s string +--- @field x number +--- @field y number + +--- @param cmd IUIPrintDrawCommand +local function commitPrint(cmd) + backend.print(cmd.s, cmd.x, cmd.y) +end + +function drawQueue.print(s, x, y) + --- @type IUIPrintDrawCommand + local cmd = iui.pool.get("print_draw_command") + + cmd.s = s + cmd.x = x + cmd.y = y + cmd.commit = commitPrint + + iui.draw.enqueue(cmd) +end + +--- @class (exact) IUIImageDrawCommand: IUIDrawCommand +--- @field image any +--- @field filter IUIImageFilter +--- @field x number +--- @field y number +--- @field w number +--- @field h number + +--- @param cmd IUIImageDrawCommand +local function commitImage(cmd) + backend.image(cmd.image, cmd.filter, cmd.x, cmd.y, cmd.w, cmd.h) +end + +function drawQueue.image(image, filter, x, y, w, h) + --- @type IUIImageDrawCommand + local cmd = iui.pool.get("image_draw_command") + + cmd.image = image + cmd.filter = filter + cmd.x = x + cmd.y = y + cmd.w = w + cmd.h = h + cmd.commit = commitImage + + iui.draw.enqueue(cmd) +end + +--- @class (exact) IUIMSDFImageDrawCommand: IUIDrawCommand +--- @field image any +--- @field x number +--- @field y number +--- @field w number +--- @field h number + +--- @param cmd IUIMSDFImageDrawCommand +local function commitMSDFImage(cmd) + backend.msdfImage(cmd.image, cmd.x, cmd.y, cmd.w, cmd.h) +end + +function drawQueue.msdfImage(image, x, y, w, h) + --- @type IUIMSDFImageDrawCommand + local cmd = iui.pool.get("msdf_image_draw_command") + + cmd.image = image + cmd.x = x + cmd.y = y + cmd.w = w + cmd.h = h + cmd.commit = commitMSDFImage + + iui.draw.enqueue(cmd) +end + +--- @class (exact) IUINineSliceDrawCommand: IUIDrawCommand +--- @field nineSlice IUIImage9Slice +--- @field filter IUIImageFilter +--- @field x number +--- @field y number +--- @field w number +--- @field h number + +--- @param cmd IUINineSliceDrawCommand +local function commitNineSlice(cmd) + backend.nineSlice(cmd.nineSlice, cmd.filter, cmd.x, cmd.y, cmd.w, cmd.h) +end + +function drawQueue.nineSlice(nineSlice, filter, x, y, w, h) + --- @type IUINineSliceDrawCommand + local cmd = iui.pool.get("nine_slice_draw_command") + + cmd.nineSlice = nineSlice + cmd.filter = filter + cmd.x = x + cmd.y = y + cmd.w = w + cmd.h = h + cmd.commit = commitNineSlice + + iui.draw.enqueue(cmd) +end + +--- @class (exact) IUIMSDFNineSliceDrawCommand: IUIDrawCommand +--- @field nineSlice IUIImage9Slice +--- @field x number +--- @field y number +--- @field w number +--- @field h number + +--- @param cmd IUIMSDFNineSliceDrawCommand +local function commitMSDFNineSlice(cmd) + backend.msdfNineSlice(cmd.nineSlice, cmd.x, cmd.y, cmd.w, cmd.h) +end + +function drawQueue.msdfNineSlice(nineSlice, x, y, w, h) + --- @type IUIMSDFNineSliceDrawCommand + local cmd = iui.pool.get("msdf_nine_slice_draw_command") + + cmd.nineSlice = nineSlice + cmd.x = x + cmd.y = y + cmd.w = w + cmd.h = h + cmd.commit = commitMSDFNineSlice + + iui.draw.enqueue(cmd) +end + +--- @param graphicsBackend IUIGraphicsBackend +function drawQueue.setBackend(graphicsBackend) + backend = graphicsBackend + setmetatable(drawQueue, { + __index = graphicsBackend + }) +end + +iui.drawQueue = drawQueue diff --git a/src/lovr/lib/iui/draw.lua b/src/lovr/lib/iui/draw.lua new file mode 100644 index 0000000..c258b33 --- /dev/null +++ b/src/lovr/lib/iui/draw.lua @@ -0,0 +1,223 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- @class IUIDraw +--- @overload fun() +local draw = {} + +--- @class (exact) IUIDrawCommand +--- @field commit fun(command: IUIDrawCommand) + +--- @class (exact) IUIDrawContext +--- @field commands IUIDrawCommand[] +--- @field currentClip? number[] +--- @field clipStack number[][] +--- @field hideCount number + +--- @class (exact) IUIDrawRootContext +--- @field width number +--- @field height number +--- @field drawContexts IUIDrawContext[] +--- @field contextIndex number + +local ctx --- @type IUIDrawRootContext + +--- @param idx? number +--- @return IUIDrawContext +local function getDrawContext(idx) + return ctx.drawContexts[idx or ctx.contextIndex] +end + +setmetatable(draw --[[@as any]], { + __call = function(_, onDraw) + if onDraw then + error("IUI no longer takes draw blocks") + else + iui.graphics.beginDraw(ctx.width, ctx.height) + for _, context in ipairs(ctx.drawContexts) do + for _, command in ipairs(context.commands) do + command.commit(command) + iui.pool.put(command) + end + end + iui.graphics.endDraw() + end + end +}) + +--- @param command IUIDrawCommand +--- @overload fun(f: function) +function draw.enqueue(command) + local drawContext = getDrawContext() + if drawContext.hideCount == 0 then + if type(command) == "function" then + --- @type IUIDrawCommand + local cmd = iui.pool.get("function_draw_command") + + cmd.commit = command + + table.insert(drawContext.commands, cmd) + else + table.insert(drawContext.commands, command) + end + end +end + +--- @return IUIDrawRootContext +function draw.newRootContext() + --- @type IUIDrawRootContext + return { + width = 0, + height = 0, + drawContexts = {}, + contextIndex = 0 + } +end + +--- @param windowManager IUIWindowManager +function draw.setWindowManager(windowManager) + ctx = windowManager.draw +end + +function draw.beginFrame() + ctx.drawContexts = {} +end + +function draw.endFrame() + if getDrawContext().currentClip ~= nil then + error("Clip stack was not empty at end of frame") + end +end + +function draw.setWindowSize(width, height) + ctx.width, ctx.height = width, height +end + +function draw.beginContext() + --- @type IUIDrawContext + local drawContext = { + clipStack = {}, + commands = {}, + hideCount = 0 + } + table.insert(ctx.drawContexts, drawContext) + ctx.contextIndex = #ctx.drawContexts +end + +function draw.endContext() + if getDrawContext().hideCount ~= 0 then + error("Hide count was not 0 at end of context") + end + + ctx.contextIndex = ctx.contextIndex - 1 +end + +function draw.beginHiding() + getDrawContext().hideCount = getDrawContext().hideCount + 1 +end + +function draw.endHiding() + getDrawContext().hideCount = getDrawContext().hideCount - 1 +end + +--- @param x number The x coordinate of the clip region +--- @param y number The y coordinate of the clip region +--- @param w number The width of the clip region +--- @param h number The height of the clip region +function draw.pushClip(x, y, w, h) + local drawContext = getDrawContext() + local currentClip = drawContext.currentClip + if currentClip then + table.insert(drawContext.clipStack, currentClip) + + local maxX, maxY = x + w, y + h + local cx, cy = currentClip[1], currentClip[2] + local cMaxX, cMaxY = cx + currentClip[3], cy + currentClip[4] + + x = math.max(x, cx) + y = math.max(y, cy) + maxX = math.min(maxX, cMaxX) + maxY = math.min(maxY, cMaxY) + + w = math.max(0, maxX - x) + h = math.max(0, maxY - y) + end + + drawContext.currentClip = { x, y, w, h } + iui.graphics.clip(x, y, w, h) +end + +function draw.popClip() + local drawContext = getDrawContext() + if drawContext.currentClip == nil then + error("Attempt to pop empty clip stack") + end + + if #drawContext.clipStack > 0 then + drawContext.currentClip = table.remove(drawContext.clipStack) + else + drawContext.currentClip = nil + end + + iui.graphics.clip() +end + +--- @return number? x, number? y, number? w, number? h +function draw.getClipBounds() + local currentClip = getDrawContext().currentClip + if currentClip then + return currentClip[1], currentClip[2], currentClip[3], currentClip[4] + else + return + end +end + +--- @param x number +--- @param y number +--- @param w number +--- @param h number +function draw.panelBackground(x, y, w, h) + if iui.idiom ~= "vr" then + iui.graphics.rectangle(x, y, w, h) + return + end + + local ww, wh = iui.layout.windowWidth, iui.layout.windowHeight + local radius = iui.style["vrWindowCornerRadius"] + local bx, by, bw, bh = x, y, w, h + local insetCount = 0 + + if x > 0 then + insetCount = insetCount + 1 + bx = bx - radius + bw = bw + radius + end + + if y > 0 then + insetCount = insetCount + 1 + by = by - radius + bh = bh + radius + end + + if x + w < ww then + insetCount = insetCount + 1 + bw = bw + radius + end + + if y + h < wh then + insetCount = insetCount + 1 + bh = bh + radius + end + + if insetCount == 4 then + iui.graphics.rectangle(x, y, w, h) + else + iui.graphics.clip(x, y, w, h) + iui.graphics.rectangle(bx, by, bw, bh, radius, radius) + iui.graphics.clip() + end +end + +iui.draw = draw diff --git a/src/lovr/lib/iui/id.lua b/src/lovr/lib/iui/id.lua new file mode 100644 index 0000000..5f22def --- /dev/null +++ b/src/lovr/lib/iui/id.lua @@ -0,0 +1,136 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- @type table> +local cache = {} +local cacheMetatable = { __mode = "k" } + +--- @type number[] +local stack = {} + +--- @type IUISet +local check = iui.set.new() + +function iui.resetIDCheck() + check:removeAll() +end + +--- @param name string +--- @param canFocus? boolean +--- @return number +function iui.beginID(name, canFocus) + if canFocus == nil then + canFocus = true + end + + if iui.isDisabled() then + canFocus = false + end + + -- Initialize the hash to either the current hash, or the default. + local stackSize = #stack + local hash = stackSize > 0 and stack[stackSize] or 2166136261 + + -- Find the cached ids for the current top id. + local ids = cache[hash] + if ids == nil then + ids = setmetatable({}, cacheMetatable) + cache[hash] = ids + end + + -- If there's a cached hash, use it, otherwise calculate it. + local cached = ids[name] + if cached then + hash = cached + else + for idx = 1, #name do + hash = bit.bxor(hash, string.byte(name, idx)) + hash = (hash * 16777619) % 0x100000000 + end + ids[name] = hash + end + + if check:has(hash) then + print("Warning: duplicate hash for " .. name) + else + check:put(hash) + end + + -- Push the hash onto the stack. + table.insert(stack, hash) + + -- Take focus if nothing else has it, and we're in the desktop idiom. + if canFocus and iui.idiom == "desktop" then + iui.layer.setFocusID(iui.layer.getFocusID() or hash) + end + + if iui.layer.getFocusID() == hash and iui.input.keyboard.pressed["tab"] then + if iui.input.keyboard.down:has("lshift") then + iui.layer.setFocusID(iui.layer.getLastID()) + else + iui.layer.setFocusID(nil) + end + iui.input.keyboard.pressed["tab"] = nil + end + + if canFocus then + iui.layer.setLastID(hash) + end + + return hash +end + +--- @param advanceLayout? boolean +function iui.endID(advanceLayout) + table.remove(stack) + + if advanceLayout == nil or advanceLayout then + iui.layout.advance() + end +end + +--- Attempts to set the current ID as the hover ID. +--- +--- An ID won't become the hover ID if another ID is the current active ID, or +--- if the control is disabled. +function iui.becomeHover() + if iui.isDisabled() then + return + end + + local id = stack[#stack] + if iui.activeID == nil or iui.activeID == id then + iui.hoverID = id + end +end + +--- @param id? number +function iui.becomeActive(id) + if not iui.isDisabled() then + if iui.activeID and iui.widgetDeactivated then + iui.widgetDeactivated() + end + + id = id or stack[#stack] + iui.activeID = id + iui.hadActiveID = true + + if iui.widgetActivated then + iui.widgetActivated() + end + end +end + +function iui.becomeFocus() + if not iui.isDisabled() then + iui.layer.setFocusID(stack[#stack]) + end +end + +--- @param id number +--- @return boolean +function iui.isFocused(id) + return iui.layer.isTop() and iui.layer.getFocusID() == id +end diff --git a/src/lovr/lib/iui/init.lua b/src/lovr/lib/iui/init.lua new file mode 100644 index 0000000..c1c430d --- /dev/null +++ b/src/lovr/lib/iui/init.lua @@ -0,0 +1,25 @@ +local currentPath = (...):gsub('%.init$', '') .. "." +local resourcePath = currentPath:gsub("%.", "/") + +--- @type IUILib +local iui = require(currentPath .. "iui") + +iui.resourcePath = resourcePath + +require(currentPath .. "utils") +require(currentPath .. "set") +require(currentPath .. "pool") +require(currentPath .. "window-manager") +require(currentPath .. "color") +require(currentPath .. "input") +require(currentPath .. "id") +require(currentPath .. "draw") +require(currentPath .. "draw-queue") +require(currentPath .. "layout") +require(currentPath .. "style") +require(currentPath .. "state") +require(currentPath .. "layer") + +require(currentPath .. "widgets") + +return iui diff --git a/src/lovr/lib/iui/input/init.lua b/src/lovr/lib/iui/input/init.lua new file mode 100644 index 0000000..272789f --- /dev/null +++ b/src/lovr/lib/iui/input/init.lua @@ -0,0 +1,61 @@ +local currentPath = (...):gsub('%.init$', '') .. "." +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @class IUIInput +local input = require(currentPath .. "input") + +--- @class (exact) IUIInputRootContext +--- @field textBuffer string? +--- @field isActive boolean + +--- @type IUIInputRootContext +local ctx + +function input.load() + ctx = { + textBuffer = nil, + isActive = true, + } + + input.keyboard.load() +end + +function input.text(s) + if ctx.textBuffer then + ctx.textBuffer = ctx.textBuffer .. s + else + ctx.textBuffer = s + end + + if ctx.isActive then + input.textBuffer = ctx.textBuffer + else + input.textBuffer = nil + end +end + +function input.endFrame() + input.mouse.endFrame() + input.keyboard.endFrame() + + ctx.textBuffer = nil + input.textBuffer = nil +end + +--- @param active boolean +function input.setActive(active) + ctx.isActive = active + input.mouse.setActive(active) + input.keyboard.setActive(active) + + if active then + input.textBuffer = ctx.textBuffer + else + input.textBuffer = nil + end +end + +iui.input = input diff --git a/src/lovr/lib/iui/input/input.lua b/src/lovr/lib/iui/input/input.lua new file mode 100644 index 0000000..4ff2d94 --- /dev/null +++ b/src/lovr/lib/iui/input/input.lua @@ -0,0 +1,45 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUIInput +local input = { + --- @type string? + textBuffer = nil +} + +--- @alias IUIMouseEvent "move" | "down" | "up" | "scroll" + +--- @class IUIMouse +--- @field x number +--- @field y number +--- @field dx number +--- @field dy number +--- @field scrollX number +--- @field scrollY number +--- @field down IUISet +--- @field pressed IUISet +--- @field released IUISet +--- @field newRootContext fun(): IUIMouseRootContext +--- @field setRootContext fun(rootContext: IUIMouseRootContext) +--- @field endFrame fun() +--- @field resetVelocity fun() +--- @field getVelocity fun(): number, number +--- @field setActive fun(active: boolean) +--- @overload fun(event: IUIMouseEvent, button: number, x: number, y: number, dx: number, dy: number) + +--- @class IUIKeyboard +--- @field down IUISet +--- @field pressed table +--- @field released IUISet +--- @field load fun() +--- @field endFrame fun() +--- @field setActive fun(active: boolean) +--- @field getPrimaryModifierKeycode fun(): string +--- @overload fun(event: IUIKeyEvent, keycode: string, isRepeat: boolean) + +--- @type IUIMouse +input.mouse = require(currentPath .. "mouse") + +--- @type IUIKeyboard +input.keyboard = require(currentPath .. "keyboard") + +return input diff --git a/src/lovr/lib/iui/input/keyboard.lua b/src/lovr/lib/iui/input/keyboard.lua new file mode 100644 index 0000000..cf4b35a --- /dev/null +++ b/src/lovr/lib/iui/input/keyboard.lua @@ -0,0 +1,79 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @alias IUIKeyEvent "down" | "up" + +--- @alias IUIKeyPressed { isRepeat: boolean } + +--- @class (exact) IUIKeyboardState +--- @field down IUISet +--- @field pressed table +--- @field released IUISet + +--- @class (exact) IUIKeyboardRootContext +--- @field storage IUIKeyboardState +--- @field disabledStorage IUIKeyboardState + +--- @return IUIKeyboardState +local function makeKeyboardState() + return { + down = iui.set.new(), + pressed = {}, + released = iui.set.new() + } +end + +local ctx --- @type IUIKeyboardRootContext + +local keyboard = { + --- @param event IUIKeyEvent + --- @param keycode string + --- @param isRepeat boolean + __call = function(_, event, keycode, isRepeat) + if event == "down" then + ctx.storage.down:put(keycode) + ctx.storage.pressed[keycode] = { isRepeat = isRepeat } + elseif event == "up" then + ctx.storage.down:remove(keycode) + ctx.storage.released:put(keycode) + end + end +} +setmetatable(keyboard, keyboard) + +function keyboard.load() + ctx = { + storage = makeKeyboardState(), + disabledStorage = makeKeyboardState() + } + + keyboard.__index = ctx.storage +end + +function keyboard.endFrame() + local pressed = ctx.storage.pressed + for k, _ in pairs(pressed) do + pressed[k] = nil + end + + ctx.storage.released:removeAll() +end + +--- @param active boolean +function keyboard.setActive(active) + keyboard.__index = active and ctx.storage or ctx.disabledStorage +end + +--- @return string keycode +function keyboard.getPrimaryModifierKeycode() + if iui.backend.system.getOS() == "macOS" then + return "lgui" + else + return "lctrl" + end +end + +return keyboard --[[@as IUIKeyboard]] diff --git a/src/lovr/lib/iui/input/mouse.lua b/src/lovr/lib/iui/input/mouse.lua new file mode 100644 index 0000000..f428a5c --- /dev/null +++ b/src/lovr/lib/iui/input/mouse.lua @@ -0,0 +1,159 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @class (exact) IUIMouseVelocityEntry +--- @field dx number +--- @field dy number +--- @field timestamp number +--- @field dt number + +--- @class (exact) IUIMouseState +--- @field x number +--- @field y number +--- @field dx number +--- @field dy number +--- @field scrollX number +--- @field scrollY number +--- @field down IUISet +--- @field pressed IUISet +--- @field released IUISet + +--- @class (exact) IUIMouseRootContext +--- @field velocityBuffer IUIMouseVelocityEntry[] +--- @field storage IUIMouseState +--- @field disabledStorage IUIMouseState + +local ctx --- @type IUIMouseRootContext + +local function limitMouseVelocityEvents() + local limit = iui.backend.system.getTimestamp() - 0.1 + + while #ctx.velocityBuffer > 0 and ctx.velocityBuffer[1].timestamp < limit do + table.remove(ctx.velocityBuffer, 1) + end +end + +local function addMouseVelocityEvent(dx, dy) + local timestamp = iui.backend.system.getTimestamp() + + --- @type IUIMouseVelocityEntry + local entry = { dx = dx, dy = dy, timestamp = timestamp, dt = iui.dt } + + table.insert(ctx.velocityBuffer, entry) + + limitMouseVelocityEvents() +end + +--- @return IUIMouseState +local function makeMouseState() + --- @type IUIMouseState + return { + x = -100, + y = -100, + dx = 0, + dy = 0, + scrollX = 0, + scrollY = 0, + down = iui.set.new(), + pressed = iui.set.new(), + released = iui.set.new() + } +end + +local mouse = { + --- @param event IUIMouseEvent + --- @param button number + --- @param x number + --- @param y number + --- @param dx number + --- @param dy number + __call = function(_, event, button, x, y, dx, dy) + if ctx == nil then return end + local storage = ctx.storage + + if event == "move" then + storage.x = x + storage.y = y + storage.dx = storage.dx + dx + storage.dy = storage.dy + dy + addMouseVelocityEvent(dx, dy) + elseif event == "down" then + storage.x = x + storage.y = y + storage.down:put(button) + storage.pressed:put(button) + elseif event == "up" then + storage.x = x + storage.y = y + storage.down:remove(button) + storage.released:put(button) + elseif event == "scroll" then + storage.scrollX = storage.scrollX + dx + storage.scrollY = storage.scrollY + dy + end + end, +} +setmetatable(mouse, mouse) + +--- @return IUIMouseRootContext +function mouse.newRootContext() + --- @type IUIMouseRootContext + return { + velocityBuffer = {}, + storage = makeMouseState(), + disabledStorage = makeMouseState() + } +end + +--- @param rootContext IUIMouseRootContext +function mouse.setRootContext(rootContext) + ctx = rootContext + + mouse.__index = ctx.storage +end + +function mouse.endFrame() + local storage = ctx.storage + + storage.dx = 0 + storage.dy = 0 + storage.scrollX = 0 + storage.scrollY = 0 + storage.pressed:removeAll() + storage.released:removeAll() +end + +function mouse.resetVelocity() + ctx.velocityBuffer = {} +end + +--- @return number vx, number vy +function mouse.getVelocity() + local vx, vy = 0, 0 + + local weightSum = 0 + local now = iui.backend.system.getTimestamp() + + for _, entry in ipairs(ctx.velocityBuffer) do + local weight = math.exp((entry.timestamp - now) * 20) + weightSum = weightSum + weight + vx = vx + (entry.dx / entry.dt) * weight + vy = vy + (entry.dy / entry.dt) * weight + end + + if weightSum ~= 0 then + vx, vy = vx / weightSum, vy / weightSum + end + + return -vx, -vy +end + +--- @param active boolean +function mouse.setActive(active) + mouse.__index = active and ctx.storage or ctx.disabledStorage +end + +return mouse --[[@as IUIMouse]] diff --git a/src/lovr/lib/iui/iui.lua b/src/lovr/lib/iui/iui.lua new file mode 100644 index 0000000..7c10212 --- /dev/null +++ b/src/lovr/lib/iui/iui.lua @@ -0,0 +1,162 @@ +--- @class IUILib +--- @field dt number The time since the last frame began. +--- @field resourcePath string +--- @field idiom IUIIdiom +--- @field detail IUIDetail +--- @field backend IUIBackend +--- @field graphics IUIGraphicsBackend +--- @field disabledCount number +--- @field hoverID? number The ID of the widget a pointer is hovering over. +--- @field activeID? number The ID of the widget that's being actively used. +--- @field cursor? IUICursorName The desired mouse cursor to display. +--- @field hadActiveID boolean Internal flag for detecting widget deactivation. +--- @field widgetActivated? fun() A callback when a widget becomes active. +--- @field widgetDeactivated? fun() A callback when a widget resigns active. +local iui = {} + +--- @type IUICursorName? +local currentCursor = nil + +--- @type table +local cursors = {} + +--- @type IUIWindowManager +local windowManager + +--- @type IUISet +local processedWindowManagers + +local rootKeys = { + disabledCount = true, + hoverID = true, + activeID = true, + hadActiveID = true, + cursor = true +} + +setmetatable(iui, { + __index = function(t, k) + if rootKeys[k] then + return windowManager[k] + end + + return nil + end, + __newindex = function(t, k, v) + if rootKeys[k] then + windowManager[k] = v + else + rawset(t, k, v) + end + end +}) + +--- @param backend IUIBackend +--- @param config? IUIConfig +function iui.load(backend, config) + config = config or {} + backend.config(config) + + iui.idiom = config.idiom or error("No idiom specified") + iui.detail = config.detail or error("No detail specified") + iui.dpi = config.dpi or backend.system.getDPI() + + iui.backend = backend + iui.graphics = iui.drawQueue + + iui.input.load() + + iui.drawQueue.setBackend(backend.graphics) + + backend.load(iui) + + --- @type IUICursorName[] + local cursorNames = { "ibeam", "sizewe", "sizens" } + for _, name in ipairs(cursorNames) do + cursors[name] = iui.backend.system.getSystemCursor(name) + end + + iui.style.load() +end + +--- @param newWindowManager IUIWindowManager +function iui.setWindowManager(newWindowManager) + windowManager = newWindowManager + + iui.draw.setWindowManager(windowManager) + iui.layer.setWindowManager(windowManager) + iui.state.setWindowManager(windowManager) +end + +--- @param dt number +function iui.beginFrame(dt) + iui.dt = dt + processedWindowManagers = iui.set.new() + + iui.backend.beginFrame(dt) +end + +--- @param width number +--- @param height number +function iui.beginWindow(width, height) + --- @type IUIWindowManager + local manager = iui.backend.getFullscreenWindowManager() + + if processedWindowManagers:has(manager) then + error("window manager already processed this frame") + end + processedWindowManagers:put(manager) + + iui.setWindowManager(manager) + windowManager:beginFrame() + + iui.layout.windowWidth = width + iui.layout.windowHeight = height + iui.draw.setWindowSize(width, height) + + iui.layout.beginPanel(0, 0, width, height) +end + +function iui.endWindow() + iui.layout.endPanel() + + windowManager:endFrame() +end + +function iui.endFrame() + iui.backend.endFrame() + iui.input.endFrame() + + if currentCursor ~= iui.cursor then + currentCursor = iui.cursor + + iui.backend.system.setCursor(cursors[currentCursor]) + end +end + +function iui.beginDisabled() + iui.disabledCount = iui.disabledCount + 1 +end + +function iui.endDisabled() + if iui.disabledCount == 0 then + error("Control disable underflow") + end + + iui.disabledCount = iui.disabledCount - 1 +end + +--- @return boolean isDisabled +function iui.isDisabled() + return iui.disabledCount ~= 0 +end + +--- @param name? IUICursorName +--- @return IUICursor? +function iui.getCursor(name) + if name then + return cursors[name] + end +end + +return iui diff --git a/src/lovr/lib/iui/layer.lua b/src/lovr/lib/iui/layer.lua new file mode 100644 index 0000000..629726d --- /dev/null +++ b/src/lovr/lib/iui/layer.lua @@ -0,0 +1,125 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- @class IUILayer +--- @field name string +--- @field focusID? number +--- @field lastID? number +local layer = {} + +--- @class (exact) IUILayerRootContext +--- @field layers IUILayer[] +--- @field newLayers IUILayer[] +--- @field layerIndex number +--- @field isUnwinding boolean + +local ctx --- @type IUILayerRootContext + +local function isInputActive() + return (#ctx.newLayers == #ctx.layers) and (not ctx.isUnwinding) +end + +local function setInputActive() + iui.input.setActive(isInputActive()) +end + +--- @return IUILayer +function layer.getCurrentLayer() + return ctx.newLayers[ctx.layerIndex] +end + +--- @return IUILayerRootContext +function layer.newRootContext() + --- @type IUILayerRootContext + return { + layers = {}, + newLayers = {}, + layerIndex = 0, + isUnwinding = false + } +end + +--- @param windowManager IUIWindowManager +function layer.setWindowManager(windowManager) + ctx = windowManager.layer +end + +function layer.beginFrame() + ctx.layerIndex = 0 + ctx.newLayers = {} + iui.beginLayer("__root") + setInputActive() +end + +function layer.endFrame() + iui.endLayer() + ctx.layers = ctx.newLayers + ctx.isUnwinding = false +end + +--- @return number? id +function layer.getFocusID() + return layer.getCurrentLayer().focusID +end + +--- @param id? number +function layer.setFocusID(id) + layer.getCurrentLayer().focusID = id +end + +--- @return number? id +function layer.getLastID() + return layer.getCurrentLayer().lastID +end + +--- @param id? number +function layer.setLastID(id) + layer.getCurrentLayer().lastID = id +end + +--- @return boolean +function layer.isTop() + return ctx.layerIndex == #ctx.layers +end + +function iui.beginLayer(name) + if ctx.isUnwinding then + error("Attempt to begin layer after having ended layers") + end + + ctx.layerIndex = ctx.layerIndex + 1 + + if #ctx.newLayers < #ctx.layers then + local existing = ctx.layers[#ctx.newLayers + 1] + if existing.name == name then + table.insert(ctx.newLayers, existing) + else + ctx.layers = ctx.newLayers + + --- @type IUILayer + local newLayer = { name = name } + table.insert(ctx.newLayers, newLayer) + end + else + --- @type IUILayer + local newLayer = { name = name } + table.insert(ctx.newLayers, newLayer) + end + + setInputActive() + iui.draw.beginContext() +end + +function iui.endLayer() + ctx.isUnwinding = true + ctx.layerIndex = ctx.layerIndex - 1 + if ctx.layerIndex < 0 then + error("Layer index underflow") + end + setInputActive() + iui.draw.endContext() +end + +iui.layer = layer diff --git a/src/lovr/lib/iui/layout.lua b/src/lovr/lib/iui/layout.lua new file mode 100644 index 0000000..12178e6 --- /dev/null +++ b/src/lovr/lib/iui/layout.lua @@ -0,0 +1,452 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- @alias IUILayoutRowKind "fixed" | "dynamic" | "intrinsic" | "mixed" + +--- @class (exact) IUILayoutPanel +--- @field x number +--- @field y number +--- @field w number +--- @field h number +--- @field margin number +--- @field rowHeight number +--- @field rowKind IUILayoutRowKind +--- @field rowData any +--- @field intrinsicDefault? number +--- @field intrinsicLimit? number +--- @field rowY number +--- @field columnX number +--- @field wantsIntrinsicWidth boolean +--- @field wantsIntrinsicHeight boolean +--- @field intrinsicWidth? number +--- @field intrinsicHeight? number +--- @field columnIndex number +--- @field columnCountCache? number +--- @field columnBoundsCache? IUIColumnXBounds[] +--- @field zStackCount? number +--- @field contentWidth number +--- @field contentHeight number + +--- @class (exact) IUIColumnXBounds +--- @field x number +--- @field w number + +--- @class (exact) IUILayoutColumn +--- @field kind "fixed" | "dynamic" +--- @field size number + +--- @class IUILayout +--- @field windowWidth number +--- @field windowHeight number +local layout = {} + +--- @type IUILayoutPanel[] +local panels = {} + +function layout.beginFrame() + +end + +function layout.endFrame() + if #panels ~= 0 then + error("Unbalanced panel stack: expected 0, got " .. #panels) + end +end + +--- @return number rowHeight +function layout.getDefaultRowHeight() + local padding = iui.style["padding"] + local fontHeight = math.floor(iui.style["font"]:getHeight()) + local rowHeight = fontHeight + padding * 2 + + return rowHeight +end + +--- @param panel IUILayoutPanel +local function resetColumnBoundsCache(panel) + if panel.columnBoundsCache ~= nil then + iui.pool.put(panel.columnBoundsCache) + panel.columnBoundsCache = nil + end +end + +--- @param x number +--- @param y number +--- @param w number +--- @param h number +--- @param margin? number +--- @return IUILayoutPanel +function layout.beginPanel(x, y, w, h, margin) + margin = margin or iui.style["margin"] + + local rowHeight = layout.getDefaultRowHeight() + + --- @type IUILayoutPanel + local panel = iui.pool.get("layout_panel") + + panel.x = x + panel.y = y + panel.w = w + panel.h = h + panel.margin = margin + panel.rowHeight = rowHeight + panel.rowY = margin + panel.columnX = margin + panel.wantsIntrinsicWidth = false + panel.wantsIntrinsicHeight = false + panel.columnIndex = 1 + panel.rowKind = "dynamic" + panel.rowData = 1 + panel.intrinsicDefault = nil + panel.intrinsicLimit = nil + panel.contentWidth = 0 + panel.contentHeight = 0 + + panel.intrinsicWidth = nil + panel.intrinsicHeight = nil + panel.columnCountCache = nil + panel.zStackCount = nil + + resetColumnBoundsCache(panel) + + table.insert(panels, panel) + + return panel +end + +--- @param advance? boolean +function layout.endPanel(advance) + local panel = layout.getPanel() + if panel.zStackCount ~= nil and panel.zStackCount ~= 0 then + error( + "Unbalanced panel zstack count: expected 0, got " .. + panel.zStackCount + ) + end + + table.remove(panels) + + iui.pool.put(panel) + + if advance == true or (#panels > 0 and advance ~= false) then + iui.layout.advance() + end +end + +--- @param index? number +function layout.getPanel(index) + return panels[index or #panels] +end + +--- @return number w, number h +function layout.getContentSize() + local panel = layout.getPanel() + return panel.contentWidth, panel.contentHeight +end + +--- @return boolean wantsIntrinsicWidth, boolean wantsIntrinsicHeight +function layout.getWantsIntrinsic() + local panel = layout.getPanel() + return panel.wantsIntrinsicWidth, panel.wantsIntrinsicHeight +end + +--- @param w number +function layout.setIntrinsicWidth(w) + local panel = layout.getPanel() + panel.intrinsicWidth = w +end + +--- @param h number +function layout.setIntrinsicHeight(h) + local panel = layout.getPanel() + panel.intrinsicHeight = h +end + +--- @param rowHeight? number +--- @return IUILayoutPanel +local function beginRowCommon(rowHeight) + if rowHeight == nil then + rowHeight = layout.getDefaultRowHeight() + end + + local panel = layout.getPanel() + + if panel.columnIndex ~= 1 then + panel.columnIndex = 1 + panel.rowY = panel.rowY + panel.rowHeight + iui.style["spacing"] + end + + panel.wantsIntrinsicHeight = false + panel.wantsIntrinsicWidth = false + panel.intrinsicWidth = nil + panel.intrinsicHeight = nil + panel.rowHeight = rowHeight + panel.columnCountCache = nil + resetColumnBoundsCache(panel) + + return panel +end + +--- @param count? number +--- @param rowHeight? number +function layout.beginDynamicRow(count, rowHeight) + local panel = beginRowCommon(rowHeight) + panel.rowKind = "dynamic" + panel.rowData = count or 1 +end + +--- @param size number +--- @param rowHeight? number +function layout.beginFixedRow(size, rowHeight) + local panel = beginRowCommon(rowHeight) + panel.rowKind = "fixed" + panel.rowData = size +end + +--- @param default? number +--- @param limit? number +--- @param rowHeight? number +function layout.beginIntrinsicRow(default, limit, rowHeight) + local panel = beginRowCommon(rowHeight) + panel.rowKind = "intrinsic" + panel.wantsIntrinsicWidth = true + panel.intrinsicDefault = default + panel.intrinsicLimit = limit +end + +--- @param columns IUILayoutColumn[] +--- @param rowHeight? number +function layout.beginMixedRow(columns, rowHeight) + local panel = beginRowCommon(rowHeight) + panel.rowKind = "mixed" + panel.rowData = columns +end + +--- Begins a row that causes the next widget to fill the remainder of the +--- current panel. +function layout.fillPanel() + local panel = layout.getPanel() + + if panel.columnIndex ~= 1 then + panel.columnIndex = 1 + panel.rowY = panel.rowY + panel.rowHeight + iui.style["spacing"] + end + + local h = panel.h - (panel.rowY + panel.margin) + layout.beginDynamicRow(1, h) +end + +function layout.beginZStack() + local panel = layout.getPanel() + panel.zStackCount = (panel.zStackCount or 0) + 1 +end + +--- @param advance? boolean +function layout.endZStack(advance) + local panel = layout.getPanel() + if panel.zStackCount == nil or panel.zStackCount == 0 then + error("Attempt to end ZStack without one on panel") + end + panel.zStackCount = panel.zStackCount - 1 + + if advance then + iui.layout.advance() + end +end + +--- @return number x, number y, number w, number h +function layout.getBounds() + local panel = layout.getPanel() + local y = panel.rowY + local h = panel.rowHeight + + local margin = panel.margin + local spacing = iui.style["spacing"] + + local x, w = 0, 0 + local rowKind = panel.rowKind + if rowKind == "fixed" then + w = panel.rowData + x = margin + (panel.columnIndex - 1) * (w + spacing) + elseif rowKind == "dynamic" then + local count = panel.rowData + w = (panel.w - (2 * margin + (count - 1) * spacing)) / count + x = margin + (panel.columnIndex - 1) * (w + spacing) + + local maxX = iui.utils.round(x + w) + x = iui.utils.round(x) + w = maxX - x + elseif rowKind == "intrinsic" then + local edge = panel.w - panel.margin + x = panel.columnX + w = panel.intrinsicWidth or panel.intrinsicDefault or (edge - x) + if x + w > edge and panel.columnIndex ~= 1 then + x = panel.margin + panel.columnIndex = 1 + panel.rowY = panel.rowY + panel.rowHeight + spacing + y = panel.rowY + end + panel.intrinsicWidth = w + elseif rowKind == "mixed" then + if panel.columnBoundsCache == nil then + --- The sum of dynamic column `size` values. + local dynamicSum = 0 + + --- The amount of space available to dynamic columns. This is + --- whatever's left over after the margin, spacing, and fixed + --- columns are accounted for. + local dynamicSpace = panel.w - margin * 2 + + --- @type IUILayoutColumn[] + local columns = panel.rowData + + -- Do a first pass through the columns, calculating the dynamic sum + -- and space values. + for idx = 1, #columns do + if idx > 1 then + dynamicSpace = dynamicSpace - spacing + end + + local column = columns[idx] + if column.kind == "dynamic" then + dynamicSum = dynamicSum + column.size + elseif column.kind == "fixed" then + dynamicSpace = dynamicSpace - column.size + end + end + + --- @type IUIColumnXBounds[] + local bounds = iui.pool.get("layout_column_bounds_cache") + for index, entry in ipairs(bounds) do + iui.pool.put(entry) + bounds[index] = nil + end + + local head = margin + for idx = 1, #columns do + local column = columns[idx] + local colX = head + local colW = 0 + if column.kind == "fixed" then + colW = column.size + elseif column.kind == "dynamic" then + colW = dynamicSpace * (column.size / dynamicSum) + end + head = head + colW + spacing + local maxX = colX + colW + colX = iui.utils.round(colX) + maxX = iui.utils.round(maxX) + colW = maxX - colX + --- @type IUIColumnXBounds + local value = iui.pool.get("layout_column_bounds_entry") + value.x = colX + value.w = colW + + table.insert(bounds, value) + end + + panel.columnBoundsCache = bounds + end + + local bounds = panel.columnBoundsCache[panel.columnIndex] + + x = bounds.x + w = bounds.w + end + + x = panel.x + x + y = panel.y + y + + panel.contentWidth = math.max(panel.contentWidth, x + w + panel.margin - panel.x) + panel.contentHeight = math.max(panel.contentHeight, y + h + panel.margin - panel.y) + + return x, y, w, h +end + +--- @param index? number +--- @return number x, number y, number w, number h +function layout.getPanelBounds(index) + local panel = layout.getPanel(index) + + return panel.x, panel.y, panel.w, panel.h +end + +--- @param x? number +--- @param y? number +--- @return boolean +function layout.containsPoint(x, y) + x = x or iui.input.mouse.x + y = y or iui.input.mouse.y + + local bx, by, bw, bh = layout.getBounds() + if not iui.utils.rectContains(bx, by, bw, bh, x, y) then + return false + end + + local cx, cy, cw, ch = iui.draw.getClipBounds() + if cx then + if not iui.utils.rectContains( + cx, cy --[[@as any]], cw --[[@as any]], ch --[[@as any]], x, y + ) then + return false + end + end + + return true +end + +function layout.spacer() + layout.advance() +end + +function layout.advance() + local panel = layout.getPanel() + + if panel.zStackCount ~= nil and panel.zStackCount ~= 0 then + return + end + + local spacing = iui.style["spacing"] + + local rowKind = panel.rowKind + local count = panel.columnCountCache + + if count == nil then + if rowKind == "fixed" then + local num = panel.w + spacing - panel.margin * 2 + local den = panel.rowData + spacing + count = math.floor(num / den) + if count < 1 then + count = 1 + end + elseif rowKind == "dynamic" then + count = panel.rowData + elseif rowKind == "mixed" then + count = #panel.rowData + end + + panel.columnCountCache = count + end + + if rowKind == "intrinsic" then + panel.columnIndex = panel.columnIndex + 1 + panel.columnX = panel.columnX + spacing + (panel.intrinsicWidth or 0) + panel.intrinsicWidth = nil + panel.intrinsicHeight = nil + + if panel.intrinsicLimit and panel.columnIndex >= panel.intrinsicLimit then + panel.columnIndex = 1 + panel.columnX = panel.margin + panel.rowY = panel.rowY + panel.rowHeight + spacing + end + elseif panel.columnIndex == count then + panel.columnIndex = 1 + panel.rowY = panel.rowY + panel.rowHeight + spacing + else + panel.columnIndex = panel.columnIndex + 1 + end +end + +iui.layout = layout diff --git a/src/lovr/lib/iui/pool.lua b/src/lovr/lib/iui/pool.lua new file mode 100644 index 0000000..f14bc02 --- /dev/null +++ b/src/lovr/lib/iui/pool.lua @@ -0,0 +1,54 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- @class IUIPool +local pool = {} + +--- @class (exact) IUIPoolStack +--- @field top number +--- @field items any[] + +--- @type table +local entries = {} + +--- @param typename string +--- @return IUIPoolStack +local function getPool(typename) + local out = entries[typename] + + if not out then + out = { top = 0, items = {} } + entries[typename] = out + end + + return out +end + +--- @generic T +--- @param typename string +--- @return T object +function pool.get(typename) + local stack = getPool(typename) + + if stack.top == 0 then + return { + _typename = typename + } + else + local value = stack.items[stack.top] + stack.top = stack.top - 1 + return value + end +end + +--- @generic T +--- @param obj T +function pool.put(obj) + local stack = getPool(obj["_typename"]) + stack.top = stack.top + 1 + stack.items[stack.top] = obj +end + +iui.pool = pool diff --git a/src/lovr/lib/iui/set.lua b/src/lovr/lib/iui/set.lua new file mode 100644 index 0000000..929ab75 --- /dev/null +++ b/src/lovr/lib/iui/set.lua @@ -0,0 +1,63 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- A type that stores an unordered collection of unique values. +--- @class IUISet +--- @field private storage table +--- @field new fun(): IUISet Returns a new set. +--- @field put fun(self: self, v: T) Inserts `v`, if not already present. +--- @field putAll fun(self: self, s: IUISet) Inserts all members of `s`. +--- @field remove fun(self: self, v: T) Removes `v`, if present. +--- @field removeAll fun(self: self) Removes all values. +--- @field has fun(self: self, v: T): boolean Returns whether the set has `v`. +--- @field getCount fun(self: self): number Returns the number of values, in O(n) time. +local Set = {} +Set.__index = Set + +function Set.new() + local out = { + storage = {} + } + + setmetatable(out, Set) + + return out +end + +function Set:put(v) + self.storage[v] = true +end + +function Set:putAll(s) + local storage = self.storage + for k, _ in pairs(s.storage) do + storage[k] = true + end +end + +function Set:remove(v) + self.storage[v] = nil +end + +function Set:removeAll() + local storage = self.storage + for k, _ in pairs(storage) do + storage[k] = nil + end +end + +function Set:has(v) + return self.storage[v] == true +end + +function Set:getCount() + local count = 0 + for _ in pairs(self.storage) do + count = count + 1 + end + return count +end + +iui.set = Set diff --git a/src/lovr/lib/iui/state.lua b/src/lovr/lib/iui/state.lua new file mode 100644 index 0000000..5ed64e1 --- /dev/null +++ b/src/lovr/lib/iui/state.lua @@ -0,0 +1,46 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- @class IUIState +--- @overload fun(id: number): table +local state = {} + +--- @class (exact) IUIStateRootContext +--- @field previousStates? table +--- @field currentStates? table + +local ctx --- @type IUIStateRootContext + +setmetatable(state --[[@as any]], { + --- @param id number + --- @return table + __call = function(_, id) + local out = (ctx.previousStates and ctx.previousStates[id]) or {} + ctx.currentStates[id] = out + return out + end +}) + +--- @return IUIStateRootContext +function state.newRootContext() + --- @type IUIStateRootContext + return {} +end + +--- @param windowManager IUIWindowManager +function state.setWindowManager(windowManager) + ctx = windowManager.state +end + +function state.beginFrame() + ctx.currentStates = {} +end + +function state.endFrame() + ctx.previousStates = ctx.currentStates + ctx.currentStates = nil +end + +iui.state = state diff --git a/src/lovr/lib/iui/style.lua b/src/lovr/lib/iui/style.lua new file mode 100644 index 0000000..a9711d6 --- /dev/null +++ b/src/lovr/lib/iui/style.lua @@ -0,0 +1,66 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- @class IUIStyle +local style = { + default = { + spacing = 8, + margin = 8, + padding = 8, + scrollSize = 16, + vrWindowCornerRadius = 16, + }, +} + +local stack = { + style.default +} + +function style.load() + style.default.font = iui.graphics.newFont(12, "normal", iui.dpi) +end + +function style.beginFrame() + style.push() +end + +function style.endFrame() + style.pop() + + if #stack ~= 1 then + error("Unbalanced style stack: expected 1, got " .. #stack) + end +end + +function style.push() + local entry = iui.pool.get("style_stack_entry") + for key, _ in pairs(entry) do + if key ~= "_typename" then + entry[key] = nil + end + end + + table.insert(stack, entry) +end + +function style.pop() + iui.pool.put(table.remove(stack)) +end + +setmetatable(style, { + __index = function(_, k) + for idx = #stack, 1, -1 do + if stack[idx][k] then + return stack[idx][k] + end + end + return nil + end, + __newindex = function(_, k, v) + stack[#stack][k] = v + end +}) + +iui.style = style diff --git a/src/lovr/lib/iui/types/backend/backend.t.lua b/src/lovr/lib/iui/types/backend/backend.t.lua new file mode 100644 index 0000000..d73131a --- /dev/null +++ b/src/lovr/lib/iui/types/backend/backend.t.lua @@ -0,0 +1,29 @@ +--- @meta _ + +--- @class IUICursor + +--- @alias IUICursorName "ibeam" | "sizewe" | "sizens" + +--- @class IUIBackend +--- @field system IUISystemBackend +--- @field graphics IUIGraphicsBackend +local backend = {} + +--- @param config IUIConfig +function backend.config(config) +end + +--- @param lib IUILib +function backend.load(lib) +end + +--- @param dt number +function backend.beginFrame(dt) +end + +function backend.endFrame() +end + +--- @return IUIWindowManager +function backend.getFullscreenWindowManager() +end diff --git a/src/lovr/lib/iui/types/backend/graphics.t.lua b/src/lovr/lib/iui/types/backend/graphics.t.lua new file mode 100644 index 0000000..65bccd9 --- /dev/null +++ b/src/lovr/lib/iui/types/backend/graphics.t.lua @@ -0,0 +1,109 @@ +--- @meta _ + +--- @alias IUIImageFilter "nearest" | "smooth" | "linear" + +--- @class IUIImage9Slice +--- @field image any +--- @field l number +--- @field t number +--- @field r number +--- @field b number + +--- @class IUILayeredImageItem +--- @field image any +--- @field color IUIColor + +--- @alias IUILayeredImage IUILayeredImageItem[] + +--- @class IUIGraphicsBackend +local graphics = {} + +--- @param width number +--- @param height number +function graphics.beginDraw(width, height) +end + +function graphics.endDraw() +end + +function graphics.newFont(size, hinting, dpiscale) +end + +--- @return number w, number h +function graphics.getImageDimensions(image) +end + +--- @param x number +--- @param y number +--- @param w number +--- @param h number +function graphics.clip(x, y, w, h) +end + +function graphics.clip() +end + +--- @param r number +--- @param g number +--- @param b number +--- @param a? number +function graphics.setColor(r, g, b, a) +end + +--- @param x number +--- @param y number +--- @param w number +--- @param h number +--- @param rx? number +--- @param ry? number +function graphics.rectangle(x, y, w, h, rx, ry) +end + +--- @param x number +--- @param y number +--- @param r number +function graphics.circle(x, y, r) +end + +function graphics.setFont(f) +end + +--- @param s string +--- @param x number +--- @param y number +function graphics.print(s, x, y) +end + +--- @param image any +--- @param filter IUIImageFilter +--- @param x number +--- @param y number +--- @param w number +--- @param h number +function graphics.image(image, filter, x, y, w, h) +end + +--- @param image any +--- @param x number +--- @param y number +--- @param w number +--- @param h number +function graphics.msdfImage(image, x, y, w, h) +end + +--- @param nineSlice IUIImage9Slice +--- @param filter IUIImageFilter +--- @param x number +--- @param y number +--- @param w number +--- @param h number +function graphics.nineSlice(nineSlice, filter, x, y, w, h) +end + +--- @param nineSlice IUIImage9Slice +--- @param x number +--- @param y number +--- @param w number +--- @param h number +function graphics.msdfNineSlice(nineSlice, x, y, w, h) +end diff --git a/src/lovr/lib/iui/types/backend/system.t.lua b/src/lovr/lib/iui/types/backend/system.t.lua new file mode 100644 index 0000000..6b42cb7 --- /dev/null +++ b/src/lovr/lib/iui/types/backend/system.t.lua @@ -0,0 +1,37 @@ +--- @meta _ + +--- @class IUISystemBackend +local system = {} + +--- Returns the name of the host operating system. +--- +--- Valid responses include, but are not limited to, "Windows", "macOS", +--- "Linux", "Android", and "iOS". +--- +--- @return string +function system.getOS() +end + +--- @return number timestamp +function system.getTimestamp() +end + +--- @param name IUICursorName +--- @return IUICursor +function system.getSystemCursor(name) +end + +--- @param cursor IUICursor +function system.setCursor(cursor) +end + +--- @param name string +function system.getMSDFImage(name) +end + +--- @return number dpi +function system.getDPI() +end + +function system.quit() +end diff --git a/src/lovr/lib/iui/types/config.t.lua b/src/lovr/lib/iui/types/config.t.lua new file mode 100644 index 0000000..70d13d5 --- /dev/null +++ b/src/lovr/lib/iui/types/config.t.lua @@ -0,0 +1,9 @@ +--- @meta _ + +--- @alias IUIIdiom "vr" | "desktop" +--- @alias IUIDetail "low" | "high" + +--- @class IUIConfig +--- @field idiom? IUIIdiom +--- @field detail? IUIDetail +--- @field dpi? number diff --git a/src/lovr/lib/iui/utils.lua b/src/lovr/lib/iui/utils.lua new file mode 100644 index 0000000..1fb87a3 --- /dev/null +++ b/src/lovr/lib/iui/utils.lua @@ -0,0 +1,151 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- @class IUIUtils +local utils = {} + +--- Returns 1 if `n` is positive, -1 if negative, and 0 if zero. +--- @param n number +--- @return number +function utils.sign(n) + if n > 0 then + return 1 + elseif n == 0 then + return 0 + else + return -1 + end +end + +--- Returns `n` rounded to the nearest integer, with 0.5 rounding up. +--- @param n number +--- @return number +function utils.round(n) + return math.floor(n + 0.5) +end + +--- Returns the closest value to `n` that is neither below `low` nor above +--- `high`. +--- @param n number +--- @param low number +--- @param high number +--- @return number +function utils.clamp(n, low, high) + if n < low then + return low + elseif n > high then + return high + end + + return n +end + +--- @param rx number +--- @param ry number +--- @param rw number +--- @param rh number +--- @param px number +--- @param py number +--- @return boolean isInside +function utils.rectContains(rx, ry, rw, rh, px, py) + if px < rx or py < ry then + return false + end + + if px >= rx + rw or py >= ry + rh then + return false + end + + return true +end + +--- @param iw number +--- @param ih number +--- @param x number +--- @param y number +--- @param w number +--- @param h number +--- @return number x, number y, number w, number h +function utils.aspectFit(iw, ih, x, y, w, h) + local aspectBounds = w / h + local aspectImage = iw / ih + + local ox, oy, ow, oh = x, y, w, h + + if aspectImage > aspectBounds then + oh = iui.utils.round(w / aspectImage) + oy = y + iui.utils.round((h - oh) / 2) + else + ow = iui.utils.round(h * aspectImage) + ox = x + iui.utils.round((w - ow) / 2) + end + + return ox, oy, ow, oh +end + +--- @param iw number +--- @param ih number +--- @param x number +--- @param y number +--- @param w number +--- @param h number +--- @return number x, number y, number w, number h +function utils.aspectFill(iw, ih, x, y, w, h) + local aspectBounds = w / h + local aspectImage = iw / ih + + local ox, oy, ow, oh = x, y, w, h + + if aspectImage < aspectBounds then + oh = iui.utils.round(ow / aspectImage) + oy = y + iui.utils.round((h - oh) / 2) + else + ow = iui.utils.round(oh * aspectImage) + ox = x + iui.utils.round((w - ow) / 2) + end + + return ox, oy, ow, oh +end + +--- @param iw number +--- @param ih number +--- @param x number +--- @param y number +--- @param w number +--- @param h number +--- @return number x, number y, number w, number h +function utils.center(iw, ih, x, y, w, h) + local ox, oy, ow, oh = x, y, w, h + + ow, oh = iw, ih + ox = x + iui.utils.round((w - ow) / 2) + oy = y + iui.utils.round((h - oh) / 2) + + return ox, oy, ow, oh +end + +--- @param mode IUIImageMode +--- @param iw number +--- @param ih number +--- @param x number +--- @param y number +--- @param w number +--- @param h number +--- @return number x, number y, number w, number h +function utils.fill(mode, iw, ih, x, y, w, h) + if mode == "fill" then + return x, y, w, h + elseif mode == "aspectFit" then + return iui.utils.aspectFit(iw, ih, x, y, w, h) + elseif mode == "aspectFill" then + return iui.utils.aspectFill(iw, ih, x, y, w, h) + elseif mode == "center" then + return iui.utils.center(iw, ih, x, y, w, h) + else + error("unrecognized fillmode") + end +end + +iui.utils = utils diff --git a/src/lovr/lib/iui/widgets/button.lua b/src/lovr/lib/iui/widgets/button.lua new file mode 100644 index 0000000..94f415e --- /dev/null +++ b/src/lovr/lib/iui/widgets/button.lua @@ -0,0 +1,95 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @param name string +--- @return boolean +function iui.button(name) + local id = iui.beginID(name) + + local intrinsicW, intrinsicH = iui.layout.getWantsIntrinsic() + + local pressed = false + local disabled = iui.isDisabled() + + local font = iui.style["font"] + local padding = iui.style["padding"] + + local textW = math.ceil(font:getWidth(name)) + local textH = math.ceil(font:getHeight()) + + if intrinsicW then + iui.layout.setIntrinsicWidth(textW + padding * 4) + end + + if intrinsicH then + iui.layout.setIntrinsicHeight(textH + padding * 2) + end + + if iui.layout.containsPoint() then + iui.becomeHover() + end + + if iui.hoverID == id and iui.input.mouse.pressed:has(1) then + iui.becomeActive() + iui.becomeFocus() + end + + if iui.activeID == id then + if not iui.input.mouse.down:has(1) then + if iui.hoverID == id then + pressed = true + end + + iui.activeID = nil + end + end + + if iui.isFocused(id) then + if iui.input.keyboard.pressed["space"] then + pressed = true + end + end + + local x, y, w, h = iui.layout.getBounds() + + -- Outline + if not disabled then + if iui.isFocused(id) and iui.idiom ~= "vr" then + iui.colors.sysAccent500:set() + elseif (iui.activeID == id and iui.hoverID == id) or pressed then + iui.colors.sysGray200:set() + elseif iui.hoverID == id then + iui.colors.sysGray400:set() + else + iui.colors.sysGray300:set() + end + iui.graphics.rectangle(x, y, w, h, 8, 8) + end + + -- Background + if (iui.activeID == id and iui.hoverID == id) or pressed then + iui.colors.sysGray50:set() + elseif iui.hoverID == id then + iui.colors.sysGray200:set() + else + iui.colors.sysGray100:set() + end + iui.graphics.rectangle(x + 1, y + 1, w - 2, h - 2, 7, 7) + + if disabled then + iui.colors.sysGray300:set() + else + iui.colors.sysGray900:set() + end + iui.graphics.setFont(font) + local textX = x + iui.utils.round((w - textW) / 2) + local textY = y + iui.utils.round((h - textH) / 2) + iui.graphics.print(name, textX, textY) + + iui.endID() + + return pressed +end diff --git a/src/lovr/lib/iui/widgets/checkbox.lua b/src/lovr/lib/iui/widgets/checkbox.lua new file mode 100644 index 0000000..8e66883 --- /dev/null +++ b/src/lovr/lib/iui/widgets/checkbox.lua @@ -0,0 +1,120 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +local checkmarkImage + +--- @param name string +--- @param checked boolean +--- @return boolean +function iui.checkbox(name, checked) + if checkmarkImage == nil then + checkmarkImage = iui.backend.system.getMSDFImage( + "assets/glyph-checkmark.png" + ) + end + + local id = iui.beginID(name) + + local intrinsicW, intrinsicH = iui.layout.getWantsIntrinsic() + + local disabled = iui.isDisabled() + + local font = iui.style["font"] + local padding = iui.style["padding"] + + local textH = math.ceil(font:getHeight()) + local size = textH + 2 + local textX = size + padding + + if intrinsicW then + local textW = math.ceil(font:getWidth(name)) + iui.layout.setIntrinsicWidth(textX + textW) + end + + if intrinsicH then + iui.layout.setIntrinsicHeight(size) + end + + if iui.layout.containsPoint() then + iui.becomeHover() + end + + if iui.hoverID == id and iui.input.mouse.pressed:has(1) then + iui.becomeActive() + iui.becomeFocus() + end + + if iui.activeID == id then + if not iui.input.mouse.down:has(1) then + if iui.hoverID == id then + checked = not checked + end + + iui.activeID = nil + end + end + + if iui.isFocused(id) then + if iui.input.keyboard.pressed["space"] then + checked = not checked + end + end + + local x, y, _, h = iui.layout.getBounds() + + local textY = y + iui.utils.round((h - textH) / 2) + local boxY = y + iui.utils.round((h - size) / 2) + + -- Outline + if not disabled then + if iui.isFocused(id) and iui.idiom ~= "vr" then + iui.colors.sysAccent500:set() + elseif iui.hoverID == id then + iui.colors.sysGray300:set() + else + iui.colors.sysGray200:set() + end + iui.graphics.rectangle(x, boxY, size, size, 2, 2) + end + + -- Background + if (iui.hoverID == id and iui.activeID == id) or disabled then + iui.colors.sysGray100:set() + else + iui.colors.sysGray0:set() + end + iui.graphics.rectangle(x + 1, boxY + 1, size - 2, size - 2, 1, 1) + + -- Check + if checked then + if disabled then + iui.colors.sysGray200:set() + elseif iui.hoverID == id and iui.activeID == id then + iui.colors.sysGray200:set() + elseif iui.hoverID == id then + iui.colors.sysGray400:set() + else + iui.colors.sysGray300:set() + end + + iui.graphics.msdfImage( + checkmarkImage, x + 2, boxY + 2, size - 4, size - 4 + ) + end + + -- Label + if disabled then + iui.colors.sysGray300:set() + else + iui.colors.sysGray900:set() + end + iui.graphics.setFont(font) + iui.graphics.print(name, x + textX, textY) + + iui.endID() + + return checked +end diff --git a/src/lovr/lib/iui/widgets/clip-view.lua b/src/lovr/lib/iui/widgets/clip-view.lua new file mode 100644 index 0000000..27e3ce0 --- /dev/null +++ b/src/lovr/lib/iui/widgets/clip-view.lua @@ -0,0 +1,16 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +function iui.clipView() + local x, y, w, h = iui.layout.getBounds() + iui.draw.pushClip(x, y, w, h) + iui.layout.beginPanel(x, y, w, h) +end + +function iui.endClipView() + iui.layout.endPanel() + iui.draw.popClip() +end diff --git a/src/lovr/lib/iui/widgets/divider.lua b/src/lovr/lib/iui/widgets/divider.lua new file mode 100644 index 0000000..be5e5c8 --- /dev/null +++ b/src/lovr/lib/iui/widgets/divider.lua @@ -0,0 +1,27 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +function iui.divider() + local panel = iui.layout.getPanel() + local rowHeight = panel.rowHeight + panel.rowHeight = iui.utils.round(rowHeight / 4) + if panel.rowHeight % 2 == 0 then + panel.rowHeight = panel.rowHeight + 1 + end + + local x, y, w, h = iui.layout.getBounds() + + -- local padding = iui.utils.round(iui.style["padding"] / 2) + local padding = 0 + local top = math.floor(y + h / 2) + + iui.colors.sysGray200:set() + local radius = (iui.detail == "high" and 0.5 or nil) + iui.graphics.rectangle(x + padding, top, w - padding * 2, 1, radius, radius) + + iui.layout.advance() + panel.rowHeight = rowHeight +end diff --git a/src/lovr/lib/iui/widgets/image-9-slice.lua b/src/lovr/lib/iui/widgets/image-9-slice.lua new file mode 100644 index 0000000..2a407cb --- /dev/null +++ b/src/lovr/lib/iui/widgets/image-9-slice.lua @@ -0,0 +1,16 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @param image IUIImage9Slice +function iui.image9Slice(image) + local x, y, w, h = iui.layout.getBounds() + local filter = iui.style["imageFilter"] or "linear" --- @type IUIImageFilter + + iui.graphics.setColor(1, 1, 1) + iui.graphics.nineSlice(image, filter, x, y, w, h) + + iui.layout.advance() +end diff --git a/src/lovr/lib/iui/widgets/image.lua b/src/lovr/lib/iui/widgets/image.lua new file mode 100644 index 0000000..1c11de3 --- /dev/null +++ b/src/lovr/lib/iui/widgets/image.lua @@ -0,0 +1,32 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @alias IUIImageMode "fill" | "aspectFit" | "aspectFill" | "center" + +--- @param image any +function iui.image(image) + local bx, by, bw, bh = iui.layout.getBounds() + local iw, ih = iui.graphics.getImageDimensions(image) + + local filter = iui.style["imageFilter"] or "linear" --- @type IUIImageFilter + local mode = iui.style["imageMode"] or "aspectFit" --- @type IUIImageMode + local clip = iui.style["imageClip"] or false --- @type boolean + + local ox, oy, ow, oh = iui.utils.fill(mode, iw, ih, bx, by, bw, bh) + + if clip then + iui.draw.pushClip(bx, by, bw, bh) + end + + iui.graphics.setColor(1, 1, 1) + iui.graphics.image(image, filter, ox, oy, ow, oh) + + if clip then + iui.draw.popClip() + end + + iui.layout.advance() +end diff --git a/src/lovr/lib/iui/widgets/init.lua b/src/lovr/lib/iui/widgets/init.lua new file mode 100644 index 0000000..b277bbe --- /dev/null +++ b/src/lovr/lib/iui/widgets/init.lua @@ -0,0 +1,26 @@ +local currentPath = (...):gsub('%.init$', '') .. "." + +require(currentPath .. "label") +require(currentPath .. "button") +require(currentPath .. "slider") +require(currentPath .. "text-field") +require(currentPath .. "checkbox") +require(currentPath .. "radio") +require(currentPath .. "split-view") +require(currentPath .. "divider") +require(currentPath .. "scroll-bar") +require(currentPath .. "clip-view") +require(currentPath .. "scroll-view") +require(currentPath .. "menu-bar") +require(currentPath .. "sub-menu") +require(currentPath .. "menu-item") +require(currentPath .. "progress") +require(currentPath .. "tab-bar") +require(currentPath .. "panel-background") +require(currentPath .. "image") +require(currentPath .. "image-9-slice") +require(currentPath .. "msdf-image") +require(currentPath .. "msdf-image-9-slice") +require(currentPath .. "msdf-layered-image") +require(currentPath .. "msdf-layered-image-9-slice") +require(currentPath .. "list-view") diff --git a/src/lovr/lib/iui/widgets/label.lua b/src/lovr/lib/iui/widgets/label.lua new file mode 100644 index 0000000..ec76224 --- /dev/null +++ b/src/lovr/lib/iui/widgets/label.lua @@ -0,0 +1,38 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +function iui.label(text) + local intrinsicW, intrinsicH = iui.layout.getWantsIntrinsic() + + local disabled = iui.isDisabled() + local font = iui.style["font"] + local padding = iui.style["padding"] + + local textW = math.ceil(font:getWidth(text)) + local textH = font:getHeight() + + if intrinsicW then + iui.layout.setIntrinsicWidth(textW + padding * 2) + end + + if intrinsicH then + iui.layout.setIntrinsicHeight(math.ceil(textH)) + end + + local x, y, w, h = iui.layout.getBounds() + + if disabled then + iui.colors.sysGray300:set() + else + iui.colors.sysGray900:set() + end + iui.graphics.setFont(font) + local textX = x + iui.utils.round((w - textW) / 2) + local textY = y + iui.utils.round((h - textH) / 2) + iui.graphics.print(text, textX, textY) + + iui.layout.advance() +end diff --git a/src/lovr/lib/iui/widgets/list-view.lua b/src/lovr/lib/iui/widgets/list-view.lua new file mode 100644 index 0000000..0140ad4 --- /dev/null +++ b/src/lovr/lib/iui/widgets/list-view.lua @@ -0,0 +1,409 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @class (exact) IUIListViewStackItem +--- @field manager IUIListManager +--- @field isMouseInBounds boolean +--- @field maxIndex number +--- @field panel IUILayoutPanel +--- @field spacing number +--- @field margin number +--- @field rowHeight number +--- @field yOffset number +--- @field lastX number +--- @field lastY number +--- @field lastW number +--- @field lastH number + +--- @type IUIListViewStackItem[] +local listStack = {} + +--- The amount of time, in seconds, to wait to select an item when pressing and +--- holding in VR. +local selectDelay = 0.2 + +--- @class (exact) IUIListSelectionChangeMutation +--- @field selection IUISet +--- @field rangeStartIndex? number +--- @field rangeEndIndex? number + +--- @class IUIListManager: IUIScrollManager +--- @field rowHeight? number +--- @field margin? number +--- @field spacing? number +--- @field selection IUISet +--- @field rangeStartIndex? number +--- @field rangeEndIndex? number +--- @field allowsSelection boolean +--- @field allowsMultipleSelection boolean +--- @field allowsEmptySelection boolean +--- @field selectTimer? number +--- @field timerIndex? number +--- @field conditionalMutation? IUIListSelectionChangeMutation +local ListManager = {} +ListManager.__index = ListManager +setmetatable(ListManager, iui.ScrollManager) + +function ListManager:beganDragging() + self.selectTimer = nil + self.timerIndex = nil + + local mutation = self.conditionalMutation + self.conditionalMutation = nil + if mutation then + self.selection:removeAll() + self.selection:putAll(mutation.selection) + self.rangeStartIndex = mutation.rangeStartIndex + self.rangeEndIndex = mutation.rangeEndIndex + end +end + +--- Returns whether the user is holding down a modifier input that indicates +--- they wish to add or remove single items from the selection. +--- +--- @return boolean +local function isHoldingSingleModifier() + local keyboard = iui.input.keyboard + local down = keyboard.down + local primary = keyboard.getPrimaryModifierKeycode() + + return down:has(primary) +end + +--- Returns whether the user is holding down a modifier input that indicates +--- they wish to add or remove a range of items from the selection. +--- +--- @return boolean +local function isHoldingRangeModifier() + local keyboard = iui.input.keyboard + local down = keyboard.down + + return down:has("lshift") +end + +--- @param manager IUIListManager +--- @param idx number +--- @return IUIListSelectionChangeMutation? mutation +local function applySelection(manager, idx, returnMutation) + --- @type IUIListSelectionChangeMutation? + local mutation = nil + + local selection = manager.selection + + if returnMutation then + mutation = { + selection = iui.set.new(), + rangeStartIndex = manager.rangeStartIndex, + rangeEndIndex = manager.rangeEndIndex, + } + + mutation.selection:putAll(selection) + end + + -- Is this row already part of the selection? + if selection:has(idx) then + local count = selection:getCount() + + -- Do we have multiple items already selected? + if count > 1 then + if isHoldingSingleModifier() then + -- If there's multiple selected items, and the user is holding + -- the single item modifier, we just remove the one item. + selection:remove(idx) + manager.rangeStartIndex = nil + manager.rangeEndIndex = nil + elseif isHoldingRangeModifier() then + -- If there's multiple selected items, and the user is holding + -- the range modifier, we add a range of items, unwinding the + -- previous range if necessary. + local startIndex = manager.rangeStartIndex or 1 + local endIndex = manager.rangeEndIndex + + -- Undo the previous range. + if endIndex and endIndex ~= startIndex then + local sign = iui.utils.sign(endIndex - startIndex) + for index = startIndex, endIndex, sign do + selection:remove(index) + end + end + + -- Add the new range. + local sign = iui.utils.sign(idx - startIndex) + if sign == 0 then + selection:put(idx) + else + for index = startIndex, idx, sign do + selection:put(index) + end + end + + manager.rangeEndIndex = idx + else + -- If there's multiple items in the range, the user's clicking + -- a selected item, and they're not holding any modifiers, we + -- replace the entire selection with just this item. + selection:removeAll() + selection:put(idx) + manager.rangeStartIndex = idx + manager.rangeEndIndex = nil + end + elseif manager.allowsEmptySelection then + -- If this row is selected, and it's the only item in the selection, + -- we can only remove it if we're allowed to have an empty + -- selection. + selection:remove(idx) + manager.rangeStartIndex = nil + manager.rangeEndIndex = nil + end + else + -- This row is not selected, so we need to add it to the selection. + + if manager.allowsMultipleSelection and isHoldingSingleModifier() then + -- Multiple selection is allowed, and the single item modifier is + -- held, so we just add the current index. + selection:put(idx) + + manager.rangeStartIndex = idx + manager.rangeEndIndex = nil + elseif manager.allowsMultipleSelection and isHoldingRangeModifier() then + -- Multiple selection is allowed, and the range modifier is held, so + -- we add a range of items, unwinding the previous range if + -- necessary. + local startIndex = manager.rangeStartIndex or 1 + local endIndex = manager.rangeEndIndex + + -- Undo the previous range. + if endIndex and endIndex ~= startIndex then + local sign = iui.utils.sign(endIndex - startIndex) + for index = startIndex, endIndex, sign do + selection:remove(index) + end + end + + -- Add the new range. + local sign = iui.utils.sign(idx - startIndex) + if sign == 0 then + selection:put(idx) + else + for index = startIndex, idx, sign do + selection:put(index) + end + end + + manager.rangeEndIndex = idx + else + -- Either multiple selection is disallowed, or no modifier is held, + -- so replace the entire selection with this index. + selection:removeAll() + selection:put(idx) + + manager.rangeStartIndex = idx + manager.rangeEndIndex = nil + end + end + + return mutation +end + +--- @param state IUIListViewStackItem +--- @param idx number +local function updateSelection(state, idx) + local manager = state.manager + + -- Selection can only change if the list allows selection at all, and the + -- pointer is inside the widget. + if not manager.allowsSelection or not state.isMouseInBounds then + return + end + + -- Did the mouse begin being pressed this frame, and has no widget + -- claimed the pointer? + -- + -- This `if` clause here is why we do all the selection change logic at + -- the beginning of `listViewStep`. The *previous* row will have been + -- generated, and any widgets inside them will have been given the + -- opportunity to claim the active id. + if iui.activeID ~= nil or not iui.input.mouse.pressed:has(1) then + return + end + + -- As a base case, this method will be called before the first row has been + -- generated, and we need to exit out of that case. The "last" row bounds + -- will not exist yet, because there hasn't been a "last" row yet. + local x, y, w, h = state.lastX, state.lastY, state.lastW, state.lastH + if not x then + return + end + + -- We already know the mouse is in the list view's bounds, now we just need + -- to make sure it's in the row's bounds. + local mx, my = iui.input.mouse.x, iui.input.mouse.y + if not iui.utils.rectContains(x, y, w, h, mx, my) then + return + end + + if iui.idiom == "vr" then + manager.selectTimer = selectDelay + manager.timerIndex = idx + manager.conditionalMutation = nil + else + applySelection(manager, idx, false) + end +end + +--- @param state IUIListViewStackItem +--- @param idx number +local function listViewStep(state, idx) + updateSelection(state, idx) + + idx = idx + 1 + if idx <= state.maxIndex then + local manager = state.manager + local panel = state.panel + local spacing = state.spacing + local margin = state.margin + local rowHeight = state.rowHeight + local yOffset = state.yOffset + + local spaced = rowHeight + spacing + + panel.rowY = (idx - 1) * spaced + margin - yOffset + iui.layout.beginDynamicRow(1, rowHeight) + + local x, y, w, h = iui.layout.getBounds() + x = x - margin + w = w + margin * 2 + + state.lastX, state.lastY, state.lastW, state.lastH = x, y, w, h + + if manager.selection:has(idx) then + iui.colors.sysAccent500:set() + iui.graphics.rectangle(x, y, w, h) + end + + return idx + end +end + +--- @param index number +function ListManager:scrollToIndex(index) + local rowHeight = self.rowHeight + local margin = self.margin + local spacing = self.spacing + if rowHeight == nil then + return + end + + local top = margin + (index - 1) * (rowHeight + spacing) + + self:scrollTo(0, top, nil, rowHeight) +end + +--- @return IUIListManager +function iui.newListManager() + local super = iui.newScrollManager() + + --- @type IUIListManager + local out = setmetatable(super --[[@as any]], ListManager) + + out.selection = iui.set.new() + out.allowsSelection = true + out.allowsEmptySelection = true + out.allowsMultipleSelection = false + + return out +end + +--- @param name string +---@param count number +---@param rowHeight? number +---@param manager? IUIListManager +function iui.listView(name, count, rowHeight, manager) + rowHeight = rowHeight or iui.layout.getDefaultRowHeight() + local id = iui.beginID(name, false) + local state = iui.state(id) + + local margin = iui.style["margin"] + local spacing = iui.style["spacing"] + + local spaced = rowHeight + spacing + local isMouseInBounds = iui.layout.containsPoint() + + if not manager then + manager = state.manager + + if not manager then + manager = iui.newListManager() + state.manager = manager + end + end + + manager.rowHeight = rowHeight + manager.margin = margin + manager.spacing = spacing + + if not iui.input.mouse.down:has(1) then + if manager.selectTimer then + applySelection(manager, manager.timerIndex, false) + end + + manager.selectTimer = nil + manager.timerIndex = nil + manager.conditionalMutation = nil + end + + if manager.selectTimer then + manager.selectTimer = manager.selectTimer - iui.dt + if manager.selectTimer <= 0 then + manager.selectTimer = nil + manager.conditionalMutation = applySelection( + manager, manager.timerIndex, true + ) + end + end + + iui.scrollView("ScrollView", manager) + + --- @type IUIListViewStackItem + local item = iui.pool.get("list_view_stack_item") + table.insert(listStack, item) + + if count > 0 then + local panel = iui.layout.getPanel() + local yOffset = iui.utils.round(manager.y) + + panel.contentHeight = math.max( + panel.contentHeight, count * spaced - spacing + margin * 2 - yOffset + ) + + local minIndex = math.max( + math.floor((yOffset - margin) / spaced) + 1, 1 + ) + + local maxIndex = math.min( + math.ceil((yOffset + panel.h - margin) / spaced), count + ) + + item.manager = manager + item.isMouseInBounds = isMouseInBounds + item.panel = panel + item.margin = margin + item.spacing = spacing + item.rowHeight = rowHeight + item.yOffset = yOffset + item.maxIndex = maxIndex + + return listViewStep, item, minIndex - 1 + end +end + +function iui.endListView() + iui.endScrollView() + + iui.endID(false) + + iui.pool.put(table.remove(listStack)) +end diff --git a/src/lovr/lib/iui/widgets/menu-bar.lua b/src/lovr/lib/iui/widgets/menu-bar.lua new file mode 100644 index 0000000..44decee --- /dev/null +++ b/src/lovr/lib/iui/widgets/menu-bar.lua @@ -0,0 +1,159 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @class IUIMenuBarStackItem +--- @field state any +--- @field controller IUISubMenuController +--- @field isShowingPanel boolean +--- @field x number +--- @field w number +--- @field h number +--- @field barHeight number + +--- @type IUIMenuBarStackItem[] +local itemStack = {} + +function iui.menuBar() + local x, y, w, h = iui.layout.getPanelBounds() + + local barHeight = iui.layout.getDefaultRowHeight() + 1 + + -- Draw bar + iui.graphics.clip(x, y, w, barHeight) + + iui.colors.sysGray100:set() + iui.draw.panelBackground(x, y, w, barHeight - 1) + + iui.colors.sysGray200:set() + iui.graphics.rectangle(x, barHeight - 1, w, 1) + + iui.graphics.clip() + + local id = iui.beginID("__menuBar", false) + local state = iui.state(id) + + if state.controller == nil then + --- @type IUISubMenuController + local subMenuController + + subMenuController = { + subMenuIDs = {}, + isMenuBar = true, + currentSubMenuIndex = 1, + panelClaimedMouseDown = false, + beginSubMenu = function() + local subMenuID = subMenuController.subMenuIDs[subMenuController.currentSubMenuIndex] + subMenuController.currentSubMenuIndex = subMenuController.currentSubMenuIndex + 1 + return subMenuID + end, + endSubMenu = function() + subMenuController.currentSubMenuIndex = subMenuController.currentSubMenuIndex - 1 + end, + setSubMenu = function(menuID) + local subMenuIDs = subMenuController.subMenuIDs + local idx = subMenuController.currentSubMenuIndex - 1 + if state.showingSubMenuController and menuID ~= subMenuIDs[idx] then + for i = idx, #subMenuIDs do + table.remove(subMenuIDs, i) + end + table.insert(subMenuIDs, menuID) + end + end, + activate = function() + state.showingSubMenuController = true + end, + deactivate = function() + state.showingSubMenuController = false + end + } + state.controller = subMenuController + end + + local controller = state.controller + + iui.style.push() + iui.style["menuItemSpacing"] = iui.style["spacing"] * 4 + iui.style["spacing"] = 0 + iui.style["subMenuController"] = controller + controller.currentSubMenuIndex = 1 + controller.panelClaimedMouseDown = false + controller.hoveredSubMenuIndex = nil + + local isShowingPanel = state.showingSubMenuController + if isShowingPanel then + iui.beginLayer("__menuBar") + end + + if iui.idiom == "vr" then + local radius = iui.style["vrWindowCornerRadius"] + iui.layout.beginPanel(x + radius, y, w - radius * 2, barHeight - 1, 0) + else + iui.layout.beginPanel(x, y, w, barHeight - 1, 0) + end + + iui.layout.beginIntrinsicRow(nil, nil, barHeight - 1) + + --- @type IUIMenuBarStackItem + local item = iui.pool.get("menu_bar_stack_item") + item.state = state + item.controller = controller + item.isShowingPanel = isShowingPanel + item.barHeight = barHeight + item.x = x + item.w = w + item.h = h + + table.insert(itemStack, item) +end + +function iui.menuBarDivider() + --- @type IUIMenuBarStackItem + local item = table.remove(itemStack) + local state = item.state + local controller = item.controller + local isShowingPanel = item.isShowingPanel + local barHeight = item.barHeight + local x, w, h = item.x, item.w, item.h + + iui.pool.put(item) + + iui.layout.endPanel() + + iui.style.pop() + + iui.endID(false) + + if controller.hoveredSubMenuIndex and controller.hoveredSubMenuIndex < #controller.subMenuIDs then + state.vanishTimer = (state.vanishTimer or 0) + iui.dt + if state.vanishTimer > 0.35 then + for idx = controller.hoveredSubMenuIndex + 1, #controller.subMenuIDs do + table.remove(controller.subMenuIDs, idx) + end + end + else + state.vanishTimer = nil + end + + if not state.showingSubMenuController and #controller.subMenuIDs > 0 then + controller.subMenuIDs = {} + end + + if isShowingPanel then + if iui.input.mouse.pressed:has(1) and not controller.panelClaimedMouseDown then + iui.input.mouse.pressed:remove(1) + controller.deactivate() + end + + iui.endLayer() + end + + -- Content panel + iui.layout.beginPanel(x, barHeight, w, h - barHeight) +end + +function iui.endMenuBar() + iui.layout.endPanel() +end diff --git a/src/lovr/lib/iui/widgets/menu-item.lua b/src/lovr/lib/iui/widgets/menu-item.lua new file mode 100644 index 0000000..7d23e33 --- /dev/null +++ b/src/lovr/lib/iui/widgets/menu-item.lua @@ -0,0 +1,80 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @param name string +--- @param command? string +--- @return boolean +function iui.menuItem(name, command) + if iui.idiom ~= "desktop" then + command = nil + end + + local id = iui.beginID(name, false) + + local intrinsicW, intrinsicH = iui.layout.getWantsIntrinsic() + + local disabled = iui.isDisabled() + local pressed = false + + local font = iui.style["font"] + local padding = iui.style["padding"] + local spacing = iui.style["menuItemSpacing"] + + local nameW = math.ceil(font:getWidth(name)) + local commandW = command and math.ceil(font:getWidth(command)) + local textH = math.ceil(font:getHeight()) + + if intrinsicW then + if command then + iui.layout.setIntrinsicWidth(nameW + spacing + commandW + padding * 2) + else + iui.layout.setIntrinsicWidth(nameW + padding * 2) + end + end + + if intrinsicH then + iui.layout.setIntrinsicHeight(textH + padding * 2) + end + + if iui.layout.containsPoint() then + iui.becomeHover() + end + + if iui.input.mouse.released:has(1) then + if iui.hoverID == id then + pressed = true + --- @type IUISubMenuController + local controller = iui.style["subMenuController"] + controller.deactivate() + end + end + + local x, y, w, h = iui.layout.getBounds() + -- Background + if iui.hoverID == id then + iui.colors.sysGray200:set() + iui.graphics.rectangle(x + 4, y, w - 8, h, 2, 2) + end + + if disabled then + iui.colors.sysGray300:set() + else + iui.colors.sysGray900:set() + end + iui.graphics.setFont(font) + local textX = x + padding + local textY = y + iui.utils.round((h - textH) / 2) + iui.graphics.print(name, textX, textY) + + if command and commandW then + local commandX = x + w - padding - commandW + iui.graphics.print(command, commandX, textY) + end + + iui.endID() + + return pressed +end diff --git a/src/lovr/lib/iui/widgets/msdf-image-9-slice.lua b/src/lovr/lib/iui/widgets/msdf-image-9-slice.lua new file mode 100644 index 0000000..d727cad --- /dev/null +++ b/src/lovr/lib/iui/widgets/msdf-image-9-slice.lua @@ -0,0 +1,18 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @param image IUIImage9Slice +--- @param color? IUIColor +function iui.msdfImage9Slice(image, color) + color = color or iui.colors.white + + local x, y, w, h = iui.layout.getBounds() + + color:set() + iui.graphics.msdfNineSlice(image, x, y, w, h) + + iui.layout.advance() +end diff --git a/src/lovr/lib/iui/widgets/msdf-image.lua b/src/lovr/lib/iui/widgets/msdf-image.lua new file mode 100644 index 0000000..85553cc --- /dev/null +++ b/src/lovr/lib/iui/widgets/msdf-image.lua @@ -0,0 +1,32 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @param image any +--- @param color? IUIColor +function iui.msdfImage(image, color) + color = color or iui.colors.white + + local bx, by, bw, bh = iui.layout.getBounds() + local iw, ih = iui.graphics.getImageDimensions(image) + + local mode = iui.style["imageMode"] or "aspectFit" --- @type IUIImageMode + local clip = iui.style["imageClip"] or false --- @type boolean + + local ox, oy, ow, oh = iui.utils.fill(mode, iw, ih, bx, by, bw, bh) + + if clip then + iui.draw.pushClip(bx, by, bw, bh) + end + + color:set() + iui.graphics.msdfImage(image, ox, oy, ow, oh) + + if clip then + iui.draw.popClip() + end + + iui.layout.advance() +end diff --git a/src/lovr/lib/iui/widgets/msdf-layered-image-9-slice.lua b/src/lovr/lib/iui/widgets/msdf-layered-image-9-slice.lua new file mode 100644 index 0000000..91ddaea --- /dev/null +++ b/src/lovr/lib/iui/widgets/msdf-layered-image-9-slice.lua @@ -0,0 +1,17 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @param image IUILayeredImage +function iui.msdfLayeredImage9Slice(image) + local x, y, w, h = iui.layout.getBounds() + + for _, item in ipairs(image) do + item.color:set() + iui.graphics.msdfNineSlice(item.image, x, y, w, h) + end + + iui.layout.advance() +end diff --git a/src/lovr/lib/iui/widgets/msdf-layered-image.lua b/src/lovr/lib/iui/widgets/msdf-layered-image.lua new file mode 100644 index 0000000..831c2fc --- /dev/null +++ b/src/lovr/lib/iui/widgets/msdf-layered-image.lua @@ -0,0 +1,31 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @param image IUILayeredImage +function iui.msdfLayeredImage(image) + local bx, by, bw, bh = iui.layout.getBounds() + local iw, ih = iui.graphics.getImageDimensions(image[1].image) + + local mode = iui.style["imageMode"] or "aspectFit" --- @type IUIImageMode + local clip = iui.style["imageClip"] or false --- @type boolean + + local ox, oy, ow, oh = iui.utils.fill(mode, iw, ih, bx, by, bw, bh) + + if clip then + iui.draw.pushClip(bx, by, bw, bh) + end + + for _, item in ipairs(image) do + item.color:set() + iui.graphics.msdfImage(item.image, ox, oy, ow, oh) + end + + if clip then + iui.draw.popClip() + end + + iui.layout.advance() +end diff --git a/src/lovr/lib/iui/widgets/panel-background.lua b/src/lovr/lib/iui/widgets/panel-background.lua new file mode 100644 index 0000000..cf6a94a --- /dev/null +++ b/src/lovr/lib/iui/widgets/panel-background.lua @@ -0,0 +1,14 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @param color? IUIColor +function iui.panelBackground(color) + color = color or iui.colors.sysGray50 + local x, y, w, h = iui.layout.getPanelBounds() + + color:set() + iui.draw.panelBackground(x, y, w, h) +end diff --git a/src/lovr/lib/iui/widgets/progress.lua b/src/lovr/lib/iui/widgets/progress.lua new file mode 100644 index 0000000..bebb8d7 --- /dev/null +++ b/src/lovr/lib/iui/widgets/progress.lua @@ -0,0 +1,69 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +local barHeight = 16 + +--- @param value number The progress value. +--- @param min? number The lower bound. Defaults to 0. +--- @param max? number The upper bound. Defaults to 1. +function iui.progress(value, min, max) + min = min or 0 + max = max or 1 + + local _, intrinsicH = iui.layout.getWantsIntrinsic() + + local disabled = iui.isDisabled() + + local x, y, w, h = iui.layout.getBounds() + y = math.floor(y + (h - barHeight) * 0.5) + h = barHeight + + local bx, by, bw, bh = x + 2, y + 2, w - 4, h - 4 + + if intrinsicH then + iui.layout.setIntrinsicHeight(barHeight) + end + + -- Border + iui.colors.sysGray200:set() + iui.graphics.rectangle(x, y, w, h, 4, 4) + + -- Background + if disabled then + iui.colors.sysGray100:set() + else + iui.colors.sysGray0:set() + end + iui.graphics.rectangle(x + 1, y + 1, w - 2, h - 2, 3, 3) + + -- Bar + local shouldClip = value > min and value < max + if shouldClip then + local percent = (value - min) / (max - min) + percent = iui.utils.clamp(percent, 0, 1) + + -- Force at least one full pixel and one empty pixel + local width = iui.utils.clamp( + iui.utils.round(bw * percent), 1, bw - 1 + ) + iui.graphics.clip(bx, by, width, bh) + end + + if value > min then + if disabled then + iui.colors.sysGray200:set() + else + iui.colors.sysAccent500:set() + end + iui.graphics.rectangle(bx, by, bw, bh, 2, 2) + end + + if shouldClip then + iui.graphics.clip() + end + + iui.layout.advance() +end diff --git a/src/lovr/lib/iui/widgets/radio.lua b/src/lovr/lib/iui/widgets/radio.lua new file mode 100644 index 0000000..ba8bfc5 --- /dev/null +++ b/src/lovr/lib/iui/widgets/radio.lua @@ -0,0 +1,114 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @generic T +--- @param name string +--- @param current T +--- @param value T +--- @return T +function iui.radio(name, current, value) + local id = iui.beginID(name) + + local intrinsicW, intrinsicH = iui.layout.getWantsIntrinsic() + + local disabled = iui.isDisabled() + + local font = iui.style["font"] + local padding = iui.style["padding"] + + local textH = math.ceil(font:getHeight()) + local size = textH + 2 + local textX = size + padding + + if intrinsicW then + local textW = math.ceil(font:getWidth(name)) + iui.layout.setIntrinsicWidth(textX + textW) + end + + if intrinsicH then + iui.layout.setIntrinsicHeight(size) + end + + if iui.layout.containsPoint() then + iui.becomeHover() + end + + if iui.hoverID == id and iui.input.mouse.pressed:has(1) then + iui.becomeActive() + iui.becomeFocus() + end + + if iui.activeID == id then + if not iui.input.mouse.down:has(1) then + if iui.hoverID == id then + current = value + end + + iui.activeID = nil + end + end + + if iui.isFocused(id) then + if iui.input.keyboard.pressed["space"] then + current = value + end + end + + local x, y, _, h = iui.layout.getBounds() + + local textY = y + iui.utils.round((h - textH) / 2) + + local radius = math.ceil(size / 2) + local radioY = y + iui.utils.round((h - size) / 2) + + -- Outline + if not disabled then + if iui.isFocused(id) and iui.idiom ~= "vr" then + iui.colors.sysAccent500:set() + elseif iui.hoverID == id then + iui.colors.sysGray300:set() + else + iui.colors.sysGray200:set() + end + iui.graphics.circle(x + radius, radioY + radius, radius) + end + + -- Background + if (iui.hoverID == id and iui.activeID == id) or disabled then + iui.colors.sysGray100:set() + else + iui.colors.sysGray0:set() + end + iui.graphics.circle(x + radius, radioY + radius, radius - 1.5) + + -- Check + if current == value then + if disabled then + iui.colors.sysGray200:set() + elseif iui.hoverID == id and iui.activeID == id then + iui.colors.sysGray200:set() + elseif iui.hoverID == id then + iui.colors.sysGray400:set() + else + iui.colors.sysGray300:set() + end + + iui.graphics.circle(x + radius, radioY + radius, radius - 4) + end + + -- Label + if disabled then + iui.colors.sysGray300:set() + else + iui.colors.sysGray900:set() + end + iui.graphics.setFont(font) + iui.graphics.print(name, x + textX, textY) + + iui.endID() + + return current +end diff --git a/src/lovr/lib/iui/widgets/scroll-bar.lua b/src/lovr/lib/iui/widgets/scroll-bar.lua new file mode 100644 index 0000000..86ccb14 --- /dev/null +++ b/src/lovr/lib/iui/widgets/scroll-bar.lua @@ -0,0 +1,81 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @alias IUIScrollBarDirection "horiz" | "vert" + +--- @param name string +--- @param dir IUIScrollBarDirection +--- @param value number +--- @param length number +--- @param min number +--- @param max number +--- @return number +function iui.scrollBar(name, dir, value, length, min, max) + local id = iui.beginID(name, false) + local state = iui.state(id) + + local x, y, w, h = iui.layout.getBounds() + local mx = iui.input.mouse.x + local my = iui.input.mouse.y + + local pixelToValue = (max - min) / h + local mousePos = (my - y) * pixelToValue + + local isScrollable = length < (max - min) + + if isScrollable then + if iui.layout.containsPoint() then + iui.becomeHover() + end + + if iui.hoverID == id and iui.input.mouse.pressed:has(1) then + iui.becomeActive() + + if (mousePos < value) or (mousePos >= (value + length)) then + state.offset = length / 2 + else + state.offset = mousePos - value + end + end + end + + if iui.activeID == id then + value = math.min(math.max(mousePos - state.offset, min), max - length) + + if not iui.input.mouse.down:has(1) then + iui.activeID = nil + end + end + + if isScrollable then + iui.colors.sysGray0:set() + iui.graphics.rectangle(x, y, w, h) + + local bx, by = x, y + (value / (max - min)) * h + local bw, bh = w, (length / (max - min)) * h + local by2 = by + bh + by = iui.utils.round(by) + bh = iui.utils.round(by2) - by + + if iui.hoverID == id or iui.activeID == id then + iui.colors.sysGray200:set() + else + iui.colors.sysGray100:set() + end + iui.graphics.rectangle(bx, by, bw, bh) + + iui.colors.sysGray200:set() + iui.graphics.rectangle(bx, by - 1, bw, 1) + iui.graphics.rectangle(bx, by + bh, bw, 1) + else + iui.colors.sysGray50:set() + iui.graphics.rectangle(x, y, w, h) + end + + iui.endID() + + return value +end diff --git a/src/lovr/lib/iui/widgets/scroll-view.lua b/src/lovr/lib/iui/widgets/scroll-view.lua new file mode 100644 index 0000000..8eb5cd7 --- /dev/null +++ b/src/lovr/lib/iui/widgets/scroll-view.lua @@ -0,0 +1,272 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @class (exact) IUIScrollViewState +--- @field manager? IUIScrollManager +--- @field vy? number +--- @field dragOrigin? { x: number, y: number } +--- @field isDragging? boolean + +--- @class (exact) IUIScrollViewStackItem +--- @field state IUIScrollViewState +--- @field manager IUIScrollManager +--- @field id number +--- @field innerX number +--- @field innerY number +--- @field innerW number +--- @field innerH number +--- @field containsMouse boolean +--- @field disabled boolean + +--- @type IUIScrollViewStackItem[] +local scrollStack = {} + +--- @class IUIScrollManager +--- @field x number +--- @field y number +--- @field contentWidth number +--- @field contentHeight number +--- @field clipWidth number +--- @field clipHeight number +--- @field shouldInterruptAnimation boolean +--- @field beganDragging? fun(self: IUIScrollManager) +local ScrollManager = {} +ScrollManager.__index = ScrollManager + +function ScrollManager:fixOffset() + self.x = math.max(math.min(self.x, self.contentWidth - self.clipWidth), 0) + self.y = math.max(math.min(self.y, self.contentHeight - self.clipHeight), 0) +end + +--- @param x number +--- @param y number +--- @param w? number +--- @param h? number +function ScrollManager:scrollTo(x, y, w, h) + local minX = self.x + local maxX = self.x + self.clipWidth + local targetMinX = x + local targetMaxX = x + (w or 1) + + local minY = self.y + local maxY = self.y + self.clipHeight + local targetMinY = y + local targetMaxY = y + (h or 1) + + if targetMaxX > maxX then + maxX = targetMaxX + minX = maxX - self.clipWidth + end + + if targetMinX < minX then + minX = targetMinX + maxX = minX + self.clipWidth + end + + if targetMaxY > maxY then + maxY = targetMaxY + minY = maxY - self.clipHeight + end + + if targetMinY < minY then + minY = targetMinY + maxY = minY + self.clipHeight + end + + self.x = minX + self.y = minY + self.shouldInterruptAnimation = true + + self:fixOffset() +end + +iui.ScrollManager = ScrollManager + +--- @return IUIScrollManager +function iui.newScrollManager() + --- @type IUIScrollManager + local out = setmetatable({}, ScrollManager) + + out.x = 0 + out.y = 0 + out.contentWidth = 0 + out.contentHeight = 0 + out.clipWidth = 0 + out.clipHeight = 0 + out.shouldInterruptAnimation = false + + return out +end + +--- @param name string +--- @param manager? IUIScrollManager +function iui.scrollView(name, manager) + local id = iui.beginID(name, false) + + --- @type IUIScrollViewState + local state = iui.state(id) + + if not manager then + manager = state.manager + + if not manager then + manager = iui.newScrollManager() + state.manager = manager + end + end + + local x, y, w, h = iui.layout.getBounds() + local containsMouse = iui.layout.containsPoint() + local disabled = iui.isDisabled() + + local scrollSize = iui.style["scrollSize"] + + local innerX, innerY = x + 1, y + 1 + local innerW, innerH = w - (3 + scrollSize), h - 2 + + iui.colors.sysGray200:set() + iui.graphics.rectangle(x, y, w, h) + + iui.colors.sysGray0:set() + iui.graphics.rectangle(innerX, innerY, innerW, innerH) + + iui.layout.beginZStack() + iui.draw.pushClip(innerX, innerY, innerW, innerH) + local panel = iui.layout.beginPanel(innerX, innerY, innerW, innerH) + panel.rowY = panel.rowY - iui.utils.round(manager.y) + + --- @type IUIScrollViewStackItem + local stackItem = iui.pool.get("scroll_view_stack_item") + + stackItem.state = state + stackItem.manager = manager + stackItem.id = id + stackItem.innerX = innerX + stackItem.innerY = innerY + stackItem.innerW = innerW + stackItem.innerH = innerH + stackItem.containsMouse = containsMouse + stackItem.disabled = disabled + + table.insert(scrollStack, stackItem) +end + +function iui.endScrollView() + --- @type IUIScrollViewStackItem + local stackItem = table.remove(scrollStack) + + local scrollSize = iui.style["scrollSize"] + + local state = stackItem.state + local manager = stackItem.manager + local id = stackItem.id + local innerX = stackItem.innerX + local innerY = stackItem.innerY + local innerW = stackItem.innerW + local innerH = stackItem.innerH + local containsMouse = stackItem.containsMouse + local disabled = stackItem.disabled + + local scrollX, scrollY = innerX + innerW + 1, innerY + local scrollW, scrollH = scrollSize, innerH + + local cw, ch = iui.layout.getContentSize() + cw, ch = cw + manager.x, ch + iui.utils.round(manager.y) + iui.layout.endPanel() + iui.draw.popClip() + + iui.layout.beginPanel(scrollX, scrollY, scrollW, scrollH, 0) + iui.layout.fillPanel() + manager.y = iui.scrollBar("scroll", "vert", manager.y, innerH, 0, ch) + iui.layout.endPanel() + iui.layout.endZStack() + + if manager.shouldInterruptAnimation then + manager.shouldInterruptAnimation = false + state.vy = 0 + end + + if state.vy ~= nil and state.vy ~= 0 then + manager.y = manager.y + state.vy * iui.dt + state.vy = state.vy * math.exp(-4 * iui.dt) + if math.abs(state.vy) * iui.dt < 0.05 then + state.vy = 0 + end + end + + local mx, my = iui.input.mouse.x, iui.input.mouse.y + + if not disabled then + if containsMouse then + local scrollIncrement = iui.input.mouse.scrollY * 21 + if iui.input.keyboard.down:has("lalt") then + scrollIncrement = scrollIncrement * 5 + end + manager.y = manager.y - scrollIncrement + + -- Set up a potential drag event + if iui.input.mouse.pressed:has(1) then + -- Terminate any scrolling + state.vy = 0 + + -- The mouse must not be in the scrollbar + local isOutsideScroll = false + isOutsideScroll = isOutsideScroll or mx < scrollX + isOutsideScroll = isOutsideScroll or mx >= scrollX + scrollW + isOutsideScroll = isOutsideScroll or my < scrollY + isOutsideScroll = isOutsideScroll or my >= scrollY + scrollH + + local canScroll = ch > innerH + canScroll = canScroll and iui.idiom == "vr" + if isOutsideScroll and canScroll then + state.dragOrigin = { x = mx, y = my } + end + end + end + + if state.dragOrigin then + if not iui.input.mouse.down:has(1) then + state.dragOrigin = nil + else + -- local dx = mx - state.dragOrigin.x + local dy = my - state.dragOrigin.y + if math.abs(dy) > 15 then + state.dragOrigin = nil + state.isDragging = true + if manager.beganDragging then + manager:beganDragging() + end + iui.becomeActive(id) + end + end + end + + if state.isDragging then + if not iui.input.mouse.down:has(1) then + state.isDragging = false + iui.activeID = nil + -- state.vy = -iui.input.mouse.dy / iui.dt + _, state.vy = iui.input.mouse.getVelocity() + if math.abs(state.vy) > 2000 then + state.vy = iui.utils.sign(state.vy) * 2000 + end + else + manager.y = manager.y - iui.input.mouse.dy + end + end + end + + manager.clipWidth = innerW + manager.clipHeight = innerH + manager.contentWidth = cw + manager.contentHeight = ch + + manager:fixOffset() + + iui.endID() + + iui.pool.put(stackItem) +end diff --git a/src/lovr/lib/iui/widgets/slider.lua b/src/lovr/lib/iui/widgets/slider.lua new file mode 100644 index 0000000..9fe9eb8 --- /dev/null +++ b/src/lovr/lib/iui/widgets/slider.lua @@ -0,0 +1,109 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +local radius = 8 + +--- @param name string +--- @param value number +--- @param min number +--- @param max number +--- @return number +function iui.slider(name, value, min, max) + local id = iui.beginID(name) + + local _, intrinsicH = iui.layout.getWantsIntrinsic() + + local disabled = iui.isDisabled() + + local x, y, w, h = iui.layout.getBounds() + local percent = (value - min) / (max - min) + local thumb = x + radius + iui.utils.round((w - radius * 2) * percent) + + if intrinsicH then + iui.layout.setIntrinsicHeight(radius * 2) + end + + if iui.layout.containsPoint() then + iui.becomeHover() + end + + if iui.hoverID == id and iui.input.mouse.pressed:has(1) then + iui.becomeActive() + iui.becomeFocus() + end + + if iui.activeID == id then + local mousePercent = (iui.input.mouse.x - x - radius) / (w - radius * 2) + mousePercent = math.max(math.min(1, mousePercent), 0) + + value = min + mousePercent * (max - min) + + if not iui.input.mouse.down:has(1) then + iui.activeID = nil + end + end + + if iui.isFocused(id) and iui.activeID ~= id then + local increment = (max - min) / 10 + local base = iui.utils.round((value - min) / increment) + if iui.input.keyboard.pressed["left"] then + value = math.max(min, min + (base - 1) * increment) + elseif iui.input.keyboard.pressed["right"] then + value = math.min(max, min + (base + 1) * increment) + end + end + + percent = (value - min) / (max - min) + thumb = x + radius + iui.utils.round((w - radius * 2) * percent) + + -- Track + if disabled then + iui.colors.sysGray100:set() + else + iui.colors.sysGray200:set() + end + iui.graphics.rectangle( + x + radius, y + iui.utils.round(h / 2) - 2, + w - radius * 2, 4, + 2, 2 + ) + + iui.colors.sysGray50:set() + iui.graphics.rectangle( + x + radius + 1, y + iui.utils.round(h / 2) - 1, + w - radius * 2 - 2, 2, + 1, 1 + ) + + -- Thumb + if disabled then + iui.colors.sysGray100:set() + elseif iui.isFocused(id) and iui.idiom ~= "vr" then + iui.colors.sysAccent500:set() + elseif iui.activeID == id then + iui.colors.sysGray200:set() + elseif iui.hoverID == id then + iui.colors.sysGray400:set() + else + iui.colors.sysGray300:set() + end + iui.graphics.circle(thumb, y + iui.utils.round(h / 2), radius) + + if not disabled then + if iui.activeID == id then + iui.colors.sysGray50:set() + elseif iui.hoverID == id then + iui.colors.sysGray200:set() + else + iui.colors.sysGray100:set() + end + iui.graphics.circle(thumb, y + iui.utils.round(h / 2), radius - 1.5) + end + + iui.endID() + + return value +end diff --git a/src/lovr/lib/iui/widgets/split-view.lua b/src/lovr/lib/iui/widgets/split-view.lua new file mode 100644 index 0000000..6006547 --- /dev/null +++ b/src/lovr/lib/iui/widgets/split-view.lua @@ -0,0 +1,167 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @alias IUISplitViewDirection "horiz" | "vert" +--- @alias IUISplitViewSide "min" | "max" + +--- @class (exact) IUISplitStackItem +--- @field direction IUISplitViewDirection +--- @field current number +--- @field x number +--- @field y number +--- @field w number +--- @field h number + +--- @type IUISplitStackItem[] +local splitStack = {} + +--- @param name string +--- @param direction IUISplitViewDirection +--- @param current number +--- @return number +function iui.splitView(name, direction, current) + local id = iui.beginID(name, false) + local state = iui.state(id) + + local x, y, w, h = iui.layout.getPanelBounds() + + local mx = iui.input.mouse.x + local my = iui.input.mouse.y + local spacing = iui.style["spacing"] + local splitMinEdge = iui.style["splitMinEdge"] or 8 + local splitMaxEdge = iui.style["splitMaxEdge"] or 8 + local splitSide = iui.style["splitSide"] or "min" --- @type IUISplitViewSide + + if splitSide == "max" then + current = w - current + end + + local handleWidth = math.ceil(spacing * 0.75) + local handleMin = current - handleWidth + local handleMax = current + handleWidth + 1 + + if direction == "horiz" then + if mx >= x + handleMin and mx <= x + handleMax then + if my >= y and my < y + h then + iui.becomeHover() + end + end + + if iui.hoverID == id then + if iui.input.mouse.pressed:has(1) then + iui.becomeActive() + state.offset = (mx - x) - current + end + end + + if iui.activeID == id then + current = (mx - x) - state.offset + + if not iui.input.mouse.down:has(1) then + iui.activeID = nil + end + end + + if iui.hoverID == id or iui.activeID == id then + iui.cursor = "sizewe" + end + elseif direction == "vert" then + if my >= y + handleMin and my <= y + handleMax then + if mx >= x and mx < x + w then + iui.becomeHover() + end + end + + if iui.hoverID == id then + if iui.input.mouse.pressed:has(1) then + iui.becomeActive() + state.offset = (mx - x) - current + end + end + + if iui.activeID == id then + current = (my - y) - state.offset + + if not iui.input.mouse.down:has(1) then + iui.activeID = nil + end + end + + if iui.hoverID == id or iui.activeID == id then + iui.cursor = "sizens" + end + end + + current = iui.utils.round(current) + + if current < splitMinEdge then + current = splitMinEdge + end + + if direction == "horiz" then + if current > w - (splitMaxEdge + 1) then + current = w - (splitMaxEdge + 1) + end + elseif direction == "vert" then + if current > h - (splitMaxEdge + 1) then + current = h - (splitMaxEdge + 1) + end + end + + iui.panelBackground() + + local pos = current + iui.colors.sysGray0:set() + if direction == "horiz" then + iui.graphics.rectangle(x + pos, y, 1, h) + elseif direction == "vert" then + iui.graphics.rectangle(x, y + pos, w, 1) + end + + if direction == "horiz" then + iui.layout.beginPanel(x, y, current, h) + elseif direction == "vert" then + iui.layout.beginPanel(x, y, w, current) + end + + --- @type IUISplitStackItem + local item = iui.pool.get("split_view_stack_item") + item.direction = direction + item.current = current + item.x, item.y, item.w, item.h = x, y, w, h + + table.insert(splitStack, item) + + if splitSide == "max" then + current = w - current + end + + return current +end + +function iui.splitViewDivider() + --- @type IUISplitStackItem + local item = table.remove(splitStack) + local direction = item.direction + local x, y, w, h = item.x, item.y, item.w, item.h + local current = item.current + + iui.layout.endPanel() + + if direction == "horiz" then + iui.layout.beginPanel(x + current + 1, y, w - (current + 1), h) + elseif direction == "vert" then + iui.layout.beginPanel(x, y + current + 1, w, h - (current + 1)) + end + + iui.pool.put(item) +end + +function iui.endSplitView() + iui.layout.endPanel() + + iui.endID() +end diff --git a/src/lovr/lib/iui/widgets/sub-menu.lua b/src/lovr/lib/iui/widgets/sub-menu.lua new file mode 100644 index 0000000..622d2eb --- /dev/null +++ b/src/lovr/lib/iui/widgets/sub-menu.lua @@ -0,0 +1,221 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @class IUISubMenuController +--- @field isMenuBar boolean +--- @field subMenuIDs number[] +--- @field currentSubMenuIndex number +--- @field hoveredSubMenuIndex? number +--- @field panelClaimedMouseDown boolean +--- @field beginSubMenu fun(): number? +--- @field endSubMenu fun() +--- @field setSubMenu fun(id: number) +--- @field activate fun() +--- @field deactivate fun() + +--- @class (exact) IUISubMenuStackItem +--- @field state { panelW: number, panelH: number } + +--- @type IUISubMenuStackItem[] +local subMenuStack = {} + +local disclosureImage + +--- @param name string +--- @return boolean isOpen +function iui.subMenu(name) + if disclosureImage == nil then + disclosureImage = iui.backend.system.getMSDFImage( + "assets/glyph-disclosure.png" + ) + end + + local id = iui.beginID(name, false) + local state = iui.state(id) + + local intrinsicW, intrinsicH = iui.layout.getWantsIntrinsic() + + local font = iui.style["font"] + local padding = iui.style["padding"] + local spacing = iui.style["menuItemSpacing"] + + --- @type IUISubMenuController + local controller = iui.style["subMenuController"] + local isPanelSubMenu = iui.style["isPanelSubMenu"] or false + + local textW = math.ceil(font:getWidth(name)) + local textH = math.ceil(font:getHeight()) + local discSize = textH - 2 + + if intrinsicW then + if isPanelSubMenu then + iui.layout.setIntrinsicWidth( + textW + padding * 2 + spacing + discSize + ) + else + iui.layout.setIntrinsicWidth(textW + padding * 2) + end + end + + if intrinsicH then + iui.layout.setIntrinsicHeight(textH + padding * 2) + end + + if iui.layout.containsPoint() then + iui.becomeHover() + end + + local isShowingPanel = false + local subMenuID = controller.beginSubMenu() + if subMenuID == id then + if iui.hoverID == id and iui.input.mouse.pressed:has(1) then + if not isPanelSubMenu then + controller.deactivate() + else + isShowingPanel = true + end + iui.input.mouse.pressed:remove(1) + else + isShowingPanel = true + end + end + + if iui.hoverID == id then + local clicked = false + if iui.input.mouse.pressed:has(1) then + if not isPanelSubMenu then + controller.activate() + end + iui.input.mouse.pressed:remove(1) + clicked = true + end + + state.hoverTime = (state.hoverTime or 0) + iui.dt + + if state.hoverTime > 0.2 or clicked or not isPanelSubMenu then + controller.setSubMenu(id) + end + + if (controller.hoveredSubMenuIndex or 0) < controller.currentSubMenuIndex - 1 then + controller.hoveredSubMenuIndex = controller.currentSubMenuIndex - 1 + end + else + state.hoverTime = nil + end + + local x, y, w, h = iui.layout.getBounds() + + -- Background + if isPanelSubMenu then + if iui.hoverID == id or subMenuID == id then + iui.colors.sysGray200:set() + iui.graphics.rectangle(x + 4, y, w - 8, h, 2, 2) + end + else + if iui.hoverID == id or subMenuID == id then + iui.colors.sysGray200:set() + iui.graphics.rectangle(x, y + 2, w, h - 4, 2, 2) + end + end + + iui.colors.sysGray900:set() + iui.graphics.setFont(font) + local textX + local textY = y + iui.utils.round((h - textH) / 2) + if isPanelSubMenu then + textX = x + padding + local discY = y + iui.utils.round((h - discSize) / 2) + iui.graphics.msdfImage( + disclosureImage, x + w - padding - discSize, discY, discSize, discSize + ) + else + textX = x + iui.utils.round((w - textW) / 2) + end + iui.graphics.print(name, textX, textY) + + iui.endID() + + if isShowingPanel then + local panelX, panelY + if isPanelSubMenu then + panelX = x + w + panelY = y - 5 + else + panelX = x + panelY = y + h - 2 + end + local panelW = state.panelW or 200 + local panelH = state.panelH or 400 + + local mx, my = iui.input.mouse.x, iui.input.mouse.y + if mx >= panelX and my >= panelY and mx < panelX + panelW and my < panelY + panelH then + if iui.input.mouse.pressed:has(1) then + controller.panelClaimedMouseDown = true + end + + if (controller.hoveredSubMenuIndex or 0) < controller.currentSubMenuIndex - 1 then + controller.hoveredSubMenuIndex = controller.currentSubMenuIndex - 1 + end + end + + if state.panelH == nil then + iui.draw.beginHiding() + end + + -- Panel background + iui.colors.sysGray300:set() + iui.graphics.rectangle(panelX, panelY, panelW, state.panelH or 0, 4, 4) + + iui.colors.sysGray100:set() + iui.graphics.rectangle(panelX + 1, panelY + 1, panelW - 2, (state.panelH or 0) - 2, 3, 3) + + iui.layout.beginPanel(panelX, panelY, panelW, panelH, 1) + iui.style.push() + iui.style["subMenuController"] = controller + iui.style["isPanelSubMenu"] = true + iui.layout.beginDynamicRow(1, 4) + iui.layout.spacer() + + if state.panelW then + iui.layout.beginDynamicRow() + else + iui.layout.beginIntrinsicRow(10, 1) + end + + --- @type IUISubMenuStackItem + local stackItem = iui.pool.get("sub_menu_stack_item") + stackItem.state = state + table.insert(subMenuStack, stackItem) + else + controller.endSubMenu() + end + + return isShowingPanel +end + +function iui.endSubMenu() + --- @type IUISubMenuStackItem + local stackItem = table.remove(subMenuStack) + + local state = stackItem.state + + iui.pool.put(stackItem) + + if state.panelH == nil then + iui.draw.endHiding() + end + + state.panelW, state.panelH = iui.layout.getContentSize() + state.panelW = math.max(state.panelW, 100) + state.panelH = state.panelH + 4 + + --- @type IUISubMenuController + local controller = iui.style["subMenuController"] + controller.endSubMenu() + + iui.style.pop() + iui.layout.endPanel(false) +end diff --git a/src/lovr/lib/iui/widgets/tab-bar.lua b/src/lovr/lib/iui/widgets/tab-bar.lua new file mode 100644 index 0000000..ea02130 --- /dev/null +++ b/src/lovr/lib/iui/widgets/tab-bar.lua @@ -0,0 +1,149 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +--- @class IUITabBarStackItem +--- @field barHeight number +--- @field x number +--- @field y number +--- @field w number +--- @field h number + +--- @type IUITabBarStackItem[] +local itemStack = {} + +function iui.tabBar() + local x, y, w, h = iui.layout.getPanelBounds() + + local barHeight = iui.layout.getDefaultRowHeight() + 1 + + iui.colors.sysGray100:set() + iui.draw.panelBackground(x, y, w, barHeight - 1) + + iui.colors.sysGray200:set() + iui.graphics.rectangle(x, y + barHeight - 1, w, 1) + + iui.layout.beginPanel(x + 1, y, w - 2, barHeight, 0) + iui.layout.beginIntrinsicRow(nil, nil, barHeight) + iui.style.push() + iui.style["spacing"] = 0 + + --- @type IUITabBarStackItem + local item = iui.pool.get("tab_bar_stack_item") + item.barHeight = barHeight + item.x, item.y, item.w, item.h = x, y, w, h + table.insert(itemStack, item) +end + +function iui.tabBarDivider() + --- @type IUITabBarStackItem + local item = table.remove(itemStack) + local barHeight = item.barHeight + local x, y, w, h = item.x, item.y, item.w, item.h + iui.pool.put(item) + + iui.style.pop() + iui.layout.endPanel() + + iui.layout.beginPanel(x, y + barHeight, w, h - barHeight) +end + +function iui.endTabBar() + iui.layout.endPanel() +end + +--- @generic T +--- @param name string +--- @param current T +--- @param value T +--- @return T +function iui.tabItem(name, current, value) + local id = iui.beginID(name) + local out = current + + local intrinsicW, intrinsicH = iui.layout.getWantsIntrinsic() + + local font = iui.style["font"] + local padding = iui.style["padding"] + + local textH = math.ceil(font:getHeight()) + local size = textH + 2 + + if intrinsicW then + local textW = math.ceil(font:getWidth(name)) + iui.layout.setIntrinsicWidth(textW + padding * 4) + end + + if intrinsicH then + iui.layout.setIntrinsicHeight(size) + end + + if iui.layout.containsPoint() then + iui.becomeHover() + end + + if iui.hoverID == id and iui.input.mouse.pressed:has(1) then + out = value + iui.becomeFocus() + end + + if iui.isFocused(id) then + if iui.input.keyboard.pressed["space"] then + out = value + end + end + + local x, y, w, h = iui.layout.getBounds() + + local textX = x + padding * 2 + local textY = y + iui.utils.round((h - textH) / 2) + + local top = y + 1 + local fh = h - 9 + + --- @type IUIColor + local backgroundColor + + if out == value then + iui.graphics.clip(x, y, w + 1, h) + backgroundColor = iui.colors.sysGray50 + textY = textY + fh = fh + 2 + else + iui.graphics.clip(x, y, w + 1, h - 1) + top = top + 2 + textY = textY + 1 + + if iui.hoverID == id then + backgroundColor = iui.colors.sysGray200 + else + backgroundColor = iui.colors.sysGray100 + end + end + + -- Outline + iui.colors.sysGray200:set() + iui.graphics.rectangle(x, top, w + 1, h + 6, 6, 6) + + -- Background + backgroundColor:set() + iui.graphics.rectangle(x + 1, top + 1, w - 1, h + 5, 5, 5) + + if iui.idiom ~= "vr" and iui.isFocused(id) then + iui.colors.sysAccent500:set() + iui.graphics.rectangle(x + padding, y + h - 6, w - padding * 2, 2, 1, 1) + end + + -- Label + iui.colors.sysGray900:set() + iui.graphics.setFont(font) + iui.graphics.print(name, textX, textY) + + iui.graphics.clip() + + iui.endID() + + return out +end diff --git a/src/lovr/lib/iui/widgets/text-field.lua b/src/lovr/lib/iui/widgets/text-field.lua new file mode 100644 index 0000000..c075504 --- /dev/null +++ b/src/lovr/lib/iui/widgets/text-field.lua @@ -0,0 +1,111 @@ +local currentPath = (...):match('(.-)[^%./]+$') +local parentPath = currentPath:match('(.-)[^%./]+%.$') + +--- @class IUILib +local iui = require(parentPath .. "iui") + +local utf8 = require "utf8" + +--- @param name string +--- @param s string +--- @return string +function iui.textField(name, s) + local id = iui.beginID(name) + + local state = iui.state(id) + + local x, y, w, h = iui.layout.getBounds() + + local disabled = iui.isDisabled() + + local font = iui.style["font"] + local padding = iui.style["padding"] + + if iui.layout.containsPoint() then + iui.becomeHover() + end + + if iui.hoverID == id then + iui.cursor = "ibeam" + + if iui.input.mouse.pressed:has(1) then + iui.becomeFocus() + end + end + + if iui.isFocused(id) then + state.time = (state.time or 0) + iui.dt + if state.showCursor == nil then + state.showCursor = true + end + + while state.time > 0.5 do + state.showCursor = not state.showCursor + state.time = state.time - 0.5 + end + + local changedBuffer = false + + if iui.input.textBuffer ~= nil then + s = s .. iui.input.textBuffer + iui.input.textBuffer = nil + + changedBuffer = true + end + + if iui.input.keyboard.pressed["backspace"] then + local idx = utf8.offset(s, -1) + if idx then + s = string.sub(s, 1, idx - 1) + + changedBuffer = true + end + end + + if changedBuffer then + state.showCursor = true + state.time = 0 + end + else + state.time = nil + state.showCursor = nil + end + + -- Outline + if iui.isFocused(id) then + iui.colors.sysAccent500:set() + elseif iui.hoverID == id then + iui.colors.sysGray300:set() + else + iui.colors.sysGray200:set() + end + iui.graphics.rectangle(x, y, w, h, 8, 8) + + -- Background + if disabled then + iui.colors.sysGray100:set() + else + iui.colors.sysGray0:set() + end + iui.graphics.rectangle(x + 1, y + 1, w - 2, h - 2, 7, 7) + + if disabled then + iui.colors.sysGray300:set() + else + iui.colors.sysGray900:set() + end + iui.graphics.setFont(font) + local textW, textH = font:getWidth(s), font:getHeight() + local textY = y + iui.utils.round((h - textH) / 2) + iui.graphics.print(s, x + padding, textY) + + if iui.isFocused(id) and state.showCursor then + iui.colors.sysAccent500:set() + local radius = (iui.detail == "high" and 1 or nil) + iui.graphics.rectangle(x + padding + textW, textY, 2, textH, radius, radius) + end + + iui.endID() + + return s +end diff --git a/src/lovr/lib/iui/window-manager.lua b/src/lovr/lib/iui/window-manager.lua new file mode 100644 index 0000000..46a47cc --- /dev/null +++ b/src/lovr/lib/iui/window-manager.lua @@ -0,0 +1,64 @@ +local currentPath = (...):match('(.-)[^%./]+$') + +--- @class IUILib +local iui = require(currentPath .. "iui") + +--- @class IUIWindowManager +--- @field draw IUIDrawRootContext +--- @field layer IUILayerRootContext +--- @field state IUIStateRootContext +--- @field disabledCount number +--- @field hoverID? number The ID of the widget a pointer is hovering over. +--- @field activeID? number The ID of the widget that's being actively used. +--- @field cursor? IUICursorName The desired mouse cursor to display. +--- @field hadActiveID boolean Internal flag for detecting widget deactivation. +local IUIWindowManager = {} +IUIWindowManager.__index = IUIWindowManager + +--- @return IUIWindowManager +function iui.newWindowManager() + --- @type IUIWindowManager + local manager = { + draw = iui.draw.newRootContext(), + layer = iui.layer.newRootContext(), + state = iui.state.newRootContext(), + disabledCount = 0, + hadActiveID = false, + } + setmetatable(manager, IUIWindowManager) + + return manager +end + +function IUIWindowManager:beginFrame() + iui.disabledCount = 0 + iui.hoverID = nil + iui.cursor = nil + + iui.resetIDCheck() + iui.state.beginFrame() + iui.draw.beginFrame() + iui.layout.beginFrame() + iui.style.beginFrame() + iui.layer.beginFrame() +end + +function IUIWindowManager:endFrame() + if iui.disabledCount ~= 0 then + error("Unbalanced control disable count") + end + + if self.activeID == nil and self.hadActiveID == true then + self.hadActiveID = false + + if iui.widgetDeactivated then + iui.widgetDeactivated() + end + end + + iui.layout.endFrame() + iui.draw.endFrame() + iui.layer.endFrame() + iui.style.endFrame() + iui.state.endFrame() +end diff --git a/src/lovr/lib/lovr-iui/.gitignore b/src/lovr/lib/lovr-iui/.gitignore new file mode 100644 index 0000000..94f1119 --- /dev/null +++ b/src/lovr/lib/lovr-iui/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.vscode diff --git a/src/lovr/lib/lovr-iui/LICENSE b/src/lovr/lib/lovr-iui/LICENSE new file mode 100644 index 0000000..faf4102 --- /dev/null +++ b/src/lovr/lib/lovr-iui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Donald Hays + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lovr/lib/lovr-iui/README.md b/src/lovr/lib/lovr-iui/README.md new file mode 100644 index 0000000..1c2856a --- /dev/null +++ b/src/lovr/lib/lovr-iui/README.md @@ -0,0 +1,136 @@ +# LÖVR-IUI + +A LÖVR backend for the [IUI](https://github.com/DonaldHays/iui) immediate mode +GUI library. + +A [sample project](https://github.com/DonaldHays/iui-sample-lovr) that uses this +backend is available. + +## Installation + +This library provides the backend for [IUI](https://github.com/DonaldHays/iui) +for use in LÖVR projects. Both `iui` and `lovr-iui` must be added to your LÖVR +project. + +If you are using the desktop idiom, you must also include the +[lovr-mouse](https://github.com/bjornbytes/lovr-mouse) library in your project. + +## Minimal Desktop Sample + +### conf.lua +```lua +function lovr.conf(t) + t.modules.headset = nil +end +``` + +### main.lua +```lua +local iui = require "iui" +local backend = require "lovr-iui" +local mouse = require "lovr-mouse" + +local labelText = "Click the button!" + +function lovr.load() + backend.mouse = mouse + + iui.load(backend) +end + +function lovr.update(dt) + iui.beginFrame(dt) + iui.beginWindow(lovr.system.getWindowDimensions()) + + iui.panelBackground() + iui.label(labelText) + if iui.button("Say Hello") then + labelText = "Hello, World!" + end + + iui.endWindow() + iui.endFrame() +end + +function lovr.draw(pass) + backend.graphics.pass = pass + + iui.draw() +end + +function lovr.mousemoved(x, y, dx, dy) + backend.mousemoved(x, y, dx, dy) +end + +function lovr.mousepressed(x, y, button) + backend.mousepressed(x, y, button) +end + +function lovr.mousereleased(x, y, button) + backend.mousereleased(x, y, button) +end + +function lovr.wheelmoved(x, y) + backend.wheelmoved(x, y) +end + +function lovr.keypressed(key, scancode, isRepeat) + backend.keypressed(key, scancode, isRepeat) +end + +function lovr.keyreleased(key, scancode) + backend.keyreleased(key, scancode) +end + +function lovr.textinput(text) + backend.textinput(text) +end +``` + +## Minimal VR Sample + +### conf.lua +```lua +function lovr.conf(t) + t.headset.supersample = 2 +end +``` + +### main.lua +```lua +local iui = require "iui" +local backend = require "lovr-iui" + +--- @type LovrIUIWorldWindow +local window + +local labelText = "Click the button!" + +function lovr.load() + iui.load(backend) + + window = backend.worldWindow.new() +end + +function lovr.update(dt) + iui.beginFrame(dt) + + if window:beginFrame() then + iui.beginWindow(window.w, window.h) + iui.panelBackground() + iui.label(labelText) + if iui.button("Say Hello") then + labelText = "Hello, World!" + end + iui.endWindow() + + window:endFrame() + end + + iui.endFrame() +end + +function lovr.draw(pass) + window:draw(pass) +end +``` diff --git a/src/lovr/lib/lovr-iui/graphics.lua b/src/lovr/lib/lovr-iui/graphics.lua new file mode 100644 index 0000000..16e7a96 --- /dev/null +++ b/src/lovr/lib/lovr-iui/graphics.lua @@ -0,0 +1,400 @@ +local iui --- @type IUILib + +local colorClipShader --- @type Shader +local fontClipShader --- @type Shader +local imageClipShader --- @type Shader +local imageUnclippedShader --- @type Shader +local msdfImageClipShader --- @type Shader +local msdfImageUnclippedShader --- @type Shader + +local nearestImageSampler --- @type Sampler +local linearImageSampler --- @type Sampler + +local currentImageFilter = "linear" --- @type IUIImageFilter +local currentShader = nil --- @type Shader? +local isFilterDirty = false --- @type boolean +local isClipDirty = false --- @type boolean +local currentClip = nil --- @type number[]? + +local currentWindow --- @type LovrIUIWorldWindow +local windowWidth --- @type number +local windowHeight --- @type number + +--- @alias LovrIUIShaderName "color" | "font" | "image" | "msdf" + +--- @class LovrIUIGraphics: IUIGraphicsBackend +--- @field pass Pass +local graphics = {} + +--- @param shader LovrIUIShaderName +local function setShader(shader) + local targetShader = nil --- @type Shader? + if shader == "color" then + if currentClip then + targetShader = colorClipShader + end + elseif shader == "font" then + if currentClip then + targetShader = fontClipShader + end + elseif shader == "image" then + if currentClip then + targetShader = imageClipShader + else + targetShader = imageUnclippedShader + end + elseif shader == "msdf" then + if currentClip then + targetShader = msdfImageClipShader + else + targetShader = msdfImageUnclippedShader + end + end + + local targetSampler = linearImageSampler + + if currentImageFilter == "nearest" then + targetSampler = nearestImageSampler + end + + if targetShader ~= currentShader then + graphics.pass:setShader(targetShader --[[@as any]]) + currentShader = targetShader + + if shader == "image" then + graphics.pass:send("useAAUV", currentImageFilter == "smooth") + graphics.pass:send("imageSampler", targetSampler) + isFilterDirty = false + elseif shader == "msdf" then + graphics.pass:send("msdfSampler", targetSampler) + end + end + + if isFilterDirty then + if shader == "image" then + graphics.pass:send("useAAUV", currentImageFilter == "smooth") + graphics.pass:send("imageSampler", targetSampler) + elseif shader == "msdf" then + graphics.pass:send("msdfSampler", targetSampler) + end + isFilterDirty = false + end + + if currentShader and currentClip and isClipDirty then + local windowCenter = currentWindow.center + + isClipDirty = false + + local left = currentClip[1] + local top = currentClip[2] + local right = left + currentClip[3] + local bottom = top + currentClip[4] + + left = (left - (currentWindow.w / 2)) / currentWindow.ppm + top = -(top - (currentWindow.h / 2)) / currentWindow.ppm + right = (right - (currentWindow.w / 2)) / currentWindow.ppm + bottom = -(bottom - (currentWindow.h / 2)) / currentWindow.ppm + + local q = currentWindow.rotation + + local leftDir = vec3(1, 0, 0):rotate(q) + local topDir = vec3(0, -1, 0):rotate(q) + local rightDir = vec3(-1, 0, 0):rotate(q) + local bottomDir = vec3(0, 1, 0):rotate(q) + + local topLeftVector = vec3(left, top, 0) + local bottomRightVector = vec3(right, bottom, 0) + + topLeftVector = topLeftVector:rotate(q) + bottomRightVector = bottomRightVector:rotate(q) + + topLeftVector = topLeftVector + windowCenter + bottomRightVector = bottomRightVector + windowCenter + + graphics.pass:send("ClipPlanes", { + centers = { + topLeftVector, + topLeftVector, + bottomRightVector, + bottomRightVector, + }, + directions = { + leftDir, + topDir, + rightDir, + bottomDir, + } + }) + end +end + +--- @param clip number[]? +local function setClip(clip) + if clip ~= nil then + if currentClip == nil then + isClipDirty = true + else + for i = 1, #clip do + if clip[i] ~= currentClip[i] then + isClipDirty = true + break + end + end + end + end + + currentClip = clip +end + +--- @param filter IUIImageFilter +local function setFilter(filter) + if filter ~= currentImageFilter then + currentImageFilter = filter + isFilterDirty = true + end +end + +local function nineSliceMesh(nineSlice, x, y, w, h) + local image = nineSlice.image --- @type Texture + + local iw, ih = image:getDimensions() + + local l, t, r, b = nineSlice.l, nineSlice.t, nineSlice.r, nineSlice.b + local uvl, uvt, uvr, uvb = l / iw, t / ih, (iw - r) / iw, (ih - b) / ih + local wh = windowHeight + + local mesh = lovr.graphics.newMesh( + { + { "VertexPosition", "vec2" }, + { "VertexUV", "vec2" }, + }, + { + { x, wh - (y), 0, 0 }, + { x + l, wh - (y), uvl, 0 }, + { x + w - r, wh - (y), uvr, 0 }, + { x + w, wh - (y), 1, 0 }, + + { x, wh - (y + t), 0, uvt }, + { x + l, wh - (y + t), uvl, uvt }, + { x + w - r, wh - (y + t), uvr, uvt }, + { x + w, wh - (y + t), 1, uvt }, + + { x, wh - (y + h - b), 0, uvb }, + { x + l, wh - (y + h - b), uvl, uvb }, + { x + w - r, wh - (y + h - b), uvr, uvb }, + { x + w, wh - (y + h - b), 1, uvb }, + + { x, wh - (y + h), 0, 1 }, + { x + l, wh - (y + h), uvl, 1 }, + { x + w - r, wh - (y + h), uvr, 1 }, + { x + w, wh - (y + h), 1, 1 }, + } + ) + + mesh:setIndices({ + 1, 5, 2, 2, 5, 6, + 2, 6, 3, 3, 6, 7, + 3, 7, 4, 4, 7, 8, + + 5, 9, 6, 6, 9, 10, + 6, 10, 7, 7, 10, 11, + 7, 11, 8, 8, 11, 12, + + 9, 13, 10, 10, 13, 14, + 10, 14, 11, 11, 14, 15, + 11, 15, 12, 12, 15, 16, + }) + + return image, mesh +end + +--- @param lib IUILib +--- @param backend LovrIUIBackend +function graphics.load(lib, backend) + iui = lib + + colorClipShader = lovr.graphics.newShader( + backend.resourcePath .. "shaders/ui-clip.glsl", "unlit" + ) + + fontClipShader = lovr.graphics.newShader( + backend.resourcePath .. "shaders/ui-clip.glsl", "font" + ) + + imageClipShader = lovr.graphics.newShader( + backend.resourcePath .. "shaders/ui-clip.glsl", + backend.resourcePath .. "shaders/ui-image.glsl" + ) + + imageUnclippedShader = lovr.graphics.newShader( + "unlit", + backend.resourcePath .. "shaders/ui-image.glsl" + ) + + msdfImageClipShader = lovr.graphics.newShader( + backend.resourcePath .. "shaders/ui-clip.glsl", + backend.resourcePath .. "shaders/ui-msdf.glsl" + ) + + msdfImageUnclippedShader = lovr.graphics.newShader( + "unlit", + backend.resourcePath .. "shaders/ui-msdf.glsl" + ) + + nearestImageSampler = lovr.graphics.newSampler { + wrap = { "clamp", "clamp", "clamp" }, + filter = { "nearest", "nearest", "linear" }, + } + + linearImageSampler = lovr.graphics.newSampler { + wrap = { "clamp", "clamp", "clamp" }, + } +end + +--- @param window LovrIUIWorldWindow +function graphics.setWindow(window) + currentWindow = window +end + +function graphics.beginDraw(width, height) + windowWidth = width + windowHeight = height + currentImageFilter = "linear" + currentShader = nil + currentClip = nil + isFilterDirty = false + isClipDirty = false + + local pass = graphics.pass + + pass:setShader() + pass:setMaterial() + + if iui.idiom == "desktop" then + pass:push() + pass:setDepthTest() + + pass:setViewPose(1, mat4():identity(), false) + pass:setProjection(1, mat4():orthographic( + 0, windowWidth, windowHeight, 0, -10, 10) + ) + end +end + +function graphics.endDraw() + if iui.idiom == "desktop" then + graphics.pass:pop() + end + + graphics.pass:setShader() +end + +function graphics.newFont(size, hinting, dpiscale) + local baseFontSize = 32 + local font = lovr.graphics.newFont(baseFontSize) + font:setPixelDensity(baseFontSize / size) + return font +end + +--- @param image Texture +function graphics.getImageDimensions(image) + return image:getDimensions() +end + +function graphics.clip(x, y, w, h) + if iui.idiom == "desktop" then + if x then + local wx, wy, ww, wh = 0, 0, windowWidth, windowHeight + local wmx, wmy = wx + ww, wy + wh + local mx, my = x + w, y + h + + local dminX, dminY = math.max(x, wx), math.max(y, wy) + local dmaxX, dmaxY = math.min(mx, wmx), math.min(my, wmy) + local dx, dy, dw, dh = dminX, dminY, dmaxX - dminX, dmaxY - dminY + + if dx < 0 or dy < 0 or dw < 0 or dh < 0 then + graphics.pass:setScissor(0, 0, 0, 0) + else + local dpi = lovr.system.getWindowDensity() + graphics.pass:setScissor(dx * dpi, dy * dpi, dw * dpi, dh * dpi) + end + else + graphics.pass:setScissor() + end + else + if x then + setClip({ x, y, w, h }) + else + setClip(nil) + end + end +end + +function graphics.setColor(r, g, b, a) + graphics.pass:setColor(r, g, b, a) +end + +function graphics.rectangle(x, y, w, h, rx, ry) + setShader("color") + if (rx or 0) == 0 and (ry or 0) == 0 then + graphics.pass:setBlendMode() + end + graphics.pass:roundrect(x + w * 0.5, windowHeight - (y + h * 0.5), 0, w, h, 0, 0, 0, 0, 0, rx) + graphics.pass:setBlendMode("alpha", "alphamultiply") +end + +function graphics.circle(x, y, r) + setShader("color") + graphics.pass:circle(x, windowHeight - y, 0, r) +end + +function graphics.setFont(f) + graphics.pass:setFont(f) +end + +function graphics.print(s, x, y) + setShader("font") + graphics.pass:text(s, x, windowHeight - y, 0, 1, 0, 0, 1, 0, 0, "left", "top") +end + +--- @param image Texture +function graphics.image(image, filter, x, y, w, h) + setFilter(filter) + setShader("image") + graphics.pass:setMaterial(image) + graphics.pass:plane(x + w * 0.5, windowHeight - (y + h * 0.5), 0, w, h) + graphics.pass:setMaterial() +end + +function graphics.nineSlice(nineSlice, filter, x, y, w, h) + local image, mesh = nineSliceMesh(nineSlice, x, y, w, h) + + setFilter(filter) + setShader("image") + + graphics.pass:setMaterial(image) + + graphics.pass:draw(mesh) + graphics.pass:setMaterial() +end + +function graphics.msdfImage(image, x, y, w, h) + setFilter("linear") + setShader("msdf") + graphics.pass:setMaterial(image) + graphics.pass:plane(x + w * 0.5, windowHeight - (y + h * 0.5), 0, w, h) + graphics.pass:setMaterial() +end + +function graphics.msdfNineSlice(nineSlice, x, y, w, h) + local image, mesh = nineSliceMesh(nineSlice, x, y, w, h) + + setFilter("linear") + setShader("msdf") + graphics.pass:setMaterial(image) + + graphics.pass:draw(mesh) + graphics.pass:setMaterial() +end + +return graphics diff --git a/src/lovr/lib/lovr-iui/init.lua b/src/lovr/lib/lovr-iui/init.lua new file mode 100644 index 0000000..39ad941 --- /dev/null +++ b/src/lovr/lib/lovr-iui/init.lua @@ -0,0 +1,144 @@ +local iui --- @type IUILib + +local currentPath = (...):gsub('%.init$', '') .. "." +local resourcePath = currentPath:gsub("%.", "/") + +--- @type LovrIUISystem +local system = require(currentPath .. "system") + +--- @type LovrIUIGraphics +local graphics = require(currentPath .. "graphics") + +--- @type LovrIUIWorldWindow +local worldWindow = require(currentPath .. "world-window") + +--- @type LovrIUIVRInput +local input = require(currentPath .. "vr-input") + +local currentWorldWindow --- @type LovrIUIWorldWindow +local desktopIdiomFullscreenWindowManager --- @type IUIWindowManager +local desktopIdiomMouseRootContext --- @type IUIMouseRootContext + +--- @class LovrIUIBackend: IUIBackend +--- @field mouse any +local backend = { + graphics = graphics, + system = system, + resourcePath = resourcePath, + worldWindow = worldWindow, + input = input, +} + +function backend.config(config) + if not config.idiom then + if lovr.headset then + config.idiom = "vr" + else + config.idiom = "desktop" + end + end + + if not config.detail then + if config.idiom == "vr" then + config.detail = "high" + else + if lovr.system.getWindowDensity() > 1 then + config.detail = "high" + else + config.detail = "low" + end + end + end +end + +function backend.load(lib) + iui = lib + + system.load(lib, backend) + graphics.load(lib, backend) + input.load(lib, backend) + worldWindow.load(lib, backend) + + if iui.idiom == "desktop" then + desktopIdiomMouseRootContext = iui.input.mouse.newRootContext() + iui.input.mouse.setRootContext(desktopIdiomMouseRootContext) + end +end + +--- @param dt number +function backend.beginFrame(dt) + if iui.idiom == "vr" then + input.beginFrame(dt) + end +end + +function backend.endFrame() + if iui.idiom == "vr" then + input.endFrame() + end +end + +--- @param newWorldWindow LovrIUIWorldWindow +function backend.setCurrentWorldWindow(newWorldWindow) + currentWorldWindow = newWorldWindow +end + +function backend.getFullscreenWindowManager() + if iui.idiom == "desktop" then + if desktopIdiomFullscreenWindowManager == nil then + desktopIdiomFullscreenWindowManager = iui.newWindowManager() + end + + return desktopIdiomFullscreenWindowManager + elseif iui.idiom == "vr" then + return currentWorldWindow:getFullscreenWindowManager() + end +end + +--- @param x number +--- @param y number +--- @param dx number +--- @param dy number +function backend.mousemoved(x, y, dx, dy) + iui.input.mouse("move", 0, x, y, dx, dy) +end + +--- @param x number +--- @param y number +--- @param button number +function backend.mousepressed(x, y, button) + iui.input.mouse("down", button, x, y, 0, 0) +end + +--- @param x number +--- @param y number +--- @param button number +function backend.mousereleased(x, y, button) + iui.input.mouse("up", button, x, y, 0, 0) +end + +--- @param x number +--- @param y number +function backend.wheelmoved(x, y) + iui.input.mouse("scroll", 0, 0, 0, x, y) +end + +--- @param key KeyCode +--- @param scancode number +--- @param isRepeat boolean +function backend.keypressed(key, scancode, isRepeat) + iui.input.keyboard("down", key, isRepeat) +end + +--- @param key KeyCode +--- @param scancode number +function backend.keyreleased(key, scancode) + iui.input.keyboard("up", key, false) +end + +--- @param text string +function backend.textinput(text) + iui.input.text(text) +end + +return backend diff --git a/src/lovr/lib/lovr-iui/shaders/ui-clip.glsl b/src/lovr/lib/lovr-iui/shaders/ui-clip.glsl new file mode 100644 index 0000000..c0f926e --- /dev/null +++ b/src/lovr/lib/lovr-iui/shaders/ui-clip.glsl @@ -0,0 +1,13 @@ +uniform ClipPlanes { + vec3 centers[4]; + vec3 directions[4]; +}; + +vec4 lovrmain() { + ClipDistance[0] = dot(PositionWorld - centers[0], directions[0]); + ClipDistance[1] = dot(PositionWorld - centers[1], directions[1]); + ClipDistance[2] = dot(PositionWorld - centers[2], directions[2]); + ClipDistance[3] = dot(PositionWorld - centers[3], directions[3]); + + return DefaultPosition; +} diff --git a/src/lovr/lib/lovr-iui/shaders/ui-image.glsl b/src/lovr/lib/lovr-iui/shaders/ui-image.glsl new file mode 100644 index 0000000..f120b0a --- /dev/null +++ b/src/lovr/lib/lovr-iui/shaders/ui-image.glsl @@ -0,0 +1,29 @@ +uniform bool useAAUV; +uniform sampler imageSampler; + +vec2 getAAUV(texture2D tex, vec2 uv) { + vec2 texSize = textureSize(tex, 0); + vec2 pixelSpaceTexCoord = uv * texSize; + vec2 centerCoord = floor(pixelSpaceTexCoord - 0.5f) + 0.5f; + vec2 halfFWidth = fwidth(pixelSpaceTexCoord) * 0.5f; + vec2 offset = smoothstep( + 0.5f - halfFWidth, + 0.5f + halfFWidth, + pixelSpaceTexCoord - centerCoord + ); + vec2 aauv = (centerCoord + offset) / texSize; + + return aauv; +} + +vec4 lovrmain() { + vec2 filteredUV; + + if (useAAUV) { + filteredUV = getAAUV(ColorTexture, UV); + } else { + filteredUV = UV; + } + + return Color * texture(sampler2D(ColorTexture, imageSampler), filteredUV); +} diff --git a/src/lovr/lib/lovr-iui/shaders/ui-msdf.glsl b/src/lovr/lib/lovr-iui/shaders/ui-msdf.glsl new file mode 100644 index 0000000..5a8fdad --- /dev/null +++ b/src/lovr/lib/lovr-iui/shaders/ui-msdf.glsl @@ -0,0 +1,25 @@ +uniform sampler msdfSampler; + +vec2 squared(vec2 v) { + return v * v; +} + +float screenPxRange() { + vec2 unitRange = vec2(2) / textureSize(ColorTexture, 0); + vec2 screenTexSize = inversesqrt(squared(dFdx(UV)) + squared(dFdy(UV))); + + return max(0.5 * dot(unitRange, screenTexSize), 1.0); +} + +float median(float a, float b, float c) { + return max(min(a, b), min(max(a, b), c)); +} + +vec4 lovrmain() { + vec3 msd = texture(sampler2D(ColorTexture, msdfSampler), UV).rgb; + float sd = median(msd.r, msd.g, msd.b); + float screenPxDistance = screenPxRange() * (sd - 0.5); + float opacity = clamp(screenPxDistance + 0.5, 0.0, 1.0); + + return vec4(Color.rgb, Color.a * opacity); +} diff --git a/src/lovr/lib/lovr-iui/system.lua b/src/lovr/lib/lovr-iui/system.lua new file mode 100644 index 0000000..0161540 --- /dev/null +++ b/src/lovr/lib/lovr-iui/system.lua @@ -0,0 +1,75 @@ +local iui --- @type IUILib + +local mouse + +--- @param filename string +--- @return Texture +local function loadLinearTexture(filename) + return lovr.graphics.newTexture(iui.resourcePath .. filename, { + linear = true, mipmaps = false + }) +end + +--- @class LovrIUISystem: IUISystemBackend +--- @field defaultCursor Texture +--- @field currentCursor Texture? +--- @field inactiveCursor Texture +local system = {} + +--- @param lib IUILib +--- @param backend LovrIUIBackend +function system.load(lib, backend) + iui = lib + + system.defaultCursor = loadLinearTexture("assets/cursor-default_sdf.png") + system.inactiveCursor = loadLinearTexture("assets/cursor-inactive_sdf.png") + system.currentCursor = system.defaultCursor + + if iui.idiom == "desktop" then + mouse = backend.mouse + + if not mouse then + error("Backend requires `mouse` library in desktop mode") + end + end +end + +function system.getOS() + return lovr.system.getOS() +end + +function system.getTimestamp() + return lovr.timer.getTime() +end + +function system.getSystemCursor(name) + if iui.idiom == "desktop" then + return mouse.getSystemCursor(name) + end + + name = "assets/cursor-" .. name .. "_sdf.png" + + return loadLinearTexture(name) +end + +function system.setCursor(cursor) + if iui.idiom == "desktop" then + mouse.setCursor(cursor) + end + + system.currentCursor = (cursor --[[@as any]]) or system.defaultCursor +end + +function system.getMSDFImage(name) + return loadLinearTexture(name) +end + +function system.getDPI() + return lovr.system.getWindowDensity() +end + +function system.quit() + lovr.event.quit() +end + +return system diff --git a/src/lovr/lib/lovr-iui/vr-input.lua b/src/lovr/lib/lovr-iui/vr-input.lua new file mode 100644 index 0000000..451b5fc --- /dev/null +++ b/src/lovr/lib/lovr-iui/vr-input.lua @@ -0,0 +1,309 @@ +local iui --- @type IUILib +local system --- @type LovrIUISystem + +local cursorShader --- @type Shader + +local cursorSampler = lovr.graphics.newSampler { + wrap = { "clamp", "clamp", "clamp" } +} + +--- @class (exact) TrackedIntersection +--- @field worldPos Vec3 +--- @field uiPos Vec2 +--- @field dist number +--- @field fuzzyInside number +--- @field window LovrIUIWorldWindow + +--- @class (exact) InputWindowSession +--- @field activeHand? Device +--- @field hadHover boolean +--- @field hadActive boolean + +--- @type Device[] +local devices = {} + +--- @type table +local intersections = {} + +--- @type table +local windowSessions = {} + +--- @type table +local newWindowSessions = {} + +--- @type table +local hoveredWindows = {} + +--- @param rayPos Vec3 +--- @param rayDir Vec3 +--- @param planePos Vec3 +--- @param planeDir Vec3 +--- @return Vec3? intersection, number distance +local function raycast(rayPos, rayDir, planePos, planeDir) + local dot = rayDir:dot(planeDir) + -- Reject glancing rays, and rays coming from behind the plane. + if dot >= -0.001 then + return nil, 0 + else + --- @type number + local distance = (planePos - rayPos):dot(planeDir) / dot + if distance > 0 then + return rayPos + rayDir * distance, distance + else + return nil, 0 + end + end +end + +--- @class LovrIUIVRInput +local input = {} + +--- @param lib IUILib +--- @param backend LovrIUIBackend +function input.load(lib, backend) + iui = lib + + system = backend.system + + if iui.idiom == "vr" then + cursorShader = lovr.graphics.newShader( + "font", backend.resourcePath .. "shaders/ui-msdf.glsl" + ) + + devices = { + "hand/right", + "hand/left", + } + end +end + +--- @param dt number +function input.beginFrame(dt) + intersections = {} + newWindowSessions = {} +end + +function input.endFrame() + -- The window sessions table consists of only the windows we encountered + -- this frame. + windowSessions = newWindowSessions + + -- Buzz controllers on hover. + for window, session in pairs(windowSessions) do + iui.input.mouse.setRootContext(window.mouse) + iui.input.mouse.endFrame() + + local hasHover, hasActive = window:getHasHover(), window:getHasActive() + hasHover = hasHover or hasActive + + if session.activeHand then + if hasHover and hasHover ~= session.hadHover then + lovr.headset.vibrate(session.activeHand, 0.2, 0.025) + end + + if hasActive ~= session.hadActive then + lovr.headset.vibrate(session.activeHand, 0.4, 0.03) + end + end + + session.hadHover, session.hadActive = hasHover, hasActive + end + + -- Empty the `hoveredWindows` table, but only for hands that aren't the + -- `activeHand` of a window that has an `activeID`. + for device, window in pairs(hoveredWindows) do + local hasActive = window:getHasActive() + local isActiveHand = false + + if hasActive then + for _, session in pairs(windowSessions) do + if session.activeHand == device then + isActiveHand = true + break + end + end + end + + if not isActiveHand then + hoveredWindows[device] = nil + end + end + + -- Update the window associated with each hand. + for device, ints in pairs(intersections) do + -- If this device is in `hoveredWindows`, then it's the `activeHand` of + -- a window, so we don't change its assignment. + if hoveredWindows[device] then + goto continue + end + + -- Find the closest intersection. + local closest = ints[1] + for _, int in ipairs(ints) do + if int.fuzzyInside == closest.fuzzyInside then + if int.dist < closest.dist then + closest = int + end + elseif int.fuzzyInside > closest.fuzzyInside then + closest = int + end + end + + -- Assign the hand to the window + hoveredWindows[device] = closest.window + + ::continue:: + end +end + +--- @param window LovrIUIWorldWindow +function input.beginWorldWindow(window) + --- @type InputWindowSession + local session = windowSessions[window] or { + hadHover = false, + hadActive = false, + } + + newWindowSessions[window] = session + + local center = window.center + local rotation = window.rotation + + for _, device in ipairs(devices) do + if lovr.headset.isTracked(device) then + local rayPos = vec3(lovr.headset.getPosition(device .. '/point')) + local rayDir = vec3(lovr.headset.getDirection(device .. '/point')) + + local hit, dist = raycast(rayPos, rayDir, center, rotation:direction() * -1) + + local mx, my = -1, -1 + if hit then + -- hit is in worldspace, convert to ui space + hit = (hit - center):rotate(quat(rotation):conjugate()) * window.ppm + mx, my = hit.x + window.w / 2, window.h * 0.5 - hit.y + local fuzzyInside = window:fuzzyInside(mx, my) + + if fuzzyInside > 0 then + if hoveredWindows[device] == window then + local canGrabActive = not window:getHasActive() + canGrabActive = canGrabActive and (lovr.headset.wasPressed(device, "trigger")) + + if session.activeHand and session.activeHand ~= device then + if lovr.headset.isDown(session.activeHand, "grip") then + canGrabActive = false + end + end + + if session.activeHand == nil or canGrabActive then + session.activeHand = device + iui.input.mouse.resetVelocity() + end + end + + intersections[device] = intersections[device] or {} + table.insert(intersections[device], { + worldPos = hit, + uiPos = Vec2(mx, my), + dist = dist, + fuzzyInside = fuzzyInside, + window = window, + }) + + mx, my = math.floor(mx), math.floor(my) + end + + if hoveredWindows[device] ~= window and session.activeHand == device then + session.activeHand = nil + + iui.input.mouse("move", 0, -100, -100, 0, 0) + end + + if session.activeHand == device then + local dx = mx - iui.input.mouse.x + local dy = my - iui.input.mouse.y + iui.input.mouse("move", 0, mx, my, dx, dy) + + if lovr.headset.isDown(device, "grip") then + if not iui.input.keyboard.down:has("lctrl") then + iui.input.keyboard("down", "lctrl", false) + end + else + if iui.input.keyboard.down:has("lctrl") then + iui.input.keyboard("up", "lctrl", false) + end + end + + if lovr.headset.wasPressed(device, "trigger") then + iui.input.mouse("down", 1, mx, my, 0, 0) + end + + if lovr.headset.wasReleased(device, "trigger") then + iui.input.mouse("up", 1, mx, my, 0, 0) + end + + local sx, sy = lovr.headset.getAxis(device, "thumbstick") + iui.input.mouse.scrollX = sx + iui.input.mouse.scrollY = sy + + if window:fuzzyInside(mx, my) <= 0 then + if not window:getHasActive() then + session.activeHand = nil + end + end + end + end + end + end +end + +--- @param window LovrIUIWorldWindow +function input.endWorldWindow(window) +end + +--- @param pass Pass +--- @param window LovrIUIWorldWindow +function input.draw(pass, window) + for device, testWindow in pairs(hoveredWindows) do + if window ~= testWindow or intersections[device] == nil then + goto outerContinue + end + + for _, intersection in ipairs(intersections[device]) do + if intersection.window ~= window then + goto innerContinue + end + + local opacity = intersection.fuzzyInside + if opacity > 0 then + pass:setColor(1, 1, 1, opacity * opacity) + + local session = windowSessions[window] + + local cursor = nil + local manager = window.fullscreenWindowManager + if manager then + cursor = iui.getCursor(manager.cursor) or system.defaultCursor + end + + if session.activeHand ~= device then + cursor = system.inactiveCursor + end + + if cursor then + local mx, my = intersection.uiPos:unpack() + + pass:setShader(cursorShader) + pass:send("msdfSampler", cursorSampler) + pass:draw(cursor, mx, window.h - my, 0, 32) + pass:setShader() + end + end + + ::innerContinue:: + end + + ::outerContinue:: + end +end + +return input diff --git a/src/lovr/lib/lovr-iui/world-window.lua b/src/lovr/lib/lovr-iui/world-window.lua new file mode 100644 index 0000000..6473eaa --- /dev/null +++ b/src/lovr/lib/lovr-iui/world-window.lua @@ -0,0 +1,180 @@ +local iui --- @type IUILib +local backend --- @type LovrIUIBackend +local graphics --- @type LovrIUIGraphics +local input --- @type LovrIUIVRInput + +--- @class (exact) IUIWorldWindowProps +--- @field center? Vec3 Defaults to { x = 0, y = 1.5, z = -1 } +--- @field rotation? Quat Defaults to { 0, 0, 0, 1 } +--- @field ppm? number Defaults to 1000 +--- @field w? number Defaults to 1280 +--- @field h? number Defaults to 720 + +--- @class LovrIUIWorldWindow +--- @field fullscreenWindowManager? IUIWindowManager The window manager that fills the world window +--- @field mouse IUIMouseRootContext Mouse input data for the window +--- @field center Vec3 The center of the window, in worldspace +--- @field rotation Quat The direction the window is facing +--- @field ppm number The scale of the window, in points per meter +--- @field w number The width of the window, in points +--- @field h number The height of the window, in points +local WorldWindow = {} +WorldWindow.__index = WorldWindow + +--- @param lib IUILib +--- @param theBackend LovrIUIBackend +function WorldWindow.load(lib, theBackend) + iui = lib + backend = theBackend + graphics = backend.graphics + input = backend.input +end + +--- @param props? IUIWorldWindowProps +--- @return LovrIUIWorldWindow +function WorldWindow.new(props) + props = props or {} + + --- @type LovrIUIWorldWindow + local output = { + mouse = iui.input.mouse.newRootContext(), + center = props.center or Vec3(0, 1.5, -1), + rotation = props.rotation or Quat(), + ppm = props.ppm or 1000, + w = props.w or 1280, + h = props.h or 720, + } + setmetatable(output, WorldWindow) + + return output +end + +--- @param x number +--- @param y number +--- @return number insideAmount +function WorldWindow:fuzzyInside(x, y) + local overflow = 60 + local inside = 1 + + local closestX = iui.utils.clamp(x, 0, self.w) + local closestY = iui.utils.clamp(y, 0, self.h) + local dx, dy = closestX - x, closestY - y + local d = math.sqrt(dx * dx + dy * dy) + + inside = iui.utils.clamp(1 - d / overflow, 0, 1) + + return inside +end + +function WorldWindow:recenter() + local headPos = vec3(lovr.headset.getPosition("head")) + local headDir = vec3(lovr.headset.getDirection("head")) + local targetPos = headPos + (headDir * 1) + + local yaw = math.atan2(-headDir[1], -headDir[3]) + local pitch = -math.asin(-headDir[2]) + + self.center = Vec3(targetPos) + self.rotation = Quat(quat():setEuler(pitch, yaw, 0)) +end + +--- @return boolean +function WorldWindow:beginFrame() + backend.setCurrentWorldWindow(self) + iui.input.mouse.setRootContext(self.mouse) + + input.beginWorldWindow(self) + + return true +end + +function WorldWindow:endFrame() + input.endWorldWindow(self) +end + +--- @return IUIWindowManager +function WorldWindow:getFullscreenWindowManager() + if self.fullscreenWindowManager == nil then + self.fullscreenWindowManager = iui.newWindowManager() + end + + return self.fullscreenWindowManager +end + +--- @return boolean +function WorldWindow:getHasHover() + if self.fullscreenWindowManager then + return self.fullscreenWindowManager.hoverID ~= nil + end + + return false +end + +--- @return boolean +function WorldWindow:getHasActive() + if self.fullscreenWindowManager then + return self.fullscreenWindowManager.activeID ~= nil + end + + return false +end + +--- @param pass Pass +function WorldWindow:draw(pass) + graphics.setWindow(self) + graphics.pass = pass + + pass:push() + + -- pass:setDepthOffset(-100, 0) + -- pass:setDepthWrite(false) + -- pass:setDepthTest() + pass:setMaterial() + pass:translate(self.center) + pass:rotate(self.rotation) + pass:scale(1 / self.ppm) + pass:translate(-self.w / 2, -self.h / 2, 0) + + -- Backface + pass:setDepthTest("gequal") + pass:setDepthWrite(true) + pass:setColor(1, 1, 1, 0.5) + pass:setFaceCull("front") + pass:roundrect( + self.w / 2, self.h / 2, 0, + self.w, self.h, 0, + 0, 0, 0, 0, + iui.style["vrWindowCornerRadius"] + ) + pass:setFaceCull() + + -- Widgets do not write depth + pass:setDepthWrite(false) + pass:setFaceCull("back") + + local manager = self.fullscreenWindowManager + if manager then + iui.draw.setWindowManager(manager) + iui.draw() + end + + -- pass:setDepthOffset(0, 0) + input.draw(pass, self) + + -- Front depth face + pass:setDepthWrite(true) + pass:setColorWrite(false) + pass:roundrect( + self.w / 2, self.h / 2, 0, + self.w, self.h, 0, + 0, 0, 0, 0, + iui.style["vrWindowCornerRadius"] + ) + pass:setColorWrite(true) + + pass:setFaceCull() + + pass:pop() +end + +return WorldWindow diff --git a/src/lovr/lib/lovr-mouse/LICENSE b/src/lovr/lib/lovr-mouse/LICENSE new file mode 100644 index 0000000..58f9eff --- /dev/null +++ b/src/lovr/lib/lovr-mouse/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018 Bjorn Swenson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lovr/lib/lovr-mouse/init.lua b/src/lovr/lib/lovr-mouse/init.lua new file mode 100644 index 0000000..a6c3695 --- /dev/null +++ b/src/lovr/lib/lovr-mouse/init.lua @@ -0,0 +1,160 @@ +assert(type(jit) == 'table' and lovr.system.getOS() ~= 'Android', 'lovr-mouse cannot run on this platform') +local ffi = require 'ffi' +local C = ffi.os == 'Windows' and ffi.load('glfw3') or ffi.C + +ffi.cdef [[ + enum { + GLFW_CURSOR = 0x00033001, + GLFW_CURSOR_NORMAL = 0x00034001, + GLFW_CURSOR_HIDDEN = 0x00034002, + GLFW_CURSOR_DISABLED = 0x00034003, + GLFW_ARROW_CURSOR = 0x00036001, + GLFW_IBEAM_CURSOR = 0x00036002, + GLFW_CROSSHAIR_CURSOR = 0x00036003, + GLFW_HAND_CURSOR = 0x00036004, + GLFW_HRESIZE_CURSOR = 0x00036005, + GLFW_VRESIZE_CURSOR = 0x00036006 + }; + + typedef struct { + int width; + int height; + unsigned char* pixels; + } GLFWimage; + + typedef struct GLFWcursor GLFWcursor; + typedef struct GLFWwindow GLFWwindow; + typedef void(*GLFWmousebuttonfun)(GLFWwindow*, int, int, int); + typedef void(*GLFWcursorposfun)(GLFWwindow*, double, double); + typedef void(*GLFWscrollfun)(GLFWwindow*, double, double); + + GLFWwindow* os_get_glfw_window(void); + int glfwGetInputMode(GLFWwindow* window, int mode); + void glfwSetInputMode(GLFWwindow* window, int mode, int value); + void glfwGetCursorPos(GLFWwindow* window, double* x, double* y); + void glfwSetCursorPos(GLFWwindow* window, double x, double y); + GLFWcursor* glfwCreateCursor(const GLFWimage* image, int xhot, int yhot); + GLFWcursor* glfwCreateStandardCursor(int kind); + void glfwSetCursor(GLFWwindow* window, GLFWcursor* cursor); + int glfwGetMouseButton(GLFWwindow* window, int button); + void glfwGetWindowSize(GLFWwindow* window, int* width, int* height); + void glfwDestroyCursor(GLFWcursor* cursor); + GLFWmousebuttonfun glfwSetMouseButtonCallback(GLFWwindow* window, GLFWmousebuttonfun callback); + GLFWcursorposfun glfwSetCursorPosCallback(GLFWwindow* window, GLFWcursorposfun callback); + GLFWcursorposfun glfwSetScrollCallback(GLFWwindow* window, GLFWscrollfun callback); +]] + +local window = ffi.C.os_get_glfw_window() + +local mouse = {} + +-- LÖVR uses framebuffer scale for everything, but glfw uses window scale for events. +-- It is necessary to convert between the two at all boundaries. +function mouse.getScale() + local x, _ = ffi.new('int[1]'), ffi.new('int[1]') + C.glfwGetWindowSize(window, x, _) + return lovr.system.getWindowWidth() / x[0] +end + +function mouse.getX() + local x = ffi.new('double[1]') + C.glfwGetCursorPos(window, x, nil) + return x[0] * mouse.getScale() +end + +function mouse.getY() + local y = ffi.new('double[1]') + C.glfwGetCursorPos(window, nil, y) + return y[0] * mouse.getScale() +end + +function mouse.getPosition() + local x, y = ffi.new('double[1]'), ffi.new('double[1]') + local scale = mouse.getScale() + C.glfwGetCursorPos(window, x, y) + return x[0] * scale, y[0] * scale +end + +function mouse.setX(x) + local y = mouse.getY() + local scale = mouse.getScale() + C.glfwSetCursorPos(window, x / scale, y / scale) +end + +function mouse.setY(y) + local x = mouse.getX() + local scale = mouse.getScale() + C.glfwSetCursorPos(window, x / scale, y / scale) +end + +function mouse.setPosition(x, y) + local scale = mouse.getScale() + C.glfwSetCursorPos(window, x / scale, y / scale) +end + +function mouse.isDown(button, ...) + if not button then return false end + return C.glfwGetMouseButton(window, button - 1) > 0 or mouse.isDown(...) +end + +function mouse.getRelativeMode() + return C.glfwGetInputMode(window, C.GLFW_CURSOR) == C.GLFW_CURSOR_DISABLED +end + +function mouse.setRelativeMode(enable) + C.glfwSetInputMode(window, C.GLFW_CURSOR, enable and C.GLFW_CURSOR_DISABLED or C.GLFW_CURSOR_NORMAL) +end + +function mouse.newCursor(source, hotx, hoty) + if type(source) == 'string' or tostring(source) == 'Blob' then + source = lovr.data.newImage(source) + else + assert(tostring(source) == 'Image', 'Bad argument #1 to newCursor (Image expected)') + end + local image = ffi.new('GLFWimage', source:getWidth(), source:getHeight(), source:getPointer()) + return ffi.gc(C.glfwCreateCursor(image, hotx or 0, hoty or 0), C.glfwDestroyCursor) +end + +function mouse.getSystemCursor(kind) + local kinds = { + arrow = C.GLFW_ARROW_CURSOR, + ibeam = C.GLFW_IBEAM_CURSOR, + crosshair = C.GLFW_CROSSHAIR_CURSOR, + hand = C.GLFW_HAND_CURSOR, + sizewe = C.GLFW_HRESIZE_CURSOR, + sizens = C.GLFW_VRESIZE_CURSOR + } + assert(kinds[kind], string.format('Unknown cursor %q', tostring(kind))) + return ffi.gc(C.glfwCreateStandardCursor(kinds[kind]), C.glfwDestroyCursor) +end + +function mouse.setCursor(cursor) + C.glfwSetCursor(window, cursor) +end + +C.glfwSetMouseButtonCallback(window, function(target, button, action, mods) + if target == window then + local x, y = mouse.getPosition() + lovr.event.push(action > 0 and 'mousepressed' or 'mousereleased', x, y, button + 1, false) + end +end) + +local px, py = mouse.getPosition() +C.glfwSetCursorPosCallback(window, function(target, x, y) + if target == window then + local scale = mouse.getScale() + x = x * scale + y = y * scale + lovr.event.push('mousemoved', x, y, x - px, y - py, false) + px, py = x, y + end +end) + +C.glfwSetScrollCallback(window, function(target, x, y) + if target == window then + local scale = mouse.getScale() + lovr.event.push('wheelmoved', x * scale, y * scale) + end +end) + +return mouse diff --git a/src/lovr/main.lua b/src/lovr/main.lua new file mode 100644 index 0000000..2a745e7 --- /dev/null +++ b/src/lovr/main.lua @@ -0,0 +1,162 @@ +if os.getenv("LOCAL_LUA_DEBUGGER_VSCODE") == "1" then + require "lldebugger".start() +end + +local util = require "util" +local launch = require "launch" + +local iui = require "lib.iui" +local backend = require "lib.lovr-iui" + +-- modify for nested lovr path for api +iui.resourcePath = "lovr/" .. iui.resourcePath +backend.resourcePath = "lovr/" .. backend.resourcePath +api.iui = iui +api.backend = backend +local ecs = api.ecs + +--- @type Texture +local envTex + +--- @type LovrIUIWorldWindow +local mainWindow + +function lovr.load() + if launch.mode == "desktop" then + backend.mouse = require "lib.lovr-mouse" + + lovr.system.setKeyRepeat(true) + end + + iui.load(backend) + + api.exec( api.ext, "load") + + if iui.idiom == "vr" then + lovr.headset.setPassthrough("opaque") + + mainWindow = backend.worldWindow.new() + api.mainWindow = mainWindow + end +end + +function lovr.update(dt) + if lovr.system.isKeyDown("escape") then + lovr.event.quit() + end + + ecs.update( ecs.world, pass, ecs.filter('updatesystem') ) + + iui.beginFrame(dt) + + if iui.idiom == "desktop" then + -- In desktop mode, we use IUI's standard window API to fill the screen. + iui.beginWindow(lovr.system.getWindowDimensions()) + + api.exec( api.ext, "update", dt) + + iui.endWindow() + + elseif iui.idiom == "vr" then + -- In VR mode, we have access to the backend's `LovrIUIWorldWindow` + -- class, which handles windowing in world-space. + if mainWindow:beginFrame() then + iui.beginWindow(mainWindow.w, mainWindow.h) + + api.exec( api.ext, "update", dt) + + iui.endWindow() + + mainWindow:endFrame() + end + end + + iui.endFrame() +end + +function lovr.draw(pass) + if iui.idiom == "desktop" then + backend.graphics.pass = pass + + iui.draw() + end + if api.iui.idiom == "vr" then + pass:setClear(0.5, 0.5, 0.5) + end + api.exec(api.ext, "draw",pass) + ecs.update( ecs.world, pass, ecs.filter('drawsystem') ) + if api.mainWindow then + api.mainWindow:draw(pass) + end + return false +end + +function lovr.recenter() + if iui.idiom == "vr" then + mainWindow:recenter() + end +end + +if launch.mode == "desktop" then + function lovr.mousemoved(x, y, dx, dy) + backend.mousemoved(x, y, dx, dy) + end + + function lovr.mousepressed(x, y, button) + backend.mousepressed(x, y, button) + end + + function lovr.mousereleased(x, y, button) + backend.mousereleased(x, y, button) + end + + function lovr.wheelmoved(x, y) + backend.wheelmoved(x, y) + end + + function lovr.keypressed(key, scancode, isRepeat) + backend.keypressed(key, scancode, isRepeat) + end + + function lovr.keyreleased(key, scancode) + backend.keyreleased(key, scancode) + end + + function lovr.textinput(text) + backend.textinput(text) + end +end + +local initECS = function(ecs) + ecs.world = ecs.world() + + -- lovr.draw => ecs.drawsystem (render-logic thread) + local renderer = ecs.processingSystem() + renderer.filter = ecs.requireAny('model') -- add more types when needed + renderer.drawsystem = true + function renderer:process(obj, pass) + if obj['model'] ~= nil then + pass:setMaterial() + pass:setCullMode('none') + pass:draw( + obj['model'], + obj['x'] or 0, + obj['y'] or 0, + obj['z'] or 0, + 1, 0, 1, 0, 0, 1) + end + end + ecs.addSystem( ecs.world, renderer ) + + -- lovr.update => ecs updatesystem (game-logic thread) + local update = ecs.processingSystem() + update.updatesystem = true + update.filter = ecs.requireAll('update') -- *TODO* might need filter later + function update:process(obj, dt) + print_r(obj['data']) + end + ecs.addSystem( ecs.world, update ) + +end + +initECS(api.ecs) diff --git a/src/lovr/sample/.gitignore b/src/lovr/sample/.gitignore new file mode 100644 index 0000000..94f1119 --- /dev/null +++ b/src/lovr/sample/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.vscode diff --git a/src/lovr/sample/LICENSE b/src/lovr/sample/LICENSE new file mode 100644 index 0000000..faf4102 --- /dev/null +++ b/src/lovr/sample/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Donald Hays + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lovr/sample/README.md b/src/lovr/sample/README.md new file mode 100644 index 0000000..46bf34f --- /dev/null +++ b/src/lovr/sample/README.md @@ -0,0 +1,13 @@ +# IUI-SAMPLE + +A sample project for the [IUI](https://github.com/DonaldHays/iui) immediate mode +GUI library. + +This sample shows many APIs and patterns for IUI, but is not a standalone +project on its own. It can be used as a basis for sample projects that are tied +to specific engines. + +# Projects That Use This Sample + +- [iui-sample-love](https://github.com/DonaldHays/iui-sample-love): LÖVE +- [iui-sample-lovr](https://github.com/DonaldHays/iui-sample-lovr): LÖVR diff --git a/src/lovr/sample/assets/game-sunset.png b/src/lovr/sample/assets/game-sunset.png new file mode 100644 index 0000000..b1b6741 Binary files /dev/null and b/src/lovr/sample/assets/game-sunset.png differ diff --git a/src/lovr/sample/assets/nine-slice-frame.png b/src/lovr/sample/assets/nine-slice-frame.png new file mode 100644 index 0000000..df15b7b Binary files /dev/null and b/src/lovr/sample/assets/nine-slice-frame.png differ diff --git a/src/lovr/sample/assets/nine-slice-interior.png b/src/lovr/sample/assets/nine-slice-interior.png new file mode 100644 index 0000000..438a1be Binary files /dev/null and b/src/lovr/sample/assets/nine-slice-interior.png differ diff --git a/src/lovr/sample/assets/smile-bg.png b/src/lovr/sample/assets/smile-bg.png new file mode 100644 index 0000000..98c22fa Binary files /dev/null and b/src/lovr/sample/assets/smile-bg.png differ diff --git a/src/lovr/sample/assets/smile-fg.png b/src/lovr/sample/assets/smile-fg.png new file mode 100644 index 0000000..0f0c0fc Binary files /dev/null and b/src/lovr/sample/assets/smile-fg.png differ diff --git a/src/lovr/sample/assets/ui-box-slice.png b/src/lovr/sample/assets/ui-box-slice.png new file mode 100644 index 0000000..f78f979 Binary files /dev/null and b/src/lovr/sample/assets/ui-box-slice.png differ diff --git a/src/lovr/sample/features/disabled-tab/disabled-tab.lua b/src/lovr/sample/features/disabled-tab/disabled-tab.lua new file mode 100644 index 0000000..30436a9 --- /dev/null +++ b/src/lovr/sample/features/disabled-tab/disabled-tab.lua @@ -0,0 +1,38 @@ +local iui = require "lib.iui" + +local function tabDisabled() + -- This function just shows what a lot of disabled controls look like. + + local x, y, w, h = iui.layout.getPanelBounds() + + iui.panelBackground() + + -- To disable controls, surround them in a pair of + -- `beginDisabled`/`endDisabled` function calls. IUI maintains a disabled + -- count internally, so the pairs can be nested. + iui.beginDisabled() + + -- For aesthetic purposes, I create a centered 300px panel so the controls + -- don't fill the width of the screen. + local panelW = math.min(300, w) + local panelX = math.floor((w - panelW) / 2) + iui.layout.beginPanel(x + panelX, y, panelW, h) + + iui.label("Disabled View") + iui.checkbox("Disabled Checkbox", true) + iui.checkbox("Disabled Checkbox 2", false) + iui.radio("Disabled Radio", 1, 1) + iui.radio("Disabled Radio 2", 1, 2) + iui.button("Disabled Button") + iui.slider("Disabled Slider", 0.5, 0, 1) + iui.progress(0.5) + iui.textField( + "Disabled Text Field", "Disabled Text Field" + ) + + iui.layout.endPanel() + + iui.endDisabled() +end + +return tabDisabled diff --git a/src/lovr/sample/features/image-tab/models/state.lua b/src/lovr/sample/features/image-tab/models/state.lua new file mode 100644 index 0000000..eeac770 --- /dev/null +++ b/src/lovr/sample/features/image-tab/models/state.lua @@ -0,0 +1,50 @@ +--- @class SimpleImageState +--- @field filter IUIImageFilter +--- @field fillMode IUIImageMode +--- @field clip boolean + +--- @class NineSliceImageState +--- @field filter IUIImageFilter +--- @field width number +--- @field height number + +--- @class MSDFImageState +--- @field fillMode IUIImageMode +--- @field clip boolean + +--- @class NineSliceMSDFImageState +--- @field width number +--- @field height number + +--- @class ImageTabState +--- @field simple SimpleImageState +--- @field nineSlice NineSliceImageState +--- @field msdf MSDFImageState +--- @field nineSliceMSDF NineSliceMSDFImageState +local ImageTabState = {} + +function ImageTabState.new() + --- @type ImageTabState + return { + simple = { + filter = "linear", + fillMode = "aspectFit", + clip = true, + }, + nineSlice = { + filter = "smooth", + width = 300, + height = 60, + }, + msdf = { + fillMode = "aspectFit", + clip = true, + }, + nineSliceMSDF = { + width = 300, + height = 100, + } + } +end + +return ImageTabState diff --git a/src/lovr/sample/features/image-tab/models/window-state.lua b/src/lovr/sample/features/image-tab/models/window-state.lua new file mode 100644 index 0000000..a82adab --- /dev/null +++ b/src/lovr/sample/features/image-tab/models/window-state.lua @@ -0,0 +1,35 @@ +--- @alias ImageTabImage "simple" | "9slice" | "msdf" | "9slice-msdf" + +--- @class ImageContentWindowState +--- @field splitValue number + +--- @class ImageTabWindowState +--- @field leftSplitValue number +--- @field selection ImageTabImage +--- @field simple ImageContentWindowState +--- @field nineSlice ImageContentWindowState +--- @field msdf ImageContentWindowState +--- @field nineSliceMSDF ImageContentWindowState +local ImageTabWindowState = {} + +function ImageTabWindowState.new() + --- @type ImageTabWindowState + return { + leftSplitValue = 200, + selection = "simple", + simple = { + splitValue = 300 + }, + nineSlice = { + splitValue = 300 + }, + msdf = { + splitValue = 300 + }, + nineSliceMSDF = { + splitValue = 300 + } + } +end + +return ImageTabWindowState diff --git a/src/lovr/sample/features/image-tab/msdf.lua b/src/lovr/sample/features/image-tab/msdf.lua new file mode 100644 index 0000000..0419562 --- /dev/null +++ b/src/lovr/sample/features/image-tab/msdf.lua @@ -0,0 +1,65 @@ +local iui = require "lib.iui" + +local function content() + local appState = iui.style["appState"] --- @type SampleAppState + local state = appState.imageTab.msdf + + local assets = iui.style["assets"] --- @type SampleAssets + + iui.layout.fillPanel() + + iui.style.push() + iui.style["imageMode"] = state.fillMode + iui.style["imageClip"] = state.clip + + iui.msdfLayeredImage(assets.smileMSDFLayeredImage) + + iui.style.pop() +end + +local function inspector() + local appState = iui.style["appState"] --- @type SampleAppState + local state = appState.imageTab.msdf + + iui.label("Fill Mode") + state.fillMode = iui.radio( + "Fill", state.fillMode, "fill" + ) + + state.fillMode = iui.radio( + "Aspect Fit", state.fillMode, "aspectFit" + ) + + state.fillMode = iui.radio( + "Aspect Fill", state.fillMode, "aspectFill" + ) + + state.fillMode = iui.radio( + "Center", state.fillMode, "center" + ) + + iui.divider() + + state.clip = iui.checkbox("Clip to Bounds", state.clip) +end + +local function msdf() + local windowState = iui.style["windowState"] --- @type SampleWindowState + + local winState = windowState.imageTab.msdf + + iui.style.push() + iui.style["splitSide"] = "max" + + winState.splitValue = iui.splitView( + "imageSplit", "horiz", winState.splitValue + ) + content() + iui.splitViewDivider() + inspector() + iui.endSplitView() + + iui.style.pop() +end + +return msdf diff --git a/src/lovr/sample/features/image-tab/nine-slice-msdf.lua b/src/lovr/sample/features/image-tab/nine-slice-msdf.lua new file mode 100644 index 0000000..421276c --- /dev/null +++ b/src/lovr/sample/features/image-tab/nine-slice-msdf.lua @@ -0,0 +1,66 @@ +local iui = require "lib.iui" + +local round = iui.utils.round + +local function content() + local appState = iui.style["appState"] --- @type SampleAppState + local state = appState.imageTab.nineSliceMSDF + + local assets = iui.style["assets"] --- @type SampleAssets + + local x, y, w, h = iui.layout.getPanelBounds() + + iui.draw.pushClip(x, y, w, h) + + local width, height = round(state.width), round(state.height) + + x = x + round((w - width) / 2) + y = y + round((h - height) / 2) + + iui.layout.beginPanel(x, y, width, height, 0) + iui.layout.fillPanel() + + iui.msdfLayeredImage9Slice(assets.nineSliceMSDFLayeredImage) + + iui.layout.endPanel() + iui.draw.popClip() +end + +local function inspector() + local appState = iui.style["appState"] --- @type SampleAppState + local state = appState.imageTab.nineSliceMSDF + + iui.label("Size") + + iui.layout.beginMixedRow { + { kind = "fixed", size = 50 }, + { kind = "dynamic", size = 1 }, + } + + iui.label("Width") + state.width = iui.slider("Width", state.width, 100, 400) + + iui.label("Height") + state.height = iui.slider("Height", state.height, 64, 200) +end + +local function nineSliceMSDF() + local windowState = iui.style["windowState"] --- @type SampleWindowState + + local winState = windowState.imageTab.nineSliceMSDF + + iui.style.push() + iui.style["splitSide"] = "max" + + winState.splitValue = iui.splitView( + "imageSplit", "horiz", winState.splitValue + ) + content() + iui.splitViewDivider() + inspector() + iui.endSplitView() + + iui.style.pop() +end + +return nineSliceMSDF diff --git a/src/lovr/sample/features/image-tab/nine-slice.lua b/src/lovr/sample/features/image-tab/nine-slice.lua new file mode 100644 index 0000000..5483ac8 --- /dev/null +++ b/src/lovr/sample/features/image-tab/nine-slice.lua @@ -0,0 +1,77 @@ +local iui = require "lib.iui" + +local round = iui.utils.round + +local function content() + local appState = iui.style["appState"] --- @type SampleAppState + local state = appState.imageTab.nineSlice + + local assets = iui.style["assets"] --- @type SampleAssets + + local x, y, w, h = iui.layout.getPanelBounds() + + iui.draw.pushClip(x, y, w, h) + + local width, height = round(state.width), round(state.height) + + x = x + round((w - width) / 2) + y = y + round((h - height) / 2) + + iui.layout.beginPanel(x, y, width, height, 0) + iui.layout.fillPanel() + + iui.style.push() + iui.style["imageFilter"] = state.filter + + iui.image9Slice(assets.nineSliceImage) + + iui.style.pop() + iui.layout.endPanel() + iui.draw.popClip() +end + +local function inspector() + local appState = iui.style["appState"] --- @type SampleAppState + local state = appState.imageTab.nineSlice + + iui.label("Filter") + state.filter = iui.radio("Nearest", state.filter, "nearest") + state.filter = iui.radio("Smooth", state.filter, "smooth") + state.filter = iui.radio("Linear", state.filter, "linear") + + iui.divider() + + iui.label("Size") + + iui.layout.beginMixedRow { + { kind = "fixed", size = 50 }, + { kind = "dynamic", size = 1 }, + } + + iui.label("Width") + state.width = iui.slider("Width", state.width, 100, 400) + + iui.label("Height") + state.height = iui.slider("Height", state.height, 20, 200) +end + +local function nineSlice() + local windowState = iui.style["windowState"] --- @type SampleWindowState + + local winState = windowState.imageTab.nineSlice + + iui.style.push() + iui.style["splitSide"] = "max" + + winState.splitValue = iui.splitView( + "imageSplit", "horiz", winState.splitValue + ) + content() + iui.splitViewDivider() + inspector() + iui.endSplitView() + + iui.style.pop() +end + +return nineSlice diff --git a/src/lovr/sample/features/image-tab/simple.lua b/src/lovr/sample/features/image-tab/simple.lua new file mode 100644 index 0000000..c37ec86 --- /dev/null +++ b/src/lovr/sample/features/image-tab/simple.lua @@ -0,0 +1,77 @@ +local iui = require "lib.iui" + +local function content() + local appState = iui.style["appState"] --- @type SampleAppState + local state = appState.imageTab.simple + + local assets = iui.style["assets"] --- @type SampleAssets + + iui.layout.fillPanel() + + local bx, by, bw, bh = iui.layout.getBounds() + iui.colors.sysGray0:set() + iui.graphics.rectangle(bx, by, bw, bh) + + iui.style.push() + iui.style["imageFilter"] = state.filter + iui.style["imageMode"] = state.fillMode + iui.style["imageClip"] = state.clip + + iui.image(assets.gameSunsetImage) + + iui.style.pop() +end + +local function inspector() + local appState = iui.style["appState"] --- @type SampleAppState + local state = appState.imageTab.simple + + iui.label("Filter") + state.filter = iui.radio("Nearest", state.filter, "nearest") + state.filter = iui.radio("Smooth", state.filter, "smooth") + state.filter = iui.radio("Linear", state.filter, "linear") + + iui.divider() + + iui.label("Fill Mode") + state.fillMode = iui.radio( + "Fill", state.fillMode, "fill" + ) + + state.fillMode = iui.radio( + "Aspect Fit", state.fillMode, "aspectFit" + ) + + state.fillMode = iui.radio( + "Aspect Fill", state.fillMode, "aspectFill" + ) + + state.fillMode = iui.radio( + "Center", state.fillMode, "center" + ) + + iui.divider() + + state.clip = iui.checkbox("Clip to Bounds", state.clip) +end + +local function simple() + local windowState = iui.style["windowState"] --- @type SampleWindowState + + local winState = windowState.imageTab.simple + + iui.style.push() + iui.style["splitSide"] = "max" + + winState.splitValue = iui.splitView( + "imageSplit", "horiz", winState.splitValue + ) + content() + iui.splitViewDivider() + inspector() + iui.endSplitView() + + iui.style.pop() +end + +return simple diff --git a/src/lovr/sample/features/image-tab/tab-image.lua b/src/lovr/sample/features/image-tab/tab-image.lua new file mode 100644 index 0000000..58a61ff --- /dev/null +++ b/src/lovr/sample/features/image-tab/tab-image.lua @@ -0,0 +1,58 @@ +local iui = require "lib.iui" + +local simple = require "sample.features.image-tab.simple" +local nineSlice = require "sample.features.image-tab.nine-slice" +local msdf = require "sample.features.image-tab.msdf" +local nineSliceMSDF = require "sample.features.image-tab.nine-slice-msdf" + +local function tabImage() + local windowState = iui.style["windowState"] --- @type SampleWindowState + local tabWinState = windowState.imageTab + + iui.style.push() + iui.style["splitMinEdge"] = 200 + iui.style["splitMaxEdge"] = 200 + iui.style["splitSide"] = "min" + + tabWinState.leftSplitValue = iui.splitView( + "imageLeftSplit", + "horiz", + tabWinState.leftSplitValue + ) + + iui.label("Image") + + tabWinState.selection = iui.radio( + "Simple", tabWinState.selection, "simple" + ) + + tabWinState.selection = iui.radio( + "9-Slice", tabWinState.selection, "9slice" + ) + + tabWinState.selection = iui.radio( + "MSDF", tabWinState.selection, "msdf" + ) + + tabWinState.selection = iui.radio( + "9-Slice MSDF", tabWinState.selection, "9slice-msdf" + ) + + iui.splitViewDivider() + + if tabWinState.selection == "simple" then + simple() + elseif tabWinState.selection == "9slice" then + nineSlice() + elseif tabWinState.selection == "msdf" then + msdf() + elseif tabWinState.selection == "9slice-msdf" then + nineSliceMSDF() + end + + iui.endSplitView() + + iui.style.pop() +end + +return tabImage diff --git a/src/lovr/sample/features/main-tab/main-tab.lua b/src/lovr/sample/features/main-tab/main-tab.lua new file mode 100644 index 0000000..1321450 --- /dev/null +++ b/src/lovr/sample/features/main-tab/main-tab.lua @@ -0,0 +1,38 @@ +local iui = require "lib.iui" + +local primaryPane = require "sample.features.main-tab.primary-pane" +local secondaryPane = require "sample.features.main-tab.secondary-pane" + +local function tabSplit() + --- @type MainTabWindowState + local windowState = iui.style["windowState"].mainTab + + -- Widgets can consult the `style` table for additional customization beyond + -- their arguments. In this case, the minimum and maximum split view resize + -- limits can be customized. + + -- The `style` table also implements a scope stack paradigm. You can push a + -- scope, customize the style, and then pop the scope later. Any + -- customizations you make within a scope will be reset after you pop it. + -- Scopes inherit all the properties from their ancestor scopes. + + iui.style.push() + + iui.style["splitMinEdge"] = 100 + iui.style["splitMaxEdge"] = 320 + + windowState.splitValue = iui.splitView( + "primarySplit", + "horiz", + windowState.splitValue + ) + primaryPane() + iui.splitViewDivider() + secondaryPane() + iui.endSplitView() + + -- Pushing a scope must be balanced with a pop. + iui.style.pop() +end + +return tabSplit diff --git a/src/lovr/sample/features/main-tab/models/state.lua b/src/lovr/sample/features/main-tab/models/state.lua new file mode 100644 index 0000000..7c6b00a --- /dev/null +++ b/src/lovr/sample/features/main-tab/models/state.lua @@ -0,0 +1,25 @@ +--- @alias RadioValue "valueA" | "valueB" | "valueC" + +--- @class MainTabState +--- @field radioValue RadioValue +--- @field labelValue string +--- @field stringValue string +--- @field floatValue number +--- @field checkA boolean +--- @field checkB boolean +local MainTabState = {} + +--- @return MainTabState +function MainTabState.new() + --- @type MainTabState + return { + radioValue = "valueA", + labelValue = "Click a Button!", + stringValue = "Hello", + floatValue = 0.5, + checkA = true, + checkB = false, + } +end + +return MainTabState diff --git a/src/lovr/sample/features/main-tab/models/window-state.lua b/src/lovr/sample/features/main-tab/models/window-state.lua new file mode 100644 index 0000000..2ce9f26 --- /dev/null +++ b/src/lovr/sample/features/main-tab/models/window-state.lua @@ -0,0 +1,22 @@ +local iui = require "lib.iui" + +--- @class MainTabWindowState +--- @field splitValue number +--- @field listManager IUIListManager +--- @field scrollManager IUIScrollManager +local MainTabWindowState = {} + +function MainTabWindowState.new() + local listManager = iui.newListManager() + + listManager.allowsMultipleSelection = true + + --- @type MainTabWindowState + return { + splitValue = 200, + listManager = listManager, + scrollManager = iui.newScrollManager(), + } +end + +return MainTabWindowState diff --git a/src/lovr/sample/features/main-tab/primary-pane.lua b/src/lovr/sample/features/main-tab/primary-pane.lua new file mode 100644 index 0000000..96dbda7 --- /dev/null +++ b/src/lovr/sample/features/main-tab/primary-pane.lua @@ -0,0 +1,47 @@ +local iui = require "lib.iui" + +local function splitPrimaryPane() + -- The app state was injected into the style table earlier, so we can + -- retrieve it without needing to take it as a function parameter. + + --- @type MainTabState + local state = iui.style["appState"].mainTab + + --- @type MainTabWindowState + local windowState = iui.style["windowState"].mainTab + + -- Check boxes take their current value, and return their new value. + state.checkA = iui.checkbox("Check A", state.checkA) + state.checkB = iui.checkbox("Check B", state.checkB) + + iui.divider() + + -- Radio buttons take the current value and their represented value, and + -- return their new value. + state.radioValue = iui.radio("Radio A", state.radioValue, "valueA") + state.radioValue = iui.radio("Radio B", state.radioValue, "valueB") + state.radioValue = iui.radio("Radio C", state.radioValue, "valueC") + + iui.divider() + if iui.button("Jump to #50") then + windowState.listManager:scrollToIndex(50) + end + + -- The `fillPanel` API is handy when you want something to fill the rest of + -- the height of the current panel. + iui.layout.fillPanel() + + -- Lists use the style's spacing between rows. This doesn't look as good + -- when the list supports selection, so let's zero out the spacing. + iui.style.push() + iui.style["spacing"] = 0 + + for index in iui.listView("List", 100, nil, windowState.listManager) do + iui.label("Item #" .. index) + end + iui.endListView() + + iui.style.pop() +end + +return splitPrimaryPane diff --git a/src/lovr/sample/features/main-tab/secondary-pane.lua b/src/lovr/sample/features/main-tab/secondary-pane.lua new file mode 100644 index 0000000..8c3ab06 --- /dev/null +++ b/src/lovr/sample/features/main-tab/secondary-pane.lua @@ -0,0 +1,108 @@ +local iui = require "lib.iui" + +--- @param value number +--- @return number newValue +local function labeledSliderSample(value) + -- This function behaves like a custom widget, but simply composes existing + -- widgets together. + + -- We're going to create a temporary panel within the bounds of the current + -- widget, and place a slider and label within. If you drag the split bar in + -- the sample, you'll see that they move with each other as a unit. + + local sx, sy, sw, sh = iui.layout.getBounds() + iui.layout.beginPanel(sx, sy, sw, sh, 0) + + -- This is one of the more advanced layout modes. We specify that we'll have + -- two columns. The second will be fixed at 50 pixels. The first will scale + -- dynamically to fill the remaining available space, after the fixed + -- columns are accounted for. The `size` value for the dynamic column is a + -- relative scale factor. Since there's only one dynamic column, it doesn't + -- do anything. But if, for example, there were two dynamic columns, one + -- with a `size` of `2` and a second with a `size` of `1`, the first column + -- would be allotted twice as much space as the other. + iui.layout.beginMixedRow { + { kind = "dynamic", size = 1 }, + { kind = "fixed", size = 50 } + } + + value = iui.slider("Slide Me", value, 0, 1) + iui.label(tostring(iui.utils.round(value * 1000) / 1000)) + + -- Any time you begin a custom panel, it must be balanced with an + -- `endPanel`. + iui.layout.endPanel() + + return value +end + +local function splitSecondaryPane() + --- @type MainTabState + local state = iui.style["appState"].mainTab + + --- @type MainTabWindowState + local windowState = iui.style["windowState"].mainTab + + -- Do layout using fixed-width columns 250 pixels wide. The layout manager + -- will fit as many columns of that width within the panel as it can. + iui.layout.beginFixedRow(250) + + if iui.button("Click Me") then + state.labelValue = "Clicked the first button!" + end + + iui.label(state.labelValue) + + if iui.button("Then Click Me") then + state.labelValue = "Clicked the second button!" + end + + -- Call `labeledSliderSample` as though it were a widget. + state.floatValue = labeledSliderSample(state.floatValue) + + iui.progress(state.floatValue) + + state.stringValue = iui.textField("Some Text", state.stringValue) + + iui.layout.beginFixedRow(300, 210) + + -- We pass a custom `IUIScrollManager` instance to this scroll view. Doing + -- so is optional. If you omit a scroll manager, the widget will create and + -- manage one itself. However, a widget-managed scroll manager will have a + -- lifecycle tied to the widget. If the widget doesn't appear in a frame + -- (such as if you navigate to another tab in this sample app), then the + -- scroll manager will be destroyed, and a new scroll manager will be + -- created when the widget next appears, which will appear to the user as + -- though the scroll position reset. By managing the scroll manager + -- externally, and passing it in, we can give it a lifecycle longer than the + -- widget. + iui.scrollView("scroll", windowState.scrollManager) + iui.label("Hello, World 1") + iui.label("Hello, World 2") + iui.label("Hello, World 3") + iui.label("Hello, World 4") + + if iui.button("Clip A") then + print("Clip A") + end + + if iui.button("Clip B") then + print("Clip B") + end + + if iui.button("Clip C") then + print("Clip C") + end + + for _ = 1, 10 do + iui.label("Some more text") + end + iui.endScrollView() + + -- Creating a single-column dynamic layout is an easy way to have a divider + -- fill the width of its panel. + iui.layout.beginDynamicRow() + iui.divider() +end + +return splitSecondaryPane diff --git a/src/lovr/sample/init.lua b/src/lovr/sample/init.lua new file mode 100644 index 0000000..dbe578c --- /dev/null +++ b/src/lovr/sample/init.lua @@ -0,0 +1,47 @@ +local iui = require "lib.iui" + +local SampleAppState = require "sample.models.app-state" +local SampleWindowState = require "sample.models.window-state" + +local sampleMenuBar = require "sample.menu-bar" +local sampleTabBar = require "sample.tab-bar" + +--- @class SampleAssets +--- @field gameSunsetImage any +--- @field nineSliceImage IUIImage9Slice +--- @field smileMSDFLayeredImage IUILayeredImage +--- @field nineSliceMSDFLayeredImage IUILayeredImage + +-- For this sample, I created two "model" object types: an app state and a +-- window state. The app state contains the values for various controls, while +-- the window state contains presentation information for the window, like which +-- tab is selected. There's no need to model your work the same way, nor even +-- use dependency injected-objects like this sample does, this is just one +-- example of how you could create a scalable architecture. + +local state = SampleAppState.new() +local windowState = SampleWindowState.new() + +--- @class Sample +--- @field assets SampleAssets +local sample = {} + +--- @param assets SampleAssets +function sample.load(assets) + sample.assets = assets +end + +function sample.main() + -- We can use the `style` table for dependency injection. Here, we inject + -- the app and window states in our main function, along with some assets, + -- making them available from anywhere in the UI. + iui.style["appState"] = state + iui.style["windowState"] = windowState + iui.style["assets"] = sample.assets + + sampleMenuBar( + sampleTabBar + ) +end + +return sample diff --git a/src/lovr/sample/menu-bar.lua b/src/lovr/sample/menu-bar.lua new file mode 100644 index 0000000..3ae0245 --- /dev/null +++ b/src/lovr/sample/menu-bar.lua @@ -0,0 +1,127 @@ +local iui = require "lib.iui" + +-- You'll see many menu items define keyboard shortcuts here. IUI merely +-- displays the shortcuts, it doesn't listen handle them in any way. It's up to +-- you to implement a keyboard shortcut handler. + +local function fileMenu() + if iui.subMenu("File") then + if iui.menuItem("New", "Ctrl+N") then + print("New!") + end + + iui.divider() + + if iui.menuItem("Open...", "Ctrl+O") then + print("Open!") + end + + if iui.subMenu("Open Recent") then + if iui.subMenu("Even More") then + if iui.menuItem("Last File") then + print("All done!") + end + + iui.endSubMenu() + end + + iui.divider() + + if iui.menuItem("File 1") then + print("File 1") + end + + if iui.menuItem("File 2") then + print("File 2") + end + + if iui.menuItem("File 3") then + print("File 3") + end + + iui.endSubMenu() + end + + iui.divider() + + if iui.menuItem("Save", "Ctrl+S") then + print("Save!") + end + if iui.menuItem("Save As...", "Ctrl+Shift+S") then + print("Save As!") + end + + iui.divider() + + if iui.subMenu("Share") then + if iui.menuItem("Share File") then + print("Time to share!") + end + + iui.endSubMenu() + end + + if iui.menuItem("Exit", "Ctrl+Q") then + iui.backend.system.quit() + end + + iui.endSubMenu() + end +end + +local function editMenu() + if iui.subMenu("Edit") then + iui.beginDisabled() + if iui.menuItem("Undo", "Ctrl+Z") then + print("Undo!") + end + if iui.menuItem("Redo", "Ctrl+Y") then + print("Redo!") + end + iui.endDisabled() + + iui.divider() + + if iui.menuItem("Cut", "Ctrl+X") then + print("Cut!") + end + if iui.menuItem("Copy", "Ctrl+C") then + print("Copy!") + end + if iui.menuItem("Paste", "Ctrl+V") then + print("Paste!") + end + + iui.endSubMenu() + end +end + +local function helpMenu() + if iui.subMenu("Help") then + if iui.menuItem("About...") then + print("About") + end + + iui.endSubMenu() + end +end + +--- @param content fun() +local function sampleMenuBar(content) + -- The menu bar widget handles both the bar itself, and setting up the + -- layout for the content area under the bar. We declare the menu items + -- ourselves here, but we take the content function as a parameter. + iui.menuBar() + + fileMenu() + editMenu() + helpMenu() + + iui.menuBarDivider() + + content() + + iui.endMenuBar() +end + +return sampleMenuBar diff --git a/src/lovr/sample/models/app-state.lua b/src/lovr/sample/models/app-state.lua new file mode 100644 index 0000000..095a979 --- /dev/null +++ b/src/lovr/sample/models/app-state.lua @@ -0,0 +1,18 @@ +local MainTabState = require "sample.features.main-tab.models.state" +local ImageTabState = require "sample.features.image-tab.models.state" + +--- @class SampleAppState +--- @field mainTab MainTabState +--- @field imageTab ImageTabState +local SampleAppState = {} + +--- @return SampleAppState +function SampleAppState.new() + --- @type SampleAppState + return { + mainTab = MainTabState.new(), + imageTab = ImageTabState.new(), + } +end + +return SampleAppState diff --git a/src/lovr/sample/models/window-state.lua b/src/lovr/sample/models/window-state.lua new file mode 100644 index 0000000..ae58805 --- /dev/null +++ b/src/lovr/sample/models/window-state.lua @@ -0,0 +1,21 @@ +local MainTabWindowState = require "sample.features.main-tab.models.window-state" +local ImageTabWindowState = require "sample.features.image-tab.models.window-state" + +--- @alias TabValue "tabA" | "tabB" | "tabC" | "tabD" + +--- @class SampleWindowState +--- @field selectedTab TabValue +--- @field mainTab MainTabWindowState +--- @field imageTab ImageTabWindowState +local SampleWindowState = {} + +function SampleWindowState.new() + --- @type SampleWindowState + return { + selectedTab = "tabA", + mainTab = MainTabWindowState.new(), + imageTab = ImageTabWindowState.new(), + } +end + +return SampleWindowState diff --git a/src/lovr/sample/tab-bar.lua b/src/lovr/sample/tab-bar.lua new file mode 100644 index 0000000..a02e3e6 --- /dev/null +++ b/src/lovr/sample/tab-bar.lua @@ -0,0 +1,46 @@ +local iui = require "lib.iui" + +local tabMain = require "sample.features.main-tab.main-tab" +local tabDisabled = require "sample.features.disabled-tab.disabled-tab" +local tabImage = require "sample.features.image-tab.tab-image" + +local function sampleTabBar() + local windowState = iui.style["windowState"] --- @type SampleWindowState + + iui.tabBar() + + windowState.selectedTab = iui.tabItem( + "Split", windowState.selectedTab, "tabA" + ) + + windowState.selectedTab = iui.tabItem( + "Disabled", windowState.selectedTab, "tabB" + ) + + windowState.selectedTab = iui.tabItem( + "Images", windowState.selectedTab, "tabC" + ) + + windowState.selectedTab = iui.tabItem( + "Misc", windowState.selectedTab, "tabD" + ) + + iui.tabBarDivider() + + if windowState.selectedTab == "tabA" then + tabMain() + elseif windowState.selectedTab == "tabB" then + tabDisabled() + elseif windowState.selectedTab == "tabC" then + tabImage() + elseif windowState.selectedTab == "tabD" then + -- This tab is too simple to justify breaking it out into its + -- own file. + iui.panelBackground() + iui.label("This Tab Intentionally Left Blank") + end + + iui.endTabBar() +end + +return sampleTabBar diff --git a/src/main.lua b/src/main.lua new file mode 100644 index 0000000..a0c0da3 --- /dev/null +++ b/src/main.lua @@ -0,0 +1,23 @@ +local util = require("util") + +api = { + json = require("json"), + browser = require("browser"), + url = require("url"), + util = require("util"), + protocol = { + http = require('http') + }, + ecs = require("tiny-ecs"), + ext = {}, -- all extensions loaded from disk at runtime + exec = function(...) util.exec(...) end -- calls function on each table item +} +if lovr ~= nil then + api = util.merge( api, lovr ) + util.loaddir( "ext", api, api.ext ) + util.loaddir( "media", api, api.media ) + require("lovr/main") + api.exec( api.ext, 'init') +end + +api.browser.to("https://coderofsalvation.codeberg.page/xrfragment-haxe/example/assets/example.glb?bar=1&f=2#foo") diff --git a/src/url.lua b/src/url.lua new file mode 100644 index 0000000..9fe8134 --- /dev/null +++ b/src/url.lua @@ -0,0 +1,118 @@ +-- this is a lua adaptation of Godot's URI.gd +-- https://gist.github.com/coderofsalvation/b2b111a2631fbdc8e76d6cab3bea8f17 + +local url = {} + +-- Helper function to split a string by a delimiter +local function split(str, delimiter) + local result = {} + if delimiter == "" then return {str} end + local pattern = string.format("([^%s]+)", delimiter) + for match in string.gmatch(str, pattern) do + table.insert(result, match) + end + return result +end + +-- Helper for guess_type (converts numbers/booleans from strings) +local function guess_type(val) + if tonumber(val) then return tonumber(val) end + if val == "true" then return true end + if val == "false" then return false end + return val +end + +-- Helper to parse query parameters and fragments +local function parseArgs(fragment) + local ARG = {} + if not fragment or fragment == "" then return ARG end + + local items = split(fragment, "&") + for _, item in ipairs(items) do + local key_value = split(item, "=") + if #key_value > 1 then + ARG[key_value[1]] = guess_type(key_value[2]) + elseif #key_value == 1 then + ARG[key_value[1]] = "" + end + end + return ARG +end + +-- Main URL Parser function attached to the module object +function url.parse(url_string) + -- Setup initial URI table structure + local URI = { + domain = "", + fragment = {}, + file = "", + extension = "", + string = url_string, + protocol = "", + path = "", + query = {}, + hash = "", + URN = "", + isLocal = false + } + local remaining = url_string + local protocol = string.match(remaining, "^(%w+://)") -- protocol + if protocol then + URI.protocol = protocol:gsub("://", "") + remaining = remaining:sub(#protocol + 1) + end + local hash = string.match(remaining, "(#.*)$") -- fragment + if hash then + URI.hash = hash + remaining = remaining:sub(1, #remaining - #hash) + end + local query = string.match(remaining, "(%?[^#]+)$") + if query then + URI.query = query + remaining = remaining:sub(1, #remaining - #query) + end + URI.path = remaining + -- Process Path, Domain, and File + if URI.path and URI.path ~= "" then + local pathParts = split(URI.path, "/") + + if #pathParts > 1 then + local firstPart = pathParts[1] + -- Check if the first part looks like a domain (contains '.' or ':') + if string.find(firstPart, "%.") or string.find(firstPart, ":") then + URI.domain = table.remove(pathParts, 1) + end + end + -- Reconstruct path + URI.path = table.concat(pathParts, "/") + -- Extract file if present + if #pathParts > 0 then + local lastPart = pathParts[#pathParts] + if string.find(lastPart, "%.") then + URI.file = lastPart + URI.extension = URI.file:match("%.([^%.]+)$"):upper() + end + end + end + if URI.hash and URI.hash ~= "" then + URI.fragment = parseArgs(URI.hash:sub(2)) -- sub(2) skips the '#' + else + URI.fragment = {} + end + + if URI.query and type(URI.query) == "string" and URI.query ~= "" then + URI.query = parseArgs(URI.query:sub(2)) -- sub(2) skips the '?' + else + URI.query = {} + end + if URI.domain ~= "" then + URI.URN = URI.string:gsub("%?.*", "") + else + URI.URN = "" + end + URI.isLocal = (URI.domain == "") + return URI +end + +-- Return the module table so it can be assigned via require() +return url diff --git a/src/util.lua b/src/util.lua new file mode 100644 index 0000000..da0979c --- /dev/null +++ b/src/util.lua @@ -0,0 +1,132 @@ +local util = { ext = {} } + +util.loaddir = function( path, api, target) + local dir = api.filesystem.getDirectoryItems(path) + foreach( dir, function(k,v) + local ext = path .. '/' .. v .. "/main.lua" + if api.filesystem.isFile(ext) then + print("[i] found extension '" .. v .. "'") + target[ v ] = api.filesystem.load( ext )(api) + end + end) +end + +util.exec = function(obj, fn,a,b,c,d,e,f) + foreach( obj, + when('enabled',true, util.call(fn,a,b,c,d,e,f) ) + ) +end + +function when(k,v,cb) + return function(kk,vv) + if vv[k] == v then cb(kk,vv) end + end +end + +function util.merge(t1,t2) + local t3 = {} + foreach( t1, function(k,v) t3[k] = v end) + foreach( t2, function(k,v) t3[k] = v end) + return t3 +end + +function util.dump(value, indent, seen) + indent = indent or "" + seen = seen or {} + local t = type(value) + if t == "string" then + return string.format("%q", value) + elseif t == "boolean" or t == "number" or t == "nil" then + return tostring(value) + elseif t == "function" or t == "userdata" or t == "thread" then + return string.format("<%s: %s>", t, tostring(value)) + elseif t == "table" then + if seen[value] then + return string.format("", tostring(value)) + end + seen[value] = true + + local result = "{\n" + local next_indent = indent .. " " + local seen_keys = {} + for i, v in ipairs(value) do + seen_keys[i] = true + result = result .. next_indent .. util.dump(v, next_indent, seen) .. ",\n" + end + + for k, v in pairs(value) do + if not seen_keys[k] then + local key_str + if type(k) == "string" and k:match("^[%a_][%w_]*$") then + key_str = k + else + key_str = "[" .. util.dump(k, next_indent, seen) .. "]" + end + + result = result .. next_indent .. key_str .. " = " .. util.dump(v, next_indent, seen) .. ",\n" + end + end + + if result:sub(-2) == ",\n" then + result = result:sub(1, -3) .. "\n" + end + + return result .. indent .. "}" + end +end + +function util.traverse(arr, cb, key) + if key == nil then key = 'children' end + foreach( arr, function(k,child) + if type(child) == 'table' then + cb(child) + if type(child[key]) == 'table' then + if child[key][1] then + util.traverse( child[key], cb, key ) + end + end + end + end) +end + +util.call = function(fn,a,b,c,d,e,f) + return function(k,v) + if v ~= nil and v[fn] then + v[fn](a,b,c,d,e,f) + end + end +end + +function foreach(table, f) + if table ~= nil then + for k, v in pairs(table) do f(k, v) end + end +end + +function when(k,v,cb) + return function(kk,vv) + if vv[k] == v then cb(kk,vv) end + end +end + +function util.merge(t1,t2) + local t3 = {} + foreach( t1, function(k,v) t3[k] = v end) + foreach( t2, function(k,v) t3[k] = v end) + return t3 +end + +function print_r(t) + print( util.dump(t) ) +end + + +util.match = function(list,k,v) + local ret = false + foreach( list, function(K,V) + if k == V then ret = true end + end) + return ret +end + +return util