commit 6dd7691d71e42cf2d3fb148b6bdf63de118ac49d Author: Lorenzo Cogotti Date: Mon Aug 15 23:41:17 2022 +0200 [*] Initial commit. 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