From 6dd7691d71e42cf2d3fb148b6bdf63de118ac49d Mon Sep 17 00:00:00 2001 From: Lorenzo Cogotti Date: Mon, 15 Aug 2022 23:41:17 +0200 Subject: [PATCH] [*] Initial commit. --- .gitignore | 48 +++++++ .lovedeps | 4 + LICENSE | 17 +++ README.ACKNOWLEDGEMENT | 29 +++++ README.md | 29 +++++ button.lua | 59 +++++++++ checkbox.lua | 64 ++++++++++ choice.lua | 148 +++++++++++++++++++++ columns.lua | 24 ++++ core.lua | 27 ++++ crush.lua | 284 +++++++++++++++++++++++++++++++++++++++++ device/love.lua | 60 +++++++++ init.lua | 18 +++ input.lua | 184 ++++++++++++++++++++++++++ label.lua | 37 ++++++ layout.lua | 251 ++++++++++++++++++++++++++++++++++++ rows.lua | 24 ++++ slider.lua | 92 +++++++++++++ spacer.lua | 14 ++ theme.lua | 11 ++ ui.lua | 176 +++++++++++++++++++++++++ widget.lua | 117 +++++++++++++++++ 22 files changed, 1717 insertions(+) create mode 100644 .gitignore create mode 100644 .lovedeps create mode 100644 LICENSE create mode 100644 README.ACKNOWLEDGEMENT create mode 100644 README.md create mode 100644 button.lua create mode 100644 checkbox.lua create mode 100644 choice.lua create mode 100644 columns.lua create mode 100644 core.lua create mode 100644 crush.lua create mode 100644 device/love.lua create mode 100644 init.lua create mode 100644 input.lua create mode 100644 label.lua create mode 100644 layout.lua create mode 100644 rows.lua create mode 100644 slider.lua create mode 100644 spacer.lua create mode 100644 theme.lua create mode 100644 ui.lua create mode 100644 widget.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..387c892 --- /dev/null +++ b/.gitignore @@ -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/ + + diff --git a/.lovedeps b/.lovedeps new file mode 100644 index 0000000..7541621 --- /dev/null +++ b/.lovedeps @@ -0,0 +1,4 @@ +{ + gear = "https://git.doublefourteen.io/lua/gear", + moonspeak = "https://git.doublefourteen.io/lua/moonspeak" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e9fd669 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.ACKNOWLEDGEMENT b/README.ACKNOWLEDGEMENT new file mode 100644 index 0000000..654b640 --- /dev/null +++ b/README.ACKNOWLEDGEMENT @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..03c83e2 --- /dev/null +++ b/README.md @@ -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. diff --git a/button.lua b/button.lua new file mode 100644 index 0000000..49bb9fe --- /dev/null +++ b/button.lua @@ -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 diff --git a/checkbox.lua b/checkbox.lua new file mode 100644 index 0000000..c0bfa4f --- /dev/null +++ b/checkbox.lua @@ -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 diff --git a/choice.lua b/choice.lua new file mode 100644 index 0000000..8bc29e6 --- /dev/null +++ b/choice.lua @@ -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 diff --git a/columns.lua b/columns.lua new file mode 100644 index 0000000..241b948 --- /dev/null +++ b/columns.lua @@ -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 diff --git a/core.lua b/core.lua new file mode 100644 index 0000000..6fe5cce --- /dev/null +++ b/core.lua @@ -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 diff --git a/crush.lua b/crush.lua new file mode 100644 index 0000000..78d4131 --- /dev/null +++ b/crush.lua @@ -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() diff --git a/device/love.lua b/device/love.lua new file mode 100644 index 0000000..288ee9c --- /dev/null +++ b/device/love.lua @@ -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 diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..0747590 --- /dev/null +++ b/init.lua @@ -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') +} diff --git a/input.lua b/input.lua new file mode 100644 index 0000000..a6771fb --- /dev/null +++ b/input.lua @@ -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 diff --git a/label.lua b/label.lua new file mode 100644 index 0000000..d1ae411 --- /dev/null +++ b/label.lua @@ -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 diff --git a/layout.lua b/layout.lua new file mode 100644 index 0000000..e88db3e --- /dev/null +++ b/layout.lua @@ -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 diff --git a/rows.lua b/rows.lua new file mode 100644 index 0000000..0fb0a68 --- /dev/null +++ b/rows.lua @@ -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 diff --git a/slider.lua b/slider.lua new file mode 100644 index 0000000..6e8466e --- /dev/null +++ b/slider.lua @@ -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 diff --git a/spacer.lua b/spacer.lua new file mode 100644 index 0000000..1d6ef66 --- /dev/null +++ b/spacer.lua @@ -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 diff --git a/theme.lua b/theme.lua new file mode 100644 index 0000000..9d17b00 --- /dev/null +++ b/theme.lua @@ -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 diff --git a/ui.lua b/ui.lua new file mode 100644 index 0000000..6243b10 --- /dev/null +++ b/ui.lua @@ -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 diff --git a/widget.lua b/widget.lua new file mode 100644 index 0000000..90b87f4 --- /dev/null +++ b/widget.lua @@ -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