[*] Initial commit.
This commit is contained in:
commit
6dd7691d71
|
@ -0,0 +1,48 @@
|
||||||
|
# ---> Lua
|
||||||
|
# Compiled Lua sources
|
||||||
|
luac.out
|
||||||
|
|
||||||
|
# luarocks build files
|
||||||
|
*.src.rock
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# Object files
|
||||||
|
*.o
|
||||||
|
*.os
|
||||||
|
*.ko
|
||||||
|
*.obj
|
||||||
|
*.elf
|
||||||
|
|
||||||
|
# Precompiled Headers
|
||||||
|
*.gch
|
||||||
|
*.pch
|
||||||
|
|
||||||
|
# Libraries
|
||||||
|
*.lib
|
||||||
|
*.a
|
||||||
|
*.la
|
||||||
|
*.lo
|
||||||
|
*.def
|
||||||
|
*.exp
|
||||||
|
|
||||||
|
# Shared objects (inc. Windows DLLs)
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.so.*
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Executables
|
||||||
|
*.exe
|
||||||
|
*.out
|
||||||
|
*.app
|
||||||
|
*.i*86
|
||||||
|
*.x86_64
|
||||||
|
*.hex
|
||||||
|
|
||||||
|
# ldoc output directory
|
||||||
|
doc/
|
||||||
|
# crush library directory
|
||||||
|
lib/
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
gear = "https://git.doublefourteen.io/lua/gear",
|
||||||
|
moonspeak = "https://git.doublefourteen.io/lua/moonspeak"
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
Copyright (c) 2022 The DoubleFourteen Code Forge
|
||||||
|
|
||||||
|
This software is provided 'as-is', without any express or implied
|
||||||
|
warranty. In no event will the authors be held liable for any damages
|
||||||
|
arising from the use of this software.
|
||||||
|
|
||||||
|
Permission is granted to anyone to use this software for any purpose,
|
||||||
|
including commercial applications, and to alter it and redistribute it
|
||||||
|
freely, subject to the following restrictions:
|
||||||
|
|
||||||
|
1. The origin of this software must not be misrepresented; you must not
|
||||||
|
claim that you wrote the original software. If you use this software
|
||||||
|
in a product, an acknowledgment in the product documentation would be
|
||||||
|
appreciated but is not required.
|
||||||
|
2. Altered source versions must be plainly marked as such, and must not be
|
||||||
|
misrepresented as being the original software.
|
||||||
|
3. This notice may not be removed or altered from any source distribution.
|
|
@ -0,0 +1,29 @@
|
||||||
|
Portions of Yui code are based on SUIT - Simple User Interface Toolkit for LÖVE,
|
||||||
|
by Mathias Richter, available at:
|
||||||
|
https://github.com/vrld/SUIT
|
||||||
|
|
||||||
|
Under the following license:
|
||||||
|
|
||||||
|
> Copyright (c) 2016 Matthias Richter
|
||||||
|
>
|
||||||
|
> 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.
|
||||||
|
>
|
||||||
|
> Except as contained in this notice, the name(s) of the above copyright holders
|
||||||
|
> shall not be used in advertising or otherwise to promote the sale, use or
|
||||||
|
> other dealings in this Software without prior written authorization.
|
||||||
|
>
|
||||||
|
> 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.
|
|
@ -0,0 +1,29 @@
|
||||||
|
Yui - A Declarative UI library for LÖVE
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
**Yui** - Yet another User Interface, is an attempt to ease the process of assembling trivial menu-like GUIs for your game using [LÖVE](https://love2d.org).
|
||||||
|
|
||||||
|
## Why is that?
|
||||||
|
|
||||||
|
Because I'm spending so much time tweaking and customizing existing libraries,
|
||||||
|
I might as well make my own.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
**Yui** depends on:
|
||||||
|
|
||||||
|
* [gear](https://git.doublefourteen.io/lua/gear) for general algorithms.
|
||||||
|
* [moonspeak](https://git.doublefourteen.io/lua/moonspeak) for its localization functionality.
|
||||||
|
|
||||||
|
## Acknowledgement
|
||||||
|
|
||||||
|
Portions of this library's widget rendering code are taken from the
|
||||||
|
Simple User Interface Toolkit (**SUIT**) for LÖVE by Matthias Richter.
|
||||||
|
|
||||||
|
SUIT's source code is available at: [vrld/SUIT](https://github.com/vrld/SUIT).
|
||||||
|
SUIT is licensed under the [MIT license](https://github.com/vrld/suit/blob/master/license.txt).
|
||||||
|
|
||||||
|
Widgets offered by **yui** are also inspired by **SUIT**.
|
||||||
|
|
||||||
|
See [ACKNOWLEDGEMENT](README.ACKNOWLEDGEMENT) for full SUIT license
|
||||||
|
information and copyright notice.
|
|
@ -0,0 +1,59 @@
|
||||||
|
local BASE = (...):gsub('button', '')
|
||||||
|
|
||||||
|
local Widget = require BASE..'widget'
|
||||||
|
local core = require BASE..'core'
|
||||||
|
|
||||||
|
local shadowtext = require 'lib.gear.shadowtext'
|
||||||
|
local T = require('lib.moonspeak').translate
|
||||||
|
|
||||||
|
local Button = setmetatable({}, Widget)
|
||||||
|
Button.__index = Button
|
||||||
|
|
||||||
|
|
||||||
|
function Button.new(args)
|
||||||
|
local self = setmetatable(args, Button)
|
||||||
|
|
||||||
|
self.text = self.text or ""
|
||||||
|
self.align = self.align or 'center'
|
||||||
|
self.valign = self.valign or 'center'
|
||||||
|
self.color = self.color or core.theme.color
|
||||||
|
self.cornerRadius = self.cornerRadius or core.theme.cornerRadius
|
||||||
|
self.active = false
|
||||||
|
if not self.notranslate then
|
||||||
|
self.text = T(self.text)
|
||||||
|
end
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hit(button)
|
||||||
|
if not button.active then
|
||||||
|
button.active = true
|
||||||
|
button:onHit()
|
||||||
|
|
||||||
|
button.ui.timer:after(0.15, function() button.active = false end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Button:onPointerInput(px,py, clicked)
|
||||||
|
self:grabFocus()
|
||||||
|
if clicked then hit(self) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Button:onActionInput(action)
|
||||||
|
if action.confirm then hit(self) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Button:draw()
|
||||||
|
local x,y,w,h = self.x,self.y,self.w,self.h
|
||||||
|
local font = self.font or love.graphics.getFont()
|
||||||
|
local c = self:colorForState()
|
||||||
|
|
||||||
|
core.drawBox(x,y,w,h, c, self.cornerRadius)
|
||||||
|
love.graphics.setColor(c.fg)
|
||||||
|
love.graphics.setFont(font)
|
||||||
|
|
||||||
|
y = y + core.verticalOffsetForAlign(self.valign, font, h)
|
||||||
|
shadowtext.printf(self.text, x+2, y, w-4, self.align)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Button
|
|
@ -0,0 +1,64 @@
|
||||||
|
local BASE = (...):gsub('checkbox', '')
|
||||||
|
|
||||||
|
local Widget = require BASE..'widget'
|
||||||
|
local core = require BASE..'core'
|
||||||
|
|
||||||
|
local shadowtext = require 'lib.gear.shadowtext'
|
||||||
|
local T = require('lib.moonspeak').translate
|
||||||
|
|
||||||
|
local Checkbox = setmetatable({}, Widget)
|
||||||
|
Checkbox.__index = Checkbox
|
||||||
|
|
||||||
|
|
||||||
|
function Checkbox.new(args)
|
||||||
|
local self = setmetatable(args, Checkbox)
|
||||||
|
|
||||||
|
self.text = self.text or ""
|
||||||
|
self.text = self.notranslate and self.text or T(self.text)
|
||||||
|
self.align = self.align or 'left'
|
||||||
|
self.valign = self.valign or 'center'
|
||||||
|
self.color = self.color or core.theme.color
|
||||||
|
self.cornerRadius = self.cornerRadius or core.theme.cornerRadius
|
||||||
|
self.checked = self.checked or false
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function Checkbox:onPointerInput(px,py, clicked)
|
||||||
|
self:grabFocus()
|
||||||
|
if clicked then
|
||||||
|
self.checked = not self.checked
|
||||||
|
self:onChange(self.checked)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Checkbox:onActionInput(action)
|
||||||
|
if action.confirm then
|
||||||
|
self.checked = not self.checked
|
||||||
|
self:onChange(self.checked)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Checkbox:draw()
|
||||||
|
local x,y,w,h = self.x,self.y,self.w,self.h
|
||||||
|
local c = self:colorForState()
|
||||||
|
local font = self.font or love.graphics.getFont()
|
||||||
|
|
||||||
|
-- Draw checkbox
|
||||||
|
core.drawBox(x+h/10,y+h/10,h*.8,h*.8, c, self.cornerRadius)
|
||||||
|
love.graphics.setColor(c.fg)
|
||||||
|
if self.checked then
|
||||||
|
love.graphics.setLineStyle('smooth')
|
||||||
|
love.graphics.setLineWidth(5)
|
||||||
|
love.graphics.setLineJoin('bevel')
|
||||||
|
love.graphics.line(x+h*.2,y+h*.55, x+h*.45,y+h*.75, x+h*.8,y+h*.2)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Most of the times checkboxes have no text, so test for performance
|
||||||
|
if self.text ~= "" then
|
||||||
|
love.graphics.setFont(font)
|
||||||
|
y = y + core.verticalOffsetForAlign(self.valign, font, self.h)
|
||||||
|
shadowtext.printf(self.text, x + h, y, w - h, self.align)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return Checkbox
|
|
@ -0,0 +1,148 @@
|
||||||
|
local BASE = (...):gsub('choice', '')
|
||||||
|
|
||||||
|
local Widget = require BASE..'widget'
|
||||||
|
local core = require BASE..'core'
|
||||||
|
|
||||||
|
local shadowtext = require 'lib.gear.shadowtext'
|
||||||
|
local T = require('lib.moonspeak').translate
|
||||||
|
|
||||||
|
local Choice = setmetatable({}, Widget)
|
||||||
|
Choice.__index = Choice
|
||||||
|
|
||||||
|
|
||||||
|
function Choice.new(args)
|
||||||
|
local self = setmetatable(args, Choice)
|
||||||
|
|
||||||
|
self.align = self.align or 'center'
|
||||||
|
self.valign = self.valign or 'center'
|
||||||
|
self.cornerRadius = self.cornerRadius or core.theme.cornerRadius
|
||||||
|
self.color = self.color or core.theme.color
|
||||||
|
self.hovered = false
|
||||||
|
self.choices = self.choices or { "" }
|
||||||
|
self.nowrap = self.nowrap or #self.choices == 0
|
||||||
|
self.index = 1 -- by default
|
||||||
|
|
||||||
|
for i,choice in ipairs(self.choices) do
|
||||||
|
-- Expand shorthands
|
||||||
|
if type(choice) ~= 'table' then
|
||||||
|
choice = {
|
||||||
|
text = tostring(choice),
|
||||||
|
notranslate = type(choice) ~= 'string',
|
||||||
|
value = choice
|
||||||
|
}
|
||||||
|
|
||||||
|
self.choices[i] = choice
|
||||||
|
end
|
||||||
|
-- Mark default choice if needed
|
||||||
|
if choice.value == self.default then
|
||||||
|
self.index = i
|
||||||
|
end
|
||||||
|
-- Translate choice
|
||||||
|
if not (self.notranslate or choice.notranslate) then
|
||||||
|
choice.text = T(choice.text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function Choice:checkIndex()
|
||||||
|
if self.nowrap then
|
||||||
|
self.index = math.min(math.max(self.index, 1), #self.choices)
|
||||||
|
else
|
||||||
|
if self.index < 1 then
|
||||||
|
self.index = #self.choices
|
||||||
|
end
|
||||||
|
if self.index > #self.choices then
|
||||||
|
self.index = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Choice:onActionInput(action)
|
||||||
|
local oldindex = self.index
|
||||||
|
local handled = false
|
||||||
|
|
||||||
|
-- Change choice
|
||||||
|
if action.left then
|
||||||
|
self.index = oldindex - 1
|
||||||
|
handled = true
|
||||||
|
end
|
||||||
|
if action.right then
|
||||||
|
self.index = oldindex + 1
|
||||||
|
handled = true
|
||||||
|
end
|
||||||
|
if not handled then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Apply wrapping
|
||||||
|
self:checkIndex()
|
||||||
|
|
||||||
|
-- Fire event if necessary
|
||||||
|
if oldindex ~= self.index then
|
||||||
|
self:onChange(self.choices[self.index])
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Choice:onPointerInput(px,py, clicked)
|
||||||
|
self:grabFocus()
|
||||||
|
if not clicked then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local mx = px - self.x
|
||||||
|
local oldindex = self.index
|
||||||
|
|
||||||
|
-- Test whether arrows are hit
|
||||||
|
-- NOTE: don't care about arrows being disabled, checkIndex() will fix that.
|
||||||
|
if mx <= self.h+2 then
|
||||||
|
self.index = self.index - 1
|
||||||
|
elseif mx >= self.w - self.h-2 then
|
||||||
|
self.index = self.index + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
self:checkIndex()
|
||||||
|
if oldindex ~= self.index then
|
||||||
|
self:onChange(self.choices[self.index])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Choice:draw()
|
||||||
|
local x,y,w,h = self.x,self.y,self.w,self.h
|
||||||
|
local font = self.font or love.graphics.getFont()
|
||||||
|
local c = self:colorForState()
|
||||||
|
|
||||||
|
core.drawBox(x,y,w,h, c, self.cornerRadius)
|
||||||
|
|
||||||
|
if self.ui.focused == self then
|
||||||
|
-- draw < and > arrows, desaturate color if arrow is disabled
|
||||||
|
local cc = self.color.hovered
|
||||||
|
|
||||||
|
love.graphics.setLineStyle('smooth')
|
||||||
|
love.graphics.setLineWidth(3)
|
||||||
|
love.graphics.setLineJoin('bevel')
|
||||||
|
|
||||||
|
local r, g, b = cc.fg[1], cc.fg[2], cc.fg[3]
|
||||||
|
local a = (self.nowrap and self.index == 1) and 0.4 or 1
|
||||||
|
|
||||||
|
love.graphics.setColor(r,g,b,a)
|
||||||
|
love.graphics.line(x+h*.8,y+h*.2, x+h*.5,y+h*.5, x+h*.8,y+h*.8)
|
||||||
|
|
||||||
|
a = (self.nowrap and self.index == #self.choices) and 0.4 or 1
|
||||||
|
|
||||||
|
love.graphics.setColor(r,g,b,a)
|
||||||
|
love.graphics.line(x+w-h*.8,y+h*.2, x+w-h*.5,y+h*.5, x+w-h*.8,y+h*.8)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- draw text
|
||||||
|
local text = self.choices[self.index].text
|
||||||
|
|
||||||
|
y = y + core.verticalOffsetForAlign(self.valign, font, h)
|
||||||
|
|
||||||
|
love.graphics.setColor(c.fg)
|
||||||
|
love.graphics.setFont(font)
|
||||||
|
shadowtext.printf(text, x+h+2, y, w-2*(h + 2), self.align)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Choice
|
|
@ -0,0 +1,24 @@
|
||||||
|
local BASE = (...):gsub('columns', '')
|
||||||
|
|
||||||
|
local Layout = require BASE..'layout'
|
||||||
|
|
||||||
|
local Columns = setmetatable({}, Layout)
|
||||||
|
Columns.__index = Columns
|
||||||
|
|
||||||
|
|
||||||
|
-- Advance position to next column,
|
||||||
|
-- given current position, widget dimensions and padding.
|
||||||
|
local function columnadvance(x,y, ww,wh, padding)
|
||||||
|
return x + ww + padding, y
|
||||||
|
end
|
||||||
|
|
||||||
|
function Columns.new(args)
|
||||||
|
local self = setmetatable(Layout.new(args), Columns)
|
||||||
|
|
||||||
|
self.advance = columnadvance
|
||||||
|
self.prev = 'left'
|
||||||
|
self.next = 'right'
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
return Columns
|
|
@ -0,0 +1,27 @@
|
||||||
|
local BASE = (...):gsub('core', '')
|
||||||
|
|
||||||
|
local core = { theme = require(BASE..'theme') }
|
||||||
|
core.__index = core
|
||||||
|
|
||||||
|
-- Helpers for drawing
|
||||||
|
function core.verticalOffsetForAlign(valign, font, h)
|
||||||
|
if valign == 'top' then
|
||||||
|
return 0
|
||||||
|
elseif valign == 'bottom' then
|
||||||
|
return h - font:getHeight()
|
||||||
|
end
|
||||||
|
-- else: "middle"
|
||||||
|
return (h - font:getHeight()) / 2
|
||||||
|
end
|
||||||
|
|
||||||
|
function core.drawBox(x,y,w,h, color, cornerRadius)
|
||||||
|
w = math.max(cornerRadius/2, w)
|
||||||
|
if h < cornerRadius/2 then
|
||||||
|
y,h = y - (cornerRadius - h), cornerRadius/2
|
||||||
|
end
|
||||||
|
|
||||||
|
love.graphics.setColor(color.bg)
|
||||||
|
love.graphics.rectangle('fill', x,y, w,h, cornerRadius)
|
||||||
|
end
|
||||||
|
|
||||||
|
return core
|
|
@ -0,0 +1,284 @@
|
||||||
|
--- crush - The uncomplicated dependency system for LÖVE.
|
||||||
|
--
|
||||||
|
-- Author: Lorenzo Cogotti
|
||||||
|
-- Copyright: 2022 The DoubleFourteen Code Forge
|
||||||
|
-- License: MIT (see LICENSE file for details)
|
||||||
|
|
||||||
|
local io = require 'io'
|
||||||
|
local os = require 'os'
|
||||||
|
|
||||||
|
-- System specific functions
|
||||||
|
--
|
||||||
|
-- Portions of this code are based on work from the LuaRocks project.
|
||||||
|
-- LuaRocks is free software and uses the MIT license.
|
||||||
|
--
|
||||||
|
-- LuaRocks website: https://luarocks.org
|
||||||
|
-- LuaRocks sources: https://github.com/luarocks/luarocks
|
||||||
|
|
||||||
|
local is_windows = package.config:sub(1,1) == "\\"
|
||||||
|
|
||||||
|
local is_directory
|
||||||
|
local Q
|
||||||
|
local quiet
|
||||||
|
local chdir
|
||||||
|
local mkdir
|
||||||
|
|
||||||
|
if is_windows then
|
||||||
|
-- ---------------
|
||||||
|
-- NOTE: untested!
|
||||||
|
-- ---------------
|
||||||
|
|
||||||
|
-- local
|
||||||
|
function is_directory(path)
|
||||||
|
local fh, _, code = io.open(path, 'r')
|
||||||
|
|
||||||
|
if code == 13 then -- directories return "Permission denied"
|
||||||
|
fh, _, code = io.open(path.."\\", 'r')
|
||||||
|
if code == 2 then -- directories return 2, files return 22
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fh then
|
||||||
|
fh:close()
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function split_path(s)
|
||||||
|
local drive = ""
|
||||||
|
local root = ""
|
||||||
|
local rest
|
||||||
|
|
||||||
|
local unquoted = s:match("^['\"](.*)['\"]$")
|
||||||
|
if unquoted then
|
||||||
|
s = unquoted
|
||||||
|
end
|
||||||
|
if s:match("^.:") then
|
||||||
|
drive = s:sub(1, 2)
|
||||||
|
s = s:sub(3)
|
||||||
|
end
|
||||||
|
if s:match("^[\\/]") then
|
||||||
|
root = s:sub(1, 1)
|
||||||
|
rest = s:sub(2)
|
||||||
|
else
|
||||||
|
rest = s
|
||||||
|
end
|
||||||
|
return drive, root, rest
|
||||||
|
end
|
||||||
|
|
||||||
|
-- local
|
||||||
|
function Q(s)
|
||||||
|
local drive, root, rest = split_path(s)
|
||||||
|
if root ~= "" then
|
||||||
|
s = s:gsub("/", "\\")
|
||||||
|
end
|
||||||
|
if s == "\\" then
|
||||||
|
return '\\' -- CHDIR needs special handling for root dir
|
||||||
|
end
|
||||||
|
|
||||||
|
-- URLs and anything else
|
||||||
|
s = s:gsub('\\(\\*)"', '\\%1%1"')
|
||||||
|
s = s:gsub('\\+$', '%0%0')
|
||||||
|
s = s:gsub('"', '\\"')
|
||||||
|
s = s:gsub('(\\*)%%', '%1%1"%%"')
|
||||||
|
return '"'..s..'"'
|
||||||
|
end
|
||||||
|
|
||||||
|
-- local
|
||||||
|
function quiet(cmd)
|
||||||
|
return cmd.." 2> NUL 1> NUL"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- local
|
||||||
|
function chdir(newdir, cmd)
|
||||||
|
local drive = newdir:match("^([A-Za-z]:)")
|
||||||
|
|
||||||
|
cmd = "cd "..Q(newdir).." & "..cmd
|
||||||
|
if drive then
|
||||||
|
cmd = drive.." & "..cmd
|
||||||
|
end
|
||||||
|
return cmd
|
||||||
|
end
|
||||||
|
|
||||||
|
-- local
|
||||||
|
function mkdir(path)
|
||||||
|
local cmd = "mkdir "..Q(path).." 2> NUL 1> NUL"
|
||||||
|
|
||||||
|
os.execute(cmd)
|
||||||
|
if not is_directory(path) then
|
||||||
|
error("Couldn't create directory '"..path.."'.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- local
|
||||||
|
function is_directory(path)
|
||||||
|
local fh, _, code = io.open(path.."/.", 'r')
|
||||||
|
|
||||||
|
if code == 2 then -- "No such file or directory"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if code == 20 then -- "Not a directory", regardless of permissions
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if code == 13 then -- "Permission denied", but is a directory
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if fh then
|
||||||
|
_, _, code = fh:read(1)
|
||||||
|
fh:close()
|
||||||
|
if code == 21 then -- "Is a directory"
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- local
|
||||||
|
function Q(s)
|
||||||
|
return "'"..s:gsub("'", "'\\''").."'"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- local
|
||||||
|
function quiet(cmd)
|
||||||
|
return cmd.." >/dev/null 2>&1"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- local
|
||||||
|
function chdir(newdir, cmd)
|
||||||
|
return "cd "..Q(newdir).." && "..cmd
|
||||||
|
end
|
||||||
|
|
||||||
|
-- local
|
||||||
|
function mkdir(path)
|
||||||
|
local cmd = "mkdir "..Q(path).." >/dev/null 2>&1"
|
||||||
|
|
||||||
|
os.execute(cmd)
|
||||||
|
if not is_directory(path) then
|
||||||
|
error("Couldn't create directory '"..path.."'.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Dependency fetch
|
||||||
|
|
||||||
|
local function fetch(dep)
|
||||||
|
local dest = 'lib/'..dep.name
|
||||||
|
|
||||||
|
print(("Dependency %s -> %s (%s)"):format(dep.name, dest, dep.url))
|
||||||
|
|
||||||
|
local cmd, fullcmd
|
||||||
|
|
||||||
|
if is_directory(dest) then
|
||||||
|
-- Directory exists, pull operation
|
||||||
|
cmd = "git pull"
|
||||||
|
fullcmd = chdir(dest, quiet("git pull"))
|
||||||
|
else
|
||||||
|
-- Directory doesn't exist, clone operation
|
||||||
|
cmd = "git clone "..Q(dep.url).." "..Q(dep.name)
|
||||||
|
fullcmd = chdir("lib", quiet(cmd))
|
||||||
|
end
|
||||||
|
|
||||||
|
if not os.execute(fullcmd) then
|
||||||
|
error(name..": Dependency fetch failed ("..cmd..").")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- .lovedeps file scan
|
||||||
|
|
||||||
|
local function map_file(name)
|
||||||
|
local fh = io.open(name, 'r')
|
||||||
|
if fh == nil then
|
||||||
|
error(name..": can't read file.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local contents = fh:read('*all')
|
||||||
|
fh:close()
|
||||||
|
|
||||||
|
return contents
|
||||||
|
end
|
||||||
|
|
||||||
|
local function scandeps(manifest, mode, deps)
|
||||||
|
mode = mode or 'nodups'
|
||||||
|
deps = deps or {}
|
||||||
|
|
||||||
|
local contents = map_file(manifest)
|
||||||
|
contents = "return "..contents
|
||||||
|
|
||||||
|
local fun, res = load(contents, manifest, 't', {})
|
||||||
|
if not fun then
|
||||||
|
error(res)
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, def = pcall(fun)
|
||||||
|
if not ok then
|
||||||
|
error(def) -- def is now pcall()'s error message
|
||||||
|
end
|
||||||
|
if type(def) ~= 'table' then
|
||||||
|
error("[string \""..manifest.."\"]: Loading resulted in a '"..type(def).."', while 'table' was expected.")
|
||||||
|
end
|
||||||
|
|
||||||
|
for name,url in pairs(def) do
|
||||||
|
if type(url) == 'function' then
|
||||||
|
goto skip -- ignore functions
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(url) ~= 'string' then
|
||||||
|
error("[string \""..manifest.."\"]: "..name..": git repository URL must be a 'string'.")
|
||||||
|
end
|
||||||
|
|
||||||
|
for i in ipairs(deps) do
|
||||||
|
if name == deps[i].name then
|
||||||
|
if mode == 'skipdups' then
|
||||||
|
goto skip
|
||||||
|
end
|
||||||
|
|
||||||
|
error("[string \""..manifest.."\"]: "..name..": Duplicate dependency.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
deps[#deps+1] = { name = name, url = url }
|
||||||
|
|
||||||
|
::skip::
|
||||||
|
end
|
||||||
|
|
||||||
|
return deps
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Entry point
|
||||||
|
|
||||||
|
local function file_exists(name)
|
||||||
|
local fh = io.open(name, 'r')
|
||||||
|
if fh ~= nil then
|
||||||
|
fh:close()
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run()
|
||||||
|
local deps = scandeps(".lovedeps")
|
||||||
|
|
||||||
|
mkdir("lib")
|
||||||
|
|
||||||
|
-- NOTE: deps array may grow while scanning
|
||||||
|
local i = 1
|
||||||
|
while i <= #deps do
|
||||||
|
local dep = deps[i]
|
||||||
|
|
||||||
|
-- Fetch dependency
|
||||||
|
fetch(dep)
|
||||||
|
|
||||||
|
-- Resolve dependency's dependencies
|
||||||
|
local depmanifest = "lib/"..dep.name.."/.lovedeps"
|
||||||
|
|
||||||
|
if file_exists(depmanifest) then
|
||||||
|
scandeps(depmanifest, 'skipdups', deps)
|
||||||
|
end
|
||||||
|
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
run()
|
|
@ -0,0 +1,60 @@
|
||||||
|
-- Simple Device driver for Yui, only depends on LÖVE 2D, listens to
|
||||||
|
-- keyboard and mouse pointer.
|
||||||
|
|
||||||
|
local Device = {}
|
||||||
|
Device.__index = Device
|
||||||
|
|
||||||
|
|
||||||
|
function Device.new()
|
||||||
|
return setmetatable({
|
||||||
|
px = nil, py = nil,
|
||||||
|
clicking = nil,
|
||||||
|
|
||||||
|
confirm = nil,
|
||||||
|
cancel = nil,
|
||||||
|
up = nil,
|
||||||
|
left = nil,
|
||||||
|
down = nil,
|
||||||
|
right = nil,
|
||||||
|
}, Device)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Device:snapshot()
|
||||||
|
local snap = {}
|
||||||
|
|
||||||
|
-- Mouse pointer
|
||||||
|
local px,py = love.mouse.getPosition()
|
||||||
|
local clicking = love.mouse.isDown(1)
|
||||||
|
|
||||||
|
snap.px,snap.py = px,py
|
||||||
|
snap.pointing = clicking
|
||||||
|
snap.clicked = self.clicking and not clicking
|
||||||
|
snap.pointer = px ~= self.px or py ~= self.py or snap.clicked or snap.pointing
|
||||||
|
|
||||||
|
-- Keyboard input
|
||||||
|
local confirm = love.keyboard.isDown('return', 'space')
|
||||||
|
local cancel = love.keyboard.isDown('escape')
|
||||||
|
local up = love.keyboard.isDown('up', 'w')
|
||||||
|
local left = love.keyboard.isDown('left', 'a')
|
||||||
|
local down = love.keyboard.isDown('down', 's')
|
||||||
|
local right = love.keyboard.isDown('right', 'd')
|
||||||
|
|
||||||
|
snap.confirm = self.confirm and not confirm
|
||||||
|
snap.cancel = self.cancel and not cancel
|
||||||
|
snap.up = self.up and not up
|
||||||
|
snap.left = self.left and not left
|
||||||
|
snap.down = self.down and not down
|
||||||
|
snap.right = self.right and not right
|
||||||
|
snap.action = snap.confirm or snap.cancel or
|
||||||
|
snap.up or snap.left or snap.down or snap.right
|
||||||
|
|
||||||
|
-- Update old state
|
||||||
|
self.px,self.py = px,py
|
||||||
|
self.clicking = clicking
|
||||||
|
self.confirm,self.cancel = confirm,cancel
|
||||||
|
self.left,self.up,self.right,self.down = left,up,right,down
|
||||||
|
|
||||||
|
return snap
|
||||||
|
end
|
||||||
|
|
||||||
|
return Device
|
|
@ -0,0 +1,18 @@
|
||||||
|
local BASE = (...)..'.'
|
||||||
|
|
||||||
|
return {
|
||||||
|
Button = require(BASE..'button'),
|
||||||
|
Checkbox = require(BASE..'checkbox'),
|
||||||
|
Choice = require(BASE..'choice'),
|
||||||
|
Columns = require(BASE..'columns'),
|
||||||
|
Input = require(BASE..'input'),
|
||||||
|
Label = require(BASE..'label'),
|
||||||
|
Layout = require(BASE..'layout'),
|
||||||
|
Rows = require(BASE..'rows'),
|
||||||
|
Slider = require(BASE..'slider'),
|
||||||
|
Spacer = require(BASE..'spacer'),
|
||||||
|
Ui = require(BASE..'ui'),
|
||||||
|
Widget = require(BASE..'widget'),
|
||||||
|
|
||||||
|
theme = require(BASE..'theme')
|
||||||
|
}
|
|
@ -0,0 +1,184 @@
|
||||||
|
local BASE = (...):gsub('input', '')
|
||||||
|
|
||||||
|
local Widget = require BASE..'widget'
|
||||||
|
local core = require BASE..'core'
|
||||||
|
|
||||||
|
local utf8 = require 'utf8'
|
||||||
|
|
||||||
|
-- Grabs keyboard on focus
|
||||||
|
local Input = setmetatable({ grabkeyboard = true }, Widget)
|
||||||
|
Input.__index = Input
|
||||||
|
|
||||||
|
|
||||||
|
local function split(str, pos)
|
||||||
|
local ofs = utf8.offset(str, pos) or 0
|
||||||
|
return str:sub(1, ofs-1), str:sub(ofs)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input.new(args)
|
||||||
|
local self = setmetatable(args, Input)
|
||||||
|
|
||||||
|
self.text = self.text or ""
|
||||||
|
self.color = self.color or core.theme.color
|
||||||
|
self.cornerRadius = self.cornerRadius or core.theme.cornerRadius
|
||||||
|
self.cursor = math.max(1, math.min(utf8.len(self.text)+1,
|
||||||
|
self.cursor or utf8.len(self.text)+1))
|
||||||
|
self.candidate = { text = "", start = 0, length = 0 }
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:onPointerInput(px,py, clicked)
|
||||||
|
if clicked then
|
||||||
|
self:grabFocus()
|
||||||
|
|
||||||
|
-- Schedule cursor reposition for next draw()
|
||||||
|
self.px = px
|
||||||
|
self.py = py
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:textedited(text, start, length)
|
||||||
|
self.candidate.text = text
|
||||||
|
self.candidate.start = start
|
||||||
|
self.candidate.length = length
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:textinput(text)
|
||||||
|
if text ~= "" then
|
||||||
|
local a,b = split(self.text, self.cursor)
|
||||||
|
|
||||||
|
self.text = table.concat {a, text, b}
|
||||||
|
self.cursor = self.cursor + utf8.len(text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:keypressed(key, code, isrepeat)
|
||||||
|
if isrepeat == nil then
|
||||||
|
-- LOVE sends 3 types of keypressed() events,
|
||||||
|
-- 1. with isrepeat = true
|
||||||
|
-- 2. with isrepeat = false
|
||||||
|
-- 3. with isrepeat = nil
|
||||||
|
--
|
||||||
|
-- We're only interested in the first 2.
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.candidate.length == 0 then
|
||||||
|
if key == 'backspace' then
|
||||||
|
local a,b = split(self.text, self.cursor)
|
||||||
|
|
||||||
|
self.text = table.concat { split(a, utf8.len(a)), b }
|
||||||
|
self.cursor = math.max(1, self.cursor-1)
|
||||||
|
|
||||||
|
elseif key == 'delete' then
|
||||||
|
local a,b = split(self.text, self.cursor)
|
||||||
|
local _,b = split(b, 2)
|
||||||
|
|
||||||
|
self.text = table.concat { a, b }
|
||||||
|
|
||||||
|
elseif key == 'left' then
|
||||||
|
self.cursor = math.max(0, self.cursor-1)
|
||||||
|
|
||||||
|
elseif key == 'right' then
|
||||||
|
self.cursor = math.min(utf8.len(self.text)+1, self.cursor+1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:keyreleased(key)
|
||||||
|
if self.candidate.length == 0 then
|
||||||
|
if key == 'home' then
|
||||||
|
self.cursor = 1
|
||||||
|
elseif key == 'end' then
|
||||||
|
self.cursor = utf8.len(self.text)+1
|
||||||
|
elseif key == 'return' then
|
||||||
|
self:onChange(self.text)
|
||||||
|
self.cursor = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:draw()
|
||||||
|
-- Cursor position is before the char (including EOS) i.e. in "hello":
|
||||||
|
-- position 1: |hello
|
||||||
|
-- position 2: h|ello
|
||||||
|
-- ...
|
||||||
|
-- position 6: hello|
|
||||||
|
|
||||||
|
local x,y,w,h = self.x,self.y,self.w,self.h
|
||||||
|
local font = self.font or love.graphics.getFont()
|
||||||
|
local th = font:getHeight()
|
||||||
|
local tw = font:getWidth(self.text)
|
||||||
|
|
||||||
|
-- Get size of text and cursor position
|
||||||
|
local cursor_pos = 0
|
||||||
|
if self.cursor > 1 then
|
||||||
|
local s = self.text:sub(1, utf8.offset(self.text, self.cursor)-1)
|
||||||
|
cursor_pos = font:getWidth(s)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Compute drawing offset
|
||||||
|
if self.px ~= nil then
|
||||||
|
-- Mouse movement
|
||||||
|
local mx = self.px - self.x
|
||||||
|
|
||||||
|
self.cursor = utf8.len(self.text) + 1
|
||||||
|
for c = 1,self.cursor do
|
||||||
|
local s = self.text:sub(0, utf8.offset(self.text, c)-1)
|
||||||
|
if font:getWidth(s) >= mx then
|
||||||
|
self.cursor = c-1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self.px,self.py = nil,nil
|
||||||
|
end
|
||||||
|
|
||||||
|
core.drawBox(x,y,w,h, self.color.normal, self.cornerRadius)
|
||||||
|
|
||||||
|
-- Apply text margins
|
||||||
|
w = math.max(w - 6, 0)
|
||||||
|
x = math.min(x + 3, x + w)
|
||||||
|
|
||||||
|
-- Set scissors
|
||||||
|
local sx, sy, sw, sh = love.graphics.getScissor()
|
||||||
|
love.graphics.setScissor(x-1,y,w+2,h)
|
||||||
|
|
||||||
|
-- Text
|
||||||
|
love.graphics.setColor(self.color.normal.fg)
|
||||||
|
love.graphics.setFont(font)
|
||||||
|
love.graphics.print(self.text, x, y + (h-th)/2)
|
||||||
|
|
||||||
|
if self.candidate.length > 0 then
|
||||||
|
-- Candidate text
|
||||||
|
local ctw = font:getWidth(self.candidate.text)
|
||||||
|
|
||||||
|
love.graphics.setColor(self.color.normal.fg)
|
||||||
|
love.graphics.print(self.candidate.text, x + tw, y + (h-th)/2)
|
||||||
|
|
||||||
|
-- Candidate text rectangle box
|
||||||
|
love.graphics.rectangle('line', x + tw, y + (h-th)/2, ctw, th)
|
||||||
|
|
||||||
|
self.candidate.text = ""
|
||||||
|
self.candidate.start = 0
|
||||||
|
self.candidate.length = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cursor
|
||||||
|
if self:isFocused() and (love.timer.getTime() % 1) > .5 then
|
||||||
|
local ct = self.candidate
|
||||||
|
local ss = ct.text:sub(1, utf8.offset(ct.text, ct.start))
|
||||||
|
local ws = ct.start > 0 and font:getWidth(ss) or 0
|
||||||
|
|
||||||
|
love.graphics.setLineWidth(1)
|
||||||
|
love.graphics.setLineStyle('rough')
|
||||||
|
love.graphics.line(x + cursor_pos + ws, y + (h-th)/2,
|
||||||
|
x + cursor_pos + ws, y + (h+th)/2)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Reset scissor
|
||||||
|
love.graphics.setScissor(sx,sy,sw,sh)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Input
|
|
@ -0,0 +1,37 @@
|
||||||
|
local BASE = (...):gsub('label', '')
|
||||||
|
|
||||||
|
local Widget = require BASE..'widget'
|
||||||
|
local core = require BASE..'core'
|
||||||
|
|
||||||
|
local shadowtext = require 'lib.gear.shadowtext'
|
||||||
|
local T = require('lib.moonspeak').translate
|
||||||
|
|
||||||
|
-- Labels don't accept focus
|
||||||
|
local Label = setmetatable({ nofocus = true }, Widget)
|
||||||
|
Label.__index = Label
|
||||||
|
|
||||||
|
|
||||||
|
function Label.new(args)
|
||||||
|
local self = setmetatable(args, Label)
|
||||||
|
|
||||||
|
self.text = self.text or ""
|
||||||
|
self.text = self.notranslate and self.text or T(self.text)
|
||||||
|
self.align = self.align or 'center'
|
||||||
|
self.valign = self.valign or 'center'
|
||||||
|
self.color = self.color or core.theme.color
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function Label:draw()
|
||||||
|
local x,y,w,h = self.x,self.y,self.w,self.h
|
||||||
|
local font = self.font or love.graphics.getFont()
|
||||||
|
local c = self.color.normal
|
||||||
|
|
||||||
|
y = y + core.verticalOffsetForAlign(self.valign, font, h)
|
||||||
|
|
||||||
|
love.graphics.setColor(c.fg)
|
||||||
|
love.graphics.setFont(font)
|
||||||
|
shadowtext.printf(self.text, x+2, y, w-4, self.align)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Label
|
|
@ -0,0 +1,251 @@
|
||||||
|
local BASE = (...):gsub('layout', '')
|
||||||
|
|
||||||
|
local Widget = require BASE..'widget'
|
||||||
|
local core = require BASE..'core'
|
||||||
|
|
||||||
|
local utils = require 'lib.gear'
|
||||||
|
|
||||||
|
local isinstance = gear.meta.isinstance
|
||||||
|
local rectunion = gear.rect.union
|
||||||
|
local pointinrect = gear.rect.pointinside
|
||||||
|
|
||||||
|
local Layout = setmetatable({}, Widget)
|
||||||
|
Layout.__index = Layout
|
||||||
|
|
||||||
|
|
||||||
|
-- Calculate initial widget size.
|
||||||
|
local function calcsize(sizes, widget)
|
||||||
|
local w, h = widget.w, widget.h
|
||||||
|
if w == nil then
|
||||||
|
assert(#sizes > 0, "Default width is undefined!")
|
||||||
|
w = sizes[#sizes].w
|
||||||
|
end
|
||||||
|
if h == nil then
|
||||||
|
assert(#sizes > 0, "Default height is undefined!")
|
||||||
|
h = sizes[#sizes].h
|
||||||
|
end
|
||||||
|
|
||||||
|
if w == 'max' then
|
||||||
|
w = 0
|
||||||
|
for _,v in ipairs(sizes) do
|
||||||
|
if v.w > w then
|
||||||
|
w = v.w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif w == 'median' then
|
||||||
|
w = 0
|
||||||
|
for _,v in ipairs(sizes) do
|
||||||
|
w = w + v.w
|
||||||
|
end
|
||||||
|
w = math.ceil(w / #sizes)
|
||||||
|
elseif w == 'min' then
|
||||||
|
w = math.huge
|
||||||
|
for _,v in ipairs(sizes) do
|
||||||
|
if v.w < w then
|
||||||
|
w = v.w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
assert(type(w) == 'number')
|
||||||
|
end
|
||||||
|
|
||||||
|
if h == 'max' then
|
||||||
|
h = 0
|
||||||
|
for _,v in ipairs(sizes) do
|
||||||
|
if v.h > h then
|
||||||
|
h = v.h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif h == 'median' then
|
||||||
|
h = 0
|
||||||
|
for _,v in ipairs(sizes) do
|
||||||
|
h = h + v.h
|
||||||
|
end
|
||||||
|
h = math.ceil(h / #sizes)
|
||||||
|
elseif h == 'min' then
|
||||||
|
h = math.huge
|
||||||
|
for _,v in ipairs(sizes) do
|
||||||
|
if v.h < h then
|
||||||
|
h = v.h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
assert(type(h) == 'number')
|
||||||
|
end
|
||||||
|
|
||||||
|
sizes[#sizes+1] = { w = w, h = h }
|
||||||
|
|
||||||
|
widget.w, widget.h = w, h
|
||||||
|
return w, h
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Lay down container widgets according to Layout type.
|
||||||
|
function Layout:layoutWidgets()
|
||||||
|
local nx,ny = self.x,self.y
|
||||||
|
local sizes = {}
|
||||||
|
local stack = self.stack
|
||||||
|
local pad = self.padding
|
||||||
|
|
||||||
|
-- Container bounds, empty
|
||||||
|
local rx,ry,rw,rh = nx,ny,-1,-1
|
||||||
|
|
||||||
|
-- Layout container children
|
||||||
|
for _,widget in ipairs(self) do
|
||||||
|
widget.x, widget.y = nx, ny
|
||||||
|
widget.ui = self.ui
|
||||||
|
widget.parent = self
|
||||||
|
|
||||||
|
if isinstance(widget, Layout) then
|
||||||
|
widget:layoutWidgets()
|
||||||
|
end
|
||||||
|
|
||||||
|
local w,h = calcsize(sizes, widget)
|
||||||
|
rx,ry,rw,rh = rectunion(rx,ry,rw,rh, nx,ny,w,h)
|
||||||
|
|
||||||
|
nx,ny = self.advance(nx,ny, w,h, pad)
|
||||||
|
|
||||||
|
stack[#stack+1] = widget
|
||||||
|
end
|
||||||
|
|
||||||
|
self.x = rx
|
||||||
|
self.y = ry
|
||||||
|
self.w = math.max(rw, 0)
|
||||||
|
self.h = math.max(rh, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Layout:onPointerInput(px,py, clicked, down)
|
||||||
|
local stack = self.stack
|
||||||
|
|
||||||
|
-- Propagate pointer event from topmost widget to bottom
|
||||||
|
for i = #stack,1,-1 do
|
||||||
|
local widget = stack[i]
|
||||||
|
local x,y,w,h = widget.x,widget.y,widget.w,widget.h
|
||||||
|
|
||||||
|
if pointinrect(px,py, x,y,w,h) then
|
||||||
|
widget:handlePointer(px,py, clicked, down)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find layout's child containing the provided widget.
|
||||||
|
local function childof(layout, widget)
|
||||||
|
local parent = widget.parent
|
||||||
|
while parent ~= layout do
|
||||||
|
widget = parent
|
||||||
|
parent = widget.parent
|
||||||
|
end
|
||||||
|
return widget
|
||||||
|
end
|
||||||
|
local function findfirst(widget)
|
||||||
|
while isinstance(widget, Layout) do
|
||||||
|
-- Find first element accepting focus
|
||||||
|
for i = 1,#widget do
|
||||||
|
if not widget[i].nofocus then
|
||||||
|
widget = widget[i]
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return widget
|
||||||
|
end
|
||||||
|
local function findnext(layout, widget)
|
||||||
|
local child = childof(layout, widget)
|
||||||
|
|
||||||
|
for i,w in ipairs(layout) do
|
||||||
|
if w == child then
|
||||||
|
-- Search to the right, wraparound to the left
|
||||||
|
for j = i+1,#layout do
|
||||||
|
if not layout[j].nofocus then
|
||||||
|
return findfirst(layout[j])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for j = 1,i-1 do
|
||||||
|
if not layout[j].nofocus then
|
||||||
|
return findfirst(layout[j])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return widget
|
||||||
|
end
|
||||||
|
local function findprev(layout, widget)
|
||||||
|
local child = childof(layout, widget)
|
||||||
|
|
||||||
|
for i,w in ipairs(layout) do
|
||||||
|
if w == child then
|
||||||
|
-- Search to the left, wraparound to the right
|
||||||
|
for j = i-1,1,-1 do
|
||||||
|
if not layout[j].nofocus then
|
||||||
|
return findfirst(layout[j])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for j = #layout,i+1,-1 do
|
||||||
|
if not layout[j].nofocus then
|
||||||
|
return findfirst(layout[j])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return widget
|
||||||
|
end
|
||||||
|
|
||||||
|
function Layout:firstFocusableWidget()
|
||||||
|
return findfirst(self)
|
||||||
|
end
|
||||||
|
function Layout:nextFocusableWidget()
|
||||||
|
return findnext(self, self.ui.focused)
|
||||||
|
end
|
||||||
|
function Layout:previousFocusableWidget()
|
||||||
|
return findprev(self, self.ui.focused)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Layout:onActionInput(action)
|
||||||
|
local handled = false
|
||||||
|
|
||||||
|
if action[self.next] then
|
||||||
|
local n = self:nextFocusableWidget()
|
||||||
|
|
||||||
|
n:grabFocus()
|
||||||
|
handled = true
|
||||||
|
end
|
||||||
|
if action[self.prev] then
|
||||||
|
local p = self:previousFocusableWidget()
|
||||||
|
|
||||||
|
p:grabFocus()
|
||||||
|
handled = true
|
||||||
|
end
|
||||||
|
return handled
|
||||||
|
end
|
||||||
|
|
||||||
|
function Layout:update(dt)
|
||||||
|
for _,widget in ipairs(self.stack) do
|
||||||
|
widget:update(dt)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Layout:draw()
|
||||||
|
-- Draw all children according to their order (topmost last)
|
||||||
|
for _,widget in ipairs(self.stack) do
|
||||||
|
widget:draw()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Layout.new(args)
|
||||||
|
local self = setmetatable(args, Layout)
|
||||||
|
|
||||||
|
self.padding = self.padding or 0
|
||||||
|
self.stack = {}
|
||||||
|
|
||||||
|
-- A Layout ignores focus if empty or containing only nofocus widgets
|
||||||
|
self.nofocus = true
|
||||||
|
for _,w in ipairs(self) do
|
||||||
|
if not w.nofocus then
|
||||||
|
self.nofocus = false
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
return Layout
|
|
@ -0,0 +1,24 @@
|
||||||
|
local BASE = (...):gsub('rows')
|
||||||
|
|
||||||
|
local Layout = require BASE..'layout'
|
||||||
|
|
||||||
|
local Rows = setmetatable({}, Layout)
|
||||||
|
Rows.__index = Rows
|
||||||
|
|
||||||
|
|
||||||
|
-- Advance position to next row,
|
||||||
|
-- given current position, widget dimensions and padding.
|
||||||
|
local function rowadvance(x,y, ww,wh, padding)
|
||||||
|
return x, y + wh + padding
|
||||||
|
end
|
||||||
|
|
||||||
|
function Rows.new(args)
|
||||||
|
local self = setmetatable(Layout.new(args), Rows)
|
||||||
|
|
||||||
|
self.advance = rowadvance
|
||||||
|
self.prev = 'up'
|
||||||
|
self.next = 'down'
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
return Rows
|
|
@ -0,0 +1,92 @@
|
||||||
|
local BASE = (...):gsub('slider', '')
|
||||||
|
|
||||||
|
local Widget = require BASE..'widget'
|
||||||
|
local core = require BASE..'core'
|
||||||
|
|
||||||
|
local Slider = setmetatable({}, Widget)
|
||||||
|
Slider.__index = Slider
|
||||||
|
|
||||||
|
|
||||||
|
function Slider.new(args)
|
||||||
|
local self = setmetatable(args, Slider)
|
||||||
|
|
||||||
|
self.color = self.color or core.theme.color
|
||||||
|
self.cornerRadius = self.cornerRadius or core.theme.cornerRadius
|
||||||
|
self.vertical = self.vertical or false
|
||||||
|
self.min = self.min or 0
|
||||||
|
self.max = self.max or 1
|
||||||
|
self.value = self.value or self.min
|
||||||
|
self.step = self.step or (self.max - self.min) / 10
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function Slider:onPointerInput(px,py, clicked, down)
|
||||||
|
self:grabFocus()
|
||||||
|
if not down then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local x,y,w,h = self.x,self.y,self.w,self.h
|
||||||
|
|
||||||
|
local fraction
|
||||||
|
if self.vertical then
|
||||||
|
fraction = math.min(1, math.max(0, (y+h - py) / h))
|
||||||
|
else
|
||||||
|
fraction = math.min(1, math.max(0, (px - x) / w))
|
||||||
|
end
|
||||||
|
|
||||||
|
local v = fraction*(self.max - self.min) + self.min
|
||||||
|
if v ~= self.value then
|
||||||
|
self.value = v
|
||||||
|
self:onChange(v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Slider:onActionInput(action)
|
||||||
|
local up = self.vertical and 'up' or 'right'
|
||||||
|
local down = self.vertical and 'down' or 'left'
|
||||||
|
|
||||||
|
local handled = false
|
||||||
|
if action[up] then
|
||||||
|
self.value = math.min(self.max, self.value + self.step)
|
||||||
|
handled = true
|
||||||
|
elseif action[down] then
|
||||||
|
self.value = math.max(self.min, self.value - self.step)
|
||||||
|
handled = true
|
||||||
|
end
|
||||||
|
if handled then
|
||||||
|
self:onChange(self.value)
|
||||||
|
end
|
||||||
|
|
||||||
|
return handled
|
||||||
|
end
|
||||||
|
|
||||||
|
function Slider:draw()
|
||||||
|
local x,y,w,h = self.x,self.y,self.w,self.h
|
||||||
|
local r = math.min(w,h) / 2.1
|
||||||
|
local c = self:colorForState()
|
||||||
|
local fraction = (self.value - self.min) / (self.max - self.min)
|
||||||
|
|
||||||
|
local xb, yb, wb, hb -- size of the progress bar
|
||||||
|
if self.vertical then
|
||||||
|
x, w = x + w*.25, w*.5
|
||||||
|
xb, yb, wb, hb = x, y+h*(1-fraction), w, h*fraction
|
||||||
|
else
|
||||||
|
y, h = y + h*.25, h*.5
|
||||||
|
xb, yb, wb, hb = x,y, w*fraction, h
|
||||||
|
end
|
||||||
|
|
||||||
|
core.drawBox(x,y,w,h, c, self.cornerRadius)
|
||||||
|
core.drawBox(xb,yb,wb,hb, {bg=c.fg}, self.cornerRadius)
|
||||||
|
|
||||||
|
if self:isFocused() then
|
||||||
|
love.graphics.setColor(c.fg)
|
||||||
|
if self.vertical then
|
||||||
|
love.graphics.circle('fill', x+wb/2, yb, r)
|
||||||
|
else
|
||||||
|
love.graphics.circle('fill', x+wb, yb+hb/2, r)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return Slider
|
|
@ -0,0 +1,14 @@
|
||||||
|
local BASE = (...):gsub('spacer', '')
|
||||||
|
|
||||||
|
local Widget = require BASE..'widget'
|
||||||
|
|
||||||
|
-- Spacers don't accept focus
|
||||||
|
local Spacer = setmetatable({ nofocus = true }, Widget)
|
||||||
|
Spacer.__index = Spacer
|
||||||
|
|
||||||
|
|
||||||
|
function Spacer.new(args)
|
||||||
|
return setmetatable(args, Spacer)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Spacer
|
|
@ -0,0 +1,11 @@
|
||||||
|
local theme = {
|
||||||
|
cornerRadius = 4,
|
||||||
|
|
||||||
|
color = {
|
||||||
|
normal = {bg = { 0.25, 0.25, 0.25}, fg = {0.73, 0.73, 0.73}},
|
||||||
|
hovered = {bg = { 0.19, 0.6, 0.73}, fg = {1, 1, 1}},
|
||||||
|
active = {bg = { 1, 0.6, 0}, fg = {1, 1, 1}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme
|
|
@ -0,0 +1,176 @@
|
||||||
|
local BASE = (...):gsub('ui', '')
|
||||||
|
|
||||||
|
local Widget = require BASE..'widget'
|
||||||
|
local Layout = require BASE..'layout'
|
||||||
|
|
||||||
|
local gear = require 'lib.gear'
|
||||||
|
|
||||||
|
local Timer = gear.Timer
|
||||||
|
local isinstance = gear.meta.isinstance
|
||||||
|
local pointinrect = gear.rect.pointinside
|
||||||
|
|
||||||
|
local Ui = {}
|
||||||
|
Ui.__index = Ui
|
||||||
|
|
||||||
|
|
||||||
|
-- Scan UI for the LAST widgets with 'cancelfocus' or 'firstfocus' flags
|
||||||
|
local function resolveautofocus(widget)
|
||||||
|
local firstfocus, cancelfocus
|
||||||
|
|
||||||
|
if isinstance(widget, Layout) then
|
||||||
|
for _,w in ipairs(widget) do
|
||||||
|
local firstf, cancelf
|
||||||
|
|
||||||
|
if not w.nofocus then
|
||||||
|
if isinstance(w, Layout) then
|
||||||
|
firstf, cancelf = resolveautofocus(w)
|
||||||
|
else
|
||||||
|
if w.firstfocus then
|
||||||
|
firstf = w
|
||||||
|
end
|
||||||
|
if w.cancelfocus then
|
||||||
|
cancelf = w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
firstfocus = firstf or firstfocus
|
||||||
|
cancelfocus = cancelf or cancelfocus
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif not widget.nofocus then
|
||||||
|
if widget.firstfocus then
|
||||||
|
firstfocus = widget
|
||||||
|
end
|
||||||
|
if widget.cancelfocus then
|
||||||
|
cancelfocus = widget
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return firstfocus, cancelfocus
|
||||||
|
end
|
||||||
|
|
||||||
|
local function propagateaction(ui, action)
|
||||||
|
local focused = ui.focused
|
||||||
|
if focused.grabkeyboard then
|
||||||
|
-- A widget stealing input
|
||||||
|
-- explicitly consumes any action.
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
while focused ~= nil do
|
||||||
|
if focused:onActionInput(action) then
|
||||||
|
return true -- action consumed
|
||||||
|
end
|
||||||
|
|
||||||
|
focused = focused.parent
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function Ui.new(args)
|
||||||
|
local self = setmetatable(args, Ui)
|
||||||
|
assert(#self == 1, "Ui.new() must have exactly one root widget.")
|
||||||
|
|
||||||
|
self.device = self.device or require('device.love').new()
|
||||||
|
self.x = self.x or 0
|
||||||
|
self.y = self.y or 0
|
||||||
|
self.pointerActive = true
|
||||||
|
self.timer = Timer.new()
|
||||||
|
|
||||||
|
local root = self[1]
|
||||||
|
if not isinstance(root, Widget) then
|
||||||
|
error("Ui.new() bad root Widget type: "..type(root)..".")
|
||||||
|
end
|
||||||
|
|
||||||
|
root.x,root.y = self.x,self.y
|
||||||
|
root.ui = self
|
||||||
|
if isinstance(root, Layout) then
|
||||||
|
root:layoutWidgets()
|
||||||
|
else
|
||||||
|
assert(type(root.w) == 'number', "Ui.new() root Widget must have a numeric width.")
|
||||||
|
assert(type(root.h) == 'number', "Ui.new() root Widget must have a numeric height.")
|
||||||
|
assert(not root.nofocus, "Ui.new() single root Widget can't be nofocus.")
|
||||||
|
end
|
||||||
|
|
||||||
|
self.w,self.h = root.w,root.h
|
||||||
|
|
||||||
|
local firstfocus, cancelfocus = resolveautofocus(root)
|
||||||
|
if firstfocus == nil then
|
||||||
|
firstfocus = isinstance(root, Layout) and
|
||||||
|
root:firstFocusableWidget() or
|
||||||
|
root
|
||||||
|
end
|
||||||
|
|
||||||
|
self.cancelfocus = cancelfocus
|
||||||
|
firstfocus:grabFocus()
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Event propagators for widgets listening to keyboard input
|
||||||
|
function Ui:keypressed(key, scancode, isrepeat)
|
||||||
|
local focused = self.focused
|
||||||
|
|
||||||
|
if focused ~= nil and focused.grabkeyboard then
|
||||||
|
focused:keypressed(key, scancode, isrepeat)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
function Ui:keyreleased(key, scancode)
|
||||||
|
local focused = self.focused
|
||||||
|
|
||||||
|
if focused ~= nil and focused.grabkeyboard then
|
||||||
|
focused:keyreleased(key, scancode)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
function Ui:textinput(text)
|
||||||
|
local focused = self.focused
|
||||||
|
|
||||||
|
if focused ~= nil and focused.grabkeyboard then
|
||||||
|
focused:textinput(text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
function Ui:textedited(text, start, length)
|
||||||
|
local focused = self.focused
|
||||||
|
|
||||||
|
if focused ~= nil and focused.grabkeyboard then
|
||||||
|
focused:textedited(text, start, length)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Ui:update(dt)
|
||||||
|
local root = self[1]
|
||||||
|
local x,y,w,h = root.x,root.y,root.w,root.h
|
||||||
|
local snap = self.device:snapshot()
|
||||||
|
|
||||||
|
self.timer:update(dt)
|
||||||
|
|
||||||
|
-- Propagate pointer events in focus order
|
||||||
|
if self.pointerActive then
|
||||||
|
if snap.pointer and pointinrect(snap.px,snap.py, x,y,w,h) then
|
||||||
|
root:onPointerInput(snap.px,snap.py, snap.clicked, snap.pointing)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Propagate actions from focused widget up
|
||||||
|
if snap.action and not propagateaction(self, snap) then
|
||||||
|
-- Take global actions if nobody consumed the event
|
||||||
|
if snap.cancel and self.cancelfocus then
|
||||||
|
-- Focus on the last widget with 'cancelfocus'
|
||||||
|
self.cancelfocus:grabFocus()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Perform regular lifetime updates
|
||||||
|
root:update(dt)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Ui:draw()
|
||||||
|
local root = self[1]
|
||||||
|
|
||||||
|
love.graphics.push('all')
|
||||||
|
root:draw()
|
||||||
|
love.graphics.pop()
|
||||||
|
end
|
||||||
|
|
||||||
|
return Ui
|
|
@ -0,0 +1,117 @@
|
||||||
|
local rectunion = require('lib.gear.rect').union
|
||||||
|
|
||||||
|
local Widget = {
|
||||||
|
__call = function(cls, args)
|
||||||
|
return cls.new(args)
|
||||||
|
end
|
||||||
|
}
|
||||||
|
Widget.__index = Widget
|
||||||
|
|
||||||
|
|
||||||
|
local function raise(widget)
|
||||||
|
local parent = widget.parent
|
||||||
|
|
||||||
|
-- A parent of a widget is necessarily a Layout
|
||||||
|
while parent ~= nil do
|
||||||
|
local stack = parent.stack
|
||||||
|
|
||||||
|
-- Move widget at the end of the stack, so it is rendered last.
|
||||||
|
for i,w in ipairs(stack) do
|
||||||
|
if w == widget then
|
||||||
|
table.remove(stack, i)
|
||||||
|
stack[#stack+1] = widget
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Focus widget's container, if any
|
||||||
|
widget = parent
|
||||||
|
parent = widget.parent
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Widget:grabFocus()
|
||||||
|
local ui = self.ui
|
||||||
|
local focused = ui.focused
|
||||||
|
|
||||||
|
if focused == self then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if focused ~= nil then
|
||||||
|
-- Notify leave
|
||||||
|
focused.hovered = false
|
||||||
|
if focused.grabkeyboard then
|
||||||
|
if love.system.getOS() == 'Android' or love.system.getOS() == 'iOS' then
|
||||||
|
love.keyboard.setTextInput(false)
|
||||||
|
end
|
||||||
|
love.keyboard.setKeyRepeat(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
focused:onLeave()
|
||||||
|
end
|
||||||
|
|
||||||
|
local wasHovered = self.hovered
|
||||||
|
|
||||||
|
self.hovered = true
|
||||||
|
if self.grabkeyboard then
|
||||||
|
love.keyboard.setTextInput(true, self.x,self.y,self.w,self.h)
|
||||||
|
love.keyboard.setKeyRepeat(true)
|
||||||
|
end
|
||||||
|
if not wasHovered then
|
||||||
|
-- First time hovered, notify enter
|
||||||
|
self:onEnter()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Raise widget
|
||||||
|
ui.focused = self
|
||||||
|
raise(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Widget:isFocused()
|
||||||
|
return self.ui.focused == self
|
||||||
|
end
|
||||||
|
|
||||||
|
function Widget.recalculateBounds()
|
||||||
|
local widget = self.parent
|
||||||
|
while widget ~= nil do
|
||||||
|
local rx,ry,rw,rh = widget.x,widget.y,-1,-1
|
||||||
|
|
||||||
|
for _,w in ipairs(widget) do
|
||||||
|
rx,ry,rw,rh = rectunion(rx,ry,rw,rh, w.x,w.y,w.w,w.h)
|
||||||
|
end
|
||||||
|
|
||||||
|
widget.x = rx
|
||||||
|
widget.y = ry
|
||||||
|
widget.w = rw
|
||||||
|
widget.h = rh
|
||||||
|
|
||||||
|
widget = widget.parent
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Helper for drawing
|
||||||
|
function Widget:colorForState()
|
||||||
|
if self.active then
|
||||||
|
return self.color.active
|
||||||
|
elseif self:isFocused() then
|
||||||
|
return self.color.hovered
|
||||||
|
else
|
||||||
|
return self.color.normal
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common NOP event handlers
|
||||||
|
function Widget:onHit() end
|
||||||
|
function Widget:onEnter() end
|
||||||
|
function Widget:onLeave() end
|
||||||
|
function Widget:onChange() end
|
||||||
|
|
||||||
|
-- NOP input event handlers
|
||||||
|
function Widget:onActionInput(action) end
|
||||||
|
function Widget:onPointerInput(x,y, clicked) end
|
||||||
|
|
||||||
|
-- NOP UI event handlers
|
||||||
|
function Widget:update(dt) end
|
||||||
|
function Widget:draw() end
|
||||||
|
|
||||||
|
return Widget
|
Loading…
Reference in New Issue