mirror of https://gitea.it/1414codeforge/yui
commit
6dd7691d71
22 changed files with 1717 additions and 0 deletions
@ -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 |
|||