[*] 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 '<filename>$' in regexp)
This commit is contained in:
Lorenzo Cogotti 2022-08-24 11:18:48 +02:00
parent 3b2b460012
commit 1d42498ee7
13 changed files with 283 additions and 187 deletions

View File

@ -1,4 +1,4 @@
local BASE = (...):gsub('button', '') local BASE = (...):gsub('button$', '')
local Widget = require(BASE..'widget') local Widget = require(BASE..'widget')
local core = require(BASE..'core') local core = require(BASE..'core')

View File

@ -1,4 +1,4 @@
local BASE = (...):gsub('checkbox', '') local BASE = (...):gsub('checkbox$', '')
local Widget = require(BASE..'widget') local Widget = require(BASE..'widget')
local core = require(BASE..'core') local core = require(BASE..'core')

View File

@ -1,4 +1,4 @@
local BASE = (...):gsub('choice', '') local BASE = (...):gsub('choice$', '')
local Widget = require(BASE..'widget') local Widget = require(BASE..'widget')
local core = require(BASE..'core') local core = require(BASE..'core')
@ -6,7 +6,9 @@ local core = require(BASE..'core')
local shadowtext = require 'lib.gear.shadowtext' local shadowtext = require 'lib.gear.shadowtext'
local T = require('lib.moonspeak').translate 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 Choice.__index = Choice

View File

@ -1,21 +1,20 @@
local BASE = (...):gsub('columns', '') local BASE = (...):gsub('columns$', '')
local Layout = require(BASE..'layout') local Layout = require(BASE..'layout')
-- Advance position to next column, -- Advance position to next column,
-- given current position, widget dimensions and padding. -- given current position, widget dimensions and padding.
local function columnadvance(x,y, ww,wh, padding) local function columnadvance(x,y, ww,wh, padding)
return x + ww + padding, y return x + ww + padding, y
end end
function Columns(args) local Columns = setmetatable({
local self = Layout.new(args) advance = columnadvance,
__call = function(cls, args) return cls.new(args) end
}, Layout)
Columns.__index = Columns
self.advance = columnadvance
self.prev = 'left' function Columns.new(args) return setmetatable(Layout.new(args), Columns) end
self.next = 'right'
return self
end
return Columns return Columns

View File

@ -1,4 +1,4 @@
local BASE = (...):gsub('core', '') local BASE = (...):gsub('core$', '')
local core = { theme = require(BASE..'theme') } local core = { theme = require(BASE..'theme') }
core.__index = core core.__index = core

View File

@ -1,12 +1,15 @@
local BASE = (...):gsub('input', '') local BASE = (...):gsub('input$', '')
local Widget = require(BASE..'widget') local Widget = require(BASE..'widget')
local core = require(BASE..'core') local core = require(BASE..'core')
local utf8 = require 'utf8' local utf8 = require 'utf8'
-- Grabs keyboard on focus -- NOTE: Input manages keyboard directly.
local Input = setmetatable({ grabkeyboard = true }, Widget) local Input = setmetatable({
grabkeyboard = true,
__call = function(cls, args) return cls.new(args) end
}, Widget)
Input.__index = Input Input.__index = Input
@ -28,6 +31,18 @@ function Input.new(args)
return self return self
end 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) function Input:onPointerInput(px,py, clicked)
if clicked then if clicked then
self:grabFocus() self:grabFocus()
@ -92,9 +107,12 @@ function Input:keyreleased(key)
self.cursor = 1 self.cursor = 1
elseif key == 'end' then elseif key == 'end' then
self.cursor = utf8.len(self.text)+1 self.cursor = utf8.len(self.text)+1
elseif key == 'return' then elseif key == 'up' or key == 'down' then
self:onChange(self.text) self.ui:navigate(key)
self.cursor = 1 elseif key == 'tab' or key == 'return' then
self.ui:navigate('right')
elseif key == 'escape' then
self.ui:navigate('cancel')
end end
end end
end end

