[*] Initial commit.

This commit is contained in:
Lorenzo Cogotti 2022-08-15 23:41:17 +02:00
commit 6dd7691d71
22 changed files with 1717 additions and 0 deletions

48
.gitignore vendored Normal file
View File

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

4
.lovedeps Normal file
View File

@ -0,0 +1,4 @@
{
gear = "https://git.doublefourteen.io/lua/gear",
moonspeak = "https://git.doublefourteen.io/lua/moonspeak"
}

17
LICENSE Normal file
View File

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

29
README.ACKNOWLEDGEMENT Normal file
View File

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

29
README.md Normal file
View File

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

59
button.lua Normal file
View File

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

64
checkbox.lua Normal file
View File

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

148
choice.lua Normal file
View File

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

24
columns.lua Normal file
View File

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

27
core.lua Normal file
View File

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

284
crush.lua Normal file
View File

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

60
device/love.lua Normal file
View File

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

18
init.lua Normal file
View File

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

184
input.lua Normal file
View File

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

37
label.lua Normal file
View File

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

251
layout.lua Normal file
View File

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

24
rows.lua Normal file
View File

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

92
slider.lua Normal file
View File

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

14
spacer.lua Normal file
View File

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

11
theme.lua Normal file
View File

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

176
ui.lua Normal file
View File

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

117
widget.lua Normal file
View File

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