mirror of
https://github.com/cave-story-randomizer/cave-story-randomizer
synced 2025-07-02 08:06:46 +00:00
445 lines
11 KiB
Lua
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
|