1st commit
17
README.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
84
src/ext/kitchensink/main.lua
Normal 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
|
||||
}
|
||||
103
src/ext/startupscene/main.lua
Normal 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
|
||||
}
|
||||
BIN
src/ext/startupscene/skybox/Dayback.jpg
Executable file
|
After Width: | Height: | Size: 638 KiB |
BIN
src/ext/startupscene/skybox/Daybottom.jpg
Executable file
|
After Width: | Height: | Size: 310 KiB |
BIN
src/ext/startupscene/skybox/Dayfront.jpg
Executable file
|
After Width: | Height: | Size: 684 KiB |
BIN
src/ext/startupscene/skybox/Dayleft.jpg
Executable file
|
After Width: | Height: | Size: 663 KiB |
BIN
src/ext/startupscene/skybox/Dayright.jpg
Executable file
|
After Width: | Height: | Size: 782 KiB |
BIN
src/ext/startupscene/skybox/Daytop.jpg
Executable file
|
After Width: | Height: | Size: 708 KiB |
BIN
src/ext/startupscene/skybox/ibl.ktx
Normal file
60
src/ext/xrfragments/main.lua
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
.DS_Store
|
||||
21
src/lovr/LICENSE
Normal 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
|
|
@ -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
|
After Width: | Height: | Size: 175 KiB |
24
src/lovr/conf.lua
Normal 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
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
.DS_Store
|
||||
.vscode
|
||||
21
src/lovr/lib/iui/LICENSE
Normal 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.
|
||||
20
src/lovr/lib/iui/README.md
Normal 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
|
||||
BIN
src/lovr/lib/iui/assets/cursor-default_sdf.png
Normal file
|
After Width: | Height: | Size: 599 B |
BIN
src/lovr/lib/iui/assets/cursor-ibeam_sdf.png
Normal file
|
After Width: | Height: | Size: 232 B |
BIN
src/lovr/lib/iui/assets/cursor-inactive_sdf.png
Normal file
|
After Width: | Height: | Size: 405 B |
BIN
src/lovr/lib/iui/assets/cursor-sizens_sdf.png
Normal file
|
After Width: | Height: | Size: 628 B |
BIN
src/lovr/lib/iui/assets/cursor-sizewe_sdf.png
Normal file
|
After Width: | Height: | Size: 617 B |
BIN
src/lovr/lib/iui/assets/glyph-checkmark.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/lovr/lib/iui/assets/glyph-disclosure.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
45
src/lovr/lib/iui/color.lua
Normal 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)
|
||||
}
|
||||
265
src/lovr/lib/iui/draw-queue.lua
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
61
src/lovr/lib/iui/input/init.lua
Normal 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
|
||||
45
src/lovr/lib/iui/input/input.lua
Normal 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
|
||||
79
src/lovr/lib/iui/input/keyboard.lua
Normal 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]]
|
||||
159
src/lovr/lib/iui/input/mouse.lua
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
46
src/lovr/lib/iui/state.lua
Normal 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
|
||||
66
src/lovr/lib/iui/style.lua
Normal 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
|
||||
29
src/lovr/lib/iui/types/backend/backend.t.lua
Normal 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
|
||||
109
src/lovr/lib/iui/types/backend/graphics.t.lua
Normal 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
|
||||
37
src/lovr/lib/iui/types/backend/system.t.lua
Normal 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
|
||||
9
src/lovr/lib/iui/types/config.t.lua
Normal 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
|
|
@ -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
|
||||
95
src/lovr/lib/iui/widgets/button.lua
Normal 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
|
||||
120
src/lovr/lib/iui/widgets/checkbox.lua
Normal 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
|
||||
16
src/lovr/lib/iui/widgets/clip-view.lua
Normal 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
|
||||
27
src/lovr/lib/iui/widgets/divider.lua
Normal 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
|
||||
16
src/lovr/lib/iui/widgets/image-9-slice.lua
Normal 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
|
||||
32
src/lovr/lib/iui/widgets/image.lua
Normal 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
|
||||
26
src/lovr/lib/iui/widgets/init.lua
Normal 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")
|
||||
38
src/lovr/lib/iui/widgets/label.lua
Normal 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
|
||||
409
src/lovr/lib/iui/widgets/list-view.lua
Normal 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
|
||||
159
src/lovr/lib/iui/widgets/menu-bar.lua
Normal 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
|
||||
80
src/lovr/lib/iui/widgets/menu-item.lua
Normal 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
|
||||
18
src/lovr/lib/iui/widgets/msdf-image-9-slice.lua
Normal 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
|
||||
32
src/lovr/lib/iui/widgets/msdf-image.lua
Normal 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
|
||||
17
src/lovr/lib/iui/widgets/msdf-layered-image-9-slice.lua
Normal 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
|
||||
31
src/lovr/lib/iui/widgets/msdf-layered-image.lua
Normal 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
|
||||
14
src/lovr/lib/iui/widgets/panel-background.lua
Normal 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
|
||||
69
src/lovr/lib/iui/widgets/progress.lua
Normal 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
|
||||
114
src/lovr/lib/iui/widgets/radio.lua
Normal 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
|
||||
81
src/lovr/lib/iui/widgets/scroll-bar.lua
Normal 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
|
||||
272
src/lovr/lib/iui/widgets/scroll-view.lua
Normal 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
|
||||
109
src/lovr/lib/iui/widgets/slider.lua
Normal 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
|
||||
167
src/lovr/lib/iui/widgets/split-view.lua
Normal 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
|
||||
221
src/lovr/lib/iui/widgets/sub-menu.lua
Normal 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
|
||||
149
src/lovr/lib/iui/widgets/tab-bar.lua
Normal 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
|
||||
111
src/lovr/lib/iui/widgets/text-field.lua
Normal 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
|
||||
64
src/lovr/lib/iui/window-manager.lua
Normal 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
|
|
@ -0,0 +1,2 @@
|
|||
.DS_Store
|
||||
.vscode
|
||||
21
src/lovr/lib/lovr-iui/LICENSE
Normal 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.
|
||||
136
src/lovr/lib/lovr-iui/README.md
Normal 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
|
||||
```
|
||||
400
src/lovr/lib/lovr-iui/graphics.lua
Normal 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
|
||||
144
src/lovr/lib/lovr-iui/init.lua
Normal 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
|
||||
13
src/lovr/lib/lovr-iui/shaders/ui-clip.glsl
Normal 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;
|
||||
}
|
||||
29
src/lovr/lib/lovr-iui/shaders/ui-image.glsl
Normal 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);
|
||||
}
|
||||
25
src/lovr/lib/lovr-iui/shaders/ui-msdf.glsl
Normal 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);
|
||||
}
|
||||
75
src/lovr/lib/lovr-iui/system.lua
Normal 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
|
||||
309
src/lovr/lib/lovr-iui/vr-input.lua
Normal 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
|
||||
180
src/lovr/lib/lovr-iui/world-window.lua
Normal 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
|
||||
19
src/lovr/lib/lovr-mouse/LICENSE
Normal 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.
|
||||
160
src/lovr/lib/lovr-mouse/init.lua
Normal 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
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
.DS_Store
|
||||
.vscode
|
||||
21
src/lovr/sample/LICENSE
Normal 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
|
|
@ -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
|
||||
BIN
src/lovr/sample/assets/game-sunset.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
src/lovr/sample/assets/nine-slice-frame.png
Normal file
|
After Width: | Height: | Size: 636 B |
BIN
src/lovr/sample/assets/nine-slice-interior.png
Normal file
|
After Width: | Height: | Size: 191 B |