cave-story-randomizer/caver/lib/luigi/layout.lua
2021-11-30 20:51:31 -06:00

445 lines
11 KiB
Lua

--[[--
A Layout contains a tree of widgets with a single `root` widget.
Layouts will resize to fit the window unless a `top` or `left`
property is found in the root widget.
Layouts are drawn in the order that they were shown, so the
most recently shown layout shown will always appear on top.
Other events are sent to layouts in the opposite direction,
and are trapped by the first layout that can handle the event
(for example, the topmost layer that is focused or hovered).
@classmod Layout
--]]--
local ROOT = (...):gsub('[^.]*$', '')
local Backend = require(ROOT .. 'backend')
local Base = require(ROOT .. 'base')
local Event = require(ROOT .. 'event')
local Widget = require(ROOT .. 'widget')
local Input = require(ROOT .. 'input')
local Style = require(ROOT .. 'style')
local Layout = Base:extend()
Layout.isLayout = true
--[[--
Layout constructor.
@function Luigi.Layout
@tparam table data
A tree of widget data.
@treturn Layout
A Layout instance.
--]]--
function Layout:constructor (data, master)
data = data or {}
if master then
self:setMaster(master)
else
self:setTheme(require(ROOT .. 'theme.light'))
self:setStyle()
end
self:addDefaultHandlers()
self.hooks = {}
self.isShown = false
self.root = data
Widget(self, data)
self.isReady = true
end
--[[--
Create a detached widget.
Internal function used to create widgets that are associated with
a layout, but "detached" from it.
Used by context menus, which use their "owner" widget's layout
for theme and style information but appear in a separate layout.
@tparam table data
A tree of widget data.
@treturn Widget
A widget instance.
--]]--
function Layout:createWidget (data)
return Widget(self, data)
end
local function clearWidget (widget)
widget.textData = nil
widget.fontData = nil
widget.position = {}
widget.dimensions = {}
widget.type = widget.type
for _, child in ipairs(widget) do
clearWidget(child)
end
local items = widget.items
if items then
for _, item in ipairs(items) do
clearWidget(item)
end
end
end
local function reset (self)
if not self.root then return end
clearWidget(self.root)
end
--[[--
Set the master layout for this layout.
This layout's theme and style will be set the same as the master layout, and
widgets added to this layout will be indexed and keyboard-accelerated by the
master layout instead of this layout.
@tparam Layout layout
Master layout
@treturn Layout Self
--]]--
function Layout:setMaster (layout)
self.master = layout
reset(self)
return self
end
--[[--
Set the style from a definition table or function.
@tparam table|function rules
Style definition.
@treturn Layout Self
--]]--
function Layout:setStyle (rules)
if type(rules) == 'function' then
rules = rules()
end
self.style = Style(rules or {}, { 'style' })
reset(self)
return self
end
--[[--
Set the theme from a definition table or function.
@tparam table|function rules
Theme definition.
--]]--
function Layout:setTheme (rules)
if type(rules) == 'function' then
rules = rules()
end
self.theme = Style(rules or {}, { 'type' })
reset(self)
return self
end
--[[--
Get the style from master layout or this layout.
@treturn table
Style table.
--]]--
function Layout:getStyle ()
return self.master and self.master:getStyle() or self.style
end
--[[--
Get the theme from master layout or this layout.
@treturn table
Theme table.
--]]--
function Layout:getTheme ()
return self.master and self.master:getTheme() or self.theme
end
--[[--
Show the layout.
Hooks all appropriate Love events and callbacks.
@treturn Layout
Return this layout for chaining.
--]]--
function Layout:show ()
if self.isShown then
Backend.hide(self)
self.isShown = nil
end
self.isShown = true
if not self.input then
self.input = Input.default -- Input(self)
end
Backend.show(self)
self.root:reshape()
Event.Show:emit(self, self)
return self
end
--[[--
Hide the layout.
Unhooks Love events and callbacks.
@treturn Layout
Return this layout for chaining.
--]]--
function Layout:hide ()
if not self.isShown then
return
end
self.isShown = nil
Backend.hide(self)
Event.Hide:emit(self, self)
return self
end
--[[--
Focus next focusable widget.
Traverses widgets using Widget:getNextNeighbor until a focusable widget is
found, and focuses that widget.
@treturn Widget
The widget that was focused, or nil
--]]--
function Layout:focusNextWidget ()
local widget = self.focusedWidget or self.root
local nextWidget = widget:getNextNeighbor()
while nextWidget ~= widget do
if nextWidget:focus() then return nextWidget end
nextWidget = nextWidget:getNextNeighbor()
end
end
--[[--
Focus previous focusable widget.
Traverses widgets using Widget:getPreviousNeighbor until a focusable widget is
found, and focuses that widget.
@treturn Widget
The widget that was focused, or nil
--]]--
function Layout:focusPreviousWidget ()
local widget = self.focusedWidget or self.root
local previousWidget = widget:getPreviousNeighbor()
while previousWidget ~= widget do
if previousWidget:focus() then return previousWidget end
previousWidget = previousWidget:getPreviousNeighbor()
end
end
--[[--
Get the innermost widget at given coordinates.
@tparam number x
Number of pixels from window's left edge.
@tparam number y
Number of pixels from window's top edge.
@tparam[opt] Widget root
Widget to search within, defaults to layout root.
--]]--
function Layout:getWidgetAt (x, y, root)
if not root then
root = self.root
end
-- Loop through in reverse, because siblings defined later in the tree
-- will overdraw earlier siblings.
for i = #root, 1, -1 do
local child = root[i]
if child:isAt(x, y) then
local inner = self:getWidgetAt(x, y, child)
if inner then return inner end
end
end
if root:isAt(x, y) then return root end
end
--[[--
Place a layout near a point or rectangle.
@tparam number left
Number of pixels from window's left edge.
@tparam number top
Number of pixels from window's top edge.
@tparam[opt] number width
Width of the rectangle to place layout outside of, defaults to 0.
@tparam[opt] number height
Height of the rectangle to place layout outside of, defaults to 0.
@treturn Layout
Return this layout for chaining.
--]]--
function Layout:placeNear (left, top, width, height)
width, height = width or 0, height or 0
local root = self.root
-- place to the left if there's no room to the right
local layoutWidth = root:getWidth()
local windowWidth, windowHeight = Backend.getWindowSize()
if left + width + layoutWidth > windowWidth then
left = left - layoutWidth - width
else
left = left + width
end
-- place above if there's no room below
local layoutHeight = root:getHeight()
if top + height + layoutHeight > windowHeight then
top = top - layoutHeight - height
else
top = top + height
end
root.left = left
root.top = top
end
-- Add handlers for keyboard shortcuts, tab focus, and mouse wheel scroll
function Layout:addDefaultHandlers ()
self.shortcuts = {}
for i = 0, 15 do
self.shortcuts[i] = {}
end
self.behavior = {}
local function createBehavior (name, hooks)
self.behavior[name] = hooks
function hooks.destroy ()
for _, hook in ipairs(hooks) do
hook:unhook()
end
self.behavior[name] = nil
end
end
createBehavior('context', {
self:onPressStart(function (event)
-- show context menu on right click
if event.button ~= 'right' then return end
local menu = event.target.context
if not menu then return end
menu:bubbleEvent('PressStart', event)
-- make sure it fits in the window
-- TODO: open in a new borderless window under SDL?
menu.menuLayout:placeNear(event.x - 1, event.y - 1, 2, 2)
return false
end)
})
createBehavior('shortcut', {
self:onKeyPress(function (event)
local entry = self.shortcuts[event.modifierFlags]
local widget = entry and entry[event.key]
if not widget then return end
widget.hovered = true
self.input:handlePressStart(self, 'left', event.x, event.y,
widget, widget.shortcut)
return false
end),
self:onKeyRelease(function (event)
local entry = self.shortcuts[event.modifierFlags]
local widget = entry and entry[event.key]
if not widget then return end
widget.hovered = false
self.input:handlePressEnd(self, 'left', event.x, event.y,
widget, widget.shortcut)
return false
end)
})
createBehavior('navigate', {
self:onKeyPress(function (event)
-- tab/shift-tab cycles focused widget
if event.key == 'tab' then
if Backend.isKeyDown('lshift', 'rshift') then
self:focusPreviousWidget()
else
self:focusNextWidget()
end
return false
end
-- space/enter presses focused widget
local widget = self.focusedWidget
if widget and event.key == 'space' or event.key == ' '
or event.key == 'return' then
self.input:handlePressStart(self, 'left', event.x, event.y,
widget, event.key)
return false
end
end),
self:onKeyRelease(function (event)
-- space / enter presses focused widget
local widget = self.focusedWidget
if widget and event.key == 'space' or event.key == ' '
or event.key == 'return' then
self.input:handlePressEnd(self, 'left', event.x, event.y,
widget, event.key)
return false
end
end)
})
createBehavior('scroll', {
self:onWheelMove(function (event)
if not event.hit then return end
local amount = event.scrollY ~= 0 and event.scrollY or event.scrollX
for widget in event.target:eachAncestor(true) do
if widget:scrollBy(amount) then return false end
end -- ancestor loop
return false
end) -- wheel move
})
createBehavior('status', {
self:onEnter(function (event)
local statusWidget = (self.master or self).statusWidget
if not statusWidget then return end
statusWidget.text = event.target.status
return false
end)
})
end
Event.injectBinders(Layout)
return Layout