View File

@ -1,4 +1,4 @@
local BASE = (...):gsub('label', '') local BASE = (...):gsub('label$', '')
local Widget = require(BASE..'widget') local Widget = require(BASE..'widget')
local core = require(BASE..'core') local core = require(BASE..'core')
@ -7,7 +7,10 @@ local shadowtext = require 'lib.gear.shadowtext'
local T = require('lib.moonspeak').translate local T = require('lib.moonspeak').translate
-- Labels don't accept focus -- 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 Label.__index = Label

View File

@ -1,4 +1,4 @@
local BASE = (...):gsub('layout', '') local BASE = (...):gsub('layout$', '')
local Widget = require(BASE..'widget') local Widget = require(BASE..'widget')
local core = require(BASE..'core') local core = require(BASE..'core')
@ -9,7 +9,9 @@ local isinstance = gear.meta.isinstance
local rectunion = gear.rect.union local rectunion = gear.rect.union
local pointinrect = gear.rect.pointinside 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 Layout.__index = Layout
@ -113,21 +115,6 @@ function Layout:layoutWidgets()
self.h = math.max(rh, 0) self.h = math.max(rh, 0)
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
-- Find layout's child containing the provided widget. -- Find layout's child containing the provided widget.
local function childof(layout, widget) local function childof(layout, widget)
local parent = widget.parent local parent = widget.parent
@ -137,98 +124,27 @@ local function childof(layout, widget)
end end
return widget return widget
end end
local function findfirst(widget) local function scanforward(layout, from)
while isinstance(widget, Layout) do from = from or 1
-- Find first element accepting focus
for i = 1,#widget do for i = from,#layout do
if not widget[i].nofocus then local w = layout[i]
widget = widget[i]
break if not w.nofocus then
end return isinstance(w, Layout) and scanforward(w) or w
end end
end end
return widget
end end
local function findnext(layout, widget) local function scanbackwards(layout, from)
local child = childof(layout, widget) from = from or #layout
for i,w in ipairs(layout) do for i = from,1,-1 do
if w == child then local w = layout[i]
-- Search to the right, wraparound to the left
for j = i+1,#layout do if not w.nofocus then
if not layout[j].nofocus then return isinstance(w, Layout) and scanforward(w) or w
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
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 end
function Layout.new(args) function Layout.new(args)
@ -245,7 +161,70 @@ function Layout.new(args)
break break
end end
end end
return self return self
end 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 return Layout

View File

@ -1,21 +1,20 @@
local BASE = (...):gsub('rows', '') local BASE = (...):gsub('rows$', '')
local Layout = require(BASE..'layout') local Layout = require(BASE..'layout')
-- Advance position to next row, -- Advance position to next row,
-- given current position, widget dimensions and padding. -- given current position, widget dimensions and padding.
local function rowadvance(x,y, ww,wh, padding) local function rowadvance(x,y, ww,wh, padding)
return x, y + wh + padding return x, y + wh + padding
end end
function Rows(args) local Rows = setmetatable({
local self = Layout.new(args) advance = rowadvance,
__call = function(cls, args) return cls.new(args) end
}, Layout)
Rows.__index = Rows
self.advance = rowadvance
self.prev = 'up' function Rows.new(args) return setmetatable(Layout.new(args), Rows) end
self.next = 'down'
return self
end
return Rows return Rows

View File

@ -1,4 +1,4 @@
local BASE = (...):gsub('slider', '') local BASE = (...):gsub('slider$', '')
local Widget = require(BASE..'widget') local Widget = require(BASE..'widget')
local core = require(BASE..'core') local core = require(BASE..'core')

View File

@ -1,14 +1,15 @@
local BASE = (...):gsub('spacer', '') local BASE = (...):gsub('spacer$', '')
local Widget = require(BASE..'widget') local Widget = require(BASE..'widget')
-- Spacers don't accept focus -- 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 Spacer.__index = Spacer
function Spacer.new(args) function Spacer.new(args) return setmetatable(args, Spacer) end
return setmetatable(args, Spacer)
end
return Spacer return Spacer

