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 |