From 1d42498ee7e23f7471035ee4971c13bdc0682b83 Mon Sep 17 00:00:00 2001 From: Lorenzo Cogotti Date: Wed, 24 Aug 2022 11:18:48 +0200 Subject: [PATCH] [*] General code improvement. * Rework navigation allowing direct management and triggering for grabkeyboard widgets. * Navigation code now behaves better with deeply nested layouts. * Allow widget hierarchies deeper than 2 (__call() is implemented for every Widget metatable). * Make BASE locals more secure (match '$' in regexp) --- button.lua | 2 +- checkbox.lua | 2 +- choice.lua | 6 +- columns.lua | 17 +++-- core.lua | 2 +- input.lua | 30 +++++++-- label.lua | 7 +- layout.lua | 185 +++++++++++++++++++++++---------------------------- rows.lua | 17 +++-- slider.lua | 2 +- spacer.lua | 11 +-- ui.lua | 156 ++++++++++++++++++++++++++++++++++--------- widget.lua | 33 ++++----- 13 files changed, 283 insertions(+), 187 deletions(-) diff --git a/button.lua b/button.lua index 76874ec..7161838 100644 --- a/button.lua +++ b/button.lua @@ -1,4 +1,4 @@ -local BASE = (...):gsub('button', '') +local BASE = (...):gsub('button$', '') local Widget = require(BASE..'widget') local core = require(BASE..'core') diff --git a/checkbox.lua b/checkbox.lua index a60a788..07bc228 100644 --- a/checkbox.lua +++ b/checkbox.lua @@ -1,4 +1,4 @@ -local BASE = (...):gsub('checkbox', '') +local BASE = (...):gsub('checkbox$', '') local Widget = require(BASE..'widget') local core = require(BASE..'core') diff --git a/choice.lua b/choice.lua index 618ea14..a8848c2 100644 --- a/choice.lua +++ b/choice.lua @@ -1,4 +1,4 @@ -local BASE = (...):gsub('choice', '') +local BASE = (...):gsub('choice$', '') local Widget = require(BASE..'widget') local core = require(BASE..'core') @@ -6,7 +6,9 @@ local core = require(BASE..'core') local shadowtext = require 'lib.gear.shadowtext' local T = require('lib.moonspeak').translate -local Choice = setmetatable({}, Widget) +local Choice = setmetatable({ + __call = function(cls, args) return cls.new(args) end +}, Widget) Choice.__index = Choice diff --git a/columns.lua b/columns.lua index da4b6b5..e2ba5bc 100644 --- a/columns.lua +++ b/columns.lua @@ -1,21 +1,20 @@ -local BASE = (...):gsub('columns', '') +local BASE = (...):gsub('columns$', '') local Layout = require(BASE..'layout') - -- 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(args) - local self = Layout.new(args) +local Columns = setmetatable({ + advance = columnadvance, + __call = function(cls, args) return cls.new(args) end +}, Layout) +Columns.__index = Columns - self.advance = columnadvance - self.prev = 'left' - self.next = 'right' - return self -end + +function Columns.new(args) return setmetatable(Layout.new(args), Columns) end return Columns diff --git a/core.lua b/core.lua index 6fe5cce..fbee686 100644 --- a/core.lua +++ b/core.lua @@ -1,4 +1,4 @@ -local BASE = (...):gsub('core', '') +local BASE = (...):gsub('core$', '') local core = { theme = require(BASE..'theme') } core.__index = core diff --git a/input.lua b/input.lua index 58bc8e2..8d4cdad 100644 --- a/input.lua +++ b/input.lua @@ -1,12 +1,15 @@ -local BASE = (...):gsub('input', '') +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) +-- NOTE: Input manages keyboard directly. +local Input = setmetatable({ + grabkeyboard = true, + __call = function(cls, args) return cls.new(args) end +}, Widget) Input.__index = Input @@ -28,6 +31,18 @@ function Input.new(args) return self end +-- NOTE: Input steals keyboard input on focus. +function Input:gainFocus() + love.keyboard.setTextInput(true, self.x,self.y,self.w,self.h) + love.keyboard.setKeyRepeat(true) +end +function Input:loseFocus() + if love.system.getOS() == 'Android' or love.system.getOS() == 'iOS' then + love.keyboard.setTextInput(false) + end + love.keyboard.setKeyRepeat(false) +end + function Input:onPointerInput(px,py, clicked) if clicked then self:grabFocus() @@ -92,9 +107,12 @@ function Input:keyreleased(key) 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 + elseif key == 'up' or key == 'down' then + self.ui:navigate(key) + elseif key == 'tab' or key == 'return' then + self.ui:navigate('right') + elseif key == 'escape' then + self.ui:navigate('cancel') end end end diff --git a/label.lua b/label.lua index f1c8a89..425b038 100644 --- a/label.lua +++ b/label.lua @@ -1,4 +1,4 @@ -local BASE = (...):gsub('label', '') +local BASE = (...):gsub('label$', '') local Widget = require(BASE..'widget') local core = require(BASE..'core') @@ -7,7 +7,10 @@ local shadowtext = require 'lib.gear.shadowtext' local T = require('lib.moonspeak').translate -- Labels don't accept focus -local Label = setmetatable({ nofocus = true }, Widget) +local Label = setmetatable({ + nofocus = true, + __call = function(cls, args) return cls.new(args) end +}, Widget) Label.__index = Label diff --git a/layout.lua b/layout.lua index c5f3da8..66959e2 100644 --- a/layout.lua +++ b/layout.lua @@ -1,4 +1,4 @@ -local BASE = (...):gsub('layout', '') +local BASE = (...):gsub('layout$', '') local Widget = require(BASE..'widget') local core = require(BASE..'core') @@ -9,7 +9,9 @@ local isinstance = gear.meta.isinstance local rectunion = gear.rect.union local pointinrect = gear.rect.pointinside -local Layout = setmetatable({}, Widget) +local Layout = setmetatable({ + __call = function(cls, args) return cls.new(args) end +}, Widget) Layout.__index = Layout @@ -113,21 +115,6 @@ function Layout:layoutWidgets() 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:onPointerInput(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 @@ -137,98 +124,27 @@ local function childof(layout, widget) 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 +local function scanforward(layout, from) + from = from or 1 + + for i = from,#layout do + local w = layout[i] + + if not w.nofocus then + return isinstance(w, Layout) and scanforward(w) or w end end - return widget end -local function findnext(layout, widget) - local child = childof(layout, widget) +local function scanbackwards(layout, from) + from = from or #layout - 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 + for i = from,1,-1 do + local w = layout[i] + + if not w.nofocus then + return isinstance(w, Layout) and scanforward(w) or w 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) @@ -245,7 +161,70 @@ function Layout.new(args) break end end + return self end +--- Find first widget in layout accepting focus. +function Layout:first() + return scanforward(self) +end + +--- Find last widget in layout accepting focus. +function Layout:last() + return scanbackwards(self) +end + +--- Find next focusable widget after the provided one. +function Layout:after(widget) + widget = childof(self, widget) + + for i = 1,#self do + if self[i] == widget then + -- Search to the right/down + return scanforward(self, i+1) + end + end +end + +--- Find previous focusable widget before the provided one. +function Layout:before(widget) + widget = childof(self, widget) + + for i = 1,#self do + if self[i] == widget then + -- Search to the left/up + return scanbackwards(self, i-1) + end + end +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:onPointerInput(px,py, clicked, down) + break + end + end +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 + return Layout diff --git a/rows.lua b/rows.lua index 07bc97f..b1f3e3e 100644 --- a/rows.lua +++ b/rows.lua @@ -1,21 +1,20 @@ -local BASE = (...):gsub('rows', '') +local BASE = (...):gsub('rows$', '') local Layout = require(BASE..'layout') - -- 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(args) - local self = Layout.new(args) +local Rows = setmetatable({ + advance = rowadvance, + __call = function(cls, args) return cls.new(args) end +}, Layout) +Rows.__index = Rows - self.advance = rowadvance - self.prev = 'up' - self.next = 'down' - return self -end + +function Rows.new(args) return setmetatable(Layout.new(args), Rows) end return Rows diff --git a/slider.lua b/slider.lua index da3e6e1..32a0d46 100644 --- a/slider.lua +++ b/slider.lua @@ -1,4 +1,4 @@ -local BASE = (...):gsub('slider', '') +local BASE = (...):gsub('slider$', '') local Widget = require(BASE..'widget') local core = require(BASE..'core') diff --git a/spacer.lua b/spacer.lua index d9aec31..bc1cae2 100644 --- a/spacer.lua +++ b/spacer.lua @@ -1,14 +1,15 @@ -local BASE = (...):gsub('spacer', '') +local BASE = (...):gsub('spacer$', '') local Widget = require(BASE..'widget') -- Spacers don't accept focus -local Spacer = setmetatable({ nofocus = true }, Widget) +local Spacer = setmetatable({ + nofocus = true, + __call = function(cls, args) return cls.new(args) end +}, Widget) Spacer.__index = Spacer -function Spacer.new(args) - return setmetatable(args, Spacer) -end +function Spacer.new(args) return setmetatable(args, Spacer) end return Spacer diff --git a/ui.lua b/ui.lua index d87ff81..892a60f 100644 --- a/ui.lua +++ b/ui.lua @@ -2,6 +2,8 @@ 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 gear = require 'lib.gear' @@ -50,24 +52,6 @@ local function resolveautofocus(widget) 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.") @@ -98,7 +82,7 @@ function Ui.new(args) local firstfocus, cancelfocus = resolveautofocus(root) if firstfocus == nil then firstfocus = isinstance(root, Layout) and - root:firstFocusableWidget() or + root:first() or root end @@ -138,28 +122,136 @@ function Ui: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() +local function actionpropagate(widget, action) + while widget ~= nil do + if widget:onActionInput(action) then + return true -- action consumed + end - self.timer:update(dt) + 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) + -- 1. Pointer events + if snap.pointer and not ui.grabpointer then + local root = ui[1] + local x,y,w,h = root.x,root.y,root.w,root.h - -- 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() + -- 2. Actions (keyboard/buttons) + if snap.action and not ui.grabkeyboard 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) diff --git a/widget.lua b/widget.lua index 90b87f4..3c2d17d 100644 --- a/widget.lua +++ b/widget.lua @@ -1,9 +1,7 @@ local rectunion = require('lib.gear.rect').union local Widget = { - __call = function(cls, args) - return cls.new(args) - end + __call = function(cls, args) return cls.new(args) end } Widget.__index = Widget @@ -40,25 +38,26 @@ function Widget:grabFocus() 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 - + -- Widget specific focus loss + focused:loseFocus() + -- Event handler focused:onLeave() + + if focused.grabkeyboard then + -- If focused widget stole input, + -- then drop current input snapshot, since + -- those events should have been already + -- managed directly + ui.device:snapshot() + end 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:gainFocus() self:onEnter() end @@ -100,7 +99,11 @@ function Widget:colorForState() end end --- Common NOP event handlers +-- NOP hooks for UI internal use +function Widget:loseFocus() end +function Widget:gainFocus() end + +-- NOP event handlers, publicly overridable function Widget:onHit() end function Widget:onEnter() end function Widget:onLeave() end