156
ui.lua
View File

@ -2,6 +2,8 @@ local BASE = (...):gsub('ui$', '')
local Widget = require(BASE..'widget') local Widget = require(BASE..'widget')
local Layout = require(BASE..'layout') local Layout = require(BASE..'layout')
local Columns = require(BASE..'columns')
local Rows = require(BASE..'rows')
local gear = require 'lib.gear' local gear = require 'lib.gear'
@ -50,24 +52,6 @@ local function resolveautofocus(widget)
return firstfocus, cancelfocus return firstfocus, cancelfocus
end 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) function Ui.new(args)
local self = setmetatable(args, Ui) local self = setmetatable(args, Ui)
assert(#self == 1, "Ui.new() must have exactly one root widget.") assert(#self == 1, "Ui.new() must have exactly one root widget.")
@ -98,7 +82,7 @@ function Ui.new(args)
local firstfocus, cancelfocus = resolveautofocus(root) local firstfocus, cancelfocus = resolveautofocus(root)
if firstfocus == nil then if firstfocus == nil then
firstfocus = isinstance(root, Layout) and firstfocus = isinstance(root, Layout) and
root:firstFocusableWidget() or root:first() or
root root
end end
@ -138,28 +122,136 @@ function Ui:textedited(text, start, length)
end end
end end
function Ui:update(dt) local function actionpropagate(widget, action)
local root = self[1] while widget ~= nil do
local x,y,w,h = root.x,root.y,root.w,root.h if widget:onActionInput(action) then
local snap = self.device:snapshot() 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 if snap.pointer and pointinrect(snap.px,snap.py, x,y,w,h) then
root:onPointerInput(snap.px,snap.py, snap.clicked, snap.pointing) root:onPointerInput(snap.px,snap.py, snap.clicked, snap.pointing)
end end
end end
-- Propagate actions from focused widget up -- 2. Actions (keyboard/buttons)
if snap.action and not propagateaction(self, snap) then if snap.action and not ui.grabkeyboard then
-- Take global actions if nobody consumed the event local consumed = actionpropagate(ui.focused, snap)
if snap.cancel and self.cancelfocus then
-- Focus on the last widget with 'cancelfocus' if not consumed then
self.cancelfocus:grabFocus() -- 3. If no widget consumed action,
-- take global actions (e.g. navigation).
globalactions(ui, snap)
end end
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 -- Perform regular lifetime updates
root:update(dt) root:update(dt)

View File

@ -1,9 +1,7 @@
local rectunion = require('lib.gear.rect').union local rectunion = require('lib.gear.rect').union
local Widget = { local Widget = {
__call = function(cls, args) __call = function(cls, args) return cls.new(args) end
return cls.new(args)
end
} }
Widget.__index = Widget Widget.__index = Widget
@ -40,25 +38,26 @@ function Widget:grabFocus()
if focused ~= nil then if focused ~= nil then
-- Notify leave -- Notify leave
focused.hovered = false focused.hovered = false
if focused.grabkeyboard then -- Widget specific focus loss
if love.system.getOS() == 'Android' or love.system.getOS() == 'iOS' then focused:loseFocus()
love.keyboard.setTextInput(false) -- Event handler
end
love.keyboard.setKeyRepeat(false)
end
focused:onLeave() 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 end
local wasHovered = self.hovered local wasHovered = self.hovered
self.hovered = true 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 if not wasHovered then
-- First time hovered, notify enter -- First time hovered, notify enter
self:gainFocus()
self:onEnter() self:onEnter()
end end
@ -100,7 +99,11 @@ function Widget:colorForState()
end end
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:onHit() end
function Widget:onEnter() end function Widget:onEnter() end
function Widget:onLeave() end function Widget:onLeave() end