1st commit

This commit is contained in:
Leon van Kammen 2026-06-06 12:35:31 +02:00
commit 0d8c32a444
124 changed files with 9503 additions and 0 deletions

17
README.md Normal file
View file

@ -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
```

24
src/browser.lua Normal file
View file

@ -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

8
src/conf.lua Normal file
View file

@ -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

37
src/ecs.lua Normal file
View file

@ -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

27
src/ext/gltf/main.lua Normal file
View file

@ -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

View file

@ -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
}

View file

@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

View file

@ -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
}

386
src/lib/json.lua Normal file
View file

@ -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

864
src/lib/tiny-ecs.lua Normal file
View file

@ -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 "<tiny-ecs_World>"
end
}
return tiny

1
src/lovr/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.DS_Store

21
src/lovr/LICENSE Normal file
View file

@ -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.

4
src/lovr/README.md Normal file
View file

@ -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.

BIN
src/lovr/assets/img/env.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

24
src/lovr/conf.lua Normal file
View file

@ -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

23
src/lovr/launch.lua Normal file
View file

@ -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

2
src/lovr/lib/iui/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.DS_Store
.vscode

21
src/lovr/lib/iui/LICENSE Normal file
View file

@ -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.

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -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)
}

View file

@ -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

223
src/lovr/lib/iui/draw.lua Normal file
View file

@ -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

136
src/lovr/lib/iui/id.lua Normal file
View file

@ -0,0 +1,136 @@
local currentPath = (...):match('(.-)[^%./]+$')
--- @class IUILib
local iui = require(currentPath .. "iui")
--- @type table<number, table<string, number>>
local cache = {}
local cacheMetatable = { __mode = "k" }
--- @type number[]
local stack = {}
--- @type IUISet<number>
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

25
src/lovr/lib/iui/init.lua Normal file
View file

@ -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

View file

@ -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

View file

@ -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<number>
--- @field pressed IUISet<number>
--- @field released IUISet<number>
--- @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<string>
--- @field pressed table<string, IUIKeyPressed>
--- @field released IUISet<string>
--- @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

View file

@ -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<string>
--- @field pressed table<string, IUIKeyPressed>
--- @field released IUISet<string>
--- @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]]

View file

@ -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<number>
--- @field pressed IUISet<number>
--- @field released IUISet<number>
--- @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]]

162
src/lovr/lib/iui/iui.lua Normal file
View file

@ -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<IUICursorName, IUICursor>
local cursors = {}
--- @type IUIWindowManager
local windowManager
--- @type IUISet<IUIWindowManager>
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

125
src/lovr/lib/iui/layer.lua Normal file
View file

@ -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

452
src/lovr/lib/iui/layout.lua Normal file
View file

@ -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

54
src/lovr/lib/iui/pool.lua Normal file
View file

@ -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<string, IUIPoolStack>
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

63
src/lovr/lib/iui/set.lua Normal file
View file

@ -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<T>
--- @field private storage table<T, true>
--- @field new fun(): IUISet<T> Returns a new set.
--- @field put fun(self: self, v: T) Inserts `v`, if not already present.
--- @field putAll fun(self: self, s: IUISet<T>) 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

View file

@ -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<number, table>
--- @field currentStates? table<number, 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,9 @@
--- @meta _
--- @alias IUIIdiom "vr" | "desktop"
--- @alias IUIDetail "low" | "high"
--- @class IUIConfig
--- @field idiom? IUIIdiom
--- @field detail? IUIDetail
--- @field dpi? number

151
src/lovr/lib/iui/utils.lua Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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<number>
--- @field rangeStartIndex? number
--- @field rangeEndIndex? number
--- @class IUIListManager: IUIScrollManager
--- @field rowHeight? number
--- @field margin? number
--- @field spacing? number
--- @field selection IUISet<number>
--- @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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

2
src/lovr/lib/lovr-iui/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.DS_Store
.vscode

View file

@ -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.

View file

@ -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
```

View file

@ -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

View file

@ -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

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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

View file

@ -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<Device, TrackedIntersection[]>
local intersections = {}
--- @type table<LovrIUIWorldWindow, InputWindowSession>
local windowSessions = {}
--- @type table<LovrIUIWorldWindow, InputWindowSession>
local newWindowSessions = {}
--- @type table<Device, LovrIUIWorldWindow>
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

View file

@ -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

View file

@ -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.

View file

@ -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

162
src/lovr/main.lua Normal file
View file

@ -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)

2
src/lovr/sample/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.DS_Store
.vscode

21
src/lovr/sample/LICENSE Normal file
View file

@ -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.

13
src/lovr/sample/README.md Normal file
View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Some files were not shown because too many files have changed in this diff Show more