--- User interface manager -- -- @classmod yui.Ui -- @copyright 2022, The DoubleFourteen Code Forge -- @author Lorenzo Cogotti, Andrea Pasquini -- --- An Ui manages a hierarchy of Widgets. -- The @{Ui} draws its widgets according to their layout and position, manages input focus, and -- dispatches events to the appropriate widgets depending on their class and activity status. local BASE = (...):gsub('ui$', '') local Widget = require(BASE..'widget') local Layout = require(BASE..'layout') local Columns = require(BASE..'columns') local Rows = require(BASE..'rows') local theme = require(BASE..'theme') local gear = require 'lib.gear' local Timer = gear.Timer local isinstance = gear.meta.isinstance local pointinrect = gear.rect.pointinside local Ui = { theme = theme -- fallback theme } 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 --- Attributes accepted by the Ui widget -- -- @field x (number) x position of the Ui -- @field y (number) y position of the Ui -- @field theme (@{yui.theme.Theme|Theme}) custom global Ui theme, defaults to @{yui.theme} -- @table UiAttributes --- Ui constructor -- @param args (@{UiAttributes}) widget attributes function Ui:new(args) self = setmetatable(args, self) assert(#self == 1, "Ui:new() must have exactly one root widget.") self.device = self.device or require(BASE..'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 not firstfocus then firstfocus = isinstance(root, Layout) and root:first() 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 and focused.grabkeyboard then focused:keypressed(key, scancode, isrepeat) end end function Ui:keyreleased(key, scancode) local focused = self.focused if focused and focused.grabkeyboard then focused:keyreleased(key, scancode) end end function Ui:textinput(text) local focused = self.focused if focused and focused.grabkeyboard then focused:textinput(text) end end function Ui:textedited(text, start, length) local focused = self.focused if focused and focused.grabkeyboard then focused:textedited(text, start, length) end end local function actionpropagate(widget, action) while widget do if widget:onActionInput(action) then return true -- action consumed end widget = widget.parent end return false end local navs = { 'cancel', 'up', 'down', 'left', 'right' } local function globalactions(ui, snap) for _,nav in ipairs(navs) do if snap[nav] then ui:navigate(nav) break -- discard other directions, if any end end end local function eventpropagate(ui, snap) -- Ignore event flags, in case focused widget requires direct input local dropPointer = ui.focused and ui.focused.grabpointer local dropAction = ui.focused and ui.focused.grabkeyboard -- 1. Pointer events if snap.pointer and not dropPointer then local root = ui[1] local x,y,w,h = root.x,root.y,root.w,root.h 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 -- 2. Actions (keyboard/buttons) if snap.action and not dropAction then local consumed = actionpropagate(ui.focused, snap) if not consumed then -- 3. If no widget consumed action, -- take global actions (e.g. navigation). globalactions(ui, snap) end end end -- cls may be Rows, Columns or Layout in general local function containerof(widget, cls) repeat widget = widget.parent if isinstance(widget, cls) then return widget end until widget == nil end local function findprev(ui, cls, widget) local child = widget while true do local container = containerof(child, cls) if container == nil then -- All the way up, either return the original -- widget or wraparound to the bottom return isinstance(child, cls) and child:last() or widget end local w = container:before(child) if w ~= nil then return w end child = container -- move up into the hierarchy end end -- Specular to findprev() local function findnext(ui, cls, widget) local child = widget while true do local container = containerof(child, cls) if container == nil then return isinstance(child, cls) and child:first() or widget end local w = container:after(child) if w ~= nil then return w end child = container end end --- Move focus to the given direction. -- -- @param where (string) Direction to move to, one of: 'up', 'down', 'left', 'right', 'cancel'. function Ui:navigate(where) local nextfocus = nil if where == 'cancel' then nextfocus = self.cancelfocus elseif where == 'up' then nextfocus = findprev(self, Rows, self.focused) elseif where == 'down' then nextfocus = findnext(self, Rows, self.focused) elseif where == 'left' then nextfocus = findprev(self, Columns, self.focused) elseif where == 'right' then nextfocus = findnext(self, Columns, self.focused) else error("Bad direction: "..tostring(where)) end if nextfocus ~= nil then nextfocus:grabFocus() end end function Ui:update(dt) local root = self[1] local snap = self.device:snapshot() -- Update timer related effects self.timer:update(dt) -- Regular event propagation eventpropagate(self, snap) -- 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